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

94 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `StoreEvent`s 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).
- [x] 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.