@prisma-next/sql-runtime 0.6.0-dev.6 → 0.6.0-dev.7

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.
@@ -1,5 +1,13 @@
1
- import type { Contract, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
2
- import type { AnyCodecDescriptor, CodecDescriptor } from '@prisma-next/framework-components/codec';
1
+ import type {
2
+ Contract,
3
+ ExecutionMutationDefaultValue,
4
+ JsonValue,
5
+ } from '@prisma-next/contract/types';
6
+ import type {
7
+ AnyCodecDescriptor,
8
+ CodecDescriptor,
9
+ CodecRef,
10
+ } from '@prisma-next/framework-components/codec';
3
11
  import type { ComponentDescriptor } from '@prisma-next/framework-components/components';
4
12
  import { checkContractComponentRequirements } from '@prisma-next/framework-components/components';
5
13
  import {
@@ -15,6 +23,7 @@ import {
15
23
  type RuntimeTargetInstance,
16
24
  } from '@prisma-next/framework-components/execution';
17
25
  import { runtimeError } from '@prisma-next/framework-components/runtime';
26
+ import { canonicalizeJson } from '@prisma-next/framework-components/utils';
18
27
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
19
28
  import {
20
29
  createSqlOperationRegistry,
@@ -23,7 +32,6 @@ import {
23
32
  import type {
24
33
  Adapter,
25
34
  AnyQueryAst,
26
- Codec,
27
35
  ContractCodecRegistry,
28
36
  LoweredStatement,
29
37
  SqlCodecInstanceContext,
@@ -37,6 +45,7 @@ import type {
37
45
  MutationDefaultsOptions,
38
46
  TypeHelperRegistry,
39
47
  } from '@prisma-next/sql-relational-core/query-lane-context';
48
+ import { createAstCodecResolver } from './codecs/ast-codec-resolver';
40
49
 
41
50
  /**
42
51
  * Runtime parameterized codec descriptor.
@@ -342,145 +351,173 @@ function validateColumnTypeParams(
342
351
  }
343
352
  }
344
353
 
345
- function isResolvedCodec(candidate: unknown): candidate is Codec {
346
- return (
347
- candidate !== null &&
348
- typeof candidate === 'object' &&
349
- 'id' in candidate &&
350
- 'decode' in candidate
351
- );
354
+ /**
355
+ * Build-time contract-integrity check: every `(table, column)` resolves to a {@link CodecRef} whose `codecId` is registered and whose `typeParams` presence matches the descriptor's `isParameterized` flag.
356
+ *
357
+ * Surfaces three classes of malformed contract that AST-bound codec resolution would otherwise mask silently:
358
+ *
359
+ * - column references a codecId no contributor registered → `RUNTIME.CODEC_DESCRIPTOR_MISSING`.
360
+ * - parameterized codec, no `typeParams` (legacy "tolerate refs without params" shape) → `RUNTIME.CODEC_PARAMETERIZATION_MISMATCH`.
361
+ * - non-parameterized codec, `typeParams` supplied → `RUNTIME.CODEC_PARAMETERIZATION_MISMATCH`.
362
+ *
363
+ * Runs unconditionally from `createExecutionContext` so contract bugs fail fast at construction time instead of silently skipping affected columns in the codec registry's pre-population walk.
364
+ */
365
+ function assertColumnCodecIntegrity(
366
+ storage: SqlStorage,
367
+ codecDescriptors: CodecDescriptorRegistry,
368
+ ): void {
369
+ for (const [tableName, table] of Object.entries(storage.tables)) {
370
+ for (const columnName of Object.keys(table.columns)) {
371
+ const ref = codecDescriptors.codecRefForColumn(tableName, columnName);
372
+ if (!ref) continue;
373
+
374
+ const descriptor = codecDescriptors.descriptorFor(ref.codecId);
375
+ if (!descriptor) {
376
+ throw runtimeError(
377
+ 'RUNTIME.CODEC_DESCRIPTOR_MISSING',
378
+ `Column '${tableName}.${columnName}' references codec '${ref.codecId}' but no contributor registered a codec descriptor for that codecId. Add the extension pack that owns the codec to the runtime stack.`,
379
+ { table: tableName, column: columnName, codecId: ref.codecId },
380
+ );
381
+ }
382
+
383
+ if (descriptor.isParameterized && ref.typeParams === undefined) {
384
+ // Some parameterized codecs declare every paramsSchema field as optional
385
+ // (e.g. `pg/timestamptz@1` precision). Defer to the descriptor's own
386
+ // schema rather than rejecting purely on structural absence: probe the
387
+ // schema with an empty params object and only fail when the schema
388
+ // rejects it (i.e. at least one field is required).
389
+ const probe = descriptor.paramsSchema['~standard'].validate({});
390
+ if (probe instanceof Promise) {
391
+ // Swallow the probe Promise's rejection so Node doesn't warn about an
392
+ // unhandled rejection once we throw synchronously below.
393
+ probe.catch(() => {});
394
+ throw runtimeError(
395
+ 'RUNTIME.TYPE_PARAMS_INVALID',
396
+ `Column '${tableName}.${columnName}' uses parameterized codec '${ref.codecId}' whose paramsSchema returned a Promise; paramsSchema must be a synchronous Standard Schema validator. Return a value/issues result directly instead of a Promise.`,
397
+ { table: tableName, column: columnName, codecId: ref.codecId },
398
+ );
399
+ }
400
+ const rejects = 'issues' in probe && !!probe.issues;
401
+ if (rejects) {
402
+ throw runtimeError(
403
+ 'RUNTIME.CODEC_PARAMETERIZATION_MISMATCH',
404
+ `Column '${tableName}.${columnName}' uses parameterized codec '${ref.codecId}' but no typeParams are supplied. Provide typeParams on the column, or use a typeRef pointing at a storage.types entry that carries them.`,
405
+ {
406
+ table: tableName,
407
+ column: columnName,
408
+ codecId: ref.codecId,
409
+ expected: 'parameterized',
410
+ actual: 'no typeParams',
411
+ },
412
+ );
413
+ }
414
+ }
415
+
416
+ if (!descriptor.isParameterized && ref.typeParams !== undefined) {
417
+ throw runtimeError(
418
+ 'RUNTIME.CODEC_PARAMETERIZATION_MISMATCH',
419
+ `Column '${tableName}.${columnName}' supplies typeParams to non-parameterized codec '${ref.codecId}'. Remove the typeParams or switch to a parameterized codec id.`,
420
+ {
421
+ table: tableName,
422
+ column: columnName,
423
+ codecId: ref.codecId,
424
+ expected: 'non-parameterized',
425
+ actual: 'has typeParams',
426
+ },
427
+ );
428
+ }
429
+ }
430
+ }
352
431
  }
353
432
 
354
433
  /**
355
- * Walk the contract's `storage.tables[].columns[]` and resolve each column to a `Codec` through the unified descriptor map. Per-instance behavior:
434
+ * Build a {@link ContractCodecRegistry} that resolves codecs exclusively through the `forCodecRef` content-keyed cache.
435
+ *
436
+ * One pre-population pass walks `storage.types` and `storage.tables[].columns[]` to seed the resolver's per-ref instance context with the *aggregated* `usedAt` set for each canonical `(codecId, typeParams)` key. The same codec materialised through `forColumn` or `forCodecRef` is therefore one instance with one `SqlCodecInstanceContext` — stateful codecs reading `usedAt` see the full column set regardless of which surface the caller used.
437
+ *
438
+ * Per-key instance-name policy:
356
439
  *
357
- * - **typeRef columns**: reuse the resolved codec materialized once by `initializeTypeHelpers` for the `storage.types` entry. Multiple columns sharing one typeRef share one codec instance.
358
- * - **inline-typeParams columns**: call `descriptor.factory(typeParams) (ctx)` once per column (per-column anonymous instance).
359
- * - **non-parameterized columns**: call `descriptor.factory()(ctx)` once. The synthesized descriptor's factory is constant — every call returns the same shared codec instance — so columns sharing a non-parameterized codec id share one resolved codec without explicit caching.
440
+ * - typeRef-shared columns use the `storage.types[name]` name.
441
+ * - inline-`typeParams` columns use `<col:Table.column>` (the first column observed at that key; additional columns sharing the key extend `usedAt`).
442
+ * - non-parameterized codec ids use `<codec:codecId>`, aggregating every column on that codec id into one `usedAt` set.
443
+ * - ad-hoc refs the contract walk did not pre-populate (e.g. AST-supplied refs from deserialised migration ops) fall back to the canonical cache key `${codecId}:${canonicalizeJson(typeParams)}` — the only structurally honest identity for an ad-hoc ref, distinct per `(codecId, typeParams)`.
360
444
  *
361
- * Codec-registry-unification spec § AC-4: every column resolves through one descriptor map without branching on parameterization. JSON-Schema validation, when required, lives inside the resolved codec's `decode` body (see `arktype-json`'s `ArktypeJsonCodecClass`); the framework no longer maintains a parallel validator registry.
445
+ * Contract integrity is enforced upstream by {@link assertColumnCodecIntegrity}: every column must reference a registered `codecId` whose `descriptor.isParameterized` flag matches the presence of `typeParams` (via `codecRefForColumn`). The pre-population walk and `forColumn` therefore make no defensive checks — malformed columns fail fast at `createExecutionContext` construction with `RUNTIME.CODEC_DESCRIPTOR_MISSING` or `RUNTIME.CODEC_PARAMETERIZATION_MISMATCH` rather than being silently skipped here.
446
+ *
447
+ * `forColumn(t, c)` is a thin delegate over `forCodecRef(codecRefForColumn(t, c))`; encode/decode hot paths read the resolver directly via `forCodecRef`. The only `undefined` `forColumn` returns is the legitimate "no such column in the contract" case.
362
448
  */
363
449
  function buildContractCodecRegistry(
364
450
  contract: Contract<SqlStorage>,
365
451
  codecDescriptors: CodecDescriptorRegistry,
366
- types: TypeHelperRegistry,
367
- parameterizedDescriptors: Map<string, RuntimeParameterizedCodecDescriptor>,
368
452
  ): ContractCodecRegistry {
369
- const byColumn = new Map<string, Codec>();
370
- const byCodecId = new Map<string, Codec>();
371
- // Codec ids whose `byCodecId` entry is ambiguous — multiple distinct resolved instances landed under the same parameterized codec id (e.g. `Vector<1024>` and `Vector<1536>` both registering under `pg/vector@1`). The refs-less `forCodecId` fallback rejects these ids so a DSL-param without a column ref cannot silently bind to the wrong instance. The validator pass enforces refs on every parameterized `ParamRef`, so this
372
- // branch is reachable only as a defensive guard for non-parameterized columns whose `byCodecId` entry is unique by construction.
373
- const ambiguousCodecIds = new Set<string>();
374
-
375
- // Pre-populate `byCodecId` with non-parameterized descriptor instances. Refs-less encode/decode call sites (computed projections without a column ref, transient builder ParamRefs) resolve through `forCodecId(id)` and need a representative instance for codec ids that no contract column declares. Non-parameterized descriptors' factories are constant — every call yields the same shared codec — so a single materialization
376
- // is correct.
377
- for (const descriptor of codecDescriptors.values()) {
378
- if (descriptor.isParameterized) continue;
379
- const ctx: SqlCodecInstanceContext = {
380
- name: `<shared:${descriptor.codecId}>`,
381
- usedAt: [],
382
- };
383
- const voidFactory = descriptor.factory as unknown as (
384
- params: undefined,
385
- ) => (ctx: SqlCodecInstanceContext) => Codec;
386
- byCodecId.set(descriptor.codecId, voidFactory(undefined)(ctx));
387
- }
453
+ const refKeyOf = (ref: CodecRef): string => `${ref.codecId}:${canonicalizeJson(ref.typeParams)}`;
388
454
 
389
- // Representative instances for parameterized descriptors whose factory tolerates `factory(undefined)` (e.g. pgvector — the factory ignores its params and returns the same shared codec). Used as the last-resort fallback in `forCodecId` for refs-less call sites whose codec id has no contract column the walk could resolve through (e.g. `cosineSimilarity(col, [literal])` builds an inline `ParamRef` without column refs).
390
- // Stored separately so column-bound walk results don't trip the ambiguity check, and consulted only when `byCodecId` has no column-bound entry. Descriptors whose factory needs real params (arktype-json) raise and are skipped — the per-column dispatch path materializes those lazily when refs are populated.
391
- const parameterizedRepresentatives = new Map<string, Codec>();
392
- for (const descriptor of codecDescriptors.values()) {
393
- if (!descriptor.isParameterized) continue;
394
- const ctx: SqlCodecInstanceContext = {
395
- name: `<shared:${descriptor.codecId}>`,
396
- usedAt: [],
455
+ const usedAtByKey = new Map<string, Array<{ readonly table: string; readonly column: string }>>();
456
+ const nameByKey = new Map<string, string>();
457
+
458
+ const typeRefSites = collectTypeRefSites(contract.storage);
459
+ for (const [typeName, typeInstance] of Object.entries(contract.storage.types ?? {})) {
460
+ const ref: CodecRef = {
461
+ codecId: typeInstance.codecId,
462
+ typeParams: typeInstance.typeParams as JsonValue,
397
463
  };
398
- // Call `factory` *as a method on the descriptor* — `descriptor.factory(undefined)` — rather than detaching it into a local. Several descriptors implement `factory` as a class method whose body returns an arrow that captures `this` (`return () => new SomeCodec(this);`), and detaching loses that binding so the codec ends up with an `undefined` descriptor and `codec.id` throws.
399
- const factory = descriptor.factory.bind(descriptor) as unknown as (
400
- params: unknown,
401
- ) => (ctx: SqlCodecInstanceContext) => Codec;
402
- try {
403
- parameterizedRepresentatives.set(descriptor.codecId, factory(undefined)(ctx));
404
- } catch {
405
- // Parameterized descriptor whose factory requires real params; refs-less fallback for this codec id is unavailable.
464
+ const key = refKeyOf(ref);
465
+ const sites = typeRefSites.get(typeName) ?? [];
466
+ const existing = usedAtByKey.get(key);
467
+ // Two `storage.types` aliases that canonicalize to the same (codecId, typeParams) share a single codec instance via the resolver. Append sites instead of replacing so a stateful codec reading the aggregated site list sees every column behind every alias rather than just the last one.
468
+ if (existing) {
469
+ existing.push(...sites);
470
+ } else {
471
+ usedAtByKey.set(key, [...sites]);
472
+ nameByKey.set(key, typeName);
406
473
  }
407
474
  }
408
475
 
409
476
  for (const [tableName, table] of Object.entries(contract.storage.tables)) {
410
477
  for (const [columnName, column] of Object.entries(table.columns)) {
411
- const columnKey = `${tableName}.${columnName}`;
412
- const descriptor = codecDescriptors.descriptorFor(column.codecId);
413
-
414
- let resolvedCodec: Codec | undefined;
415
-
416
- if (descriptor) {
417
- const isParameterized = parameterizedDescriptors.has(column.codecId);
418
-
419
- if (column.typeRef) {
420
- // The named instance was already materialized once by `initializeTypeHelpers`; reuse it so multiple columns sharing the same typeRef share one codec instance (and any per-instance helper state on it).
421
- const helper = types[column.typeRef];
422
- if (isResolvedCodec(helper)) {
423
- resolvedCodec = helper;
424
- }
425
- } else if (column.typeParams && isParameterized) {
426
- const parameterizedDescriptor = parameterizedDescriptors.get(column.codecId);
427
- if (parameterizedDescriptor) {
428
- const validatedParams = validateTypeParams(column.typeParams, parameterizedDescriptor, {
429
- tableName,
430
- columnName,
431
- });
432
- const ctx: SqlCodecInstanceContext = {
433
- name: `<col:${tableName}.${columnName}>`,
434
- usedAt: [{ table: tableName, column: columnName }],
435
- };
436
- resolvedCodec = parameterizedDescriptor.factory(validatedParams)(ctx);
437
- }
438
- } else if (!isParameterized) {
439
- // Non-parameterized column: materialize a fresh codec instance per `forColumn(table, column)` entry with a column-specific `SqlCodecInstanceContext`. The pre-populated `byCodecId` representative (built with the synthetic `<shared:codecId>` context and empty `usedAt`) is reserved for `forCodecId()` refs-less fallbacks; reusing it for column-bound dispatch would erase per-column diagnostics for any descriptor whose factory reads `CodecInstanceContext`.
440
- const ctx: SqlCodecInstanceContext = {
441
- name: `<col:${tableName}.${columnName}>`,
442
- usedAt: [{ table: tableName, column: columnName }],
443
- };
444
- // The descriptor's `P` is `void` for non-parameterized codecs; the runtime's `void` value is `undefined`. The cast narrows the descriptor's family-agnostic `CodecInstanceContext` slot to the SQL `SqlCodecInstanceContext` we pass at this call site — function-argument contravariance makes the narrow safe.
445
- // `bind` preserves the `this`-on-descriptor invariant — several descriptors implement `factory` as a class method whose body returns an arrow that captures `this`; detaching loses the binding and produces a codec whose `descriptor` is `undefined`.
446
- const voidFactory = descriptor.factory.bind(descriptor) as unknown as (
447
- params: undefined,
448
- ) => (ctx: SqlCodecInstanceContext) => Codec;
449
- resolvedCodec = voidFactory(undefined)(ctx);
450
- }
451
- // else: parameterized codec id with no typeRef and no typeParams — this is the legitimate "undimensioned" form for codecs that ship a no-params column variant alongside a parameterized one (e.g. pgvector's `vectorColumn` vs. `vector(N)`). Leave `resolvedCodec` undefined; encode/decode for this column flows through `forCodecId`. The fallback works for these cases because their wire format is params-independent (vector
452
- // formats `[v1,v2,...]` regardless of declared length).
478
+ if (column.typeRef !== undefined) continue;
479
+ const ref = codecDescriptors.codecRefForColumn(tableName, columnName);
480
+ if (!ref) continue;
481
+ const key = refKeyOf(ref);
482
+ const site = { table: tableName, column: columnName };
483
+ const existing = usedAtByKey.get(key);
484
+ if (existing) {
485
+ existing.push(site);
486
+ } else {
487
+ usedAtByKey.set(key, [site]);
488
+ const name =
489
+ ref.typeParams !== undefined
490
+ ? `<col:${tableName}.${columnName}>`
491
+ : `<codec:${ref.codecId}>`;
492
+ nameByKey.set(key, name);
453
493
  }
494
+ }
495
+ }
454
496
 
455
- if (resolvedCodec) {
456
- byColumn.set(columnKey, resolvedCodec);
457
- const existing = byCodecId.get(column.codecId);
458
- if (existing === undefined) {
459
- byCodecId.set(column.codecId, resolvedCodec);
460
- } else if (existing !== resolvedCodec && parameterizedDescriptors.has(column.codecId)) {
461
- ambiguousCodecIds.add(column.codecId);
462
- }
463
- }
497
+ const resolver = createAstCodecResolver(codecDescriptors, (ref) => {
498
+ const key = refKeyOf(ref);
499
+ // Fallback uses the canonical cache key as the instance name. Two ad-hoc refs with the same `codecId` but different `typeParams` resolve to distinct codecs (different cache keys) and must therefore expose distinct `name`s; a `codecId`-only fallback would collide and break stateful codecs that key per-instance state on `name`.
500
+ return {
501
+ name: nameByKey.get(key) ?? key,
502
+ usedAt: usedAtByKey.get(key) ?? [],
503
+ };
504
+ });
505
+
506
+ for (const [tableName, table] of Object.entries(contract.storage.tables)) {
507
+ for (const columnName of Object.keys(table.columns)) {
508
+ const ref = codecDescriptors.codecRefForColumn(tableName, columnName);
509
+ if (!ref) continue;
510
+ resolver.forCodecRef(ref);
464
511
  }
465
512
  }
466
513
 
467
514
  const registry: ContractCodecRegistry = {
468
515
  forColumn(table, column) {
469
- return byColumn.get(`${table}.${column}`);
516
+ const ref = codecDescriptors.codecRefForColumn(table, column);
517
+ return ref ? resolver.forCodecRef(ref) : undefined;
470
518
  },
471
- forCodecId(codecId) {
472
- // Codec-id-only fallback for refs-less sites. The validator pass (`validateParamRefRefs`) enforces refs on every parameterized `ParamRef` before encode, so this path is only legitimately reachable for non-parameterized codec ids. The map is pre-populated with every non-parameterized descriptor's canonical instance and overlaid with column-resolved instances from the contract walk; parameterized codec ids without a
473
- // typeRef/typeParams-bound column never reach this map.
474
- //
475
- // Reject ambiguous parameterized fallbacks: if the contract walk resolved more than one distinct codec instance under this id (e.g. multiple vector dimensions, multiple arktype-json schemas), the codec-id-keyed lookup cannot honor the call site — fail fast rather than bind to whichever instance happened to land first.
476
- if (ambiguousCodecIds.has(codecId)) {
477
- throw runtimeError(
478
- 'RUNTIME.TYPE_PARAMS_INVALID',
479
- `Codec '${codecId}' resolves to multiple parameterized instances; column-aware dispatch is required.`,
480
- { codecId },
481
- );
482
- }
483
- return byCodecId.get(codecId) ?? parameterizedRepresentatives.get(codecId);
519
+ forCodecRef(ref) {
520
+ return resolver.forCodecRef(ref);
484
521
  },
485
522
  };
486
523
 
@@ -684,7 +721,8 @@ export function createExecutionContext<
684
721
  }
685
722
  }
686
723
 
687
- const codecDescriptors = buildCodecDescriptorRegistry(allCodecDescriptors);
724
+ const codecDescriptors = buildCodecDescriptorRegistry(allCodecDescriptors, contract.storage);
725
+ assertColumnCodecIntegrity(contract.storage, codecDescriptors);
688
726
  const mutationDefaultGeneratorRegistry = collectMutationDefaultGenerators(contributors);
689
727
  assertMutationDefaultGeneratorsAvailable(contract, mutationDefaultGeneratorRegistry);
690
728
 
@@ -694,12 +732,7 @@ export function createExecutionContext<
694
732
 
695
733
  const types = initializeTypeHelpers(contract.storage, parameterizedCodecDescriptors);
696
734
 
697
- const contractCodecs = buildContractCodecRegistry(
698
- contract,
699
- codecDescriptors,
700
- types,
701
- parameterizedCodecDescriptors,
702
- );
735
+ const contractCodecs = buildContractCodecRegistry(contract, codecDescriptors);
703
736
 
704
737
  return {
705
738
  contract,
@@ -24,7 +24,6 @@ import type {
24
24
  SqlQueryable,
25
25
  SqlTransaction,
26
26
  } from '@prisma-next/sql-relational-core/ast';
27
- import { validateParamRefRefs } from '@prisma-next/sql-relational-core/ast';
28
27
  import {
29
28
  createSqlParamRefMutator,
30
29
  type SqlParamRefMutator,
@@ -194,7 +193,6 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
194
193
  plan: SqlQueryPlan,
195
194
  ctx: SqlCodecCallContext,
196
195
  ): Promise<SqlExecutionPlan> {
197
- validateParamRefRefs(plan.ast, this.codecDescriptors);
198
196
  const lowered = lowerSqlPlan(this.adapter, this.contract, plan);
199
197
  return Object.freeze({
200
198
  ...lowered,
@@ -257,9 +255,6 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
257
255
 
258
256
  let exec: SqlExecutionPlan;
259
257
  if (isExecutionPlan(plan)) {
260
- if (plan.ast) {
261
- validateParamRefRefs(plan.ast, self.codecDescriptors);
262
- }
263
258
  exec = Object.freeze({
264
259
  ...plan,
265
260
  params: await encodeParams(plan, codecCtx, self.contractCodecs),