cursedbelt-server 4.0.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/dist/server/storage/binaryStore.d.ts +385 -0
- package/dist/server/storage/binaryStore.js +739 -0
- package/dist/server/storage/binaryStoreFake.d.ts +56 -0
- package/dist/server/storage/binaryStoreFake.js +63 -0
- package/package.json +19 -1
- package/src/leafSubpathsImportNothing.spec.ts +37 -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
- package/src/server/storage/binaryStore.spec.ts +908 -0
- package/src/server/storage/binaryStore.ts +1049 -0
- package/src/server/storage/binaryStoreFake.ts +111 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `BinaryStore` that does nothing, so adding a method to the interface stops
|
|
3
|
+
* breaking twenty-two test files in apps nobody touched.
|
|
4
|
+
*
|
|
5
|
+
* ── The measurement (2026-09-08) ────────────────────────────────────────────
|
|
6
|
+
* `BinaryStore.fetchMedia` was added to serve audio off this Mac while
|
|
7
|
+
* Cloudflare's daily Worker quota was spent — a good change, landed on `main`,
|
|
8
|
+
* with apps/music green. It also broke **apps/collections and apps/roms**, four
|
|
9
|
+
* test files each, with `Property 'fetchMedia' is missing in type`. Both apps
|
|
10
|
+
* were left unable to typecheck, and therefore unable to gate or deploy, by a
|
|
11
|
+
* commit neither of them appears in. Nobody noticed for hours: a worker's
|
|
12
|
+
* `verify:scoped` only typechecks the workspaces its own diff touches, which is
|
|
13
|
+
* exactly the property that makes it fast.
|
|
14
|
+
*
|
|
15
|
+
* The owner's rule, in his words: *"a change to roms for example cannot block
|
|
16
|
+
* family tree app from passing its tests."* This is that rule for the one
|
|
17
|
+
* interface twenty-two suites all fake by hand.
|
|
18
|
+
*
|
|
19
|
+
* 🔴 **This is why the fake travels WITH the interface, into this package.** An
|
|
20
|
+
* interface whose change can break two other apps from a third app's commit is a
|
|
21
|
+
* shared interface already; a fake living in a different repo from the interface it
|
|
22
|
+
* fakes cannot do the job it exists for, because the two can be changed
|
|
23
|
+
* independently. Published as `cursedbelt-server/binary-store/testing`, beside
|
|
24
|
+
* `cursedbelt-server/binary-store` — the same split as `./d1` and `./d1/testing`, and
|
|
25
|
+
* for the same reason: a consumer's PRODUCTION graph must not be able to reach a
|
|
26
|
+
* fake.
|
|
27
|
+
*
|
|
28
|
+
* ── 🔴 Why defaults and not optional members ────────────────────────────────
|
|
29
|
+
* The obvious alternative is to mark each new capability `fetchMedia?:` on the
|
|
30
|
+
* interface. That moves the cost to every REAL consumer — each one then has to
|
|
31
|
+
* narrow before calling, for a method the production store always has — and it
|
|
32
|
+
* makes "did we implement this?" unanswerable by the typechecker, which is the
|
|
33
|
+
* one question the interface exists to answer. The fakes are the side that
|
|
34
|
+
* should absorb the change, so the defaults live here.
|
|
35
|
+
*
|
|
36
|
+
* ── What a default may do ───────────────────────────────────────────────────
|
|
37
|
+
* As little as possible, and never something a test could mistake for a result.
|
|
38
|
+
* `get` and `meta` answer "not here"; `fetchMedia` answers 404; the mutators do
|
|
39
|
+
* nothing. A suite that cares about any of them overrides it — spread this first
|
|
40
|
+
* and the file's own members win:
|
|
41
|
+
*
|
|
42
|
+
* const store: BinaryStore = {
|
|
43
|
+
* ...binaryStoreFakeDefaults("collections"),
|
|
44
|
+
* async meta(key) { … what this suite is actually about … },
|
|
45
|
+
* };
|
|
46
|
+
*/
|
|
47
|
+
import type { BinaryStore } from './binaryStore';
|
|
48
|
+
/**
|
|
49
|
+
* Every member of {@link BinaryStore}, inert.
|
|
50
|
+
*
|
|
51
|
+
* @param appId the tenant the fake claims to be. Required, and deliberately so —
|
|
52
|
+
* a blank tenant is the mistake `binaryStoreTenant` exists to catch, and a
|
|
53
|
+
* fake that quietly defaults it would hide a test written against the
|
|
54
|
+
* wrong app.
|
|
55
|
+
*/
|
|
56
|
+
export declare function binaryStoreFakeDefaults(appId: string): BinaryStore;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every member of {@link BinaryStore}, inert.
|
|
3
|
+
*
|
|
4
|
+
* @param appId the tenant the fake claims to be. Required, and deliberately so —
|
|
5
|
+
* a blank tenant is the mistake `binaryStoreTenant` exists to catch, and a
|
|
6
|
+
* fake that quietly defaults it would hide a test written against the
|
|
7
|
+
* wrong app.
|
|
8
|
+
*/
|
|
9
|
+
export function binaryStoreFakeDefaults(appId) {
|
|
10
|
+
return {
|
|
11
|
+
appId,
|
|
12
|
+
async put() { },
|
|
13
|
+
async putLarge() { },
|
|
14
|
+
async get() {
|
|
15
|
+
return null;
|
|
16
|
+
},
|
|
17
|
+
async meta() {
|
|
18
|
+
return null;
|
|
19
|
+
},
|
|
20
|
+
async stat() {
|
|
21
|
+
return null;
|
|
22
|
+
},
|
|
23
|
+
async has() {
|
|
24
|
+
return false;
|
|
25
|
+
},
|
|
26
|
+
// 🔴 `"unknown"`, never `"absent"`. A fake that claimed to know a key is missing
|
|
27
|
+
// would let a listing route's "answer only from the memo" branch look exercised
|
|
28
|
+
// when nothing was ever memoized — the one thing a test of that branch is for.
|
|
29
|
+
known() {
|
|
30
|
+
return 'unknown';
|
|
31
|
+
},
|
|
32
|
+
// 🔴 `0`, and never a lie about work done. The real one returns how many
|
|
33
|
+
// negative memos it dropped, and an app's repair route reports that number to
|
|
34
|
+
// the owner — a fake that invented one would make a broken repair read as a
|
|
35
|
+
// working one in the only test that could catch it.
|
|
36
|
+
forgetMisses() {
|
|
37
|
+
return 0;
|
|
38
|
+
},
|
|
39
|
+
cacheStats() {
|
|
40
|
+
return { present: 0, absent: 0 };
|
|
41
|
+
},
|
|
42
|
+
async remove() { },
|
|
43
|
+
async removePrefix() { },
|
|
44
|
+
async mediaUrl(key) {
|
|
45
|
+
// Carries the key, so an assertion on "which object was linked" reads.
|
|
46
|
+
return `https://binary-server.test/media/${appId}/${key}`;
|
|
47
|
+
},
|
|
48
|
+
async mediaPrefixUrl(prefix, master) {
|
|
49
|
+
// Carries BOTH, because the bug this shape exists to prevent is signing the wrong
|
|
50
|
+
// one of them — a URL pointing at the master but scoped to the wrong prefix 403s
|
|
51
|
+
// every segment while the master itself loads fine.
|
|
52
|
+
return `https://binary-server.test/media/${appId}/${master}?p=${encodeURIComponent(prefix)}`;
|
|
53
|
+
},
|
|
54
|
+
async fetchMedia() {
|
|
55
|
+
// 🔴 404, not an empty 200. A fake that answers "here are zero bytes" makes a
|
|
56
|
+
// streaming route look like it worked on an object that does not exist.
|
|
57
|
+
return new Response(null, { status: 404 });
|
|
58
|
+
},
|
|
59
|
+
async signToken(_claims, _ttlSeconds) {
|
|
60
|
+
return 'fake-token';
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
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.",
|
|
@@ -54,6 +54,24 @@
|
|
|
54
54
|
"source": "./src/server/bench/index.ts",
|
|
55
55
|
"import": "./dist/server/bench/index.js"
|
|
56
56
|
},
|
|
57
|
+
"./binary-store": {
|
|
58
|
+
"types": "./dist/server/storage/binaryStore.d.ts",
|
|
59
|
+
"bun": "./src/server/storage/binaryStore.ts",
|
|
60
|
+
"source": "./src/server/storage/binaryStore.ts",
|
|
61
|
+
"import": "./dist/server/storage/binaryStore.js"
|
|
62
|
+
},
|
|
63
|
+
"./binary-store/testing": {
|
|
64
|
+
"types": "./dist/server/storage/binaryStoreFake.d.ts",
|
|
65
|
+
"bun": "./src/server/storage/binaryStoreFake.ts",
|
|
66
|
+
"source": "./src/server/storage/binaryStoreFake.ts",
|
|
67
|
+
"import": "./dist/server/storage/binaryStoreFake.js"
|
|
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
|
+
},
|
|
57
75
|
"./d1": {
|
|
58
76
|
"types": "./dist/server/d1/index.d.ts",
|
|
59
77
|
"bun": "./src/server/d1/index.ts",
|
|
@@ -114,6 +114,43 @@ const LEAVES = [
|
|
|
114
114
|
/** The failed-login backoff. Every app with a login door; shipped as 2.5.1. */
|
|
115
115
|
evaluates: 'createLoginThrottle',
|
|
116
116
|
},
|
|
117
|
+
{
|
|
118
|
+
subpath: './binary-store',
|
|
119
|
+
/**
|
|
120
|
+
* 🔴 The binary-server CLIENT, and the leaf whose promise is the largest. It arrived
|
|
121
|
+
* in 4.1.0 from five app-side copies, and it is the module that decides whether a ROM
|
|
122
|
+
* byte, a photograph, a song and a scanned document are readable — so every app takes
|
|
123
|
+
* it, and none of them may pay `sharp`/`otplib`/`kysely` for the privilege. It sits
|
|
124
|
+
* in `src/server/storage/` beside the file-catalogue and transcode graph, which is
|
|
125
|
+
* exactly the barrel this must never be reached through: `./storage`'s index is heavy
|
|
126
|
+
* and this module's whole point is that it costs `node:crypto` and nothing else.
|
|
127
|
+
*/
|
|
128
|
+
evaluates: 'createBinaryStore',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
subpath: './binary-store/testing',
|
|
132
|
+
/**
|
|
133
|
+
* The fake travels WITH the interface — it exists so that adding a method to
|
|
134
|
+
* `BinaryStore` stops breaking twenty-two suites in apps nobody touched, and a fake
|
|
135
|
+
* in a different package from its interface cannot do that job. Split from
|
|
136
|
+
* `./binary-store` so a consumer's PRODUCTION graph can never reach it, the same way
|
|
137
|
+
* `./d1/testing` is split from `./d1`.
|
|
138
|
+
*/
|
|
139
|
+
evaluates: 'binaryStoreFakeDefaults',
|
|
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
|
+
},
|
|
117
154
|
] as const;
|
|
118
155
|
|
|
119
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
|
|
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 {
|
|
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 () =>
|
|
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
|
}
|
package/src/server/d1/fakeD1.ts
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.
|
|
@@ -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;
|
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
|
|