@ultimat3/realtime 23.0.0 → 25.0.0

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.
Files changed (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
package/src/channel.ts CHANGED
@@ -5,10 +5,10 @@
5
5
 
6
6
  import {
7
7
  type Actor,
8
+ assertCoded,
8
9
  type Ctx,
9
- createContext,
10
+ ctxOf,
10
11
  finiteOption,
11
- invariant,
12
12
  logger,
13
13
  renderThrowable,
14
14
  } from '@ultimat3/core';
@@ -16,7 +16,8 @@ import type { ChangeEvent } from './changefeed';
16
16
  import { authorizeChannel } from './channel-authz';
17
17
  import { type Bridge, unsubscribeWhenOpen } from './channel-bridge';
18
18
  import type { Channel, Topic } from './channel-decl';
19
- import { ChannelLogs } from './channel-logs';
19
+ import { DenialLatch } from './channel-latch';
20
+ import { ChannelLogs, type ChannelTopic } from './channel-logs';
20
21
  import { getChannel, registeredChannels } from './channel-registry';
21
22
  import type { ChannelEventsFrame, ChannelSubscribeTarget } from './channel-wire';
22
23
  import {
@@ -52,6 +53,12 @@ export interface ChannelHubOptions {
52
53
  readonly ringSize?: number;
53
54
  }
54
55
 
56
+ /** The longest value one channel param may carry — a uuid is 36. */
57
+ export const MAX_CHANNEL_PARAM_LENGTH = 128;
58
+
59
+ /** `#settle`'s "nobody has decided this seat yet": distinct from `null`, which is an actor. */
60
+ const UNDECIDED = Symbol('undecided');
61
+
55
62
  /** Distinct topics one node bridges before `X_SUBSCRIPTION_LIMIT`. */
56
63
  export const DEFAULT_MAX_TOPICS_PER_NODE = 10_000;
57
64
 
@@ -80,12 +87,13 @@ export class ChannelHub {
80
87
  readonly #only: ReadonlyMap<string, Channel> | null;
81
88
  readonly #logs: ChannelLogs;
82
89
  readonly #ctx: Ctx;
90
+ /** Topics a socket's policy refused, until its actor changes (`channel-latch.ts`). */
91
+ readonly #latched: DenialLatch;
83
92
  /**
84
- * Topics a socket's policy refused, latched until its actor changes: a denial is a decision, so
85
- * a client re-asking in a loop is answered without re-running the policy each time — and only
86
- * THAT channel is refused, every other one on the socket keeps flowing.
93
+ * Topics whose guard could not DECIDE on a re-auth. Not a denial, so the seat and its bridge
94
+ * reference are kept; not a pass either, so nothing is delivered until one succeeds.
87
95
  */
88
- readonly #latched = new WeakMap<SyncSocket, Set<string>>();
96
+ readonly #suspended = new WeakMap<SyncSocket, Set<Topic>>();
89
97
 
90
98
  constructor(options: ChannelHubOptions) {
91
99
  this.#transport = options.transport;
@@ -100,7 +108,8 @@ export class ChannelHub {
100
108
  'maxTopicsPerNode',
101
109
  options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE,
102
110
  );
103
- this.#ctx = options.ctx ?? createContext();
111
+ this.#latched = new DenialLatch(this.#maxTopicsPerSocket, 'maxTopicsPerSocket');
112
+ this.#ctx = options.ctx ?? ctxOf();
104
113
  this.#logs = new ChannelLogs(options.sockets, options.ringSize);
105
114
  this.#only =
106
115
  options.channels === undefined ? null : new Map(options.channels.map((c) => [c.name, c]));
@@ -123,31 +132,94 @@ export class ChannelHub {
123
132
  }
124
133
  const params: Record<string, string> = {};
125
134
  for (const param of declared.params) {
126
- params[param] = Object.hasOwn(target.params, param) ? (target.params[param] ?? '') : '';
135
+ const value = Object.hasOwn(target.params, param) ? (target.params[param] ?? '') : '';
136
+ // A topic is retained (the bridge table, the latch, the client's sid), and `topic()` checks
137
+ // a segment's alphabet, never its size — so a param is the client's to grow only this far.
138
+ if (value.length > MAX_CHANNEL_PARAM_LENGTH) {
139
+ throw new TopicForbiddenError({
140
+ topic: target.channel,
141
+ actorId: socket.actorId,
142
+ reason: `param "${param}" is ${value.length} characters; the limit is ${MAX_CHANNEL_PARAM_LENGTH}`,
143
+ });
144
+ }
145
+ params[param] = value;
127
146
  }
128
147
  const name = declared.topic(params);
129
- if (this.#latched.get(socket)?.has(name) === true) {
148
+ if (this.#latched.has(socket, name)) {
130
149
  throw new TopicForbiddenError({
131
150
  topic: name,
132
151
  actorId: socket.actorId,
133
152
  reason: 'denied earlier on this connection; it is re-decided when the session changes',
134
153
  });
135
154
  }
155
+ this.#latched.assertRoom(socket);
156
+ const seat: ChannelTopic = { channel: declared, params };
157
+ // A suspended seat asked for again (the client's beat) is the next pass, never a second seat.
158
+ if (this.#suspended.get(socket)?.has(name) === true) {
159
+ await this.#settle(socket, name, seat, UNDECIDED);
160
+ return name;
161
+ }
136
162
  // Asked before the join seats it: a repeated `add` (the presence beat) is not a fresh seat.
137
163
  const fresh = !socket.topics.has(name);
164
+ // Captured, because the guard awaits and a re-auth may land under it: the verdict below is
165
+ // about THIS actor, and `onActorChange` cannot re-decide a seat that does not exist yet.
166
+ const actor = socket.actor;
138
167
  await this.#join(socket, name, async () => {
139
168
  try {
140
- await authorizeChannel(declared, this.#ctx, socket.actor, name, params);
169
+ await authorizeChannel(declared, this.#ctx, actor, name, params);
141
170
  } catch (error) {
142
- if (error instanceof TopicForbiddenError) this.#latch(socket, name);
171
+ if (error instanceof TopicForbiddenError && socket.actor === actor) {
172
+ this.#latched.add(socket, name);
173
+ }
143
174
  throw error;
144
175
  }
145
176
  });
146
- this.#logs.open(name, { channel: declared, params });
177
+ // The socket died while the guard or the bridge was answering: `#join` gave the seat back, so
178
+ // there is no member to open a ring for or to resume.
179
+ if (!socket.topics.has(name)) return name;
180
+ this.#logs.open(name, seat);
181
+ await this.#settle(socket, name, seat, actor);
182
+ // Dropped is a throw; suspended is a seat nothing is delivered on, the resume included.
183
+ if (!socket.topics.has(name)) return name;
147
184
  this.#logs.resume(socket, name, target.since, fresh);
148
185
  return name;
149
186
  }
150
187
 
188
+ /**
189
+ * Decide a seat under the socket's CURRENT actor until the actor it was decided for is still the
190
+ * one on the socket. A seat granted to the actor a re-auth has since replaced was kept until the
191
+ * next grant expiry; a re-ask on a suspended seat ignored a denial and answered success.
192
+ */
193
+ async #settle(
194
+ socket: SyncSocket,
195
+ name: Topic,
196
+ seat: ChannelTopic,
197
+ decidedFor: Actor | null | typeof UNDECIDED,
198
+ ): Promise<void> {
199
+ let actor = decidedFor;
200
+ while (actor === UNDECIDED || socket.actor !== actor) {
201
+ actor = socket.actor;
202
+ if ((await this.#decide(socket, name, seat, actor)) !== 'denied') continue;
203
+ if (socket.actor === actor) this.#latched.add(socket, name);
204
+ throw new TopicForbiddenError({
205
+ topic: name,
206
+ actorId: socket.actorId,
207
+ reason: `channel "${seat.channel.name}" policy denied the subscribe`,
208
+ });
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Every open records topic is presumed to have missed a change: a new epoch, and every member
214
+ * told `replay-gap`. The `sync` node calls it beside `LiveQueryRegistry.invalidate()` — seq is
215
+ * minted here from the changes this node SEES, so a change the bus dropped leaves a hole the
216
+ * ring cannot detect and would replay as complete history. Given an `entity`, only the topics
217
+ * carrying it: what a bulk write the in-process replicator could not shape as rows stales.
218
+ */
219
+ invalidate(entity?: string): number {
220
+ return this.#logs.invalidate(entity);
221
+ }
222
+
151
223
  /**
152
224
  * One committed change from the feed, turned into `records` frames on every declared channel it
153
225
  * touches and delivered on THIS node. Called for every change the node receives — the same
@@ -163,7 +235,7 @@ export class ChannelHub {
163
235
  params: Readonly<Record<K, string>>,
164
236
  event: JsonObject,
165
237
  ): Promise<void> {
166
- invariant(
238
+ assertCoded(
167
239
  declared.events,
168
240
  'X_CHANNEL_DECLARATION_INVALID',
169
241
  `channel("${declared.name}") declares no events, so nothing may publish one on it`,
@@ -178,6 +250,9 @@ export class ChannelHub {
178
250
  * a topic a caller spelled; every `Topic` comes from a declaration.
179
251
  */
180
252
  async emit(name: Topic, event: JsonObject): Promise<void> {
253
+ // A topic this node holds open on a channel declared without `events` carries none: a
254
+ // presence leave for it was an event on a channel whose members were promised records only.
255
+ if (this.#logs.target(name)?.channel.events === false) return;
181
256
  const frame: ChannelEventsFrame = { type: 'events', v: PROTOCOL_VERSION, channel: name, event };
182
257
  await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, JSON.stringify(frame));
183
258
  }
@@ -189,12 +264,6 @@ export class ChannelHub {
189
264
  return this.#logs.target(name);
190
265
  }
191
266
 
192
- #latch(socket: SyncSocket, name: string): void {
193
- const latched = this.#latched.get(socket) ?? new Set<string>();
194
- latched.add(name);
195
- this.#latched.set(socket, latched);
196
- }
197
-
198
267
  /** Sockets this node will deliver `name` to. The metric the fanout reads. */
199
268
  subscriberCount(name: Topic): number {
200
269
  return this.#sockets.subscriberCount(name);
@@ -222,7 +291,8 @@ export class ChannelHub {
222
291
  async #join(socket: SyncSocket, name: Topic, authorize: () => Promise<void>): Promise<void> {
223
292
  if (socket.topics.has(name)) return;
224
293
  const claimed = this.#claimed.get(socket) ?? 0;
225
- if (socket.topics.size + claimed >= this.#maxTopicsPerSocket) {
294
+ const held = socket.topics.size + (this.#suspended.get(socket)?.size ?? 0);
295
+ if (held + claimed >= this.#maxTopicsPerSocket) {
226
296
  throw new SubscriptionLimitError({
227
297
  scope: 'socket',
228
298
  id: socket.id,
@@ -263,51 +333,81 @@ export class ChannelHub {
263
333
  // fanout reads are one fact, and two call sites for one fact is the drift that makes an index
264
334
  // wrong. The registry owns it because it is the only thing that sees a socket die.
265
335
  this.#sockets.joinTopic(socket, name);
336
+ // The socket closed while this subscribe was parked: its teardown has already walked its
337
+ // topics, so a seat taken now is a bridge pinned with no member and nothing left to release it.
338
+ if (socket.closed) this.unsubscribe(socket, name);
266
339
  }
267
340
 
268
341
  unsubscribe(socket: SyncSocket, name: Topic): void {
269
- if (!socket.topics.has(name)) return;
270
- this.#sockets.leaveTopic(socket, name);
342
+ const suspended = this.#suspended.get(socket)?.delete(name) === true;
343
+ if (!suspended && !socket.topics.has(name)) return;
344
+ if (!suspended) this.#sockets.leaveTopic(socket, name);
271
345
  this.#release(name);
272
346
  }
273
347
 
348
+ /** Every topic this socket holds a seat on — the delivered ones and the suspended ones. */
349
+ topicsOf(socket: SyncSocket): readonly Topic[] {
350
+ return [...socket.topics, ...(this.#suspended.get(socket) ?? [])] as Topic[];
351
+ }
352
+
274
353
  /**
275
354
  * Called when a socket's session changes (login, logout, role change, token refresh).
276
355
  *
277
- * A denial drops the topic; anything else keeps it. A guard is app code and may reach a database,
278
- * so `catch { unsubscribe }` reported a store that timed out as a revoked grant — during one
279
- * outage, every topic on every re-authenticated socket on the node, silently, with the client
280
- * never told to resubscribe. The same split `LiveQueryRegistry.reauthorize` already makes, and
281
- * for the same reason: a failure is not a decision.
356
+ * A denial drops the topic. Anything else is not a decision — a guard is app code and may reach
357
+ * a database, and reading a store that timed out as a revoked grant dropped every topic on every
358
+ * re-authenticated socket during one outage. It is not a pass either: the seat is kept and
359
+ * SUSPENDED, so nothing is delivered on it until a later pass (the next re-auth, or the client's
360
+ * own beat) decides. Returns the topics dropped — denials and nothing else.
282
361
  */
283
362
  async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
284
363
  socket.actor = actor;
285
364
  // A new session re-decides everything, the latched denials included.
286
- this.#latched.delete(socket);
365
+ this.#latched.clear(socket);
287
366
  const dropped: Topic[] = [];
288
- for (const name of [...socket.topics] as Topic[]) {
289
- try {
290
- const target = this.#logs.target(name);
291
- if (target !== undefined) {
292
- await authorizeChannel(target.channel, this.#ctx, actor, name, target.params);
293
- }
294
- } catch (error) {
295
- if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
296
- this.unsubscribe(socket, name);
297
- dropped.push(name);
298
- continue;
299
- }
300
- this.#guardFailures += 1;
301
- logger.warn('channel.guard_failed', {
302
- topic: name,
303
- socketId: socket.id,
304
- error: renderThrowable(error),
305
- });
306
- }
367
+ for (const name of this.topicsOf(socket)) {
368
+ const target = this.#logs.target(name);
369
+ if (target === undefined) continue;
370
+ if ((await this.#decide(socket, name, target, actor)) === 'denied') dropped.push(name);
307
371
  }
308
372
  return dropped;
309
373
  }
310
374
 
375
+ /** One seat, re-decided: dropped on a denial, suspended on a failure, delivered on a pass. */
376
+ async #decide(
377
+ socket: SyncSocket,
378
+ name: Topic,
379
+ target: ChannelTopic,
380
+ actor: Actor | null,
381
+ ): Promise<'allowed' | 'denied' | 'undecided'> {
382
+ try {
383
+ await authorizeChannel(target.channel, this.#ctx, actor, name, target.params);
384
+ } catch (error) {
385
+ if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
386
+ this.unsubscribe(socket, name);
387
+ return 'denied';
388
+ }
389
+ this.#guardFailures += 1;
390
+ logger.warn('channel.guard_failed', {
391
+ topic: name,
392
+ socketId: socket.id,
393
+ error: renderThrowable(error),
394
+ });
395
+ if (socket.topics.has(name)) {
396
+ this.#sockets.leaveTopic(socket, name);
397
+ const suspended = this.#suspended.get(socket) ?? new Set<Topic>();
398
+ this.#suspended.set(socket, suspended.add(name));
399
+ }
400
+ return 'undecided';
401
+ }
402
+ // Still suspended, and still open: a socket that closed meanwhile gave the seat back already.
403
+ if (this.#suspended.get(socket)?.delete(name) === true) {
404
+ this.#sockets.joinTopic(socket, name);
405
+ // What the suspension withheld is unknown to the client, so a records channel is re-read.
406
+ this.#logs.resume(socket, name, undefined, true);
407
+ }
408
+ return 'allowed';
409
+ }
410
+
311
411
  async close(): Promise<void> {
312
412
  // Set BEFORE the table is walked, because the table is not the whole story: a reservation an
313
413
  // in-flight `subscribe` has not opened yet is `sub === null`, so `unsubscribeWhenOpen` does
@@ -208,6 +208,10 @@ export class ChannelBook {
208
208
  event(frame: ChannelEventsFrame): void {
209
209
  const entry = this.#byTopic.get(frame.channel);
210
210
  const presence = readPresence(frame.event);
211
+ // The node delivers an event only to a member it has seated, and for an events-only channel
212
+ // (no rows, so no `records` and no `replay-gap`) the roster is the one answer a subscribe
213
+ // gets. Only from `joining`: a read in flight, a refusal and a dead socket are not undone.
214
+ if (entry?.state === 'joining') this.#set(entry, 'live');
211
215
  for (const holder of entry?.holders ?? []) {
212
216
  if (presence !== null) holder.onPresence?.(presence);
213
217
  else holder.onEvent?.(frame.event);
@@ -230,7 +234,12 @@ export class ChannelBook {
230
234
  const contiguous = entry.contiguous ?? frame.seq - 1;
231
235
  // A duplicate is dropped; a replayed seq filling a hole is new. A numeric hole on its own is
232
236
  // never a gap — a row this socket may not see is skipped for it; only `replay-gap` is.
233
- if (frame.seq <= contiguous || entry.above.has(frame.seq)) return;
237
+ if (frame.seq <= contiguous || entry.above.has(frame.seq)) {
238
+ // Still a frame of THIS epoch on THIS seat — and the whole of the node's answer to a resume
239
+ // that was already current (`ChannelLogs.resume`): nothing to apply, and no longer joining.
240
+ if (entry.state !== 'live') this.#set(entry, 'live');
241
+ return;
242
+ }
234
243
  const store = this.#deps.store;
235
244
  store.batch(() => {
236
245
  for (const [type, rows] of Object.entries(frame.adopt ?? {})) store.adopt(type, rows);
@@ -283,6 +292,8 @@ export class ChannelBook {
283
292
  entry.disarm?.();
284
293
  entry.disarm = null;
285
294
  this.#set(entry, 'catching-up');
295
+ // Names no write (#507 pt 2: owner O-507, default none) — a pending write this read already
296
+ // reflects settles on its own HTTP answer. Where a catch-up names its writes, it goes here.
286
297
  this.#deps.catchUp(entry.catchUp, entry.params).then(
287
298
  () => this.#drain(entry),
288
299
  (error: unknown) => {
@@ -63,7 +63,10 @@ export interface LiveClientOptions {
63
63
  readonly clock?: Clock;
64
64
  /** How a pending reconnect is armed. Defaults to `setTimeout`; tests fire theirs by hand. */
65
65
  readonly scheduler?: Scheduler;
66
- /** How often a live socket re-announces itself, in ms. `0` disables it. Default 15s. */
66
+ /**
67
+ * How often a live socket re-announces itself, in ms, until the node names its own beat on its
68
+ * `hello` (`hello.heartbeatMs`). `0` disables the pass, whatever the node says. Default 10s.
69
+ */
67
70
  readonly heartbeatMs?: number;
68
71
  /** Where a failure nobody awaits is reported. Defaults to `console.error`. */
69
72
  readonly onError?: (error: unknown) => void;
@@ -36,6 +36,10 @@ export interface ClientFrameTarget {
36
36
  now(): number;
37
37
  /** A newer build is live; the app decides when to reload. */
38
38
  setUpdate(buildId: string | null): void;
39
+ /** The node named the beat this socket must keep (`hello.heartbeatMs`). */
40
+ followHeartbeat(intervalMs: number): void;
41
+ /** A refused registration the node said may succeed later: subscribe it again after `afterMs`. */
42
+ retryLater(registration: Registration, afterMs: number): void;
39
43
  /** The node assigned this socket its own delay before closing it. */
40
44
  scheduleReconnect(afterMs: number | null): void;
41
45
  closeSocket(code: number, reason: string): void;
@@ -75,7 +79,8 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
75
79
  registration.cursor = frame.cursor;
76
80
  registration.state = 'live';
77
81
  registration.error = undefined;
78
- target.windows.snapshot(registration, frame.entity ?? null, frame.rows, frame.keys);
82
+ const { entity, rows, keys, writes } = frame;
83
+ target.windows.snapshot(registration, entity ?? null, rows, keys, writes);
79
84
  return;
80
85
  }
81
86
  case 'patch': {
@@ -91,7 +96,8 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
91
96
  registration.cursor = next;
92
97
  }
93
98
  registration.state = 'live';
94
- target.windows.patch(registration, frame.patches);
99
+ // The writes the frame names settle in the window's own batch, as a `records` frame's do.
100
+ target.windows.patch(registration, frame.patches, frame.writes);
95
101
  return;
96
102
  }
97
103
  // A patch names a live registration or nothing: a channel's rows ride `records` frames.
@@ -110,6 +116,9 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
110
116
  registration.state = 'failed';
111
117
  registration.error = refusal;
112
118
  registration.notify();
119
+ // A refusal that states when it may succeed (a rate limit's refill) is not final.
120
+ const after = frame.error.retryAfterSeconds;
121
+ if (after !== undefined) target.retryLater(registration, after * 1000);
113
122
  return;
114
123
  }
115
124
  case 'reconnect': {
@@ -135,8 +144,12 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
135
144
  target.channels.gap(frame);
136
145
  return;
137
146
  case 'hello':
147
+ // The node's reply. It carries one instruction — how often to beat — and a node that names
148
+ // none (an older build) leaves the client on its own default.
149
+ if (frame.heartbeatMs !== undefined) target.followHeartbeat(frame.heartbeatMs);
150
+ return;
138
151
  case 'subscribe':
139
- // Client-authored frames: never received. Ignored rather than thrown, so a future
152
+ // A client-authored frame: never received. Ignored rather than thrown, so a future
140
153
  // bidirectional use of the same kind cannot break an old client.
141
154
  return;
142
155
  }
@@ -5,15 +5,22 @@
5
5
  import type { Scheduler } from './thundering-herd';
6
6
 
7
7
  /**
8
- * The client's own number, and `As of 2026-08-19` the only one. It used to be described as a
9
- * restatement of `realtime.heartbeatMs` in `@ultimat3/core`'s config — that key was read by
10
- * nothing and is deleted, so there is no second value to keep this equal to. The server side of
11
- * the beat is DERIVED, never configured: `PresenceRegistry.heartbeatMs` is `ttlMs / 3`.
8
+ * A third of the node's default presence ttl (`DEFAULT_PRESENCE_TTL_MS`, 30 s), which is what the
9
+ * node derives for itself (`PresenceRegistry.heartbeatMs` is `ttlMs / 3`): two beats land inside
10
+ * every ttl, so ONE lost beat is never a false leave. It was 15 s — half the ttl, where the beat
11
+ * after a lost one lands on the expiry itself. `client-heartbeat.test.ts` holds the two equal;
12
+ * this file cannot import the server's constant (`transport-env.ts` links `nats`).
12
13
  */
13
- export const DEFAULT_HEARTBEAT_MS = 15_000;
14
+ export const DEFAULT_HEARTBEAT_MS = 10_000;
15
+
16
+ /** The node floors the beat it names at 1 s (`clientHeartbeatMs`); less than that is not its word. */
17
+ export const MIN_HEARTBEAT_MS = 1_000;
14
18
 
15
19
  export interface HeartbeatOptions {
16
- /** `0` (or less) disables the pass entirely — the shape a test that owns the clock wants. */
20
+ /**
21
+ * Until the node names its own (`follow`). `0` (or less) disables the pass entirely, whatever
22
+ * the node says — the shape a test that owns the clock wants.
23
+ */
17
24
  readonly intervalMs: number;
18
25
  readonly schedule: Scheduler;
19
26
  readonly now: () => number;
@@ -32,19 +39,42 @@ export class Heartbeat {
32
39
  readonly #options: HeartbeatOptions;
33
40
  #cancel: (() => void) | null = null;
34
41
  #lastSeen = 0;
42
+ /** The beat in force on THIS socket: the option's, until the node's `hello` names another. */
43
+ #intervalMs: number;
35
44
 
36
45
  constructor(options: HeartbeatOptions) {
37
46
  this.#options = options;
47
+ this.#intervalMs = options.intervalMs;
48
+ }
49
+
50
+ get intervalMs(): number {
51
+ return this.#intervalMs;
38
52
  }
39
53
 
40
54
  /** The socket is up. `now` seeds the silence window, so the first tick judges this connection. */
41
55
  start(now: number): void {
42
56
  this.stop();
43
- if (this.#options.intervalMs <= 0) return;
57
+ // A new socket may be another node's: its beat is not known until its `hello` says so.
58
+ this.#intervalMs = this.#options.intervalMs;
59
+ if (this.#intervalMs <= 0) return;
44
60
  this.#lastSeen = now;
45
61
  this.#arm();
46
62
  }
47
63
 
64
+ /**
65
+ * The node named the beat (`hello.heartbeatMs`): a third of ITS presence ttl, bounded by ITS idle
66
+ * budget — two numbers a browser cannot read, and the reason a fixed client beat was wrong for
67
+ * every app that set either. Re-armed at once, so a shorter beat does not wait out the long one.
68
+ */
69
+ follow(intervalMs: number): void {
70
+ if (this.#options.intervalMs <= 0) return;
71
+ if (intervalMs < MIN_HEARTBEAT_MS || intervalMs === this.#intervalMs) return;
72
+ this.#intervalMs = intervalMs;
73
+ if (this.#cancel === null) return;
74
+ this.stop();
75
+ this.#arm();
76
+ }
77
+
48
78
  /** A frame arrived. Anything counts: the point is that bytes still cross in this direction. */
49
79
  saw(now: number): void {
50
80
  this.#lastSeen = now;
@@ -60,7 +90,7 @@ export class Heartbeat {
60
90
  this.#cancel = this.#options.schedule(() => {
61
91
  this.#cancel = null;
62
92
  this.#tick();
63
- }, this.#options.intervalMs);
93
+ }, this.#intervalMs);
64
94
  }
65
95
 
66
96
  #tick(): void {
@@ -68,7 +98,7 @@ export class Heartbeat {
68
98
  // Two windows, not one: a beat and the answer to it share the window they were sent in, so a
69
99
  // single quiet interval is a slow round trip and not a dead socket. Nothing is re-armed after
70
100
  // a silence — `onSilence` drops the socket, and the next `start()` is the next connection's.
71
- if (now - this.#lastSeen > this.#options.intervalMs * 2) {
101
+ if (now - this.#lastSeen > this.#intervalMs * 2) {
72
102
  this.#options.onSilence();
73
103
  return;
74
104
  }
package/src/client.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // signal belongs to one bundle's solid-js, so everything here is a plain read plus a listener.
5
5
  // Read-only: the socket carries no writes (`useMutation` is HTTP).
6
6
 
7
- import { type Clock, finiteOption, systemClock, uuid } from '@ultimat3/core/page';
7
+ import { type Clock, finiteOption, systemClock, uuidV7 } from '@ultimat3/core/page';
8
8
  import {
9
9
  ChannelBook,
10
10
  type ChannelHandlers,
@@ -49,6 +49,8 @@ export class LiveClient {
49
49
  readonly #clock: Clock;
50
50
  readonly #onError: (error: unknown) => void;
51
51
  readonly #registrations = new Map<string, Registration>();
52
+ /** A refused registration's armed re-subscribe, by sid — the node said when it may succeed. */
53
+ readonly #retries = new Map<string, () => void>();
52
54
  readonly #windows: RowWindows;
53
55
  readonly #channels: ChannelBook;
54
56
  readonly #heartbeat: Heartbeat;
@@ -194,6 +196,9 @@ export class LiveClient {
194
196
  */
195
197
  #offline(): void {
196
198
  this.#heartbeat.stop();
199
+ // The reopen replays every registration, so a timer left armed would send one twice.
200
+ for (const cancel of this.#retries.values()) cancel();
201
+ this.#retries.clear();
197
202
  this.#setStatus({ connected: false });
198
203
  // Told once, not two ways: a `useConnection().offline` that flips while a live window still
199
204
  // reads 'live' is one dead socket rendered as two states. A refused one stays refused.
@@ -230,7 +235,7 @@ export class LiveClient {
230
235
  query: LiveQueryRef,
231
236
  input: JsonValue,
232
237
  ): LiveHandle<R> {
233
- const sid = uuid();
238
+ const sid = uuidV7();
234
239
  const listeners = new Set<() => void>();
235
240
  const registration: Registration = {
236
241
  sid,
@@ -254,6 +259,8 @@ export class LiveClient {
254
259
  if (!open) return;
255
260
  open = false;
256
261
  this.#registrations.delete(sid);
262
+ this.#retries.get(sid)?.();
263
+ this.#retries.delete(sid);
257
264
  close();
258
265
  listeners.clear();
259
266
  this.#send({
@@ -321,12 +328,33 @@ export class LiveClient {
321
328
  // against a re-snapshot, so the frame path reads the same clock every other path does.
322
329
  now: () => this.#clock.now().getTime(),
323
330
  setUpdate: (buildId) => this.#setStatus({ update: buildId }),
331
+ followHeartbeat: (intervalMs) => this.#heartbeat.follow(intervalMs),
324
332
  scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
333
+ retryLater: (registration, afterMs) => this.#retryLater(registration, afterMs),
325
334
  closeSocket: (code, reason) => this.#socket?.close(code, reason),
326
335
  report: (error) => this.#onError(error),
327
336
  };
328
337
  }
329
338
 
339
+ /**
340
+ * Ask again once the node's stated delay has passed — a rate-limited subscribe, whose bucket
341
+ * refills. Only while the registration is still held, still refused and the socket still up:
342
+ * anything else and the reopen's replay, or the unsubscribe, already decided its fate.
343
+ */
344
+ #retryLater(registration: Registration, afterMs: number): void {
345
+ this.#retries.get(registration.sid)?.();
346
+ const cancel = (this.#options.scheduler ?? timeoutScheduler)(() => {
347
+ this.#retries.delete(registration.sid);
348
+ if (this.#registrations.get(registration.sid) !== registration) return;
349
+ if (!this.#connected || registration.state !== 'failed') return;
350
+ registration.state = 'loading';
351
+ registration.error = undefined;
352
+ registration.notify();
353
+ this.#sendSubscribe(registration);
354
+ }, afterMs);
355
+ this.#retries.set(registration.sid, cancel);
356
+ }
357
+
330
358
  /** The opening frame, and the heartbeat's. One shape, because it makes one claim: I am here. */
331
359
  #hello(): Frame {
332
360
  return {
package/src/cursor.ts CHANGED
@@ -76,6 +76,17 @@ export interface ResumeSource {
76
76
  * the entry, which is the only moment anything knows the window is unreachable.
77
77
  */
78
78
  forget?(qid: string): void;
79
+ /**
80
+ * The window behind `qid` was read at `lsn`, so what is retained for it is complete from there
81
+ * and from nowhere earlier; `exclusive` refuses a cursor exactly at it (a forced re-read), `sole`
82
+ * answers that cursor and no other below the first patch (a read with no position behind it).
83
+ * Optional for `forget`'s reason. When it is called, and why, is one rule: `change-buffer.ts`.
84
+ */
85
+ floorAt?(
86
+ qid: string,
87
+ lsn: string,
88
+ options?: { readonly exclusive?: boolean; readonly sole?: boolean },
89
+ ): void;
79
90
  }
80
91
 
81
92
  export type ResumeResult<R extends Row = Row> =