M2: replace shape-specific backend with generic KV doc store (firebase mirror)

Backend was endpoint-mapper (POST /api/campaigns ignores path id, etc). Adapter
translation layer brittle, untested, lost doc identity. Generic contract (Layer 2)
test caught 15 bugs immediately.

Rewrite to firebase-mirror KV model:
- server/db.js: docs table (path PK, parent, data JSON). getDoc/setDoc/updateDoc/
  deleteDoc/getCollection/batchWrite. parent = path prefix for collection queries.
- server/index.js: generic REST (GET/PUT/PATCH/DELETE /api/doc, GET /api/collection,
  POST /api/collection addDoc, POST /api/batch). WS subscribe by path (doc|collection),
  broadcast to doc subs at changed path + collection subs at parent path.
- src/storage/ws.js: thin passthrough adapter. norm() strips firebase prefix.
  initial value via REST (independent of WS connect), subsequent changes via WS.
- shared/turn.js kept (M4 use). server/handlers.js + server.test.js removed (logic
  now in App, backend is dumb KV).
- src/storage/contract.js: flush() bumped to 50ms (WS roundtrip > setTimeout(0)).

Layer 2 test (server/ws-contract.test.js): spins fresh backend per test, runs same
storage contract spec against createWsStorage. Catches adapter translation bugs
that firebase-mock Layer 1 tests cannot.

nanoid v5 ESM breaks jest CJS -> replaced with crypto.randomUUID (Node builtin).

