cursedbelt-server 4.2.0 โ†’ 4.4.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.
@@ -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
  /** CPU-milliseconds included in the Workers Paid plan each month. */
31
35
  export declare const WORKERS_PAID_INCLUDED_CPU_MS = 30000000;
@@ -42,6 +46,34 @@ export declare const WORKERS_PAID_USD_PER_MILLION_REQUESTS = 0.3;
42
46
  export declare const OWNER_PROJECTED_REQUESTS_PER_DAY = 100000;
43
47
  /** 365.25 / 12 โ€” the average calendar month, since billing is monthly. */
44
48
  export declare const DAYS_PER_MONTH = 30.4375;
49
+ /**
50
+ * ๐Ÿ”ด The re-derivation has been DONE โ€” 2026-09-16 โ€” and the answer was not 9.9. This
51
+ * constant exists so the next reader does not spend the measurement again.
52
+ *
53
+ * Summed from the six apps still holding a populated `request_metrics` table in
54
+ * `$FORGE_STATE/apps/<app>/metrics.sqlite` (rows รท retained window, since five of the
55
+ * six sit at a ~100,4xx retention cap and only `patterns` is a true count):
56
+ *
57
+ * ```
58
+ * family 15,489/d ยท roms 10,580/d ยท music 7,034/d ยท vault 5,690/d
59
+ * collections 3,737/d ยท patterns 442/d โ†’ 42,971/day
60
+ * ```
61
+ *
62
+ * That is **43 % of {@link OWNER_PROJECTED_REQUESTS_PER_DAY}**, so the real per-request
63
+ * allowance is ~22.9 CPU-ms rather than 9.9 โ€” 2.3ร— looser than the shipped default.
64
+ *
65
+ * ๐Ÿ”ด **The default was deliberately NOT raised to it**, and the reason is the corpus:
66
+ * every one of those tables stops within hours of its app's graduation (`roms` ends
67
+ * `2026-09-15 12:00:03`), because `requestLogger` has no callers in this generation
68
+ * yet. It is the RETIRED generation's traffic, and no app is on a Worker at all โ€” so
69
+ * nothing here has been measured against the quantity Cloudflare actually bills.
70
+ * Loosening every budget in the fleet on evidence that cannot support it is the one
71
+ * direction a wrong guess costs money in; being 2.3ร— conservative costs nothing.
72
+ *
73
+ * `budget.spec.ts` holds that ordering as an assertion rather than as this paragraph:
74
+ * the default may never drift ABOVE the measured allowance without a red build.
75
+ */
76
+ export declare const MEASURED_FLEET_REQUESTS_PER_DAY = 42971;
45
77
  /**
46
78
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
47
79
  * included CPU allowance.
@@ -77,7 +109,9 @@ export declare function monthlyOverageUsd(opts: {
77
109
  * 9.9 CPU-ms โ€” `30,000,000 รท (100,000 ร— 30.4375)` = 9.856, to one decimal.
78
110
  *
79
111
  * Generous for a JSON route, tight for anything that renders, parses or derives a key.
80
- * ๐Ÿ”ด Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
112
+ *
113
+ * ๐Ÿ”ด Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
114
+ * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
81
115
  */
82
116
  export declare const DEFAULT_ROUTE_CPU_BUDGET_MS: number;
83
117
  /** A route that is measured against a number. */
@@ -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
  /** CPU-milliseconds included in the Workers Paid plan each month. */
31
35
  export const WORKERS_PAID_INCLUDED_CPU_MS = 30_000_000;
@@ -42,6 +46,34 @@ export const WORKERS_PAID_USD_PER_MILLION_REQUESTS = 0.3;
42
46
  export const OWNER_PROJECTED_REQUESTS_PER_DAY = 100_000;
43
47
  /** 365.25 / 12 โ€” the average calendar month, since billing is monthly. */
44
48
  export const DAYS_PER_MONTH = 30.4375;
49
+ /**
50
+ * ๐Ÿ”ด The re-derivation has been DONE โ€” 2026-09-16 โ€” and the answer was not 9.9. This
51
+ * constant exists so the next reader does not spend the measurement again.
52
+ *
53
+ * Summed from the six apps still holding a populated `request_metrics` table in
54
+ * `$FORGE_STATE/apps/<app>/metrics.sqlite` (rows รท retained window, since five of the
55
+ * six sit at a ~100,4xx retention cap and only `patterns` is a true count):
56
+ *
57
+ * ```
58
+ * family 15,489/d ยท roms 10,580/d ยท music 7,034/d ยท vault 5,690/d
59
+ * collections 3,737/d ยท patterns 442/d โ†’ 42,971/day
60
+ * ```
61
+ *
62
+ * That is **43 % of {@link OWNER_PROJECTED_REQUESTS_PER_DAY}**, so the real per-request
63
+ * allowance is ~22.9 CPU-ms rather than 9.9 โ€” 2.3ร— looser than the shipped default.
64
+ *
65
+ * ๐Ÿ”ด **The default was deliberately NOT raised to it**, and the reason is the corpus:
66
+ * every one of those tables stops within hours of its app's graduation (`roms` ends
67
+ * `2026-09-15 12:00:03`), because `requestLogger` has no callers in this generation
68
+ * yet. It is the RETIRED generation's traffic, and no app is on a Worker at all โ€” so
69
+ * nothing here has been measured against the quantity Cloudflare actually bills.
70
+ * Loosening every budget in the fleet on evidence that cannot support it is the one
71
+ * direction a wrong guess costs money in; being 2.3ร— conservative costs nothing.
72
+ *
73
+ * `budget.spec.ts` holds that ordering as an assertion rather than as this paragraph:
74
+ * the default may never drift ABOVE the measured allowance without a red build.
75
+ */
76
+ export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
45
77
  /**
46
78
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
47
79
  * included CPU allowance.
@@ -78,7 +110,9 @@ export function monthlyOverageUsd(opts) {
78
110
  * 9.9 CPU-ms โ€” `30,000,000 รท (100,000 ร— 30.4375)` = 9.856, to one decimal.
79
111
  *
80
112
  * Generous for a JSON route, tight for anything that renders, parses or derives a key.
81
- * ๐Ÿ”ด Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
113
+ *
114
+ * ๐Ÿ”ด Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
115
+ * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
82
116
  */
