svelte-adapter-uws-extensions 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MIGRATION.md CHANGED
@@ -36,7 +36,7 @@ Both hashes have per-field TTLs via `HPEXPIRE`. The pre-fix whole-key EXPIRE is
36
36
 
37
37
  3. **`metrics().staleCleanedTotal` is now always 0.** Per-field staleness is enforced atomically by Redis HPEXPIRE rather than by an application-side cleanup script, so there is nothing to count. The field stays in the return shape for backward-compatibility. The corresponding Prometheus counter `presence_stale_cleaned_total` is no longer registered.
38
38
 
39
- 4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `presence_state` when one fired. Same shape in 0.5 (the per-topic hash whole-key expiry only fires when every field has expired, i.e. no live instances). Per-field expiry (a single crashed instance) does NOT trigger this notification; users disappear from `list()` / `count()` results lazily. If you need explicit per-field-expiry events, subscribe to `__keyevent@*__:hexpired` separately (separate Redis CONFIG flag).
39
+ 4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `state` when one fired. Same shape in 0.5 (the per-topic hash whole-key expiry only fires when every field has expired, i.e. no live instances). Per-field expiry (a single crashed instance) does NOT trigger this notification; users disappear from `list()` / `count()` results lazily. If you need explicit per-field-expiry events, subscribe to `__keyevent@*__:hexpired` separately (separate Redis CONFIG flag).
40
40
 
41
41
  5. **Falling back to single-instance.** If you can't upgrade Redis, switch to the in-memory `createPresence` plugin from `svelte-adapter-uws/plugins/presence`. Public API is the same; you lose cross-instance presence which is the whole point of the Redis variant, but for single-instance deployments it's a clean drop-in.
42
42
 
