@prisma-next/sql-runtime 0.5.0-dev.9 → 0.5.1

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.
@@ -0,0 +1,44 @@
1
+ import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
2
+ import { canonicalStringify } from '@prisma-next/utils/canonical-stringify';
3
+ import { hashContent } from '@prisma-next/utils/hash-content';
4
+
5
+ /**
6
+ * Computes a stable content hash for a lowered SQL execution plan.
7
+ *
8
+ * Internally builds an unambiguous canonical-stringified preimage from
9
+ * three components:
10
+ *
11
+ * 1. `meta.storageHash` — discriminates by schema. A migration changes the
12
+ * storage hash, which invalidates cached entries automatically.
13
+ * 2. `exec.sql` — the raw lowered SQL text. Two queries with different
14
+ * structure produce different keys. Note that we deliberately do **not**
15
+ * use `computeSqlFingerprint` here: that helper strips literals to group
16
+ * executions by statement shape (used by telemetry), which is the
17
+ * opposite of what a content hash needs — we want per-execution
18
+ * discrimination, not per-statement-shape grouping.
19
+ * 3. `exec.params` — the bound parameters. `canonicalStringify` produces a
20
+ * deterministic serialization that is stable across object key
21
+ * insertion order and that distinguishes types JSON would otherwise
22
+ * conflate (e.g. `BigInt(1)` vs `1`).
23
+ *
24
+ * The components are wrapped in an object and canonicalized as a single
25
+ * unit (rather than concatenated with a delimiter) so component
26
+ * boundaries are unambiguous: any character appearing inside `sql` or
27
+ * `storageHash` cannot bleed across components and produce a collision
28
+ * with a different split of the same characters.
29
+ *
30
+ * The canonical string is then piped through `hashContent` to produce a
31
+ * bounded, opaque digest. See `@prisma-next/utils/hash-content` for the
32
+ * rationale.
33
+ *
34
+ * @internal
35
+ */
36
+ export function computeSqlContentHash(exec: SqlExecutionPlan): Promise<string> {
37
+ return hashContent(
38
+ canonicalStringify({
39
+ storageHash: exec.meta.storageHash,
40
+ sql: exec.sql,
41
+ params: exec.params,
42
+ }),
43
+ );
44
+ }
@@ -2,13 +2,14 @@ export type {
2
2
  AfterExecuteResult,
3
3
  RuntimeLog as Log,
4
4
  } from '@prisma-next/framework-components/runtime';
5
- export type { MarkerStatement } from '@prisma-next/sql-relational-core/ast';
5
+ export type { MarkerReadResult, MarkerStatement } from '@prisma-next/sql-relational-core/ast';
6
6
  export {
7
7
  extractCodecIds,
8
8
  validateCodecRegistryCompleteness,
9
9
  validateContractCodecMappings,
10
10
  } from '../codecs/validation';
11
11
  export { lowerSqlPlan } from '../lower-sql-plan';
12
+ export { parseContractMarkerRow } from '../marker';
12
13
  export type { BudgetsOptions } from '../middleware/budgets';
13
14
  export { budgets } from '../middleware/budgets';
14
15
  export type { LintsOptions } from '../middleware/lints';
@@ -23,6 +24,7 @@ export type {
23
24
  } from '../runtime-spi';
24
25
  export type {
25
26
  ExecutionContext,
27
+ GeneratorStability,
26
28
  RuntimeMutationDefaultGenerator,
27
29
  RuntimeParameterizedCodecDescriptor,
28
30
  SqlExecutionStack,
@@ -42,6 +44,7 @@ export {
42
44
  } from '../sql-context';
43
45
  export type { SqlStatement } from '../sql-marker';
44
46
  export {
47
+ APP_SPACE_ID,
45
48
  ensureSchemaStatement,
46
49
  ensureTableStatement,
47
50
  readContractMarker,
@@ -1,4 +1,4 @@
1
- import type { PlanMeta, PlanRefs } from '@prisma-next/contract/types';
1
+ import type { PlanMeta } from '@prisma-next/contract/types';
2
2
 
3
3
  export type LintSeverity = 'error' | 'warn';
4
4
  export type BudgetSeverity = 'error' | 'warn';
@@ -103,58 +103,9 @@ export function evaluateRawGuardrails(
103
103
  );
104
104
  }
105
105
 
106
- const refs = plan.meta.refs;
107
- if (refs) {
108
- evaluateIndexCoverage(refs, lints);
109
- }
110
-
111
106
  return { lints, budgets, statement: statementType };
112
107
  }
