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
@@ -16,7 +16,11 @@ export type CpuViolationKind =
16
16
  /** An exempt route crossed its own `noticeAboveMs`. Never fails a build. */
17
17
  | 'exempt-notice'
18
18
  /** The clock could not measure at all. */
19
- | 'unmeasurable';
19
+ | 'unmeasurable'
20
+ /** The clock claimed to work and the report holds no route at all. */
21
+ | 'no-samples'
22
+ /** Every reading on every route was exactly zero — a reader that is not reading. */
23
+ | 'degenerate';
20
24
  export interface CpuViolation {
21
25
  kind: CpuViolationKind;
22
26
  /** `'GET /api/notes/:id'`. */
@@ -43,10 +47,19 @@ export interface AssertCpuBudgetsOpts {
43
47
  */
44
48
  requireDeclared?: boolean;
45
49
  /**
46
- * Treat an unavailable clock as a pass. Off by default — a gate that goes green
47
- * because it measured nothing is worse than no gate.
50
+ * Treat an unavailable clock — or a report holding no route at all — as a pass. Off by
51
+ * default: a gate that goes green because it measured nothing is worse than no gate.
48
52
  */
49
53
  allowUnmeasurable?: boolean;
54
+ /**
55
+ * Accept a report in which every reading on every route was exactly zero. Off by
56
+ * default.
57
+ *
58
+ * 🔴 The only reason to turn this on is a runtime whose CPU reader has whole-millisecond
59
+ * resolution and an app genuinely below it — and then the budget is not measuring
60
+ * anything either. Prefer a finer reader.
61
+ */
62
+ allowZeroCpu?: boolean;
50
63
  }
51
64
  /** Compute violations without throwing. {@link assertCpuBudgets} is this plus a throw. */
52
65
  export declare function checkCpuBudgets(report: CpuBudgetReport, opts?: AssertCpuBudgetsOpts): CpuViolation[];
@@ -27,6 +27,60 @@ export function checkCpuBudgets(report, opts = {}) {
27
27
  },
28
28
  ];
29
29
  }
