@prisma-next/sql-runtime 0.6.0-dev.1 → 0.6.0-dev.10

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,
@@ -10,6 +10,7 @@ import {
10
10
  RuntimeCore,
11
11
  type RuntimeExecuteOptions,
12
12
  type RuntimeLog,
13
+ runBeforeExecuteChain,
13
14
  runtimeError,
14
15
  runWithMiddleware,
15
16
  } from '@prisma-next/framework-components/runtime';
@@ -24,7 +25,6 @@ import type {
24
25
  SqlQueryable,
25
26
  SqlTransaction,
26
27
  } from '@prisma-next/sql-relational-core/ast';
27
- import { validateParamRefRefs } from '@prisma-next/sql-relational-core/ast';
28
28
  import {
29
29
  createSqlParamRefMutator,
30
30
  type SqlParamRefMutator,
@@ -186,19 +186,57 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
186
186
  }
187
187
 
188
188
  /**
189
- * Lower a `SqlQueryPlan` (AST + meta) into a `SqlExecutionPlan` with encoded parameters ready for the driver. This is the single point at which params transition from app-layer values to driver wire-format.
189
+ * Lower a `SqlQueryPlan` (AST + meta) into a `SqlExecutionPlan`
190
+ * with encoded parameters ready for the driver.
190
191
  *
191
- * `ctx: SqlCodecCallContext` is forwarded to `encodeParams` so per-query cancellation reaches every codec body during parameter encoding. The framework abstract typed this as `CodecCallContext`; the SQL family narrows it to the SQL-specific extension. SQL params do not populate `ctx.column` — encode-side column metadata is the middleware's domain.
192
+ * Implementation note: SQL splits lower-then-encode across
193
+ * {@link lowerToDraft} + {@link encodeDraftParams} so the runtime
194
+ * can fire the `beforeExecute` middleware chain between them
195
+ * (cipherstash bulk-encrypt, for example, mutates pre-encode
196
+ * `ParamRef.value` slots). This protected hook composes the two
197
+ * back into the cross-family `lower()` shape `RuntimeCore.execute`
198
+ * expects, and is called from the no-middleware fast paths /
199
+ * fixtures that hit `RuntimeCore`'s default template directly.
200
+ * `execute()` overrides the template and uses the split form so
201
+ * `beforeExecute` lands between the two halves.
202
+ *
203
+ * `ctx: SqlCodecCallContext` is forwarded to `encodeParams` so
204
+ * per-query cancellation reaches every codec body during parameter
205
+ * encoding. SQL params do not populate `ctx.column` — encode-side
206
+ * column metadata is the middleware's domain.
192
207
  */
193
208
  protected override async lower(
194
209
  plan: SqlQueryPlan,
195
210
  ctx: SqlCodecCallContext,
196
211
  ): Promise<SqlExecutionPlan> {
197
- validateParamRefRefs(plan.ast, this.codecDescriptors);
198
- const lowered = lowerSqlPlan(this.adapter, this.contract, plan);
212
+ const draft = this.lowerToDraft(plan);
213
+ return await this.encodeDraftParams(draft, ctx);
214
+ }
215
+
216
+ /**
217
+ * AST → pre-encode draft. The returned plan has `sql` rendered and
218
+ * `params` populated with the user-domain values the lowering site
219
+ * collected from `ParamRef` nodes. No codec encode has happened
220
+ * yet; consumers can mutate `params` via the `SqlParamRefMutator`
221
+ * before {@link encodeDraftParams} runs.
222
+ */
223
+ private lowerToDraft(plan: SqlQueryPlan): SqlExecutionPlan {
224
+ return lowerSqlPlan(this.adapter, this.contract, plan);
225
+ }
226
+
227
+ /**
228
+ * Encode a draft plan's params through the per-column codecs and
229
+ * freeze the result into the final `SqlExecutionPlan` the driver
230
+ * sees. Errors surface as `RUNTIME.ENCODE_FAILED` envelopes from
231
+ * {@link encodeParams}.
232
+ */
233
+ private async encodeDraftParams(
234
+ draft: SqlExecutionPlan,
235
+ ctx: SqlCodecCallContext,
236
+ ): Promise<SqlExecutionPlan> {
199
237
  return Object.freeze({
200
- ...lowered,
201
- params: await encodeParams(lowered, ctx, this.contractCodecs),
238
+ ...draft,
239
+ params: await encodeParams(draft, ctx, this.contractCodecs),
202
240
  });
203
241
  }
