@prisma-next/sql-runtime 0.5.0-dev.8 → 0.5.0-dev.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -21
- package/dist/{exports-Cssiepsb.mjs → exports-BOHa3Emo.mjs} +326 -60
- package/dist/exports-BOHa3Emo.mjs.map +1 -0
- package/dist/{index-yb51L_1h.d.mts → index-CZmC2kD3.d.mts} +53 -16
- package/dist/index-CZmC2kD3.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/test/utils.d.mts +6 -5
- package/dist/test/utils.d.mts.map +1 -1
- package/dist/test/utils.mjs +1 -1
- package/dist/test/utils.mjs.map +1 -1
- package/package.json +9 -10
- package/src/codecs/decoding.ts +11 -7
- package/src/codecs/encoding.ts +3 -2
- package/src/exports/index.ts +10 -7
- package/src/fingerprint.ts +22 -0
- package/src/guardrails/raw.ts +214 -0
- package/src/lower-sql-plan.ts +3 -3
- package/src/marker.ts +82 -0
- package/src/middleware/budgets.ts +14 -11
- package/src/middleware/lints.ts +3 -3
- package/src/middleware/sql-middleware.ts +6 -5
- package/src/runtime-spi.ts +43 -0
- package/src/sql-family-adapter.ts +3 -2
- package/src/sql-marker.ts +1 -1
- package/src/sql-runtime.ts +272 -112
- package/dist/exports-Cssiepsb.mjs.map +0 -1
- package/dist/index-yb51L_1h.d.mts.map +0 -1
package/src/codecs/decoding.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ExecutionPlan } from '@prisma-next/contract/types';
|
|
2
1
|
import { isRuntimeError, runtimeError } from '@prisma-next/framework-components/runtime';
|
|
3
2
|
import type { Codec, CodecRegistry } from '@prisma-next/sql-relational-core/ast';
|
|
3
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
4
4
|
import type { JsonSchemaValidatorRegistry } from '@prisma-next/sql-relational-core/query-lane-context';
|
|
5
5
|
import { validateJsonValue } from './json-schema-validation';
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ const WIRE_PREVIEW_LIMIT = 100;
|
|
|
11
11
|
|
|
12
12
|
function resolveRowCodec(
|
|
13
13
|
alias: string,
|
|
14
|
-
plan:
|
|
14
|
+
plan: SqlExecutionPlan,
|
|
15
15
|
registry: CodecRegistry,
|
|
16
16
|
): Codec | null {
|
|
17
17
|
const planCodecId = plan.meta.annotations?.codecs?.[alias] as string | undefined;
|
|
@@ -35,7 +35,11 @@ function resolveRowCodec(
|
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Builds a lookup index from column name → { table, column } ref.
|
|
40
|
+
* Called once per decodeRow invocation to avoid O(aliases × refs) linear scans.
|
|
41
|
+
*/
|
|
42
|
+
function buildColumnRefIndex(plan: SqlExecutionPlan): ColumnRefIndex | null {
|
|
39
43
|
const columns = plan.meta.refs?.columns;
|
|
40
44
|
if (!columns) return null;
|
|
41
45
|
|
|
@@ -64,7 +68,7 @@ function parseProjectionRef(value: string): ColumnRef | null {
|
|
|
64
68
|
|
|
65
69
|
function resolveColumnRefForAlias(
|
|
66
70
|
alias: string,
|
|
67
|
-
projection:
|
|
71
|
+
projection: SqlExecutionPlan['meta']['projection'],
|
|
68
72
|
fallbackColumnRefIndex: ColumnRefIndex | null,
|
|
69
73
|
): ColumnRef | undefined {
|
|
70
74
|
if (projection && !Array.isArray(projection)) {
|
|
@@ -160,10 +164,10 @@ function decodeIncludeAggregate(alias: string, wireValue: unknown): unknown {
|
|
|
160
164
|
async function decodeField(
|
|
161
165
|
alias: string,
|
|
162
166
|
wireValue: unknown,
|
|
163
|
-
plan:
|
|
167
|
+
plan: SqlExecutionPlan,
|
|
164
168
|
registry: CodecRegistry,
|
|
165
169
|
jsonValidators: JsonSchemaValidatorRegistry | undefined,
|
|
166
|
-
projection:
|
|
170
|
+
projection: SqlExecutionPlan['meta']['projection'],
|
|
167
171
|
fallbackColumnRefIndex: ColumnRefIndex | null,
|
|
168
172
|
): Promise<unknown> {
|
|
169
173
|
if (wireValue === null || wireValue === undefined) {
|
|
@@ -205,7 +209,7 @@ async function decodeField(
|
|
|
205
209
|
*/
|
|
206
210
|
export async function decodeRow(
|
|
207
211
|
row: Record<string, unknown>,
|
|
208
|
-
plan:
|
|
212
|
+
plan: SqlExecutionPlan,
|
|
209
213
|
registry: CodecRegistry,
|
|
210
214
|
jsonValidators?: JsonSchemaValidatorRegistry,
|
|
211
215
|
): Promise<Record<string, unknown>> {
|
package/src/codecs/encoding.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ParamDescriptor } from '@prisma-next/contract/types';
|
|
2
2
|
import { runtimeError } from '@prisma-next/framework-components/runtime';
|
|
3
3
|
import type { Codec, CodecRegistry } from '@prisma-next/sql-relational-core/ast';
|
|
4
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
4
5
|
|
|
5
6
|
function resolveParamCodec(
|
|
6
7
|
paramDescriptor: ParamDescriptor,
|
|
@@ -72,7 +73,7 @@ export async function encodeParam(
|
|
|
72
73
|
* return`. Param-level failures are wrapped in `RUNTIME.ENCODE_FAILED`.
|
|
73
74
|
*/
|
|
74
75
|
export async function encodeParams(
|
|
75
|
-
plan:
|
|
76
|
+
plan: SqlExecutionPlan,
|
|
76
77
|
registry: CodecRegistry,
|
|
77
78
|
): Promise<readonly unknown[]> {
|
|
78
79
|
if (plan.params.length === 0) {
|
package/src/exports/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
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,
|
|
@@ -15,6 +14,13 @@ export { budgets } from '../middleware/budgets';
|
|
|
15
14
|
export type { LintsOptions } from '../middleware/lints';
|
|
16
15
|
export { lints } from '../middleware/lints';
|
|
17
16
|
export type { SqlMiddleware, SqlMiddlewareContext } from '../middleware/sql-middleware';
|
|
17
|
+
export type {
|
|
18
|
+
MarkerReader,
|
|
19
|
+
RuntimeFamilyAdapter,
|
|
20
|
+
RuntimeTelemetryEvent,
|
|
21
|
+
RuntimeVerifyOptions,
|
|
22
|
+
TelemetryOutcome,
|
|
23
|
+
} from '../runtime-spi';
|
|
18
24
|
export type {
|
|
19
25
|
ExecutionContext,
|
|
20
26
|
RuntimeMutationDefaultGenerator,
|
|
@@ -46,10 +52,7 @@ export type {
|
|
|
46
52
|
Runtime,
|
|
47
53
|
RuntimeConnection,
|
|
48
54
|
RuntimeQueryable,
|
|
49
|
-
RuntimeTelemetryEvent,
|
|
50
55
|
RuntimeTransaction,
|
|
51
|
-
RuntimeVerifyOptions,
|
|
52
|
-
TelemetryOutcome,
|
|
53
56
|
TransactionContext,
|
|
54
57
|
} from '../sql-runtime';
|
|
55
58
|
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,214 @@
|
|
|
1
|
+
import type { PlanMeta, PlanRefs } from '@prisma-next/contract/types';
|
|
2
|
+
|
|
3
|
+
export type LintSeverity = 'error' | 'warn';
|
|
4
|
+
export type BudgetSeverity = 'error' | 'warn';
|
|
5
|
+
|
|
6
|
+
export interface LintFinding {
|
|
7
|
+
readonly code: `LINT.${string}`;
|
|
8
|
+
readonly severity: LintSeverity;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly details?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BudgetFinding {
|
|
14
|
+
readonly code: `BUDGET.${string}`;
|
|
15
|
+
readonly severity: BudgetSeverity;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
readonly details?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RawGuardrailConfig {
|
|
21
|
+
readonly budgets?: {
|
|
22
|
+
readonly unboundedSelectSeverity?: BudgetSeverity;
|
|
23
|
+
readonly estimatedRows?: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RawGuardrailResult {
|
|
28
|
+
readonly lints: LintFinding[];
|
|
29
|
+
readonly budgets: BudgetFinding[];
|
|
30
|
+
readonly statement: 'select' | 'mutation' | 'other';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Minimal plan view consumed by raw-SQL guardrails. Structurally satisfied
|
|
35
|
+
* by `SqlExecutionPlan`; declared inline so this module stays decoupled
|
|
36
|
+
* from a specific plan type at the call site.
|
|
37
|
+
*/
|
|
38
|
+
interface RawGuardrailPlan {
|
|
39
|
+
readonly sql: string;
|
|
40
|
+
readonly meta: PlanMeta;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SELECT_STAR_REGEX = /select\s+\*/i;
|
|
44
|
+
const LIMIT_REGEX = /\blimit\b/i;
|
|
45
|
+
const MUTATION_PREFIX_REGEX = /^(insert|update|delete|create|alter|drop|truncate)\b/i;
|
|
46
|
+
|
|
47
|
+
const READ_ONLY_INTENTS = new Set(['read', 'report', 'readonly']);
|
|
48
|
+
|
|
49
|
+
export function evaluateRawGuardrails(
|
|
50
|
+
plan: RawGuardrailPlan,
|
|
51
|
+
config?: RawGuardrailConfig,
|
|
52
|
+
): RawGuardrailResult {
|
|
53
|
+
const lints: LintFinding[] = [];
|
|
54
|
+
const budgets: BudgetFinding[] = [];
|
|
55
|
+
|
|
56
|
+
const normalized = normalizeWhitespace(plan.sql);
|
|
57
|
+
const statementType = classifyStatement(normalized);
|
|
58
|
+
|
|
59
|
+
if (statementType === 'select') {
|
|
60
|
+
if (SELECT_STAR_REGEX.test(normalized)) {
|
|
61
|
+
lints.push(
|
|
62
|
+
createLint('LINT.SELECT_STAR', 'error', 'Raw SQL plan selects all columns via *', {
|
|
63
|
+
sql: snippet(plan.sql),
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!LIMIT_REGEX.test(normalized)) {
|
|
69
|
+
const severity = config?.budgets?.unboundedSelectSeverity ?? 'error';
|
|
70
|
+
lints.push(
|
|
71
|
+
createLint('LINT.NO_LIMIT', 'warn', 'Raw SQL plan omits LIMIT clause', {
|
|
72
|
+
sql: snippet(plan.sql),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
budgets.push(
|
|
77
|
+
createBudget(
|
|
78
|
+
'BUDGET.ROWS_EXCEEDED',
|
|
79
|
+
severity,
|
|
80
|
+
'Raw SQL plan is unbounded and may exceed row budget',
|
|
81
|
+
{
|
|
82
|
+
sql: snippet(plan.sql),
|
|
83
|
+
...(config?.budgets?.estimatedRows !== undefined
|
|
84
|
+
? { estimatedRows: config.budgets.estimatedRows }
|
|
85
|
+
: {}),
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isMutationStatement(statementType) && isReadOnlyIntent(plan.meta)) {
|
|
93
|
+
lints.push(
|
|
94
|
+
createLint(
|
|
95
|
+
'LINT.READ_ONLY_MUTATION',
|
|
96
|
+
'error',
|
|
97
|
+
'Raw SQL plan mutates data despite read-only intent',
|
|
98
|
+
{
|
|
99
|
+
sql: snippet(plan.sql),
|
|
100
|
+
intent: plan.meta.annotations?.['intent'],
|
|
101
|
+
},
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const refs = plan.meta.refs;
|
|
107
|
+
if (refs) {
|
|
108
|
+
evaluateIndexCoverage(refs, lints);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { lints, budgets, statement: statementType };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function evaluateIndexCoverage(refs: PlanRefs, lints: LintFinding[]) {
|
|
115
|
+
const predicateColumns = refs.columns ?? [];
|
|
116
|
+
if (predicateColumns.length === 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const indexes = refs.indexes ?? [];
|
|
121
|
+
|
|
122
|
+
if (indexes.length === 0) {
|
|
123
|
+
lints.push(
|
|
124
|
+
createLint(
|
|
125
|
+
'LINT.UNINDEXED_PREDICATE',
|
|
126
|
+
'warn',
|
|
127
|
+
'Raw SQL plan predicates lack supporting indexes',
|
|
128
|
+
{
|
|
129
|
+
predicates: predicateColumns,
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const hasSupportingIndex = predicateColumns.every((column) =>
|
|
137
|
+
indexes.some(
|
|
138
|
+
(index) =>
|
|
139
|
+
index.table === column.table &&
|
|
140
|
+
index.columns.some((col) => col.toLowerCase() === column.column.toLowerCase()),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!hasSupportingIndex) {
|
|
145
|
+
lints.push(
|
|
146
|
+
createLint(
|
|
147
|
+
'LINT.UNINDEXED_PREDICATE',
|
|
148
|
+
'warn',
|
|
149
|
+
'Raw SQL plan predicates lack supporting indexes',
|
|
150
|
+
{
|
|
151
|
+
predicates: predicateColumns,
|
|
152
|
+
},
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function classifyStatement(sql: string): 'select' | 'mutation' | 'other' {
|
|
159
|
+
const trimmed = sql.trim();
|
|
160
|
+
const lower = trimmed.toLowerCase();
|
|
161
|
+
|
|
162
|
+
if (lower.startsWith('with')) {
|
|
163
|
+
if (lower.includes('select')) {
|
|
164
|
+
return 'select';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (lower.startsWith('select')) {
|
|
169
|
+
return 'select';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (MUTATION_PREFIX_REGEX.test(trimmed)) {
|
|
173
|
+
return 'mutation';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return 'other';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isMutationStatement(statement: 'select' | 'mutation' | 'other'): boolean {
|
|
180
|
+
return statement === 'mutation';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isReadOnlyIntent(meta: PlanMeta): boolean {
|
|
184
|
+
const annotations = meta.annotations as { intent?: string } | undefined;
|
|
185
|
+
const intent =
|
|
186
|
+
typeof annotations?.intent === 'string' ? annotations.intent.toLowerCase() : undefined;
|
|
187
|
+
return intent !== undefined && READ_ONLY_INTENTS.has(intent);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeWhitespace(value: string): string {
|
|
191
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function snippet(sql: string): string {
|
|
195
|
+
return normalizeWhitespace(sql).slice(0, 200);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createLint(
|
|
199
|
+
code: LintFinding['code'],
|
|
200
|
+
severity: LintFinding['severity'],
|
|
201
|
+
message: string,
|
|
202
|
+
details?: Record<string, unknown>,
|
|
203
|
+
): LintFinding {
|
|
204
|
+
return { code, severity, message, ...(details ? { details } : {}) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createBudget(
|
|
208
|
+
code: BudgetFinding['code'],
|
|
209
|
+
severity: BudgetFinding['severity'],
|
|
210
|
+
message: string,
|
|
211
|
+
details?: Record<string, unknown>,
|
|
212
|
+
): BudgetFinding {
|
|
213
|
+
return { code, severity, message, ...(details ? { details } : {}) };
|
|
214
|
+
}
|
package/src/lower-sql-plan.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { Contract
|
|
1
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
2
2
|
import type { SqlStorage } from '@prisma-next/sql-contract/types';
|
|
3
3
|
import type { Adapter, AnyQueryAst, LoweredStatement } from '@prisma-next/sql-relational-core/ast';
|
|
4
|
-
import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
|
|
4
|
+
import type { SqlExecutionPlan, SqlQueryPlan } from '@prisma-next/sql-relational-core/plan';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Lowers a SQL query plan to an executable Plan by calling the adapter's lower method.
|
|
@@ -15,7 +15,7 @@ export function lowerSqlPlan<Row>(
|
|
|
15
15
|
adapter: Adapter<AnyQueryAst, Contract<SqlStorage>, LoweredStatement>,
|
|
16
16
|
contract: Contract<SqlStorage>,
|
|
17
17
|
queryPlan: SqlQueryPlan<Row>,
|
|
18
|
-
):
|
|
18
|
+
): SqlExecutionPlan<Row> {
|
|
19
19
|
const lowered = adapter.lower(queryPlan.ast, {
|
|
20
20
|
contract,
|
|
21
21
|
params: queryPlan.params,
|
package/src/marker.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ContractMarkerRecord } from '@prisma-next/contract/types';
|
|
2
|
+
import { type } from 'arktype';
|
|
3
|
+
|
|
4
|
+
export interface ContractMarkerRow {
|
|
5
|
+
core_hash: string;
|
|
6
|
+
profile_hash: string;
|
|
7
|
+
contract_json: unknown | null;
|
|
8
|
+
canonical_version: number | null;
|
|
9
|
+
updated_at: Date;
|
|
10
|
+
app_tag: string | null;
|
|
11
|
+
meta: unknown | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MetaSchema = type({ '[string]': 'unknown' });
|
|
15
|
+
|
|
16
|
+
function parseMeta(meta: unknown): Record<string, unknown> {
|
|
17
|
+
if (meta === null || meta === undefined) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let parsed: unknown;
|
|
22
|
+
if (typeof meta === 'string') {
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(meta);
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
parsed = meta;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = MetaSchema(parsed);
|
|
33
|
+
if (result instanceof type.errors) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ContractMarkerRowSchema = type({
|
|
41
|
+
core_hash: 'string',
|
|
42
|
+
profile_hash: 'string',
|
|
43
|
+
'contract_json?': 'unknown | null',
|
|
44
|
+
'canonical_version?': 'number | null',
|
|
45
|
+
'updated_at?': 'Date | string',
|
|
46
|
+
'app_tag?': 'string | null',
|
|
47
|
+
'meta?': 'unknown | null',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function parseContractMarkerRow(row: unknown): ContractMarkerRecord {
|
|
51
|
+
const result = ContractMarkerRowSchema(row);
|
|
52
|
+
if (result instanceof type.errors) {
|
|
53
|
+
const messages = result.map((p: { message: string }) => p.message).join('; ');
|
|
54
|
+
throw new Error(`Invalid contract marker row: ${messages}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const validatedRow = result as {
|
|
58
|
+
core_hash: string;
|
|
59
|
+
profile_hash: string;
|
|
60
|
+
contract_json?: unknown | null;
|
|
61
|
+
canonical_version?: number | null;
|
|
62
|
+
updated_at?: Date | string;
|
|
63
|
+
app_tag?: string | null;
|
|
64
|
+
meta?: unknown | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const updatedAt = validatedRow.updated_at
|
|
68
|
+
? validatedRow.updated_at instanceof Date
|
|
69
|
+
? validatedRow.updated_at
|
|
70
|
+
: new Date(validatedRow.updated_at)
|
|
71
|
+
: new Date();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
storageHash: validatedRow.core_hash,
|
|
75
|
+
profileHash: validatedRow.profile_hash,
|
|
76
|
+
contractJson: validatedRow.contract_json ?? null,
|
|
77
|
+
canonicalVersion: validatedRow.canonical_version ?? null,
|
|
78
|
+
updatedAt,
|
|
79
|
+
appTag: validatedRow.app_tag ?? null,
|
|
80
|
+
meta: parseMeta(validatedRow.meta),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -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 {
|
|
@@ -48,7 +51,7 @@ function estimateRowsFromAst(
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
function estimateRowsFromHeuristics(
|
|
51
|
-
plan:
|
|
54
|
+
plan: SqlExecutionPlan,
|
|
52
55
|
tableRows: Record<string, number>,
|
|
53
56
|
defaultTableRows: number,
|
|
54
57
|
): number | null {
|
|
@@ -67,7 +70,7 @@ function estimateRowsFromHeuristics(
|
|
|
67
70
|
return tableEstimate;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
function hasDetectableLimitFromHeuristics(plan:
|
|
73
|
+
function hasDetectableLimitFromHeuristics(plan: SqlExecutionPlan): boolean {
|
|
71
74
|
return typeof plan.meta.annotations?.['limit'] === 'number';
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -93,13 +96,13 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
93
96
|
const maxLatencyMs = options?.maxLatencyMs ?? 1_000;
|
|
94
97
|
const rowSeverity = options?.severities?.rowCount ?? 'error';
|
|
95
98
|
|
|
96
|
-
const observedRowsByPlan = new WeakMap<
|
|
99
|
+
const observedRowsByPlan = new WeakMap<SqlExecutionPlan, { count: number }>();
|
|
97
100
|
|
|
98
101
|
return Object.freeze({
|
|
99
102
|
name: 'budgets',
|
|
100
103
|
familyId: 'sql' as const,
|
|
101
104
|
|
|
102
|
-
async beforeExecute(plan:
|
|
105
|
+
async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
103
106
|
observedRowsByPlan.set(plan, { count: 0 });
|
|
104
107
|
|
|
105
108
|
if (isQueryAst(plan.ast)) {
|
|
@@ -112,7 +115,7 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
112
115
|
return evaluateWithHeuristics(plan, ctx);
|
|
113
116
|
},
|
|
114
117
|
|
|
115
|
-
async onRow(_row: Record<string, unknown>, plan:
|
|
118
|
+
async onRow(_row: Record<string, unknown>, plan: SqlExecutionPlan, _ctx: SqlMiddlewareContext) {
|
|
116
119
|
const state = observedRowsByPlan.get(plan);
|
|
117
120
|
if (!state) return;
|
|
118
121
|
state.count += 1;
|
|
@@ -126,7 +129,7 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
126
129
|
},
|
|
127
130
|
|
|
128
131
|
async afterExecute(
|
|
129
|
-
_plan:
|
|
132
|
+
_plan: SqlExecutionPlan,
|
|
130
133
|
result: AfterExecuteResult,
|
|
131
134
|
ctx: SqlMiddlewareContext,
|
|
132
135
|
) {
|
|
@@ -145,7 +148,7 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
145
148
|
},
|
|
146
149
|
});
|
|
147
150
|
|
|
148
|
-
function evaluateSelectAst(plan:
|
|
151
|
+
function evaluateSelectAst(plan: SqlExecutionPlan, ast: SelectAst, ctx: SqlMiddlewareContext) {
|
|
149
152
|
const hasAggNoGroup = hasAggregateWithoutGroupBy(ast);
|
|
150
153
|
const estimated = estimateRowsFromAst(
|
|
151
154
|
ast,
|
|
@@ -195,7 +198,7 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware {
|
|
|
195
198
|
}
|
|
196
199
|
}
|
|
197
200
|
|
|
198
|
-
async function evaluateWithHeuristics(plan:
|
|
201
|
+
async function evaluateWithHeuristics(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
199
202
|
const estimated = estimateRowsFromHeuristics(plan, tableRows, defaultTableRows);
|
|
200
203
|
const isUnbounded = !hasDetectableLimitFromHeuristics(plan);
|
|
201
204
|
const sqlUpper = plan.sql.trimStart().toUpperCase();
|
package/src/middleware/lints.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { ExecutionPlan } from '@prisma-next/contract/types';
|
|
2
1
|
import { runtimeError } from '@prisma-next/framework-components/runtime';
|
|
3
|
-
import { evaluateRawGuardrails } from '@prisma-next/runtime-executor';
|
|
4
2
|
import {
|
|
5
3
|
type AnyFromSource,
|
|
6
4
|
type AnyQueryAst,
|
|
7
5
|
isQueryAst,
|
|
8
6
|
} from '@prisma-next/sql-relational-core/ast';
|
|
7
|
+
import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
9
8
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
9
|
+
import { evaluateRawGuardrails } from '../guardrails/raw';
|
|
10
10
|
import type { SqlMiddleware, SqlMiddlewareContext } from './sql-middleware';
|
|
11
11
|
|
|
12
12
|
export interface LintsOptions {
|
|
@@ -145,7 +145,7 @@ export function lints(options?: LintsOptions): SqlMiddleware {
|
|
|
145
145
|
name: 'lints',
|
|
146
146
|
familyId: 'sql' as const,
|
|
147
147
|
|
|
148
|
-
async beforeExecute(plan:
|
|
148
|
+
async beforeExecute(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext) {
|
|
149
149
|
if (isQueryAst(plan.ast)) {
|
|
150
150
|
const findings = evaluateAstLints(plan.ast);
|
|
151
151
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Contract,
|
|
1
|
+
import type { Contract, PlanMeta } from '@prisma-next/contract/types';
|
|
2
2
|
import type {
|
|
3
3
|
AfterExecuteResult,
|
|
4
4
|
RuntimeMiddleware,
|
|
@@ -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 { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan';
|
|
9
10
|
|
|
10
11
|
export interface SqlMiddlewareContext extends RuntimeMiddlewareContext {
|
|
11
12
|
readonly contract: Contract<SqlStorage>;
|
|
@@ -20,7 +21,7 @@ export interface DraftPlan {
|
|
|
20
21
|
readonly meta: PlanMeta;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
export interface SqlMiddleware extends RuntimeMiddleware {
|
|
24
|
+
export interface SqlMiddleware extends RuntimeMiddleware<SqlExecutionPlan> {
|
|
24
25
|
readonly familyId?: 'sql';
|
|
25
26
|
/**
|
|
26
27
|
* Rewrite the query AST before it is lowered to SQL. Middlewares run in
|
|
@@ -41,14 +42,14 @@ export interface SqlMiddleware extends RuntimeMiddleware {
|
|
|
41
42
|
* See `docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md`.
|
|
42
43
|
*/
|
|
43
44
|
beforeCompile?(draft: DraftPlan, ctx: SqlMiddlewareContext): Promise<DraftPlan | undefined>;
|
|
44
|
-
beforeExecute?(plan:
|
|
45
|
+
beforeExecute?(plan: SqlExecutionPlan, ctx: SqlMiddlewareContext): Promise<void>;
|
|
45
46
|
onRow?(
|
|
46
47
|
row: Record<string, unknown>,
|
|
47
|
-
plan:
|
|
48
|
+
plan: SqlExecutionPlan,
|
|
48
49
|
ctx: SqlMiddlewareContext,
|
|
49
50
|
): Promise<void>;
|
|
50
51
|
afterExecute?(
|
|
51
|
-
plan:
|
|
52
|
+
plan: SqlExecutionPlan,
|
|
52
53
|
result: AfterExecuteResult,
|
|
53
54
|
ctx: SqlMiddlewareContext,
|
|
54
55
|
): Promise<void>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ExecutionPlan } from '@prisma-next/framework-components/runtime';
|
|
2
|
+
import type { MarkerStatement } from '@prisma-next/sql-relational-core/ast';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reader of the SQL contract marker. SQL runtimes verify the database's
|
|
6
|
+
* `prisma_contract.marker` row against the runtime's contract by issuing
|
|
7
|
+
* this statement before executing user queries (when `verify` is enabled).
|
|
8
|
+
*
|
|
9
|
+
* Structurally satisfied by `AdapterProfile`, which already exposes
|
|
10
|
+
* `readMarkerStatement(): MarkerStatement` for adapter-level introspection.
|
|
11
|
+
*/
|
|
12
|
+
export interface MarkerReader {
|
|
13
|
+
readMarkerStatement(): MarkerStatement;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SQL family adapter SPI consumed by `SqlRuntime`. Encapsulates the
|
|
18
|
+
* runtime contract, marker reader, and plan validation logic so the
|
|
19
|
+
* runtime can be unit-tested without a concrete SQL adapter profile.
|
|
20
|
+
*
|
|
21
|
+
* Implemented by `SqlFamilyAdapter` for production and by mock classes
|
|
22
|
+
* in tests.
|
|
23
|
+
*/
|
|
24
|
+
export interface RuntimeFamilyAdapter<TContract = unknown> {
|
|
25
|
+
readonly contract: TContract;
|
|
26
|
+
readonly markerReader: MarkerReader;
|
|
27
|
+
validatePlan(plan: ExecutionPlan, contract: TContract): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RuntimeVerifyOptions {
|
|
31
|
+
readonly mode: 'onFirstUse' | 'startup' | 'always';
|
|
32
|
+
readonly requireMarker: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TelemetryOutcome = 'success' | 'runtime-error';
|
|
36
|
+
|
|
37
|
+
export interface RuntimeTelemetryEvent {
|
|
38
|
+
readonly lane: string;
|
|
39
|
+
readonly target: string;
|
|
40
|
+
readonly fingerprint: string;
|
|
41
|
+
readonly outcome: TelemetryOutcome;
|
|
42
|
+
readonly durationMs?: number;
|
|
43
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { Contract
|
|
1
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
2
|
+
import type { ExecutionPlan } from '@prisma-next/framework-components/runtime';
|
|
2
3
|
import { runtimeError } from '@prisma-next/framework-components/runtime';
|
|
3
|
-
import type { MarkerReader, RuntimeFamilyAdapter } from '@prisma-next/runtime-executor';
|
|
4
4
|
import type { SqlStorage } from '@prisma-next/sql-contract/types';
|
|
5
5
|
import type { AdapterProfile } from '@prisma-next/sql-relational-core/ast';
|
|
6
|
+
import type { MarkerReader, RuntimeFamilyAdapter } from './runtime-spi';
|
|
6
7
|
|
|
7
8
|
export class SqlFamilyAdapter<TContract extends Contract<SqlStorage>>
|
|
8
9
|
implements RuntimeFamilyAdapter<TContract>
|
package/src/sql-marker.ts
CHANGED