2026-06-29 13:00:24 -04:00
|
|
|
// ws.js — thin storage adapter over generic KV backend (HTTP + WebSocket).
|
|
|
|
|
// Passthrough: no shape translation. Backend = firebase mirror.
|
2026-06-28 17:51:39 -04:00
|
|
|
// Implements same interface as memory.js. Tested by storage contract vs running server.
|
|
|
|
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
2026-06-29 13:00:24 -04:00
|
|
|
// Native browser WebSocket if present, else ws pkg (Node/jest).
|
2026-06-29 12:36:16 -04:00
|
|
|
let WebSocketImpl;
|
|
|
|
|
if (typeof WebSocket !== 'undefined') {
|
|
|
|
|
WebSocketImpl = WebSocket;
|
|
|
|
|
} else {
|
|
|
|
|
WebSocketImpl = require('ws').WebSocket;
|
|
|
|
|
}
|
2026-06-28 17:51:39 -04:00
|
|
|
|
|
|
|
|
function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|
|
|
|
const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, '');
|
|
|
|
|
const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws');
|
|
|
|
|
|
2026-06-29 13:00:24 -04:00
|
|
|
// App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
|
|
|
|
|
// Backend uses canonical paths. Strip prefix.
|
2026-06-29 12:36:16 -04:00
|
|
|
function norm(p) {
|
2026-06-29 13:00:24 -04:00
|
|
|
if (!p) return p;
|
2026-06-29 12:36:16 -04:00
|
|
|
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:51:39 -04:00
|
|
|
const docSubs = new Map(); // path -> Set<cb>
|
|
|
|
|
const collSubs = new Map(); // collPath -> Set<cb>
|
|
|
|
|
let ws = null;
|
|
|
|
|
let wsReady = null;
|
|
|
|
|
|
|
|
|
|
function ensureWs() {
|
|
|
|
|
if (wsReady) return wsReady;
|
|
|
|
|
wsReady = new Promise((resolve, reject) => {
|
2026-06-29 12:36:16 -04:00
|
|
|
ws = new WebSocketImpl(WS);
|
|
|
|
|
const onOpen = () => resolve(ws);
|
|
|
|
|
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
|
|
|
|
|
const onClose = () => { wsReady = null; };
|
|
|
|
|
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; }
|
2026-06-28 17:51:39 -04:00
|
|
|
handleMessage(msg);
|
2026-06-29 12:36:16 -04:00
|
|
|
};
|
|
|
|
|
ws.onopen = onOpen;
|
|
|
|
|
ws.onerror = onError;
|
|
|
|
|
ws.onclose = onClose;
|
|
|
|
|
ws.onmessage = onMessage;
|
|
|
|
|
if (typeof ws.addEventListener === 'function') {
|
|
|
|
|
ws.addEventListener('open', onOpen);
|
|
|
|
|
ws.addEventListener('error', onError);
|
|
|
|
|
ws.addEventListener('close', onClose);
|
|
|
|
|
ws.addEventListener('message', onMessage);
|
|
|
|
|
}
|
2026-06-28 17:51:39 -04:00
|
|
|
});
|
|
|
|
|
return wsReady;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 13:00:24 -04:00
|
|
|
// Backend pushes change notices keyed by path. Re-fetch affected subscribers.
|
2026-06-28 17:51:39 -04:00
|
|
|
async function handleMessage(msg) {
|
|
|
|
|
if (msg.type !== 'change' || !msg.change) return;
|
|
|
|
|
const c = msg.change;
|
2026-06-29 13:00:24 -04:00
|
|
|
// 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));
|
2026-06-28 17:51:39 -04:00
|
|
|
}
|
2026-06-29 13:00:24 -04:00
|
|
|
// 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));
|
2026-06-28 17:51:39 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 13:00:24 -04:00
|
|
|
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, {
|
2026-06-28 17:51:39 -04:00
|
|
|
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 = {
|
2026-06-29 12:36:16 -04:00
|
|
|
async getDoc(rawPath) {
|
2026-06-29 13:00:24 -04:00
|
|
|
const p = norm(rawPath);
|
|
|
|
|
const res = await api('GET', '/api/doc', { path: p });
|
|
|
|
|
return res && res.data !== undefined ? res.data : null;
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
async setDoc(rawPath, data) {
|
2026-06-29 13:00:24 -04:00
|
|
|
const p = norm(rawPath);
|
|
|
|
|
await api('PUT', '/api/doc', null, { path: p, data });
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
async updateDoc(rawPath, patch) {
|
2026-06-29 13:00:24 -04:00
|
|
|
const p = norm(rawPath);
|
|
|
|
|
await api('PATCH', '/api/doc', null, { path: p, patch });
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
async deleteDoc(rawPath) {
|
2026-06-29 13:00:24 -04:00
|
|
|
const p = norm(rawPath);
|
|
|
|
|
await api('DELETE', '/api/doc', { path: p });
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
async addDoc(rawCollPath, data) {
|
2026-06-29 13:00:24 -04:00
|
|
|
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 });
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async batchWrite(ops) {
|
2026-06-29 13:00:24 -04:00
|
|
|
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
|
|
|
|
|
await api('POST', '/api/batch', null, { ops: normOps });
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
subscribeDoc(rawPath, cb) {
|
2026-06-29 13:00:24 -04:00
|
|
|
const p = norm(rawPath);
|
|
|
|
|
// Initial value via REST (independent of WS connect).
|
|
|
|
|
storage.getDoc(p).then(cb).catch(() => {});
|
|
|
|
|
// WS only for subsequent change notifications.
|
2026-06-28 17:51:39 -04:00
|
|
|
ensureWs().then(() => {
|
2026-06-29 13:00:24 -04:00
|
|
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
2026-06-28 17:51:39 -04:00
|
|
|
}).catch(() => {});
|
2026-06-29 13:00:24 -04:00
|
|
|
if (!docSubs.has(p)) docSubs.set(p, new Set());
|
|
|
|
|
docSubs.get(p).add(cb);
|
|
|
|
|
return () => { docSubs.get(p)?.delete(cb); };
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
2026-06-29 12:36:16 -04:00
|
|
|
subscribeCollection(rawCollPath, cb) {
|
2026-06-29 13:00:24 -04:00
|
|
|
const p = norm(rawCollPath);
|
|
|
|
|
// Initial value via REST (independent of WS connect).
|
|
|
|
|
storage.getCollection(p).then(cb).catch(() => {});
|
|
|
|
|
// WS only for subsequent change notifications.
|
2026-06-28 17:51:39 -04:00
|
|
|
ensureWs().then(() => {
|
2026-06-29 13:00:24 -04:00
|
|
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
2026-06-28 17:51:39 -04:00
|
|
|
}).catch(() => {});
|
2026-06-29 13:00:24 -04:00
|
|
|
if (!collSubs.has(p)) collSubs.set(p, new Set());
|
|
|
|
|
collSubs.get(p).add(cb);
|
|
|
|
|
return () => { collSubs.get(p)?.delete(cb); };
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); },
|
|
|
|
|
|
|
|
|
|
_api: api,
|
2026-06-30 13:59:58 -04:00
|
|
|
_test: {
|
|
|
|
|
getWs: () => ws,
|
|
|
|
|
forceDrop: () => { if (ws) ws.close(); },
|
|
|
|
|
getReady: () => wsReady,
|
|
|
|
|
docSubs, collSubs,
|
|
|
|
|
},
|
2026-06-28 17:51:39 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return storage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { createWsStorage };
|