204
242
 
@@ -257,15 +295,44 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
257
295
 
258
296
  let exec: SqlExecutionPlan;
259
297
  if (isExecutionPlan(plan)) {
260
- if (plan.ast) {
261
- validateParamRefRefs(plan.ast, self.codecDescriptors);
262
- }
298
+ // Pre-lowered fixture path. The plan's params are typically
299
+ // already encoded; we still fire `beforeExecute` so middleware
300
+ // that mutates ParamRef values (e.g. cipherstash bulk-encrypt)
301
+ // gets a chance to run, then re-encode so any mutations land.
302
+ const preEncodeMutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(plan);
303
+ await runBeforeExecuteChain<SqlExecutionPlan, SqlParamRefMutator>(
304
+ plan,
305
+ self.middleware,
306
+ execMiddlewareCtx,
307
+ preEncodeMutator,
308
+ );
263
309
  exec = Object.freeze({
264
310
  ...plan,
265
- params: await encodeParams(plan, codecCtx, self.contractCodecs),
311
+ params: await encodeParams(
312
+ { ...plan, params: preEncodeMutator.currentParams() },
313
+ codecCtx,
314
+ self.contractCodecs,
315
+ ),
266
316
  });
267
317
  } else {
268
- exec = await self.lower(await self.runBeforeCompile(plan), codecCtx);
318
+ // Standard AST → exec path. Split lower from encode so the
319
+ // `beforeExecute` chain fires between them with a mutator built
320
+ // over the pre-encode draft params; encode then renders the
321
+ // (possibly mutated) values through the column codecs.
322
+ const compiled = await self.runBeforeCompile(plan);
323
+ const draft = self.lowerToDraft(compiled);
324
+ const preEncodeMutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(draft);
325
+ await runBeforeExecuteChain<SqlExecutionPlan, SqlParamRefMutator>(
326
+ draft,
327
+ self.middleware,
328
+ execMiddlewareCtx,
329
+ preEncodeMutator,
330
+ );
331
+ const draftWithMutations: SqlExecutionPlan = Object.freeze({
332
+ ...draft,
333
+ params: preEncodeMutator.currentParams(),
334
+ });
335
+ exec = await self.encodeDraftParams(draftWithMutations, codecCtx);
269
336
  }
270
337
 
271
338
  self.familyAdapter.validatePlan(exec, self.contract);
@@ -287,25 +354,18 @@ class SqlRuntimeImpl<TContract extends Contract<SqlStorage> = Contract<SqlStorag
287
354
  await self.verifyMarker();
288
355
  }
289
356
 
290
- const paramsMutator: SqlParamRefMutatorInternal = createSqlParamRefMutator(exec);
291
- const stream = runWithMiddleware<
292
- SqlExecutionPlan,
293
- Record<string, unknown>,
294
- SqlParamRefMutator
295
- >(
357
+ const stream = runWithMiddleware<SqlExecutionPlan, Record<string, unknown>>(
296
358
  exec,
297
359
  self.middleware,
298
360
  execMiddlewareCtx,
299
361
  () =>
300
362
  queryable.execute<Record<string, unknown>>({
301
363
  sql: exec.sql,
302
- // Read params after the `beforeExecute` middleware chain has
303
- // run so that any `mutator.replaceValue(...)` calls land in
304
- // the driver-bound params array. When no middleware mutated,
305
- // `currentParams()` returns `exec.params` by reference identity.
306
- params: paramsMutator.currentParams(),
364
+ // `beforeExecute` ran on the pre-encode draft (see
365
+ // generator setup above); `exec.params` already carries
366
+ // any mutator-driven replacements through `encodeParams`.
367
+ params: exec.params,
307
368
  }),
308
- paramsMutator,
309
369
  );
310
370
 
311
371
  // Manually drive the driver's async iterator so the between-row abort check fires *before* requesting the next row. With a `for await...of` loop the runtime would await `iterator.next()` first, leaving a window where one extra row is pulled through the driver after the signal aborted.