diff --git a/packages/dds/tree/src/codec/codec.ts b/packages/dds/tree/src/codec/codec.ts index 1ee3a6a00890..cb55d992740f 100644 --- a/packages/dds/tree/src/codec/codec.ts +++ b/packages/dds/tree/src/codec/codec.ts @@ -196,9 +196,10 @@ export interface IJsonCodec< TDecoded, TEncoded = JsonCompatibleReadOnly, TValidate = TEncoded, - TContext = void, -> extends IEncoder, - IDecoder { + TEncodeContext = void, + TDecodeContext = TEncodeContext, +> extends IEncoder, + IDecoder { encodedSchema?: TAnySchema; } @@ -211,9 +212,13 @@ export interface IJsonCodec< * * This portion of a codec is not responsible for managing versioning or validation of the data against the schema. */ -export interface JsonCodecPart - extends IEncoder, TContext>, - IDecoder, TContext> { +export interface JsonCodecPart< + TDecoded, + TEncodedSchema extends TAnySchema, + TEncodeContext = void, + TDecodeContext = TEncodeContext, +> extends IEncoder, TEncodeContext>, + IDecoder, TDecodeContext> { /** * TypeBox schema which describes the encoded format for this chunk of data. * @remarks @@ -230,11 +235,18 @@ export function eraseEncodedType< TDecoded, TEncoded = JsonCompatibleReadOnly, TValidate = TEncoded, - TContext = void, + TEncodeContext = void, + TDecodeContext = TEncodeContext, >( - codec: IJsonCodec, -): IJsonCodec { - return codec as unknown as IJsonCodec; + codec: IJsonCodec, +): IJsonCodec { + return codec as unknown as IJsonCodec< + TDecoded, + TValidate, + TValidate, + TEncodeContext, + TDecodeContext + >; } /** @@ -246,11 +258,15 @@ export function eraseEncodedType< * allows avoiding some duplicate work at encode/decode time, since the vast majority of document usage will not * involve mixed format versions. * - * @privateRemarks This interface currently assumes all codecs in a family require the same encode/decode context, - * which isn't necessarily true. - * This may need to be relaxed in the future. + * @privateRemarks + * Encode and decode can be parameterized independently — the encode-side context type need not equal the decode-side context type. + * `TDecodeContext` defaults to `TEncodeContext` so callers that share a single context shape (the common case) don't need to specify it. */ -export interface ICodecFamily { +export interface ICodecFamily< + TDecoded, + TEncodeContext = void, + TDecodeContext = TEncodeContext, +> { /** * @returns a codec that can be used to encode and decode data in the specified format. * @throws if the format version is not supported by this family. @@ -260,7 +276,13 @@ export interface ICodecFamily { */ resolve( formatVersion: FormatVersion, - ): IJsonCodec; + ): IJsonCodec< + TDecoded, + JsonCompatibleReadOnly, + JsonCompatibleReadOnly, + TEncodeContext, + TDecodeContext + >; /** * @returns an iterable of all format versions supported by this family. @@ -338,17 +360,29 @@ export const DependentFormatVersion = { /** * Creates a codec family from a registry of codecs. */ -export function makeCodecFamily( +export function makeCodecFamily( registry: Iterable< [ formatVersion: FormatVersion, - codec: IJsonCodec, + codec: IJsonCodec< + TDecoded, + JsonCompatibleReadOnly, + JsonCompatibleReadOnly, + TEncodeContext, + TDecodeContext + >, ] >, -): ICodecFamily { +): ICodecFamily { const codecs: Map< FormatVersion, - IJsonCodec + IJsonCodec< + TDecoded, + JsonCompatibleReadOnly, + JsonCompatibleReadOnly, + TEncodeContext, + TDecodeContext + > > = new Map(); for (const [formatVersion, codec] of registry) { if (codecs.has(formatVersion)) { @@ -360,7 +394,13 @@ export function makeCodecFamily( return { resolve( formatVersion: FormatVersion, - ): IJsonCodec { + ): IJsonCodec< + TDecoded, + JsonCompatibleReadOnly, + JsonCompatibleReadOnly, + TEncodeContext, + TDecodeContext + > { const codec = codecs.get(formatVersion); assert(codec !== undefined, 0x5e6 /* Requested codec for unsupported format. */); return codec; @@ -395,25 +435,32 @@ export function withSchemaValidation< EncodedSchema extends TSchema, TEncodedFormat, TValidate, - TContext, + TEncodeContext, + TDecodeContext = TEncodeContext, >( schema: EncodedSchema, - codec: IJsonCodec, + codec: IJsonCodec< + TInMemoryFormat, + TEncodedFormat, + TValidate, + TEncodeContext, + TDecodeContext + >, validator?: JsonValidator | FormatValidator, -): IJsonCodec { +): IJsonCodec { if (!validator) { return codec; } const compiledFormat = extractJsonValidator(validator).compile(schema); return { - encode: (obj: TInMemoryFormat, context: TContext): TEncodedFormat => { + encode: (obj: TInMemoryFormat, context: TEncodeContext): TEncodedFormat => { const encoded = codec.encode(obj, context); if (!compiledFormat.check(encoded)) { fail(0xac0 /* Encoded data should validate */); } return encoded; }, - decode: (encoded: TValidate, context: TContext): TInMemoryFormat => { + decode: (encoded: TValidate, context: TDecodeContext): TInMemoryFormat => { if (!compiledFormat.check(encoded)) { fail(0xac1 /* Data being decoded should validate */); } diff --git a/packages/dds/tree/src/codec/versioned/codec.ts b/packages/dds/tree/src/codec/versioned/codec.ts index 3cacc5f3a35f..80858ea02e3a 100644 --- a/packages/dds/tree/src/codec/versioned/codec.ts +++ b/packages/dds/tree/src/codec/versioned/codec.ts @@ -48,14 +48,15 @@ function makeVersionedCodec< TDecoded, TEncoded extends Versioned = VersionedJson, TValidate = TEncoded, - TContext = void, + TEncodeContext = void, + TDecodeContext = TEncodeContext, >( supportedVersions: Set, { jsonValidator: validator }: ICodecOptions, - inner: IJsonCodec, -): IJsonCodec { + inner: IJsonCodec, +): IJsonCodec { const codec = { - encode: (data: TDecoded, context: TContext): TEncoded => { + encode: (data: TDecoded, context: TEncodeContext): TEncoded => { const encoded = inner.encode(data, context); assert( supportedVersions.has(encoded.version), @@ -63,7 +64,7 @@ function makeVersionedCodec< ); return encoded; }, - decode: (data: TValidate, context: TContext): TDecoded => { + decode: (data: TValidate, context: TDecodeContext): TDecoded => { const versioned = data as Versioned; // Validated by withSchemaValidation if (!supportedVersions.has(versioned.version)) { throw new UsageError( @@ -96,14 +97,15 @@ function makeVersionedValidatedCodec< TDecoded, TEncoded extends Versioned = VersionedJson, TValidate = TEncoded, - TContext = void, + TEncodeContext = void, + TDecodeContext = TEncodeContext, >( options: ICodecOptions, supportedVersions: Set, schema: EncodedSchema, - codec: IJsonCodec, -): IJsonCodec & - Pick, "schema"> { + codec: IJsonCodec, +): IJsonCodec & + Pick, "schema"> { return { ...makeVersionedCodec( supportedVersions, @@ -119,12 +121,11 @@ function makeVersionedValidatedCodec< */ export function makeDiscontinuedCodecAndSchema< TDecoded, - TContext, TFormatVersion extends FormatVersion = FormatVersion, >( discontinuedVersion: TFormatVersion, discontinuedSince: SemanticVersion, -): CodecVersion { +): CodecVersion { return { minVersionForCollab: undefined, formatVersion: discontinuedVersion, @@ -150,9 +151,19 @@ export function makeDiscontinuedCodecAndSchema< * The codec should not perform its own schema validation. * The schema validation gets added when normalizing to {@link NormalizedCodecVersion}. */ -export type CodecAndSchema = { +export type CodecAndSchema< + TDecoded, + TEncodeContext = void, + TDecodeContext = TEncodeContext, +> = { readonly schema: TSchema; -} & IJsonCodec; +} & IJsonCodec< + TDecoded, + VersionedJson, + JsonCompatibleReadOnly, + TEncodeContext, + TDecodeContext +>; /** * A codec alongside its format version and schema. @@ -183,12 +194,13 @@ export interface CodecVersionBase< */ export interface CodecVersion< TDecoded, - TContext, + TEncodeContext, TFormatVersion extends FormatVersion, TBuildOptions extends ICodecOptions = ICodecOptions, + TDecodeContext = TEncodeContext, > extends CodecVersionBase< - | CodecAndSchema - | ((options: TBuildOptions) => CodecAndSchema), + | CodecAndSchema + | ((options: TBuildOptions) => CodecAndSchema), TFormatVersion > {} @@ -200,11 +212,12 @@ export interface CodecVersion< */ export interface NormalizedCodecVersion< TDecoded, - TContext, + TEncodeContext, TFormatVersion extends FormatVersion, TBuildOptions extends ICodecOptions, + TDecodeContext = TEncodeContext, > extends CodecVersionBase< - (options: TBuildOptions) => CodecAndSchema, + (options: TBuildOptions) => CodecAndSchema, TFormatVersion > {} @@ -213,8 +226,15 @@ export interface NormalizedCodecVersion< * @remarks * Produced by {@link VersionDispatchingCodecBuilder.applyOptions}. */ -interface EvaluatedCodecVersion - extends CodecVersionBase, TFormatVersion> {} +interface EvaluatedCodecVersion< + TDecoded, + TEncodeContext, + TFormatVersion extends FormatVersion, + TDecodeContext = TEncodeContext, +> extends CodecVersionBase< + CodecAndSchema, + TFormatVersion + > {} /** * Normalize the codec to a single format. @@ -223,17 +243,34 @@ interface EvaluatedCodecVersion( - codecVersion: CodecVersion, -): NormalizedCodecVersion { - const codecBuilder: (options: TBuildOptions) => CodecAndSchema = + codecVersion: CodecVersion< + TDecoded, + TEncodeContext, + TFormatVersion, + TBuildOptions, + TDecodeContext + >, +): NormalizedCodecVersion< + TDecoded, + TEncodeContext, + TFormatVersion, + TBuildOptions, + TDecodeContext +> { + const codecBuilder: ( + options: TBuildOptions, + ) => CodecAndSchema = typeof codecVersion.codec === "function" ? codecVersion.codec - : () => codecVersion.codec as CodecAndSchema; - const codec = (options: TBuildOptions): CodecAndSchema => { + : () => codecVersion.codec as CodecAndSchema; + const codec = ( + options: TBuildOptions, + ): CodecAndSchema => { const built = codecBuilder(options); return makeVersionedValidatedCodec( options, @@ -261,9 +298,16 @@ function normalizeCodecVersion< */ export interface VersionDispatchingCodec< TDecoded, - TContext, + TEncodeContext, TFormatVersion extends FormatVersion, -> extends IJsonCodec { + TDecodeContext = TEncodeContext, +> extends IJsonCodec< + TDecoded, + JsonCompatibleReadOnly, + JsonCompatibleReadOnly, + TEncodeContext, + TDecodeContext + > { /** * The format version which this codec writes. * @remarks @@ -281,15 +325,17 @@ export interface VersionDispatchingCodec< export class VersionDispatchingCodecBuilder< TBuildOptions extends ICodecOptions = ICodecOptions, TDecoded = unknown, - TContext = unknown, + TEncodeContext = unknown, TFormatVersion extends FormatVersion = FormatVersion, TName extends CodecName = string, + TDecodeContext = TEncodeContext, > { public readonly registry: readonly NormalizedCodecVersion< TDecoded, - TContext, + TEncodeContext, TFormatVersion, - TBuildOptions + TBuildOptions, + TDecodeContext >[]; /** @@ -308,13 +354,20 @@ export class VersionDispatchingCodecBuilder< /** * The registry of codecs which this builder can use to encode and decode data. */ - inputRegistry: readonly CodecVersion[], + inputRegistry: readonly CodecVersion< + TDecoded, + TEncodeContext, + TFormatVersion, + TBuildOptions, + TDecodeContext + >[], ) { type Normalized = NormalizedCodecVersion< TDecoded, - TContext, + TEncodeContext, TFormatVersion, - TBuildOptions + TBuildOptions, + TDecodeContext >; const normalizedRegistry: Normalized[] = []; const formats: Set = new Set(); @@ -362,7 +415,7 @@ export class VersionDispatchingCodecBuilder< */ public applyOptions( options: TBuildOptions, - ): EvaluatedCodecVersion[] { + ): EvaluatedCodecVersion[] { return this.registry.map((codec) => ({ minVersionForCollab: codec.minVersionForCollab, formatVersion: codec.formatVersion, @@ -376,12 +429,12 @@ export class VersionDispatchingCodecBuilder< */ public build( options: TBuildOptions & CodecWriteOptions, - ): VersionDispatchingCodec { + ): VersionDispatchingCodec { const [applied, decoder] = this.buildDecoderInternal(options); const writeVersion = getWriteVersion(this.name, options, applied); return { ...decoder, - encode: (data: TDecoded, context: TContext): JsonCompatibleReadOnly => { + encode: (data: TDecoded, context: TEncodeContext): JsonCompatibleReadOnly => { return writeVersion.codec.encode(data, context); }, writeVersion: writeVersion.formatVersion, @@ -391,21 +444,27 @@ export class VersionDispatchingCodecBuilder< private buildDecoderInternal( options: TBuildOptions, ): [ - EvaluatedCodecVersion[], + EvaluatedCodecVersion[], Pick< - IJsonCodec, + IJsonCodec< + TDecoded, + JsonCompatibleReadOnly, + JsonCompatibleReadOnly, + TEncodeContext, + TDecodeContext + >, "decode" >, ] { const applied = this.applyOptions(options); const fromFormatVersion = new Map< FormatVersion, - EvaluatedCodecVersion + EvaluatedCodecVersion >(applied.map((codec) => [codec.formatVersion, codec])); return [ applied, { - decode: (data: JsonCompatibleReadOnly, context: TContext): TDecoded => { + decode: (data: JsonCompatibleReadOnly, context: TDecodeContext): TDecoded => { const versioned = data as Partial; const codec = fromFormatVersion.get(versioned.version); if (codec === undefined) { @@ -432,7 +491,10 @@ The client which encoded this data likely specified an "minVersionForCollab" val */ public buildDecoder( options: TBuildOptions, - ): Pick, "decode"> { + ): Pick< + VersionDispatchingCodec, + "decode" + > { return this.buildDecoderInternal(options)[1]; } @@ -464,23 +526,33 @@ The client which encoded this data likely specified an "minVersionForCollab" val // eslint-disable-next-line @typescript-eslint/explicit-function-return-type public static build< Name extends CodecName, - Entry extends CodecVersion, + Entry extends CodecVersion, >(name: Name, inputRegistry: readonly Entry[]) { type TDecoded2 = - Entry extends CodecVersion ? D : never; - type TContext2 = - Entry extends CodecVersion ? C : never; + Entry extends CodecVersion ? D : never; + type TEncodeContext2 = + Entry extends CodecVersion ? C : never; type TFormatVersion2 = - Entry extends CodecVersion ? F : never; + Entry extends CodecVersion ? F : never; type TBuildOptions2 = - Entry extends CodecVersion ? B : never; + Entry extends CodecVersion + ? B + : never; + type TDecodeContext2 = + Entry extends CodecVersion ? D : never; + + type ResolvedEncodeContext = unknown extends TEncodeContext2 ? void : TEncodeContext2; + type ResolvedDecodeContext = unknown extends TDecodeContext2 + ? ResolvedEncodeContext + : TDecodeContext2; type CodecFinal = CodecVersion< TDecoded2, // If it does not matter what context is provided, undefined is fine, so allow it to be omitted. - unknown extends TContext2 ? void : TContext2, + ResolvedEncodeContext, TFormatVersion2, - TBuildOptions2 + TBuildOptions2, + ResolvedDecodeContext >; const input = inputRegistry as readonly unknown[] as readonly CodecFinal[]; @@ -488,9 +560,10 @@ The client which encoded this data likely specified an "minVersionForCollab" val const builder = new VersionDispatchingCodecBuilder< TBuildOptions2, TDecoded2, - unknown extends TContext2 ? void : TContext2, + ResolvedEncodeContext, TFormatVersion2, - Name + Name, + ResolvedDecodeContext >(name, input); return builder; } diff --git a/packages/dds/tree/src/test/codec/versioned/codec.spec.ts b/packages/dds/tree/src/test/codec/versioned/codec.spec.ts index a07accc72811..1c18957f5857 100644 --- a/packages/dds/tree/src/test/codec/versioned/codec.spec.ts +++ b/packages/dds/tree/src/test/codec/versioned/codec.spec.ts @@ -142,6 +142,39 @@ The client which encoded this data likely specified an "minVersionForCollab" val ); }); + it("distinct encode and decode context types", () => { + interface EncodeContext { + encodeOffset: number; + } + interface DecodeContext { + decodeOffset: number; + } + interface Encoded { + version: 1; + value: number; + } + const contextualCodec: CodecAndSchema = { + encode: (value, context) => ({ version: 1, value: value + context.encodeOffset }), + decode: (data, context) => (data as unknown as Encoded).value + context.decodeOffset, + schema: Versioned, + }; + const contextualBuilder = VersionDispatchingCodecBuilder.build("Contextual", [ + { + minVersionForCollab: lowestMinVersionForCollab, + formatVersion: 1, + codec: contextualCodec, + }, + ]); + const codec = contextualBuilder.build({ + minVersionForCollab: "2.0.0", + jsonValidator: FormatValidatorBasic, + }); + + const encoded = codec.encode(5, { encodeOffset: 10 }); + assert.deepEqual(encoded, { version: 1, value: 15 }); + assert.equal(codec.decode(encoded, { decodeOffset: -10 }), 5); + }); + it("good builds", () => { VersionDispatchingCodecBuilder.build("Test", [ {