Files
ttrpg-initiative-tracker/src/storage/server.js
T
david raistrick 055895875a Dev-only bulk delete all campaigns; deleteCollection SQL bulk + gates
Feature: debug button to wipe all campaigns/encounters/logs in dev builds.
Previously bulk delete fetched all logs per campaign, client-filtered,
batchWrite — 30s+/campaign. Now SQL bulk DELETE, no fetch.

Server (server/db.js, server/index.js):
- deleteCollection(collPath, {where}) — SQL DELETE FROM docs WHERE parent=?,
  optional where-filter. Broadcasts deletions to WS subscribers.
- DELETE /api/collection endpoint
- Gate: ALLOW_DEV_ENDPOINTS=1 env OR createServer({allowDevEndpoints:true})
- createServer accepts allowDevEndpoints param (tests bypass env)

Storage (src/storage/server.js, src/storage/firebase.js):
- deleteCollection(path, whereField, whereValue) both adapters
- Firebase: fetch matching + batch-delete (firestore no bulk), 500-chunk
- Gate: throws if NODE_ENV not development/test
- Contract-tested both backends

App (src/App.js):
- deleteCampaignCascade refactored (reusable, no try/catch split)
- handleDeleteAllCampaigns: Promise.all per campaign, deleteCollection for
  encounters (no fetch), deleteCollection logs once globally, parallel
- Button dev-gated (NODE_ENV), confirm modal, hidden when no campaigns

Mock fixes (surfaced by new tests):
- firebase firestore mock: added where() export, getDocs applies constraints
  (was returning all docs ignoring query constraints — pre-existing gap)

Tests:
- contract: deleteCollection (bulk, where-filter, empty) both backends
- server-contract: live deleteCollection (bulk, where, 403 gate)
- runStorageContract via makeStorage({allowDevEndpoints:true})

Safety (3 layers):
- UI button hidden in prod (NODE_ENV gate)
- storage method throws in prod (NODE_ENV gate)
- HTTP endpoint 403 in prod (env/param gate)
2026-07-06 19:06:52 -04:00

328 lines
13 KiB
JavaScript

