From 7c7a048b326a86051152b6488b2ec115184a01b9 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:31:58 +0200 Subject: [PATCH] Initialize cold WAL bookkeeping for subprocess snapshots --- scripts/backup.ts | 4 +++- scripts/scheduled-backup.ts | 4 +++- src/ops/backups.test.ts | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/scripts/backup.ts b/scripts/backup.ts index a0396be..b6ceb90 100644 --- a/scripts/backup.ts +++ b/scripts/backup.ts @@ -1,5 +1,7 @@ import { Database } from "bun:sqlite"; import { databasePath } from "../src/db/config"; import { backupConfig, createBackup } from "../src/ops/backups"; -const db = new Database(databasePath, { readonly: true, strict: true }); +// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database. +// Only snapshot SQL runs here; never create a missing source file. +const db = new Database(databasePath, { readwrite: true, create: false, strict: true }); try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); } diff --git a/scripts/scheduled-backup.ts b/scripts/scheduled-backup.ts index 83150ea..47ac3fb 100644 --- a/scripts/scheduled-backup.ts +++ b/scripts/scheduled-backup.ts @@ -7,7 +7,9 @@ const config = JSON.parse(process.argv[3]) as BackupConfig; const latest = latestBackupTime(config); if (latest && Date.now() - latest < config.intervalMs) console.log(JSON.stringify({ timestamp: latest, created: false })); else { - const db = new Database(path, { readonly: true, strict: true }); + // SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database. + // Only snapshot SQL runs here; never create a missing source file. + const db = new Database(path, { readwrite: true, create: false, strict: true }); try { db.exec('PRAGMA busy_timeout=5000'); createBackup(db, config); diff --git a/src/ops/backups.test.ts b/src/ops/backups.test.ts index dc29f83..96a6385 100644 --- a/src/ops/backups.test.ts +++ b/src/ops/backups.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { Database } from "bun:sqlite"; -import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fixture } from "../habits/test-fixture"; @@ -116,3 +116,34 @@ test('scheduled backup ticks coalesce, expose failure and recovery, and stop adm const disabled = startBackups('/tmp/test-only.sqlite', { ...config, enabled: false }, run); await disabled.ready; expect(calls).toBe(2); disabled.stop(); }); + + +test('the first scheduled snapshot initializes cold WAL bookkeeping without changing source rows', async () => { + const directory = mkdtempSync(join(tmpdir(), 'minabot-cold-wal-')); + const path = join(directory, 'live.sqlite'), f = fixture(path); + f.sqlite.exec('PRAGMA journal_mode=DELETE'); f.close(); + const db = new Database(path, { readwrite: true, create: false, strict: true }); + let runner: ReturnType | undefined; + try { + db.exec('PRAGMA journal_mode=WAL'); + expect(existsSync(`${path}-shm`)).toBe(false); + runner = startBackups(db, backupConfig(path, {})); + await runner.ready; + expect(runner.state.failed).toBe(false); + expect(runner.state.lastSuccess).toBeGreaterThan(0); + expect(db.query('SELECT count(*) AS n FROM users').get()).toEqual({ n: 2 }); + expect(db.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 }); + expect(db.query('SELECT total_changes() AS n').get()).toEqual({ n: 0 }); + } finally { runner?.stop(); db.close(); rmSync(directory, { recursive: true, force: true }); } +}); + +test('the scheduled snapshot never creates a missing source database', async () => { + const directory = mkdtempSync(join(tmpdir(), 'minabot-missing-source-')); + const path = join(directory, 'missing.sqlite'); + const runner = startBackups(path, backupConfig(path, {})); + try { + await runner.ready; + expect(runner.state.failed).toBe(true); + expect(existsSync(path)).toBe(false); + } finally { runner.stop(); rmSync(directory, { recursive: true, force: true }); } +});