@prisma-next/sql-runtime 0.5.0-dev.7 → 0.5.0-dev.70
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-CXtbKm5q.mjs +1516 -0
- package/dist/exports-CXtbKm5q.mjs.map +1 -0
- package/dist/{index-yb51L_1h.d.mts → index-C4Dz0JKE.d.mts} +116 -45
- package/dist/index-C4Dz0JKE.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 -56
- package/dist/test/utils.mjs.map +1 -1
- package/package.json +18 -19
- package/src/codecs/alias-resolver.ts +34 -0
- package/src/codecs/decoding.ts +263 -176
- package/src/codecs/encoding.ts +151 -38
- 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 -0
- package/src/middleware/budgets.ts +36 -120
- package/src/middleware/lints.ts +3 -3
- package/src/middleware/sql-middleware.ts +6 -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 +305 -144
- package/dist/exports-BQZSVXXt.mjs +0 -981
- package/dist/exports-BQZSVXXt.mjs.map +0 -1
- package/dist/index-yb51L_1h.d.mts.map +0 -1
- package/src/codecs/json-schema-validation.ts +0 -61
- package/test/async-iterable-result.test.ts +0 -141
- package/test/before-compile-chain.test.ts +0 -223
- 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 -160
- 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 -792
- package/test/utils.ts +0 -297
package/src/codecs/encoding.ts
CHANGED
|
@@ -1,66 +1,179 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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';
|
|
3
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
|
+
*/
|
|
4
40
|
function resolveParamCodec(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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;
|
|
13
54
|
}
|
|
55
|
+
return contractCodecs?.forCodecId(metadata.codecId);
|
|
56
|
+
}
|
|
14
57
|
|
|
15
|
-
|
|
58
|
+
function paramLabel(metadata: ParamMetadata, paramIndex: number): string {
|
|
59
|
+
return metadata.name ?? `param[${paramIndex}]`;
|
|
16
60
|
}
|
|
17
61
|
|
|
18
|
-
|
|
62
|
+
function wrapEncodeFailure(
|
|
63
|
+
error: unknown,
|
|
64
|
+
metadata: ParamMetadata,
|
|
65
|
+
paramIndex: number,
|
|
66
|
+
codecId: string,
|
|
67
|
+
): never {
|
|
68
|
+
const label = paramLabel(metadata, paramIndex);
|
|
69
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
70
|
+
const wrapped = runtimeError(
|
|
71
|
+
'RUNTIME.ENCODE_FAILED',
|
|
72
|
+
`Failed to encode parameter ${label} with codec '${codecId}': ${message}`,
|
|
73
|
+
{ label, codec: codecId, paramIndex },
|
|
74
|
+
);
|
|
75
|
+
wrapped.cause = error;
|
|
76
|
+
throw wrapped;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
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.
|
|
83
|
+
*/
|
|
84
|
+
export async function encodeParam(
|
|
19
85
|
value: unknown,
|
|
20
|
-
|
|
86
|
+
paramRef: {
|
|
87
|
+
readonly codecId?: string;
|
|
88
|
+
readonly name?: string;
|
|
89
|
+
readonly refs?: ParamRefBindingRefs;
|
|
90
|
+
},
|
|
21
91
|
paramIndex: number,
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
108
|
+
paramIndex: number,
|
|
109
|
+
ctx: SqlCodecCallContext,
|
|
110
|
+
contractCodecs: ContractCodecRegistry | undefined,
|
|
111
|
+
aliasResolver: (alias: string) => string,
|
|
112
|
+
): Promise<unknown> {
|
|
24
113
|
if (value === null || value === undefined) {
|
|
25
114
|
return null;
|
|
26
115
|
}
|
|
27
116
|
|
|
28
|
-
const codec = resolveParamCodec(
|
|
117
|
+
const codec = resolveParamCodec(metadata, contractCodecs, aliasResolver);
|
|
29
118
|
if (!codec) {
|
|
30
119
|
return value;
|
|
31
120
|
}
|
|
32
121
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
}
|
|
122
|
+
try {
|
|
123
|
+
return await codec.encode(value, ctx);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
wrapEncodeFailure(error, metadata, paramIndex, codec.id);
|
|
42
126
|
}
|
|
43
|
-
|
|
44
|
-
return value;
|
|
45
127
|
}
|
|
46
128
|
|
|
47
|
-
|
|
129
|
+
/**
|
|
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).
|
|
137
|
+
*/
|
|
138
|
+
export async function encodeParams(
|
|
139
|
+
plan: SqlExecutionPlan,
|
|
140
|
+
ctx: SqlCodecCallContext,
|
|
141
|
+
contractCodecs?: ContractCodecRegistry,
|
|
142
|
+
): Promise<readonly unknown[]> {
|
|
143
|
+
checkAborted(ctx, 'encode');
|
|
144
|
+
const signal = ctx.signal;
|
|
145
|
+
|
|
48
146
|
if (plan.params.length === 0) {
|
|
49
147
|
return plan.params;
|
|
50
148
|
}
|
|
51
149
|
|
|
52
|
-
const
|
|
150
|
+
const paramCount = plan.params.length;
|
|
151
|
+
const metadata: ParamMetadata[] = new Array(paramCount).fill(NO_METADATA);
|
|
53
152
|
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
encoded.push(paramValue);
|
|
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
|
+
}
|
|
62
160
|
}
|
|
63
161
|
}
|
|
64
162
|
|
|
65
|
-
|
|
163
|
+
const aliasResolver = makeAliasResolver(plan.ast);
|
|
164
|
+
|
|
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
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const settled = await raceAgainstAbort(Promise.all(tasks), signal, 'encode');
|
|
178
|
+
return Object.freeze(settled);
|
|
66
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);
|
|
@@ -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
|
+
}
|
package/src/exports/index.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
export type {
|
|
2
2
|
AfterExecuteResult,
|
|
3
|
-
Log,
|
|
4
|
-
|
|
5
|
-
|
|
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,
|
|
27
|
+
GeneratorStability,
|
|
20
28
|
RuntimeMutationDefaultGenerator,
|
|
21
29
|
RuntimeParameterizedCodecDescriptor,
|
|
22
30
|
SqlExecutionStack,
|
|
@@ -36,6 +44,7 @@ export {
|
|
|
36
44
|
} from '../sql-context';
|
|
37
45
|
export type { SqlStatement } from '../sql-marker';
|
|
38
46
|
export {
|
|
47
|
+
APP_SPACE_ID,
|
|
39
48
|
ensureSchemaStatement,
|
|
40
49
|
ensureTableStatement,
|
|
41
50
|
readContractMarker,
|
|
@@ -46,10 +55,7 @@ export type {
|
|
|
46
55
|
Runtime,
|
|
47
56
|
RuntimeConnection,
|
|
48
57
|
RuntimeQueryable,
|
|
49
|
-
RuntimeTelemetryEvent,
|
|
50
58
|
RuntimeTransaction,
|
|
51
|
-
RuntimeVerifyOptions,
|
|
52
|
-
TelemetryOutcome,
|
|
53
59
|
TransactionContext,
|
|
54
60
|
} from '../sql-runtime';
|
|
55
61
|
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
|
+
}
|
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,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
|
+
}
|