/** * `CrdtClient` — the browser half of `@delightstack/crdt`. * * Owns a set of open Loro documents, their local persistence, and the * conversation with the document servers. It does **not** own the connection * (see `transport.ts`) and it has no opinion about the document's schema — it * moves update blobs and keeps them durable. * * ## The bootstrap gate — read this before anything else * * A Loro shallow snapshot can only be imported into a document whose version * already covers the snapshot's shallow start. An empty document is the special * case that always works. A document with **one single operation** in it that * the server has already compacted away is not: `import()` returns * `{ success: {}, pending: {} }`, throws nothing, and leaves the document * exactly as it was. Nothing in the Loro API signals this. * * A rich-text editor's *first transaction writes an empty document into the * CRDT.* So an editor mounted before the first sync completes puts the client * permanently behind a compacted server's shallow start, and that device can * never be caught up again — silently, forever. This is not hypothetical; it is * exactly how the Milestone 0 spike failed. * * The gate is {@link CrdtHandle.loading} / {@link CrdtHandle.ready}. It clears * on the **first** of: * * 1. the first `sync` message from the server for this document; * 2. local storage already containing operations (the document cannot be * "empty and dirty", so there is nothing left to protect); * 3. {@link CrdtClientConfig.bootstrap_timeout_ms} (default 1.5s), which is * what makes a genuinely offline first run usable. * * And it has teeth: {@link CrdtHandle.transact} **throws** while `loading`. * Documenting the ordering rule was not enough for the spike, so the client * enforces it. * * ```ts * const handle = await crdt.open(node_id); * await handle.ready(); // ← never mount an editor before this * mountEditor(handle.doc); * ``` */ import { LoroDoc, type LoroEventBatch } from '../loro.client.js'; import type { Actor, Frontier } from '../types.js'; import { type CrdtStorage } from './storage.js'; import type { CrdtTransport } from './transport.js'; /** How long to wait for a first `sync` before letting a cold document be edited. */ export declare const DEFAULT_BOOTSTRAP_TIMEOUT_MS = 1500; /** * How long a local commit waits before it goes on the wire. * * The spike measured one blob, one frame and one `op_id` per keystroke: ~90 * bytes of CRDT inside a ~175 byte frame, so nearly half the traffic was * framing. Commits inside this window are coalesced into a single update, which * removes that tax at the cost of this much extra exposure on a crash — and the * OPFS append is *not* debounced, so "exposure" means "the server hears about * it later", never "the edit is lost". */ export declare const DEFAULT_SEND_DEBOUNCE_MS = 200; /** How long a document stays resident after its last reader closes it. */ export declare const DEFAULT_IDLE_EVICT_MS: number; /** Soft cap on total local body storage before LRU eviction starts. */ export declare const DEFAULT_QUOTA_BYTES = 2000000000; /** Updates appended to the pending log before it is folded into a snapshot. */ export declare const DEFAULT_SNAPSHOT_EVERY = 50; /** The unified sync indicator described in `03-sync-and-offline.md`. */ export type CrdtSyncState = 'synced' | 'syncing' | 'offline' | 'error'; /** What the caller must be told when a device is too far behind to merge. */ export interface CrdtResetInfo { node_id: string; /** Local commits that can never reach the server. Data loss, if it is > 0. */ unacked_ops: number; } export interface CrdtClientConfig { /** Moves bytes. This package never opens a socket — see `transport.ts`. */ transport: CrdtTransport; /** * `'opfs'` (default) is the only durable backend. `'idb'` throws * `not_implemented` — IndexedDB cannot offer the synchronous append * `transact()` guarantees, so supporting it would weaken the contract rather * than widen support. A `CrdtStorage` instance may be passed instead. */ storage?: 'opfs' | 'idb' | CrdtStorage; /** Soft cap on local body bytes. Default 2GB. */ quota_bytes?: number; /** Default `actor` recorded on updates. Per-call `transact` opts override it. */ actor?: Actor; bootstrap_timeout_ms?: number; send_debounce_ms?: number; idle_evict_ms?: number; snapshot_every?: number; /** * The server told this device it is behind the retained history. * * Nothing can merge in either direction: local commits can never be * accepted, and the server's snapshot can never be imported on top of them. * The client refuses to apply the reset by itself — it marks the handle * unusable and calls this, because discarding a user's offline work is a * decision a UI must make, not a library. Recover with * `await crdt.purge(node_id)` then `await crdt.open(node_id)`. */ on_reset?: (info: CrdtResetInfo) => void; /** Override `op_id` generation. Defaults to a 20-char timestamp id. */ generateOpId?: () => string; } /** One open document. */ export interface CrdtHandle { readonly node_id: string; /** The live Loro document. Read freely; write only through {@link transact}. */ readonly doc: LoroDoc; /** The document's current point in history. */ readonly frontier: Frontier; /** Reactive. True until the bootstrap gate clears — see the module comment. */ readonly loading: boolean; /** Reactive. Local commits this device has not had acked. */ readonly pending_count: number; /** Resolves when {@link loading} goes false. Await before mounting an editor. */ ready(): Promise; /** * Apply a local change. * * Synchronous, and the resulting update blob is appended to the local log * before this returns (given a worker-hosted OPFS — see `opfs.storage.ts`). * Persistence and the network send are fire-and-forget from here. * * @throws `bootstrap_pending` while {@link loading} is true. */ transact(fn: (doc: LoroDoc) => void, opts?: { actor?: Actor; }): void; /** Subscribe to Loro events. Returns an unsubscribe function. */ subscribe(fn: (event: LoroEventBatch) => void): () => void; } export declare class CrdtClient { #private; readonly transport: CrdtTransport; readonly storage: CrdtStorage; readonly actor: Actor; readonly quota_bytes: number; readonly send_debounce_ms: number; readonly snapshot_every: number; constructor(config: CrdtClientConfig); /** Reactive. The unified indicator from `03-sync-and-offline.md`. */ get sync_state(): CrdtSyncState; /** Reactive. Local commits across all open documents that are unacked. */ get pending_count(): number; /** * Open a document. * * Resolves once local storage has been replayed — no network on that path, * so it is fast and works offline. The returned handle is still `loading`: * **await `handle.ready()` before mounting an editor.** See the module * comment for why that ordering is not a nicety. */ open(node_id: string): Promise; /** * Stop reading a document. * * The document stays resident for `idle_evict_ms` after the last reader — * reopening a manuscript you just closed should not re-read OPFS — and is * then snapshotted and dropped. */ close(node_id: string): void; /** * Write the document's snapshot and drop the Loro instance from memory. * * Memory only — the local copy stays on disk and a later {@link open} * restores from it. A document with unacked local commits is **not** evicted: * dropping it would leave nothing in memory to resend from until something * reopened it. */ evict(node_id: string): Promise; /** * Delete a document's local copy entirely. * * The recovery path from a `reset`: the device's local state can never merge * with the server's, so it is discarded and the next {@link open} bootstraps * from scratch. **Destructive** — unacked local commits are lost, which is * why the client never does this on its own. */ purge(node_id: string): Promise; /** Every currently resident document. */ listOpen(): string[]; /** Flush every issued write. Call before the worker is torn down. */ flush(): Promise; /** Snapshot and drop everything, then stop listening to the transport. */ destroy(): Promise; /** * Bring local storage back under the soft quota by dropping LRU documents. * * Two things are never dropped: a document that is currently resident, and a * document holding unacked local commits. The second is the important one — * quota pressure must never be a route to losing an edit that the server has * not seen, so a workspace whose entire quota is unacked work simply stays * over quota. */ enforceQuota(): Promise; nextOpId(): string; recount(): void; reportReset(info: CrdtResetInfo): void; } //# sourceMappingURL=crdt.client.svelte.d.ts.map