write/server/db.js
2026-06-26 18:13:18 -04:00

114 lines
4.1 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
);
CREATE TABLE IF NOT EXISTS characters (
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,
name TEXT NOT NULL DEFAULT 'New Character',
role TEXT NOT NULL DEFAULT 'supporting',
age TEXT DEFAULT '',
description TEXT DEFAULT '',
notes TEXT DEFAULT '',
photo TEXT DEFAULT NULL,
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')
}
// Migrate: add photo column to characters if missing (pre-existing table)
const charCols = db.pragma('table_info(characters)').map(c => c.name)
if (charCols.length > 0 && !charCols.includes('photo')) {
db.exec('ALTER TABLE characters ADD COLUMN photo TEXT DEFAULT NULL')
}
export default db