// server/db.js — SQLite persistence layer. // Owns the DB file. Only writer. Synchronous via better-sqlite3. // // Schema mirrors the Firestore doc tree used by src/App.js: // artifacts/{APP_ID}/public/data/ // campaigns/{id} -> name, bg, ownerId, createdAt, players[] // campaigns/{id}/encounters/{eid} -> name, participants[], round, currentTurnParticipantId, isStarted, isPaused, turnOrderIds[] // activeDisplay/status -> activeCampaignId, activeEncounterId, hidePlayerHp // logs/{id} -> timestamp, message, encounterName, undo, undone // // Collections (campaigns, encounters, logs) -> rows with JSON blobs for fields. // Single-row "status" docs (activeDisplay) -> their own tables. 'use strict'; const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); const SCHEMA = ` CREATE TABLE IF NOT EXISTS campaigns ( id TEXT PRIMARY KEY, name TEXT NOT NULL, playerDisplayBackgroundUrl TEXT NOT NULL DEFAULT '', ownerId TEXT, createdAt TEXT NOT NULL, players TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS encounters ( id TEXT NOT NULL, campaignId TEXT NOT NULL, name TEXT NOT NULL, participants TEXT NOT NULL DEFAULT '[]', round INTEGER NOT NULL DEFAULT 0, currentTurnParticipantId TEXT, isStarted INTEGER NOT NULL DEFAULT 0, isPaused INTEGER NOT NULL DEFAULT 0, turnOrderIds TEXT NOT NULL DEFAULT '[]', createdAt TEXT NOT NULL, PRIMARY KEY (campaignId, id), FOREIGN KEY (campaignId) REFERENCES campaigns(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS active_display ( id INTEGER PRIMARY KEY CHECK (id = 1), activeCampaignId TEXT, activeEncounterId TEXT, hidePlayerHp INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS logs ( id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, message TEXT NOT NULL, encounterName TEXT, undo TEXT, undone INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC); CREATE INDEX IF NOT EXISTS idx_encounters_campaign ON encounters(campaignId); `; function openDb(dbPath) { const dir = path.dirname(dbPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const db = new Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(SCHEMA); // Ensure the single active_display row exists. db.prepare('INSERT OR IGNORE INTO active_display (id, hidePlayerHp) VALUES (1, 1)').run(); return db; } // --- JSON helpers --- const parseArr = (s) => (s ? JSON.parse(s) : []); const parseObj = (s, fallback = null) => (s ? JSON.parse(s) : fallback); // --- Campaign shape (matches Firestore doc) --- function rowToCampaign(row) { if (!row) return null; return { id: row.id, name: row.name, playerDisplayBackgroundUrl: row.playerDisplayBackgroundUrl, ownerId: row.ownerId, createdAt: row.createdAt, players: parseArr(row.players), }; } // --- Encounter shape (matches Firestore doc) --- function rowToEncounter(row) { if (!row) return null; return { id: row.id, campaignId: row.campaignId, name: row.name, participants: parseArr(row.participants), round: row.round, currentTurnParticipantId: row.currentTurnParticipantId, isStarted: !!row.isStarted, isPaused: !!row.isPaused, turnOrderIds: parseArr(row.turnOrderIds), createdAt: row.createdAt, }; } // --- Active display shape --- function rowToActiveDisplay(row) { if (!row) return null; return { activeCampaignId: row.activeCampaignId, activeEncounterId: row.activeEncounterId, hidePlayerHp: !!row.hidePlayerHp, }; } module.exports = { openDb, rowToCampaign, rowToEncounter, rowToActiveDisplay, };