rspace-online/shared/local-first/document.ts

290 lines
7.5 KiB
TypeScript

/**
* 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<T> {
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<DocumentId, Automerge.Doc<any>>();
#meta = new Map<DocumentId, DocMeta>();
#schemas = new Map<string, DocSchema<any>>();
#changeListeners = new Map<DocumentId, Set<(doc: any) => void>>();
/**
* Register a schema so documents can be opened with type safety.
*/
registerSchema<T>(schema: DocSchema<T>): 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<any> | 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<T extends Record<string, any>>(
id: DocumentId,
schema: DocSchema<T>,
binary?: Uint8Array
): Automerge.Doc<T> {
// Return cached if already open
const existing = this.#docs.get(id);
if (existing) return existing as Automerge.Doc<T>;
let doc: Automerge.Doc<T>;
const now = Date.now();
if (binary) {
// Load from persisted binary
doc = Automerge.load<T>(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<T>();
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<T>(id: DocumentId): Automerge.Doc<T> | undefined {
return this.#docs.get(id) as Automerge.Doc<T> | undefined;
}
/**
* Apply a change to a document (Automerge.change wrapper).
* Notifies change listeners.
*/
change<T>(
id: DocumentId,
message: string,
fn: (doc: T) => void
): Automerge.Doc<T> {
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<T>;
}
/**
* Replace a document (e.g. after receiving sync data).
*/
set<T>(id: DocumentId, doc: Automerge.Doc<T>): 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<T>(id: DocumentId, cb: (doc: Automerge.Doc<T>) => 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);
}
}
}
}