cursedbelt-server 4.1.0 → 4.2.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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Reading a request body without first agreeing how big it may be.
3
+ *
4
+ * ── Why this is one function rather than one per app (2026-07-27) ────────────
5
+ * The two apps behind the shared owner-auth were inconsistently hardened, in a
6
+ * way an adversarial review found and neither test suite could:
7
+ *
8
+ * - the companion app (since folded into `apps/orch`) read `c.req.text()`,
9
+ * checked the length, and returned 413 BEFORE parsing — correct.
10
+ * - `apps/roms` called `await c.req.json()` first and checked the size deep
11
+ * inside the save-state store — i.e. it buffered and parsed an arbitrary body
12
+ * into a process whose unit caps at 300 MB of memory before forming an
13
+ * opinion about its size.
14
+ *
15
+ * Both went through {@link readBoundedJson} after that. The guard is one
16
+ * function so the posture cannot diverge again, and it is deliberately the
17
+ * *last* line of defense: a proxy's `client_max_body_size` refuses an oversized
18
+ * body at the edge, and this refuses it if the proxy is absent, misconfigured,
19
+ * or bypassed — which is exactly the case in dev, in `preview:prod`, and for any
20
+ * request that reaches the loopback port directly.
21
+ *
22
+ * ── Where it came from (2026-09-17) ──────────────────────────────────────────
23
+ * Five apps — `roms`, `family`, `music`, `vault`, `collections` — carried this
24
+ * file byte-for-byte identical (`b36d0eedee7a`), which is the posture diverging
25
+ * again, only more slowly and with nothing watching: a repo's gate proves that
26
+ * repo, so five green gates is what five copies of a security guard look like
27
+ * from the outside. Confirmed identical with `shasum -a256` before the move, so
28
+ * there was no fork to reconcile and nothing was dropped.
29
+ *
30
+ * 🔴 It is published as the LEAF subpath `cursedbelt-server/body-limit`, never
31
+ * through the `./middleware` barrel — that barrel drags `hono` and the error
32
+ * envelope, and this module's whole promise is that it imports NOTHING. The
33
+ * source is a `{ req: { text() } }` / `{ req: { arrayBuffer() } }` /
34
+ * `{ req: { header() } }` structural type rather than a Hono context, so a
35
+ * caller needs no framework to use it and a test needs none to exercise it.
36
+ * `src/leafSubpathsImportNothing.spec.ts` is what holds that promise.
37
+ */
38
+ /**
39
+ * Read a body as TEXT, refusing anything over `maxBytes`.
40
+ *
41
+ * The sibling of {@link readBoundedJson}, for a payload that IS the text rather
42
+ * than a document containing it — `apps/patterns`' chunked sends put their
43
+ * metadata in the query string and the chunk in the raw body, which spares a
44
+ * megabyte-scale caller from JSON-escaping every piece and spares this process
45
+ * from parsing what it is only going to concatenate.
46
+ *
47
+ * Same byte-not-character measurement, and for the same reason: it is the number
48
+ * the proxy in front of this is enforcing.
49
+ */
50
+ export async function readBoundedText(c, options) {
51
+ const { maxBytes, label = 'body' } = options;
52
+ let raw;
53
+ try {
54
+ raw = await c.req.text();
55
+ }
56
+ catch {
57
+ return { ok: false, status: 400, error: `${label} could not be read` };
58
+ }
59
+ const bytes = new TextEncoder().encode(raw).length;
60
+ if (bytes > maxBytes) {
61
+ return {
62
+ ok: false,
63
+ status: 413,
64
+ error: `${label} too large (${bytes} bytes; the limit is ${maxBytes})`,
65
+ };
66
+ }
67
+ return { ok: true, value: raw };
68
+ }
69
+ /**
70
+ * Read a body as RAW BYTES, refusing anything over `maxBytes`.
71
+ *
72
+ * The third member of the family, for a payload that is neither JSON nor text:
73
+ * roms' ROM-upload chunks are cartridge bytes, and routing them through
74
+ * {@link readBoundedText} would decode arbitrary binary as UTF-8 — which is
75
+ * lossy (every invalid sequence becomes U+FFFD) and measures a size that is not
76
+ * the size the proxy in front is enforcing.
77
+ *
78
+ * A `Content-Length` claim is deliberately NOT trusted here: it is a header, and
79
+ * the guard has to hold against a body that disagrees with it. The measurement is
80
+ * the buffer that actually arrived.
81
+ */
82
+ export async function readBoundedBytes(c, options) {
83
+ const { maxBytes, label = 'body' } = options;
84
+ let buffer;
85
+ try {
86
+ buffer = await c.req.arrayBuffer();
87
+ }
88
+ catch {
89
+ return { ok: false, status: 400, error: `${label} could not be read` };
90
+ }
91
+ if (buffer.byteLength > maxBytes) {
92
+ return {
93
+ ok: false,
94
+ status: 413,
95
+ error: `${label} too large (${buffer.byteLength} bytes; the limit is ${maxBytes})`,
96
+ };
97
+ }
98
+ return { ok: true, value: new Uint8Array(buffer) };
99
+ }
100
+ /**
101
+ * Refuse a body by its DECLARED `Content-Length`, without reading it.
102
+ *
103
+ * The fourth member of the family, and the only one for a body that cannot be
104
+ * buffered first: a `multipart/form-data` upload. The other three measure the
105
+ * bytes that actually arrived, which is strictly better — but they get to,
106
+ * because they buffer. `c.req.formData()` gives no such opportunity: by the time
107
+ * it resolves, the whole upload is already in a process whose unit caps at
108
+ * 300 MB of memory, which is the thing the limit exists to prevent.
109
+ *
110
+ * So this trusts the header, and the docblock says so plainly. A liar gets
111
+ * through to the buffering call — but a liar was always going to, and this still
112
+ * turns the honest 3 GB folder-drop (the actual failure shape) into a refusal
113
+ * the app can explain instead of an OOM or an nginx HTML 413 the app never sees.
114
+ * It is a companion to the proxy's `client_max_body_size`, never a replacement.
115
+ *
116
+ * A missing or unparseable header is NOT a refusal: chunked requests omit it
117
+ * legitimately, and refusing them would break a correct client to catch a
118
+ * dishonest one.
119
+ */
120
+ export function refuseOversizedBody(c, options) {
121
+ const { maxBytes, label = 'body' } = options;
122
+ const declared = Number(c.req.header('content-length'));
123
+ if (!Number.isFinite(declared) || declared <= maxBytes)
124
+ return null;
125
+ return {
126
+ status: 413,
127
+ error: `${label} too large (${declared} bytes; the limit is ${maxBytes})`,
128
+ };
129
+ }
130
+ /**
131
+ * Read a JSON body, refusing anything over `maxBytes` before parsing it.
132
+ *
133
+ * The size is measured in BYTES, not characters. `String.length` counts UTF-16
134
+ * code units, so a body of 4-byte emoji measures half its true size that way —
135
+ * which would let a caller past a byte limit the proxy is enforcing in real
136
+ * bytes, and produce the mismatch this module exists to close.
137
+ */
138
+ export async function readBoundedJson(c, options) {
139
+ const { maxBytes, label = 'body' } = options;
140
+ let raw;
141
+ try {
142
+ raw = await c.req.text();
143
+ }
144
+ catch {
145
+ return { ok: false, status: 400, error: `${label} could not be read` };
146
+ }
147
+ const bytes = new TextEncoder().encode(raw).length;
148
+ if (bytes > maxBytes) {
149
+ return {
150
+ ok: false,
151
+ status: 413,
152
+ error: `${label} too large (${bytes} bytes; the limit is ${maxBytes})`,
153
+ };
154
+ }
155
+ try {
156
+ return { ok: true, value: JSON.parse(raw) };
157
+ }
158
+ catch {
159
+ return { ok: false, status: 400, error: `${label} must be JSON` };
160
+ }
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -66,6 +66,12 @@
66
66
  "source": "./src/server/storage/binaryStoreFake.ts",
67
67
  "import": "./dist/server/storage/binaryStoreFake.js"
68
68
  },
