diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 769d94b..049a758 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -14,11 +14,10 @@ TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm wor package.json # workspaces root src/ # React frontend (CRA, existing) App.js # ~2935 lines, Firebase direct (M2 abstracts this) - server/ # Backend: Express + ws + better-sqlite3 - index.js # REST + WS bootstrap - db.js # SQLite schema, row mappers - handlers.js # action -> shared turn fn -> tx persist -> broadcast - server.test.js # integration tests + server/ # Backend: generic KV doc store (firebase mirror) + index.js # REST (doc/coll/batch) + WS bootstrap + db.js # SQLite docs table, KV ops, broadcast + ws-contract.test.js # adapter vs live backend (Layer 2) shared/ # Pure logic, no I/O (client + server + tests import) turn.js # turn-order state machine turn.characterization.test.js @@ -67,7 +66,7 @@ Three commands: ```bash npm run test:all # runs shared/ + server/ suites in sequence npm run shared:test # turn logic only (shared/ folder) -npm run server:test # backend REST + combat flow (server/ folder) +npm run server:test # backend ws-contract (adapter vs live backend) ``` What each runs: diff --git a/docs/REWORK_PLAN.md b/docs/REWORK_PLAN.md index 028824f..538cbf0 100644 --- a/docs/REWORK_PLAN.md +++ b/docs/REWORK_PLAN.md @@ -96,11 +96,9 @@ Memory impl: in-memory Map + EventEmitter, for tests (M3). memory.js # NEW — test only types.js # interface contract (JSDoc) server/ # NEW - index.js # Express + ws bootstrap - db.js # better-sqlite3, schema, migrations - turn.js # turn-order logic (pure, server-authoritative) - handlers/ # action handlers (call turn logic, persist, broadcast) - server.test.js # API + WS integration tests + index.js # Express + ws bootstrap, generic KV REST + db.js # better-sqlite3, docs table (KV), broadcast + ws-contract.test.js # adapter vs live backend (Layer 2 test) shared/ # pure logic, no I/O, importable by client + server + tests turn.js # turn logic (single source; server imports, tests import) types.js diff --git a/server/db.js b/server/db.js index c2327f2..63ac4de 100644 --- a/server/db.js +++ b/server/db.js @@ -1,15 +1,16 @@ -// server/db.js — SQLite persistence layer. -// Owns the DB file. Only writer. Synchronous via better-sqlite3. +// server/db.js — generic KV document store on SQLite. +// Mirrors Firestore doc-tree model: every doc lives at a string path. +// Collections are implicit = all docs whose parent path equals the collection path. // -// 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 +// Path examples (canonical, prefix already stripped by adapter): +// campaigns/{id} doc +// campaigns/{cid}/encounters/{eid} doc +// campaigns/{cid}/encounters collection (parent of encounter docs) +// activeDisplay/status doc +// logs/{id} doc // -// Collections (campaigns, encounters, logs) -> rows with JSON blobs for fields. -// Single-row "status" docs (activeDisplay) -> their own tables. +// No shape-specific tables. Data is opaque JSON. This is the firebase mirror: +// the adapter (src/storage/ws.js) is a thin passthrough, app logic unchanged. 'use strict'; @@ -18,48 +19,14 @@ 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 docs ( + path TEXT PRIMARY KEY, + parent TEXT, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL ); -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); +CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent); `; function openDb(dbPath) { @@ -67,60 +34,77 @@ function openDb(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), - }; +// parentOf('campaigns/abc/encounters/xyz') => 'campaigns/abc/encounters' +// parentOf('campaigns') => null (root-level doc, no parent collection tracked) +function parentOf(p) { + const i = p.lastIndexOf('/'); + return i === -1 ? null : p.slice(0, i); } -// --- 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, - }; +function makeStore(db, broadcast) { + const stmtGet = db.prepare('SELECT data FROM docs WHERE path = ?'); + const stmtUpsert = db.prepare(` + INSERT INTO docs (path, parent, data, updated_at) VALUES (@path, @parent, @data, @ts) + ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts + `); + const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?'); + const stmtColl = db.prepare('SELECT path, data FROM docs WHERE parent = ? ORDER BY path ASC'); + + function getDoc(p) { + const row = stmtGet.get(p); + return row ? JSON.parse(row.data) : null; + } + + function setDoc(p, data) { + const ts = Date.now(); + stmtUpsert.run({ path: p, parent: parentOf(p), data: JSON.stringify(data), ts }); + if (broadcast) broadcast({ path: p, parent: parentOf(p) }); + return data; + } + + // shallow merge; if doc missing, patch becomes the doc (matches firebase updateDoc create-on-miss) + function updateDoc(p, patch) { + const existing = getDoc(p) || {}; + const merged = { ...existing, ...patch }; + setDoc(p, merged); + return merged; + } + + function deleteDoc(p) { + stmtDelete.run(p); + if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true }); + } + + function getCollection(collPath) { + return stmtColl.all(collPath).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) })); + } + + function batchWrite(ops) { + const run = db.transaction((items) => { + const changed = []; + for (const op of items) { + if (op.type === 'set') { + setDoc(op.path, op.data); + changed.push({ path: op.path, parent: parentOf(op.path) }); + } else if (op.type === 'delete') { + deleteDoc(op.path); + changed.push({ path: op.path, parent: parentOf(op.path), deleted: true }); + } else if (op.type === 'update') { + updateDoc(op.path, op.data); + changed.push({ path: op.path, parent: parentOf(op.path) }); + } + } + return changed; + }); + const changed = run(ops); + if (broadcast) changed.forEach(c => broadcast(c)); + } + + return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, batchWrite }; } -// --- 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, -}; +module.exports = { openDb, parentOf, makeStore }; diff --git a/server/handlers.js b/server/handlers.js deleted file mode 100644 index e40d138..0000000 --- a/server/handlers.js +++ /dev/null @@ -1,378 +0,0 @@ -// server/handlers.js — action → shared turn fn → tx persist → broadcast notify. -// Server-authoritative. Client sends action; server computes result, persists, notifies subscribers. - -'use strict'; - -const shared = require('@ttrpg/shared'); -const { buildCharacterParticipant, buildMonsterParticipant, generateId } = shared; - -// Create a store facade with all encounter/campaign mutations + a notify hook. -// notify(change) fans out to WS subscribers. Caller wires it. -function createStore(db, notify) { - // --- read helpers --- - function getCampaign(campaignId) { - const row = db.prepare('SELECT * FROM campaigns WHERE id = ?').get(campaignId); - return rowToCampaignLocal(row); - } - function getEncounter(campaignId, encounterId) { - const row = db.prepare('SELECT * FROM encounters WHERE campaignId = ? AND id = ?').get(campaignId, encounterId); - return rowToEncounterLocal(row); - } - function listEncounters(campaignId) { - return db.prepare('SELECT * FROM encounters WHERE campaignId = ? ORDER BY createdAt ASC').all(campaignId).map(rowToEncounterLocal); - } - function getActiveDisplay() { - const row = db.prepare('SELECT * FROM active_display WHERE id = 1').get(); - return { - activeCampaignId: row.activeCampaignId, - activeEncounterId: row.activeEncounterId, - hidePlayerHp: !!row.hidePlayerHp, - }; - } - - // --- write helpers --- - function saveCampaign(c) { - db.prepare(`INSERT INTO campaigns (id, name, playerDisplayBackgroundUrl, ownerId, createdAt, players) - VALUES (@id, @name, @playerDisplayBackgroundUrl, @ownerId, @createdAt, @players) - ON CONFLICT(id) DO UPDATE SET - name=@name, playerDisplayBackgroundUrl=@playerDisplayBackgroundUrl, - ownerId=@ownerId, players=@players`) - .run({ - id: c.id, name: c.name, playerDisplayBackgroundUrl: c.playerDisplayBackgroundUrl || '', - ownerId: c.ownerId || null, createdAt: c.createdAt, players: JSON.stringify(c.players || []), - }); - } - function saveEncounter(e) { - db.prepare(`INSERT INTO encounters (id, campaignId, name, participants, round, currentTurnParticipantId, isStarted, isPaused, turnOrderIds, createdAt) - VALUES (@id, @campaignId, @name, @participants, @round, @currentTurnParticipantId, @isStarted, @isPaused, @turnOrderIds, @createdAt) - ON CONFLICT(campaignId, id) DO UPDATE SET - name=@name, participants=@participants, round=@round, - currentTurnParticipantId=@currentTurnParticipantId, isStarted=@isStarted, - isPaused=@isPaused, turnOrderIds=@turnOrderIds`) - .run({ - id: e.id, campaignId: e.campaignId, name: e.name, participants: JSON.stringify(e.participants || []), - round: e.round || 0, currentTurnParticipantId: e.currentTurnParticipantId || null, - isStarted: e.isStarted ? 1 : 0, isPaused: e.isPaused ? 1 : 0, - turnOrderIds: JSON.stringify(e.turnOrderIds || []), createdAt: e.createdAt, - }); - } - function setActiveDisplay(patch) { - const cur = getActiveDisplay(); - const next = { ...cur, ...patch }; - db.prepare('UPDATE active_display SET activeCampaignId=?, activeEncounterId=?, hidePlayerHp=? WHERE id=1') - .run(next.activeCampaignId || null, next.activeEncounterId || null, next.hidePlayerHp ? 1 : 0); - } - function deleteEncounter(campaignId, encounterId) { - db.prepare('DELETE FROM encounters WHERE campaignId=? AND id=?').run(campaignId, encounterId); - } - function addLog(entry) { - if (!entry) return null; - const id = generateId(); - db.prepare(`INSERT INTO logs (id, timestamp, message, encounterName, undo, undone) - VALUES (?, ?, ?, ?, ?, 0)`) - .run(id, Date.now(), entry.message, entry.encounterName || null, - entry.undo ? JSON.stringify(entry.undo) : null); - return id; - } - - // --- apply patch from a shared turn fn --- - function applyEncounterPatch(campaignId, encounterId, patch) { - if (!patch) return null; - const e = getEncounter(campaignId, encounterId); - if (!e) throw new Error('Encounter not found.'); - const updated = { ...e, ...patch }; - saveEncounter(updated); - return updated; - } - - // --- public store API: each action runs in a tx, returns result + notifies --- - const store = { - // --- reads --- - getCampaign, - getEncounter, - listEncounters, - getActiveDisplay, - listCampaigns() { - return db.prepare('SELECT * FROM campaigns ORDER BY createdAt ASC').all().map(rowToCampaignLocal); - }, - listLogs(limit = 500) { - return db.prepare('SELECT * FROM logs ORDER BY timestamp DESC LIMIT ?').all(limit) - .map(r => ({ - id: r.id, timestamp: r.timestamp, message: r.message, - encounterName: r.encounterName, undo: r.undo ? JSON.parse(r.undo) : null, undone: !!r.undone, - })); - }, - - // --- campaign mutations --- - createCampaign({ name, backgroundUrl, ownerId }) { - const c = { - id: generateId(), name: name.trim(), playerDisplayBackgroundUrl: (backgroundUrl || '').trim(), - ownerId, createdAt: new Date().toISOString(), players: [], - }; - saveCampaign(c); - notify({ type: 'campaigns' }); - return c; - }, - deleteCampaign(campaignId) { - const tx = db.transaction(() => { - db.prepare('DELETE FROM encounters WHERE campaignId=?').run(campaignId); - db.prepare('DELETE FROM campaigns WHERE id=?').run(campaignId); - const ad = getActiveDisplay(); - if (ad.activeCampaignId === campaignId) { - setActiveDisplay({ activeCampaignId: null, activeEncounterId: null }); - } - }); - tx(); - notify({ type: 'campaigns' }); - notify({ type: 'encounters', campaignId }); - notify({ type: 'activeDisplay' }); - }, - addCampaignCharacter(campaignId, character) { - const c = getCampaign(campaignId); - if (!c) throw new Error('Campaign not found.'); - const newChar = { - id: generateId(), name: character.name.trim(), - defaultMaxHp: parseInt(character.defaultMaxHp, 10) || 10, - defaultInitMod: parseInt(character.defaultInitMod, 10) || 0, - }; - c.players = [...c.players, newChar]; - saveCampaign(c); - notify({ type: 'campaign', campaignId }); - return newChar; - }, - updateCampaignCharacter(campaignId, characterId, data) { - const c = getCampaign(campaignId); - if (!c) throw new Error('Campaign not found.'); - c.players = c.players.map(ch => ch.id === characterId - ? { ...ch, name: data.name.trim(), defaultMaxHp: parseInt(data.defaultMaxHp, 10) || 10, defaultInitMod: parseInt(data.defaultInitMod, 10) || 0 } - : ch); - saveCampaign(c); - notify({ type: 'campaign', campaignId }); - }, - deleteCampaignCharacter(campaignId, characterId) { - const c = getCampaign(campaignId); - if (!c) throw new Error('Campaign not found.'); - c.players = c.players.filter(ch => ch.id !== characterId); - saveCampaign(c); - notify({ type: 'campaign', campaignId }); - }, - - // --- encounter mutations --- - createEncounter(campaignId, name) { - const e = { - id: generateId(), campaignId, name: name.trim(), - participants: [], round: 0, currentTurnParticipantId: null, - isStarted: false, isPaused: false, turnOrderIds: [], - createdAt: new Date().toISOString(), - }; - saveEncounter(e); - notify({ type: 'encounters', campaignId }); - return e; - }, - deleteEncounter(campaignId, encounterId) { - deleteEncounter(campaignId, encounterId); - const ad = getActiveDisplay(); - if (ad.activeEncounterId === encounterId) { - setActiveDisplay({ activeCampaignId: null, activeEncounterId: null }); - } - notify({ type: 'encounters', campaignId }); - notify({ type: 'activeDisplay' }); - }, - togglePlayerDisplay(campaignId, encounterId) { - const ad = getActiveDisplay(); - if (ad.activeCampaignId === campaignId && ad.activeEncounterId === encounterId) { - setActiveDisplay({ activeCampaignId: null, activeEncounterId: null }); - } else { - setActiveDisplay({ activeCampaignId: campaignId, activeEncounterId: encounterId }); - } - notify({ type: 'activeDisplay' }); - }, - - // --- participant mutations (call shared turn fns) --- - addParticipant(campaignId, encounterId, { type, ...rest }) { - let built; - if (type === 'character') { - const c = getCampaign(rest.campaignId || campaignId); - const char = (c && c.players || []).find(ch => ch.id === rest.characterId); - if (!char) throw new Error('Character not found.'); - const e = getEncounter(campaignId, encounterId); - if ((e.participants || []).some(p => p.type === 'character' && p.originalCharacterId === char.id)) { - throw new Error(`${char.name} already in encounter.`); - } - built = buildCharacterParticipant(char); - } else { - built = buildMonsterParticipant(rest); - } - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.addParticipant(e, built.participant); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - addLog({ ...log, encounterName: e.name }); - notify({ type: 'encounter', campaignId, encounterId }); - return { encounter: updated, roll: built.roll }; - }, - addAllCampaignCharacters(campaignId, encounterId) { - const c = getCampaign(campaignId); - const e = getEncounter(campaignId, encounterId); - const existing = (e.participants || []).filter(p => p.type === 'character' && p.originalCharacterId).map(p => p.originalCharacterId); - const newParts = (c.players || []).filter(ch => !existing.includes(ch.id)).map(ch => buildCharacterParticipant(ch).participant); - const { patch } = shared.addParticipants(e, newParts); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - notify({ type: 'encounter', campaignId, encounterId }); - return { encounter: updated, added: newParts.length }; - }, - updateParticipant(campaignId, encounterId, participantId, data) { - const e = getEncounter(campaignId, encounterId); - const { patch } = shared.updateParticipant(e, participantId, { - name: data.name.trim(), - initiative: parseInt(data.initiative, 10), - currentHp: parseInt(data.currentHp, 10), - maxHp: parseInt(data.maxHp, 10), - isNpc: data.isNpc || false, - }); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - notify({ type: 'encounter', campaignId, encounterId }); - return updated; - }, - removeParticipant(campaignId, encounterId, participantId) { - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.removeParticipant(e, participantId); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - addLog({ ...log, encounterName: e.name }); - notify({ type: 'encounter', campaignId, encounterId }); - return updated; - }, - toggleParticipantActive(campaignId, encounterId, participantId) { - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.toggleParticipantActive(e, participantId); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - addLog({ ...log, encounterName: e.name }); - notify({ type: 'encounter', campaignId, encounterId }); - return updated; - }, - applyHpChange(campaignId, encounterId, participantId, changeType, amount) { - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.applyHpChange(e, participantId, changeType, parseInt(amount, 10)); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - if (log) addLog({ ...log, encounterName: e.name }); - notify({ type: 'encounter', campaignId, encounterId }); - return updated; - }, - deathSave(campaignId, encounterId, participantId, saveNumber) { - const e = getEncounter(campaignId, encounterId); - const { patch, isDying } = shared.deathSave(e, participantId, saveNumber); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - notify({ type: 'encounter', campaignId, encounterId }); - return { encounter: updated, isDying }; - }, - toggleCondition(campaignId, encounterId, participantId, conditionId) { - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.toggleCondition(e, participantId, conditionId); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - addLog({ ...log, encounterName: e.name }); - notify({ type: 'encounter', campaignId, encounterId }); - return updated; - }, - reorderParticipants(campaignId, encounterId, draggedId, targetId) { - const e = getEncounter(campaignId, encounterId); - const { patch } = shared.reorderParticipants(e, draggedId, targetId); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - notify({ type: 'encounter', campaignId, encounterId }); - return updated; - }, - - // --- combat controls (call shared turn fns) --- - startEncounter(campaignId, encounterId) { - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.startEncounter(e); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - addLog({ ...log, encounterName: e.name }); - setActiveDisplay({ activeCampaignId: campaignId, activeEncounterId: encounterId }); - notify({ type: 'encounter', campaignId, encounterId }); - notify({ type: 'activeDisplay' }); - return updated; - }, - nextTurn(campaignId, encounterId) { - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.nextTurn(e); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - if (log) addLog({ ...log, encounterName: e.name }); - notify({ type: 'encounter', campaignId, encounterId }); - return updated; - }, - togglePause(campaignId, encounterId) { - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.togglePause(e); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - addLog({ ...log, encounterName: e.name }); - notify({ type: 'encounter', campaignId, encounterId }); - return updated; - }, - endEncounter(campaignId, encounterId) { - const e = getEncounter(campaignId, encounterId); - const { patch, log } = shared.endEncounter(e); - const updated = applyEncounterPatch(campaignId, encounterId, patch); - addLog({ ...log, encounterName: e.name }); - setActiveDisplay({ activeCampaignId: null, activeEncounterId: null }); - notify({ type: 'encounter', campaignId, encounterId }); - notify({ type: 'activeDisplay' }); - return updated; - }, - - // --- display settings --- - toggleHidePlayerHp() { - const ad = getActiveDisplay(); - setActiveDisplay({ hidePlayerHp: !ad.hidePlayerHp }); - notify({ type: 'activeDisplay' }); - return !ad.hidePlayerHp; - }, - - // --- logs --- - clearLogs() { - db.prepare('DELETE FROM logs').run(); - notify({ type: 'logs' }); - }, - undoLog(logId) { - const entry = db.prepare('SELECT * FROM logs WHERE id=?').get(logId); - if (!entry) throw new Error('Log not found.'); - if (entry.undone) throw new Error('Already undone.'); - if (!entry.undo) throw new Error('No undo data.'); - const undo = JSON.parse(entry.undo); - // Undo payload shape: { encounterPath, updates } (legacy) OR { updates } (ours) - const updates = undo.updates || undo; - // find encounter from message context if available; require campaignId/encounterId via payload - if (undo.encounterPath) { - // legacy firebase path — we can't resolve here. Caller must pass ids. - throw new Error('Legacy undo payload; requires campaignId/encounterId.'); - } - db.prepare('UPDATE logs SET undone=1 WHERE id=?').run(logId); - notify({ type: 'logs' }); - return undo; - }, - - // --- internals exposed for tests/handlers --- - _db: db, - }; - - // local row mappers (avoid circular import with db.js shape) - function rowToCampaignLocal(row) { - if (!row) return null; - return { - id: row.id, name: row.name, playerDisplayBackgroundUrl: row.playerDisplayBackgroundUrl, - ownerId: row.ownerId, createdAt: row.createdAt, players: row.players ? JSON.parse(row.players) : [], - }; - } - function rowToEncounterLocal(row) { - if (!row) return null; - return { - id: row.id, campaignId: row.campaignId, name: row.name, - participants: row.participants ? JSON.parse(row.participants) : [], - round: row.round, currentTurnParticipantId: row.currentTurnParticipantId, - isStarted: !!row.isStarted, isPaused: !!row.isPaused, - turnOrderIds: row.turnOrderIds ? JSON.parse(row.turnOrderIds) : [], createdAt: row.createdAt, - }; - } - - return store; -} - -module.exports = { createStore }; diff --git a/server/index.js b/server/index.js index 8f2d09b..d79260d 100644 --- a/server/index.js +++ b/server/index.js @@ -1,19 +1,15 @@ -// server/index.js — Express (HTTP) + ws (WebSocket) bootstrap. -// HTTP: REST-ish endpoints for actions. WS: real-time state push (replaces onSnapshot). -// -// Env: -// PORT (default 4001) -// DB_PATH (default ./data/tracker.sqlite) -// CORS_ORIGIN (default '*'; in-house only) +// server/index.js — generic KV document store over HTTP + WebSocket. +// firebase mirror: doc-tree model. Thin REST, path-based WS push. +// Adapter (src/storage/ws.js) = passthrough, no shape translation. 'use strict'; const express = require('express'); const cors = require('cors'); const http = require('http'); +const crypto = require('crypto'); const { WebSocketServer } = require('ws'); -const { openDb } = require('./db'); -const { createStore } = require('./handlers'); +const { openDb, makeStore } = require('./db'); function createServer({ dbPath, port, corsOrigin } = {}) { const db = openDb(dbPath || './data/tracker.sqlite'); @@ -21,144 +17,98 @@ function createServer({ dbPath, port, corsOrigin } = {}) { app.use(cors({ origin: corsOrigin || '*' })); app.use(express.json({ limit: '1mb' })); - // WS subscribers. Map: key -> Set. key = 'campaigns' | 'campaign:id' | etc. - const subscribers = new Map(); - function subscribe(key, ws) { - if (!subscribers.has(key)) subscribers.set(key, new Set()); - subscribers.get(key).add(ws); + // WS subscribers: path -> Set. + // Subscribers register a path (doc or collection). On change, notify: + // - doc subscribers at the changed path + // - collection subscribers at the changed doc's parent path + const docSubscribers = new Map(); // path -> Set + const collSubscribers = new Map(); // collPath -> Set + + function addSub(map, key, ws) { + if (!map.has(key)) map.set(key, new Set()); + map.get(key).add(ws); } - function unsubscribe(key, ws) { - const set = subscribers.get(key); - if (set) { set.delete(ws); if (set.size === 0) subscribers.delete(key); } + function removeSub(map, key, ws) { + const set = map.get(key); + if (set) { set.delete(ws); if (set.size === 0) map.delete(key); } } + function dropWs(ws) { + for (const [key, set] of docSubscribers) { set.delete(ws); if (set.size === 0) docSubscribers.delete(key); } + for (const [key, set] of collSubscribers) { set.delete(ws); if (set.size === 0) collSubscribers.delete(key); } + } + function broadcast(change) { - const set = subscribers.get(change.type) || new Set(); - // 'encounter'/'campaign' changes also notify bare type subscribers. - [...set].forEach(ws => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'change', change })); }); + const payload = JSON.stringify({ type: 'change', change }); + // doc subscriber at exact path + const docSet = docSubscribers.get(change.path); + if (docSet) [...docSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload)); + // collection subscribers at parent path (collection contains this doc) + if (change.parent) { + const collSet = collSubscribers.get(change.parent); + if (collSet) [...collSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload)); + } } - const store = createStore(db, broadcast); + const store = makeStore(db, broadcast); + + // --- generic REST --- - // --- REST read endpoints --- app.get('/health', (req, res) => res.json({ ok: true })); - app.get('/api/campaigns', (req, res) => res.json(store.listCampaigns())); - app.get('/api/campaigns/:campaignId', (req, res) => { - const c = store.getCampaign(req.params.campaignId); - if (!c) return res.status(404).json({ error: 'Not found' }); - res.json(c); - }); - app.get('/api/campaigns/:campaignId/encounters', (req, res) => res.json(store.listEncounters(req.params.campaignId))); - app.get('/api/campaigns/:campaignId/encounters/:encounterId', (req, res) => { - const e = store.getEncounter(req.params.campaignId, req.params.encounterId); - if (!e) return res.status(404).json({ error: 'Not found' }); - res.json(e); - }); - app.get('/api/activeDisplay', (req, res) => res.json(store.getActiveDisplay())); - app.get('/api/logs', (req, res) => res.json(store.listLogs(parseInt(req.query.limit, 10) || 500))); - // --- campaign mutations --- - app.post('/api/campaigns', (req, res) => { - try { res.json(store.createCampaign(req.body)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.delete('/api/campaigns/:campaignId', (req, res) => { - try { store.deleteCampaign(req.params.campaignId); res.json({ ok: true }); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/characters', (req, res) => { - try { res.json(store.addCampaignCharacter(req.params.campaignId, req.body)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.put('/api/campaigns/:campaignId/characters/:characterId', (req, res) => { - try { store.updateCampaignCharacter(req.params.campaignId, req.params.characterId, req.body); res.json({ ok: true }); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.delete('/api/campaigns/:campaignId/characters/:characterId', (req, res) => { - try { store.deleteCampaignCharacter(req.params.campaignId, req.params.characterId); res.json({ ok: true }); } - catch (err) { res.status(400).json({ error: err.message }); } + // GET /api/doc?path=campaigns/abc/encounters/xyz + app.get('/api/doc', (req, res) => { + const { path: p } = req.query; + if (!p) return res.status(400).json({ error: 'path required' }); + res.json({ path: p, data: store.getDoc(p) }); }); - // --- encounter mutations --- - app.post('/api/campaigns/:campaignId/encounters', (req, res) => { - try { res.json(store.createEncounter(req.params.campaignId, req.body.name)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.delete('/api/campaigns/:campaignId/encounters/:encounterId', (req, res) => { - try { store.deleteEncounter(req.params.campaignId, req.params.encounterId); res.json({ ok: true }); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/display', (req, res) => { - try { store.togglePlayerDisplay(req.params.campaignId, req.params.encounterId); res.json({ ok: true }); } - catch (err) { res.status(400).json({ error: err.message }); } + // GET /api/collection?path=campaigns/abc/encounters + app.get('/api/collection', (req, res) => { + const { path: p } = req.query; + if (!p) return res.status(400).json({ error: 'path required' }); + res.json(store.getCollection(p)); }); - // --- participant mutations --- - app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants', (req, res) => { - try { res.json(store.addParticipant(req.params.campaignId, req.params.encounterId, req.body)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/addAll', (req, res) => { - try { res.json(store.addAllCampaignCharacters(req.params.campaignId, req.params.encounterId)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.put('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId', (req, res) => { - try { res.json(store.updateParticipant(req.params.campaignId, req.params.encounterId, req.params.participantId, req.body)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.delete('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId', (req, res) => { - try { res.json(store.removeParticipant(req.params.campaignId, req.params.encounterId, req.params.participantId)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId/active', (req, res) => { - try { res.json(store.toggleParticipantActive(req.params.campaignId, req.params.encounterId, req.params.participantId)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId/hp', (req, res) => { - try { res.json(store.applyHpChange(req.params.campaignId, req.params.encounterId, req.params.participantId, req.body.changeType, req.body.amount)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId/deathSave', (req, res) => { - try { res.json(store.deathSave(req.params.campaignId, req.params.encounterId, req.params.participantId, req.body.saveNumber)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId/condition', (req, res) => { - try { res.json(store.toggleCondition(req.params.campaignId, req.params.encounterId, req.params.participantId, req.body.conditionId)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/reorder', (req, res) => { - try { res.json(store.reorderParticipants(req.params.campaignId, req.params.encounterId, req.body.draggedId, req.body.targetId)); } - catch (err) { res.status(400).json({ error: err.message }); } + // PUT /api/doc body: { path, data } (replace) + app.put('/api/doc', (req, res) => { + const { path: p, data } = req.body || {}; + if (!p) return res.status(400).json({ error: 'path required' }); + res.json({ path: p, data: store.setDoc(p, data) }); }); - // --- combat controls --- - app.post('/api/campaigns/:campaignId/encounters/:encounterId/start', (req, res) => { - try { res.json(store.startEncounter(req.params.campaignId, req.params.encounterId)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/nextTurn', (req, res) => { - try { res.json(store.nextTurn(req.params.campaignId, req.params.encounterId)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/pause', (req, res) => { - try { res.json(store.togglePause(req.params.campaignId, req.params.encounterId)); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.post('/api/campaigns/:campaignId/encounters/:encounterId/end', (req, res) => { - try { res.json(store.endEncounter(req.params.campaignId, req.params.encounterId)); } - catch (err) { res.status(400).json({ error: err.message }); } + // PATCH /api/doc body: { path, patch } (shallow merge, create-on-miss) + app.patch('/api/doc', (req, res) => { + const { path: p, patch } = req.body || {}; + if (!p) return res.status(400).json({ error: 'path required' }); + res.json({ path: p, data: store.updateDoc(p, patch) }); }); - // --- display + logs --- - app.post('/api/activeDisplay/hidePlayerHp', (req, res) => { - try { res.json({ hidePlayerHp: store.toggleHidePlayerHp() }); } - catch (err) { res.status(400).json({ error: err.message }); } - }); - app.delete('/api/logs', (req, res) => { - try { store.clearLogs(); res.json({ ok: true }); } - catch (err) { res.status(400).json({ error: err.message }); } + // DELETE /api/doc?path=... + app.delete('/api/doc', (req, res) => { + const { path: p } = req.query; + if (!p) return res.status(400).json({ error: 'path required' }); + store.deleteDoc(p); + res.json({ ok: true }); }); - // --- WebSocket: real-time push --- + // POST /api/collection body: { path, data } (addDoc: auto-id under collection) + app.post('/api/collection', (req, res) => { + const { path: collPath, data } = req.body || {}; + if (!collPath) return res.status(400).json({ error: 'path required' }); + const id = crypto.randomUUID(); + const docPath = `${collPath}/${id}`; + res.json({ id, path: docPath, data: store.setDoc(docPath, data) }); + }); + + // POST /api/batch body: { ops: [{type:'set'|'update'|'delete', path, data?}] } + app.post('/api/batch', (req, res) => { + const { ops } = req.body || {}; + if (!Array.isArray(ops)) return res.status(400).json({ error: 'ops array required' }); + store.batchWrite(ops); + res.json({ ok: true }); + }); + + // --- WebSocket: subscribe by path --- const server = http.createServer(app); const wss = new WebSocketServer({ server, path: '/ws' }); @@ -166,16 +116,16 @@ function createServer({ dbPath, port, corsOrigin } = {}) { ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; } - if (msg.type === 'subscribe' && msg.key) { - subscribe(msg.key, ws); - ws.send(JSON.stringify({ type: 'subscribed', key: msg.key })); - } else if (msg.type === 'unsubscribe' && msg.key) { - unsubscribe(msg.key, ws); + if (msg.type === 'subscribe' && msg.path) { + if (msg.kind === 'collection') addSub(collSubscribers, msg.path, ws); + else addSub(docSubscribers, msg.path, ws); + ws.send(JSON.stringify({ type: 'subscribed', path: msg.path, kind: msg.kind || 'doc' })); + } else if (msg.type === 'unsubscribe' && msg.path) { + if (msg.kind === 'collection') removeSub(collSubscribers, msg.path, ws); + else removeSub(docSubscribers, msg.path, ws); } }); - ws.on('close', () => { - for (const [key, set] of subscribers) { set.delete(ws); if (set.size === 0) subscribers.delete(key); } - }); + ws.on('close', () => dropWs(ws)); ws.on('error', () => {}); }); diff --git a/server/server.test.js b/server/server.test.js deleted file mode 100644 index 3ff315a..0000000 --- a/server/server.test.js +++ /dev/null @@ -1,113 +0,0 @@ -// Integration smoke for server. Spin on random port, hit REST, check WS broadcast. -'use strict'; - -const http = require('http'); -const { createServer } = require('./index'); - -let BASE; -let handle; - -beforeEach(async () => { - handle = createServer({ dbPath: ':memory:' }); - await new Promise(r => handle.server.listen(0, r)); - const addr = handle.server.address(); - BASE = `http://127.0.0.1:${addr.port}`; -}); -afterEach(async () => { - await new Promise(r => handle.close(r)); -}); - -async function req(method, path, body) { - const res = await fetch(`${BASE}${path}`, { - method, - headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - const text = await res.text(); - const json = text ? JSON.parse(text) : null; - return { status: res.status, json }; -} - -describe('server REST', () => { - test('health', async () => { - const { status, json } = await req('GET', '/health'); - expect(status).toBe(200); - expect(json.ok).toBe(true); - }); - - test('campaign create + list', async () => { - const { json: c } = await req('POST', '/api/campaigns', { name: 'Test', backgroundUrl: '' }); - expect(c.name).toBe('Test'); - const { json: list } = await req('GET', '/api/campaigns'); - expect(list).toHaveLength(1); - expect(list[0].id).toBe(c.id); - }); - - test('encounter create + add monster + start', async () => { - const { json: c } = await req('POST', '/api/campaigns', { name: 'C' }); - const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' }); - expect(e.participants).toEqual([]); - - const { json: addRes } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, { - type: 'monster', name: 'Goblin', maxHp: 7, initMod: 2, isNpc: false, - }); - expect(addRes.encounter.participants).toHaveLength(1); - expect(addRes.encounter.participants[0].name).toBe('Goblin'); - expect(addRes.roll.total).toBeGreaterThan(2); - - const { json: started } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`); - expect(started.isStarted).toBe(true); - expect(started.round).toBe(1); - expect(started.currentTurnParticipantId).toBe(addRes.encounter.participants[0].id); - }); - - test('next turn advances', async () => { - const { json: c } = await req('POST', '/api/campaigns', { name: 'C' }); - const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' }); - const ids = []; - for (let i = 0; i < 3; i++) { - const { json: r } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, { - type: 'monster', name: `M${i}`, maxHp: 10, initMod: i, - }); - ids.push(r.encounter.participants[r.encounter.participants.length - 1].id); - } - await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`); - const { json: t1 } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/nextTurn`); - expect(t1.currentTurnParticipantId).not.toBe(t1.turnOrderIds[0]); - }); - - test('damage to 0 deactivates', async () => { - const { json: c } = await req('POST', '/api/campaigns', { name: 'C' }); - const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' }); - const { json: r } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, { - type: 'monster', name: 'Orc', maxHp: 5, initMod: 0, - }); - const pid = r.encounter.participants[0].id; - await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`); - const { json: after } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants/${pid}/hp`, { - changeType: 'damage', amount: 5, - }); - expect(after.participants[0].currentHp).toBe(0); - expect(after.participants[0].isActive).toBe(false); - }); - - test('error: start with no participants', async () => { - const { json: c } = await req('POST', '/api/campaigns', { name: 'C' }); - const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' }); - const { status, json } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`); - expect(status).toBe(400); - expect(json.error).toMatch(/participants/i); - }); - - test('logs recorded on actions', async () => { - const { json: c } = await req('POST', '/api/campaigns', { name: 'C' }); - const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' }); - await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, { - type: 'monster', name: 'Goblin', maxHp: 7, initMod: 2, - }); - await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`); - const { json: logs } = await req('GET', '/api/logs'); - expect(logs.length).toBeGreaterThanOrEqual(2); - expect(logs.some(l => /Combat started/.test(l.message))).toBe(true); - }); -}); diff --git a/server/ws-contract.test.js b/server/ws-contract.test.js new file mode 100644 index 0000000..6ad9c13 --- /dev/null +++ b/server/ws-contract.test.js @@ -0,0 +1,36 @@ +// Layer 2 test: exercise ws.js storage adapter against a LIVE backend. +// Complements Layer 1 (App + firebase mock) which proves App call shape but +// never touches ws.js. This catches translation bugs in the adapter. +// +// Runs the shared storage contract (same spec memory/firebase satisfy) against +// createWsStorage pointed at an ephemeral backend instance. A FRESH backend is +// spun up per test to guarantee isolation (backend has no reset endpoint yet). + +'use strict'; + +const path = require('path'); +const os = require('os'); +const { createServer } = require('../server/index'); +const { createWsStorage } = require('../src/storage/ws'); +const { runStorageContract } = require('../src/storage/contract'); + +let nextPort = 4000 + Math.floor(Math.random() * 999); + +// Factory: fresh backend (unique sqlite file) + storage pointed at it. +// Disposing the storage closes the backend so each test is fully isolated. +async function makeStorage() { + const port = nextPort++; + const dbPath = path.join(os.tmpdir(), `ws-contract-${port}-${Date.now()}.sqlite`); + const handle = createServer({ dbPath, port }); + await new Promise((resolve, reject) => { + handle.server.on('error', reject); + handle.server.listen(port, resolve); + }); + const baseUrl = `http://127.0.0.1:${port}`; + const wsUrl = `ws://127.0.0.1:${port}/ws`; + const storage = createWsStorage({ baseUrl, wsUrl }); + storage.dispose = (done) => handle.close(done); + return storage; +} + +runStorageContract('ws (live backend)', makeStorage); diff --git a/src/storage/contract.js b/src/storage/contract.js index b05ef48..8e76bcd 100644 --- a/src/storage/contract.js +++ b/src/storage/contract.js @@ -191,9 +191,10 @@ function runStorageContract(name, factory) { }); } -// microtask flush so async subscribers settle. +// flush so async subscribers settle. WS roundtrip needs real delay (network), +// memory fires near-instant. 50ms covers localhost WS comfortably. function flush() { - return new Promise((resolve) => setTimeout(resolve, 0)); + return new Promise((resolve) => setTimeout(resolve, 50)); } module.exports = { runStorageContract, flush }; diff --git a/src/storage/ws.js b/src/storage/ws.js index 92b8b35..712e741 100644 --- a/src/storage/ws.js +++ b/src/storage/ws.js @@ -1,15 +1,14 @@ -// ws.js — storage adapter talking to backend over REST + WebSocket. +// ws.js — thin storage adapter over generic KV backend (HTTP + WebSocket). +// Passthrough: no shape translation. Backend = firebase mirror. // Implements same interface as memory.js. Tested by storage contract vs running server. 'use strict'; -// Use native browser WebSocket when available (production). Fallback to the -// `ws` npm package in Node/jest where global WebSocket is absent. +// Native browser WebSocket if present, else ws pkg (Node/jest). let WebSocketImpl; if (typeof WebSocket !== 'undefined') { WebSocketImpl = WebSocket; } else { - // require inside else so webpack ignores it in browser bundle WebSocketImpl = require('ws').WebSocket; } @@ -17,9 +16,10 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, ''); const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws'); - // App.js passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/... - // Backend uses canonical: campaigns/... Strip the prefix so all matchers work. + // App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/... + // Backend uses canonical paths. Strip prefix. function norm(p) { + if (!p) return p; return p.replace(/^[\s\S]*\/public\/data\//, ''); } @@ -27,13 +27,11 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { const collSubs = new Map(); // collPath -> Set let ws = null; let wsReady = null; - const pendingPaths = new Set(); function ensureWs() { if (wsReady) return wsReady; wsReady = new Promise((resolve, reject) => { ws = new WebSocketImpl(WS); - // addEventListener works on both browser WebSocket and Node ws pkg. const onOpen = () => resolve(ws); const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); }; const onClose = () => { wsReady = null; }; @@ -42,12 +40,10 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; } handleMessage(msg); }; - // browser-style property handlers ws.onopen = onOpen; ws.onerror = onError; ws.onclose = onClose; ws.onmessage = onMessage; - // Node ws-style addEventListener fallback (noop in browser if absent) if (typeof ws.addEventListener === 'function') { ws.addEventListener('open', onOpen); ws.addEventListener('error', onError); @@ -58,47 +54,33 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { return wsReady; } - // Backend pushes change notices (coarse: type-based). We re-fetch affected paths. + // Backend pushes change notices keyed by path. Re-fetch affected subscribers. async function handleMessage(msg) { if (msg.type !== 'change' || !msg.change) return; const c = msg.change; - // Notify doc subscribers whose normalized path we cached. - for (const [rawPath, cbs] of docSubs) { - const path = norm(rawPath); - if (pathMatchesChange(path, c)) { - const doc = await storage.getDoc(path); - cbs.forEach(cb => cb(doc)); - } + // doc subscriber at exact changed path + const docCbs = docSubs.get(c.path); + if (docCbs) { + const doc = await storage.getDoc(c.path); + docCbs.forEach(cb => cb(doc)); } - for (const [rawCollPath, cbs] of collSubs) { - const collPath = norm(rawCollPath); - if (collMatchesChange(collPath, c)) { - const docs = await storage.getCollection(collPath); - cbs.forEach(cb => cb(docs)); + // collection subscribers at parent path (doc belongs to this collection) + if (c.parent) { + const collCbs = collSubs.get(c.parent); + if (collCbs) { + const docs = await storage.getCollection(c.parent); + collCbs.forEach(cb => cb(docs)); } } } - function pathMatchesChange(path, c) { - // Naive: campaign doc path includes campaignId; encounter doc includes encounterId. - if (c.type === 'campaign' && c.campaignId && path === docPathForCampaign(c.campaignId)) return true; - if (c.type === 'encounter' && c.campaignId && c.encounterId && path === docPathForEncounter(c.campaignId, c.encounterId)) return true; - if (c.type === 'activeDisplay' && path === 'activeDisplay/status') return true; - return false; - } - function collMatchesChange(collPath, c) { - if (c.type === 'campaigns' && collPath === 'campaigns') return true; - if (c.type === 'encounters' && c.campaignId && collPath === `campaigns/${c.campaignId}/encounters`) return true; - if (c.type === 'logs' && collPath === 'logs') return true; - return false; - } - - // Backend uses different shape (rows) than firebase docs. We adapt to doc model. - // To keep contract passing + match App.js expectations, we expose docs at canonical paths - // AND translate backend REST responses into doc-shaped data. - - async function api(method, path, body) { - const res = await fetch(`${API}${path}`, { + async function api(method, path, query, body) { + let url = `${API}${path}`; + if (query) { + const qs = new URLSearchParams(query).toString(); + url += `?${qs}`; + } + const res = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, @@ -112,134 +94,67 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { } const storage = { - // --- reads --- async getDoc(rawPath) { - const path = norm(rawPath); - if (path === 'activeDisplay/status') { - const ad = await api('GET', '/api/activeDisplay'); - return ad; - } - const m = path.match(/^campaigns\/([^/]+)$/); - if (m) { - const c = await api('GET', `/api/campaigns/${m[1]}`); - return c || null; - } - const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/); - if (em) { - const e = await api('GET', `/api/campaigns/${em[1]}/encounters/${em[2]}`); - return e || null; - } - return null; + const p = norm(rawPath); + const res = await api('GET', '/api/doc', { path: p }); + return res && res.data !== undefined ? res.data : null; }, - async getCollection(rawCollPath) { - const collPath = norm(rawCollPath); - if (collPath === 'campaigns') return await api('GET', '/api/campaigns'); - const m = collPath.match(/^campaigns\/([^/]+)\/encounters$/); - if (m) return await api('GET', `/api/campaigns/${m[1]}/encounters`); - if (collPath === 'logs') return await api('GET', '/api/logs'); - return []; - }, - - // --- writes (translated to backend action endpoints) --- async setDoc(rawPath, data) { - const path = norm(rawPath); - // activeDisplay merges - if (path === 'activeDisplay/status') { - if ('activeCampaignId' in data || 'activeEncounterId' in data) { - await api('POST', `/api/campaigns/${data.activeCampaignId}/encounters/${data.activeEncounterId}/display`).catch(() => {}); - } - if ('hidePlayerHp' in data) { - await api('POST', '/api/activeDisplay/hidePlayerHp').catch(() => {}); - } - return; - } - const cm = path.match(/^campaigns\/([^/]+)$/); - if (cm) { - // create or replace campaign - await api('POST', '/api/campaigns', { name: data.name, backgroundUrl: data.playerDisplayBackgroundUrl, ownerId: data.ownerId }); - return; - } - const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/); - if (em) { - await api('POST', `/api/campaigns/${em[1]}/encounters`, { name: data.name }); - return; - } + const p = norm(rawPath); + await api('PUT', '/api/doc', null, { path: p, data }); }, async updateDoc(rawPath, patch) { - const path = norm(rawPath); - const cm = path.match(/^campaigns\/([^/]+)$/); - if (cm) { - if (Array.isArray(patch.players)) { - // players array is full replacement of character roster - // backend has dedicated char endpoints; for bulk we just set via direct if needed. - // For now: no-op bulk (App.js uses add/update/delete char endpoints individually upstream) - return; - } - return; - } - const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/); - if (em) { - const [campaignId, encounterId] = [em[1], em[2]]; - // participants array patch = full replace. Map to per-participant ops is complex; - // backend owns participants via dedicated endpoints, so direct array replace unsupported here. - // Most App.js writes go through dedicated endpoints; this path mainly used by drag-drop reorder. - if (patch.participants && patch.dragInfo) { - await api('POST', `/api/campaigns/${campaignId}/encounters/${encounterId}/reorder`, patch.dragInfo); - } - return; - } + const p = norm(rawPath); + await api('PATCH', '/api/doc', null, { path: p, patch }); }, async deleteDoc(rawPath) { - const path = norm(rawPath); - const cm = path.match(/^campaigns\/([^/]+)$/); - if (cm) { await api('DELETE', `/api/campaigns/${cm[1]}`); return; } - const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/); - if (em) { await api('DELETE', `/api/campaigns/${em[1]}/encounters/${em[2]}`); return; } + const p = norm(rawPath); + await api('DELETE', '/api/doc', { path: p }); }, async addDoc(rawCollPath, data) { - const collPath = norm(rawCollPath); - if (collPath === 'logs') { - // backend auto-logs; direct insert not needed - return { id: 'auto', path: 'logs/auto' }; - } - return { id: 'unsupported', path: collPath + '/unsupported' }; + const p = norm(rawCollPath); + const res = await api('POST', '/api/collection', null, { path: p, data }); + return { id: res.id, path: res.path }; + }, + + async getCollection(rawCollPath) { + const p = norm(rawCollPath); + return await api('GET', '/api/collection', { path: p }); }, async batchWrite(ops) { - for (const op of ops) { - if (op.type === 'set') await storage.setDoc(op.path, op.data); - else if (op.type === 'delete') await storage.deleteDoc(op.path); - } + const normOps = ops.map(op => ({ ...op, path: norm(op.path) })); + await api('POST', '/api/batch', null, { ops: normOps }); }, subscribeDoc(rawPath, cb) { - const path = norm(rawPath); + const p = norm(rawPath); + // Initial value via REST (independent of WS connect). + storage.getDoc(p).then(cb).catch(() => {}); + // WS only for subsequent change notifications. ensureWs().then(() => { - // subscribe to coarse change types that could affect this path - const types = changeTypesForDocPath(path); - types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t }))); - // fire current - storage.getDoc(path).then(cb).catch(() => {}); + ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p })); }).catch(() => {}); - if (!docSubs.has(path)) docSubs.set(path, new Set()); - docSubs.get(path).add(cb); - return () => { docSubs.get(path)?.delete(cb); }; + if (!docSubs.has(p)) docSubs.set(p, new Set()); + docSubs.get(p).add(cb); + return () => { docSubs.get(p)?.delete(cb); }; }, subscribeCollection(rawCollPath, cb) { - const collPath = norm(rawCollPath); + const p = norm(rawCollPath); + // Initial value via REST (independent of WS connect). + storage.getCollection(p).then(cb).catch(() => {}); + // WS only for subsequent change notifications. ensureWs().then(() => { - const types = changeTypesForCollPath(collPath); - types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t }))); - storage.getCollection(collPath).then(cb).catch(() => {}); + ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p })); }).catch(() => {}); - if (!collSubs.has(collPath)) collSubs.set(collPath, new Set()); - collSubs.get(collPath).add(cb); - return () => { collSubs.get(collPath)?.delete(cb); }; + if (!collSubs.has(p)) collSubs.set(p, new Set()); + collSubs.get(p).add(cb); + return () => { collSubs.get(p)?.delete(cb); }; }, dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); }, @@ -250,21 +165,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { return storage; } -function changeTypesForDocPath(rawPath) { - const path = rawPath.replace(/^[\s\S]*\/public\/data\//, ''); - if (path === 'activeDisplay/status') return ['activeDisplay']; - if (path.match(/^campaigns\/[^/]+\/encounters\//)) return ['encounter', 'activeDisplay']; - if (path.match(/^campaigns\//)) return ['campaign', 'campaigns']; - return []; -} -function changeTypesForCollPath(rawCollPath) { - const collPath = rawCollPath.replace(/^[\s\S]*\/public\/data\//, ''); - if (collPath === 'campaigns') return ['campaigns']; - if (collPath.match(/^campaigns\/[^/]+\/encounters$/)) return ['encounters']; - if (collPath === 'logs') return ['logs']; - return []; -} -function docPathForCampaign(id) { return `campaigns/${id}`; } -function docPathForEncounter(campaignId, encounterId) { return `campaigns/${campaignId}/encounters/${encounterId}`; } - module.exports = { createWsStorage };