write/server/db.js
chris c9126f718d DB resilience: hourly WAL checkpoint + daily rolling 7-day backup
- Hourly PASSIVE WAL checkpoint prevents unbounded WAL growth and
  ensures all writes are merged into the main .db file regularly.
  Previously the WAL was never checkpointed — all data was accumulating
  in stories.db-wal with no protection if that file was lost.
- Daily backup using better-sqlite3 .backup() writes a safe online
  snapshot to data/backups/stories-YYYY-MM-DD.db on startup and
  every 24 h; keeps last 7 days, pruning older ones automatically.
- busy_timeout = 5000 so concurrent requests wait briefly rather
  than failing with SQLITE_BUSY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:15:15 -04:00

94 lines
3.3 KiB
JavaScript

import Database from 'better-sqlite3'
import { mkdirSync, readdirSync, unlinkSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const dataDir = path.join(__dirname, 'data')
mkdirSync(dataDir, { recursive: true })
const db = new Database(path.join(dataDir, 'stories.db'))
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.pragma('busy_timeout = 5000') // wait up to 5 s instead of failing immediately
// Merge the WAL back into the main DB file once an hour.
// Without this, all writes live in the WAL indefinitely, and losing
// that file means losing all data written since the last checkpoint.
setInterval(() => {
try { db.pragma('wal_checkpoint(PASSIVE)') }
catch (err) { console.warn('[db] WAL checkpoint failed:', err.message) }
}, 60 * 60 * 1000) // every hour
// Daily backup — keeps the last 7 daily snapshots in data/backups/
const backupDir = path.join(dataDir, 'backups')
mkdirSync(backupDir, { recursive: true })
async function runBackup() {
try {
const stamp = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
const dest = path.join(backupDir, `stories-${stamp}.db`)
await db.backup(dest)
console.log(`[db] Backup written → ${dest}`)
// Prune backups older than 7 days
const files = readdirSync(backupDir)
.filter(f => f.startsWith('stories-') && f.endsWith('.db'))
.sort()
for (const old of files.slice(0, -7)) {
try { unlinkSync(path.join(backupDir, old)) } catch {}
}
} catch (err) {
console.warn('[db] Backup failed:', err.message)
}
}
// Run once on startup, then every 24 h
runBackup()
setInterval(runBackup, 24 * 60 * 60 * 1000)
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS stories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Untitled Story',
content TEXT DEFAULT '{}',
cover_image TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
story_id INTEGER NOT NULL REFERENCES stories(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Untitled Note',
content TEXT DEFAULT '{}',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`)
// Migrate: rename email -> username for existing databases
const userCols = db.pragma('table_info(users)').map(c => c.name)
if (userCols.includes('email') && !userCols.includes('username')) {
db.exec('ALTER TABLE users RENAME COLUMN email TO username')
}
export default db