/** * Layer 6: Query — Materialized views + full-text search over Automerge documents. * * All computation is client-side. Views are automatically recomputed when their * source documents change (via DocumentManager change subscriptions). */ import type { DocumentId } from './document'; import type { DocumentManager } from './document'; // ============================================================================ // TYPES // ============================================================================ /** * A materialized view: takes a document and projects it into a view shape. * Views are cached and recomputed lazily on document change. */ export interface MaterializedView { /** Unique view identifier */ id: string; /** Which document this view is derived from */ docId: DocumentId; /** Project the document into the view */ compute(doc: T): V; } export interface SearchResult { docId: DocumentId; field: string; /** The matched text snippet */ snippet: string; /** Relevance score (higher = better match) */ score: number; } interface IndexEntry { docId: DocumentId; field: string; text: string; /** Lowercase tokens for matching */ tokens: string[]; } // ============================================================================ // ViewEngine // ============================================================================ export class ViewEngine { #views = new Map(); #cache = new Map(); #documents: DocumentManager; #unsubs = new Map void>(); #subscribers = new Map void>>(); constructor(documents: DocumentManager) { this.#documents = documents; } /** * Register a materialized view. Immediately computes it if the source doc is open. */ register(view: MaterializedView): void { this.#views.set(view.id, view); // Compute initial value if doc is available const doc = this.#documents.get(view.docId); if (doc) { this.#recompute(view); } // Subscribe to document changes const unsub = this.#documents.onChange(view.docId, () => { this.#recompute(view); }); this.#unsubs.set(view.id, unsub); } /** * Unregister a view. */ unregister(viewId: string): void { this.#views.delete(viewId); this.#cache.delete(viewId); this.#subscribers.delete(viewId); const unsub = this.#unsubs.get(viewId); if (unsub) { unsub(); this.#unsubs.delete(viewId); } } /** * Get the current value of a view (cached). */ get(viewId: string): V | undefined { return this.#cache.get(viewId) as V | undefined; } /** * Subscribe to view changes. Returns unsubscribe function. */ subscribe(viewId: string, cb: (v: V) => void): () => void { let set = this.#subscribers.get(viewId); if (!set) { set = new Set(); this.#subscribers.set(viewId, set); } set.add(cb); // Immediately call with current value if available const current = this.#cache.get(viewId); if (current !== undefined) { cb(current as V); } return () => { set!.delete(cb); }; } /** * Force recompute a view. */ refresh(viewId: string): void { const view = this.#views.get(viewId); if (view) this.#recompute(view); } /** * Destroy all views and clean up subscriptions. */ destroy(): void { for (const unsub of this.#unsubs.values()) { unsub(); } this.#views.clear(); this.#cache.clear(); this.#unsubs.clear(); this.#subscribers.clear(); } #recompute(view: MaterializedView): void { const doc = this.#documents.get(view.docId); if (!doc) return; try { const value = view.compute(doc); this.#cache.set(view.id, value); const subs = this.#subscribers.get(view.id); if (subs) { for (const cb of subs) { try { cb(value); } catch { /* ignore */ } } } } catch (e) { console.error(`[ViewEngine] Error computing view "${view.id}":`, e); } } } // ============================================================================ // LocalSearchEngine // ============================================================================ /** * Client-side full-text search over Automerge documents. * Simple token-based matching — not a full inverted index, but fast enough * for the expected data sizes (hundreds, not millions of documents). */ export class LocalSearchEngine { #index: IndexEntry[] = []; #documents: DocumentManager; #indexedDocs = new Set(); // "docId:field" set for dedup constructor(documents: DocumentManager) { this.#documents = documents; } /** * Index specific fields of a document for searching. * Call this when a document is opened or changes. */ index(docId: DocumentId, fields: string[]): void { const doc = this.#documents.get(docId); if (!doc) return; for (const field of fields) { const key = `${docId}:${field}`; // Remove old entries for this doc+field this.#index = this.#index.filter((e) => !(e.docId === docId && e.field === field)); this.#indexedDocs.delete(key); const text = extractText(doc, field); if (!text) continue; this.#index.push({ docId, field, text, tokens: tokenize(text), }); this.#indexedDocs.add(key); } } /** * Index all text fields from a map/object structure. * Walks one level of keys, indexes any string values. */ indexMap(docId: DocumentId, mapField: string): void { const doc = this.#documents.get(docId); if (!doc) return; const map = (doc as any)[mapField]; if (!map || typeof map !== 'object') return; for (const [itemId, item] of Object.entries(map)) { if (!item || typeof item !== 'object') continue; for (const [key, value] of Object.entries(item as Record)) { if (typeof value !== 'string') continue; const fullField = `${mapField}.${itemId}.${key}`; const compositeKey = `${docId}:${fullField}`; this.#index = this.#index.filter((e) => !(e.docId === docId && e.field === fullField)); this.#indexedDocs.delete(compositeKey); this.#index.push({ docId, field: fullField, text: value, tokens: tokenize(value), }); this.#indexedDocs.add(compositeKey); } } } /** * Remove all index entries for a document. */ removeDoc(docId: DocumentId): void { this.#index = this.#index.filter((e) => e.docId !== docId); // Clean up indexedDocs set for (const key of this.#indexedDocs) { if (key.startsWith(`${docId}:`)) { this.#indexedDocs.delete(key); } } } /** * Search across all indexed documents. */ search(query: string, opts?: { module?: string; maxResults?: number }): SearchResult[] { const queryTokens = tokenize(query); if (queryTokens.length === 0) return []; const results: SearchResult[] = []; const moduleFilter = opts?.module; const maxResults = opts?.maxResults ?? 50; for (const entry of this.#index) { // Optional module filter if (moduleFilter) { const parts = entry.docId.split(':'); if (parts[1] !== moduleFilter) continue; } const score = computeScore(queryTokens, entry.tokens); if (score > 0) { results.push({ docId: entry.docId, field: entry.field, snippet: createSnippet(entry.text, query), score, }); } } // Sort by score descending results.sort((a, b) => b.score - a.score); return results.slice(0, maxResults); } /** * Clear the entire index. */ clear(): void { this.#index = []; this.#indexedDocs.clear(); } } // ============================================================================ // UTILITIES // ============================================================================ function tokenize(text: string): string[] { return text .toLowerCase() .replace(/[^\w\s]/g, ' ') .split(/\s+/) .filter((t) => t.length > 1); } function extractText(doc: any, fieldPath: string): string | null { const parts = fieldPath.split('.'); let value: any = doc; for (const part of parts) { if (value == null || typeof value !== 'object') return null; value = value[part]; } return typeof value === 'string' ? value : null; } function computeScore(queryTokens: string[], docTokens: string[]): number { let matches = 0; for (const qt of queryTokens) { for (const dt of docTokens) { if (dt.includes(qt)) { matches++; break; } } } // Score: fraction of query tokens matched return matches / queryTokens.length; } function createSnippet(text: string, query: string, contextChars = 60): string { const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); const idx = lowerText.indexOf(lowerQuery); if (idx === -1) { // No exact match; return beginning of text return text.length > contextChars * 2 ? text.slice(0, contextChars * 2) + '...' : text; } const start = Math.max(0, idx - contextChars); const end = Math.min(text.length, idx + query.length + contextChars); let snippet = text.slice(start, end); if (start > 0) snippet = '...' + snippet; if (end < text.length) snippet = snippet + '...'; return snippet; }