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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user