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.
This commit is contained in:
Schwitzz
2026-10-24 18:31:20 -05:00
parent 53f4c5b4c7
commit 1c9b531996
95 changed files with 10006 additions and 16 deletions
+101
View File
@@ -0,0 +1,101 @@
-- 001_initial.sql — the full PubScript schema (CHUNK #2 step 2).
-- Applied in filename order; tracked in `schema_migrations`.
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'author' CHECK (role IN ('admin', 'author')),
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
cover TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
CREATE TABLE IF NOT EXISTS project_shares (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
perm TEXT NOT NULL CHECK (perm IN ('read', 'edit')),
PRIMARY KEY (project_id, user_id)
);
CREATE TABLE IF NOT EXISTS books (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
ord INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_books_project ON books(project_id);
CREATE TABLE IF NOT EXISTS chapters (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
doc TEXT NOT NULL DEFAULT '{"type":"doc","content":[]}',
rev INTEGER NOT NULL DEFAULT 0,
ord INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chapters_project ON chapters(project_id);
CREATE INDEX IF NOT EXISTS idx_chapters_book ON chapters(book_id);
CREATE TABLE IF NOT EXISTS entities (
id TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
name TEXT NOT NULL,
fields TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (project_id, kind, id)
);
CREATE INDEX IF NOT EXISTS idx_entities_project_kind ON entities(project_id, kind);
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
nodes TEXT NOT NULL DEFAULT '[]',
edges TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_boards_project ON boards(project_id);
CREATE TABLE IF NOT EXISTS ai_sessions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
role TEXT NOT NULL,
model TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
messages TEXT NOT NULL DEFAULT '[]',
suggestions TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ai_sessions_project ON ai_sessions(project_id);
CREATE TABLE IF NOT EXISTS audit (
id TEXT PRIMARY KEY,
at TEXT NOT NULL,
user_id TEXT,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit(at);