Files
website/src/lib/documents/ydoc.ts
2026-09-06 21:47:53 +00:00

104 lines
3.2 KiB
TypeScript

import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
const DB_PREFIX = 'layer-doc-';
/** Callbacks a session reports through. */
export interface DocumentSessionEvents {
/** Fired on every Yjs update (local typing and, later, remote peers). */
onUpdate: () => void;
/** Fired once the local copy has been loaded into the document. */
onSynced: () => void;
}
export interface DocumentSession {
doc: Y.Doc;
/** Resolved when the local snapshot has been applied. */
whenSynced: Promise<void>;
/** Tears down persistence and frees the document. Safe to call twice. */
destroy: () => void;
}
/** IndexedDB is unavailable in some environments (tests, locked-down modes). */
function indexedDbAvailable(): boolean {
try {
return typeof indexedDB !== 'undefined';
} catch {
return false;
}
}
/**
* Opens the Yjs document for `documentId` with offline persistence behind it.
*
* The session is created and torn down in a `useEffect`, so React Strict
* Mode's mount → unmount → mount cycle simply opens, closes and reopens it:
* each mount gets its own `Y.Doc`, no provider or subscription outlives its
* effect, and the IndexedDB write behind an update is idempotent, so the
* double-mount cannot corrupt or duplicate state.
*
* Without IndexedDB the session degrades to an in-memory document: editing
* still works, it just does not survive a reload until the Phase 2 service
* provides persistence.
*
* Phase 2 attaches a Hocuspocus WebSocket provider here; `ydoc.on('update')`
* is the single fan-in point, so nothing else changes.
*/
export function openDocumentSession(
documentId: string,
events: DocumentSessionEvents,
): DocumentSession {
const doc = new Y.Doc();
const persistence = indexedDbAvailable()
? new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc)
: null;
const onUpdate = () => events.onUpdate();
doc.on('update', onUpdate);
let destroyed = false;
// `whenSynced` resolves with the persistence instance; callers only care
// that the snapshot landed. Without persistence the doc starts empty and is
// "synced" immediately — asynchronously, matching the IndexedDB contract.
let whenSynced: Promise<void>;
let cleanup: () => void;
if (persistence) {
const onSynced = () => events.onSynced();
persistence.on('synced', onSynced);
whenSynced = persistence.whenSynced.then(() => undefined);
cleanup = () => persistence.off('synced', onSynced);
} else {
whenSynced = Promise.resolve().then(() => {
if (!destroyed) events.onSynced();
});
cleanup = () => {};
}
return {
doc,
whenSynced,
destroy: () => {
if (destroyed) return;
destroyed = true;
doc.off('update', onUpdate);
cleanup();
persistence?.destroy();
doc.destroy();
},
};
}
/** Removes the persisted body of a document that was deleted from the index. */
export async function deletePersistedDocument(documentId: string): Promise<void> {
if (!indexedDbAvailable()) return;
const doc = new Y.Doc();
const persistence = new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc);
try {
await persistence.clearData();
} finally {
persistence.destroy();
doc.destroy();
}
}