@ultimat3/realtime 1.2.0 → 3.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 (61) hide show
  1. package/CLAUDE.md +641 -0
  2. package/README.md +336 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +202 -20
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +99 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +187 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +225 -34
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +324 -248
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. package/src/pg-replication-fixture.ts +0 -261
@@ -1,50 +1,109 @@
1
- // The bounded per-query change window that makes reconnect a delta instead of a refetch.
2
- // Lives on the `replicator` (one per DB), so a reconnecting client costs zero DB work while its
3
- // gap is inside the window. Outside it, `resumeFrom` takes one snapshot — never WAL traversal.
1
+ // The bounded per-query change window that makes reconnect a delta instead of a refetch. Inside
2
+ // the window a reconnecting client costs zero DB work; outside it, `resumeFrom` takes one bounded
3
+ // snapshot — never WAL traversal.
4
+ //
5
+ // **It is per `sync` node, and a `qid` window can only be.** The header used to say it lives on the
6
+ // replicator; it does not, and it could not — a patch is query-scoped, so producing one needs that
7
+ // query's compiled shape, its matcher and its current window, none of which the replicator has (it
8
+ // is entity-scoped by construction). The consequence is real and is not fixed here: a client that
9
+ // reconnects onto a node that never served its `qid` finds no ring, `shouldResnapshot` answers
10
+ // `out-of-window`, and it takes the snapshot path. What that costs is one *shared* read per
11
+ // (query, node) — `fillWindow` joins every subscriber arriving during a read into it — and not one
12
+ // read per client. Making the delta path work across nodes means an **entity**-keyed window every
13
+ // node fills from the change stream it already subscribes to, which is a `ResumeSource` shape
14
+ // change, not a placement change.
4
15
 
5
16
  import type { ResumeSource } from './cursor';
6
17
  import type { RowPatch } from './json';
7
18
 
8
19
  export interface ChangeBufferOptions {
9
- /** Retained patches per query hash. */
20
+ /** Retained patches per query hash — a REPLAY bound: what a delta resume may cost to fold. */
10
21
  readonly capacity?: number;
11
22
  /** Retained query hashes; the least-recently-written is dropped first. */
12
23
  readonly maxQueries?: number;
24
+ /** Retained bytes per query hash. The memory bound, and the one that actually holds. */
25
+ readonly maxBytesPerQuery?: number;
26
+ /** Retained bytes across every query on this node. */
27
+ readonly maxBytes?: number;
13
28
  }
14
29
 
30
+ /**
31
+ * The node's retained-patch memory ceiling. `packages/cache/src/lru.ts:1-2` states the rule this
32
+ * exists to obey: bounded by BYTES, never by entry count — 4,096 queries x 1,024 patches is 4.19M
33
+ * retained `RowPatch` objects, each holding a whole row, and nothing in that product is memory.
34
+ */
35
+ export const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
36
+ export const DEFAULT_MAX_BUFFER_BYTES_PER_QUERY = 1024 * 1024;
37
+
15
38
  interface Ring {
16
39
  patches: RowPatch[];
40
+ bytes: number;
17
41
  /** Highest lsn already dropped. A cursor at or after this is still resumable. */
18
42
  evictedThrough: string | null;
19
43
  }
20
44
 
