127 lines
3.7 KiB
JavaScript
127 lines
3.7 KiB
JavaScript
|
|
// 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,
|
||
|
|
};
|