69
+ "./body-limit": {
70
+ "types": "./dist/server/middleware/bodyLimit.d.ts",
71
+ "bun": "./src/server/middleware/bodyLimit.ts",
72
+ "source": "./src/server/middleware/bodyLimit.ts",
73
+ "import": "./dist/server/middleware/bodyLimit.js"
74
+ },
69
75
  "./d1": {
70
76
  "types": "./dist/server/d1/index.d.ts",
71
77
  "bun": "./src/server/d1/index.ts",
@@ -138,6 +138,19 @@ const LEAVES = [
138
138
  */
139
139
  evaluates: 'binaryStoreFakeDefaults',
140
140
  },
141
+ {
142
+ subpath: './body-limit',
143
+ /**
144
+ * 🔴 The last line of defense on an oversized request body, and the leaf with the
145
+ * strictest promise of the lot: it imports NOTHING — not `hono`, not the error
146
+ * envelope, not a sibling in `src/server/middleware/`. It takes its request as a
147
+ * structural `{ req: { text() } }` source precisely so that neither a consumer nor a
148
+ * test needs a framework, which is what made it an ADDITION rather than a port when it
149
+ * arrived in 4.2.0 from five byte-identical app-side copies. Reaching it through the
150
+ * `./middleware` barrel would undo all of that in one import.
151
+ */
152
+ evaluates: 'readBoundedJson',
153
+ },
141
154
  ] as const;
