cursedbelt-server 2.0.0 → 3.0.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 +61 -0
- package/dist/server/bench/assert.js +117 -0
- package/dist/server/bench/budget.d.ts +130 -0
- package/dist/server/bench/budget.js +131 -0
- package/dist/server/bench/cpuBudget.d.ts +45 -0
- package/dist/server/bench/cpuBudget.js +34 -0
- package/dist/server/bench/cpuClock.d.ts +65 -0
- package/dist/server/bench/cpuClock.js +100 -0
- package/dist/server/bench/index.d.ts +40 -0
- package/dist/server/bench/index.js +40 -0
- package/dist/server/bench/recorder.d.ts +70 -0
- package/dist/server/bench/recorder.js +95 -0
- package/dist/server/bench/runBench.d.ts +61 -0
- package/dist/server/bench/runBench.js +61 -0
- package/dist/server/d1/backup.d.ts +110 -0
- package/dist/server/d1/backup.js +128 -0
- package/dist/server/d1/fakeD1.d.ts +41 -0
- package/dist/server/d1/fakeD1.js +185 -0
- package/dist/server/d1/index.d.ts +24 -0
- package/dist/server/d1/index.js +24 -0
- package/dist/server/d1/kysely.d.ts +56 -0
- package/dist/server/d1/kysely.js +138 -0
- package/dist/server/d1/limits.d.ts +56 -0
- package/dist/server/d1/limits.js +96 -0
- package/dist/server/d1/local.d.ts +31 -0
- package/dist/server/d1/local.js +135 -0
- package/dist/server/d1/remote.d.ts +59 -0
- package/dist/server/d1/remote.js +124 -0
- package/dist/server/d1/scheduling.d.ts +113 -0
- package/dist/server/d1/scheduling.js +164 -0
- package/dist/server/d1/types.d.ts +143 -0
- package/dist/server/d1/types.js +80 -0
- package/dist/server/d1/values.d.ts +50 -0
- package/dist/server/d1/values.js +124 -0
- package/dist/server/master-lock/guard.d.ts +10 -0
- package/dist/server/master-lock/guard.js +70 -19
- package/dist/server/master-lock/index.d.ts +1 -1
- package/dist/server/master-lock/index.js +1 -1
- package/dist/server/master-lock/lockPage.d.ts +1 -1
- package/dist/server/master-lock/lockPage.js +68 -3
- package/dist/server/master-lock/masterLock.d.ts +250 -76
- package/dist/server/master-lock/masterLock.js +426 -114
- package/dist/server/master-lock/principals.js +6 -1
- package/dist/server/master-lock/seed.d.ts +5 -1
- package/dist/server/master-lock/seed.js +18 -1
- package/package.json +21 -3
- package/src/leafSubpathsImportNothing.spec.ts +15 -3
- package/src/server/bench/assert.ts +192 -0
- package/src/server/bench/budget.spec.ts +126 -0
- package/src/server/bench/budget.ts +207 -0
- package/src/server/bench/cpuBudget.spec.ts +302 -0
- package/src/server/bench/cpuBudget.ts +81 -0
- package/src/server/bench/cpuClock.ts +119 -0
- package/src/server/bench/index.ts +81 -0
- package/src/server/bench/recorder.ts +163 -0
- package/src/server/bench/runBench.ts +110 -0
- package/src/server/d1/backup.spec.ts +121 -0
- package/src/server/d1/backup.ts +186 -0
- package/src/server/d1/fakeD1.ts +193 -0
- package/src/server/d1/index.ts +62 -0
- package/src/server/d1/kysely.spec.ts +145 -0
- package/src/server/d1/kysely.ts +169 -0
- package/src/server/d1/limits.spec.ts +90 -0
- package/src/server/d1/limits.ts +123 -0
- package/src/server/d1/local.ts +173 -0
- package/src/server/d1/remote.ts +182 -0
- package/src/server/d1/sameShape.spec.ts +279 -0
- package/src/server/d1/scheduling.spec.ts +120 -0
- package/src/server/d1/scheduling.ts +210 -0
- package/src/server/d1/types.ts +163 -0
- package/src/server/d1/values.ts +138 -0
- package/src/server/master-lock/accounts.spec.ts +308 -0
- package/src/server/master-lock/guard.spec.ts +69 -7
- package/src/server/master-lock/guard.ts +78 -20
- package/src/server/master-lock/index.ts +3 -0
- package/src/server/master-lock/lockPage.ts +70 -3
- package/src/server/master-lock/masterLock.spec.ts +56 -23
- package/src/server/master-lock/masterLock.ts +529 -151
- package/src/server/master-lock/principals.spec.ts +45 -15
- package/src/server/master-lock/principals.ts +6 -1
- package/src/server/master-lock/seed.spec.ts +7 -2
- package/src/server/master-lock/seed.ts +22 -2
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
* an env var that pre-set someone's password would be exactly the "one credential opens
|
|
39
39
|
* everybody" shape this replaces. A principal with no record is `awaitingEnrollment` —
|
|
40
40
|
* locked, and offered the choose-a-password form.
|
|
41
|
+
*
|
|
42
|
+
* 🔴 That last sentence used to need an `enrollable: true` flag, and does not any more.
|
|
43
|
+
* Failing closed with an enrollment form is now what EVERY lock does when it holds no
|
|
44
|
+
* account (see `masterLock.ts`'s `unlocked`), so the flag that used to separate the two
|
|
45
|
+
* modes has nothing left to decide. What still makes this file per-PERSON is the key each
|
|
46
|
+
* record is stored under, which is the only thing it ever really was.
|
|
41
47
|
*/
|
|
42
48
|
import { createHash } from "node:crypto";
|
|
43
49
|
import { MasterLock, readRecord } from "./masterLock";
|
|
@@ -86,7 +92,6 @@ export class MasterLockDirectory {
|
|
|
86
92
|
};
|
|
87
93
|
const lock = new MasterLock({
|
|
88
94
|
store,
|
|
89
|
-
enrollable: true,
|
|
90
95
|
...(this.options.now ? { now: this.options.now } : {}),
|
|
91
96
|
...(this.options.log
|
|
92
97
|
? { log: (line) => this.options.log?.(`[${principalId}] ${line}`) }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MasterLockKdfParams } from "cursedbelt-core/master-lock/kdf";
|
|
2
|
-
import type
|
|
2
|
+
import { type MasterLockRecord } from "./masterLock";
|
|
3
3
|
/**
|
|
4
4
|
* The idle window a freshly minted lock starts with.
|
|
5
5
|
*
|
|
@@ -17,6 +17,10 @@ export interface MintMasterLockSeedOptions {
|
|
|
17
17
|
idleMs?: number;
|
|
18
18
|
/** Reuse existing params instead of generating a salt — for a rotation. */
|
|
19
19
|
kdf?: MasterLockKdfParams;
|
|
20
|
+
/** What to call the tenant this seed creates. Defaults to `Account 1`. */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** The owner's reminder for this password, shown on the lock page's "I forgot". */
|
|
23
|
+
hint?: string;
|
|
20
24
|
}
|
|
21
25
|
/**
|
|
22
26
|
* `password` → the record an app can be seeded with.
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { DEFAULT_IDLE_MS } from "cursedbelt-core/master-lock/policy";
|
|
26
26
|
import { deriveMasterLockVerifier, newMasterLockKdfParams, } from "cursedbelt-core/master-lock/kdf";
|
|
27
|
+
import { FIRST_ACCOUNT_ID } from "./masterLock";
|
|
27
28
|
/**
|
|
28
29
|
* The idle window a freshly minted lock starts with.
|
|
29
30
|
*
|
|
@@ -52,8 +53,24 @@ export async function mintMasterLockSeed(password, options = {}) {
|
|
|
52
53
|
const verifier = await deriveMasterLockVerifier(password, kdf);
|
|
53
54
|
return {
|
|
54
55
|
kdf,
|
|
55
|
-
verifierHash: await Bun.password.hash(verifier, { algorithm: "argon2id" }),
|
|
56
56
|
idleMs: options.idleMs ?? DEFAULT_SEED_IDLE_MS,
|
|
57
|
+
/*
|
|
58
|
+
* 🔴 ONE account, and its id is `FIRST_ACCOUNT_ID` rather than anything derived
|
|
59
|
+
* from `options`. A seed is what an app boots with before anybody has typed
|
|
60
|
+
* anything, so the tenant it names is the tenant whose rows the app already holds —
|
|
61
|
+
* and `readRecord` maps the legacy single-password shape onto the same id for
|
|
62
|
+
* exactly that reason. Two different "first" ids would give a seeded app and a
|
|
63
|
+
* migrated app different answers to "whose library is this".
|
|
64
|
+
*/
|
|
65
|
+
accounts: [
|
|
66
|
+
{
|
|
67
|
+
id: FIRST_ACCOUNT_ID,
|
|
68
|
+
label: options.label ?? "Account 1",
|
|
69
|
+
hint: options.hint ?? "",
|
|
70
|
+
verifierHash: await Bun.password.hash(verifier, { algorithm: "argon2id" }),
|
|
71
|
+
createdAt: 0,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
57
74
|
};
|
|
58
75
|
}
|
|
59
76
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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.",
|
|
@@ -48,6 +48,24 @@
|
|
|
48
48
|
"source": "./src/server/context.ts",
|
|
49
49
|
"import": "./dist/server/context.js"
|
|
50
50
|
},
|
|
51
|
+
"./bench": {
|
|
52
|
+
"types": "./dist/server/bench/index.d.ts",
|
|
53
|
+
"bun": "./src/server/bench/index.ts",
|
|
54
|
+
"source": "./src/server/bench/index.ts",
|
|
55
|
+
"import": "./dist/server/bench/index.js"
|
|
56
|
+
},
|
|
57
|
+
"./d1": {
|
|
58
|
+
"types": "./dist/server/d1/index.d.ts",
|
|
59
|
+
"bun": "./src/server/d1/index.ts",
|
|
60
|
+
"source": "./src/server/d1/index.ts",
|
|
61
|
+
"import": "./dist/server/d1/index.js"
|
|
62
|
+
},
|
|
63
|
+
"./d1/testing": {
|
|
64
|
+
"types": "./dist/server/d1/fakeD1.d.ts",
|
|
65
|
+
"bun": "./src/server/d1/fakeD1.ts",
|
|
66
|
+
"source": "./src/server/d1/fakeD1.ts",
|
|
67
|
+
"import": "./dist/server/d1/fakeD1.js"
|
|
68
|
+
},
|
|
51
69
|
"./dropzone": {
|
|
52
70
|
"types": "./dist/server/dropzone/index.d.ts",
|
|
53
71
|
"bun": "./src/server/dropzone/index.ts",
|
|
@@ -182,8 +200,8 @@
|
|
|
182
200
|
}
|
|
183
201
|
},
|
|
184
202
|
"dependencies": {
|
|
185
|
-
"cursedbelt-core": "^
|
|
186
|
-
"cwip": "^
|
|
203
|
+
"cursedbelt-core": "^2.0.0",
|
|
204
|
+
"cwip": "^4.1.0",
|
|
187
205
|
"jose": "^6.2.3"
|
|
188
206
|
},
|
|
189
207
|
"peerDependencies": {
|
|
@@ -156,8 +156,10 @@ const sourceOf = (subpath: string): string => {
|
|
|
156
156
|
* Resolution here is therefore explicit rather than inherited. The redirected root is
|
|
157
157
|
* used when it lies outside the repo (the normal case — the runner always sets
|
|
158
158
|
* `$FORGE_STATE`, and the preload's sweep then cleans up after a killed run). When it
|
|
159
|
-
* does not, this hops to `$
|
|
160
|
-
*
|
|
159
|
+
* does not, this hops to `$FORGE_STATE/test-scratch.noindex` — or, with no generation
|
|
160
|
+
* in the environment, `~/.code/test-scratch.noindex`, which is where this machine keeps
|
|
161
|
+
* everything it builds for itself. `resolveTestTmpRoot` refuses `$HOME` for the general
|
|
162
|
+
* default and this check genuinely requires an isolated root: that isolation is the whole
|
|
161
163
|
* measurement. Stale siblings are swept by mtime, the same way the preload does it,
|
|
162
164
|
* so the hop cannot accumulate.
|
|
163
165
|
*/
|
|
@@ -175,7 +177,17 @@ const fixtureRoot = (): string => {
|
|
|
175
177
|
'a check that cannot isolate itself is not a passing check',
|
|
176
178
|
);
|
|
177
179
|
}
|
|
178
|
-
|
|
180
|
+
// 🔴 Under `$FORGE_STATE`, or under `~/.code` — never a dotted directory of its own in
|
|
181
|
+
// `$HOME`. Measured 2026-09-17: this hop had left three `~/.cursedbelt*-leaf-fixture.noindex`
|
|
182
|
+
// trees in the owner's home directory, indistinguishable at a glance from the forty a Mac's
|
|
183
|
+
// real software puts there, and the sweep below only ever cleaned their INSIDES. A check that
|
|
184
|
+
// tidies up after itself and still leaves its address behind for ever is the shape this
|
|
185
|
+
// generation's home-dir rule exists to stop; `tools/check-home-dir.ts` now fails on it.
|
|
186
|
+
const state = process.env.FORGE_STATE?.trim();
|
|
187
|
+
const outside =
|
|
188
|
+
state !== undefined && state !== '' && !`${state}/`.startsWith(REPO_PREFIX)
|
|
189
|
+
? `${state}/test-scratch.noindex/${pkg.name}-leaf-fixture`
|
|
190
|
+
: `${home}/.code/test-scratch.noindex/${pkg.name}-leaf-fixture`;
|
|
179
191
|
try {
|
|
180
192
|
const cutoff = Date.now() - 6 * 60 * 60 * 1000;
|
|
181
193
|
for (const entry of readdirSync(outside)) {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate assertion. *"Document the WHY; automate the WHETHER."*
|
|
3
|
+
*
|
|
4
|
+
* 🔴 A report that is printed and not asserted on is the paragraph this module exists to
|
|
5
|
+
* replace. {@link assertCpuBudgets} THROWS, so a route that grows past its budget reddens
|
|
6
|
+
* a build instead of adding a line to a log nobody opens.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_ROUTE_CPU_BUDGET_MS } from './budget';
|
|
10
|
+
import type { CpuBudgetReport, RouteCpuStats } from './recorder';
|
|
11
|
+
|
|
12
|
+
export type CpuViolationKind =
|
|
13
|
+
/** p99 above the route's declared (or default) budget. */
|
|
14
|
+
| 'over-budget'
|
|
15
|
+
/** Too few samples for the percentile to mean anything. */
|
|
16
|
+
| 'insufficient-samples'
|
|
17
|
+
/** `requireDeclared` and the route fell through to the default. */
|
|
18
|
+
| 'undeclared'
|
|
19
|
+
/** An exempt route crossed its own `noticeAboveMs`. Never fails a build. */
|
|
20
|
+
| 'exempt-notice'
|
|
21
|
+
/** The clock could not measure at all. */
|
|
22
|
+
| 'unmeasurable';
|
|
23
|
+
|
|
24
|
+
export interface CpuViolation {
|
|
25
|
+
kind: CpuViolationKind;
|
|
26
|
+
/** `'GET /api/notes/:id'`. */
|
|
27
|
+
key: string;
|
|
28
|
+
method: string;
|
|
29
|
+
route: string;
|
|
30
|
+
p99: number;
|
|
31
|
+
budgetMs: number | null;
|
|
32
|
+
samples: number;
|
|
33
|
+
/** False for `exempt-notice`, which is informational by design. */
|
|
34
|
+
fails: boolean;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AssertCpuBudgetsOpts {
|
|
39
|
+
/**
|
|
40
|
+
* Minimum samples before a p99 is trusted. Below it the route is a violation rather
|
|
41
|
+
* than a pass: a budget nothing could measure is not a budget that was met.
|
|
42
|
+
* Default: 20.
|
|
43
|
+
*/
|
|
44
|
+
minSamples?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Fail any route that relied on the default budget instead of declaring one. Off by
|
|
47
|
+
* default; an app turns it on to prove EVERY route has been thought about.
|
|
48
|
+
*/
|
|
49
|
+
requireDeclared?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Treat an unavailable clock as a pass. Off by default — a gate that goes green
|
|
52
|
+
* because it measured nothing is worse than no gate.
|
|
53
|
+
*/
|
|
54
|
+
allowUnmeasurable?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fmt = (n: number): string => (Number.isFinite(n) ? n.toFixed(2) : 'n/a');
|
|
58
|
+
|
|
59
|
+
/** Compute violations without throwing. {@link assertCpuBudgets} is this plus a throw. */
|
|
60
|
+
export function checkCpuBudgets(
|
|
61
|
+
report: CpuBudgetReport,
|
|
62
|
+
opts: AssertCpuBudgetsOpts = {},
|
|
63
|
+
): CpuViolation[] {
|
|
64
|
+
const minSamples = opts.minSamples ?? 20;
|
|
65
|
+
const violations: CpuViolation[] = [];
|
|
66
|
+
|
|
67
|
+
if (!report.available && !opts.allowUnmeasurable) {
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
kind: 'unmeasurable',
|
|
71
|
+
key: '*',
|
|
72
|
+
method: '*',
|
|
73
|
+
route: '*',
|
|
74
|
+
p99: Number.NaN,
|
|
75
|
+
budgetMs: null,
|
|
76
|
+
samples: 0,
|
|
77
|
+
fails: true,
|
|
78
|
+
message:
|
|
79
|
+
`CPU budget: nothing could be measured — clock source '${report.source}' is ` +
|
|
80
|
+
'unavailable. Pass a runtime CPU reader, or set allowUnmeasurable to accept it.',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const r of report.routes) {
|
|
86
|
+
const base: Omit<CpuViolation, 'kind' | 'message' | 'fails'> = {
|
|
87
|
+
key: r.key,
|
|
88
|
+
method: r.method,
|
|
89
|
+
route: r.route,
|
|
90
|
+
p99: r.p99,
|
|
91
|
+
budgetMs: r.budgetMs,
|
|
92
|
+
samples: r.samples,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (r.budgetMs === null) {
|
|
96
|
+
if (r.noticeAboveMs !== undefined && r.p99 > r.noticeAboveMs) {
|
|
97
|
+
violations.push({
|
|
98
|
+
...base,
|
|
99
|
+
kind: 'exempt-notice',
|
|
100
|
+
fails: false,
|
|
101
|
+
message:
|
|
102
|
+
`${r.key} is exempt (${r.exemptReason}) and its p99 is ${fmt(r.p99)} CPU-ms, ` +
|
|
103
|
+
`above its own notice threshold of ${fmt(r.noticeAboveMs)}.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (opts.requireDeclared && !r.declared) {
|
|
110
|
+
violations.push({
|
|
111
|
+
...base,
|
|
112
|
+
kind: 'undeclared',
|
|
113
|
+
fails: true,
|
|
114
|
+
message:
|
|
115
|
+
`${r.key} declares no CPU budget and fell through to the default of ` +
|
|
116
|
+
`${fmt(r.budgetMs)} CPU-ms. requireDeclared is on: give it a number, or an ` +
|
|
117
|
+
'exemption naming the reason.',
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (r.samples < minSamples) {
|
|
123
|
+
violations.push({
|
|
124
|
+
...base,
|
|
125
|
+
kind: 'insufficient-samples',
|
|
126
|
+
fails: true,
|
|
127
|
+
message:
|
|
128
|
+
`${r.key} has ${r.samples} sample(s); a p99 needs at least ${minSamples}. ` +
|
|
129
|
+
'Drive the route more times, or lower minSamples deliberately.',
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (r.p99 > r.budgetMs) {
|
|
135
|
+
violations.push({
|
|
136
|
+
...base,
|
|
137
|
+
kind: 'over-budget',
|
|
138
|
+
fails: true,
|
|
139
|
+
message:
|
|
140
|
+
`${r.key} spent ${fmt(r.p99)} CPU-ms at p99, over its budget of ` +
|
|
141
|
+
`${fmt(r.budgetMs)} CPU-ms (${(r.p99 / r.budgetMs).toFixed(1)}×). ` +
|
|
142
|
+
`mean ${fmt(r.mean)} · p50 ${fmt(r.p50)} · p95 ${fmt(r.p95)} · max ${fmt(r.max)} ` +
|
|
143
|
+
`over ${r.samples} samples.`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return violations;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A human-readable table of every route measured, worst p99 first. */
|
|
152
|
+
export function formatCpuBudgetReport(report: CpuBudgetReport): string {
|
|
153
|
+
const head =
|
|
154
|
+
`CPU per route — source '${report.source}'` +
|
|
155
|
+
(report.proxy
|
|
156
|
+
? ' 🔴 PROXY: these are local process CPU deltas, NOT Worker CPU-ms.'
|
|
157
|
+
: ' (runtime-reported Worker CPU-ms).');
|
|
158
|
+
const rows = report.routes.map((r: RouteCpuStats) => {
|
|
159
|
+
const budget =
|
|
160
|
+
r.budgetMs === null ? `exempt: ${r.exemptReason}` : `${fmt(r.budgetMs)}ms budget`;
|
|
161
|
+
const flag = r.budgetMs !== null && r.p99 > r.budgetMs ? ' ← OVER' : '';
|
|
162
|
+
return (
|
|
163
|
+
` ${r.key.padEnd(44)} p50 ${fmt(r.p50).padStart(8)} p95 ${fmt(r.p95).padStart(8)} ` +
|
|
164
|
+
`p99 ${fmt(r.p99).padStart(8)} max ${fmt(r.max).padStart(8)} ` +
|
|
165
|
+
`n=${String(r.samples).padStart(5)} ${budget}${flag}`
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
return [head, ...rows].join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Throw if any route exceeds its budget.
|
|
173
|
+
*
|
|
174
|
+
* 🔴 The message names the ROUTE and its NUMBER, because a failure that says only
|
|
175
|
+
* "a route is over budget" sends the reader back to the report to find out which.
|
|
176
|
+
*/
|
|
177
|
+
export function assertCpuBudgets(
|
|
178
|
+
report: CpuBudgetReport,
|
|
179
|
+
opts: AssertCpuBudgetsOpts = {},
|
|
180
|
+
): CpuViolation[] {
|
|
181
|
+
const violations = checkCpuBudgets(report, opts);
|
|
182
|
+
const failing = violations.filter((v) => v.fails);
|
|
183
|
+
if (failing.length > 0) {
|
|
184
|
+
const lines = failing.map((v) => ` · [${v.kind}] ${v.message}`);
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${failing.length} route(s) over CPU budget ` +
|
|
187
|
+
`(default ${DEFAULT_ROUTE_CPU_BUDGET_MS} CPU-ms):\n${lines.join('\n')}\n\n` +
|
|
188
|
+
formatCpuBudgetReport(report),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return violations;
|
|
192
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
DAYS_PER_MONTH,
|
|
4
|
+
DEFAULT_ROUTE_CPU_BUDGET_MS,
|
|
5
|
+
deriveCpuBudgetMs,
|
|
6
|
+
monthlyOverageUsd,
|
|
7
|
+
OWNER_PROJECTED_REQUESTS_PER_DAY,
|
|
8
|
+
resolveBudget,
|
|
9
|
+
validateCpuBudgetConfig,
|
|
10
|
+
WORKERS_PAID_INCLUDED_CPU_MS,
|
|
11
|
+
} from './budget';
|
|
12
|
+
import { percentile } from './recorder';
|
|
13
|
+
|
|
14
|
+
describe('the derivation', () => {
|
|
15
|
+
it('derives 9.9 CPU-ms from the Workers Paid allowance and the projected traffic', () => {
|
|
16
|
+
// 30,000,000 CPU-ms ÷ (100,000/day × 30.4375 days) = 9.856…
|
|
17
|
+
const raw = deriveCpuBudgetMs();
|
|
18
|
+
expect(raw).toBeCloseTo(9.856, 2);
|
|
19
|
+
expect(DEFAULT_ROUTE_CPU_BUDGET_MS).toBe(9.9);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('is a FUNCTION of traffic, so the number moves when the measurement replaces the guess', () => {
|
|
23
|
+
// Twice the traffic halves what each request may spend. This is the call the outcome
|
|
24
|
+
// asks for once an app is live — not a constant somebody has to remember to edit.
|
|
25
|
+
expect(deriveCpuBudgetMs({ requestsPerDay: 200_000 })).toBeCloseTo(4.928, 2);
|
|
26
|
+
expect(deriveCpuBudgetMs({ requestsPerDay: 10_000 })).toBeCloseTo(98.56, 1);
|
|
27
|
+
expect(deriveCpuBudgetMs({ requestsPerMonth: 30_000_000 })).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('holds the owner projection it was derived from, so a reader can check the arithmetic', () => {
|
|
31
|
+
expect(OWNER_PROJECTED_REQUESTS_PER_DAY * DAYS_PER_MONTH).toBeCloseTo(3_043_750, 0);
|
|
32
|
+
expect(WORKERS_PAID_INCLUDED_CPU_MS).toBe(30_000_000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('refuses a zero/negative request volume rather than returning Infinity', () => {
|
|
36
|
+
expect(() => deriveCpuBudgetMs({ requestsPerMonth: 0 })).toThrow(/must be > 0/);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('what going over actually costs', () => {
|
|
41
|
+
it('prices the overage — 3× the CPU budget at this traffic is about $1.20/month', () => {
|
|
42
|
+
const requestsPerMonth = OWNER_PROJECTED_REQUESTS_PER_DAY * DAYS_PER_MONTH;
|
|
43
|
+
const { cpuUsd, requestsUsd, totalUsd } = monthlyOverageUsd({
|
|
44
|
+
requestsPerMonth,
|
|
45
|
+
meanCpuMsPerRequest: DEFAULT_ROUTE_CPU_BUDGET_MS * 3,
|
|
46
|
+
});
|
|
47
|
+
// Requests are still well inside the included 10 M, so the whole bill is CPU.
|
|
48
|
+
expect(requestsUsd).toBe(0);
|
|
49
|
+
expect(cpuUsd).toBeCloseTo(1.2, 1);
|
|
50
|
+
expect(totalUsd).toBeCloseTo(1.2, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('charges nothing while both allowances are unspent', () => {
|
|
54
|
+
const { totalUsd } = monthlyOverageUsd({
|
|
55
|
+
requestsPerMonth: 1_000_000,
|
|
56
|
+
meanCpuMsPerRequest: 5,
|
|
57
|
+
});
|
|
58
|
+
expect(totalUsd).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('declaring a budget', () => {
|
|
63
|
+
it('prefers a method-specific key over a path-wide one', () => {
|
|
64
|
+
const config = { routes: { 'POST /api/x': 40, '/api/x': 2 } };
|
|
65
|
+
expect(resolveBudget(config, 'POST', '/api/x').cpuMs).toBe(40);
|
|
66
|
+
expect(resolveBudget(config, 'GET', '/api/x').cpuMs).toBe(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('falls through to the default and says it was not declared', () => {
|
|
70
|
+
const r = resolveBudget({}, 'GET', '/whatever');
|
|
71
|
+
expect(r.cpuMs).toBe(DEFAULT_ROUTE_CPU_BUDGET_MS);
|
|
72
|
+
expect(r.declared).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('carries an exemption through as a null ceiling plus its reason', () => {
|
|
76
|
+
const r = resolveBudget(
|
|
77
|
+
{ routes: { 'POST /unlock': { exempt: true, reason: 'argon2id KDF' } } },
|
|
78
|
+
'POST',
|
|
79
|
+
'/unlock',
|
|
80
|
+
);
|
|
81
|
+
expect(r.cpuMs).toBeNull();
|
|
82
|
+
expect(r.exemptReason).toBe('argon2id KDF');
|
|
83
|
+
expect(r.declared).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// 🔴 The rule the task names: an opt-out must justify itself.
|
|
87
|
+
it('REFUSES an exemption with no reason', () => {
|
|
88
|
+
expect(() =>
|
|
89
|
+
validateCpuBudgetConfig({
|
|
90
|
+
routes: { '/unlock': { exempt: true, reason: ' ' } },
|
|
91
|
+
}),
|
|
92
|
+
).toThrow(/exempt with no reason/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('refuses a nonsense budget up front, not when the route is first hit', () => {
|
|
96
|
+
expect(() => validateCpuBudgetConfig({ routes: { '/a': 0 } })).toThrow(/must be > 0/);
|
|
97
|
+
expect(() => validateCpuBudgetConfig({ routes: { '/a': -3 } })).toThrow(/must be > 0/);
|
|
98
|
+
expect(() => validateCpuBudgetConfig({ defaultCpuMs: 0 })).toThrow(/defaultCpuMs/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('percentiles', () => {
|
|
103
|
+
// 🔴 The reason the report is a p99 and not a mean, stated as a test: this exact
|
|
104
|
+
// distribution PASSES a 9.9 ms budget on its mean and FAILS it on its p99.
|
|
105
|
+
it('reports the tail the mean hides', () => {
|
|
106
|
+
const samples = [...Array(98).fill(1), 400, 400].sort((a, b) => a - b);
|
|
107
|
+
const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
108
|
+
expect(mean).toBeCloseTo(8.98, 2);
|
|
109
|
+
expect(mean).toBeLessThan(DEFAULT_ROUTE_CPU_BUDGET_MS); // a mean-based gate: green
|
|
110
|
+
expect(percentile(samples, 99)).toBe(400); // a p99-based gate: red, correctly
|
|
111
|
+
expect(percentile(samples, 50)).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('uses nearest-rank, so it never reports a cost no request actually paid', () => {
|
|
115
|
+
const s = [1, 2, 3, 4];
|
|
116
|
+
expect(percentile(s, 50)).toBe(2);
|
|
117
|
+
expect(percentile(s, 75)).toBe(3);
|
|
118
|
+
expect(percentile(s, 99)).toBe(4);
|
|
119
|
+
// An interpolating p75 would answer 3.25 — a number no sample equals.
|
|
120
|
+
expect(s).toContain(percentile(s, 75));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('answers NaN for no samples rather than 0', () => {
|
|
124
|
+
expect(percentile([], 99)).toBeNaN();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CPU budget, DERIVED rather than asserted.
|
|
3
|
+
*
|
|
4
|
+
* ## Why CPU and not requests
|
|
5
|
+
*
|
|
6
|
+
* The Workers Paid plan (`developers.cloudflare.com/workers/platform/pricing/`) meters
|
|
7
|
+
* requests and CPU time as two SEPARATE included allowances:
|
|
8
|
+
*
|
|
9
|
+
* · $5/month base
|
|
10
|
+
* · 10 million requests included, then +$0.30 per additional million
|
|
11
|
+
* · 30 million CPU-milliseconds included, then +$0.02 per additional million
|
|
12
|
+
* · 30 s CPU limit per invocation (raisable to 5 min)
|
|
13
|
+
* · duration: "No charge or limit for duration" — wall clock is NEVER billed
|
|
14
|
+
*
|
|
15
|
+
* 🔴 There is no 125 ms per-request ceiling and no wall-clock fallback. That was the
|
|
16
|
+
* retired Bundled/Unbound model. Nothing here should be reasoned about in wall time:
|
|
17
|
+
* an `await` on a subrequest costs duration, and duration is free.
|
|
18
|
+
*
|
|
19
|
+
* At the owner's projection of 100k requests/day the two allowances are not equally
|
|
20
|
+
* tight — requests run to 30 % of the included 10 M while CPU is the binding one, which
|
|
21
|
+
* is the whole reason this module exists.
|
|
22
|
+
*
|
|
23
|
+
* ## 🔴 The default is a PROJECTION until an app is live
|
|
24
|
+
*
|
|
25
|
+
* {@link DEFAULT_ROUTE_CPU_BUDGET_MS} falls out of {@link OWNER_PROJECTED_REQUESTS_PER_DAY},
|
|
26
|
+
* which is the owner's estimate and not a measurement. {@link deriveCpuBudgetMs} is the
|
|
27
|
+
* function to re-run against real traffic — the re-derivation is a call, not a paragraph
|
|
28
|
+
* somebody has to remember to do.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** CPU-milliseconds included in the Workers Paid plan each month. */
|
|
32
|
+
export const WORKERS_PAID_INCLUDED_CPU_MS = 30_000_000;
|
|
33
|
+
|
|
34
|
+
/** Requests included in the Workers Paid plan each month. */
|
|
35
|
+
export const WORKERS_PAID_INCLUDED_REQUESTS = 10_000_000;
|
|
36
|
+
|
|
37
|
+
/** USD per additional million CPU-ms, once the included allowance is spent. */
|
|
38
|
+
export const WORKERS_PAID_USD_PER_MILLION_CPU_MS = 0.02;
|
|
39
|
+
|
|
40
|
+
/** USD per additional million requests, once the included allowance is spent. */
|
|
41
|
+
export const WORKERS_PAID_USD_PER_MILLION_REQUESTS = 0.3;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The owner's own projection, 2026-09-15. 🔴 A PROJECTION, not a measurement — the
|
|
45
|
+
* whole point of {@link deriveCpuBudgetMs} is to replace it once an app is serving.
|
|
46
|
+
*/
|
|
47
|
+
export const OWNER_PROJECTED_REQUESTS_PER_DAY = 100_000;
|
|
48
|
+
|
|
49
|
+
/** 365.25 / 12 — the average calendar month, since billing is monthly. */
|
|
50
|
+
export const DAYS_PER_MONTH = 30.4375;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Derive the average CPU-ms a single request may spend before the fleet exceeds its
|
|
54
|
+
* included CPU allowance.
|
|
55
|
+
*
|
|
56
|
+
* 🔴 This is an AVERAGE-per-request allowance, not a per-invocation limit. A route may
|
|
57
|
+
* exceed it and cost nothing, provided cheap routes carry the mean. What it is for is
|
|
58
|
+
* ranking: a route whose p99 sits above the average budget is a route that cannot be
|
|
59
|
+
* allowed to become the common case.
|
|
60
|
+
*/
|
|
61
|
+
export function deriveCpuBudgetMs(
|
|
62
|
+
opts: {
|
|
63
|
+
/** Included CPU-ms per month. Default: the Workers Paid allowance. */
|
|
64
|
+
includedCpuMs?: number;
|
|
65
|
+
/** Measured (or projected) requests per month. */
|
|
66
|
+
requestsPerMonth?: number;
|
|
67
|
+
/** Measured (or projected) requests per day — converted with {@link DAYS_PER_MONTH}. */
|
|
68
|
+
requestsPerDay?: number;
|
|
69
|
+
} = {},
|
|
70
|
+
): number {
|
|
71
|
+
const includedCpuMs = opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS;
|
|
72
|
+
const requestsPerMonth =
|
|
73
|
+
opts.requestsPerMonth ??
|
|
74
|
+
(opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
|
|
75
|
+
if (!(requestsPerMonth > 0)) {
|
|
76
|
+
throw new Error('deriveCpuBudgetMs: requestsPerMonth must be > 0');
|
|
77
|
+
}
|
|
78
|
+
return includedCpuMs / requestsPerMonth;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What a month costs in overage once the included allowances are spent. Used by the
|
|
83
|
+
* report so a violation carries a PRICE and not only a number — the honest framing is
|
|
84
|
+
* that going 3× over the CPU budget at this traffic is about $1.20/month, which is a
|
|
85
|
+
* reason to care about the outlier route and not a reason to panic.
|
|
86
|
+
*/
|
|
87
|
+
export function monthlyOverageUsd(opts: {
|
|
88
|
+
requestsPerMonth: number;
|
|
89
|
+
meanCpuMsPerRequest: number;
|
|
90
|
+
}): { cpuUsd: number; requestsUsd: number; totalUsd: number } {
|
|
91
|
+
const cpuMs = opts.requestsPerMonth * opts.meanCpuMsPerRequest;
|
|
92
|
+
const cpuOver = Math.max(0, cpuMs - WORKERS_PAID_INCLUDED_CPU_MS);
|
|
93
|
+
const reqOver = Math.max(0, opts.requestsPerMonth - WORKERS_PAID_INCLUDED_REQUESTS);
|
|
94
|
+
const cpuUsd = (cpuOver / 1_000_000) * WORKERS_PAID_USD_PER_MILLION_CPU_MS;
|
|
95
|
+
const requestsUsd = (reqOver / 1_000_000) * WORKERS_PAID_USD_PER_MILLION_REQUESTS;
|
|
96
|
+
return { cpuUsd, requestsUsd, totalUsd: cpuUsd + requestsUsd };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 9.9 CPU-ms — `30,000,000 ÷ (100,000 × 30.4375)` = 9.856, to one decimal.
|
|
101
|
+
*
|
|
102
|
+
* Generous for a JSON route, tight for anything that renders, parses or derives a key.
|
|
103
|
+
* 🔴 Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
|
|
104
|
+
*/
|
|
105
|
+
export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
|
|
106
|
+
|
|
107
|
+
/** A route that is measured against a number. */
|
|
108
|
+
export interface CpuBudgetLimit {
|
|
109
|
+
cpuMs: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A route that is deliberately NOT measured against the default.
|
|
114
|
+
*
|
|
115
|
+
* 🔴 `reason` is required by the type AND checked at runtime. An exemption whose
|
|
116
|
+
* justification is blank is the paragraph this module exists to replace — a silent
|
|
117
|
+
* opt-out is indistinguishable from a route nobody looked at.
|
|
118
|
+
*/
|
|
119
|
+
export interface CpuBudgetExemption {
|
|
120
|
+
exempt: true;
|
|
121
|
+
reason: string;
|
|
122
|
+
/**
|
|
123
|
+
* The exemption still records, and a p99 above this is reported as a NOTICE rather
|
|
124
|
+
* than a violation. Optional — an exemption with no ceiling is never surprising.
|
|
125
|
+
*/
|
|
126
|
+
noticeAboveMs?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type RouteBudget = CpuBudgetLimit | CpuBudgetExemption | number;
|
|
130
|
+
|
|
131
|
+
export interface CpuBudgetConfig {
|
|
132
|
+
/** Applied to any route without its own entry. Default: {@link DEFAULT_ROUTE_CPU_BUDGET_MS}. */
|
|
133
|
+
defaultCpuMs?: number;
|
|
134
|
+
/**
|
|
135
|
+
* Per-route budgets, keyed either `'GET /api/notes/:id'` (method-specific, wins) or
|
|
136
|
+
* `'/api/notes/:id'` (any method). Keys are matched against the same normalized route
|
|
137
|
+
* label the metrics tier uses, so `:params` are already collapsed.
|
|
138
|
+
*/
|
|
139
|
+
routes?: Record<string, RouteBudget>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isExemption(b: RouteBudget): b is CpuBudgetExemption {
|
|
143
|
+
return typeof b === 'object' && 'exempt' in b && b.exempt === true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The resolved budget for one route label. */
|
|
147
|
+
export interface ResolvedBudget {
|
|
148
|
+
/** The ceiling in CPU-ms, or `null` when the route is exempt. */
|
|
149
|
+
cpuMs: number | null;
|
|
150
|
+
exemptReason?: string;
|
|
151
|
+
noticeAboveMs?: number;
|
|
152
|
+
/** True when no explicit entry matched and the default was applied. */
|
|
153
|
+
declared: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 🔴 Validate the whole config up front rather than at the moment a route is hit. A
|
|
158
|
+
* blank exemption reason on a route nobody exercised in the bench would otherwise ship.
|
|
159
|
+
*/
|
|
160
|
+
export function validateCpuBudgetConfig(config: CpuBudgetConfig): void {
|
|
161
|
+
if (config.defaultCpuMs !== undefined && !(config.defaultCpuMs > 0)) {
|
|
162
|
+
throw new Error(`cpuBudget: defaultCpuMs must be > 0, got ${config.defaultCpuMs}`);
|
|
163
|
+
}
|
|
164
|
+
for (const [key, budget] of Object.entries(config.routes ?? {})) {
|
|
165
|
+
if (isExemption(budget)) {
|
|
166
|
+
if (typeof budget.reason !== 'string' || budget.reason.trim() === '') {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`cpuBudget: route '${key}' is exempt with no reason. An opt-out must name why — ` +
|
|
169
|
+
'that is the difference between a decision and an oversight.',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (budget.noticeAboveMs !== undefined && !(budget.noticeAboveMs > 0)) {
|
|
173
|
+
throw new Error(`cpuBudget: route '${key}' has noticeAboveMs ≤ 0`);
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const cpuMs = typeof budget === 'number' ? budget : budget.cpuMs;
|
|
178
|
+
if (!(cpuMs > 0)) {
|
|
179
|
+
throw new Error(`cpuBudget: route '${key}' has a budget of ${cpuMs}; it must be > 0`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Resolve `method` + `route` against the config. Method-specific keys win. */
|
|
185
|
+
export function resolveBudget(
|
|
186
|
+
config: CpuBudgetConfig,
|
|
187
|
+
method: string,
|
|
188
|
+
route: string,
|
|
189
|
+
): ResolvedBudget {
|
|
190
|
+
const routes = config.routes ?? {};
|
|
191
|
+
const explicit = routes[`${method} ${route}`] ?? routes[route];
|
|
192
|
+
if (explicit === undefined) {
|
|
193
|
+
return { cpuMs: config.defaultCpuMs ?? DEFAULT_ROUTE_CPU_BUDGET_MS, declared: false };
|
|
194
|
+
}
|
|
195
|
+
if (isExemption(explicit)) {
|
|
196
|
+
return {
|
|
197
|
+
cpuMs: null,
|
|
198
|
+
exemptReason: explicit.reason,
|
|
199
|
+
noticeAboveMs: explicit.noticeAboveMs,
|
|
200
|
+
declared: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
cpuMs: typeof explicit === 'number' ? explicit : explicit.cpuMs,
|
|
205
|
+
declared: true,
|
|
206
|
+
};
|
|
207
|
+
}
|