Files
PubScript/CHUNK#2.md
T
Schwitzz 1c9b531996 Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed
Complete the server-backed storage layer for PubScript so the app can run
against a real Fastify + SQLite backend instead of only local IndexedDB.

Server (@pubscript/server):
- db.ts: better-sqlite3 schema + migrations (users, shares, projects, books,
  chapters, entities, boards, ai_sessions, audit); WAL mode; idempotent
  admin bootstrap via PUBSCRIPT_ADMIN_EMAIL/PASSWORD.
- auth.ts: argon2 hashing, JWT-ish session tokens, guards, auth routes.
- routes.ts: REST CRUD for projects/books/chapters/entities/boards/ai_sessions
  with multi-tenant isolation and optimistic concurrency (baseRev).
- ws.ts: Fastify websocket hub for autosave + presence events.
- mapping.ts: row <-> core type conversion.

Core (@pubscript/core):
- Zod domain schemas (projects, books, chapters, entities, boards, ai_sessions)
  exported from index.
- storage-local.ts (IndexedDB/Dexie) and storage-server.ts (REST) adapters
  implementing the StorageAdapter contract.

Tests & tooling:
- storage.contract.test.ts: parameterized over both adapters; wired as
  `pnpm contract` and added to CI.
- seed.ts (`pnpm --filter server seed`): creates demo project "The Glass Harbor"
  — 3 chapters of prose, 4 linked characters, 1 world location, 6-node board.
- Dockerfile + compose wiring for the server runtime.

Acceptance: seed project opens with 3 chapters, 4 linked characters, 1 board.
2026-10-24 18:31:20 -05:00

8.1 KiB
Raw Blame History

CHUNK #2 — Data Model, Storage & Auth

Duration: ~3 days · Prereqs: CHUNK #1 (green CI, app shell) · Follows: CHUNK #1 · Unblocks: everything (this is the spine)

Purpose: Implement the full domain model, both StorageAdapter implementations (local + server), the multi-user Fastify API with auth, roles, and project sharing, the autosave engine, and — critically — the storage contract test suite that proves Local and Server adapters behave identically. After this chunk, the app can store and sync real content, and users can sign in, create projects, and share them.


Steps

1. Domain model finalization (packages/core)

  1. In packages/core/src/types.ts, ensure every type from PLAN.md §3.3 is complete and exported. Add:
    • ProjectMeta (list view), NewProject, DocumentRef { id, projectId, bookId?, title, doc: ProseMirrorDoc, rev: number }.
    • ProseMirrorDoc = { type: 'doc', content: JsonNode[] } (JSON form of the TipTap schema).
    • StoreEvent payloads (typed per event kind), Entity = Character | WorldItem | Note (discriminated union on kind).
    • AISession, Suggestion, Role, Permission (already in Chunk 1 — verify against PLAN.md).
  2. packages/core/src/schema.ts — a single ENTITY_FIELDS registry: for each entity kind, the ordered field list ({ key, label, type: 'text'|'textarea'|'select'|'list'|'link', options? }). This registry drives the generic entity editor (Chunk 4) and the server's validation. Single source of truth.

2. SQLite schema (server/src/db.ts)

  1. Create the DB (WAL mode) with migrations in server/src/migrations/ (plain SQL, applied in order, tracked in a schema_migrations table):
    • users(id, email UNIQUE, pass_hash, display_name, role, created_at)
    • sessions(token_hash PK, user_id, expires_at, created_at)
    • projects(id, owner_id, title, settings JSON, cover, created_at, updated_at)
    • project_shares(project_id, user_id, perm, PRIMARY KEY(project_id,user_id))
    • books(id, project_id, title, ord) · chapters(id, book_id, project_id, title, doc JSON, rev, ord, updated_at)
    • entities(id, project_id, kind, name, fields JSON, created_at, updated_at, UNIQUE(project_id,kind,id))
    • boards(id, project_id, nodes JSON, edges JSON, updated_at)
    • ai_sessions(id, project_id, role, model, settings JSON, messages JSON, suggestions JSON, updated_at)
    • audit(id, at, user_id, action, detail JSON)
  2. Indexes: chapters(project_id), entities(project_id,kind), projects(owner_id).
  3. Seed bootstrap: on first boot with PUBSCRIPT_ADMIN_EMAIL, create that admin user (password from PUBSCRIPT_ADMIN_PASSWORD or interactive prompt) — idempotent.

3. Auth (server/src/auth.ts)

  1. POST /api/auth/register — email+password (argon2id), creates author; first-ever user becomes admin if no users exist.
  2. POST /api/auth/login → sets httpOnly ps_session cookie (web) and returns a bearer token (desktop uses bearer). POST /api/auth/logout revokes.
  3. GET /api/me → current user + role.
  4. Middleware: requireAuth, requireAdmin, canAccessProject(projectId, perm) — enforces owner-or-shared. Every content route uses these.
  5. Rate limit @fastify/rate-limit on auth endpoints (10/min/IP).