83
117
  export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
84
118
  export function isExemption(b) {
@@ -33,7 +33,7 @@
33
33
  * alternative is a number that gets quoted as a billing fact.
34
34
  */
35
35
  export { type AssertCpuBudgetsOpts, assertCpuBudgets, checkCpuBudgets, type CpuViolation, type CpuViolationKind, formatCpuBudgetReport, } from './assert';
36
- export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
36
+ export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
37
37
  export { type CpuBudgetOpts, cpuBudget } from './cpuBudget';
38
38
  export { type CpuClock, type CpuSpan, fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock';
39
39
  export { type CpuBudgetReport, type CpuRecorder, type CpuRecorderOpts, createCpuRecorder, percentile, type RouteCpuStats, } from './recorder';
@@ -33,7 +33,7 @@
33
33
  * alternative is a number that gets quoted as a billing fact.
34
34
  */
35
35
  export { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport, } from './assert';
36
- export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
36
+ export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget';
37
37
  export { cpuBudget } from './cpuBudget';
38
38
  export { fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock';
39
39
  export { createCpuRecorder, percentile, } from './recorder';
@@ -15,7 +15,7 @@
15
15
  * ```
16
16
  */
17
17
  export { backupFor, type BackupPoint, checkpointWal, createLocalBackup, createTimeTravelBackup, type DatabaseBackup, type LocalBackupOpts, type TimeTravelOpts, } from './backup';
18
- export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
18
+ export { type InvocationD1, perInvocation } 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';
@@ -15,7 +15,14 @@
15
15
  * ```
16
16
  */
17
17
  export { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup, } from './backup';
18
- export { createD1Kysely, D1LikeDialect } from './kysely';
18
+ // ๐Ÿ”ด `./kysely` is deliberately NOT re-exported here โ€” import it from
19
+ // `cursedbelt-server/d1/kysely`. It statically imports `kysely` (real values: `Kysely`,
20
+ // `SqliteAdapter`, `SqliteQueryCompiler`), which is an OPTIONAL peer, so re-exporting it
21
+ // made `import 'cursedbelt-server/d1'` throw `Cannot find package 'kysely'` for every app
22
+ // that does not use the query builder โ€” which per `../db/kysely.ts`'s own header is most
23
+ // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
24
+ // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
25
+ export { perInvocation } from './invocation';
19
26
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
20
27
  export { createLocalD1, refuseInteractiveTransaction } from './local';
21
28
  export { createRemoteD1, } from './remote';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The per-invocation query budget โ€” D1's 1,000-queries-per-Worker-invocation cap, counted.
3
+ *
4
+ * ## ๐Ÿ”ด Why this file exists: the limit the rest of the seam only documented
5
+ *
6
+ * `./limits` lists `queriesPerInvocation: 1_000` FIRST, and its header names the exact
7
+ * failure โ€” *"An N+1 loop over `family`'s 489 people exceeds it. Join, or `batch()`; do not
8
+ * loop."* Measured here 2026-09-18, that limit was the one thing in the table nothing
9
+ * enforced: `assertBatchSize` checks the length of a SINGLE `batch()` call, so a loop
10
+ * issuing 1,001 separate `await db.prepare(โ€ฆ).run()` calls passed every check in the seam.
11
+ * Each statement is under 100 parameters, each batch is under 1,000 members, and the
12
+ * invocation still dies in production.
13
+ *
14
+ * That is the seam's own defining defect wearing a third costume. Sync-locally and
15
+ * lenient-locally are both "green on this Mac, red in the Worker"; so is uncounted-locally,
16
+ * because `bun:sqlite` has no such cap and never will. The N+1 loop is also the single most
17
+ * likely shape to appear during a port โ€” it is what the un-ported synchronous code already
18
+ * looks like, and awaiting it in a `for` loop is the laziest mechanical translation.
19
+ *
20
+ * ## The counter belongs to a REQUEST, not to a handle
21
+ *
22
+ * D1's cap is per Worker invocation, and a `D1LikeDatabase` is not an invocation: on a
23
+ * Worker it happens to be built per request, but locally `createLocalD1` is built once per
24
+ * PROCESS and lives for days. A counter on the handle would therefore be correct remotely
25
+ * and a false positive locally after the 1,000th query of the morning โ€” the mirror image of
26
+ * the bug this seam exists to prevent, and just as fatal to the rule's credibility.
27
+ *
28
+ * So the scope is explicit and cheap, and the caller opens one per request:
29
+ *
30
+ * ```ts
31
+ * export default {
32
+ * async fetch(req: Request, env: Env) {
33
+ * const db = perInvocation(createRemoteD1(env.DB));
34
+ * return handle(req, db); // 1,001st query throws, naming the loop
35
+ * },
36
+ * };
37
+ * ```
38
+ *
39
+ * It wraps either driver, because it sits ABOVE the seam and only counts. That is what
40
+ * makes the local gate able to prove a production-only limit: wrap a local database in a
41
+ * test, run the loop, and the laptop fails exactly where the Worker would.
42
+ */
43
+ import { type D1LikeDatabase } from './types';
44
+ /**
45
+ * A database handle that knows how much of its invocation budget it has spent.
46
+ *
47
+ * `used` counts QUERIES the way D1 bills them: one per terminal statement call
48
+ * (`first`/`all`/`run`/`raw`), one per member of a `batch()`, and one per statement an
49
+ * `exec()` ran. Building a statement with `prepare()` or `bind()` costs nothing, because it
50
+ * reaches the database only when it is executed.
51
+ */
52
+ export interface InvocationD1 extends D1LikeDatabase {
53
+ /** Queries charged to this invocation so far. */
54
+ readonly used: number;
55
+ /** Queries left before the cap, floored at 0. */
56
+ readonly remaining: number;
57
+ }
58
+ /**
59
+ * Give `db` a query budget for ONE Worker invocation.
60
+ *
61
+ * Construct a fresh one per request โ€” the count is the request's, not the handle's, and
62
+ * re-using one across requests would refuse the 1,001st query of the day rather than of the
63
+ * invocation. See the header for why that distinction is the whole design.
64
+ *
65
+ * `max` exists for tests and for a caller that wants to be refused sooner than D1 would
66
+ * (a route with a budget of 50 finds its own N+1 long before the hard cap does). It may not
67
+ * be raised above D1's real limit, because a budget larger than the service's is a number
68
+ * that reports success right up until production disagrees.
69
+ */
70
+ export declare function perInvocation(db: D1LikeDatabase, opts?: {
71
+ max?: number;
72
+ }): InvocationD1;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The per-invocation query budget โ€” D1's 1,000-queries-per-Worker-invocation cap, counted.
3
+ *
4
+ * ## ๐Ÿ”ด Why this file exists: the limit the rest of the seam only documented
5
+ *
6
+ * `./limits` lists `queriesPerInvocation: 1_000` FIRST, and its header names the exact
7
+ * failure โ€” *"An N+1 loop over `family`'s 489 people exceeds it. Join, or `batch()`; do not
8
+ * loop."* Measured here 2026-09-18, that limit was the one thing in the table nothing
9
+ * enforced: `assertBatchSize` checks the length of a SINGLE `batch()` call, so a loop
10
+ * issuing 1,001 separate `await db.prepare(โ€ฆ).run()` calls passed every check in the seam.
11
+ * Each statement is under 100 parameters, each batch is under 1,000 members, and the
12
+ * invocation still dies in production.
13
+ *
14
+ * That is the seam's own defining defect wearing a third costume. Sync-locally and
15
+ * lenient-locally are both "green on this Mac, red in the Worker"; so is uncounted-locally,
16
+ * because `bun:sqlite` has no such cap and never will. The N+1 loop is also the single most
17
+ * likely shape to appear during a port โ€” it is what the un-ported synchronous code already
18
+ * looks like, and awaiting it in a `for` loop is the laziest mechanical translation.
19
+ *
20
+ * ## The counter belongs to a REQUEST, not to a handle
21
+ *
22
+ * D1's cap is per Worker invocation, and a `D1LikeDatabase` is not an invocation: on a
23
+ * Worker it happens to be built per request, but locally `createLocalD1` is built once per
24
+ * PROCESS and lives for days. A counter on the handle would therefore be correct remotely
25
+ * and a false positive locally after the 1,000th query of the morning โ€” the mirror image of
26
+ * the bug this seam exists to prevent, and just as fatal to the rule's credibility.
27
+ *
28
+ * So the scope is explicit and cheap, and the caller opens one per request:
29
+ *
30
+ * ```ts
31
+ * export default {
32
+ * async fetch(req: Request, env: Env) {
33
+ * const db = perInvocation(createRemoteD1(env.DB));
34
+ * return handle(req, db); // 1,001st query throws, naming the loop
35
+ * },
36
+ * };
37
+ * ```
38
+ *
39
+ * It wraps either driver, because it sits ABOVE the seam and only counts. That is what
40
+ * makes the local gate able to prove a production-only limit: wrap a local database in a
41
+ * test, run the loop, and the laptop fails exactly where the Worker would.
42
+ */
43
+ import { LIMITS } from './limits';
44
+ import { D1LimitError } from './types';
45
+ /**
46
+ * The statements a wrapper handed out, mapped back to the driver's own.
47
+ *
48
+ * ๐Ÿ”ด Both drivers identify their own statements structurally โ€” `local.ts` probes for
49
+ * `allSync`, `remote.ts` for `binding` โ€” and refuse anything else with "a local statement
50
+ * cannot run inside a D1 batch". A counted wrapper has neither property, so passing the
51
+ * wrappers straight through would break `batch()` on BOTH sides. `batch()` therefore
52
+ * unwraps here before delegating. A statement this does not know is passed through
53
+ * untouched, so the driver's own error is what a genuine cross-driver mix-up still gets.
54
+ */
55
+ const INNER = new WeakMap();
56
+ class Budget {
57
+ max;
58
+ used = 0;
59
+ constructor(max) {
60
+ this.max = max;
61
+ }
62
+ /** Charge before the work goes out, so the query over the cap is never issued. */
63
+ charge(n, what) {
64
+ if (this.used + n > this.max) {
65
+ throw new D1LimitError('queries per Worker invocation', this.used + n, this.max, `${what} would be query ${this.used + n} of this invocation. This is almost always an N+1 loop โ€” ` +
66
+ 'replace the loop with a JOIN, or collect the statements and send them as one `batch()`. ' +
67
+ 'Work that genuinely needs more than a thousand queries belongs in a Queue consumer or a Cron Trigger, ' +
68
+ 'which get a fresh budget per invocation.');
69
+ }
70
+ this.used += n;
71
+ }
72
+ /**
73
+ * Record a cost that was only knowable after the fact โ€” see `exec()` below. Deliberately
74
+ * unchecked: the statements have already run, so throwing here would report a limit
75
+ * breach by hiding the result that proves it. Going over simply means the next
76
+ * {@link charge} throws, which is the first moment a refusal can still prevent anything.
77
+ */
78
+ settle(n) {
79
+ this.used += n;
80
+ }
81
+ }
82
+ /** Wrap one statement so every terminal call is charged to `budget`. */
83
+ function countedStatement(inner, budget, sql) {
84
+ const label = `\`${sql.trim().slice(0, 60)}\``;
85
+ class CountedStatement {
86
+ bind(...values) {
87
+ // Binding is free โ€” it reaches nothing. The NEW statement is wrapped too, or a
88
+ // `prepare().bind().run()` would escape the count entirely.
89
+ return countedStatement(inner.bind(...values), budget, sql);
90
+ }
91
+ async first(column) {
92
+ budget.charge(1, label);
93
+ return column === undefined ? inner.first() : inner.first(column);
94
+ }
95
+ async all() {
96
+ budget.charge(1, label);
97
+ return inner.all();
98
+ }
99
+ async run() {
100
+ budget.charge(1, label);
101
+ return inner.run();
102
+ }
103
+ async raw() {
104
+ budget.charge(1, label);
105
+ return inner.raw();
106
+ }
107
+ }
108
+ const wrapped = new CountedStatement();
109
+ INNER.set(wrapped, inner);
110
+ return wrapped;
111
+ }
112
+ /**
113
+ * Give `db` a query budget for ONE Worker invocation.
114
+ *
115
+ * Construct a fresh one per request โ€” the count is the request's, not the handle's, and
116
+ * re-using one across requests would refuse the 1,001st query of the day rather than of the
117
+ * invocation. See the header for why that distinction is the whole design.
118
+ *
119
+ * `max` exists for tests and for a caller that wants to be refused sooner than D1 would
120
+ * (a route with a budget of 50 finds its own N+1 long before the hard cap does). It may not
121
+ * be raised above D1's real limit, because a budget larger than the service's is a number
122
+ * that reports success right up until production disagrees.
123
+ */
124
+ export function perInvocation(db, opts = {}) {
125
+ const max = opts.max ?? LIMITS.queriesPerInvocation;
126
+ if (!Number.isInteger(max) || max < 1) {
127
+ throw new RangeError(`max must be a positive integer, got ${max}`);
128
+ }
129
+ if (max > LIMITS.queriesPerInvocation) {
130
+ throw new D1LimitError('queries per Worker invocation', max, LIMITS.queriesPerInvocation, 'A budget above D1\'s own cap cannot be honoured โ€” the service refuses first. Lower it, or split the work across invocations.');
131
+ }
132
+ const budget = new Budget(max);
133
+ return {
134
+ flavor: db.flavor,
135
+ get used() {
136
+ return budget.used;
137
+ },
138
+ get remaining() {
139
+ return Math.max(0, budget.max - budget.used);
140
+ },
141
+ prepare(sql) {
142
+ return countedStatement(db.prepare(sql), budget, sql);
143
+ },
144
+ async batch(statements) {
145
+ // One round trip, but D1 bills each member โ€” so does this.
146
+ budget.charge(statements.length, `a batch() of ${statements.length}`);
147
+ return db.batch(statements.map((s) => INNER.get(s) ?? s));
148
+ },
149
+ /**
150
+ * ๐Ÿ”ด The one cost that cannot be charged up front. `exec()` runs however many
151
+ * statements the SQL contains, and remotely that number comes back FROM the service โ€”
152
+ * there is nothing trustworthy to count beforehand. So this reserves one query, runs,
153
+ * and settles the true cost afterwards; an `exec` that blows the budget is reported on
154
+ * the next query rather than prevented. That is an acceptable trade only because
155
+ * `exec` is the DDL/migration path โ€” it carries no user input by contract, and it is
156
+ * not the shape an N+1 loop takes.
157
+ */
158
+ async exec(sql) {
159
+ budget.charge(1, 'an exec()');
160
+ const res = await db.exec(sql);
161
+ if (res.count > 1)
162
+ budget.settle(res.count - 1);
163
+ return res;
164
+ },
165
+ };
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.2.0",
3
+ "version": "4.4.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.",
@@ -78,6 +78,12 @@
78
78
  "source": "./src/server/d1/index.ts",
79
79
  "import": "./dist/server/d1/index.js"
80
80
  },
81
+ "./d1/kysely": {
82
+ "types": "./dist/server/d1/kysely.d.ts",
83
+ "bun": "./src/server/d1/kysely.ts",
84
+ "source": "./src/server/d1/kysely.ts",
85
+ "import": "./dist/server/d1/kysely.js"
86
+ },
81
87
  "./d1/testing": {
82
88
  "types": "./dist/server/d1/fakeD1.d.ts",
83
89
  "bun": "./src/server/d1/fakeD1.ts",
@@ -258,7 +264,7 @@
258
264
  "@node-rs/argon2": "^2.0.2",
259
265
  "@types/bun": "^1.3.14",
260
266
  "@types/node": "^24",
261
- "cursedbelt": "^3.0.0",
267
+ "cursedbelt": "^4.1.1",
262
268
  "hono": "4.12.28",
263
269
  "kysely": "^0.28.17",
264
270
  "kysely-bun-sqlite": "^0.4.0",
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import pkg from '../package.json';
5
+
6
+ /**
7
+ * A public subpath must not STATICALLY drag an OPTIONAL peer, because a static import is
8
+ * paid at import time by every consumer โ€” including the ones that will never call it.
9
+ *
10
+ * ## ๐Ÿ”ด This is the same defect for the THIRD time, and the first two are why the rule is
11
+ * a check rather than a sentence
12
+ *
13
+ * `cursedbelt/src/barrelsReachNoOptionalPeer.spec.ts` carries the full history; the short
14
+ * version is that `apps/collections` once imported a TYPE from `cursedbelt/server/storage`
15
+ * and paid for it with `error: Cannot find package 'otplib'` and four dead route suites,
16
+ * because the barrel reached `streamToken.ts` โ†’ the `../auth` index โ†’ `totp.ts` โ†’ an
17
+ * optional peer. The app installed a one-time-password library to satisfy a claim shape.
18
+ *
19
+ * When the server tier split into this package (task 148), the RUNTIME-FIXTURE half came
20
+ * with it as `leafSubpathsImportNothing.spec.ts` โ€” but that spec proves what a declared
21
+ * ZERO-RUNTIME LEAF pulls in, and a barrel is neither. So the barrel half stayed behind in
22
+ * `cursedbelt`, pointed at `./react`, and **this package shipped with no barrel check at
23
+ * all.**
24
+ *
25
+ * ๐Ÿ”ด It cost exactly what the first two cost, measured 2026-09-18 against the PUBLISHED
26
+ * `cursedbelt-server@4.3.0` tarball in a clean directory:
27
+ *
28
+ * $ bun add cursedbelt-server && bun -e 'import "cursedbelt-server/d1"'
29
+ * error: Cannot find package 'kysely' from โ€ฆ/src/server/d1/kysely.ts
30
+ *
31
+ * `./d1`'s index re-exported `./kysely`, which statically imports real VALUES from
32
+ * `kysely` (`Kysely`, `SqliteAdapter`, `SqliteQueryCompiler` โ€” not types, so they cannot be
33
+ * erased). `kysely` is an optional peer. So the D1 seam โ€” the thing the whole fleet is
34
+ * meant to port ONTO โ€” could not be imported by any app that had not already installed a
35
+ * query builder, which by `../db/kysely.ts`'s own header is most of them: *"Business /
36
+ * `data JSON` tables stay on raw `db.query()` โ€” do NOT add them here"*. The seam was
37
+ * unusable by precisely the apps it was built for.
38
+ *
39
+ * The fix was to give `createD1Kysely` its own subpath (`./d1/kysely`) and take it out of
40
+ * the barrel. This spec is what stops the fourth occurrence.
41
+ *
42
+ * ## What it measures
43
+ *
44
+ * Every subpath in the `exports` map is bundled with bare imports left external, and the
45
+ * remaining static specifiers are read out of the emitted JS. A DYNAMIC `await
46
+ * import('sharp')` inside the function that needs it matches neither branch of
47
+ * {@link STATIC_SPECIFIER} and must not โ€” that is the lazy shape this file exists to
48
+ * permit, not forbid.
49
+ *
50
+ * ## Verified failing before it was trusted (2026-09-18)
51
+ *
52
+ * ยท re-adding `export โ€ฆ from './kysely'` to `src/server/d1/index.ts`
53
+ * โ†’ red: "./d1 statically drags optional peer(s): kysely"
54
+ * ยท adding `import 'plainjob';` to `src/server/d1/local.ts` โ€” two hops out of the
55
+ * barrel, which grepping the index file cannot see
56
+ * โ†’ red identically. That is the branch that matters.
57
+ * ยท removing `'./jobs'` from {@link MAY_DRAG} โ†’ red, proving the allowlist is load-bearing
58
+ * rather than decorative.
59
+ */
60
+
61
+ const REPO = fileURLToPath(new URL('..', import.meta.url));
62
+
63
+ /**
64
+ * The subpaths whose whole PURPOSE is the optional peer they pull, mapped to what they are
65
+ * allowed to pull and why. An entry here is a promise that the peer is the point of the
66
+ * subpath โ€” not a place to park a new drag.
67
+ *
68
+ * ๐Ÿ”ด Measured 2026-09-18: these three are the ONLY subpaths in the map that reach an
69
+ * optional peer. Everything else is clean, which is what makes this list an allowlist
70
+ * rather than a baseline โ€” it can only shrink.
71
+ */
72
+ const MAY_DRAG: Record<string, readonly string[]> = {
73
+ // The whole-package barrel. It exists to re-export everything, so it necessarily reaches
74
+ // what the specific subpaths reach. An app importing `cursedbelt-server` whole is asking
75
+ // for that; an app importing `cursedbelt-server/d1` is not, and that is the distinction
76
+ // this spec protects.
77
+ '.': ['kysely', 'kysely-bun-sqlite', 'otplib', 'plainjob'],
78
+ // `createD1Kysely` IS the kysely adapter. Split out of `./d1` on 2026-09-18 precisely so
79
+ // the seam itself stops paying for it โ€” see the header.
80
+ './d1/kysely': ['kysely'],
81
+ // The job queue is plainjob. Nothing else here is.
82
+ './jobs': ['plainjob'],
83
+ };
84
+
85
+ const OPTIONAL_PEERS = new Set(
86
+ Object.entries(
87
+ (pkg as { peerDependenciesMeta?: Record<string, { optional?: boolean }> }).peerDependenciesMeta ?? {},
88
+ )
89
+ .filter(([, meta]) => meta?.optional === true)
90
+ .map(([name]) => name),
91
+ );
92
+
93
+ /**
94
+ * `import x from "pkg"` ยท `export โ€ฆ from "pkg"` ยท the side-effect-only `import "pkg";`.
95
+ *
96
+ * Anchored on the `from` clause rather than on a line starting with `import`, so a
97
+ * multi-line named import still counts โ€” bun emits `import {\n โ€ฆ \n} from "pkg";` and a
98
+ * line-anchored pattern silently misses it. A dynamic `import("pkg")` matches neither
99
+ * branch, which is deliberate.
100
+ */
101
+ const STATIC_SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+)["'];?\s*$/gm;
102
+
103
+ /** `@scope/name/deep` โ†’ `@scope/name`, `pkg/deep` โ†’ `pkg`. */
104
+ const packageOf = (specifier: string): string | undefined => {
105
+ const parts = specifier.split('/');
106
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
107
+ };
108
+
109
+ /**
110
+ * Bundle one entry and return the bare packages it still imports.
111
+ *
112
+ * ๐Ÿ”ด A bundle that did not happen must never read as a subpath that pulls nothing, so a
113
+ * non-zero exit or an empty bundle THROWS rather than returning an empty set. That is the
114
+ * one way a check like this dies quietly.
115
+ */
116
+ const staticExternalsOf = (entry: string, subpath: string): Set<string> => {
117
+ const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-server-barrel-scan/${subpath.replace(/[^a-z0-9]+/gi, '-')}`;
118
+ const build = Bun.spawnSync(['bun', 'build', entry, '--target=bun', '--packages=external', '--outdir', outdir], {
119
+ cwd: REPO,
120
+ stdout: 'pipe',
121
+ stderr: 'pipe',
122
+ });
123
+ const emitted = [...new Bun.Glob('**/*.js').scanSync({ cwd: outdir, onlyFiles: true })];
124
+ const bundle = emitted.map((f) => readFileSync(`${outdir}/${f}`, 'utf8')).join('\n');
125
+ if (build.exitCode !== 0 || bundle.trim() === '') {
126
+ throw new Error(
127
+ `could not bundle ${subpath} (${entry}, exit ${build.exitCode}) โ€” a check that cannot measure is not a passing check:\n${build.stderr.toString()}`,
128
+ );
129
+ }
130
+ const found = new Set<string>();
131
+ for (const match of bundle.matchAll(STATIC_SPECIFIER)) {
132
+ const specifier = match[1] ?? match[2];
133
+ if (specifier === undefined || specifier.startsWith('.') || specifier.startsWith('bun:')) continue;
134
+ const name = packageOf(specifier);
135
+ if (name !== undefined) found.add(name);
136
+ }
137
+ return found;
138
+ };
139
+
140
+ /** Every subpath with a `source` entry โ€” the ones whose real graph can be measured. */
141
+ const SUBPATHS = Object.entries(pkg.exports as Record<string, { source?: string }>)
142
+ .filter(([, e]) => typeof e?.source === 'string' && /\.tsx?$/.test(e.source))
143
+ .map(([subpath, e]) => ({ subpath, entry: (e as { source: string }).source }));
144
+
145
+ describe('no public subpath statically drags an optional peer', () => {
146
+ it('measures a non-trivial number of subpaths, so a broken exports map cannot pass vacuously', () => {
147
+ expect(SUBPATHS.length).toBeGreaterThan(20);
148
+ expect(OPTIONAL_PEERS.size).toBeGreaterThan(0);
149
+ });
150
+
151
+ for (const { subpath, entry } of SUBPATHS) {
152
+ const allowed = MAY_DRAG[subpath] ?? [];
153
+ it(`${subpath} drags ${allowed.length === 0 ? 'no optional peer' : allowed.join(' + ') + ' and nothing more'}`, () => {
154
+ const dragged = [...staticExternalsOf(entry, subpath)].filter((n) => OPTIONAL_PEERS.has(n)).sort();
155
+ const unexpected = dragged.filter((n) => !allowed.includes(n));
156
+ expect(
157
+ unexpected,
158
+ `${subpath} statically drags optional peer(s): ${unexpected.join(', ')}\n` +
159
+ ` Every app importing '${pkg.name}${subpath.slice(1)}' must now install them or crash on import.\n` +
160
+ ` Give the adapter its own subpath and take it out of this barrel โ€” see ./d1/kysely, which is\n` +
161
+ ` exactly this fix applied on 2026-09-18 after the published 4.3.0 could not be imported at all.`,
162
+ ).toEqual([]);
163
+
164
+ // The allowlist may only shrink: an entry that no longer drags what it promised is
165
+ // a stale exception, and a stale exception is how a list like this stops meaning
166
+ // anything.
167
+ const stale = allowed.filter((n) => !dragged.includes(n));
168
+ expect(stale, `${subpath} is allowed to drag ${stale.join(', ')} but no longer does โ€” remove the exception`).toEqual(
169
+ [],
170
+ );
171
+ });
172
+ }
173
+ });
@@ -3,6 +3,7 @@ import {
3
3
  DAYS_PER_MONTH,
4
4
  DEFAULT_ROUTE_CPU_BUDGET_MS,
5
5
  deriveCpuBudgetMs,
6
+ MEASURED_FLEET_REQUESTS_PER_DAY,
6
7
  monthlyOverageUsd,
7
8
  OWNER_PROJECTED_REQUESTS_PER_DAY,
8
9
  resolveBudget,
@@ -35,6 +36,32 @@ describe('the derivation', () => {
35
36
  it('refuses a zero/negative request volume rather than returning Infinity', () => {
36
37
  expect(() => deriveCpuBudgetMs({ requestsPerMonth: 0 })).toThrow(/must be > 0/);
37
38
  });
39
+
40
+ it('re-derives ~22.9 CPU-ms from the MEASURED fleet volume, not the projection', () => {
41
+ // The outcome asked for the 9.9 to be re-derived from real traffic. It was, on
42
+ // 2026-09-16: six apps' `request_metrics` tables sum to 42,971/day, which is 43 %
43
+ // of the owner's 100,000/day guess โ€” so the allowance is 2.3ร— wider than shipped.
44
+ expect(MEASURED_FLEET_REQUESTS_PER_DAY / OWNER_PROJECTED_REQUESTS_PER_DAY).toBeCloseTo(
45
+ 0.43,
46
+ 2,
47
+ );
48
+ const measured = deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY });
49
+ expect(measured).toBeCloseTo(22.9, 1);
50
+ // And the measured volume is well inside the included REQUEST allowance, which is
51
+ // the whole reason CPU โ€” not requests โ€” is the budget worth gating on.
52
+ expect((MEASURED_FLEET_REQUESTS_PER_DAY * DAYS_PER_MONTH) / 10_000_000).toBeLessThan(0.2);
53
+ });
54
+
55
+ it('๐Ÿ”ด keeps the shipped default STRICTER than the measured allowance', () => {
56
+ // This is the guard, not the paragraph. The measured corpus is the RETIRED
57
+ // generation's โ€” every table stops within hours of its app's graduation and no app
58
+ // is on a Worker yet โ€” so raising the default to 22.9 would loosen every budget in
59
+ // the fleet on evidence that cannot carry it. Being 2.3ร— conservative costs nothing;
60
+ // being 2.3ร— loose costs the thing this module exists to prevent. If someone later
61
+ // drifts the default above what the traffic can justify, this goes red.
62
+ const measured = deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY });
63
+ expect(DEFAULT_ROUTE_CPU_BUDGET_MS).toBeLessThanOrEqual(measured);
64
+ });
38
65
  });
39
66
 
40
67
  describe('what going over actually costs', () => {
@@ -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
- * ๐Ÿ”ด Re-derive with {@link deriveCpuBudgetMs} once an app has real traffic.
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
 
@@ -49,6 +49,7 @@ export {
49
49
  DEFAULT_ROUTE_CPU_BUDGET_MS,
50
50
  deriveCpuBudgetMs,
51
51
  isExemption,
52
+ MEASURED_FLEET_REQUESTS_PER_DAY,
52
53
  monthlyOverageUsd,
53
54
  OWNER_PROJECTED_REQUESTS_PER_DAY,
54
55
  type ResolvedBudget,
@@ -25,7 +25,14 @@ export {
25
25
  type LocalBackupOpts,
26
26
  type TimeTravelOpts,
27
27
  } from './backup';
28
- export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
28
+ // ๐Ÿ”ด `./kysely` is deliberately NOT re-exported here โ€” import it from
29
+ // `cursedbelt-server/d1/kysely`. It statically imports `kysely` (real values: `Kysely`,
30
+ // `SqliteAdapter`, `SqliteQueryCompiler`), which is an OPTIONAL peer, so re-exporting it
31
+ // made `import 'cursedbelt-server/d1'` throw `Cannot find package 'kysely'` for every app
32
+ // that does not use the query builder โ€” which per `../db/kysely.ts`'s own header is most
33
+ // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
34
+ // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
35
+ export { type InvocationD1, perInvocation } from './invocation';
29
36
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
30
37
  export { createLocalD1, refuseInteractiveTransaction } from './local';
31
38
  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
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The per-invocation query budget โ€” D1's 1,000-queries-per-Worker-invocation cap, counted.
3
+ *
4
+ * ## ๐Ÿ”ด Why this file exists: the limit the rest of the seam only documented
5
+ *
6
+ * `./limits` lists `queriesPerInvocation: 1_000` FIRST, and its header names the exact
7
+ * failure โ€” *"An N+1 loop over `family`'s 489 people exceeds it. Join, or `batch()`; do not
8
+ * loop."* Measured here 2026-09-18, that limit was the one thing in the table nothing
9
+ * enforced: `assertBatchSize` checks the length of a SINGLE `batch()` call, so a loop
10
+ * issuing 1,001 separate `await db.prepare(โ€ฆ).run()` calls passed every check in the seam.
11
+ * Each statement is under 100 parameters, each batch is under 1,000 members, and the
12
+ * invocation still dies in production.
13
+ *
14
+ * That is the seam's own defining defect wearing a third costume. Sync-locally and
15
+ * lenient-locally are both "green on this Mac, red in the Worker"; so is uncounted-locally,
16
+ * because `bun:sqlite` has no such cap and never will. The N+1 loop is also the single most
17
+ * likely shape to appear during a port โ€” it is what the un-ported synchronous code already
18
+ * looks like, and awaiting it in a `for` loop is the laziest mechanical translation.
19
+ *
20
+ * ## The counter belongs to a REQUEST, not to a handle
21
+ *
22
+ * D1's cap is per Worker invocation, and a `D1LikeDatabase` is not an invocation: on a
23
+ * Worker it happens to be built per request, but locally `createLocalD1` is built once per
24
+ * PROCESS and lives for days. A counter on the handle would therefore be correct remotely
25
+ * and a false positive locally after the 1,000th query of the morning โ€” the mirror image of
26
+ * the bug this seam exists to prevent, and just as fatal to the rule's credibility.
27
+ *
28
+ * So the scope is explicit and cheap, and the caller opens one per request:
29
+ *
30
+ * ```ts
31
+ * export default {
32
+ * async fetch(req: Request, env: Env) {
33
+ * const db = perInvocation(createRemoteD1(env.DB));
34
+ * return handle(req, db); // 1,001st query throws, naming the loop
35
+ * },
36
+ * };
37
+ * ```
38
+ *
39
+ * It wraps either driver, because it sits ABOVE the seam and only counts. That is what
40
+ * makes the local gate able to prove a production-only limit: wrap a local database in a
41
+ * test, run the loop, and the laptop fails exactly where the Worker would.
42
+ */
43
+
44
+ import { LIMITS } from './limits';
45
+ import { D1LimitError, type D1LikeBindable, type D1LikeDatabase, type D1LikeResult, type D1LikeRow, type D1LikeStatement, type D1LikeValue } from './types';
46
+
47
+ /**
48
+ * A database handle that knows how much of its invocation budget it has spent.
49
+ *
50
+ * `used` counts QUERIES the way D1 bills them: one per terminal statement call
51
+ * (`first`/`all`/`run`/`raw`), one per member of a `batch()`, and one per statement an
52
+ * `exec()` ran. Building a statement with `prepare()` or `bind()` costs nothing, because it
53
+ * reaches the database only when it is executed.
54
+ */
55
+ export interface InvocationD1 extends D1LikeDatabase {
56
+ /** Queries charged to this invocation so far. */
57
+ readonly used: number;
58
+ /** Queries left before the cap, floored at 0. */
59
+ readonly remaining: number;
60
+ }
61
+
62
+ /**
63
+ * The statements a wrapper handed out, mapped back to the driver's own.
64
+ *
65
+ * ๐Ÿ”ด Both drivers identify their own statements structurally โ€” `local.ts` probes for
66
+ * `allSync`, `remote.ts` for `binding` โ€” and refuse anything else with "a local statement
67
+ * cannot run inside a D1 batch". A counted wrapper has neither property, so passing the
68
+ * wrappers straight through would break `batch()` on BOTH sides. `batch()` therefore
69
+ * unwraps here before delegating. A statement this does not know is passed through
70
+ * untouched, so the driver's own error is what a genuine cross-driver mix-up still gets.
71
+ */
72
+ const INNER = new WeakMap<D1LikeStatement, D1LikeStatement>();
73
+
74
+ class Budget {
75
+ used = 0;
76
+
77
+ constructor(readonly max: number) {}
78
+
79
+ /** Charge before the work goes out, so the query over the cap is never issued. */
80
+ charge(n: number, what: string): void {
81
+ if (this.used + n > this.max) {
82
+ throw new D1LimitError(
83
+ 'queries per Worker invocation',
84
+ this.used + n,
85
+ this.max,
86
+ `${what} would be query ${this.used + n} of this invocation. This is almost always an N+1 loop โ€” ` +
87
+ 'replace the loop with a JOIN, or collect the statements and send them as one `batch()`. ' +
88
+ 'Work that genuinely needs more than a thousand queries belongs in a Queue consumer or a Cron Trigger, ' +
89
+ 'which get a fresh budget per invocation.',
90
+ );
91
+ }
92
+ this.used += n;
93
+ }
94
+
95
+ /**
96
+ * Record a cost that was only knowable after the fact โ€” see `exec()` below. Deliberately
97
+ * unchecked: the statements have already run, so throwing here would report a limit
98
+ * breach by hiding the result that proves it. Going over simply means the next
99
+ * {@link charge} throws, which is the first moment a refusal can still prevent anything.
100
+ */
101
+ settle(n: number): void {
102
+ this.used += n;
103
+ }
104
+ }
105
+
106
+ /** Wrap one statement so every terminal call is charged to `budget`. */
107
+ function countedStatement(inner: D1LikeStatement, budget: Budget, sql: string): D1LikeStatement {
108
+ const label = `\`${sql.trim().slice(0, 60)}\``;
109
+
110
+ class CountedStatement implements D1LikeStatement {
111
+ bind(...values: D1LikeBindable[]): D1LikeStatement {
112
+ // Binding is free โ€” it reaches nothing. The NEW statement is wrapped too, or a
113
+ // `prepare().bind().run()` would escape the count entirely.
114
+ return countedStatement(inner.bind(...values), budget, sql);
115
+ }
116
+
117
+ async first<T = D1LikeRow>(column?: string): Promise<T | null> {
118
+ budget.charge(1, label);
119
+ return column === undefined ? inner.first<T>() : (inner.first<T>(column) as Promise<T | null>);
120
+ }
121
+
122
+ async all<T = D1LikeRow>(): Promise<D1LikeResult<T>> {
123
+ budget.charge(1, label);
124
+ return inner.all<T>();
125
+ }
126
+
127
+ async run(): Promise<D1LikeResult<never>> {
128
+ budget.charge(1, label);
129
+ return inner.run();
130
+ }
131
+
132
+ async raw<V = D1LikeValue>(): Promise<V[][]> {
133
+ budget.charge(1, label);
134
+ return inner.raw<V>();
135
+ }
136
+ }
137
+
138
+ const wrapped = new CountedStatement();
139
+ INNER.set(wrapped, inner);
140
+ return wrapped;
141
+ }
142
+
143
+ /**
144
+ * Give `db` a query budget for ONE Worker invocation.
145
+ *
146
+ * Construct a fresh one per request โ€” the count is the request's, not the handle's, and
147
+ * re-using one across requests would refuse the 1,001st query of the day rather than of the
148
+ * invocation. See the header for why that distinction is the whole design.
149
+ *
150
+ * `max` exists for tests and for a caller that wants to be refused sooner than D1 would
151
+ * (a route with a budget of 50 finds its own N+1 long before the hard cap does). It may not
152
+ * be raised above D1's real limit, because a budget larger than the service's is a number
153
+ * that reports success right up until production disagrees.
154
+ */
155
+ export function perInvocation(db: D1LikeDatabase, opts: { max?: number } = {}): InvocationD1 {
156
+ const max = opts.max ?? LIMITS.queriesPerInvocation;
157
+ if (!Number.isInteger(max) || max < 1) {
158
+ throw new RangeError(`max must be a positive integer, got ${max}`);
159
+ }
160
+ if (max > LIMITS.queriesPerInvocation) {
161
+ throw new D1LimitError(
162
+ 'queries per Worker invocation',
163
+ max,
164
+ LIMITS.queriesPerInvocation,
165
+ 'A budget above D1\'s own cap cannot be honoured โ€” the service refuses first. Lower it, or split the work across invocations.',
166
+ );
167
+ }
168
+ const budget = new Budget(max);
169
+
170
+ return {
171
+ flavor: db.flavor,
172
+
173
+ get used() {
174
+ return budget.used;
175
+ },
176
+
177
+ get remaining() {
178
+ return Math.max(0, budget.max - budget.used);
179
+ },
180
+
181
+ prepare(sql: string): D1LikeStatement {
182
+ return countedStatement(db.prepare(sql), budget, sql);
183
+ },
184
+
185
+ async batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]> {
186
+ // One round trip, but D1 bills each member โ€” so does this.
187
+ budget.charge(statements.length, `a batch() of ${statements.length}`);
188
+ return db.batch<T>(statements.map((s) => INNER.get(s) ?? s));
189
+ },
190
+
191
+ /**
192
+ * ๐Ÿ”ด The one cost that cannot be charged up front. `exec()` runs however many
193
+ * statements the SQL contains, and remotely that number comes back FROM the service โ€”
194
+ * there is nothing trustworthy to count beforehand. So this reserves one query, runs,
195
+ * and settles the true cost afterwards; an `exec` that blows the budget is reported on
196
+ * the next query rather than prevented. That is an acceptable trade only because
197
+ * `exec` is the DDL/migration path โ€” it carries no user input by contract, and it is
198
+ * not the shape an N+1 loop takes.
199
+ */
200
+ async exec(sql: string): Promise<{ count: number; duration: number }> {
201
+ budget.charge(1, 'an exec()');
202
+ const res = await db.exec(sql);
203
+ if (res.count > 1) budget.settle(res.count - 1);
204
+ return res;
205
+ },
206
+ };
207
+ }