import { DurableObject } from 'cloudflare:workers'; import { LoroDoc, VersionVector } from '../loro.server.js'; import type { Actor, ApplyResult, Checkpoint, CheckpointKind, CompactionResult, CrdtConfig, CrdtSyncResult, EditSession, Frontier, PeerRecord, SnapshotRef, UpdateMeta } from '../types.js'; /** Op-log size that makes compaction worth its CPU. Spike-confirmed at 2MB. */ export declare const DEFAULT_COMPACT_THRESHOLD_BYTES = 2000000; /** * Snapshots above this go to R2 rather than into the Durable Object's SQLite. * DO SQLite caps a single value at 2MB; 512KB leaves headroom and keeps the * common case (a 20k-word document snapshots at ~159KB) inline, where reading * it costs no network round trip. */ export declare const DEFAULT_INLINE_SNAPSHOT_MAX_BYTES = 512000; /** How long a silent peer keeps history pinned. See {@link CrdtDocumentServer.peerFloor}. */ export declare const DEFAULT_PEER_FLOOR_TTL_MS: number; /** * One collaborative document, stored in one Durable Object. * * ## What this class owns * * A single Loro document plus the append-only log that produced it, and * everything derived from that pair: edit sessions, named checkpoints, time * travel, snapshots and compaction. Loro lives **only** behind this class — * consumers never import `loro-crdt`, which is what makes the packaging * problem (three published builds, two of which fail in the environment they * are resolved into) solvable in one place. * * ## Sync vs. async * * `applyUpdate`, `listUpdates`, `listSessions`, `checkpoint`, `restore` and * `syncFor` are **synchronous**: DO SQLite is synchronous and Loro is * synchronous, so making them async would only add microtask latency to the * hot path. * * `getVersion`, `snapshot` and `compact` are **asynchronous**, which is a * deliberate departure from the signature sketched in * `04-crdt-and-history.md`. Snapshots above `inline_snapshot_max_bytes` live in * R2, and R2 is async; a synchronous `getVersion` could only ever read inline * snapshots, which would make the R2 tier unreadable and quietly cap the * document size at which history works. * * ## What it does not own * * Transport. There is no WebSocket handling here: {@link syncFor} computes what * one peer must be sent, and the consuming Durable Object decides how to send * it (hibernatable sockets, RPC, HTTP). That keeps this class testable without * a runtime and keeps the wire protocol the application's business. * * Projection. `config.project` is called by {@link runProjection}, which the * consumer schedules — the package has no opinion about how long "debounced" * is, and a projection is far too expensive to run inside `applyUpdate`. */ export declare class CrdtDocumentServer extends DurableObject { #private; readonly crdt_config: CrdtConfig; constructor(ctx: DurableObjectState, env: Env, config?: CrdtConfig); private get sql(); private get store(); private get session_gap_ms(); /** * Rebuild the in-memory document from durable state, once per instance. * * Newest snapshot first, then every update it does not already cover. Before * the first compaction there is no snapshot and this is a plain replay of * the whole log — which is exactly why compaction exists. * * Hydration is lazy rather than done in `blockConcurrencyWhile`, because it * is synchronous: there is nothing to await, and a lazy check costs one * boolean on every call instead of a promise on every instantiation. An * inline snapshot is always readable here; an R2-tiered one is not (R2 is * async), so the boundary snapshot is deliberately never offloaded — see * {@link compact}. */ private ensureHydrated; /** The live document. Read-only by convention: write through {@link applyUpdate}. */ protected get doc(): LoroDoc; /** The document's current frontier. */ get frontier(): Frontier; /** The highest `seq` in the op log, or 0 when the log is empty. */ get head_seq(): number; private lastInsertRowId; private sumUpdateBytes; private sumSnapshotBytes; /** Total durable bytes this document occupies — the number invariant 7 is about. */ storageStats(): { update_bytes: number; snapshot_bytes: number; total_bytes: number; }; /** * Apply one inbound update blob and append it to the log. * * **Idempotent on `op_id`.** Every mutation in this system crosses at least * one retry boundary (an offline queue drain, a sync replay, an agent * retry), so a repeat is normal traffic rather than an error. A repeat * returns `applied: false` with the *current* frontier, the `seq` of the * original row, and appends nothing: Loro's `import` is idempotent anyway, * so the point of the dedupe is that the **log** does not grow on retries. * * An update whose causal dependencies have not arrived yet is still logged. * Loro parks it as pending and applies it when the gap fills, so the log * stays a faithful record of what arrived, and the frontier recorded on the * row is honestly "where the document was after this arrived" rather than * "where it would be if everything had arrived in order". */ applyUpdate(op_id: string, actor: Actor, blob: Uint8Array): ApplyResult; /** * Op-log metadata, oldest first. Never reads an update blob — `byte_size` is * a stored column, so listing a 40,000-op history is a narrow row scan and * nothing is decoded. */ listUpdates(opts?: { from?: number; to?: number; limit?: number; }): UpdateMeta[]; /** * The history rail's unit: runs of edits by one actor with no long pause and * no checkpoint inside them. Derived from {@link listUpdates} metadata, so * this is as cheap as listing and can be recomputed with a different gap at * any time without a migration. */ listSessions(opts?: { gap_ms?: number; from?: number; to?: number; }): EditSession[]; /** Every checkpoint on this document, oldest first. */ listCheckpoints(): Checkpoint[]; /** Every snapshot on this document, oldest first. */ listSnapshots(): SnapshotRef[]; /** * Record a named point in history. * * A checkpoint is a promise: *this exact version stays readable forever, no * matter what compaction does*. {@link compact} enforces that by * materialising a snapshot at the frontier before it discards anything, so * every checkpoint taken is a permanent, if small, storage commitment. */ checkpoint(input: { kind: CheckpointKind; label: string; actor: Actor; }): Checkpoint; /** * Reconstruct one past version and return it as an importable document blob. * * Three things about the implementation are load-bearing and none of them is * the obvious choice: * * 1. **Replay onto a throwaway document, never `forkAt()`.** `forkAt` is the * shorter route and is what the API suggests, but it is *not implemented * on shallow documents* — so it works right up until the first compaction * and then throws forever. * 2. **Replay only up to the target, then export.** Exporting a document * that has been `checkout()`-ed exports its whole oplog state, not the * checked-out state — so "replay everything, check out the past, export" * silently returns the *present*. The throwaway document's log must * therefore end at the target. * 3. **A point whose blobs were discarded and which is not a checkpoint is * reported unreachable**, never approximated with the nearest snapshot. * Silently serving a neighbouring version as if it were exact is how a * history feature loses someone's work. */ getVersion(frontier: Frontier): Promise; /** * The earliest `seq` at which the document stood at `frontier`. * * Earliest rather than latest: an update that arrives out of causal order is * parked as pending and leaves the frontier where it was, so several rows * can carry the same frontier. The first of them is the one whose prefix * actually produces that state. */ private seqOfFrontier; /** Read a snapshot's bytes from wherever they live. */ private readSnapshot; /** * Build a detached document holding the state after exactly `target_seq` * updates: the newest snapshot at or below the target, then every retained * update above it. * * The returned document's own log ends at the target, which is what makes * exporting it produce the past rather than the present. */ private replayTo; /** * Make the document equal the version at `frontier` **by writing forward**. * * History is append-only, always: a restore is a new edit that happens to * produce an old state, never a rewrite. That is what makes undoing a * restore just another restore, and it is why invariant 4 ("restore never * removes history") is true by construction rather than by care. * * The generated operations are logged like any other update and returned as * a `restore` checkpoint, so the restore itself is a point in history. * * Limitation worth knowing: this uses Loro's `revertTo`, which needs the * target inside the document's *retained* history. A checkpoint that only * survives as a snapshot after compaction can still be **read** * ({@link getVersion}) but cannot be reverted to here — reconstructing * minimal operations from a detached document is a content-aware diff * (ProseMirror-shaped), and belongs to the editor layer, not to a CRDT * store that knows nothing about the schema inside the document. */ restore(frontier: Frontier, actor: Actor): Checkpoint; /** * Record that `peer_key` holds every update up to `acked_seq`. * * This is what stops compaction from destroying a device. `acked_seq` only * ever moves forward: a peer that reconnects with a stale vector must not be * able to *lower* the floor and re-pin history that was already released. */ notePeer(peer_key: string, acked_seq: number): void; /** Stop holding history open for a peer — a device that was explicitly reset or removed. */ forgetPeer(peer_key: string): void; /** Every peer still inside the floor TTL, oldest acknowledgement first. */ listPeers(now?: number): PeerRecord[]; /** * The `seq` compaction must not trim past, or `null` when nothing constrains it. * * This is the sharpest edge in the whole design, and the API gives no help * with it. Once history has been trimmed to a shallow start, a peer whose * version predates that start is *unrecoverable in both directions*: it * cannot be caught up (a shallow snapshot silently no-ops when imported into * a document that is behind its start — `import` returns success and changes * nothing) and its own pending operations can never be accepted, because * their dependencies are gone here too. Nothing throws. The device simply * stops syncing, forever, and both sides believe they are fine. * * So retention takes a floor at the least-advanced live peer. "Live" means * seen within `peer_floor_ttl_ms` (30 days by default): a device that has * been dark for longer is presumed gone rather than pinning history for the * rest of the document's life, and when it does reappear {@link syncFor} * tells it to `reset` instead of pretending it can merge. * * A document with no registered peers has no floor. That is the correct * default for a server-authoritative deployment where clients are told to * re-bootstrap — but a deployment with real offline devices **must** call * {@link syncFor} (or {@link notePeer}) so they are known. */ peerFloor(now?: number): number | null; /** * Work out what one peer must be sent to converge with this document, and * record that it now holds everything. * * `peer_version` is the peer's encoded Loro version vector, or `null`/empty * for a peer with nothing at all. * * The interesting case is the middle one. A shallow (compacted) document * cannot serve an incremental update to a peer that is behind its shallow * start: `export({ mode: 'update', from })` happily returns a blob whose * dependencies were trimmed, and the receiver's `import` reports success and * stays where it was. The check has to be made *here*, by comparing the * peer's vector against `shallowSinceVV()` — nothing downstream can detect * the failure. */ syncFor(peer_key: string, peer_version: Uint8Array | null): CrdtSyncResult; /** * Whether an incremental update from `peer_version` is actually applicable — * i.e. this document still holds every operation the peer is missing. */ canServeIncrementally(peer_version: VersionVector): boolean; /** * Write a snapshot of the document as it stands now. * * `full` carries history and can bootstrap anyone; `shallow` carries only * the state and is roughly 40% smaller (85KB vs 159KB on a 20k-word * document, measured) because on a real document the history, not the text, * is the bulk of a snapshot. */ snapshot(kind: 'shallow' | 'full'): Promise; /** Tier a snapshot blob to R2 if it is large and a bucket is configured. */ private prepareSnapshot; /** * Insert a prepared snapshot. `INSERT OR IGNORE` then a pin update: a * frontier already snapshotted holds exactly the same bytes, so re-writing * it would be pure churn — but a snapshot that was incidental and is now a * checkpoint's lifeline must gain its pin. */ private writeSnapshot; /** * Trim the op log, keeping every promise the document has made. * * The order of operations is the whole design: * * 1. Decide a **boundary** — the head of the log, pulled back to the * {@link peerFloor} if a live peer has not caught up. Never trim past a * peer that may still hold unsynced operations. * 2. For every checkpoint at or before the boundary with no snapshot at its * exact frontier, replay to that checkpoint and snapshot it, **pinned**. * This happens before a single blob is deleted, which is what makes * invariant 3 ("every checkpoint stays checkoutable forever") true. * 3. Snapshot the boundary itself. * 4. Delete the update blobs at or below the boundary, and any unpinned * snapshot the boundary supersedes. * * Steps 2–4 run in one `transactionSync`. If the result would be *larger* * than what it replaced — which really happens: a 9-op document with one * checkpoint measured 871B → 1188B, because a mandatory checkpoint snapshot * costs more than the handful of blobs it stands in for — the transaction is * rolled back and the run reports `would_not_shrink`. Invariant 7 is only * true at or above the threshold, and this is how it is *made* true rather * than assumed. * * @param options.force Run regardless of threshold and shrinkage. For tests * and for an explicit "compact now" action; the daily alarm should not use it. */ compact(options?: { force?: boolean; }): Promise; /** * Run `config.project` against the current document, at most once per * frontier. * * Idempotence is enforced here rather than trusted to the projector: a * projection fans out to a search index, link rows and an R2 mirror, and * "re-running it at the same version is free" is much easier to guarantee * with one recorded frontier than in four downstream systems. * * Scheduling is the caller's: this class has no opinion about how long the * debounce is, and running a projection inside `applyUpdate` would put a * markdown serialization on every keystroke. */ runProjection(options?: { force?: boolean; }): Promise; /** Read one `doc_meta` value. */ meta(key: string): string | null; /** Write one `doc_meta` value. */ setMeta(key: string, value: string): void; } //# sourceMappingURL=document.server.d.ts.map