30
+ // 🔴 A report with no route in it is a wiring failure wearing a pass. The loop below
31
+ // runs zero times and returns zero violations, so `cpuBudget()` left unmounted, a bench
32
+ // whose cases never reached the middleware, or a reader returning a non-number (every
33
+ // sample dropped as unmeasurable by `createCpuRecorder`) all assert GREEN. Measured
34
+ // 2026-09-17 against this file before this guard existed: all three did.
35
+ if (report.available && report.routes.length === 0 && !opts.allowUnmeasurable) {
36
+ return [
37
+ {
38
+ kind: 'no-samples',
39
+ key: '*',
40
+ method: '*',
41
+ route: '*',
42
+ p99: Number.NaN,
43
+ budgetMs: null,
44
+ samples: 0,
45
+ fails: true,
46
+ message: `CPU budget: clock source '${report.source}' reported itself available but not ` +
47
+ 'one route was recorded. Nothing was measured, so nothing was proven: check ' +
48
+ 'that cpuBudget() is mounted on the app under test, that the bench cases reach ' +
49
+ 'it, and that the CPU reader returns a finite number.',
50
+ },
51
+ ];
52
+ }
53
+ // 🔴 A reader that answers the same number every time is the failure `cpuClock.ts`'s
54
+ // header names: "a clock that reads zero for ever and a gate that can never go red".
55
+ // It is the more dangerous shape of the two above, because `workerCpuClock` stamps it
56
+ // `proxy: false` — so the report claims to be the real quantity Cloudflare bills while
57
+ // every route sits at 0.00 and every budget passes. Requiring EVERY route to be totally
58
+ // flat keeps this off a real measurement: one non-zero reading anywhere clears it.
59
+ const totalSamples = report.routes.reduce((n, r) => n + r.samples, 0);
60
+ const allFlatZero = report.routes.every((r) => r.retained > 0 && r.max === 0 && r.totalCpuMs === 0);
61
+ if (report.available &&
62
+ report.routes.length > 0 &&
63
+ totalSamples >= minSamples &&
64
+ allFlatZero &&
65
+ !opts.allowZeroCpu) {
66
+ return [
67
+ {
68
+ kind: 'degenerate',
69
+ key: '*',
70
+ method: '*',
71
+ route: '*',
72
+ p99: 0,
73
+ budgetMs: null,
74
+ samples: totalSamples,
75
+ fails: true,
76
+ message: `CPU budget: every one of ${totalSamples} sample(s) across ` +
77
+ `${report.routes.length} route(s) read exactly 0 CPU-ms from source ` +
78
+ `'${report.source}'${report.proxy ? '' : ' (reported as NON-proxy)'}. A reader ` +
79
+ 'that never moves cannot ever redden this gate. Wire a real CPU reader, or set ' +
80
+ 'allowZeroCpu deliberately.',
81
+ },
82
+ ];
83
+ }
30
84
  for (const r of report.routes) {
31
85
  const base = {
32
86
  key: r.key,
@@ -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
  /** CPU-milliseconds included in the Workers Paid plan each month. */
31
35
  export declare const WORKERS_PAID_INCLUDED_CPU_MS = 30000000;
@@ -42,6 +46,34 @@ export declare const WORKERS_PAID_USD_PER_MILLION_REQUESTS = 0.3;
42
46
  export declare const OWNER_PROJECTED_REQUESTS_PER_DAY = 100000;
43
47
  /** 365.25 / 12 — the average calendar month, since billing is monthly. */
44
48
  export declare const DAYS_PER_MONTH = 30.4375;
49
+ /**
50
+ * 🔴 The re-derivation has been DONE — 2026-09-16 — and the answer was not 9.9. This
51
+ * constant exists so the next reader does not spend the measurement again.
52
+ *
53
+ * Summed from the six apps still holding a populated `request_metrics` table in
54
+ * `$FORGE_STATE/apps/<app>/metrics.sqlite` (rows ÷ retained window, since five of the
55
+ * six sit at a ~100,4xx retention cap and only `patterns` is a true count):
56
+ *
57
+ * ```
58
+ * family 15,489/d · roms 10,580/d · music 7,034/d · vault 5,690/d
59
+ * collections 3,737/d · patterns 442/d → 42,971/day
60
+ * ```
61
+ *
62
+ * That is **43 % of {@link OWNER_PROJECTED_REQUESTS_PER_DAY}**, so the real per-request
63
+ * allowance is ~22.9 CPU-ms rather than 9.9 — 2.3× looser than the shipped default.
64
+ *
65
+ * 🔴 **The default was deliberately NOT raised to it**, and the reason is the corpus:
66
+ * every one of those tables stops within hours of its app's graduation (`roms` ends
67
+ * `2026-09-15 12:00:03`), because `requestLogger` has no callers in this generation
68
+ * yet. It is the RETIRED generation's traffic, and no app is on a Worker at all — so
69
+ * nothing here has been measured against the quantity Cloudflare actually bills.
70
+ * Loosening every budget in the fleet on evidence that cannot support it is the one
71
+ * direction a wrong guess costs money in; being 2.3× conservative costs nothing.
72
+ *
73
+ * `budget.spec.ts` holds that ordering as an assertion rather than as this paragraph:
74
+ * the default may never drift ABOVE the measured allowance without a red build.
75
+ */
76
+ export declare const MEASURED_FLEET_REQUESTS_PER_DAY = 42971;
45
77
  /**
46
78
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
47
79
  * included CPU allowance.
@@ -77,7 +109,9 @@ export declare function monthlyOverageUsd(opts: {
77
109
  * 9.9 CPU-ms — `30,000,000 ÷ (100,000 × 30.4375)` = 9.856, to one decimal.
78
110
  *
79
111
  * Generous for a JSON route, tight for anything that renders, parses or derives a key.
80
- * 🔴 Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
112
+ *
113
+ * 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
114
+ * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
81
115
  */
82
116
  export declare const DEFAULT_ROUTE_CPU_BUDGET_MS: number;
83
117
  /** A route that is measured against a number. */
@@ -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
  /** CPU-milliseconds included in the Workers Paid plan each month. */
31
35
  export const WORKERS_PAID_INCLUDED_CPU_MS = 30_000_000;
@@ -42,6 +46,34 @@ export const WORKERS_PAID_USD_PER_MILLION_REQUESTS = 0.3;
42
46
  export const OWNER_PROJECTED_REQUESTS_PER_DAY = 100_000;
43
47
  /** 365.25 / 12 — the average calendar month, since billing is monthly. */
44
48
  export const DAYS_PER_MONTH = 30.4375;
49
+ /**
50
+ * 🔴 The re-derivation has been DONE — 2026-09-16 — and the answer was not 9.9. This
51
+ * constant exists so the next reader does not spend the measurement again.
52
+ *
53
+ * Summed from the six apps still holding a populated `request_metrics` table in
54
+ * `$FORGE_STATE/apps/<app>/metrics.sqlite` (rows ÷ retained window, since five of the
55
+ * six sit at a ~100,4xx retention cap and only `patterns` is a true count):
56
+ *
57
+ * ```
58
+ * family 15,489/d · roms 10,580/d · music 7,034/d · vault 5,690/d
59
+ * collections 3,737/d · patterns 442/d → 42,971/day
60
+ * ```
61
+ *
62
+ * That is **43 % of {@link OWNER_PROJECTED_REQUESTS_PER_DAY}**, so the real per-request
63
+ * allowance is ~22.9 CPU-ms rather than 9.9 — 2.3× looser than the shipped default.
64
+ *
65
+ * 🔴 **The default was deliberately NOT raised to it**, and the reason is the corpus:
66
+ * every one of those tables stops within hours of its app's graduation (`roms` ends
67
+ * `2026-09-15 12:00:03`), because `requestLogger` has no callers in this generation
68
+ * yet. It is the RETIRED generation's traffic, and no app is on a Worker at all — so
69
+ * nothing here has been measured against the quantity Cloudflare actually bills.
70
+ * Loosening every budget in the fleet on evidence that cannot support it is the one
71
+ * direction a wrong guess costs money in; being 2.3× conservative costs nothing.
72
+ *
73
+ * `budget.spec.ts` holds that ordering as an assertion rather than as this paragraph:
74
+ * the default may never drift ABOVE the measured allowance without a red build.
75
+ */
76
+ export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
45
77
  /**
46
78
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
47
79
  * included CPU allowance.
@@ -78,7 +110,9 @@ export function monthlyOverageUsd(opts) {
78
110
  * 9.9 CPU-ms — `30,000,000 ÷ (100,000 × 30.4375)` = 9.856, to one decimal.
79
111
  *
80
112
  * Generous for a JSON route, tight for anything that renders, parses or derives a key.
81
- * 🔴 Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
113
+ *
114
+ * 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
115
+ * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
82
116
  */
83
117
  export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
84
118
  export function isExemption(b) {
@@ -51,13 +51,33 @@ export function processCpuClock() {
51
51
  * @param readCpuMs Returns monotonically non-decreasing CPU-ms for the current invocation.
52
52
  */
53
53
  export function workerCpuClock(readCpuMs, source = 'worker-runtime') {
54
+ // This function is the only place `proxy: false` is stamped — the claim that a number may
55
+ // be quoted as a Cloudflare billing fact is made HERE, so it is checked here. A reader
56
+ // that is not callable can never be one, and finding that out at wiring time costs one
57
+ // cold start; finding it out later costs a gate that reads zero for ever.
58
+ if (typeof readCpuMs !== 'function') {
59
+ throw new TypeError('workerCpuClock: needs a function returning CPU-ms for the current invocation. ' +
60
+ 'There is no built-in Cloudflare reader — the isolate does not hand a handler its ' +
61
+ 'own CPU time, so this comes from a tail worker feeding cpuTime back.');
62
+ }
63
+ // 🔴 The reader is NOT called here. Cloudflare's CPU readings are request-scoped, so a
64
+ // correct reader may legitimately throw or read nothing at construction; validating
65
+ // eagerly would reject the very readers this exists to accept.
54
66
  return {
55
67
  source,
56
68
  proxy: false,
57
69
  available: true,
58
70
  start() {
59
71
  const before = readCpuMs();
60
- return () => Math.max(0, readCpuMs() - before);
72
+ return () => {
73
+ const after = readCpuMs();
74
+ // A non-numeric reading is unmeasurable, never zero. NaN is what
75
+ // `createCpuRecorder` drops as "not a sample"; a 0 would be recorded as a
76
+ // confident measurement of no work, which is the lie this module exists to avoid.
77
+ if (!Number.isFinite(before) || !Number.isFinite(after))
78
+ return Number.NaN;
79
+ return Math.max(0, after - before);
80
+ };
61
81
  },
62
82
  };
63
83
  }
@@ -33,7 +33,7 @@
33
33
  * alternative is a number that gets quoted as a billing fact.
34
34
  */
35
35
  export { type AssertCpuBudgetsOpts, assertCpuBudgets, checkCpuBudgets, type CpuViolation, type CpuViolationKind, formatCpuBudgetReport, } from './assert';
36
- export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
36
+ export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
37
37
  export { type CpuBudgetOpts, cpuBudget } from './cpuBudget';
38
38
  export { type CpuClock, type CpuSpan, fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock';
39
39
  export { type CpuBudgetReport, type CpuRecorder, type CpuRecorderOpts, createCpuRecorder, percentile, type RouteCpuStats, } from './recorder';
@@ -33,7 +33,7 @@
33
33
  * alternative is a number that gets quoted as a billing fact.
34
34
  */
35
35
  export { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport, } from './assert';
36
- export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
36
+ export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
37
37
  export { cpuBudget } from './cpuBudget';
38
38
  export { fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock';
39
39
  export { createCpuRecorder, percentile, } from './recorder';
@@ -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.
@@ -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.
@@ -28,6 +33,7 @@
28
33
  * makes a port testable on a laptop, and `190`'s Worker preview is the thing that proves
29
34
  * it.
30
35
  */
36
+ import { isPureReadSql } from './values';
31
37
  /** The bind types a real D1 binding accepts. Everything else throws. */
32
38
  function assertD1Bindable(value, index) {
33
39
  if (value === null)
@@ -128,7 +134,48 @@ class FakeD1Statement {
128
134
  const rows = this.db.query(this.sql).values(...this.params);
129
135
  return rows.map((r) => r.map(toWireValue));
130
136
  }
137
+ /**
138
+ * Execute synchronously and report the wire result, for `batch()`.
139
+ *
140
+ * 🔴 Synchronous on purpose: a `bun:sqlite` transaction callback may not await — a
141
+ * promise resolved inside it settles a microtask after the transaction has already
142
+ * committed. The local driver keeps a `SyncExecutable` for exactly this reason.
143
+ *
144
+ * The counters are read the same way real D1 populates `meta`, and deliberately NOT by
145
+ * copying `local.ts`: this file is a divergence simulator, so it reaches its answer
146
+ * independently and the shared-bug case stays detectable.
147
+ */
148
+ runSync() {
149
+ assertRunnableOnD1(this.sql);
150
+ const started = performance.now();
151
+ const stmt = this.db.query(this.sql);
152
+ const bound = this.params;
153
+ // No result columns → cannot return rows, and `.run()` is the only call that reports
154
+ // `changes`. This is the ordinary batch member: an INSERT, UPDATE or DELETE.
155
+ if (stmt.columnNames.length === 0) {
156
+ const res = stmt.run(...bound);
157
+ return { results: [], success: true, meta: this.meta(started, res.changes, Number(res.lastInsertRowid)) };
158
+ }
159
+ // A pure read wrote nothing. Its `changes()` would be the PREVIOUS statement's.
160
+ if (isPureReadSql(this.sql)) {
161
+ const rows = stmt.all(...bound);
162
+ return { results: rows.map(toWireRow), success: true, meta: this.meta(started, 0, 0) };
163
+ }
164
+ // Returns rows AND writes — `INSERT … RETURNING` and friends.
165
+ const before = totalChanges(this.db);
166
+ const rows = this.db.query(this.sql).all(...bound);
167
+ const changes = totalChanges(this.db) - before;
168
+ return {
169
+ results: rows.map(toWireRow),
170
+ success: true,
171
+ meta: this.meta(started, changes, changes > 0 ? lastInsertRowid(this.db) : 0),
172
+ };
173
+ }
131
174
  }
175
+ /** Cumulative rows changed on this connection — a difference is one statement's count. */
176
+ const totalChanges = (db) => db.query('SELECT total_changes() AS n').get().n;
177
+ /** The connection's last inserted rowid, which is what D1 puts in `meta.last_row_id`. */
178
+ const lastInsertRowid = (db) => db.query('SELECT last_insert_rowid() AS r').get().r;
132
179
  /**
133
180
  * Wrap a real `bun:sqlite` database in D1's API and D1's restrictions.
134
181
  *
@@ -145,26 +192,12 @@ export function createFakeD1Binding(db) {
145
192
  async batch(statements) {
146
193
  // D1's batch is atomic — it is the only atomicity D1 offers. A real `bun:sqlite`
147
194
  // transaction is the faithful local equivalent.
195
+ // 🔴 Each member reports its OWN `changes` / `last_row_id`, because real D1 does.
196
+ // This used to hardcode zero — see note 6 in the header for what that cost.
148
197
  const results = [];
149
198
  const run = db.transaction((stmts) => {
150
- for (const s of stmts) {
151
- const started = performance.now();
152
- const inner = s;
153
- assertRunnableOnD1(inner.sql);
154
- const rows = db.query(inner.sql).all(...inner.params);
155
- results.push({
156
- results: rows.map(toWireRow),
157
- success: true,
158
- meta: {
159
- duration: performance.now() - started,
160
- changes: 0,
161
- last_row_id: 0,
162
- rows_read: rows.length,
163
- rows_written: 0,
164
- served_by: 'fake-d1',
165
- },
166
- });
167
- }
199
+ for (const s of stmts)
200
+ results.push(s.runSync());
168
201
  });
169
202
  run(statements);
170
203
  return results;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export { backupFor, type BackupPoint, checkpointWal, createLocalBackup, createTimeTravelBackup, type DatabaseBackup, type LocalBackupOpts, type TimeTravelOpts, } from './backup';
18
18
  export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
19
+ export { type InvocationD1, perInvocation } from './invocation';
19
20
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
20
21
  export { createLocalD1, refuseInteractiveTransaction } from './local';
21
22
  export { createRemoteD1, type D1BindingLike, type D1BindingMeta, type D1BindingResult, type D1BindingStatement, } from './remote';
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup, } from './backup';
18
18
  export { createD1Kysely, D1LikeDialect } from './kysely';
19
+ export { perInvocation } from './invocation';
19
20
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
20
21
  export { createLocalD1, refuseInteractiveTransaction } from './local';
21
22
  export { createRemoteD1, } from './remote';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The per-invocation query budget — D1's 1,000-queries-per-Worker-invocation cap, counted.
3
+ *
4
+ * ## 🔴 Why this file exists: the limit the rest of the seam only documented
5
+ *
6
+ * `./limits` lists `queriesPerInvocation: 1_000` FIRST, and its header names the exact
7
+ * failure — *"An N+1 loop over `family`'s 489 people exceeds it. Join, or `batch()`; do not
8
+ * loop."* Measured here 2026-09-18, that limit was the one thing in the table nothing
9
+ * enforced: `assertBatchSize` checks the length of a SINGLE `batch()` call, so a loop
10
+ * issuing 1,001 separate `await db.prepare(…).run()` calls passed every check in the seam.
11
+ * Each statement is under 100 parameters, each batch is under 1,000 members, and the
12
+ * invocation still dies in production.
13
+ *
14
+ * That is the seam's own defining defect wearing a third costume. Sync-locally and
15
+ * lenient-locally are both "green on this Mac, red in the Worker"; so is uncounted-locally,
16
+ * because `bun:sqlite` has no such cap and never will. The N+1 loop is also the single most
17
+ * likely shape to appear during a port — it is what the un-ported synchronous code already
18
+ * looks like, and awaiting it in a `for` loop is the laziest mechanical translation.
19
+ *
20
+ * ## The counter belongs to a REQUEST, not to a handle
21
+ *
22
+ * D1's cap is per Worker invocation, and a `D1LikeDatabase` is not an invocation: on a
23
+ * Worker it happens to be built per request, but locally `createLocalD1` is built once per
24
+ * PROCESS and lives for days. A counter on the handle would therefore be correct remotely
25
+ * and a false positive locally after the 1,000th query of the morning — the mirror image of
26
+ * the bug this seam exists to prevent, and just as fatal to the rule's credibility.
27
+ *
28
+ * So the scope is explicit and cheap, and the caller opens one per request:
29
+ *
30
+ * ```ts
31
+ * export default {
32
+ * async fetch(req: Request, env: Env) {
33
+ * const db = perInvocation(createRemoteD1(env.DB));
34
+ * return handle(req, db); // 1,001st query throws, naming the loop
35
+ * },
36
+ * };
37
+ * ```
38
+ *
39
+ * It wraps either driver, because it sits ABOVE the seam and only counts. That is what
40
+ * makes the local gate able to prove a production-only limit: wrap a local database in a
41
+ * test, run the loop, and the laptop fails exactly where the Worker would.
42
+ */
43
+ import { type D1LikeDatabase } from './types';
44
+ /**
45
+ * A database handle that knows how much of its invocation budget it has spent.
46
+ *
47
+ * `used` counts QUERIES the way D1 bills them: one per terminal statement call
48
+ * (`first`/`all`/`run`/`raw`), one per member of a `batch()`, and one per statement an
49
+ * `exec()` ran. Building a statement with `prepare()` or `bind()` costs nothing, because it
50
+ * reaches the database only when it is executed.
51
+ */
52
+ export interface InvocationD1 extends D1LikeDatabase {
53
+ /** Queries charged to this invocation so far. */
54
+ readonly used: number;
55
+ /** Queries left before the cap, floored at 0. */
56
+ readonly remaining: number;
57
+ }
58
+ /**
59
+ * Give `db` a query budget for ONE Worker invocation.
60
+ *
61
+ * Construct a fresh one per request — the count is the request's, not the handle's, and
62
+ * re-using one across requests would refuse the 1,001st query of the day rather than of the
63
+ * invocation. See the header for why that distinction is the whole design.
64
+ *
65
+ * `max` exists for tests and for a caller that wants to be refused sooner than D1 would
66
+ * (a route with a budget of 50 finds its own N+1 long before the hard cap does). It may not
67
+ * be raised above D1's real limit, because a budget larger than the service's is a number
68
+ * that reports success right up until production disagrees.
69
+ */
70
+ export declare function perInvocation(db: D1LikeDatabase, opts?: {
71
+ max?: number;
72
+ }): InvocationD1;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The per-invocation query budget — D1's 1,000-queries-per-Worker-invocation cap, counted.
3
+ *
4
+ * ## 🔴 Why this file exists: the limit the rest of the seam only documented
5
+ *
6
+ * `./limits` lists `queriesPerInvocation: 1_000` FIRST, and its header names the exact
7
+ * failure — *"An N+1 loop over `family`'s 489 people exceeds it. Join, or `batch()`; do not
8
+ * loop."* Measured here 2026-09-18, that limit was the one thing in the table nothing
9
+ * enforced: `assertBatchSize` checks the length of a SINGLE `batch()` call, so a loop
10
+ * issuing 1,001 separate `await db.prepare(…).run()` calls passed every check in the seam.
11
+ * Each statement is under 100 parameters, each batch is under 1,000 members, and the
12
+ * invocation still dies in production.
13
+ *
14
+ * That is the seam's own defining defect wearing a third costume. Sync-locally and
15
+ * lenient-locally are both "green on this Mac, red in the Worker"; so is uncounted-locally,
16
+ * because `bun:sqlite` has no such cap and never will. The N+1 loop is also the single most
17
+ * likely shape to appear during a port — it is what the un-ported synchronous code already
18
+ * looks like, and awaiting it in a `for` loop is the laziest mechanical translation.
19
+ *
20
+ * ## The counter belongs to a REQUEST, not to a handle
21
+ *
22
+ * D1's cap is per Worker invocation, and a `D1LikeDatabase` is not an invocation: on a
23
+ * Worker it happens to be built per request, but locally `createLocalD1` is built once per
24
+ * PROCESS and lives for days. A counter on the handle would therefore be correct remotely
25
+ * and a false positive locally after the 1,000th query of the morning — the mirror image of
26
+ * the bug this seam exists to prevent, and just as fatal to the rule's credibility.
27
+ *
28
+ * So the scope is explicit and cheap, and the caller opens one per request:
29
+ *
30
+ * ```ts
31
+ * export default {
32
+ * async fetch(req: Request, env: Env) {
33
+ * const db = perInvocation(createRemoteD1(env.DB));
34
+ * return handle(req, db); // 1,001st query throws, naming the loop
35
+ * },
36
+ * };
37
+ * ```
38
+ *
39
+ * It wraps either driver, because it sits ABOVE the seam and only counts. That is what
40
+ * makes the local gate able to prove a production-only limit: wrap a local database in a
41
+ * test, run the loop, and the laptop fails exactly where the Worker would.
42
+ */
43
+ import { LIMITS } from './limits';
44
+ import { D1LimitError } from './types';
45
+ /**
46
+ * The statements a wrapper handed out, mapped back to the driver's own.
47
+ *
48
+ * 🔴 Both drivers identify their own statements structurally — `local.ts` probes for
49
+ * `allSync`, `remote.ts` for `binding` — and refuse anything else with "a local statement
50
+ * cannot run inside a D1 batch". A counted wrapper has neither property, so passing the
51
+ * wrappers straight through would break `batch()` on BOTH sides. `batch()` therefore
52
+ * unwraps here before delegating. A statement this does not know is passed through
53
+ * untouched, so the driver's own error is what a genuine cross-driver mix-up still gets.
54
+ */
55
+ const INNER = new WeakMap();
56
+ class Budget {
57
+ max;
58
+ used = 0;
59
+ constructor(max) {
60
+ this.max = max;
61
+ }
62
+ /** Charge before the work goes out, so the query over the cap is never issued. */
63
+ charge(n, what) {
64
+ if (this.used + n > this.max) {
65
+ throw new D1LimitError('queries per Worker invocation', this.used + n, this.max, `${what} would be query ${this.used + n} of this invocation. This is almost always an N+1 loop — ` +
66
+ 'replace the loop with a JOIN, or collect the statements and send them as one `batch()`. ' +
67
+ 'Work that genuinely needs more than a thousand queries belongs in a Queue consumer or a Cron Trigger, ' +
68
+ 'which get a fresh budget per invocation.');
69
+ }
70
+ this.used += n;
71
+ }
72
+ /**
73
+ * Record a cost that was only knowable after the fact — see `exec()` below. Deliberately
74
+ * unchecked: the statements have already run, so throwing here would report a limit
75
+ * breach by hiding the result that proves it. Going over simply means the next
76
+ * {@link charge} throws, which is the first moment a refusal can still prevent anything.
77
+ */
78
+ settle(n) {
79
+ this.used += n;
80
+ }
81
+ }
82
+ /** Wrap one statement so every terminal call is charged to `budget`. */
83
+ function countedStatement(inner, budget, sql) {
84
+ const label = `\`${sql.trim().slice(0, 60)}\``;
85
+ class CountedStatement {
86
+ bind(...values) {
87
+ // Binding is free — it reaches nothing. The NEW statement is wrapped too, or a
88
+ // `prepare().bind().run()` would escape the count entirely.
89
+ return countedStatement(inner.bind(...values), budget, sql);
90
+ }
91
+ async first(column) {
92
+ budget.charge(1, label);
93
+ return column === undefined ? inner.first() : inner.first(column);
94
+ }
95
+ async all() {
96
+ budget.charge(1, label);
97
+ return inner.all();
98
+ }
99
+ async run() {
100
+ budget.charge(1, label);
101
+ return inner.run();
102
+ }
103
+ async raw() {
104
+ budget.charge(1, label);
105
+ return inner.raw();
106
+ }
107
+ }
108
+ const wrapped = new CountedStatement();
109
+ INNER.set(wrapped, inner);
110
+ return wrapped;
111
+ }
112
+ /**
113
+ * Give `db` a query budget for ONE Worker invocation.
114
+ *
115
+ * Construct a fresh one per request — the count is the request's, not the handle's, and
116
+ * re-using one across requests would refuse the 1,001st query of the day rather than of the
117
+ * invocation. See the header for why that distinction is the whole design.
118
+ *
119
+ * `max` exists for tests and for a caller that wants to be refused sooner than D1 would
120
+ * (a route with a budget of 50 finds its own N+1 long before the hard cap does). It may not
121
+ * be raised above D1's real limit, because a budget larger than the service's is a number
122
+ * that reports success right up until production disagrees.
123
+ */
124
+ export function perInvocation(db, opts = {}) {
125
+ const max = opts.max ?? LIMITS.queriesPerInvocation;
126
+ if (!Number.isInteger(max) || max < 1) {
127
+ throw new RangeError(`max must be a positive integer, got ${max}`);
128
+ }
129
+ if (max > LIMITS.queriesPerInvocation) {
130
+ throw new D1LimitError('queries per Worker invocation', max, LIMITS.queriesPerInvocation, 'A budget above D1\'s own cap cannot be honoured — the service refuses first. Lower it, or split the work across invocations.');
131
+ }
132
+ const budget = new Budget(max);
133
+ return {
134
+ flavor: db.flavor,
135
+ get used() {
136
+ return budget.used;
137
+ },
138
+ get remaining() {
139
+ return Math.max(0, budget.max - budget.used);
140
+ },
141
+ prepare(sql) {
142
+ return countedStatement(db.prepare(sql), budget, sql);
143
+ },
144
+ async batch(statements) {
145
+ // One round trip, but D1 bills each member — so does this.
146
+ budget.charge(statements.length, `a batch() of ${statements.length}`);
147
+ return db.batch(statements.map((s) => INNER.get(s) ?? s));
148
+ },
149
+ /**
150
+ * 🔴 The one cost that cannot be charged up front. `exec()` runs however many
151
+ * statements the SQL contains, and remotely that number comes back FROM the service —
152
+ * there is nothing trustworthy to count beforehand. So this reserves one query, runs,
153
+ * and settles the true cost afterwards; an `exec` that blows the budget is reported on
154
+ * the next query rather than prevented. That is an acceptable trade only because
155
+ * `exec` is the DDL/migration path — it carries no user input by contract, and it is
156
+ * not the shape an N+1 loop takes.
157
+ */
158
+ async exec(sql) {
159
+ budget.charge(1, 'an exec()');
160
+ const res = await db.exec(sql);
161
+ if (res.count > 1)
162
+ budget.settle(res.count - 1);
163
+ return res;
164
+ },
165
+ };
166
+ }