// 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.
// Native browser WebSocket if present, else ws pkg (Node/jest).
// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require().
let WebSocketImpl;
if (typeof WebSocket !== 'undefined') {
WebSocketImpl = WebSocket;
}
function createServerStorage({ baseUrl, realtimeUrl } = {}) {
// Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy).
// Fallback to localhost for bare `npm start` dev without proxy.
const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, '');
let WS;
if (realtimeUrl) {
WS = realtimeUrl;
} else if (typeof window !== 'undefined' && window.location) {
// derive from current origin (http→ws, https→wss), same host/port.
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
WS = `${proto}//${window.location.host}/ws`;
} else {
WS = 'ws://127.0.0.1:4001/ws';
}
// 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\//, '');
}
// Inject id (last path segment) into doc — matches main firebase truth:
// { id: docSnap.id, ...snap.data() }
// Backend stores docs WITHOUT id; client adds it so all adapters share shape.
function withId(rawPath, data) {
if (data === null || data === undefined) return data;
const id = norm(rawPath).split('/').pop();
return { id, ...data };
}
const docSubs = new Map(); // path -> Set<cb>
const collSubs = new Map(); // collPath -> Set<cb>
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
// Last data emitted per path. Dedupe identical re-emits so WS-open
// re-fetch (authoritative catch-up) doesn't double-fire when REST already
// delivered the same data. Race: REST empty → WS-open populated fires.
const lastEmitted = new Map(); // path -> serialized data (or null for empty)
let ws = null;
let wsReady = null;
let disposed = false;
let reconnectTimer = null;
let everConnected = false;
const RECONNECT_DELAY = 500;
// Emit helper: dedupe identical re-emits per path. WS-open re-fetch
// (authoritative catch-up) + initial REST both may fire; skip dup.
function emit(kind, path, data, cbs) {
const key = `${kind}:${path}`;
const ser = data === undefined ? 'undef' : JSON.stringify(data);
if (lastEmitted.get(key) === ser) return; // identical, skip
lastEmitted.set(key, ser);
cbs.forEach(cb => cb(data));
}
function ensureWs() {
if (wsReady) return wsReady;
wsReady = new Promise((resolve, reject) => {
(async () => {
// Node/jest only: load ws pkg via dynamic import. Browser uses global
// WebSocket. Avoids require() in CRA prod ESM bundle (webpack crash).
let WsClass = WebSocketImpl;
if (!WsClass) {
const wsPkg = await import('ws');
WsClass = wsPkg.WebSocket;
}
ws = new WsClass(WS);
const onOpen = () => {
const isReconnect = everConnected;
everConnected = true;
// resubscribe all existing subscribers after (re)connect
for (const p of docSubs.keys()) {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
}
for (const p of collSubs.keys()) {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
}
// Reconnect only: re-fetch current values — catches writes that
// happened while disconnected (broadcast missed). Skip on first connect
// (initial REST fetch in subscribeDoc/subscribeCollection already did).
if (isReconnect) {
for (const [p, cbs] of docSubs) {
storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {});
}
for (const [p, cbs] of collSubs) {
const cstr = collConstraints.get(p) || [];
storage.getCollection(p, cstr).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
}
}
resolve(ws);
};
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
const onClose = () => {
wsReady = null;
ws = null;
if (disposed) return;
// auto-reconnect (BUG-8): try again after delay. ensureWs() re-arms.
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!disposed) ensureWs().catch(() => {});
}, RECONNECT_DELAY);
if (reconnectTimer && typeof reconnectTimer.unref === 'function') reconnectTimer.unref();
};
const onMessage = (ev) => {
const raw = typeof ev === 'string' ? ev : (ev.data !== undefined ? ev.data : ev);
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
handleMessage(msg);
};
// Bind ONCE. Browser WS + ws pkg are EventTargets: assigning onX AND
// addEventListener('x') registers the handler twice -> every event
// fires 2x (double onOpen -> spurious reconnect re-fetch, double
// onMessage -> emit dedupe races under write load -> UI looks stale).
ws.onopen = onOpen;
ws.onerror = onError;
ws.onclose = onClose;
ws.onmessage = onMessage;
})();
});
return wsReady;
}
// 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;
// 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));
}
// collection subscribers at parent path (apply their stored constraints)
if (c.parent) {
const collCbs = collSubs.get(c.parent);
if (collCbs) {
const constraints = collConstraints.get(c.parent) || [];
// Server honors orderBy + limit in SQL now.
const docs = await storage.getCollection(c.parent, constraints);
collCbs.forEach(cb => cb(docs));
}
}
}
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,
});
if (!res.ok) {
const t = await res.text().catch(() => '');
throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
const storage = {
async getDoc(rawPath) {
const p = norm(rawPath);
const res = await api('GET', '/api/doc', { path: p });
const data = res && res.data !== undefined ? res.data : null;
return withId(rawPath, data);
},
async setDoc(rawPath, data, opts = {}) {
const p = norm(rawPath);
// merge flag -> PATCH (shallow merge, create-on-miss). Matches firebase
// setDoc(path, data, {merge:true}) semantics.
if (opts.merge) {
await api('PATCH', '/api/doc', null, { path: p, patch: data });
} else {
await api('PUT', '/api/doc', null, { path: p, data });
}
},
async updateDoc(rawPath, patch) {
const p = norm(rawPath);
await api('PATCH', '/api/doc', null, { path: p, patch });
},
async deleteDoc(rawPath) {
const p = norm(rawPath);
await api('DELETE', '/api/doc', { path: p });
},
// Bulk delete collection (optionally where-filtered). No fetch. SQL DELETE.
// DEV ONLY. Method ships in prod build but throws if not dev.
async deleteCollection(rawCollPath, whereField, whereValue) {
if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') {
throw new Error('deleteCollection: dev only');
}
const p = norm(rawCollPath);
const query = { path: p };
if (whereField) { query.whereField = whereField; query.whereValue = whereValue; }
const res = await api('DELETE', '/api/collection', query);
return res.deleted;
},
async addDoc(rawCollPath, data) {
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, queryConstraints = []) {
const p = norm(rawCollPath);
const query = { path: p };
// Push where + orderBy + limit + offset to server SQL when present.
// Avoids client-side full-scan + re-sort on every WS change notification.
for (const c of queryConstraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'where') {
query.whereField = c.field;
query.whereOp = c.op;
query.whereValue = c.value;
} else if (c.__type === 'orderBy') {
query.orderBy = c.field;
query.dir = c.dir || 'asc';
} else if (c.__type === 'limit') {
query.limit = c.n;
} else if (c.__type === 'offset') {
query.offset = c.offset;
}
}
const docs = await api('GET', '/api/collection', query);
if (!Array.isArray(docs)) return [];
return docs.map(d => {
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
return { id: d.id, ...d.data };
}
return { ...d };
});
},
async countCollection(rawCollPath) {
const p = norm(rawCollPath);
const res = await api('GET', '/api/collection/count', { path: p });
return (res && typeof res.count === 'number') ? res.count : 0;
},
async batchWrite(ops) {
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
await api('POST', '/api/batch', null, { ops: normOps });
},
// Transactional undo (server mode only). Firebase falls back to 2-write
// in App.js (via store.txUndo check). undo = { encounterPath, updates, redo }.
// redo=true applies redo patch + flips undone:false.
async undo({ logPath, undo, redo = false }) {
const normUndo = { ...undo, encounterPath: norm(undo.encounterPath) };
const normLog = norm(logPath);
await api('POST', '/api/undo', null, { logPath: normLog, undo: normUndo, redo });
},
subscribeDoc(rawPath, cb, errCb) {
const p = norm(rawPath);
lastEmitted.delete('doc:'+p); // fresh sub → allow first emit
// Initial value via REST (independent of WS connect). getDoc injects id.
storage.getDoc(p).then(doc => emit('doc', p, doc, docSubs.get(p) || new Set([cb]))).catch(() => {});
// WS only for subsequent change notifications.
ensureWs().then(() => {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
}).catch(() => {});
if (!docSubs.has(p)) docSubs.set(p, new Set());
docSubs.get(p).add(cb);
return () => { docSubs.get(p)?.delete(cb); };
},
subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
const p = norm(rawCollPath);
collConstraints.set(p, queryConstraints || []);
lastEmitted.delete('coll:'+p); // fresh sub → allow first emit
// Server honors orderBy + limit in SQL now. No client re-sort.
storage.getCollection(p, queryConstraints || [])
.then(docs => emit('coll', p, docs, collSubs.get(p) || new Set([cb])))
.catch(err => { if (errCb) errCb(err); });
// WS only for subsequent change notifications.
ensureWs().then(() => {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
}).catch(() => {});
if (!collSubs.has(p)) collSubs.set(p, new Set());
collSubs.get(p).add(cb);
return () => { collSubs.get(p)?.delete(cb); };
},
dispose(cb) {
disposed = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) ws.close();
docSubs.clear(); collSubs.clear(); collConstraints.clear();
if (typeof cb === 'function') cb();
},
_api: api,
_test: {
getWs: () => ws,
forceDrop: () => { if (ws) ws.close(); },
getReady: () => wsReady,
docSubs, collSubs,
},
};
return storage;
}
export { createServerStorage };