@prisma-next/sql-runtime 0.3.0-dev.10 → 0.3.0-dev.113

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