@@ -219,19 +219,19 @@ export function leaveBoard(ws, { topic, platform }) {
219
219
 
220
220
  No `close`-hook change required: uWS releases all per-`ws` subscriptions on disconnect, so `cursors.remove(ws, platform)` in your `close` hook still handles cleanup. The legacy `cursors.hooks.subscribe` slot is now a no-op (the adapter's wire gate prevents it from ever firing); it stays exported for source-compat but new code should not rely on it.
221
221
 
222
- ### Presence wire shape on `__presence:{topic}` migrated to `presence_state` / `presence_diff`
222
+ ### Presence wire shape on `__presence:{topic}` migrated to `state` / `diff`
223
223
 
224
224
  **Only impacts apps with hand-rolled WebSocket clients consuming the wire directly.**
225
225
 
226
226
  **What changed.** The `redis/presence` channel previously emitted `list` / `join` / `leave` / `updated` / `heartbeat` events. It now emits:
227
227
 
228
- - `presence_state` (sent once on subscribe to a single connection): payload is `{[userKey]: data}` - a flat snapshot.
229
- - `presence_diff` (broadcast to topic subscribers, microtask-batched): payload is `{joins: {[key]: data}, leaves: {[key]: data}}`. Same-tick joins+leaves on the same key collapse to the latest op; updates appear as a `joins` entry with the new data.
228
+ - `state` (sent once on subscribe to a single connection): payload is `{[userKey]: data}` - a flat snapshot.
229
+ - `diff` (broadcast to topic subscribers, microtask-batched): payload is `{joins: {[key]: data}, leaves: {[key]: data}}`. Same-tick joins+leaves on the same key collapse to the latest op; updates appear as a `joins` entry with the new data.
230
230
  - `heartbeat` is unchanged.
231
231
 
232
- The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `presence_state` event (was an empty `list` event) on hash expiry.
232
+ The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `state` event (was an empty `list` event) on hash expiry.
233
233
 
234
- **How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `presence_state` / `presence_diff`:
234
+ **How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `state` / `diff`:
235
235
 
236
236
  ```js
237
237
  // before
@@ -241,11 +241,11 @@ case 'leave': remove(payload.key); break;
241
241
  case 'updated': set(payload.key, payload.data); break;
242
242
 
243
243
  // after
244
- case 'presence_state':
244
+ case 'state':
245
245
  state.clear();
246
246
  for (const k of Object.keys(payload)) state.set(k, payload[k]);
247
247
  break;
248
- case 'presence_diff':
248
+ case 'diff':
249
249
  for (const k of Object.keys(payload.joins)) state.set(k, payload.joins[k]);
250
250
  for (const k of Object.keys(payload.leaves)) state.delete(k);
251
251
  break;
package/README.md CHANGED
@@ -608,13 +608,13 @@ Clients see three event types on `__presence:{topic}`. Mirrors the adapter's bun
608
608
 
609
609
  | Event | When | Payload | Direction |
610
610
  |---|---|---|---|
611
- | `presence_state` | Once on subscribe | `{[userKey]: data}` - flat snapshot of current presence | Server -> single connection |
612
- | `presence_diff` | Microtask-batched after joins / leaves / updates | `{joins: {[key]: data}, leaves: {[key]: data}}` | Server -> topic subscribers |
611
+ | `state` | Once on subscribe | `{[userKey]: data}` - flat snapshot of current presence | Server -> single connection |
612
+ | `diff` | Microtask-batched after joins / leaves / updates | `{joins: {[key]: data}, leaves: {[key]: data}}` | Server -> topic subscribers |
613
613
  | `heartbeat` | Per heartbeat interval | `string[]` - array of currently-known user keys | Server -> topic subscribers |
614
614
 
615
- `presence_diff` collapses by key per-tick: if the same user joins and leaves in the same microtask, only the latest op survives on the wire. An update (same user re-joins with different data) appears as a `joins` entry carrying the new data, since clients overwrite their `Map.set` on the same key.
615
+ `diff` collapses by key per-tick: if the same user joins and leaves in the same microtask, only the latest op survives on the wire. An update (same user re-joins with different data) appears as a `joins` entry carrying the new data, since clients overwrite their `Map.set` on the same key.
616
616
 
617
- Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `presence_state` / `presence_diff` shape regardless of which instance the change originated on.
617
+ Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `state` / `diff` shape regardless of which instance the change originated on.
618
618
 
619
619
  Joins are staged with full rollback on failure: local state is set up first, then the Redis hashes are written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone - local maps, the Redis state, and any buffered diff entry are reversed. Compensating join+leave ops on the same key in the same tick collapse to nothing on the wire.
620
620
 
@@ -662,7 +662,7 @@ export async function close(ws, { platform }) {
662
662
  | `select` | strips `__`-prefixed keys | Extract public fields from userData |
663
663
  | `heartbeat` | `30000` | TTL refresh interval in ms |
664
664
  | `ttl` | `90` | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
665
- | `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `presence_state` event. See [Keyspace cleanup mode](#keyspace-cleanup-mode). |
665
+ | `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `state` event. See [Keyspace cleanup mode](#keyspace-cleanup-mode). |
666
666
 
667
667
  #### API
668
668
 
@@ -674,7 +674,7 @@ export async function close(ws, { platform }) {
674
674
  | `list(topic)` | Get current users |
675
675
  | `count(topic)` | Count unique users |
676
676
  | `metrics()` | Synchronous snapshot: `{ totalOnline, heartbeatLatencyMs, staleCleanedTotal }`. See [Metrics snapshot](#metrics-snapshot). |
677
- | `flushDiffs()` | Drain the pending `presence_diff` buffer synchronously. Use in graceful-shutdown paths or tests that need the diff to land before the await chain continues. |
677
+ | `flushDiffs()` | Drain the pending `diff` buffer synchronously. Use in graceful-shutdown paths or tests that need the diff to land before the await chain continues. |
678
678
  | `clear()` | Reset all presence state |
679
679
  | `destroy()` | Stop heartbeat and subscriber |
680
680
  | `hooks` | `{ subscribe, close }` - ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
@@ -695,14 +695,14 @@ Two additional counters track the diff-protocol behavior:
695
695
 
696
696
  | Metric | Description |
697
697
  |---|---|
698
- | `presence_diff_frames_total{topic="..."}` | `presence_diff` frames published to topic subscribers. Compared against `presence_joins_total` + `presence_leaves_total` it tells you how much per-tick coalescing the buffer is doing - the bigger the gap, the more bandwidth saved versus per-event broadcast. |
698
+ | `presence_diff_frames_total{topic="..."}` | `diff` frames published to topic subscribers. Compared against `presence_joins_total` + `presence_leaves_total` it tells you how much per-tick coalescing the buffer is doing - the bigger the gap, the more bandwidth saved versus per-event broadcast. |
699
699
  | `presence_diff_coalesced_total{topic="..."}` | Buffered diff entries overwritten by a later op in the same tick. A non-zero rate confirms the same-key collapse is working (e.g. a user reconnecting fast enough to leave-then-join in one tick). Zero is also a valid state under steady traffic. |
700
700
 
701
701
  #### Keyspace cleanup mode
702
702
 
703
- By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `presence_diff` with the user in `leaves`. If the tracking instance crashes, the broadcast never fires and the observer's UI shows stale data until the page is reloaded.
703
+ By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `diff` with the user in `leaves`. If the tracking instance crashes, the broadcast never fires and the observer's UI shows stale data until the page is reloaded.
704
704
 
705
- `keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore - typically because the only tracker crashed), this instance emits an empty `presence_state` event on `__presence:<topic>` so local subscribers can replace their entire local map with "no one here."
705
+ `keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore - typically because the only tracker crashed), this instance emits an empty `state` event on `__presence:<topic>` so local subscribers can replace their entire local map with "no one here."
706
706
 
707
707
  ```js
708
708
  const presence = createPresence(redis, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -154,7 +154,7 @@
154
154
  "node": ">=22.0.0"
155
155
  },
156
156
  "peerDependencies": {
157
- "svelte-adapter-uws": "^0.5.5"
157
+ "svelte-adapter-uws": "^0.5.7"
158
158
  },
159
159
  "dependencies": {
160
160
  "ioredis": "^5.0.0"
package/redis/cursor.js CHANGED
@@ -403,16 +403,11 @@ export function createCursor(client, options = {}) {
403
403
  * - `lastFlush`: target-anchored timestamp of the most recent flush.
404
404
  * Advanced by `topicThrottleMs` per cycle (not to actual fire time) so
405
405
  * a single late tick does not compound drift on subsequent cycles.
406
- * - `pendingMicroflush`: a leading-edge flush is queued for the current
407
- * cadence slot; co-arriving broadcasts (from either local OR peer-
408
- * relay sources) in the same JS pass append to `dirty` /
409
- * `inboundDirty` and ship as one combined frame when the microtask
410
- * runs. Without this, a single co-arriving cursor fired alone as a
411
- * single-cursor UPDATE while every other cursor in the same pass
412
- * queued for the trailing tick - 86 percent fragmentation observed at
413
- * 1000-mover load on Hetzner CCX13 with `topicThrottle: 8`.
406
+ * Initialized to `Date.now() - topicThrottleMs` so the first broadcast
407
+ * on a new topic is "cycle ready" without polluting drift stats with
408
+ * the full `Date.now()` lateness an init of 0 would imply.
414
409
  *
415
- * @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number, pendingMicroflush: boolean }>}
410
+ * @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
416
411
  */
417
412
  const topicFlush = new Map();
418
413
 
@@ -570,9 +565,10 @@ export function createCursor(client, options = {}) {
570
565
  dirtyTopics.delete(topic);
571
566
 
572
567
  // Target-anchored: advance lastFlush by the cadence amount.
573
- // Multi-cycle backlog collapse to `now` so the next leading-
574
- // edge check `(now - lastFlush) >= topicThrottleMs` works as
575
- // expected without firing every queued cycle on this turn.
568
+ // Multi-cycle backlog collapse to `now` so the next
569
+ // broadcast's `Date.now() - lastFlush >= topicThrottleMs`
570
+ // delay computation does not fire every queued cycle on
571
+ // this turn.
576
572
  state.lastFlush = drift < topicThrottleMs ? deadline : now;
577
573
  } else if (deadline < nextDeadline) {
578
574
  nextDeadline = deadline;
@@ -591,21 +587,31 @@ export function createCursor(client, options = {}) {
591
587
  }
592
588
 
593
589
  /**
594
- * Schedule a local cursor for the next coalesced flush. The leading-edge
595
- * check claims the cadence slot synchronously when `topicThrottleMs` has
596
- * elapsed since the last flush, then defers the actual `flushBoth` by one
597
- * microtask. Co-arriving broadcasts in the same JS pass (from this
598
- * function OR `enqueueInbound` - they share `state.pendingMicroflush`)
599
- * see `now - state.lastFlush < topicThrottleMs` and take the trailing
600
- * path, accumulating into `state.dirty` / `state.inboundDirty`. The
601
- * microtask then flushes everything as one combined frame.
590
+ * Schedule a local cursor for the next coalesced flush. Always-tick: every
591
+ * call appends to `state.dirty`, adds the topic to `dirtyTopics`, and arms
592
+ * the tracker-wide tick timer. NO leading-edge synchronous fire and NO
593
+ * microtask defer.
602
594
  *
603
- * Without this defer, the first cursor in a post-pause burst fires alone
604
- * as a single-cursor UPDATE while every co-arriving cursor in the same
605
- * pass queues for the trailing tick. Demo measured 86 percent
606
- * fragmentation (1794 single UPDATEs vs 38 BULKs in 30s) at 1000-mover
607
- * load on Hetzner CCX13 with `topicThrottle: 8`. After the defer:
608
- * single UPDATE rate collapses, BULK rate matches cycle rate.
595
+ * Why: uWS dispatches each WS message as its own JS task, and N-API
596
+ * drains microtasks at the C++/JS boundary between dispatches. A
597
+ * `queueMicrotask`-deferred flush fires BEFORE the next socket's message
598
+ * handler runs, so cross-socket coalescing is impossible at the microtask
599
+ * level. `setTimeout(0)` is in libuv's timers phase and fires only after
600
+ * the poll phase processes every ready message on every socket - so all
601
+ * broadcasts dispatched in the same loop iteration end up in one flush
602
+ * regardless of how many task boundaries separate them.
603
+ *
604
+ * 0.5.5/0.5.6 shipped a `queueMicrotask` + `pendingMicroflush` variant
605
+ * built on the wrong dispatch-model assumption (that co-arriving
606
+ * broadcasts share a JS task). Demo measured ~99% single-cursor UPDATE /
607
+ * ~1% BULK at 1000-mover load on the deployed 0.5.6 - essentially the
608
+ * pre-fix shape. The bench validated the assumed input shape (all
609
+ * broadcasts in one synchronous task) instead of the input shape uWS
610
+ * actually produces (per-message tasks separated by microtask drains).
611
+ *
612
+ * First-cursor latency cost of always-tick: up to `topicThrottleMs` (16ms
613
+ * default, one frame budget) before fanout. Below the perceptual floor
614
+ * for cursors. Same trade-off the adapter's bundled cursor plugin makes.
609
615
  */
610
616
  function broadcast(topic, key, user, data, platform) {
611
617
  if (topicThrottleMs <= 0) {
@@ -615,34 +621,19 @@ export function createCursor(client, options = {}) {
615
621
 
616
622
  let state = topicFlush.get(topic);
617
623
  if (!state) {
618
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0, pendingMicroflush: false };
624
+ // Anchor `lastFlush` one cycle in the past so the first broadcast
625
+ // is treated as "cycle ready" with zero drift on the very first
626
+ // tick. Without this, `Date.now() - 0` would be a huge "lateness"
627
+ // that pollutes the drift stats forever.
628
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
619
629
  topicFlush.set(topic, state);
620
630
  }
621
631
  state.dirty.set(key, { user, data, platform });
622
-
623
- const now = Date.now();
624
- if (now - state.lastFlush >= topicThrottleMs) {
625
- // Claim the cadence slot synchronously so subsequent broadcasts
626
- // in the same JS pass take the trailing path and accumulate.
627
- state.lastFlush = now;
628
- dirtyTopics.delete(topic);
629
- if (!state.pendingMicroflush) {
630
- state.pendingMicroflush = true;
631
- queueMicrotask(() => {
632
- state.pendingMicroflush = false;
633
- // flushBoth clears `dirty` and `inboundDirty` internally
634
- // and early-returns on empty; this guard saves the
635
- // for-of entry cost on the empty case.
636
- if (state.dirty.size === 0 && state.inboundDirty.size === 0) return;
637
- flushBoth(topic, state);
638
- });
639
- }
640
- return;
641
- }
642
-
643
- // Within window: trailing-edge flush via the scheduler tick.
644
632
  dirtyTopics.add(topic);
645
- armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
633
+
634
+ const elapsed = Date.now() - state.lastFlush;
635
+ const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
636
+ armTick(delay);
646
637
  }
647
638
 
648
639
  /**
@@ -669,31 +660,19 @@ export function createCursor(client, options = {}) {
669
660
 
670
661
  let state = topicFlush.get(topic);
671
662
  if (!state) {
672
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0, pendingMicroflush: false };
663
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
673
664
  topicFlush.set(topic, state);
674
665
  }
675
666
  state.inboundDirty.set(key, { data, platform });
676
-
677
- const now = Date.now();
678
- if (now - state.lastFlush >= topicThrottleMs) {
679
- // Symmetric with `broadcast()`: same shared `pendingMicroflush`
680
- // per topic state, so if a local broadcast already claimed the
681
- // slot the peer-relay entry just appends and ships with it.
682
- state.lastFlush = now;
683
- dirtyTopics.delete(topic);
684
- if (!state.pendingMicroflush) {
685
- state.pendingMicroflush = true;
686
- queueMicrotask(() => {
687
- state.pendingMicroflush = false;
688
- if (state.dirty.size === 0 && state.inboundDirty.size === 0) return;
689
- flushBoth(topic, state);
690
- });
691
- }
692
- return;
693
- }
694
-
695
667
  dirtyTopics.add(topic);
696
- armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
668
+
669
+ // Symmetric with `broadcast()` always-tick: both source paths share
670
+ // the same `state` and the same tracker-wide tick timer, so a local
671
+ // broadcast and a peer-relayed inbound landing in the same loop
672
+ // iteration ship together as one combined frame at the next tick.
673
+ const elapsed = Date.now() - state.lastFlush;
674
+ const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
675
+ armTick(delay);
697
676
  }
698
677
 
699
678
  async function broadcastRemove(topic, key, platform) {
@@ -38,8 +38,8 @@ export interface RedisPresenceOptions {
38
38
  * both single-instance and cluster deployments.
39
39
  */
40
40
  export type PresenceWireEvent =
41
- | { event: 'presence_state'; data: Record<string, Record<string, any>> }
42
- | { event: 'presence_diff'; data: { joins: Record<string, Record<string, any>>; leaves: Record<string, Record<string, any>> } }
41
+ | { event: 'state'; data: Record<string, Record<string, any>> }
42
+ | { event: 'diff'; data: { joins: Record<string, Record<string, any>>; leaves: Record<string, Record<string, any>> } }
43
43
  | { event: 'heartbeat'; data: string[] };
44
44
 
45
45
  export interface PresenceMetricsSnapshot {
@@ -101,7 +101,7 @@ export interface RedisPresenceTracker {
101
101
  * Drain the pending diff buffer synchronously. The diff buffer
102
102
  * normally flushes on the next microtask after a join / leave /
103
103
  * update; call this when a test or graceful-shutdown path needs
104
- * the `presence_diff` to land before the await chain continues.
104
+ * the `diff` to land before the await chain continues.
105
105
  */
106
106
  flushDiffs(): void;
107
107
 
package/redis/presence.js CHANGED
@@ -6,9 +6,9 @@
6
6
  * for cross-instance join/leave notifications.
7
7
  *
8
8
  * Wire shape clients see on `__presence:{topic}`:
9
- * - `presence_state` (sent once on subscribe to a single connection)
9
+ * - `state` (sent once on subscribe to a single connection)
10
10
  * payload: `{[userKey]: data}` flat snapshot of current presence
11
- * - `presence_diff` (broadcast to topic subscribers, microtask-batched)
11
+ * - `diff` (broadcast to topic subscribers, microtask-batched)
12
12
  * payload: `{joins: {[key]: data}, leaves: {[key]: data}}`
13
13
  * Same-tick joins+leaves on the same key collapse: latest op wins.
14
14
  * - `heartbeat` (broadcast to topic subscribers, per heartbeat interval)
@@ -148,7 +148,7 @@ return 0
148
148
 
149
149
  /**
150
150
  * Internal cross-instance Redis pub/sub envelope event names. NOT the
151
- * client wire shape - clients see `presence_state` / `presence_diff` /
151
+ * client wire shape - clients see `state` / `diff` /
152
152
  * `heartbeat`. These names live on the `presence:events:{topic}` channel
153
153
  * between instances and are routed into the local diff buffer on receive.
154
154
  */
@@ -256,7 +256,7 @@ export function createPresence(client, options = {}) {
256
256
  const mTotalOnline = m?.gauge('presence_total_online', 'Unique users present per topic on this instance', ['topic']);
257
257
  const mHeartbeatLatency = m?.gauge('presence_heartbeat_latency_ms', 'Duration of the most recent heartbeat tick in milliseconds');
258
258
  const mKeyspaceCleanups = m?.counter('presence_keyspace_cleanups_total', 'Topics whose hash expiry triggered a local empty-list emit');
259
- const mDiffFrames = m?.counter('presence_diff_frames_total', 'presence_diff frames published to topic subscribers', ['topic']);
259
+ const mDiffFrames = m?.counter('presence_diff_frames_total', 'diff frames published to topic subscribers', ['topic']);
260
260
  const mDiffCoalesced = m?.counter('presence_diff_coalesced_total', 'Buffered diff entries overwritten by a later op in the same tick', ['topic']);
261
261
 
262
262
  let lastHeartbeatLatency = 0;
@@ -363,7 +363,7 @@ export function createPresence(client, options = {}) {
363
363
  else leaves[key] = data;
364
364
  }
365
365
  try {
366
- platform.publish('__presence:' + topic, 'presence_diff', { joins, leaves }, { relay: false });
366
+ platform.publish('__presence:' + topic, 'diff', { joins, leaves }, { relay: false });
367
367
  mDiffFrames?.inc({ topic: mt(topic) });
368
368
  } catch { /* platform unavailable mid-flight */ }
369
369
  }
@@ -533,11 +533,11 @@ export function createPresence(client, options = {}) {
533
533
  // handler could only refresh `existing` entries; an
534
534
  // entry the client swept (cross-replica relay latency,
535
535
  // brief backpressure, JS thread saturation) could never
536
- // be recovered without a presence_diff for that user.
536
+ // be recovered without a diff for that user.
537
537
  // Older clients fall back gracefully: they see an
538
538
  // object instead of an array and skip the legacy
539
- // "refresh-existing" branch, but the next presence_diff
540
- // or presence_state still reconciles them.
539
+ // "refresh-existing" branch, but the next diff
540
+ // or state still reconciles them.
541
541
  /** @type {Record<string, any>} */
542
542
  const dataMap = {};
543
543
  for (const [userKey, entry] of data) dataMap[userKey] = entry.data;
@@ -590,7 +590,7 @@ export function createPresence(client, options = {}) {
590
590
  // The per-topic hash key expires only when every field has
591
591
  // expired (no live instances presenting any user on this
592
592
  // topic). That is the "whole topic empty" signal we forward
593
- // as an empty presence_state to local subscribers. Per-user
593
+ // as an empty state to local subscribers. Per-user
594
594
  // hash keys (presence:user:{topic}:{userKey}) and the events
595
595
  // channel are filtered out.
596
596
  const topicPrefix = client.key('presence:topic:');
@@ -599,7 +599,7 @@ export function createPresence(client, options = {}) {
599
599
  if (!expiredKey.startsWith(topicPrefix)) return;
600
600
  const topic = expiredKey.slice(topicPrefix.length);
601
601
  if (activePlatform) {
602
- activePlatform.publish('__presence:' + topic, 'presence_state', {}, { relay: false });
602
+ activePlatform.publish('__presence:' + topic, 'state', {}, { relay: false });
603
603
  mKeyspaceCleanups?.inc();
604
604
  }
605
605
  });
@@ -1223,7 +1223,7 @@ export function createPresence(client, options = {}) {
1223
1223
  state[userKey] = entry.data;
1224
1224
  }
1225
1225
  try {
1226
- platform.send(ws, '__presence:' + topic, 'presence_state', state);
1226
+ platform.send(ws, '__presence:' + topic, 'state', state);
1227
1227
  } catch {
1228
1228
  // WebSocket closed before send
1229
1229
  }
@@ -1270,7 +1270,7 @@ export function createPresence(client, options = {}) {
1270
1270
 
1271
1271
  try {
1272
1272
  ws.subscribe(presenceTopic);
1273
- platform.send(ws, presenceTopic, 'presence_state', state);
1273
+ platform.send(ws, presenceTopic, 'state', state);
1274
1274
  } catch {
1275
1275
  const topics = syncObservers.get(ws);
1276
1276
  if (topics && topics.has(topic)) {
@@ -1396,13 +1396,13 @@ export function createPresence(client, options = {}) {
1396
1396
  // Client-initiated reconnect-snapshot. The presence plugin
1397
1397
  // client sends `{type:'presence-snapshot', topic}` on every
1398
1398
  // status==='open' (initial connect + reconnect). Re-emits
1399
- // `presence_state` to the requesting ws via `tracker.sync`,
1399
+ // `state` to the requesting ws via `tracker.sync`,
1400
1400
  // which is the same path that fires on a fresh subscribe.
1401
1401
  // Symmetric to cursor's `cursor-snapshot` text frame.
1402
1402
  //
1403
1403
  // Without this, board-scoped presence stayed stale across
1404
1404
  // reconnects: a tab that had joined via an RPC saw no
1405
- // presence_diff during the disconnect window, and on
1405
+ // diff during the disconnect window, and on
1406
1406
  // reconnect its in-memory map was whatever it last knew.
1407
1407
  // Global presence accidentally self-healed because most
1408
1408
  // apps call `presence.join('global')` from the `open` hook