@prisma-next/sql-runtime 0.3.0-pr.99.6 → 0.4.0-dev.1
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 +141 -24
- package/dist/exports-BO6Fl7yn.mjs +889 -0
- package/dist/exports-BO6Fl7yn.mjs.map +1 -0
- package/dist/index-n6z6trta.d.mts +186 -0
- package/dist/index-n6z6trta.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +3 -0
- package/dist/test/utils.d.mts +77 -0
- package/dist/test/utils.d.mts.map +1 -0
- package/dist/test/utils.mjs +221 -0
- package/dist/test/utils.mjs.map +1 -0
- package/package.json +26 -20
- package/src/codecs/decoding.ts +84 -3
- package/src/codecs/encoding.ts +5 -15
- package/src/codecs/json-schema-validation.ts +61 -0
- package/src/codecs/validation.ts +7 -6
- package/src/exports/index.ts +20 -9
- package/src/lower-sql-plan.ts +9 -9
- package/src/middleware/budgets.ts +256 -0
- package/src/middleware/lints.ts +192 -0
- package/src/middleware/sql-middleware.ts +26 -0
- package/src/sql-context.ts +357 -257
- package/src/sql-family-adapter.ts +17 -23
- package/src/sql-marker.ts +2 -2
- package/src/sql-runtime.ts +136 -61
- package/test/async-iterable-result.test.ts +42 -37
- package/test/budgets.test.ts +431 -0
- package/test/context.types.test-d.ts +18 -20
- package/test/execution-stack.test.ts +164 -0
- package/test/json-schema-validation.test.ts +571 -0
- package/test/lints.test.ts +159 -0
- package/test/mutation-default-generators.test.ts +254 -0
- package/test/parameterized-types.test.ts +181 -205
- package/test/sql-context.test.ts +301 -134
- package/test/sql-family-adapter.test.ts +37 -20
- package/test/sql-runtime.test.ts +261 -49
- package/test/utils.ts +101 -67
- package/dist/accelerate-EEKAFGN3-P6A6XJWJ.js +0 -137863
- package/dist/accelerate-EEKAFGN3-P6A6XJWJ.js.map +0 -1
- package/dist/amcheck-24VY6X5V.js +0 -13
- package/dist/amcheck-24VY6X5V.js.map +0 -1
- package/dist/bloom-VS74NLHT.js +0 -13
- package/dist/bloom-VS74NLHT.js.map +0 -1
- package/dist/btree_gin-WBC4EAAI.js +0 -13
- package/dist/btree_gin-WBC4EAAI.js.map +0 -1
- package/dist/btree_gist-UNC6QD3M.js +0 -13
- package/dist/btree_gist-UNC6QD3M.js.map +0 -1
- package/dist/chunk-3KTOEDFX.js +0 -49
- package/dist/chunk-3KTOEDFX.js.map +0 -1
- package/dist/chunk-47DZBRQC.js +0 -1280
- package/dist/chunk-47DZBRQC.js.map +0 -1
- package/dist/chunk-52N6AFZM.js +0 -133
- package/dist/chunk-52N6AFZM.js.map +0 -1
- package/dist/chunk-7D4SUZUM.js +0 -38
- package/dist/chunk-7D4SUZUM.js.map +0 -1
- package/dist/chunk-APA6GHYY.js +0 -537
- package/dist/chunk-APA6GHYY.js.map +0 -1
- package/dist/chunk-ECWIHLAT.js +0 -37
- package/dist/chunk-ECWIHLAT.js.map +0 -1
- package/dist/chunk-EI626SDC.js +0 -105
- package/dist/chunk-EI626SDC.js.map +0 -1
- package/dist/chunk-UKKOYUGL.js +0 -578
- package/dist/chunk-UKKOYUGL.js.map +0 -1
- package/dist/chunk-XPLNMXQV.js +0 -1537
- package/dist/chunk-XPLNMXQV.js.map +0 -1
- package/dist/citext-T7MXGUY7.js +0 -13
- package/dist/citext-T7MXGUY7.js.map +0 -1
- package/dist/client-5FENX6AW.js +0 -299
- package/dist/client-5FENX6AW.js.map +0 -1
- package/dist/cube-TFDQBZCI.js +0 -13
- package/dist/cube-TFDQBZCI.js.map +0 -1
- package/dist/dict_int-AEUOPGWP.js +0 -13
- package/dist/dict_int-AEUOPGWP.js.map +0 -1
- package/dist/dict_xsyn-DAAYX3FL.js +0 -13
- package/dist/dict_xsyn-DAAYX3FL.js.map +0 -1
- package/dist/dist-AQ3LWXOX.js +0 -570
- package/dist/dist-AQ3LWXOX.js.map +0 -1
- package/dist/dist-LBVX6BJW.js +0 -189
- package/dist/dist-LBVX6BJW.js.map +0 -1
- package/dist/dist-WLKUVDN2.js +0 -5127
- package/dist/dist-WLKUVDN2.js.map +0 -1
- package/dist/earthdistance-KIGTF4LE.js +0 -13
- package/dist/earthdistance-KIGTF4LE.js.map +0 -1
- package/dist/file_fdw-5N55UP6I.js +0 -13
- package/dist/file_fdw-5N55UP6I.js.map +0 -1
- package/dist/fuzzystrmatch-KN3YWBFP.js +0 -13
- package/dist/fuzzystrmatch-KN3YWBFP.js.map +0 -1
- package/dist/hstore-YX726NKN.js +0 -13
- package/dist/hstore-YX726NKN.js.map +0 -1
- package/dist/http-exception-FZY2H4OF.js +0 -8
- package/dist/http-exception-FZY2H4OF.js.map +0 -1
- package/dist/index.js +0 -30
- package/dist/index.js.map +0 -1
- package/dist/intarray-NKVXNO2D.js +0 -13
- package/dist/intarray-NKVXNO2D.js.map +0 -1
- package/dist/isn-FTEMJGEV.js +0 -13
- package/dist/isn-FTEMJGEV.js.map +0 -1
- package/dist/lo-DB7L4NGI.js +0 -13
- package/dist/lo-DB7L4NGI.js.map +0 -1
- package/dist/logger-WQ7SHNDD.js +0 -68
- package/dist/logger-WQ7SHNDD.js.map +0 -1
- package/dist/ltree-Z32TZT6W.js +0 -13
- package/dist/ltree-Z32TZT6W.js.map +0 -1
- package/dist/nodefs-NM46ACH7.js +0 -31
- package/dist/nodefs-NM46ACH7.js.map +0 -1
- package/dist/opfs-ahp-NJO33LVZ.js +0 -332
- package/dist/opfs-ahp-NJO33LVZ.js.map +0 -1
- package/dist/pageinspect-YP3IZR4X.js +0 -13
- package/dist/pageinspect-YP3IZR4X.js.map +0 -1
- package/dist/pg_buffercache-7TD5J2FB.js +0 -13
- package/dist/pg_buffercache-7TD5J2FB.js.map +0 -1
- package/dist/pg_dump-SG4KYBUB.js +0 -2492
- package/dist/pg_dump-SG4KYBUB.js.map +0 -1
- package/dist/pg_freespacemap-DZDNCPZK.js +0 -13
- package/dist/pg_freespacemap-DZDNCPZK.js.map +0 -1
- package/dist/pg_surgery-J2MUEWEP.js +0 -13
- package/dist/pg_surgery-J2MUEWEP.js.map +0 -1
- package/dist/pg_trgm-7VNQOYS6.js +0 -13
- package/dist/pg_trgm-7VNQOYS6.js.map +0 -1
- package/dist/pg_visibility-TTSIPHFL.js +0 -13
- package/dist/pg_visibility-TTSIPHFL.js.map +0 -1
- package/dist/pg_walinspect-KPFHSHRJ.js +0 -13
- package/dist/pg_walinspect-KPFHSHRJ.js.map +0 -1
- package/dist/proxy-signals-GUDAMDHV.js +0 -39
- package/dist/proxy-signals-GUDAMDHV.js.map +0 -1
- package/dist/seg-IYVDLE4O.js +0 -13
- package/dist/seg-IYVDLE4O.js.map +0 -1
- package/dist/src/codecs/decoding.d.ts +0 -4
- package/dist/src/codecs/decoding.d.ts.map +0 -1
- package/dist/src/codecs/encoding.d.ts +0 -5
- package/dist/src/codecs/encoding.d.ts.map +0 -1
- package/dist/src/codecs/validation.d.ts +0 -6
- package/dist/src/codecs/validation.d.ts.map +0 -1
- package/dist/src/exports/index.d.ts +0 -11
- package/dist/src/exports/index.d.ts.map +0 -1
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/lower-sql-plan.d.ts +0 -15
- package/dist/src/lower-sql-plan.d.ts.map +0 -1
- package/dist/src/sql-context.d.ts +0 -130
- package/dist/src/sql-context.d.ts.map +0 -1
- package/dist/src/sql-family-adapter.d.ts +0 -10
- package/dist/src/sql-family-adapter.d.ts.map +0 -1
- package/dist/src/sql-marker.d.ts +0 -22
- package/dist/src/sql-marker.d.ts.map +0 -1
- package/dist/src/sql-runtime.d.ts +0 -25
- package/dist/src/sql-runtime.d.ts.map +0 -1
- package/dist/tablefunc-EF4RCS7S.js +0 -13
- package/dist/tablefunc-EF4RCS7S.js.map +0 -1
- package/dist/tcn-3VT5BQYW.js +0 -13
- package/dist/tcn-3VT5BQYW.js.map +0 -1
- package/dist/test/utils.d.ts +0 -60
- package/dist/test/utils.d.ts.map +0 -1
- package/dist/test/utils.js +0 -24635
- package/dist/test/utils.js.map +0 -1
- package/dist/tiny-CW6F4GX6.js +0 -10
- package/dist/tiny-CW6F4GX6.js.map +0 -1
- package/dist/tsm_system_rows-ES7KNUQH.js +0 -13
- package/dist/tsm_system_rows-ES7KNUQH.js.map +0 -1
- package/dist/tsm_system_time-76WEIMBG.js +0 -13
- package/dist/tsm_system_time-76WEIMBG.js.map +0 -1
- package/dist/unaccent-7RYF3R64.js +0 -13
- package/dist/unaccent-7RYF3R64.js.map +0 -1
- package/dist/utility-Q5A254LJ-J4HTKZPT.js +0 -347
- package/dist/utility-Q5A254LJ-J4HTKZPT.js.map +0 -1
- package/dist/uuid_ossp-4ETE4FPE.js +0 -13
- package/dist/uuid_ossp-4ETE4FPE.js.map +0 -1
- package/dist/vector-74GPNV7V.js +0 -13
- package/dist/vector-74GPNV7V.js.map +0 -1
- package/src/index.ts +0 -1
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import type { ExecutionPlan, PlanMeta } from '@prisma-next/contract/types';
|
|
2
|
+
import type { AfterExecuteResult, MiddlewareContext } from '@prisma-next/runtime-executor';
|
|
3
|
+
import {
|
|
4
|
+
AggregateExpr,
|
|
5
|
+
ColumnRef,
|
|
6
|
+
DeleteAst,
|
|
7
|
+
ProjectionItem,
|
|
8
|
+
SelectAst,
|
|
9
|
+
TableSource,
|
|
10
|
+
} from '@prisma-next/sql-relational-core/ast';
|
|
11
|
+
import { timeouts } from '@prisma-next/test-utils';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { budgets } from '../src/middleware/budgets';
|
|
14
|
+
|
|
15
|
+
const userTable = TableSource.named('user');
|
|
16
|
+
const idCol = ColumnRef.of('user', 'id');
|
|
17
|
+
|
|
18
|
+
function createMiddlewareContext(
|
|
19
|
+
overrides?: Partial<MiddlewareContext<unknown>>,
|
|
20
|
+
): MiddlewareContext<unknown> {
|
|
21
|
+
return {
|
|
22
|
+
contract: {},
|
|
23
|
+
mode: 'strict' as const,
|
|
24
|
+
now: () => Date.now(),
|
|
25
|
+
log: {
|
|
26
|
+
info: vi.fn(),
|
|
27
|
+
warn: vi.fn(),
|
|
28
|
+
error: vi.fn(),
|
|
29
|
+
},
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const baseMeta: PlanMeta = {
|
|
35
|
+
target: 'postgres',
|
|
36
|
+
storageHash: 'sha256:test',
|
|
37
|
+
lane: 'dsl',
|
|
38
|
+
paramDescriptors: [],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type PlanOverrides = Partial<Omit<ExecutionPlan, 'meta'>> & { meta?: Partial<PlanMeta> };
|
|
42
|
+
|
|
43
|
+
function createPlan(overrides: PlanOverrides): ExecutionPlan {
|
|
44
|
+
const { meta: metaOverrides, ...rest } = overrides;
|
|
45
|
+
return {
|
|
46
|
+
sql: 'SELECT 1',
|
|
47
|
+
params: [],
|
|
48
|
+
meta: { ...baseMeta, ...(metaOverrides ?? {}) } as unknown as PlanMeta,
|
|
49
|
+
...rest,
|
|
50
|
+
} as unknown as ExecutionPlan;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('budgets middleware', () => {
|
|
54
|
+
describe('heuristic row budget (no AST)', () => {
|
|
55
|
+
it(
|
|
56
|
+
'throws for unbounded raw SELECT exceeding budget',
|
|
57
|
+
async () => {
|
|
58
|
+
const plan = createPlan({
|
|
59
|
+
sql: 'SELECT id, email FROM "user"',
|
|
60
|
+
meta: { refs: { tables: ['user'] } },
|
|
61
|
+
});
|
|
62
|
+
const mw = budgets({ maxRows: 50, defaultTableRows: 10_000 });
|
|
63
|
+
const ctx = createMiddlewareContext();
|
|
64
|
+
|
|
65
|
+
await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
66
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
67
|
+
category: 'BUDGET',
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
timeouts.default,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
it(
|
|
74
|
+
'throws for unbounded raw SELECT even without table refs',
|
|
75
|
+
async () => {
|
|
76
|
+
const plan = createPlan({
|
|
77
|
+
sql: 'SELECT 1',
|
|
78
|
+
});
|
|
79
|
+
const mw = budgets({ maxRows: 50 });
|
|
80
|
+
const ctx = createMiddlewareContext();
|
|
81
|
+
|
|
82
|
+
await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
83
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
84
|
+
category: 'BUDGET',
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
timeouts.default,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
it(
|
|
91
|
+
'allows bounded raw SELECT with limit annotation within budget',
|
|
92
|
+
async () => {
|
|
93
|
+
const plan = createPlan({
|
|
94
|
+
sql: 'SELECT id FROM "user" LIMIT 5',
|
|
95
|
+
meta: {
|
|
96
|
+
refs: { tables: ['user'] },
|
|
97
|
+
annotations: { limit: 5 },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const mw = budgets({ maxRows: 10_000, defaultTableRows: 10_000 });
|
|
101
|
+
const ctx = createMiddlewareContext();
|
|
102
|
+
|
|
103
|
+
await mw.beforeExecute?.(plan, ctx);
|
|
104
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
105
|
+
},
|
|
106
|
+
timeouts.default,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
it(
|
|
110
|
+
'throws when estimated rows exceed budget for bounded query',
|
|
111
|
+
async () => {
|
|
112
|
+
const plan = createPlan({
|
|
113
|
+
sql: 'SELECT id FROM "user" LIMIT 500',
|
|
114
|
+
meta: {
|
|
115
|
+
refs: { tables: ['user'] },
|
|
116
|
+
annotations: { limit: 500 },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const mw = budgets({ maxRows: 50, defaultTableRows: 10_000 });
|
|
120
|
+
const ctx = createMiddlewareContext();
|
|
121
|
+
|
|
122
|
+
await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
123
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
124
|
+
category: 'BUDGET',
|
|
125
|
+
details: expect.objectContaining({ source: 'heuristic' }),
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
timeouts.default,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it(
|
|
132
|
+
'uses tableRows config for estimation',
|
|
133
|
+
async () => {
|
|
134
|
+
const plan = createPlan({
|
|
135
|
+
sql: 'SELECT id FROM "user"',
|
|
136
|
+
meta: { refs: { tables: ['user'] } },
|
|
137
|
+
});
|
|
138
|
+
const mw = budgets({ maxRows: 100, tableRows: { user: 50 } });
|
|
139
|
+
const ctx = createMiddlewareContext();
|
|
140
|
+
|
|
141
|
+
await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
142
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
timeouts.default,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
it(
|
|
149
|
+
'does not check row budget for non-SELECT statements',
|
|
150
|
+
async () => {
|
|
151
|
+
const plan = createPlan({
|
|
152
|
+
sql: 'INSERT INTO "user" (id, email) VALUES ($1, $2)',
|
|
153
|
+
});
|
|
154
|
+
const mw = budgets({ maxRows: 1 });
|
|
155
|
+
const ctx = createMiddlewareContext();
|
|
156
|
+
|
|
157
|
+
await mw.beforeExecute?.(plan, ctx);
|
|
158
|
+
},
|
|
159
|
+
timeouts.default,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('observed row count (onRow)', () => {
|
|
164
|
+
it(
|
|
165
|
+
'throws when observed rows exceed budget',
|
|
166
|
+
async () => {
|
|
167
|
+
const mw = budgets({ maxRows: 2 });
|
|
168
|
+
const plan = createPlan({
|
|
169
|
+
sql: 'INSERT INTO "user" (id) VALUES ($1)',
|
|
170
|
+
});
|
|
171
|
+
const ctx = createMiddlewareContext();
|
|
172
|
+
|
|
173
|
+
await mw.beforeExecute?.(plan, ctx);
|
|
174
|
+
await mw.onRow?.({}, plan, ctx);
|
|
175
|
+
await mw.onRow?.({}, plan, ctx);
|
|
176
|
+
await expect(mw.onRow?.({}, plan, ctx)).rejects.toMatchObject({
|
|
177
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
178
|
+
details: expect.objectContaining({ source: 'observed' }),
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
timeouts.default,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
it(
|
|
185
|
+
'tracks row counts independently per execution plan',
|
|
186
|
+
async () => {
|
|
187
|
+
const mw = budgets({ maxRows: 2 });
|
|
188
|
+
const planA = createPlan({ sql: 'INSERT INTO "user" (id) VALUES ($1)' });
|
|
189
|
+
const planB = createPlan({ sql: 'INSERT INTO "user" (id) VALUES ($2)' });
|
|
190
|
+
const ctxA = createMiddlewareContext();
|
|
191
|
+
const ctxB = createMiddlewareContext();
|
|
192
|
+
|
|
193
|
+
await mw.beforeExecute?.(planA, ctxA);
|
|
194
|
+
await mw.beforeExecute?.(planB, ctxB);
|
|
195
|
+
|
|
196
|
+
await mw.onRow?.({}, planA, ctxA);
|
|
197
|
+
await mw.onRow?.({}, planB, ctxB);
|
|
198
|
+
await mw.onRow?.({}, planA, ctxA);
|
|
199
|
+
await mw.onRow?.({}, planB, ctxB);
|
|
200
|
+
|
|
201
|
+
await expect(mw.onRow?.({}, planA, ctxA)).rejects.toMatchObject({
|
|
202
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
203
|
+
});
|
|
204
|
+
await expect(mw.onRow?.({}, planB, ctxB)).rejects.toMatchObject({
|
|
205
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
timeouts.default,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('latency budget (afterExecute)', () => {
|
|
213
|
+
it(
|
|
214
|
+
'warns when latency exceeds budget in non-strict mode',
|
|
215
|
+
async () => {
|
|
216
|
+
const mw = budgets({ maxLatencyMs: 100, severities: { latency: 'warn' } });
|
|
217
|
+
const plan = createPlan({ sql: 'SELECT 1', meta: { annotations: { limit: 1 } } });
|
|
218
|
+
const ctx = createMiddlewareContext({ mode: 'permissive' });
|
|
219
|
+
const result: AfterExecuteResult = { rowCount: 1, latencyMs: 200, completed: true };
|
|
220
|
+
|
|
221
|
+
await mw.afterExecute?.(plan, result, ctx);
|
|
222
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
223
|
+
expect.objectContaining({ code: 'BUDGET.TIME_EXCEEDED' }),
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
timeouts.default,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
it(
|
|
230
|
+
'throws when latency exceeds budget in strict mode with error severity',
|
|
231
|
+
async () => {
|
|
232
|
+
const mw = budgets({ maxLatencyMs: 100, severities: { latency: 'error' } });
|
|
233
|
+
const plan = createPlan({ sql: 'SELECT 1', meta: { annotations: { limit: 1 } } });
|
|
234
|
+
const ctx = createMiddlewareContext({ mode: 'strict' });
|
|
235
|
+
const result: AfterExecuteResult = { rowCount: 1, latencyMs: 200, completed: true };
|
|
236
|
+
|
|
237
|
+
await expect(mw.afterExecute?.(plan, result, ctx)).rejects.toMatchObject({
|
|
238
|
+
code: 'BUDGET.TIME_EXCEEDED',
|
|
239
|
+
category: 'BUDGET',
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
timeouts.default,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
it(
|
|
246
|
+
'throws when latency exceeds budget in strict mode even with warn severity',
|
|
247
|
+
async () => {
|
|
248
|
+
const mw = budgets({ maxLatencyMs: 100, severities: { latency: 'warn' } });
|
|
249
|
+
const plan = createPlan({ sql: 'SELECT 1', meta: { annotations: { limit: 1 } } });
|
|
250
|
+
const ctx = createMiddlewareContext({ mode: 'strict' });
|
|
251
|
+
const result: AfterExecuteResult = { rowCount: 1, latencyMs: 200, completed: true };
|
|
252
|
+
|
|
253
|
+
await expect(mw.afterExecute?.(plan, result, ctx)).rejects.toMatchObject({
|
|
254
|
+
code: 'BUDGET.TIME_EXCEEDED',
|
|
255
|
+
category: 'BUDGET',
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
timeouts.default,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
it(
|
|
262
|
+
'does not warn when latency is within budget',
|
|
263
|
+
async () => {
|
|
264
|
+
const mw = budgets({ maxLatencyMs: 1000 });
|
|
265
|
+
const plan = createPlan({ sql: 'SELECT 1', meta: { annotations: { limit: 1 } } });
|
|
266
|
+
const ctx = createMiddlewareContext();
|
|
267
|
+
const result: AfterExecuteResult = { rowCount: 1, latencyMs: 50, completed: true };
|
|
268
|
+
|
|
269
|
+
await mw.afterExecute?.(plan, result, ctx);
|
|
270
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
271
|
+
},
|
|
272
|
+
timeouts.default,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('severity configuration', () => {
|
|
277
|
+
it(
|
|
278
|
+
'warns instead of throwing when rowCount severity is warn and mode is permissive',
|
|
279
|
+
async () => {
|
|
280
|
+
const plan = createPlan({
|
|
281
|
+
sql: 'SELECT id FROM "user"',
|
|
282
|
+
meta: { refs: { tables: ['user'] } },
|
|
283
|
+
});
|
|
284
|
+
const mw = budgets({
|
|
285
|
+
maxRows: 50,
|
|
286
|
+
defaultTableRows: 10_000,
|
|
287
|
+
severities: { rowCount: 'warn' },
|
|
288
|
+
});
|
|
289
|
+
const ctx = createMiddlewareContext({ mode: 'permissive' });
|
|
290
|
+
|
|
291
|
+
await mw.beforeExecute?.(plan, ctx);
|
|
292
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
293
|
+
expect.objectContaining({ code: 'BUDGET.ROWS_EXCEEDED' }),
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
timeouts.default,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('AST-based row budget', () => {
|
|
301
|
+
it(
|
|
302
|
+
'allows bounded SelectAst with limit within budget',
|
|
303
|
+
async () => {
|
|
304
|
+
const ast = SelectAst.from(userTable)
|
|
305
|
+
.withProjection([ProjectionItem.of('id', idCol)])
|
|
306
|
+
.withLimit(5);
|
|
307
|
+
const plan = createPlan({
|
|
308
|
+
ast,
|
|
309
|
+
meta: { refs: { tables: ['user'] } },
|
|
310
|
+
});
|
|
311
|
+
const mw = budgets({ maxRows: 10_000, defaultTableRows: 10_000 });
|
|
312
|
+
const ctx = createMiddlewareContext();
|
|
313
|
+
|
|
314
|
+
await mw.beforeExecute?.(plan, ctx);
|
|
315
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
316
|
+
},
|
|
317
|
+
timeouts.default,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
it(
|
|
321
|
+
'throws for unbounded SelectAst without limit',
|
|
322
|
+
async () => {
|
|
323
|
+
const ast = SelectAst.from(userTable).withProjection([ProjectionItem.of('id', idCol)]);
|
|
324
|
+
const plan = createPlan({
|
|
325
|
+
ast,
|
|
326
|
+
meta: { refs: { tables: ['user'] } },
|
|
327
|
+
});
|
|
328
|
+
const mw = budgets({ maxRows: 50, defaultTableRows: 10_000 });
|
|
329
|
+
const ctx = createMiddlewareContext();
|
|
330
|
+
|
|
331
|
+
await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
332
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
333
|
+
category: 'BUDGET',
|
|
334
|
+
details: expect.objectContaining({ source: 'ast' }),
|
|
335
|
+
});
|
|
336
|
+
},
|
|
337
|
+
timeouts.default,
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
it(
|
|
341
|
+
'throws for unbounded SelectAst without table refs',
|
|
342
|
+
async () => {
|
|
343
|
+
const ast = SelectAst.from(userTable).withProjection([ProjectionItem.of('id', idCol)]);
|
|
344
|
+
const plan = createPlan({ ast });
|
|
345
|
+
const mw = budgets({ maxRows: 50 });
|
|
346
|
+
const ctx = createMiddlewareContext();
|
|
347
|
+
|
|
348
|
+
await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
349
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
350
|
+
details: expect.objectContaining({ source: 'ast' }),
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
timeouts.default,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
it(
|
|
357
|
+
'reads limit from AST, not from annotations',
|
|
358
|
+
async () => {
|
|
359
|
+
const ast = SelectAst.from(userTable)
|
|
360
|
+
.withProjection([ProjectionItem.of('id', idCol)])
|
|
361
|
+
.withLimit(5);
|
|
362
|
+
const plan = createPlan({
|
|
363
|
+
ast,
|
|
364
|
+
meta: {
|
|
365
|
+
refs: { tables: ['user'] },
|
|
366
|
+
annotations: { limit: 99999 },
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
const mw = budgets({ maxRows: 10_000, defaultTableRows: 10_000 });
|
|
370
|
+
const ctx = createMiddlewareContext();
|
|
371
|
+
|
|
372
|
+
await mw.beforeExecute?.(plan, ctx);
|
|
373
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
374
|
+
},
|
|
375
|
+
timeouts.default,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
it(
|
|
379
|
+
'does not check row budget for non-SelectAst (e.g. DeleteAst)',
|
|
380
|
+
async () => {
|
|
381
|
+
const ast = DeleteAst.from(userTable);
|
|
382
|
+
const plan = createPlan({ ast });
|
|
383
|
+
const mw = budgets({ maxRows: 1 });
|
|
384
|
+
const ctx = createMiddlewareContext();
|
|
385
|
+
|
|
386
|
+
await mw.beforeExecute?.(plan, ctx);
|
|
387
|
+
},
|
|
388
|
+
timeouts.default,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
it(
|
|
392
|
+
'estimates 1 row for aggregate without GROUP BY',
|
|
393
|
+
async () => {
|
|
394
|
+
const ast = SelectAst.from(userTable).withProjection([
|
|
395
|
+
ProjectionItem.of('count', AggregateExpr.count()),
|
|
396
|
+
]);
|
|
397
|
+
const plan = createPlan({
|
|
398
|
+
ast,
|
|
399
|
+
meta: { refs: { tables: ['user'] } },
|
|
400
|
+
});
|
|
401
|
+
const mw = budgets({ maxRows: 1, defaultTableRows: 10_000 });
|
|
402
|
+
const ctx = createMiddlewareContext();
|
|
403
|
+
|
|
404
|
+
await mw.beforeExecute?.(plan, ctx);
|
|
405
|
+
expect(ctx.log.warn).not.toHaveBeenCalled();
|
|
406
|
+
},
|
|
407
|
+
timeouts.default,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
it(
|
|
411
|
+
'does not reduce estimate for aggregate with GROUP BY',
|
|
412
|
+
async () => {
|
|
413
|
+
const ast = SelectAst.from(userTable)
|
|
414
|
+
.withProjection([ProjectionItem.of('count', AggregateExpr.count())])
|
|
415
|
+
.withGroupBy([idCol]);
|
|
416
|
+
const plan = createPlan({
|
|
417
|
+
ast,
|
|
418
|
+
meta: { refs: { tables: ['user'] } },
|
|
419
|
+
});
|
|
420
|
+
const mw = budgets({ maxRows: 50, defaultTableRows: 10_000 });
|
|
421
|
+
const ctx = createMiddlewareContext();
|
|
422
|
+
|
|
423
|
+
await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({
|
|
424
|
+
code: 'BUDGET.ROWS_EXCEEDED',
|
|
425
|
+
details: expect.objectContaining({ source: 'ast' }),
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
timeouts.default,
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Contract, StorageHashBase } from '@prisma-next/contract/types';
|
|
2
|
+
import type { SqlStorage } from '@prisma-next/sql-contract/types';
|
|
2
3
|
import { expectTypeOf, test } from 'vitest';
|
|
3
|
-
import type {
|
|
4
|
+
import type { ExecutionContext, TypeHelperRegistry } from '../src/sql-context';
|
|
4
5
|
|
|
5
6
|
// Contract type with storage.types using literal types (matching emission output)
|
|
6
|
-
type TestContract =
|
|
7
|
+
type TestContract = Contract<
|
|
7
8
|
{
|
|
9
|
+
readonly storageHash: StorageHashBase<string>;
|
|
8
10
|
readonly tables: {
|
|
9
11
|
readonly document: {
|
|
10
12
|
readonly columns: {
|
|
@@ -28,28 +30,24 @@ type TestContract = SqlContract<
|
|
|
28
30
|
};
|
|
29
31
|
};
|
|
30
32
|
},
|
|
31
|
-
Record<string, never
|
|
32
|
-
Record<string, never>,
|
|
33
|
-
SqlMappings
|
|
33
|
+
Record<string, never>
|
|
34
34
|
>;
|
|
35
35
|
|
|
36
|
-
test('
|
|
37
|
-
//
|
|
36
|
+
test('ExecutionContext.types is TypeHelperRegistry', () => {
|
|
37
|
+
// ExecutionContext.types is intentionally loose (Record<string, unknown>)
|
|
38
38
|
// The strong typing comes from schema(context).types via ExtractSchemaTypes
|
|
39
|
-
expectTypeOf<
|
|
40
|
-
TypeHelperRegistry | undefined
|
|
41
|
-
>();
|
|
39
|
+
expectTypeOf<ExecutionContext<TestContract>['types']>().toEqualTypeOf<TypeHelperRegistry>();
|
|
42
40
|
|
|
43
41
|
// TypeHelperRegistry allows any values - the actual type depends on init hooks
|
|
44
42
|
expectTypeOf<TypeHelperRegistry>().toEqualTypeOf<Record<string, unknown>>();
|
|
45
43
|
});
|
|
46
44
|
|
|
47
|
-
test('
|
|
48
|
-
// Verify the contract type is preserved in
|
|
49
|
-
expectTypeOf<
|
|
45
|
+
test('ExecutionContext preserves contract type parameter', () => {
|
|
46
|
+
// Verify the contract type is preserved in ExecutionContext
|
|
47
|
+
expectTypeOf<ExecutionContext<TestContract>['contract']>().toEqualTypeOf<TestContract>();
|
|
50
48
|
|
|
51
49
|
// Verify we can access storage.types through the context's contract
|
|
52
|
-
type ContractStorageTypes =
|
|
50
|
+
type ContractStorageTypes = ExecutionContext<TestContract>['contract']['storage']['types'];
|
|
53
51
|
expectTypeOf<ContractStorageTypes>().toExtend<
|
|
54
52
|
| {
|
|
55
53
|
readonly Vector1536: {
|
|
@@ -62,9 +60,9 @@ test('RuntimeContext preserves contract type parameter', () => {
|
|
|
62
60
|
>();
|
|
63
61
|
});
|
|
64
62
|
|
|
65
|
-
test('
|
|
66
|
-
// Verify
|
|
67
|
-
type DefaultContext =
|
|
68
|
-
expectTypeOf<DefaultContext['contract']>().toExtend<
|
|
69
|
-
expectTypeOf<DefaultContext['types']>().toEqualTypeOf<TypeHelperRegistry
|
|
63
|
+
test('ExecutionContext accepts generic Contract', () => {
|
|
64
|
+
// Verify ExecutionContext defaults work
|
|
65
|
+
type DefaultContext = ExecutionContext;
|
|
66
|
+
expectTypeOf<DefaultContext['contract']>().toExtend<Contract<SqlStorage>>();
|
|
67
|
+
expectTypeOf<DefaultContext['types']>().toEqualTypeOf<TypeHelperRegistry>();
|
|
70
68
|
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createExecutionStack } from '@prisma-next/framework-components/execution';
|
|
2
|
+
import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { createExecutionContext, createSqlExecutionStack } from '../src/exports';
|
|
5
|
+
import type {
|
|
6
|
+
ExecutionContext,
|
|
7
|
+
SqlRuntimeAdapterDescriptor,
|
|
8
|
+
SqlRuntimeExtensionDescriptor,
|
|
9
|
+
SqlRuntimeTargetDescriptor,
|
|
10
|
+
} from '../src/sql-context';
|
|
11
|
+
import { createTestContract } from './utils';
|
|
12
|
+
|
|
13
|
+
function createStubAdapterDescriptor(): SqlRuntimeAdapterDescriptor<'postgres'> {
|
|
14
|
+
const registry = createCodecRegistry();
|
|
15
|
+
registry.register(
|
|
16
|
+
codec({
|
|
17
|
+
typeId: 'pg/text@1',
|
|
18
|
+
targetTypes: ['text'],
|
|
19
|
+
encode: (value: string) => value,
|
|
20
|
+
decode: (wire: string) => wire,
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
kind: 'adapter',
|
|
26
|
+
id: 'test-adapter',
|
|
27
|
+
version: '0.0.1',
|
|
28
|
+
familyId: 'sql' as const,
|
|
29
|
+
targetId: 'postgres' as const,
|
|
30
|
+
codecs: () => registry,
|
|
31
|
+
parameterizedCodecs: () => [],
|
|
32
|
+
create() {
|
|
33
|
+
return Object.assign(
|
|
34
|
+
{ familyId: 'sql' as const, targetId: 'postgres' as const },
|
|
35
|
+
{
|
|
36
|
+
profile: {
|
|
37
|
+
id: 'test-profile',
|
|
38
|
+
target: 'postgres',
|
|
39
|
+
capabilities: {},
|
|
40
|
+
codecs: () => registry,
|
|
41
|
+
readMarkerStatement: () => ({ sql: '', params: [] }),
|
|
42
|
+
},
|
|
43
|
+
lower() {
|
|
44
|
+
return {
|
|
45
|
+
profileId: 'test-profile',
|
|
46
|
+
body: Object.freeze({ sql: '', params: [] }),
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createStubTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> {
|
|
56
|
+
return {
|
|
57
|
+
kind: 'target',
|
|
58
|
+
id: 'postgres',
|
|
59
|
+
version: '0.0.1',
|
|
60
|
+
familyId: 'sql' as const,
|
|
61
|
+
targetId: 'postgres' as const,
|
|
62
|
+
codecs: () => createCodecRegistry(),
|
|
63
|
+
parameterizedCodecs: () => [],
|
|
64
|
+
create() {
|
|
65
|
+
return { familyId: 'sql' as const, targetId: 'postgres' as const };
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createStubExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postgres'> {
|
|
71
|
+
const registry = createCodecRegistry();
|
|
72
|
+
registry.register(
|
|
73
|
+
codec({
|
|
74
|
+
typeId: 'pg/uuid@1',
|
|
75
|
+
targetTypes: ['uuid'],
|
|
76
|
+
encode: (value: string) => value,
|
|
77
|
+
decode: (wire: string) => wire,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const operations = [
|
|
82
|
+
{
|
|
83
|
+
method: 'example',
|
|
84
|
+
args: [] as const,
|
|
85
|
+
returns: { codecId: 'pg/text@1', nullable: false },
|
|
86
|
+
lowering: {
|
|
87
|
+
targetFamily: 'sql' as const,
|
|
88
|
+
strategy: 'function' as const,
|
|
89
|
+
template: 'example({args})',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
kind: 'extension',
|
|
96
|
+
id: 'test-extension',
|
|
97
|
+
version: '0.0.1',
|
|
98
|
+
familyId: 'sql' as const,
|
|
99
|
+
targetId: 'postgres' as const,
|
|
100
|
+
codecs: () => registry,
|
|
101
|
+
queryOperations: () => operations,
|
|
102
|
+
parameterizedCodecs: () => [],
|
|
103
|
+
create() {
|
|
104
|
+
return {
|
|
105
|
+
familyId: 'sql' as const,
|
|
106
|
+
targetId: 'postgres' as const,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe('createExecutionStack', () => {
|
|
113
|
+
it('defaults driver to undefined and extensions to empty', () => {
|
|
114
|
+
const stack = createExecutionStack({
|
|
115
|
+
target: createStubTargetDescriptor(),
|
|
116
|
+
adapter: createStubAdapterDescriptor(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(stack.driver).toBeUndefined();
|
|
120
|
+
expect(stack.extensionPacks).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('creates an execution context from descriptors-only stack', () => {
|
|
124
|
+
const contract = createTestContract({
|
|
125
|
+
storage: { tables: {} },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const context = createExecutionContext({
|
|
129
|
+
contract,
|
|
130
|
+
stack: {
|
|
131
|
+
target: createStubTargetDescriptor(),
|
|
132
|
+
adapter: createStubAdapterDescriptor(),
|
|
133
|
+
extensionPacks: [createStubExtensionDescriptor()],
|
|
134
|
+
},
|
|
135
|
+
}) as ExecutionContext<typeof contract>;
|
|
136
|
+
|
|
137
|
+
expect(context.contract).toBe(contract);
|
|
138
|
+
expect(context.codecs.get('pg/text@1')).toBeDefined();
|
|
139
|
+
expect(context.codecs.get('pg/uuid@1')).toBeDefined();
|
|
140
|
+
expect(context.queryOperations.entries()['example']).toBeDefined();
|
|
141
|
+
expect(context.types).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('createSqlExecutionStack', () => {
|
|
146
|
+
it('preserves descriptor references and defaults extensions', () => {
|
|
147
|
+
const target = createStubTargetDescriptor();
|
|
148
|
+
const adapter = createStubAdapterDescriptor();
|
|
149
|
+
const stack = createSqlExecutionStack({ target, adapter });
|
|
150
|
+
|
|
151
|
+
expect(stack.target).toBe(target);
|
|
152
|
+
expect(stack.adapter).toBe(adapter);
|
|
153
|
+
expect(stack.extensionPacks).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps extension packs intact', () => {
|
|
157
|
+
const target = createStubTargetDescriptor();
|
|
158
|
+
const adapter = createStubAdapterDescriptor();
|
|
159
|
+
const extension = createStubExtensionDescriptor();
|
|
160
|
+
const stack = createSqlExecutionStack({ target, adapter, extensionPacks: [extension] });
|
|
161
|
+
|
|
162
|
+
expect(stack.extensionPacks).toEqual([extension]);
|
|
163
|
+
});
|
|
164
|
+
});
|