cursedbelt-server 4.6.0 β†’ 4.8.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.
@@ -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: `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.
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
- * 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.
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;
@@ -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: `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.
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 { backupFor, type BackupPoint, checkpointWal, createLocalBackup, createTimeTravelBackup, type DatabaseBackup, type LocalBackupOpts, type TimeTravelOpts, } from './backup';
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';
@@ -14,7 +14,18 @@
14
14
  * const ky = createD1Kysely(db);
15
15
  * ```
16
16
  */
17
- export { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup, } from './backup';
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
+ }
@@ -454,7 +454,12 @@ export function createBinaryStore(cfg) {
454
454
  const titleQs = title ? `&title=${encodeURIComponent(title)}` : '';
455
455
  const immutableQs = opts?.immutable ? '&immutable=1' : '';
456
456
  for (let ci = 0; ci < total; ci++) {
457
- const token = await signToken({ k: appKey(key), sid, ci, tc: total }, UPLOAD_TOKEN_TTL_SECONDS);
457
+ // πŸ”΄ `sz` is the SOURCE length, and it is what makes `tc` above not load-bearing: bs
458
+ // checks it before and after assembly and answers 422 rather than indexing a short
459
+ // object (see `FileTokenClaims.sz` in `./types`). `total` is derived from `blob.size`
460
+ // two lines up, so the number is already in hand β€” declaring it costs nothing, and
461
+ // NOT declaring it is what left 579 of 579 fleet assembles unprovable on 2026-09-18.
462
+ const token = await signToken({ k: appKey(key), sid, ci, tc: total, sz: blob.size }, UPLOAD_TOKEN_TTL_SECONDS);
458
463
  const slice = new Uint8Array(await blob
459
464
  .slice(ci * chunkBytes, Math.min((ci + 1) * chunkBytes, blob.size))
460
465
  .arrayBuffer());
@@ -69,6 +69,32 @@ export type FileTokenClaims = {
69
69
  ci?: number;
70
70
  /** Total chunk count. */
71
71
  tc?: number;
72
+ /**
73
+ * The SOURCE file's exact byte length, declared by the minting client when the chunk session
74
+ * is created. binary-server checks it before and after assembly and answers 422 on a
75
+ * mismatch, indexing nothing.
76
+ *
77
+ * πŸ”΄ This is the only END-TO-END integrity claim the upload path has; everything else in it
78
+ * is an internal consistency check. `tc` comes from the client, the assemble fires when `tc`
79
+ * parts have been counted, and the checksum is computed over whatever was assembled β€” so a
80
+ * session that believes a 6-minute video is one 16 MiB part gets exactly that stored,
81
+ * faithfully, with a 201. That is what happened to `collections/file/u_fd668e6a-…` on
82
+ * 2026-08-13: nothing anywhere compared the finished object to the file it came from.
83
+ *
84
+ * With `sz`, `tc` stops being load-bearing. The part size is the client's choice by design
85
+ * (a browser picks its own slice), so `tc` and the chunk size are one degree of freedom and
86
+ * the server cannot derive either; declaring the length costs a client nothing, because
87
+ * every mint site already has the number in hand. A wrong `tc` can then only cost a REFUSED
88
+ * upload, never a short object indexed `active`.
89
+ *
90
+ * Optional, and it stays optional until a tenant is measured at zero undeclared assembles:
91
+ * an upload the owner cannot complete is worse than the hole. An absent `sz` is recorded on
92
+ * the assemble event (`resp_meta.declared` is `null`), so which tenants still do not declare
93
+ * is a query rather than an audit. `apps/binary-server/src/tokens.ts` is the mirror this
94
+ * shape is verified against, and `declaredSizeOf` in `apps/binary-server/src/server.ts` is
95
+ * what reads it.
96
+ */
97
+ sz?: number;
72
98
  /** Download filename β†’ Content-Disposition. */
73
99
  fn?: string;
74
100
  dl?: 'inline' | 'attachment';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.6.0",
3
+ "version": "4.8.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 an OPTIONAL peer, because a static import is
8
- * paid at import time by every consumer β€” including the ones that will never call it.
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. A DYNAMIC `await
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 the bare packages it still imports.
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 staticExternalsOf = (entry: string, subpath: string): Set<string> => {
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 found = new Set<string>();
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('.') || specifier.startsWith('bun:')) continue;
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) found.add(name);
208
+ if (name !== undefined) packages.add(name);
136
209
  }
