@prisma-next/sql-runtime 0.5.0-dev.8 → 0.5.0-dev.80
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.
- package/README.md +31 -22
- package/dist/exports-C0exnDCV.mjs +1514 -0
- package/dist/exports-C0exnDCV.mjs.map +1 -0
- package/dist/{index-yb51L_1h.d.mts → index-CFbuVnYJ.d.mts} +130 -45
- package/dist/index-CFbuVnYJ.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -3
- package/dist/test/utils.d.mts +38 -33
- package/dist/test/utils.d.mts.map +1 -1
- package/dist/test/utils.mjs +107 -61
- package/dist/test/utils.mjs.map +1 -1
- package/package.json +17 -17
- package/src/codecs/alias-resolver.ts +37 -0
- package/src/codecs/decoding.ts +168 -130
- package/src/codecs/encoding.ts +123 -47
- package/src/codecs/validation.ts +4 -4
- package/src/content-hash.ts +44 -0
- package/src/exports/index.ts +13 -7
- package/src/fingerprint.ts +22 -0
- package/src/guardrails/raw.ts +165 -0
- package/src/lower-sql-plan.ts +3 -3
- package/src/marker.ts +75 -0
- package/src/middleware/before-compile-chain.ts +1 -31
- package/src/middleware/budgets.ts +36 -120
- package/src/middleware/lints.ts +20 -26
- package/src/middleware/sql-middleware.ts +25 -5
- package/src/runtime-spi.ts +44 -0
- package/src/sql-context.ts +315 -105
- package/src/sql-family-adapter.ts +3 -2
- package/src/sql-marker.ts +89 -51
- package/src/sql-runtime.ts +325 -146
- package/dist/exports-Cssiepsb.mjs +0 -1068
- package/dist/exports-Cssiepsb.mjs.map +0 -1
- package/dist/index-yb51L_1h.d.mts.map +0 -1
- package/src/codecs/json-schema-validation.ts +0 -61
package/src/codecs/decoding.ts
CHANGED
|
@@ -1,81 +1,134 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import {
|
|
2
|
+
checkAborted,
|
|
3
|
+
isRuntimeError,
|
|
4
|
+
raceAgainstAbort,
|
|
5
|
+
runtimeError,
|
|
6
|
+
} from '@prisma-next/framework-components/runtime';
|
|
7
|
+
import type {
|
|
8
|
+
AnyQueryAst,
|
|
9
|
+
Codec,
|
|
10
|
+
ContractCodecRegistry,
|
|
11
|
+
ProjectionItem,
|
|
12
|
+
SqlCodecCallContext,
|
|
13
|
+
} from '@prisma-next/sql-relational-core/ast';
|
|
14
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
15
|
+
import { makeAliasResolver } from './alias-resolver';
|
|
6
16
|
|
|
7
17
|
type ColumnRef = { table: string; column: string };
|
|
8
|
-
|
|
18
|
+
|
|
19
|
+
interface DecodeContext {
|
|
20
|
+
readonly aliases: ReadonlyArray<string> | undefined;
|
|
21
|
+
readonly codecs: ReadonlyMap<string, Codec>;
|
|
22
|
+
readonly columnRefs: ReadonlyMap<string, ColumnRef>;
|
|
23
|
+
readonly includeAliases: ReadonlySet<string>;
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
const WIRE_PREVIEW_LIMIT = 100;
|
|
27
|
+
const EMPTY_INCLUDE_ALIASES: ReadonlySet<string> = new Set<string>();
|
|
11
28
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const planCodecId = plan.meta.annotations?.codecs?.[alias] as string | undefined;
|
|
18
|
-
if (planCodecId) {
|
|
19
|
-
const codec = registry.get(planCodecId);
|
|
20
|
-
if (codec) {
|
|
21
|
-
return codec;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
29
|
+
function isAstBackedPlan(
|
|
30
|
+
plan: SqlExecutionPlan,
|
|
31
|
+
): plan is SqlExecutionPlan & { readonly ast: AnyQueryAst } {
|
|
32
|
+
return plan.ast !== undefined;
|
|
33
|
+
}
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const codec = registry.get(typeId);
|
|
29
|
-
if (codec) {
|
|
30
|
-
return codec;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
35
|
+
function projectionListFromAst(ast: AnyQueryAst): ReadonlyArray<ProjectionItem> | undefined {
|
|
36
|
+
if (ast.kind === 'select') {
|
|
37
|
+
return ast.projection;
|
|
33
38
|
}
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
if (ast.kind === 'raw-sql') {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return ast.returning;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the per-cell codec for a projection item.
|
|
47
|
+
*
|
|
48
|
+
* When a `(table, column)` ref is available — either implicit on a `column-ref` expression or carried explicitly via `item.refs` for column-bound non-`column-ref` projections — prefer `contractCodecs.forColumn(table, column)`: that returns the per-instance codec materialized from the descriptor's factory for that column, encoding any per-instance state (typeParams like vector length, schema validators, etc.).
|
|
49
|
+
*
|
|
50
|
+
* The wrong-instance risk for parameterized codecs is closed off structurally:
|
|
51
|
+
*
|
|
52
|
+
* 1. `buildContractCodecRegistry` pre-populates `byCodecId` with one canonical instance per non-parameterized descriptor; parameterized descriptors are intentionally absent. 2. `forCodecId` rejects ambiguous parameterized fallbacks (`ambiguousCodecIds`). 3. The non-ambiguous parameterized case stores the column-correct per-instance codec under `byCodecId`, so the fall-through still resolves to the right instance.
|
|
53
|
+
*
|
|
54
|
+
* The `forCodecId` fallback otherwise covers projections that are *not* column-bound (computed projections, raw SQL aliases) but still carry a `codecId` (ADR 205 stamps every `ProjectionItem` with the producer's codec id).
|
|
55
|
+
*
|
|
56
|
+
* Codec-registry-unification spec § AC-4 / AC-5.
|
|
57
|
+
*/
|
|
58
|
+
function resolveProjectionCodec(
|
|
59
|
+
item: ProjectionItem,
|
|
60
|
+
contractCodecs: ContractCodecRegistry | undefined,
|
|
61
|
+
aliasResolver: (alias: string) => string,
|
|
62
|
+
): Codec | undefined {
|
|
63
|
+
if (contractCodecs) {
|
|
64
|
+
if (item.expr.kind === 'column-ref') {
|
|
65
|
+
const byColumn = contractCodecs.forColumn(aliasResolver(item.expr.table), item.expr.column);
|
|
66
|
+
// Only honour `byColumn` when its codec id agrees with `item.codecId`. They can legitimately disagree when an `OperationExpr`-shaped projection carries a single inner column-ref but transforms the value's codec (e.g. `cosineDistance(col, x)` projects `pg/float8@1` while the inner column-ref points at a `pg/vector@1` column).
|
|
67
|
+
if (byColumn && (item.codecId === undefined || byColumn.id === item.codecId)) return byColumn;
|
|
68
|
+
} else if (item.refs) {
|
|
69
|
+
const byColumn = contractCodecs.forColumn(aliasResolver(item.refs.table), item.refs.column);
|
|
70
|
+
if (byColumn && (item.codecId === undefined || byColumn.id === item.codecId)) return byColumn;
|
|
71
|
+
}
|
|
45
72
|
}
|
|
46
|
-
|
|
73
|
+
if (item.codecId) {
|
|
74
|
+
return contractCodecs?.forCodecId(item.codecId);
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
47
77
|
}
|
|
48
78
|
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
79
|
+
function buildDecodeContext(
|
|
80
|
+
plan: SqlExecutionPlan,
|
|
81
|
+
contractCodecs: ContractCodecRegistry | undefined,
|
|
82
|
+
): DecodeContext {
|
|
83
|
+
if (!isAstBackedPlan(plan)) {
|
|
84
|
+
return {
|
|
85
|
+
aliases: undefined,
|
|
86
|
+
codecs: new Map(),
|
|
87
|
+
columnRefs: new Map(),
|
|
88
|
+
includeAliases: EMPTY_INCLUDE_ALIASES,
|
|
89
|
+
};
|
|
52
90
|
}
|
|
53
91
|
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
return
|
|
92
|
+
const projection = projectionListFromAst(plan.ast);
|
|
93
|
+
if (!projection) {
|
|
94
|
+
return {
|
|
95
|
+
aliases: undefined,
|
|
96
|
+
codecs: new Map(),
|
|
97
|
+
columnRefs: new Map(),
|
|
98
|
+
includeAliases: EMPTY_INCLUDE_ALIASES,
|
|
99
|
+
};
|
|
57
100
|
}
|
|
58
101
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
102
|
+
const aliases: string[] = [];
|
|
103
|
+
const codecs = new Map<string, Codec>();
|
|
104
|
+
const columnRefs = new Map<string, ColumnRef>();
|
|
105
|
+
const includeAliases = new Set<string>();
|
|
106
|
+
const aliasResolver = makeAliasResolver(plan.ast);
|
|
64
107
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
108
|
+
for (const item of projection) {
|
|
109
|
+
aliases.push(item.alias);
|
|
110
|
+
|
|
111
|
+
const codec = resolveProjectionCodec(item, contractCodecs, aliasResolver);
|
|
112
|
+
if (codec) {
|
|
113
|
+
codecs.set(item.alias, codec);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (item.expr.kind === 'column-ref') {
|
|
117
|
+
columnRefs.set(item.alias, {
|
|
118
|
+
table: aliasResolver(item.expr.table),
|
|
119
|
+
column: item.expr.column,
|
|
120
|
+
});
|
|
121
|
+
} else if (item.refs) {
|
|
122
|
+
columnRefs.set(item.alias, {
|
|
123
|
+
table: aliasResolver(item.refs.table),
|
|
124
|
+
column: item.refs.column,
|
|
125
|
+
});
|
|
126
|
+
} else if (item.expr.kind === 'subquery' || item.expr.kind === 'json-array-agg') {
|
|
127
|
+
includeAliases.add(item.alias);
|
|
74
128
|
}
|
|
75
|
-
return parseProjectionRef(mappedRef) ?? undefined;
|
|
76
129
|
}
|
|
77
130
|
|
|
78
|
-
return
|
|
131
|
+
return { aliases, codecs, columnRefs, includeAliases };
|
|
79
132
|
}
|
|
80
133
|
|
|
81
134
|
function previewWireValue(wireValue: unknown): string {
|
|
@@ -87,10 +140,6 @@ function previewWireValue(wireValue: unknown): string {
|
|
|
87
140
|
return String(wireValue).substring(0, WIRE_PREVIEW_LIMIT);
|
|
88
141
|
}
|
|
89
142
|
|
|
90
|
-
function isJsonSchemaValidationError(error: unknown): boolean {
|
|
91
|
-
return isRuntimeError(error) && error.code === 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED';
|
|
92
|
-
}
|
|
93
|
-
|
|
94
143
|
function wrapDecodeFailure(
|
|
95
144
|
error: unknown,
|
|
96
145
|
alias: string,
|
|
@@ -153,77 +202,82 @@ function decodeIncludeAggregate(alias: string, wireValue: unknown): unknown {
|
|
|
153
202
|
}
|
|
154
203
|
|
|
155
204
|
/**
|
|
156
|
-
* Decodes a single field. Single-armed: every cell takes the same path —
|
|
157
|
-
*
|
|
158
|
-
*
|
|
205
|
+
* Decodes a single field. Single-armed: every cell takes the same path — `codec.decode → await → return plain value` — so sync- and async-authored codecs are indistinguishable to callers. JSON-Schema validation, when required, lives inside the resolved codec's `decode` body (e.g. `arktype-json` validates against its rehydrated schema and throws `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` from `decode` directly); there is
|
|
206
|
+
* no separate validator-registry pass.
|
|
207
|
+
*
|
|
208
|
+
* The row-level `rowCtx` is repackaged into a per-cell `SqlCodecCallContext` whose `column = { table, name }` is a structural projection of the per-cell `ColumnRef = { table, column }` resolved from the AST-backed `DecodeContext` (the same resolution `wrapDecodeFailure` uses for envelope construction — one resolution per cell, two consumers). Cells the runtime cannot resolve to a single underlying column (aggregate
|
|
209
|
+
* aliases, computed projections without a simple ref) get `column: undefined`, matching the spec contract that the runtime never silently defaults this field.
|
|
159
210
|
*/
|
|
160
211
|
async function decodeField(
|
|
161
212
|
alias: string,
|
|
162
213
|
wireValue: unknown,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
jsonValidators: JsonSchemaValidatorRegistry | undefined,
|
|
166
|
-
projection: ExecutionPlan['meta']['projection'],
|
|
167
|
-
fallbackColumnRefIndex: ColumnRefIndex | null,
|
|
214
|
+
decodeCtx: DecodeContext,
|
|
215
|
+
rowCtx: SqlCodecCallContext,
|
|
168
216
|
): Promise<unknown> {
|
|
169
|
-
if (wireValue === null
|
|
170
|
-
return
|
|
217
|
+
if (wireValue === null) {
|
|
218
|
+
return null;
|
|
171
219
|
}
|
|
172
220
|
|
|
173
|
-
const codec =
|
|
221
|
+
const codec = decodeCtx.codecs.get(alias);
|
|
174
222
|
if (!codec) {
|
|
175
223
|
return wireValue;
|
|
176
224
|
}
|
|
177
225
|
|
|
178
|
-
const ref =
|
|
226
|
+
const ref = decodeCtx.columnRefs.get(alias);
|
|
179
227
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
228
|
+
// Per-cell ctx: the cell-level `column` is a `SqlColumnRef = { table, name }` projection of the resolved `ColumnRef = { table, column }` (same resolution `wrapDecodeFailure` uses below — no double work). Cells the runtime cannot resolve (aggregate aliases, computed projections without a simple ref) drop the `column` field entirely — explicitly cleared so a previously-populated `rowCtx.column` cannot leak through to
|
|
229
|
+
// unrelated cells. Destructuring (rather than `column: undefined`) is required because `SqlCodecCallContext.column` is declared `column?: SqlColumnRef` under `exactOptionalPropertyTypes`.
|
|
230
|
+
let cellCtx: SqlCodecCallContext;
|
|
231
|
+
if (ref) {
|
|
232
|
+
cellCtx = { ...rowCtx, column: { table: ref.table, name: ref.column } };
|
|
233
|
+
} else {
|
|
234
|
+
const { column: _drop, ...rowCtxWithoutColumn } = rowCtx;
|
|
235
|
+
cellCtx = rowCtxWithoutColumn;
|
|
185
236
|
}
|
|
186
237
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
238
|
+
try {
|
|
239
|
+
return await codec.decode(wireValue, cellCtx);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
// Codec-authored runtime envelopes (e.g. `RUNTIME.DECODE_FAILED` thrown from inside the codec body, or `RUNTIME.ABORTED` raised via `CodecCallContext.signal` per ADR 207) carry their own per-codec context — wrapping them again would erase that context and coerce the abort intent into a generic decode failure. Pass them through unchanged; only foreign errors get the `wrapDecodeFailure` envelope.
|
|
242
|
+
if (isRuntimeError(error)) {
|
|
243
|
+
throw error;
|
|
193
244
|
}
|
|
245
|
+
wrapDecodeFailure(error, alias, ref, codec, wireValue);
|
|
194
246
|
}
|
|
195
|
-
|
|
196
|
-
return decoded;
|
|
197
247
|
}
|
|
198
248
|
|
|
199
249
|
/**
|
|
200
|
-
* Decodes a row by dispatching all per-cell codec calls concurrently via
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
250
|
+
* Decodes a row by dispatching all per-cell codec calls concurrently via `Promise.all`. Each cell follows the single-armed `decodeField` path. Failures are wrapped in `RUNTIME.DECODE_FAILED` with `{ table, column, codec }` (or `{ alias, codec }` when no column ref is resolvable) and the original error attached on `cause`.
|
|
251
|
+
*
|
|
252
|
+
* When `rowCtx.signal` is provided:
|
|
253
|
+
*
|
|
254
|
+
* - **Already-aborted at entry** short-circuits with `RUNTIME.ABORTED` (`{ phase: 'decode' }`) before any `codec.decode` call is made.
|
|
255
|
+
* - **Mid-flight aborts** race the per-cell `Promise.all` against the signal so the runtime returns promptly even when codec bodies ignore it. In-flight bodies that ignore the signal complete in the background (cooperative cancellation).
|
|
256
|
+
* - Existing `RUNTIME.DECODE_FAILED` envelopes from codec bodies pass through unchanged (no double wrap).
|
|
205
257
|
*/
|
|
206
258
|
export async function decodeRow(
|
|
207
259
|
row: Record<string, unknown>,
|
|
208
|
-
plan:
|
|
209
|
-
|
|
210
|
-
|
|
260
|
+
plan: SqlExecutionPlan,
|
|
261
|
+
rowCtx: SqlCodecCallContext,
|
|
262
|
+
contractCodecs?: ContractCodecRegistry,
|
|
211
263
|
): Promise<Record<string, unknown>> {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
264
|
+
checkAborted(rowCtx, 'decode');
|
|
265
|
+
const signal = rowCtx.signal;
|
|
266
|
+
|
|
267
|
+
const decodeCtx = buildDecodeContext(plan, contractCodecs);
|
|
268
|
+
|
|
269
|
+
const aliases = decodeCtx.aliases ?? Object.keys(row);
|
|
270
|
+
|
|
271
|
+
if (decodeCtx.aliases !== undefined) {
|
|
272
|
+
for (const alias of decodeCtx.aliases) {
|
|
273
|
+
if (!Object.hasOwn(row, alias)) {
|
|
274
|
+
throw runtimeError('RUNTIME.DECODE_FAILED', `Row missing projection alias "${alias}"`, {
|
|
275
|
+
alias,
|
|
276
|
+
expectedAliases: decodeCtx.aliases,
|
|
277
|
+
presentKeys: Object.keys(row),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
227
281
|
}
|
|
228
282
|
|
|
229
283
|
const tasks: Promise<unknown>[] = [];
|
|
@@ -233,34 +287,18 @@ export async function decodeRow(
|
|
|
233
287
|
const alias = aliases[i] as string;
|
|
234
288
|
const wireValue = row[alias];
|
|
235
289
|
|
|
236
|
-
|
|
237
|
-
projection && typeof projection === 'object' && !Array.isArray(projection)
|
|
238
|
-
? (projection as Record<string, string>)[alias]
|
|
239
|
-
: undefined;
|
|
240
|
-
|
|
241
|
-
if (typeof projectionValue === 'string' && projectionValue.startsWith('include:')) {
|
|
290
|
+
if (decodeCtx.includeAliases.has(alias)) {
|
|
242
291
|
includeIndices.push({ index: i, alias, value: wireValue });
|
|
243
292
|
tasks.push(Promise.resolve(undefined));
|
|
244
293
|
continue;
|
|
245
294
|
}
|
|
246
295
|
|
|
247
|
-
tasks.push(
|
|
248
|
-
decodeField(
|
|
249
|
-
alias,
|
|
250
|
-
wireValue,
|
|
251
|
-
plan,
|
|
252
|
-
registry,
|
|
253
|
-
jsonValidators,
|
|
254
|
-
projection,
|
|
255
|
-
fallbackColumnRefIndex,
|
|
256
|
-
),
|
|
257
|
-
);
|
|
296
|
+
tasks.push(decodeField(alias, wireValue, decodeCtx, rowCtx));
|
|
258
297
|
}
|
|
259
298
|
|
|
260
|
-
const settled = await Promise.all(tasks);
|
|
299
|
+
const settled = await raceAgainstAbort(Promise.all(tasks), signal, 'decode');
|
|
261
300
|
|
|
262
|
-
// Include aggregates are decoded synchronously after concurrent codec
|
|
263
|
-
// dispatch settles, so any decode failures upstream propagate first.
|
|
301
|
+
// Include aggregates are decoded synchronously after concurrent codec dispatch settles, so any decode failures upstream propagate first.
|
|
264
302
|
for (const entry of includeIndices) {
|
|
265
303
|
settled[entry.index] = decodeIncludeAggregate(entry.alias, entry.value);
|
|
266
304
|
}
|
package/src/codecs/encoding.ts
CHANGED
|
@@ -1,32 +1,71 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
checkAborted,
|
|
3
|
+
raceAgainstAbort,
|
|
4
|
+
runtimeError,
|
|
5
|
+
} from '@prisma-next/framework-components/runtime';
|
|
6
|
+
import {
|
|
7
|
+
type Codec,
|
|
8
|
+
type ContractCodecRegistry,
|
|
9
|
+
collectOrderedParamRefs,
|
|
10
|
+
type ParamRefBindingRefs,
|
|
11
|
+
type SqlCodecCallContext,
|
|
12
|
+
} from '@prisma-next/sql-relational-core/ast';
|
|
13
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
14
|
+
import { makeAliasResolver } from './alias-resolver';
|
|
4
15
|
|
|
16
|
+
interface ParamMetadata {
|
|
17
|
+
readonly codecId: string | undefined;
|
|
18
|
+
readonly name: string | undefined;
|
|
19
|
+
readonly refs: ParamRefBindingRefs | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NO_METADATA: ParamMetadata = Object.freeze({
|
|
23
|
+
codecId: undefined,
|
|
24
|
+
name: undefined,
|
|
25
|
+
refs: undefined,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the codec for an outgoing param.
|
|
30
|
+
*
|
|
31
|
+
* Column-aware dispatch: when `metadata.refs` is populated by a column-bound construction site, prefer `contractCodecs.forColumn(refs.table, refs.column)` — that returns the per-instance codec the contract walk materialized for the `(table, column)` pair, encoding the column's typeParams (e.g. `vector(1024)` vs. `vector(1536)`).
|
|
32
|
+
*
|
|
33
|
+
* On a column-lookup miss the resolver falls through to `forCodecId`. The wrong-instance risk for parameterized codecs is closed off structurally:
|
|
34
|
+
*
|
|
35
|
+
* 1. `buildContractCodecRegistry` pre-populates `byCodecId` with one canonical instance per non-parameterized descriptor; parameterized descriptors are intentionally absent from this pre-population. 2. `forCodecId` rejects ambiguous parameterized fallbacks (`ambiguousCodecIds`) — if the contract walk resolved more than one distinct instance under a single parameterized id, the call throws rather than binding to
|
|
36
|
+
* whichever landed first. 3. For the non-ambiguous parameterized case (a single column with that id), `byCodecId` stores the column-correct per-instance codec, so the fall-through still resolves to the right instance.
|
|
37
|
+
*
|
|
38
|
+
* Refs-less fallback: ParamRefs constructed outside a column-bound site (literals, transient builder state) carry a non-parameterized `codecId` whose dispatch is ambiguity-free. The validator pass (`validateParamRefRefs`) already enforced refs on every parameterized ParamRef before encode runs.
|
|
39
|
+
*/
|
|
5
40
|
function resolveParamCodec(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
41
|
+
metadata: ParamMetadata,
|
|
42
|
+
contractCodecs: ContractCodecRegistry | undefined,
|
|
43
|
+
aliasResolver: (alias: string) => string,
|
|
44
|
+
): Codec | undefined {
|
|
45
|
+
if (!metadata.codecId) return undefined;
|
|
46
|
+
if (metadata.refs && contractCodecs) {
|
|
47
|
+
const byColumn = contractCodecs.forColumn(
|
|
48
|
+
aliasResolver(metadata.refs.table),
|
|
49
|
+
metadata.refs.column,
|
|
50
|
+
);
|
|
51
|
+
// Only honour `byColumn` when its codec id agrees with the `ParamRef`'s declared `codecId`. They can legitimately disagree when a heuristic (e.g. the ORM's `refsFromLeft`) lifts column refs out of an `OperationExpr` that changed the codec id — e.g. `cosineDistance(p.embedding, x).lt(1)` carries `refs={post,embedding}` (a vector column) but the comparison side's codec is `pg/float8@1`. Trusting `byColumn` blindly would dispatch the float
|
|
52
|
+
// literal through the vector codec.
|
|
53
|
+
if (byColumn && byColumn.id === metadata.codecId) return byColumn;
|
|
14
54
|
}
|
|
15
|
-
|
|
16
|
-
return null;
|
|
55
|
+
return contractCodecs?.forCodecId(metadata.codecId);
|
|
17
56
|
}
|
|
18
57
|
|
|
19
|
-
function paramLabel(
|
|
20
|
-
return
|
|
58
|
+
function paramLabel(metadata: ParamMetadata, paramIndex: number): string {
|
|
59
|
+
return metadata.name ?? `param[${paramIndex}]`;
|
|
21
60
|
}
|
|
22
61
|
|
|
23
62
|
function wrapEncodeFailure(
|
|
24
63
|
error: unknown,
|
|
25
|
-
|
|
64
|
+
metadata: ParamMetadata,
|
|
26
65
|
paramIndex: number,
|
|
27
66
|
codecId: string,
|
|
28
67
|
): never {
|
|
29
|
-
const label = paramLabel(
|
|
68
|
+
const label = paramLabel(metadata, paramIndex);
|
|
30
69
|
const message = error instanceof Error ? error.message : String(error);
|
|
31
70
|
const wrapped = runtimeError(
|
|
32
71
|
'RUNTIME.ENCODE_FAILED',
|
|
@@ -38,66 +77,103 @@ function wrapEncodeFailure(
|
|
|
38
77
|
}
|
|
39
78
|
|
|
40
79
|
/**
|
|
41
|
-
* Encodes a single parameter through its codec. Always awaits codec.encode so
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* `RUNTIME.ENCODE_FAILED` with `{ label, codec, paramIndex }` and the original
|
|
45
|
-
* error attached on `cause`.
|
|
80
|
+
* Encodes a single parameter through its codec. Always awaits codec.encode so a Promise can never leak into the driver, even if a sync-authored codec is lifted to async by the codec factory. Failures are wrapped in `RUNTIME.ENCODE_FAILED` with `{ label, codec, paramIndex }` and the original error attached on `cause`.
|
|
81
|
+
*
|
|
82
|
+
* `ctx` is forwarded verbatim to `codec.encode` so codec authors who opt into the `(value, ctx)` arity see the same `SqlCodecCallContext` the runtime built for the surrounding `runtime.execute()` call. The ctx is always present; its `signal` field may be `undefined`. Encode call sites do not populate `ctx.column` — encode-time column context is the middleware's domain.
|
|
46
83
|
*/
|
|
47
84
|
export async function encodeParam(
|
|
48
85
|
value: unknown,
|
|
49
|
-
|
|
86
|
+
paramRef: {
|
|
87
|
+
readonly codecId?: string;
|
|
88
|
+
readonly name?: string;
|
|
89
|
+
readonly refs?: ParamRefBindingRefs;
|
|
90
|
+
},
|
|
91
|
+
paramIndex: number,
|
|
92
|
+
ctx: SqlCodecCallContext,
|
|
93
|
+
contractCodecs?: ContractCodecRegistry,
|
|
94
|
+
): Promise<unknown> {
|
|
95
|
+
return encodeParamValue(
|
|
96
|
+
value,
|
|
97
|
+
{ codecId: paramRef.codecId, name: paramRef.name, refs: paramRef.refs },
|
|
98
|
+
paramIndex,
|
|
99
|
+
ctx,
|
|
100
|
+
contractCodecs,
|
|
101
|
+
(alias) => alias,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function encodeParamValue(
|
|
106
|
+
value: unknown,
|
|
107
|
+
metadata: ParamMetadata,
|
|
50
108
|
paramIndex: number,
|
|
51
|
-
|
|
109
|
+
ctx: SqlCodecCallContext,
|
|
110
|
+
contractCodecs: ContractCodecRegistry | undefined,
|
|
111
|
+
aliasResolver: (alias: string) => string,
|
|
52
112
|
): Promise<unknown> {
|
|
53
113
|
if (value === null || value === undefined) {
|
|
54
114
|
return null;
|
|
55
115
|
}
|
|
56
116
|
|
|
57
|
-
const codec = resolveParamCodec(
|
|
117
|
+
const codec = resolveParamCodec(metadata, contractCodecs, aliasResolver);
|
|
58
118
|
if (!codec) {
|
|
59
119
|
return value;
|
|
60
120
|
}
|
|
61
121
|
|
|
62
122
|
try {
|
|
63
|
-
return await codec.encode(value);
|
|
123
|
+
return await codec.encode(value, ctx);
|
|
64
124
|
} catch (error) {
|
|
65
|
-
wrapEncodeFailure(error,
|
|
125
|
+
wrapEncodeFailure(error, metadata, paramIndex, codec.id);
|
|
66
126
|
}
|
|
67
127
|
}
|
|
68
128
|
|
|
69
129
|
/**
|
|
70
|
-
* Encodes all parameters concurrently via `Promise.all`. Per parameter, sync-
|
|
71
|
-
*
|
|
72
|
-
*
|
|
130
|
+
* Encodes all parameters concurrently via `Promise.all`. Per parameter, sync-and async-authored codecs share the same path: `codec.encode → await → return`. Param-level failures are wrapped in `RUNTIME.ENCODE_FAILED`.
|
|
131
|
+
*
|
|
132
|
+
* When `ctx.signal` is provided:
|
|
133
|
+
*
|
|
134
|
+
* - **Already-aborted at entry** short-circuits with `RUNTIME.ABORTED` (`{ phase: 'encode' }`) before any `codec.encode` call is made — codecs can pin this with a per-call counter that stays at zero.
|
|
135
|
+
* - **Mid-flight abort** races the per-param `Promise.all` against `abortable(ctx.signal)`. The runtime returns `RUNTIME.ABORTED` promptly even if codec bodies ignore the signal; the in-flight bodies are abandoned and run to completion in the background (cooperative cancellation, see ADR 204).
|
|
136
|
+
* - Existing `RUNTIME.ENCODE_FAILED` envelopes that surface from a codec body before the runtime observes the abort pass through unchanged (no double wrap).
|
|
73
137
|
*/
|
|
74
138
|
export async function encodeParams(
|
|
75
|
-
plan:
|
|
76
|
-
|
|
139
|
+
plan: SqlExecutionPlan,
|
|
140
|
+
ctx: SqlCodecCallContext,
|
|
141
|
+
contractCodecs?: ContractCodecRegistry,
|
|
77
142
|
): Promise<readonly unknown[]> {
|
|
143
|
+
checkAborted(ctx, 'encode');
|
|
144
|
+
const signal = ctx.signal;
|
|
145
|
+
|
|
78
146
|
if (plan.params.length === 0) {
|
|
79
147
|
return plan.params;
|
|
80
148
|
}
|
|
81
149
|
|
|
82
|
-
const descriptorCount = plan.meta.paramDescriptors.length;
|
|
83
150
|
const paramCount = plan.params.length;
|
|
151
|
+
const metadata: ParamMetadata[] = new Array(paramCount).fill(NO_METADATA);
|
|
84
152
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'RUNTIME.MISSING_PARAM_DESCRIPTOR',
|
|
93
|
-
`Missing paramDescriptor for parameter at index ${i} (plan has ${paramCount} params, ${descriptorCount} descriptors). The planner must emit one descriptor per param; this is a contract violation.`,
|
|
94
|
-
{ paramIndex: i, paramCount, descriptorCount },
|
|
95
|
-
);
|
|
153
|
+
if (plan.ast) {
|
|
154
|
+
const refs = collectOrderedParamRefs(plan.ast);
|
|
155
|
+
for (let i = 0; i < paramCount && i < refs.length; i++) {
|
|
156
|
+
const ref = refs[i];
|
|
157
|
+
if (ref) {
|
|
158
|
+
metadata[i] = { codecId: ref.codecId, name: ref.name, refs: ref.refs };
|
|
159
|
+
}
|
|
96
160
|
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const aliasResolver = makeAliasResolver(plan.ast);
|
|
97
164
|
|
|
98
|
-
|
|
165
|
+
const tasks: Promise<unknown>[] = new Array(paramCount);
|
|
166
|
+
for (let i = 0; i < paramCount; i++) {
|
|
167
|
+
tasks[i] = encodeParamValue(
|
|
168
|
+
plan.params[i],
|
|
169
|
+
metadata[i] ?? NO_METADATA,
|
|
170
|
+
i,
|
|
171
|
+
ctx,
|
|
172
|
+
contractCodecs,
|
|
173
|
+
aliasResolver,
|
|
174
|
+
);
|
|
99
175
|
}
|
|
100
176
|
|
|
101
|
-
const
|
|
102
|
-
return Object.freeze(
|
|
177
|
+
const settled = await raceAgainstAbort(Promise.all(tasks), signal, 'encode');
|
|
178
|
+
return Object.freeze(settled);
|
|
103
179
|
}
|
package/src/codecs/validation.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
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
|
-
import type {
|
|
4
|
+
import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context';
|
|
5
5
|
|
|
6
6
|
export function extractCodecIds(contract: Contract<SqlStorage>): Set<string> {
|
|
7
7
|
const codecIds = new Set<string>();
|
|
@@ -31,14 +31,14 @@ function extractCodecIdsFromColumns(contract: Contract<SqlStorage>): Map<string,
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function validateContractCodecMappings(
|
|
34
|
-
registry:
|
|
34
|
+
registry: CodecDescriptorRegistry,
|
|
35
35
|
contract: Contract<SqlStorage>,
|
|
36
36
|
): void {
|
|
37
37
|
const codecIds = extractCodecIdsFromColumns(contract);
|
|
38
38
|
const invalidCodecs: Array<{ table: string; column: string; codecId: string }> = [];
|
|
39
39
|
|
|
40
40
|
for (const [key, codecId] of codecIds.entries()) {
|
|
41
|
-
if (
|
|
41
|
+
if (registry.descriptorFor(codecId) === undefined) {
|
|
42
42
|
const parts = key.split('.');
|
|
43
43
|
const table = parts[0] ?? '';
|
|
44
44
|
const column = parts[1] ?? '';
|
|
@@ -61,7 +61,7 @@ export function validateContractCodecMappings(
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function validateCodecRegistryCompleteness(
|
|
64
|
-
registry:
|
|
64
|
+
registry: CodecDescriptorRegistry,
|
|
65
65
|
contract: Contract<SqlStorage>,
|
|
66
66
|
): void {
|
|
67
67
|
validateContractCodecMappings(registry, contract);
|