113
108
 
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
109
  function classifyStatement(sql: string): 'select' | 'mutation' | 'other' {
159
110
  const trimmed = sql.trim();
160
111
  const lower = trimmed.toLowerCase();
package/src/marker.ts CHANGED
@@ -9,6 +9,7 @@ export interface ContractMarkerRow {
9
9
  updated_at: Date;
10
10
  app_tag: string | null;
11
11
  meta: unknown | null;
12
+ invariants: readonly string[];
12
13
  }
13
14
 
14
15
  const MetaSchema = type({ '[string]': 'unknown' });
@@ -45,6 +46,7 @@ const ContractMarkerRowSchema = type({
45
46
  'updated_at?': 'Date | string',
46
47
  'app_tag?': 'string | null',
47
48
  'meta?': 'unknown | null',
49
+ invariants: type('string').array(),
48
50
  });
49
51
 
50
52
  export function parseContractMarkerRow(row: unknown): ContractMarkerRecord {
@@ -54,29 +56,20 @@ export function parseContractMarkerRow(row: unknown): ContractMarkerRecord {
54
56
  throw new Error(`Invalid contract marker row: ${messages}`);
55
57
  }
56
58
 
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)
59
+ const updatedAt = result.updated_at
60
+ ? result.updated_at instanceof Date
61
+ ? result.updated_at
62
+ : new Date(result.updated_at)
71
63
  : new Date();
72
64
 
73
65
  return {
74
- storageHash: validatedRow.core_hash,
75
- profileHash: validatedRow.profile_hash,
76
- contractJson: validatedRow.contract_json ?? null,
77
- canonicalVersion: validatedRow.canonical_version ?? null,
66
+ storageHash: result.core_hash,
67
+ profileHash: result.profile_hash,
68
+ contractJson: result.contract_json ?? null,
69
+ canonicalVersion: result.canonical_version ?? null,
78
70
  updatedAt,
79
- appTag: validatedRow.app_tag ?? null,
80
- meta: parseMeta(validatedRow.meta),
71
+ appTag: result.app_tag ?? null,
72
+ meta: parseMeta(result.meta),
73
+ invariants: result.invariants,
81
74
  };
82
75
  }
@@ -1,5 +1,3 @@
1
- import type { ParamDescriptor, PlanMeta } from '@prisma-next/contract/types';
2
- import type { AnyQueryAst } from '@prisma-next/sql-relational-core/ast';
3
1
  import type { DraftPlan, SqlMiddleware, SqlMiddlewareContext } from './sql-middleware';
4
2
 
5
3
  export async function runBeforeCompileChain(
@@ -27,33 +25,5 @@ export async function runBeforeCompileChain(
27
25
  current = result;
28
26
  }
29
27
 
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;
28
+ return current;
59
29
  }
@@ -25,23 +25,31 @@ 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 {
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
+ // v8 ignore next 4
35
+ default:
36
+ throw new Error(
37
+ `Unsupported source kind: ${(ast.from satisfies never as { kind: string }).kind}`,
38
+ );
39
+ }
40
+ }
41
+
28
42
  function estimateRowsFromAst(
29
43
  ast: SelectAst,
30
44
  tableRows: Record<string, number>,
31
45
  defaultTableRows: number,
32
- refs: { tables?: readonly string[] } | undefined,
33
46
  hasAggregateWithoutGroup: boolean,
34
- ): number | null {
47
+ ): number {
35
48
  if (hasAggregateWithoutGroup) {
36
49
  return 1;
37
50
  }
38
51
 
39
- const table = refs?.tables?.[0];
40
- if (!table) {
41
- return null;
42
- }
43
-
44
- const tableEstimate = tableRows[table] ?? defaultTableRows;
52
+ const tableEstimate = tableRows[primaryTableFromAst(ast)] ?? defaultTableRows;
45
53
 
46
54
  if (ast.limit !== undefined) {
47
55
  return Math.min(ast.limit, tableEstimate);
@@ -50,30 +58,6 @@ function estimateRowsFromAst(
50
58
  return tableEstimate;
51
59
  }
52
60
 
53
- function estimateRowsFromHeuristics(
54
- plan: SqlExecutionPlan,
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: SqlExecutionPlan): boolean {
74
- return typeof plan.meta.annotations?.['limit'] === 'number';
75
- }
76
-
77
61
  function emitBudgetViolation(
78
62
  error: RuntimeErrorEnvelope,
79
63
  shouldBlock: boolean,
@@ -105,14 +89,9 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
105
89
  async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
106
90
  observedRowsByPlan.set(plan, { count: 0 });
107
91
 
108
- if (isQueryAst(plan.ast)) {
109
- if (plan.ast.kind === 'select') {
110
- return evaluateSelectAst(plan, plan.ast, ctx);
111
- }
112
- return;
92
+ if (isQueryAst(plan.ast) && plan.ast.kind === 'select') {
93
+ return evaluateSelectAst(plan.ast, ctx);
113
94
  }
114
-
115
- return evaluateWithHeuristics(plan, ctx);
116
95
  },
117
96
 
118
97
  async onRow(_row: Record<string, unknown>, plan: SqlExecutionPlan, _ctx: SqlMiddlewareContext) {
@@ -148,44 +127,26 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
148
127
  },
149
128
  });
150
129
 
151
- function evaluateSelectAst(plan: SqlExecutionPlan, ast: SelectAst, ctx: SqlMiddlewareContext) {
130
+ function evaluateSelectAst(ast: SelectAst, ctx: SqlMiddlewareContext) {
152
131
  const hasAggNoGroup = hasAggregateWithoutGroupBy(ast);
153
- const estimated = estimateRowsFromAst(
154
- ast,
155
- tableRows,
156
- defaultTableRows,
157
- plan.meta.refs,
158
- hasAggNoGroup,
159
- );
132
+ const estimated = estimateRowsFromAst(ast, tableRows, defaultTableRows, hasAggNoGroup);
160
133
  const isUnbounded = ast.limit === undefined && !hasAggNoGroup;
161
134
  const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
162
135
 
163
136
  if (isUnbounded) {
164
- if (estimated !== null && estimated >= maxRows) {
165
- emitBudgetViolation(
166
- runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
167
- source: 'ast',
168
- estimatedRows: estimated,
169
- maxRows,
170
- }),
171
- shouldBlock,
172
- ctx,
173
- );
174
- return;
175
- }
176
-
137
+ const details =
138
+ estimated >= maxRows
139
+ ? { source: 'ast', estimatedRows: estimated, maxRows }
140
+ : { source: 'ast', maxRows };
177
141
  emitBudgetViolation(
178
- runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
179
- source: 'ast',
180
- maxRows,
181
- }),
142
+ runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', details),
182
143
  shouldBlock,
183
144
  ctx,
184
145
  );
