@ultimat3/realtime 27.14.0 → 27.14.1

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/CLAUDE.md CHANGED
@@ -103,10 +103,10 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
103
103
  `staleChanges`). **A gap is detected**: the replicator stamps `producer` + `seq`; a skipped
104
104
  sequence — or a NEW producer after a known one — stales every window; `refillWindowInLane` replaces it.
105
105
  - **A bulk write stales only its entity's readers**: windows + channel topics, `invalidate(entity)`
106
- on both from `onBulk`. **A stale window's re-read re-snapshots EVERY subscriber**, before the lsn
106
+ on both from `onBulk` (channels: not a `recordPublisher` table). **A stale window's re-read re-snapshots EVERY subscriber**, before the lsn
107
107
  guard, even on a non-match (`live-replicator-bulk`, `live-fanout` tests).
108
108
  - **A WINDOW is per TENANT**: its id is `windowId(queryHash(name, input), tenant)` (`live-tenant.ts`;
109
- tenant = the subscriber's `actor.orgId`). This package owns no hash. The read runs `readFor`
109
+ tenant = the subscriber's `actor.orgId`). The read runs `readFor`
110
110
  (`live-definition.ts`): the node's ctx, a service actor carrying ONLY that org — never the
111
111
  subscriber. A cursor naming another window is a cold start; `reauthorize` re-seats a
112
112
  subscription whose actor changed org (`live-tenancy.test.ts`; `docs/history/realtime.md`).
@@ -278,7 +278,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
278
278
  (with its cursor) and per topic.
279
279
  - **The client beats** (`DEFAULT_HEARTBEAT_MS` 10 s = default ttl / 3, pinned by
280
280
  `client-heartbeat.test.ts`; `0` disables): a `hello` plus one subscribe per topic. Two silent
281
- windows close with `4000`. **The node names the beat: `hello.heartbeatMs`** — `Heartbeat.follow`
281
+ windows after an UNANSWERED beat close with `4000`. **The node names the beat: `hello.heartbeatMs`** — `Heartbeat.follow`
282
282
  per socket; the engine reaps ports on the longer of its own and the node's.
283
283
  - **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()` maps a
284
284
  `BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve.
@@ -312,7 +312,7 @@ bun test packages/realtime/src # from the REPO ROOT
312
312
  bun run typecheck
313
313
  ```
314
314
 
315
- `bunfig.toml`'s preload installs `@ultimat3/testing`'s matchers; Bun reads it from the cwd.
315
+ `bunfig.toml`'s preload (matchers) is read from the cwd.
316
316
 
317
317
  Changing a frame shape means a fixture in `sync-protocol.test.ts` (the round-trip test fails
318
318
  without one) and bumping `PROTOCOL_VERSION` **when an old frame becomes unreadable in either
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "27.14.0",
3
+ "version": "27.14.1",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,10 +40,10 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/core": "27.14.0",
44
- "@ultimat3/entity": "27.14.0",
45
- "@ultimat3/policy": "27.14.0",
46
- "@ultimat3/query": "27.14.0",
43
+ "@ultimat3/core": "27.14.1",
44
+ "@ultimat3/entity": "27.14.1",
45
+ "@ultimat3/policy": "27.14.1",
46
+ "@ultimat3/query": "27.14.1",
47
47
  "nats": "2.29.3"
48
48
  }
49
49
  }
@@ -39,6 +39,8 @@ export class Heartbeat {
39
39
  readonly #options: HeartbeatOptions;
40
40
  #cancel: (() => void) | null = null;
41
41
  #lastSeen = 0;
42
+ /** Whether a frame arrived since this socket last asked (the opening `hello`, then each beat). */
43
+ #answered = false;
42
44
  /** The beat in force on THIS socket: the option's, until the node's `hello` names another. */
43
45
  #intervalMs: number;
44
46
 
@@ -58,6 +60,7 @@ export class Heartbeat {
58
60
  this.#intervalMs = this.#options.intervalMs;
59
61
  if (this.#intervalMs <= 0) return;
60
62
  this.#lastSeen = now;
63
+ this.#answered = false;
61
64
  this.#arm();
62
65
  }
63
66
 
@@ -78,6 +81,7 @@ export class Heartbeat {
78
81
  /** A frame arrived. Anything counts: the point is that bytes still cross in this direction. */
79
82
  saw(now: number): void {
80
83
  this.#lastSeen = now;
84
+ this.#answered = true;
81
85
  }
82
86
 
83
87
  stop(): void {
@@ -98,10 +102,14 @@ export class Heartbeat {
98
102
  // Two windows, not one: a beat and the answer to it share the window they were sent in, so a
99
103
  // single quiet interval is a slow round trip and not a dead socket. Nothing is re-armed after
100
104
  // a silence — `onSilence` drops the socket, and the next `start()` is the next connection's.
101
- if (now - this.#lastSeen > this.#intervalMs * 2) {
105
+ // And only when the last beat went UNANSWERED: a hidden tab's timers are throttled (Chrome runs
106
+ // a chained timer about once a minute), so a late tick on a socket that answered every beat it
107
+ // was sent is the page's clock, not the socket — dropping it churned every background tab.
108
+ if (!this.#answered && now - this.#lastSeen > this.#intervalMs * 2) {
102
109
  this.#options.onSilence();
103
110
  return;
104
111
  }
112
+ this.#answered = false;
105
113
  this.#options.beat();
106
114
  this.#arm();
107
115
  }
package/src/json.ts CHANGED
@@ -74,9 +74,24 @@ export function changedColumns(before: JsonObject | null, after: JsonObject): Js
74
74
  return out;
75
75
  }
76
76
 
77
- function sameJson(a: JsonValue | undefined, b: JsonValue | undefined): boolean {
78
- if (a === b) return true;
79
- if (a === undefined || b === undefined || a === null || b === null) return false;
80
- if (typeof a !== 'object' || typeof b !== 'object') return false;
81
- return JSON.stringify(a) === JSON.stringify(b);
77
+ /**
78
+ * Structural equality over the value domain: arrays by position, plain objects by key whatever
79
+ * their order (Postgres `jsonb` reorders keys), a `Date` by its time. A JSON column is a fresh
80
+ * object on every read, so identity said "changed" for every re-read of an unchanged row.
81
+ */
82
+ export function sameJson(a: unknown, b: unknown): boolean {
83
+ if (Object.is(a, b)) return true;
84
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
85
+ if (a instanceof Date || b instanceof Date) {
86
+ return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
87
+ }
88
+ if (Array.isArray(a) || Array.isArray(b)) {
89
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
90
+ return a.every((item, index) => sameJson(item, b[index]));
91
+ }
92
+ const left = a as Record<string, unknown>;
93
+ const right = b as Record<string, unknown>;
94
+ const keys = Object.keys(left);
95
+ if (keys.length !== Object.keys(right).length) return false;
96
+ return keys.every((key) => Object.hasOwn(right, key) && sameJson(left[key], right[key]));
82
97
  }
@@ -151,8 +151,11 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
151
151
  if (stopped) return;
152
152
  const table = tableOf(change.entity);
153
153
  registry.invalidate(table);
154
- // Channels too, or a tab seated on one held the pre-update rows with nothing to re-read.
155
- options.channels?.invalidate(table);
154
+ // Channels too, or a tab seated on one held the pre-update rows with nothing to re-read —
155
+ // unless a `recordPublisher` claims the table, as `onChange` asks: the app's own publish is
156
+ // its channel delivery, bulk writes included, and a replay-gap on every `updateWhere` sent
157
+ // every seated tab back to a full catch-up read for rows it was already being sent.
158
+ if (!isPublishedTable(table)) options.channels?.invalidate(table);
156
159
  },
157
160
  };
158
161
 
@@ -3,6 +3,7 @@
3
3
  // state — it answers what moved, and the store decides who hears about it.
4
4
 
5
5
  import type { Row } from '@ultimat3/core/page';
6
+ import { sameJson } from './json';
6
7
  import type { RecordKey } from './record-key';
7
8
 
8
9
  export class SyncedLayer {
@@ -44,7 +45,9 @@ export class SyncedLayer {
44
45
  /**
45
46
  * One server write. Merged, never replaced: a projection that selected fewer columns must not
46
47
  * blank the columns another is rendering, so an omitted (or `undefined`) field is left alone.
47
- * Answers the row as it now stands, and whether anything in it changed.
48
+ * Answers the row as it now stands, and whether anything in it changed. Compared STRUCTURALLY:
49
+ * a JSON column is a fresh array or object on every read, and by identity every catch-up, poll
50
+ * and reconnect moved every row. An unmoved column keeps its object, so its readers stay put.
48
51
  */
49
52
  merge(rk: RecordKey, columns: Row): { readonly row: Row; readonly changed: boolean } {
50
53
  // A restored row is a guess from disk: the server's first answer is the whole truth.
@@ -53,7 +56,8 @@ export class SyncedLayer {
53
56
  let changed = current === undefined;
54
57
  for (const [column, value] of Object.entries(columns)) {
55
58
  if (value === undefined) continue;
56
- if (current === undefined || current[column] !== value) changed = true;
59
+ if (current !== undefined && sameJson(current[column], value)) continue;
60
+ changed = true;
57
61
  next[column] = value;
58
62
  }
59
63
  if (!changed && current !== undefined) return { row: current, changed: false };
package/src/record-tx.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // over the OLD row must become `+ 1` over the row the server just sent, never the stale sum.
4
4
 
5
5
  import type { ConflictPolicy, Row } from '@ultimat3/core/page';
6
+ import { sameJson } from './json';
6
7
 
7
8
  /** One table as a mutator's `local` half sees it, keyed by record type (the entity's name). */
8
9
  export interface LocalTable<R extends Row = Row> {
@@ -45,13 +46,16 @@ export interface ReplayResult {
45
46
  readonly failed: readonly string[];
46
47
  }
47
48
 
48
- /** Shallow content equality — a replay producing the same columns must not re-render anything. */
49
+ /**
50
+ * Content equality, column by column and STRUCTURAL within one (`sameJson`) — a replay producing
51
+ * the same columns must not re-render anything, and a twin's array is a fresh one every replay.
52
+ */
49
53
  export function sameRow(a: Row | null, b: Row | null): boolean {
50
54
  if (a === b) return true;
51
55
  if (a === null || b === null) return false;
52
56
  const keys = Object.keys(a);
53
57
  if (keys.length !== Object.keys(b).length) return false;
54
- return keys.every((key) => Object.hasOwn(b, key) && a[key] === b[key]);
58
+ return keys.every((key) => Object.hasOwn(b, key) && sameJson(a[key], b[key]));
55
59
  }
56
60
 
57
61
  export function replayOverlays(