142
155
 
143
156
  /**
@@ -19,7 +19,11 @@ export type CpuViolationKind =
19
19
  /** An exempt route crossed its own `noticeAboveMs`. Never fails a build. */
20
20
  | 'exempt-notice'
21
21
  /** The clock could not measure at all. */
22
- | 'unmeasurable';
22
+ | 'unmeasurable'
23
+ /** The clock claimed to work and the report holds no route at all. */
24
+ | 'no-samples'
25
+ /** Every reading on every route was exactly zero — a reader that is not reading. */
26
+ | 'degenerate';
23
27
 
24
28
  export interface CpuViolation {
25
29
  kind: CpuViolationKind;
@@ -48,10 +52,19 @@ export interface AssertCpuBudgetsOpts {
48
52
  */
49
53
  requireDeclared?: boolean;
50
54
  /**
51
- * Treat an unavailable clock as a pass. Off by default — a gate that goes green
52
- * because it measured nothing is worse than no gate.
55
+ * Treat an unavailable clock — or a report holding no route at all — as a pass. Off by
56
+ * default: a gate that goes green because it measured nothing is worse than no gate.
53
57
  */
54
58
  allowUnmeasurable?: boolean;
59
+ /**
60
+ * Accept a report in which every reading on every route was exactly zero. Off by
61
+ * default.
62
+ *
63
+ * 🔴 The only reason to turn this on is a runtime whose CPU reader has whole-millisecond
64
+ * resolution and an app genuinely below it — and then the budget is not measuring
65
+ * anything either. Prefer a finer reader.
66
+ */
67
+ allowZeroCpu?: boolean;
55
68
  }
56
69
 
57
70
  const fmt = (n: number): string => (Number.isFinite(n) ? n.toFixed(2) : 'n/a');
@@ -82,6 +95,68 @@ export function checkCpuBudgets(
82
95
  ];
83
96
  }
84
97
 
