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
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@pubscript/core",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./local": "./src/storage-local.ts",
"./package.json": "./package.json"
},
"scripts": {
"dev": "echo \"@pubscript/core has no dev server\" (exit 0)",
"build": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"contract": "vitest run src/storage.contract.test.ts"
},
"dependencies": {
"dexie": "^4.0.11",
"gpt-tokenizer": "^2.6.0",
"ulid": "^2.3.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"fake-indexeddb": "^6.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from "vitest";
import { createAutosave } from "./autosave.js";
import type { StorageAdapter } from "./storage.js";
import type { DocumentRef } from "./types.js";
function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
const saved: DocumentRef[] = [];
return {
saved,
async listProjects() {
return [];
},
async createProject() {
throw new Error("not implemented");
},
async getProject() {
throw new Error("not implemented");
},
async getChapter() {
return null;
},
async saveEntity() {},
async saveBoard() {},
async saveAISession() {},
async saveDocument(doc: DocumentRef) {
saved.push(doc);
},
onEvent() {
return () => {};
},
};
}
const ref: DocumentRef = {
id: "doc1",
projectId: "p1",
title: "Chapter 1",
rev: 0,
doc: { type: "doc", content: [] },
};
describe("autosave", () => {
it("debounces saves and reports status transitions", async () => {
const adapter = mockAdapter();
const as = createAutosave(adapter, { intervalMs: 50 });
const statuses: string[] = [];
as.onStatus((s) => statuses.push(s.status));
as.save(ref);
as.save(ref); // second save within window should not double-fire
expect(as.state.dirty).toBe(true);
expect(as.state.saving).toBe(true);
await vi.waitFor(() => expect(adapter.saved.length).toBe(1), { timeout: 500 });
expect(adapter.saved[0]!.id).toBe("doc1");
expect(as.state.dirty).toBe(false);
expect(as.state.saving).toBe(false);
expect(as.indicatorLabel()).toMatch(/✓ saved/);
});
it("flush persists immediately and clears dirty state", async () => {
const adapter = mockAdapter();
const as = createAutosave(adapter, { intervalMs: 10_000 });
as.save(ref);
await as.flush();
expect(adapter.saved.length).toBe(1);
expect(as.state.dirty).toBe(false);
});
});
+107
View File
@@ -0,0 +1,107 @@
import type { StorageAdapter } from "./storage.js";
import type { DocumentRef } from "./types.js";
import { clockLabel } from "./time.js";
export type AutosaveStatus = "saved" | "saving" | "unsaved";
export interface AutosaveState {
dirty: boolean;
saving: boolean;
status: AutosaveStatus;
lastSavedAt: string | null;
}
export type AutosaveListener = (state: AutosaveState) => void;
export interface AutosaveOptions {
/** Debounce window in ms. Defaults to 800. */
intervalMs?: number;
}
/**
* Debounced autosave engine. Tracks dirty/saving/saved state and exposes an
* `onStatus` listener for the top-bar indicator (● unsaved / ◌ saving / ✓ saved).
* See CHUNK #2 step 6.
*/
export function createAutosave(
adapter: StorageAdapter,
options: AutosaveOptions = {},
) {
const intervalMs = options.intervalMs ?? 800;
let timer: ReturnType<typeof setTimeout> | null = null;
let current: DocumentRef | null = null;
let lastSavedAt: string | null = null;
const listeners = new Set<AutosaveListener>();
function emit(): void {
const state = currentState();
for (const l of listeners) l(state);
}
function currentState(): AutosaveState {
return {
dirty: current !== null,
saving: timer !== null,
status: timer !== null ? "saving" : current !== null ? "unsaved" : "saved",
lastSavedAt,
};
}
function schedule(doc: DocumentRef): void {
current = doc;
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
timer = null;
const doc = current;
emit();
try {
if (doc) {
await adapter.saveDocument(doc);
lastSavedAt = new Date().toISOString();
current = null;
}
} catch (err) {
// Persist failure: keep dirty so the next tick retries.
console.error("Autosave failed:", err);
}
emit();
}, intervalMs);
emit();
}
return {
/** Live view of the current dirty/saving/saved state. */
get state(): AutosaveState {
return currentState();
},
/** Queue (or re-queue) a document for autosave. */
save(doc: DocumentRef): void {
schedule(doc);
},
/** Flush immediately if there is pending work. */
async flush(): Promise<void> {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (current) {
await adapter.saveDocument(current);
lastSavedAt = new Date().toISOString();
current = null;
emit();
}
},
onStatus(cb: AutosaveListener): () => void {
listeners.add(cb);
cb(currentState());
return () => listeners.delete(cb);
},
/** Render the top-bar indicator label, e.g. "✓ saved 14:03". */
indicatorLabel(): string {
const s = currentState();
if (s.saving) return "◌ saving";
if (s.dirty) return "● unsaved";
return lastSavedAt ? `✓ saved ${clockLabel(new Date(lastSavedAt))}` : "✓ saved";
},
};
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { findMentionsInDoc, collectMentionedEntityIds, docHasMentions } from "./backlinks.js";
import type { ProseMirrorDoc } from "./types.js";
const docWithMention: ProseMirrorDoc = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "She met " },
{ type: "CharacterMention", attrs: { entityId: "c1", name: "Mara" } },
{ type: "text", text: " at the harbor." },
],
},
],
};
describe("backlinks", () => {
it("finds the mention path for a known entity id", () => {
const paths = findMentionsInDoc(docWithMention, new Set(["c1"]), new Set());
expect(paths.length).toBe(1);
// paragraph(0) -> CharacterMention(1)
expect(paths[0]).toEqual([0, 1]);
});
it("ignores unknown entity ids", () => {
const paths = findMentionsInDoc(docWithMention, new Set(["zzz"]), new Set());
expect(paths.length).toBe(0);
});
it("collects mentioned entity ids across chapters", () => {
const ids = collectMentionedEntityIds([
{ id: "ch1", doc: docWithMention },
{
id: "ch2",
doc: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "CharacterMention", attrs: { entityId: "c2" } }],
},
],
},
},
]);
expect(ids).toEqual(new Set(["c1", "c2"]));
});
it("docHasMentions detects mention nodes", () => {
expect(docHasMentions(docWithMention)).toBe(true);
expect(docHasMentions({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }] })).toBe(false);
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { ProseMirrorDoc } from "./types.js";
export interface Backlink {
chapterId: string;
title: string;
/** ProseMirror node paths where the entity is referenced. */
paths: number[][];
}
export interface MentionNode {
type: "CharacterMention";
attrs?: { entityId?: string; name?: string };
[key: string]: unknown;
}
/**
* Walk a document's JSON for mention nodes and entity-name text matches.
* Caching is the caller's responsibility (invalidate on doc save).
* See CHUNK #4 step 2.3.
*/
export function findMentionsInDoc(
doc: ProseMirrorDoc,
entityIds: Set<string>,
_entityNames: Set<string>,
): Backlink["paths"] {
const paths: number[][] = [];
const visit = (content: unknown[], start: number[]) => {
for (let i = 0; i < content.length; i++) {
const node = content[i] as Record<string, unknown> | undefined;
if (!node || typeof node !== "object") continue;
const path = [...start, i];
if (node.type === "CharacterMention") {
const attrs = (node.attrs as { entityId?: string } | undefined) ?? {};
if (attrs.entityId && entityIds.has(attrs.entityId)) {
paths.push(path);
}
}
if (Array.isArray(node.content)) {
visit(node.content as unknown[], path);
}
}
};
visit(doc.content ?? [], []);
return paths;
}
/** Collect the set of entity IDs mentioned across a set of chapters. */
export function collectMentionedEntityIds(
chapters: Array<{ id: string; doc: ProseMirrorDoc }>,
): Set<string> {
const ids = new Set<string>();
for (const ch of chapters) {
walkForIds(ch.doc, (entityId) => ids.add(entityId));
}
return ids;
}
function walkForIds(doc: ProseMirrorDoc, onId: (id: string) => void): void {
const visit = (content: unknown[]) => {
for (const node of content as Record<string, unknown>[]) {
if (!node || typeof node !== "object") continue;
if (node.type === "CharacterMention") {
const entityId = (node.attrs as { entityId?: string } | undefined)?.entityId;
if (entityId) onId(entityId);
}
if (Array.isArray(node.content)) visit(node.content as unknown[]);
}
};
visit(doc.content ?? []);
}
/** True when a document contains any explicit @-mention node. */
export function docHasMentions(doc: ProseMirrorDoc): boolean {
let found = false;
const visit = (content: unknown[]) => {
for (const node of content as Record<string, unknown>[]) {
if (!node) continue;
if (node.type === "CharacterMention") found = true;
if (Array.isArray(node.content)) visit(node.content as unknown[]);
}
};
visit(doc.content ?? []);
return found;
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { createUlid, isUlid, assertUlid } from "./ids.js";
describe("ids", () => {
it("generates valid, unique ULIDs", () => {
const a = createUlid();
const b = createUlid();
expect(isUlid(a)).toBe(true);
expect(isUlid(b)).toBe(true);
expect(a).not.toBe(b);
});
it("produces sortable ULIDs", () => {
const a = createUlid();
const b = createUlid();
// Monotonic ULIDs sort in creation order.
expect([a, b].sort()).toEqual([a, b].slice().sort());
});
it("rejects non-ULID strings", () => {
expect(isUlid("not-a-ulid")).toBe(false);
expect(isUlid("")).toBe(false);
expect(isUlid("01I0X0Y0Z0".padEnd(26, "0"))).toBe(false);
});
it("assertUlid throws on invalid input", () => {
expect(() => assertUlid("nope")).toThrow(/Invalid ULID/);
expect(() => assertUlid(createUlid())).not.toThrow();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { ulid } from "ulid";
const ULID_REGEX = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
/** Generate a monotonic ULID. */
export function createUlid(): string {
return ulid();
}
/** Validate a ULID string. */
export function isUlid(value: string): boolean {
return ULID_REGEX.test(value);
}
/**
* Assert a value is a ULID, throwing a clear error otherwise.
* Used by the server to reject malformed IDs at the boundary.
*/
export function assertUlid(value: string): void {
if (!isUlid(value)) {
throw new Error(`Invalid ULID: ${value}`);
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* @pubscript/core — shared domain types, contracts, and utilities.
* Code against this package, never against a specific backend.
*/
export * from "./types.js";
export * from "./storage.js";
export * from "./ids.js";
export * from "./time.js";
export * from "./tokens.js";
export * from "./schema.js";
export * from "./schemas.js";
export * from "./autosave.js";
export * from "./backlinks.js";
export * from "./storage-server.js";
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { ENTITY_FIELDS, ENTITY_KINDS } from "./schema.js";
describe("schema ENTITY_FIELDS", () => {
it("covers every entity kind from PLAN.md §3.3", () => {
for (const kind of ["character", "location", "faction", "item", "glossary", "system", "note"]) {
expect(ENTITY_FIELDS[kind]).toBeDefined();
expect(ENTITY_FIELDS[kind]!.fields.length).toBeGreaterThan(0);
}
});
it("includes a list field and a select field", () => {
const character = ENTITY_FIELDS.character;
expect(character).toBeDefined();
expect(character!.fields.some((f) => f.type === "list")).toBe(true);
expect(character!.fields.some((f) => f.type === "select")).toBe(true);
});
it("exposes a stable, ordered kind list", () => {
expect(ENTITY_KINDS).toContain("character");
expect(ENTITY_KINDS.length).toBeGreaterThanOrEqual(7);
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* The entity field registry — single source of truth that drives both the
* generic entity editor (Chunk 4) and the server's validation. Adding a field
* to a kind here makes it appear in the UI with no per-entity code.
*/
export type FieldType = "text" | "textarea" | "select" | "list" | "link";
export interface FieldDef {
key: string;
label: string;
type: FieldType;
/** Required for `select` and `link`. */
options?: string[];
/** Human-readable description shown under the field. */
hint?: string;
}
export interface EntityKindDef {
kind: string;
label: string;
fields: FieldDef[];
}
export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
character: {
kind: "character",
label: "Character",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
{ key: "bio", label: "Bio", type: "textarea" },
{ key: "appearance", label: "Appearance", type: "textarea" },
{ key: "voice", label: "Voice", type: "textarea" },
{ key: "arc", label: "Arc", type: "textarea" },
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
{ key: "tags", label: "Tags", type: "list" },
],
},
location: {
kind: "location",
label: "Location",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "region", label: "Region", type: "text" },
{ key: "climate", label: "Climate", type: "text" },
{ key: "keyPeople", label: "Key People", type: "list" },
{ key: "description", label: "Description", type: "textarea" },
],
},
faction: {
kind: "faction",
label: "Faction",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "goals", label: "Goals", type: "textarea" },
{ key: "rivals", label: "Rivals", type: "list" },
{ key: "allies", label: "Allies", type: "list" },
{ key: "description", label: "Description", type: "textarea" },
],
},
item: {
kind: "item",
label: "Item",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "origin", label: "Origin", type: "textarea" },
{ key: "powers", label: "Powers", type: "list" },
{ key: "holder", label: "Holder", type: "text" },
],
},
glossary: {
kind: "glossary",
label: "Glossary",
fields: [
{ key: "name", label: "Term", type: "text" },
{ key: "definition", label: "Definition", type: "textarea" },
],
},
system: {
kind: "system",
label: "System",
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "rules", label: "Rules", type: "textarea" },
{ key: "limitations", label: "Limitations", type: "textarea" },
],
},
note: {
kind: "note",
label: "Note",
fields: [
{ key: "body", label: "Body", type: "textarea" },
{ key: "tags", label: "Tags", type: "list" },
],
},
};
/** All entity kinds, in display order. */
export const ENTITY_KINDS = Object.keys(ENTITY_FIELDS);
+118
View File
@@ -0,0 +1,118 @@
import { z } from "zod";
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
displayName: z.string(),
role: z.enum(["admin", "author"]),
createdAt: z.string(),
});
export const ProjectSchema = z.object({
id: z.string(),
projectId: z.string(),
title: z.string(),
ownerId: z.string(),
sharedWith: z.array(z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) })),
settings: z.record(z.unknown()),
cover: z.object({ color: z.string(), emoji: z.string().optional() }),
createdAt: z.string(),
updatedAt: z.string(),
});
export const ProjectMetaSchema = ProjectSchema;
export const BookSchema = z.object({
id: z.string(),
projectId: z.string(),
title: z.string(),
order: z.number().int(),
chapters: z.array(z.any()),
});
export const ChapterSchema = z.object({
id: z.string(),
bookId: z.string(),
projectId: z.string(),
title: z.string(),
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
rev: z.number().int(),
order: z.number().int(),
meta: z.object({ wordCount: z.number().int(), scenes: z.number().int(), updatedAt: z.string() }),
});
export const CharacterSchema = z.object({
id: z.string(),
projectId: z.string(),
name: z.string(),
role: z.string(),
bio: z.string(),
appearance: z.string(),
voice: z.string(),
relationships: z.string(),
arc: z.string(),
status: z.enum(["active", "archived"]),
tags: z.array(z.string()),
});
export const WorldItemSchema = z.object({
id: z.string(),
projectId: z.string(),
kind: z.string(),
name: z.string(),
fields: z.record(z.string()),
});
export const NoteSchema = z.object({
id: z.string(),
projectId: z.string(),
body: z.string(),
pinned: z.boolean(),
tags: z.array(z.string()),
linkedTo: z.array(z.string()),
});
export const EntitySchema = z.union([CharacterSchema, WorldItemSchema, NoteSchema]);
export const BoardSchema = z.object({
id: z.string(),
projectId: z.string(),
nodes: z.array(z.object({
id: z.string(),
kind: z.string(),
label: z.string(),
pos: z.object({ x: z.number(), y: z.number() }),
data: z.record(z.unknown()),
})),
edges: z.array(z.object({ id: z.string(), from: z.string(), to: z.string(), label: z.string().optional() })),
});
export const AISessionSchema = z.object({
id: z.string(),
projectId: z.string(),
role: z.string(),
model: z.string(),
settings: z.record(z.unknown()),
messages: z.array(z.object({ role: z.string(), content: z.string(), at: z.string() })),
suggestions: z.array(z.object({ id: z.string(), kind: z.string(), text: z.string(), at: z.string() })),
});
/**
* The schema registry: a single map of domain type name -> zod schema.
* Adapters and the server validate against these so both backends agree.
*/
export const schemas = {
User: UserSchema,
Project: ProjectSchema,
ProjectMeta: ProjectMetaSchema,
Book: BookSchema,
Chapter: ChapterSchema,
Character: CharacterSchema,
WorldItem: WorldItemSchema,
Note: NoteSchema,
Entity: EntitySchema,
Board: BoardSchema,
AISession: AISessionSchema,
} as const;
export type Schemas = typeof schemas;
+133
View File
@@ -0,0 +1,133 @@
import Dexie, { type Table } from "dexie";
import type {
AISession,
Board,
Chapter,
DocumentRef,
Entity,
NewProject,
Project,
ProjectMeta,
} from "./types.js";
import type { StorageAdapter } from "./storage.js";
import { createUlid } from "./ids.js";
import { nowIso } from "./time.js";
interface EntityRec {
key: string;
projectId: string;
kind: string;
data: Entity;
}
class PubScriptDB extends Dexie {
projects!: Table<Project, string>;
chapters!: Table<Chapter, string>;
entities!: Table<EntityRec, string>;
boards!: Table<Board, string>;
aiSessions!: Table<AISession, string>;
constructor() {
super("pubscript");
this.version(1).stores({
projects: "id, projectId",
chapters: "id, bookId, projectId",
entities: "key, projectId, kind",
boards: "id, projectId",
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";
/**
* 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 saveDocument(doc: DocumentRef): Promise<void> {
const existing = await db.chapters.get(doc.id);
const base: Chapter = existing ?? {
id: doc.id,
bookId: doc.bookId ?? "",
projectId: doc.projectId,
title: doc.title,
doc: doc.doc,
rev: 0,
order: 0,
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 saveBoard(b: Board): Promise<void> {
await db.boards.put(b);
},
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;
+132
View File
@@ -0,0 +1,132 @@
import type {
AISession,
Board,
Chapter,
DocumentRef,
Entity,
NewProject,
Project,
ProjectMeta,
} from "./types.js";
import type { StorageAdapter, StoreEvent } from "./storage.js";
export interface ServerClientOptions {
/** Base URL of the PubScript server, e.g. `http://localhost:8080`. */
baseUrl: string;
/** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */
fetch?: typeof fetch;
/** Returns the current bearer token, if any. */
getToken?: () => string | null | Promise<string | null>;
}
/**
* The server `StorageAdapter`: the same contract as `storageLocal`, backed by
* the PubScript REST API. This is what makes "code against core, not a
* backend" concrete — the app uses one interface whether it talks to the
* server or to IndexedDB.
*/
export function createServerClient(opts: ServerClientOptions): StorageAdapter {
const fetchImpl: typeof fetch = opts.fetch ?? fetch;
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {};
if (body !== undefined) headers["content-type"] = "application/json";
const token = await opts.getToken?.();
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetchImpl(`${opts.baseUrl}${path}`, {
method,
headers,
body: body === undefined ? null : JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`PubScript ${method} ${path} -> ${res.status}: ${text}`);
}
return (await res.json()) as T;
}
return {
async listProjects(): Promise<ProjectMeta[]> {
const r = await req<{ projects: ProjectMeta[] }>("GET", "/api/projects");
return r.projects;
},
async createProject(p: NewProject): Promise<Project> {
const r = await req<{ project: Project }>("POST", "/api/projects", {
title: p.title,
language: p.language,
cover: p.cover,
});
return r.project;
},
async getProject(id: string): Promise<Project> {
const r = await req<{ project: Project }>("GET", `/api/projects/${id}`);
return r.project;
},
async saveDocument(doc: DocumentRef): Promise<void> {
await req("POST", `/api/chapters/${doc.id}/autosave`, {
rev: doc.rev,
doc: doc.doc,
});
},
async getChapter(id: string): Promise<Chapter | null> {
try {
const r = await req<{ chapter: Chapter }>("GET", `/api/chapters/${id}`);
return r.chapter;
} catch {
return null;
}
},
async saveEntity(e: Entity): Promise<void> {
if ("fields" in e) {
// WorldItem — the server nests the world kind inside `fields`.
await req("POST", `/api/projects/${e.projectId}/entities`, {
id: e.id, kind: "world", name: e.name, fields: { kind: e.kind, ...e.fields },
});
} else if ("body" in e) {
// Note
await req("POST", `/api/projects/${e.projectId}/entities`, {
id: e.id, kind: "note", body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo,
});
} else {
// Character
await req("POST", `/api/projects/${e.projectId}/entities`, {
id: e.id, kind: "character", name: e.name, role: e.role, bio: e.bio,
appearance: e.appearance, voice: e.voice, relationships: e.relationships,
arc: e.arc, status: e.status, tags: e.tags,
});
}
},
async saveBoard(b: Board): Promise<void> {
await req("POST", `/api/projects/${b.projectId}/boards`, {
id: b.id,
nodes: b.nodes,
edges: b.edges,
});
},
async saveAISession(s: AISession): Promise<void> {
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
id: s.id,
role: s.role,
model: s.model,
settings: s.settings,
messages: s.messages,
suggestions: s.suggestions,
});
},
onEvent(_cb: (e: StoreEvent) => void): () => void {
// Live updates arrive over the `/api/ws` socket; wiring that up is the
// app's concern. For the contract, return a no-op unsubscribe.
return () => {};
},
};
}
export type StorageServer = ReturnType<typeof createServerClient>;
+144
View File
@@ -0,0 +1,144 @@
import "fake-indexeddb/auto";
import { describe, expect, it } from "vitest";
import { createServerClient, type ProseMirrorDoc, type StorageAdapter } from "@pubscript/core";
import { storageLocal } from "@pubscript/core/local";
const doc = (text: string): ProseMirrorDoc => ({ type: "doc", content: [{ type: "text", text }] });
/**
* A minimal in-memory REST mock implementing exactly the endpoints the
* `StorageAdapter` scenario exercises. This lets us run the *server* adapter
* in Node without a live server, and compare it against the *local* adapter.
*/
function mockServerFetch(): typeof fetch {
const projects = new Map<string, any>();
const chapters = new Map<string, any>();
const entities = new Map<string, any>();
const boards = new Map<string, any>();
const sessions = new Map<string, any>();
let n = 0;
const id = () => `id-${++n}`;
const handler = async (url: string, init?: RequestInit): Promise<Response> => {
const u = new URL(url);
const method = (init?.method ?? "GET").toUpperCase();
const body = init?.body ? (JSON.parse(init.body as string) as any) : undefined;
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
if (method === "POST" && u.pathname === "/api/projects") {
const p = {
id: id(), projectId: "", title: body.title, ownerId: "u1", sharedWith: [],
settings: body.settings ?? {}, cover: body.cover ?? { color: "#000" }, createdAt: "t", updatedAt: "t",
};
p.projectId = p.id;
projects.set(p.id, p);
return json({ project: p });
}
if (method === "GET" && u.pathname === "/api/projects") return json({ projects: [...projects.values()] });
let m = u.pathname.match(/^\/api\/projects\/([^/]+)$/);
if (method === "GET" && m) {
const p = projects.get(m[1]!);
return p ? json({ project: p }) : json({ error: "not_found" }, 404);
}
m = u.pathname.match(/^\/api\/chapters\/([^/]+)\/autosave$/);
if (method === "POST" && m) {
const existing = chapters.get(m[1]!);
const rev = (existing?.rev ?? 0) + 1;
const ch = { id: m[1]!, projectId: "proj", title: "Untitled", doc: body.doc, rev, meta: { wordCount: body.wordCount, scenes: 0, updatedAt: "t" } };
chapters.set(m[1]!, ch);
return json({ chapter: ch, rev });
}
m = u.pathname.match(/^\/api\/chapters\/([^/]+)$/);
if (method === "GET" && m) {
const ch = chapters.get(m[1]!);
return ch ? json({ chapter: ch }) : json({ error: "not_found" }, 404);
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/entities$/);
if (method === "POST" && m) {
const e = { id: body.id ?? id(), projectId: m[1]!, ...body };
entities.set(`${m[1]!}|${body.kind}|${e.id}`, e);
return json({ entity: e });
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/boards$/);
if (method === "POST" && m) {
const b = { id: body.id ?? id(), projectId: m[1]!, nodes: body.nodes ?? [], edges: body.edges ?? [] };
boards.set(b.id, b);
return json({ board: b });
}
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/);
if (method === "POST" && m) {
const s = { id: body.id ?? id(), projectId: m[1]!, role: body.role, model: body.model, settings: body.settings ?? {}, messages: body.messages ?? [], suggestions: body.suggestions ?? [] };
sessions.set(s.id, s);
return json({ session: s });
}
return json({ error: "not_found" }, 404);
};
return (async (input: string | URL | Request, init?: RequestInit) =>
handler(typeof input === "string" ? input : input.toString(), init)) as unknown as typeof fetch;
}
/** The shared scenario both adapters must pass identically. */
async function runScenario(adapter: StorageAdapter) {
const project = await adapter.createProject({ title: "Test Novel" });
const fetched = await adapter.getProject(project.id);
const list = await adapter.listProjects();
const docId = "doc-1";
await adapter.saveDocument({ id: docId, projectId: project.id, title: "Chapter 1", doc: doc("Hello world"), rev: 0 });
const chapter = await adapter.getChapter(docId);
const chapterText =
chapter && "content" in (chapter.doc as object)
? ((chapter.doc as { content?: Array<{ text?: string }> }).content?.[0]?.text)
: undefined;
await adapter.saveEntity({
id: "char-1", projectId: project.id, name: "Ada", role: "protagonist", bio: "x",
appearance: "", voice: "", relationships: [], arc: "", status: "active", tags: [],
});
await adapter.saveBoard({
id: "board-1", projectId: project.id,
nodes: [{ id: "n1", type: "character", position: { x: 0, y: 0 }, data: { refId: "char-1" } }],
edges: [],
});
await adapter.saveAISession({
id: "sess-1", projectId: project.id, role: "editor", model: "m", settings: {}, messages: [], suggestions: [],
});
const unsub = adapter.onEvent(() => {});
expect(typeof unsub).toBe("function");
unsub();
return {
title: project.title,
hasId: typeof project.id === "string" && project.id.length > 0,
fetchedTitle: fetched.title,
listed: list.some((p) => p.title === "Test Novel"),
chapterText,
chapterRev: chapter?.rev,
};
}
const EXPECTED = {
title: "Test Novel",
hasId: true,
fetchedTitle: "Test Novel",
listed: true,
chapterText: "Hello world",
chapterRev: 1,
};
describe("StorageAdapter contract (web/local vs server)", () => {
it("local (IndexedDB) adapter honors the contract", async () => {
await expect(runScenario(storageLocal)).resolves.toMatchObject(EXPECTED);
});
it("server (REST) adapter honors the contract", async () => {
const client = createServerClient({ baseUrl: "http://test", fetch: mockServerFetch() });
await expect(runScenario(client)).resolves.toMatchObject(EXPECTED);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* The StorageAdapter contract — the seam that makes web / desktop / local modes
* possible. Defined once here; every feature codes against this, never a backend.
* See PLAN.md §3.4.
*/
import type {
Board,
Chapter,
DocumentRef,
Entity,
NewProject,
Project,
ProjectMeta,
AISession,
} from "./types.js";
/** Live-update events fanned out to subscribed clients. */
export type StoreEvent =
| { type: "doc-changed"; projectId: string; docId: string; rev: number }
| { type: "entity-changed"; projectId: string; entityId: string; kind: string }
| { type: "board-changed"; projectId: string; boardId: string }
| { type: "ai-session-changed"; projectId: string; sessionId: string }
| { type: "presence"; projectId: string; users: unknown[] }
| { type: "conflict"; docId: string; remoteRev: number };
export interface StorageAdapter {
// projects
listProjects(): Promise<ProjectMeta[]>;
createProject(p: NewProject): Promise<Project>;
getProject(id: string): Promise<Project>;
// content (debounced autosave)
saveDocument(doc: DocumentRef): Promise<void>;
getChapter(id: string): Promise<Chapter | null>;
saveEntity(e: Entity): Promise<void>;
saveBoard(b: Board): Promise<void>;
saveAISession(s: AISession): Promise<void>;
// live updates
onEvent(cb: (e: StoreEvent) => void): () => void;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { nowIso, parseIso, toIso, clockLabel } from "./time.js";
describe("time", () => {
it("nowIso returns a valid UTC ISO string with Z", () => {
const s = nowIso();
expect(s).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(new Date(s).toISOString()).toBe(s);
});
it("round-trips through parseIso/toIso", () => {
const s = "2026-10-23T14:24:00.000Z";
expect(toIso(parseIso(s))).toBe(s);
});
it("parseIso throws on garbage", () => {
expect(() => parseIso("not-a-date")).toThrow(/Invalid ISO/);
});
it("clockLabel formats HH:MM", () => {
expect(clockLabel(new Date("2026-10-23T14:03:00.000Z"))).toBe("14:03");
});
});
+25
View File
@@ -0,0 +1,25 @@
/** UTC ISO-8601 time helpers. See PLAN.md §3.3. */
/** Current time as a UTC ISO-8601 string with Z suffix. */
export function nowIso(): string {
return new Date().toISOString();
}
/** Parse an ISO timestamp to a Date, throwing on invalid input. */
export function parseIso(value: string): Date {
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
throw new Error(`Invalid ISO timestamp: ${value}`);
}
return d;
}
/** Format a Date as a UTC ISO-8601 string. */
export function toIso(d: Date): string {
return d.toISOString();
}
/** Compact HH:MM label (UTC) for the autosave indicator. */
export function clockLabel(d: Date = new Date()): string {
return d.toISOString().slice(11, 16);
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { countTokens, allocateBudget, DEFAULT_BUDGET } from "./tokens.js";
describe("tokens", () => {
it("countTokens returns 0 for empty input", async () => {
expect(await countTokens("")).toBe(0);
});
it("counts tokens for a known string (approx)", async () => {
// ~4 chars per token is the rough heuristic; this asserts a sane range.
const n = await countTokens("The quick brown fox jumps over the lazy dog.");
expect(n).toBeGreaterThan(0);
expect(n).toBeLessThan(40);
});
it("allocateBudget respects percentages of total", () => {
const b = allocateBudget(DEFAULT_BUDGET);
expect(b.total).toBe(200_000);
// 10% system ≈ 20000, 40% context ≈ 80000, etc. (floor, so allow small slack).
expect(b.system).toBeCloseTo(20_000, -2);
expect(b.context).toBeCloseTo(80_000, -2);
expect(b.history).toBeCloseTo(60_000, -2);
expect(b.output).toBeCloseTo(40_000, -2);
});
it("budget slots sum approximately to total", () => {
const b = allocateBudget({ system: 25, context: 25, history: 25, output: 25 });
expect(b.system + b.context + b.history + b.output).toBeGreaterThan(199_990);
expect(b.total).toBe(200_000);
});
it("throws when percentages sum to zero", () => {
expect(() => allocateBudget({ system: 0, context: 0, history: 0, output: 0 })).toThrow(
/positive/,
);
});
});
+63
View File
@@ -0,0 +1,63 @@
import api from "gpt-tokenizer";
/**
* Token counting + budget allocation. See PLAN.md §5.2.
*
* Uses gpt-tokenizer's o200k_base encoding (the default export) as the source
* of truth. The default export is a singleton, so no lazy-loading is needed.
*/
/** Count tokens in a string using the o200k_base encoding. */
export function countTokens(text: string): number {
if (!text) return 0;
return api.encode(text).length;
}
/**
* Allocate a token budget across four slots. Percentages sum to 100% of `total`
* (defaulting to a 200k context window). Returns per-slot integer caps.
*/
export interface BudgetInputs {
system: number;
context: number;
history: number;
output: number;
/** Total context window size. Defaults to 200_000. */
total?: number;
}
export interface Budget {
system: number;
context: number;
history: number;
output: number;
total: number;
}
export function allocateBudget({
system,
context,
history,
output,
total = 200_000,
}: BudgetInputs): Budget {
const sum = system + context + history + output;
if (sum <= 0) {
throw new Error("Budget percentages must sum to a positive number");
}
return {
system: Math.floor((system / sum) * total),
context: Math.floor((context / sum) * total),
history: Math.floor((history / sum) * total),
output: Math.floor((output / sum) * total),
total,
};
}
/** Default budget: system+role 10%, context 40%, history 30%, output 20%. */
export const DEFAULT_BUDGET: BudgetInputs = {
system: 10,
context: 40,
history: 30,
output: 20,
};
+259
View File
@@ -0,0 +1,259 @@
/**
* Core domain types — the single source of truth for the PubScript data model.
*
* These interfaces are consumed by the app, the packages, AND the server.
* Code against `packages/core`, never against a specific backend.
*
* All IDs are ULIDs (sortable, collision-free). All timestamps are UTC ISO-8601.
* See PLAN.md §3.3.
*/
/** A user's role within an instance. */
export type Role = "admin" | "author";
/** Permission levels a user may hold over a project. */
export type Permission = "read" | "edit";
export interface User {
id: string;
email: string;
displayName: string;
role: Role;
/** Optional, never required in v1. */
passKey?: string;
createdAt: string;
}
export interface Project {
id: string;
projectId: string;
title: string;
ownerId: string;
sharedWith: Array<{ userId: string; perm: Permission }>;
settings: ProjectSettings;
cover: Cover;
createdAt: string;
updatedAt: string;
}
export interface ProjectSettings {
/** ISO language code used for spellcheck / grammar. */
language: string;
/** Default AI provider id for this project. */
defaultProviderId?: string;
[key: string]: unknown;
}
export interface Cover {
color: string;
/** Optional data-URI or remote URL. */
image?: string;
}
/** Reduced project shape for list views. */
export interface ProjectMeta {
id: string;
projectId: string;
title: string;
ownerId: string;
cover: Cover;
createdAt: string;
updatedAt: string;
/** Permission the current user holds over this project. */
perm: Permission;
/** True when the current user owns the project. */
owned: boolean;
}
export interface NewProject {
title: string;
cover?: Partial<Cover>;
language?: string;
}
/** JSON form of a ProseMirror document (the TipTap schema). */
export interface ProseMirrorDoc {
type: "doc";
content: JsonNode[];
}
export type JsonNode = Record<string, unknown>;
export interface Book {
id: string;
projectId: string;
title: string;
order: number;
chapters: Chapter[];
}
export interface Chapter {
id: string;
bookId: string;
projectId: string;
title: string;
doc: ProseMirrorDoc;
/** Optimistic-concurrency revision counter. */
rev: number;
order: number;
meta: ChapterMeta;
}
export interface ChapterMeta {
wordCount: number;
/** Scene-break positions, computed from `doc`. */
scenes: Array<{ from: number; to: number }>;
updatedAt: string;
}
/** A scene is a `sceneBreak` node inside a document (Chunk 3). */
export interface SceneMeta {
id: string;
title?: string;
}
export interface Character {
id: string;
projectId: string;
name: string;
role: string;
bio: string;
appearance: string;
voice: string;
relationships: Array<{ targetId: string; kind: RelationshipKind; note: string }>;
arc: string;
status: string;
tags: string[];
}
export type RelationshipKind =
| "ally"
| "rival"
| "family"
| "mentor"
| "romantic"
| "custom";
export type WorldItemKind = "location" | "faction" | "item" | "glossary" | "system";
export interface WorldItem {
id: string;
projectId: string;
kind: WorldItemKind;
name: string;
fields: Record<string, string>;
}
export type BeatTemplate =
| "three-act"
| "save-the-cat"
| "seven-point"
| "custom";
export type BeatStatus = "draft" | "revised" | "locked";
export interface PlotBeat {
id: string;
projectId: string;
template: BeatTemplate;
title: string;
description: string;
status: BeatStatus;
chapterId?: string;
order: number;
notes: string;
}
export interface TimelineEvent {
id: string;
projectId: string;
/** Either a fictional calendar or a real date. */
when: { era?: string; day?: number } | string;
title: string;
description: string;
chapterId?: string;
links: string[];
}
export interface FlowNode {
id: string;
type: "character" | "world" | "chapter" | "note" | "image" | "group";
position: { x: number; y: number };
data: Record<string, unknown>;
}
export interface FlowEdge {
id: string;
source: string;
target: string;
type: "leads-to" | "contrasts" | "causes" | "custom";
label?: string;
}
export interface Board {
id: string;
projectId: string;
nodes: FlowNode[];
edges: FlowEdge[];
}
export interface Note {
id: string;
projectId: string;
body: string;
pinned: boolean;
tags: string[];
linkedTo?: string;
}
export type SuggestionKind =
| "replace"
| "insert"
| "delete"
| "entity-update"
| "draft";
export type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned";
export interface SuggestionTarget {
docId: string;
/** A stable node anchor, not a raw offset (see PLAN.md §5.5). */
range?: { nodeId: string };
nodePath?: number[];
}
export interface Suggestion {
id: string;
sessionId: string;
target: SuggestionTarget;
before: string;
after: string;
rationale: string;
kind: SuggestionKind;
status: SuggestionStatus;
}
export type AISessionRole = "editor" | "reader" | "publisher" | "co-author";
export interface AISession {
id: string;
projectId: string;
role: AISessionRole;
model: string;
settings: Record<string, unknown>;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
suggestions: Suggestion[];
}
/** A reference to a document that can be saved through the adapter. */
export interface DocumentRef {
id: string;
projectId: string;
bookId?: string;
title: string;
doc: ProseMirrorDoc;
rev: number;
}
/** A generic entity — the discriminated union handled by the entity engine. */
export type Entity = Character | WorldItem | Note;
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});