# `@ai-matrx/realtime`

**The ONE Supabase Realtime layer for Matrx clients.** Hand-writing `.channel(` in a
consuming app is a code-review defect.

```bash
pnpm add @ai-matrx/realtime
```

---

## Why this package exists

> "We struggle with it so much across our system. We've struggled with it trying to set it up
> for messaging, trying to set it up for notes. To this day, after hundreds of hours of
> development, the realtime in our note system still doesn't work perfectly." — Arman, 2026-08-29

Realtime is not hard because subscribing is hard. Subscribing is four lines. It is hard
because of eight things that are invisible until they hurt, each of which was learned here
the expensive way — a browser that froze, a note that lost an edit, a chat that showed "Ada
is typing…" for an hour. This package writes those eight things down ONCE, as code, and
makes every consumer use them.

That list is the doctrine below. It replaces the prose version that used to live in
matrx-frontend's `supabase-realtime` skill; the skill now points here.

---

## The doctrine

### 1. Suppress your own echoes — revision-monotonic FIRST, timestamp second

**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 it lands, any "save in flight" flag has been cleared. **Flag-only suppression
always misses the echo.**

What an unsuppressed echo does: the echo of save N arrives while the user typed toward N+1 →
dirty-local ≠ server → a false conflict → the conflict UI does O(n²) diff work per render →
the next save clears it → its echo re-arms it. Self-sustaining main-thread saturation.

What OVER-suppression does (the phantom conflict, 2026-09-13): every platform table's
`version` moves on EVERY update — a desktop sync stamping `file_path`, an ingest job writing
metadata. Same actor, identical content, our write pending: the old ledger called it an echo,
the client kept the old number, and its next compare-and-swap missed into a conflict dialog
for a row whose text equalled the client's own base. **The client had suppressed the one event
that carried the number it needed.**

The package's write ledger classifies every payload, **by revision first** (the row's
`version`, read from `revisionField`, default `version`):

- R1. Older revision than we hold → **stale**.
- R2. The revision an unsettled write of ours will produce, with matching content →
  **own-echo** (the echo that beats the REST response, recognized by number).
- R3. An already-settled revision with matching content → **own-echo**; divergent → remote.
- R4. **Anything newer → remote, always** — same actor, same content, pending write or not.
  Delivering it is harmless; swallowing it is the next phantom conflict.

Only when no revision is present does the timestamp doctrine apply:

