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.
- package/dist/server/bench/assert.d.ts +16 -3
- package/dist/server/bench/assert.js +54 -0
- package/dist/server/bench/budget.d.ts +35 -1
- package/dist/server/bench/budget.js +35 -1
- package/dist/server/bench/cpuClock.js +21 -1
- package/dist/server/bench/index.d.ts +1 -1
- package/dist/server/bench/index.js +1 -1
- package/dist/server/d1/fakeD1.d.ts +5 -0
- package/dist/server/d1/fakeD1.js +51 -18
- package/dist/server/d1/index.d.ts +1 -0
- package/dist/server/d1/index.js +1 -0
- package/dist/server/d1/invocation.d.ts +72 -0
- package/dist/server/d1/invocation.js +166 -0
- package/dist/server/d1/local.js +48 -3
- package/dist/server/d1/values.d.ts +19 -2
- package/dist/server/d1/values.js +21 -2
- package/dist/server/middleware/bodyLimit.d.ts +152 -0
- package/dist/server/middleware/bodyLimit.js +161 -0
- package/package.json +8 -2
- package/src/leafSubpathsImportNothing.spec.ts +13 -0
- package/src/server/bench/assert.ts +78 -3
- package/src/server/bench/budget.spec.ts +27 -0
- package/src/server/bench/budget.ts +36 -1
- package/src/server/bench/cpuBudget.spec.ts +101 -1
- package/src/server/bench/cpuClock.ts +22 -1
- package/src/server/bench/index.ts +1 -0
- package/src/server/d1/fakeD1.ts +56 -18
- package/src/server/d1/index.ts +1 -0
- package/src/server/d1/invocation.spec.ts +174 -0
- package/src/server/d1/invocation.ts +207 -0
- package/src/server/d1/local.ts +56 -3
- package/src/server/d1/sameShape.spec.ts +73 -0
- package/src/server/d1/values.ts +23 -2
- package/src/server/middleware/bodyLimit.spec.ts +238 -0
- package/src/server/middleware/bodyLimit.ts +210 -0
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
|
|
44
|
+
import { LIMITS } from './limits';
|
|
45
|
+
import { D1LimitError, type D1LikeBindable, type D1LikeDatabase, type D1LikeResult, type D1LikeRow, type D1LikeStatement, type D1LikeValue } from './types';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A database handle that knows how much of its invocation budget it has spent.
|
|
49
|
+
*
|
|
50
|
+
* `used` counts QUERIES the way D1 bills them: one per terminal statement call
|
|
51
|
+
* (`first`/`all`/`run`/`raw`), one per member of a `batch()`, and one per statement an
|
|
52
|
+
* `exec()` ran. Building a statement with `prepare()` or `bind()` costs nothing, because it
|
|
53
|
+
* reaches the database only when it is executed.
|
|
54
|
+
*/
|
|
55
|
+
export interface InvocationD1 extends D1LikeDatabase {
|
|
56
|
+
/** Queries charged to this invocation so far. */
|
|
57
|
+
readonly used: number;
|
|
58
|
+
/** Queries left before the cap, floored at 0. */
|
|
59
|
+
readonly remaining: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The statements a wrapper handed out, mapped back to the driver's own.
|
|
64
|
+
*
|
|
65
|
+
* 🔴 Both drivers identify their own statements structurally — `local.ts` probes for
|
|
66
|
+
* `allSync`, `remote.ts` for `binding` — and refuse anything else with "a local statement
|
|
67
|
+
* cannot run inside a D1 batch". A counted wrapper has neither property, so passing the
|
|
68
|
+
* wrappers straight through would break `batch()` on BOTH sides. `batch()` therefore
|
|
69
|
+
* unwraps here before delegating. A statement this does not know is passed through
|
|
70
|
+
* untouched, so the driver's own error is what a genuine cross-driver mix-up still gets.
|
|
71
|
+
*/
|
|
72
|
+
const INNER = new WeakMap<D1LikeStatement, D1LikeStatement>();
|
|
73
|
+
|
|
74
|
+
class Budget {
|
|
75
|
+
used = 0;
|
|
76
|
+
|
|
77
|
+
constructor(readonly max: number) {}
|
|
78
|
+
|
|
79
|
+
/** Charge before the work goes out, so the query over the cap is never issued. */
|
|
80
|
+
charge(n: number, what: string): void {
|
|
81
|
+
if (this.used + n > this.max) {
|
|
82
|
+
throw new D1LimitError(
|
|
83
|
+
'queries per Worker invocation',
|
|
84
|
+
this.used + n,
|
|
85
|
+
this.max,
|
|
86
|
+
`${what} would be query ${this.used + n} of this invocation. This is almost always an N+1 loop — ` +
|
|
87
|
+
'replace the loop with a JOIN, or collect the statements and send them as one `batch()`. ' +
|
|
88
|
+
'Work that genuinely needs more than a thousand queries belongs in a Queue consumer or a Cron Trigger, ' +
|
|
89
|
+
'which get a fresh budget per invocation.',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
this.used += n;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Record a cost that was only knowable after the fact — see `exec()` below. Deliberately
|
|
97
|
+
* unchecked: the statements have already run, so throwing here would report a limit
|
|
98
|
+
* breach by hiding the result that proves it. Going over simply means the next
|
|
99
|
+
* {@link charge} throws, which is the first moment a refusal can still prevent anything.
|
|
100
|
+
*/
|
|
101
|
+
settle(n: number): void {
|
|
102
|
+
this.used += n;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Wrap one statement so every terminal call is charged to `budget`. */
|
|
107
|
+
function countedStatement(inner: D1LikeStatement, budget: Budget, sql: string): D1LikeStatement {
|
|
108
|
+
const label = `\`${sql.trim().slice(0, 60)}\``;
|
|
109
|
+
|
|
110
|
+
class CountedStatement implements D1LikeStatement {
|
|
111
|
+
bind(...values: D1LikeBindable[]): D1LikeStatement {
|
|
112
|
+
// Binding is free — it reaches nothing. The NEW statement is wrapped too, or a
|
|
113
|
+
// `prepare().bind().run()` would escape the count entirely.
|
|
114
|
+
return countedStatement(inner.bind(...values), budget, sql);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async first<T = D1LikeRow>(column?: string): Promise<T | null> {
|
|
118
|
+
budget.charge(1, label);
|
|
119
|
+
return column === undefined ? inner.first<T>() : (inner.first<T>(column) as Promise<T | null>);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async all<T = D1LikeRow>(): Promise<D1LikeResult<T>> {
|
|
123
|
+
budget.charge(1, label);
|
|
124
|
+
return inner.all<T>();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async run(): Promise<D1LikeResult<never>> {
|
|
128
|
+
budget.charge(1, label);
|
|
129
|
+
return inner.run();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async raw<V = D1LikeValue>(): Promise<V[][]> {
|
|
133
|
+
budget.charge(1, label);
|
|
134
|
+
return inner.raw<V>();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const wrapped = new CountedStatement();
|
|
139
|
+
INNER.set(wrapped, inner);
|
|
140
|
+
return wrapped;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Give `db` a query budget for ONE Worker invocation.
|
|
145
|
+
*
|
|
146
|
+
* Construct a fresh one per request — the count is the request's, not the handle's, and
|
|
147
|
+
* re-using one across requests would refuse the 1,001st query of the day rather than of the
|
|
148
|
+
* invocation. See the header for why that distinction is the whole design.
|
|
149
|
+
*
|
|
150
|
+
* `max` exists for tests and for a caller that wants to be refused sooner than D1 would
|
|
151
|
+
* (a route with a budget of 50 finds its own N+1 long before the hard cap does). It may not
|
|
152
|
+
* be raised above D1's real limit, because a budget larger than the service's is a number
|
|
153
|
+
* that reports success right up until production disagrees.
|
|
154
|
+
*/
|
|
155
|
+
export function perInvocation(db: D1LikeDatabase, opts: { max?: number } = {}): InvocationD1 {
|
|
156
|
+
const max = opts.max ?? LIMITS.queriesPerInvocation;
|
|
157
|
+
if (!Number.isInteger(max) || max < 1) {
|
|
158
|
+
throw new RangeError(`max must be a positive integer, got ${max}`);
|
|
159
|
+
}
|
|
160
|
+
if (max > LIMITS.queriesPerInvocation) {
|
|
161
|
+
throw new D1LimitError(
|
|
162
|
+
'queries per Worker invocation',
|
|
163
|
+
max,
|
|
164
|
+
LIMITS.queriesPerInvocation,
|
|
165
|
+
'A budget above D1\'s own cap cannot be honoured — the service refuses first. Lower it, or split the work across invocations.',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const budget = new Budget(max);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
flavor: db.flavor,
|
|
172
|
+
|
|
173
|
+
get used() {
|
|
174
|
+
return budget.used;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
get remaining() {
|
|
178
|
+
return Math.max(0, budget.max - budget.used);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
prepare(sql: string): D1LikeStatement {
|
|
182
|
+
return countedStatement(db.prepare(sql), budget, sql);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]> {
|
|
186
|
+
// One round trip, but D1 bills each member — so does this.
|
|
187
|
+
budget.charge(statements.length, `a batch() of ${statements.length}`);
|
|
188
|
+
return db.batch<T>(statements.map((s) => INNER.get(s) ?? s));
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 🔴 The one cost that cannot be charged up front. `exec()` runs however many
|
|
193
|
+
* statements the SQL contains, and remotely that number comes back FROM the service —
|
|
194
|
+
* there is nothing trustworthy to count beforehand. So this reserves one query, runs,
|
|
195
|
+
* and settles the true cost afterwards; an `exec` that blows the budget is reported on
|
|
196
|
+
* the next query rather than prevented. That is an acceptable trade only because
|
|
197
|
+
* `exec` is the DDL/migration path — it carries no user input by contract, and it is
|
|
198
|
+
* not the shape an N+1 loop takes.
|
|
199
|
+
*/
|
|
200
|
+
async exec(sql: string): Promise<{ count: number; duration: number }> {
|
|
201
|
+
budget.charge(1, 'an exec()');
|
|
202
|
+
const res = await db.exec(sql);
|
|
203
|
+
if (res.count > 1) budget.settle(res.count - 1);
|
|
204
|
+
return res;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
package/src/server/d1/local.ts
CHANGED
|
@@ -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
|
|
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({
|
|
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
|
|
|
@@ -276,4 +276,77 @@ describe('local and D1 drivers return identical shapes', () => {
|
|
|
276
276
|
expect(await attempt(p.local)).toBe(0);
|
|
277
277
|
expect(await attempt(p.remote)).toBe(0);
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
// ── batch META, which both sides got wrong the same way until 2026-09-17 ──────────
|
|
281
|
+
//
|
|
282
|
+
// 🔴 These four assert VALUES, not just agreement. The bug they pin was `changes: 0`
|
|
283
|
+
// hardcoded in `local.ts`'s `allSync` AND in `fakeD1`'s `batch`, so the two drivers
|
|
284
|
+
// agreed perfectly on a wrong answer and every `expect(local).toEqual(remote)` above
|
|
285
|
+
// stayed green. A shared bug is the one failure mode a two-implementation test cannot
|
|
286
|
+
// see, so the numbers have to be written down here. Found by porting
|
|
287
|
+
// `apps/patterns/src/server/tokens.ts` onto the seam, on its first atomic write.
|
|
288
|
+
|
|
289
|
+
test('batch() reports each member’s OWN changes — not zero, on both', async () => {
|
|
290
|
+
p.seed(`INSERT INTO t (id, text_col) VALUES (1, 'a'), (2, 'b'), (3, 'c')`);
|
|
291
|
+
const { local, remote } = await both(p, async (db) => {
|
|
292
|
+
const res = await db.batch([
|
|
293
|
+
db.prepare("UPDATE t SET text_col = 'z'"),
|
|
294
|
+
db.prepare('DELETE FROM t WHERE id = ?').bind(1),
|
|
295
|
+
]);
|
|
296
|
+
return res.map((r) => r.meta.changes);
|
|
297
|
+
});
|
|
298
|
+
expect(local).toEqual(remote);
|
|
299
|
+
// The whole point: three rows updated and one deleted, reported per statement.
|
|
300
|
+
expect(local).toEqual([3, 1]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('batch() reports last_row_id for an insert, on both', async () => {
|
|
304
|
+
const { local, remote } = await both(p, async (db) => {
|
|
305
|
+
const res = await db.batch([
|
|
306
|
+
db.prepare('INSERT INTO t (id, text_col) VALUES (41, ?)').bind('a'),
|
|
307
|
+
db.prepare('INSERT INTO t (id, text_col) VALUES (42, ?)').bind('b'),
|
|
308
|
+
]);
|
|
309
|
+
return res.map((r) => ({ changes: r.meta.changes, last_row_id: r.meta.last_row_id }));
|
|
310
|
+
});
|
|
311
|
+
expect(local).toEqual(remote);
|
|
312
|
+
expect(local).toEqual([
|
|
313
|
+
{ changes: 1, last_row_id: 41 },
|
|
314
|
+
{ changes: 1, last_row_id: 42 },
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('a READ inside a batch reports changes 0, even right after a write', async () => {
|
|
319
|
+
// 🔴 The trap in the obvious fix. SQLite's `changes()` is connection-wide and
|
|
320
|
+
// sticky, so a driver that simply reads it after every statement reports the
|
|
321
|
+
// UPDATE's 3 as the SELECT's own count. Measured 2026-09-17: a `SELECT` following
|
|
322
|
+
// a one-row insert still answers `changes() = 1`.
|
|
323
|
+
p.seed(`INSERT INTO t (id, text_col) VALUES (1, 'a'), (2, 'b'), (3, 'c')`);
|
|
324
|
+
const { local, remote } = await both(p, async (db) => {
|
|
325
|
+
const res = await db.batch([
|
|
326
|
+
db.prepare("UPDATE t SET text_col = 'z'"),
|
|
327
|
+
db.prepare('SELECT id FROM t ORDER BY id'),
|
|
328
|
+
]);
|
|
329
|
+
return {
|
|
330
|
+
changes: res.map((r) => r.meta.changes),
|
|
331
|
+
rows: res[1]?.results,
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
expect(local).toEqual(remote);
|
|
335
|
+
expect(local.changes).toEqual([3, 0]);
|
|
336
|
+
expect(local.rows).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('INSERT … RETURNING in a batch reports its rows AND its changes, on both', async () => {
|
|
340
|
+
// Returns rows and writes, so neither the `.run()` path nor the pure-read path
|
|
341
|
+
// answers it — this is the case `total_changes()` differencing exists for.
|
|
342
|
+
const { local, remote } = await both(p, async (db) => {
|
|
343
|
+
const res = await db.batch([
|
|
344
|
+
db.prepare('INSERT INTO t (id, text_col) VALUES (7, ?), (8, ?) RETURNING id').bind('a', 'b'),
|
|
345
|
+
]);
|
|
346
|
+
return { rows: res[0]?.results, changes: res[0]?.meta.changes };
|
|
347
|
+
});
|
|
348
|
+
expect(local).toEqual(remote);
|
|
349
|
+
expect(local.changes).toBe(2);
|
|
350
|
+
expect(local.rows).toEqual([{ id: 7 }, { id: 8 }]);
|
|
351
|
+
});
|
|
279
352
|
});
|
package/src/server/d1/values.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one place that decides what a bound parameter
|
|
3
|
-
* both drivers.
|
|
2
|
+
* The one place that decides what a bound parameter, a returned column and a statement's
|
|
3
|
+
* own `meta` MEAN, shared by both drivers.
|
|
4
4
|
*
|
|
5
5
|
* 🔴 **Both drivers call these, and that is why the identical-shapes test can pass.** If
|
|
6
6
|
* the local driver normalized its own way and the remote driver normalized its own way,
|
|
@@ -81,6 +81,27 @@ export function normalizeBind(value: unknown, index: number): D1LikeBindable {
|
|
|
81
81
|
export const normalizeBinds = (values: readonly unknown[]): D1LikeBindable[] =>
|
|
82
82
|
values.map((v, i) => normalizeBind(v, i));
|
|
83
83
|
|
|
84
|
+
/** Leading whitespace and SQL comments, which sit in front of the real first keyword. */
|
|
85
|
+
const LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is this statement a PURE READ — one that cannot possibly have changed a row?
|
|
89
|
+
*
|
|
90
|
+
* 🔴 Measured 2026-09-17, and this is why the question has to be asked at all:
|
|
91
|
+
* SQLite's `changes()` and `last_insert_rowid()` are **connection-wide and sticky**.
|
|
92
|
+
* After a plain `SELECT` they still read the counters left by the last write — probed
|
|
93
|
+
* here, a `SELECT` following a 1-row insert reports `changes = 1`. So those counters may
|
|
94
|
+
* only ever be attributed to a statement that actually wrote something, and a driver
|
|
95
|
+
* that reads them after a read invents a `changes` out of the previous statement's.
|
|
96
|
+
*
|
|
97
|
+
* Only a leading `SELECT` is claimed, because that is the one keyword that is
|
|
98
|
+
* unambiguous: `WITH … INSERT … RETURNING` and `INSERT … RETURNING` both return rows AND
|
|
99
|
+
* write. Anything this returns `false` for is measured exactly instead (see
|
|
100
|
+
* `local.ts`), so a statement shape this does not recognise costs two extra queries —
|
|
101
|
+
* never a wrong number.
|
|
102
|
+
*/
|
|
103
|
+
export const isPureReadSql = (sql: string): boolean => /^select\b/i.test(sql.replace(LEADING_NOISE, ''));
|
|
104
|
+
|
|
84
105
|
/**
|
|
85
106
|
* Normalize one column value coming BACK from a driver.
|
|
86
107
|
*
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type BodyBytesSource,
|
|
4
|
+
type BodyHeaderSource,
|
|
5
|
+
type BodyTextSource,
|
|
6
|
+
readBoundedBytes,
|
|
7
|
+
readBoundedJson,
|
|
8
|
+
readBoundedText,
|
|
9
|
+
refuseOversizedBody,
|
|
10
|
+
} from './bodyLimit';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 🔴 A guard that is the LAST line of defense ships with the test of its failure path,
|
|
14
|
+
* not only of its success path.
|
|
15
|
+
*
|
|
16
|
+
* `apps/roms/src/server/limits.test.ts` is where this came from (2026-09-17). Most of
|
|
17
|
+
* that file is about roms' own `routes.ts` — that every bounded-body call site takes its
|
|
18
|
+
* ceiling from `MAX_BODY_BYTES` — and stays there, because it asserts about an app's
|
|
19
|
+
* routes. What travels with the module is its two behavioural tests (an honest oversized
|
|
20
|
+
* declaration is refused with both numbers in the message; a body with no declared length
|
|
21
|
+
* is NOT refused on that ground alone), extended here to the three readers the app-side
|
|
22
|
+
* file never exercised.
|
|
23
|
+
*
|
|
24
|
+
* Every source below is a plain object literal. That is the module's design showing
|
|
25
|
+
* through rather than a shortcut: the four entry points take structural
|
|
26
|
+
* `{ req: { … } }` sources, so nothing here needs Hono, a server, or a socket — which is
|
|
27
|
+
* also why `cursedbelt-server/body-limit` can promise to import nothing at runtime.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const textSource = (raw: string): BodyTextSource => ({ req: { text: async () => raw } });
|
|
31
|
+
|
|
32
|
+
const bytesSource = (bytes: Uint8Array): BodyBytesSource => ({
|
|
33
|
+
req: {
|
|
34
|
+
async arrayBuffer() {
|
|
35
|
+
return bytes.buffer.slice(
|
|
36
|
+
bytes.byteOffset,
|
|
37
|
+
bytes.byteOffset + bytes.byteLength,
|
|
38
|
+
) as ArrayBuffer;
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** A body that dies mid-read — a client that hung up, a socket reset. */
|
|
44
|
+
const brokenText: BodyTextSource = {
|
|
45
|
+
req: {
|
|
46
|
+
text: async () => {
|
|
47
|
+
throw new Error('connection reset');
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const brokenBytes: BodyBytesSource = {
|
|
53
|
+
req: {
|
|
54
|
+
arrayBuffer: async () => {
|
|
55
|
+
throw new Error('connection reset');
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const headerSource = (contentLength: string | undefined): BodyHeaderSource => ({
|
|
61
|
+
req: { header: (name) => (name === 'content-length' ? contentLength : undefined) },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('readBoundedJson', () => {
|
|
65
|
+
it('parses a body inside the ceiling', async () => {
|
|
66
|
+
const result = await readBoundedJson<{ hello: string }>(
|
|
67
|
+
textSource('{"hello":"world"}'),
|
|
68
|
+
{ maxBytes: 1024 },
|
|
69
|
+
);
|
|
70
|
+
expect(result.ok).toBe(true);
|
|
71
|
+
expect(result.ok === true ? result.value : null).toEqual({ hello: 'world' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses an oversized body with 413 and BOTH numbers in the message', async () => {
|
|
75
|
+
const raw = JSON.stringify({ blob: 'x'.repeat(200) });
|
|
76
|
+
const result = await readBoundedJson(textSource(raw), {
|
|
77
|
+
maxBytes: 64,
|
|
78
|
+
label: 'save state',
|
|
79
|
+
});
|
|
80
|
+
expect(result.ok).toBe(false);
|
|
81
|
+
expect(result.ok === false ? result.status : 0).toBe(413);
|
|
82
|
+
const error = result.ok === false ? result.error : '';
|
|
83
|
+
expect(error).toContain('save state');
|
|
84
|
+
expect(error).toContain(String(raw.length)); // what arrived
|
|
85
|
+
expect(error).toContain('64'); // what was allowed
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 🔴 The reason the module measures `TextEncoder().encode(raw).length` and not
|
|
90
|
+
* `raw.length`. A 4-byte emoji is 2 UTF-16 code units, so a `String.length` check
|
|
91
|
+
* measures this body at 8 against a real size of 14 — and lets it past a byte limit
|
|
92
|
+
* the proxy in front is enforcing in real bytes. That mismatch is what this module
|
|
93
|
+
* exists to close, so it gets an assertion rather than a sentence.
|
|
94
|
+
*/
|
|
95
|
+
it('measures BYTES, not UTF-16 code units', async () => {
|
|
96
|
+
const raw = JSON.stringify('😀😀😀');
|
|
97
|
+
expect(raw.length).toBe(8);
|
|
98
|
+
expect(new TextEncoder().encode(raw).length).toBe(14);
|
|
99
|
+
const result = await readBoundedJson(textSource(raw), { maxBytes: 10 });
|
|
100
|
+
expect(result.ok).toBe(false);
|
|
101
|
+
expect(result.ok === false ? result.status : 0).toBe(413);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('allows a body exactly ON the ceiling — the limit is inclusive', async () => {
|
|
105
|
+
const raw = '"abcd"'; // 6 bytes
|
|
106
|
+
const result = await readBoundedJson(textSource(raw), { maxBytes: 6 });
|
|
107
|
+
expect(result.ok).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** A 400, never a throw: the caller gets a status to return, not an exception to catch. */
|
|
111
|
+
it('answers 400 for a body that is not JSON', async () => {
|
|
112
|
+
const result = await readBoundedJson(textSource('not json at all'), {
|
|
113
|
+
maxBytes: 1024,
|
|
114
|
+
label: 'preferences',
|
|
115
|
+
});
|
|
116
|
+
expect(result.ok).toBe(false);
|
|
117
|
+
expect(result.ok === false ? result.status : 0).toBe(400);
|
|
118
|
+
expect(result.ok === false ? result.error : '').toBe('preferences must be JSON');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('answers 400 when the body cannot be read at all', async () => {
|
|
122
|
+
const result = await readBoundedJson(brokenText, { maxBytes: 1024 });
|
|
123
|
+
expect(result.ok).toBe(false);
|
|
124
|
+
expect(result.ok === false ? result.status : 0).toBe(400);
|
|
125
|
+
expect(result.ok === false ? result.error : '').toBe('body could not be read');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('readBoundedText', () => {
|
|
130
|
+
it('hands back the raw text inside the ceiling', async () => {
|
|
131
|
+
const result = await readBoundedText(textSource('chunk-payload'), { maxBytes: 64 });
|
|
132
|
+
expect(result.ok).toBe(true);
|
|
133
|
+
expect(result.ok === true ? result.value : '').toBe('chunk-payload');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('refuses an oversized body with 413, measured in bytes', async () => {
|
|
137
|
+
const result = await readBoundedText(textSource('😀'.repeat(4)), {
|
|
138
|
+
maxBytes: 8,
|
|
139
|
+
label: 'chunk',
|
|
140
|
+
});
|
|
141
|
+
expect(result.ok).toBe(false);
|
|
142
|
+
expect(result.ok === false ? result.status : 0).toBe(413);
|
|
143
|
+
expect(result.ok === false ? result.error : '').toContain('chunk too large (16 bytes');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('answers 400 when the body cannot be read', async () => {
|
|
147
|
+
const result = await readBoundedText(brokenText, { maxBytes: 64 });
|
|
148
|
+
expect(result.ok === false ? result.status : 0).toBe(400);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('readBoundedBytes', () => {
|
|
153
|
+
it('hands back the exact bytes inside the ceiling', async () => {
|
|
154
|
+
const payload = new Uint8Array([0x00, 0xff, 0x80, 0x41]);
|
|
155
|
+
const result = await readBoundedBytes(bytesSource(payload), { maxBytes: 16 });
|
|
156
|
+
expect(result.ok).toBe(true);
|
|
157
|
+
expect([...(result.ok === true ? result.value : [])]).toEqual([0x00, 0xff, 0x80, 0x41]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The reason this exists beside {@link readBoundedText}: 0x80 and 0xff are not valid
|
|
162
|
+
* UTF-8, so decoding cartridge bytes as text would replace them with U+FFFD and measure
|
|
163
|
+
* a size that is not the size the proxy is enforcing.
|
|
164
|
+
*/
|
|
165
|
+
it('does not corrupt bytes that are invalid UTF-8', async () => {
|
|
166
|
+
const payload = new Uint8Array([0x80, 0xfe, 0xff]);
|
|
167
|
+
const result = await readBoundedBytes(bytesSource(payload), { maxBytes: 16 });
|
|
168
|
+
expect(result.ok === true ? result.value.length : -1).toBe(3);
|
|
169
|
+
expect(new TextDecoder().decode(result.ok === true ? result.value : new Uint8Array())).toBe(
|
|
170
|
+
'���',
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('refuses an oversized buffer with 413 and both numbers', async () => {
|
|
175
|
+
const result = await readBoundedBytes(bytesSource(new Uint8Array(100)), {
|
|
176
|
+
maxBytes: 32,
|
|
177
|
+
label: 'rom chunk',
|
|
178
|
+
});
|
|
179
|
+
expect(result.ok === false ? result.status : 0).toBe(413);
|
|
180
|
+
expect(result.ok === false ? result.error : '').toBe(
|
|
181
|
+
'rom chunk too large (100 bytes; the limit is 32)',
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('answers 400 when the body cannot be read', async () => {
|
|
186
|
+
const result = await readBoundedBytes(brokenBytes, { maxBytes: 32 });
|
|
187
|
+
expect(result.ok === false ? result.status : 0).toBe(400);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('refuseOversizedBody', () => {
|
|
192
|
+
/** The guard itself: an honest oversized declaration is refused, and the message names
|
|
193
|
+
* both numbers so a 413 is actionable rather than mysterious. */
|
|
194
|
+
it('refuses an oversized declared body with both numbers in the message', () => {
|
|
195
|
+
const refusal = refuseOversizedBody(headerSource('4194305'), {
|
|
196
|
+
maxBytes: 4 * 1024 * 1024,
|
|
197
|
+
label: 'save state',
|
|
198
|
+
});
|
|
199
|
+
expect(refusal?.status).toBe(413);
|
|
200
|
+
expect(refusal?.error).toContain('save state');
|
|
201
|
+
expect(refusal?.error).toContain('4194305');
|
|
202
|
+
expect(refusal?.error).toContain('4194304');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A chunked request legitimately omits `Content-Length`. Refusing it would break a
|
|
207
|
+
* correct client to catch a dishonest one — and the dishonest one is caught by the
|
|
208
|
+
* buffering readers anyway, which measure what actually arrived.
|
|
209
|
+
*/
|
|
210
|
+
it('does not refuse a body with no declared length on that ground alone', () => {
|
|
211
|
+
expect(refuseOversizedBody(headerSource(undefined), { maxBytes: 1024 })).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does not refuse an unparseable declared length', () => {
|
|
215
|
+
expect(refuseOversizedBody(headerSource('not-a-number'), { maxBytes: 1024 })).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('does not refuse a body exactly on the ceiling', () => {
|
|
219
|
+
expect(refuseOversizedBody(headerSource('1024'), { maxBytes: 1024 })).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 🔴 The compensating control, asserted rather than described. This function TRUSTS the
|
|
224
|
+
* header — it has to, because a `multipart/form-data` body cannot be measured without
|
|
225
|
+
* buffering it first, which is the thing the limit exists to prevent. What makes that
|
|
226
|
+
* safe is that a liar only gets as far as the next guard: a body declaring 1 byte and
|
|
227
|
+
* carrying 5 MB is still refused by the reader that measures what arrived.
|
|
228
|
+
*/
|
|
229
|
+
it('is bypassed by a lying header — and the buffering reader still refuses', async () => {
|
|
230
|
+
const liar = headerSource('1');
|
|
231
|
+
expect(refuseOversizedBody(liar, { maxBytes: 1024 })).toBeNull();
|
|
232
|
+
|
|
233
|
+
const actuallyHuge = await readBoundedBytes(bytesSource(new Uint8Array(5000)), {
|
|
234
|
+
maxBytes: 1024,
|
|
235
|
+
});
|
|
236
|
+
expect(actuallyHuge.ok === false ? actuallyHuge.status : 0).toBe(413);
|
|
237
|
+
});
|
|
238
|
+
});
|