/** * Layer 2: Document — Automerge document management with typed schemas. * * DocumentId format: "{space}:{module}:{collection}" or "{space}:{module}:{collection}:{itemId}" * * Granularity principle: one document per "unit of collaboration." * Binary blobs (PDFs, .splat, images) stay in blob storage with metadata refs in Automerge. */ import * as Automerge from '@automerge/automerge'; // ============================================================================ // TYPES // ============================================================================ /** * Document ID — hierarchical, colon-separated. * 3-part: space-level collection (e.g. "demo:notes:items") * 4-part: item-level doc (e.g. "demo:work:boards:board-1") */ export type DocumentId = | `${string}:${string}:${string}` | `${string}:${string}:${string}:${string}`; /** * Parse a DocumentId into its components. */ export interface ParsedDocumentId { space: string; module: string; collection: string; itemId?: string; } export function parseDocumentId(id: DocumentId): ParsedDocumentId { const parts = id.split(':'); if (parts.length < 3 || parts.length > 4) { throw new Error(`Invalid DocumentId: "${id}" — expected 3 or 4 colon-separated parts`); } return { space: parts[0], module: parts[1], collection: parts[2], itemId: parts[3], }; } export function makeDocumentId( space: string, module: string, collection: string, itemId?: string ): DocumentId { if (itemId) { return `${space}:${module}:${collection}:${itemId}` as DocumentId; } return `${space}:${module}:${collection}` as DocumentId; } /** * Schema definition for an Automerge document. * Each module/collection pair defines a schema with version + initializer. */ export interface DocSchema { module: string; collection: string; version: number; /** Create the initial document state */ init: () => T; /** Migrate from an older version (called when loaded doc version < schema version) */ migrate?: (doc: T, fromVersion: number) => T; } /** * Metadata stored alongside each document. */ export interface DocMeta { docId: DocumentId; module: string; collection: string; version: number; createdAt: number; updatedAt: number; } // ============================================================================ // DOCUMENT MANAGER // ============================================================================ /** * DocumentManager — manages multiple Automerge documents in memory. * * Responsibilities: * - Open/create documents with typed schemas * - Track open documents and their metadata * - Apply changes with Automerge.change() * - List documents by space/module * * Does NOT handle persistence (that's Layer 3) or sync (Layer 4). */ export class DocumentManager { #docs = new Map>(); #meta = new Map(); #schemas = new Map>(); #changeListeners = new Map void>>(); /** * Register a schema so documents can be opened with type safety. */ registerSchema(schema: DocSchema): void { const key = `${schema.module}:${schema.collection}`; this.#schemas.set(key, schema); } /** * Get the registered schema for a module/collection. */ getSchema(module: string, collection: string): DocSchema | undefined { return this.#schemas.get(`${module}:${collection}`); } /** * Open (or create) a document. If already open, returns the cached instance. * If binary data is provided, loads from that; otherwise creates from schema.init(). */ open>( id: DocumentId, schema: DocSchema, binary?: Uint8Array ): Automerge.Doc { // Return cached if already open const existing = this.#docs.get(id); if (existing) return existing as Automerge.Doc; let doc: Automerge.Doc; const now = Date.now(); if (binary) { // Load from persisted binary doc = Automerge.load(binary); // Check if migration is needed const meta = this.#meta.get(id); if (meta && meta.version < schema.version && schema.migrate) { doc = Automerge.change(doc, `Migrate ${id} to v${schema.version}`, (d) => { schema.migrate!(d, meta.version); }); } } else { // Create fresh document from schema doc = Automerge.init(); doc = Automerge.change(doc, `Initialize ${id}`, (d) => { Object.assign(d, schema.init()); }); } this.#docs.set(id, doc); this.#meta.set(id, { docId: id, module: schema.module, collection: schema.collection, version: schema.version, createdAt: now, updatedAt: now, }); // Register schema if not already this.registerSchema(schema); return doc; } /** * Get an already-open document. */ get(id: DocumentId): Automerge.Doc | undefined { return this.#docs.get(id) as Automerge.Doc | undefined; } /** * Apply a change to a document (Automerge.change wrapper). * Notifies change listeners. */ change( id: DocumentId, message: string, fn: (doc: T) => void ): Automerge.Doc { const doc = this.#docs.get(id); if (!doc) { throw new Error(`Document not open: ${id}`); } const updated = Automerge.change(doc, message, fn as any); this.#docs.set(id, updated); // Update metadata timestamp const meta = this.#meta.get(id); if (meta) { meta.updatedAt = Date.now(); } // Notify listeners this.#notifyChange(id, updated); return updated as Automerge.Doc; } /** * Replace a document (e.g. after receiving sync data). */ set(id: DocumentId, doc: Automerge.Doc): void { this.#docs.set(id, doc); const meta = this.#meta.get(id); if (meta) { meta.updatedAt = Date.now(); } this.#notifyChange(id, doc); } /** * Close a document — remove from in-memory cache. */ close(id: DocumentId): void { this.#docs.delete(id); this.#meta.delete(id); this.#changeListeners.delete(id); } /** * Get document binary for persistence. */ save(id: DocumentId): Uint8Array | null { const doc = this.#docs.get(id); if (!doc) return null; return Automerge.save(doc); } /** * Get metadata for a document. */ getMeta(id: DocumentId): DocMeta | undefined { return this.#meta.get(id); } /** * List all open document IDs for a given space and module. */ list(space: string, module?: string): DocumentId[] { const results: DocumentId[] = []; for (const [id, meta] of this.#meta) { const parsed = parseDocumentId(id); if (parsed.space !== space) continue; if (module && parsed.module !== module) continue; results.push(id); } return results; } /** * List all open document IDs. */ listAll(): DocumentId[] { return Array.from(this.#docs.keys()); } /** * Subscribe to changes on a document. */ onChange(id: DocumentId, cb: (doc: Automerge.Doc) => void): () => void { let listeners = this.#changeListeners.get(id); if (!listeners) { listeners = new Set(); this.#changeListeners.set(id, listeners); } listeners.add(cb); return () => { listeners!.delete(cb); }; } #notifyChange(id: DocumentId, doc: any): void { const listeners = this.#changeListeners.get(id); if (!listeners) return; for (const cb of listeners) { try { cb(doc); } catch (e) { console.error(`[DocumentManager] Change listener error for ${id}:`, e); } } } }