@prisma-next/sql-runtime 0.5.0-dev.1 → 0.5.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.
- package/README.md +29 -21
- package/dist/{exports-TJ70Qw3r.mjs → exports-BOHa3Emo.mjs} +502 -121
- package/dist/exports-BOHa3Emo.mjs.map +1 -0
- package/dist/{index-DyDQ4fyK.d.mts → index-CZmC2kD3.d.mts} +87 -23
- package/dist/index-CZmC2kD3.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/test/utils.d.mts +6 -5
- package/dist/test/utils.d.mts.map +1 -1
- package/dist/test/utils.mjs +7 -2
- package/dist/test/utils.mjs.map +1 -1
- package/package.json +12 -14
- package/src/codecs/decoding.ts +172 -116
- package/src/codecs/encoding.ts +59 -21
- package/src/exports/index.ts +10 -7
- package/src/fingerprint.ts +22 -0
- package/src/guardrails/raw.ts +214 -0
- package/src/lower-sql-plan.ts +3 -3
- package/src/marker.ts +82 -0
- package/src/middleware/before-compile-chain.ts +59 -0
- package/src/middleware/budgets.ts +25 -33
- package/src/middleware/lints.ts +5 -5
- package/src/middleware/sql-middleware.ts +36 -6
- package/src/runtime-spi.ts +43 -0
- package/src/sql-family-adapter.ts +3 -2
- package/src/sql-marker.ts +1 -1
- package/src/sql-runtime.ts +279 -101
- package/dist/exports-TJ70Qw3r.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 -161
- 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 -634
- package/test/utils.ts +0 -297
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { PlanMeta, PlanRefs } from '@prisma-next/contract/types';
|
|
2
|
+
|
|
3
|
+
export type LintSeverity = 'error' | 'warn';
|
|
4
|
+
export type BudgetSeverity = 'error' | 'warn';
|
|
5
|
+
|
|
6
|
+
export interface LintFinding {
|
|
7
|
+
readonly code: `LINT.${string}`;
|
|
8
|
+
readonly severity: LintSeverity;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly details?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BudgetFinding {
|
|
14
|
+
readonly code: `BUDGET.${string}`;
|
|
15
|
+
readonly severity: BudgetSeverity;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
readonly details?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RawGuardrailConfig {
|
|
21
|
+
readonly budgets?: {
|
|
22
|
+
readonly unboundedSelectSeverity?: BudgetSeverity;
|
|
23
|
+
readonly estimatedRows?: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RawGuardrailResult {
|
|
28
|
+
readonly lints: LintFinding[];
|
|
29
|
+
readonly budgets: BudgetFinding[];
|
|
30
|
+
readonly statement: 'select' | 'mutation' | 'other';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Minimal plan view consumed by raw-SQL guardrails. Structurally satisfied
|
|
35
|
+
* by `SqlExecutionPlan`; declared inline so this module stays decoupled
|
|
36
|
+
* from a specific plan type at the call site.
|
|
37
|
+
*/
|
|
38
|
+
interface RawGuardrailPlan {
|
|
39
|
+
readonly sql: string;
|
|
40
|
+
readonly meta: PlanMeta;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SELECT_STAR_REGEX = /select\s+\*/i;
|
|
44
|
+
const LIMIT_REGEX = /\blimit\b/i;
|
|
45
|
+
const MUTATION_PREFIX_REGEX = /^(insert|update|delete|create|alter|drop|truncate)\b/i;
|
|
46
|
+
|
|
47
|
+
const READ_ONLY_INTENTS = new Set(['read', 'report', 'readonly']);
|
|
48
|
+
|
|
49
|
+
export function evaluateRawGuardrails(
|
|
50
|
+
plan: RawGuardrailPlan,
|
|
51
|
+
config?: RawGuardrailConfig,
|
|
52
|
+
): RawGuardrailResult {
|
|
53
|
+
const lints: LintFinding[] = [];
|
|
54
|
+
const budgets: BudgetFinding[] = [];
|
|
55
|
+
|
|
56
|
+
const normalized = normalizeWhitespace(plan.sql);
|
|
57
|
+
const statementType = classifyStatement(normalized);
|
|
58
|
+
|
|
59
|
+
if (statementType === 'select') {
|
|
60
|
+
if (SELECT_STAR_REGEX.test(normalized)) {
|
|
61
|
+
lints.push(
|
|
62
|
+
createLint('LINT.SELECT_STAR', 'error', 'Raw SQL plan selects all columns via *', {
|
|
63
|
+
sql: snippet(plan.sql),
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!LIMIT_REGEX.test(normalized)) {
|
|
69
|
+
const severity = config?.budgets?.unboundedSelectSeverity ?? 'error';
|
|
70
|
+
lints.push(
|
|
71
|
+
createLint('LINT.NO_LIMIT', 'warn', 'Raw SQL plan omits LIMIT clause', {
|
|
72
|
+
sql: snippet(plan.sql),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
budgets.push(
|
|
77
|
+
createBudget(
|
|
78
|
+
'BUDGET.ROWS_EXCEEDED',
|
|
79
|
+
severity,
|
|
80
|
+
'Raw SQL plan is unbounded and may exceed row budget',
|
|
81
|
+
{
|
|
82
|
+
sql: snippet(plan.sql),
|
|
83
|
+
...(config?.budgets?.estimatedRows !== undefined
|
|
84
|
+
? { estimatedRows: config.budgets.estimatedRows }
|
|
85
|
+
: {}),
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isMutationStatement(statementType) && isReadOnlyIntent(plan.meta)) {
|
|
93
|
+
lints.push(
|
|
94
|
+
createLint(
|
|
95
|
+
'LINT.READ_ONLY_MUTATION',
|
|
96
|
+
'error',
|
|
97
|
+
'Raw SQL plan mutates data despite read-only intent',
|
|
98
|
+
{
|
|
99
|
+
sql: snippet(plan.sql),
|
|
100
|
+
intent: plan.meta.annotations?.['intent'],
|
|
101
|
+
},
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const refs = plan.meta.refs;
|
|
107
|
+
if (refs) {
|
|
108
|
+
evaluateIndexCoverage(refs, lints);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { lints, budgets, statement: statementType };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function evaluateIndexCoverage(refs: PlanRefs, lints: LintFinding[]) {
|
|
115
|
+
const predicateColumns = refs.columns ?? [];
|
|
116
|
+
if (predicateColumns.length === 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const indexes = refs.indexes ?? [];
|
|
121
|
+
|
|
122
|
+
if (indexes.length === 0) {
|
|
123
|
+
lints.push(
|
|
124
|
+
createLint(
|
|
125
|
+
'LINT.UNINDEXED_PREDICATE',
|
|
126
|
+
'warn',
|
|
127
|
+
'Raw SQL plan predicates lack supporting indexes',
|
|
128
|
+
{
|
|
129
|
+
predicates: predicateColumns,
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const hasSupportingIndex = predicateColumns.every((column) =>
|
|
137
|
+
indexes.some(
|
|
138
|
+
(index) =>
|
|
139
|
+
index.table === column.table &&
|
|
140
|
+
index.columns.some((col) => col.toLowerCase() === column.column.toLowerCase()),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!hasSupportingIndex) {
|
|
145
|
+
lints.push(
|
|
146
|
+
createLint(
|
|
147
|
+
'LINT.UNINDEXED_PREDICATE',
|
|
148
|
+
'warn',
|
|
149
|
+
'Raw SQL plan predicates lack supporting indexes',
|
|
150
|
+
{
|
|
151
|
+
predicates: predicateColumns,
|
|
152
|
+
},
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function classifyStatement(sql: string): 'select' | 'mutation' | 'other' {
|
|
159
|
+
const trimmed = sql.trim();
|
|
160
|
+
const lower = trimmed.toLowerCase();
|
|
161
|
+
|
|
162
|
+
if (lower.startsWith('with')) {
|
|
163
|
+
if (lower.includes('select')) {
|
|
164
|
+
return 'select';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (lower.startsWith('select')) {
|
|
169
|
+
return 'select';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (MUTATION_PREFIX_REGEX.test(trimmed)) {
|
|
173
|
+
return 'mutation';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return 'other';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isMutationStatement(statement: 'select' | 'mutation' | 'other'): boolean {
|
|
180
|
+
return statement === 'mutation';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isReadOnlyIntent(meta: PlanMeta): boolean {
|
|
184
|
+
const annotations = meta.annotations as { intent?: string } | undefined;
|
|
185
|
+
const intent =
|
|
186
|
+
typeof annotations?.intent === 'string' ? annotations.intent.toLowerCase() : undefined;
|
|
187
|
+
return intent !== undefined && READ_ONLY_INTENTS.has(intent);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeWhitespace(value: string): string {
|
|
191
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function snippet(sql: string): string {
|
|
195
|
+
return normalizeWhitespace(sql).slice(0, 200);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createLint(
|
|
199
|
+
code: LintFinding['code'],
|
|
200
|
+
severity: LintFinding['severity'],
|
|
201
|
+
message: string,
|
|
202
|
+
details?: Record<string, unknown>,
|
|
203
|
+
): LintFinding {
|
|
204
|
+
return { code, severity, message, ...(details ? { details } : {}) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createBudget(
|
|
208
|
+
code: BudgetFinding['code'],
|
|
209
|
+
severity: BudgetFinding['severity'],
|
|
210
|
+
message: string,
|
|
211
|
+
details?: Record<string, unknown>,
|
|
212
|
+
): BudgetFinding {
|
|
213
|
+
return { code, severity, message, ...(details ? { details } : {}) };
|
|
214
|
+
}
|
package/src/lower-sql-plan.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { Contract
|
|
1
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
2
2
|
import type { SqlStorage } from '@prisma-next/sql-contract/types';
|
|
3
3
|
import type { Adapter, AnyQueryAst, LoweredStatement } from '@prisma-next/sql-relational-core/ast';
|
|
4
|
-
import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
|
|
4
|
+
import type { SqlExecutionPlan, SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Lowers a SQL query plan to an executable Plan by calling the adapter's lower method.
|
|
@@ -15,7 +15,7 @@ export function lowerSqlPlan<Row>(
|
|
|
15
15
|
adapter: Adapter<AnyQueryAst, Contract<SqlStorage>, LoweredStatement>,
|
|
16
16
|
contract: Contract<SqlStorage>,
|
|
17
17
|
queryPlan: SqlQueryPlan<Row>,
|
|
18
|
-
):
|
|
18
|
+
): SqlExecutionPlan<Row> {
|
|
19
19
|
const lowered = adapter.lower(queryPlan.ast, {
|
|
20
20
|
contract,
|
|
21
21
|
params: queryPlan.params,
|
package/src/marker.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ContractMarkerRecord } from '@prisma-next/contract/types';
|
|
2
|
+
import { type } from 'arktype';
|
|
3
|
+
|
|
4
|
+
export interface ContractMarkerRow {
|
|
5
|
+
core_hash: string;
|
|
6
|
+
profile_hash: string;
|
|
7
|
+
contract_json: unknown | null;
|
|
8
|
+
canonical_version: number | null;
|
|
9
|
+
updated_at: Date;
|
|
10
|
+
app_tag: string | null;
|
|
11
|
+
meta: unknown | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MetaSchema = type({ '[string]': 'unknown' });
|
|
15
|
+
|
|
16
|
+
function parseMeta(meta: unknown): Record<string, unknown> {
|
|
17
|
+
if (meta === null || meta === undefined) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let parsed: unknown;
|
|
22
|
+
if (typeof meta === 'string') {
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(meta);
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
parsed = meta;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = MetaSchema(parsed);
|
|
33
|
+
if (result instanceof type.errors) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ContractMarkerRowSchema = type({
|
|
41
|
+
core_hash: 'string',
|
|
42
|
+
profile_hash: 'string',
|
|
43
|
+
'contract_json?': 'unknown | null',
|
|
44
|
+
'canonical_version?': 'number | null',
|
|
45
|
+
'updated_at?': 'Date | string',
|
|
46
|
+
'app_tag?': 'string | null',
|
|
47
|
+
'meta?': 'unknown | null',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function parseContractMarkerRow(row: unknown): ContractMarkerRecord {
|
|
51
|
+
const result = ContractMarkerRowSchema(row);
|
|
52
|
+
if (result instanceof type.errors) {
|
|
53
|
+
const messages = result.map((p: { message: string }) => p.message).join('; ');
|
|
54
|
+
throw new Error(`Invalid contract marker row: ${messages}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const validatedRow = result as {
|
|
58
|
+
core_hash: string;
|
|
59
|
+
profile_hash: string;
|
|
60
|
+
contract_json?: unknown | null;
|
|
61
|
+
canonical_version?: number | null;
|
|
62
|
+
updated_at?: Date | string;
|
|
63
|
+
app_tag?: string | null;
|
|
64
|
+
meta?: unknown | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const updatedAt = validatedRow.updated_at
|
|
68
|
+
? validatedRow.updated_at instanceof Date
|
|
69
|
+
? validatedRow.updated_at
|
|
70
|
+
: new Date(validatedRow.updated_at)
|
|
71
|
+
: new Date();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
storageHash: validatedRow.core_hash,
|
|
75
|
+
profileHash: validatedRow.profile_hash,
|
|
76
|
+
contractJson: validatedRow.contract_json ?? null,
|
|
77
|
+
canonicalVersion: validatedRow.canonical_version ?? null,
|
|
78
|
+
updatedAt,
|
|
79
|
+
appTag: validatedRow.app_tag ?? null,
|
|
80
|
+
meta: parseMeta(validatedRow.meta),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ParamDescriptor, PlanMeta } from '@prisma-next/contract/types';
|
|
2
|
+
import type { AnyQueryAst } from '@prisma-next/sql-relational-core/ast';
|
|
3
|
+
import type { DraftPlan, SqlMiddleware, SqlMiddlewareContext } from './sql-middleware';
|
|
4
|
+
|
|
5
|
+
export async function runBeforeCompileChain(
|
|
6
|
+
middleware: readonly SqlMiddleware[],
|
|
7
|
+
initial: DraftPlan,
|
|
8
|
+
ctx: SqlMiddlewareContext,
|
|
9
|
+
): Promise<DraftPlan> {
|
|
10
|
+
let current = initial;
|
|
11
|
+
for (const mw of middleware) {
|
|
12
|
+
if (!mw.beforeCompile) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const result = await mw.beforeCompile(current, ctx);
|
|
16
|
+
if (result === undefined) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (result.ast === current.ast) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
ctx.log.debug?.({
|
|
23
|
+
event: 'middleware.rewrite',
|
|
24
|
+
middleware: mw.name,
|
|
25
|
+
lane: current.meta.lane,
|
|
26
|
+
});
|
|
27
|
+
current = result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (current.ast === initial.ast) {
|
|
31
|
+
return current;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The rewritten AST may have introduced, removed, or replaced ParamRefs, so
|
|
35
|
+
// the descriptors collected at lane build time no longer line up with what
|
|
36
|
+
// the adapter will emit when it walks the new AST. Re-derive descriptors
|
|
37
|
+
// from the rewritten AST so `params` and `paramDescriptors` stay in lockstep
|
|
38
|
+
// by the time `encodeParams` runs.
|
|
39
|
+
const paramDescriptors = deriveParamDescriptorsFromAst(current.ast);
|
|
40
|
+
const meta: PlanMeta = { ...current.meta, paramDescriptors };
|
|
41
|
+
return { ast: current.ast, meta };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function deriveParamDescriptorsFromAst(ast: AnyQueryAst): ReadonlyArray<ParamDescriptor> {
|
|
45
|
+
const refs = ast.collectParamRefs();
|
|
46
|
+
const seen = new Set<unknown>();
|
|
47
|
+
const descriptors: ParamDescriptor[] = [];
|
|
48
|
+
for (const ref of refs) {
|
|
49
|
+
if (seen.has(ref)) continue;
|
|
50
|
+
seen.add(ref);
|
|
51
|
+
descriptors.push({
|
|
52
|
+
index: descriptors.length + 1,
|
|
53
|
+
...(ref.name !== undefined ? { name: ref.name } : {}),
|
|
54
|
+
source: 'dsl',
|
|
55
|
+
...(ref.codecId !== undefined ? { codecId: ref.codecId } : {}),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return descriptors;
|
|
59
|
+
}
|
|
@@ -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;
|
|
@@ -51,7 +51,7 @@ function estimateRowsFromAst(
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function estimateRowsFromHeuristics(
|
|
54
|
-
plan:
|
|
54
|
+
plan: SqlExecutionPlan,
|
|
55
55
|
tableRows: Record<string, number>,
|
|
56
56
|
defaultTableRows: number,
|
|
57
57
|
): number | null {
|
|
@@ -70,14 +70,14 @@ function estimateRowsFromHeuristics(
|
|
|
70
70
|
return tableEstimate;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function hasDetectableLimitFromHeuristics(plan:
|
|
73
|
+
function hasDetectableLimitFromHeuristics(plan: SqlExecutionPlan): boolean {
|
|
74
74
|
return typeof plan.meta.annotations?.['limit'] === 'number';
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function emitBudgetViolation(
|
|
78
78
|
error: RuntimeErrorEnvelope,
|
|
79
79
|
shouldBlock: boolean,
|
|
80
|
-
ctx:
|
|
80
|
+
ctx: SqlMiddlewareContext,
|
|
81
81
|
): void {
|
|
82
82
|
if (shouldBlock) {
|
|
83
83
|
throw error;
|
|
@@ -89,20 +89,20 @@ function emitBudgetViolation(
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export function budgets
|
|
92
|
+
export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
93
93
|
const maxRows = options?.maxRows ?? 10_000;
|
|
94
94
|
const defaultTableRows = options?.defaultTableRows ?? 10_000;
|
|
95
95
|
const tableRows = options?.tableRows ?? {};
|
|
96
96
|
const maxLatencyMs = options?.maxLatencyMs ?? 1_000;
|
|
97
97
|
const rowSeverity = options?.severities?.rowCount ?? 'error';
|
|
98
98
|
|
|
99
|
-
const observedRowsByPlan = new WeakMap<
|
|
99
|
+
const observedRowsByPlan = new WeakMap<SqlExecutionPlan, { count: number }>();
|
|
100
100
|
|
|
101
101
|
return Object.freeze({
|
|
102
102
|
name: 'budgets',
|
|
103
103
|
familyId: 'sql' as const,
|
|
104
104
|
|
|
105
|
-
async beforeExecute(plan:
|
|
105
|
+
async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
106
106
|
observedRowsByPlan.set(plan, { count: 0 });
|
|
107
107
|
|
|
108
108
|
if (isQueryAst(plan.ast)) {
|
|
@@ -115,11 +115,7 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
115
115
|
return evaluateWithHeuristics(plan, ctx);
|
|
116
116
|
},
|
|
117
117
|
|
|
118
|
-
async onRow(
|
|
119
|
-
_row: Record<string, unknown>,
|
|
120
|
-
plan: ExecutionPlan,
|
|
121
|
-
_ctx: MiddlewareContext<TContract>,
|
|
122
|
-
) {
|
|
118
|
+
async onRow(_row: Record<string, unknown>, plan: SqlExecutionPlan, _ctx: SqlMiddlewareContext) {
|
|
123
119
|
const state = observedRowsByPlan.get(plan);
|
|
124
120
|
if (!state) return;
|
|
125
121
|
state.count += 1;
|
|
@@ -133,9 +129,9 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
133
129
|
},
|
|
134
130
|
|
|
135
131
|
async afterExecute(
|
|
136
|
-
_plan:
|
|
132
|
+
_plan: SqlExecutionPlan,
|
|
137
133
|
result: AfterExecuteResult,
|
|
138
|
-
ctx:
|
|
134
|
+
ctx: SqlMiddlewareContext,
|
|
139
135
|
) {
|
|
140
136
|
const latencyMs = result.latencyMs;
|
|
141
137
|
if (latencyMs > maxLatencyMs) {
|
|
@@ -146,17 +142,13 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
146
142
|
maxLatencyMs,
|
|
147
143
|
}),
|
|
148
144
|
shouldBlock,
|
|
149
|
-
ctx
|
|
145
|
+
ctx,
|
|
150
146
|
);
|
|
151
147
|
}
|
|
152
148
|
},
|
|
153
149
|
});
|
|
154
150
|
|
|
155
|
-
function evaluateSelectAst(
|
|
156
|
-
plan: ExecutionPlan,
|
|
157
|
-
ast: SelectAst,
|
|
158
|
-
ctx: MiddlewareContext<TContract>,
|
|
159
|
-
) {
|
|
151
|
+
function evaluateSelectAst(plan: SqlExecutionPlan, ast: SelectAst, ctx: SqlMiddlewareContext) {
|
|
160
152
|
const hasAggNoGroup = hasAggregateWithoutGroupBy(ast);
|
|
161
153
|
const estimated = estimateRowsFromAst(
|
|
162
154
|
ast,
|
|
@@ -177,7 +169,7 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
177
169
|
maxRows,
|
|
178
170
|
}),
|
|
179
171
|
shouldBlock,
|
|
180
|
-
ctx
|
|
172
|
+
ctx,
|
|
181
173
|
);
|
|
182
174
|
return;
|
|
183
175
|
}
|
|
@@ -188,7 +180,7 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
188
180
|
maxRows,
|
|
189
181
|
}),
|
|
190
182
|
shouldBlock,
|
|
191
|
-
ctx
|
|
183
|
+
ctx,
|
|
192
184
|
);
|
|
193
185
|
return;
|
|
194
186
|
}
|
|
@@ -201,12 +193,12 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
201
193
|
maxRows,
|
|
202
194
|
}),
|
|
203
195
|
shouldBlock,
|
|
204
|
-
ctx
|
|
196
|
+
ctx,
|
|
205
197
|
);
|
|
206
198
|
}
|
|
207
199
|
}
|
|
208
200
|
|
|
209
|
-
async function evaluateWithHeuristics(plan:
|
|
201
|
+
async function evaluateWithHeuristics(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
210
202
|
const estimated = estimateRowsFromHeuristics(plan, tableRows, defaultTableRows);
|
|
211
203
|
const isUnbounded = !hasDetectableLimitFromHeuristics(plan);
|
|
212
204
|
const sqlUpper = plan.sql.trimStart().toUpperCase();
|
|
@@ -222,7 +214,7 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
222
214
|
maxRows,
|
|
223
215
|
}),
|
|
224
216
|
shouldBlock,
|
|
225
|
-
ctx
|
|
217
|
+
ctx,
|
|
226
218
|
);
|
|
227
219
|
return;
|
|
228
220
|
}
|
|
@@ -233,7 +225,7 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
233
225
|
maxRows,
|
|
234
226
|
}),
|
|
235
227
|
shouldBlock,
|
|
236
|
-
ctx
|
|
228
|
+
ctx,
|
|
237
229
|
);
|
|
238
230
|
return;
|
|
239
231
|
}
|
|
@@ -247,7 +239,7 @@ export function budgets<TContract = unknown>(options?: BudgetsOptions): Middlewa
|
|
|
247
239
|
maxRows,
|
|
248
240
|
}),
|
|
249
241
|
shouldBlock,
|
|
250
|
-
ctx
|
|
242
|
+
ctx,
|
|
251
243
|
);
|
|
252
244
|
}
|
|
253
245
|
return;
|
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,43 @@
|
|
|
1
|
+
import type { ExecutionPlan } from '@prisma-next/framework-components/runtime';
|
|
2
|
+
import type { MarkerStatement } from '@prisma-next/sql-relational-core/ast';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reader of the SQL contract marker. SQL runtimes verify the database's
|
|
6
|
+
* `prisma_contract.marker` row against the runtime's contract by issuing
|
|
7
|
+
* this statement before executing user queries (when `verify` is enabled).
|
|
8
|
+
*
|
|
9
|
+
* Structurally satisfied by `AdapterProfile`, which already exposes
|
|
10
|
+
* `readMarkerStatement(): MarkerStatement` for adapter-level introspection.
|
|
11
|
+
*/
|
|
12
|
+
export interface MarkerReader {
|
|
13
|
+
readMarkerStatement(): MarkerStatement;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SQL family adapter SPI consumed by `SqlRuntime`. Encapsulates the
|
|
18
|
+
* runtime contract, marker reader, and plan validation logic so the
|
|
19
|
+
* runtime can be unit-tested without a concrete SQL adapter profile.
|
|
20
|
+
*
|
|
21
|
+
* Implemented by `SqlFamilyAdapter` for production and by mock classes
|
|
22
|
+
* in tests.
|
|
23
|
+
*/
|
|
24
|
+
export interface RuntimeFamilyAdapter<TContract = unknown> {
|
|
25
|
+
readonly contract: TContract;
|
|
26
|
+
readonly markerReader: MarkerReader;
|
|
27
|
+
validatePlan(plan: ExecutionPlan, contract: TContract): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RuntimeVerifyOptions {
|
|
31
|
+
readonly mode: 'onFirstUse' | 'startup' | 'always';
|
|
32
|
+
readonly requireMarker: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TelemetryOutcome = 'success' | 'runtime-error';
|
|
36
|
+
|
|
37
|
+
export interface RuntimeTelemetryEvent {
|
|
38
|
+
readonly lane: string;
|
|
39
|
+
readonly target: string;
|
|
40
|
+
readonly fingerprint: string;
|
|
41
|
+
readonly outcome: TelemetryOutcome;
|
|
42
|
+
readonly durationMs?: number;
|
|
43
|
+
}
|