cursedbelt-server 4.2.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.
@@ -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';
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export { backupFor, type BackupPoint, checkpointWal, createLocalBackup, createTimeTravelBackup, type DatabaseBackup, type LocalBackupOpts, type TimeTravelOpts, } from './backup';
18
18
  export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
19
+ export { type InvocationD1, perInvocation } from './invocation';
19
20
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
20
21
  export { createLocalD1, refuseInteractiveTransaction } from './local';
21
22
  export { createRemoteD1, type D1BindingLike, type D1BindingMeta, type D1BindingResult, type D1BindingStatement, } from './remote';
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup, } from './backup';
18
18
  export { createD1Kysely, D1LikeDialect } from './kysely';
19
+ export { perInvocation } from './invocation';
19
20
  export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
20
21
  export { createLocalD1, refuseInteractiveTransaction } from './local';
21
22
  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.3.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.",
@@ -258,7 +258,7 @@
258
258
  "@node-rs/argon2": "^2.0.2",
259
259
  "@types/bun": "^1.3.14",
260
260
  "@types/node": "^24",
261
- "cursedbelt": "^3.0.0",
261
+ "cursedbelt": "^4.1.1",
262
262
  "hono": "4.12.28",
263
263
  "kysely": "^0.28.17",
264
264
  "kysely-bun-sqlite": "^0.4.0",
@@ -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,
@@ -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
+ });
@@ -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
+ }