185
146
  return;
186
147
  }
187
148
 
188
- if (estimated !== null && estimated > maxRows) {
149
+ if (estimated > maxRows) {
189
150
  emitBudgetViolation(
190
151
  runtimeError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
191
152
  source: 'ast',
@@ -197,52 +158,4 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
197
158
  );
198
159
  }
199
160
  }
200
-
201
- async function evaluateWithHeuristics(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
202
- const estimated = estimateRowsFromHeuristics(plan, tableRows, defaultTableRows);
203
- const isUnbounded = !hasDetectableLimitFromHeuristics(plan);
204
- const sqlUpper = plan.sql.trimStart().toUpperCase();
205
- const isSelect = sqlUpper.startsWith('SELECT');
206
- const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
207
-
208
- if (isSelect && isUnbounded) {
209
- if (estimated !== null && estimated >= maxRows) {
210
- emitBudgetViolation(
211
- runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
212
- source: 'heuristic',
213
- estimatedRows: estimated,
214
- maxRows,
215
- }),
216
- shouldBlock,
217
- ctx,
218
- );
219
- return;
220
- }
221
-
222
- emitBudgetViolation(
223
- runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
224
- source: 'heuristic',
225
- maxRows,
226
- }),
227
- shouldBlock,
228
- ctx,
229
- );
230
- return;
231
- }
232
-
233
- if (estimated !== null) {
234
- if (estimated > maxRows) {
235
- emitBudgetViolation(
236
- runtimeError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
237
- source: 'heuristic',
238
- estimatedRows: estimated,
239
- maxRows,
240
- }),
241
- shouldBlock,
242
- ctx,
243
- );
244
- }
245
- return;
246
- }
247
- }
248
161
  }
