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)
328 lines
13 KiB
JavaScript
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 };
|