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
+268
View File
@@ -0,0 +1,268 @@
import type {
AISession,
AISessionRole,
Board,
Book,
Chapter,
Character,
Entity,
Note,
Project,
ProjectMeta,
User,
WorldItem,
} from "@pubscript/core";
// ---- Row shapes (mirror the SQLite schema) ----
export interface UserRow {
id: string;
email: string;
pass_hash: string;
display_name: string;
role: "admin" | "author";
disabled: number;
created_at: string;
}
export interface ShareRow {
project_id: string;
user_id: string;
perm: "read" | "edit";
}
export interface ProjectRow {
id: string;
owner_id: string;
title: string;
settings: string;
cover: string;
created_at: string;
updated_at: string;
}
export interface BookRow {
id: string;
project_id: string;
title: string;
ord: number;
}
export interface ChapterRow {
id: string;
book_id: string;
project_id: string;
title: string;
doc: string;
rev: number;
ord: number;
updated_at: string;
}
export interface EntityRow {
id: string;
project_id: string;
kind: string;
name: string;
fields: string;
created_at: string;
updated_at: string;
}
export interface BoardRow {
id: string;
project_id: string;
nodes: string;
edges: string;
updated_at: string;
}
export interface AISessionRow {
id: string;
project_id: string;
role: string;
model: string;
settings: string;
messages: string;
suggestions: string;
updated_at: string;
}
// ---- Row -> core types ----
export function toUser(r: UserRow): User {
return {
id: r.id,
email: r.email,
displayName: r.display_name,
role: r.role,
createdAt: r.created_at,
};
}
export function toProject(r: ProjectRow, shares: ShareRow[]): Project {
return {
id: r.id,
projectId: r.id,
title: r.title,
ownerId: r.owner_id,
sharedWith: shares.map((s) => ({ userId: s.user_id, perm: s.perm })),
settings: JSON.parse(r.settings),
cover: JSON.parse(r.cover),
createdAt: r.created_at,
updatedAt: r.updated_at,
};
}
export function toProjectMeta(r: ProjectRow, shares: ShareRow[], user: User): ProjectMeta {
const owned = r.owner_id === user.id;
const mine = shares.find((s) => s.user_id === user.id);
return {
id: r.id,
projectId: r.id,
title: r.title,
ownerId: r.owner_id,
cover: JSON.parse(r.cover),
createdAt: r.created_at,
updatedAt: r.updated_at,
perm: owned ? "edit" : (mine?.perm ?? "read"),
owned,
};
}
export function toBook(r: BookRow, chapters: ChapterRow[]): Book {
return {
id: r.id,
projectId: r.project_id,
title: r.title,
order: r.ord,
chapters: chapters
.slice()
.sort((a, b) => a.ord - b.ord)
.map(toChapter),
};
}
export function toChapter(r: ChapterRow): Chapter {
return {
id: r.id,
bookId: r.book_id,
projectId: r.project_id,
title: r.title,
doc: JSON.parse(r.doc),
rev: r.rev,
order: r.ord,
meta: {
wordCount: wordCount(JSON.parse(r.doc)),
scenes: [],
updatedAt: r.updated_at,
},
};
}
export function toBoard(r: BoardRow): Board {
return {
id: r.id,
projectId: r.project_id,
nodes: JSON.parse(r.nodes),
edges: JSON.parse(r.edges),
};
}
export function toAISession(r: AISessionRow): AISession {
return {
id: r.id,
projectId: r.project_id,
role: r.role as AISessionRole,
model: r.model,
settings: JSON.parse(r.settings),
messages: JSON.parse(r.messages),
suggestions: JSON.parse(r.suggestions),
};
}
export function toEntity(r: EntityRow): Entity {
const f = JSON.parse(r.fields);
if (r.kind === "character") {
return {
id: r.id,
projectId: r.project_id,
name: r.name,
role: f.role ?? "",
bio: f.bio ?? "",
appearance: f.appearance ?? "",
voice: f.voice ?? "",
relationships: f.relationships ?? "",
arc: f.arc ?? "",
status: f.status ?? "active",
tags: f.tags ?? [],
} satisfies Character;
}
if (r.kind === "world") {
return {
id: r.id,
projectId: r.project_id,
kind: f.kind ?? "location",
name: r.name,
fields: f.fields ?? {},
} satisfies WorldItem;
}
return {
id: r.id,
projectId: r.project_id,
body: f.body ?? "",
pinned: !!f.pinned,
tags: f.tags ?? [],
linkedTo: f.linkedTo ?? [],
} satisfies Note;
}
// ---- core types -> row (for writes) ----
export function entityToRow(e: Entity): Omit<EntityRow, "created_at" | "updated_at"> {
if ("fields" in e) {
// WorldItem
return {
id: e.id,
project_id: e.projectId,
kind: "world",
name: e.name,
fields: JSON.stringify({ kind: e.kind, fields: e.fields }),
};
}
if ("body" in e) {
// Note
return {
id: e.id,
project_id: e.projectId,
kind: "note",
name: e.body.slice(0, 80),
fields: JSON.stringify({ body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo }),
};
}
// Character
const c = e as Character;
return {
id: c.id,
project_id: c.projectId,
kind: "character",
name: c.name,
fields: JSON.stringify({
role: c.role,
bio: c.bio,
appearance: c.appearance,
voice: c.voice,
relationships: c.relationships,
arc: c.arc,
status: c.status,
tags: c.tags,
}),
};
}
// ---- helpers ----
/** Count words in a ProseMirror doc (text nodes only). */
export function wordCount(doc: unknown): number {
let count = 0;
const walk = (node: any): void => {
if (node && typeof node === "object") {
if (node.type === "text") {
const t = String(node.text ?? "");
count += t.split(/\s+/).filter(Boolean).length;
}
if (Array.isArray(node.content)) for (const c of node.content) walk(c);
}
};
walk(doc);
return count;
}