Tests: 114 green (39 shared + 19 ws-contract + 56 frontend).
This commit is contained in:
david raistrick
2026-06-29 13:00:24 -04:00
parent 35cd1581e3
commit 52866784b2
9 changed files with 277 additions and 902 deletions
+5 -6
View File
@@ -14,11 +14,10 @@ TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm wor
package.json # workspaces root package.json # workspaces root
src/ # React frontend (CRA, existing) src/ # React frontend (CRA, existing)
App.js # ~2935 lines, Firebase direct (M2 abstracts this) App.js # ~2935 lines, Firebase direct (M2 abstracts this)
server/ # Backend: Express + ws + better-sqlite3 server/ # Backend: generic KV doc store (firebase mirror)
index.js # REST + WS bootstrap index.js # REST (doc/coll/batch) + WS bootstrap
db.js # SQLite schema, row mappers db.js # SQLite docs table, KV ops, broadcast
handlers.js # action -> shared turn fn -> tx persist -> broadcast ws-contract.test.js # adapter vs live backend (Layer 2)
server.test.js # integration tests
shared/ # Pure logic, no I/O (client + server + tests import) shared/ # Pure logic, no I/O (client + server + tests import)
turn.js # turn-order state machine turn.js # turn-order state machine
turn.characterization.test.js turn.characterization.test.js
@@ -67,7 +66,7 @@ Three commands:
```bash ```bash
npm run test:all # runs shared/ + server/ suites in sequence npm run test:all # runs shared/ + server/ suites in sequence
npm run shared:test # turn logic only (shared/ folder) 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: What each runs:
+3 -5
View File
@@ -96,11 +96,9 @@ Memory impl: in-memory Map + EventEmitter, for tests (M3).
memory.js # NEW — test only memory.js # NEW — test only
types.js # interface contract (JSDoc) types.js # interface contract (JSDoc)
server/ # NEW server/ # NEW
index.js # Express + ws bootstrap index.js # Express + ws bootstrap, generic KV REST
db.js # better-sqlite3, schema, migrations db.js # better-sqlite3, docs table (KV), broadcast
turn.js # turn-order logic (pure, server-authoritative) ws-contract.test.js # adapter vs live backend (Layer 2 test)
handlers/ # action handlers (call turn logic, persist, broadcast)
server.test.js # API + WS integration tests
shared/ # pure logic, no I/O, importable by client + server + tests shared/ # pure logic, no I/O, importable by client + server + tests
turn.js # turn logic (single source; server imports, tests import) turn.js # turn logic (single source; server imports, tests import)
types.js types.js
+83 -99
View File
@@ -1,15 +1,16 @@
// server/db.js — SQLite persistence layer. // server/db.js — generic KV document store on SQLite.
// Owns the DB file. Only writer. Synchronous via better-sqlite3. // 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: // Path examples (canonical, prefix already stripped by adapter):
// artifacts/{APP_ID}/public/data/ // campaigns/{id} doc
// campaigns/{id} -> name, bg, ownerId, createdAt, players[] // campaigns/{cid}/encounters/{eid} doc
// campaigns/{id}/encounters/{eid} -> name, participants[], round, currentTurnParticipantId, isStarted, isPaused, turnOrderIds[] // campaigns/{cid}/encounters collection (parent of encounter docs)
// activeDisplay/status -> activeCampaignId, activeEncounterId, hidePlayerHp // activeDisplay/status doc
// logs/{id} -> timestamp, message, encounterName, undo, undone // logs/{id} doc
// //
// Collections (campaigns, encounters, logs) -> rows with JSON blobs for fields. // No shape-specific tables. Data is opaque JSON. This is the firebase mirror:
// Single-row "status" docs (activeDisplay) -> their own tables. // the adapter (src/storage/ws.js) is a thin passthrough, app logic unchanged.
'use strict'; 'use strict';
@@ -18,48 +19,14 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const SCHEMA = ` const SCHEMA = `
CREATE TABLE IF NOT EXISTS campaigns ( CREATE TABLE IF NOT EXISTS docs (
id TEXT PRIMARY KEY, path TEXT PRIMARY KEY,
name TEXT NOT NULL, parent TEXT,
playerDisplayBackgroundUrl TEXT NOT NULL DEFAULT '', data TEXT NOT NULL,
ownerId TEXT, updated_at INTEGER NOT NULL
createdAt TEXT NOT NULL,
players TEXT NOT NULL DEFAULT '[]'
); );
CREATE TABLE IF NOT EXISTS encounters ( CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent);
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) { function openDb(dbPath) {
@@ -67,60 +34,77 @@ function openDb(dbPath) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA); 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; return db;
} }
// --- JSON helpers --- // parentOf('campaigns/abc/encounters/xyz') => 'campaigns/abc/encounters'
const parseArr = (s) => (s ? JSON.parse(s) : []); // parentOf('campaigns') => null (root-level doc, no parent collection tracked)
const parseObj = (s, fallback = null) => (s ? JSON.parse(s) : fallback); function parentOf(p) {
const i = p.lastIndexOf('/');
// --- Campaign shape (matches Firestore doc) --- return i === -1 ? null : p.slice(0, i);
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 makeStore(db, broadcast) {
function rowToEncounter(row) { const stmtGet = db.prepare('SELECT data FROM docs WHERE path = ?');
if (!row) return null; const stmtUpsert = db.prepare(`
return { INSERT INTO docs (path, parent, data, updated_at) VALUES (@path, @parent, @data, @ts)
id: row.id, ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts
campaignId: row.campaignId, `);
name: row.name, const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?');
participants: parseArr(row.participants), const stmtColl = db.prepare('SELECT path, data FROM docs WHERE parent = ? ORDER BY path ASC');
round: row.round,
currentTurnParticipantId: row.currentTurnParticipantId, function getDoc(p) {
isStarted: !!row.isStarted, const row = stmtGet.get(p);
isPaused: !!row.isPaused, return row ? JSON.parse(row.data) : null;
turnOrderIds: parseArr(row.turnOrderIds), }
createdAt: row.createdAt,
}; 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 --- module.exports = { openDb, parentOf, makeStore };
function rowToActiveDisplay(row) {
if (!row) return null;
return {
activeCampaignId: row.activeCampaignId,
activeEncounterId: row.activeEncounterId,
hidePlayerHp: !!row.hidePlayerHp,
};
}
module.exports = {
openDb,
rowToCampaign,
rowToEncounter,
rowToActiveDisplay,
};
-378
View File
@@ -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 };
+87 -137
View File
@@ -1,19 +1,15 @@
// server/index.js — Express (HTTP) + ws (WebSocket) bootstrap. // server/index.js — generic KV document store over HTTP + WebSocket.
// HTTP: REST-ish endpoints for actions. WS: real-time state push (replaces onSnapshot). // firebase mirror: doc-tree model. Thin REST, path-based WS push.
// // Adapter (src/storage/ws.js) = passthrough, no shape translation.
// Env:
// PORT (default 4001)
// DB_PATH (default ./data/tracker.sqlite)
// CORS_ORIGIN (default '*'; in-house only)
'use strict'; 'use strict';
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const http = require('http'); const http = require('http');
const crypto = require('crypto');
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const { openDb } = require('./db'); const { openDb, makeStore } = require('./db');
const { createStore } = require('./handlers');
function createServer({ dbPath, port, corsOrigin } = {}) { function createServer({ dbPath, port, corsOrigin } = {}) {
const db = openDb(dbPath || './data/tracker.sqlite'); const db = openDb(dbPath || './data/tracker.sqlite');
@@ -21,144 +17,98 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
app.use(cors({ origin: corsOrigin || '*' })); app.use(cors({ origin: corsOrigin || '*' }));
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
// WS subscribers. Map: key -> Set<ws>. key = 'campaigns' | 'campaign:id' | etc. // WS subscribers: path -> Set<ws>.
const subscribers = new Map(); // Subscribers register a path (doc or collection). On change, notify:
function subscribe(key, ws) { // - doc subscribers at the changed path
if (!subscribers.has(key)) subscribers.set(key, new Set()); // - collection subscribers at the changed doc's parent path
subscribers.get(key).add(ws); const docSubscribers = new Map(); // path -> Set<ws>
const collSubscribers = new Map(); // collPath -> Set<ws>
function addSub(map, key, ws) {
if (!map.has(key)) map.set(key, new Set());
map.get(key).add(ws);
} }
function unsubscribe(key, ws) { function removeSub(map, key, ws) {
const set = subscribers.get(key); const set = map.get(key);
if (set) { set.delete(ws); if (set.size === 0) subscribers.delete(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) { function broadcast(change) {
const set = subscribers.get(change.type) || new Set(); const payload = JSON.stringify({ type: 'change', change });
// 'encounter'/'campaign' changes also notify bare type subscribers. // doc subscriber at exact path
[...set].forEach(ws => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'change', change })); }); 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('/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 --- // GET /api/doc?path=campaigns/abc/encounters/xyz
app.post('/api/campaigns', (req, res) => { app.get('/api/doc', (req, res) => {
try { res.json(store.createCampaign(req.body)); } const { path: p } = req.query;
catch (err) { res.status(400).json({ error: err.message }); } if (!p) return res.status(400).json({ error: 'path required' });
}); res.json({ path: p, data: store.getDoc(p) });
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 }); }
}); });
// --- encounter mutations --- // GET /api/collection?path=campaigns/abc/encounters
app.post('/api/campaigns/:campaignId/encounters', (req, res) => { app.get('/api/collection', (req, res) => {
try { res.json(store.createEncounter(req.params.campaignId, req.body.name)); } const { path: p } = req.query;
catch (err) { res.status(400).json({ error: err.message }); } if (!p) return res.status(400).json({ error: 'path required' });
}); res.json(store.getCollection(p));
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 }); }
}); });
// --- participant mutations --- // PUT /api/doc body: { path, data } (replace)
app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants', (req, res) => { app.put('/api/doc', (req, res) => {
try { res.json(store.addParticipant(req.params.campaignId, req.params.encounterId, req.body)); } const { path: p, data } = req.body || {};
catch (err) { res.status(400).json({ error: err.message }); } if (!p) return res.status(400).json({ error: 'path required' });
}); res.json({ path: p, data: store.setDoc(p, data) });
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 }); }
}); });
// --- combat controls --- // PATCH /api/doc body: { path, patch } (shallow merge, create-on-miss)
app.post('/api/campaigns/:campaignId/encounters/:encounterId/start', (req, res) => { app.patch('/api/doc', (req, res) => {
try { res.json(store.startEncounter(req.params.campaignId, req.params.encounterId)); } const { path: p, patch } = req.body || {};
catch (err) { res.status(400).json({ error: err.message }); } if (!p) return res.status(400).json({ error: 'path required' });
}); res.json({ path: p, data: store.updateDoc(p, patch) });
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 }); }
}); });
// --- display + logs --- // DELETE /api/doc?path=...
app.post('/api/activeDisplay/hidePlayerHp', (req, res) => { app.delete('/api/doc', (req, res) => {
try { res.json({ hidePlayerHp: store.toggleHidePlayerHp() }); } const { path: p } = req.query;
catch (err) { res.status(400).json({ error: err.message }); } if (!p) return res.status(400).json({ error: 'path required' });
}); store.deleteDoc(p);
app.delete('/api/logs', (req, res) => { res.json({ ok: true });
try { store.clearLogs(); res.json({ ok: true }); }
catch (err) { res.status(400).json({ error: err.message }); }
}); });
// --- 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 server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' }); const wss = new WebSocketServer({ server, path: '/ws' });
@@ -166,16 +116,16 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
ws.on('message', (raw) => { ws.on('message', (raw) => {
let msg; let msg;
try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; } try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; }
if (msg.type === 'subscribe' && msg.key) { if (msg.type === 'subscribe' && msg.path) {
subscribe(msg.key, ws); if (msg.kind === 'collection') addSub(collSubscribers, msg.path, ws);
ws.send(JSON.stringify({ type: 'subscribed', key: msg.key })); else addSub(docSubscribers, msg.path, ws);
} else if (msg.type === 'unsubscribe' && msg.key) { ws.send(JSON.stringify({ type: 'subscribed', path: msg.path, kind: msg.kind || 'doc' }));
unsubscribe(msg.key, ws); } 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', () => { ws.on('close', () => dropWs(ws));
for (const [key, set] of subscribers) { set.delete(ws); if (set.size === 0) subscribers.delete(key); }
});
ws.on('error', () => {}); ws.on('error', () => {});
}); });
-113
View File
@@ -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);
});
});
+36
View File
@@ -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);
+3 -2
View File
@@ -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() { function flush() {
return new Promise((resolve) => setTimeout(resolve, 0)); return new Promise((resolve) => setTimeout(resolve, 50));
} }
module.exports = { runStorageContract, flush }; module.exports = { runStorageContract, flush };
+60 -162
View File
@@ -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. // Implements same interface as memory.js. Tested by storage contract vs running server.
'use strict'; 'use strict';
// Use native browser WebSocket when available (production). Fallback to the // Native browser WebSocket if present, else ws pkg (Node/jest).
// `ws` npm package in Node/jest where global WebSocket is absent.
let WebSocketImpl; let WebSocketImpl;
if (typeof WebSocket !== 'undefined') { if (typeof WebSocket !== 'undefined') {
WebSocketImpl = WebSocket; WebSocketImpl = WebSocket;
} else { } else {
// require inside else so webpack ignores it in browser bundle
WebSocketImpl = require('ws').WebSocket; WebSocketImpl = require('ws').WebSocket;
} }
@@ -17,9 +16,10 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, ''); const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, '');
const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws'); const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws');
// App.js passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/... // App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
// Backend uses canonical: campaigns/... Strip the prefix so all matchers work. // Backend uses canonical paths. Strip prefix.
function norm(p) { function norm(p) {
if (!p) return p;
return p.replace(/^[\s\S]*\/public\/data\//, ''); return p.replace(/^[\s\S]*\/public\/data\//, '');
} }
@@ -27,13 +27,11 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
const collSubs = new Map(); // collPath -> Set<cb> const collSubs = new Map(); // collPath -> Set<cb>
let ws = null; let ws = null;
let wsReady = null; let wsReady = null;
const pendingPaths = new Set();
function ensureWs() { function ensureWs() {
if (wsReady) return wsReady; if (wsReady) return wsReady;
wsReady = new Promise((resolve, reject) => { wsReady = new Promise((resolve, reject) => {
ws = new WebSocketImpl(WS); ws = new WebSocketImpl(WS);
// addEventListener works on both browser WebSocket and Node ws pkg.
const onOpen = () => resolve(ws); const onOpen = () => resolve(ws);
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); }; const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
const onClose = () => { wsReady = null; }; 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; } let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
handleMessage(msg); handleMessage(msg);
}; };
// browser-style property handlers
ws.onopen = onOpen; ws.onopen = onOpen;
ws.onerror = onError; ws.onerror = onError;
ws.onclose = onClose; ws.onclose = onClose;
ws.onmessage = onMessage; ws.onmessage = onMessage;
// Node ws-style addEventListener fallback (noop in browser if absent)
if (typeof ws.addEventListener === 'function') { if (typeof ws.addEventListener === 'function') {
ws.addEventListener('open', onOpen); ws.addEventListener('open', onOpen);
ws.addEventListener('error', onError); ws.addEventListener('error', onError);
@@ -58,47 +54,33 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return wsReady; 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) { async function handleMessage(msg) {
if (msg.type !== 'change' || !msg.change) return; if (msg.type !== 'change' || !msg.change) return;
const c = msg.change; const c = msg.change;
// Notify doc subscribers whose normalized path we cached. // doc subscriber at exact changed path
for (const [rawPath, cbs] of docSubs) { const docCbs = docSubs.get(c.path);
const path = norm(rawPath); if (docCbs) {
if (pathMatchesChange(path, c)) { const doc = await storage.getDoc(c.path);
const doc = await storage.getDoc(path); docCbs.forEach(cb => cb(doc));
cbs.forEach(cb => cb(doc));
}
} }
for (const [rawCollPath, cbs] of collSubs) { // collection subscribers at parent path (doc belongs to this collection)
const collPath = norm(rawCollPath); if (c.parent) {
if (collMatchesChange(collPath, c)) { const collCbs = collSubs.get(c.parent);
const docs = await storage.getCollection(collPath); if (collCbs) {
cbs.forEach(cb => cb(docs)); const docs = await storage.getCollection(c.parent);
collCbs.forEach(cb => cb(docs));
} }
} }
} }
function pathMatchesChange(path, c) { async function api(method, path, query, body) {
// Naive: campaign doc path includes campaignId; encounter doc includes encounterId. let url = `${API}${path}`;
if (c.type === 'campaign' && c.campaignId && path === docPathForCampaign(c.campaignId)) return true; if (query) {
if (c.type === 'encounter' && c.campaignId && c.encounterId && path === docPathForEncounter(c.campaignId, c.encounterId)) return true; const qs = new URLSearchParams(query).toString();
if (c.type === 'activeDisplay' && path === 'activeDisplay/status') return true; url += `?${qs}`;
return false; }
} const res = await fetch(url, {
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}`, {
method, method,
headers: body ? { 'Content-Type': 'application/json' } : undefined, headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
@@ -112,134 +94,67 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
} }
const storage = { const storage = {
// --- reads ---
async getDoc(rawPath) { async getDoc(rawPath) {
const path = norm(rawPath); const p = norm(rawPath);
if (path === 'activeDisplay/status') { const res = await api('GET', '/api/doc', { path: p });
const ad = await api('GET', '/api/activeDisplay'); return res && res.data !== undefined ? res.data : null;
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;
}, },
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) { async setDoc(rawPath, data) {
const path = norm(rawPath); const p = norm(rawPath);
// activeDisplay merges await api('PUT', '/api/doc', null, { path: p, data });
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;
}
}, },
async updateDoc(rawPath, patch) { async updateDoc(rawPath, patch) {
const path = norm(rawPath); const p = norm(rawPath);
const cm = path.match(/^campaigns\/([^/]+)$/); await api('PATCH', '/api/doc', null, { path: p, patch });
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;
}
}, },
async deleteDoc(rawPath) { async deleteDoc(rawPath) {
const path = norm(rawPath); const p = norm(rawPath);
const cm = path.match(/^campaigns\/([^/]+)$/); await api('DELETE', '/api/doc', { path: p });
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; }
}, },
async addDoc(rawCollPath, data) { async addDoc(rawCollPath, data) {
const collPath = norm(rawCollPath); const p = norm(rawCollPath);
if (collPath === 'logs') { const res = await api('POST', '/api/collection', null, { path: p, data });
// backend auto-logs; direct insert not needed return { id: res.id, path: res.path };
return { id: 'auto', path: 'logs/auto' }; },
}
return { id: 'unsupported', path: collPath + '/unsupported' }; async getCollection(rawCollPath) {
const p = norm(rawCollPath);
return await api('GET', '/api/collection', { path: p });
}, },
async batchWrite(ops) { async batchWrite(ops) {
for (const op of ops) { const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
if (op.type === 'set') await storage.setDoc(op.path, op.data); await api('POST', '/api/batch', null, { ops: normOps });
else if (op.type === 'delete') await storage.deleteDoc(op.path);
}
}, },
subscribeDoc(rawPath, cb) { 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(() => { ensureWs().then(() => {
// subscribe to coarse change types that could affect this path ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
const types = changeTypesForDocPath(path);
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
// fire current
storage.getDoc(path).then(cb).catch(() => {});
}).catch(() => {}); }).catch(() => {});
if (!docSubs.has(path)) docSubs.set(path, new Set()); if (!docSubs.has(p)) docSubs.set(p, new Set());
docSubs.get(path).add(cb); docSubs.get(p).add(cb);
return () => { docSubs.get(path)?.delete(cb); }; return () => { docSubs.get(p)?.delete(cb); };
}, },
subscribeCollection(rawCollPath, 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(() => { ensureWs().then(() => {
const types = changeTypesForCollPath(collPath); ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
storage.getCollection(collPath).then(cb).catch(() => {});
}).catch(() => {}); }).catch(() => {});
if (!collSubs.has(collPath)) collSubs.set(collPath, new Set()); if (!collSubs.has(p)) collSubs.set(p, new Set());
collSubs.get(collPath).add(cb); collSubs.get(p).add(cb);
return () => { collSubs.get(collPath)?.delete(cb); }; return () => { collSubs.get(p)?.delete(cb); };
}, },
dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); }, dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); },
@@ -250,21 +165,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return storage; 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 }; module.exports = { createWsStorage };