Files
ttrpg-initiative-tracker/docs/DEVELOPMENT.md
T
david raistrick 52866784b2 M2: replace shape-specific backend with generic KV doc store (firebase mirror)
Backend was endpoint-mapper (POST /api/campaigns ignores path id, etc). Adapter
translation layer brittle, untested, lost doc identity. Generic contract (Layer 2)
test caught 15 bugs immediately.

Rewrite to firebase-mirror KV model:
- server/db.js: docs table (path PK, parent, data JSON). getDoc/setDoc/updateDoc/
  deleteDoc/getCollection/batchWrite. parent = path prefix for collection queries.
- server/index.js: generic REST (GET/PUT/PATCH/DELETE /api/doc, GET /api/collection,
  POST /api/collection addDoc, POST /api/batch). WS subscribe by path (doc|collection),
  broadcast to doc subs at changed path + collection subs at parent path.
- src/storage/ws.js: thin passthrough adapter. norm() strips firebase prefix.
  initial value via REST (independent of WS connect), subsequent changes via WS.
- shared/turn.js kept (M4 use). server/handlers.js + server.test.js removed (logic
  now in App, backend is dumb KV).
- src/storage/contract.js: flush() bumped to 50ms (WS roundtrip > setTimeout(0)).

Layer 2 test (server/ws-contract.test.js): spins fresh backend per test, runs same
storage contract spec against createWsStorage. Catches adapter translation bugs
that firebase-mock Layer 1 tests cannot.

nanoid v5 ESM breaks jest CJS -> replaced with crypto.randomUUID (Node builtin).

Tests: 114 green (39 shared + 19 ws-contract + 56 frontend).
2026-06-29 13:00:24 -04:00

4.8 KiB

Development

TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm workspaces.

Prerequisites

  • Node.js 22+
  • npm 10+

Layout

/
  package.json          # workspaces root
  src/                  # React frontend (CRA, existing)
    App.js              # ~2935 lines, Firebase direct (M2 abstracts this)
  server/               # Backend: generic KV doc store (firebase mirror)
    index.js            # REST (doc/coll/batch) + WS bootstrap
    db.js               # SQLite docs table, KV ops, broadcast
    ws-contract.test.js # adapter vs live backend (Layer 2)
  shared/               # Pure logic, no I/O (client + server + tests import)
    turn.js             # turn-order state machine
    turn.characterization.test.js
  docs/
    REWORK_PLAN.md      # milestone plan
    DEVELOPMENT.md      # this file

Setup

git clone git@github.com:keen99/ttrpg-initiative-tracker.git
cd ttrpg-initiative-tracker
npm install

Run

Frontend (dev server)

npm start                 # http://localhost:3000

Still uses Firebase by default. Set REACT_APP_FIREBASE_* in .env.local (copy env.example).

Backend (dev)

npm run server:dev        # :4001, db: server/data/tracker.sqlite
# or direct with env:
DB_PATH=/tmp/tracker.sqlite PORT=4001 node server/index.js

Smoke check:

curl http://127.0.0.1:4001/health   # -> {"ok":true}

Frontend not yet wired to backend — that is M2 (storage adapter + WS client).

Test

Three commands:

npm run test:all          # runs shared/ + server/ suites in sequence
npm run shared:test       # turn logic only (shared/ folder)
npm run server:test       # backend ws-contract (adapter vs live backend)

What each runs:

Suite What Count
shared/*.test.js turn FSM, pure functions 39
server/*.test.js REST + combat flow, in-memory db 7

Server tests use --forceExit (open WS handles). Tests spin server on random port, in-memory sqlite, tear down per test.

Local pipeline (pre-push hook)

Private repo = no free GitHub Actions. Tests run locally via git hook.

.githooks/pre-push runs npm run test:all before every push.

Enable on clone (do once):

git config core.hooksPath .githooks

Already configured on this checkout. Skip with:

git push --no-verify

Future: when repo goes public, free GH Actions viable. Then add .github/workflows/ci.yml.

Build

npm run build             # CRA production build -> build/

Docker build (existing, frontend-only):

docker build -t ttrpg-initiative-tracker .
docker run -p 8080:80 --rm ttrpg-initiative-tracker

Full-stack docker-compose arrives in M5.

Backend architecture

Server-authoritative. Kills client-side last-write-wins races (root cause of skip bug).

Client (browser)                  Server
  |                                 |
  |-- POST /api/.../nextTurn ----->|  action
  |                                 |  store.nextTurn():
  |                                 |    shared.nextTurn(enc) -> patch
  |                                 |    db tx: apply patch
  |                                 |    addLog
  |                                 |    broadcast(change)
  |<---- WS {change} --------------|  push to all subscribers
  |                                 |
  Display / tablet                 |
  |<---- WS {change} --------------|  same push
  • SQLite owns truth. Single writer (server). WAL mode.
  • shared/turn.js = pure logic, ported verbatim from App.js. Bugs preserved for M3 characterization, fixed in M4.
  • WS = real-time push (replaces Firebase onSnapshot). Client subscribes to a key ('campaigns', 'encounter:id', 'activeDisplay'...), server pushes on change.
  • Actions not results. Client sends "do X", server computes X, persists, broadcasts. No client-side state mutation.

Storage backend choice

Browser sandbox cannot touch filesystem. Cross-device (DM + tablet + player view) requires a real backend owning the DB file. SQLite = single file, docker volume, trivial backup. Postgres deferred until public multiuser exposure.

Status

Milestone State
0 repo/branch done
1 backend + tests done
2 frontend WS adapter next
3 characterization tests
4 skip fix + manual override
5 docker compose
6 undo rework
7 playwright e2e deferred

See docs/REWORK_PLAN.md for full plan.

Git

  • origin = github.com:keen99/ttrpg-initiative-tracker (this fork)
  • upstream = code.draft13.com/robert/ttrpg-initiative-tracker (friend's Gitea, read-only)
  • work branch: rework-backend (off main)
git fetch upstream         # pull friend's changes
git merge upstream/main    # rebase our branch onto his