cursedbelt-server 4.1.0 โ†’ 4.3.0

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 (35) hide show
  1. package/dist/server/bench/assert.d.ts +16 -3
  2. package/dist/server/bench/assert.js +54 -0
  3. package/dist/server/bench/budget.d.ts +35 -1
  4. package/dist/server/bench/budget.js +35 -1
  5. package/dist/server/bench/cpuClock.js +21 -1
  6. package/dist/server/bench/index.d.ts +1 -1
  7. package/dist/server/bench/index.js +1 -1
  8. package/dist/server/d1/fakeD1.d.ts +5 -0
  9. package/dist/server/d1/fakeD1.js +51 -18
  10. package/dist/server/d1/index.d.ts +1 -0
  11. package/dist/server/d1/index.js +1 -0
  12. package/dist/server/d1/invocation.d.ts +72 -0
  13. package/dist/server/d1/invocation.js +166 -0
  14. package/dist/server/d1/local.js +48 -3
  15. package/dist/server/d1/values.d.ts +19 -2
  16. package/dist/server/d1/values.js +21 -2
  17. package/dist/server/middleware/bodyLimit.d.ts +152 -0
  18. package/dist/server/middleware/bodyLimit.js +161 -0
  19. package/package.json +8 -2
  20. package/src/leafSubpathsImportNothing.spec.ts +13 -0
  21. package/src/server/bench/assert.ts +78 -3
  22. package/src/server/bench/budget.spec.ts +27 -0
  23. package/src/server/bench/budget.ts +36 -1
  24. package/src/server/bench/cpuBudget.spec.ts +101 -1
  25. package/src/server/bench/cpuClock.ts +22 -1
  26. package/src/server/bench/index.ts +1 -0
  27. package/src/server/d1/fakeD1.ts +56 -18
  28. package/src/server/d1/index.ts +1 -0
  29. package/src/server/d1/invocation.spec.ts +174 -0
  30. package/src/server/d1/invocation.ts +207 -0
  31. package/src/server/d1/local.ts +56 -3
  32. package/src/server/d1/sameShape.spec.ts +73 -0
  33. package/src/server/d1/values.ts +23 -2
  34. package/src/server/middleware/bodyLimit.spec.ts +238 -0
  35. package/src/server/middleware/bodyLimit.ts +210 -0
@@ -26,6 +26,10 @@
26
26
  * which is the owner's estimate and not a measurement. {@link deriveCpuBudgetMs} is the
27
27
  * function to re-run against real traffic โ€” the re-derivation is a call, not a paragraph
28
28
  * somebody has to remember to do.
29
+ *
30
+ * ๐Ÿ”ด **It has already been run once** โ€” see {@link MEASURED_FLEET_REQUESTS_PER_DAY}, which
31
+ * carries both the number and the reason the default was left stricter than it. Do not
32
+ * spend that measurement again; what is still missing is Worker traffic, not Mac traffic.
29
33
  */
30
34
 
31
35
  /** CPU-milliseconds included in the Workers Paid plan each month. */
@@ -49,6 +53,35 @@ export const OWNER_PROJECTED_REQUESTS_PER_DAY = 100_000;
49
53
  /** 365.25 / 12 โ€” the average calendar month, since billing is monthly. */
50
54
  export const DAYS_PER_MONTH = 30.4375;
51
55
 