@@ -94,6 +94,12 @@ function evaluateAstLints(ast: AnyQueryAst): LintFinding[] {
94
94
  case 'insert':
95
95
  break;
96
96
 
97
+ case 'raw-sql':
98
+ // Raw-SQL ASTs opt out of structural lints (LIMIT / WHERE etc.) —
99
+ // the embedded SQL fragments are caller-authored and the lint's
100
+ // shape-based heuristics don't apply.
101
+ break;
102
+
97
103
  // v8 ignore next 2
98
104
  default:
99
105
  throw new Error(`Unsupported AST kind: ${(ast satisfies never as { kind: string }).kind}`);
@@ -146,33 +152,21 @@ export function lints(options?: LintsOptions): SqlMiddleware {
146
152
  familyId: 'sql' as const,
147
153
 
148
154
  async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
155
+ const findings: LintFinding[] = [];
149
156
  if (isQueryAst(plan.ast)) {
150
- const findings = evaluateAstLints(plan.ast);
151
-
152
- for (const lint of findings) {
153
- const configuredSeverity = getConfiguredSeverity(lint.code, options);
154
- const effectiveSeverity = configuredSeverity ?? lint.severity;
155
-
156
- if (effectiveSeverity === 'error') {
157
- throw runtimeError(lint.code, lint.message, lint.details);
158
- }
159
- if (effectiveSeverity === 'warn') {
160
- ctx.log.warn({
161
- code: lint.code,
162
- message: lint.message,
163
- details: lint.details,
164
- });
165
- }
157
+ findings.push(...evaluateAstLints(plan.ast));
158
+ // Raw-SQL ASTs opt out of structural AST lints (no LIMIT /
159
+ // WHERE shape to inspect) but the embedded SQL text still
160
+ // wants the raw-heuristic guardrails. Without this the lint
161
+ // middleware would silently disable both for raw plans.
162
+ if (plan.ast.kind === 'raw-sql') {
163
+ findings.push(...evaluateRawGuardrails(plan).lints);
166
164
  }
167
- return;
168
- }
169
-
170
- if (fallback === 'skip') {
171
- return;
165
+ } else if (fallback !== 'skip') {
166
+ findings.push(...evaluateRawGuardrails(plan).lints);
172
167
  }
173
168
 
174
- const evaluation = evaluateRawGuardrails(plan);
175
- for (const lint of evaluation.lints) {
169
+ for (const lint of findings) {
176
170
  const configuredSeverity = getConfiguredSeverity(lint.code, options);
177
171
  const effectiveSeverity = configuredSeverity ?? lint.severity;
178
172
 
@@ -6,6 +6,7 @@ import type {
6
6
  } from '@prisma-next/framework-components/runtime';
7
7
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
8
8
  import type { AnyQueryAst } from '@prisma-next/sql-relational-core/ast';
9
+ import type { SqlParamRefMutator } from '@prisma-next/sql-relational-core/middleware';
9
10
  import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
10
11
 
11
12
  export interface SqlMiddlewareContext extends RuntimeMiddlewareContext {
@@ -21,7 +22,8 @@ export interface DraftPlan {
21
22
  readonly meta: PlanMeta;
22
23
  }
23
24
 
24
- export interface SqlMiddleware extends RuntimeMiddleware<SqlExecutionPlan> {
25
+ export interface SqlMiddleware<TCodecMap extends Record<string, unknown> = Record<string, unknown>>
26
+ extends RuntimeMiddleware<SqlExecutionPlan, SqlParamRefMutator<TCodecMap>> {
25
27
  readonly familyId?: 'sql';
26
28
  /**
27
29
  * Rewrite the query AST before it is lowered to SQL. Middlewares run in
@@ -42,7 +44,24 @@ export interface SqlMiddleware extends RuntimeMiddleware<SqlExecutionPlan> {
42
44
  * See `docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md`.
43
45
  */
44
46
  beforeCompile?(draft: DraftPlan, ctx: SqlMiddlewareContext): Promise<DraftPlan | undefined>;
45
- beforeExecute?(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext): Promise<void>;
47
+ /**
48
+ * Mutate `ParamRef.value` slots before encode runs. The third `params`
49
+ * argument is a {@link SqlParamRefMutator} scoped to value slots only —
50
+ * SQL strings, projections, and `ParamRef` membership are not mutable.
51
+ * Existing `(plan)` and `(plan, ctx)` middleware bodies that ignore the
52
+ * additional argument continue to compile and run unchanged.
53
+ *
54
+ * `ctx.signal` carries the per-query `AbortSignal` (ADR 207); middleware
55
+ * that wraps a network SDK forwards `ctx.signal` to that SDK.
56
+ * Cooperative cancellation: a body that ignores the signal still
57
+ * surfaces `RUNTIME.ABORTED { phase: 'beforeExecute' }` promptly via
58
+ * the runtime's race against the signal.
59
+ */
60
+ beforeExecute?(
61
+ plan: SqlExecutionPlan,
62
+ ctx: SqlMiddlewareContext,
63
+ params?: SqlParamRefMutator<TCodecMap>,
64
+ ): void | Promise<void>;
46
65
  onRow?(
47
66
  row: Record<string, unknown>,
48
67
  plan: SqlExecutionPlan,
@@ -1,16 +1,11 @@
1
1
  import type { ExecutionPlan } from '@prisma-next/framework-components/runtime';
2
- import type { MarkerStatement } from '@prisma-next/sql-relational-core/ast';
2
+ import type { MarkerReadResult, SqlQueryable } from '@prisma-next/sql-relational-core/ast';
3
3
 
4
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.
5
+ * Reader of the SQL contract marker. SQL runtimes call `readMarker` before executing user queries (when `verify` is enabled). The adapter owns the full marker-read flow — probing for storage, issuing the read, decoding the row — and returns a tagged result so callers can distinguish "marker storage missing", "no row for this space", and "present".
11
6
  */
12
7
  export interface MarkerReader {
13
- readMarkerStatement(): MarkerStatement;
8
+ readMarker(queryable: SqlQueryable): Promise<MarkerReadResult>;
14
9
  }
15
10
 
16
11
  /**