cursedbelt-server 2.0.0 โ 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/bench/assert.d.ts +61 -0
- package/dist/server/bench/assert.js +117 -0
- package/dist/server/bench/budget.d.ts +130 -0
- package/dist/server/bench/budget.js +131 -0
- package/dist/server/bench/cpuBudget.d.ts +45 -0
- package/dist/server/bench/cpuBudget.js +34 -0
- package/dist/server/bench/cpuClock.d.ts +65 -0
- package/dist/server/bench/cpuClock.js +100 -0
- package/dist/server/bench/index.d.ts +40 -0
- package/dist/server/bench/index.js +40 -0
- package/dist/server/bench/recorder.d.ts +70 -0
- package/dist/server/bench/recorder.js +95 -0
- package/dist/server/bench/runBench.d.ts +61 -0
- package/dist/server/bench/runBench.js +61 -0
- package/dist/server/d1/backup.d.ts +110 -0
- package/dist/server/d1/backup.js +128 -0
- package/dist/server/d1/fakeD1.d.ts +41 -0
- package/dist/server/d1/fakeD1.js +185 -0
- package/dist/server/d1/index.d.ts +24 -0
- package/dist/server/d1/index.js +24 -0
- package/dist/server/d1/kysely.d.ts +56 -0
- package/dist/server/d1/kysely.js +138 -0
- package/dist/server/d1/limits.d.ts +56 -0
- package/dist/server/d1/limits.js +96 -0
- package/dist/server/d1/local.d.ts +31 -0
- package/dist/server/d1/local.js +135 -0
- package/dist/server/d1/remote.d.ts +59 -0
- package/dist/server/d1/remote.js +124 -0
- package/dist/server/d1/scheduling.d.ts +113 -0
- package/dist/server/d1/scheduling.js +164 -0
- package/dist/server/d1/types.d.ts +143 -0
- package/dist/server/d1/types.js +80 -0
- package/dist/server/d1/values.d.ts +50 -0
- package/dist/server/d1/values.js +124 -0
- package/package.json +21 -3
- package/src/leafSubpathsImportNothing.spec.ts +15 -3
- package/src/server/bench/assert.ts +192 -0
- package/src/server/bench/budget.spec.ts +126 -0
- package/src/server/bench/budget.ts +207 -0
- package/src/server/bench/cpuBudget.spec.ts +302 -0
- package/src/server/bench/cpuBudget.ts +81 -0
- package/src/server/bench/cpuClock.ts +119 -0
- package/src/server/bench/index.ts +81 -0
- package/src/server/bench/recorder.ts +163 -0
- package/src/server/bench/runBench.ts +110 -0
- package/src/server/d1/backup.spec.ts +121 -0
- package/src/server/d1/backup.ts +186 -0
- package/src/server/d1/fakeD1.ts +193 -0
- package/src/server/d1/index.ts +62 -0
- package/src/server/d1/kysely.spec.ts +145 -0
- package/src/server/d1/kysely.ts +169 -0
- package/src/server/d1/limits.spec.ts +90 -0
- package/src/server/d1/limits.ts +123 -0
- package/src/server/d1/local.ts +173 -0
- package/src/server/d1/remote.ts +182 -0
- package/src/server/d1/sameShape.spec.ts +279 -0
- package/src/server/d1/scheduling.spec.ts +120 -0
- package/src/server/d1/scheduling.ts +210 -0
- package/src/server/d1/types.ts +163 -0
- package/src/server/d1/values.ts +138 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1's hard limits, and the helpers that keep a query inside them.
|
|
3
|
+
*
|
|
4
|
+
* ๐ด **These shape the schema, not just the bill.** Three of them change how code must be
|
|
5
|
+
* written, and all three are invisible on `bun:sqlite` โ which has no such caps, so every
|
|
6
|
+
* one of them is a green-here / red-there defect waiting for the first real dataset:
|
|
7
|
+
*
|
|
8
|
+
* ยท **1,000 queries per Worker invocation.** An N+1 loop over `family`'s 489 people
|
|
9
|
+
* exceeds it. Join, or `batch()`; do not loop.
|
|
10
|
+
* ยท **100 bound parameters per query.** Any seed, import or bulk insert hits this. Use
|
|
11
|
+
* {@link chunkForBind}, which does the arithmetic rather than leaving it to a guess.
|
|
12
|
+
* ยท **2 MB per string / BLOB / row.** Nothing may store a media byte in D1.
|
|
13
|
+
*
|
|
14
|
+
* The limits are asserted by the LOCAL driver as well as the remote one, which is the
|
|
15
|
+
* whole point: a bulk insert that would fail in production fails on the laptop, at the
|
|
16
|
+
* call site, with the chunk size it should have used in the message.
|
|
17
|
+
*
|
|
18
|
+
* Values are the Workers **Paid** tier, which is what the owner already pays for.
|
|
19
|
+
*/
|
|
20
|
+
import { D1LimitError } from './types';
|
|
21
|
+
export const LIMITS = {
|
|
22
|
+
/** Bound parameters in a single statement. */
|
|
23
|
+
boundParams: 100,
|
|
24
|
+
/** Queries a single Worker invocation may issue, including every `batch()` member. */
|
|
25
|
+
queriesPerInvocation: 1_000,
|
|
26
|
+
/** Bytes of SQL text in one statement. A generated `IN (โฆ)` list hits this first. */
|
|
27
|
+
sqlLength: 100_000,
|
|
28
|
+
/** Bytes in any single string or BLOB value, and in any single row. */
|
|
29
|
+
valueBytes: 2_000_000,
|
|
30
|
+
/** Columns in one table. The fleet's `data JSON` pattern stays far under it. */
|
|
31
|
+
columnsPerTable: 100,
|
|
32
|
+
/** Wall-clock milliseconds one query may take. */
|
|
33
|
+
queryDurationMs: 30_000,
|
|
34
|
+
};
|
|
35
|
+
const byteLength = (v) => {
|
|
36
|
+
if (typeof v === 'string')
|
|
37
|
+
return Buffer.byteLength(v, 'utf8');
|
|
38
|
+
if (v instanceof Uint8Array)
|
|
39
|
+
return v.byteLength;
|
|
40
|
+
if (v instanceof ArrayBuffer)
|
|
41
|
+
return v.byteLength;
|
|
42
|
+
return 0;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Refuse a statement that D1 would refuse โ checked on BOTH drivers, at `bind()` time, so
|
|
46
|
+
* the failure lands on the call site that built the query rather than in production.
|
|
47
|
+
*/
|
|
48
|
+
export function assertWithinLimits(sql, params) {
|
|
49
|
+
if (params.length > LIMITS.boundParams) {
|
|
50
|
+
throw new D1LimitError('bound parameters per query', params.length, LIMITS.boundParams, 'Chunk the work โ `chunkForBind(items, paramsPerItem)` sizes the batches for you, ' +
|
|
51
|
+
'or use `batch()` for independent statements.');
|
|
52
|
+
}
|
|
53
|
+
const sqlBytes = Buffer.byteLength(sql, 'utf8');
|
|
54
|
+
if (sqlBytes > LIMITS.sqlLength) {
|
|
55
|
+
throw new D1LimitError('SQL statement length (bytes)', sqlBytes, LIMITS.sqlLength, 'A generated `IN (...)` list hits this before the parameter cap โ bind parameters instead of inlining values.');
|
|
56
|
+
}
|
|
57
|
+
for (let i = 0; i < params.length; i++) {
|
|
58
|
+
const bytes = byteLength(params[i]);
|
|
59
|
+
if (bytes > LIMITS.valueBytes) {
|
|
60
|
+
throw new D1LimitError(`value size for parameter ${i + 1} (bytes)`, bytes, LIMITS.valueBytes, 'D1 is not a blob store. Media bytes belong in R2 behind the binary server โ see task 193.');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Refuse a `batch()` bigger than one Worker invocation may issue. */
|
|
65
|
+
export function assertBatchSize(count) {
|
|
66
|
+
if (count > LIMITS.queriesPerInvocation) {
|
|
67
|
+
throw new D1LimitError('queries per Worker invocation', count, LIMITS.queriesPerInvocation, 'Split the work across invocations โ a Queue consumer or a Cron Trigger, not one giant batch.');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Split a list into chunks that fit under the 100-parameter cap, given how many bound
|
|
72
|
+
* parameters each item contributes.
|
|
73
|
+
*
|
|
74
|
+
* A bulk insert of `n` rows with `c` columns binds `n * c` parameters, so the batch size
|
|
75
|
+
* is `floor(100 / c)` โ the arithmetic nobody does correctly by eye at 3 a.m., which is
|
|
76
|
+
* why it is a function and not a sentence in a doc.
|
|
77
|
+
*
|
|
78
|
+
* ```ts
|
|
79
|
+
* for (const rows of chunkForBind(people, 4)) { // 4 columns โ 25 rows a chunk
|
|
80
|
+
* await db.prepare(insertSql(rows.length)).bind(...rows.flatMap(toParams)).run();
|
|
81
|
+
* }
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export function chunkForBind(items, paramsPerItem) {
|
|
85
|
+
if (!Number.isInteger(paramsPerItem) || paramsPerItem < 1) {
|
|
86
|
+
throw new RangeError(`paramsPerItem must be a positive integer, got ${paramsPerItem}`);
|
|
87
|
+
}
|
|
88
|
+
if (paramsPerItem > LIMITS.boundParams) {
|
|
89
|
+
throw new D1LimitError('bound parameters per query', paramsPerItem, LIMITS.boundParams, 'A single item already exceeds the cap โ store fewer columns, or move the wide value out of D1.');
|
|
90
|
+
}
|
|
91
|
+
const size = Math.floor(LIMITS.boundParams / paramsPerItem);
|
|
92
|
+
const out = [];
|
|
93
|
+
for (let i = 0; i < items.length; i += size)
|
|
94
|
+
out.push(items.slice(i, i + size));
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LOCAL implementation of {@link D1LikeDatabase}, over `bun:sqlite`.
|
|
3
|
+
*
|
|
4
|
+
* This is the half that keeps the gate, the dev loop and a Mac-hosted app working
|
|
5
|
+
* unchanged while the fleet ports. It is async because the seam is async โ not because
|
|
6
|
+
* anything here awaits I/O. `bun:sqlite` answers synchronously and these methods resolve
|
|
7
|
+
* an already-computed value, so the cost is a microtask, not a round trip.
|
|
8
|
+
*
|
|
9
|
+
* ๐ด **It is deliberately no more permissive than D1.** Every bind goes through
|
|
10
|
+
* `normalizeBind`, which refuses the two values `bun:sqlite` would have accepted silently
|
|
11
|
+
* (`undefined`, and out-of-range `bigint`) and coerces the one both can agree on
|
|
12
|
+
* (`boolean`). D1's limits are asserted here too, and an interactive transaction is
|
|
13
|
+
* refused here exactly as it is refused remotely. The point of a local driver is to fail
|
|
14
|
+
* the same way, early and on a laptop.
|
|
15
|
+
*/
|
|
16
|
+
import type { Database } from 'bun:sqlite';
|
|
17
|
+
import { type D1LikeDatabase } from './types';
|
|
18
|
+
/**
|
|
19
|
+
* Wrap a `bun:sqlite` {@link Database} in the async seam.
|
|
20
|
+
*
|
|
21
|
+
* The handle is the SAME one a not-yet-ported app still uses directly, so a half-ported
|
|
22
|
+
* app has one connection, one WAL and one transaction space โ the property `createKysely`
|
|
23
|
+
* was written for. That is what makes a file-at-a-time port possible instead of an
|
|
24
|
+
* app-at-a-time one, and it is why this takes a `Database` rather than a path.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createLocalD1(db: Database): D1LikeDatabase;
|
|
27
|
+
/**
|
|
28
|
+
* Refuse an interactive transaction, for the same reason D1 refuses it. Exported so the
|
|
29
|
+
* Kysely dialect and any app-level helper give one message instead of three.
|
|
30
|
+
*/
|
|
31
|
+
export declare const refuseInteractiveTransaction: () => never;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LOCAL implementation of {@link D1LikeDatabase}, over `bun:sqlite`.
|
|
3
|
+
*
|
|
4
|
+
* This is the half that keeps the gate, the dev loop and a Mac-hosted app working
|
|
5
|
+
* unchanged while the fleet ports. It is async because the seam is async โ not because
|
|
6
|
+
* anything here awaits I/O. `bun:sqlite` answers synchronously and these methods resolve
|
|
7
|
+
* an already-computed value, so the cost is a microtask, not a round trip.
|
|
8
|
+
*
|
|
9
|
+
* ๐ด **It is deliberately no more permissive than D1.** Every bind goes through
|
|
10
|
+
* `normalizeBind`, which refuses the two values `bun:sqlite` would have accepted silently
|
|
11
|
+
* (`undefined`, and out-of-range `bigint`) and coerces the one both can agree on
|
|
12
|
+
* (`boolean`). D1's limits are asserted here too, and an interactive transaction is
|
|
13
|
+
* refused here exactly as it is refused remotely. The point of a local driver is to fail
|
|
14
|
+
* the same way, early and on a laptop.
|
|
15
|
+
*/
|
|
16
|
+
import { assertBatchSize, assertWithinLimits } from './limits';
|
|
17
|
+
import { D1UnsupportedError, } from './types';
|
|
18
|
+
import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
|
|
19
|
+
/** Shared clock, so `meta.duration` means the same thing on both sides of the seam. */
|
|
20
|
+
const now = () => performance.now();
|
|
21
|
+
class LocalStatement {
|
|
22
|
+
db;
|
|
23
|
+
sql;
|
|
24
|
+
params;
|
|
25
|
+
constructor(db, sql, params = []) {
|
|
26
|
+
this.db = db;
|
|
27
|
+
this.sql = sql;
|
|
28
|
+
this.params = params;
|
|
29
|
+
}
|
|
30
|
+
bind(...values) {
|
|
31
|
+
const params = normalizeBinds(values);
|
|
32
|
+
assertWithinLimits(this.sql, params);
|
|
33
|
+
return new LocalStatement(this.db, this.sql, params);
|
|
34
|
+
}
|
|
35
|
+
stmt() {
|
|
36
|
+
return this.db.query(this.sql);
|
|
37
|
+
}
|
|
38
|
+
allSync() {
|
|
39
|
+
const started = now();
|
|
40
|
+
const rows = this.stmt().all(...this.params);
|
|
41
|
+
return {
|
|
42
|
+
results: normalizeRows(rows),
|
|
43
|
+
success: true,
|
|
44
|
+
meta: makeMeta({ duration: now() - started }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async first(column) {
|
|
48
|
+
const row = this.stmt().get(...this.params);
|
|
49
|
+
if (row === null || row === undefined)
|
|
50
|
+
return null;
|
|
51
|
+
if (column === undefined)
|
|
52
|
+
return normalizeRows([row])[0] ?? null;
|
|
53
|
+
if (!(column in row)) {
|
|
54
|
+
throw new RangeError(`column '${column}' is not in the result of: ${this.sql.slice(0, 80)}`);
|
|
55
|
+
}
|
|
56
|
+
return normalizeValue(row[column]);
|
|
57
|
+
}
|
|
58
|
+
async all() {
|
|
59
|
+
return this.allSync();
|
|
60
|
+
}
|
|
61
|
+
async run() {
|
|
62
|
+
const started = now();
|
|
63
|
+
const res = this.stmt().run(...this.params);
|
|
64
|
+
return {
|
|
65
|
+
results: [],
|
|
66
|
+
success: true,
|
|
67
|
+
meta: makeMeta({
|
|
68
|
+
changes: res.changes,
|
|
69
|
+
last_row_id: res.lastInsertRowid,
|
|
70
|
+
duration: now() - started,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async raw() {
|
|
75
|
+
const rows = this.stmt().values(...this.params);
|
|
76
|
+
return rows.map((r) => r.map((v) => normalizeValue(v)));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Wrap a `bun:sqlite` {@link Database} in the async seam.
|
|
81
|
+
*
|
|
82
|
+
* The handle is the SAME one a not-yet-ported app still uses directly, so a half-ported
|
|
83
|
+
* app has one connection, one WAL and one transaction space โ the property `createKysely`
|
|
84
|
+
* was written for. That is what makes a file-at-a-time port possible instead of an
|
|
85
|
+
* app-at-a-time one, and it is why this takes a `Database` rather than a path.
|
|
86
|
+
*/
|
|
87
|
+
export function createLocalD1(db) {
|
|
88
|
+
return {
|
|
89
|
+
flavor: 'local',
|
|
90
|
+
prepare(sql) {
|
|
91
|
+
return new LocalStatement(db, sql);
|
|
92
|
+
},
|
|
93
|
+
/**
|
|
94
|
+
* Atomic on both sides. Locally that is a real `bun:sqlite` transaction; on D1 it is
|
|
95
|
+
* one round trip the service commits or discards as a unit. The guarantee a caller
|
|
96
|
+
* can rely on is the same, which is the only reason `batch()` can be the fleet's
|
|
97
|
+
* transaction primitive โ see the note on {@link SyncExecutable} for why the body
|
|
98
|
+
* below may not await.
|
|
99
|
+
*/
|
|
100
|
+
async batch(statements) {
|
|
101
|
+
assertBatchSize(statements.length);
|
|
102
|
+
const run = db.transaction((stmts) => {
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const s of stmts) {
|
|
105
|
+
const sync = s;
|
|
106
|
+
if (typeof sync.allSync !== 'function') {
|
|
107
|
+
throw new TypeError('batch() accepts only statements from this same local database โ ' +
|
|
108
|
+
'a remote statement cannot run inside a local transaction.');
|
|
109
|
+
}
|
|
110
|
+
out.push(sync.allSync());
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
});
|
|
114
|
+
return run(statements);
|
|
115
|
+
},
|
|
116
|
+
async exec(sql) {
|
|
117
|
+
const started = now();
|
|
118
|
+
db.exec(sql);
|
|
119
|
+
// D1's `exec` reports how many statements it ran, and counts them by `;`.
|
|
120
|
+
// Matching that keeps the field meaning one thing on both sides.
|
|
121
|
+
const count = sql
|
|
122
|
+
.split(';')
|
|
123
|
+
.map((s) => s.trim())
|
|
124
|
+
.filter((s) => s.length > 0).length;
|
|
125
|
+
return { count, duration: now() - started };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Refuse an interactive transaction, for the same reason D1 refuses it. Exported so the
|
|
131
|
+
* Kysely dialect and any app-level helper give one message instead of three.
|
|
132
|
+
*/
|
|
133
|
+
export const refuseInteractiveTransaction = () => {
|
|
134
|
+
throw new D1UnsupportedError('an interactive transaction (BEGIN/COMMIT)', 'collect the statements and pass them to `db.batch([...])`, which is atomic on both drivers');
|
|
135
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REMOTE implementation of {@link D1LikeDatabase}, over a Cloudflare D1 binding.
|
|
3
|
+
*
|
|
4
|
+
* It is thin on purpose. D1's own API is the shape `./types` mirrors, so nearly all of
|
|
5
|
+
* this file is the normalization that makes a D1 answer indistinguishable from a
|
|
6
|
+
* `bun:sqlite` one โ the same `normalizeBind` / `normalizeValue` the local driver calls,
|
|
7
|
+
* so "the two drivers agree" is a property of one implementation rather than a
|
|
8
|
+
* coincidence between two.
|
|
9
|
+
*
|
|
10
|
+
* ๐ด **`workerd` is not imported here, and must not be.** This package is built and tested
|
|
11
|
+
* with Bun, and `@cloudflare/workers-types` would be a dependency the other 99 % of the
|
|
12
|
+
* library does not need. The binding is described structurally by {@link D1BindingLike}
|
|
13
|
+
* below, which is the subset of Cloudflare's `D1Database` this driver actually calls โ so
|
|
14
|
+
* a real binding satisfies it without a cast, and the test suite can satisfy it with a
|
|
15
|
+
* fake that has no `workerd` in sight.
|
|
16
|
+
*/
|
|
17
|
+
import { type D1LikeDatabase } from './types';
|
|
18
|
+
/** What D1 reports back about a statement. Its `meta` is richer than the local driver's. */
|
|
19
|
+
export interface D1BindingMeta {
|
|
20
|
+
duration?: number;
|
|
21
|
+
changes?: number;
|
|
22
|
+
last_row_id?: number;
|
|
23
|
+
rows_read?: number;
|
|
24
|
+
rows_written?: number;
|
|
25
|
+
served_by?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface D1BindingResult {
|
|
28
|
+
results?: unknown[];
|
|
29
|
+
success?: boolean;
|
|
30
|
+
meta?: D1BindingMeta;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
/** The subset of Cloudflare's `D1PreparedStatement` this driver calls. */
|
|
34
|
+
export interface D1BindingStatement {
|
|
35
|
+
bind(...values: unknown[]): D1BindingStatement;
|
|
36
|
+
first(column?: string): Promise<unknown>;
|
|
37
|
+
all(): Promise<D1BindingResult>;
|
|
38
|
+
run(): Promise<D1BindingResult>;
|
|
39
|
+
raw(): Promise<unknown[][]>;
|
|
40
|
+
}
|
|
41
|
+
/** The subset of Cloudflare's `D1Database` this driver calls. A real binding satisfies it. */
|
|
42
|
+
export interface D1BindingLike {
|
|
43
|
+
prepare(sql: string): D1BindingStatement;
|
|
44
|
+
batch(statements: D1BindingStatement[]): Promise<D1BindingResult[]>;
|
|
45
|
+
exec(sql: string): Promise<{
|
|
46
|
+
count: number;
|
|
47
|
+
duration: number;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wrap a Cloudflare D1 binding in the async seam.
|
|
52
|
+
*
|
|
53
|
+
* On a Worker this is constructed per request from `env.DB` โ a binding is not a
|
|
54
|
+
* connection and holds no state worth caching, so there is nothing to pool and nothing to
|
|
55
|
+
* close.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createRemoteD1(binding: D1BindingLike): D1LikeDatabase;
|
|
58
|
+
/** Re-exported so both drivers refuse an interactive transaction with one message. */
|
|
59
|
+
export declare const refuseInteractiveTransaction: () => never;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REMOTE implementation of {@link D1LikeDatabase}, over a Cloudflare D1 binding.
|
|
3
|
+
*
|
|
4
|
+
* It is thin on purpose. D1's own API is the shape `./types` mirrors, so nearly all of
|
|
5
|
+
* this file is the normalization that makes a D1 answer indistinguishable from a
|
|
6
|
+
* `bun:sqlite` one โ the same `normalizeBind` / `normalizeValue` the local driver calls,
|
|
7
|
+
* so "the two drivers agree" is a property of one implementation rather than a
|
|
8
|
+
* coincidence between two.
|
|
9
|
+
*
|
|
10
|
+
* ๐ด **`workerd` is not imported here, and must not be.** This package is built and tested
|
|
11
|
+
* with Bun, and `@cloudflare/workers-types` would be a dependency the other 99 % of the
|
|
12
|
+
* library does not need. The binding is described structurally by {@link D1BindingLike}
|
|
13
|
+
* below, which is the subset of Cloudflare's `D1Database` this driver actually calls โ so
|
|
14
|
+
* a real binding satisfies it without a cast, and the test suite can satisfy it with a
|
|
15
|
+
* fake that has no `workerd` in sight.
|
|
16
|
+
*/
|
|
17
|
+
import { assertBatchSize, assertWithinLimits } from './limits';
|
|
18
|
+
import { D1UnsupportedError, } from './types';
|
|
19
|
+
import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
|
|
20
|
+
/**
|
|
21
|
+
* D1 reports a failed statement by RESOLVING with `success: false` in some paths rather
|
|
22
|
+
* than rejecting, so a driver that only catches rejections reports a silent no-op as a
|
|
23
|
+
* successful write. Every result goes through here.
|
|
24
|
+
*/
|
|
25
|
+
function assertSucceeded(res, sql) {
|
|
26
|
+
if (res.success === false || res.error) {
|
|
27
|
+
throw new Error(`D1 refused the statement: ${res.error ?? 'success: false'}\n ${sql.slice(0, 160)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const toMeta = (res) => makeMeta({
|
|
31
|
+
changes: res.meta?.changes ?? 0,
|
|
32
|
+
last_row_id: res.meta?.last_row_id ?? null,
|
|
33
|
+
duration: res.meta?.duration ?? 0,
|
|
34
|
+
rows_read: res.meta?.rows_read ?? null,
|
|
35
|
+
rows_written: res.meta?.rows_written ?? null,
|
|
36
|
+
});
|
|
37
|
+
class RemoteStatement {
|
|
38
|
+
stmt;
|
|
39
|
+
sql;
|
|
40
|
+
constructor(stmt, sql) {
|
|
41
|
+
this.stmt = stmt;
|
|
42
|
+
this.sql = sql;
|
|
43
|
+
}
|
|
44
|
+
/** The underlying binding statement โ how `batch()` reaches the real objects. */
|
|
45
|
+
get binding() {
|
|
46
|
+
return this.stmt;
|
|
47
|
+
}
|
|
48
|
+
bind(...values) {
|
|
49
|
+
const params = normalizeBinds(values);
|
|
50
|
+
assertWithinLimits(this.sql, params);
|
|
51
|
+
// D1 accepts ArrayBuffer for BLOBs; `normalizeBind` has already reduced every blob
|
|
52
|
+
// to a `Uint8Array`, so this is the one place that converts back out.
|
|
53
|
+
const wire = params.map((p) => (p instanceof Uint8Array ? p.buffer.slice(p.byteOffset, p.byteOffset + p.byteLength) : p));
|
|
54
|
+
return new RemoteStatement(this.stmt.bind(...wire), this.sql);
|
|
55
|
+
}
|
|
56
|
+
async first(column) {
|
|
57
|
+
const row = await this.stmt.first(column);
|
|
58
|
+
if (row === null || row === undefined)
|
|
59
|
+
return null;
|
|
60
|
+
if (column !== undefined)
|
|
61
|
+
return normalizeValue(row);
|
|
62
|
+
return normalizeRows([row])[0] ?? null;
|
|
63
|
+
}
|
|
64
|
+
async all() {
|
|
65
|
+
const res = await this.stmt.all();
|
|
66
|
+
assertSucceeded(res, this.sql);
|
|
67
|
+
return {
|
|
68
|
+
results: normalizeRows((res.results ?? [])),
|
|
69
|
+
success: true,
|
|
70
|
+
meta: toMeta(res),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async run() {
|
|
74
|
+
const res = await this.stmt.run();
|
|
75
|
+
assertSucceeded(res, this.sql);
|
|
76
|
+
return { results: [], success: true, meta: toMeta(res) };
|
|
77
|
+
}
|
|
78
|
+
async raw() {
|
|
79
|
+
const rows = await this.stmt.raw();
|
|
80
|
+
return rows.map((r) => r.map((v) => normalizeValue(v)));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Wrap a Cloudflare D1 binding in the async seam.
|
|
85
|
+
*
|
|
86
|
+
* On a Worker this is constructed per request from `env.DB` โ a binding is not a
|
|
87
|
+
* connection and holds no state worth caching, so there is nothing to pool and nothing to
|
|
88
|
+
* close.
|
|
89
|
+
*/
|
|
90
|
+
export function createRemoteD1(binding) {
|
|
91
|
+
return {
|
|
92
|
+
flavor: 'd1',
|
|
93
|
+
prepare(sql) {
|
|
94
|
+
return new RemoteStatement(binding.prepare(sql), sql);
|
|
95
|
+
},
|
|
96
|
+
async batch(statements) {
|
|
97
|
+
assertBatchSize(statements.length);
|
|
98
|
+
const bindings = statements.map((s) => {
|
|
99
|
+
const b = s.binding;
|
|
100
|
+
if (!b) {
|
|
101
|
+
throw new TypeError('batch() accepts only statements from this same D1 database โ ' +
|
|
102
|
+
'a local statement cannot run inside a D1 batch.');
|
|
103
|
+
}
|
|
104
|
+
return b;
|
|
105
|
+
});
|
|
106
|
+
const results = await binding.batch(bindings);
|
|
107
|
+
return results.map((res, i) => {
|
|
108
|
+
assertSucceeded(res, `batch statement ${i + 1}`);
|
|
109
|
+
return {
|
|
110
|
+
results: normalizeRows((res.results ?? [])),
|
|
111
|
+
success: true,
|
|
112
|
+
meta: toMeta(res),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
exec(sql) {
|
|
117
|
+
return binding.exec(sql);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Re-exported so both drivers refuse an interactive transaction with one message. */
|
|
122
|
+
export const refuseInteractiveTransaction = () => {
|
|
123
|
+
throw new D1UnsupportedError('an interactive transaction (BEGIN/COMMIT)', 'collect the statements and pass them to `db.batch([...])`, which is atomic on both drivers');
|
|
124
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plainjob` โ the Workers answer, and the choice is measured rather than guessed.
|
|
3
|
+
*
|
|
4
|
+
* ## What `plainjob` actually is, and why a Worker cannot have it
|
|
5
|
+
*
|
|
6
|
+
* An in-process SQLite job queue that POLLS a table on a timer. A Worker has no background
|
|
7
|
+
* loop: it exists for the duration of a request or a scheduled event and then stops. There
|
|
8
|
+
* is no timer to hang a poller off, so the mechanism does not port โ it is replaced.
|
|
9
|
+
*
|
|
10
|
+
* ## ๐ด The measurement that makes this cheaper than the task file assumed
|
|
11
|
+
*
|
|
12
|
+
* Re-measured 2026-09-16 over `apps/` and `libs/`: all **12** `plainjob` files are in
|
|
13
|
+
* `libs/` (ten in `cursedbelt-server`, two in `cursedbelt-cc`), and **zero** are in
|
|
14
|
+
* `apps/`. No app declares a job through this engine yet. The original table read "12
|
|
15
|
+
* files using plainjob" as twelve call sites to port; they are one mechanism in one
|
|
16
|
+
* library, which is this seam. The port is a library change, not a fleet sweep.
|
|
17
|
+
*
|
|
18
|
+
* That also means the shape below is free to be the right one rather than the compatible
|
|
19
|
+
* one โ there is no app-side caller to keep happy.
|
|
20
|
+
*
|
|
21
|
+
* ## The three primitives, and the rule for choosing
|
|
22
|
+
*
|
|
23
|
+
* | primitive | cost | when it is the answer |
|
|
24
|
+
* |---|---|---|
|
|
25
|
+
* | **Cron Triggers** | free | anything periodic at โฅ 1-minute cadence. Nightly backups and ingests โ most of this fleet |
|
|
26
|
+
* | **Queues** | $0.40/M operations, 1M included | work fanned out FROM a request, where the response must not wait |
|
|
27
|
+
* | **Durable Object Alarms** | DO pricing | per-entity scheduling: one timer per row, set from the row's own lifecycle |
|
|
28
|
+
*
|
|
29
|
+
* ๐ด **The hard discriminator is cadence, and it is a real cliff.** Cloudflare Cron
|
|
30
|
+
* Triggers take a **5-field** expression with a **one-minute floor**. `JobDef.cron` accepts
|
|
31
|
+
* a 6-field expression with a leading seconds column, and this repo already contains three
|
|
32
|
+
* jobs declared `'* * * * * *'` โ once per second. Those cannot become Cron Triggers at
|
|
33
|
+
* all, and {@link classifySchedule} says so by name instead of silently rounding them up to
|
|
34
|
+
* a minute, which would change behaviour in production and nowhere else.
|
|
35
|
+
*/
|
|
36
|
+
import type { D1LikeDatabase } from './types';
|
|
37
|
+
/** The Workers primitive a piece of scheduled work maps onto. */
|
|
38
|
+
export type WorkersPrimitive = 'cron-trigger' | 'queue' | 'durable-object-alarm' | 'unportable';
|
|
39
|
+
/** How a job is triggered โ the input to the choice, not the choice itself. */
|
|
40
|
+
export type TriggerShape = {
|
|
41
|
+
kind: 'periodic';
|
|
42
|
+
cron: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'fanned-out-from-request';
|
|
45
|
+
expectedPerDay?: number;
|
|
46
|
+
} | {
|
|
47
|
+
kind: 'per-entity';
|
|
48
|
+
};
|
|
49
|
+
export interface ScheduleVerdict {
|
|
50
|
+
primitive: WorkersPrimitive;
|
|
51
|
+
/** Why โ quoted into the port task, so the next reader sees the reasoning not the answer. */
|
|
52
|
+
reason: string;
|
|
53
|
+
/** The 5-field expression to put in `wrangler.toml`, when there is one. */
|
|
54
|
+
cron: string | null;
|
|
55
|
+
/** Monthly cost at the stated volume, in US dollars. */
|
|
56
|
+
estimatedMonthlyUsd: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A cron expression with six fields carries a leading SECONDS column. Cloudflare has no
|
|
60
|
+
* such column and no cadence below one minute.
|
|
61
|
+
*/
|
|
62
|
+
export declare function cronFieldCount(cron: string): number;
|
|
63
|
+
/**
|
|
64
|
+
* Drop the seconds column from a 6-field expression, reporting whether doing so changes
|
|
65
|
+
* when the job fires. `'0 3 * * * *'` is 3 a.m. daily either way; `'* * * * * *'` is not.
|
|
66
|
+
*/
|
|
67
|
+
export declare function toFiveField(cron: string): {
|
|
68
|
+
cron: string;
|
|
69
|
+
lossless: boolean;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Choose the Workers primitive for a piece of scheduled work.
|
|
73
|
+
*
|
|
74
|
+
* Returns `'unportable'` rather than a best guess when the cadence is below Cloudflare's
|
|
75
|
+
* floor โ a job that silently became 60ร less frequent in production would be found by a
|
|
76
|
+
* user, not by a check.
|
|
77
|
+
*/
|
|
78
|
+
export declare function classifySchedule(shape: TriggerShape): ScheduleVerdict;
|
|
79
|
+
/** A scheduled job, declared once and readable by both the local runner and `wrangler`. */
|
|
80
|
+
export interface PortableJob {
|
|
81
|
+
name: string;
|
|
82
|
+
shape: TriggerShape;
|
|
83
|
+
/** The work. Takes the async seam, so the same handler runs on either side. */
|
|
84
|
+
run(ctx: PortableJobContext): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
export interface PortableJobContext {
|
|
87
|
+
db: D1LikeDatabase;
|
|
88
|
+
signal: AbortSignal;
|
|
89
|
+
log: {
|
|
90
|
+
info(m: string): void;
|
|
91
|
+
warn(m: string): void;
|
|
92
|
+
error(m: string): void;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Render the `[triggers]` block of a `wrangler.toml` from a job list โ and refuse to render
|
|
97
|
+
* one that silently drops a job.
|
|
98
|
+
*
|
|
99
|
+
* ๐ด It throws on an unportable job rather than skipping it. A generated config that
|
|
100
|
+
* quietly omits a job is a job that stops running in production while every local test
|
|
101
|
+
* still passes, which is the failure mode this whole seam exists to prevent.
|
|
102
|
+
*/
|
|
103
|
+
export declare function toCronTriggers(jobs: readonly PortableJob[]): {
|
|
104
|
+
crons: string[];
|
|
105
|
+
queues: string[];
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* ๐ด Cloudflare dispatches a Cron Trigger by EXPRESSION, not by job name โ the
|
|
109
|
+
* `scheduled` handler is told which cron fired and nothing else. Two jobs sharing
|
|
110
|
+
* `0 3 * * *` arrive as one event, so the Worker has to fan back out to every job matching
|
|
111
|
+
* that expression. Forgetting this runs the first job and silently never runs the second.
|
|
112
|
+
*/
|
|
113
|
+
export declare function jobsForCron(jobs: readonly PortableJob[], firedCron: string): PortableJob[];
|