45
+ const encoder = new TextEncoder();
46
+
47
+ /** What one retained patch costs. Its serialised size: the row is the whole of it. */
48
+ function patchBytes(patch: RowPatch): number {
49
+ return encoder.encode(JSON.stringify(patch)).length;
50
+ }
51
+
21
52
  export class RingChangeBuffer implements ResumeSource {
22
53
  readonly #rings = new Map<string, Ring>();
23
54
  readonly #capacity: number;
24
55
  readonly #maxQueries: number;
56
+ readonly #maxBytesPerQuery: number;
57
+ readonly #maxBytes: number;
58
+ #bytes = 0;
25
59
 
26
60
  constructor(options: ChangeBufferOptions = {}) {
27
61
  this.#capacity = options.capacity ?? 1024;
28
62
  this.#maxQueries = options.maxQueries ?? 4096;
63
+ this.#maxBytesPerQuery = options.maxBytesPerQuery ?? DEFAULT_MAX_BUFFER_BYTES_PER_QUERY;
64
+ this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BUFFER_BYTES;
65
+ }
66
+
67
+ /** Retained bytes across every query on this node. The number the ceiling is about. */
68
+ get bytes(): number {
69
+ return this.#bytes;
29
70
  }
30
71
 
31
72
  append(qid: string, patch: RowPatch): void {
32
73
  const existing = this.#rings.get(qid);
33
- const ring: Ring = existing ?? { patches: [], evictedThrough: null };
74
+ const ring: Ring = existing ?? { patches: [], bytes: 0, evictedThrough: null };
34
75
  ring.patches.push(patch);
35
- while (ring.patches.length > this.#capacity) {
36
- const dropped = ring.patches.shift();
37
- if (dropped) ring.evictedThrough = dropped.lsn;
76
+ const cost = patchBytes(patch);
77
+ ring.bytes += cost;
78
+ this.#bytes += cost;
79
+ // Two ceilings, because they bound two different things: the count bounds what a resume has
80
+ // to fold, the bytes bound what this process holds. Whichever bites first, bites.
81
+ while (ring.patches.length > this.#capacity || ring.bytes > this.#maxBytesPerQuery) {
82
+ if (!this.#shift(ring)) break;
38
83
  }
39
84
  // Re-insert to move this qid to the tail of the LRU order.
40
85
  if (existing) this.#rings.delete(qid);
41
86
  this.#rings.set(qid, ring);
42
- if (this.#rings.size > this.#maxQueries) {
87
+ while (this.#rings.size > this.#maxQueries || this.#bytes > this.#maxBytes) {
43
88
  const oldest = this.#rings.keys().next();
44
- if (!oldest.done) this.#rings.delete(oldest.value);
89
+ // The only ring left is the one just written: evicting it would make a node under memory
90
+ // pressure retain nothing at all, and every reconnect a snapshot.
91
+ if (oldest.done || this.#rings.size === 1) break;
92
+ this.forget(oldest.value);
45
93
  }
46
94
  }
47
95
 
96
+ /** Drop the oldest patch of a ring, keeping both byte counters honest. Answers what it did. */
97
+ #shift(ring: Ring): boolean {
98
+ const dropped = ring.patches.shift();
99
+ if (!dropped) return false;
100
+ const cost = patchBytes(dropped);
101
+ ring.bytes -= cost;
102
+ this.#bytes -= cost;
103
+ ring.evictedThrough = dropped.lsn;
104
+ return true;
105
+ }
106
+
48
107
  since(qid: string, lsn: string): RowPatch[] | null {
49
108
  const ring = this.#rings.get(qid);
50
109
  if (!ring) return null;
@@ -58,8 +117,15 @@ export class RingChangeBuffer implements ResumeSource {
58
117
  return last ? last.lsn : null;
59
118
  }
60
119
 
61
- /** Called when the last subscriber of a query goes away, so an idle query stops costing memory. */
120
+ /**
121
+ * Called when the last subscriber of a query goes away, so an idle query stops costing memory.
122
+ * It had no caller until `LiveQueryRegistry.unsubscribe` gained one: the entry was dropped and
123
+ * the ring behind it kept every patch it held until the LRU happened to reach it.
124
+ */
62
125
  forget(qid: string): void {
126
+ const ring = this.#rings.get(qid);
127
+ if (!ring) return;
128
+ this.#bytes -= ring.bytes;
63
129
  this.#rings.delete(qid);
64
130
  }
65
131
 
package/src/channel.ts CHANGED
@@ -4,9 +4,14 @@
4
4
  // append-only stream, so tier 1 needs no frame of its own. That is why climbing the ladder is a
5
5
  // config change: the client's frame handler is the same code at every rung.
6
6
 
7
- import type { Actor } from '@ultimat3/core';
7
+ import { type Actor, logger, renderThrowable } from '@ultimat3/core';
8
8
  import { formatLsn } from './changefeed';
9
- import { SubscriptionLimitError, TopicForbiddenError } from './errors';
9
+ import {
10
+ isPolicyDenial,
11
+ SubscriptionLimitError,
12
+ TopicForbiddenError,
13
+ TransportUnavailableError,
14
+ } from './errors';
10
15
  import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
11
16
  import type { JsonObject } from './json';
12
17
  import type { SocketRegistry, SyncSocket } from './socket';
@@ -46,6 +51,30 @@ export interface ChannelHubOptions {
46
51
  readonly transport: Transport;
47
52
  readonly sockets: SocketRegistry;
48
53
  readonly maxTopicsPerSocket?: number;
54
+ /**
55
+ * Distinct topics this node will bridge at once. Each one is a live transport subscription, and
56
+ * `topic()` admits any `[A-Za-z0-9_-]+` segment — so even a guard as tight as `org.<myorg>.>`
57
+ * admits unbounded distinct names inside one tenant, and a per-socket cap bounds nothing.
58
+ */
59
+ readonly maxTopicsPerNode?: number;
60
+ }
61
+
62
+ /** Distinct topics one node bridges before `X_SUBSCRIPTION_LIMIT`. */
63
+ export const DEFAULT_MAX_TOPICS_PER_NODE = 10_000;
64
+
65
+ /**
66
+ * One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
67
+ * into the table before it is awaited: looked up before the await and written after it, two sockets
68
+ * reaching one topic at once opened two transport subscriptions — the second replacing the first in
69
+ * the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
70
+ * anything else, delivering every message on that topic a second time for the life of the process.
71
+ *
72
+ * `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
73
+ * runs, so the reservation has to exist before there is anything to reserve it with.
74
+ */
75
+ interface Bridge {
76
+ sub: Promise<TransportSubscription> | null;
77
+ refs: number;
49
78
  }
50
79
 
51
80
  /**
@@ -56,14 +85,43 @@ export class ChannelHub {
56
85
  readonly #transport: Transport;
57
86
  readonly #sockets: SocketRegistry;
58
87
  readonly #guards: Array<{ pattern: string; guard: TopicGuard }> = [];
59
- readonly #bridges = new Map<string, { sub: TransportSubscription; refs: number }>();
88
+ readonly #bridges = new Map<string, Bridge>();
89
+ /**
90
+ * Topics this socket has asked for and not yet joined. Weakly keyed, so it needs no teardown
91
+ * path of its own: a socket that dies mid-subscribe takes its claims with it.
92
+ */
93
+ readonly #claimed = new WeakMap<SyncSocket, number>();
60
94
  readonly #maxTopicsPerSocket: number;
95
+ readonly #maxTopicsPerNode: number;
96
+ #guardFailures = 0;
61
97
  #sequence = 0n;
98
+ /** Set by `close()`. Read by `#open`, which is the only thing that can reach a late subscription. */
99
+ #closed = false;
62
100
 
63
101
  constructor(options: ChannelHubOptions) {
64
102
  this.#transport = options.transport;
65
103
  this.#sockets = options.sockets;
66
104
  this.#maxTopicsPerSocket = options.maxTopicsPerSocket ?? 64;
105
+ this.#maxTopicsPerNode = options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE;
106
+ }
107
+
108
+ /** Sockets this node will deliver `name` to. The metric the fanout reads. */
109
+ subscriberCount(name: Topic): number {
110
+ return this.#sockets.subscriberCount(name);
111
+ }
112
+
113
+ /** Distinct topics bridged from this node — one live transport subscription each. */
114
+ get topicCount(): number {
115
+ return this.#bridges.size;
116
+ }
117
+
118
+ /**
119
+ * `channel.guard_failed` for this node: guards that raised instead of deciding, during a re-auth.
120
+ * Never a denial — the same split `LiveQueryRegistry.reauthorize` makes one layer up, and an
121
+ * alert fires on one of them.
122
+ */
123
+ get guardFailures(): number {
124
+ return this.#guardFailures;
67
125
  }
68
126
 
69
127
  /** `pattern` uses NATS wildcards: `org.*.cursors`, `org.>`. First registered match wins. */
@@ -72,36 +130,90 @@ export class ChannelHub {
72
130
  return this;
73
131
  }
74
132
 
133
+ /**
134
+ * Both caps and the node's bridge slot are taken SYNCHRONOUSLY, before the guard is awaited: read
135
+ * at the top and acted on after two awaits, one WebSocket write carrying N subscribe frames
136
+ * passed each of them N times, and `maxTopicsPerSocket`/`maxTopicsPerNode` bounded nothing.
137
+ */
75
138
  async subscribe(socket: SyncSocket, name: Topic): Promise<void> {
76
139
  if (socket.topics.has(name)) return;
77
- if (socket.topics.size >= this.#maxTopicsPerSocket) {
140
+ const claimed = this.#claimed.get(socket) ?? 0;
141
+ if (socket.topics.size + claimed >= this.#maxTopicsPerSocket) {
78
142
  throw new SubscriptionLimitError({
79
143
  scope: 'socket',
80
144
  id: socket.id,
81
145
  limit: this.#maxTopicsPerSocket,
146
+ // Named, never defaulted: the default for this scope is `maxPerSocket`, which is
147
+ // `LiveQueryRegistry`'s cap on live subscriptions — a different ceiling in a different
148
+ // constructor, so an operator following this fix line would have moved the wrong number.
149
+ knob: 'maxTopicsPerSocket',
82
150
  });
83
151
  }
84
- await this.#authorize(socket.actor, name);
85
- await this.#bridge(name);
86
- socket.subscribeTopic(name);
152
+ // Refused before the guard runs and before a transport subscription is opened: a node that is
153
+ // out of topics has nothing to decide, and the answer must not depend on who asked.
154
+ const bridge = this.#reserve(name);
155
+ this.#claimed.set(socket, claimed + 1);
156
+ try {
157
+ await this.#authorize(socket.actor, name);
158
+ await this.#open(name, bridge);
159
+ } catch (error) {
160
+ // The slot this subscribe took, given back on the one path that will never fill it — and
161
+ // given back to the bridge this subscribe actually reserved. `close()` clears the table, so
162
+ // a later subscribe may have put a DIFFERENT bridge under this name in the meantime, and
163
+ // decrementing that one's refs releases a topic somebody else is holding.
164
+ this.#release(name, bridge);
165
+ throw error;
166
+ } finally {
167
+ const held = this.#claimed.get(socket) ?? 1;
168
+ if (held <= 1) this.#claimed.delete(socket);
169
+ else this.#claimed.set(socket, held - 1);
170
+ }
171
+ // A concurrent subscribe for this same socket and topic got there first: it holds the one
172
+ // membership this socket's close will give back, so the reference taken above has to go now or
173
+ // it is a bridge nothing will ever release.
174
+ if (socket.topics.has(name)) {
175
+ this.#release(name, bridge);
176
+ return;
177
+ }
178
+ // Through the registry, never `socket.subscribeTopic` directly: membership and the index the
179
+ // fanout reads are one fact, and two call sites for one fact is the drift that makes an index
180
+ // wrong. The registry owns it because it is the only thing that sees a socket die.
181
+ this.#sockets.joinTopic(socket, name);
87
182
  }
88
183
 
89
184
  unsubscribe(socket: SyncSocket, name: Topic): void {
90
185
  if (!socket.topics.has(name)) return;
91
- socket.unsubscribeTopic(name);
186
+ this.#sockets.leaveTopic(socket, name);
92
187
  this.#release(name);
93
188
  }
94
189
 
95
- /** Called when a socket's session changes (login, logout, role change, token refresh). */
190
+ /**
191
+ * Called when a socket's session changes (login, logout, role change, token refresh).
192
+ *
193
+ * A denial drops the topic; anything else keeps it. A guard is app code and may reach a database,
194
+ * so `catch { unsubscribe }` reported a store that timed out as a revoked grant — during one
195
+ * outage, every topic on every re-authenticated socket on the node, silently, with the client
196
+ * never told to resubscribe. The same split `LiveQueryRegistry.reauthorize` already makes, and
197
+ * for the same reason: a failure is not a decision.
198
+ */
96
199
  async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
97
200
  socket.actor = actor;
98
201
  const dropped: Topic[] = [];
99
202
  for (const name of [...socket.topics] as Topic[]) {
100
203
  try {
101
204
  await this.#authorize(actor, name);
102
- } catch {
103
- this.unsubscribe(socket, name);
104
- dropped.push(name);
205
+ } catch (error) {
206
+ if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
207
+ this.unsubscribe(socket, name);
208
+ dropped.push(name);
209
+ continue;
210
+ }
211
+ this.#guardFailures += 1;
212
+ logger.warn('channel.guard_failed', {
213
+ topic: name,
214
+ socketId: socket.id,
215
+ error: renderThrowable(error),
216
+ });
105
217
  }
106
218
  }
107
219
  return dropped;
@@ -120,7 +232,14 @@ export class ChannelHub {
120
232
  }
121
233
 
122
234
  async close(): Promise<void> {
123
- for (const bridge of this.#bridges.values()) bridge.sub.unsubscribe();
235
+ // Set BEFORE the table is walked, because the table is not the whole story: a reservation an
236
+ // in-flight `subscribe` has not opened yet is `sub === null`, so `unsubscribeWhenOpen` does
237
+ // nothing to it and `clear()` drops the entry. That open then lands on a `Bridge` nothing can
238
+ // name — `#release` looks the topic up, misses and returns — and its handler keeps calling
239
+ // `deliver` for the life of the process. The same orphan the `Bridge` comment describes, one
240
+ // state earlier, so the open that creates the subscription has to be the thing that closes it.
241
+ this.#closed = true;
242
+ for (const bridge of this.#bridges.values()) unsubscribeWhenOpen(bridge);
124
243
  this.#bridges.clear();
125
244
  }
126
245
 
@@ -147,29 +266,92 @@ export class ChannelHub {
147
266
  }
148
267
  }
149
268
 
150
- /** One transport subscription per topic per node, refcounted across sockets. */
151
- async #bridge(name: Topic): Promise<void> {
269
+ /**
270
+ * The node's slot for this topic, taken synchronously. One bridge per topic per node, refcounted
271
+ * across sockets — and the refcount includes the subscribes still deciding, so the count the node
272
+ * cap reads is the count that will exist.
273
+ */
274
+ #reserve(name: Topic): Bridge {
152
275
  const existing = this.#bridges.get(name);
153
276
  if (existing) {
154
277
  existing.refs += 1;
155
- return;
278
+ return existing;
156
279
  }
157
- const sub = await this.#transport.subscribe(`${CHANNEL_SUBJECT_PREFIX}.${name}`, (payload) => {
280
+ if (this.#bridges.size >= this.#maxTopicsPerNode) {
281
+ throw new SubscriptionLimitError({
282
+ scope: 'node',
283
+ id: 'topics',
284
+ limit: this.#maxTopicsPerNode,
285
+ knob: 'maxTopicsPerNode',
286
+ });
287
+ }
288
+ const created: Bridge = { sub: null, refs: 1 };
289
+ this.#bridges.set(name, created);
290
+ return created;
291
+ }
292
+
293
+ /** Opens the reserved bridge once, and shares the in-flight open with everyone else waiting. */
294
+ async #open(name: Topic, bridge: Bridge): Promise<void> {
295
+ // Published into the bridge before it is awaited: that is what makes a second subscriber join
296
+ // this open instead of starting a second one the table can never reach again.
297
+ bridge.sub ??= this.#transport.subscribe(`${CHANNEL_SUBJECT_PREFIX}.${name}`, (payload) => {
158
298
  this.#sockets.deliver(name, decode(payload));
159
299
  });
160
- this.#bridges.set(name, { sub, refs: 1 });
300
+ await bridge.sub;
301
+ // The hub shut down while the transport was answering. `close()` either never saw this bridge
302
+ // or saw it with nothing to close, so this is the last reference to the subscription: it closes
303
+ // here or never. The entry goes with it, so a second post-close subscribe opens and closes its
304
+ // own rather than double-unsubscribing this one's handle.
305
+ if (this.#closed) {
306
+ unsubscribeWhenOpen(bridge);
307
+ if (this.#bridges.get(name) === bridge) this.#bridges.delete(name);
308
+ // RAISED, not returned. Returning let `subscribe` fall through to `joinTopic`, so the socket
309
+ // became a member of a topic nothing on this node is bridged to: silent for the life of the
310
+ // connection, with no error on either side and nothing telling the client to redial. The
311
+ // same refusal the transport itself answers when it is gone, because from the client's side
312
+ // that is what happened — this node's bus for that topic is closed.
313
+ throw new TransportUnavailableError({
314
+ transport: 'channel',
315
+ reason: `the hub closed while "${name}" was opening`,
316
+ // The reader is a browser websocket client, which cannot run a CLI — so pure command
317
+ // advice would be worse than prose here. The shape that satisfies axiom 4 anyway is
318
+ // `http/src/error-map.ts`'s: a command that SHIPS (`x errors explain`, unlike the planned
319
+ // `x logs tail`) for whoever is holding a terminal, then the instruction as a comment.
320
+ fix: 'x errors explain X_TRANSPORT_UNAVAILABLE --json # then reconnect and resubscribe: this node is draining',
321
+ });
322
+ }
161
323
  }
162
324
 
163
- #release(name: Topic): void {
325
+ /**
326
+ * `expected` is the bridge the caller reserved. Without it a release looks the topic up by name,
327
+ * and after a `close()` cleared the table that name may hold a bridge a LATER subscribe opened.
328
+ */
329
+ #release(name: Topic, expected?: Bridge): void {
164
330
  const bridge = this.#bridges.get(name);
165
331
  if (!bridge) return;
332
+ if (expected !== undefined && bridge !== expected) return;
166
333
  bridge.refs -= 1;
167
334
  if (bridge.refs > 0) return;
168
- bridge.sub.unsubscribe();
335
+ unsubscribeWhenOpen(bridge);
169
336
  this.#bridges.delete(name);
170
337
  }
171
338
  }
172
339
 
340
+ /**
341
+ * A bridge released while its subscription is still opening still has to be closed — the transport
342
+ * hands the handle back after the caller has gone, and dropping the promise would leave a live
343
+ * subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
344
+ * rejection was already answered to the subscriber that caused it.
345
+ */
346
+ function unsubscribeWhenOpen(bridge: Bridge): void {
347
+ void bridge.sub?.then(
348
+ (sub) => {
349
+ sub.unsubscribe();
350
+ },
351
+ () => undefined,
352
+ );
353
+ }
354
+
173
355
  export function channelFrame(name: Topic, lsn: string, message: JsonObject): Frame {
174
356
  return {
175
357
  type: 'patch',
@@ -0,0 +1,81 @@
1
+ // What a client IS, as types: the injected seams, the options, and the handles a subscription
2
+ // gives back. Declared apart from the client that implements them for the same reason
3
+ // `live-contract.ts` is — the hooks, the typed projection, the type pins and the mutation path all
4
+ // need these shapes, and none of them needs the connection lifecycle that runs underneath.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import type { LiveCursor } from './cursor';
8
+ import type { JsonValue, Row } from './json';
9
+ import type { LiveState } from './live-rows';
10
+ import type { LocalStore, LocalTx, TableMap } from './local-store';
11
+ import type { OfflineQueue } from './offline-queue';
12
+ import type { ConflictStrategy, RebaseLog } from './rebase';
13
+ import type { BackoffPolicy, Rng, Scheduler } from './thundering-herd';
14
+
15
+ /** Injected reactive primitive. `createSignal` from Solid satisfies this exactly. */
16
+ export type SignalFactory = <T>(initial: T) => [get: () => T, set: (next: T) => void];
17
+
18
+ /** Injected socket, so tests drive the protocol without a network. */
19
+ export interface ClientSocket {
20
+ send(data: string): void;
21
+ close(code?: number, reason?: string): void;
22
+ onOpen(handler: () => void): void;
23
+ onMessage(handler: (data: string) => void): void;
24
+ onClose(handler: (code: number) => void): void;
25
+ /**
26
+ * Bytes queued but not yet on the wire — `WebSocket.bufferedAmount`. Optional because a socket
27
+ * that cannot answer is treated as never backed up; supplying it is what lets the mutation drain
28
+ * stop instead of pushing a queue the tab is not draining into one it cannot see.
29
+ */
30
+ readonly bufferedAmount?: number;
31
+ }
32
+
33
+ export interface LiveHandle<R extends Row = Row> extends Disposable {
34
+ /** The reactive accessor. In an app this is the Solid signal `useLive` returns. */
35
+ readonly rows: () => readonly R[];
36
+ readonly state: () => LiveState;
37
+ readonly cursor: () => LiveCursor | null;
38
+ unsubscribe(): void;
39
+ /** The same call as `unsubscribe`, so `using sub = client.useLive(...)` just works. */
40
+ [Symbol.dispose](): void;
41
+ }
42
+
43
+ /** What `subscribe()` returns for a tier-1 topic: callable to unsubscribe, and `using`-able too. */
44
+ export type Unsubscribe = (() => void) & Disposable;
45
+
46
+ export interface LiveQueryRef {
47
+ readonly name: string;
48
+ }
49
+
50
+ export interface MutatorRef<T extends TableMap = TableMap> {
51
+ readonly name: string;
52
+ /** Optimistic twin. Pure — no I/O, no Date.now(), no Math.random(). */
53
+ local?: (tx: LocalTx<T>, input: JsonValue) => void;
54
+ readonly entity?: string;
55
+ readonly conflict?: ConflictStrategy;
56
+ }
57
+
58
+ export interface LiveClientOptions<T extends TableMap = TableMap> {
59
+ readonly signal: SignalFactory;
60
+ /** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
61
+ readonly connect: () => ClientSocket;
62
+ readonly buildId: string;
63
+ readonly actorId?: string | null;
64
+ /** Tier 3 only. Without these, mutations are server-only and nothing is queued offline. */
65
+ readonly store?: LocalStore<T>;
66
+ readonly queue?: OfflineQueue;
67
+ readonly log?: RebaseLog<T>;
68
+ readonly backoff?: BackoffPolicy;
69
+ readonly rng?: Rng;
70
+ readonly clock?: Clock;
71
+ /** How a pending reconnect is armed. Defaults to `setTimeout`; tests fire theirs by hand. */
72
+ readonly scheduler?: Scheduler;
73
+ /**
74
+ * How often a live socket re-announces itself, in ms. `0` disables it. Defaults to
75
+ * `DEFAULT_HEARTBEAT_MS` — the same 15s as the server's `realtime.heartbeatMs`, which browser
76
+ * code cannot read, so it is an option here rather than a value shipped down the wire.
77
+ */
78
+ readonly heartbeatMs?: number;
79
+ /** Where a dial failure inside the reconnect timer is reported. Defaults to `reportToConsole`. */
80
+ readonly onError?: (error: unknown) => void;
81
+ }