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.
- package/dist/server/bench/budget.d.ts +35 -1
- package/dist/server/bench/budget.js +35 -1
- package/dist/server/bench/index.d.ts +1 -1
- package/dist/server/bench/index.js +1 -1
- package/dist/server/d1/index.d.ts +1 -1
- package/dist/server/d1/index.js +8 -1
- package/dist/server/d1/invocation.d.ts +72 -0
- package/dist/server/d1/invocation.js +166 -0
- package/package.json +8 -2
- package/src/barrelsReachNoOptionalPeer.spec.ts +173 -0
- package/src/server/bench/budget.spec.ts +27 -0
- package/src/server/bench/budget.ts +36 -1
- package/src/server/bench/index.ts +1 -0
- package/src/server/d1/index.ts +8 -1
- package/src/server/d1/invocation.spec.ts +174 -0
- package/src/server/d1/invocation.ts +207 -0
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
* which is the owner's estimate and not a measurement. {@link deriveCpuBudgetMs} is the
|
|
27
27
|
* function to re-run against real traffic โ the re-derivation is a call, not a paragraph
|
|
28
28
|
* somebody has to remember to do.
|
|
29
|
+
*
|
|
30
|
+
* ๐ด **It has already been run once** โ see {@link MEASURED_FLEET_REQUESTS_PER_DAY}, which
|
|
31
|
+
* carries both the number and the reason the default was left stricter than it. Do not
|
|
32
|
+
* spend that measurement again; what is still missing is Worker traffic, not Mac traffic.
|
|
29
33
|
*/
|
|
30
34
|
/** 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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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';
|
package/dist/server/d1/index.js
CHANGED
|
@@ -15,7 +15,14 @@
|
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
17
|
export { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup, } from './backup';
|
|
18
|
-
|
|
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.
|
|
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": "^
|
|
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
|
-
*
|
|
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
|
|
package/src/server/d1/index.ts
CHANGED
|
@@ -25,7 +25,14 @@ export {
|
|
|
25
25
|
type LocalBackupOpts,
|
|
26
26
|
type TimeTravelOpts,
|
|
27
27
|
} from './backup';
|
|
28
|
-
|
|
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
|
+
}
|