@prisma-next/sql-runtime 0.5.0-dev.8 → 0.5.0-dev.81
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-C0exnDCV.mjs +1514 -0
- package/dist/exports-C0exnDCV.mjs.map +1 -0
- package/dist/{index-yb51L_1h.d.mts → index-CFbuVnYJ.d.mts} +130 -45
- package/dist/index-CFbuVnYJ.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 -61
- package/dist/test/utils.mjs.map +1 -1
- package/package.json +17 -17
- package/src/codecs/alias-resolver.ts +37 -0
- package/src/codecs/decoding.ts +168 -130
- package/src/codecs/encoding.ts +123 -47
- 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 -31
- package/src/middleware/budgets.ts +36 -120
- package/src/middleware/lints.ts +20 -26
- package/src/middleware/sql-middleware.ts +25 -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 +325 -146
- package/dist/exports-Cssiepsb.mjs +0 -1068
- package/dist/exports-Cssiepsb.mjs.map +0 -1
- package/dist/index-yb51L_1h.d.mts.map +0 -1
- package/src/codecs/json-schema-validation.ts +0 -61
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
type AfterExecuteResult,
|
|
3
|
+
type RuntimeErrorEnvelope,
|
|
4
|
+
runtimeError,
|
|
5
|
+
} from '@prisma-next/framework-components/runtime';
|
|
4
6
|
import { isQueryAst, type SelectAst } from '@prisma-next/sql-relational-core/ast';
|
|
7
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
5
8
|
import type { SqlMiddleware, SqlMiddlewareContext } from './sql-middleware';
|
|
6
9
|
|
|
7
10
|
export interface BudgetsOptions {
|
|
@@ -22,23 +25,31 @@ function hasAggregateWithoutGroupBy(ast: SelectAst): boolean {
|
|
|
22
25
|
return ast.projection.some((item) => item.expr.kind === 'aggregate');
|
|
23
26
|
}
|
|
24
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
|
+
|
|
25
42
|
function estimateRowsFromAst(
|
|
26
43
|
ast: SelectAst,
|
|
27
44
|
tableRows: Record<string, number>,
|
|
28
45
|
defaultTableRows: number,
|
|
29
|
-
refs: { tables?: readonly string[] } | undefined,
|
|
30
46
|
hasAggregateWithoutGroup: boolean,
|
|
31
|
-
): number
|
|
47
|
+
): number {
|
|
32
48
|
if (hasAggregateWithoutGroup) {
|
|
33
49
|
return 1;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
const
|
|
37
|
-
if (!table) {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const tableEstimate = tableRows[table] ?? defaultTableRows;
|
|
52
|
+
const tableEstimate = tableRows[primaryTableFromAst(ast)] ?? defaultTableRows;
|
|
42
53
|
|
|
43
54
|
if (ast.limit !== undefined) {
|
|
44
55
|
return Math.min(ast.limit, tableEstimate);
|
|
@@ -47,30 +58,6 @@ function estimateRowsFromAst(
|
|
|
47
58
|
return tableEstimate;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
|
-
function estimateRowsFromHeuristics(
|
|
51
|
-
plan: ExecutionPlan,
|
|
52
|
-
tableRows: Record<string, number>,
|
|
53
|
-
defaultTableRows: number,
|
|
54
|
-
): number | null {
|
|
55
|
-
const table = plan.meta.refs?.tables?.[0];
|
|
56
|
-
if (!table) {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const tableEstimate = tableRows[table] ?? defaultTableRows;
|
|
61
|
-
|
|
62
|
-
const limit = plan.meta.annotations?.['limit'];
|
|
63
|
-
if (typeof limit === 'number') {
|
|
64
|
-
return Math.min(limit, tableEstimate);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return tableEstimate;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function hasDetectableLimitFromHeuristics(plan: ExecutionPlan): boolean {
|
|
71
|
-
return typeof plan.meta.annotations?.['limit'] === 'number';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
61
|
function emitBudgetViolation(
|
|
75
62
|
error: RuntimeErrorEnvelope,
|
|
76
63
|
shouldBlock: boolean,
|
|
@@ -93,26 +80,21 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
93
80
|
const maxLatencyMs = options?.maxLatencyMs ?? 1_000;
|
|
94
81
|
const rowSeverity = options?.severities?.rowCount ?? 'error';
|
|
95
82
|
|
|
96
|
-
const observedRowsByPlan = new WeakMap<
|
|
83
|
+
const observedRowsByPlan = new WeakMap<SqlExecutionPlan, { count: number }>();
|
|
97
84
|
|
|
98
85
|
return Object.freeze({
|
|
99
86
|
name: 'budgets',
|
|
100
87
|
familyId: 'sql' as const,
|
|
101
88
|
|
|
102
|
-
async beforeExecute(plan:
|
|
89
|
+
async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
103
90
|
observedRowsByPlan.set(plan, { count: 0 });
|
|
104
91
|
|
|
105
|
-
if (isQueryAst(plan.ast)) {
|
|
106
|
-
|
|
107
|
-
return evaluateSelectAst(plan, plan.ast, ctx);
|
|
108
|
-
}
|
|
109
|
-
return;
|
|
92
|
+
if (isQueryAst(plan.ast) && plan.ast.kind === 'select') {
|
|
93
|
+
return evaluateSelectAst(plan.ast, ctx);
|
|
110
94
|
}
|
|
111
|
-
|
|
112
|
-
return evaluateWithHeuristics(plan, ctx);
|
|
113
95
|
},
|
|
114
96
|
|
|
115
|
-
async onRow(_row: Record<string, unknown>, plan:
|
|
97
|
+
async onRow(_row: Record<string, unknown>, plan: SqlExecutionPlan, _ctx: SqlMiddlewareContext) {
|
|
116
98
|
const state = observedRowsByPlan.get(plan);
|
|
117
99
|
if (!state) return;
|
|
118
100
|
state.count += 1;
|
|
@@ -126,7 +108,7 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
126
108
|
},
|
|
127
109
|
|
|
128
110
|
async afterExecute(
|
|
129
|
-
_plan:
|
|
111
|
+
_plan: SqlExecutionPlan,
|
|
130
112
|
result: AfterExecuteResult,
|
|
131
113
|
ctx: SqlMiddlewareContext,
|
|
132
114
|
) {
|
|
@@ -145,44 +127,26 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
145
127
|
},
|
|
146
128
|
});
|
|
147
129
|
|
|
148
|
-
function evaluateSelectAst(
|
|
130
|
+
function evaluateSelectAst(ast: SelectAst, ctx: SqlMiddlewareContext) {
|
|
149
131
|
const hasAggNoGroup = hasAggregateWithoutGroupBy(ast);
|
|
150
|
-
const estimated = estimateRowsFromAst(
|
|
151
|
-
ast,
|
|
152
|
-
tableRows,
|
|
153
|
-
defaultTableRows,
|
|
154
|
-
plan.meta.refs,
|
|
155
|
-
hasAggNoGroup,
|
|
156
|
-
);
|
|
132
|
+
const estimated = estimateRowsFromAst(ast, tableRows, defaultTableRows, hasAggNoGroup);
|
|
157
133
|
const isUnbounded = ast.limit === undefined && !hasAggNoGroup;
|
|
158
134
|
const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
|
|
159
135
|
|
|
160
136
|
if (isUnbounded) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
estimatedRows: estimated,
|
|
166
|
-
maxRows,
|
|
167
|
-
}),
|
|
168
|
-
shouldBlock,
|
|
169
|
-
ctx,
|
|
170
|
-
);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
137
|
+
const details =
|
|
138
|
+
estimated >= maxRows
|
|
139
|
+
? { source: 'ast', estimatedRows: estimated, maxRows }
|
|
140
|
+
: { source: 'ast', maxRows };
|
|
174
141
|
emitBudgetViolation(
|
|
175
|
-
runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget',
|
|
176
|
-
source: 'ast',
|
|
177
|
-
maxRows,
|
|
178
|
-
}),
|
|
142
|
+
runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', details),
|
|
179
143
|
shouldBlock,
|
|
180
144
|
ctx,
|
|
181
145
|
);
|
|
182
146
|
return;
|
|
183
147
|
}
|
|
184
148
|
|
|
185
|
-
if (estimated
|
|
149
|
+
if (estimated > maxRows) {
|
|
186
150
|
emitBudgetViolation(
|
|
187
151
|
runtimeError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
|
|
188
152
|
source: 'ast',
|
|
@@ -194,52 +158,4 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
194
158
|
);
|
|
195
159
|
}
|
|
196
160
|
}
|
|
197
|
-
|
|
198
|
-
async function evaluateWithHeuristics(plan: ExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
199
|
-
const estimated = estimateRowsFromHeuristics(plan, tableRows, defaultTableRows);
|
|
200
|
-
const isUnbounded = !hasDetectableLimitFromHeuristics(plan);
|
|
201
|
-
const sqlUpper = plan.sql.trimStart().toUpperCase();
|
|
202
|
-
const isSelect = sqlUpper.startsWith('SELECT');
|
|
203
|
-
const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
|
|
204
|
-
|
|
205
|
-
if (isSelect && isUnbounded) {
|
|
206
|
-
if (estimated !== null && estimated >= maxRows) {
|
|
207
|
-
emitBudgetViolation(
|
|
208
|
-
runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
|
|
209
|
-
source: 'heuristic',
|
|
210
|
-
estimatedRows: estimated,
|
|
211
|
-
maxRows,
|
|
212
|
-
}),
|
|
213
|
-
shouldBlock,
|
|
214
|
-
ctx,
|
|
215
|
-
);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
emitBudgetViolation(
|
|
220
|
-
runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
|
|
221
|
-
source: 'heuristic',
|
|
222
|
-
maxRows,
|
|
223
|
-
}),
|
|
224
|
-
shouldBlock,
|
|
225
|
-
ctx,
|
|
226
|
-
);
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (estimated !== null) {
|
|
231
|
-
if (estimated > maxRows) {
|
|
232
|
-
emitBudgetViolation(
|
|
233
|
-
runtimeError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
|
|
234
|
-
source: 'heuristic',
|
|
235
|
-
estimatedRows: estimated,
|
|
236
|
-
maxRows,
|
|
237
|
-
}),
|
|
238
|
-
shouldBlock,
|
|
239
|
-
ctx,
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
161
|
}
|