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
+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() {
return new Promise((resolve) => setTimeout(resolve, 0));
return new Promise((resolve) => setTimeout(resolve, 50));
}
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.
'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<cb>
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 };