refactor: rename ws adapter to server across codebase

'ws' conflated storage mode with transport protocol (WebSocket). Renamed for
clarity: the adapter talks the self-hosted server (src/storage/server.js), which
happens to use WebSocket for realtime — but the name should describe what it
connects to, not how.

Renames:
- src/storage/ws.js -> server.js
- createWsStorage() -> createServerStorage()
- storage mode 'ws' -> 'server'
- REACT_APP_BACKEND_WS -> REACT_APP_BACKEND_REALTIME_URL
- factory param wsUrl -> realtimeUrl
- server/tests/ws-*.test.js -> server-*.test.js

Kept literal: 'ws' npm package, ws:// protocol URLs, /ws WebSocket endpoint.
Transport is accurate there; only storage-naming changed.

Updated all docs (DEVELOPMENT, TESTING, REWORK_PLAN, GLOSSARY, TODO), scripts
(dev-start.sh, replay-combat.js), factory + ESM tests. 204 tests green.
This commit is contained in:
david raistrick
2026-07-04 15:47:13 -04:00
parent cb41d9ec8f
commit e94e1959ff
16 changed files with 64 additions and 65 deletions
+6 -6
View File
@@ -20,11 +20,11 @@ TODO: custom condition field
ws = YOUR SQLite server. server/db.js = better-sqlite3. src/storage/ws.js = client that talks to it (REST +
server = YOUR SQLite server. server/db.js = better-sqlite3. src/storage/server.js = client that talks to it (REST +
websocket). Name bad. Should be "sqlite-adapter" or "server". You right.
memory = in-process JS Map. Useless? Mostly yes for prod. Value: fast contract tests, no server spinup. If
you skip memory, contract test still runs vs firebase-mock + live ws-server. Memory = optional convenience.
contract test runs vs firebase-mock + live server backend.
Memory = optional convenience. === extra complication.
@@ -136,7 +136,7 @@ Memory = optional convenience. === extra complication.
`src/__mocks__/firebase/firestore.js` setDoc records call, IGNORES opts
(no actual merge). Mock must simulate firebase merge semantics for test
to pass. Fix = mock setDoc: if opts.merge, MOCK_DB.merge(path,data) else
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, ws adapter
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, server adapter
uses PATCH). Decide which.
- Test: render App + DisplayView, toggle hide-HP, assert display still shows
encounter (not paused).
@@ -166,8 +166,8 @@ Memory = optional convenience. === extra complication.
- `reorderParticipants` returns `log: null`. Other ops return `log.undo`.
- Cannot undo drag-drop. Candidate for undo system (M6).
### BUG-8: ws adapter has no reconnect
- Test: `server/tests/ws-reconnect.test.js` (RED).
### BUG-8: server adapter has no reconnect
- Test: `server/tests/server-reconnect.test.js` (RED).
- WS dies (idle/error/close) → `wsReady=null`, subscribers dead forever.
- Display frozen until full reload.
- Fix: `onclose` → reconnect + re-subscribe existing paths.
@@ -245,7 +245,7 @@ Memory = optional convenience. === extra complication.
- [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)
- [x] BUG-8: ws adapter reconnect (implemented + GREEN)
- [x] BUG-8: server 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)
+6 -6
View File
@@ -65,12 +65,12 @@ curl http://127.0.0.1:4001/health # -> {"ok":true}
Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Frontend (dev server, ws mode)
### Frontend (dev server, server mode)
```bash
REACT_APP_STORAGE=ws \
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
npm start
```
@@ -225,12 +225,12 @@ App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/..
`getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`).
- `firebase` → real SDK init
- `ws`/`memory` → stub auth + db sentinel, route via `storage.*` adapter
- `server` → stub auth + db sentinel, route via `storage.*` adapter
## Test layers
- **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises ws adapter.
- **Layer 2**: ws adapter vs live backend. Proves translation + path identity.
- **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises server adapter.
- **Layer 2**: server adapter vs live backend. Proves translation + path identity.
Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers).
+2 -2
View File
@@ -52,8 +52,8 @@ Round 2: turn 1 (Fighter) → ... → turn 8 (Merchant) [round counter +=1 at
| Term | Meaning |
|------|---------|
| **Adapter** | `src/storage/{firebase,ws,memory}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
| **Adapter** | `src/storage/{firebase,server}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
| **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. |
| **Generic KV doc store** | Backend stores opaque JSON at arbitrary path strings. No shape-specific endpoints. Backend = firebase mirror, not REST API for app entities. |
| **Layer 1 test** | App vs firebase mock. Proves adapter call shape. |
| **Layer 2 test** | ws adapter vs live backend. Proves translation + path identity. |
| **Layer 2 test** | server adapter vs live backend. Proves translation + path identity. |
+10 -10
View File
@@ -61,7 +61,7 @@ The storage interface is the test seam and the upstream-compat layer.
| Impl | When used | Automated-tested? |
|---|---|---|
| `firebase.js` | default (`STORAGE=firebase`) — upstream path | No — requires live Firebase project |
| `ws.js` | `STORAGE=ws` — our fork, talks to backend | Yes — against running backend |
| `server.js` | `STORAGE=server` — our fork, talks to backend | Yes — against running backend |
| `memory.js` | test-only, in-process | Yes — fast, deterministic |
**Frontend interface contract** (all three implement):
@@ -82,7 +82,7 @@ Memory impl: in-memory Map + EventEmitter, for tests (M3).
storage/
index.js # factory: pick impl from STORAGE env
firebase.js # extracted from current App.js (verbatim)
ws.js # NEW — talks to backend
server.js # talks to backend (was ws.js)
memory.js # NEW — test only
contract.js # interface spec (runStorageContract)
tests/ # frontend tests
@@ -116,7 +116,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
| M | Does | Tests? |
|---|---|---|
| 0 | repo, branch, remotes | no |
| 1 | build backend (Node+Express+ws+better-sqlite3) | unit tests as built |
| 1 | build backend (Node+Express+WebSocket+better-sqlite3) | unit tests as built |
| 2 | frontend WS adapter — app runs vs backend, cross-device works | yes |
| 3 | characterization tests lock current behavior | yes |
| 4 | resolve initiative rotation corruption (BUG-5) | yes |
@@ -135,7 +135,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
- **Upstream-PRable:** n/a (fork infra)
### Milestone 1 — Build backend ✅
- `server/`: Express + ws + better-sqlite3.
- `server/`: Express + WebSocket + better-sqlite3.
- Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path.
- Server holds authoritative state. No turn logic server-side (logic stays client-side in `shared/turn.js`).
- **Exit criteria:** backend boots, serves state over WS, persists to SQLite, unit tests green. ✅ DONE.
@@ -144,10 +144,10 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Milestone 2 — Frontend WS adapter ✅
- Define `storage/contract.js` interface spec.
- Move all Firestore call sites from `App.js` into `storage/firebase.js` behind interface (verbatim).
- Implement `storage/ws.js` per interface, talking to backend. Generic KV ops, subscribes to WS.
- Implement `storage/server.js` per interface, talking to backend. Generic KV ops, subscribes via WebSocket.
- Implement `storage/memory.js` for frontend unit tests.
- `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged).
- App runs against backend with `STORAGE=ws`.
- App runs against backend with `STORAGE=server`.
- Cross-device verified manually: DM view + player display + tablet.
- **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE.
- **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌.
@@ -155,7 +155,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Milestone 3 — Characterization tests lock current behavior ✅
- Lock current behavior via tests.
- Cover: START, NEXT_TURN, PAUSE, RESUME, ADD_PARTICIPANT, REMOVE_PARTICIPANT, TOGGLE_ACTIVE, REORDER, APPLY_DAMAGE/HEAL, DEATH_SAVE, END.
- Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (ws adapter vs live backend, proves translation).
- Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (server adapter vs live backend, proves translation).
- Iterate until confident: baseline solid, regressions impossible to silently slip.
- **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE.
- **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape).
@@ -173,7 +173,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
- Single container: caddy (front, static + proxy) + node backend (internal :4001).
- Files in `docker/` tree (kept separate from upstream root Dockerfile):
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback
- `docker/Caddyfile` — proxy /api + /ws (WebSocket path) to node, static SPA fallback
- `docker/entrypoint.sh` — node bg + caddy fg
- `docker/docker-compose.yml` — one `app` service, volume for sqlite
- Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080.
@@ -214,7 +214,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Manual smoke via config flags
- `STORAGE=firebase` → current behavior (friend's path, upstream default).
- `STORAGE=ws` → our path, local backend.
- `STORAGE=server` → our path, local backend.
- docker-compose profiles mirror the above.
### Accepted test gap
@@ -261,6 +261,6 @@ Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally
- M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
- Backend live: port 4001, db `./data/tracker.sqlite`
- Frontend: port 3999 with `REACT_APP_STORAGE=ws`
- Frontend: port 3999 with `REACT_APP_STORAGE=server`
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
- Next milestones: M5 docker-compose. Undo moved to TODO backlog.
+5 -6
View File
@@ -194,11 +194,11 @@ curl http://127.0.0.1:4001/health # → {"ok":true}
Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Frontend (ws mode)
### Frontend (server mode)
```bash
REACT_APP_STORAGE=ws \
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
npm start
```
@@ -210,10 +210,9 @@ Firebase mode (default): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.e
`STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`:
- `firebase` (default) → real SDK
- `ws` → backend (docker/prod)
- `memory` → in-process (test seed)
- `server` → backend (docker/prod)
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs memory/ws/firebase.
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs server/firebase.
## Known RED / backlog
+1 -1
View File
@@ -4,7 +4,7 @@ Manual demo tool. NOT test.
## replay-combat.js
Live backend demo. Drives full combat via ws adapter (same contract as App).
Live backend demo. Drives full combat via server adapter (same contract as App).
Player display live-updates. Watch UI react to state changes.
```bash
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Start local dev stack: node backend (sqlite) + react frontend, ws storage mode.
# Start local dev stack: node backend (sqlite) + react frontend, server storage mode.
# Usage: ./scripts/dev-start.sh
# Stop: ./scripts/dev-stop.sh
set -euo pipefail
@@ -25,12 +25,12 @@ else
echo "backend already on :4001"
fi
# frontend: ws storage, :3999
# frontend: server storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then
echo "starting frontend :3999..."
REACT_APP_STORAGE=ws \
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid
+3 -3
View File
@@ -30,10 +30,10 @@ const {
toggleParticipantActive, applyHpChange, deathSave,
toggleCondition, reorderParticipants, endEncounter,
} = shared;
const { createWsStorage } = require('../src/storage/ws');
const { createServerStorage } = require('../src/storage/server');
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws';
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
const ROUNDS = parseInt(process.argv[2], 10) || 100;
const DELAY = parseInt(process.argv[3], 10) || 200;
@@ -51,7 +51,7 @@ const getPath = {
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Use the ADAPTER as the contract boundary (same as App). No raw REST.
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL });
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
// Mirror App.js CONDITIONS so we exercise all of them.
const CONDITIONS = [
+1 -1
View File
@@ -10,7 +10,7 @@
// logs/{id} doc
//
// No shape-specific tables. Data is opaque JSON. This is the firebase mirror:
// the adapter (src/storage/ws.js) is a thin passthrough, app logic unchanged.
// the adapter (src/storage/server.js) is a thin passthrough, app logic unchanged.
'use strict';
+1 -1
View File
@@ -1,6 +1,6 @@
// 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.
// Adapter (src/storage/server.js) = passthrough, no shape translation.
'use strict';
@@ -1,9 +1,9 @@
// Layer 2 test: exercise ws.js storage adapter against a LIVE backend.
// Layer 2 test: exercise server.js storage adapter against a LIVE backend.
// Complements Layer 1 (App + firebase mock) which proves App call shape but
// never touches ws.js. This catches translation bugs in the adapter.
//
// Runs the shared storage contract (same spec memory/firebase satisfy) against
// createWsStorage pointed at an ephemeral backend instance. A FRESH backend is
// createServerStorage pointed at an ephemeral backend instance. A FRESH backend is
// spun up per test to guarantee isolation (backend has no reset endpoint yet).
'use strict';
@@ -11,7 +11,7 @@
const path = require('path');
const os = require('os');
const { createServer } = require('../index');
const { createWsStorage } = require('../../src/storage/ws');
const { createServerStorage } = require('../../src/storage/server');
const { runStorageContract } = require('../../src/storage/contract');
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
@@ -26,9 +26,9 @@ async function makeStorage() {
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 });
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
storage.dispose = (done) => handle.close(done);
return storage;
}
runStorageContract('ws (live backend)', makeStorage);
runStorageContract('server (live backend)', makeStorage);
@@ -8,7 +8,7 @@
const path = require('path');
const os = require('os');
const { createServer } = require('../index');
const { createWsStorage } = require('../../src/storage/ws');
const { createServerStorage } = require('../../src/storage/server');
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
@@ -22,7 +22,7 @@ async function makeStorage() {
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 });
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
storage.dispose = (done) => handle.close(done);
return storage;
}
+6 -6
View File
@@ -1,5 +1,5 @@
// src/storage/index.js — storage factory + SDK re-exports.
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=ws: backend.
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=server: backend.
// App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending).
import { initializeApp } from 'firebase/app';
@@ -11,7 +11,7 @@ import {
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
} from 'firebase/firestore';
import { initFirebase, createFirebaseStorage } from './firebase';
import { createWsStorage } from './ws';
import { createServerStorage } from './server';
let storageInstance = null;
@@ -23,13 +23,13 @@ export function getStorage() {
const ok = initFirebase();
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
storageInstance = createFirebaseStorage();
} else if (mode === 'ws') {
storageInstance = createWsStorage({
} else if (mode === 'server') {
storageInstance = createServerStorage({
baseUrl: process.env.REACT_APP_BACKEND_URL || '',
wsUrl: process.env.REACT_APP_BACKEND_WS || '',
realtimeUrl: process.env.REACT_APP_BACKEND_REALTIME_URL || '',
});
} else {
throw new Error(`Unknown REACT_APP_STORAGE mode: ${mode}. Use 'firebase' or 'ws'.`);
throw new Error(`Unknown REACT_APP_STORAGE mode: ${mode}. Use 'firebase' or 'server'.`);
}
return storageInstance;
}
+4 -4
View File
@@ -11,13 +11,13 @@ if (typeof WebSocket !== 'undefined') {
WebSocketImpl = WebSocket;
}
function createWsStorage({ baseUrl, wsUrl } = {}) {
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 (wsUrl) {
WS = wsUrl;
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:';
@@ -259,4 +259,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return storage;
}
export { createWsStorage };
export { createServerStorage };
+2 -2
View File
@@ -1,6 +1,6 @@
// Lock: storage adapters must use ESM exports (no module.exports).
// Regression guard: CJS in src/ crashes CRA prod build (ESM strict).
// Bug history: ws.js + firebase.js used module.exports. Dev lenient (masked),
// Bug history: server.js + firebase.js used module.exports. Dev lenient (masked),
// prod bundle crashed blank page. firebase.js always ESM.
import fs from 'fs';
import path from 'path';
@@ -8,7 +8,7 @@ import path from 'path';
const ADAPTER_DIR = path.join(__dirname, '..', 'storage');
describe('storage adapters use ESM (no CJS)', () => {
const adapters = ['ws.js', 'firebase.js', 'index.js'];
const adapters = ['server.js', 'firebase.js', 'index.js'];
test.each(adapters)('%s has no module.exports', (file) => {
const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8');
// strip line comments so words like 'require' in explanatory comments don't trip the guard
+6 -6
View File
@@ -27,10 +27,10 @@ describe('getStorageMode', () => {
expect(getStorageMode()).toBe('firebase');
});
test('returns ws when REACT_APP_STORAGE=ws', () => {
process.env.REACT_APP_STORAGE = 'ws';
test('returns server when REACT_APP_STORAGE=server', () => {
process.env.REACT_APP_STORAGE = 'server';
const { getStorageMode } = require('../storage/index');
expect(getStorageMode()).toBe('ws');
expect(getStorageMode()).toBe('server');
});
test('throws on unknown mode', () => {
@@ -41,8 +41,8 @@ describe('getStorageMode', () => {
});
describe('getStorage factory routing', () => {
test('ws mode returns ws adapter (init may fail without backend — catch)', () => {
process.env.REACT_APP_STORAGE = 'ws';
test('server mode returns server adapter (init may fail without backend — catch)', () => {
process.env.REACT_APP_STORAGE = 'server';
const { getStorage } = require('../storage/index');
try {
const s = getStorage();
@@ -56,7 +56,7 @@ describe('getStorage factory routing', () => {
});
test('returns singleton on repeat call (same instance)', () => {
process.env.REACT_APP_STORAGE = 'ws';
process.env.REACT_APP_STORAGE = 'server';
const { getStorage } = require('../storage/index');
let a;
try { a = getStorage(); } catch (e) { return; } // no backend ok