cursedbelt-server 1.1.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/dist/server/sync/http.d.ts +20 -3
- package/dist/server/sync/http.js +20 -14
- package/dist/server/sync/index.d.ts +10 -2
- package/dist/server/sync/index.js +9 -1
- package/dist/server/sync/planner.d.ts +38 -8
- package/dist/server/sync/planner.js +32 -8
- package/dist/server/sync/signal.d.ts +161 -0
- package/dist/server/sync/signal.js +348 -0
- package/dist/server/sync/timer.d.ts +63 -19
- package/dist/server/sync/timer.js +104 -45
- package/dist/server/sync/types.d.ts +0 -2
- package/package.json +21 -3
- package/src/leafSubpathsImportNothing.spec.ts +15 -3
- package/src/noTimerDialsAPeer.spec.ts +469 -0
- 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
- package/src/server/sync/http.ts +31 -16
- package/src/server/sync/index.ts +23 -1
- package/src/server/sync/planner.spec.ts +33 -16
- package/src/server/sync/planner.ts +48 -11
- package/src/server/sync/signal.spec.ts +306 -0
- package/src/server/sync/signal.ts +422 -0
- package/src/server/sync/timer.spec.ts +97 -16
- package/src/server/sync/timer.ts +124 -47
- package/src/server/sync/types.ts +0 -2
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backup seam — `PRAGMA wal_checkpoint(TRUNCATE)` locally, Time Travel remotely.
|
|
3
|
+
*
|
|
4
|
+
* ## 🔴 BOTH, not one
|
|
5
|
+
*
|
|
6
|
+
* D1 has no WAL a caller can checkpoint, and 30 days of free point-in-time restore is
|
|
7
|
+
* strictly better than the file copy it replaces. It is tempting to read that as "the
|
|
8
|
+
* checkpoint goes away". It does not: `PRAGMA wal_checkpoint(TRUNCATE)` is in the backup
|
|
9
|
+
* path of every app in this fleet and it is load-bearing while any of them is still
|
|
10
|
+
* Mac-hosted. Measured previously, `family.sqlite` was **2.2 MB of principal against a
|
|
11
|
+
* 2.1 MB WAL** — an un-checkpointed copy is half a database, and `VACUUM INTO` merging
|
|
12
|
+
* the WAL is the only reason the existing `../sqlite/backup.ts` is safe.
|
|
13
|
+
*
|
|
14
|
+
* So the local implementation keeps checkpointing, the remote one records a restore
|
|
15
|
+
* coordinate, and both answer the same interface. An app's backup job stops caring which
|
|
16
|
+
* side it is on — which is the property that lets the job move before the database does.
|
|
17
|
+
*
|
|
18
|
+
* ## The remote side does not "take" a backup, and that is not a gap
|
|
19
|
+
*
|
|
20
|
+
* Time Travel is automatic and continuous: D1 retains 30 days and restores to any
|
|
21
|
+
* timestamp or bookmark within it. There is nothing to trigger, so
|
|
22
|
+
* {@link createTimeTravelBackup} records the coordinate rather than inventing an API call
|
|
23
|
+
* that does not exist. The coordinate IS the backup — `wrangler d1 time-travel restore`
|
|
24
|
+
* takes a timestamp.
|
|
25
|
+
*
|
|
26
|
+
* 🔴 **`wrangler d1 export` is the off-Cloudflare leg, and a Worker cannot run it.** A
|
|
27
|
+
* Worker has no shell. That leg belongs to a Cron Trigger on this Mac or in CI, which is
|
|
28
|
+
* why {@link BackupPoint.exportCommand} hands back the command rather than running it —
|
|
29
|
+
* the same split as the binary server, where the bytes and the thing that copies them are
|
|
30
|
+
* deliberately not the same process.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Database } from 'bun:sqlite';
|
|
34
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import { snapshotSqlite } from '../sqlite/backup';
|
|
37
|
+
|
|
38
|
+
/** A restore coordinate — a file on this Mac, or a point in D1's retention window. */
|
|
39
|
+
export interface BackupPoint {
|
|
40
|
+
kind: 'file' | 'time-travel';
|
|
41
|
+
/** A filesystem path for `file`; an ISO-8601 timestamp for `time-travel`. */
|
|
42
|
+
ref: string;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
/** Bytes on disk, or `null` where the platform does not tell us. */
|
|
45
|
+
bytes: number | null;
|
|
46
|
+
/** The exact command that restores this point. Printed in the job log, not executed. */
|
|
47
|
+
restoreCommand: string;
|
|
48
|
+
/** For `time-travel`, the command that pulls a copy OFF Cloudflare. `null` locally. */
|
|
49
|
+
exportCommand: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DatabaseBackup {
|
|
53
|
+
/** Capture a restore point now, and describe it. */
|
|
54
|
+
capture(): Promise<BackupPoint>;
|
|
55
|
+
/** Whether this side needs a periodic capture at all. Time Travel does not. */
|
|
56
|
+
readonly needsPeriodicCapture: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LocalBackupOpts {
|
|
60
|
+
/** The live handle — checkpointed before the snapshot is taken. */
|
|
61
|
+
db: Database;
|
|
62
|
+
/** Path of the database file being backed up. */
|
|
63
|
+
sourcePath: string;
|
|
64
|
+
/** Directory the snapshot is written into. Created if missing. */
|
|
65
|
+
destDir: string;
|
|
66
|
+
/** Override the snapshot's file name. Defaults to `<name>-<ISO>.sqlite`. */
|
|
67
|
+
nameFor?: (now: Date) => string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Force the WAL back into the principal database and truncate the log file.
|
|
72
|
+
*
|
|
73
|
+
* `TRUNCATE` — not `PASSIVE` — because `PASSIVE` gives up silently when a reader holds the
|
|
74
|
+
* WAL, leaving a WAL that never drains while every log line says the backup succeeded.
|
|
75
|
+
*
|
|
76
|
+
* 🔴 **`busy` is the only field worth reading, and that is a measured correction.** Under
|
|
77
|
+
* `TRUNCATE` SQLite reports the counters from AFTER the truncation, so a completely
|
|
78
|
+
* successful checkpoint returns `(busy 0, log 0, checkpointed 0)` — measured 2026-09-16,
|
|
79
|
+
* draining a 70,072-byte WAL to zero reported `checkpointed: 0`. Anything asserting
|
|
80
|
+
* `checkpointed > 0` as proof of work is asserting a value that is structurally always
|
|
81
|
+
* zero here. The honest post-condition is the WAL file's SIZE, which is what
|
|
82
|
+
* `backup.spec.ts` checks; the honest error signal is `busy`.
|
|
83
|
+
*/
|
|
84
|
+
export function checkpointWal(db: Database): { busy: boolean; logPages: number; checkpointed: number } {
|
|
85
|
+
// One row: (busy, log, checkpointed). See the note above on what they mean here.
|
|
86
|
+
const row = db.query('PRAGMA wal_checkpoint(TRUNCATE)').get() as
|
|
87
|
+
| { busy?: number; log?: number; checkpointed?: number }
|
|
88
|
+
| null;
|
|
89
|
+
return {
|
|
90
|
+
busy: (row?.busy ?? 0) === 1,
|
|
91
|
+
logPages: row?.log ?? 0,
|
|
92
|
+
checkpointed: row?.checkpointed ?? 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The Mac-hosted implementation: checkpoint, then `VACUUM INTO` a fresh file.
|
|
98
|
+
*
|
|
99
|
+
* `VACUUM INTO` is already transactionally consistent under WAL, so the checkpoint is not
|
|
100
|
+
* what makes the copy correct — it is what keeps the WAL from growing without bound on a
|
|
101
|
+
* database that is written far more often than it is read, which is how a 2.2 MB database
|
|
102
|
+
* came to carry a 2.1 MB WAL.
|
|
103
|
+
*/
|
|
104
|
+
export function createLocalBackup(opts: LocalBackupOpts): DatabaseBackup {
|
|
105
|
+
return {
|
|
106
|
+
needsPeriodicCapture: true,
|
|
107
|
+
|
|
108
|
+
async capture(): Promise<BackupPoint> {
|
|
109
|
+
const checkpoint = checkpointWal(opts.db);
|
|
110
|
+
if (checkpoint.busy) {
|
|
111
|
+
// Not fatal — `VACUUM INTO` still produces a consistent copy — but it is the
|
|
112
|
+
// signal that the WAL is not draining, and silence here is how it grows.
|
|
113
|
+
console.warn(
|
|
114
|
+
`[d1/backup] wal_checkpoint(TRUNCATE) reported BUSY for ${opts.sourcePath} — ` +
|
|
115
|
+
`${checkpoint.logPages} page(s) still in the WAL. A reader is holding it open.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!existsSync(opts.destDir)) mkdirSync(opts.destDir, { recursive: true });
|
|
119
|
+
|
|
120
|
+
const now = new Date();
|
|
121
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
122
|
+
const name = opts.nameFor?.(now) ?? `backup-${stamp}.sqlite`;
|
|
123
|
+
const dest = join(opts.destDir, name);
|
|
124
|
+
|
|
125
|
+
const bytes = snapshotSqlite(opts.sourcePath, dest);
|
|
126
|
+
return {
|
|
127
|
+
kind: 'file',
|
|
128
|
+
ref: dest,
|
|
129
|
+
createdAt: now.toISOString(),
|
|
130
|
+
bytes,
|
|
131
|
+
restoreCommand: `cp '${dest}' '${opts.sourcePath}' # with the app stopped`,
|
|
132
|
+
exportCommand: null,
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TimeTravelOpts {
|
|
139
|
+
/** The database name as `wrangler` knows it — what the restore command needs. */
|
|
140
|
+
databaseName: string;
|
|
141
|
+
/** Where an export should be written, for the off-Cloudflare leg. */
|
|
142
|
+
exportPath?: string;
|
|
143
|
+
/** Injected for the test; defaults to the real clock. */
|
|
144
|
+
now?: () => Date;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The D1 implementation: record the coordinate, because retention is automatic.
|
|
149
|
+
*
|
|
150
|
+
* 🔴 It reports `needsPeriodicCapture: false`, and a scheduler must honour that rather
|
|
151
|
+
* than calling `capture()` on a timer — a nightly no-op job that logs "backup complete"
|
|
152
|
+
* is worse than no job, because it reads as evidence.
|
|
153
|
+
*/
|
|
154
|
+
export function createTimeTravelBackup(opts: TimeTravelOpts): DatabaseBackup {
|
|
155
|
+
const clock = opts.now ?? (() => new Date());
|
|
156
|
+
const exportPath = opts.exportPath ?? `./${opts.databaseName}-export.sql`;
|
|
157
|
+
return {
|
|
158
|
+
needsPeriodicCapture: false,
|
|
159
|
+
|
|
160
|
+
async capture(): Promise<BackupPoint> {
|
|
161
|
+
const at = clock().toISOString();
|
|
162
|
+
return {
|
|
163
|
+
kind: 'time-travel',
|
|
164
|
+
ref: at,
|
|
165
|
+
createdAt: at,
|
|
166
|
+
// Cloudflare does not report a size for a restore point, and a fabricated
|
|
167
|
+
// number would read as a measurement.
|
|
168
|
+
bytes: null,
|
|
169
|
+
restoreCommand: `wrangler d1 time-travel restore ${opts.databaseName} --timestamp=${at}`,
|
|
170
|
+
exportCommand: `wrangler d1 export ${opts.databaseName} --remote --output=${exportPath}`,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pick the backup implementation from the driver flavor, so an app's job definition reads
|
|
178
|
+
* the same on both sides.
|
|
179
|
+
*/
|
|
180
|
+
export function backupFor(
|
|
181
|
+
flavor: 'local' | 'd1',
|
|
182
|
+
local: () => LocalBackupOpts,
|
|
183
|
+
remote: () => TimeTravelOpts,
|
|
184
|
+
): DatabaseBackup {
|
|
185
|
+
return flavor === 'local' ? createLocalBackup(local()) : createTimeTravelBackup(remote());
|
|
186
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `bun:sqlite`-backed stand-in for a real D1 binding, for tests that must run without a
|
|
3
|
+
* Worker.
|
|
4
|
+
*
|
|
5
|
+
* ## 🔴 It is a DIVERGENCE SIMULATOR, not a mirror of our driver
|
|
6
|
+
*
|
|
7
|
+
* The whole value of this file rests on one discipline: it reproduces the ways D1 differs
|
|
8
|
+
* from `bun:sqlite`, deliberately and on purpose. A fake written to agree with
|
|
9
|
+
* `createRemoteD1` would make `sameShape.spec.ts` assert that our code agrees with itself,
|
|
10
|
+
* which is a test that can never fail and therefore is not one.
|
|
11
|
+
*
|
|
12
|
+
* So it does the awkward things D1 really does, each measured against `bun:sqlite` on
|
|
13
|
+
* 2026-09-16 and each one a real defect this fleet would otherwise ship:
|
|
14
|
+
*
|
|
15
|
+
* 1. **BLOBs come back as `number[]`**, not `Uint8Array` — D1 crosses the wire as JSON.
|
|
16
|
+
* 2. **`undefined` and `boolean` binds are REFUSED**, where `bun:sqlite` silently binds
|
|
17
|
+
* NULL and silently coerces to 0/1.
|
|
18
|
+
* 3. **`bigint` binds are refused** — `bun:sqlite` coerces them to numbers, losing
|
|
19
|
+
* precision above 2^53 without a word.
|
|
20
|
+
* 4. **Results are wrapped** in `{ results, success, meta }` rather than returned bare.
|
|
21
|
+
* 5. **`BEGIN`/`COMMIT` is refused** — D1 has no interactive transaction.
|
|
22
|
+
*
|
|
23
|
+
* SQL semantics are real: it runs against an actual in-memory SQLite. Only the edges are
|
|
24
|
+
* emulated, which are exactly the edges under test.
|
|
25
|
+
*
|
|
26
|
+
* 🔴 **It is not a substitute for running against real D1 once.** It cannot reproduce
|
|
27
|
+
* network failure, a `rows_read` budget or the 30-second query cap. It is the thing that
|
|
28
|
+
* makes a port testable on a laptop, and `190`'s Worker preview is the thing that proves
|
|
29
|
+
* it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Database } from 'bun:sqlite';
|
|
33
|
+
import type { D1BindingLike, D1BindingResult, D1BindingStatement } from './remote';
|
|
34
|
+
|
|
35
|
+
/** The bind types a real D1 binding accepts. Everything else throws. */
|
|
36
|
+
function assertD1Bindable(value: unknown, index: number): void {
|
|
37
|
+
if (value === null) return;
|
|
38
|
+
const t = typeof value;
|
|
39
|
+
if (t === 'string' || t === 'number') {
|
|
40
|
+
if (t === 'number' && !Number.isFinite(value as number)) {
|
|
41
|
+
throw new TypeError(`D1_TYPE_ERROR: value ${String(value)} at position ${index + 1} is not a finite number`);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (value instanceof ArrayBuffer || value instanceof Uint8Array) return;
|
|
46
|
+
throw new TypeError(
|
|
47
|
+
`D1_TYPE_ERROR: Type '${t === 'undefined' ? 'undefined' : ((value as object)?.constructor?.name ?? t)}' ` +
|
|
48
|
+
`not supported for value at position ${index + 1}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** D1 returns BLOB columns as plain arrays of byte values, because the wire is JSON. */
|
|
53
|
+
function toWireValue(value: unknown): unknown {
|
|
54
|
+
if (value instanceof Uint8Array) return Array.from(value);
|
|
55
|
+
if (typeof value === 'bigint') return Number(value);
|
|
56
|
+
return value ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const toWireRow = (row: Record<string, unknown>): Record<string, unknown> => {
|
|
60
|
+
const out: Record<string, unknown> = {};
|
|
61
|
+
for (const k of Object.keys(row)) out[k] = toWireValue(row[k]);
|
|
62
|
+
return out;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const REFUSED_SQL = /^\s*(begin|commit|rollback|savepoint|release|vacuum|attach|detach|pragma\s+journal_mode)\b/i;
|
|
66
|
+
|
|
67
|
+
function assertRunnableOnD1(sql: string): void {
|
|
68
|
+
if (REFUSED_SQL.test(sql)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`D1_ERROR: not authorized — D1 does not permit '${sql.trim().split(/\s+/)[0]}'. ` +
|
|
71
|
+
'Use batch() for atomicity; D1 has no interactive transaction and no WAL to configure.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class FakeD1Statement implements D1BindingStatement {
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly db: Database,
|
|
79
|
+
/** Public so `batch()` can re-run the statement inside its transaction. */
|
|
80
|
+
readonly sql: string,
|
|
81
|
+
readonly params: unknown[] = [],
|
|
82
|
+
) {}
|
|
83
|
+
|
|
84
|
+
bind(...values: unknown[]): D1BindingStatement {
|
|
85
|
+
values.forEach((v, i) => assertD1Bindable(v, i));
|
|
86
|
+
// A real binding stores what it was given; the ArrayBuffer→Uint8Array conversion
|
|
87
|
+
// here is `bun:sqlite`'s requirement, not D1's, and happens below the emulated edge.
|
|
88
|
+
const stored = values.map((v) => (v instanceof ArrayBuffer ? new Uint8Array(v) : v));
|
|
89
|
+
return new FakeD1Statement(this.db, this.sql, stored);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private meta(started: number, changes = 0, lastRowId = 0) {
|
|
93
|
+
return {
|
|
94
|
+
duration: performance.now() - started,
|
|
95
|
+
changes,
|
|
96
|
+
last_row_id: lastRowId,
|
|
97
|
+
// D1 reports these; the numbers here are not meaningful, but their PRESENCE is
|
|
98
|
+
// what the driver's `meta` mapping has to cope with.
|
|
99
|
+
rows_read: 0,
|
|
100
|
+
rows_written: changes,
|
|
101
|
+
served_by: 'fake-d1',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async first(column?: string): Promise<unknown> {
|
|
106
|
+
assertRunnableOnD1(this.sql);
|
|
107
|
+
const row = this.db.query(this.sql).get(...(this.params as never[])) as Record<string, unknown> | null;
|
|
108
|
+
if (!row) return null;
|
|
109
|
+
const wire = toWireRow(row);
|
|
110
|
+
if (column === undefined) return wire;
|
|
111
|
+
if (!(column in wire)) throw new Error(`D1_ERROR: no such column: ${column}`);
|
|
112
|
+
return wire[column];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async all(): Promise<D1BindingResult> {
|
|
116
|
+
assertRunnableOnD1(this.sql);
|
|
117
|
+
const started = performance.now();
|
|
118
|
+
const rows = this.db.query(this.sql).all(...(this.params as never[])) as Record<string, unknown>[];
|
|
119
|
+
return { results: rows.map(toWireRow), success: true, meta: this.meta(started) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async run(): Promise<D1BindingResult> {
|
|
123
|
+
assertRunnableOnD1(this.sql);
|
|
124
|
+
const started = performance.now();
|
|
125
|
+
const res = this.db.query(this.sql).run(...(this.params as never[]));
|
|
126
|
+
return {
|
|
127
|
+
results: [],
|
|
128
|
+
success: true,
|
|
129
|
+
meta: this.meta(started, res.changes, Number(res.lastInsertRowid)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async raw(): Promise<unknown[][]> {
|
|
134
|
+
assertRunnableOnD1(this.sql);
|
|
135
|
+
const rows = this.db.query(this.sql).values(...(this.params as never[])) as unknown[][];
|
|
136
|
+
return rows.map((r) => r.map(toWireValue));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Wrap a real `bun:sqlite` database in D1's API and D1's restrictions.
|
|
142
|
+
*
|
|
143
|
+
* ```ts
|
|
144
|
+
* const sqlite = new Database(':memory:');
|
|
145
|
+
* const db = createRemoteD1(createFakeD1Binding(sqlite)); // behaves like the real thing
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export function createFakeD1Binding(db: Database): D1BindingLike {
|
|
149
|
+
return {
|
|
150
|
+
prepare(sql: string): D1BindingStatement {
|
|
151
|
+
return new FakeD1Statement(db, sql);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async batch(statements: D1BindingStatement[]): Promise<D1BindingResult[]> {
|
|
155
|
+
// D1's batch is atomic — it is the only atomicity D1 offers. A real `bun:sqlite`
|
|
156
|
+
// transaction is the faithful local equivalent.
|
|
157
|
+
const results: D1BindingResult[] = [];
|
|
158
|
+
const run = db.transaction((stmts: D1BindingStatement[]) => {
|
|
159
|
+
for (const s of stmts) {
|
|
160
|
+
const started = performance.now();
|
|
161
|
+
const inner = s as FakeD1Statement;
|
|
162
|
+
assertRunnableOnD1(inner.sql);
|
|
163
|
+
const rows = db.query(inner.sql).all(...(inner.params as never[])) as Record<string, unknown>[];
|
|
164
|
+
results.push({
|
|
165
|
+
results: rows.map(toWireRow),
|
|
166
|
+
success: true,
|
|
167
|
+
meta: {
|
|
168
|
+
duration: performance.now() - started,
|
|
169
|
+
changes: 0,
|
|
170
|
+
last_row_id: 0,
|
|
171
|
+
rows_read: rows.length,
|
|
172
|
+
rows_written: 0,
|
|
173
|
+
served_by: 'fake-d1',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
run(statements);
|
|
179
|
+
return results;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async exec(sql: string): Promise<{ count: number; duration: number }> {
|
|
183
|
+
const started = performance.now();
|
|
184
|
+
const statements = sql
|
|
185
|
+
.split(';')
|
|
186
|
+
.map((s) => s.trim())
|
|
187
|
+
.filter((s) => s.length > 0);
|
|
188
|
+
for (const s of statements) assertRunnableOnD1(s);
|
|
189
|
+
for (const s of statements) db.exec(s);
|
|
190
|
+
return { count: statements.length, duration: performance.now() - started };
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/d1` — the async database seam the fleet crosses to reach Cloudflare
|
|
3
|
+
* Workers, with a local `bun:sqlite` implementation so the gate, the dev loop and every
|
|
4
|
+
* still-Mac-hosted app keep working unchanged.
|
|
5
|
+
*
|
|
6
|
+
* Start at `./types`, which explains why the interface is async on BOTH sides and why the
|
|
7
|
+
* local driver is deliberately as strict as D1 rather than as lenient as Bun.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { createLocalD1, createRemoteD1, createD1Kysely } from 'cursedbelt-server/d1';
|
|
11
|
+
*
|
|
12
|
+
* const db = env.DB ? createRemoteD1(env.DB) : createLocalD1(sqlite);
|
|
13
|
+
* const row = await db.prepare('SELECT * FROM people WHERE id = ?').bind(id).first();
|
|
14
|
+
* const ky = createD1Kysely(db);
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
backupFor,
|
|
20
|
+
type BackupPoint,
|
|
21
|
+
checkpointWal,
|
|
22
|
+
createLocalBackup,
|
|
23
|
+
createTimeTravelBackup,
|
|
24
|
+
type DatabaseBackup,
|
|
25
|
+
type LocalBackupOpts,
|
|
26
|
+
type TimeTravelOpts,
|
|
27
|
+
} from './backup';
|
|
28
|
+
export { createD1Kysely, D1LikeDialect, type D1PlumbingDb } from './kysely';
|
|
29
|
+
export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
|
|
30
|
+
export { createLocalD1, refuseInteractiveTransaction } from './local';
|
|
31
|
+
export {
|
|
32
|
+
createRemoteD1,
|
|
33
|
+
type D1BindingLike,
|
|
34
|
+
type D1BindingMeta,
|
|
35
|
+
type D1BindingResult,
|
|
36
|
+
type D1BindingStatement,
|
|
37
|
+
} from './remote';
|
|
38
|
+
export {
|
|
39
|
+
classifySchedule,
|
|
40
|
+
cronFieldCount,
|
|
41
|
+
jobsForCron,
|
|
42
|
+
type PortableJob,
|
|
43
|
+
type PortableJobContext,
|
|
44
|
+
type ScheduleVerdict,
|
|
45
|
+
toCronTriggers,
|
|
46
|
+
toFiveField,
|
|
47
|
+
type TriggerShape,
|
|
48
|
+
type WorkersPrimitive,
|
|
49
|
+
} from './scheduling';
|
|
50
|
+
export {
|
|
51
|
+
D1BindError,
|
|
52
|
+
type D1LikeBindable,
|
|
53
|
+
type D1LikeDatabase,
|
|
54
|
+
type D1LikeMeta,
|
|
55
|
+
type D1LikeResult,
|
|
56
|
+
type D1LikeRow,
|
|
57
|
+
type D1LikeStatement,
|
|
58
|
+
type D1LikeValue,
|
|
59
|
+
D1LimitError,
|
|
60
|
+
D1UnsupportedError,
|
|
61
|
+
} from './types';
|
|
62
|
+
export { makeMeta, normalizeBind, normalizeBinds, normalizeRow, normalizeRows, normalizeValue } from './values';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cheap half, proved on BOTH drivers with the same assertions — the point being that
|
|
3
|
+
* `createD1Kysely` is one dialect over the driver seam, so a plumbing query cannot tell
|
|
4
|
+
* which side it is on.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Database } from 'bun:sqlite';
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import type { Kysely } from 'kysely';
|
|
10
|
+
import { createFakeD1Binding } from './fakeD1';
|
|
11
|
+
import { createD1Kysely } from './kysely';
|
|
12
|
+
import { createLocalD1 } from './local';
|
|
13
|
+
import { createRemoteD1 } from './remote';
|
|
14
|
+
import { D1UnsupportedError, type D1LikeDatabase } from './types';
|
|
15
|
+
|
|
16
|
+
const DDL = `CREATE TABLE app_config (
|
|
17
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL, updated_by TEXT
|
|
18
|
+
)`;
|
|
19
|
+
|
|
20
|
+
interface Schema {
|
|
21
|
+
app_config: { key: string; value: string; updated_at: string; updated_by: string | null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The same schema behind each driver, so every test below runs twice. */
|
|
25
|
+
const drivers = (): Array<[string, () => D1LikeDatabase]> => [
|
|
26
|
+
[
|
|
27
|
+
'local',
|
|
28
|
+
() => {
|
|
29
|
+
const db = new Database(':memory:');
|
|
30
|
+
db.run(DDL);
|
|
31
|
+
return createLocalD1(db);
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
'd1',
|
|
36
|
+
() => {
|
|
37
|
+
const db = new Database(':memory:');
|
|
38
|
+
db.run(DDL);
|
|
39
|
+
return createRemoteD1(createFakeD1Binding(db));
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const [flavor, make] of drivers()) {
|
|
45
|
+
describe(`createD1Kysely on the ${flavor} driver`, () => {
|
|
46
|
+
const fresh = (): Kysely<Schema> => createD1Kysely<Schema>(make());
|
|
47
|
+
|
|
48
|
+
test('selectAll on an empty table returns []', async () => {
|
|
49
|
+
expect(await fresh().selectFrom('app_config').selectAll().execute()).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('insert + select round-trips a row', async () => {
|
|
53
|
+
const ky = fresh();
|
|
54
|
+
await ky
|
|
55
|
+
.insertInto('app_config')
|
|
56
|
+
.values({ key: 'media_backend', value: 'disk', updated_at: 't', updated_by: null })
|
|
57
|
+
.execute();
|
|
58
|
+
|
|
59
|
+
const row = await ky
|
|
60
|
+
.selectFrom('app_config')
|
|
61
|
+
.select(['key', 'value'])
|
|
62
|
+
.where('key', '=', 'media_backend')
|
|
63
|
+
.executeTakeFirst();
|
|
64
|
+
|
|
65
|
+
expect(row).toEqual({ key: 'media_backend', value: 'disk' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an insert reports the rows it affected', async () => {
|
|
69
|
+
const ky = fresh();
|
|
70
|
+
const res = await ky
|
|
71
|
+
.insertInto('app_config')
|
|
72
|
+
.values({ key: 'a', value: '1', updated_at: 't', updated_by: null })
|
|
73
|
+
.executeTakeFirst();
|
|
74
|
+
expect(Number(res.numInsertedOrUpdatedRows)).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('update and delete report their affected row counts', async () => {
|
|
78
|
+
const ky = fresh();
|
|
79
|
+
await ky
|
|
80
|
+
.insertInto('app_config')
|
|
81
|
+
.values({ key: 'a', value: '1', updated_at: 't', updated_by: null })
|
|
82
|
+
.execute();
|
|
83
|
+
|
|
84
|
+
const updated = await ky
|
|
85
|
+
.updateTable('app_config')
|
|
86
|
+
.set({ value: '2' })
|
|
87
|
+
.where('key', '=', 'a')
|
|
88
|
+
.executeTakeFirst();
|
|
89
|
+
expect(Number(updated.numUpdatedRows)).toBe(1);
|
|
90
|
+
|
|
91
|
+
const deleted = await ky.deleteFrom('app_config').where('key', '=', 'a').executeTakeFirst();
|
|
92
|
+
expect(Number(deleted.numDeletedRows)).toBe(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('RETURNING gives back rows rather than a count', async () => {
|
|
96
|
+
const ky = fresh();
|
|
97
|
+
const returned = await ky
|
|
98
|
+
.insertInto('app_config')
|
|
99
|
+
.values({ key: 'k', value: 'v', updated_at: 't', updated_by: null })
|
|
100
|
+
.returning(['key', 'value'])
|
|
101
|
+
.executeTakeFirst();
|
|
102
|
+
expect(returned).toEqual({ key: 'k', value: 'v' });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a NULL column comes back as null on both', async () => {
|
|
106
|
+
const ky = fresh();
|
|
107
|
+
await ky
|
|
108
|
+
.insertInto('app_config')
|
|
109
|
+
.values({ key: 'a', value: '1', updated_at: 't', updated_by: null })
|
|
110
|
+
.execute();
|
|
111
|
+
const row = await ky.selectFrom('app_config').selectAll().executeTakeFirstOrThrow();
|
|
112
|
+
expect(row.updated_by).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('🔴 an interactive transaction is REFUSED — D1 has none, so neither does local', async () => {
|
|
116
|
+
// The trap this closes: a local dialect that honoured BEGIN would be green here
|
|
117
|
+
// and red in the Worker. `db.batch()` is the atomicity primitive instead.
|
|
118
|
+
const ky = fresh();
|
|
119
|
+
await expect(
|
|
120
|
+
ky.transaction().execute(async (trx) => {
|
|
121
|
+
await trx
|
|
122
|
+
.insertInto('app_config')
|
|
123
|
+
.values({ key: 'x', value: '1', updated_at: 't', updated_by: null })
|
|
124
|
+
.execute();
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow(D1UnsupportedError);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a refused transaction leaves NOTHING behind', async () => {
|
|
130
|
+
const db = make();
|
|
131
|
+
const ky = createD1Kysely<Schema>(db);
|
|
132
|
+
await ky
|
|
133
|
+
.transaction()
|
|
134
|
+
.execute(async (trx) => {
|
|
135
|
+
await trx
|
|
136
|
+
.insertInto('app_config')
|
|
137
|
+
.values({ key: 'x', value: '1', updated_at: 't', updated_by: null })
|
|
138
|
+
.execute();
|
|
139
|
+
})
|
|
140
|
+
.catch(() => {});
|
|
141
|
+
const rows = await ky.selectFrom('app_config').selectAll().execute();
|
|
142
|
+
expect(rows).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|