diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/chunkDecoding.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/chunkDecoding.ts index 75ae93e2804c..5ac5f063c1d1 100644 --- a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/chunkDecoding.ts +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/chunkDecoding.ts @@ -40,7 +40,7 @@ import { import type { IncrementalDecoder } from "./codecs.js"; import { type EncodedAnyShape, - type EncodedChunkShapeV1OrV2, + type EncodedChunkShape, type EncodedChunkShapeV2, type EncodedFieldBatchV1OrV2, type EncodedFieldBatchV2, @@ -48,7 +48,9 @@ import { type EncodedInlineArrayShape, type EncodedNestedArrayShape, type EncodedNodeShape, + type EncodedSpecializedNodeShape, type EncodedValueShape, + type ShapeIndex, SpecialField, supportsIncrementalEncoding, } from "./format/index.js"; @@ -76,9 +78,129 @@ export function decode( ); } +/** + * Resolves `shapeIndex` to a fully-resolved {@link EncodedNodeShape}, normalizing away any + * specialized node shapes (`f`) along the way by applying their overlays via + * {@link applySpecialization} until a concrete node shape is reached. + * + * @param input - The index of the shape to resolve, which must be a concrete or specialized node shape. + * @param context - The decoding context containing the shape definitions. + * @param pendingResolution - (Internal) A set of shape indices visited so far in the current resolution chain, used to detect cycles in the specialization chain. Most callers should not provide this argument. + * + * @remarks + * Exported for testing. + */ +export function normalizeToNodeShape( + input: EncodedNodeShape | EncodedSpecializedNodeShape, + context: DecoderContext, + pendingResolution: Set = new Set(), +): EncodedNodeShape { + if (!("base" in input)) { + return input; + } + + const baseIndex = input.base; + assert(!pendingResolution.has(baseIndex), "cyclic specialized node shape chain"); + pendingResolution.add(baseIndex); + const encoded = context.shapes[baseIndex]; + assert(encoded !== undefined, "shape index out of bounds"); + + const baseShape = encoded.c ?? ("f" in encoded ? encoded.f : undefined); + assert( + baseShape !== undefined, + "shape at index must be a concrete (c) or specialized (f) node shape", + ); + + return applySpecialization( + normalizeToNodeShape(baseShape, context, pendingResolution), + input, + context, + ); +} + +/** + * Produces a specialized {@link EncodedNodeShape} by overlaying `overrides` onto `base`. + * + * See {@link EncodedSpecializedNodeShape} for the override/inherit/clear semantics. + * + * @remarks + * Exported for testing. + */ +export function applySpecialization( + base: EncodedNodeShape, + overrides: EncodedSpecializedNodeShape, + context: DecoderContext, +): EncodedNodeShape { + const fields = [...(base.fields ?? [])]; + const indexFromKey = new Map(); + for (const [i, [keyEncoded]] of fields.entries()) { + const key = context.identifier(keyEncoded); + assert(!indexFromKey.has(key), "duplicate field key in base node shape"); + indexFromKey.set(key, i); + } + + // Replace fields in base with overrides, append new keys in overrides in the order they are specified. + const seenOverrideKeys = new Set(); + for (const [keyEncoded, shapeIndex] of overrides.fields ?? []) { + const key = context.identifier(keyEncoded); + assert(!seenOverrideKeys.has(key), "duplicate field key in specialized node shape"); + seenOverrideKeys.add(key); + const existingIndex = indexFromKey.get(key); + if (existingIndex === undefined) { + fields.push([keyEncoded, shapeIndex]); + } else { + const index = fields[existingIndex]; + assert(index !== undefined, "expected existing field index"); + fields[existingIndex] = [index[0], shapeIndex]; + } + } + + return { + type: base.type, + value: resolveOverride(overrides.value, base.value), + fields: fields.length > 0 ? fields : undefined, + extraFields: resolveOverride(overrides.extraFields, base.extraFields), + }; +} + +// `undefined` means the override is absent (inherit from base); `null` is the explicit-clear +// sentinel needed because JSON.stringify drops `undefined`-valued properties, making +// property-presence indistinguishable from absent on the wire. +function resolveOverride( + // eslint-disable-next-line @rushstack/no-new-null + override: T | null | undefined, + baseValue: T | undefined, +): T | undefined { + if (override === undefined) { + return baseValue; + } + if (override === null) { + return undefined; + } + return override; +} + +/** + * Decoder for {@link EncodedSpecializedNodeShape}s. + * Applies the specialization's field overrides to the resolved base node shape, then delegates + * to a {@link NodeDecoder} built from the resulting shape. + */ +export class SpecializedNodeDecoder implements ChunkDecoder { + private readonly inner: NodeDecoder; + public constructor( + shape: EncodedSpecializedNodeShape, + context: DecoderContext, + ) { + this.inner = new NodeDecoder(normalizeToNodeShape(shape, context), context); + } + public decode(decoders: readonly ChunkDecoder[], stream: StreamCursor): TreeChunk { + return this.inner.decode(decoders, stream); + } +} + const decoderLibrary = new DiscriminatedUnionDispatcher< - EncodedChunkShapeV1OrV2, - [context: DecoderContext], + EncodedChunkShape, + [context: DecoderContext], ChunkDecoder >({ a(shape: EncodedNestedArrayShape, context): ChunkDecoder { @@ -99,6 +221,9 @@ const decoderLibrary = new DiscriminatedUnionDispatcher< ): ChunkDecoder { return new IncrementalChunkDecoder(context); }, + f(shape: EncodedSpecializedNodeShape, context): ChunkDecoder { + return new SpecializedNodeDecoder(shape, context); + }, }); /** @@ -300,7 +425,7 @@ type BasicFieldDecoder = ( * Get a decoder for fields of a provided (via `shape` and `context`). */ function fieldDecoder( - context: DecoderContext, + context: DecoderContext, key: FieldKey, shape: number, ): BasicFieldDecoder { @@ -319,7 +444,7 @@ export class NodeDecoder implements ChunkDecoder { private readonly fieldDecoders: readonly BasicFieldDecoder[]; public constructor( private readonly shape: EncodedNodeShape, - private readonly context: DecoderContext, + private readonly context: DecoderContext, ) { this.type = shape.type === undefined ? undefined : context.identifier(shape.type); diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/compressedEncode.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/compressedEncode.ts index 1bd8ca2a8bc0..de9366693114 100644 --- a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/compressedEncode.ts +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/compressedEncode.ts @@ -31,7 +31,7 @@ import type { FieldBatch } from "./fieldBatch.js"; import { type EncodedAnyShape, type EncodedChunkShapeV1, - type EncodedChunkShapeV1OrV2, + type EncodedChunkShape, type EncodedChunkShapeV2, type EncodedFieldBatchV1OrV2, type EncodedNestedArrayShape, @@ -63,8 +63,8 @@ export function compressedEncode( return updateShapesAndIdentifiersEncoding(context.version, batchBuffer); } -export type BufferFormat = BufferFormatGeneric; -export type Shape = ShapeGeneric; +export type BufferFormat = BufferFormatGeneric; +export type Shape = ShapeGeneric; /** * Like {@link FieldEncoder}, except data will be prefixed with the key. @@ -166,7 +166,7 @@ export function asNodesEncoder(encoder: NodeEncoder): NodesEncoder { /** * Encodes a chunk with {@link EncodedAnyShape} by prefixing the data with its shape. */ -export class AnyShape extends ShapeGeneric { +export class AnyShape extends ShapeGeneric { private constructor() { super(); } @@ -271,7 +271,7 @@ export const anyFieldEncoder: FieldEncoder = { * which is an easy way to keep all the related code together without extra objects. */ export class InlineArrayEncoder - extends ShapeGeneric + extends ShapeGeneric implements NodesEncoder, FieldEncoder { public static readonly empty: InlineArrayEncoder = new InlineArrayEncoder(0, { @@ -355,7 +355,7 @@ export class InlineArrayEncoder /** * Encodes the shape for a nested array as {@link EncodedNestedArrayShape} shape. */ -export class NestedArrayShape extends ShapeGeneric { +export class NestedArrayShape extends ShapeGeneric { /** * @param innerShape - The shape of each item in this nested array. */ @@ -366,7 +366,7 @@ export class NestedArrayShape extends ShapeGeneric { public encodeShape( identifiers: DeduplicationTable, shapes: DeduplicationTable, - ): EncodedChunkShapeV1OrV2 { + ): EncodedChunkShape { const shape: EncodedNestedArrayShape = shapes.valueToIndex.get(this.innerShape) ?? fail(0xb4f /* index for shape not found in table */); diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/formatV2.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/formatV2.ts index 89ab9907f668..2245d9c186a5 100644 --- a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/formatV2.ts +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/formatV2.ts @@ -16,16 +16,20 @@ import { shapesV1 } from "./formatV1.js"; export type EncodedIncrementalChunkShape = Static; export const EncodedIncrementalChunkShape = Type.Literal(0); +/** + * The chunk shapes supported by the V2 format. + * @remarks + * See {@link EncodedChunkShapeV2}. + */ +export const shapesV2 = { + ...shapesV1, + e: Type.Optional(EncodedIncrementalChunkShape), +} as const; + /** * V2 extension of {@link EncodedChunkShapeV1}. * @remarks * See {@link DiscriminatedUnionDispatcher} for more information on this pattern. */ export type EncodedChunkShapeV2 = Static; -export const EncodedChunkShapeV2 = Type.Object( - { - ...shapesV1, - e: Type.Optional(EncodedIncrementalChunkShape), - }, - unionOptions, -); +export const EncodedChunkShapeV2 = Type.Object(shapesV2, unionOptions); diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/formatVText.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/formatVText.ts new file mode 100644 index 000000000000..c9c547499c70 --- /dev/null +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/formatVText.ts @@ -0,0 +1,83 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { type Static, Type } from "@sinclair/typebox"; + +import { unionOptions } from "../../../../codec/index.js"; + +import { ShapeIndex } from "./formatGeneric.js"; +import { EncodedFieldShape, EncodedValueShape } from "./formatV1.js"; +import { shapesV2 } from "./formatV2.js"; + +/** + * A node shape that derives from another node shape by overlaying property-level overrides. + * + * @remarks + * Compresses runs of node shapes that differ only in a few properties: a base node shape + * defines the structural skeleton, and the specialization narrows specific properties. + * + * For example, a base `FormatNode` with a variable-boolean `bold` field can be specialized + * to a shape that pins `bold` to a constant `true` — every node decoded with the + * specialization contributes zero stream tokens for `bold`. + * + * Specialization rules: `type` is always inherited from the resolved base. `fields` overrides + * apply per-key: entries whose key matches a base field replace that entry's shape index in + * place; entries with new keys are appended after all base fields. For `value` and + * `extraFields`: if the property is absent on the wire, the base's value is inherited; if + * `null`, the resulting shape has no value / no extraFields (explicit clear); any other value + * replaces the base's. + * + * The `null` sentinel exists because JSON does not preserve `undefined`-valued properties, + * so override-vs-inherit cannot be discriminated by property presence after persistence. + * + * Decoded by {@link applySpecialization}. + */ +export type EncodedSpecializedNodeShape = Static; +export const EncodedSpecializedNodeShape = Type.Object( + { + /** + * Index into the enclosing batch's shapes array of the shape this specializes. + * Must resolve to either an {@link EncodedNodeShape} or another + * `EncodedSpecializedNodeShape`; chains are followed transitively until a node shape + * is reached. This restriction is enforced at runtime, not by the schema. + */ + base: ShapeIndex, + /** + * Field-level overrides applied to the resolved base's `fields`. Entries whose key + * matches a base field replace that field's shape index in place; entries with new + * keys are appended after all base fields, in the order given here. Base field order + * is preserved — this is the stream consumption order at decode time, so encoders + * must serialize per-field tokens in the resulting field order, not in this list's order. + */ + fields: Type.Optional(Type.Array(EncodedFieldShape)), + /** + * If absent, inherits the resolved base's value shape. If `null`, the resulting shape + * has no value shape (explicit clear). Any other value replaces the base's. + */ + value: Type.Optional(Type.Union([EncodedValueShape, Type.Null()])), + /** + * If absent, inherits the resolved base's extraFields shape. If `null`, the resulting + * shape has no extraFields (explicit clear). Any other value replaces the base's. + */ + extraFields: Type.Optional(Type.Union([ShapeIndex, Type.Null()])), + }, + { additionalProperties: false }, +); + +/** + * Experimental extension of {@link EncodedChunkShapeV2}. + * @remarks + * See {@link DiscriminatedUnionDispatcher} for more information on this pattern. + */ +export type EncodedChunkShapeVTextExperimental = Static< + typeof EncodedChunkShapeVTextExperimental +>; +export const EncodedChunkShapeVTextExperimental = Type.Object( + { + ...shapesV2, + f: Type.Optional(EncodedSpecializedNodeShape), + }, + unionOptions, +); diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/index.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/index.ts index e8d2749a732c..fc74535714d2 100644 --- a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/index.ts +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/index.ts @@ -16,14 +16,19 @@ export { SpecialField, } from "./formatV1.js"; export { EncodedIncrementalChunkShape, EncodedChunkShapeV2 } from "./formatV2.js"; +export { + EncodedChunkShapeVTextExperimental, + EncodedSpecializedNodeShape, +} from "./formatVText.js"; export { FieldBatchFormatVersion, EncodedFieldBatchV1, EncodedFieldBatchV2, + EncodedFieldBatchVTextExperimental, supportsIncrementalEncoding, type EncodedFieldBatchV1OrV2, type EncodedFieldBatchV1AndV2, - type EncodedChunkShapeV1OrV2, + type EncodedChunkShape, } from "./versions.js"; export type { ShapeIndex, diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/versions.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/versions.ts index 4622d394f9e8..1c6fb25fd589 100644 --- a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/versions.ts +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/format/versions.ts @@ -10,6 +10,7 @@ import { strictEnum, type Values } from "../../../../util/index.js"; import { EncodedFieldBatchGeneric } from "./formatGeneric.js"; import { EncodedChunkShapeV1 } from "./formatV1.js"; import { EncodedChunkShapeV2 } from "./formatV2.js"; +import { EncodedChunkShapeVTextExperimental } from "./formatVText.js"; /** * The format version for the field batch. @@ -29,6 +30,10 @@ export const FieldBatchFormatVersion = strictEnum("FieldBatchFormatVersion", { * {@link EncodedIncrementalChunkShape} was added in this version. */ v2: 2, + /** + * Experimental codec with optimizations for text. + */ + vTextExperimental: "text", }); /** @@ -68,6 +73,17 @@ export const EncodedFieldBatchV2 = EncodedFieldBatchGeneric( EncodedChunkShapeV2, ); +/** + * Encoded {@link FieldBatch} using the experimental text optimized format. + */ +export type EncodedFieldBatchVTextExperimental = Static< + typeof EncodedFieldBatchVTextExperimental +>; +export const EncodedFieldBatchVTextExperimental = EncodedFieldBatchGeneric( + FieldBatchFormatVersion.vTextExperimental, + EncodedChunkShapeVTextExperimental, +); + /** * Encoded {@link FieldBatch}, which might use V2 features, but might also have been from a V1 encoder. * @remarks @@ -88,9 +104,14 @@ export type EncodedFieldBatchV1OrV2 = EncodedFieldBatchV1 | EncodedFieldBatchV2; export type EncodedFieldBatchV1AndV2 = EncodedFieldBatchV1 & EncodedFieldBatchV2; /** - * Encoded chunk shape, which might use V2 features, but might also have been from a V1 encoder. + * An encoded chunk shape from any known {@link FieldBatchFormatVersion}. + * * @remarks - * Type wise, equivalent to V2, as that is a superset of V1. - * Used instead of just V2 for clarity. + * Use this when working with chunk shapes uniformly across versions — for example, in the + * shared decoder dispatcher and in encoder shape base classes. New format versions should + * add their chunk shape variant to this union. */ -export type EncodedChunkShapeV1OrV2 = EncodedChunkShapeV1 | EncodedChunkShapeV2; +export type EncodedChunkShape = + | EncodedChunkShapeV1 + | EncodedChunkShapeV2 + | EncodedChunkShapeVTextExperimental; diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/nodeEncoder.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/nodeEncoder.ts index 748bebf3b66f..3fe2775f5ed4 100644 --- a/packages/dds/tree/src/feature-libraries/chunked-forest/codec/nodeEncoder.ts +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/codec/nodeEncoder.ts @@ -25,7 +25,7 @@ import { encodeValue, } from "./compressedEncode.js"; import type { - EncodedChunkShapeV1OrV2, + EncodedChunkShape, EncodedFieldShape, EncodedValueShape, } from "./format/index.js"; @@ -36,10 +36,7 @@ import type { * The fact this is also a Shape is an implementation detail of the encoder: that allows the shape it uses to be itself, * which is an easy way to keep all the related code together without extra objects. */ -export class NodeShapeBasedEncoder - extends Shape - implements NodeEncoder -{ +export class NodeShapeBasedEncoder extends Shape implements NodeEncoder { /** * Set of keys for fields that are encoded using {@link NodeShapeBasedEncoder.specializedFieldEncoders}. * TODO: Ensure uniform chunks, encoding and identifier generation sort fields the same. @@ -87,7 +84,7 @@ export class NodeShapeBasedEncoder public encodeNode( cursor: ITreeCursorSynchronous, context: EncoderContext, - outputBuffer: BufferFormat, + outputBuffer: BufferFormat, ): void { if (this.type === undefined) { outputBuffer.push(new IdentifierToken(cursor.type)); @@ -101,7 +98,7 @@ export class NodeShapeBasedEncoder cursor.exitField(); } - const otherFieldsBuffer: BufferFormat = []; + const otherFieldsBuffer: BufferFormat = []; forEachField(cursor, () => { const key = cursor.getFieldKey(); @@ -122,8 +119,8 @@ export class NodeShapeBasedEncoder public encodeShape( identifiers: DeduplicationTable, - shapes: DeduplicationTable>, - ): EncodedChunkShapeV1OrV2 { + shapes: DeduplicationTable>, + ): EncodedChunkShape { return { c: { type: encodeOptionalIdentifier(this.type, identifiers), @@ -136,7 +133,7 @@ export class NodeShapeBasedEncoder public countReferencedShapesAndIdentifiers( identifiers: Counter, - shapeDiscovered: (shape: Shape) => void, + shapeDiscovered: (shape: Shape) => void, ): void { if (this.type !== undefined) { identifiers.add(this.type); @@ -152,7 +149,7 @@ export class NodeShapeBasedEncoder } } - public get shape(): Shape { + public get shape(): Shape { return this; } } @@ -160,7 +157,7 @@ export class NodeShapeBasedEncoder export function encodeFieldShapes( fieldEncoders: readonly KeyedFieldEncoder[], identifiers: DeduplicationTable, - shapes: DeduplicationTable>, + shapes: DeduplicationTable>, ): EncodedFieldShape[] | undefined { if (fieldEncoders.length === 0) { return undefined; @@ -189,14 +186,14 @@ function encodeOptionalIdentifier( function encodeOptionalFieldShape( encoder: FieldEncoder | undefined, - shapes: DeduplicationTable>, + shapes: DeduplicationTable>, ): number | undefined { return encoder === undefined ? undefined : dedupShape(encoder.shape, shapes); } function dedupShape( - shape: Shape, - shapes: DeduplicationTable>, + shape: Shape, + shapes: DeduplicationTable>, ): number { return shapes.valueToIndex.get(shape) ?? fail(0xb51 /* missing shape */); } diff --git a/packages/dds/tree/src/test/feature-libraries/chunked-forest/codec/chunkDecoding.spec.ts b/packages/dds/tree/src/test/feature-libraries/chunked-forest/codec/chunkDecoding.spec.ts index 6fb055b1a741..b598ce06bb0e 100644 --- a/packages/dds/tree/src/test/feature-libraries/chunked-forest/codec/chunkDecoding.spec.ts +++ b/packages/dds/tree/src/test/feature-libraries/chunked-forest/codec/chunkDecoding.spec.ts @@ -22,10 +22,13 @@ import { IncrementalChunkDecoder, NestedArrayDecoder, NodeDecoder, + SpecializedNodeDecoder, aggregateChunks, anyDecoder, + applySpecialization, deaggregateChunks, decode, + normalizeToNodeShape, readValue, // eslint-disable-next-line import-x/no-internal-modules } from "../../../../feature-libraries/chunked-forest/codec/chunkDecoding.js"; @@ -39,8 +42,11 @@ import { } from "../../../../feature-libraries/chunked-forest/codec/codecs.js"; import { type EncodedChunkShapeV1, + type EncodedChunkShape, + type EncodedChunkShapeVTextExperimental, type EncodedFieldBatchV1OrV2, type EncodedNodeShape, + type EncodedSpecializedNodeShape, FieldBatchFormatVersion, SpecialField, // eslint-disable-next-line import-x/no-internal-modules @@ -77,6 +83,12 @@ function assertRefCount(item: ReferenceCountedBase, count: 0 | 1 | "shared"): vo } } +// To test for properties that would be dropped across a serialization boundary. +function jsonRoundTrip(value: T): T { + // eslint-disable-next-line unicorn/prefer-structured-clone + return JSON.parse(JSON.stringify(value)) as T; +} + /** * Appends a message to the log read from the stream when decoding (if not provided as `message`), and returns a ref to the provided chunk. */ @@ -433,6 +445,998 @@ describe("chunkDecoding", () => { }); }); + describe("SpecializedNodeDecoder", () => { + function makeContext( + identifiers: string[], + shapes: EncodedChunkShapeVTextExperimental[], + ): DecoderContext { + return new DecoderContext( + identifiers, + shapes as unknown as EncodedChunkShape[], + idDecodingContext, + undefined, + ); + } + + it("delegates to base when f is empty", () => { + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [] } }, + ]; + const context = makeContext([], shapes); + const decoder = new SpecializedNodeDecoder({ base: 0, fields: [] }, context); + const stream = { data: [], offset: 0 }; + const result = decoder.decode([], stream); + assertChunkCursorEquals(result, [{ type: brand("MyNode") }]); + assert.equal(stream.offset, 0); + }); + + it("overrides a field with a constant-value shape", () => { + // shapes[0]: base FormatNode with two variable-value fields + // shapes[1]: variable boolean (bold base shape — not used after override) + // shapes[2]: variable number (size) + // shapes[3]: constant false boolean (bold override — contributes 0 stream tokens) + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { + c: { + type: "FormatNode", + value: false, + fields: [ + ["bold", 1], + ["size", 2], + ], + }, + }, + { c: { type: "boolean", value: true } }, + { c: { type: "number", value: true } }, + { c: { type: "boolean", value: [false] } }, + ]; + const context = makeContext([], shapes); + const decoders = shapes.map((s) => new NodeDecoder(s.c as EncodedNodeShape, context)); + const decoder = new SpecializedNodeDecoder({ base: 0, fields: [["bold", 3]] }, context); + + // Only size=12 is in the stream; bold contributes no tokens (constant). + const stream = { data: [12], offset: 0 }; + const result = decoder.decode(decoders, stream); + + assertChunkCursorEquals(result, [ + { + type: brand("FormatNode"), + fields: { + bold: [{ type: brand("boolean"), value: false }], + size: [{ type: brand("number"), value: 12 }], + }, + }, + ]); + assert.equal(stream.offset, 1); + }); + + it("appends a field not present in the base", () => { + // shapes[0]: base with only "a" + // shapes[1]: leaf shape for "a" + // shapes[2]: constant leaf shape for new field "b" + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [["a", 1]] } }, + { c: { type: "leaf", value: true } }, + { c: { type: "leaf", value: ["extra"] } }, + ]; + const context = makeContext([], shapes); + const decoders = shapes.map((s) => new NodeDecoder(s.c as EncodedNodeShape, context)); + const decoder = new SpecializedNodeDecoder({ base: 0, fields: [["b", 2]] }, context); + + const stream = { data: [99], offset: 0 }; + const result = decoder.decode(decoders, stream); + + assertChunkCursorEquals(result, [ + { + type: brand("MyNode"), + fields: { + a: [{ type: brand("leaf"), value: 99 }], + b: [{ type: brand("leaf"), value: "extra" }], + }, + }, + ]); + assert.equal(stream.offset, 1); + }); + + it("chains through an intermediate f shape", () => { + // shapes[0]: base c-shape with two variable fields + // shapes[1]: variable leaf + // shapes[2]: constant leaf + // shapes[3]: intermediate f — overrides "a" with constant + // override: {base:3, fields:[["b", 2]]} — further overrides "b" with constant + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { + c: { + type: "MyNode", + value: false, + fields: [ + ["a", 1], + ["b", 1], + ], + }, + }, + { c: { type: "leaf", value: true } }, + { c: { type: "leaf", value: ["const"] } }, + { f: { base: 0, fields: [["a", 2]] } }, + ]; + const context = makeContext([], shapes); + const decoders = [ + new NodeDecoder(shapes[0].c as EncodedNodeShape, context), + new NodeDecoder(shapes[1].c as EncodedNodeShape, context), + new NodeDecoder(shapes[2].c as EncodedNodeShape, context), + // decoders[3] is never called since SpecializedNodeDecoder resolves f chains at construction + new NodeDecoder(shapes[1].c as EncodedNodeShape, context), + ]; + const decoder = new SpecializedNodeDecoder({ base: 3, fields: [["b", 2]] }, context); + + // Both fields are now constant — stream is empty. + const stream = { data: [], offset: 0 }; + const result = decoder.decode(decoders, stream); + + assertChunkCursorEquals(result, [ + { + type: brand("MyNode"), + fields: { + a: [{ type: brand("leaf"), value: "const" }], + b: [{ type: brand("leaf"), value: "const" }], + }, + }, + ]); + assert.equal(stream.offset, 0); + }); + + it("preserves base order when override lists fields in different order", () => { + // base lists fields [a, b]; override replaces both but lists them as [b, a]. + // Stream consumption follows merged-fields order, which the implementation + // derives from base order — so a is read first, then b. Both end up using + // shape 2 (the override target), producing type "after". + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { + c: { + type: "MyNode", + value: false, + fields: [ + ["a", 1], + ["b", 1], + ], + }, + }, + { c: { type: "before", value: true } }, + { c: { type: "after", value: true } }, + ]; + const context = makeContext([], shapes); + const decoders = shapes.map((s) => new NodeDecoder(s.c as EncodedNodeShape, context)); + const decoder = new SpecializedNodeDecoder( + { + base: 0, + fields: [ + ["b", 2], + ["a", 2], + ], + }, + context, + ); + + const stream = { data: [10, 20], offset: 0 }; + const result = decoder.decode(decoders, stream); + + assertChunkCursorEquals(result, [ + { + type: brand("MyNode"), + fields: { + a: [{ type: brand("after"), value: 10 }], + b: [{ type: brand("after"), value: 20 }], + }, + }, + ]); + assert.equal(stream.offset, 2); + }); + + it("appends new keys in override order, after base fields", () => { + // base: [[a, 1]]. override: [[x, 1], [y, 1]] — two new keys. + // Merged order is base-then-override: [a, x, y]. The stream is consumed + // in that order, so a=10, x=20, y=30. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [["a", 1]] } }, + { c: { type: "leaf", value: true } }, + ]; + const context = makeContext([], shapes); + const decoders = shapes.map((s) => new NodeDecoder(s.c as EncodedNodeShape, context)); + const decoder = new SpecializedNodeDecoder( + { + base: 0, + fields: [ + ["x", 1], + ["y", 1], + ], + }, + context, + ); + + const stream = { data: [10, 20, 30], offset: 0 }; + const result = decoder.decode(decoders, stream); + + assertChunkCursorEquals(result, [ + { + type: brand("MyNode"), + fields: { + a: [{ type: brand("leaf"), value: 10 }], + x: [{ type: brand("leaf"), value: 20 }], + y: [{ type: brand("leaf"), value: 30 }], + }, + }, + ]); + assert.equal(stream.offset, 3); + }); + + it("interleaved override entries: overrides land at base positions, new keys append in override order", () => { + // base.fields = [[a, 1], [b, 1]] + // override.fields = [[x, 1], [b, 2], [y, 1], [a, 2]] — interleaves new x, override + // b, new y, override a. Merged order should be base-overrides-in-place then + // new-keys-in-override-order: [[a, 2], [b, 2], [x, 1], [y, 1]]. + // + // Stream layout follows the merged order: a, b, x, y. If the implementation + // followed override order instead, x would land where a should be, etc. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { + c: { + type: "MyNode", + value: false, + fields: [ + ["a", 1], + ["b", 1], + ], + }, + }, + { c: { type: "before", value: true } }, + { c: { type: "after", value: true } }, + ]; + const context = makeContext([], shapes); + const decoders = shapes.map((s) => new NodeDecoder(s.c as EncodedNodeShape, context)); + const decoder = new SpecializedNodeDecoder( + { + base: 0, + fields: [ + ["x", 1], + ["b", 2], + ["y", 1], + ["a", 2], + ], + }, + context, + ); + + const stream = { data: [10, 20, 30, 40], offset: 0 }; + const result = decoder.decode(decoders, stream); + + // a (override → shape 2 "after") reads 10 + // b (override → shape 2 "after") reads 20 + // x (new, shape 1 "before") reads 30 + // y (new, shape 1 "before") reads 40 + assertChunkCursorEquals(result, [ + { + type: brand("MyNode"), + fields: { + a: [{ type: brand("after"), value: 10 }], + b: [{ type: brand("after"), value: 20 }], + x: [{ type: brand("before"), value: 30 }], + y: [{ type: brand("before"), value: 40 }], + }, + }, + ]); + assert.equal(stream.offset, 4); + }); + + it("chain: outer f extends and overrides keys added by inner f", () => { + // shapes[0]: base with [a]. + // shapes[1]: variable leaf "before". + // shapes[2]: variable leaf "after" (the override target). + // shapes[3]: inner f — adds x with shape 1. + // outer override: { base: 3, fields: [[x, 2], [y, 1]] } + // - overrides x (added by inner) to shape 2 + // - adds y as a brand new key + // Merged order: [a (base), x (inner-added), y (outer-added)]. The outer's + // override of x is applied at x's existing position, not at the end. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [["a", 1]] } }, + { c: { type: "before", value: true } }, + { c: { type: "after", value: true } }, + { f: { base: 0, fields: [["x", 1]] } }, + ]; + const context = makeContext([], shapes); + const decoders = [ + new NodeDecoder(shapes[0].c as EncodedNodeShape, context), + new NodeDecoder(shapes[1].c as EncodedNodeShape, context), + new NodeDecoder(shapes[2].c as EncodedNodeShape, context), + // decoders[3] is never invoked — f-chains are resolved at construction. + new NodeDecoder(shapes[1].c as EncodedNodeShape, context), + ]; + const decoder = new SpecializedNodeDecoder( + { + base: 3, + fields: [ + ["x", 2], + ["y", 1], + ], + }, + context, + ); + + const stream = { data: [10, 20, 30], offset: 0 }; + const result = decoder.decode(decoders, stream); + + // a (base, shape 1) reads 10 → "before". + // x (inner-added, outer overrode to shape 2) reads 20 → "after". + // y (outer-added, shape 1) reads 30 → "before". + assertChunkCursorEquals(result, [ + { + type: brand("MyNode"), + fields: { + a: [{ type: brand("before"), value: 10 }], + x: [{ type: brand("after"), value: 20 }], + y: [{ type: brand("before"), value: 30 }], + }, + }, + ]); + assert.equal(stream.offset, 3); + }); + + it("overrides value to a constant", () => { + // Base declares value as variable (`true`). Override pins it to a constant + // ["bold"], so per-occurrence the value contributes 0 stream tokens. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "FormatNode", value: true, fields: [] } }, + ]; + const context = makeContext([], shapes); + const decoder = new SpecializedNodeDecoder({ base: 0, value: ["bold"] }, context); + + const stream = { data: [], offset: 0 }; + const result = decoder.decode([], stream); + + assertChunkCursorEquals(result, [{ type: brand("FormatNode"), value: "bold" }]); + assert.equal(stream.offset, 0); + }); + + it("overrides value to false to narrow from variable to no-value", () => { + // Base declares value as variable (`true`). Override pins it to `false` (no value). + // Discriminates "value" in override semantics from a `??` fallback — `false ?? base` + // would incorrectly inherit from the base. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "FormatNode", value: true, fields: [] } }, + ]; + const context = makeContext([], shapes); + const decoder = new SpecializedNodeDecoder({ base: 0, value: false }, context); + + const stream = { data: [], offset: 0 }; + const result = decoder.decode([], stream); + + assertChunkCursorEquals(result, [{ type: brand("FormatNode") }]); + assert.equal(stream.offset, 0); + }); + + it("overrides value to null to clear an inherited value, surviving JSON round-trip", () => { + // `null` is the explicit-clear sentinel. It exists because JSON drops + // `undefined`-valued properties, so the override-vs-inherit distinction must + // survive a round-trip. Base pins value to a constant ["base-val"]; override + // sets `value: null` to clear that pin, so the merged shape reads the standard + // boolean-prefixed value form (here, `false` = no value). + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: ["base-val"] } }, + ]; + const context = makeContext([], shapes); + const overrides = jsonRoundTrip({ + base: 0, + value: null, + fields: [], + }); + const decoder = new SpecializedNodeDecoder(overrides, context); + const stream = { data: [false], offset: 0 }; + const result = decoder.decode([], stream); + assertChunkCursorEquals(result, [{ type: brand("MyNode") }]); + assert.equal(stream.offset, 1); + }); + + it("overrides extraFields to enable extra-field decoding", () => { + // Base has no extraFields. Override adds extraFields pointing at a leaf shape. + // Stream carries one nested array — the extra-fields tape — containing one + // [key, ...data] pair: ["x", 99]. The leaf decoder reads 99. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [] } }, + { c: { type: "leaf", value: true } }, + ]; + const context = makeContext([], shapes); + const decoders = shapes.map((s) => new NodeDecoder(s.c as EncodedNodeShape, context)); + const decoder = new SpecializedNodeDecoder({ base: 0, extraFields: 1 }, context); + + const stream = { data: [["x", 99]], offset: 0 }; + const result = decoder.decode(decoders, stream); + + assertChunkCursorEquals(result, [ + { + type: brand("MyNode"), + fields: { x: [{ type: brand("leaf"), value: 99 }] }, + }, + ]); + assert.equal(stream.offset, 1); + }); + + it("overrides extraFields to null to disable inherited extra-field decoding, surviving JSON round-trip", () => { + // `null` is the explicit-clear sentinel. Base has extraFields pointing at + // shape 0; override sets `extraFields: null` to clear that, so the merged + // shape has no extra fields and the tape is not consumed. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [], extraFields: 0 } }, + ]; + const context = makeContext([], shapes); + const overrides = jsonRoundTrip({ + base: 0, + extraFields: null, + fields: [], + }); + const decoder = new SpecializedNodeDecoder(overrides, context); + const stream = { data: [], offset: 0 }; + const result = decoder.decode([], stream); + assertChunkCursorEquals(result, [{ type: brand("MyNode") }]); + assert.equal(stream.offset, 0); + }); + + it("inherits value, extraFields, and fields from base when override omits them", () => { + // Base declares value as a constant ["base-val"], extraFields pointing at shape 1, + // and a single fixed field "a". Override is `{ base: 0 }` — no overrides. + // All three should pass through unchanged. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { + c: { + type: "MyNode", + value: ["base-val"], + fields: [["a", 1]], + extraFields: 1, + }, + }, + { c: { type: "leaf", value: true } }, + ]; + const context = makeContext([], shapes); + const decoders = shapes.map((s) => new NodeDecoder(s.c as EncodedNodeShape, context)); + const decoder = new SpecializedNodeDecoder({ base: 0 }, context); + + // Stream layout: a's value (10), then the extraFields nested array (one pair k=7). + const stream = { data: [10, ["k", 7]], offset: 0 }; + const result = decoder.decode(decoders, stream); + + assertChunkCursorEquals(result, [ + { + type: brand("MyNode"), + value: "base-val", + fields: { + a: [{ type: brand("leaf"), value: 10 }], + k: [{ type: brand("leaf"), value: 7 }], + }, + }, + ]); + assert.equal(stream.offset, 2); + }); + + it("asserts when base index is out of bounds", () => { + const context = makeContext([], []); + assert.throws( + () => new SpecializedNodeDecoder({ base: 0, fields: [] }, context), + validateAssertionError("shape index out of bounds"), + ); + }); + + it("asserts when base resolves to a non-node shape", () => { + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { a: 0 }, // NestedArray shape, not a node shape + ]; + const context = makeContext([], shapes); + assert.throws( + () => new SpecializedNodeDecoder({ base: 0, fields: [] }, context), + validateAssertionError( + "shape at index must be a concrete (c) or specialized (f) node shape", + ), + ); + }); + + it("asserts on a cyclic f-chain", () => { + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { f: { base: 1, fields: [] } }, + { f: { base: 0, fields: [] } }, + ]; + const context = makeContext([], shapes); + assert.throws( + () => new SpecializedNodeDecoder({ base: 0, fields: [] }, context), + validateAssertionError("cyclic specialized node shape chain"), + ); + }); + + it("asserts on a self-referential f-chain", () => { + const shapes: EncodedChunkShapeVTextExperimental[] = [{ f: { base: 0, fields: [] } }]; + const context = makeContext([], shapes); + assert.throws( + () => new SpecializedNodeDecoder({ base: 0, fields: [] }, context), + validateAssertionError("cyclic specialized node shape chain"), + ); + }); + + it("asserts on duplicate keys in override.fields", () => { + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [] } }, + ]; + const context = makeContext([], shapes); + assert.throws( + () => + new SpecializedNodeDecoder( + { + base: 0, + fields: [ + ["k", 1], + ["k", 2], + ], + }, + context, + ), + validateAssertionError("duplicate field key in specialized node shape"), + ); + }); + + it("asserts on duplicate resolved keys in override.fields (string vs identifier index)", () => { + // Both ["k", 1] and [0, 2] resolve to the FieldKey "k" once context.identifier runs, + // because identifiers[0] === "k". Without the resolved-key check, both entries would + // silently be pushed into mergedFields. + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [] } }, + ]; + const context = makeContext(["k"], shapes); + assert.throws( + () => + new SpecializedNodeDecoder( + { + base: 0, + fields: [ + ["k", 1], + [0, 2], + ], + }, + context, + ), + validateAssertionError("duplicate field key in specialized node shape"), + ); + }); + + it("asserts on duplicate keys in base.fields", () => { + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { + c: { + type: "MyNode", + value: false, + fields: [ + ["k", 1], + ["k", 2], + ], + }, + }, + ]; + const context = makeContext([], shapes); + assert.throws( + () => new SpecializedNodeDecoder({ base: 0, fields: [] }, context), + validateAssertionError("duplicate field key in base node shape"), + ); + }); + + it("dispatches through top-level decode() when f shape is in the batch", () => { + // shapes[0]: base FormatNode with one variable-boolean field "bold" + // shapes[1]: variable boolean + // shapes[2]: constant false boolean (0 stream tokens) + // shapes[3]: f — overrides bold to always-false + // + // data: [[3]] — anyDecoder reads shape index 3, dispatching to the f decoder. + // Bold is constant so nothing else is consumed; stream is fully exhausted. + // + // `f` is part of the vTextExperimental format, so the batch is tagged with that + // version to match the on-the-wire contract. + const batch = { + version: FieldBatchFormatVersion.vTextExperimental, + identifiers: [], + shapes: [ + { c: { type: "FormatNode", value: false, fields: [["bold", 1]] } }, + { c: { type: "boolean", value: true } }, + { c: { type: "boolean", value: [false] } }, + { f: { base: 0, fields: [["bold", 2]] } }, + ], + data: [[3]], + } as unknown as EncodedFieldBatchV1OrV2; + + const result = decode(batch, idDecodingContext); + + assert(result.length === 1); + const chunk = result[0]; + assert(chunk !== undefined); + assertChunkCursorEquals(chunk, [ + { + type: brand("FormatNode"), + fields: { + bold: [{ type: brand("boolean"), value: false }], + }, + }, + ]); + }); + }); + + describe("normalizeToNodeShape", () => { + function makeContext( + identifiers: string[], + shapes: EncodedChunkShapeVTextExperimental[], + ): DecoderContext { + return new DecoderContext( + identifiers, + shapes as unknown as EncodedChunkShape[], + idDecodingContext, + undefined, + ); + } + + it("returns a concrete (c) shape unchanged", () => { + const c: EncodedNodeShape = { type: "MyNode", value: false, fields: [["a", 1]] }; + const context = makeContext([], []); + assert.deepEqual(normalizeToNodeShape(c, context), c); + }); + + it("merges a single-step f chain", () => { + // f specializes c at index 0 by overriding "a" with shape index 2. + const f: EncodedSpecializedNodeShape = { base: 0, fields: [["a", 2]] }; + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [["a", 99]] } }, + ]; + const context = makeContext([], shapes); + assert.deepEqual(normalizeToNodeShape(f, context), { + type: "MyNode", + value: false, + fields: [["a", 2]], + extraFields: undefined, + }); + }); + + it("merges a multi-step f chain back to its concrete base", () => { + // shapes[0]: base c with fields [a, b] + // shapes[1]: inner f — overrides "a" to shape 7 + // outer f (input): overrides "b" to shape 8 and adds new key "c" at shape 9 + // Expected merged shape: type from c, fields in base order with overrides applied + // in place, then "c" appended. + const outer: EncodedSpecializedNodeShape = { + base: 1, + fields: [ + ["b", 8], + ["c", 9], + ], + }; + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { + c: { + type: "MyNode", + value: false, + fields: [ + ["a", 1], + ["b", 1], + ], + }, + }, + { f: { base: 0, fields: [["a", 7]] } }, + ]; + const context = makeContext([], shapes); + assert.deepEqual(normalizeToNodeShape(outer, context), { + type: "MyNode", + value: false, + fields: [ + ["a", 7], + ["b", 8], + ["c", 9], + ], + extraFields: undefined, + }); + }); + + it("propagates value/extraFields overrides through a chain", () => { + const outer: EncodedSpecializedNodeShape = { base: 1, extraFields: 5 }; + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: true, fields: [] } }, + { f: { base: 0, value: ["pinned"] } }, + ]; + const context = makeContext([], shapes); + assert.deepEqual(normalizeToNodeShape(outer, context), { + type: "MyNode", + value: ["pinned"], + fields: undefined, + extraFields: 5, + }); + }); + + it("throws on cyclic chain", () => { + const entry: EncodedSpecializedNodeShape = { base: 1, fields: [] }; + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { f: entry }, + { f: { base: 0, fields: [] } }, + ]; + const context = makeContext([], shapes); + assert.throws( + () => normalizeToNodeShape(entry, context), + validateAssertionError("cyclic specialized node shape chain"), + ); + }); + + it("throws on a non-node shape in the chain", () => { + // A specialized shape whose base index resolves to a non-node shape (NestedArrayShape). + const f: EncodedSpecializedNodeShape = { base: 0, fields: [] }; + const shapes: EncodedChunkShapeVTextExperimental[] = [{ a: 0 }]; + const context = makeContext([], shapes); + assert.throws( + () => normalizeToNodeShape(f, context), + validateAssertionError( + "shape at index must be a concrete (c) or specialized (f) node shape", + ), + ); + }); + + it("throws on a 3-step cycle", () => { + const entry: EncodedSpecializedNodeShape = { base: 1, fields: [] }; + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { f: entry }, + { f: { base: 2, fields: [] } }, + { f: { base: 0, fields: [] } }, + ]; + const context = makeContext([], shapes); + assert.throws( + () => normalizeToNodeShape(entry, context), + validateAssertionError("cyclic specialized node shape chain"), + ); + }); + + it("outer f override of a field also overridden by inner f wins", () => { + // shapes[0]: base c with field [a → 100]. + // shapes[1]: inner f — overrides a → 7. + // outer f (input): overrides a → 8 (the same field again). + // Merged result must show 8, not 7. + const outer: EncodedSpecializedNodeShape = { base: 1, fields: [["a", 8]] }; + const shapes: EncodedChunkShapeVTextExperimental[] = [ + { c: { type: "MyNode", value: false, fields: [["a", 100]] } }, + { f: { base: 0, fields: [["a", 7]] } }, + ]; + const context = makeContext([], shapes); + assert.deepEqual(normalizeToNodeShape(outer, context), { + type: "MyNode", + value: false, + fields: [["a", 8]], + extraFields: undefined, + }); + }); + }); + + describe("applySpecialization", () => { + function makeContext(identifiers: string[]): DecoderContext { + return new DecoderContext(identifiers, [], idDecodingContext, undefined); + } + + it("inherits all properties when override is empty", () => { + const base: EncodedNodeShape = { + type: "MyNode", + value: ["base-val"], + fields: [["a", 1]], + extraFields: 2, + }; + assert.deepEqual(applySpecialization(base, { base: 0 }, makeContext([])), { + type: "MyNode", + value: ["base-val"], + fields: [["a", 1]], + extraFields: 2, + }); + }); + + it("replaces overridden fields in base order, then appends new keys in override order", () => { + const base: EncodedNodeShape = { + type: "MyNode", + value: false, + fields: [ + ["a", 1], + ["b", 1], + ], + }; + const merged = applySpecialization( + base, + { + base: 0, + fields: [ + ["x", 3], + ["b", 2], + ["y", 4], + ["a", 2], + ], + }, + makeContext([]), + ); + // Base order [a, b] preserved with overrides applied in place; then [x, y] appended + // in override order. + assert.deepEqual(merged.fields, [ + ["a", 2], + ["b", 2], + ["x", 3], + ["y", 4], + ]); + }); + + it("treats explicit value: false as an override (not inherited)", () => { + // Verifies that `value: false` is treated as an explicit override, not collapsed to + // inheritance via a `??` fallback (base.value=true would be incorrectly inherited + // under `overrides.value ?? base.value`). `null` is the explicit-clear sentinel; any + // other defined value, including `false`, is an override. + const base: EncodedNodeShape = { type: "MyNode", value: true }; + const merged = applySpecialization(base, { base: 0, value: false }, makeContext([])); + assert.equal(merged.value, false); + }); + + it("throws on duplicate keys in override.fields", () => { + const base: EncodedNodeShape = { type: "MyNode", value: false }; + assert.throws( + () => + applySpecialization( + base, + { + base: 0, + fields: [ + ["k", 1], + ["k", 2], + ], + }, + makeContext([]), + ), + validateAssertionError("duplicate field key in specialized node shape"), + ); + }); + + it("throws on duplicate keys in base.fields", () => { + const base: EncodedNodeShape = { + type: "MyNode", + value: false, + fields: [ + ["k", 1], + ["k", 2], + ], + }; + assert.throws( + () => applySpecialization(base, { base: 0 }, makeContext([])), + validateAssertionError("duplicate field key in base node shape"), + ); + }); + + it("matches override against base by resolved key, regardless of encoding", () => { + // Base stores the key for "a" as identifier-index 0; override uses the string "a". + // Both resolve to the same FieldKey, so the override must replace base's entry + // in place rather than being treated as a new key and appended. + const base: EncodedNodeShape = { + type: "MyNode", + value: false, + fields: [[0, 1]], + }; + const merged = applySpecialization( + base, + { base: 0, fields: [["a", 2]] }, + makeContext(["a"]), + ); + // Order: still one entry. Encoding of the key follows base (the index), but the + // shape-index follows the override. + assert.deepEqual(merged.fields, [[0, 2]]); + }); + + it("detects duplicates in override.fields across mixed key encodings", () => { + // "k" as a string and identifier-index 0 (= "k") resolve to the same FieldKey. + const base: EncodedNodeShape = { type: "MyNode", value: false }; + assert.throws( + () => + applySpecialization( + base, + { + base: 0, + fields: [ + ["k", 1], + [0, 2], + ], + }, + makeContext(["k"]), + ), + validateAssertionError("duplicate field key in specialized node shape"), + ); + }); + + it("returns fields: undefined when both base and override contribute no fields", () => { + const base: EncodedNodeShape = { type: "MyNode", value: false }; + const merged = applySpecialization(base, { base: 0 }, makeContext([])); + assert.equal(merged.fields, undefined); + }); + + it("treats override.fields: [] the same as override.fields omitted", () => { + const base: EncodedNodeShape = { + type: "MyNode", + value: false, + fields: [["a", 1]], + }; + const fromOmitted = applySpecialization(base, { base: 0 }, makeContext([])); + const fromEmpty = applySpecialization(base, { base: 0, fields: [] }, makeContext([])); + assert.deepEqual(fromEmpty, fromOmitted); + }); + + it("treats value: null as explicit clear (override to undefined)", () => { + const base: EncodedNodeShape = { type: "MyNode", value: ["base-val"] }; + const merged = applySpecialization(base, { base: 0, value: null }, makeContext([])); + assert.equal(merged.value, undefined); + }); + + it("treats extraFields: null as explicit clear (override to undefined)", () => { + const base: EncodedNodeShape = { type: "MyNode", value: false, extraFields: 5 }; + const merged = applySpecialization( + base, + { base: 0, extraFields: null }, + makeContext([]), + ); + assert.equal(merged.extraFields, undefined); + }); + + it("treats value: undefined as absent (inherits from base)", () => { + // Property-presence semantics no longer apply: an explicitly-undefined value on the + // override is indistinguishable from absent (matching JSON wire semantics, which is + // why `null` is the explicit-clear sentinel). + const base: EncodedNodeShape = { type: "MyNode", value: ["base-val"] }; + const merged = applySpecialization(base, { base: 0, value: undefined }, makeContext([])); + assert.deepEqual(merged.value, ["base-val"]); + }); + + it("null clear sentinel survives JSON round-trip", () => { + // The whole reason `null` is the sentinel: it is preserved by JSON.stringify, so the + // override-vs-clear distinction survives summary persistence. + const base: EncodedNodeShape = { + type: "MyNode", + value: ["base-val"], + extraFields: 5, + }; + const overrides: EncodedSpecializedNodeShape = { + base: 0, + value: null, + extraFields: null, + }; + const merged = applySpecialization(base, jsonRoundTrip(overrides), makeContext([])); + assert.equal(merged.value, undefined); + assert.equal(merged.extraFields, undefined); + }); + + it("preserves base order when every base field is overridden", () => { + // All three base fields overridden, override lists them in reverse order. Merged + // fields must follow base's [a, b, c] order, not the override's [c, b, a]. + const base: EncodedNodeShape = { + type: "MyNode", + value: false, + fields: [ + ["a", 1], + ["b", 1], + ["c", 1], + ], + }; + const merged = applySpecialization( + base, + { + base: 0, + fields: [ + ["c", 9], + ["b", 8], + ["a", 7], + ], + }, + makeContext([]), + ); + assert.deepEqual(merged.fields, [ + ["a", 7], + ["b", 8], + ["c", 9], + ]); + }); + }); + describe("EncodedIncrementalChunkShape", () => { const fieldBatchVersion = brand(FieldBatchFormatVersion.v2);