@prisma-next/sql-runtime 0.5.0-dev.30 → 0.5.0-dev.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import {
6
6
  import {
7
7
  type Codec,
8
8
  type CodecRegistry,
9
+ type ContractCodecRegistry,
9
10
  collectOrderedParamRefs,
10
11
  type SqlCodecCallContext,
11
12
  } from '@prisma-next/sql-relational-core/ast';
@@ -18,6 +19,39 @@ interface ParamMetadata {
18
19
 
19
20
  const NO_METADATA: ParamMetadata = Object.freeze({ codecId: undefined, name: undefined });
20
21
 
22
+ /**
23
+ * Resolve the codec for an outgoing param.
24
+ *
25
+ * Phase B (and AC-5-deferred carve-out): `ParamRef` does not carry a
26
+ * `(table, column)` ref today — every `ParamRef` carries `codecId` but
27
+ * not the column it relates to. Encode-side dispatch therefore consults
28
+ * `contractCodecs.forCodecId(codecId)` (which itself prefers the
29
+ * contract-walk-derived shared codec, falling back to the legacy
30
+ * `CodecRegistry.get` for parameterized codec ids whose contracts don't
31
+ * have a column the walk could resolve through).
32
+ *
33
+ * For the parameterized codecs shipped at Phase B (pgvector, postgres
34
+ * json/jsonb), encode is per-instance-stateless w.r.t. params:
35
+ * - pgvector formats `[v1,v2,...]` regardless of declared length;
36
+ * - postgres json/jsonb encode is `JSON.stringify` regardless of schema.
37
+ *
38
+ * So the codec-id-keyed lookup yields a structurally equivalent encoder
39
+ * even when the resolved per-instance codec carries extra state (e.g. a
40
+ * compiled JSON-Schema validator used only by `decode`). TML-2357 retires
41
+ * the fallback by threading `ParamRef.refs` through column-bound
42
+ * construction sites.
43
+ */
44
+ function resolveParamCodec(
45
+ metadata: ParamMetadata,
46
+ registry: CodecRegistry,
47
+ contractCodecs: ContractCodecRegistry | undefined,
48
+ ): Codec | undefined {
49
+ if (!metadata.codecId) return undefined;
50
+ const fromContract = contractCodecs?.forCodecId(metadata.codecId);
51
+ if (fromContract) return fromContract;
52
+ return registry.get(metadata.codecId);
53
+ }
54
+
21
55
  function paramLabel(metadata: ParamMetadata, paramIndex: number): string {
22
56
  return metadata.name ?? `param[${paramIndex}]`;
23
57
  }
@@ -59,6 +93,7 @@ export async function encodeParam(
59
93
  paramIndex: number,
60
94
  registry: CodecRegistry,
61
95
  ctx: SqlCodecCallContext,
96
+ contractCodecs?: ContractCodecRegistry,
62
97
  ): Promise<unknown> {
63
98
  return encodeParamValue(
64
99
  value,
@@ -66,6 +101,7 @@ export async function encodeParam(
66
101
  paramIndex,
67
102
  registry,
68
103
  ctx,
104
+ contractCodecs,
69
105
  );
70
106
  }
71
107
 
@@ -75,16 +111,13 @@ async function encodeParamValue(
75
111
  paramIndex: number,
76
112
  registry: CodecRegistry,
77
113
  ctx: SqlCodecCallContext,
114
+ contractCodecs: ContractCodecRegistry | undefined,
78
115
  ): Promise<unknown> {
79
116
  if (value === null || value === undefined) {
80
117
  return null;
81
118
  }
82
119
 
83
- if (!metadata.codecId) {
84
- return value;
85
- }
86
-
87
- const codec: Codec | undefined = registry.get(metadata.codecId);
120
+ const codec = resolveParamCodec(metadata, registry, contractCodecs);
88
121
  if (!codec) {
89
122
  return value;
90
123
  }
@@ -119,6 +152,7 @@ export async function encodeParams(
119
152
  plan: SqlExecutionPlan,
120
153
  registry: CodecRegistry,
121
154
  ctx: SqlCodecCallContext,
155
+ contractCodecs?: ContractCodecRegistry,
122
156
  ): Promise<readonly unknown[]> {
123
157
  checkAborted(ctx, 'encode');
124
158
  const signal = ctx.signal;
@@ -142,7 +176,14 @@ export async function encodeParams(
142
176
 
143
177
  const tasks: Promise<unknown>[] = new Array(paramCount);
144
178
  for (let i = 0; i < paramCount; i++) {
145
- tasks[i] = encodeParamValue(plan.params[i], metadata[i] ?? NO_METADATA, i, registry, ctx);
179
+ tasks[i] = encodeParamValue(
180
+ plan.params[i],
181
+ metadata[i] ?? NO_METADATA,
182
+ i,
183
+ registry,
184
+ ctx,
185
+ contractCodecs,
186
+ );
146
187
  }
147
188
 
148
189
  const settled = await raceAgainstAbort(Promise.all(tasks), signal, 'encode');
@@ -2,6 +2,7 @@ import type { Contract } from '@prisma-next/contract/types';
2
2
  import { runtimeError } from '@prisma-next/framework-components/runtime';
3
3
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
4
4
  import type { CodecRegistry } from '@prisma-next/sql-relational-core/ast';
5
+ import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context';
5
6
 
6
7
  export function extractCodecIds(contract: Contract<SqlStorage>): Set<string> {
7
8
  const codecIds = new Set<string>();
@@ -30,15 +31,33 @@ function extractCodecIdsFromColumns(contract: Contract<SqlStorage>): Map<string,
30
31
  return codecIds;
31
32
  }
32
33
 
34
+ interface CodecLookupForValidation {
35
+ has(id: string): boolean;
36
+ }
37
+
38
+ function adaptDescriptorRegistry(registry: CodecDescriptorRegistry): CodecLookupForValidation {
39
+ return { has: (id: string) => registry.descriptorFor(id) !== undefined };
40
+ }
41
+
42
+ function isDescriptorRegistry(
43
+ registry: CodecRegistry | CodecDescriptorRegistry,
44
+ ): registry is CodecDescriptorRegistry {
45
+ return 'descriptorFor' in registry;
46
+ }
47
+
33
48
  export function validateContractCodecMappings(
34
- registry: CodecRegistry,
49
+ registry: CodecRegistry | CodecDescriptorRegistry,
35
50
  contract: Contract<SqlStorage>,
36
51
  ): void {
52
+ const lookup: CodecLookupForValidation = isDescriptorRegistry(registry)
53
+ ? adaptDescriptorRegistry(registry)
54
+ : registry;
55
+
37
56
  const codecIds = extractCodecIdsFromColumns(contract);
38
57
  const invalidCodecs: Array<{ table: string; column: string; codecId: string }> = [];
39
58
 
40
59
  for (const [key, codecId] of codecIds.entries()) {
41
- if (!registry.has(codecId)) {
60
+ if (!lookup.has(codecId)) {
42
61
  const parts = key.split('.');
43
62
  const table = parts[0] ?? '';
44
63
  const column = parts[1] ?? '';
@@ -61,7 +80,7 @@ export function validateContractCodecMappings(
61
80
  }
62
81
 
63
82
  export function validateCodecRegistryCompleteness(
64
- registry: CodecRegistry,
83
+ registry: CodecRegistry | CodecDescriptorRegistry,
65
84
  contract: Contract<SqlStorage>,
66
85
  ): void {
67
86
  validateContractCodecMappings(registry, contract);
@@ -1,4 +1,6 @@
1
1
  import type { Contract, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
2
+ import type { CodecDescriptor } from '@prisma-next/framework-components/codec';
3
+ import { synthesizeNonParameterizedDescriptor } from '@prisma-next/framework-components/codec';
2
4
  import type { ComponentDescriptor } from '@prisma-next/framework-components/components';
3
5
  import { checkContractComponentRequirements } from '@prisma-next/framework-components/components';
4
6
  import {
@@ -14,7 +16,7 @@ import {
14
16
  type RuntimeTargetInstance,
15
17
  } from '@prisma-next/framework-components/execution';
16
18
  import { runtimeError } from '@prisma-next/framework-components/runtime';
17
- import type { SqlStorage, StorageTypeInstance } from '@prisma-next/sql-contract/types';
19
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
18
20
  import {
19
21
  createSqlOperationRegistry,
20
22
  type SqlOperationDescriptor,
@@ -22,39 +24,41 @@ import {
22
24
  import type {
23
25
  Adapter,
24
26
  AnyQueryAst,
25
- CodecParamsDescriptor,
27
+ Codec,
26
28
  CodecRegistry,
29
+ ContractCodecRegistry,
27
30
  LoweredStatement,
31
+ SqlCodecInstanceContext,
28
32
  SqlDriver,
29
33
  } from '@prisma-next/sql-relational-core/ast';
30
34
  import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast';
31
35
  import type {
32
36
  AppliedMutationDefault,
37
+ CodecDescriptorRegistry,
33
38
  ExecutionContext,
34
39
  JsonSchemaValidateFn,
35
40
  JsonSchemaValidatorRegistry,
36
41
  MutationDefaultsOptions,
37
42
  TypeHelperRegistry,
38
43
  } from '@prisma-next/sql-relational-core/query-lane-context';
39
- import { type as arktype } from 'arktype';
40
44
 
41
45
  /**
42
46
  * Runtime parameterized codec descriptor.
43
- * Provides validation schema and optional init hook for codecs that support type parameters.
44
- * Used at runtime to validate typeParams and create type helpers.
45
47
  *
46
- * This is a type alias for `CodecParamsDescriptor` from the AST layer,
47
- * which is the shared definition used by both adapter and runtime.
48
+ * The unified `CodecDescriptor<P>` shape applied to parameterized codecs
49
+ * `paramsSchema: StandardSchemaV1<P>` for JSON-boundary validation,
50
+ * `factory: (P) => (CodecInstanceContext) => Codec` for the curried higher-order codec.
51
+ * The factory is called once per `storage.types` instance (or once per
52
+ * inline-`typeParams` column); per-instance state lives in the closure.
53
+ *
54
+ * Codec-registry-unification spec § Decision.
48
55
  */
49
- export type RuntimeParameterizedCodecDescriptor<
50
- TParams = Record<string, unknown>,
51
- THelper = unknown,
52
- > = CodecParamsDescriptor<TParams, THelper>;
56
+ export type RuntimeParameterizedCodecDescriptor<P = Record<string, unknown>> = CodecDescriptor<P>;
53
57
 
54
58
  export interface SqlStaticContributions {
55
59
  readonly codecs: () => CodecRegistry;
56
60
  // biome-ignore lint/suspicious/noExplicitAny: needed for covariance with concrete descriptor types
57
- readonly parameterizedCodecs: () => ReadonlyArray<RuntimeParameterizedCodecDescriptor<any, any>>;
61
+ readonly parameterizedCodecs: () => ReadonlyArray<RuntimeParameterizedCodecDescriptor<any>>;
58
62
  readonly queryOperations?: () => ReadonlyArray<SqlOperationDescriptor>;
59
63
  readonly mutationDefaultGenerators?: () => ReadonlyArray<RuntimeMutationDefaultGenerator>;
60
64
  }
@@ -206,9 +210,16 @@ function validateTypeParams(
206
210
  codecDescriptor: RuntimeParameterizedCodecDescriptor,
207
211
  context: { typeName?: string; tableName?: string; columnName?: string },
208
212
  ): Record<string, unknown> {
209
- const result = codecDescriptor.paramsSchema(typeParams);
210
- if (result instanceof arktype.errors) {
211
- const messages = result.map((p: { message: string }) => p.message).join('; ');
213
+ const result = codecDescriptor.paramsSchema['~standard'].validate(typeParams);
214
+ if (result instanceof Promise) {
215
+ throw runtimeError(
216
+ 'RUNTIME.TYPE_PARAMS_INVALID',
217
+ `paramsSchema for codec '${codecDescriptor.codecId}' returned a Promise; runtime validation requires a synchronous Standard Schema validator.`,
218
+ { ...context, codecId: codecDescriptor.codecId, typeParams },
219
+ );
220
+ }
221
+ if (result.issues) {
222
+ const messages = result.issues.map((issue) => issue.message).join('; ');
212
223
  const locationInfo = context.typeName
213
224
  ? `type '${context.typeName}'`
214
225
  : `column '${context.tableName}.${context.columnName}'`;
@@ -218,7 +229,7 @@ function validateTypeParams(
218
229
  { ...context, codecId: codecDescriptor.codecId, typeParams },
219
230
  );
220
231
  }
221
- return result as Record<string, unknown>;
232
+ return result.value as Record<string, unknown>;
222
233
  }
223
234
 
224
235
  function collectParameterizedCodecDescriptors(
@@ -242,32 +253,116 @@ function collectParameterizedCodecDescriptors(
242
253
  return descriptors;
243
254
  }
244
255
 
256
+ /**
257
+ * Build the unified descriptor map. Combines parameterized descriptors
258
+ * (which already ship as `CodecDescriptor`s) with synthesized descriptors
259
+ * for non-parameterized codecs registered through the legacy `codecs:`
260
+ * slot. Codec ids that ship a parameterized descriptor take precedence —
261
+ * even when the legacy registry registers a representative codec under
262
+ * the same id, the parameterized descriptor is the authoritative source.
263
+ *
264
+ * Codec-registry-unification spec § Decision: every codec resolves
265
+ * through one descriptor map; reads are non-branching.
266
+ */
267
+ function buildCodecDescriptorRegistry(
268
+ codecRegistry: CodecRegistry,
269
+ parameterizedDescriptors: Map<string, RuntimeParameterizedCodecDescriptor>,
270
+ ): CodecDescriptorRegistry {
271
+ type AnyDescriptor = CodecDescriptor<unknown>;
272
+ const byId = new Map<string, AnyDescriptor>();
273
+ const byTargetType = new Map<string, Array<AnyDescriptor>>();
274
+
275
+ function registerInIndices(descriptor: AnyDescriptor): void {
276
+ byId.set(descriptor.codecId, descriptor);
277
+ for (const targetType of descriptor.targetTypes) {
278
+ const list = byTargetType.get(targetType);
279
+ if (list) {
280
+ list.push(descriptor);
281
+ } else {
282
+ byTargetType.set(targetType, [descriptor]);
283
+ }
284
+ }
285
+ }
286
+
287
+ // The descriptor map is heterogeneous in `P` — each codec id has its own
288
+ // params shape. The public `CodecDescriptorRegistry` interface widens to
289
+ // `CodecDescriptor<unknown>` and consumers narrow per codec id at the
290
+ // call site (the descriptor's `paramsSchema` validates JSON-sourced
291
+ // params before the factory ever sees them, so the runtime narrow is
292
+ // safe). The cast at registration goes through `unknown` because
293
+ // `CodecDescriptor<P>` is invariant in `P` (the `factory` and
294
+ // `renderOutputType` slots use `P` contravariantly).
295
+ for (const descriptor of parameterizedDescriptors.values()) {
296
+ registerInIndices(descriptor as unknown as AnyDescriptor);
297
+ }
298
+
299
+ for (const codec of codecRegistry.values()) {
300
+ if (byId.has(codec.id)) continue;
301
+ registerInIndices(synthesizeNonParameterizedDescriptor(codec) as unknown as AnyDescriptor);
302
+ }
303
+
304
+ return {
305
+ descriptorFor(codecId: string): AnyDescriptor | undefined {
306
+ return byId.get(codecId);
307
+ },
308
+ *values(): IterableIterator<AnyDescriptor> {
309
+ yield* byId.values();
310
+ },
311
+ byTargetType(targetType: string): readonly AnyDescriptor[] {
312
+ return byTargetType.get(targetType) ?? Object.freeze([]);
313
+ },
314
+ };
315
+ }
316
+
317
+ function collectTypeRefSites(
318
+ storage: SqlStorage,
319
+ ): Map<string, Array<{ readonly table: string; readonly column: string }>> {
320
+ const sites = new Map<string, Array<{ readonly table: string; readonly column: string }>>();
321
+ for (const [tableName, table] of Object.entries(storage.tables)) {
322
+ for (const [columnName, column] of Object.entries(table.columns)) {
323
+ if (typeof column.typeRef !== 'string') continue;
324
+ const list = sites.get(column.typeRef);
325
+ const entry = { table: tableName, column: columnName };
326
+ if (list) {
327
+ list.push(entry);
328
+ } else {
329
+ sites.set(column.typeRef, [entry]);
330
+ }
331
+ }
332
+ }
333
+ return sites;
334
+ }
335
+
245
336
  function initializeTypeHelpers(
246
- storageTypes: Record<string, StorageTypeInstance> | undefined,
337
+ storage: SqlStorage,
247
338
  codecDescriptors: Map<string, RuntimeParameterizedCodecDescriptor>,
248
339
  ): TypeHelperRegistry {
249
340
  const helpers: TypeHelperRegistry = {};
341
+ const storageTypes = storage.types;
250
342
 
251
343
  if (!storageTypes) {
252
344
  return helpers;
253
345
  }
254
346
 
347
+ const typeRefSites = collectTypeRefSites(storage);
348
+
255
349
  for (const [typeName, typeInstance] of Object.entries(storageTypes)) {
256
350
  const descriptor = codecDescriptors.get(typeInstance.codecId);
257
351
 
258
- if (descriptor) {
259
- const validatedParams = validateTypeParams(typeInstance.typeParams, descriptor, {
260
- typeName,
261
- });
262
-
263
- if (descriptor.init) {
264
- helpers[typeName] = descriptor.init(validatedParams);
265
- } else {
266
- helpers[typeName] = typeInstance;
267
- }
268
- } else {
352
+ if (!descriptor) {
353
+ // No parameterized descriptor for this codec id — store the raw
354
+ // type instance for callers that need typeParams metadata.
269
355
  helpers[typeName] = typeInstance;
356
+ continue;
270
357
  }
358
+
359
+ const validatedParams = validateTypeParams(typeInstance.typeParams, descriptor, {
360
+ typeName,
361
+ });
362
+
363
+ const usedAt = typeRefSites.get(typeName) ?? [];
364
+ const ctx: SqlCodecInstanceContext = { name: typeName, usedAt };
365
+ helpers[typeName] = descriptor.factory(validatedParams)(ctx);
271
366
  }
272
367
 
273
368
  return helpers;
@@ -290,67 +385,217 @@ function validateColumnTypeParams(
290
385
  }
291
386
 
292
387
  /**
293
- * Builds a registry of compiled JSON Schema validators by scanning the contract
294
- * for columns whose codec descriptor provides an `init` hook returning `{ validate }`.
388
+ * View of a codec that exposes a per-instance JSON-schema `validate`
389
+ * function. Codecs declare this contract by including the
390
+ * `'json-validator'` `CodecTrait` in their `traits` array; the trait is
391
+ * the gate that lets `extractValidator` resolve from structurally-typed
392
+ * `unknown` to this typed view.
393
+ */
394
+ type JsonValidatorCodec = {
395
+ readonly traits?: ReadonlyArray<unknown>;
396
+ readonly validate: JsonSchemaValidateFn;
397
+ };
398
+
399
+ function hasJsonValidatorTrait(candidate: unknown): candidate is JsonValidatorCodec {
400
+ if (candidate === null || typeof candidate !== 'object') return false;
401
+ const traits = (candidate as { readonly traits?: unknown }).traits;
402
+ if (!Array.isArray(traits)) return false;
403
+ if (!traits.includes('json-validator')) return false;
404
+ const validate = (candidate as { readonly validate?: unknown }).validate;
405
+ return typeof validate === 'function';
406
+ }
407
+
408
+ function extractValidator(candidate: unknown): JsonSchemaValidateFn | undefined {
409
+ return hasJsonValidatorTrait(candidate) ? candidate.validate : undefined;
410
+ }
411
+
412
+ function isResolvedCodec(candidate: unknown): candidate is Codec {
413
+ return (
414
+ candidate !== null &&
415
+ typeof candidate === 'object' &&
416
+ 'id' in candidate &&
417
+ 'decode' in candidate
418
+ );
419
+ }
420
+
421
+ /**
422
+ * Walk the contract's `storage.tables[].columns[]` and resolve each
423
+ * column to a `Codec` through the unified descriptor map. Per-instance
424
+ * behavior:
425
+ *
426
+ * - **typeRef columns**: reuse the resolved codec materialized once by
427
+ * `initializeTypeHelpers` for the `storage.types` entry. Multiple
428
+ * columns sharing one typeRef share one codec instance.
429
+ * - **inline-typeParams columns**: call `descriptor.factory(typeParams)
430
+ * (ctx)` once per column (per-column anonymous instance).
431
+ * - **non-parameterized columns**: call `descriptor.factory()(ctx)`
432
+ * once. The synthesized descriptor's factory is constant — every call
433
+ * returns the same shared codec instance — so columns sharing a non-
434
+ * parameterized codec id share one resolved codec without explicit
435
+ * caching.
295
436
  *
296
- * Handles both:
297
- * - Inline `typeParams.schema` on columns
298
- * - `typeRef` `storage.types[ref]` with init hook results already in `types` registry
437
+ * Combines what `initializeTypeHelpers` (named-instance walk) and the
438
+ * old `buildJsonSchemaValidatorRegistry` (per-column walk) used to do
439
+ * separately: one walk over all columns, one resolved codec per column,
440
+ * one trait-gated validator extraction per column. The result drives
441
+ * both the dispatch registry (`ContractCodecRegistry.forColumn`) and the
442
+ * validator registry.
443
+ *
444
+ * Codec-registry-unification spec § AC-4: every column resolves through
445
+ * one descriptor map without branching on parameterization.
299
446
  */
300
- function buildJsonSchemaValidatorRegistry(
447
+ function buildContractCodecRegistry(
301
448
  contract: Contract<SqlStorage>,
449
+ codecDescriptors: CodecDescriptorRegistry,
450
+ legacyCodecRegistry: CodecRegistry,
302
451
  types: TypeHelperRegistry,
303
- codecDescriptors: Map<string, RuntimeParameterizedCodecDescriptor>,
304
- ): JsonSchemaValidatorRegistry | undefined {
452
+ parameterizedDescriptors: Map<string, RuntimeParameterizedCodecDescriptor>,
453
+ ): {
454
+ readonly registry: ContractCodecRegistry;
455
+ readonly jsonValidators: JsonSchemaValidatorRegistry | undefined;
456
+ } {
457
+ const byColumn = new Map<string, Codec>();
458
+ const byCodecId = new Map<string, Codec>();
459
+ // Codec ids whose `byCodecId` entry is ambiguous — multiple distinct
460
+ // resolved instances landed under the same parameterized codec id (e.g.
461
+ // `Vector<1024>` and `Vector<1536>` both registering under
462
+ // `pg/vector@1`). The encode-side `forCodecId` fallback rejects these
463
+ // ids so a DSL-param without a column ref cannot silently bind to the
464
+ // wrong instance. Retires when AC-5's `ParamRef.refs` plumbing lands
465
+ // (TML-2357).
466
+ const ambiguousCodecIds = new Set<string>();
305
467
  const validators = new Map<string, JsonSchemaValidateFn>();
306
468
 
307
- // Collect codec IDs that have init hooks (these produce { validate } helpers)
308
- const codecIdsWithInit = new Set<string>();
309
- for (const [codecId, descriptor] of codecDescriptors) {
310
- if (descriptor.init) {
311
- codecIdsWithInit.add(codecId);
312
- }
313
- }
314
-
315
- if (codecIdsWithInit.size === 0) {
316
- return undefined;
317
- }
318
-
319
469
  for (const [tableName, table] of Object.entries(contract.storage.tables)) {
320
470
  for (const [columnName, column] of Object.entries(table.columns)) {
321
- if (!codecIdsWithInit.has(column.codecId)) continue;
322
-
323
- const key = `${tableName}.${columnName}`;
324
-
325
- // Case 1: column references a named type → validator already compiled via init hook
326
- if (column.typeRef) {
327
- const helper = types[column.typeRef] as { validate?: JsonSchemaValidateFn } | undefined;
328
- if (helper?.validate) {
329
- validators.set(key, helper.validate);
471
+ const columnKey = `${tableName}.${columnName}`;
472
+ const descriptor = codecDescriptors.descriptorFor(column.codecId);
473
+
474
+ let resolvedCodec: Codec | undefined;
475
+
476
+ if (descriptor) {
477
+ const isParameterized = parameterizedDescriptors.has(column.codecId);
478
+
479
+ if (column.typeRef) {
480
+ // The named instance was already materialized once by
481
+ // `initializeTypeHelpers`; reuse it so multiple columns sharing
482
+ // the same typeRef share one codec instance (and any per-
483
+ // instance helper state on it).
484
+ const helper = types[column.typeRef];
485
+ if (isResolvedCodec(helper)) {
486
+ resolvedCodec = helper;
487
+ }
488
+ } else if (column.typeParams && isParameterized) {
489
+ const parameterizedDescriptor = parameterizedDescriptors.get(column.codecId);
490
+ if (parameterizedDescriptor) {
491
+ const validatedParams = validateTypeParams(column.typeParams, parameterizedDescriptor, {
492
+ tableName,
493
+ columnName,
494
+ });
495
+ const ctx: SqlCodecInstanceContext = {
496
+ name: `<anon:${tableName}.${columnName}>`,
497
+ usedAt: [{ table: tableName, column: columnName }],
498
+ };
499
+ resolvedCodec = parameterizedDescriptor.factory(validatedParams)(ctx);
500
+ }
501
+ } else if (!isParameterized) {
502
+ // Non-parameterized column. Cache the resolved codec by codec
503
+ // id — the synthesized descriptor's factory is constant for
504
+ // non-parameterized codecs, so columns sharing this codec id
505
+ // share one resolved instance.
506
+ let cached = byCodecId.get(column.codecId);
507
+ if (!cached) {
508
+ const ctx: SqlCodecInstanceContext = {
509
+ name: `<shared:${column.codecId}>`,
510
+ usedAt: [{ table: tableName, column: columnName }],
511
+ };
512
+ // `synthesizeNonParameterizedDescriptor` produces a
513
+ // `CodecDescriptor<void>` whose factory ignores its params
514
+ // and ctx; the runtime's `void` value is `undefined`. The
515
+ // structural cast goes through `unknown` to satisfy the
516
+ // heterogeneous-`P` registry boundary (the factory's
517
+ // declared `P` is `any` here; the consumer narrows per
518
+ // codec id). The cast narrows the descriptor's
519
+ // family-agnostic `CodecInstanceContext` slot to the SQL
520
+ // `SqlCodecInstanceContext` we pass at this call site —
521
+ // function-argument contravariance makes the narrow safe
522
+ // (a callee that accepts the base will also accept the
523
+ // SQL extension). Per spec § Non-functional constraints.
524
+ const voidFactory = descriptor.factory as unknown as (
525
+ params: undefined,
526
+ ) => (ctx: SqlCodecInstanceContext) => Codec;
527
+ cached = voidFactory(undefined)(ctx);
528
+ byCodecId.set(column.codecId, cached);
529
+ }
530
+ resolvedCodec = cached;
330
531
  }
331
- continue;
532
+ // else: parameterized codec id with no typeRef and no typeParams
533
+ // — this is the legitimate "undimensioned" form for codecs that
534
+ // ship a no-params column variant alongside a parameterized one
535
+ // (e.g. pgvector's `vectorColumn` vs. `vector(N)`). Leave
536
+ // `resolvedCodec` undefined; encode/decode for this column flows
537
+ // through `forCodecId` (the AC-5-deferred carve-out documented
538
+ // in `relational-core/src/ast/codec-types.ts`). The fallback
539
+ // works for these cases because their wire format is
540
+ // params-independent (vector formats `[v1,v2,...]` regardless
541
+ // of declared length).
332
542
  }
333
543
 
334
- // Case 2: inline typeParams with schema → compile via init hook
335
- if (column.typeParams) {
336
- const descriptor = codecDescriptors.get(column.codecId);
337
- if (descriptor?.init) {
338
- const helper = descriptor.init(column.typeParams) as
339
- | { validate?: JsonSchemaValidateFn }
340
- | undefined;
341
- if (helper?.validate) {
342
- validators.set(key, helper.validate);
343
- }
544
+ if (resolvedCodec) {
545
+ byColumn.set(columnKey, resolvedCodec);
546
+ const validate = extractValidator(resolvedCodec);
547
+ if (validate) {
548
+ validators.set(columnKey, validate);
549
+ }
550
+ const existing = byCodecId.get(column.codecId);
551
+ if (existing === undefined) {
552
+ byCodecId.set(column.codecId, resolvedCodec);
553
+ } else if (existing !== resolvedCodec && parameterizedDescriptors.has(column.codecId)) {
554
+ ambiguousCodecIds.add(column.codecId);
344
555
  }
345
556
  }
346
557
  }
347
558
  }
348
559
 
349
- if (validators.size === 0) return undefined;
350
- return {
351
- get: (key: string) => validators.get(key),
352
- size: validators.size,
560
+ const registry: ContractCodecRegistry = {
561
+ forColumn(table, column) {
562
+ return byColumn.get(`${table}.${column}`);
563
+ },
564
+ forCodecId(codecId) {
565
+ // Codec-id-only fallback for sites without a column ref (encode-
566
+ // side DSL params whose `ParamRef.refs` isn't populated). Prefer
567
+ // the contract-walk-derived shared codec; fall back to the legacy
568
+ // `codecRegistry.get` for parameterized codec ids whose contracts
569
+ // don't have a typeRef/typeParams column the walk could resolve
570
+ // through. The legacy fallback retires once `ParamRef.refs` is
571
+ // threaded everywhere (TML-2357).
572
+ //
573
+ // Reject ambiguous parameterized fallbacks: if the contract walk
574
+ // resolved more than one distinct codec instance under this id
575
+ // (e.g. multiple vector dimensions, multiple arktype-json
576
+ // schemas), the codec-id-keyed lookup cannot honor the call site
577
+ // — fail fast rather than bind to whichever instance happened to
578
+ // land first.
579
+ if (ambiguousCodecIds.has(codecId)) {
580
+ throw runtimeError(
581
+ 'RUNTIME.TYPE_PARAMS_INVALID',
582
+ `Codec '${codecId}' resolves to multiple parameterized instances; column-aware dispatch is required.`,
583
+ { codecId },
584
+ );
585
+ }
586
+ return byCodecId.get(codecId) ?? legacyCodecRegistry.get(codecId);
587
+ },
353
588
  };
589
+
590
+ const jsonValidators: JsonSchemaValidatorRegistry | undefined =
591
+ validators.size > 0
592
+ ? {
593
+ get: (key: string) => validators.get(key),
594
+ size: validators.size,
595
+ }
596
+ : undefined;
597
+
598
+ return { registry, jsonValidators };
354
599
  }
355
600
 
356
601
  function collectMutationDefaultGenerators(
@@ -476,23 +721,32 @@ export function createExecutionContext<
476
721
  }
477
722
 
478
723
  const parameterizedCodecDescriptors = collectParameterizedCodecDescriptors(contributors);
724
+ const codecDescriptors = buildCodecDescriptorRegistry(
725
+ codecRegistry,
726
+ parameterizedCodecDescriptors,
727
+ );
479
728
  const mutationDefaultGeneratorRegistry = collectMutationDefaultGenerators(contributors);
480
729
 
481
730
  if (parameterizedCodecDescriptors.size > 0) {
482
731
  validateColumnTypeParams(contract.storage, parameterizedCodecDescriptors);
483
732
  }
484
733
 
485
- const types = initializeTypeHelpers(contract.storage.types, parameterizedCodecDescriptors);
734
+ const types = initializeTypeHelpers(contract.storage, parameterizedCodecDescriptors);
486
735
 
487
- const jsonSchemaValidators = buildJsonSchemaValidatorRegistry(
488
- contract,
489
- types,
490
- parameterizedCodecDescriptors,
491
- );
736
+ const { registry: contractCodecs, jsonValidators: jsonSchemaValidators } =
737
+ buildContractCodecRegistry(
738
+ contract,
739
+ codecDescriptors,
740
+ codecRegistry,
741
+ types,
742
+ parameterizedCodecDescriptors,
743
+ );
492
744
 
493
745
  return {
494
746
  contract,
495
747
  codecs: codecRegistry,
748
+ contractCodecs,
749
+ codecDescriptors,
496
750
  queryOperations: queryOperationRegistry,
497
751
  types,
498
752
  ...(jsonSchemaValidators ? { jsonSchemaValidators } : {}),