diff --git a/packages/1-framework/1-core/framework-components/src/shared/resolve-codec.ts b/packages/1-framework/1-core/framework-components/src/shared/resolve-codec.ts index 200f26a65bd5..68fb58d3f8c2 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/resolve-codec.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/resolve-codec.ts @@ -66,12 +66,9 @@ export function validateCodecTypeParams(descriptor: AnyCodecDescriptor, ref: Cod /** * Resolves a `Codec` instance: validates `ref.typeParams` via - * {@link validateCodecTypeParams} then calls `descriptor.factory(validated)(ctx)`. - * - * The descriptor's `factory` is typed against its own `P`; the registry erases - * `P` to `any`, so the factory is narrowed to `(params: unknown) => (ctx) => Codec` - * at the call boundary. The `paramsSchema` validates the input above before we - * forward it, so the narrowing is safe by construction. + * {@link validateCodecTypeParams} then calls `descriptor.factory(validated)(ctx)` + * as a method on `descriptor`, preserving `this` for factories that build + * their returned codec from the descriptor instance (e.g. `new XCodec(this)`). */ export function materializeCodec( descriptor: AnyCodecDescriptor, @@ -79,8 +76,5 @@ export function materializeCodec( ctx: CodecInstanceContext, ): Codec { const validated = validateCodecTypeParams(descriptor, ref); - return blindCast< - (params: unknown) => (ctx: CodecInstanceContext) => Codec, - 'registry erases P to any; paramsSchema validates input before forwarding' - >(descriptor.factory)(validated)(ctx); + return descriptor.factory(validated)(ctx); } diff --git a/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts b/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts new file mode 100644 index 000000000000..f57fefef9e06 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/materialize-codec.test.ts @@ -0,0 +1,125 @@ +import type { JsonValue } from '@internal/contract/types'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { test } from 'vitest'; +import { + type AnyCodecDescriptor, + type CodecCallContext, + type CodecDescriptor, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type CodecRef, + type CodecTrait, + materializeCodec, + voidParamsSchema, +} from '../src/exports/codec'; + +class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], number, number> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +class Int4FixtureDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'demo/int4@1' as const; + override readonly traits: readonly CodecTrait[] = ['equality']; + override readonly targetTypes: readonly string[] = ['int4']; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec { + return () => new Int4FixtureCodec(this); + } +} + +const int4FixtureDescriptor = new Int4FixtureDescriptor(); + +type VectorParams = { readonly length: number }; +const vectorFixtureParamsSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'demo', + validate: (input) => ({ value: input as VectorParams }), + }, +}; + +class VectorFixtureCodec extends CodecImpl< + 'demo/vector@1', + readonly ['equality'], + string, + number[] +> { + constructor( + descriptor: CodecDescriptor, + public readonly dimension: N, + ) { + super(descriptor); + } + async encode(value: number[], _ctx: CodecCallContext): Promise { + return `[${value.join(',')}]`; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire.slice(1, -1).split(',').map(Number); + } + encodeJson(value: number[]): JsonValue { + return value; + } + decodeJson(json: JsonValue): number[] { + return json as number[]; + } +} + +class VectorFixtureDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'demo/vector@1' as const; + override readonly traits: readonly CodecTrait[] = ['equality']; + override readonly targetTypes: readonly string[] = ['vector']; + override readonly paramsSchema = vectorFixtureParamsSchema; + override factory(params: { + readonly length: N; + }): (ctx: CodecInstanceContext) => VectorFixtureCodec { + return () => new VectorFixtureCodec(this, params.length); + } +} + +const vectorFixtureDescriptor = new VectorFixtureDescriptor(); + +const stubCtx = {} as CodecInstanceContext; + +function descriptorFor(ref: CodecRef): AnyCodecDescriptor { + if (ref.codecId === int4FixtureDescriptor.codecId) return int4FixtureDescriptor; + if (ref.codecId === vectorFixtureDescriptor.codecId) return vectorFixtureDescriptor; + throw new Error(`no fixture descriptor for ${ref.codecId}`); +} + +test('materializeCodec resolves a non-parameterized codec whose id reads the descriptor codecId', ({ + expect, +}) => { + const ref: CodecRef = { codecId: 'demo/int4@1' }; + const codec = materializeCodec(descriptorFor(ref), ref, stubCtx); + expect(codec.id).toBe('demo/int4@1'); +}); + +test('materializeCodec resolves a parameterized codec whose id reads the descriptor codecId', ({ + expect, +}) => { + const ref: CodecRef = { codecId: 'demo/vector@1', typeParams: { length: 1536 } }; + const codec = materializeCodec(descriptorFor(ref), ref, stubCtx); + expect(codec.id).toBe('demo/vector@1'); +}); + +test('materializeCodec produces a codec whose encode/decode still run through the descriptor-bound factory', async ({ + expect, +}) => { + const ref: CodecRef = { codecId: 'demo/vector@1', typeParams: { length: 3 } }; + const codec = materializeCodec(descriptorFor(ref), ref, stubCtx); + const wire = await codec.encode([1, 2, 3], {}); + expect(wire).toBe('[1,2,3]'); + expect(await codec.decode(wire, {})).toEqual([1, 2, 3]); +});