/** * Reconnect backoff with a STABILITY RESET and jitter. * * Two mistakes this replaces, both measured in matrx-frontend: * * - **Resetting the attempt counter on SUBSCRIBED.** A flapping channel then * cycles at the 1s floor forever, and each cycle fires a full catch-up * fetch — a self-inflicted request storm on the exact network that is * already struggling. The counter resets only after the channel has STAYED * healthy for `stabilityMs`. * - **No jitter.** Every tab of every user reconnects on the same doubling * schedule after a Realtime deploy, so the service gets a synchronized * thundering herd at 1s, 2s, 4s… Jitter spreads them. */ interface BackoffOptions { /** First delay in ms; doubles up to `maxDelayMs`. Default 1000. */ initialDelayMs?: number; /** Ceiling for the delay. Default 30000. */ maxDelayMs?: number; /** * How long a connection must HOLD before its next failure starts from the * floor again. Default 30000. */ stabilityMs?: number; /** Fraction of the delay applied as random jitter, 0..1. Default 0.25. */ jitter?: number; /** Injectable for tests. Default `Math.random`. */ random?: () => number; /** Injectable for tests. Default `Date.now`. */ now?: () => number; } interface Backoff { /** Delay for the next attempt, advancing the attempt counter. */ nextDelayMs(): number; /** Record that the connection came up (starts the stability window). */ markConnected(): void; /** Consecutive failed attempts since the last stability reset. */ attempts(): number; /** True once `attempts` passes the alarm threshold — for loud diagnostics. */ isAlarming(): boolean; reset(): void; } /** * Past this many consecutive failures the channel is not "retrying", it is * BROKEN, and the diagnostics sink escalates from warn to error. Silence here * is how a permanently dead subscription hides behind a spinner. */ declare const RECONNECT_ALARM_ATTEMPTS = 6; /** * The bounded policy: after this many consecutive failed attempts WITHOUT a * stable connection in between, the manager stops retrying a channel, says so * once (`channel.reconnect.abandoned`), and leaves it `disconnected` until the * tab wakes or the network returns. A channel for a conversation that was never * created (or whose first send was refused) otherwise reconnects forever. */ declare const RECONNECT_GIVE_UP_ATTEMPTS = 10; declare function createBackoff(options?: BackoffOptions): Backoff; /** * Idempotency: at-least-once in, exactly-once out (R3). * * Realtime delivery is at-least-once by nature — a resubscribe after a flap, * a channel that rejoined while the old one was still draining, or a consumer * that also broadcasts a row it INSERTed will all deliver the same logical * event twice. Every duplicate that reaches a reducer is a doubled message in * a thread, a doubled row in a list, or a doubled network call. * * The consumer supplies an event key (or lets the package use the envelope's * `eid`); this seen-set drops the repeat. It is time-bounded, not unbounded: * a session that runs for a day must not grow a set of every event it ever saw. */ interface DedupeOptions { /** How long a key stays "seen". Default 300000 (5 minutes). */ ttlMs?: number; /** * Hard cap on retained keys. Default 5000. On overflow the OLDEST keys are * evicted — the newest events are the ones a duplicate is likely to shadow. */ maxKeys?: number; /** Injectable for tests. Default `Date.now`. */ now?: () => number; } interface Dedupe { /** `true` if this key has NOT been seen (i.e. deliver it); records the key. */ admit(key: string): boolean; /** Number of keys currently retained. */ size(): number; /** Total keys rejected as duplicates since creation. */ rejected(): number; clear(): void; } declare function createDedupe(options?: DedupeOptions): Dedupe; /** * THE ENVIRONMENT BRIDGE (R4) — tab sleep, app background, and network flaps. * * A realtime channel does not usually die loudly. It dies because the laptop * closed, because a background tab was throttled until the socket idled out, * or because Wi-Fi handed off to LTE. In every one of those the page comes back * looking fine and silently showing stale data — the worst possible failure, * because the user trusts it. * * So the manager needs to know two things about its host: is the surface * VISIBLE, and is the device ONLINE. This port supplies them. It ships a * working default for every platform we run on — the browser one here, the * React Native one in `../native/environment`, and an inert one for SSR and * node tests. A host never has to fill this in to get correct behavior. */ type EnvironmentSignal = "visible" | "hidden" | "online" | "offline"; interface EnvironmentBridge { /** Name of the bridge, for the diagnostics surface. */ readonly name: string; isVisible(): boolean; isOnline(): boolean; subscribe(listener: (signal: EnvironmentSignal) => void): () => void; } /** * The SSR / node / worker bridge: always visible, always online, no events. * It is inert by design and says so — importing this package during SSR must * never touch `document` or `window`. */ declare function createInertEnvironment(): EnvironmentBridge; /** The browser bridge: `visibilitychange` + `online`/`offline`. */ declare function createBrowserEnvironment(): EnvironmentBridge; /** * The default: the browser bridge where a DOM exists, the inert one otherwise. * This is what makes `import`ing the package safe during SSR (contract point 4 * of the independence contract). */ declare function defaultEnvironment(): EnvironmentBridge; /** * THE WRITE LEDGER — echo suppression for Postgres Changes, and the named * solution to autosave↔realtime coexistence (R2 + R6). * * The problem, stated exactly (it is not the one people expect): * * Supabase sends you your OWN writes. Postgres Changes has no "not to self" * option — the echo of your UPDATE arrives 50–500ms AFTER your REST response * already returned the fresh row. By the time the echo lands, any "save in * flight" flag has been cleared. **Flag-only suppression therefore always * misses the echo.** * * What an unsuppressed echo does (the notes freeze, 2026-07): the echo of save * N arrives while the user has typed toward N+1 → dirty-local ≠ server → a * false "conflict" → the conflict UI does O(n²) diff work every render → the * next save clears it → its echo re-arms it. Self-sustaining main-thread * saturation, from one missing comparison. * * And what OVER-suppression does (the notes phantom conflict, 2026-09-13): * every platform table carries an integer `version` that `_touch_row` bumps on * EVERY update — a desktop sync writing `file_path`, an ingest job stamping * metadata, a folder move. Such a write changes nothing the editor compares * (same content, same label), and it lands under the same actor. A ledger that * decides "same content + same actor + our write pending ⇒ own echo" swallows * it — and the client keeps the OLD version. Its next save's compare-and-swap * misses on a row whose text is identical to the client's own base, and the * user is shown a conflict dialog for an edit nobody made. The client had * suppressed the one event that carried the number it needed. * * So the ledger is REVISION-MONOTONIC FIRST, then timestamp, then flags: * * R1. A revision strictly older than what we hold → `stale`. * R2. A revision equal to the one an unsettled write of ours will produce, * with matching content (or, with no content to compare, the same actor) * → `own-echo` (this is the echo that beats the REST response). A newer * revision carrying exactly the content of an unsettled write of ours is * also our echo (our write landed above the number we expected, after a * compare-and-swap rebase). * R3. A revision equal to what we already settled, with matching content → * `own-echo`; with different content → `remote` (a real divergence). * R4. A revision NEWER than anything we hold or expect → `remote`, ALWAYS — * same actor, same content, pending write or not. We did not produce that * revision, or we have not learned it yet; either way the consumer must * see it, because a version the client never adopts is the next false * conflict. Delivering it is harmless: a consumer that compares content * finds nothing to merge and simply advances its base. * * Only when neither side carries a revision does the older doctrine apply: * * 1. Strictly older `updatedAt` than what we already hold → `stale`. An event * that is not newer carries zero information, and merging it REGRESSES the * row's `updated_at`, which then fails the next save's optimistic lock and * produces a sticky false conflict. * 2. Content we are known to have written and not yet settled → `own-echo`. * 3. Equal `updatedAt` → an echo only if the content fingerprint also matches. * A same-millisecond collaborator write must still land. * 4. While one of OUR writes is unsettled, an event attributed to this client * (by `requestId`, by client id, or by actor id) is an echo — but a * DIVERGENT payload still comes through as `remote`, because that is a real * conflict and hiding it is worse than showing it. * 5. If either timestamp is unparseable, fall through to the fingerprint and * otherwise DELIVER. Degrade to delivering, never to silently dropping. * * `localWritePending` on the verdict is R6's answer: the consumer can tell an * optimistic local write still in flight from its echo from a genuine remote * write, using package APIs alone. */ type EventOrigin = "own-echo" | "remote" | "stale"; type OriginReason = "request-id-match" | "older-revision" | "pending-write-same-revision" | "equal-revision-same-content" | "newer-revision" | "older-timestamp" | "equal-timestamp-same-content" | "pending-write-same-actor" | "pending-write-same-content" | "no-local-record" | "newer-timestamp" | "divergent-content"; interface OriginVerdict { origin: EventOrigin; /** Stable machine reason — the diagnostics surface counts these. */ reason: OriginReason; /** True while THIS client has an unsettled write for the row (autosave in flight). */ localWritePending: boolean; } interface LocalWrite { /** Fully-qualified table, e.g. `workbench.notes`. */ table: string; /** Row primary key as a string. */ id: string; /** Content fingerprint of what we wrote — any stable string the consumer builds. */ fingerprint?: string | undefined; /** The `updated_at` we expect the server to stamp, once known. */ updatedAt?: string | undefined; /** * The integer revision this write will PRODUCE (the row's `version` after * the server's `_touch_row` bump — on a platform table that is the * compare-and-swap `expectedVersion + 1`). Supplying it is what lets the * ledger recognize the echo that beats the REST response by number instead * of by content, and — the important half — refuse to call a HIGHER number * an echo. */ revision?: number | undefined; /** * The id the SERVER will stamp on the row's metadata for this write, when * the write goes through a backend that stamps one (the `metadata.request_id` * convention). An exact-match echo test, and the only fully reliable one. */ requestId?: string | undefined; } interface WriteTicket { readonly key: string; readonly requestId: string | undefined; } interface ClassifyInput { table: string; id: string; updatedAt?: string | null | undefined; fingerprint?: string | undefined; /** `updated_by` from the row, if the table carries the actor stamp. */ updatedBy?: string | null | undefined; /** `metadata.request_id` from the row, if the backend stamps one. */ requestId?: string | null | undefined; /** The row's integer `version`, if the table carries one. */ revision?: number | null | undefined; } interface WriteLedgerOptions { /** * The user id this client acts as, when known. Enables the `updated_by` * echo test — the cheapest and most reliable one for tables carrying the * platform `_stamp_actor` trigger. */ actorId?: string | undefined; /** * How long an unsettled write stays on the ledger before it is assumed lost. * Default 30000. Without a TTL a failed request holds suppression open * forever and the row stops updating from anyone. */ ttlMs?: number; /** Injectable for tests. Default `Date.now`. */ now?: () => number; } interface SettleResult { updatedAt?: string | undefined; fingerprint?: string | undefined; /** The revision the server actually stamped (the REST response's `version`). */ revision?: number | undefined; } interface ObservedRecord { table: string; id: string; updatedAt?: string | null | undefined; fingerprint?: string | undefined; /** The row's `version` as read. */ revision?: number | null | undefined; } interface WriteLedger { /** Register a write BEFORE the optimistic reducer and the REST call. */ begin(write: LocalWrite): WriteTicket; /** Record what the server actually returned. Keeps the monotonic floor honest. */ settle(ticket: WriteTicket, result?: SettleResult): void; /** The request failed; stop suppressing for it. */ abandon(ticket: WriteTicket): void; /** Record server state we learned some other way (a list fetch, a backfill). */ observe(record: ObservedRecord): void; /** Classify one incoming payload. */ classify(input: ClassifyInput): OriginVerdict; /** Is one of OUR writes still unsettled for this row? */ isPending(table: string, id: string): boolean; /** The highest revision the ledger holds for a row, if it holds one. Diagnostics and tests. */ heldRevision(table: string, id: string): number | undefined; /** Rows currently tracked. Diagnostics. */ size(): number; clear(): void; } /** A revision is meaningful only as a finite integer; anything else is "absent". */ declare function canonicalRevision(value: number | null | undefined): number | undefined; declare function createWriteLedger(options?: WriteLedgerOptions): WriteLedger; /** * Structural contracts for @ai-matrx/realtime. * * THE PACKAGE NEVER IMPORTS `@supabase/supabase-js`. Every Supabase surface it * touches is described here STRUCTURALLY, so the package installs into a Vite * app, a Next.js app, an Expo app, a Chrome extension, and a node test harness * with one dependency graph and no version skew against the host's client. * * The signatures below must match Supabase's real overloads PRECISELY — a * wider filter type or a `never`-typed callback makes the real client fail to * satisfy these interfaces, and the HOST discovers it at its own typecheck * instead of us discovering it here. `src/core/supabase-shape.test.ts` carries * the compile-time proof against a stand-in that mirrors the real declarations. */ /** Where a channel currently stands, from the consumer's point of view. */ type RealtimeStatus = "connecting" | "connected" | "reconnecting" | "disconnected"; /** Aggregate connection health across every channel this manager owns. */ type ConnectionHealth = "live" | "degraded" | "down"; type PostgresChangeEvent = "*" | "INSERT" | "UPDATE" | "DELETE"; /** The payload `postgres_changes` delivers. */ interface PostgresChangePayload { eventType?: string; schema?: string; table?: string; new?: unknown; old?: unknown; commit_timestamp?: string; errors?: unknown; } /** The `postgres_changes` filter, exactly as Supabase declares it. */ interface PostgresChangeFilter { event: PostgresChangeEvent; schema: string; table?: string; filter?: string; } /** A presence roster keyed by presence key, as `presenceState()` returns it. */ type PresenceStateLike = Record; /** Structural stand-in for `RealtimeChannel`. */ interface RealtimeChannelLike { on(type: "postgres_changes", filter: PostgresChangeFilter, callback: (payload: PostgresChangePayload) => void): RealtimeChannelLike; on(type: "broadcast", filter: { event: string; }, callback: (payload: { event: string; payload?: unknown; }) => void): RealtimeChannelLike; on(type: "presence", filter: { event: "sync" | "join" | "leave"; }, callback: (payload: unknown) => void): RealtimeChannelLike; subscribe(callback?: (status: string, error?: Error) => void): unknown; send(args: { type: "broadcast"; event: string; payload?: unknown; }): unknown; track(state: Record): unknown; untrack(): unknown; presenceState(): PresenceStateLike; } /** Structural stand-in for `SupabaseClient` (realtime surface only). */ interface RealtimeClientLike { channel(topic: string, params?: unknown): RealtimeChannelLike; removeChannel(channel: RealtimeChannelLike): unknown; /** * The realtime socket handle. Optional because the package accepts a client * STRUCTURALLY and must never require more of it than it uses: this is read * only for a PRIVATE channel, where Supabase authorizes the topic against * `realtime.messages` RLS and refuses the join unless the socket is carrying * the current access token. A client without it simply cannot open a private * channel, and the manager says so rather than joining a channel that will * silently receive nothing. */ realtime?: { setAuth(token?: string): unknown; }; } /** * The diagnostic sink. Background failures NEVER go to a swallowed catch and * never to a bare `console.log` a host cannot route — they come out here, typed, * with a remedy sentence. The default sink writes to `console.warn`/`error` so * a host that injects nothing still sees everything. */ type DiagnosticLevel = "info" | "warn" | "error"; interface DiagnosticEvent { level: DiagnosticLevel; /** Stable machine code, e.g. `channel.reconnect.scheduled`. */ code: string; message: string; /** What the operator or developer should DO about it. Never empty for warn/error. */ remedy?: string; topic?: string; detail?: Record; } type DiagnosticsSink = (event: DiagnosticEvent) => void; /** The Matrx envelope every package-sent broadcast carries. */ interface MatrxBroadcastEnvelope { /** Envelope version — bump only with a documented consumer action. */ v: 1; /** The sending client session. This is what makes echo suppression possible. */ cid: string; /** Unique per emitted event; the dedup key when the consumer supplies none. */ eid: string; /** Wall-clock send time (ms). Diagnostics + ordering hints only. */ ts: number; /** The consumer's actual payload. */ data: TPayload; } /** * PRESENCE (R5) — who is here, without ghosts. * * Supabase presence removes a member when its socket closes cleanly. The * interesting cases are the ones where it does NOT close cleanly: a closed * laptop, a killed tab, a phone that lost signal in a tunnel. The entry then * lingers until the server's own timeout, and a room shows a participant who * left twenty minutes ago. Worse, a rejoin during that window shows the same * person twice. * * Two mechanisms fix that here, and neither depends on clocks agreeing between * devices (they never do): * * - **Heartbeat.** This client re-`track()`s itself every `heartbeatMs`. That * is a positive liveness signal, not an absence of one. * - **Local-clock expiry.** Every member's `lastSeenAt` is stamped with OUR * clock when WE received their state. A member we have not heard from in * `expireAfterMs` is pruned from the roster we publish, whatever the server * still holds. A ghost therefore cannot outlive one expiry window. * * Join/leave bursts are debounced: a rejoining channel emits a full `sync` plus * a `join` per member, and a roster that re-renders once per member is the * O(N²) selector storm this package exists to prevent. */ interface PresenceMember { /** The presence key this member tracked under. */ key: string; /** The member's client session id (distinct per tab, even for one user). */ clientId: string; state: TState; /** OUR clock, first time we saw this member. */ joinedAt: number; /** OUR clock, most recent time we saw this member's state. */ lastSeenAt: number; isSelf: boolean; } interface PresenceOptions> { /** The presence key for THIS client. Defaults to the client session id. */ key?: string; /** What this client publishes about itself. */ state: TState; /** How often this client re-announces itself. Default 15000. */ heartbeatMs?: number; /** A member unheard-from this long is pruned locally. Default 45000. */ expireAfterMs?: number; /** Coalesce sync/join/leave bursts into one emission. Default 150. */ debounceMs?: number; /** * Whether THIS holder announces itself. Default true. * * One channel carries ONE presence entry per key, so a topic must have * exactly one publisher: a second holder that also tracks overwrites the * first's state on every heartbeat, and the platform's own typing indicator * is what that breaks. A surface that only wants to READ the roster (a * typists label, an online-dot list) sets `publish: false` — it still sees * everyone, it just stops fighting over the entry. */ publish?: boolean; onChange?: (members: readonly PresenceMember[]) => void; } interface PresenceRoom> { readonly key: string; members(): readonly PresenceMember[]; /** Publish new state for this client. Merged, not replaced. */ update(state: Partial): void; subscribe(listener: (members: readonly PresenceMember[]) => void): () => void; dispose(): void; } interface PresenceRuntime> extends PresenceRoom { /** Called by the manager once the channel is SUBSCRIBED. */ onSubscribed(channel: RealtimeChannelLike): void; /** Called by the manager when the channel drops (roster is no longer trustworthy). */ onDisconnected(): void; /** Called by the manager whenever the channel reports a presence event. */ onPresenceEvent(channel: RealtimeChannelLike): void; } interface PresenceTimers { setInterval: (run: () => void, ms: number) => unknown; clearInterval: (handle: unknown) => void; setTimeout: (run: () => void, ms: number) => unknown; clearTimeout: (handle: unknown) => void; now: () => number; } declare function createPresenceRuntime>(options: PresenceOptions, timers?: PresenceTimers): PresenceRuntime; /** * THE REALTIME MANAGER — the one object that owns channels. * * Everything a consumer used to hand-roll lives here, and none of it is * optional: unique topics, enforced teardown, echo suppression, dedup, the * decoupled ordered handler queue, jittered reconnect with a stability reset, * a BACKFILL door on every recovery path, tab-sleep and network awareness, and * live diagnostics. Opening a channel is one call; there is no correct-usage * checklist to forget. * * THE RULE THAT MADE THIS PACKAGE: realtime has NO REPLAY. Every event that * happened while the socket was down is gone forever. A reconnect without a * re-read leaves a permanently wrong screen that looks perfectly healthy — so * reconnecting IS re-reading here, not something each caller remembers. */ /** Why the package is asking the consumer to re-read from the database. */ type BackfillReason = "reconnect" | "wake" | "network-restored" | "queue-overflow" | "manual"; interface BackfillContext { reason: BackfillReason; topic: string; /** ms the channel was not receiving events, when known. */ gapMs: number | null; } interface PostgresChangeDelivery> { payload: PostgresChangePayload; /** The row the event carries (`new` for INSERT/UPDATE, `old` for DELETE). */ row: TRow | null; /** Where this event came from, per the write ledger. Always `remote` here. */ origin: OriginVerdict; } interface PostgresChangeBinding> { event: PostgresChangeEvent; schema: string; table: string; /** PostgREST filter, e.g. `file_id=eq.`. */ filter?: string; /** * The row's primary key, as a string. REQUIRED for echo suppression and for * the default dedup key — without it the package cannot tell your write from * anyone else's, and it says so once through the diagnostics sink rather than * silently degrading. */ rowId?: (row: Record) => string | undefined; /** A stable string summarizing the row's content. Powers content-aware echo tests. */ fingerprint?: (row: Record) => string | undefined; /** Column carrying the row's `updated_at`. Default `updated_at`. */ updatedAtField?: string; /** Column carrying the actor stamp. Default `updated_by`. */ updatedByField?: string; /** * Column carrying the row's integer revision. Default `version` — every * platform base-contract table has one, bumped by `_touch_row` on EVERY * update. A revision the ledger has never seen is never suppressed as an * echo, whatever the content or actor says (see `origin.ts` R4). Set to * `null` for a table whose `version` column means something else. */ revisionField?: string | null; onChange: (delivery: PostgresChangeDelivery) => void | Promise; } interface BroadcastMessage { event: string; data: TData; /** The sending client session id. */ from: string; eventId: string; sentAt: number; /** False when the message did not carry a Matrx envelope (a foreign sender). */ enveloped: boolean; } interface BroadcastBinding { event: string; onMessage: (message: BroadcastMessage) => void | Promise; } /** * RAW WIRE — for a channel whose payload shape is owned by a FOREIGN PEER. * * Normally this package wraps every broadcast in the Matrx envelope * (`{v, cid, eid, ts, data}`), and that envelope is what makes echo suppression * and dedup possible at all. But some channels do not get to choose their wire: * the other end is a separately released program that reads a payload shape it * defined — the deployed Chrome extension's `matrx-extension-bridge:` * channel is the case that produced this mode. Wrapping there would put a shape * on the wire the peer does not recognize, and the peer would simply stop * hearing us with nothing failing loudly on either side. * * So raw mode means EXACTLY this, in both directions: * * - **Send:** the consumer's payload goes on the wire verbatim. Byte-identical * to what a hand-rolled `channel.send({type:"broadcast", event, payload})` * would have put there. * - **Receive:** no envelope is read, ever — not even from a payload that * happens to look enveloped. `BroadcastMessage.enveloped` is always `false` * and `data` is the payload exactly as it arrived. * * WHAT THAT COSTS, AND WHY IT IS NEVER SILENT. Two protections ride the * envelope, and raw mode has to get them back explicitly or announce their * absence: * * 1. **Client-side echo suppression** used `cid`. There is no `cid` on a raw * wire, so either supply {@link RawWireOptions.isOwnMessage} (the peer's * shape carries something identifying the sender) or set * {@link RawWireOptions.acceptEchoFromSelf} to say in code that you accept * the server-side `broadcast.self:false` as the only protection. Supplying * neither is legal and produces a `channel.raw.echo-unguarded` warning — the * package will not quietly downgrade a guarantee. * 2. **Dedup** keyed off `eid`. Without one, every arrival is a distinct key * and dedup does nothing. Supply {@link ChannelSpec.eventKey} — it receives * the raw payload — or accept a `channel.raw.dedup-unguarded` warning. * * Raw mode is TRANSITIONAL by intent: it exists so both ends of a foreign wire * can ride this package today, and so the day the peer ships an envelope-aware * release, the only change is deleting `wire` from the spec. */ interface RawWireOptions { /** * Does this inbound payload come from THIS client? Return `true` and the * message is suppressed exactly as an envelope echo would have been. Supply it * whenever the foreign shape carries anything identifying the sender. */ isOwnMessage?: (payload: unknown) => boolean; /** * Set `true` to state, in code, that this channel runs without client-side * echo suppression because the foreign shape carries no sender identity. The * package still announces the fact (at `info`), because a reader of the * diagnostics stream must be able to see which channels are unguarded. */ acceptEchoFromSelf?: boolean; } /** How this channel's broadcasts ride the wire. Default: the Matrx envelope. */ type BroadcastWire = { mode: "matrx"; } | ({ mode: "raw"; } & RawWireOptions); interface ChannelSpec = Record> { /** Build it with a channel namespace — never a hand-written string. */ topic: string; postgresChanges?: readonly PostgresChangeBinding[]; broadcast?: readonly BroadcastBinding[]; presence?: PresenceOptions; /** * THE BACKFILL DOOR. Re-read whatever the missed events would have changed. * Called on every recovery path: reconnect, tab wake, network restore, and * queue overflow. Omitting it is allowed and LOUD — the package warns once, * because a channel with no reconciliation is a screen that will eventually * lie. */ onBackfill?: (context: BackfillContext) => void | Promise; onStatusChange?: (status: RealtimeStatus) => void; /** * Echo suppression is ON by default and opting out is the discouraged path * (R2). Set `false` only when the consumer genuinely needs to see its own * writes come back over the wire. */ echoSuppression?: boolean; /** * A PRIVATE channel — Supabase authorizes the topic against `realtime.messages` * RLS before allowing the join, which is how Database Broadcast * (`realtime.broadcast_changes()` from a Postgres trigger) delivers rows only * to the users entitled to them. * * Two things follow, and both are handled here rather than by every consumer: * the channel is created with `config.private: true`, and the socket must be * carrying the current access token, so `client.realtime.setAuth()` is awaited * BEFORE subscribing. Skipping that second step is the silent failure this * exists to prevent — the join is refused and the channel receives nothing * while looking perfectly healthy. * * A client with no `realtime.setAuth` cannot open a private channel; the * manager emits `channel.private.unsupported` and connects anyway as a public * channel would, so the failure is visible instead of mysterious. */ private?: boolean; /** * THE WIRE MODE for this channel's broadcasts. Defaults to `{mode:"matrx"}`. * Use `{mode:"raw", …}` ONLY when a foreign peer owns the payload shape — * see {@link RawWireOptions} for what raw mode costs and how to buy it back. * A raw channel's `topic` is likewise the peer's contract, so declare its * namespace with `foreignTopic` rather than inventing an `mx:` name. */ wire?: BroadcastWire; /** * Idempotency key for an event. Defaults to the broadcast envelope's `eid`, * and for Postgres Changes to `table:rowId:eventType:commitTimestamp`. * On a raw-wire channel there is no `eid`, so supplying this is the ONLY way * to keep dedup — `payload` is the raw payload exactly as it arrived. */ eventKey?: (source: "broadcast" | "postgres_changes", payload: unknown) => string | undefined; /** * Called when this holder gains or loses TAB LEADERSHIP of its topic. Several * holders of one topic in one tab (two Board tiles of one document) share one * client id and one channel; the work that must happen once per tab — the * autosave, the host election — belongs to the leader. See * {@link ChannelHandle.isLocalLeader}. */ onLeaderChange?: (isLeader: boolean) => void; } interface ChannelHandle = Record> { readonly topic: string; status(): RealtimeStatus; /** * Send a broadcast, wrapped in the Matrx envelope so receivers can suppress * it — unless the spec declared `wire: {mode:"raw"}`, in which case `data` * goes on the wire verbatim. */ send(event: string, data: TData): void; /** The presence room, when the spec declared one. */ presence: PresenceRoom | null; /** Ask for a reconciliation pass right now. */ requestBackfill(reason?: BackfillReason): void; /** * True for exactly ONE open holder of this topic per manager (per tab): the * longest-open one. When it closes, the next-oldest becomes leader and hears * `onLeaderChange(true)`. Use it to run once-per-tab work (autosave, host * election) — same-tab holders share a client id, so nothing else can tell * them apart. A holder with no peers is the leader. */ isLocalLeader(): boolean; /** Enforced teardown. Idempotent. */ close(): void; } interface ManagerTimers extends PresenceTimers { schedule: (run: () => void) => void; } interface RealtimeManagerOptions { /** Any Supabase client. The package never constructs one — hosts own identity. */ client: RealtimeClientLike; /** The signed-in user id, when known. Enables the `updated_by` echo test. */ actorId?: string | undefined; /** Visibility/online bridge. Defaults to the browser one, or inert under SSR. */ environment?: EnvironmentBridge; /** Background-failure sink. Defaults to a console sink that states the remedy. */ diagnostics?: DiagnosticsSink; backoff?: BackoffOptions; /** * Consecutive failed reconnects (no stable connection between) after which a * channel is abandoned: one `channel.reconnect.abandoned` diagnostic, status * `disconnected`, no more timers. A tab wake or network restore starts a * fresh bounded round. Default 10; 0 or Infinity disables the bound. */ giveUpAfterAttempts?: number; dedupe?: DedupeOptions; queue?: { maxDepth?: number; sliceMs?: number; }; /** Share one write ledger with the host's write path. One is created if omitted. */ ledger?: WriteLedger; /** * How long a surface may be hidden before waking triggers a reconcile. * Default 10000 — shorter than any realistic socket idle-out. */ wakeBackfillAfterMs?: number; timers?: ManagerTimers; } interface RealtimeManager { readonly clientId: string; /** The shared write ledger — register optimistic writes on it (R6). */ readonly ledger: WriteLedger; open = Record>(spec: ChannelSpec): ChannelHandle; health(): ConnectionHealth; /** Reconcile every open channel (a manual "I don't trust the screen" button). */ backfillAll(reason?: BackfillReason): void; openChannelCount(): number; dispose(): void; } declare function createRealtimeManager(options: RealtimeManagerOptions): RealtimeManager; export { createPresenceRuntime as $, type PresenceOptions as A, type BackfillContext as B, type ChannelSpec as C, type DiagnosticsSink as D, type EnvironmentBridge as E, type PresenceRuntime as F, type PresenceStateLike as G, RECONNECT_ALARM_ATTEMPTS as H, RECONNECT_GIVE_UP_ATTEMPTS as I, type RawWireOptions as J, type RealtimeChannelLike as K, type LocalWrite as L, type ManagerTimers as M, type RealtimeClientLike as N, type ObservedRecord as O, type PresenceRoom as P, type RealtimeManagerOptions as Q, type RealtimeManager as R, type SettleResult as S, type WriteLedgerOptions as T, type WriteTicket as U, canonicalRevision as V, type WriteLedger as W, createBackoff as X, createBrowserEnvironment as Y, createDedupe as Z, createInertEnvironment as _, type RealtimeStatus as a, createRealtimeManager as a0, createWriteLedger as a1, defaultEnvironment as a2, type ConnectionHealth as b, type PresenceTimers as c, type BackfillReason as d, type Backoff as e, type BackoffOptions as f, type BroadcastBinding as g, type BroadcastMessage as h, type BroadcastWire as i, type ChannelHandle as j, type ClassifyInput as k, type Dedupe as l, type DedupeOptions as m, type DiagnosticEvent as n, type DiagnosticLevel as o, type EnvironmentSignal as p, type EventOrigin as q, type MatrxBroadcastEnvelope as r, type OriginReason as s, type OriginVerdict as t, type PostgresChangeBinding as u, type PostgresChangeDelivery as v, type PostgresChangeEvent as w, type PostgresChangeFilter as x, type PostgresChangePayload as y, type PresenceMember as z };