Files
ttrpg-initiative-tracker/server/index.js
T
david raistrick 52866784b2 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).
2026-06-29 13:00:24 -04:00

149 lines
5.5 KiB
JavaScript

// 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, makeStore } = require('./db');
function createServer({ dbPath, port, corsOrigin } = {}) {
const db = openDb(dbPath || './data/tracker.sqlite');
const app = express();
app.use(cors({ origin: corsOrigin || '*' }));
app.use(express.json({ limit: '1mb' }));
// 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 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 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 = makeStore(db, broadcast);
// --- generic REST ---
app.get('/health', (req, res) => res.json({ ok: true }));
// 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) });
});
// 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));
});
// 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) });
});
// 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) });
});
// 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 });
});
// 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' });
wss.on('connection', (ws) => {
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.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', () => dropWs(ws));
ws.on('error', () => {});
});
return {
app, server, wss, store, db,
close(done) { wss.close(); server.close(() => { db.close(); if (done) done(); }); },
};
}
// Boot standalone if run directly.
if (require.main === module) {
const port = parseInt(process.env.PORT, 10) || 4001;
const dbPath = process.env.DB_PATH || './data/tracker.sqlite';
const { server } = createServer({ dbPath, port });
server.listen(port, () => {
console.log(`ttrpg backend listening on :${port} (db: ${dbPath})`);
});
}
module.exports = { createServer };