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:
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user