1c9b531996
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.
94 lines
8.1 KiB
Markdown
94 lines
8.1 KiB
Markdown
# 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.
|