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:
+87
-137
@@ -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<ws>. 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<ws>.
|
||||
// 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<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) {
|
||||
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', () => {});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user