import Dexie, { type Table } from "dexie"; import type { AISession, Board, Book, Chapter, DocumentRef, Entity, NewProject, PlotBeat, Project, ProjectMeta, } from "./types.js"; import type { StorageAdapter } from "./storage.js"; import { createUlid } from "./ids.js"; import { nowIso } from "./time.js"; import { fuzzySort } from "./fuzzy.js"; interface EntityRec { key: string; projectId: string; kind: string; data: Entity; } class PubScriptDB extends Dexie { projects!: Table; books!: Table; chapters!: Table; entities!: Table; boards!: Table; plotBeats!: Table; aiSessions!: Table; constructor() { super("pubscript"); this.version(1).stores({ projects: "id, projectId", books: "id, projectId", chapters: "id, bookId, projectId", entities: "key, projectId, kind", boards: "id, projectId", // Plot beats — atomic units of story structure (CHUNK #5). plotBeats: "id, [projectId+act], projectId, chapterId", aiSessions: "id, projectId", }); } } const db = new PubScriptDB(); const entityKey = (projectId: string, kind: string, id: string): string => `${projectId}|${kind}|${id}`; const kindOf = (e: Entity): string => "fields" in e ? "world" : "body" in e ? "note" : "character"; /** * Concatenate every human-readable field of an entity into a single searchable * string (name + field values). Used by the fuzzy search index. */ function entitySearchText(e: Entity): string { if ("fields" in e) { // WorldItem const parts = [e.name, e.kind]; for (const v of Object.values(e.fields)) if (typeof v === "string") parts.push(v); return parts.join(" "); } if ("body" in e) { // Note return [e.body, ...e.tags].join(" "); } // Character return [ e.name, e.role, e.bio, e.appearance, e.voice, e.relationships, e.arc, e.status, ...e.tags, ].join(" "); } /** * The local (offline-first) `StorageAdapter`, backed by IndexedDB via Dexie. * Implements the exact `StorageAdapter` contract so the app can run fully * offline and sync later. `onEvent` is a no-op offline (no remote peers). */ export const storageLocal: StorageAdapter = { async listProjects(): Promise { const rows = await db.projects.toArray(); return rows.map( ({ id, projectId, title, ownerId, sharedWith, settings, cover, createdAt, updatedAt }) => ({ id, projectId, title, ownerId, sharedWith, settings, cover, createdAt, updatedAt, perm: "edit" as const, owned: true, }), ); }, async createProject(p: NewProject): Promise { const id = createUlid(); const t = nowIso(); const project: Project = { id, projectId: id, title: p.title, ownerId: "local", sharedWith: [], settings: { language: p.language ?? "en" }, cover: { color: p.cover?.color ?? "#8b5cf6", ...(p.cover?.image ? { image: p.cover.image } : {}) }, createdAt: t, updatedAt: t, }; await db.projects.put(project); return project; }, async getProject(id: string): Promise { const p = await db.projects.get(id); if (!p) throw new Error(`project ${id} not found`); return p; }, async deleteProject(id: string): Promise { await db.projects.delete(id); await db.books.where("projectId").equals(id).delete(); await db.chapters.where("projectId").equals(id).delete(); await db.entities.where("projectId").equals(id).delete(); await db.boards.where("projectId").equals(id).delete(); await db.plotBeats.where("projectId").equals(id).delete(); await db.aiSessions.where("projectId").equals(id).delete(); }, async createBook(b: { projectId: string; title: string }): Promise { const id = createUlid(); const book: Book = { id, projectId: b.projectId, title: b.title, order: 0, chapters: [] }; await db.books.put(book); return book; }, async listBooks(projectId: string): Promise { const rows = await db.books.where("projectId").equals(projectId).toArray(); return rows.sort((a, b) => a.order - b.order); }, async listChapters(bookId: string): Promise { const rows = await db.chapters.where("bookId").equals(bookId).toArray(); return rows.sort((a, b) => a.order - b.order); }, async deleteChapter(id: string): Promise { await db.chapters.delete(id); }, async saveDocument(doc: DocumentRef): Promise { const existing = await db.chapters.get(doc.id); const existingCount = !existing && doc.bookId ? await db.chapters.where("bookId").equals(doc.bookId).count() : 0; const base: Chapter = existing ?? { id: doc.id, bookId: doc.bookId ?? "", projectId: doc.projectId, title: doc.title, doc: doc.doc, rev: 0, order: existingCount, meta: { wordCount: 0, scenes: [], updatedAt: nowIso() }, }; await db.chapters.put({ ...base, title: doc.title, doc: doc.doc, rev: doc.rev + 1, meta: { ...base.meta, updatedAt: nowIso() }, }); }, async getChapter(id: string): Promise { return (await db.chapters.get(id)) ?? null; }, async saveEntity(e: Entity): Promise { const kind = kindOf(e); await db.entities.put({ key: entityKey(e.projectId, kind, e.id), projectId: e.projectId, kind, data: e }); }, async listEntities(projectId: string, kind?: string): Promise { const rows = await db.entities .where("projectId") .equals(projectId) .toArray(); const filtered = kind ? rows.filter((r) => r.kind === kind) : rows; return filtered.map((r) => r.data); }, async getEntity(projectId: string, kind: string, id: string): Promise { const row = await db.entities.get(entityKey(projectId, kind, id)); return row ? row.data : null; }, async deleteEntity(projectId: string, kind: string, id: string): Promise { await db.entities.delete(entityKey(projectId, kind, id)); }, async searchEntities(projectId: string, q: string): Promise { const all = await this.listEntities(projectId); if (!q) return all; return fuzzySort(all, q, entitySearchText); }, async saveBoard(b: Board): Promise { await db.boards.put(b); }, async getBoard(projectId: string): Promise { const rows = await db.boards .where("projectId") .equals(projectId) .toArray(); return rows[0] ?? null; }, async listPlotBeats(projectId: string): Promise { const rows = await db.plotBeats .where("projectId") .equals(projectId) .toArray(); // Stable order: by act, then order within act. return rows.sort((a, b) => a.act - b.act || a.order - b.order); }, async savePlotBeat(b: PlotBeat): Promise { await db.plotBeats.put(b); }, async deletePlotBeat(projectId: string, id: string): Promise { const existing = await db.plotBeats.get(id); if (!existing || existing.projectId !== projectId) { throw new Error(`No plot beat ${id} in project ${projectId}.`); } await db.plotBeats.delete(id); }, async saveAISession(s: AISession): Promise { await db.aiSessions.put(s); }, onEvent(): () => void { // Offline: no remote live events to relay. return () => {}; }, }; export type StorageLocal = typeof storageLocal;