98
+ // 🔴 A report with no route in it is a wiring failure wearing a pass. The loop below
99
+ // runs zero times and returns zero violations, so `cpuBudget()` left unmounted, a bench
100
+ // whose cases never reached the middleware, or a reader returning a non-number (every
101
+ // sample dropped as unmeasurable by `createCpuRecorder`) all assert GREEN. Measured
102
+ // 2026-09-17 against this file before this guard existed: all three did.
103
+ if (report.available && report.routes.length === 0 && !opts.allowUnmeasurable) {
104
+ return [
105
+ {
106
+ kind: 'no-samples',
107
+ key: '*',
108
+ method: '*',
109
+ route: '*',
110
+ p99: Number.NaN,
111
+ budgetMs: null,
112
+ samples: 0,
113
+ fails: true,
114
+ message:
115
+ `CPU budget: clock source '${report.source}' reported itself available but not ` +
116
+ 'one route was recorded. Nothing was measured, so nothing was proven: check ' +
117
+ 'that cpuBudget() is mounted on the app under test, that the bench cases reach ' +
118
+ 'it, and that the CPU reader returns a finite number.',
119
+ },
120
+ ];
121
+ }
122
+
123
+ // 🔴 A reader that answers the same number every time is the failure `cpuClock.ts`'s
124
+ // header names: "a clock that reads zero for ever and a gate that can never go red".
125
+ // It is the more dangerous shape of the two above, because `workerCpuClock` stamps it
126
+ // `proxy: false` — so the report claims to be the real quantity Cloudflare bills while
127
+ // every route sits at 0.00 and every budget passes. Requiring EVERY route to be totally
128
+ // flat keeps this off a real measurement: one non-zero reading anywhere clears it.
129
+ const totalSamples = report.routes.reduce((n, r) => n + r.samples, 0);
130
+ const allFlatZero = report.routes.every(
131
+ (r) => r.retained > 0 && r.max === 0 && r.totalCpuMs === 0,
132
+ );
133
+ if (
134
+ report.available &&
135
+ report.routes.length > 0 &&
136
+ totalSamples >= minSamples &&
137
+ allFlatZero &&
138
+ !opts.allowZeroCpu
139
+ ) {
140
+ return [
141
+ {
142
+ kind: 'degenerate',
143
+ key: '*',
144
+ method: '*',
145
+ route: '*',
146
+ p99: 0,
147
+ budgetMs: null,
148
+ samples: totalSamples,
149
+ fails: true,
150
+ message:
151
+ `CPU budget: every one of ${totalSamples} sample(s) across ` +
152
+ `${report.routes.length} route(s) read exactly 0 CPU-ms from source ` +
153
+ `'${report.source}'${report.proxy ? '' : ' (reported as NON-proxy)'}. A reader ` +
154
+ 'that never moves cannot ever redden this gate. Wire a real CPU reader, or set ' +
155
+ 'allowZeroCpu deliberately.',
156
+ },
157
+ ];
158
+ }
159
+
85
160
  for (const r of report.routes) {
86
161
  const base: Omit<CpuViolation, 'kind' | 'message' | 'fails'> = {
87
162
  key: r.key,
@@ -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
  }
@@ -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;
@@ -25,11 +25,22 @@ import {
25
25
  type D1LikeValue,
26
26
  D1UnsupportedError,
27
27
  } from './types';
28
- import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
28
+ import { isPureReadSql, makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
29
29
 
30
30
  /** Shared clock, so `meta.duration` means the same thing on both sides of the seam. */
31
31
  const now = (): number => performance.now();
32
32
 
33
+ /**
34
+ * Rows changed on this CONNECTION since it was opened — monotonic, so a difference across
35
+ * one statement is that statement's own count. Used only where `.run()` cannot report it.
36
+ */
37
+ const totalChanges = (db: Database): number =>
38
+ (db.query('SELECT total_changes() AS n').get() as { n: number }).n;
39
+
40
+ /** The connection's last inserted rowid, which is what D1 reports in `meta.last_row_id`. */
41
+ const lastInsertRowid = (db: Database): number =>
42
+ (db.query('SELECT last_insert_rowid() AS r').get() as { r: number }).r;
43
+
33
44
  /**
34
45
  * 🔴 The synchronous core, kept separate from the async surface on purpose.
35
46
  *
@@ -63,11 +74,53 @@ class LocalStatement implements D1LikeStatement, SyncExecutable {
63
74
 
64
75
  allSync<T = D1LikeRow>(): D1LikeResult<T> {
65
76
  const started = now();
66
- const rows = this.stmt().all(...(this.params as never[])) as Record<string, unknown>[];
77
+ const stmt = this.stmt();
78
+
79
+ // 🔴 A statement with NO result columns cannot return rows, so `.run()` is both the
80
+ // cheaper path and the only one that reports `changes` at all — `.all()` does not.
81
+ // `columnNames` is `bun:sqlite`'s own answer and is exact: `[]` for INSERT/UPDATE/
82
+ // DELETE, populated for SELECT and for anything carrying RETURNING (probed 2026-09-17).
83
+ if (stmt.columnNames.length === 0) {
84
+ const res = stmt.run(...(this.params as never[]));
85
+ return {
86
+ results: [],
87
+ success: true,
88
+ meta: makeMeta({
89
+ changes: res.changes,
90
+ last_row_id: res.lastInsertRowid,
91
+ duration: now() - started,
92
+ }),
93
+ };
94
+ }
95
+
96
+ const rowsOf = () => this.stmt().all(...(this.params as never[])) as Record<string, unknown>[];
97
+
98
+ // A pure read changed nothing, and its counters are the PREVIOUS statement's — see
99
+ // `isPureReadSql`. Reporting zero is the measurement, not a default.
100
+ if (isPureReadSql(this.sql)) {
101
+ const rows = rowsOf();
102
+ return {
103
+ results: normalizeRows<T>(rows),
104
+ success: true,
105
+ meta: makeMeta({ duration: now() - started }),
106
+ };
107
+ }
108
+
109
+ // Returns rows AND may write: `INSERT … RETURNING`, `WITH … UPDATE … RETURNING`.
110
+ // `total_changes()` is cumulative for the connection, so the DIFFERENCE across the
111
+ // statement is this statement's own count — the one reading that stays correct when
112
+ // the statement is neither a plain read nor a plain write.
113
+ const before = totalChanges(this.db);
114
+ const rows = rowsOf();
115
+ const changes = totalChanges(this.db) - before;
67
116
  return {
68
117
  results: normalizeRows<T>(rows),
69
118
  success: true,
70
- meta: makeMeta({ duration: now() - started }),
119
+ meta: makeMeta({
120
+ changes,
121
+ last_row_id: changes > 0 ? lastInsertRowid(this.db) : null,
122
+ duration: now() - started,
123
+ }),
71
124
  };
72
125
  }
73
126