@prisma-next/runtime-executor 0.3.0-dev.1 → 0.3.0-dev.100
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/LICENSE +201 -0
- package/README.md +2 -3
- package/dist/index.d.mts +153 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +390 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +26 -18
- package/src/async-iterable-result.ts +78 -0
- package/src/errors.ts +39 -0
- package/src/exports/index.ts +27 -0
- package/src/fingerprint.ts +14 -0
- package/src/guardrails/raw.ts +204 -0
- package/src/index.ts +1 -0
- package/src/marker.ts +85 -0
- package/src/plugins/types.ts +42 -0
- package/src/runtime-core.ts +362 -0
- package/src/runtime-spi.ts +16 -0
- package/dist/index.d.ts +0 -157
- package/dist/index.js +0 -746
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
export function computeSqlFingerprint(sql: string): string {
|
|
8
|
+
const withoutStrings = sql.replace(STRING_LITERAL_REGEX, '?');
|
|
9
|
+
const withoutNumbers = withoutStrings.replace(NUMERIC_LITERAL_REGEX, '?');
|
|
10
|
+
const normalized = withoutNumbers.replace(WHITESPACE_REGEX, ' ').trim().toLowerCase();
|
|
11
|
+
|
|
12
|
+
const hash = createHash('sha256').update(normalized).digest('hex');
|
|
13
|
+
return `sha256:${hash}`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { ExecutionPlan, 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
|
+
const SELECT_STAR_REGEX = /select\s+\*/i;
|
|
34
|
+
const LIMIT_REGEX = /\blimit\b/i;
|
|
35
|
+
const MUTATION_PREFIX_REGEX = /^(insert|update|delete|create|alter|drop|truncate)\b/i;
|
|
36
|
+
|
|
37
|
+
const READ_ONLY_INTENTS = new Set(['read', 'report', 'readonly']);
|
|
38
|
+
|
|
39
|
+
export function evaluateRawGuardrails(
|
|
40
|
+
plan: ExecutionPlan,
|
|
41
|
+
config?: RawGuardrailConfig,
|
|
42
|
+
): RawGuardrailResult {
|
|
43
|
+
const lints: LintFinding[] = [];
|
|
44
|
+
const budgets: BudgetFinding[] = [];
|
|
45
|
+
|
|
46
|
+
const normalized = normalizeWhitespace(plan.sql);
|
|
47
|
+
const statementType = classifyStatement(normalized);
|
|
48
|
+
|
|
49
|
+
if (statementType === 'select') {
|
|
50
|
+
if (SELECT_STAR_REGEX.test(normalized)) {
|
|
51
|
+
lints.push(
|
|
52
|
+
createLint('LINT.SELECT_STAR', 'error', 'Raw SQL plan selects all columns via *', {
|
|
53
|
+
sql: snippet(plan.sql),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!LIMIT_REGEX.test(normalized)) {
|
|
59
|
+
const severity = config?.budgets?.unboundedSelectSeverity ?? 'error';
|
|
60
|
+
lints.push(
|
|
61
|
+
createLint('LINT.NO_LIMIT', 'warn', 'Raw SQL plan omits LIMIT clause', {
|
|
62
|
+
sql: snippet(plan.sql),
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
budgets.push(
|
|
67
|
+
createBudget(
|
|
68
|
+
'BUDGET.ROWS_EXCEEDED',
|
|
69
|
+
severity,
|
|
70
|
+
'Raw SQL plan is unbounded and may exceed row budget',
|
|
71
|
+
{
|
|
72
|
+
sql: snippet(plan.sql),
|
|
73
|
+
...(config?.budgets?.estimatedRows !== undefined
|
|
74
|
+
? { estimatedRows: config.budgets.estimatedRows }
|
|
75
|
+
: {}),
|
|
76
|
+
},
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isMutationStatement(statementType) && isReadOnlyIntent(plan.meta)) {
|
|
83
|
+
lints.push(
|
|
84
|
+
createLint(
|
|
85
|
+
'LINT.READ_ONLY_MUTATION',
|
|
86
|
+
'error',
|
|
87
|
+
'Raw SQL plan mutates data despite read-only intent',
|
|
88
|
+
{
|
|
89
|
+
sql: snippet(plan.sql),
|
|
90
|
+
intent: plan.meta.annotations?.['intent'],
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const refs = plan.meta.refs;
|
|
97
|
+
if (refs) {
|
|
98
|
+
evaluateIndexCoverage(refs, lints);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { lints, budgets, statement: statementType };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function evaluateIndexCoverage(refs: PlanRefs, lints: LintFinding[]) {
|
|
105
|
+
const predicateColumns = refs.columns ?? [];
|
|
106
|
+
if (predicateColumns.length === 0) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const indexes = refs.indexes ?? [];
|
|
111
|
+
|
|
112
|
+
if (indexes.length === 0) {
|
|
113
|
+
lints.push(
|
|
114
|
+
createLint(
|
|
115
|
+
'LINT.UNINDEXED_PREDICATE',
|
|
116
|
+
'warn',
|
|
117
|
+
'Raw SQL plan predicates lack supporting indexes',
|
|
118
|
+
{
|
|
119
|
+
predicates: predicateColumns,
|
|
120
|
+
},
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hasSupportingIndex = predicateColumns.every((column) =>
|
|
127
|
+
indexes.some(
|
|
128
|
+
(index) =>
|
|
129
|
+
index.table === column.table &&
|
|
130
|
+
index.columns.some((col) => col.toLowerCase() === column.column.toLowerCase()),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (!hasSupportingIndex) {
|
|
135
|
+
lints.push(
|
|
136
|
+
createLint(
|
|
137
|
+
'LINT.UNINDEXED_PREDICATE',
|
|
138
|
+
'warn',
|
|
139
|
+
'Raw SQL plan predicates lack supporting indexes',
|
|
140
|
+
{
|
|
141
|
+
predicates: predicateColumns,
|
|
142
|
+
},
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function classifyStatement(sql: string): 'select' | 'mutation' | 'other' {
|
|
149
|
+
const trimmed = sql.trim();
|
|
150
|
+
const lower = trimmed.toLowerCase();
|
|
151
|
+
|
|
152
|
+
if (lower.startsWith('with')) {
|
|
153
|
+
if (lower.includes('select')) {
|
|
154
|
+
return 'select';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (lower.startsWith('select')) {
|
|
159
|
+
return 'select';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (MUTATION_PREFIX_REGEX.test(trimmed)) {
|
|
163
|
+
return 'mutation';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return 'other';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isMutationStatement(statement: 'select' | 'mutation' | 'other'): boolean {
|
|
170
|
+
return statement === 'mutation';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isReadOnlyIntent(meta: PlanMeta): boolean {
|
|
174
|
+
const annotations = meta.annotations as { intent?: string } | undefined;
|
|
175
|
+
const intent =
|
|
176
|
+
typeof annotations?.intent === 'string' ? annotations.intent.toLowerCase() : undefined;
|
|
177
|
+
return intent !== undefined && READ_ONLY_INTENTS.has(intent);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeWhitespace(value: string): string {
|
|
181
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function snippet(sql: string): string {
|
|
185
|
+
return normalizeWhitespace(sql).slice(0, 200);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function createLint(
|
|
189
|
+
code: LintFinding['code'],
|
|
190
|
+
severity: LintFinding['severity'],
|
|
191
|
+
message: string,
|
|
192
|
+
details?: Record<string, unknown>,
|
|
193
|
+
): LintFinding {
|
|
194
|
+
return { code, severity, message, ...(details ? { details } : {}) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function createBudget(
|
|
198
|
+
code: BudgetFinding['code'],
|
|
199
|
+
severity: BudgetFinding['severity'],
|
|
200
|
+
message: string,
|
|
201
|
+
details?: Record<string, unknown>,
|
|
202
|
+
): BudgetFinding {
|
|
203
|
+
return { code, severity, message, ...(details ? { details } : {}) };
|
|
204
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './exports';
|
package/src/marker.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ContractMarkerRecord } from '@prisma-next/contract/types';
|
|
2
|
+
import { type } from 'arktype';
|
|
3
|
+
|
|
4
|
+
// Re-export for backward compatibility
|
|
5
|
+
export type { ContractMarkerRecord } from '@prisma-next/contract/types';
|
|
6
|
+
|
|
7
|
+
export interface ContractMarkerRow {
|
|
8
|
+
core_hash: string;
|
|
9
|
+
profile_hash: string;
|
|
10
|
+
contract_json: unknown | null;
|
|
11
|
+
canonical_version: number | null;
|
|
12
|
+
updated_at: Date;
|
|
13
|
+
app_tag: string | null;
|
|
14
|
+
meta: unknown | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MetaSchema = type({ '[string]': 'unknown' });
|
|
18
|
+
|
|
19
|
+
function parseMeta(meta: unknown): Record<string, unknown> {
|
|
20
|
+
if (meta === null || meta === undefined) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let parsed: unknown;
|
|
25
|
+
if (typeof meta === 'string') {
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(meta);
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
parsed = meta;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = MetaSchema(parsed);
|
|
36
|
+
if (result instanceof type.errors) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result as Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ContractMarkerRowSchema = type({
|
|
44
|
+
core_hash: 'string',
|
|
45
|
+
profile_hash: 'string',
|
|
46
|
+
'contract_json?': 'unknown | null',
|
|
47
|
+
'canonical_version?': 'number | null',
|
|
48
|
+
'updated_at?': 'Date | string',
|
|
49
|
+
'app_tag?': 'string | null',
|
|
50
|
+
'meta?': 'unknown | null',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export function parseContractMarkerRow(row: unknown): ContractMarkerRecord {
|
|
54
|
+
const result = ContractMarkerRowSchema(row);
|
|
55
|
+
if (result instanceof type.errors) {
|
|
56
|
+
const messages = result.map((p: { message: string }) => p.message).join('; ');
|
|
57
|
+
throw new Error(`Invalid contract marker row: ${messages}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const validatedRow = result as {
|
|
61
|
+
core_hash: string;
|
|
62
|
+
profile_hash: string;
|
|
63
|
+
contract_json?: unknown | null;
|
|
64
|
+
canonical_version?: number | null;
|
|
65
|
+
updated_at?: Date | string;
|
|
66
|
+
app_tag?: string | null;
|
|
67
|
+
meta?: unknown | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const updatedAt = validatedRow.updated_at
|
|
71
|
+
? validatedRow.updated_at instanceof Date
|
|
72
|
+
? validatedRow.updated_at
|
|
73
|
+
: new Date(validatedRow.updated_at)
|
|
74
|
+
: new Date();
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
storageHash: validatedRow.core_hash,
|
|
78
|
+
profileHash: validatedRow.profile_hash,
|
|
79
|
+
contractJson: validatedRow.contract_json ?? null,
|
|
80
|
+
canonicalVersion: validatedRow.canonical_version ?? null,
|
|
81
|
+
updatedAt,
|
|
82
|
+
appTag: validatedRow.app_tag ?? null,
|
|
83
|
+
meta: parseMeta(validatedRow.meta),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ExecutionPlan } from '@prisma-next/contract/types';
|
|
2
|
+
|
|
3
|
+
export type Severity = 'error' | 'warn' | 'info';
|
|
4
|
+
|
|
5
|
+
export interface Log {
|
|
6
|
+
info(event: unknown): void;
|
|
7
|
+
warn(event: unknown): void;
|
|
8
|
+
error(event: unknown): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PluginContext<TContract = unknown, TAdapter = unknown, TDriver = unknown> {
|
|
12
|
+
readonly contract: TContract;
|
|
13
|
+
readonly adapter: TAdapter;
|
|
14
|
+
readonly driver: TDriver;
|
|
15
|
+
readonly mode: 'strict' | 'permissive';
|
|
16
|
+
readonly now: () => number;
|
|
17
|
+
readonly log: Log;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AfterExecuteResult {
|
|
21
|
+
readonly rowCount: number;
|
|
22
|
+
readonly latencyMs: number;
|
|
23
|
+
readonly completed: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Plugin<TContract = unknown, TAdapter = unknown, TDriver = unknown> {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
beforeExecute?(
|
|
29
|
+
plan: ExecutionPlan,
|
|
30
|
+
ctx: PluginContext<TContract, TAdapter, TDriver>,
|
|
31
|
+
): Promise<void>;
|
|
32
|
+
onRow?(
|
|
33
|
+
row: Record<string, unknown>,
|
|
34
|
+
plan: ExecutionPlan,
|
|
35
|
+
ctx: PluginContext<TContract, TAdapter, TDriver>,
|
|
36
|
+
): Promise<void>;
|
|
37
|
+
afterExecute?(
|
|
38
|
+
plan: ExecutionPlan,
|
|
39
|
+
result: AfterExecuteResult,
|
|
40
|
+
ctx: PluginContext<TContract, TAdapter, TDriver>,
|
|
41
|
+
): Promise<void>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { ExecutionPlan } from '@prisma-next/contract/types';
|
|
2
|
+
import type { OperationRegistry } from '@prisma-next/operations';
|
|
3
|
+
import { AsyncIterableResult } from './async-iterable-result';
|
|
4
|
+
import { runtimeError } from './errors';
|
|
5
|
+
import { computeSqlFingerprint } from './fingerprint';
|
|
6
|
+
import { parseContractMarkerRow } from './marker';
|
|
7
|
+
import type { Log, Plugin, PluginContext } from './plugins/types';
|
|
8
|
+
import type { RuntimeFamilyAdapter } from './runtime-spi';
|
|
9
|
+
|
|
10
|
+
export interface RuntimeVerifyOptions {
|
|
11
|
+
readonly mode: 'onFirstUse' | 'startup' | 'always';
|
|
12
|
+
readonly requireMarker: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TelemetryOutcome = 'success' | 'runtime-error';
|
|
16
|
+
|
|
17
|
+
export interface RuntimeTelemetryEvent {
|
|
18
|
+
readonly lane: string;
|
|
19
|
+
readonly target: string;
|
|
20
|
+
readonly fingerprint: string;
|
|
21
|
+
readonly outcome: TelemetryOutcome;
|
|
22
|
+
readonly durationMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RuntimeCoreOptions<TContract = unknown, TAdapter = unknown, TDriver = unknown> {
|
|
26
|
+
readonly familyAdapter: RuntimeFamilyAdapter<TContract>;
|
|
27
|
+
readonly driver: TDriver;
|
|
28
|
+
readonly verify: RuntimeVerifyOptions;
|
|
29
|
+
readonly plugins?: readonly Plugin<TContract, TAdapter, TDriver>[];
|
|
30
|
+
readonly mode?: 'strict' | 'permissive';
|
|
31
|
+
readonly log?: Log;
|
|
32
|
+
readonly operationRegistry: OperationRegistry;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RuntimeCore<TContract = unknown, TAdapter = unknown, TDriver = unknown>
|
|
36
|
+
extends RuntimeQueryable {
|
|
37
|
+
// Type parameters are used in the implementation for type safety
|
|
38
|
+
readonly _typeContract?: TContract;
|
|
39
|
+
readonly _typeAdapter?: TAdapter;
|
|
40
|
+
readonly _typeDriver?: TDriver;
|
|
41
|
+
connection(): Promise<RuntimeConnection>;
|
|
42
|
+
telemetry(): RuntimeTelemetryEvent | null;
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
operations(): OperationRegistry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RuntimeConnection extends RuntimeQueryable {
|
|
48
|
+
transaction(): Promise<RuntimeTransaction>;
|
|
49
|
+
release(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RuntimeTransaction extends RuntimeQueryable {
|
|
53
|
+
commit(): Promise<void>;
|
|
54
|
+
rollback(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RuntimeQueryable {
|
|
58
|
+
execute<Row = Record<string, unknown>>(plan: ExecutionPlan<Row>): AsyncIterableResult<Row>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface DriverWithQuery<_TDriver> {
|
|
62
|
+
query(sql: string, params: readonly unknown[]): Promise<{ rows: ReadonlyArray<unknown> }>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface DriverWithConnection<_TDriver> {
|
|
66
|
+
acquireConnection(): Promise<DriverConnection>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DriverConnection extends Queryable {
|
|
70
|
+
beginTransaction(): Promise<DriverTransaction>;
|
|
71
|
+
release(): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DriverTransaction extends Queryable {
|
|
75
|
+
commit(): Promise<void>;
|
|
76
|
+
rollback(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Queryable {
|
|
80
|
+
execute<Row = Record<string, unknown>>(options: {
|
|
81
|
+
sql: string;
|
|
82
|
+
params: readonly unknown[];
|
|
83
|
+
}): AsyncIterable<Row>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface DriverWithClose<_TDriver> {
|
|
87
|
+
close(): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class RuntimeCoreImpl<TContract = unknown, TAdapter = unknown, TDriver = unknown>
|
|
91
|
+
implements RuntimeCore<TContract, TAdapter, TDriver>
|
|
92
|
+
{
|
|
93
|
+
readonly _typeContract?: TContract;
|
|
94
|
+
readonly _typeAdapter?: TAdapter;
|
|
95
|
+
readonly _typeDriver?: TDriver;
|
|
96
|
+
private readonly contract: TContract;
|
|
97
|
+
private readonly familyAdapter: RuntimeFamilyAdapter<TContract>;
|
|
98
|
+
private readonly driver: TDriver;
|
|
99
|
+
private readonly plugins: readonly Plugin<TContract, TAdapter, TDriver>[];
|
|
100
|
+
private readonly mode: 'strict' | 'permissive';
|
|
101
|
+
private readonly verify: RuntimeVerifyOptions;
|
|
102
|
+
private readonly operationRegistry: OperationRegistry;
|
|
103
|
+
private readonly pluginContext: PluginContext<TContract, TAdapter, TDriver>;
|
|
104
|
+
|
|
105
|
+
private verified: boolean;
|
|
106
|
+
private startupVerified: boolean;
|
|
107
|
+
private _telemetry: RuntimeTelemetryEvent | null;
|
|
108
|
+
|
|
109
|
+
constructor(options: RuntimeCoreOptions<TContract, TAdapter, TDriver>) {
|
|
110
|
+
const { familyAdapter, driver } = options;
|
|
111
|
+
this.contract = familyAdapter.contract;
|
|
112
|
+
this.familyAdapter = familyAdapter;
|
|
113
|
+
this.driver = driver;
|
|
114
|
+
this.plugins = options.plugins ?? [];
|
|
115
|
+
this.mode = options.mode ?? 'strict';
|
|
116
|
+
this.verify = options.verify;
|
|
117
|
+
this.operationRegistry = options.operationRegistry;
|
|
118
|
+
|
|
119
|
+
this.verified = options.verify.mode === 'startup' ? false : options.verify.mode === 'always';
|
|
120
|
+
this.startupVerified = false;
|
|
121
|
+
this._telemetry = null;
|
|
122
|
+
|
|
123
|
+
this.pluginContext = {
|
|
124
|
+
contract: this.contract,
|
|
125
|
+
adapter: options.familyAdapter as unknown as TAdapter,
|
|
126
|
+
driver: this.driver,
|
|
127
|
+
mode: this.mode,
|
|
128
|
+
now: () => Date.now(),
|
|
129
|
+
log: options.log ?? {
|
|
130
|
+
info: () => {
|
|
131
|
+
// No-op in MVP - diagnostics stay out of runtime core
|
|
132
|
+
},
|
|
133
|
+
warn: () => {
|
|
134
|
+
// No-op in MVP - diagnostics stay out of runtime core
|
|
135
|
+
},
|
|
136
|
+
error: () => {
|
|
137
|
+
// No-op in MVP - diagnostics stay out of runtime core
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async verifyPlanIfNeeded(_plan: ExecutionPlan): Promise<void> {
|
|
144
|
+
void _plan;
|
|
145
|
+
if (this.verify.mode === 'always') {
|
|
146
|
+
this.verified = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.verified) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const readStatement = this.familyAdapter.markerReader.readMarkerStatement();
|
|
154
|
+
const driver = this.driver as unknown as DriverWithQuery<TDriver>;
|
|
155
|
+
const result = await driver.query(readStatement.sql, readStatement.params);
|
|
156
|
+
|
|
157
|
+
if (result.rows.length === 0) {
|
|
158
|
+
if (this.verify.requireMarker) {
|
|
159
|
+
throw runtimeError('CONTRACT.MARKER_MISSING', 'Contract marker not found in database');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.verified = true;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const marker = parseContractMarkerRow(result.rows[0]);
|
|
167
|
+
|
|
168
|
+
const contract = this.contract as {
|
|
169
|
+
storageHash: string;
|
|
170
|
+
executionHash?: string | null;
|
|
171
|
+
profileHash?: string | null;
|
|
172
|
+
};
|
|
173
|
+
if (marker.storageHash !== contract.storageHash) {
|
|
174
|
+
throw runtimeError(
|
|
175
|
+
'CONTRACT.MARKER_MISMATCH',
|
|
176
|
+
'Database storage hash does not match contract',
|
|
177
|
+
{
|
|
178
|
+
expected: contract.storageHash,
|
|
179
|
+
actual: marker.storageHash,
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const expectedProfile = contract.profileHash ?? null;
|
|
185
|
+
if (expectedProfile !== null && marker.profileHash !== expectedProfile) {
|
|
186
|
+
throw runtimeError(
|
|
187
|
+
'CONTRACT.MARKER_MISMATCH',
|
|
188
|
+
'Database profile hash does not match contract',
|
|
189
|
+
{
|
|
190
|
+
expectedProfile,
|
|
191
|
+
actualProfile: marker.profileHash,
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.verified = true;
|
|
197
|
+
this.startupVerified = true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private validatePlan(plan: ExecutionPlan): void {
|
|
201
|
+
this.familyAdapter.validatePlan(plan, this.contract);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private recordTelemetry(
|
|
205
|
+
plan: ExecutionPlan,
|
|
206
|
+
outcome: TelemetryOutcome,
|
|
207
|
+
durationMs?: number,
|
|
208
|
+
): void {
|
|
209
|
+
const contract = this.contract as { target: string };
|
|
210
|
+
this._telemetry = Object.freeze({
|
|
211
|
+
lane: plan.meta.lane,
|
|
212
|
+
target: contract.target,
|
|
213
|
+
fingerprint: computeSqlFingerprint(plan.sql),
|
|
214
|
+
outcome,
|
|
215
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
execute<Row = Record<string, unknown>>(plan: ExecutionPlan<Row>): AsyncIterableResult<Row> {
|
|
220
|
+
return this.#executeWith(plan, this.driver as Queryable);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async connection(): Promise<RuntimeConnection> {
|
|
224
|
+
const driver = this.driver as unknown as DriverWithConnection<TDriver>;
|
|
225
|
+
const driverConn = await driver.acquireConnection();
|
|
226
|
+
const self = this;
|
|
227
|
+
|
|
228
|
+
const runtimeConnection: RuntimeConnection = {
|
|
229
|
+
async transaction(): Promise<RuntimeTransaction> {
|
|
230
|
+
const driverTx = await driverConn.beginTransaction();
|
|
231
|
+
const runtimeTx: RuntimeTransaction = {
|
|
232
|
+
async commit(): Promise<void> {
|
|
233
|
+
await driverTx.commit();
|
|
234
|
+
},
|
|
235
|
+
async rollback(): Promise<void> {
|
|
236
|
+
await driverTx.rollback();
|
|
237
|
+
},
|
|
238
|
+
execute<Row = Record<string, unknown>>(
|
|
239
|
+
plan: ExecutionPlan<Row>,
|
|
240
|
+
): AsyncIterableResult<Row> {
|
|
241
|
+
return self.#executeWith(plan, driverTx);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
return runtimeTx;
|
|
245
|
+
},
|
|
246
|
+
execute<Row = Record<string, unknown>>(plan: ExecutionPlan<Row>): AsyncIterableResult<Row> {
|
|
247
|
+
return self.#executeWith(plan, driverConn);
|
|
248
|
+
},
|
|
249
|
+
async release(): Promise<void> {
|
|
250
|
+
await driverConn.release();
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return runtimeConnection;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
telemetry(): RuntimeTelemetryEvent | null {
|
|
258
|
+
return this._telemetry;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
operations(): OperationRegistry {
|
|
262
|
+
return this.operationRegistry;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
close(): Promise<void> {
|
|
266
|
+
const driver = this.driver as unknown as DriverWithClose<TDriver>;
|
|
267
|
+
if (typeof driver.close === 'function') {
|
|
268
|
+
return driver.close();
|
|
269
|
+
}
|
|
270
|
+
return Promise.resolve();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#executeWith<Row = Record<string, unknown>>(
|
|
274
|
+
plan: ExecutionPlan<Row>,
|
|
275
|
+
queryable: Queryable,
|
|
276
|
+
): AsyncIterableResult<Row> {
|
|
277
|
+
this.validatePlan(plan);
|
|
278
|
+
this._telemetry = null;
|
|
279
|
+
|
|
280
|
+
const iterator = async function* (
|
|
281
|
+
self: RuntimeCoreImpl<TContract, TAdapter, TDriver>,
|
|
282
|
+
): AsyncGenerator<Row, void, unknown> {
|
|
283
|
+
const startedAt = Date.now();
|
|
284
|
+
let rowCount = 0;
|
|
285
|
+
let completed = false;
|
|
286
|
+
|
|
287
|
+
if (!self.startupVerified && self.verify.mode === 'startup') {
|
|
288
|
+
await self.verifyPlanIfNeeded(plan);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (self.verify.mode === 'onFirstUse') {
|
|
292
|
+
await self.verifyPlanIfNeeded(plan);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
if (self.verify.mode === 'always') {
|
|
297
|
+
await self.verifyPlanIfNeeded(plan);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const plugin of self.plugins) {
|
|
301
|
+
if (plugin.beforeExecute) {
|
|
302
|
+
await plugin.beforeExecute(plan, self.pluginContext);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const encodedParams = plan.params;
|
|
307
|
+
|
|
308
|
+
for await (const row of queryable.execute<Record<string, unknown>>({
|
|
309
|
+
sql: plan.sql,
|
|
310
|
+
params: encodedParams,
|
|
311
|
+
})) {
|
|
312
|
+
for (const plugin of self.plugins) {
|
|
313
|
+
if (plugin.onRow) {
|
|
314
|
+
await plugin.onRow(row, plan, self.pluginContext);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
rowCount++;
|
|
318
|
+
yield row as Row;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
completed = true;
|
|
322
|
+
self.recordTelemetry(plan, 'success', Date.now() - startedAt);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (self._telemetry === null) {
|
|
325
|
+
self.recordTelemetry(plan, 'runtime-error', Date.now() - startedAt);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const latencyMs = Date.now() - startedAt;
|
|
329
|
+
for (const plugin of self.plugins) {
|
|
330
|
+
if (plugin.afterExecute) {
|
|
331
|
+
try {
|
|
332
|
+
await plugin.afterExecute(
|
|
333
|
+
plan,
|
|
334
|
+
{ rowCount, latencyMs, completed },
|
|
335
|
+
self.pluginContext,
|
|
336
|
+
);
|
|
337
|
+
} catch {
|
|
338
|
+
// Ignore errors from afterExecute hooks
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const latencyMs = Date.now() - startedAt;
|
|
347
|
+
for (const plugin of self.plugins) {
|
|
348
|
+
if (plugin.afterExecute) {
|
|
349
|
+
await plugin.afterExecute(plan, { rowCount, latencyMs, completed }, self.pluginContext);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
return new AsyncIterableResult(iterator(this));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function createRuntimeCore<TContract = unknown, TAdapter = unknown, TDriver = unknown>(
|
|
359
|
+
options: RuntimeCoreOptions<TContract, TAdapter, TDriver>,
|
|
360
|
+
): RuntimeCore<TContract, TAdapter, TDriver> {
|
|
361
|
+
return new RuntimeCoreImpl(options);
|
|
362
|
+
}
|