137
- return found;
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 drags an optional peer', () => {
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 = [...staticExternalsOf(entry, subpath)].filter((n) => OPTIONAL_PEERS.has(n)).sort();
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 { backupFor, checkpointWal, createLocalBackup, createTimeTravelBackup } from './backup';
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 => {
@@ -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: `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.
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
- * 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.
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
- }
@@ -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
+ }
@@ -283,6 +283,26 @@ describe('putLarge', () => {
283
283
  expect(claims.every((p) => p.k === 'roms/video/x')).toBe(true);
284
284
  });
285
285
 
286
+ it('πŸ”΄ declares the SOURCE byte length as `sz` on every chunk, whatever the split', async () => {
287
+ // The claim that makes `tc` above not load-bearing. binary-server checks `sz` before and
288
+ // after assembly and answers 422 rather than indexing a short object β€” but only for a
289
+ // client that declares it, and on 2026-09-18 that was 0 of 579 fleet assembles. So: the
290
+ // number is the SOURCE's, it is identical on every part, and it does not move when the
291
+ // client picks a different slice size (`tc` and the chunk size are the client's one
292
+ // degree of freedom; the length is not).
293
+ const store = makeStore();
294
+ reply = () => ok(null, { status: 201 });
295
+
296
+ await store.putLarge('video/x', new Uint8Array(10), 'video/mp4', { chunkBytes: 4 });
297
+ expect(calls.map((c) => tokenOf(c.url).payload.sz)).toEqual([10, 10, 10]);
298
+
299
+ calls = [];
300
+ await store.putLarge('video/y', new Uint8Array(10), 'video/mp4', { chunkBytes: 7 });
301
+ const claims = calls.map((c) => tokenOf(c.url).payload);
302
+ expect(claims.map((p) => p.tc)).toEqual([2, 2]); // a different split…
303
+ expect(claims.map((p) => p.sz)).toEqual([10, 10]); // …and the same declared length
304
+ });
305
+
286
306
  it('πŸ”΄ sends the real BYTES of each slice, not a lazy view', async () => {
287
307
  // The 2026-08-13 hang: a Bun.file partial view handed to fetch never sends, and
288
308
  // 42 files / 35 GB failed 42/42 for a day with nothing in binary-server's log.
@@ -741,8 +741,13 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
741
741
  const titleQs = title ? `&title=${encodeURIComponent(title)}` : '';
742
742
  const immutableQs = opts?.immutable ? '&immutable=1' : '';
743
743
  for (let ci = 0; ci < total; ci++) {
744
+ // πŸ”΄ `sz` is the SOURCE length, and it is what makes `tc` above not load-bearing: bs
745
+ // checks it before and after assembly and answers 422 rather than indexing a short
746
+ // object (see `FileTokenClaims.sz` in `./types`). `total` is derived from `blob.size`
747
+ // two lines up, so the number is already in hand β€” declaring it costs nothing, and
748
+ // NOT declaring it is what left 579 of 579 fleet assembles unprovable on 2026-09-18.
744
749
  const token = await signToken(
745
- { k: appKey(key), sid, ci, tc: total },
750
+ { k: appKey(key), sid, ci, tc: total, sz: blob.size },
746
751
  UPLOAD_TOKEN_TTL_SECONDS,
747
752
  );
748
753
  const slice = new Uint8Array(
@@ -93,6 +93,32 @@ export type FileTokenClaims = {
93
93
  ci?: number;
94
94
  /** Total chunk count. */
95
95
  tc?: number;
96
+ /**
97
+ * The SOURCE file's exact byte length, declared by the minting client when the chunk session
98
+ * is created. binary-server checks it before and after assembly and answers 422 on a
99
+ * mismatch, indexing nothing.
100
+ *
101
+ * πŸ”΄ This is the only END-TO-END integrity claim the upload path has; everything else in it
102
+ * is an internal consistency check. `tc` comes from the client, the assemble fires when `tc`
103
+ * parts have been counted, and the checksum is computed over whatever was assembled β€” so a
104
+ * session that believes a 6-minute video is one 16 MiB part gets exactly that stored,
105
+ * faithfully, with a 201. That is what happened to `collections/file/u_fd668e6a-…` on
106
+ * 2026-08-13: nothing anywhere compared the finished object to the file it came from.
107
+ *
108
+ * With `sz`, `tc` stops being load-bearing. The part size is the client's choice by design
109
+ * (a browser picks its own slice), so `tc` and the chunk size are one degree of freedom and
110
+ * the server cannot derive either; declaring the length costs a client nothing, because
111
+ * every mint site already has the number in hand. A wrong `tc` can then only cost a REFUSED
112
+ * upload, never a short object indexed `active`.
113
+ *
114
+ * Optional, and it stays optional until a tenant is measured at zero undeclared assembles:
115
+ * an upload the owner cannot complete is worse than the hole. An absent `sz` is recorded on
116
+ * the assemble event (`resp_meta.declared` is `null`), so which tenants still do not declare
117
+ * is a query rather than an audit. `apps/binary-server/src/tokens.ts` is the mirror this
118
+ * shape is verified against, and `declaredSizeOf` in `apps/binary-server/src/server.ts` is
119
+ * what reads it.
120
+ */
121
+ sz?: number;
96
122
  /** Download filename β†’ Content-Disposition. */
97
123
  fn?: string;
98
124
  dl?: 'inline' | 'attachment';