cursedbelt-server 4.1.0 โ 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/bench/assert.d.ts +16 -3
- package/dist/server/bench/assert.js +54 -0
- package/dist/server/bench/budget.d.ts +35 -1
- package/dist/server/bench/budget.js +35 -1
- package/dist/server/bench/cpuClock.js +21 -1
- package/dist/server/bench/index.d.ts +1 -1
- package/dist/server/bench/index.js +1 -1
- package/dist/server/d1/fakeD1.d.ts +5 -0
- package/dist/server/d1/fakeD1.js +51 -18
- package/dist/server/d1/index.d.ts +1 -0
- package/dist/server/d1/index.js +1 -0
- package/dist/server/d1/invocation.d.ts +72 -0
- package/dist/server/d1/invocation.js +166 -0
- package/dist/server/d1/local.js +48 -3
- package/dist/server/d1/values.d.ts +19 -2
- package/dist/server/d1/values.js +21 -2
- package/dist/server/middleware/bodyLimit.d.ts +152 -0
- package/dist/server/middleware/bodyLimit.js +161 -0
- package/package.json +8 -2
- package/src/leafSubpathsImportNothing.spec.ts +13 -0
- package/src/server/bench/assert.ts +78 -3
- package/src/server/bench/budget.spec.ts +27 -0
- package/src/server/bench/budget.ts +36 -1
- package/src/server/bench/cpuBudget.spec.ts +101 -1
- package/src/server/bench/cpuClock.ts +22 -1
- package/src/server/bench/index.ts +1 -0
- package/src/server/d1/fakeD1.ts +56 -18
- package/src/server/d1/index.ts +1 -0
- package/src/server/d1/invocation.spec.ts +174 -0
- package/src/server/d1/invocation.ts +207 -0
- package/src/server/d1/local.ts +56 -3
- package/src/server/d1/sameShape.spec.ts +73 -0
- package/src/server/d1/values.ts +23 -2
- package/src/server/middleware/bodyLimit.spec.ts +238 -0
- package/src/server/middleware/bodyLimit.ts +210 -0
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
* which is the owner's estimate and not a measurement. {@link deriveCpuBudgetMs} is the
|
|
27
27
|
* function to re-run against real traffic โ the re-derivation is a call, not a paragraph
|
|
28
28
|
* somebody has to remember to do.
|
|
29
|
+
*
|
|
30
|
+
* ๐ด **It has already been run once** โ see {@link MEASURED_FLEET_REQUESTS_PER_DAY}, which
|
|
31
|
+
* carries both the number and the reason the default was left stricter than it. Do not
|
|
32
|
+
* spend that measurement again; what is still missing is Worker traffic, not Mac traffic.
|
|
29
33
|
*/
|
|
30
34
|
|
|
31
35
|
/** CPU-milliseconds included in the Workers Paid plan each month. */
|
|
@@ -49,6 +53,35 @@ export const OWNER_PROJECTED_REQUESTS_PER_DAY = 100_000;
|
|
|
49
53
|
/** 365.25 / 12 โ the average calendar month, since billing is monthly. */
|
|
50
54
|
export const DAYS_PER_MONTH = 30.4375;
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* ๐ด The re-derivation has been DONE โ 2026-09-16 โ and the answer was not 9.9. This
|
|
58
|
+
* constant exists so the next reader does not spend the measurement again.
|
|
59
|
+
*
|
|
60
|
+
* Summed from the six apps still holding a populated `request_metrics` table in
|
|
61
|
+
* `$FORGE_STATE/apps/<app>/metrics.sqlite` (rows รท retained window, since five of the
|
|
62
|
+
* six sit at a ~100,4xx retention cap and only `patterns` is a true count):
|
|
63
|
+
*
|
|
64
|
+
* ```
|
|
65
|
+
* family 15,489/d ยท roms 10,580/d ยท music 7,034/d ยท vault 5,690/d
|
|
66
|
+
* collections 3,737/d ยท patterns 442/d โ 42,971/day
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* That is **43 % of {@link OWNER_PROJECTED_REQUESTS_PER_DAY}**, so the real per-request
|
|
70
|
+
* allowance is ~22.9 CPU-ms rather than 9.9 โ 2.3ร looser than the shipped default.
|
|
71
|
+
*
|
|
72
|
+
* ๐ด **The default was deliberately NOT raised to it**, and the reason is the corpus:
|
|
73
|
+
* every one of those tables stops within hours of its app's graduation (`roms` ends
|
|
74
|
+
* `2026-09-15 12:00:03`), because `requestLogger` has no callers in this generation
|
|
75
|
+
* yet. It is the RETIRED generation's traffic, and no app is on a Worker at all โ so
|
|
76
|
+
* nothing here has been measured against the quantity Cloudflare actually bills.
|
|
77
|
+
* Loosening every budget in the fleet on evidence that cannot support it is the one
|
|
78
|
+
* direction a wrong guess costs money in; being 2.3ร conservative costs nothing.
|
|
79
|
+
*
|
|
80
|
+
* `budget.spec.ts` holds that ordering as an assertion rather than as this paragraph:
|
|
81
|
+
* the default may never drift ABOVE the measured allowance without a red build.
|
|
82
|
+
*/
|
|
83
|
+
export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
|
|
84
|
+
|
|
52
85
|
/**
|
|
53
86
|
* Derive the average CPU-ms a single request may spend before the fleet exceeds its
|
|
54
87
|
* included CPU allowance.
|
|
@@ -100,7 +133,9 @@ export function monthlyOverageUsd(opts: {
|
|
|
100
133
|
* 9.9 CPU-ms โ `30,000,000 รท (100,000 ร 30.4375)` = 9.856, to one decimal.
|
|
101
134
|
*
|
|
102
135
|
* Generous for a JSON route, tight for anything that renders, parses or derives a key.
|
|
103
|
-
*
|
|
136
|
+
*
|
|
137
|
+
* ๐ด Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
|
|
138
|
+
* derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
|
|
104
139
|
*/
|
|
105
140
|
export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
|
|
106
141
|
|
|
@@ -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/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export {
|
|
|
26
26
|
type TimeTravelOpts,
|
|
27
27
|
} from './backup';
|
|
28
28
|
export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
|
|
29
|
+
export { type InvocationD1, perInvocation } from './invocation';
|
|
29
30
|
export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
|
|
30
31
|
export { createLocalD1, refuseInteractiveTransaction } from './local';
|
|
31
32
|
export {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
3
|
+
import { createFakeD1Binding } from './fakeD1';
|
|
4
|
+
import { perInvocation } from './invocation';
|
|
5
|
+
import { LIMITS } from './limits';
|
|
6
|
+
import { createLocalD1 } from './local';
|
|
7
|
+
import { createRemoteD1 } from './remote';
|
|
8
|
+
import { D1LimitError, type D1LikeDatabase } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ๐ด Every test here would have passed against the seam as published in 4.2.0, because
|
|
12
|
+
* nothing counted. `assertBatchSize` guards the length of one `batch()`; the N+1 loop
|
|
13
|
+
* below never builds a batch at all.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const seeded = () => {
|
|
17
|
+
const db = new Database(':memory:');
|
|
18
|
+
db.run('CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT)');
|
|
19
|
+
for (let i = 1; i <= 5; i++) db.run('INSERT INTO people (id, name) VALUES (?, ?)', [i, `p${i}`]);
|
|
20
|
+
return db;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('the per-invocation query budget', () => {
|
|
24
|
+
let base: D1LikeDatabase;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
base = createLocalD1(seeded());
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('๐ด the N+1 loop the limits header names is refused โ and it is the case nothing caught before', async () => {
|
|
31
|
+
const db = perInvocation(base, { max: 10 });
|
|
32
|
+
// The laziest mechanical port of a synchronous loop: await inside `for`.
|
|
33
|
+
const run = async () => {
|
|
34
|
+
for (let i = 0; i < 50; i++) {
|
|
35
|
+
await db.prepare('SELECT name FROM people WHERE id = ?').bind(1).first();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
await expect(run()).rejects.toThrow(D1LimitError);
|
|
39
|
+
// It stopped AT the cap, not after it โ the query over budget was never issued.
|
|
40
|
+
expect(db.used).toBe(10);
|
|
41
|
+
expect(db.remaining).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the refusal names the remedy rather than the number', async () => {
|
|
45
|
+
const db = perInvocation(base, { max: 1 });
|
|
46
|
+
await db.prepare('SELECT 1 AS n').all();
|
|
47
|
+
try {
|
|
48
|
+
await db.prepare('SELECT 2 AS n').all();
|
|
49
|
+
throw new Error('expected a refusal');
|
|
50
|
+
} catch (e) {
|
|
51
|
+
expect(e).toBeInstanceOf(D1LimitError);
|
|
52
|
+
const msg = (e as Error).message;
|
|
53
|
+
expect(msg).toContain('N+1');
|
|
54
|
+
expect(msg).toContain('JOIN');
|
|
55
|
+
expect(msg).toContain('batch()');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the JOIN that replaces the loop costs one query', async () => {
|
|
60
|
+
const db = perInvocation(base, { max: 1 });
|
|
61
|
+
const res = await db.prepare('SELECT id, name FROM people ORDER BY id').all();
|
|
62
|
+
expect(res.results).toHaveLength(5);
|
|
63
|
+
expect(db.used).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('every terminal call is charged, and building a statement is not', async () => {
|
|
67
|
+
const db = perInvocation(base);
|
|
68
|
+
// prepare + bind reach nothing, so they cost nothing โ otherwise a re-bound
|
|
69
|
+
// statement kept in a helper would bill for merely existing.
|
|
70
|
+
const stmt = db.prepare('SELECT name FROM people WHERE id = ?').bind(1);
|
|
71
|
+
expect(db.used).toBe(0);
|
|
72
|
+
|
|
73
|
+
await stmt.first();
|
|
74
|
+
await stmt.all();
|
|
75
|
+
await stmt.raw();
|
|
76
|
+
await db.prepare('INSERT INTO people (id, name) VALUES (?, ?)').bind(99, 'x').run();
|
|
77
|
+
expect(db.used).toBe(4);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a batch is charged per MEMBER, because D1 bills it that way', async () => {
|
|
81
|
+
const db = perInvocation(base);
|
|
82
|
+
const stmts = [1, 2, 3].map((i) => db.prepare('SELECT name FROM people WHERE id = ?').bind(i));
|
|
83
|
+
const out = await db.batch(stmts);
|
|
84
|
+
expect(out).toHaveLength(3);
|
|
85
|
+
expect(db.used).toBe(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('๐ด batch() still works through the wrapper โ the statements are unwrapped for the driver', async () => {
|
|
89
|
+
// The regression this guards: both drivers identify their own statements
|
|
90
|
+
// structurally (`allSync` local, `binding` remote) and reject anything else. A
|
|
91
|
+
// wrapper that forwarded itself would break batch() on both sides.
|
|
92
|
+
const db = perInvocation(base);
|
|
93
|
+
const stmts = [
|
|
94
|
+
db.prepare('INSERT INTO people (id, name) VALUES (?, ?)').bind(20, 'a'),
|
|
95
|
+
db.prepare('INSERT INTO people (id, name) VALUES (?, ?)').bind(21, 'b'),
|
|
96
|
+
];
|
|
97
|
+
await db.batch(stmts);
|
|
98
|
+
const check = await db.prepare('SELECT COUNT(*) AS n FROM people').first<{ n: number }>();
|
|
99
|
+
expect(check?.n).toBe(7);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a batch that would exceed the remaining budget is refused before it is sent', async () => {
|
|
103
|
+
const db = perInvocation(base, { max: 2 });
|
|
104
|
+
const stmts = [1, 2, 3].map((i) => db.prepare('SELECT name FROM people WHERE id = ?').bind(i));
|
|
105
|
+
await expect(db.batch(stmts)).rejects.toThrow(D1LimitError);
|
|
106
|
+
// Nothing was charged, because nothing was sent.
|
|
107
|
+
expect(db.used).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('exec settles its true cost afterwards, and the NEXT query is what refuses', async () => {
|
|
111
|
+
const db = perInvocation(base, { max: 3 });
|
|
112
|
+
// Three statements through a budget of three: exec reserves one, then settles two.
|
|
113
|
+
const res = await db.exec('CREATE TABLE a (x); CREATE TABLE b (x); CREATE TABLE c (x)');
|
|
114
|
+
expect(res.count).toBe(3);
|
|
115
|
+
expect(db.used).toBe(3);
|
|
116
|
+
await expect(db.prepare('SELECT 1 AS n').all()).rejects.toThrow(D1LimitError);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the cap is not off by one โ exactly `max` queries are allowed', async () => {
|
|
120
|
+
const db = perInvocation(base, { max: 3 });
|
|
121
|
+
for (let i = 0; i < 3; i++) await db.prepare('SELECT 1 AS n').all();
|
|
122
|
+
expect(db.used).toBe(3);
|
|
123
|
+
await expect(db.prepare('SELECT 1 AS n').all()).rejects.toThrow(D1LimitError);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('the default budget is D1\'s own, and `remaining` starts there', () => {
|
|
127
|
+
const db = perInvocation(base);
|
|
128
|
+
expect(db.remaining).toBe(LIMITS.queriesPerInvocation);
|
|
129
|
+
expect(db.used).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a budget above D1\'s real cap is refused rather than honoured', () => {
|
|
133
|
+
expect(() => perInvocation(base, { max: LIMITS.queriesPerInvocation + 1 })).toThrow(D1LimitError);
|
|
134
|
+
expect(() => perInvocation(base, { max: 0 })).toThrow(RangeError);
|
|
135
|
+
expect(() => perInvocation(base, { max: 1.5 })).toThrow(RangeError);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('the flavor of the wrapped driver shows through', () => {
|
|
139
|
+
expect(perInvocation(base).flavor).toBe('local');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('๐ด each invocation gets its own budget โ the count is the request\'s, not the handle\'s', async () => {
|
|
143
|
+
// The false positive this design avoids: `createLocalD1` lives for the whole
|
|
144
|
+
// process, so a counter on the HANDLE would refuse the 1,001st query of the day.
|
|
145
|
+
for (let request = 0; request < 3; request++) {
|
|
146
|
+
const db = perInvocation(base, { max: 2 });
|
|
147
|
+
await db.prepare('SELECT 1 AS n').all();
|
|
148
|
+
await db.prepare('SELECT 1 AS n').all();
|
|
149
|
+
expect(db.used).toBe(2);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('the budget counts the REMOTE driver identically', () => {
|
|
155
|
+
// Same assertions, other side of the seam โ a budget that only worked locally would be
|
|
156
|
+
// the sync-locally defect over again.
|
|
157
|
+
test('the loop is refused on D1 too, at the same point', async () => {
|
|
158
|
+
const db = perInvocation(createRemoteD1(createFakeD1Binding(seeded())), { max: 4 });
|
|
159
|
+
expect(db.flavor).toBe('d1');
|
|
160
|
+
const run = async () => {
|
|
161
|
+
for (let i = 0; i < 20; i++) await db.prepare('SELECT name FROM people WHERE id = ?').bind(1).first();
|
|
162
|
+
};
|
|
163
|
+
await expect(run()).rejects.toThrow(D1LimitError);
|
|
164
|
+
expect(db.used).toBe(4);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('batch() unwraps for the remote driver as well', async () => {
|
|
168
|
+
const db = perInvocation(createRemoteD1(createFakeD1Binding(seeded())));
|
|
169
|
+
const stmts = [1, 2].map((i) => db.prepare('SELECT name FROM people WHERE id = ?').bind(i));
|
|
170
|
+
const out = await db.batch(stmts);
|
|
171
|
+
expect(out).toHaveLength(2);
|
|
172
|
+
expect(db.used).toBe(2);
|
|
173
|
+
});
|
|
174
|
+
});
|