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.
- package/dist/server/bench/assert.d.ts +16 -3
- package/dist/server/bench/assert.js +54 -0
- package/dist/server/bench/cpuClock.js +21 -1
- package/dist/server/d1/fakeD1.d.ts +5 -0
- package/dist/server/d1/fakeD1.js +51 -18
- 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 +7 -1
- package/src/leafSubpathsImportNothing.spec.ts +13 -0
- package/src/server/bench/assert.ts +78 -3
- package/src/server/bench/cpuBudget.spec.ts +101 -1
- package/src/server/bench/cpuClock.ts +22 -1
- package/src/server/d1/fakeD1.ts +56 -18
- 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
|
@@ -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
|
|
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,
|
|
@@ -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 () =>
|
|
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
|
}
|
|
@@ -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.
|
package/dist/server/d1/fakeD1.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/server/d1/local.js
CHANGED
|
@@ -15,9 +15,16 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { assertBatchSize, assertWithinLimits } from './limits';
|
|
17
17
|
import { D1UnsupportedError, } from './types';
|
|
18
|
-
import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
|
|
18
|
+
import { isPureReadSql, makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
|
|
19
19
|
/** Shared clock, so `meta.duration` means the same thing on both sides of the seam. */
|
|
20
20
|
const now = () => performance.now();
|
|
21
|
+
/**
|
|
22
|
+
* Rows changed on this CONNECTION since it was opened — monotonic, so a difference across
|
|
23
|
+
* one statement is that statement's own count. Used only where `.run()` cannot report it.
|
|
24
|
+
*/
|
|
25
|
+
const totalChanges = (db) => db.query('SELECT total_changes() AS n').get().n;
|
|
26
|
+
/** The connection's last inserted rowid, which is what D1 reports in `meta.last_row_id`. */
|
|
27
|
+
const lastInsertRowid = (db) => db.query('SELECT last_insert_rowid() AS r').get().r;
|
|
21
28
|
class LocalStatement {
|
|
22
29
|
db;
|
|
23
30
|
sql;
|
|
@@ -37,11 +44,49 @@ class LocalStatement {
|
|
|
37
44
|
}
|
|
38
45
|
allSync() {
|
|
39
46
|
const started = now();
|
|
40
|
-
const
|
|
47
|
+
const stmt = this.stmt();
|
|
48
|
+
// 🔴 A statement with NO result columns cannot return rows, so `.run()` is both the
|
|
49
|
+
// cheaper path and the only one that reports `changes` at all — `.all()` does not.
|
|
50
|
+
// `columnNames` is `bun:sqlite`'s own answer and is exact: `[]` for INSERT/UPDATE/
|
|
51
|
+
// DELETE, populated for SELECT and for anything carrying RETURNING (probed 2026-09-17).
|
|
52
|
+
if (stmt.columnNames.length === 0) {
|
|
53
|
+
const res = stmt.run(...this.params);
|
|
54
|
+
return {
|
|
55
|
+
results: [],
|
|
56
|
+
success: true,
|
|
57
|
+
meta: makeMeta({
|
|
58
|
+
changes: res.changes,
|
|
59
|
+
last_row_id: res.lastInsertRowid,
|
|
60
|
+
duration: now() - started,
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const rowsOf = () => this.stmt().all(...this.params);
|
|
65
|
+
// A pure read changed nothing, and its counters are the PREVIOUS statement's — see
|
|
66
|
+
// `isPureReadSql`. Reporting zero is the measurement, not a default.
|
|
67
|
+
if (isPureReadSql(this.sql)) {
|
|
68
|
+
const rows = rowsOf();
|
|
69
|
+
return {
|
|
70
|
+
results: normalizeRows(rows),
|
|
71
|
+
success: true,
|
|
72
|
+
meta: makeMeta({ duration: now() - started }),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Returns rows AND may write: `INSERT … RETURNING`, `WITH … UPDATE … RETURNING`.
|
|
76
|
+
// `total_changes()` is cumulative for the connection, so the DIFFERENCE across the
|
|
77
|
+
// statement is this statement's own count — the one reading that stays correct when
|
|
78
|
+
// the statement is neither a plain read nor a plain write.
|
|
79
|
+
const before = totalChanges(this.db);
|
|
80
|
+
const rows = rowsOf();
|
|
81
|
+
const changes = totalChanges(this.db) - before;
|
|
41
82
|
return {
|
|
42
83
|
results: normalizeRows(rows),
|
|
43
84
|
success: true,
|
|
44
|
-
meta: makeMeta({
|
|
85
|
+
meta: makeMeta({
|
|
86
|
+
changes,
|
|
87
|
+
last_row_id: changes > 0 ? lastInsertRowid(this.db) : null,
|
|
88
|
+
duration: now() - started,
|
|
89
|
+
}),
|
|
45
90
|
};
|
|
46
91
|
}
|
|
47
92
|
async first(column) {
|
|
@@ -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,
|
|
@@ -23,6 +23,23 @@ import { type D1LikeBindable, type D1LikeMeta, type D1LikeValue } from './types'
|
|
|
23
23
|
export declare function normalizeBind(value: unknown, index: number): D1LikeBindable;
|
|
24
24
|
/** Normalize a whole parameter list, reporting the offending position on failure. */
|
|
25
25
|
export declare const normalizeBinds: (values: readonly unknown[]) => D1LikeBindable[];
|
|
26
|
+
/**
|
|
27
|
+
* Is this statement a PURE READ — one that cannot possibly have changed a row?
|
|
28
|
+
*
|
|
29
|
+
* 🔴 Measured 2026-09-17, and this is why the question has to be asked at all:
|
|
30
|
+
* SQLite's `changes()` and `last_insert_rowid()` are **connection-wide and sticky**.
|
|
31
|
+
* After a plain `SELECT` they still read the counters left by the last write — probed
|
|
32
|
+
* here, a `SELECT` following a 1-row insert reports `changes = 1`. So those counters may
|
|
33
|
+
* only ever be attributed to a statement that actually wrote something, and a driver
|
|
34
|
+
* that reads them after a read invents a `changes` out of the previous statement's.
|
|
35
|
+
*
|
|
36
|
+
* Only a leading `SELECT` is claimed, because that is the one keyword that is
|
|
37
|
+
* unambiguous: `WITH … INSERT … RETURNING` and `INSERT … RETURNING` both return rows AND
|
|
38
|
+
* write. Anything this returns `false` for is measured exactly instead (see
|
|
39
|
+
* `local.ts`), so a statement shape this does not recognise costs two extra queries —
|
|
40
|
+
* never a wrong number.
|
|
41
|
+
*/
|
|
42
|
+
export declare const isPureReadSql: (sql: string) => boolean;
|
|
26
43
|
/**
|
|
27
44
|
* Normalize one column value coming BACK from a driver.
|
|
28
45
|
*
|
package/dist/server/d1/values.js
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,
|
|
@@ -69,6 +69,25 @@ export function normalizeBind(value, index) {
|
|
|
69
69
|
}
|
|
70
70
|
/** Normalize a whole parameter list, reporting the offending position on failure. */
|
|
71
71
|
export const normalizeBinds = (values) => values.map((v, i) => normalizeBind(v, i));
|
|
72
|
+
/** Leading whitespace and SQL comments, which sit in front of the real first keyword. */
|
|
73
|
+
const LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
|
|
74
|
+
/**
|
|
75
|
+
* Is this statement a PURE READ — one that cannot possibly have changed a row?
|
|
76
|
+
*
|
|
77
|
+
* 🔴 Measured 2026-09-17, and this is why the question has to be asked at all:
|
|
78
|
+
* SQLite's `changes()` and `last_insert_rowid()` are **connection-wide and sticky**.
|
|
79
|
+
* After a plain `SELECT` they still read the counters left by the last write — probed
|
|
80
|
+
* here, a `SELECT` following a 1-row insert reports `changes = 1`. So those counters may
|
|
81
|
+
* only ever be attributed to a statement that actually wrote something, and a driver
|
|
82
|
+
* that reads them after a read invents a `changes` out of the previous statement's.
|
|
83
|
+
*
|
|
84
|
+
* Only a leading `SELECT` is claimed, because that is the one keyword that is
|
|
85
|
+
* unambiguous: `WITH … INSERT … RETURNING` and `INSERT … RETURNING` both return rows AND
|
|
86
|
+
* write. Anything this returns `false` for is measured exactly instead (see
|
|
87
|
+
* `local.ts`), so a statement shape this does not recognise costs two extra queries —
|
|
88
|
+
* never a wrong number.
|
|
89
|
+
*/
|
|
90
|
+
export const isPureReadSql = (sql) => /^select\b/i.test(sql.replace(LEADING_NOISE, ''));
|
|
72
91
|
/**
|
|
73
92
|
* Normalize one column value coming BACK from a driver.
|
|
74
93
|
*
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
/** What {@link readBoundedBytes} gives back. */
|
|
39
|
+
export type BoundedBytes = {
|
|
40
|
+
ok: true;
|
|
41
|
+
value: Uint8Array;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
status: 413 | 400;
|
|
45
|
+
error: string;
|
|
46
|
+
};
|
|
47
|
+
/** The minimum a route needs to hand over a raw body. */
|
|
48
|
+
export interface BodyBytesSource {
|
|
49
|
+
req: {
|
|
50
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** What {@link readBoundedJson} gives back — a discriminated result, never a throw. */
|
|
54
|
+
export type BoundedJson<T> = {
|
|
55
|
+
ok: true;
|
|
56
|
+
value: T;
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
status: 413;
|
|
60
|
+
error: string;
|
|
61
|
+
} | {
|
|
62
|
+
ok: false;
|
|
63
|
+
status: 400;
|
|
64
|
+
error: string;
|
|
65
|
+
};
|
|
66
|
+
export interface BoundedJsonOptions {
|
|
67
|
+
/** Hard ceiling on the raw body, in bytes. */
|
|
68
|
+
maxBytes: number;
|
|
69
|
+
/** What to call the payload in the error message (e.g. "save state"). */
|
|
70
|
+
label?: string;
|
|
71
|
+
}
|
|
72
|
+
/** The minimum a route needs from Hono's context — kept structural so tests need no Hono. */
|
|
73
|
+
export interface BodyTextSource {
|
|
74
|
+
req: {
|
|
75
|
+
text(): Promise<string>;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** What {@link readBoundedText} gives back. Same shape, minus the parse failure. */
|
|
79
|
+
export type BoundedText = {
|
|
80
|
+
ok: true;
|
|
81
|
+
value: string;
|
|
82
|
+
} | {
|
|
83
|
+
ok: false;
|
|
84
|
+
status: 413 | 400;
|
|
85
|
+
error: string;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Read a body as TEXT, refusing anything over `maxBytes`.
|
|
89
|
+
*
|
|
90
|
+
* The sibling of {@link readBoundedJson}, for a payload that IS the text rather
|
|
91
|
+
* than a document containing it — `apps/patterns`' chunked sends put their
|
|
92
|
+
* metadata in the query string and the chunk in the raw body, which spares a
|
|
93
|
+
* megabyte-scale caller from JSON-escaping every piece and spares this process
|
|
94
|
+
* from parsing what it is only going to concatenate.
|
|
95
|
+
*
|
|
96
|
+
* Same byte-not-character measurement, and for the same reason: it is the number
|
|
97
|
+
* the proxy in front of this is enforcing.
|
|
98
|
+
*/
|
|
99
|
+
export declare function readBoundedText(c: BodyTextSource, options: BoundedJsonOptions): Promise<BoundedText>;
|
|
100
|
+
/**
|
|
101
|
+
* Read a body as RAW BYTES, refusing anything over `maxBytes`.
|
|
102
|
+
*
|
|
103
|
+
* The third member of the family, for a payload that is neither JSON nor text:
|
|
104
|
+
* roms' ROM-upload chunks are cartridge bytes, and routing them through
|
|
105
|
+
* {@link readBoundedText} would decode arbitrary binary as UTF-8 — which is
|
|
106
|
+
* lossy (every invalid sequence becomes U+FFFD) and measures a size that is not
|
|
107
|
+
* the size the proxy in front is enforcing.
|
|
108
|
+
*
|
|
109
|
+
* A `Content-Length` claim is deliberately NOT trusted here: it is a header, and
|
|
110
|
+
* the guard has to hold against a body that disagrees with it. The measurement is
|
|
111
|
+
* the buffer that actually arrived.
|
|
112
|
+
*/
|
|
113
|
+
export declare function readBoundedBytes(c: BodyBytesSource, options: BoundedJsonOptions): Promise<BoundedBytes>;
|
|
114
|
+
/** The minimum {@link refuseOversizedBody} needs — a header lookup, nothing else. */
|
|
115
|
+
export interface BodyHeaderSource {
|
|
116
|
+
req: {
|
|
117
|
+
header(name: string): string | undefined;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Refuse a body by its DECLARED `Content-Length`, without reading it.
|
|
122
|
+
*
|
|
123
|
+
* The fourth member of the family, and the only one for a body that cannot be
|
|
124
|
+
* buffered first: a `multipart/form-data` upload. The other three measure the
|
|
125
|
+
* bytes that actually arrived, which is strictly better — but they get to,
|
|
126
|
+
* because they buffer. `c.req.formData()` gives no such opportunity: by the time
|
|
127
|
+
* it resolves, the whole upload is already in a process whose unit caps at
|
|
128
|
+
* 300 MB of memory, which is the thing the limit exists to prevent.
|
|
129
|
+
*
|
|
130
|
+
* So this trusts the header, and the docblock says so plainly. A liar gets
|
|
131
|
+
* through to the buffering call — but a liar was always going to, and this still
|
|
132
|
+
* turns the honest 3 GB folder-drop (the actual failure shape) into a refusal
|
|
133
|
+
* the app can explain instead of an OOM or an nginx HTML 413 the app never sees.
|
|
134
|
+
* It is a companion to the proxy's `client_max_body_size`, never a replacement.
|
|
135
|
+
*
|
|
136
|
+
* A missing or unparseable header is NOT a refusal: chunked requests omit it
|
|
137
|
+
* legitimately, and refusing them would break a correct client to catch a
|
|
138
|
+
* dishonest one.
|
|
139
|
+
*/
|
|
140
|
+
export declare function refuseOversizedBody(c: BodyHeaderSource, options: BoundedJsonOptions): {
|
|
141
|
+
status: 413;
|
|
142
|
+
error: string;
|
|
143
|
+
} | null;
|
|
144
|
+
/**
|
|
145
|
+
* Read a JSON body, refusing anything over `maxBytes` before parsing it.
|
|
146
|
+
*
|
|
147
|
+
* The size is measured in BYTES, not characters. `String.length` counts UTF-16
|
|
148
|
+
* code units, so a body of 4-byte emoji measures half its true size that way —
|
|
149
|
+
* which would let a caller past a byte limit the proxy is enforcing in real
|
|
150
|
+
* bytes, and produce the mismatch this module exists to close.
|
|
151
|
+
*/
|
|
152
|
+
export declare function readBoundedJson<T = unknown>(c: BodyTextSource, options: BoundedJsonOptions): Promise<BoundedJson<T>>;
|