cursedbelt-server 4.6.0 → 4.7.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/d1/backup.d.ts +13 -53
- package/dist/server/d1/backup.js +14 -80
- package/dist/server/d1/index.d.ts +1 -1
- package/dist/server/d1/index.js +12 -1
- package/dist/server/d1/localBackup.d.ts +84 -0
- package/dist/server/d1/localBackup.js +110 -0
- package/package.json +7 -1
- package/src/barrelsReachNoOptionalPeer.spec.ts +125 -12
- package/src/server/d1/backup.spec.ts +4 -1
- package/src/server/d1/backup.ts +13 -105
- package/src/server/d1/index.ts +11 -4
- package/src/server/d1/localBackup.ts +137 -0
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The backup seam — `PRAGMA wal_checkpoint(TRUNCATE)` locally, Time Travel remotely.
|
|
3
3
|
*
|
|
4
|
-
* ## 🔴 BOTH, not one
|
|
4
|
+
* ## 🔴 BOTH, not one — but only one of them is in this file
|
|
5
5
|
*
|
|
6
6
|
* D1 has no WAL a caller can checkpoint, and 30 days of free point-in-time restore is
|
|
7
7
|
* strictly better than the file copy it replaces. It is tempting to read that as "the
|
|
8
|
-
* checkpoint goes away". It does not:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the WAL is the only reason the existing `../sqlite/backup.ts` is safe.
|
|
8
|
+
* checkpoint goes away". It does not: the local implementation keeps checkpointing, the
|
|
9
|
+
* remote one records a restore coordinate, and both answer the same interface. An app's
|
|
10
|
+
* backup job stops caring which side it is on — which is the property that lets the job
|
|
11
|
+
* move before the database does.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* **The local half lives in `./localBackup` (`cursedbelt-server/d1/backup-local`), and
|
|
14
|
+
* that split is load-bearing.** It reaches `bun:sqlite` through `../sqlite/backup`, and
|
|
15
|
+
* `wrangler`'s esbuild resolves imports statically, so while it was re-exported from the
|
|
16
|
+
* `./d1` barrel no Worker could import the seam at all — measured 2026-09-18, the first
|
|
17
|
+
* real Worker deploy in this fleet died on `Could not resolve "bun:sqlite"`. This file
|
|
18
|
+
* therefore holds the interface and the remote implementation ONLY, and everything in it
|
|
19
|
+
* must stay importable on a Worker. `../../barrelsReachNoOptionalPeer.spec.ts` is what
|
|
20
|
+
* keeps it that way.
|
|
17
21
|
*
|
|
18
22
|
* ## The remote side does not "take" a backup, and that is not a gap
|
|
19
23
|
*
|
|
@@ -29,7 +33,6 @@
|
|
|
29
33
|
* the same split as the binary server, where the bytes and the thing that copies them are
|
|
30
34
|
* deliberately not the same process.
|
|
31
35
|
*/
|
|
32
|
-
import type { Database } from 'bun:sqlite';
|
|
33
36
|
/** A restore coordinate — a file on this Mac, or a point in D1's retention window. */
|
|
34
37
|
export interface BackupPoint {
|
|
35
38
|
kind: 'file' | 'time-travel';
|
|
@@ -49,44 +52,6 @@ export interface DatabaseBackup {
|
|
|
49
52
|
/** Whether this side needs a periodic capture at all. Time Travel does not. */
|
|
50
53
|
readonly needsPeriodicCapture: boolean;
|
|
51
54
|
}
|
|
52
|
-
export interface LocalBackupOpts {
|
|
53
|
-
/** The live handle — checkpointed before the snapshot is taken. */
|
|
54
|
-
db: Database;
|
|
55
|
-
/** Path of the database file being backed up. */
|
|
56
|
-
sourcePath: string;
|
|
57
|
-
/** Directory the snapshot is written into. Created if missing. */
|
|
58
|
-
destDir: string;
|
|
59
|
-
/** Override the snapshot's file name. Defaults to `<name>-<ISO>.sqlite`. */
|
|
60
|
-
nameFor?: (now: Date) => string;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Force the WAL back into the principal database and truncate the log file.
|
|
64
|
-
*
|
|
65
|
-
* `TRUNCATE` — not `PASSIVE` — because `PASSIVE` gives up silently when a reader holds the
|
|
66
|
-
* WAL, leaving a WAL that never drains while every log line says the backup succeeded.
|
|
67
|
-
*
|
|
68
|
-
* 🔴 **`busy` is the only field worth reading, and that is a measured correction.** Under
|
|
69
|
-
* `TRUNCATE` SQLite reports the counters from AFTER the truncation, so a completely
|
|
70
|
-
* successful checkpoint returns `(busy 0, log 0, checkpointed 0)` — measured 2026-09-16,
|
|
71
|
-
* draining a 70,072-byte WAL to zero reported `checkpointed: 0`. Anything asserting
|
|
72
|
-
* `checkpointed > 0` as proof of work is asserting a value that is structurally always
|
|
73
|
-
* zero here. The honest post-condition is the WAL file's SIZE, which is what
|
|
74
|
-
* `backup.spec.ts` checks; the honest error signal is `busy`.
|
|
75
|
-
*/
|
|
76
|
-
export declare function checkpointWal(db: Database): {
|
|
77
|
-
busy: boolean;
|
|
78
|
-
logPages: number;
|
|
79
|
-
checkpointed: number;
|
|
80
|
-
};
|
|
81
|
-
/**
|
|
82
|
-
* The Mac-hosted implementation: checkpoint, then `VACUUM INTO` a fresh file.
|
|
83
|
-
*
|
|
84
|
-
* `VACUUM INTO` is already transactionally consistent under WAL, so the checkpoint is not
|
|
85
|
-
* what makes the copy correct — it is what keeps the WAL from growing without bound on a
|
|
86
|
-
* database that is written far more often than it is read, which is how a 2.2 MB database
|
|
87
|
-
* came to carry a 2.1 MB WAL.
|
|
88
|
-
*/
|
|
89
|
-
export declare function createLocalBackup(opts: LocalBackupOpts): DatabaseBackup;
|
|
90
55
|
export interface TimeTravelOpts {
|
|
91
56
|
/** The database name as `wrangler` knows it — what the restore command needs. */
|
|
92
57
|
databaseName: string;
|
|
@@ -103,8 +68,3 @@ export interface TimeTravelOpts {
|
|
|
103
68
|
* is worse than no job, because it reads as evidence.
|
|
104
69
|
*/
|
|
105
70
|
export declare function createTimeTravelBackup(opts: TimeTravelOpts): DatabaseBackup;
|
|
106
|
-
/**
|
|
107
|
-
* Pick the backup implementation from the driver flavor, so an app's job definition reads
|
|
108
|
-
* the same on both sides.
|
|
109
|
-
*/
|
|
110
|
-
export declare function backupFor(flavor: 'local' | 'd1', local: () => LocalBackupOpts, remote: () => TimeTravelOpts): DatabaseBackup;
|
package/dist/server/d1/backup.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The backup seam — `PRAGMA wal_checkpoint(TRUNCATE)` locally, Time Travel remotely.
|
|
3
3
|
*
|
|
4
|
-
* ## 🔴 BOTH, not one
|
|
4
|
+
* ## 🔴 BOTH, not one — but only one of them is in this file
|
|
5
5
|
*
|
|
6
6
|
* D1 has no WAL a caller can checkpoint, and 30 days of free point-in-time restore is
|
|
7
7
|
* strictly better than the file copy it replaces. It is tempting to read that as "the
|
|
8
|
-
* checkpoint goes away". It does not:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
8
|
+
* checkpoint goes away". It does not: the local implementation keeps checkpointing, the
|
|
9
|
+
* remote one records a restore coordinate, and both answer the same interface. An app's
|
|
10
|
+
* backup job stops caring which side it is on — which is the property that lets the job
|
|
11
|
+
* move before the database does.
|
|
12
|
+
*
|
|
13
|
+
* **The local half lives in `./localBackup` (`cursedbelt-server/d1/backup-local`), and
|
|
14
|
+
* that split is load-bearing.** It reaches `bun:sqlite` through `../sqlite/backup`, and
|
|
15
|
+
* `wrangler`'s esbuild resolves imports statically, so while it was re-exported from the
|
|
16
|
+
* `./d1` barrel no Worker could import the seam at all — measured 2026-09-18, the first
|
|
17
|
+
* real Worker deploy in this fleet died on `Could not resolve "bun:sqlite"`. This file
|
|
18
|
+
* therefore holds the interface and the remote implementation ONLY, and everything in it
|
|
19
|
+
* must stay importable on a Worker. `../../barrelsReachNoOptionalPeer.spec.ts` is what
|
|
20
|
+
* keeps it that way.
|
|
17
21
|
*
|
|
18
22
|
* ## The remote side does not "take" a backup, and that is not a gap
|
|
19
23
|
*
|
|
@@ -29,69 +33,6 @@
|
|
|
29
33
|
* the same split as the binary server, where the bytes and the thing that copies them are
|
|
30
34
|
* deliberately not the same process.
|
|
31
35
|
*/
|
|
32
|
-
import { existsSync, mkdirSync } from 'node:fs';
|
|
33
|
-
import { join } from 'node:path';
|
|
34
|
-
import { snapshotSqlite } from '../sqlite/backup';
|
|
35
|
-
/**
|
|
36
|
-
* Force the WAL back into the principal database and truncate the log file.
|
|
37
|
-
*
|
|
38
|
-
* `TRUNCATE` — not `PASSIVE` — because `PASSIVE` gives up silently when a reader holds the
|
|
39
|
-
* WAL, leaving a WAL that never drains while every log line says the backup succeeded.
|
|
40
|
-
*
|
|
41
|
-
* 🔴 **`busy` is the only field worth reading, and that is a measured correction.** Under
|
|
42
|
-
* `TRUNCATE` SQLite reports the counters from AFTER the truncation, so a completely
|
|
43
|
-
* successful checkpoint returns `(busy 0, log 0, checkpointed 0)` — measured 2026-09-16,
|
|
44
|
-
* draining a 70,072-byte WAL to zero reported `checkpointed: 0`. Anything asserting
|
|
45
|
-
* `checkpointed > 0` as proof of work is asserting a value that is structurally always
|
|
46
|
-
* zero here. The honest post-condition is the WAL file's SIZE, which is what
|
|
47
|
-
* `backup.spec.ts` checks; the honest error signal is `busy`.
|
|
48
|
-
*/
|
|
49
|
-
export function checkpointWal(db) {
|
|
50
|
-
// One row: (busy, log, checkpointed). See the note above on what they mean here.
|
|
51
|
-
const row = db.query('PRAGMA wal_checkpoint(TRUNCATE)').get();
|
|
52
|
-
return {
|
|
53
|
-
busy: (row?.busy ?? 0) === 1,
|
|
54
|
-
logPages: row?.log ?? 0,
|
|
55
|
-
checkpointed: row?.checkpointed ?? 0,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* The Mac-hosted implementation: checkpoint, then `VACUUM INTO` a fresh file.
|
|
60
|
-
*
|
|
61
|
-
* `VACUUM INTO` is already transactionally consistent under WAL, so the checkpoint is not
|
|
62
|
-
* what makes the copy correct — it is what keeps the WAL from growing without bound on a
|
|
63
|
-
* database that is written far more often than it is read, which is how a 2.2 MB database
|
|
64
|
-
* came to carry a 2.1 MB WAL.
|
|
65
|
-
*/
|
|
66
|
-
export function createLocalBackup(opts) {
|
|
67
|
-
return {
|
|
68
|
-
needsPeriodicCapture: true,
|
|
69
|
-
async capture() {
|
|
70
|
-
const checkpoint = checkpointWal(opts.db);
|
|
71
|
-
if (checkpoint.busy) {
|
|
72
|
-
// Not fatal — `VACUUM INTO` still produces a consistent copy — but it is the
|
|
73
|
-
// signal that the WAL is not draining, and silence here is how it grows.
|
|
74
|
-
console.warn(`[d1/backup] wal_checkpoint(TRUNCATE) reported BUSY for ${opts.sourcePath} — ` +
|
|
75
|
-
`${checkpoint.logPages} page(s) still in the WAL. A reader is holding it open.`);
|
|
76
|
-
}
|
|
77
|
-
if (!existsSync(opts.destDir))
|
|
78
|
-
mkdirSync(opts.destDir, { recursive: true });
|
|
79
|
-
const now = new Date();
|
|
80
|
-
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
81
|
-
const name = opts.nameFor?.(now) ?? `backup-${stamp}.sqlite`;
|
|
82
|
-
const dest = join(opts.destDir, name);
|
|
83
|
-
const bytes = snapshotSqlite(opts.sourcePath, dest);
|
|
84
|
-
return {
|
|
85
|
-
kind: 'file',
|
|
86
|
-
ref: dest,
|
|
87
|
-
createdAt: now.toISOString(),
|
|
88
|
-
bytes,
|
|
89
|
-
restoreCommand: `cp '${dest}' '${opts.sourcePath}' # with the app stopped`,
|
|
90
|
-
exportCommand: null,
|
|
91
|
-
};
|
|
92
|
-
},
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
36
|
/**
|
|
96
37
|
* The D1 implementation: record the coordinate, because retention is automatic.
|
|
97
38
|
*
|
|
@@ -119,10 +60,3 @@ export function createTimeTravelBackup(opts) {
|
|
|
119
60
|
},
|
|
120
61
|
};
|
|
121
62
|
}
|
|
122
|
-
/**
|
|
123
|
-
* Pick the backup implementation from the driver flavor, so an app's job definition reads
|
|
124
|
-
* the same on both sides.
|
|
125
|
-
*/
|
|
126
|
-
export function backupFor(flavor, local, remote) {
|
|
127
|
-
return flavor === 'local' ? createLocalBackup(local()) : createTimeTravelBackup(remote());
|
|
128
|
-
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* const ky = createD1Kysely(db);
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export {
|
|
17
|
+
export { type BackupPoint, createTimeTravelBackup, type DatabaseBackup, type TimeTravelOpts, } from './backup';
|
|
18
18
|
export { type InvocationD1, perInvocation } from './invocation';
|
|
19
19
|
export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits';
|
|
20
20
|
export { createLocalD1, refuseInteractiveTransaction } from './local';
|
package/dist/server/d1/index.js
CHANGED
|
@@ -14,7 +14,18 @@
|
|
|
14
14
|
* const ky = createD1Kysely(db);
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export {
|
|
17
|
+
export { createTimeTravelBackup, } from './backup';
|
|
18
|
+
// 🔴 `./localBackup` is deliberately NOT re-exported here — import `checkpointWal`,
|
|
19
|
+
// `createLocalBackup`, `LocalBackupOpts` and `backupFor` from
|
|
20
|
+
// `cursedbelt-server/d1/backup-local`. It reaches `bun:sqlite` for real (via
|
|
21
|
+
// `../sqlite/backup`'s `snapshotSqlite`), and `wrangler` bundles with esbuild, which
|
|
22
|
+
// resolves imports STATICALLY — so while it was re-exported here, `wrangler deploy` died
|
|
23
|
+
// with `Could not resolve "bun:sqlite"` naming `dist/server/sqlite/backup.js`, and the
|
|
24
|
+
// seam written FOR Workers was the one thing that could not load on one. Measured
|
|
25
|
+
// 2026-09-18 by the first real Worker deploy in this fleet (`apps/patterns`). Same class
|
|
26
|
+
// as the note below, one step wider: a barrel must reach neither an optional peer nor a
|
|
27
|
+
// runtime the target platform lacks. `barrelsReachNoOptionalPeer.spec.ts` checks both.
|
|
28
|
+
//
|
|
18
29
|
// 🔴 `./kysely` is deliberately NOT re-exported here — import it from
|
|
19
30
|
// `cursedbelt-server/d1/kysely`. It statically imports `kysely` (real values: `Kysely`,
|
|
20
31
|
// `SqliteAdapter`, `SqliteQueryCompiler`), which is an OPTIONAL peer, so re-exporting it
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/d1/backup-local` — the Mac-hosted half of the backup seam:
|
|
3
|
+
* `PRAGMA wal_checkpoint(TRUNCATE)`, then `VACUUM INTO` a fresh file.
|
|
4
|
+
*
|
|
5
|
+
* ## 🔴 Why this is a subpath and not part of `./d1`
|
|
6
|
+
*
|
|
7
|
+
* It reaches `bun:sqlite` for real, through `../sqlite/backup`'s {@link snapshotSqlite}.
|
|
8
|
+
* `wrangler deploy` bundles with esbuild, which resolves imports STATICALLY — so a Worker
|
|
9
|
+
* that imported `cursedbelt-server/d1` for `createRemoteD1` paid for this file too and the
|
|
10
|
+
* deploy failed outright:
|
|
11
|
+
*
|
|
12
|
+
* ✘ [ERROR] Could not resolve "bun:sqlite"
|
|
13
|
+
* node_modules/cursedbelt-server/dist/server/sqlite/backup.js:1:25
|
|
14
|
+
*
|
|
15
|
+
* Measured 2026-09-18 by the first real Worker deploy in this fleet (`apps/patterns`). The
|
|
16
|
+
* error names the leaf rather than the barrel that dragged it in, which is why the first
|
|
17
|
+
* five minutes went into the wrong file. It is the same defect as `./d1/kysely` one class
|
|
18
|
+
* wider: there, the barrel reached an optional PEER; here, it reached a runtime the target
|
|
19
|
+
* platform does not have. `../../barrelsReachNoOptionalPeer.spec.ts` now checks both.
|
|
20
|
+
*
|
|
21
|
+
* The remote half — {@link createTimeTravelBackup}, {@link BackupPoint},
|
|
22
|
+
* {@link DatabaseBackup} — stays in `./backup` and stays in the barrel, because a Worker
|
|
23
|
+
* needs it and it reaches nothing.
|
|
24
|
+
*
|
|
25
|
+
* ## The checkpoint is load-bearing, and stays
|
|
26
|
+
*
|
|
27
|
+
* D1 has no WAL a caller can checkpoint, so it is tempting to read the port as "the
|
|
28
|
+
* checkpoint goes away". It does not: `PRAGMA wal_checkpoint(TRUNCATE)` is in the backup
|
|
29
|
+
* path of every app in this fleet and it is load-bearing while any of them is still
|
|
30
|
+
* Mac-hosted. Measured previously, `family.sqlite` was **2.2 MB of principal against a
|
|
31
|
+
* 2.1 MB WAL** — an un-checkpointed copy is half a database, and `VACUUM INTO` merging the
|
|
32
|
+
* WAL is the only reason `../sqlite/backup.ts` is safe.
|
|
33
|
+
*/
|
|
34
|
+
import type { Database } from 'bun:sqlite';
|
|
35
|
+
import type { DatabaseBackup, TimeTravelOpts } from './backup';
|
|
36
|
+
export interface LocalBackupOpts {
|
|
37
|
+
/** The live handle — checkpointed before the snapshot is taken. */
|
|
38
|
+
db: Database;
|
|
39
|
+
/** Path of the database file being backed up. */
|
|
40
|
+
sourcePath: string;
|
|
41
|
+
/** Directory the snapshot is written into. Created if missing. */
|
|
42
|
+
destDir: string;
|
|
43
|
+
/** Override the snapshot's file name. Defaults to `<name>-<ISO>.sqlite`. */
|
|
44
|
+
nameFor?: (now: Date) => string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Force the WAL back into the principal database and truncate the log file.
|
|
48
|
+
*
|
|
49
|
+
* `TRUNCATE` — not `PASSIVE` — because `PASSIVE` gives up silently when a reader holds the
|
|
50
|
+
* WAL, leaving a WAL that never drains while every log line says the backup succeeded.
|
|
51
|
+
*
|
|
52
|
+
* 🔴 **`busy` is the only field worth reading, and that is a measured correction.** Under
|
|
53
|
+
* `TRUNCATE` SQLite reports the counters from AFTER the truncation, so a completely
|
|
54
|
+
* successful checkpoint returns `(busy 0, log 0, checkpointed 0)` — measured 2026-09-16,
|
|
55
|
+
* draining a 70,072-byte WAL to zero reported `checkpointed: 0`. Anything asserting
|
|
56
|
+
* `checkpointed > 0` as proof of work is asserting a value that is structurally always
|
|
57
|
+
* zero here. The honest post-condition is the WAL file's SIZE, which is what
|
|
58
|
+
* `backup.spec.ts` checks; the honest error signal is `busy`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function checkpointWal(db: Database): {
|
|
61
|
+
busy: boolean;
|
|
62
|
+
logPages: number;
|
|
63
|
+
checkpointed: number;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The Mac-hosted implementation: checkpoint, then `VACUUM INTO` a fresh file.
|
|
67
|
+
*
|
|
68
|
+
* `VACUUM INTO` is already transactionally consistent under WAL, so the checkpoint is not
|
|
69
|
+
* what makes the copy correct — it is what keeps the WAL from growing without bound on a
|
|
70
|
+
* database that is written far more often than it is read, which is how a 2.2 MB database
|
|
71
|
+
* came to carry a 2.1 MB WAL.
|
|
72
|
+
*/
|
|
73
|
+
export declare function createLocalBackup(opts: LocalBackupOpts): DatabaseBackup;
|
|
74
|
+
/**
|
|
75
|
+
* Pick the backup implementation from the driver flavor, so an app's job definition reads
|
|
76
|
+
* the same on both sides.
|
|
77
|
+
*
|
|
78
|
+
* 🔴 This lives here rather than in `./backup` because it CONSTRUCTS the local one, and a
|
|
79
|
+
* construction is a value import: a `backupFor` in the barrel would drag `bun:sqlite` back
|
|
80
|
+
* in for every Worker. A Worker never needs it — its flavor is always `'d1'`, so it calls
|
|
81
|
+
* {@link createTimeTravelBackup} from `cursedbelt-server/d1` directly. This is the seam for
|
|
82
|
+
* a host that genuinely has both.
|
|
83
|
+
*/
|
|
84
|
+
export declare function backupFor(flavor: 'local' | 'd1', local: () => LocalBackupOpts, remote: () => TimeTravelOpts): DatabaseBackup;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/d1/backup-local` — the Mac-hosted half of the backup seam:
|
|
3
|
+
* `PRAGMA wal_checkpoint(TRUNCATE)`, then `VACUUM INTO` a fresh file.
|
|
4
|
+
*
|
|
5
|
+
* ## 🔴 Why this is a subpath and not part of `./d1`
|
|
6
|
+
*
|
|
7
|
+
* It reaches `bun:sqlite` for real, through `../sqlite/backup`'s {@link snapshotSqlite}.
|
|
8
|
+
* `wrangler deploy` bundles with esbuild, which resolves imports STATICALLY — so a Worker
|
|
9
|
+
* that imported `cursedbelt-server/d1` for `createRemoteD1` paid for this file too and the
|
|
10
|
+
* deploy failed outright:
|
|
11
|
+
*
|
|
12
|
+
* ✘ [ERROR] Could not resolve "bun:sqlite"
|
|
13
|
+
* node_modules/cursedbelt-server/dist/server/sqlite/backup.js:1:25
|
|
14
|
+
*
|
|
15
|
+
* Measured 2026-09-18 by the first real Worker deploy in this fleet (`apps/patterns`). The
|
|
16
|
+
* error names the leaf rather than the barrel that dragged it in, which is why the first
|
|
17
|
+
* five minutes went into the wrong file. It is the same defect as `./d1/kysely` one class
|
|
18
|
+
* wider: there, the barrel reached an optional PEER; here, it reached a runtime the target
|
|
19
|
+
* platform does not have. `../../barrelsReachNoOptionalPeer.spec.ts` now checks both.
|
|
20
|
+
*
|
|
21
|
+
* The remote half — {@link createTimeTravelBackup}, {@link BackupPoint},
|
|
22
|
+
* {@link DatabaseBackup} — stays in `./backup` and stays in the barrel, because a Worker
|
|
23
|
+
* needs it and it reaches nothing.
|
|
24
|
+
*
|
|
25
|
+
* ## The checkpoint is load-bearing, and stays
|
|
26
|
+
*
|
|
27
|
+
* D1 has no WAL a caller can checkpoint, so it is tempting to read the port as "the
|
|
28
|
+
* checkpoint goes away". It does not: `PRAGMA wal_checkpoint(TRUNCATE)` is in the backup
|
|
29
|
+
* path of every app in this fleet and it is load-bearing while any of them is still
|
|
30
|
+
* Mac-hosted. Measured previously, `family.sqlite` was **2.2 MB of principal against a
|
|
31
|
+
* 2.1 MB WAL** — an un-checkpointed copy is half a database, and `VACUUM INTO` merging the
|
|
32
|
+
* WAL is the only reason `../sqlite/backup.ts` is safe.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import { snapshotSqlite } from '../sqlite/backup';
|
|
37
|
+
import { createTimeTravelBackup } from './backup';
|
|
38
|
+
/**
|
|
39
|
+
* Force the WAL back into the principal database and truncate the log file.
|
|
40
|
+
*
|
|
41
|
+
* `TRUNCATE` — not `PASSIVE` — because `PASSIVE` gives up silently when a reader holds the
|
|
42
|
+
* WAL, leaving a WAL that never drains while every log line says the backup succeeded.
|
|
43
|
+
*
|
|
44
|
+
* 🔴 **`busy` is the only field worth reading, and that is a measured correction.** Under
|
|
45
|
+
* `TRUNCATE` SQLite reports the counters from AFTER the truncation, so a completely
|
|
46
|
+
* successful checkpoint returns `(busy 0, log 0, checkpointed 0)` — measured 2026-09-16,
|
|
47
|
+
* draining a 70,072-byte WAL to zero reported `checkpointed: 0`. Anything asserting
|
|
48
|
+
* `checkpointed > 0` as proof of work is asserting a value that is structurally always
|
|
49
|
+
* zero here. The honest post-condition is the WAL file's SIZE, which is what
|
|
50
|
+
* `backup.spec.ts` checks; the honest error signal is `busy`.
|
|
51
|
+
*/
|
|
52
|
+
export function checkpointWal(db) {
|
|
53
|
+
// One row: (busy, log, checkpointed). See the note above on what they mean here.
|
|
54
|
+
const row = db.query('PRAGMA wal_checkpoint(TRUNCATE)').get();
|
|
55
|
+
return {
|
|
56
|
+
busy: (row?.busy ?? 0) === 1,
|
|
57
|
+
logPages: row?.log ?? 0,
|
|
58
|
+
checkpointed: row?.checkpointed ?? 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The Mac-hosted implementation: checkpoint, then `VACUUM INTO` a fresh file.
|
|
63
|
+
*
|
|
64
|
+
* `VACUUM INTO` is already transactionally consistent under WAL, so the checkpoint is not
|
|
65
|
+
* what makes the copy correct — it is what keeps the WAL from growing without bound on a
|
|
66
|
+
* database that is written far more often than it is read, which is how a 2.2 MB database
|
|
67
|
+
* came to carry a 2.1 MB WAL.
|
|
68
|
+
*/
|
|
69
|
+
export function createLocalBackup(opts) {
|
|
70
|
+
return {
|
|
71
|
+
needsPeriodicCapture: true,
|
|
72
|
+
async capture() {
|
|
73
|
+
const checkpoint = checkpointWal(opts.db);
|
|
74
|
+
if (checkpoint.busy) {
|
|
75
|
+
// Not fatal — `VACUUM INTO` still produces a consistent copy — but it is the
|
|
76
|
+
// signal that the WAL is not draining, and silence here is how it grows.
|
|
77
|
+
console.warn(`[d1/backup] wal_checkpoint(TRUNCATE) reported BUSY for ${opts.sourcePath} — ` +
|
|
78
|
+
`${checkpoint.logPages} page(s) still in the WAL. A reader is holding it open.`);
|
|
79
|
+
}
|
|
80
|
+
if (!existsSync(opts.destDir))
|
|
81
|
+
mkdirSync(opts.destDir, { recursive: true });
|
|
82
|
+
const now = new Date();
|
|
83
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
84
|
+
const name = opts.nameFor?.(now) ?? `backup-${stamp}.sqlite`;
|
|
85
|
+
const dest = join(opts.destDir, name);
|
|
86
|
+
const bytes = snapshotSqlite(opts.sourcePath, dest);
|
|
87
|
+
return {
|
|
88
|
+
kind: 'file',
|
|
89
|
+
ref: dest,
|
|
90
|
+
createdAt: now.toISOString(),
|
|
91
|
+
bytes,
|
|
92
|
+
restoreCommand: `cp '${dest}' '${opts.sourcePath}' # with the app stopped`,
|
|
93
|
+
exportCommand: null,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Pick the backup implementation from the driver flavor, so an app's job definition reads
|
|
100
|
+
* the same on both sides.
|
|
101
|
+
*
|
|
102
|
+
* 🔴 This lives here rather than in `./backup` because it CONSTRUCTS the local one, and a
|
|
103
|
+
* construction is a value import: a `backupFor` in the barrel would drag `bun:sqlite` back
|
|
104
|
+
* in for every Worker. A Worker never needs it — its flavor is always `'d1'`, so it calls
|
|
105
|
+
* {@link createTimeTravelBackup} from `cursedbelt-server/d1` directly. This is the seam for
|
|
106
|
+
* a host that genuinely has both.
|
|
107
|
+
*/
|
|
108
|
+
export function backupFor(flavor, local, remote) {
|
|
109
|
+
return flavor === 'local' ? createLocalBackup(local()) : createTimeTravelBackup(remote());
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.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.",
|
|
@@ -84,6 +84,12 @@
|
|
|
84
84
|
"source": "./src/server/d1/index.ts",
|
|
85
85
|
"import": "./dist/server/d1/index.js"
|
|
86
86
|
},
|
|
87
|
+
"./d1/backup-local": {
|
|
88
|
+
"types": "./dist/server/d1/localBackup.d.ts",
|
|
89
|
+
"bun": "./src/server/d1/localBackup.ts",
|
|
90
|
+
"source": "./src/server/d1/localBackup.ts",
|
|
91
|
+
"import": "./dist/server/d1/localBackup.js"
|
|
92
|
+
},
|
|
87
93
|
"./d1/kysely": {
|
|
88
94
|
"types": "./dist/server/d1/kysely.d.ts",
|
|
89
95
|
"bun": "./src/server/d1/kysely.ts",
|
|
@@ -4,8 +4,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import pkg from '../package.json';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* A public subpath must not STATICALLY drag
|
|
8
|
-
*
|
|
7
|
+
* A public subpath must not STATICALLY drag anything its consumers cannot have — an
|
|
8
|
+
* OPTIONAL PEER they have not installed, or a BUN BUILTIN their runtime does not provide.
|
|
9
|
+
* A static import is paid at import time (or at bundle time) by every consumer, including
|
|
10
|
+
* the ones that will never call it.
|
|
9
11
|
*
|
|
10
12
|
* ## 🔴 This is the same defect for the THIRD time, and the first two are why the rule is
|
|
11
13
|
* a check rather than a sentence
|
|
@@ -39,13 +41,45 @@ import pkg from '../package.json';
|
|
|
39
41
|
* The fix was to give `createD1Kysely` its own subpath (`./d1/kysely`) and take it out of
|
|
40
42
|
* the barrel. This spec is what stops the fourth occurrence.
|
|
41
43
|
*
|
|
44
|
+
* ## 🔴 And the FOURTH, which was the same class one step wider
|
|
45
|
+
*
|
|
46
|
+
* Measured 2026-09-18 by the first real Worker deploy in this fleet (`apps/patterns`),
|
|
47
|
+
* hours after the `./d1/kysely` split above:
|
|
48
|
+
*
|
|
49
|
+
* ✘ [ERROR] Could not resolve "bun:sqlite"
|
|
50
|
+
* node_modules/cursedbelt-server/dist/server/sqlite/backup.js:1:25
|
|
51
|
+
*
|
|
52
|
+
* `wrangler deploy` failed outright. `./d1`'s index re-exported `./backup`, whose local
|
|
53
|
+
* half imported `snapshotSqlite` from `../sqlite/backup`, which imports `Database` from
|
|
54
|
+
* `bun:sqlite` — so the seam written FOR Workers was the one thing that could not load on
|
|
55
|
+
* one. esbuild resolves imports statically, so it did not matter that a Worker never calls
|
|
56
|
+
* it, and the error named the library's leaf rather than the barrel that dragged it in.
|
|
57
|
+
*
|
|
58
|
+
* `kysely` and `bun:sqlite` are the same defect with different missing things: a public
|
|
59
|
+
* subpath may not statically reach what its consumers do not have. An optional peer is
|
|
60
|
+
* absent because nobody installed it; a bun builtin is absent because `workerd` is not Bun
|
|
61
|
+
* and never will be. Neither can be fixed by the consumer, and both fail at import/bundle
|
|
62
|
+
* time rather than at the call that needed them. So both are checked here, off the same
|
|
63
|
+
* bundle, against the same shape of allowlist. The fix was to give the local half its own
|
|
64
|
+
* subpath (`./d1/backup-local`) and take it out of the barrel — exactly the `./d1/kysely`
|
|
65
|
+
* move, a second time.
|
|
66
|
+
*
|
|
42
67
|
* ## What it measures
|
|
43
68
|
*
|
|
44
69
|
* 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
|
|
70
|
+
* remaining static specifiers are read out of the emitted JS — bare ones checked against
|
|
71
|
+
* {@link MAY_DRAG}, `bun:*` ones against {@link MAY_NEED_BUN}. A DYNAMIC `await
|
|
46
72
|
* import('sharp')` inside the function that needs it matches neither branch of
|
|
47
73
|
* {@link STATIC_SPECIFIER} and must not — that is the lazy shape this file exists to
|
|
48
|
-
* permit, not forbid.
|
|
74
|
+
* permit, not forbid. A TYPE-ONLY `import type { Database } from 'bun:sqlite'` is erased
|
|
75
|
+
* by the bundler and so does not count either, which is correct and is why `./d1/testing`
|
|
76
|
+
* and `./d1` itself pass while holding that exact line.
|
|
77
|
+
*
|
|
78
|
+
* `bun:*` is the whole bun-builtin class, not just `bun:sqlite` — `bun:ffi`, `bun:jsc` and
|
|
79
|
+
* `bun:test` are equally absent on a Worker. `node:*` is deliberately NOT checked: Workers
|
|
80
|
+
* provide a growing subset of it under `nodejs_compat`, so which members are absent depends
|
|
81
|
+
* on the consumer's compatibility date, and a check whose verdict moves with somebody
|
|
82
|
+
* else's config is a check that will be deleted the first time it is wrong.
|
|
49
83
|
*
|
|
50
84
|
* ## Verified failing before it was trusted (2026-09-18)
|
|
51
85
|
*
|
|
@@ -56,6 +90,11 @@ import pkg from '../package.json';
|
|
|
56
90
|
* → red identically. That is the branch that matters.
|
|
57
91
|
* · removing `'./jobs'` from {@link MAY_DRAG} → red, proving the allowlist is load-bearing
|
|
58
92
|
* rather than decorative.
|
|
93
|
+
* · re-adding `export { createLocalBackup } from './localBackup'` to
|
|
94
|
+
* `src/server/d1/index.ts` — the deploy failure above, reproduced
|
|
95
|
+
* → red: "./d1 statically reaches bun builtin(s): bun:sqlite"
|
|
96
|
+
* · removing `'./sqlite'` from {@link MAY_NEED_BUN} → red, same proof for the second
|
|
97
|
+
* allowlist.
|
|
59
98
|
*/
|
|
60
99
|
|
|
61
100
|
const REPO = fileURLToPath(new URL('..', import.meta.url));
|
|
@@ -82,6 +121,31 @@ const MAY_DRAG: Record<string, readonly string[]> = {
|
|
|
82
121
|
'./jobs': ['plainjob'],
|
|
83
122
|
};
|
|
84
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The subpaths whose whole PURPOSE is a bun-specific runtime, mapped to the builtins they
|
|
126
|
+
* are allowed to reach. Everything NOT listed here must be importable on `workerd`, which
|
|
127
|
+
* is the property the D1 seam exists to deliver.
|
|
128
|
+
*
|
|
129
|
+
* 🔴 Measured 2026-09-18 after the `./d1/backup-local` split: these five are the ONLY
|
|
130
|
+
* subpaths in the map that reach a bun builtin at all. Like {@link MAY_DRAG} it is an
|
|
131
|
+
* allowlist, not a baseline — it can only shrink, and an entry that stops being true is a
|
|
132
|
+
* failure rather than a tidy-up.
|
|
133
|
+
*/
|
|
134
|
+
const MAY_NEED_BUN: Record<string, readonly string[]> = {
|
|
135
|
+
// The whole-package barrel — it re-exports everything, including the Mac-hosted halves.
|
|
136
|
+
// An app importing `cursedbelt-server` whole is a Bun server by construction; an app
|
|
137
|
+
// importing `cursedbelt-server/d1` is not, and that is the distinction this protects.
|
|
138
|
+
'.': ['bun:sqlite'],
|
|
139
|
+
// The local backup IS `VACUUM INTO` against a live `bun:sqlite` handle. Split out of
|
|
140
|
+
// `./d1` on 2026-09-18 precisely so the seam itself stops paying for it — see the header.
|
|
141
|
+
'./d1/backup-local': ['bun:sqlite'],
|
|
142
|
+
// The guard's revocation store is a `bun:sqlite` table, and `./guard` mounts it.
|
|
143
|
+
'./guard': ['bun:sqlite'],
|
|
144
|
+
'./guard/revocations': ['bun:sqlite'],
|
|
145
|
+
// `./sqlite` is the bun:sqlite tier. Naming it is the point of the subpath.
|
|
146
|
+
'./sqlite': ['bun:sqlite'],
|
|
147
|
+
};
|
|
148
|
+
|
|
85
149
|
const OPTIONAL_PEERS = new Set(
|
|
86
150
|
Object.entries(
|
|
87
151
|
(pkg as { peerDependenciesMeta?: Record<string, { optional?: boolean }> }).peerDependenciesMeta ?? {},
|
|
@@ -107,13 +171,14 @@ const packageOf = (specifier: string): string | undefined => {
|
|
|
107
171
|
};
|
|
108
172
|
|
|
109
173
|
/**
|
|
110
|
-
* Bundle one entry and return
|
|
174
|
+
* Bundle one entry and return what it still statically imports, split into the two classes
|
|
175
|
+
* a consumer can be missing: bare `packages`, and `bun` builtins.
|
|
111
176
|
*
|
|
112
177
|
* 🔴 A bundle that did not happen must never read as a subpath that pulls nothing, so a
|
|
113
178
|
* non-zero exit or an empty bundle THROWS rather than returning an empty set. That is the
|
|
114
179
|
* one way a check like this dies quietly.
|
|
115
180
|
*/
|
|
116
|
-
const
|
|
181
|
+
const scan = (entry: string, subpath: string): { packages: Set<string>; bun: Set<string> } => {
|
|
117
182
|
const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-server-barrel-scan/${subpath.replace(/[^a-z0-9]+/gi, '-')}`;
|
|
118
183
|
const build = Bun.spawnSync(['bun', 'build', entry, '--target=bun', '--packages=external', '--outdir', outdir], {
|
|
119
184
|
cwd: REPO,
|
|
@@ -127,14 +192,35 @@ const staticExternalsOf = (entry: string, subpath: string): Set<string> => {
|
|
|
127
192
|
`could not bundle ${subpath} (${entry}, exit ${build.exitCode}) — a check that cannot measure is not a passing check:\n${build.stderr.toString()}`,
|
|
128
193
|
);
|
|
129
194
|
}
|
|
130
|
-
const
|
|
195
|
+
const packages = new Set<string>();
|
|
196
|
+
const bun = new Set<string>();
|
|
131
197
|
for (const match of bundle.matchAll(STATIC_SPECIFIER)) {
|
|
132
198
|
const specifier = match[1] ?? match[2];
|
|
133
|
-
if (specifier === undefined || specifier.startsWith('.')
|
|
199
|
+
if (specifier === undefined || specifier.startsWith('.')) continue;
|
|
200
|
+
// `bun:*` is a builtin, not a package — `workerd` has none of them, and no consumer
|
|
201
|
+
// can install one. `node:*` falls through to `packageOf` and is simply never an
|
|
202
|
+
// optional peer, so it lands in neither verdict; the header says why.
|
|
203
|
+
if (specifier.startsWith('bun:')) {
|
|
204
|
+
bun.add(specifier);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
134
207
|
const name = packageOf(specifier);
|
|
135
|
-
if (name !== undefined)
|
|
208
|
+
if (name !== undefined) packages.add(name);
|
|
136
209
|
}
|
|
137
|
-
return
|
|
210
|
+
return { packages, bun };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One bundle per subpath, shared by both assertions. Bundling is the expensive part of this
|
|
215
|
+
* file and the answer cannot change between two `it`s in the same run.
|
|
216
|
+
*/
|
|
217
|
+
const scans = new Map<string, { packages: Set<string>; bun: Set<string> }>();
|
|
218
|
+
const scanOnce = (entry: string, subpath: string): { packages: Set<string>; bun: Set<string> } => {
|
|
219
|
+
const cached = scans.get(subpath);
|
|
220
|
+
if (cached !== undefined) return cached;
|
|
221
|
+
const fresh = scan(entry, subpath);
|
|
222
|
+
scans.set(subpath, fresh);
|
|
223
|
+
return fresh;
|
|
138
224
|
};
|
|
139
225
|
|
|
140
226
|
/** Every subpath with a `source` entry — the ones whose real graph can be measured. */
|
|
@@ -142,7 +228,7 @@ const SUBPATHS = Object.entries(pkg.exports as Record<string, { source?: string
|
|
|
142
228
|
.filter(([, e]) => typeof e?.source === 'string' && /\.tsx?$/.test(e.source))
|
|
143
229
|
.map(([subpath, e]) => ({ subpath, entry: (e as { source: string }).source }));
|
|
144
230
|
|
|
145
|
-
describe('no public subpath statically
|
|
231
|
+
describe('no public subpath statically reaches what its consumers may not have', () => {
|
|
146
232
|
it('measures a non-trivial number of subpaths, so a broken exports map cannot pass vacuously', () => {
|
|
147
233
|
expect(SUBPATHS.length).toBeGreaterThan(20);
|
|
148
234
|
expect(OPTIONAL_PEERS.size).toBeGreaterThan(0);
|
|
@@ -151,7 +237,7 @@ describe('no public subpath statically drags an optional peer', () => {
|
|
|
151
237
|
for (const { subpath, entry } of SUBPATHS) {
|
|
152
238
|
const allowed = MAY_DRAG[subpath] ?? [];
|
|
153
239
|
it(`${subpath} drags ${allowed.length === 0 ? 'no optional peer' : allowed.join(' + ') + ' and nothing more'}`, () => {
|
|
154
|
-
const dragged = [...
|
|
240
|
+
const dragged = [...scanOnce(entry, subpath).packages].filter((n) => OPTIONAL_PEERS.has(n)).sort();
|
|
155
241
|
const unexpected = dragged.filter((n) => !allowed.includes(n));
|
|
156
242
|
expect(
|
|
157
243
|
unexpected,
|
|
@@ -170,4 +256,31 @@ describe('no public subpath statically drags an optional peer', () => {
|
|
|
170
256
|
);
|
|
171
257
|
});
|
|
172
258
|
}
|
|
259
|
+
|
|
260
|
+
for (const { subpath, entry } of SUBPATHS) {
|
|
261
|
+
const allowed = MAY_NEED_BUN[subpath] ?? [];
|
|
262
|
+
it(`${subpath} ${allowed.length === 0 ? 'loads on a Worker — no bun builtin' : `needs ${allowed.join(' + ')} and nothing more`}`, () => {
|
|
263
|
+
const reached = [...scanOnce(entry, subpath).bun].sort();
|
|
264
|
+
const unexpected = reached.filter((n) => !allowed.includes(n));
|
|
265
|
+
expect(
|
|
266
|
+
unexpected,
|
|
267
|
+
`${subpath} statically reaches bun builtin(s): ${unexpected.join(', ')}\n` +
|
|
268
|
+
` '${pkg.name}${subpath.slice(1)}' can no longer be imported on Cloudflare Workers — wrangler\n` +
|
|
269
|
+
` bundles with esbuild, which resolves imports STATICALLY, so 'wrangler deploy' fails with\n` +
|
|
270
|
+
` \`Could not resolve "${unexpected[0] ?? 'bun:sqlite'}"\` naming the leaf rather than this barrel.\n` +
|
|
271
|
+
` Nothing needs to CALL it. Measured 2026-09-18 on apps/patterns: the ./d1 seam written for\n` +
|
|
272
|
+
` Workers was itself unloadable on one. Give the bun-specific half its own subpath — see\n` +
|
|
273
|
+
` ./d1/backup-local, which is exactly this fix — or make the import \`import type\`, which the\n` +
|
|
274
|
+
` bundler erases.`,
|
|
275
|
+
).toEqual([]);
|
|
276
|
+
|
|
277
|
+
// Same one-way ratchet as above: an allowed subpath that no longer reaches what it
|
|
278
|
+
// promised is a stale exception, and this list may only shrink.
|
|
279
|
+
const stale = allowed.filter((n) => !reached.includes(n));
|
|
280
|
+
expect(
|
|
281
|
+
stale,
|
|
282
|
+
`${subpath} is allowed to reach ${stale.join(', ')} but no longer does — remove the exception`,
|
|
283
|
+
).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
173
286
|
});
|
|
@@ -3,7 +3,10 @@ import { afterEach, describe, expect, test } from 'bun:test';
|
|
|
3
3
|
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { createTimeTravelBackup } from './backup';
|
|
7
|
+
// The local half is a separate module on purpose — it reaches `bun:sqlite`, so it may not
|
|
8
|
+
// sit in the `./d1` barrel a Worker imports. See `./localBackup.ts`'s header.
|
|
9
|
+
import { backupFor, checkpointWal, createLocalBackup } from './localBackup';
|
|
7
10
|
|
|
8
11
|
const dirs: string[] = [];
|
|
9
12
|
const scratch = (): string => {
|
package/src/server/d1/backup.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The backup seam — `PRAGMA wal_checkpoint(TRUNCATE)` locally, Time Travel remotely.
|
|
3
3
|
*
|
|
4
|
-
* ## 🔴 BOTH, not one
|
|
4
|
+
* ## 🔴 BOTH, not one — but only one of them is in this file
|
|
5
5
|
*
|
|
6
6
|
* D1 has no WAL a caller can checkpoint, and 30 days of free point-in-time restore is
|
|
7
7
|
* strictly better than the file copy it replaces. It is tempting to read that as "the
|
|
8
|
-
* checkpoint goes away". It does not:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the WAL is the only reason the existing `../sqlite/backup.ts` is safe.
|
|
8
|
+
* checkpoint goes away". It does not: the local implementation keeps checkpointing, the
|
|
9
|
+
* remote one records a restore coordinate, and both answer the same interface. An app's
|
|
10
|
+
* backup job stops caring which side it is on — which is the property that lets the job
|
|
11
|
+
* move before the database does.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* **The local half lives in `./localBackup` (`cursedbelt-server/d1/backup-local`), and
|
|
14
|
+
* that split is load-bearing.** It reaches `bun:sqlite` through `../sqlite/backup`, and
|
|
15
|
+
* `wrangler`'s esbuild resolves imports statically, so while it was re-exported from the
|
|
16
|
+
* `./d1` barrel no Worker could import the seam at all — measured 2026-09-18, the first
|
|
17
|
+
* real Worker deploy in this fleet died on `Could not resolve "bun:sqlite"`. This file
|
|
18
|
+
* therefore holds the interface and the remote implementation ONLY, and everything in it
|
|
19
|
+
* must stay importable on a Worker. `../../barrelsReachNoOptionalPeer.spec.ts` is what
|
|
20
|
+
* keeps it that way.
|
|
17
21
|
*
|
|
18
22
|
* ## The remote side does not "take" a backup, and that is not a gap
|
|
19
23
|
*
|
|
@@ -30,11 +34,6 @@
|
|
|
30
34
|
* deliberately not the same process.
|
|
31
35
|
*/
|
|
32
36
|
|
|
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
37
|
/** A restore coordinate — a file on this Mac, or a point in D1's retention window. */
|
|
39
38
|
export interface BackupPoint {
|
|
40
39
|
kind: 'file' | 'time-travel';
|
|
@@ -56,85 +55,6 @@ export interface DatabaseBackup {
|
|
|
56
55
|
readonly needsPeriodicCapture: boolean;
|
|
57
56
|
}
|
|
58
57
|
|
|
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
58
|
export interface TimeTravelOpts {
|
|
139
59
|
/** The database name as `wrangler` knows it — what the restore command needs. */
|
|
140
60
|
databaseName: string;
|
|
@@ -172,15 +92,3 @@ export function createTimeTravelBackup(opts: TimeTravelOpts): DatabaseBackup {
|
|
|
172
92
|
},
|
|
173
93
|
};
|
|
174
94
|
}
|
|
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
|
-
}
|
package/src/server/d1/index.ts
CHANGED
|
@@ -16,15 +16,22 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
export {
|
|
19
|
-
backupFor,
|
|
20
19
|
type BackupPoint,
|
|
21
|
-
checkpointWal,
|
|
22
|
-
createLocalBackup,
|
|
23
20
|
createTimeTravelBackup,
|
|
24
21
|
type DatabaseBackup,
|
|
25
|
-
type LocalBackupOpts,
|
|
26
22
|
type TimeTravelOpts,
|
|
27
23
|
} from './backup';
|
|
24
|
+
// 🔴 `./localBackup` is deliberately NOT re-exported here — import `checkpointWal`,
|
|
25
|
+
// `createLocalBackup`, `LocalBackupOpts` and `backupFor` from
|
|
26
|
+
// `cursedbelt-server/d1/backup-local`. It reaches `bun:sqlite` for real (via
|
|
27
|
+
// `../sqlite/backup`'s `snapshotSqlite`), and `wrangler` bundles with esbuild, which
|
|
28
|
+
// resolves imports STATICALLY — so while it was re-exported here, `wrangler deploy` died
|
|
29
|
+
// with `Could not resolve "bun:sqlite"` naming `dist/server/sqlite/backup.js`, and the
|
|
30
|
+
// seam written FOR Workers was the one thing that could not load on one. Measured
|
|
31
|
+
// 2026-09-18 by the first real Worker deploy in this fleet (`apps/patterns`). Same class
|
|
32
|
+
// as the note below, one step wider: a barrel must reach neither an optional peer nor a
|
|
33
|
+
// runtime the target platform lacks. `barrelsReachNoOptionalPeer.spec.ts` checks both.
|
|
34
|
+
//
|
|
28
35
|
// 🔴 `./kysely` is deliberately NOT re-exported here — import it from
|
|
29
36
|
// `cursedbelt-server/d1/kysely`. It statically imports `kysely` (real values: `Kysely`,
|
|
30
37
|
// `SqliteAdapter`, `SqliteQueryCompiler`), which is an OPTIONAL peer, so re-exporting it
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/d1/backup-local` — the Mac-hosted half of the backup seam:
|
|
3
|
+
* `PRAGMA wal_checkpoint(TRUNCATE)`, then `VACUUM INTO` a fresh file.
|
|
4
|
+
*
|
|
5
|
+
* ## 🔴 Why this is a subpath and not part of `./d1`
|
|
6
|
+
*
|
|
7
|
+
* It reaches `bun:sqlite` for real, through `../sqlite/backup`'s {@link snapshotSqlite}.
|
|
8
|
+
* `wrangler deploy` bundles with esbuild, which resolves imports STATICALLY — so a Worker
|
|
9
|
+
* that imported `cursedbelt-server/d1` for `createRemoteD1` paid for this file too and the
|
|
10
|
+
* deploy failed outright:
|
|
11
|
+
*
|
|
12
|
+
* ✘ [ERROR] Could not resolve "bun:sqlite"
|
|
13
|
+
* node_modules/cursedbelt-server/dist/server/sqlite/backup.js:1:25
|
|
14
|
+
*
|
|
15
|
+
* Measured 2026-09-18 by the first real Worker deploy in this fleet (`apps/patterns`). The
|
|
16
|
+
* error names the leaf rather than the barrel that dragged it in, which is why the first
|
|
17
|
+
* five minutes went into the wrong file. It is the same defect as `./d1/kysely` one class
|
|
18
|
+
* wider: there, the barrel reached an optional PEER; here, it reached a runtime the target
|
|
19
|
+
* platform does not have. `../../barrelsReachNoOptionalPeer.spec.ts` now checks both.
|
|
20
|
+
*
|
|
21
|
+
* The remote half — {@link createTimeTravelBackup}, {@link BackupPoint},
|
|
22
|
+
* {@link DatabaseBackup} — stays in `./backup` and stays in the barrel, because a Worker
|
|
23
|
+
* needs it and it reaches nothing.
|
|
24
|
+
*
|
|
25
|
+
* ## The checkpoint is load-bearing, and stays
|
|
26
|
+
*
|
|
27
|
+
* D1 has no WAL a caller can checkpoint, so it is tempting to read the port as "the
|
|
28
|
+
* checkpoint goes away". It does not: `PRAGMA wal_checkpoint(TRUNCATE)` is in the backup
|
|
29
|
+
* path of every app in this fleet and it is load-bearing while any of them is still
|
|
30
|
+
* Mac-hosted. Measured previously, `family.sqlite` was **2.2 MB of principal against a
|
|
31
|
+
* 2.1 MB WAL** — an un-checkpointed copy is half a database, and `VACUUM INTO` merging the
|
|
32
|
+
* WAL is the only reason `../sqlite/backup.ts` is safe.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Database } from 'bun:sqlite';
|
|
36
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { snapshotSqlite } from '../sqlite/backup';
|
|
39
|
+
import type { BackupPoint, DatabaseBackup, TimeTravelOpts } from './backup';
|
|
40
|
+
import { createTimeTravelBackup } from './backup';
|
|
41
|
+
|
|
42
|
+
export interface LocalBackupOpts {
|
|
43
|
+
/** The live handle — checkpointed before the snapshot is taken. */
|
|
44
|
+
db: Database;
|
|
45
|
+
/** Path of the database file being backed up. */
|
|
46
|
+
sourcePath: string;
|
|
47
|
+
/** Directory the snapshot is written into. Created if missing. */
|
|
48
|
+
destDir: string;
|
|
49
|
+
/** Override the snapshot's file name. Defaults to `<name>-<ISO>.sqlite`. */
|
|
50
|
+
nameFor?: (now: Date) => string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Force the WAL back into the principal database and truncate the log file.
|
|
55
|
+
*
|
|
56
|
+
* `TRUNCATE` — not `PASSIVE` — because `PASSIVE` gives up silently when a reader holds the
|
|
57
|
+
* WAL, leaving a WAL that never drains while every log line says the backup succeeded.
|
|
58
|
+
*
|
|
59
|
+
* 🔴 **`busy` is the only field worth reading, and that is a measured correction.** Under
|
|
60
|
+
* `TRUNCATE` SQLite reports the counters from AFTER the truncation, so a completely
|
|
61
|
+
* successful checkpoint returns `(busy 0, log 0, checkpointed 0)` — measured 2026-09-16,
|
|
62
|
+
* draining a 70,072-byte WAL to zero reported `checkpointed: 0`. Anything asserting
|
|
63
|
+
* `checkpointed > 0` as proof of work is asserting a value that is structurally always
|
|
64
|
+
* zero here. The honest post-condition is the WAL file's SIZE, which is what
|
|
65
|
+
* `backup.spec.ts` checks; the honest error signal is `busy`.
|
|
66
|
+
*/
|
|
67
|
+
export function checkpointWal(db: Database): { busy: boolean; logPages: number; checkpointed: number } {
|
|
68
|
+
// One row: (busy, log, checkpointed). See the note above on what they mean here.
|
|
69
|
+
const row = db.query('PRAGMA wal_checkpoint(TRUNCATE)').get() as
|
|
70
|
+
| { busy?: number; log?: number; checkpointed?: number }
|
|
71
|
+
| null;
|
|
72
|
+
return {
|
|
73
|
+
busy: (row?.busy ?? 0) === 1,
|
|
74
|
+
logPages: row?.log ?? 0,
|
|
75
|
+
checkpointed: row?.checkpointed ?? 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The Mac-hosted implementation: checkpoint, then `VACUUM INTO` a fresh file.
|
|
81
|
+
*
|
|
82
|
+
* `VACUUM INTO` is already transactionally consistent under WAL, so the checkpoint is not
|
|
83
|
+
* what makes the copy correct — it is what keeps the WAL from growing without bound on a
|
|
84
|
+
* database that is written far more often than it is read, which is how a 2.2 MB database
|
|
85
|
+
* came to carry a 2.1 MB WAL.
|
|
86
|
+
*/
|
|
87
|
+
export function createLocalBackup(opts: LocalBackupOpts): DatabaseBackup {
|
|
88
|
+
return {
|
|
89
|
+
needsPeriodicCapture: true,
|
|
90
|
+
|
|
91
|
+
async capture(): Promise<BackupPoint> {
|
|
92
|
+
const checkpoint = checkpointWal(opts.db);
|
|
93
|
+
if (checkpoint.busy) {
|
|
94
|
+
// Not fatal — `VACUUM INTO` still produces a consistent copy — but it is the
|
|
95
|
+
// signal that the WAL is not draining, and silence here is how it grows.
|
|
96
|
+
console.warn(
|
|
97
|
+
`[d1/backup] wal_checkpoint(TRUNCATE) reported BUSY for ${opts.sourcePath} — ` +
|
|
98
|
+
`${checkpoint.logPages} page(s) still in the WAL. A reader is holding it open.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (!existsSync(opts.destDir)) mkdirSync(opts.destDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
const now = new Date();
|
|
104
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
105
|
+
const name = opts.nameFor?.(now) ?? `backup-${stamp}.sqlite`;
|
|
106
|
+
const dest = join(opts.destDir, name);
|
|
107
|
+
|
|
108
|
+
const bytes = snapshotSqlite(opts.sourcePath, dest);
|
|
109
|
+
return {
|
|
110
|
+
kind: 'file',
|
|
111
|
+
ref: dest,
|
|
112
|
+
createdAt: now.toISOString(),
|
|
113
|
+
bytes,
|
|
114
|
+
restoreCommand: `cp '${dest}' '${opts.sourcePath}' # with the app stopped`,
|
|
115
|
+
exportCommand: null,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pick the backup implementation from the driver flavor, so an app's job definition reads
|
|
123
|
+
* the same on both sides.
|
|
124
|
+
*
|
|
125
|
+
* 🔴 This lives here rather than in `./backup` because it CONSTRUCTS the local one, and a
|
|
126
|
+
* construction is a value import: a `backupFor` in the barrel would drag `bun:sqlite` back
|
|
127
|
+
* in for every Worker. A Worker never needs it — its flavor is always `'d1'`, so it calls
|
|
128
|
+
* {@link createTimeTravelBackup} from `cursedbelt-server/d1` directly. This is the seam for
|
|
129
|
+
* a host that genuinely has both.
|
|
130
|
+
*/
|
|
131
|
+
export function backupFor(
|
|
132
|
+
flavor: 'local' | 'd1',
|
|
133
|
+
local: () => LocalBackupOpts,
|
|
134
|
+
remote: () => TimeTravelOpts,
|
|
135
|
+
): DatabaseBackup {
|
|
136
|
+
return flavor === 'local' ? createLocalBackup(local()) : createTimeTravelBackup(remote());
|
|
137
|
+
}
|