1. Strictly older `updated_at` than what we hold → **stale**, dropped (merging it *regresses*
   `updated_at`, which fails the next save's optimistic lock into a sticky false conflict).
2. Content we are known to have written and not yet settled → **own-echo**.
3. Equal `updated_at` → own-echo only if the content fingerprint also matches; a
   same-millisecond collaborator write still lands.
4. Unparseable timestamps → deliver. **Degrade to delivering, never to silently dropping.**

Register your own writes with their number: `ledger.begin({ …, revision: expectedVersion + 1 })`,
`ledger.settle(ticket, { revision: response.version })`, `ledger.observe({ …, revision })`.
And pair it with `@ai-matrx/data`'s `guardedUpdate({ rebase })`, so a bump the client still
missed (a slept tab) is retried against the current number instead of shown as a conflict.

Broadcast echoes are handled by the envelope: every send carries this client's session id and
the receiver drops its own.

### 2. Echo suppression is ON by default; opting out is the discouraged path

`echoSuppression: false` exists for the rare consumer that genuinely needs its own events back
over the wire. It is never the default and never the fix for something else.

### 3. At-least-once in, exactly-once out

Resubscribes, rejoins, and a consumer that broadcasts a row it also INSERTs all deliver the
same logical event twice. Supply an `eventKey` (or accept the default) and the package dedups.

### 4. Handlers never run on the socket path

A Supabase channel calls your handler on the socket's callback path. A handler that dispatches
to Redux, diffs content, or makes an RPC therefore executes back-to-back with no yield when a
burst arrives — and the tab freezes. Every historical Matrx browser lockup is a variant of
this; a background tab waking with 500 queued events is the classic trigger.

The package puts a bounded, ordered, yielding queue between the socket and your handler.
Order is preserved per channel (an async handler is awaited before the next event starts), the
drain yields to the event loop every few milliseconds, and depth is capped — an overflow
**sheds the oldest work, says so, and triggers a reconcile**, which is the correct recovery
for "we could not keep up".

### 5. Reconnect means RE-READ. 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 every channel
declares `onBackfill`, and the package calls it on **every** recovery path: reconnect, tab
wake, network restore, and queue overflow. A channel with no `onBackfill` gets a loud warning.

Backoff is jittered and has a **stability reset**: the attempt counter resets only after the
channel has *held* for 30s, never on `SUBSCRIBED`. Resetting on subscribe makes a flapping
channel cycle at the 1s floor forever, firing a full catch-up fetch every cycle.

### 6. Tab sleep and app background are the normal failure, not the exotic one

A backgrounded tab gets throttled until the socket idles out; a suspended phone comes back
with a WebSocket that still *looks* open and will never deliver another event. Both map onto
the same wake path: reconcile, and re-join anything not demonstrably healthy. Web uses
`visibilitychange`; React Native uses `AppState` (`@ai-matrx/realtime/native`).

### 7. One place names channels

Supabase's `client.channel(topic)` **dedupes by topic**: two features that both picked
`"messages"` silently share one channel and lose each other's bindings. Declare a namespace
instead — a second, different declaration of the same name throws.

Separately, React 19 double-invokes effects and `removeChannel` is async, so a remount can be
handed the previous mount's still-joined channel and `.on(...)` throws *"cannot add
postgres_changes callbacks after subscribe()"*.

**🚨 THE TOPIC-SEMANTICS SPLIT — the two transports do not mean the same thing by "topic", and
the package treats them differently.**

- **Postgres Changes: the topic is only a client-side connection key.** The server pushes on the
  declared filter, whatever the channel is called. So every connection attempt gets a **unique
  instance topic** (`mx:notes:u1#7`), which makes the remount collision above impossible for free.
- **Broadcast and presence: the topic IS the room.** It is the routing key the server fans
  messages out on. A unique instance topic there does not cost nothing — it costs *everything*:
  two browsers that both declared `mx:room:1` land in `#1` and `#2` and never see each other.
  (That shipped in 0.1.x and was found by `@ai-matrx/messaging`; fixed in 0.2.0.)

So a channel carrying broadcast or presence puts its **declared topic on the wire verbatim**, and
the manager keeps **one underlying channel per declared topic, ref-counted**: a second `open()` of
that topic attaches its bindings to the same channel, and the channel is removed only when the
last holder closes. The remount collision is handled there by deferring the real teardown one
turn, so React's cleanup-then-re-run re-attaches instead of racing an async `removeChannel`.

Consequences worth knowing before you open two holders on one topic:

- They share ONE channel config. The first one's `echoSuppression` and presence key win, and a
  disagreement is a loud `channel.room.config-mismatch` diagnostic — not a silent surprise.
- **They hear each other locally.** Same-tab holders share one client id and the channel is opened
  `broadcast.self:false`, so the wire never echoes one holder's frame to its sibling. The manager
  hands every `send()` directly to the other open holders of that topic (same envelope, same
  dedup; never back to the sender; raw-wire channels excluded). Cross-tab delivery is unchanged.
- **Exactly ONE of them is the tab leader.** `handle.isLocalLeader()` is true for the
  longest-open holder only, and `onLeaderChange(isLeader)` fires when leadership moves because
  the leader closed. Run once-per-tab work (autosave, CRDT host election) on the leader — two
  holders each concluding "I am alone, I am host" is the two-autosaves, last-snapshot-wins bug.
- They share ONE presence entry (one key per channel), so a topic needs exactly ONE presence
  publisher. A surface that only READS the roster — a typists label, an online-dot list — passes
  `presence: { publish: false }` (or `publish: false` to `useTyping`): it sees everyone and stops
  fighting over the entry. Two publishers is a loud
  `channel.room.presence.multiple-publishers` warning, not a mystery.
- Echo suppression still applies per client, so one holder does **not** see its sibling's own
  `send()` — that is the point of echo suppression, not a room failure. What both holders see is
  every *remote* peer's message.
- A holder that brings Postgres Changes bindings to an already-joined room forces a rebuild (those
  bindings ride the JOIN payload and cannot be added later). It is announced as
  `channel.room.rebuild`, and every holder reconciles through its backfill door. Open a mixed
  channel's holders together at mount and it never happens.

### 8. Choosing the transport

- **Postgres Changes** — durable rows where RLS must gate delivery (notes, files, tasks). The
  default for entity sync.
- **Broadcast** — ephemeral or high-frequency data with no authorization nuance (CRDT updates,
  typing, signaling). **Never manually broadcast a row you also INSERT** — every subscriber
  gets it twice.
- **Presence** — who's-here rosters. Ephemeral only.
- **One channel per feature.** Postgres Changes, broadcast, and presence can share one; do not
  run three.
- If the table carries `updated_by` (the platform `_stamp_actor` trigger), every UPDATE already
  identifies its editor — you may not need presence at all.

### 9. A foreign peer owns its wire — say so with `wire: {mode:"raw"}`

Every broadcast this package sends is wrapped in the Matrx envelope
(`{v, cid, eid, ts, data}`), and that envelope is what makes echo suppression and dedup possible
at all. Some channels do not get to choose: the other end is a **separately released program**
that reads a payload shape it defined, and re-shaping the wire under it would simply stop the peer
hearing us with nothing failing loudly at either end. The case that produced this mode is the
Chrome extension's `matrx-extension-bridge:<userId>` channel — the deployed extension parses a
bare `BridgeEnvelope` (`{direction, action, requestId, payload, timestamp}`) with a Zod schema.

```ts
const bridge = defineChannelNamespace({
  namespace: "extension-bridge",
  parts: ["userId"],
  description: "matrx-extend ↔ matrx-frontend RPC bridge (foreign wire).",
  foreignTopic: "matrx-extension-bridge", // the peer's topic root, used verbatim
});

manager.open({
  topic: bridge.topic({ userId }),
  wire: { mode: "raw", acceptEchoFromSelf: true },
  broadcast: [{ event: "FRONTEND_RPC", onMessage: ({ data }) => route(data) }],
  eventKey: (_source, payload) => `${payload.direction}:${payload.requestId}`,
  onBackfill: () => {},
});
```

Raw mode means exactly two things, in both directions: **send puts your payload on the wire
verbatim** (byte-identical to a hand-rolled `channel.send`), and **receive never reads an
envelope** — not even from a payload that happens to look enveloped. `BroadcastMessage.enveloped`
is always `false` there.

**It is a named downgrade, and it is never silent.** Two protections ride the envelope:

- **Echo suppression** used `cid`, and a raw wire has none. Supply `wire.isOwnMessage` when the
  peer's shape identifies its sender; otherwise set `wire.acceptEchoFromSelf: true` to state in
  code that the server's `broadcast.self:false` is the only protection. Supplying neither is legal
  and produces `channel.raw.echo-unguarded` (warn); the explicit opt-out still announces itself as
  `channel.raw.echo-accepted` (info), because the diagnostics stream must show which channels run
  unguarded.
- **Dedup** keyed off `eid`. Without one, every arrival is a distinct key. Supply `eventKey` — it
  receives the raw payload — or take `channel.raw.dedup-unguarded` (warn).

**`foreignTopic` is the topic half of the same idea.** The namespace is still declared and still
collision-checked; only the `mx:` root is replaced by the peer's. Renaming a foreign topic to
`mx:…` does not migrate anything — it puts this client in a room of one while the peer keeps
listening where it always did.

Raw mode is **transitional by intent**: it exists so both ends of a foreign wire can ride this
package today. When the peer ships an envelope-aware release, the change is deleting `wire` from
the spec — coordinate the two releases and record the order in the peer's channel contract.

### Silent-zero-events checklist

A subscription that compiles but receives nothing has one of these:

1. The table is not in the `supabase_realtime` **publication**.
2. RLS filters the events (realtime is RLS-authorized — that is a feature, but grants gate
   delivery).
3. `REPLICA IDENTITY FULL` is missing and you need old-row data or RLS-on-DELETE.
4. The table is not yet in the PostgREST/realtime schema cache (`notify pgrst, 'reload schema'`).

---

## Using it

### React

```tsx
import { RealtimeProvider, useChannel } from "@ai-matrx/realtime/react";
import { defineChannelNamespace } from "@ai-matrx/realtime";

// Once, beside the feature. Declaring twice with a different shape throws.
export const notesChannel = defineChannelNamespace({
  namespace: "notes",
  parts: ["userId"],
  description: "Note rows for one user",
});

function App({ supabase, userId }) {
  return (
    <RealtimeProvider client={supabase} actorId={userId}>
      <Notes userId={userId} />
    </RealtimeProvider>
  );
}

function Notes({ userId }) {
  const { status } = useChannel({
    topic: notesChannel.topic({ userId }),
    postgresChanges: [
      {
        event: "*",
        schema: "workbench",
        table: "notes",
        rowId: (row) => String(row.id),
        fingerprint: (row) => String(row.content),
        onChange: ({ row }) => applyNote(row),
      },
    ],
    // Realtime has no replay. This is how the gap gets closed.
    onBackfill: () => refetchNotes(userId),
  });

  return <StatusDot status={status} />;
}
```

Handlers may be fresh closures on every render — the subscription is keyed on the channel's
*shape*, not on handler identity, and the latest handler is always the one that runs.

### One provider per app — and how a sibling package rides it

`<RealtimeProvider>` builds exactly ONE manager, and that manager owns the write ledger that
makes an optimistic write's own echo recognizable. A second provider anywhere in the tree is a
second ledger and a second set of channels, so one manager's own writes look REMOTE to the
other. Apps mount one.

A PACKAGE that does realtime work of its own (`@ai-matrx/messaging`, `@ai-matrx/meet`) must
therefore ride the host's manager when the host has one, and mount its own only in an app that
wires none:

```tsx
const hostProvidesRealtime = useIsRealtimeProviderMounted();
if (hostProvidesRealtime) return <Runtime {...props} />;
return <RealtimeProvider client={client}><Runtime {...props} /></RealtimeProvider>;
```

Ask `useIsRealtimeProviderMounted()`, never `useRealtimeManager() !== null` — the manager is
also null while a mounted provider waits for its Supabase client, and a package that guessed
from it would mount a provider and then tear it, and every channel under it, back down the
instant the host's client arrived.

### Redux middleware, thunks, and other non-React owners

Rule 4 prefers a Redux middleware as a feature's subscription owner — a component effect
re-subscribes on identity churn and multiplies channels across mounts. Middleware cannot call
a hook, so the provider **publishes** its manager and non-React owners read it. This is a
pointer to the ONE manager the provider built; constructing your own in middleware is a second
write ledger, and every optimistic write of yours would then classify as `remote` and cost a
refetch.

```ts
import { subscribeToRealtimeManager } from "@ai-matrx/realtime";

// Opens on arrival, closes on departure, RE-OPENS on the new manager when the
// provider rebuilds one (the signed-in user changed). Returns a stop function.
const stop = subscribeToRealtimeManager(() => ({
  topic: notesChannel.topic({ userId: currentUserId() }),
  postgresChanges: [ /* … */ ],
  onBackfill: () => store.dispatch(fetchNotes()),
}));
```

`currentRealtimeManager()` and `onRealtimeManagerChange(listener)` are the primitives underneath.
Reading `currentRealtimeManager()` **once** at module load is almost always wrong: it is `null`
before auth resolves, and the manager is rebuilt (and the old one disposed, with every channel it
owned) when the signed-in user changes. Prefer `subscribeToRealtimeManager`.

The spec is a **factory** so the owner captures the current user id / `dispatch` at open time
rather than at module load.

### Private channels (Database Broadcast)

A topic authorized against `realtime.messages` RLS — how `realtime.broadcast_changes()`
from a Postgres trigger delivers rows only to the entitled users — declares `private`:

```ts
manager.open({
  topic: schedulerChannel.topic({ userId }),
  private: true,
  broadcast: [{ event: "UPDATE", onMessage: ({ data }) => apply(data) }],
  onBackfill: () => refetchSchedules(),
});
```

The package creates the channel with `config.private: true` **and** authorizes the socket
(`client.realtime.setAuth()`) before subscribing. That second step is not optional and is
the quietest failure in this whole surface: without it the join is refused, the channel
receives nothing forever, and it reports itself perfectly healthy. A client that cannot
authorize gets `channel.private.unsupported`; a token that will not attach gets
`channel.private.auth-failed`.

### Autosave coexistence

Register the write on the shared ledger **before** the optimistic update, and the echo is
suppressed while a genuine collaborator write still lands:

```ts
const manager = useRealtimeManager();
const ticket = manager.ledger.begin({
  table: "workbench.notes",
  id: note.id,
  fingerprint: draft.content,
});
try {
  const saved = await saveNote(draft);
  manager.ledger.settle(ticket, { updatedAt: saved.updated_at, fingerprint: saved.content });
} catch (error) {
  manager.ledger.abandon(ticket);
  throw error;
}
```

`ledger.classify(...)` returns `own-echo | remote | stale` plus `localWritePending`, so an
optimistic write, its echo, and a real remote write are all distinguishable.

### Presence and typing

```tsx
const { others } = usePresence({
  topic: roomChannel.topic({ roomId }),
  presence: { state: { userId, name } },
});

const { typists, onKeystroke, stopTyping } = useTyping({
  topic: chatChannel.topic({ conversationId }),
  identity: { userId, name },
});
```

A crashed client cannot leave a ghost participant (heartbeat + local-clock expiry), and
"X is typing…" cannot stick (typing is a renewed lease, never a latch that waits for a stop
message that may never arrive).

### React Native / Expo

```tsx
import { NativeRealtimeProvider, useChannel } from "@ai-matrx/realtime/native";

<NativeRealtimeProvider client={supabase} actorId={userId}>
  {children}
</NativeRealtimeProvider>;
```

Identical hooks, with the `AppState` seam already wired: background → foreground reconciles
and re-joins. `react-native` is an optional peer; the web entries never load it.

### Diagnostics

```ts
import { realtimeDiagnostics } from "@ai-matrx/realtime";
// or: const snapshot = useRealtimeDiagnostics();
```

Live channels, subscription state, events/minute, and the numbers nobody ever had:
`suppressedEcho`, `deduped`, `stale`, `queueDepth`, `queueDropped`, `handlerErrors`,
`backfills`. Realtime bugs are meant to be *observed*, not guessed at.

---

## Entry points

| Entry | Contains | `"use client"` |
|---|---|---|
| `@ai-matrx/realtime` | The framework-free core: manager, write ledger, topics, presence/typing runtimes, queue, backoff, dedup, diagnostics | no (safe from RSC, Redux middleware, workers, node) |
| `@ai-matrx/realtime/react` | `RealtimeProvider`, `useChannel`, `usePresence`, `useTyping`, `useRealtimeDiagnostics`, `useRealtimeHealth` — plus the whole core | yes |
| `@ai-matrx/realtime/native` | `NativeRealtimeProvider`, `createReactNativeEnvironment` — plus the whole React binding | yes |

Zero runtime dependencies. `react` and `react-native` are optional peers. The package never
imports `@supabase/supabase-js`: the client is accepted structurally, and a compile-time proof
against the real declarations lives in `src/core/supabase-shape.test.ts`.

---

## Migrating off hand-rolled channels

1. Declare a namespace for the feature; delete the hand-written topic string.
2. Replace the `.channel(...)` block with `useChannel` (or `manager.open` in middleware).
3. Move the catch-up read into `onBackfill` — if the module had none, this is the bug you are
   fixing.
4. Delete the module's own echo/dedup/backoff code. Keeping a second copy beside the package
   is the named failure, not the safe option.
5. `uniqueChannelTopic` lives HERE. `@ai-matrx/data/db` re-exports it (data 0.6.3) so its
   existing call sites keep working — but a migrating feature stops calling it at all: a
   namespace names the channel and the manager decides the wire topic per the topic-semantics
   split in rule 7. **A hand-rolled broadcast or presence channel that was calling
   `uniqueChannelTopic` itself was splitting its own room** — that is a bug you are fixing, not
   behavior to preserve.

## License

MIT
