Rework backend #1

Merged
robert merged 86 commits from rework-backend into main 2026-07-01 19:29:34 -04:00
5 changed files with 61 additions and 17 deletions
Showing only changes of commit c1d982b4a4 - Show all commits
+1 -1
View File
@@ -183,7 +183,7 @@ REWORK_PLAN.md.
- [x] BUG-6: fixed structurally (1-list model)
- [x] BUG-12: fixed — campaign selection follows activeDisplay
- [x] BUG-15: fixed — DisplayView no longer re-sorts (drag order preserved)
- [ ] BUG-8: ws adapter reconnect
- [x] BUG-8: ws adapter reconnect (implemented + GREEN)
- [ ] BUG-10: deact+reactivate double-act
- [ ] BUG-11: FE Combat.scenario crash
- [ ] BUG-13: reorder cross-pointer semantics (RED + decide block/allow)
+5 -1
View File
@@ -131,7 +131,11 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
return {
app, server, wss, store, db,
close(done) { wss.close(); server.close(() => { db.close(); if (done) done(); }); },
close(done) {
wss.clients.forEach(c => { try { c.terminate(); } catch {} });
wss.close();
server.close(() => { db.close(); if (done) done(); });
},
};
}
+4 -6
View File
@@ -14,18 +14,16 @@ const { createServer } = require('../index');
const { createWsStorage } = require('../../src/storage/ws');
const { runStorageContract } = require('../../src/storage/contract');
let nextPort = 4000 + Math.floor(Math.random() * 999);
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
// Disposing the storage closes the backend so each test is fully isolated.
async function makeStorage() {
const port = nextPort++;
const dbPath = path.join(os.tmpdir(), `ws-contract-${port}-${Date.now()}.sqlite`);
const handle = createServer({ dbPath, port });
const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const handle = createServer({ dbPath, port: 0 });
await new Promise((resolve, reject) => {
handle.server.on('error', reject);
handle.server.listen(port, resolve);
handle.server.listen(0, resolve);
});
const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createWsStorage({ baseUrl, wsUrl });
+4 -6
View File
@@ -12,16 +12,14 @@ const { createWsStorage } = require('../../src/storage/ws');
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
let nextPort = 5000 + Math.floor(Math.random() * 999);
async function makeStorage() {
const port = nextPort++;
const dbPath = path.join(os.tmpdir(), `ws-recon-${port}-${Date.now()}.sqlite`);
const handle = createServer({ dbPath, port });
const dbPath = path.join(os.tmpdir(), `ws-recon-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const handle = createServer({ dbPath, port: 0 });
await new Promise((resolve, reject) => {
handle.server.on('error', reject);
handle.server.listen(port, resolve);
handle.server.listen(0, resolve);
});
const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createWsStorage({ baseUrl, wsUrl });
+47 -3
View File
@@ -39,13 +39,51 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
let ws = null;
let wsReady = null;
let disposed = false;
let reconnectTimer = null;
let everConnected = false;
const RECONNECT_DELAY = 500;
function ensureWs() {
if (wsReady) return wsReady;
wsReady = new Promise((resolve, reject) => {
ws = new WebSocketImpl(WS);
const onOpen = () => resolve(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 }));
}
// On 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) {
storage.getCollection(p).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; };
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; }
@@ -168,7 +206,13 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return () => { collSubs.get(p)?.delete(cb); };
},
dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); },
dispose(cb) {
disposed = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) ws.close();
docSubs.clear(); collSubs.clear();
if (typeof cb === 'function') cb();
},
_api: api,
_test: {