56
+ /**
57
+ * ๐Ÿ”ด The re-derivation has been DONE โ€” 2026-09-16 โ€” and the answer was not 9.9. This
58
+ * constant exists so the next reader does not spend the measurement again.
59
+ *
60
+ * Summed from the six apps still holding a populated `request_metrics` table in
61
+ * `$FORGE_STATE/apps/<app>/metrics.sqlite` (rows รท retained window, since five of the
62
+ * six sit at a ~100,4xx retention cap and only `patterns` is a true count):
63
+ *
64
+ * ```
65
+ * family 15,489/d ยท roms 10,580/d ยท music 7,034/d ยท vault 5,690/d
66
+ * collections 3,737/d ยท patterns 442/d โ†’ 42,971/day
67
+ * ```
68
+ *
69
+ * That is **43 % of {@link OWNER_PROJECTED_REQUESTS_PER_DAY}**, so the real per-request
70
+ * allowance is ~22.9 CPU-ms rather than 9.9 โ€” 2.3ร— looser than the shipped default.
71
+ *
72
+ * ๐Ÿ”ด **The default was deliberately NOT raised to it**, and the reason is the corpus:
73
+ * every one of those tables stops within hours of its app's graduation (`roms` ends
74
+ * `2026-09-15 12:00:03`), because `requestLogger` has no callers in this generation
75
+ * yet. It is the RETIRED generation's traffic, and no app is on a Worker at all โ€” so
76
+ * nothing here has been measured against the quantity Cloudflare actually bills.
77
+ * Loosening every budget in the fleet on evidence that cannot support it is the one
78
+ * direction a wrong guess costs money in; being 2.3ร— conservative costs nothing.
79
+ *
80
+ * `budget.spec.ts` holds that ordering as an assertion rather than as this paragraph:
81
+ * the default may never drift ABOVE the measured allowance without a red build.
82
+ */
83
+ export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
84
+
52
85
  /**
53
86
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
54
87
  * included CPU allowance.
@@ -100,7 +133,9 @@ export function monthlyOverageUsd(opts: {
100
133
  * 9.9 CPU-ms โ€” `30,000,000 รท (100,000 ร— 30.4375)` = 9.856, to one decimal.
101
134
  *
102
135
  * Generous for a JSON route, tight for anything that renders, parses or derives a key.
103
- * ๐Ÿ”ด Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
136
+ *
137
+ * ๐Ÿ”ด Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
138
+ * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
104
139
  */
105
140
  export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
106
141
 
@@ -3,7 +3,12 @@ import { Hono } from 'hono';
3
3
  import { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport } from './assert';
4
4
  import { type CpuBudgetConfig, DEFAULT_ROUTE_CPU_BUDGET_MS } from './budget';
5
5
  import { cpuBudget } from './cpuBudget';
6
- import { fixedCpuClock, processCpuClock, unavailableCpuClock } from './cpuClock';
6
+ import {
7
+ fixedCpuClock,
8
+ processCpuClock,
9
+ unavailableCpuClock,
10
+ workerCpuClock,
11
+ } from './cpuClock';
7
12
  import { createCpuRecorder } from './recorder';
8
13
  import { runCpuBench } from './runBench';
9
14
 
@@ -300,3 +305,98 @@ describe('the bench runner', () => {
300
305
  await expect(runCpuBench({ app, recorder, cases: [] })).rejects.toThrow(/no cases/);
301
306
  });
302
307
  });
