@prisma-next/sql-runtime 0.5.0-dev.3 → 0.5.0-dev.30

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.
Files changed (42) hide show
  1. package/README.md +29 -21
  2. package/dist/{exports-BQZSVXXt.mjs → exports-Cq_9ZrU4.mjs} +649 -275
  3. package/dist/exports-Cq_9ZrU4.mjs.map +1 -0
  4. package/dist/{index-yb51L_1h.d.mts → index-Df2GsLSH.d.mts} +65 -16
  5. package/dist/index-Df2GsLSH.d.mts.map +1 -0
  6. package/dist/index.d.mts +2 -2
  7. package/dist/index.mjs +2 -2
  8. package/dist/test/utils.d.mts +6 -5
  9. package/dist/test/utils.d.mts.map +1 -1
  10. package/dist/test/utils.mjs +11 -5
  11. package/dist/test/utils.mjs.map +1 -1
  12. package/package.json +10 -12
  13. package/src/codecs/decoding.ts +256 -173
  14. package/src/codecs/encoding.ts +123 -39
  15. package/src/exports/index.ts +11 -7
  16. package/src/fingerprint.ts +22 -0
  17. package/src/guardrails/raw.ts +165 -0
  18. package/src/lower-sql-plan.ts +3 -3
  19. package/src/marker.ts +75 -0
  20. package/src/middleware/before-compile-chain.ts +1 -0
  21. package/src/middleware/budgets.ts +26 -96
  22. package/src/middleware/lints.ts +3 -3
  23. package/src/middleware/sql-middleware.ts +6 -5
  24. package/src/runtime-spi.ts +44 -0
  25. package/src/sql-family-adapter.ts +3 -2
  26. package/src/sql-marker.ts +62 -47
  27. package/src/sql-runtime.ts +321 -111
  28. package/dist/exports-BQZSVXXt.mjs.map +0 -1
  29. package/dist/index-yb51L_1h.d.mts.map +0 -1
  30. package/test/async-iterable-result.test.ts +0 -141
  31. package/test/before-compile-chain.test.ts +0 -223
  32. package/test/budgets.test.ts +0 -431
  33. package/test/context.types.test-d.ts +0 -68
  34. package/test/execution-stack.test.ts +0 -161
  35. package/test/json-schema-validation.test.ts +0 -571
  36. package/test/lints.test.ts +0 -160
  37. package/test/mutation-default-generators.test.ts +0 -254
  38. package/test/parameterized-types.test.ts +0 -529
  39. package/test/sql-context.test.ts +0 -384
  40. package/test/sql-family-adapter.test.ts +0 -103
  41. package/test/sql-runtime.test.ts +0 -792
  42. package/test/utils.ts +0 -297
@@ -1,66 +1,150 @@
1
- import type { ExecutionPlan, ParamDescriptor } from '@prisma-next/contract/types';
2
- import type { Codec, CodecRegistry } from '@prisma-next/sql-relational-core/ast';
1
+ import {
2
+ checkAborted,
3
+ raceAgainstAbort,
4
+ runtimeError,
5
+ } from '@prisma-next/framework-components/runtime';
6
+ import {
7
+ type Codec,
8
+ type CodecRegistry,
9
+ collectOrderedParamRefs,
10
+ type SqlCodecCallContext,
11
+ } from '@prisma-next/sql-relational-core/ast';
12
+ import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
3
13
 
