cursedbelt-server 4.8.0 → 4.11.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/cpuClock.d.ts +4 -0
- package/dist/server/bench/cpuClock.js +57 -3
- package/dist/server/bench/runBench.d.ts +27 -0
- package/dist/server/bench/runBench.js +42 -2
- package/dist/server/d1/index.d.ts +1 -1
- package/dist/server/d1/index.js +1 -1
- package/dist/server/d1/invocation.d.ts +72 -0
- package/dist/server/d1/invocation.js +65 -0
- package/dist/server/storage/files/chunkedUpload.d.ts +12 -1
- package/dist/server/storage/files/chunkedUpload.js +6 -1
- package/package.json +1 -1
- package/src/nodeBuiltinsAreProbedByCalling.spec.ts +233 -0
- package/src/publicSurface.spec.ts +88 -0
- package/src/server/bench/cpuBudget.spec.ts +191 -1
- package/src/server/bench/cpuClock.spec.ts +141 -0
- package/src/server/bench/cpuClock.ts +55 -2
- package/src/server/bench/runBench.ts +73 -2
- package/src/server/d1/index.ts +1 -1
- package/src/server/d1/invocation.spec.ts +65 -1
- package/src/server/d1/invocation.ts +86 -0
- package/src/server/storage/files/chunkedUpload.spec.ts +15 -1
- package/src/server/storage/files/chunkedUpload.ts +18 -2
|
@@ -40,6 +40,10 @@ export interface CpuClock {
|
|
|
40
40
|
* That is why {@link runCpuBench} drives its cases SERIALLY — a serial driver is what
|
|
41
41
|
* makes this proxy sound enough to gate on. Mounted in production it is advisory: useful
|
|
42
42
|
* for ranking routes, not for a billing claim.
|
|
43
|
+
*
|
|
44
|
+
* Where the runtime has no such reading, this returns {@link unavailableCpuClock} — a
|
|
45
|
+
* clock, never a throw. {@link probeProcessCpuUsage} is what decides, and it decides by
|
|
46
|
+
* CALLING.
|
|
43
47
|
*/
|
|
44
48
|
export declare function processCpuClock(): CpuClock;
|
|
45
49
|
/**
|
|
@@ -26,11 +26,15 @@
|
|
|
26
26
|
* That is why {@link runCpuBench} drives its cases SERIALLY — a serial driver is what
|
|
27
27
|
* makes this proxy sound enough to gate on. Mounted in production it is advisory: useful
|
|
28
28
|
* for ranking routes, not for a billing claim.
|
|
29
|
+
*
|
|
30
|
+
* Where the runtime has no such reading, this returns {@link unavailableCpuClock} — a
|
|
31
|
+
* clock, never a throw. {@link probeProcessCpuUsage} is what decides, and it decides by
|
|
32
|
+
* CALLING.
|
|
29
33
|
*/
|
|
30
34
|
export function processCpuClock() {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
33
|
-
return unavailableCpuClock(
|
|
35
|
+
const probe = probeProcessCpuUsage();
|
|
36
|
+
if (!probe.ok)
|
|
37
|
+
return unavailableCpuClock(`process.cpuUsage (${probe.why})`);
|
|
34
38
|
return {
|
|
35
39
|
source: 'process.cpuUsage',
|
|
36
40
|
proxy: true,
|
|
@@ -44,6 +48,56 @@ export function processCpuClock() {
|
|
|
44
48
|
},
|
|
45
49
|
};
|
|
46
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* 🔴 **Is `process.cpuUsage()` a clock here? Answered by CALLING it, never by `typeof`.**
|
|
53
|
+
*
|
|
54
|
+
* Measured 2026-09-19 on the first real deploy of a Worker mounting `cpuBudget()`: under
|
|
55
|
+
* `nodejs_compat`, workerd's strategy for the parts of `node:process` it does not implement
|
|
56
|
+
* is to PROVIDE the method and throw when it is called —
|
|
57
|
+
*
|
|
58
|
+
* ```
|
|
59
|
+
* Error [ERR_METHOD_NOT_IMPLEMENTED]: The process.cpuUsage method is not implemented
|
|
60
|
+
* at process.cpuUsage (node-internal:public_process:235:11)
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* — so `typeof process.cpuUsage === 'function'` answers *yes* about the one runtime the
|
|
64
|
+
* probe exists to answer *no* about. The clock that came back threw on first use, and
|
|
65
|
+
* because `cpuBudget()` is mounted FIRST by design, the throw landed in front of the whole
|
|
66
|
+
* app: every path 500, with 843 green tests behind it, because `bun test` runs where the
|
|
67
|
+
* method works.
|
|
68
|
+
*
|
|
69
|
+
* This is a capability CLASS, not one method: `nodejs_compat` stubs a great deal of
|
|
70
|
+
* `node:process`, `node:os` and `node:v8` the same way. `src/nodeBuiltinsAreProbedByCalling.spec.ts`
|
|
71
|
+
* is the check that keeps a `typeof` gate from coming back anywhere in this library.
|
|
72
|
+
*
|
|
73
|
+
* Both call shapes are exercised, because the span uses both and only one of them is
|
|
74
|
+
* probed by the first: `cpuUsage()` for the baseline and `cpuUsage(before)` for the delta.
|
|
75
|
+
* That costs two calls at wiring time — the same trade {@link workerCpuClock} argues for on
|
|
76
|
+
* the other side, and the alternative is a gate that reads zero for ever.
|
|
77
|
+
*/
|
|
78
|
+
function probeProcessCpuUsage() {
|
|
79
|
+
// Present-but-throwing and absent are both unavailable; the distinction only LABELS the
|
|
80
|
+
// clock, and is never the decision — which is why this line may carry the excuse that
|
|
81
|
+
// `src/nodeBuiltinsAreProbedByCalling.spec.ts` refuses everywhere else.
|
|
82
|
+
// probe-by-calling:ignore — label only; the call below is what decides.
|
|
83
|
+
const present = typeof process !== 'undefined' && typeof process.cpuUsage === 'function';
|
|
84
|
+
let reading;
|
|
85
|
+
try {
|
|
86
|
+
// A bare `process` that does not exist throws a ReferenceError in here, which is the
|
|
87
|
+
// same answer as a method that throws: not a clock.
|
|
88
|
+
const before = process.cpuUsage();
|
|
89
|
+
const delta = process.cpuUsage(before);
|
|
90
|
+
reading = before.user + before.system + delta.user + delta.system;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { ok: false, why: present ? 'throws' : 'absent' };
|
|
94
|
+
}
|
|
95
|
+
// A stub that returns something non-numeric is no more usable than one that throws, and
|
|
96
|
+
// would otherwise reach the recorder as a confident 0.00 for every route.
|
|
97
|
+
if (!Number.isFinite(reading))
|
|
98
|
+
return { ok: false, why: 'non-numeric' };
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
47
101
|
/**
|
|
48
102
|
* A clock over a CPU-ms reader the runtime supplies. `proxy: false` — this is the real
|
|
49
103
|
* quantity Cloudflare bills, so a report built on it may be quoted as one.
|
|
@@ -31,6 +31,33 @@ export interface BenchCase {
|
|
|
31
31
|
* that never ran — a green budget over a broken route.
|
|
32
32
|
*/
|
|
33
33
|
expectStatus?: number | number[] | ((status: number) => boolean);
|
|
34
|
+
/**
|
|
35
|
+
* Called before EVERY iteration of this case — warm-up included. For a **destructive**
|
|
36
|
+
* route, rebuild the fixture here; without it the second call measures an empty database.
|
|
37
|
+
*
|
|
38
|
+
* 🔴 This is the hook a destructive route cannot be benched without. `runCpuBench` fires a
|
|
39
|
+
* case `warmup + iterations` times against ONE app instance, which is exactly right for a
|
|
40
|
+
* read and a lie for a purge: the first call runs against a populated library and the next
|
|
41
|
+
* twenty-nine against an empty one, so the p99 is a small number for the wrong reason and
|
|
42
|
+
* the budget it satisfies means nothing. `expectStatus` cannot catch it either — the second
|
|
43
|
+
* call legitimately answers 200 with `{ deleted: 0 }`.
|
|
44
|
+
*
|
|
45
|
+
* It is **per case**, not run-wide, so the cheap read cases pay nothing for it — neither
|
|
46
|
+
* the rebuild nor the accounting {@link runCpuBench} does around it.
|
|
47
|
+
*
|
|
48
|
+
* 🔴 **Its cost is not measured, and that is checked rather than assumed.** The recorder is
|
|
49
|
+
* written through by the `cpuBudget()` middleware inside `app.fetch`, so a hook that runs
|
|
50
|
+
* outside that call is already excluded by ordering — but ordering is an accident, and a
|
|
51
|
+
* fixture rebuild that cost 200 ms and got attributed to the route would make this feature
|
|
52
|
+
* a worse lie than the hole it closes. So {@link runCpuBench} refuses a hook that drives
|
|
53
|
+
* the app under bench (see the error it throws), and `cpuBudget.spec.ts` proves a hook that
|
|
54
|
+
* burns real CPU leaves the route's number alone.
|
|
55
|
+
*
|
|
56
|
+
* `iteration` is 0-based and monotonic across the WHOLE case, warm-up first: warm-up gets
|
|
57
|
+
* `0 … warmup-1` and the measured pass continues from `warmup`. A hook that seeds ids from
|
|
58
|
+
* it therefore never collides between the two passes.
|
|
59
|
+
*/
|
|
60
|
+
beforeEach?: (c: BenchCase, iteration: number) => void | Promise<void>;
|
|
34
61
|
}
|
|
35
62
|
export interface RunCpuBenchOpts {
|
|
36
63
|
/** Anything with Hono's `fetch` shape. */
|
|
@@ -25,6 +25,39 @@ function statusAllowed(c, status) {
|
|
|
25
25
|
function describe(c) {
|
|
26
26
|
return `${(c.method ?? 'GET').toUpperCase()} ${c.path}`;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Every request the recorder has attributed to any route so far. `samples` counts what was
|
|
30
|
+
* SEEN rather than what the reservoir retained, so this only ever goes up within a phase.
|
|
31
|
+
*/
|
|
32
|
+
function totalSamples(recorder) {
|
|
33
|
+
let n = 0;
|
|
34
|
+
for (const r of recorder.report().routes)
|
|
35
|
+
n += r.samples;
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run a case's {@link BenchCase.beforeEach}, and prove it stayed out of the histogram.
|
|
40
|
+
*
|
|
41
|
+
* 🔴 The obvious way to rebuild a fixture is to drive the app that already has the routes —
|
|
42
|
+
* `POST /api/upload` thirty times to re-seed a library. Those requests go through
|
|
43
|
+
* `cpuBudget()` like any other, so the rebuild lands in the report as real samples on real
|
|
44
|
+
* routes, and the destructive route this hook exists to measure honestly is now benched
|
|
45
|
+
* beside rows nobody asked for. Refusing it here is cheap and only costs a case that has a
|
|
46
|
+
* hook at all.
|
|
47
|
+
*/
|
|
48
|
+
async function prepare(opts, c, iteration) {
|
|
49
|
+
if (!c.beforeEach)
|
|
50
|
+
return;
|
|
51
|
+
const before = totalSamples(opts.recorder);
|
|
52
|
+
await c.beforeEach(c, iteration);
|
|
53
|
+
const after = totalSamples(opts.recorder);
|
|
54
|
+
if (after !== before) {
|
|
55
|
+
throw new Error(`cpu-bench: ${describe(c)}'s beforeEach drove ${after - before} request(s) through the ` +
|
|
56
|
+
'app under bench, so the fixture rebuild is now IN the measurement it exists to keep ' +
|
|
57
|
+
'out of it. Rebuild the fixture directly — against the database, the store, the ' +
|
|
58
|
+
'seed helper — never through app.fetch.');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
28
61
|
async function fire(opts, c) {
|
|
29
62
|
const method = (c.method ?? c.init?.method ?? 'GET').toUpperCase();
|
|
30
63
|
const req = new Request(`${opts.origin ?? BENCH_ORIGIN}${c.path}`, { ...c.init, method });
|
|
@@ -46,16 +79,23 @@ export async function runCpuBench(opts) {
|
|
|
46
79
|
const warmup = opts.warmup ?? 5;
|
|
47
80
|
if (!(iterations > 0))
|
|
48
81
|
throw new Error('runCpuBench: iterations must be > 0');
|
|
82
|
+
// `beforeEach` runs over the warm-up too, deliberately. Warm-up exists to reach a handler's
|
|
83
|
+
// steady state, and a destructive route warmed against the empty database it emptied warms
|
|
84
|
+
// the wrong branch — the loop the p99 is about never runs.
|
|
49
85
|
for (const c of opts.cases) {
|
|
50
|
-
for (let i = 0; i < warmup; i += 1)
|
|
86
|
+
for (let i = 0; i < warmup; i += 1) {
|
|
87
|
+
await prepare(opts, c, i);
|
|
51
88
|
await fire(opts, c);
|
|
89
|
+
}
|
|
52
90
|
}
|
|
53
91
|
// 🔴 Everything above was warm-up. Discard it — measuring it is the bug this guards.
|
|
54
92
|
opts.recorder.reset();
|
|
55
93
|
for (const c of opts.cases) {
|
|
56
94
|
const n = c.iterations ?? iterations;
|
|
57
|
-
for (let i = 0; i < n; i += 1)
|
|
95
|
+
for (let i = 0; i < n; i += 1) {
|
|
96
|
+
await prepare(opts, c, warmup + i);
|
|
58
97
|
await fire(opts, c);
|
|
98
|
+
}
|
|
59
99
|
}
|
|
60
100
|
return opts.recorder.report();
|
|
61
101
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
17
|
export { type BackupPoint, createTimeTravelBackup, type DatabaseBackup, type TimeTravelOpts, } from './backup';
|
|
18
|
-
export { type InvocationD1, perInvocation } from './invocation';
|
|
18
|
+
export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation';
|
|
19
19
|
export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
|
|
20
20
|
export { createLocalD1, refuseInteractiveTransaction } from './local';
|
|
21
21
|
export { createRemoteD1, type D1BindingLike, type D1BindingMeta, type D1BindingResult, type D1BindingStatement, } from './remote';
|
package/dist/server/d1/index.js
CHANGED
|
@@ -33,7 +33,7 @@ export { createTimeTravelBackup, } from './backup';
|
|
|
33
33
|
// that does not use the query builder — which per `../db/kysely.ts`'s own header is most
|
|
34
34
|
// of them, since business tables stay on raw statements. Measured 2026-09-18 against the
|
|
35
35
|
// published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
|
|
36
|
-
export { perInvocation } from './invocation';
|
|
36
|
+
export { createPendingWrites, perInvocation } from './invocation';
|
|
37
37
|
export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
|
|
38
38
|
export { createLocalD1, refuseInteractiveTransaction } from './local';
|
|
39
39
|
export { createRemoteD1, } from './remote';
|
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
* It wraps either driver, because it sits ABOVE the seam and only counts. That is what
|
|
40
40
|
* makes the local gate able to prove a production-only limit: wrap a local database in a
|
|
41
41
|
* test, run the loop, and the laptop fails exactly where the Worker would.
|
|
42
|
+
*
|
|
43
|
+
* ## The other thing that is true for the span of one request: {@link createPendingWrites}
|
|
44
|
+
*
|
|
45
|
+
* A Worker port's synchronous stores answer from a snapshot and queue their durable writes
|
|
46
|
+
* as promises, which the entry point awaits BEFORE the response leaves. That collector has
|
|
47
|
+
* the same lifetime as the budget above — one per invocation, constructed by the caller in
|
|
48
|
+
* the same breath — so it lives in the same file. See its own header for why it was moved
|
|
49
|
+
* here on 2026-09-19.
|
|
42
50
|
*/
|
|
43
51
|
import { type D1LikeDatabase } from './types';
|
|
44
52
|
/**
|
|
@@ -70,3 +78,67 @@ export interface InvocationD1 extends D1LikeDatabase {
|
|
|
70
78
|
export declare function perInvocation(db: D1LikeDatabase, opts?: {
|
|
71
79
|
max?: number;
|
|
72
80
|
}): InvocationD1;
|
|
81
|
+
/**
|
|
82
|
+
* Where a durable write goes on its way out of one invocation.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately two members, and the narrower `add`-only half is what a STORE takes. A store
|
|
85
|
+
* that also held `settle` could end the request's writes from inside the request, which is
|
|
86
|
+
* the entry point's job and nobody else's. `cursedauth/d1-stores` declares exactly this
|
|
87
|
+
* two-method shape under its own name (`WriteCollector`) because that package sits UNDER
|
|
88
|
+
* this one and may not import it — structural typing means an object made here satisfies it
|
|
89
|
+
* verbatim, with no dependency edge in either direction.
|
|
90
|
+
*/
|
|
91
|
+
export interface WriteCollector {
|
|
92
|
+
add(promise: Promise<unknown>): void;
|
|
93
|
+
}
|
|
94
|
+
/** A write that must land before the response is returned. See {@link createPendingWrites}. */
|
|
95
|
+
export interface PendingWrites extends WriteCollector {
|
|
96
|
+
/** Await every collected write. Called BEFORE the response is returned, never after. */
|
|
97
|
+
settle(): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Collect the durable writes of ONE invocation, to be awaited before the response leaves.
|
|
101
|
+
*
|
|
102
|
+
* ## Why this is the Worker-write primitive and not a convenience
|
|
103
|
+
*
|
|
104
|
+
* A ported app's synchronous stores cannot await — their signatures are somebody else's
|
|
105
|
+
* published contract — so the shape every port lands on is: one read fills a snapshot, every
|
|
106
|
+
* sync call answers from it, and every mutation both updates the snapshot and pushes its
|
|
107
|
+
* statement's promise in here. The entry point then does the awaiting:
|
|
108
|
+
*
|
|
109
|
+
* ```ts
|
|
110
|
+
* export default {
|
|
111
|
+
* async fetch(req: Request, env: Env) {
|
|
112
|
+
* const db = perInvocation(createRemoteD1(env.DB));
|
|
113
|
+
* const writes = createPendingWrites();
|
|
114
|
+
* const res = await app.fetch(req, { db, writes });
|
|
115
|
+
* await writes.settle(); // 🔴 BEFORE the response, never after
|
|
116
|
+
* return res;
|
|
117
|
+
* },
|
|
118
|
+
* };
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* 🔴 **`settle()` before the response, never `ctx.waitUntil`.** A Worker isolate is per-colo
|
|
122
|
+
* and short-lived; a write acknowledged by a 200 and then dropped means "sign out" reported
|
|
123
|
+
* success and left the credential live — a failure invisible to any single-process test and
|
|
124
|
+
* indistinguishable, to the person, from the feature being broken.
|
|
125
|
+
*
|
|
126
|
+
* 🔴 **`Promise.all`, not `allSettled`.** A failed durable write must not be reported as a
|
|
127
|
+
* success. It surfaces as a 500 from the entry point, which is the honest answer to "did my
|
|
128
|
+
* sign-out take". `allSettled` here would convert every lost write into a silent 200 — the
|
|
129
|
+
* same outcome as not collecting them at all, reached by a road that looks careful.
|
|
130
|
+
*
|
|
131
|
+
* ## Why it lives here
|
|
132
|
+
*
|
|
133
|
+
* It was written three times before it was published once — `apps/patterns/worker/stores.ts`,
|
|
134
|
+
* `apps/collections/worker/stores.ts` and `libs/cursedauth/src/d1Stores.ts`, byte-identical
|
|
135
|
+
* but for a comment, with six more ports queued behind them. It is a D1-INVOCATION primitive
|
|
136
|
+
* rather than an auth one, so it belongs beside {@link perInvocation}: both are constructed
|
|
137
|
+
* per request, both describe what is true for the span of one, and neither knows anything
|
|
138
|
+
* about who is signed in.
|
|
139
|
+
*
|
|
140
|
+
* Ordering is NOT what this gives you. Everything added is already in flight, so two writes
|
|
141
|
+
* that must land in order go in one `batch()`, never in two `add`s — a DELETE that must
|
|
142
|
+
* precede its INSERT will otherwise race and trip a unique index.
|
|
143
|
+
*/
|
|
144
|
+
export declare function createPendingWrites(): PendingWrites;
|
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
* It wraps either driver, because it sits ABOVE the seam and only counts. That is what
|
|
40
40
|
* makes the local gate able to prove a production-only limit: wrap a local database in a
|
|
41
41
|
* test, run the loop, and the laptop fails exactly where the Worker would.
|
|
42
|
+
*
|
|
43
|
+
* ## The other thing that is true for the span of one request: {@link createPendingWrites}
|
|
44
|
+
*
|
|
45
|
+
* A Worker port's synchronous stores answer from a snapshot and queue their durable writes
|
|
46
|
+
* as promises, which the entry point awaits BEFORE the response leaves. That collector has
|
|
47
|
+
* the same lifetime as the budget above — one per invocation, constructed by the caller in
|
|
48
|
+
* the same breath — so it lives in the same file. See its own header for why it was moved
|
|
49
|
+
* here on 2026-09-19.
|
|
42
50
|
*/
|
|
43
51
|
import { LIMITS } from './limits';
|
|
44
52
|
import { D1LimitError } from './types';
|
|
@@ -164,3 +172,60 @@ export function perInvocation(db, opts = {}) {
|
|
|
164
172
|
},
|
|
165
173
|
};
|
|
166
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Collect the durable writes of ONE invocation, to be awaited before the response leaves.
|
|
177
|
+
*
|
|
178
|
+
* ## Why this is the Worker-write primitive and not a convenience
|
|
179
|
+
*
|
|
180
|
+
* A ported app's synchronous stores cannot await — their signatures are somebody else's
|
|
181
|
+
* published contract — so the shape every port lands on is: one read fills a snapshot, every
|
|
182
|
+
* sync call answers from it, and every mutation both updates the snapshot and pushes its
|
|
183
|
+
* statement's promise in here. The entry point then does the awaiting:
|
|
184
|
+
*
|
|
185
|
+
* ```ts
|
|
186
|
+
* export default {
|
|
187
|
+
* async fetch(req: Request, env: Env) {
|
|
188
|
+
* const db = perInvocation(createRemoteD1(env.DB));
|
|
189
|
+
* const writes = createPendingWrites();
|
|
190
|
+
* const res = await app.fetch(req, { db, writes });
|
|
191
|
+
* await writes.settle(); // 🔴 BEFORE the response, never after
|
|
192
|
+
* return res;
|
|
193
|
+
* },
|
|
194
|
+
* };
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
197
|
+
* 🔴 **`settle()` before the response, never `ctx.waitUntil`.** A Worker isolate is per-colo
|
|
198
|
+
* and short-lived; a write acknowledged by a 200 and then dropped means "sign out" reported
|
|
199
|
+
* success and left the credential live — a failure invisible to any single-process test and
|
|
200
|
+
* indistinguishable, to the person, from the feature being broken.
|
|
201
|
+
*
|
|
202
|
+
* 🔴 **`Promise.all`, not `allSettled`.** A failed durable write must not be reported as a
|
|
203
|
+
* success. It surfaces as a 500 from the entry point, which is the honest answer to "did my
|
|
204
|
+
* sign-out take". `allSettled` here would convert every lost write into a silent 200 — the
|
|
205
|
+
* same outcome as not collecting them at all, reached by a road that looks careful.
|
|
206
|
+
*
|
|
207
|
+
* ## Why it lives here
|
|
208
|
+
*
|
|
209
|
+
* It was written three times before it was published once — `apps/patterns/worker/stores.ts`,
|
|
210
|
+
* `apps/collections/worker/stores.ts` and `libs/cursedauth/src/d1Stores.ts`, byte-identical
|
|
211
|
+
* but for a comment, with six more ports queued behind them. It is a D1-INVOCATION primitive
|
|
212
|
+
* rather than an auth one, so it belongs beside {@link perInvocation}: both are constructed
|
|
213
|
+
* per request, both describe what is true for the span of one, and neither knows anything
|
|
214
|
+
* about who is signed in.
|
|
215
|
+
*
|
|
216
|
+
* Ordering is NOT what this gives you. Everything added is already in flight, so two writes
|
|
217
|
+
* that must land in order go in one `batch()`, never in two `add`s — a DELETE that must
|
|
218
|
+
* precede its INSERT will otherwise race and trip a unique index.
|
|
219
|
+
*/
|
|
220
|
+
export function createPendingWrites() {
|
|
221
|
+
const pending = [];
|
|
222
|
+
return {
|
|
223
|
+
add: (promise) => void pending.push(promise),
|
|
224
|
+
async settle() {
|
|
225
|
+
// `all`, not `allSettled`: a failed durable write must not be reported as a
|
|
226
|
+
// success. It surfaces as a 500 from the entry point, which is the honest answer
|
|
227
|
+
// to "did my sign-out take".
|
|
228
|
+
await Promise.all(pending);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -35,7 +35,7 @@ export interface ChunkedUploadService {
|
|
|
35
35
|
/**
|
|
36
36
|
* Mint the direct-to-backend PUT URL for one chunk. The app supplies this so the module stays
|
|
37
37
|
* backend-agnostic. For binary-server: `${base}/upload-chunk/${app}/${key}?token=` with a token
|
|
38
|
-
* carrying { k, uid, sid, ci, tc }.
|
|
38
|
+
* carrying { k, uid, sid, ci, tc, sz }.
|
|
39
39
|
*/
|
|
40
40
|
export type MintChunkUpload = (args: {
|
|
41
41
|
key: string;
|
|
@@ -43,6 +43,17 @@ export type MintChunkUpload = (args: {
|
|
|
43
43
|
ci: number;
|
|
44
44
|
tc: number;
|
|
45
45
|
owner: string;
|
|
46
|
+
/**
|
|
47
|
+
* The SOURCE byte length this session was opened with — pass it straight into the token's `sz`
|
|
48
|
+
* claim (`FileTokenClaims.sz`), which is the only end-to-end integrity check the upload path
|
|
49
|
+
* has: binary-server compares it to the assembled object and answers 422 instead of indexing a
|
|
50
|
+
* short one.
|
|
51
|
+
*
|
|
52
|
+
* 🔴 `undefined` when the caller could not state a positive length, and then the claim must be
|
|
53
|
+
* OMITTED rather than sent as `0` — a declared zero refuses every assemble. That is the same
|
|
54
|
+
* posture the claim itself has: optional, so that declaring it could never take an upload down.
|
|
55
|
+
*/
|
|
56
|
+
sz?: number;
|
|
46
57
|
}) => Promise<string>;
|
|
47
58
|
export declare function createChunkedUploadService(cfg: {
|
|
48
59
|
catalogue: FileCatalogue;
|
|
@@ -39,7 +39,12 @@ export function createChunkedUploadService(cfg) {
|
|
|
39
39
|
tags: req.tags,
|
|
40
40
|
createdAt: now(),
|
|
41
41
|
});
|
|
42
|
-
|
|
42
|
+
// 🔴 The source length rides along, because `chunkCount` above is derived from it and a
|
|
43
|
+
// count is not a length: a session that splits the file wrongly stores whatever it sent,
|
|
44
|
+
// faithfully, with a 201. Omitted rather than zeroed when the caller stated no positive
|
|
45
|
+
// size — `sz: 0` would refuse every assemble. See `MintChunkUpload.sz`.
|
|
46
|
+
const declaredSize = Number.isFinite(req.size) && req.size > 0 ? req.size : undefined;
|
|
47
|
+
const chunkUploadUrls = await Promise.all(Array.from({ length: chunkCount }, (_, ci) => cfg.mintChunkUpload({ key, sid, ci, tc: chunkCount, owner, sz: declaredSize })));
|
|
43
48
|
return { fileId, sid, key, chunkSize, chunkCount, chunkUploadUrls };
|
|
44
49
|
},
|
|
45
50
|
async complete(sid, confirmed) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.11.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.",
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 🔴 **No `typeof x === 'function'` gate in front of a Node built-in. Probe by CALLING.**
|
|
7
|
+
*
|
|
8
|
+
* Measured 2026-09-19, the first real deploy of a cursedbelt app onto Workers:
|
|
9
|
+
* `https://collections.curtwphillips.workers.dev/healthz` answered `500` on **every path**,
|
|
10
|
+
* with 843 green tests behind it. One line did it — `bench/cpuClock.ts`:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const usable = typeof process !== 'undefined' && typeof process.cpuUsage === 'function';
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Under `nodejs_compat`, workerd's strategy for the parts of `node:process` it does not
|
|
17
|
+
* implement is to PROVIDE the method and throw when it is called:
|
|
18
|
+
*
|
|
19
|
+
* ```
|
|
20
|
+
* Error [ERR_METHOD_NOT_IMPLEMENTED]: The process.cpuUsage method is not implemented
|
|
21
|
+
* at process.cpuUsage (node-internal:public_process:235:11)
|
|
22
|
+
* at Object.start (index.js:2772:30) <- cpuBudget's middleware
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* So the availability probe answered *yes* about the one runtime it exists to answer *no*
|
|
26
|
+
* about, and because `cpuBudget()` is mounted first by design, the throw landed in front of
|
|
27
|
+
* the whole app.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 **That is a capability CLASS, not one method.** `nodejs_compat` stubs a great deal of
|
|
30
|
+
* `node:process`, `node:os` and `node:v8` the same way, and five apps have a "move to a
|
|
31
|
+
* Worker" task queued behind this library. `cpuClock.ts` is fixed; this is what stops the
|
|
32
|
+
* shape coming back somewhere else, where the next deploy would find it instead of a test.
|
|
33
|
+
*
|
|
34
|
+
* ## What it measures
|
|
35
|
+
*
|
|
36
|
+
* For every non-spec source file: blank the comments and string/template literals, work out
|
|
37
|
+
* which identifiers name a Node built-in (the `process` global, plus every binding imported
|
|
38
|
+
* from a `node:*` module, static or dynamic), and red on `typeof <that>.… === 'function'` in
|
|
39
|
+
* either direction and either operand order.
|
|
40
|
+
*
|
|
41
|
+
* `typeof x === 'undefined'` is deliberately NOT flagged: "is there a `process` at all" is a
|
|
42
|
+
* question a stub cannot lie about, and it is the guard that stops a bare reference throwing
|
|
43
|
+
* a `ReferenceError` before any `try` can catch it. What is forbidden is concluding a method
|
|
44
|
+
* WORKS because it EXISTS.
|
|
45
|
+
*
|
|
46
|
+
* ## The excuse, and why there is one
|
|
47
|
+
*
|
|
48
|
+
* A line carrying `probe-by-calling:ignore` (on it, or on the line above) is allowed — for
|
|
49
|
+
* the one honest use left: labelling. `cpuClock.ts` decides by calling and then uses a
|
|
50
|
+
* presence check only to say `(absent)` or `(throws)` in the clock's source string. Excuse
|
|
51
|
+
* the LINE, never delete the rule.
|
|
52
|
+
*
|
|
53
|
+
* ## Verified failing before it was trusted, 2026-09-19
|
|
54
|
+
*
|
|
55
|
+
* Four probes, each written to a scratch `src/server/bench/probe.agent.ts`, run, and deleted:
|
|
56
|
+
*
|
|
57
|
+
* · the pre-fix line itself, `typeof process !== 'undefined' && typeof process.cpuUsage
|
|
58
|
+
* === 'function'` → **red**, `1 pass, 1 fail`, naming
|
|
59
|
+
* `server/bench/probe.agent.ts:1 — typeof process.cpuUsage === 'function'`
|
|
60
|
+
* · the same line under a `probe-by-calling:ignore` comment → green — the excuse working
|
|
61
|
+
* · `typeof process !== 'undefined'` alone → green — the carve-out working
|
|
62
|
+
* · `import { homedir } from 'node:os'` + `typeof homedir === 'function'` → **red**, naming
|
|
63
|
+
* line 2. That is the import-tracking half, which the `process` global never exercises.
|
|
64
|
+
*
|
|
65
|
+
* 🔴 The first probe is why there was a rewrite rather than a lucky green: run against the
|
|
66
|
+
* scanner's first draft it PASSED, because that draft blanked string literals along with
|
|
67
|
+
* comments and the pattern it hunts for ends in the literal `'function'`. A check whose
|
|
68
|
+
* first probe is green is a check nobody has seen work — see {@link stripComments}.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
const SRC = fileURLToPath(new URL('.', import.meta.url));
|
|
72
|
+
|
|
73
|
+
/** `typeof A.b.c === 'function'`, and the three other ways to write it. */
|
|
74
|
+
const TYPEOF_FUNCTION =
|
|
75
|
+
/typeof\s+([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*[!=]==?\s*['"]function['"]|['"]function['"]\s*[!=]==?\s*typeof\s+([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)/g;
|
|
76
|
+
|
|
77
|
+
/** The one global that is a Node built-in without being imported. */
|
|
78
|
+
const GLOBAL_NODE_BUILTINS = new Set(['process']);
|
|
79
|
+
|
|
80
|
+
const IGNORE_MARKER = 'probe-by-calling:ignore';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Blank COMMENTS, preserving line structure and every string literal, so a scanner is not
|
|
84
|
+
* fooled by prose.
|
|
85
|
+
*
|
|
86
|
+
* 🔴 Both halves of that are load-bearing, and each was found by a probe rather than by
|
|
87
|
+
* reasoning. Comments must go: this package is comment-dense and `cpuClock.ts`'s own header
|
|
88
|
+
* QUOTES the forbidden line as the bug it fixed, so a comment-blind scan reds on the fix.
|
|
89
|
+
* Strings must STAY: the pattern this looks for ends in `'function'`, which IS a string
|
|
90
|
+
* literal — blanking literals made the check silently unable to match anything at all, and
|
|
91
|
+
* a probe file carrying the exact pre-fix line passed. Strings are still TRACKED (never
|
|
92
|
+
* blanked) so that a `//` inside one — `'https://…'` — cannot be read as a comment.
|
|
93
|
+
*/
|
|
94
|
+
function stripComments(source: string): string {
|
|
95
|
+
let out = '';
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < source.length) {
|
|
98
|
+
const c = source[i];
|
|
99
|
+
const next = source[i + 1];
|
|
100
|
+
if (c === '/' && next === '/') {
|
|
101
|
+
while (i < source.length && source[i] !== '\n') {
|
|
102
|
+
out += ' ';
|
|
103
|
+
i += 1;
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (c === '/' && next === '*') {
|
|
108
|
+
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
|
|
109
|
+
out += source[i] === '\n' ? '\n' : ' ';
|
|
110
|
+
i += 1;
|
|
111
|
+
}
|
|
112
|
+
out += ' ';
|
|
113
|
+
i += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
117
|
+
const quote = c;
|
|
118
|
+
out += c;
|
|
119
|
+
i += 1;
|
|
120
|
+
while (i < source.length && source[i] !== quote) {
|
|
121
|
+
if (source[i] === '\\') {
|
|
122
|
+
out += source[i];
|
|
123
|
+
i += 1;
|
|
124
|
+
if (i < source.length) {
|
|
125
|
+
out += source[i];
|
|
126
|
+
i += 1;
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
out += source[i];
|
|
131
|
+
i += 1;
|
|
132
|
+
}
|
|
133
|
+
out += source[i] ?? '';
|
|
134
|
+
i += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
out += c;
|
|
138
|
+
i += 1;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every local name in this file that refers to something out of a `node:*` module. */
|
|
144
|
+
function nodeBuiltinRoots(code: string): Set<string> {
|
|
145
|
+
const roots = new Set(GLOBAL_NODE_BUILTINS);
|
|
146
|
+
// `import x, { a, b as c } from 'node:os'` / `import * as os from 'node:os'`
|
|
147
|
+
// and `const { homedir } = await import('node:os')`.
|
|
148
|
+
const statics = /import\s+([\s\S]*?)\s+from\s+['"]node:[^'"]+['"]/g;
|
|
149
|
+
const dynamics =
|
|
150
|
+
/(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*await\s+import\s*\(\s*['"]node:/g;
|
|
151
|
+
// 🔴 Read from the RAW source, not the stripped copy: the module specifier is a string
|
|
152
|
+
// literal, so stripping blanks the very `'node:os'` this has to match. A binding named
|
|
153
|
+
// only inside a comment widens the rule by one identifier, which is the safe direction.
|
|
154
|
+
for (const m of code.matchAll(statics)) collectBindings(m[1] ?? '', roots);
|
|
155
|
+
for (const m of code.matchAll(dynamics)) collectBindings(m[1] ?? '', roots);
|
|
156
|
+
return roots;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Pull the local names out of an import clause: `x, { a, b as c }`, `* as ns`, `{ a }`. */
|
|
160
|
+
function collectBindings(clause: string, into: Set<string>): void {
|
|
161
|
+
for (const part of clause.replace(/[{}]/g, ',').split(',')) {
|
|
162
|
+
const token = part.trim();
|
|
163
|
+
if (!token || token === 'type') continue;
|
|
164
|
+
const aliased = token.match(/\bas\s+([A-Za-z_$][\w$]*)$/);
|
|
165
|
+
const name = aliased ? aliased[1] : token.replace(/^type\s+/, '');
|
|
166
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) into.add(name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sourceFiles(dir: string, found: string[] = []): string[] {
|
|
171
|
+
for (const entry of readdirSync(dir)) {
|
|
172
|
+
const full = `${dir}${entry}`;
|
|
173
|
+
if (statSync(full).isDirectory()) {
|
|
174
|
+
sourceFiles(`${full}/`, found);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Specs are excluded: a test may legitimately assert about a probe, and a `typeof`
|
|
178
|
+
// gate in one cannot 500 a deployed app.
|
|
179
|
+
if (entry.endsWith('.ts') && !entry.endsWith('.spec.ts')) found.push(full);
|
|
180
|
+
}
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface Violation {
|
|
185
|
+
file: string;
|
|
186
|
+
line: number;
|
|
187
|
+
text: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function scan(): Violation[] {
|
|
191
|
+
const violations: Violation[] = [];
|
|
192
|
+
for (const file of sourceFiles(SRC)) {
|
|
193
|
+
const raw = readFileSync(file, 'utf8');
|
|
194
|
+
const rawLines = raw.split('\n');
|
|
195
|
+
const code = stripComments(raw);
|
|
196
|
+
const roots = nodeBuiltinRoots(raw);
|
|
197
|
+
code.split('\n').forEach((line, index) => {
|
|
198
|
+
for (const m of line.matchAll(TYPEOF_FUNCTION)) {
|
|
199
|
+
const subject = (m[1] ?? m[2] ?? '').replace(/\s+/g, '');
|
|
200
|
+
const root = subject.split('.')[0];
|
|
201
|
+
if (!roots.has(root)) continue;
|
|
202
|
+
const here = rawLines[index] ?? '';
|
|
203
|
+
const above = rawLines[index - 1] ?? '';
|
|
204
|
+
if (here.includes(IGNORE_MARKER) || above.includes(IGNORE_MARKER)) continue;
|
|
205
|
+
violations.push({
|
|
206
|
+
file: file.slice(SRC.length),
|
|
207
|
+
line: index + 1,
|
|
208
|
+
text: `typeof ${subject} === 'function'`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return violations;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
describe('Node built-ins are probed by CALLING, never by typeof', () => {
|
|
217
|
+
it('has a real tree to scan — otherwise every assertion below passes vacuously', () => {
|
|
218
|
+
const files = sourceFiles(SRC);
|
|
219
|
+
expect(files.length).toBeGreaterThan(100);
|
|
220
|
+
// The scanner must actually resolve `node:*` imports, or the import half is dead.
|
|
221
|
+
const withOsImport = files.find((f) => f.endsWith('server/sqlite/export.ts'));
|
|
222
|
+
expect(withOsImport).toBeTruthy();
|
|
223
|
+
expect(nodeBuiltinRoots(readFileSync(withOsImport as string, 'utf8')).has('tmpdir')).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('finds no typeof gate in front of a Node built-in anywhere in src', () => {
|
|
227
|
+
const violations = scan();
|
|
228
|
+
const message = violations.map((v) => ` ${v.file}:${v.line} — ${v.text}`).join('\n');
|
|
229
|
+
expect(
|
|
230
|
+
violations.length === 0 ? '' : `\n${message}\n\nProbe by calling, inside a try.\n`,
|
|
231
|
+
).toBe('');
|
|
232
|
+
});
|
|
233
|
+
});
|