@prisma-next/sql-runtime 0.4.1 → 0.4.3
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 +29 -21
- package/dist/exports-CrHMfIKo.mjs +1564 -0
- package/dist/exports-CrHMfIKo.mjs.map +1 -0
- package/dist/{index-DyDQ4fyK.d.mts → index-_dXSGeho.d.mts} +112 -32
- package/dist/index-_dXSGeho.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/test/utils.d.mts +6 -5
- package/dist/test/utils.d.mts.map +1 -1
- package/dist/test/utils.mjs +16 -13
- package/dist/test/utils.mjs.map +1 -1
- package/package.json +12 -14
- package/src/codecs/decoding.ts +294 -173
- package/src/codecs/encoding.ts +162 -37
- package/src/codecs/validation.ts +22 -3
- package/src/exports/index.ts +11 -7
- package/src/fingerprint.ts +22 -0
- package/src/guardrails/raw.ts +165 -0
- package/src/lower-sql-plan.ts +5 -7
- package/src/marker.ts +75 -0
- package/src/middleware/before-compile-chain.ts +29 -0
- package/src/middleware/budgets.ts +34 -115
- package/src/middleware/lints.ts +5 -5
- package/src/middleware/sql-middleware.ts +36 -6
- package/src/runtime-spi.ts +44 -0
- package/src/sql-context.ts +332 -78
- package/src/sql-family-adapter.ts +3 -2
- package/src/sql-marker.ts +62 -47
- package/src/sql-runtime.ts +339 -104
- package/dist/exports-Cv7I7ZD5.mjs +0 -953
- package/dist/exports-Cv7I7ZD5.mjs.map +0 -1
- package/dist/index-DyDQ4fyK.d.mts.map +0 -1
- package/test/async-iterable-result.test.ts +0 -141
- package/test/budgets.test.ts +0 -431
- package/test/context.types.test-d.ts +0 -68
- package/test/execution-stack.test.ts +0 -164
- package/test/json-schema-validation.test.ts +0 -571
- package/test/lints.test.ts +0 -159
- package/test/mutation-default-generators.test.ts +0 -254
- package/test/parameterized-types.test.ts +0 -529
- package/test/sql-context.test.ts +0 -384
- package/test/sql-family-adapter.test.ts +0 -103
- package/test/sql-runtime.test.ts +0 -637
- package/test/utils.ts +0 -300
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
MiddlewareContext,
|
|
7
|
-
} from '@prisma-next/runtime-executor';
|
|
1
|
+
import {
|
|
2
|
+
type AfterExecuteResult,
|
|
3
|
+
type RuntimeErrorEnvelope,
|
|
4
|
+
runtimeError,
|
|
5
|
+
} from '@prisma-next/framework-components/runtime';
|
|
8
6
|
import { isQueryAst, type SelectAst } from '@prisma-next/sql-relational-core/ast';
|
|
7
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
8
|
+
import type { SqlMiddleware, SqlMiddlewareContext } from './sql-middleware';
|
|
9
9
|
|
|
10
10
|
export interface BudgetsOptions {
|
|
11
11
|
readonly maxRows?: number;
|
|
@@ -25,18 +25,28 @@ function hasAggregateWithoutGroupBy(ast: SelectAst): boolean {
|
|
|
25
25
|
return ast.projection.some((item) => item.expr.kind === 'aggregate');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function primaryTableFromAst(ast: SelectAst): string | undefined {
|
|
29
|
+
switch (ast.from.kind) {
|
|
30
|
+
case 'table-source':
|
|
31
|
+
return ast.from.name;
|
|
32
|
+
case 'derived-table-source':
|
|
33
|
+
return ast.from.alias;
|
|
34
|
+
default:
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
function estimateRowsFromAst(
|
|
29
40
|
ast: SelectAst,
|
|
30
41
|
tableRows: Record<string, number>,
|
|
31
42
|
defaultTableRows: number,
|
|
32
|
-
refs: { tables?: readonly string[] } | undefined,
|
|
33
43
|
hasAggregateWithoutGroup: boolean,
|
|
34
44
|
): number | null {
|
|
35
45
|
if (hasAggregateWithoutGroup) {
|
|
36
46
|
return 1;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
|
-
const table =
|
|
49
|
+
const table = primaryTableFromAst(ast);
|
|
40
50
|
if (!table) {
|
|
41
51
|
return null;
|
|
42
52
|
}
|
|
@@ -50,34 +60,10 @@ function estimateRowsFromAst(
|
|
|
50
60
|
return tableEstimate;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
function estimateRowsFromHeuristics(
|
|
54
|
-
plan: ExecutionPlan,
|
|
55
|
-
tableRows: Record<string, number>,
|
|
56
|
-
defaultTableRows: number,
|
|
57
|
-
): number | null {
|
|
58
|
-
const table = plan.meta.refs?.tables?.[0];
|
|
59
|
-
if (!table) {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const tableEstimate = tableRows[table] ?? defaultTableRows;
|
|
64
|
-
|
|
65
|
-
const limit = plan.meta.annotations?.['limit'];
|
|
66
|
-
if (typeof limit === 'number') {
|
|
67
|
-
return Math.min(limit, tableEstimate);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return tableEstimate;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function hasDetectableLimitFromHeuristics(plan: ExecutionPlan): boolean {
|
|
74
|
-
return typeof plan.meta.annotations?.['limit'] === 'number';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
63
|
function emitBudgetViolation(
|
|
78
64
|
error: RuntimeErrorEnvelope,
|
|
79
65
|
shouldBlock: boolean,
|
|
80
|
-
ctx:
|
|
66
|
+
ctx: SqlMiddlewareContext,
|
|
81
67
|
): void {
|
|
82
68
|
if (shouldBlock) {
|
|
83
69
|
throw error;
|
|
@@ -89,37 +75,28 @@ function emitBudgetViolation(
|
|
|
89
75
|
});
|
|
90
76
|
}
|
|
91
77
|
|
|
92
|
-
export function budgets
|
|
78
|
+
export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
93
79
|
const maxRows = options?.maxRows ?? 10_000;
|
|
94
80
|
const defaultTableRows = options?.defaultTableRows ?? 10_000;
|
|
95
81
|
const tableRows = options?.tableRows ?? {};
|
|
96
82
|
const maxLatencyMs = options?.maxLatencyMs ?? 1_000;
|
|
97
83
|
const rowSeverity = options?.severities?.rowCount ?? 'error';
|
|
98
84
|
|
|
99
|
-
const observedRowsByPlan = new WeakMap<
|
|
85
|
+
const observedRowsByPlan = new WeakMap<SqlExecutionPlan, { count: number }>();
|
|
100
86
|
|
|
101
87
|
return Object.freeze({
|
|
102
88
|
name: 'budgets',
|
|
103
89
|
familyId: 'sql' as const,
|
|
104
90
|
|
|
105
|
-
async beforeExecute(plan:
|
|
91
|
+
async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
106
92
|
observedRowsByPlan.set(plan, { count: 0 });
|
|
107
93
|
|
|
108
|
-
if (isQueryAst(plan.ast)) {
|
|
109
|
-
|
|
110
|
-
return evaluateSelectAst(plan, plan.ast, ctx);
|
|
111
|
-
}
|
|
112
|
-
return;
|
|
94
|
+
if (isQueryAst(plan.ast) && plan.ast.kind === 'select') {
|
|
95
|
+
return evaluateSelectAst(plan.ast, ctx);
|
|
113
96
|
}
|
|
114
|
-
|
|
115
|
-
return evaluateWithHeuristics(plan, ctx);
|
|
116
97
|
},
|
|
117
98
|
|
|
118
|
-
async onRow(
|
|
119
|
-
_row: Record<string, unknown>,
|
|
120
|
-
plan: ExecutionPlan,
|
|
121
|
-
_ctx: MiddlewareContext<TContract>,
|
|
122
|
-
) {
|
|
99
|
+
async onRow(_row: Record<string, unknown>, plan: SqlExecutionPlan, _ctx: SqlMiddlewareContext) {
|
|
123
100
|
const state = observedRowsByPlan.get(plan);
|
|
124
101
|
if (!state) return;
|
|
125
102
|
state.count += 1;
|
|
@@ -133,9 +110,9 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
133
110
|
},
|
|
134
111
|
|
|
135
112
|
async afterExecute(
|
|
136
|
-
_plan:
|
|
113
|
+
_plan: SqlExecutionPlan,
|
|
137
114
|
result: AfterExecuteResult,
|
|
138
|
-
ctx:
|
|
115
|
+
ctx: SqlMiddlewareContext,
|
|
139
116
|
) {
|
|
140
117
|
const latencyMs = result.latencyMs;
|
|
141
118
|
if (latencyMs > maxLatencyMs) {
|
|
@@ -146,25 +123,15 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
146
123
|
maxLatencyMs,
|
|
147
124
|
}),
|
|
148
125
|
shouldBlock,
|
|
149
|
-
ctx
|
|
126
|
+
ctx,
|
|
150
127
|
);
|
|
151
128
|
}
|
|
152
129
|
},
|
|
153
130
|
});
|
|
154
131
|
|
|
155
|
-
function evaluateSelectAst(
|
|
156
|
-
plan: ExecutionPlan,
|
|
157
|
-
ast: SelectAst,
|
|
158
|
-
ctx: MiddlewareContext<TContract>,
|
|
159
|
-
) {
|
|
132
|
+
function evaluateSelectAst(ast: SelectAst, ctx: SqlMiddlewareContext) {
|
|
160
133
|
const hasAggNoGroup = hasAggregateWithoutGroupBy(ast);
|
|
161
|
-
const estimated = estimateRowsFromAst(
|
|
162
|
-
ast,
|
|
163
|
-
tableRows,
|
|
164
|
-
defaultTableRows,
|
|
165
|
-
plan.meta.refs,
|
|
166
|
-
hasAggNoGroup,
|
|
167
|
-
);
|
|
134
|
+
const estimated = estimateRowsFromAst(ast, tableRows, defaultTableRows, hasAggNoGroup);
|
|
168
135
|
const isUnbounded = ast.limit === undefined && !hasAggNoGroup;
|
|
169
136
|
const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
|
|
170
137
|
|
|
@@ -177,7 +144,7 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
177
144
|
maxRows,
|
|
178
145
|
}),
|
|
179
146
|
shouldBlock,
|
|
180
|
-
ctx
|
|
147
|
+
ctx,
|
|
181
148
|
);
|
|
182
149
|
return;
|
|
183
150
|
}
|
|
@@ -188,7 +155,7 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
188
155
|
maxRows,
|
|
189
156
|
}),
|
|
190
157
|
shouldBlock,
|
|
191
|
-
ctx
|
|
158
|
+
ctx,
|
|
192
159
|
);
|
|
193
160
|
return;
|
|
194
161
|
}
|
|
@@ -201,56 +168,8 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
201
168
|
maxRows,
|
|
202
169
|
}),
|
|
203
170
|
shouldBlock,
|
|
204
|
-
ctx
|
|
171
|
+
ctx,
|
|
205
172
|
);
|
|
206
173
|
}
|
|
207
174
|
}
|
|
208
|
-
|
|
209
|
-
async function evaluateWithHeuristics(plan: ExecutionPlan, ctx: MiddlewareContext<TContract>) {
|
|
210
|
-
const estimated = estimateRowsFromHeuristics(plan, tableRows, defaultTableRows);
|
|
211
|
-
const isUnbounded = !hasDetectableLimitFromHeuristics(plan);
|
|
212
|
-
const sqlUpper = plan.sql.trimStart().toUpperCase();
|
|
213
|
-
const isSelect = sqlUpper.startsWith('SELECT');
|
|
214
|
-
const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
|
|
215
|
-
|
|
216
|
-
if (isSelect && isUnbounded) {
|
|
217
|
-
if (estimated !== null && estimated >= maxRows) {
|
|
218
|
-
emitBudgetViolation(
|
|
219
|
-
runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
|
|
220
|
-
source: 'heuristic',
|
|
221
|
-
estimatedRows: estimated,
|
|
222
|
-
maxRows,
|
|
223
|
-
}),
|
|
224
|
-
shouldBlock,
|
|
225
|
-
ctx as MiddlewareContext<unknown>,
|
|
226
|
-
);
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
emitBudgetViolation(
|
|
231
|
-
runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
|
|
232
|
-
source: 'heuristic',
|
|
233
|
-
maxRows,
|
|
234
|
-
}),
|
|
235
|
-
shouldBlock,
|
|
236
|
-
ctx as MiddlewareContext<unknown>,
|
|
237
|
-
);
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (estimated !== null) {
|
|
242
|
-
if (estimated > maxRows) {
|
|
243
|
-
emitBudgetViolation(
|
|
244
|
-
runtimeError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
|
|
245
|
-
source: 'heuristic',
|
|
246
|
-
estimatedRows: estimated,
|
|
247
|
-
maxRows,
|
|
248
|
-
}),
|
|
249
|
-
shouldBlock,
|
|
250
|
-
ctx as MiddlewareContext<unknown>,
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
175
|
}
|
package/src/middleware/lints.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { ExecutionPlan } from '@prisma-next/contract/types';
|
|
2
1
|
import { runtimeError } from '@prisma-next/framework-components/runtime';
|
|
3
|
-
import type { Middleware, MiddlewareContext } from '@prisma-next/runtime-executor';
|
|
4
|
-
import { evaluateRawGuardrails } from '@prisma-next/runtime-executor';
|
|
5
2
|
import {
|
|
6
3
|
type AnyFromSource,
|
|
7
4
|
type AnyQueryAst,
|
|
8
5
|
isQueryAst,
|
|
9
6
|
} from '@prisma-next/sql-relational-core/ast';
|
|
7
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
10
8
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
9
|
+
import { evaluateRawGuardrails } from '../guardrails/raw';
|
|
10
|
+
import type { SqlMiddleware, SqlMiddlewareContext } from './sql-middleware';
|
|
11
11
|
|
|
12
12
|
export interface LintsOptions {
|
|
13
13
|
readonly severities?: {
|
|
@@ -138,14 +138,14 @@ function getConfiguredSeverity(code: string, options?: LintsOptions): 'warn' | '
|
|
|
138
138
|
* Fallback: When ast is missing, `fallbackWhenAstMissing: 'raw'` uses heuristic
|
|
139
139
|
* SQL parsing; `'skip'` skips all lints. Default is `'raw'`.
|
|
140
140
|
*/
|
|
141
|
-
export function lints
|
|
141
|
+
export function lints(options?: LintsOptions): SqlMiddleware {
|
|
142
142
|
const fallback = options?.fallbackWhenAstMissing ?? 'raw';
|
|
143
143
|
|
|
144
144
|
return Object.freeze({
|
|
145
145
|
name: 'lints',
|
|
146
146
|
familyId: 'sql' as const,
|
|
147
147
|
|
|
148
|
-
async beforeExecute(plan:
|
|
148
|
+
async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
149
149
|
if (isQueryAst(plan.ast)) {
|
|
150
150
|
const findings = evaluateAstLints(plan.ast);
|
|
151
151
|
|
|
@@ -1,25 +1,55 @@
|
|
|
1
|
-
import type { Contract,
|
|
1
|
+
import type { Contract, PlanMeta } from '@prisma-next/contract/types';
|
|
2
2
|
import type {
|
|
3
3
|
AfterExecuteResult,
|
|
4
4
|
RuntimeMiddleware,
|
|
5
5
|
RuntimeMiddlewareContext,
|
|
6
6
|
} from '@prisma-next/framework-components/runtime';
|
|
7
7
|
import type { SqlStorage } from '@prisma-next/sql-contract/types';
|
|
8
|
+
import type { AnyQueryAst } from '@prisma-next/sql-relational-core/ast';
|
|
9
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
8
10
|
|
|
9
11
|
export interface SqlMiddlewareContext extends RuntimeMiddlewareContext {
|
|
10
12
|
readonly contract: Contract<SqlStorage>;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Pre-lowering query view passed to `beforeCompile`. Carries the typed SQL
|
|
17
|
+
* AST and plan metadata; `sql`/`params` are produced later by the adapter.
|
|
18
|
+
*/
|
|
19
|
+
export interface DraftPlan {
|
|
20
|
+
readonly ast: AnyQueryAst;
|
|
21
|
+
readonly meta: PlanMeta;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SqlMiddleware extends RuntimeMiddleware<SqlExecutionPlan> {
|
|
25
|
+
readonly familyId?: 'sql';
|
|
26
|
+
/**
|
|
27
|
+
* Rewrite the query AST before it is lowered to SQL. Middlewares run in
|
|
28
|
+
* registration order; each sees the predecessor's output, so rewrites
|
|
29
|
+
* compose (e.g. soft-delete + tenant isolation).
|
|
30
|
+
*
|
|
31
|
+
* Return `undefined` (or a draft whose `ast` reference equals the input's)
|
|
32
|
+
* to pass through. Return a draft with a new `ast` reference to replace it;
|
|
33
|
+
* the runtime emits a `middleware.rewrite` debug log event and continues
|
|
34
|
+
* with the new draft. `adapter.lower()` runs once after the chain.
|
|
35
|
+
*
|
|
36
|
+
* Use `AstRewriter` / `SelectAst.withWhere` / `AndExpr.of` etc. to build
|
|
37
|
+
* the rewritten AST. Predicates and literals go through parameterized
|
|
38
|
+
* constructors by default — no SQL-injection surface is added. **Warning:**
|
|
39
|
+
* constructing `LiteralExpr.of(userInput)` from untrusted input bypasses
|
|
40
|
+
* that guarantee; use `ParamRef.of(userInput, ...)` instead.
|
|
41
|
+
*
|
|
42
|
+
* See `docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md`.
|
|
43
|
+
*/
|
|
44
|
+
beforeCompile?(draft: DraftPlan, ctx: SqlMiddlewareContext): Promise<DraftPlan | undefined>;
|
|
45
|
+
beforeExecute?(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext): Promise<void>;
|
|
16
46
|
onRow?(
|
|
17
47
|
row: Record<string, unknown>,
|
|
18
|
-
plan:
|
|
48
|
+
plan: SqlExecutionPlan,
|
|
19
49
|
ctx: SqlMiddlewareContext,
|
|
20
50
|
): Promise<void>;
|
|
21
51
|
afterExecute?(
|
|
22
|
-
plan:
|
|
52
|
+
plan: SqlExecutionPlan,
|
|
23
53
|
result: AfterExecuteResult,
|
|
24
54
|
ctx: SqlMiddlewareContext,
|
|
25
55
|
): Promise<void>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ContractMarkerRecord } from '@prisma-next/contract/types';
|
|
2
|
+
import type { ExecutionPlan } from '@prisma-next/framework-components/runtime';
|
|
3
|
+
import type { MarkerStatement } from '@prisma-next/sql-relational-core/ast';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reader of the SQL contract marker. SQL runtimes verify the database's
|
|
7
|
+
* `prisma_contract.marker` row against the runtime's contract by issuing
|
|
8
|
+
* this statement before executing user queries (when `verify` is enabled).
|
|
9
|
+
* Each adapter is responsible for any target-specific row decoding before
|
|
10
|
+
* delegating to the shared row schema.
|
|
11
|
+
*/
|
|
12
|
+
export interface MarkerReader {
|
|
13
|
+
readMarkerStatement(): MarkerStatement;
|
|
14
|
+
parseMarkerRow(row: unknown): ContractMarkerRecord;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SQL family adapter SPI consumed by `SqlRuntime`. Encapsulates the
|
|
19
|
+
* runtime contract, marker reader, and plan validation logic so the
|
|
20
|
+
* runtime can be unit-tested without a concrete SQL adapter profile.
|
|
21
|
+
*
|
|
22
|
+
* Implemented by `SqlFamilyAdapter` for production and by mock classes
|
|
23
|
+
* in tests.
|
|
24
|
+
*/
|
|
25
|
+
export interface RuntimeFamilyAdapter<TContract = unknown> {
|
|
26
|
+
readonly contract: TContract;
|
|
27
|
+
readonly markerReader: MarkerReader;
|
|
28
|
+
validatePlan(plan: ExecutionPlan, contract: TContract): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RuntimeVerifyOptions {
|
|
32
|
+
readonly mode: 'onFirstUse' | 'startup' | 'always';
|
|
33
|
+
readonly requireMarker: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type TelemetryOutcome = 'success' | 'runtime-error';
|
|
37
|
+
|
|
38
|
+
export interface RuntimeTelemetryEvent {
|
|
39
|
+
readonly lane: string;
|
|
40
|
+
readonly target: string;
|
|
41
|
+
readonly fingerprint: string;
|
|
42
|
+
readonly outcome: TelemetryOutcome;
|
|
43
|
+
readonly durationMs?: number;
|
|
44
|
+
}
|