import * as Y from 'yjs'; import { Awareness } from 'y-protocols/awareness'; import { R as RealtimeManager } from './manager-CwqlSmAA.js'; /** * Collab types — wire-protocol shapes for the workbook CRDT layer (v2). * * See `FEATURE.md` in this directory for the full integration plan. * * V1 of the workbook surface uses snapshot-per-save persistence and is * intentionally NOT collaborative beyond hot-swap on remote snapshot. * V2 layers Yjs CRDT updates over Supabase Broadcast on top of that store — * the snapshot table stays the canonical state, Broadcast handles the * ephemeral causal log between active editors. * * Nothing in this file imports `yjs` so that consumers (e.g. snapshot * tooling) can pull the types without dragging the CRDT lib into the bundle. * The `Y.Doc` / `Awareness` references in JSDoc point at the types provided * by `yjs` and `y-protocols/awareness` once those deps land. */ /** Per-peer presence payload. ~80 bytes JSON. */ type AwarenessState = { /** Stable user id (matches auth.uid). */ uid: string; /** Display name as shown next to the cursor. */ name: string; /** Cursor color (hex, e.g. "#3b82f6"). Derived deterministically from uid. */ color: string; /** Univer sheet the cursor is currently focused on. */ sheetId: string | null; /** Cursor row index, 0-based. */ row: number | null; /** Cursor column index, 0-based. */ col: number | null; /** Last-update ms timestamp. Used to age out stale cursors. */ ts: number; }; /** * Wire envelope for the binary Yjs update over Supabase Broadcast. * Broadcast payloads are JSON, so the binary update is base64-encoded. */ type YjsUpdateMessage = { type: "y-update"; /** base64(Uint8Array) of the Yjs update. */ u: string; /** When chunked, the index of this frame (0-based). */ seq?: number; /** When chunked, total number of frames. */ total?: number; }; /** * Wire envelope for awareness (presence + cursor) updates. Sent on a separate * broadcast event so we can throttle independently of the y-update channel. */ type AwarenessMessage = { type: "y-awareness"; /** base64(Uint8Array) of the encoded awareness update. */ a: string; }; /** * Initial-state request broadcast when a new peer joins. The first existing * peer to respond wins; late responses are dropped by client id seen-set. */ type StateRequestMessage = { type: "y-request-state"; /** Random per-connection id so responders can dedupe. */ clientId: string; }; /** Response to a StateRequestMessage: the requester's first full doc state. */ type StateResponseMessage = { type: "y-state"; /** base64(Uint8Array) of `Y.encodeStateAsUpdateV2(doc)`. */ sv: string; /** Echoes the requester id so they accept the right response. */ forClientId: string; }; /** Operational metadata stored next to each shared Y.Doc in memory. */ type WorkbookCollabContext = { workbookId: string; /** Stable per-tab session id; used in state-request dedupe. */ clientId: string; /** Authenticated user id (matches auth.uid). */ uid: string; }; /** * Result of the "am I the snapshot host?" deterministic election. The lowest * `uid` (lexicographically) among connected peers writes the canonical * snapshot; other peers skip the autosave path. Re-evaluated whenever * Awareness changes. */ type HostElectionResult = { isHost: boolean; hostUid: string | null; }; /** A peer's identity in a collab room (awareness state: `uid` = person id). */ interface CollabPeer { uid: string; clientId: string; name: string; color: string; } /** * openCollabSession — one co-editing room: a Y.Doc, its Awareness, the * Supabase-Broadcast provider on the app's ONE RealtimeManager, and the saver * election (election.ts, frozen as the editors ran it). "Exactly one peer per * session saves" is `isSaver()`. */ interface CollabSession { doc: Y.Doc; awareness: Awareness; /** Exactly one peer per session saves. */ isSaver(): boolean; onSaverChange(cb: (isSaver: boolean) => void): () => void; destroy(): void; } interface OpenCollabSessionOptions { /** `:`, e.g. `workbook:`. The wire topic is `yjs::`. */ channel: `${string}:${string}`; /** Default: the app's ONE manager (`currentRealtimeManager`); a second only in tests. */ manager?: RealtimeManager; me: CollabPeer; /** Workbooks true (one tab of a person writes); documents false (each originating tab writes its own edit). */ tabTieBreak: boolean; /** Tests / harnesses only; see SupabaseYjsProvider. */ antiEntropyIntervalMs?: number; readAntiEntropyIntervalMs?: () => Promise; } declare function openCollabSession(o: OpenCollabSessionOptions): CollabSession; /** * SupabaseYjsProvider — Yjs sync transport over Supabase Broadcast. * * Self-contained: no Univer dependencies. Carries Yjs document updates and * y-protocols awareness (presence) between peers in a single workbook channel. * * Wire protocol (see `./types.ts`): * event: "y-update" — base64 Yjs updateV2, optionally chunked * event: "y-awareness" — base64 awareness update * event: "y-request-state" — new joiner asks existing peers for full state * event: "y-state" — full state response targeted at one clientId * event: "y-sv" — anti-entropy probe: state vector + delete-set digest * * Design notes: * - Outbound y-doc updates use a "remote" origin convention so the doc.update * listener can avoid echoing applied remote updates back onto the wire. * - Initial state sync is best-effort: 1500ms "alone timer" decides solo mode. * - Awareness outbound is throttled at ~50ms to coalesce cursor spam. * * REALTIME: `@ai-matrx/realtime` owns the channel. THE TOPIC IS THE ROOM here * (pure broadcast), so the package puts the declared topic on the wire verbatim * and ref-counts one underlying channel per room — which is precisely the * problem the `client` option existed to work around ("supabase-js returns the * existing channel object for a duplicate topic and a second subscribe on it * fails"), so that option is gone and the verification harness passes two * MANAGERS instead of two clients. * * What else the adoption bought this provider: the decoupled ordered handler * queue (a burst of chunked frames no longer runs back-to-back on the socket * callback path — the frozen-tab class, and this provider ships 200KB base64 * frames), jittered reconnect with the stability reset, tab-sleep and network * awareness, dedup, and diagnostics. * * AND THE CATCH-UP. Realtime has no replay, and a CRDT is not exempt: every * peer update that landed while the socket was down is gone, and Yjs cannot * know it is missing them — the doc simply stays quietly divergent. The correct * re-read for a CRDT is to ask the peers for state again, which is exactly what * a new joiner already does, so `onBackfill` re-sends `y-request-state` — and * EVERY state answer addressed to us is merged, not only the first (Yjs applies * are idempotent; "first answer wins" made the catch-up a no-op). The gap runs * both ways, so the catch-up also re-sends this member's whole state: its own * offline edits reach the peers. * * ANTI-ENTROPY. Broadcast is at-most-once, and a frame can be lost while the * socket stays up (rate limit, a hiccup that never becomes a disconnect). Yjs * parks every later update that builds on the lost one as pending, forever, * and a lost delete leaves no trace at all. So while connected each member * probes the room with `y-sv` — its state vector plus a digest of its delete * set — (a) shortly after it holds pending structs, (b) once after a burst of * local edits goes quiet, and (c) on a jittered knob-backed interval, but only * if the doc changed since the last tick or something is still pending. A peer * that holds structs or deletes the prober lacks answers with * `encodeStateAsUpdateV2(doc, theirVector)` as a targeted `y-state`; a peer that * lacks something answers once with its own probe (marked `reply`, which is never * answered with another probe). Equal vectors and digests produce no frame, so an * idle room is silent. Our own probes are rate-limited (MIN_PROBE_GAP_MS). * * TEARDOWN. `disconnect()` sends the pending awareness (the "leave") before it * closes, so a caller tears down synchronously — a deferred disconnect left an * unmounted screen's provider applying the room's frames to its doc. A destroyed * doc disconnects its provider, and nothing is ever applied to one. * * Echo suppression is the package default now (it was `broadcast: {self:false}`, * the same intent). The vestigial `presence: {key}` in the old channel config is * gone: this provider binds no presence handlers and never called `track()`, so * it published nothing — awareness rides the `y-awareness` broadcast instead. * */ type SupabaseYjsProviderOptions = { /** * Opaque resource identifier (e.g. workbookId, documentId). Combined with * `channelPrefix` to form the Broadcast channel name, so distinct resource * types live on distinct channels even when their UUIDs are unrelated. */ workbookId: string; /** * Channel namespace — defaults to `"workbook"` for back-compat with the * spreadsheet surface that built this provider. The docs surface passes * `"document"` so the channel becomes `yjs:document:` instead of * `yjs:workbook:`. Pick distinct values per surface to keep CRDT rooms * from cross-talking. */ channelPrefix?: string; /** Stable per-tab session id. Use crypto.randomUUID() at the call site. */ clientId: string; doc: Y.Doc; awareness: Awareness; /** Cap base64-encoded payload size. Default 200_000 (under Broadcast's 256KB limit, accounting for envelope overhead). */ chunkSize?: number; /** * Override the realtime manager. Defaults to the app's ONE manager (the * provider publishes it). Only the verification harness passes its own — * two providers in ONE process must not share a manager, or the package's * room registry would correctly hand them the same channel and they would * never see each other's frames. */ manager?: RealtimeManager; /** Fires when this holder gains or loses tab leadership of its room. */ onLeaderChange?: () => void; /** * Take frames the package believes came from this same client session. * The package stamps every frame with ONE per-process session id and drops * frames carrying its own, so two managers in one process (the verification * harness) silence each other. Safe here: doc updates are idempotent CRDT * updates and the control frames filter their own `clientId`. Off in the app. */ acceptOwnSessionFrames?: boolean; /** * Base period (ms) of the anti-entropy tick. Defaults to the * `readAntiEntropyIntervalMs`; tests pass a number. */ antiEntropyIntervalMs?: number; /** * Reader for the anti-entropy period when `antiEntropyIntervalMs` is not * given. The app supplies its feature-knob reader * (`collab.anti_entropy_interval_ms`); the package owns no knob store. With * neither, the tick runs at DEFAULT_ANTI_ENTROPY_MS. */ readAntiEntropyIntervalMs?: () => Promise; }; /** Anti-entropy period (ms) when the host supplies neither a number nor a reader. */ declare const DEFAULT_ANTI_ENTROPY_MS = 30000; declare class SupabaseYjsProvider { private readonly workbookId; private readonly clientId; private readonly acceptOwnSessionFrames; private readonly doc; private readonly awareness; private readonly chunkSize; private readonly channelName; private readonly manager; private readonly onLeaderChange; private channel; private _disposed; private connected; private hasInitialState; private readyPromise; private resolveReady; private aloneTimer; private readonly pendingDocBatches; private readonly pendingStateBatches; private awarenessFlushTimer; private awarenessFlushQueue; private readonly antiEntropyIntervalOption; private readonly readAntiEntropyIntervalMs; private antiEntropyIntervalMs; private tickTimer; private quietTimer; private pendingTimer; private pendingBackoffMs; private lastProbeAt; private changedSinceTick; private readonly onDocDestroy; private readonly onDocUpdate; private readonly onAwarenessUpdate; constructor(options: SupabaseYjsProviderOptions); connect(): Promise; disconnect(): void; ready(): Promise; /** * One holder per room per tab is the leader (package-owned). Same-tab holders * of one document also hear each other's updates through the package, so they * converge; only the leader may be host/autosaver. No channel (solo) = leader. */ isTabLeader(): boolean; private broadcastChunked; /** Torn down, or the doc under us is gone: nothing may be applied or answered. */ private inert; private handleDocFrame; private handleAwarenessFrame; private handleStateRequest; private handleStateResponse; private startAntiEntropy; /** Jittered (±25%) so a room's members do not probe in lockstep. */ private scheduleTick; /** One probe after a burst of local edits goes quiet (debounced). */ private scheduleQuietProbe; /** After a remote apply: a gap that survives the reorder window is probed. */ private checkPending; private schedulePendingProbe; private deleteSetDigest; /** Send our state vector + delete-set digest. Rate-limited unless it is a reply. */ private probe; /** A rate-limited non-reply probe is deferred, never dropped. */ private schedulePendingProbeAfterGap; private handleStateVector; /** Reassemble chunked frames by batchId; returns the joined base64 string when complete. */ private assemble; private scheduleAwarenessFlush; private flushAwareness; private makeBatchId; } /** * Who writes the canonical snapshot in a collab workbook — pure, so it loads in the editor without yjs * (the session file pulls yjs in; the editor must not). */ /** The sentinel the session stamps on a mutation that ARRIVED from a peer (value: the peer's uid). */ declare const REMOTE_PARAM_TAG = "__matrxRemote"; /** * THE host election (the host writes the canonical snapshot for OTHER people's edits). The lowest uid * wins. Two tabs of ONE person share a uid; with `tabTieBreak` the tab's own client id breaks the tie so * exactly one tab is host (the document editor keeps the uid-only rule: there every originating tab * writes its own edit). */ declare function electCollabHost(states: Array>, selfUid: string, selfClientId: string, tabLeader: boolean, tabTieBreak?: boolean): { isHost: boolean; hostUid: string | null; }; /** * Who may AUTOSAVE after one mutation, so that one edit is written exactly once (review 2026-10-15: * with two tabs of one person open, the tab that made the edit AND the tab that received it both wrote * a snapshot and patched the workbook). * - An edit made HERE (`remoteFrom` null): written here when this person's tabs are the host * (`hostUid` is me, or nobody is known yet = solo). If another person is host, the host writes it. * - An edit that ARRIVED from a peer: only the elected host writes it, and never when it came from * this same person (the tab that made it writes it). */ declare function mayAutosave(i: { collab: boolean; isHost: boolean; hostUid: string | null; selfUid: string; remoteFrom: string | true | null; }): boolean; /** Where a mutation that Univer just ran came from, read off the tag the session stamps on remote ones. */ declare function remoteOriginOf(params: unknown): string | true | null; export { type AwarenessMessage, type AwarenessState, type CollabPeer, type CollabSession, DEFAULT_ANTI_ENTROPY_MS, type HostElectionResult, type OpenCollabSessionOptions, REMOTE_PARAM_TAG, type StateRequestMessage, type StateResponseMessage, SupabaseYjsProvider, type SupabaseYjsProviderOptions, type WorkbookCollabContext, type YjsUpdateMessage, electCollabHost, mayAutosave, openCollabSession, remoteOriginOf };