290 lines
7.5 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
}
|