8.1 KiB
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)
- 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).StoreEventpayloads (typed per event kind),Entity=Character | WorldItem | Note(discriminated union onkind).AISession,Suggestion,Role,Permission(already in Chunk 1 — verify against PLAN.md).
packages/core/src/schema.ts— a singleENTITY_FIELDSregistry: 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)
- Create the DB (WAL mode) with migrations in
server/src/migrations/(plain SQL, applied in order, tracked in aschema_migrationstable):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)
- Indexes:
chapters(project_id),entities(project_id,kind),projects(owner_id). - Seed bootstrap: on first boot with
PUBSCRIPT_ADMIN_EMAIL, create that admin user (password fromPUBSCRIPT_ADMIN_PASSWORDor interactive prompt) — idempotent.
3. Auth (server/src/auth.ts)
POST /api/auth/register— email+password (argon2id), createsauthor; first-ever user becomesadminif no users exist.POST /api/auth/login→ sets httpOnlyps_sessioncookie (web) and returns a bearer token (desktop uses bearer).POST /api/auth/logoutrevokes.GET /api/me→ current user + role.- Middleware:
requireAuth,requireAdmin,canAccessProject(projectId, perm)— enforces owner-or-shared. Every content route uses these. - Rate limit
@fastify/rate-limiton 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),DELETEshare,POST /api/projects/:id/transfer.books.ts+chapters.ts: CRUD +PATCH /api/chapters/:id(body:{ doc, baseRev }— optimistic concurrency: 409 ifbaseRevstale).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-outStoreEvents per project to subscribed clients.
5. ServerAdapter (packages/api-client)
- New package
packages/api-clientimplementingStorageAdapter:- REST calls with bearer (desktop) or cookies (web) — chosen by
navigator/Tauri detection. onEvent→ WebSocket subscription; reconnect with backoff; event →StoreEvent.- 409 handling → emit
conflictevent (UI shows "reloaded newer version").
- REST calls with bearer (desktop) or cookies (web) — chosen by
LocalAdapter(packages/storage-local): Dexie (IndexedDB) tables mirroring the SQLite schema; same method signatures;onEventvia 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)
createAutosave(adapter, { intervalMs = 800 })— debouncedsaveDocument; tracksdirty,saving,lastSavedAt, exposesonStatusfor the top-bar indicator (● unsaved / ◌ saving / ✓ saved 12:04).- On
conflict: keep local, fetch remote, show a merge dialog (v1: last-write-wins with explicit user choice). beforeunloadflush.
7. App wiring (apps/web)
- Auth screens:
/login,/register(usepackages/uicomponents). 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". - Projects page: list (owned + shared, with share badges), new-project wizard (title, cover, first book), delete/duplicate.
- 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.
- Settings → Accounts (admin sees user management stub — full UI in Chunk 10).
- 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)
- 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.
- Wire as
pnpm contractand add to CI.
Acceptance criteria (all must pass)
pnpm contractgreen 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
KVBackendinterface; 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.