Files
PubScript/packages/core/src/storage-local.ts
T

259 lines
7.5 KiB
TypeScript

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<Project, string>;
books!: Table<Book, string>;
chapters!: Table<Chapter, string>;
entities!: Table<EntityRec, string>;
boards!: Table<Board, string>;
plotBeats!: Table<PlotBeat, string>;
aiSessions!: Table<AISession, string>;
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<ProjectMeta[]> {
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<Project> {
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<Project> {
const p = await db.projects.get(id);
if (!p) throw new Error(`project ${id} not found`);
return p;
},
async deleteProject(id: string): Promise<void> {
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<Book> {
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<Book[]> {
const rows = await db.books.where("projectId").equals(projectId).toArray();
return rows.sort((a, b) => a.order - b.order);
},
async listChapters(bookId: string): Promise<Chapter[]> {
const rows = await db.chapters.where("bookId").equals(bookId).toArray();
return rows.sort((a, b) => a.order - b.order);
},
async deleteChapter(id: string): Promise<void> {
await db.chapters.delete(id);
},
async saveDocument(doc: DocumentRef): Promise<void> {
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<Chapter | null> {
return (await db.chapters.get(id)) ?? null;
},
async saveEntity(e: Entity): Promise<void> {
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<Entity[]> {
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<Entity | null> {
const row = await db.entities.get(entityKey(projectId, kind, id));
return row ? row.data : null;
},
async deleteEntity(projectId: string, kind: string, id: string): Promise<void> {
await db.entities.delete(entityKey(projectId, kind, id));
},
async searchEntities(projectId: string, q: string): Promise<Entity[]> {
const all = await this.listEntities(projectId);
if (!q) return all;
return fuzzySort(all, q, entitySearchText);
},
async saveBoard(b: Board): Promise<void> {
await db.boards.put(b);
},
async getBoard(projectId: string): Promise<Board | null> {
const rows = await db.boards
.where("projectId")
.equals(projectId)
.toArray();
return rows[0] ?? null;
},
async listPlotBeats(projectId: string): Promise<PlotBeat[]> {
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<void> {
await db.plotBeats.put(b);
},
async deletePlotBeat(projectId: string, id: string): Promise<void> {
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<void> {
await db.aiSessions.put(s);
},
onEvent(): () => void {
// Offline: no remote live events to relay.
return () => {};
},
};
export type StorageLocal = typeof storageLocal;