308
+
309
+ /**
310
+ * ๐Ÿ”ด **The third direction: a gate that CANNOT go red.**
311
+ *
312
+ * The two directions above prove the assertion fires on a real over-budget route and stays
313
+ * quiet on a real under-budget one. Both assume the report describes something that was
314
+ * measured. Measured 2026-09-17, before the guards these cases cover: an empty report, a
315
+ * constant-zero `workerCpuClock`, and a reader returning `undefined` ALL asserted green โ€”
316
+ * and the middle one did it carrying `proxy: false`, the exact flag that says "quote this
317
+ * as a Cloudflare billing fact".
318
+ *
319
+ * `cpuClock.ts`'s own header predicted it โ€” *"a clock that reads zero for ever and a gate
320
+ * that can never go red"* โ€” so this block is that paragraph turned into checks.
321
+ */
322
+ describe('direction 3 โ€” a gate that measured nothing must FAIL, not pass', () => {
323
+ it('fails an available clock that recorded no route at all', () => {
324
+ const recorder = createCpuRecorder({ clock: processCpuClock() });
325
+ const report = recorder.report();
326
+ expect(report.available).toBe(true);
327
+ expect(report.routes).toHaveLength(0);
328
+
329
+ expect(() => assertCpuBudgets(report)).toThrow(/not one route was recorded/);
330
+ // requireDeclared must not be what saves it โ€” it has no routes to require anything of.
331
+ expect(() => assertCpuBudgets(report, { requireDeclared: true })).toThrow(/no-samples|not one route/);
332
+ // The one deliberate escape hatch.
333
+ expect(assertCpuBudgets(report, { allowUnmeasurable: true })).toEqual([]);
334
+ });
335
+
336
+ it('fails a bench whose app never mounted cpuBudget() โ€” the real wiring mistake', async () => {
337
+ const recorder = createCpuRecorder({ clock: processCpuClock() });
338
+ const app = new Hono(); // ๐Ÿ”ด no app.use('*', cpuBudget({ recorder }))
339
+ app.get('/api/cheap', (c) => c.json({ ok: true }));
340
+
341
+ // The bench itself is happy: every case returns 200.
342
+ const report = await runCpuBench({ app, recorder, cases: [{ path: '/api/cheap' }] });
343
+ expect(report.routes).toHaveLength(0);
344
+ expect(() => assertCpuBudgets(report)).toThrow(/cpuBudget\(\) is mounted/);
345
+ });
346
+
347
+ it('fails a constant-zero worker reader, and says it was reported as NON-proxy', () => {
348
+ const clock = workerCpuClock(() => 0);
349
+ const recorder = createCpuRecorder({ clock });
350
+ for (let i = 0; i < 40; i += 1) recorder.record('GET', '/api/thing', clock.start()());
351
+
352
+ const report = recorder.report();
353
+ // This is the shape that makes it dangerous: it claims to be billable truth.
354
+ expect(report.proxy).toBe(false);
355
+ expect(report.routes[0].samples).toBe(40);
356
+ expect(report.routes[0].p99).toBe(0);
357
+
358
+ expect(() => assertCpuBudgets(report)).toThrow(/never moves cannot ever redden/);
359
+ expect(() => assertCpuBudgets(report)).toThrow(/NON-proxy/);
360
+ expect(assertCpuBudgets(report, { allowZeroCpu: true })).toEqual([]);
361
+ });
362
+
363
+ it('does NOT fire when any route read a real number โ€” one non-zero clears it', () => {
364
+ const recorder = createCpuRecorder({ clock: fixedCpuClock([0]) });
365
+ for (let i = 0; i < 40; i += 1) recorder.record('GET', '/api/flat', 0);
366
+ recorder.record('GET', '/api/real', 0.25);
367
+
368
+ const violations = checkCpuBudgets(recorder.report(), { minSamples: 1 });
369
+ expect(violations.map((v) => v.kind)).not.toContain('degenerate');
370
+ });
371
+
372
+ it('refuses a reader that is not callable, at wiring time', () => {
373
+ expect(() => workerCpuClock(undefined as unknown as () => number)).toThrow(
374
+ /needs a function returning CPU-ms/,
375
+ );
376
+ });
377
+
378
+ it('treats a non-numeric reading as unmeasurable, never as zero', () => {
379
+ const clock = workerCpuClock(() => undefined as unknown as number);
380
+ expect(Number.isNaN(clock.start()())).toBe(true);
381
+
382
+ // NaN is dropped by the recorder, so the run lands on the no-samples guard rather
383
+ // than reporting a confident 0.00 for every route.
384
+ const recorder = createCpuRecorder({ clock });
385
+ for (let i = 0; i < 40; i += 1) recorder.record('GET', '/api/thing', clock.start()());
386
+ expect(() => assertCpuBudgets(recorder.report())).toThrow(/finite number/);
387
+ });
388
+
389
+ it('passes a worker clock that actually moves โ€” the guards do not block a real one', () => {
390
+ let cpu = 0;
391
+ const clock = workerCpuClock(() => (cpu += 1.5), 'tail-worker cpuTime');
392
+ const recorder = createCpuRecorder({ clock, config: { routes: { 'GET /api/thing': 5 } } });
393
+ for (let i = 0; i < 40; i += 1) recorder.record('GET', '/api/thing', clock.start()());
394
+
395
+ const report = recorder.report();
396
+ expect(report.proxy).toBe(false);
397
+ expect(report.source).toBe('tail-worker cpuTime');
398
+ expect(report.routes[0].p99).toBeGreaterThan(0);
399
+ expect(assertCpuBudgets(report, { requireDeclared: true })).toEqual([]);
400
+ expect(formatCpuBudgetReport(report)).toContain('runtime-reported Worker CPU-ms');
401
+ });
402
+ });
@@ -68,13 +68,34 @@ export function processCpuClock(): CpuClock {
68
68
  * @param readCpuMs Returns monotonically non-decreasing CPU-ms for the current invocation.
69
69
  */
70
70
  export function workerCpuClock(readCpuMs: () => number, source = 'worker-runtime'): CpuClock {
71
+ // This function is the only place `proxy: false` is stamped โ€” the claim that a number may
72
+ // be quoted as a Cloudflare billing fact is made HERE, so it is checked here. A reader
73
+ // that is not callable can never be one, and finding that out at wiring time costs one
74
+ // cold start; finding it out later costs a gate that reads zero for ever.
75
+ if (typeof readCpuMs !== 'function') {
76
+ throw new TypeError(
77
+ 'workerCpuClock: needs a function returning CPU-ms for the current invocation. ' +
78
+ 'There is no built-in Cloudflare reader โ€” the isolate does not hand a handler its ' +
79
+ 'own CPU time, so this comes from a tail worker feeding cpuTime back.',
80
+ );
81
+ }
82
+ // ๐Ÿ”ด The reader is NOT called here. Cloudflare's CPU readings are request-scoped, so a
83
+ // correct reader may legitimately throw or read nothing at construction; validating
84
+ // eagerly would reject the very readers this exists to accept.
71
85
  return {
72
86
  source,
73
87
  proxy: false,
74
88
  available: true,
75
89
  start(): CpuSpan {
76
90
  const before = readCpuMs();
77
- return () => Math.max(0, readCpuMs() - before);
91
+ return () => {
92
+ const after = readCpuMs();
93
+ // A non-numeric reading is unmeasurable, never zero. NaN is what
94
+ // `createCpuRecorder` drops as "not a sample"; a 0 would be recorded as a
95
+ // confident measurement of no work, which is the lie this module exists to avoid.
96
+ if (!Number.isFinite(before) || !Number.isFinite(after)) return Number.NaN;
97
+ return Math.max(0, after - before);
98
+ };
78
99
  },
79
100
  };
80
101
  }
@@ -49,6 +49,7 @@ export {
49
49
  DEFAULT_ROUTE_CPU_BUDGET_MS,
50
50
  deriveCpuBudgetMs,
51
51
  isExemption,
52
+ MEASURED_FLEET_REQUESTS_PER_DAY,
52
53
  monthlyOverageUsd,
53
54
  OWNER_PROJECTED_REQUESTS_PER_DAY,
54
55
  type ResolvedBudget,
@@ -19,6 +19,11 @@
19
19
  * precision above 2^53 without a word.
20
20
  * 4. **Results are wrapped** in `{ results, success, meta }` rather than returned bare.
21
21
  * 5. **`BEGIN`/`COMMIT` is refused** โ€” D1 has no interactive transaction.
22
+ * 6. **`batch()` reports each member's own `changes` / `last_row_id`**, because real D1
23
+ * does. ๐Ÿ”ด Until 2026-09-17 this file hardcoded `changes: 0` there and so did the
24
+ * local driver, so `sameShape.spec.ts` was green on a bug BOTH sides shared โ€” the one
25
+ * failure mode a two-implementation test cannot see. A real port of
26
+ * `apps/patterns/src/server/tokens.ts` found it on its first atomic write.
22
27
  *
23
28
  * SQL semantics are real: it runs against an actual in-memory SQLite. Only the edges are
24
29
  * emulated, which are exactly the edges under test.
@@ -31,6 +36,7 @@
31
36
 
32
37
  import type { Database } from 'bun:sqlite';
33
38
  import type { D1BindingLike, D1BindingResult, D1BindingStatement } from './remote';
39
+ import { isPureReadSql } from './values';
34
40
 
35
41
  /** The bind types a real D1 binding accepts. Everything else throws. */
36
42
  function assertD1Bindable(value: unknown, index: number): void {
@@ -135,8 +141,55 @@ class FakeD1Statement implements D1BindingStatement {
135
141
  const rows = this.db.query(this.sql).values(...(this.params as never[])) as unknown[][];
136
142
  return rows.map((r) => r.map(toWireValue));
137
143
  }
144
+
145
+ /**
146
+ * Execute synchronously and report the wire result, for `batch()`.
147
+ *
148
+ * ๐Ÿ”ด Synchronous on purpose: a `bun:sqlite` transaction callback may not await โ€” a
149
+ * promise resolved inside it settles a microtask after the transaction has already
150
+ * committed. The local driver keeps a `SyncExecutable` for exactly this reason.
151
+ *
152
+ * The counters are read the same way real D1 populates `meta`, and deliberately NOT by
153
+ * copying `local.ts`: this file is a divergence simulator, so it reaches its answer
154
+ * independently and the shared-bug case stays detectable.
155
+ */
156
+ runSync(): D1BindingResult {
157
+ assertRunnableOnD1(this.sql);
158
+ const started = performance.now();
159
+ const stmt = this.db.query(this.sql);
160
+ const bound = this.params as never[];
161
+
162
+ // No result columns โ†’ cannot return rows, and `.run()` is the only call that reports
163
+ // `changes`. This is the ordinary batch member: an INSERT, UPDATE or DELETE.
164
+ if (stmt.columnNames.length === 0) {
165
+ const res = stmt.run(...bound);
166
+ return { results: [], success: true, meta: this.meta(started, res.changes, Number(res.lastInsertRowid)) };
167
+ }
168
+
169
+ // A pure read wrote nothing. Its `changes()` would be the PREVIOUS statement's.
170
+ if (isPureReadSql(this.sql)) {
171
+ const rows = stmt.all(...bound) as Record<string, unknown>[];
172
+ return { results: rows.map(toWireRow), success: true, meta: this.meta(started, 0, 0) };
173
+ }
174
+
175
+ // Returns rows AND writes โ€” `INSERT โ€ฆ RETURNING` and friends.
176
+ const before = totalChanges(this.db);
177
+ const rows = this.db.query(this.sql).all(...bound) as Record<string, unknown>[];
178
+ const changes = totalChanges(this.db) - before;
179
+ return {
180
+ results: rows.map(toWireRow),
181
+ success: true,
182
+ meta: this.meta(started, changes, changes > 0 ? lastInsertRowid(this.db) : 0),
183
+ };
184
+ }
138
185
  }
139
186
 
187
+ /** Cumulative rows changed on this connection โ€” a difference is one statement's count. */
188
+ const totalChanges = (db: Database): number => (db.query('SELECT total_changes() AS n').get() as { n: number }).n;
189
+
190
+ /** The connection's last inserted rowid, which is what D1 puts in `meta.last_row_id`. */
191
+ const lastInsertRowid = (db: Database): number => (db.query('SELECT last_insert_rowid() AS r').get() as { r: number }).r;
192
+
140
193
  /**
141
194
  * Wrap a real `bun:sqlite` database in D1's API and D1's restrictions.
142
195
  *
@@ -154,26 +207,11 @@ export function createFakeD1Binding(db: Database): D1BindingLike {
154
207
  async batch(statements: D1BindingStatement[]): Promise<D1BindingResult[]> {
155
208
  // D1's batch is atomic โ€” it is the only atomicity D1 offers. A real `bun:sqlite`
156
209
  // transaction is the faithful local equivalent.
210
+ // ๐Ÿ”ด Each member reports its OWN `changes` / `last_row_id`, because real D1 does.
211
+ // This used to hardcode zero โ€” see note 6 in the header for what that cost.
157
212
  const results: D1BindingResult[] = [];
158
213
  const run = db.transaction((stmts: D1BindingStatement[]) => {
159
- for (const s of stmts) {
160
- const started = performance.now();
161
- const inner = s as FakeD1Statement;
162
- assertRunnableOnD1(inner.sql);
163
- const rows = db.query(inner.sql).all(...(inner.params as never[])) as Record<string, unknown>[];
164
- results.push({
165
- results: rows.map(toWireRow),
166
- success: true,
167
- meta: {
168
- duration: performance.now() - started,
169
- changes: 0,
170
- last_row_id: 0,
171
- rows_read: rows.length,
172
- rows_written: 0,
173
- served_by: 'fake-d1',
174
- },
175
- });
176
- }
214
+ for (const s of stmts) results.push((s as FakeD1Statement).runSync());
177
215
  });
178
216
  run(statements);
179
217
  return results;
@@ -26,6 +26,7 @@ export {
26
26
  type TimeTravelOpts,
27
27
  } from './backup';
28
28
  export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
29
+ export { type InvocationD1, perInvocation } from './invocation';
29
30
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
30
31
  export { createLocalD1, refuseInteractiveTransaction } from './local';
31
32
  export {
@@ -0,0 +1,174 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { beforeEach, describe, expect, test } from 'bun:test';
3
+ import { createFakeD1Binding } from './fakeD1';
4
+ import { perInvocation } from './invocation';
5
+ import { LIMITS } from './limits';
6
+ import { createLocalD1 } from './local';
7
+ import { createRemoteD1 } from './remote';
8
+ import { D1LimitError, type D1LikeDatabase } from './types';
9
+
10
+ /**
11
+ * ๐Ÿ”ด Every test here would have passed against the seam as published in 4.2.0, because
12
+ * nothing counted. `assertBatchSize` guards the length of one `batch()`; the N+1 loop
13
+ * below never builds a batch at all.
14
+ */
15
+
16
+ const seeded = () => {
17
+ const db = new Database(':memory:');
18
+ db.run('CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT)');
19
+ for (let i = 1; i <= 5; i++) db.run('INSERT INTO people (id, name) VALUES (?, ?)', [i, `p${i}`]);
20
+ return db;
21
+ };
22
+
23
+ describe('the per-invocation query budget', () => {
24
+ let base: D1LikeDatabase;
25
+
26
+ beforeEach(() => {
27
+ base = createLocalD1(seeded());
28
+ });
29
+
30
+ test('๐Ÿ”ด the N+1 loop the limits header names is refused โ€” and it is the case nothing caught before', async () => {
31
+ const db = perInvocation(base, { max: 10 });
32
+ // The laziest mechanical port of a synchronous loop: await inside `for`.
33
+ const run = async () => {
34
+ for (let i = 0; i < 50; i++) {
35
+ await db.prepare('SELECT name FROM people WHERE id = ?').bind(1).first();
36
+ }
37
+ };
38
+ await expect(run()).rejects.toThrow(D1LimitError);
39
+ // It stopped AT the cap, not after it โ€” the query over budget was never issued.
40
+ expect(db.used).toBe(10);
41
+ expect(db.remaining).toBe(0);
42
+ });
43
+
44
+ test('the refusal names the remedy rather than the number', async () => {
45
+ const db = perInvocation(base, { max: 1 });
46
+ await db.prepare('SELECT 1 AS n').all();
47
+ try {
48
+ await db.prepare('SELECT 2 AS n').all();
49
+ throw new Error('expected a refusal');
50
+ } catch (e) {
51
+ expect(e).toBeInstanceOf(D1LimitError);
52
+ const msg = (e as Error).message;
53
+ expect(msg).toContain('N+1');
54
+ expect(msg).toContain('JOIN');
55
+ expect(msg).toContain('batch()');
56
+ }
57
+ });
58
+
59
+ test('the JOIN that replaces the loop costs one query', async () => {
60
+ const db = perInvocation(base, { max: 1 });
61
+ const res = await db.prepare('SELECT id, name FROM people ORDER BY id').all();
62
+ expect(res.results).toHaveLength(5);
63
+ expect(db.used).toBe(1);
64
+ });
65
+
66
+ test('every terminal call is charged, and building a statement is not', async () => {
67
+ const db = perInvocation(base);
68
+ // prepare + bind reach nothing, so they cost nothing โ€” otherwise a re-bound
69
+ // statement kept in a helper would bill for merely existing.
70
+ const stmt = db.prepare('SELECT name FROM people WHERE id = ?').bind(1);
71
+ expect(db.used).toBe(0);
72
+
73
+ await stmt.first();
74
+ await stmt.all();
75
+ await stmt.raw();
76
+ await db.prepare('INSERT INTO people (id, name) VALUES (?, ?)').bind(99, 'x').run();
77
+ expect(db.used).toBe(4);
78
+ });
79
+
80
+ test('a batch is charged per MEMBER, because D1 bills it that way', async () => {
81
+ const db = perInvocation(base);
82
+ const stmts = [1, 2, 3].map((i) => db.prepare('SELECT name FROM people WHERE id = ?').bind(i));
83
+ const out = await db.batch(stmts);
84
+ expect(out).toHaveLength(3);
85
+ expect(db.used).toBe(3);
86
+ });
87
+
88
+ test('๐Ÿ”ด batch() still works through the wrapper โ€” the statements are unwrapped for the driver', async () => {
89
+ // The regression this guards: both drivers identify their own statements
90
+ // structurally (`allSync` local, `binding` remote) and reject anything else. A
91
+ // wrapper that forwarded itself would break batch() on both sides.
92
+ const db = perInvocation(base);
93
+ const stmts = [
94
+ db.prepare('INSERT INTO people (id, name) VALUES (?, ?)').bind(20, 'a'),
95
+ db.prepare('INSERT INTO people (id, name) VALUES (?, ?)').bind(21, 'b'),
96
+ ];
97
+ await db.batch(stmts);
98
+ const check = await db.prepare('SELECT COUNT(*) AS n FROM people').first<{ n: number }>();
99
+ expect(check?.n).toBe(7);
100
+ });
101
+
102
+ test('a batch that would exceed the remaining budget is refused before it is sent', async () => {
103
+ const db = perInvocation(base, { max: 2 });
104
+ const stmts = [1, 2, 3].map((i) => db.prepare('SELECT name FROM people WHERE id = ?').bind(i));
105
+ await expect(db.batch(stmts)).rejects.toThrow(D1LimitError);
106
+ // Nothing was charged, because nothing was sent.
107
+ expect(db.used).toBe(0);
108
+ });
109
+
110
+ test('exec settles its true cost afterwards, and the NEXT query is what refuses', async () => {
111
+ const db = perInvocation(base, { max: 3 });
112
+ // Three statements through a budget of three: exec reserves one, then settles two.
113
+ const res = await db.exec('CREATE TABLE a (x); CREATE TABLE b (x); CREATE TABLE c (x)');
114
+ expect(res.count).toBe(3);
115
+ expect(db.used).toBe(3);
116
+ await expect(db.prepare('SELECT 1 AS n').all()).rejects.toThrow(D1LimitError);
117
+ });
118
+
119
+ test('the cap is not off by one โ€” exactly `max` queries are allowed', async () => {
120
+ const db = perInvocation(base, { max: 3 });
121
+ for (let i = 0; i < 3; i++) await db.prepare('SELECT 1 AS n').all();
122
+ expect(db.used).toBe(3);
123
+ await expect(db.prepare('SELECT 1 AS n').all()).rejects.toThrow(D1LimitError);
124
+ });
125
+
126
+ test('the default budget is D1\'s own, and `remaining` starts there', () => {
127
+ const db = perInvocation(base);
128
+ expect(db.remaining).toBe(LIMITS.queriesPerInvocation);
129
+ expect(db.used).toBe(0);
130
+ });
131
+
132
+ test('a budget above D1\'s real cap is refused rather than honoured', () => {
133
+ expect(() => perInvocation(base, { max: LIMITS.queriesPerInvocation + 1 })).toThrow(D1LimitError);
134
+ expect(() => perInvocation(base, { max: 0 })).toThrow(RangeError);
135
+ expect(() => perInvocation(base, { max: 1.5 })).toThrow(RangeError);
136
+ });
137
+
138
+ test('the flavor of the wrapped driver shows through', () => {
139
+ expect(perInvocation(base).flavor).toBe('local');
140
+ });
141
+
142
+ test('๐Ÿ”ด each invocation gets its own budget โ€” the count is the request\'s, not the handle\'s', async () => {
143
+ // The false positive this design avoids: `createLocalD1` lives for the whole
144
+ // process, so a counter on the HANDLE would refuse the 1,001st query of the day.
145
+ for (let request = 0; request < 3; request++) {
146
+ const db = perInvocation(base, { max: 2 });
147
+ await db.prepare('SELECT 1 AS n').all();
148
+ await db.prepare('SELECT 1 AS n').all();
149
+ expect(db.used).toBe(2);
150
+ }
151
+ });
152
+ });
153
+
154
+ describe('the budget counts the REMOTE driver identically', () => {
155
+ // Same assertions, other side of the seam โ€” a budget that only worked locally would be
156
+ // the sync-locally defect over again.
157
+ test('the loop is refused on D1 too, at the same point', async () => {
158
+ const db = perInvocation(createRemoteD1(createFakeD1Binding(seeded())), { max: 4 });
159
+ expect(db.flavor).toBe('d1');
160
+ const run = async () => {
161
+ for (let i = 0; i < 20; i++) await db.prepare('SELECT name FROM people WHERE id = ?').bind(1).first();
162
+ };
163
+ await expect(run()).rejects.toThrow(D1LimitError);
164
+ expect(db.used).toBe(4);
165
+ });
166
+
167
+ test('batch() unwraps for the remote driver as well', async () => {
168
+ const db = perInvocation(createRemoteD1(createFakeD1Binding(seeded())));
169
+ const stmts = [1, 2].map((i) => db.prepare('SELECT name FROM people WHERE id = ?').bind(i));
170
+ const out = await db.batch(stmts);
171
+ expect(out).toHaveLength(2);
172
+ expect(db.used).toBe(2);
173
+ });
174
+ });