4
- function resolveParamCodec(
5
- paramDescriptor: ParamDescriptor,
6
- registry: CodecRegistry,
7
- ): Codec | null {
8
- if (paramDescriptor.codecId) {
9
- const codec = registry.get(paramDescriptor.codecId);
10
- if (codec) {
11
- return codec;
12
- }
13
- }
14
+ interface ParamMetadata {
15
+ readonly codecId: string | undefined;
16
+ readonly name: string | undefined;
17
+ }
18
+
19
+ const NO_METADATA: ParamMetadata = Object.freeze({ codecId: undefined, name: undefined });
20
+
21
+ function paramLabel(metadata: ParamMetadata, paramIndex: number): string {
22
+ return metadata.name ?? `param[${paramIndex}]`;
23
+ }
14
24
 
15
- return null;
25
+ function wrapEncodeFailure(
26
+ error: unknown,
27
+ metadata: ParamMetadata,
28
+ paramIndex: number,
29
+ codecId: string,
30
+ ): never {
31
+ const label = paramLabel(metadata, paramIndex);
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ const wrapped = runtimeError(
34
+ 'RUNTIME.ENCODE_FAILED',
35
+ `Failed to encode parameter ${label} with codec '${codecId}': ${message}`,
36
+ { label, codec: codecId, paramIndex },
37
+ );
38
+ wrapped.cause = error;
39
+ throw wrapped;
16
40
  }
17
41
 
18
- export function encodeParam(
42
+ /**
43
+ * Encodes a single parameter through its codec. Always awaits codec.encode so
44
+ * a Promise can never leak into the driver, even if a sync-authored codec is
45
+ * lifted to async by the codec() factory. Failures are wrapped in
46
+ * `RUNTIME.ENCODE_FAILED` with `{ label, codec, paramIndex }` and the original
47
+ * error attached on `cause`.
48
+ *
49
+ * `ctx` is forwarded verbatim to `codec.encode` so codec authors who opt
50
+ * into the `(value, ctx)` arity see the same `SqlCodecCallContext` the
51
+ * runtime built for the surrounding `runtime.execute()` call. The ctx is
52
+ * always present; its `signal` field may be `undefined`. Encode call
53
+ * sites do not populate `ctx.column` — encode-time column context is the
54
+ * middleware's domain.
55
+ */
56
+ export async function encodeParam(
19
57
  value: unknown,
20
- paramDescriptor: ParamDescriptor,
58
+ paramRef: { readonly codecId?: string; readonly name?: string },
21
59
  paramIndex: number,
22
60
  registry: CodecRegistry,
23
- ): unknown {
61
+ ctx: SqlCodecCallContext,
62
+ ): Promise<unknown> {
63
+ return encodeParamValue(
64
+ value,
65
+ { codecId: paramRef.codecId, name: paramRef.name },
66
+ paramIndex,
67
+ registry,
68
+ ctx,
69
+ );
70
+ }
71
+
72
+ async function encodeParamValue(
73
+ value: unknown,
74
+ metadata: ParamMetadata,
75
+ paramIndex: number,
76
+ registry: CodecRegistry,
77
+ ctx: SqlCodecCallContext,
78
+ ): Promise<unknown> {
24
79
  if (value === null || value === undefined) {
25
80
  return null;
26
81
  }
27
82
 
28
- const codec = resolveParamCodec(paramDescriptor, registry);
29
- if (!codec) {
83
+ if (!metadata.codecId) {
30
84
  return value;
31
85
  }
32
86
 
33
- if (codec.encode) {
34
- try {
35
- return codec.encode(value);
36
- } catch (error) {
37
- const label = paramDescriptor.name ?? `param[${paramIndex}]`;
38
- throw new Error(
39
- `Failed to encode parameter ${label}: ${error instanceof Error ? error.message : String(error)}`,
40
- );
41
- }
87
+ const codec: Codec | undefined = registry.get(metadata.codecId);
88
+ if (!codec) {
89
+ return value;
42
90
  }
43
91
 
44
- return value;
92
+ try {
93
+ return await codec.encode(value, ctx);
94
+ } catch (error) {
95
+ wrapEncodeFailure(error, metadata, paramIndex, codec.id);
96
+ }
45
97
  }
46
98
 
47
- export function encodeParams(plan: ExecutionPlan, registry: CodecRegistry): readonly unknown[] {
99
+ /**
100
+ * Encodes all parameters concurrently via `Promise.all`. Per parameter, sync-
101
+ * and async-authored codecs share the same path: `codec.encode → await →
102
+ * return`. Param-level failures are wrapped in `RUNTIME.ENCODE_FAILED`.
103
+ *
104
+ * When `ctx.signal` is provided:
105
+ *
106
+ * - **Already-aborted at entry** short-circuits with `RUNTIME.ABORTED`
107
+ * (`{ phase: 'encode' }`) before any `codec.encode` call is made — codecs
108
+ * can pin this with a per-call counter that stays at zero.
109
+ * - **Mid-flight abort** races the per-param `Promise.all` against
110
+ * `abortable(ctx.signal)`. The runtime returns `RUNTIME.ABORTED` promptly
111
+ * even if codec bodies ignore the signal; the in-flight bodies are
112
+ * abandoned and run to completion in the background (cooperative
113
+ * cancellation, see ADR 204).
114
+ * - Existing `RUNTIME.ENCODE_FAILED` envelopes that surface from a codec
115
+ * body before the runtime observes the abort pass through unchanged
116
+ * (no double wrap).
117
+ */
118
+ export async function encodeParams(
119
+ plan: SqlExecutionPlan,
120
+ registry: CodecRegistry,
121
+ ctx: SqlCodecCallContext,
122
+ ): Promise<readonly unknown[]> {
123
+ checkAborted(ctx, 'encode');
124
+ const signal = ctx.signal;
125
+
48
126
  if (plan.params.length === 0) {
49
127
  return plan.params;
50
128
  }
51
129
 
52
- const encoded: unknown[] = [];
53
-
54
- for (let i = 0; i < plan.params.length; i++) {
55
- const paramValue = plan.params[i];
56
- const paramDescriptor = plan.meta.paramDescriptors[i];
130
+ const paramCount = plan.params.length;
131
+ const metadata: ParamMetadata[] = new Array(paramCount).fill(NO_METADATA);
57
132
 
58
- if (paramDescriptor) {
59
- encoded.push(encodeParam(paramValue, paramDescriptor, i, registry));
60
- } else {
61
- encoded.push(paramValue);
133
+ if (plan.ast) {
134
+ const refs = collectOrderedParamRefs(plan.ast);
135
+ for (let i = 0; i < paramCount && i < refs.length; i++) {
136
+ const ref = refs[i];
137
+ if (ref) {
138
+ metadata[i] = { codecId: ref.codecId, name: ref.name };
139
+ }
62
140
  }
63
141
  }
64
142
 
65
- return Object.freeze(encoded);
143
+ const tasks: Promise<unknown>[] = new Array(paramCount);
144
+ for (let i = 0; i < paramCount; i++) {
145
+ tasks[i] = encodeParamValue(plan.params[i], metadata[i] ?? NO_METADATA, i, registry, ctx);
146
+ }
147
+
148
+ const settled = await raceAgainstAbort(Promise.all(tasks), signal, 'encode');
149
+ return Object.freeze(settled);
66
150
  }
@@ -1,20 +1,27 @@
1
1
  export type {
2
2
  AfterExecuteResult,
3
- Log,
4
- Middleware,
5
- MiddlewareContext,
6
- } from '@prisma-next/runtime-executor';
3
+ RuntimeLog as Log,
4
+ } from '@prisma-next/framework-components/runtime';
5
+ export type { MarkerStatement } from '@prisma-next/sql-relational-core/ast';
7
6
  export {
8
7
  extractCodecIds,
9
8
  validateCodecRegistryCompleteness,
10
9
  validateContractCodecMappings,
11
10
  } from '../codecs/validation';
12
11
  export { lowerSqlPlan } from '../lower-sql-plan';
12
+ export { parseContractMarkerRow } from '../marker';
13
13
  export type { BudgetsOptions } from '../middleware/budgets';
14
14
  export { budgets } from '../middleware/budgets';
15
15
  export type { LintsOptions } from '../middleware/lints';
16
16
  export { lints } from '../middleware/lints';
17
17
  export type { SqlMiddleware, SqlMiddlewareContext } from '../middleware/sql-middleware';
18
+ export type {
19
+ MarkerReader,
20
+ RuntimeFamilyAdapter,
21
+ RuntimeTelemetryEvent,
22
+ RuntimeVerifyOptions,
23
+ TelemetryOutcome,
24
+ } from '../runtime-spi';
18
25
  export type {
19
26
  ExecutionContext,
20
27
  RuntimeMutationDefaultGenerator,
@@ -46,10 +53,7 @@ export type {
46
53
  Runtime,
47
54
  RuntimeConnection,
48
55
  RuntimeQueryable,
49
- RuntimeTelemetryEvent,
50
56
  RuntimeTransaction,
51
- RuntimeVerifyOptions,
52
- TelemetryOutcome,
53
57
  TransactionContext,
54
58
  } from '../sql-runtime';
55
59
  export { createRuntime, withTransaction } from '../sql-runtime';
@@ -0,0 +1,22 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const STRING_LITERAL_REGEX = /'(?:''|[^'])*'/g;
4
+ const NUMERIC_LITERAL_REGEX = /\b\d+(?:\.\d+)?\b/g;
5
+ const WHITESPACE_REGEX = /\s+/g;
6
+
7
+ /**
8
+ * Computes a literal-stripped, normalized fingerprint of a SQL statement.
9
+ *
10
+ * The function strips string and numeric literals, collapses whitespace, and
11
+ * lowercases the result before hashing — so two structurally equivalent
12
+ * statements (with different parameter values) produce the same fingerprint.
13
+ * Used by SQL telemetry to group queries.
14
+ */
15
+ export function computeSqlFingerprint(sql: string): string {
16
+ const withoutStrings = sql.replace(STRING_LITERAL_REGEX, '?');
17
+ const withoutNumbers = withoutStrings.replace(NUMERIC_LITERAL_REGEX, '?');
18
+ const normalized = withoutNumbers.replace(WHITESPACE_REGEX, ' ').trim().toLowerCase();
19
+
20
+ const hash = createHash('sha256').update(normalized).digest('hex');
21
+ return `sha256:${hash}`;
22
+ }
@@ -0,0 +1,165 @@
1
+ import type { PlanMeta } 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
+ return { lints, budgets, statement: statementType };
107
+ }
108
+
109
+ function classifyStatement(sql: string): 'select' | 'mutation' | 'other' {
110
+ const trimmed = sql.trim();
111
+ const lower = trimmed.toLowerCase();
112
+
113
+ if (lower.startsWith('with')) {
114
+ if (lower.includes('select')) {
115
+ return 'select';
116
+ }
117
+ }
118
+
119
+ if (lower.startsWith('select')) {
120
+ return 'select';
121
+ }
122
+
123
+ if (MUTATION_PREFIX_REGEX.test(trimmed)) {
124
+ return 'mutation';
125
+ }
126
+
127
+ return 'other';
128
+ }
129
+
130
+ function isMutationStatement(statement: 'select' | 'mutation' | 'other'): boolean {
131
+ return statement === 'mutation';
132
+ }
133
+
134
+ function isReadOnlyIntent(meta: PlanMeta): boolean {
135
+ const annotations = meta.annotations as { intent?: string } | undefined;
136
+ const intent =
137
+ typeof annotations?.intent === 'string' ? annotations.intent.toLowerCase() : undefined;
138
+ return intent !== undefined && READ_ONLY_INTENTS.has(intent);
139
+ }
140
+
141
+ function normalizeWhitespace(value: string): string {
142
+ return value.replace(/\s+/g, ' ').trim();
143
+ }
144
+
145
+ function snippet(sql: string): string {
146
+ return normalizeWhitespace(sql).slice(0, 200);
147
+ }
148
+
149
+ function createLint(
150
+ code: LintFinding['code'],
151
+ severity: LintFinding['severity'],
152
+ message: string,
153
+ details?: Record<string, unknown>,
154
+ ): LintFinding {
155
+ return { code, severity, message, ...(details ? { details } : {}) };
156
+ }
157
+
158
+ function createBudget(
159
+ code: BudgetFinding['code'],
160
+ severity: BudgetFinding['severity'],
161
+ message: string,
162
+ details?: Record<string, unknown>,
163
+ ): BudgetFinding {
164
+ return { code, severity, message, ...(details ? { details } : {}) };
165
+ }
@@ -1,7 +1,7 @@
1
- import type { Contract, ExecutionPlan } from '@prisma-next/contract/types';
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
- ): ExecutionPlan<Row> {
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,75 @@
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
+ invariants: readonly string[];
13
+ }
14
+
15
+ const MetaSchema = type({ '[string]': 'unknown' });
16
+
17
+ function parseMeta(meta: unknown): Record<string, unknown> {
18
+ if (meta === null || meta === undefined) {
19
+ return {};
20
+ }
21
+
22
+ let parsed: unknown;
23
+ if (typeof meta === 'string') {
24
+ try {
25
+ parsed = JSON.parse(meta);
26
+ } catch {
27
+ return {};
28
+ }
29
+ } else {
30
+ parsed = meta;
31
+ }
32
+
33
+ const result = MetaSchema(parsed);
34
+ if (result instanceof type.errors) {
35
+ return {};
36
+ }
37
+
38
+ return result as Record<string, unknown>;
39
+ }
40
+
41
+ const ContractMarkerRowSchema = type({
42
+ core_hash: 'string',
43
+ profile_hash: 'string',
44
+ 'contract_json?': 'unknown | null',
45
+ 'canonical_version?': 'number | null',
46
+ 'updated_at?': 'Date | string',
47
+ 'app_tag?': 'string | null',
48
+ 'meta?': 'unknown | null',
49
+ invariants: type('string').array(),
50
+ });
51
+
52
+ export function parseContractMarkerRow(row: unknown): ContractMarkerRecord {
53
+ const result = ContractMarkerRowSchema(row);
54
+ if (result instanceof type.errors) {
55
+ const messages = result.map((p: { message: string }) => p.message).join('; ');
56
+ throw new Error(`Invalid contract marker row: ${messages}`);
57
+ }
58
+
59
+ const updatedAt = result.updated_at
60
+ ? result.updated_at instanceof Date
61
+ ? result.updated_at
62
+ : new Date(result.updated_at)
63
+ : new Date();
64
+
65
+ return {
66
+ storageHash: result.core_hash,
67
+ profileHash: result.profile_hash,
68
+ contractJson: result.contract_json ?? null,
69
+ canonicalVersion: result.canonical_version ?? null,
70
+ updatedAt,
71
+ appTag: result.app_tag ?? null,
72
+ meta: parseMeta(result.meta),
73
+ invariants: result.invariants,
74
+ };
75
+ }
@@ -24,5 +24,6 @@ export async function runBeforeCompileChain(
24
24
  });
25
25
  current = result;
26
26
  }
27
+
27
28
  return current;
28
29
  }