4. Content API (server/src/routes/)

Implement against the core types (zod-validated):

  • projects.ts: GET /api/projects (owned ∪ shared), POST /api/projects, GET/PATCH/DELETE /api/projects/:id, POST /api/projects/:id/shares (invite by email, read|edit), DELETE share, POST /api/projects/:id/transfer.
  • books.ts + chapters.ts: CRUD + PATCH /api/chapters/:id (body: { doc, baseRev } — optimistic concurrency: 409 if baseRev stale).
  • entities.ts: CRUD per kind; GET /api/projects/:id/entities?kind=&q= (fuzzy).
  • boards.ts, ai.ts (session CRUD + POST /api/ai/sessions/:id/suggestions), notes.ts.
  • admin.ts (requireAdmin): GET /api/admin/users, POST /api/admin/users (create), PATCH /api/admin/users/:id (role/disable), GET /api/admin/activity (audit tail).
  • ws.ts: GET /ws (WebSocket, auth via token query or cookie) → fan-out StoreEvents per project to subscribed clients.

5. ServerAdapter (packages/api-client)

  1. New package packages/api-client implementing StorageAdapter:
    • REST calls with bearer (desktop) or cookies (web) — chosen by navigator/Tauri detection.
    • onEvent → WebSocket subscription; reconnect with backoff; event → StoreEvent.
    • 409 handling → emit conflict event (UI shows "reloaded newer version").
  2. LocalAdapter (packages/storage-local): Dexie (IndexedDB) tables mirroring the SQLite schema; same method signatures; onEvent via a local event bus. In Tauri, persist to disk via @tauri-apps/plugin-fs (same Dexie code, different driver).

6. Autosave engine (packages/core/src/autosave.ts)

  1. createAutosave(adapter, { intervalMs = 800 }) — debounced saveDocument; tracks dirty, saving, lastSavedAt, exposes onStatus for the top-bar indicator (● unsaved / ◌ saving / ✓ saved 12:04).
  2. On conflict: keep local, fetch remote, show a merge dialog (v1: last-write-wins with explicit user choice).
  3. beforeunload flush.

7. App wiring (apps/web)

  1. Auth screens: /login, /register (use packages/ui components). On boot: GET /api/me (web) or stored token (desktop) → set current user; no server configured (local mode) → skip auth, banner "Local mode — data stays on this device".
  2. Projects page: list (owned + shared, with share badges), new-project wizard (title, cover, first book), delete/duplicate.
  3. Project workspace: left rail = document tree (Book → Part → Chapter, dnd reorder, per-chapter word count); center = placeholder "editor slot" (real editor lands in Chunk 3); right rail = placeholder for AI/notes.
  4. Settings → Accounts (admin sees user management stub — full UI in Chunk 10).
  5. Seed script server/src/seed.ts (pnpm --filter server seed): creates a demo project "The Glass Harbor" — 3 chapters of real prose, 4 characters with relationships, 1 world location, 1 board with 6 nodes — so every later chunk has something to show.

8. Contract test suite (packages/core/src/storage.contract.test.ts)

  1. One suite, parameterized over both adapters (Local via Dexie in jsdom/happy-dom; Server against a real Fastify instance + SQLite in :memory: or a temp file):
    • project CRUD round-trips; document save/load byte-identical; entity CRUD per kind; board save/load; AI session + suggestions round-trip.
    • multi-tenant isolation: user A can never read user B's project (server adapter only — Local adapter trivially passes).
    • onEvent: a save in one "client" is observed by a second subscriber (WS on server; bus on local).
    • optimistic concurrency: stale baseRev → conflict.
  2. Wire as pnpm contract and add to CI.

Acceptance criteria (all must pass)

  • pnpm contract green against both adapters.
  • Register → login → create project → save a document → reload: content intact.
  • Admin creates a second user; that user logs in, sees only their projects; admin shares a project (edit) → it appears; disable → their session is dead within one event round-trip.
  • Autosave indicator cycles correctly; kill the tab mid-typing, reopen → ≤1 s of work lost.
  • Two browser windows on the same project: save in one appears in the other within ~1 s (WS).
  • Seed project opens with 3 chapters, 4 linked characters, 1 board.
  • CI green including the contract suite.

Definition of done

The app is a real multi-user product skeleton: accounts, roles, sharing, and a storage layer that is provably identical whether data lives on disk or on a server. Chunks 3–10 only add features on top of this spine.

Risks & fallbacks

  • Dexie in Tauri vs. browser → abstract the driver behind one KVBackend interface; test both.
  • WS reconnect storms → exponential backoff + jitter; cap at 30 s.
  • JSON doc storage size → store ProseMirror JSON (not HTML); chapters are individually loadable, so a 100k-word novel never loads as one blob.