@ultimat3/realtime 1.2.0 → 2.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 +591 -0
  2. package/README.md +320 -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 +174 -19
  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 +96 -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 +151 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +170 -14
  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 +284 -243
  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
package/src/cursor.ts CHANGED
@@ -7,7 +7,7 @@ import { type Clock, systemClock } from '@ultimat3/core';
7
7
  import { CursorStaleError } from './errors';
8
8
  import { canonicalJson, fnv1a, type Row, type RowPatch } from './json';
9
9
 
10
- /** Ids are bounded so a cursor stays small enough to ship in a `hello` frame. */
10
+ /** Ids are bounded so a cursor stays small enough to ship on every `subscribe` frame. */
11
11
  export const CURSOR_ID_LIMIT = 512;
12
12
 
13
13
  /** A digest of `''` means "not verified at this lsn" — set on every delta resume. */
@@ -69,6 +69,12 @@ export interface ResumeSource {
69
69
  /** Patches strictly after `lsn`, or `null` when the gap is not covered by the retained window. */
70
70
  since(qid: string, lsn: string): RowPatch[] | null;
71
71
  headLsn(qid: string): string | null;
72
+ /**
73
+ * The last subscriber of this query went away, so nothing will ever resume from its retained
74
+ * patches. Optional because a source may retain nothing; the registry calls it when it drops
75
+ * the entry, which is the only moment anything knows the window is unreachable.
76
+ */
77
+ forget?(qid: string): void;
72
78
  }
73
79
 
74
80
  export type ResumeResult<R extends Row = Row> =
package/src/errors.ts CHANGED
@@ -7,6 +7,8 @@ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
7
7
  export const REALTIME_OWNED_ERROR_CODES = [
8
8
  'X_TOPIC_FORBIDDEN',
9
9
  'X_SUBSCRIPTION_LIMIT',
10
+ 'X_SUBSCRIPTION_ID_TAKEN',
11
+ 'X_FRAME_RATE_LIMIT',
10
12
  'X_PROTOCOL_VERSION',
11
13
  'X_CURSOR_STALE',
12
14
  'X_REBASE_CONFLICT',
@@ -17,6 +19,10 @@ export const REALTIME_OWNED_ERROR_CODES = [
17
19
  'X_REPLICATOR_SLOT_HELD',
18
20
  'X_LIVE_CLIENT_MISSING',
19
21
  'X_LIVE_ROW_UNIDENTIFIED',
22
+ 'X_LIVE_QUERY_UNKNOWN',
23
+ 'X_QUERY_NOT_SUBSCRIBABLE',
24
+ 'X_SOCKET_UNAUTHENTICATED',
25
+ 'X_SOCKET_AUTH_UNAVAILABLE',
20
26
  ] as const;
21
27
 
22
28
  /**
@@ -26,6 +32,62 @@ export const REALTIME_OWNED_ERROR_CODES = [
26
32
  */
27
33
  export const REALTIME_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
28
34
 
35
+ /**
36
+ * The two codes an authz **decision** carries. Everything else a gate throws — a rule that reached
37
+ * for a row and timed out, a predicate with a typo in it — is a failure to reach a decision at all,
38
+ * and reading one as "denied" publishes an outage as a permission change: rows leave the screen,
39
+ * `live.rows_denied` ticks up, and nothing ever pages anyone.
40
+ */
41
+ export const POLICY_DENIAL_CODES: ReadonlySet<string> = new Set([
42
+ 'X_FORBIDDEN',
43
+ 'X_UNAUTHENTICATED',
44
+ ]);
45
+
46
+ /**
47
+ * The sync protocol's answer to "which of these is a 4xx". A denied topic, a subscription cap, a
48
+ * skewed protocol version and a cursor that fell out of the buffer are all conditions the CLIENT
49
+ * caused and the ack frame already explains — so an error monitor that held them would be a log
50
+ * nobody reads. Everything else, including an accidental `TypeError`, is this node's fault.
51
+ * Kept beside the code list so the two cannot drift, and it spreads the denial codes rather than
52
+ * respelling them: a denial is always the client's own condition.
53
+ */
54
+ export const REALTIME_CLIENT_FAULT_CODES: ReadonlySet<string> = new Set([
55
+ ...POLICY_DENIAL_CODES,
56
+ 'X_TOPIC_FORBIDDEN',
57
+ 'X_SUBSCRIPTION_LIMIT',
58
+ 'X_SUBSCRIPTION_ID_TAKEN',
59
+ // The client is the one sending too fast, and it is the one that can stop.
60
+ 'X_FRAME_RATE_LIMIT',
61
+ 'X_PROTOCOL_VERSION',
62
+ 'X_LIVE_QUERY_UNKNOWN',
63
+ 'X_CURSOR_STALE',
64
+ 'X_REBASE_CONFLICT',
65
+ // The credential is the client's to send; the node deciding it has none is not this node failing.
66
+ // Its twin, `X_SOCKET_AUTH_UNAVAILABLE`, is deliberately absent — that one IS this node failing.
67
+ 'X_SOCKET_UNAUTHENTICATED',
68
+ ]);
69
+
70
+ /** True when the client is the one who can fix it, so the node must not page anyone about it. */
71
+ export function isClientFault(error: unknown): boolean {
72
+ return REALTIME_CLIENT_FAULT_CODES.has(codeOf(error) ?? '');
73
+ }
74
+
75
+ /**
76
+ * True when a gate **decided** against the actor, false when it never got that far. The gates take
77
+ * arbitrary functions — `LiveQueryDefinition.authorize` and `.visible` are supplied by the caller —
78
+ * so the question is asked of the error's code rather than of a class this package could import.
79
+ */
80
+ export function isPolicyDenial(error: unknown): boolean {
81
+ return POLICY_DENIAL_CODES.has(codeOf(error) ?? '');
82
+ }
83
+
84
+ /** The `X_*` code an unknown throw carries, or `null` — the one place that reads it off `unknown`. */
85
+ function codeOf(error: unknown): string | null {
86
+ if (typeof error !== 'object' || error === null || !('code' in error)) return null;
87
+ const code = (error as { code: unknown }).code;
88
+ return typeof code === 'string' ? code : null;
89
+ }
90
+
29
91
  /** Every code realtime can throw through `RealtimeError`: the ones it owns plus the borrowed one. */
30
92
  export const REALTIME_ERROR_CODES = [
31
93
  ...REALTIME_OWNED_ERROR_CODES,
@@ -37,7 +99,9 @@ export type RealtimeErrorCode = (typeof REALTIME_ERROR_CODES)[number];
37
99
 
38
100
  export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, string>> = {
39
101
  X_TOPIC_FORBIDDEN: 'the actor may not subscribe to this topic',
40
- X_SUBSCRIPTION_LIMIT: 'socket or tenant hit its subscription cap',
102
+ X_SUBSCRIPTION_LIMIT: 'socket, tenant or node hit its subscription cap',
103
+ X_SUBSCRIPTION_ID_TAKEN: 'a subscribe frame reused a sid this socket already holds',
104
+ X_FRAME_RATE_LIMIT: 'one socket sent frames faster than this node will route them',
41
105
  X_PROTOCOL_VERSION: 'client and sync node disagree on the wire protocol',
42
106
  X_CURSOR_STALE: 'the resume LSN is outside the change buffer',
43
107
  X_REBASE_CONFLICT: 'a local mutation could not be rebased',
@@ -48,6 +112,10 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
48
112
  X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
49
113
  X_LIVE_CLIENT_MISSING: 'a realtime hook ran with no LiveClient registered',
50
114
  X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
115
+ X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
116
+ X_QUERY_NOT_SUBSCRIBABLE: 'a hook was bound to a query that is not declared live',
117
+ X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
118
+ X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
51
119
  };
52
120
 
53
121
  // One unconditional call, so a second package claiming one of realtime's codes throws
@@ -83,13 +151,60 @@ export class TopicForbiddenError extends RealtimeError {
83
151
  }
84
152
  }
85
153
 
86
- /** Load shedding, not a crash: a socket or tenant asked for more subscriptions than the cap. */
154
+ /**
155
+ * Load shedding, not a crash: a socket, a tenant or this node asked for more than its cap.
156
+ *
157
+ * `knob` is the option that raises it, and it is passed rather than derived because the `node`
158
+ * scope has more than one — a live-query entry ceiling and a channel-topic ceiling are two
159
+ * different numbers on two different objects. The fix names the constructor option, never an
160
+ * `app.config.ts` field: there is none (`docs/architecture/07-realtime-internals.md:244`), and a
161
+ * fix line naming a field that does not exist is an instruction that cannot be followed.
162
+ */
87
163
  export class SubscriptionLimitError extends RealtimeError {
88
- constructor(args: { scope: 'socket' | 'tenant'; id: string; limit: number }) {
164
+ constructor(args: {
165
+ scope: 'socket' | 'tenant' | 'node';
166
+ id: string;
167
+ limit: number;
168
+ knob?: string;
169
+ }) {
170
+ const knob = args.knob ?? (args.scope === 'socket' ? 'maxPerSocket' : 'maxPerTenant');
89
171
  super({
90
172
  code: 'X_SUBSCRIPTION_LIMIT',
91
173
  cause: `${args.scope} ${args.id} reached the subscription cap of ${args.limit}`,
92
- fix: `raise realtime.limits.${args.scope === 'socket' ? 'perSocket' : 'perTenant'} in app.config.ts, or unsubscribe unused live queries`,
174
+ fix: `raise ${knob} where this sync node is constructed, or unsubscribe unused live queries`,
175
+ });
176
+ }
177
+ }
178
+
179
+ /**
180
+ * One socket sent frames faster than the node will route them. The accept budget spends a token
181
+ * per UPGRADE, so before this existed an authenticated socket — the cheapest possible foothold —
182
+ * could drive an unbounded number of subscribe frames into a DB read, a presence write and a
183
+ * fleet-wide publish each, with nothing between the frame and the work.
184
+ *
185
+ * A client fault, so it never pages anyone: the ack frame carries this and the client backs off.
186
+ */
187
+ export class FrameRateLimitError extends RealtimeError {
188
+ constructor(args: { socketId: string; perSecond: number }) {
189
+ super({
190
+ code: 'X_FRAME_RATE_LIMIT',
191
+ cause: `socket ${args.socketId} exceeded ${args.perSecond} frames per second`,
192
+ fix: 'batch subscribes into one frame per subscription and retry after the delay, or raise maxFramesPerSecond where createSyncNode() is called',
193
+ });
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The client chose a subscription id it is already using on this socket. Refused rather than
199
+ * replaced: attaching over it would strand the earlier subscription inside its query entry, where
200
+ * nothing can unsubscribe it and the entry's matcher and shared window are never freed.
201
+ */
202
+ export class SubscriptionIdTakenError extends RealtimeError {
203
+ constructor(args: { sid: string; socketId: string }) {
204
+ super({
205
+ code: 'X_SUBSCRIPTION_ID_TAKEN',
206
+ cause: `socket ${args.socketId} already holds a live subscription with sid "${args.sid}"`,
207
+ fix: 'send a fresh sid with each subscribe frame — crypto.randomUUID() is what the bundled client uses',
93
208
  });
94
209
  }
95
210
  }
@@ -241,6 +356,80 @@ export class LiveRowUnidentifiedError extends RealtimeError {
241
356
  }
242
357
  }
243
358
 
359
+ /**
360
+ * A `subscribe` frame named a live query this node does not have. Distinct from a version skew
361
+ * because the two have opposite instructions: this one was reported as `X_PROTOCOL_VERSION`, whose
362
+ * fix is "x build && redeploy the client" — and redeploying a client that spells the name the same
363
+ * way changes nothing, while the registry that would have shown the mismatch never gets opened.
364
+ * A misspelling and an unregistered query produce the same frame, so the fix names both.
365
+ *
366
+ * The name it prints is the one the client sent; the registry is never enumerated back over the
367
+ * wire, because an unauthenticated socket asking for "a" through "zz" is not entitled to a list of
368
+ * every read this app declares.
369
+ *
370
+ * `fix` is the command and nothing else. What to do with what it prints belongs in `cause`: a fix
371
+ * line is pasted into a shell, so prose appended to it is a command that does not run.
372
+ */
373
+ export class LiveQueryUnknownError extends RealtimeError {
374
+ constructor(args: { name: string }) {
375
+ super({
376
+ code: 'X_LIVE_QUERY_UNKNOWN',
377
+ cause: `no live query is registered as "${args.name}" on this node — subscribe under a name the registry prints, or pass the query to defineApi({ queries }) if it is missing`,
378
+ fix: 'x queries list --json',
379
+ });
380
+ }
381
+ }
382
+
383
+ /**
384
+ * `liveHookFor` was handed a read that never patches. Refused where the binding is written rather
385
+ * than at the first render, because a hook over a non-live query has nothing to subscribe to — it
386
+ * would return an empty set forever and look like a policy denial or an empty table.
387
+ */
388
+ export class QueryNotSubscribableError extends RealtimeError {
389
+ constructor(args: { name: string }) {
390
+ super({
391
+ code: 'X_QUERY_NOT_SUBSCRIBABLE',
392
+ // Empty at module load, when the binding runs and `registerQueries()` has not stamped a
393
+ // name yet — say so rather than printing `query ""`.
394
+ cause: `query ${args.name === '' ? '<unregistered>' : `"${args.name}"`} is not declared live: true, so it has no subscription for a hook to read`,
395
+ fix: 'add live: true to the query declaration, or read it once through query.client({ baseUrl }) — wiki/Queries-And-Live-Queries.md',
396
+ });
397
+ }
398
+ }
399
+
400
+ /**
401
+ * The app's `authenticate` decided this upgrade belongs to nobody. A **decision**, so it is the
402
+ * client's own condition and never pages anyone: the refusal is the whole point of the hook.
403
+ *
404
+ * Distinct from `X_TOPIC_FORBIDDEN`, which is a subscriber that got a socket and then asked for
405
+ * something it may not have. This one never gets a socket at all — a websocket refused after the
406
+ * upgrade is a connection the client must tear down to learn about.
407
+ */
408
+ export class SocketUnauthenticatedError extends RealtimeError {
409
+ constructor(args: { reason: string }) {
410
+ super({
411
+ code: 'X_SOCKET_UNAUTHENTICATED',
412
+ cause: `the websocket upgrade was refused: ${args.reason}`,
413
+ fix: 'send the credential createSyncNode({ authenticate }) reads on the upgrade request, or return an anonymous Actor from it to admit this socket',
414
+ });
415
+ }
416
+ }
417
+
418
+ /**
419
+ * `authenticate` raised instead of deciding. The same rule the row gate follows: a failure is not a
420
+ * denial, so the client is told to come back rather than told it may not connect — a token service
421
+ * that timed out must not read to a user as "you are signed out", and it must page someone.
422
+ */
423
+ export class SocketAuthUnavailableError extends RealtimeError {
424
+ constructor(args: { detail: string }) {
425
+ super({
426
+ code: 'X_SOCKET_AUTH_UNAVAILABLE',
427
+ cause: `authenticate() raised instead of deciding who a connecting socket is: ${args.detail}`,
428
+ fix: 'x doctor --json',
429
+ });
430
+ }
431
+ }
432
+
244
433
  /** Deep infrastructure that is interface-complete but not wired. Carries the exact next step. */
245
434
  export class NotImplementedError extends RealtimeError {
246
435
  constructor(args: { what: string; fix: string }) {
@@ -0,0 +1,58 @@
1
+ // The order this node applies one socket's inbound frames in. `sync-node.message` dispatches every
2
+ // frame as `void (async () => routeFrame(…))()`, so nothing upstream orders them and a router that
3
+ // awaits a policy, a snapshot read or `onMutate` finishes in whatever order those settle.
4
+ //
5
+ // **Not one lane per socket.** A global per-socket lane puts every frame behind the slowest one,
6
+ // and the slowest one is a snapshot read — a database round trip that every reconnecting client
7
+ // pays once per live query, which is precisely the 50,000-client restart storm this framework is
8
+ // measured on. What has to be ordered is narrower and exact:
9
+ //
10
+ // | Frames | Lane | Why that is the unit |
11
+ // |---|---|---|
12
+ // | `mutate` | `mutate` (one per socket) | they write the database, and the client numbered them |
13
+ // | `subscribe` on a query | `sub:<sid>` | `add` then `drop` for one sid, or the drop finds nothing and the add strands the subscription it was meant to end |
14
+ // | `subscribe` on a topic | `topic:<name>` | the same add/drop pair, one membership |
15
+ // | `hello`, server-authored kinds | none | they read state and write none of it |
16
+ //
17
+ // The caps are NOT this file's job — a lane makes concurrent frames sequential, and N sequential
18
+ // subscribes still pass a check-then-act cap N times. `SubscriptionBook.reserve` and
19
+ // `ChannelHub`'s bridge reservation are what bound them, synchronously, before the first await.
20
+
21
+ import type { Frame } from './sync-protocol';
22
+ import { WindowLock } from './window-lock';
23
+
24
+ /**
25
+ * FIFO per key, concurrent across keys. A lane exists only while something is queued on it, so the
26
+ * map is empty between frames — keyed by a client-chosen sid, a lane that outlived its work would
27
+ * be an unbounded map one socket could grow at will.
28
+ */
29
+ export class FrameLanes {
30
+ readonly #lanes = new Map<string, { lock: WindowLock; queued: number }>();
31
+
32
+ run<T>(key: string, work: () => Promise<T>): Promise<T> {
33
+ const lane = this.#lanes.get(key) ?? { lock: new WindowLock(), queued: 0 };
34
+ lane.queued += 1;
35
+ this.#lanes.set(key, lane);
36
+ const result = lane.lock.run(work);
37
+ const done = (): void => {
38
+ lane.queued -= 1;
39
+ // Only when nothing is waiting: dropping a lane with work still queued on it would let the
40
+ // next frame open a second lane beside the first and overtake it.
41
+ if (lane.queued === 0 && this.#lanes.get(key) === lane) this.#lanes.delete(key);
42
+ };
43
+ result.then(done, done);
44
+ return result;
45
+ }
46
+
47
+ /** Test-only probe: lanes still held. A count that does not return to zero is the leak. */
48
+ get size(): number {
49
+ return this.#lanes.size;
50
+ }
51
+ }
52
+
53
+ /** The lane a frame belongs in, or `null` for the kinds nothing has to order. */
54
+ export function laneKeyOf(frame: Frame): string | null {
55
+ if (frame.type === 'mutate') return 'mutate';
56
+ if (frame.type !== 'subscribe') return null;
57
+ return frame.target.kind === 'topic' ? `topic:${frame.target.topic}` : `sub:${frame.sid}`;
58
+ }
package/src/hooks.ts CHANGED
@@ -15,26 +15,34 @@ interface Registered {
15
15
  /** Read to subscribe, bumped to invalidate: `OfflineQueue` stores plain arrays, not signals. */
16
16
  readonly version: () => number;
17
17
  readonly bump: () => void;
18
+ /** Drops this registration's queue listener. The client outlives the registration. */
19
+ readonly release: () => void;
18
20
  }
19
21
 
20
22
  let registered: Registered | null = null;
21
23
 
22
24
  /** Register once, in the app entry, before the first render. One app, one socket, one client. */
23
25
  export function setLiveClient(client: LiveClient): void {
26
+ // The previous registration's listener goes with it. The client outlives `setLiveClient` — a hot
27
+ // reload, a test's next case, an app that re-registers after signing in — so a discarded
28
+ // unsubscribe is a listener nothing can reach, bumping a signal nothing renders, once per
29
+ // registration this process ever made.
30
+ registered?.release();
24
31
  const [version, setVersion] = client.signal<number>(0);
25
32
  const bump = (): void => {
26
33
  setVersion(version() + 1);
27
34
  };
28
- registered = { client, version, bump };
29
35
  // Closes the gap a direct call can't: a reconnect drains automatically inside `connect()`, and
30
36
  // an ack/fail frame arrives asynchronously inside `#onFrame` — neither is awaited by any hook, so
31
37
  // this is the only path that reaches them. The direct `bump()` calls below stay too: they fire at
32
38
  // the earliest possible moment for the call that made them, and a redundant bump is harmless.
33
- client.onQueueChange(bump);
39
+ const release = client.onQueueChange(bump);
40
+ registered = { client, version, bump, release };
34
41
  }
35
42
 
36
43
  /** For tests: drop the registration so cases stay independent. */
37
44
  export function clearLiveClient(): void {
45
+ registered?.release();
38
46
  registered = null;
39
47
  }
40
48
 
@@ -54,9 +62,14 @@ export type LiveInput = JsonValue | (() => JsonValue);
54
62
 
55
63
  /**
56
64
  * A callable result set: `feed()` are the rows, `feed.state()` / `feed.cursor()` /
57
- * `feed.unsubscribe()` are the rest of the `LiveHandle` hanging off it.
65
+ * `feed.unsubscribe()` are the rest of the `LiveHandle` hanging off it. It is also `Disposable`
66
+ * (inherited from `LiveHandle`), so `using feed = useLive(...)` unsubscribes on scope exit.
67
+ *
68
+ * `R` is only constrained to `object`: on the wire every row is a `Row`, but a hook bound to a
69
+ * declared query (`query-hook.ts`) answers in that query's own row type, which is whatever its
70
+ * `sql` returns. The three members hanging off the accessor are the same for every `R`.
58
71
  */
59
- export type LiveRows<R extends Row = Row> = (() => readonly R[]) & Omit<LiveHandle<R>, 'rows'>;
72
+ export type LiveRows<R extends object = Row> = (() => readonly R[]) & Omit<LiveHandle, 'rows'>;
60
73
 
61
74
  /**
62
75
  * Subscribe to a live query. `query` is anything carrying a `name`, which a `@ultimat3/query`
@@ -66,7 +79,7 @@ export type LiveRows<R extends Row = Row> = (() => readonly R[]) & Omit<LiveHand
66
79
  * A thunk `input` is read **once**, at subscribe time: tier 3 has no reactive runtime of its own,
67
80
  * so nothing re-runs it when its dependencies change. Changing input means a new subscription.
68
81
  * The caller owns `unsubscribe` — nothing here disposes on unmount, because nothing here knows
69
- * what a mount is.
82
+ * what a mount is. `using` works too, when the caller does have a scope to hang it on.
70
83
  */
71
84
  export function useLive<R extends Row = Row>(query: LiveQueryRef, input: LiveInput): LiveRows<R> {
72
85
  const handle = live('useLive').client.useLive<R>(
@@ -77,6 +90,7 @@ export function useLive<R extends Row = Row>(query: LiveQueryRef, input: LiveInp
77
90
  state: handle.state,
78
91
  cursor: handle.cursor,
79
92
  unsubscribe: handle.unsubscribe,
93
+ [Symbol.dispose]: handle[Symbol.dispose],
80
94
  });
81
95
  }
82
96
 
@@ -0,0 +1,141 @@
1
+ // One row VALUE per `(scope, id)` for the whole client — the identity map the thesis takes from
2
+ // Ember Data. Two components holding two copies of one row is the bug it makes unrepresentable:
3
+ // a live query's window and the tier-3 local store are both projections over this one map, so a
4
+ // write through either is the same row for both. Membership and order live in the projections.
5
+
6
+ import type { JsonObject, JsonValue, Row } from './json';
7
+
8
+ /**
9
+ * The entity's table — the same name `ChangeEvent.entity`, `tx.<table>` and a mutator's `entity`
10
+ * already use, which is what makes the live path and the local store address one row identically.
11
+ * A subscription whose entity the server did not name gets a private `?query:<name>` scope: no
12
+ * sharing is worse than sharing two different entities that happen to spell one id the same way.
13
+ */
14
+ export type RowScope = string;
15
+
16
+ /** `scope`+NUL+`id`. NUL cannot occur in an entity name, so the join is unambiguous. */
17
+ export type RowKey = string;
18
+
19
+ export function rowKey(scope: RowScope, id: string): RowKey {
20
+ return `${scope}\u0000${id}`;
21
+ }
22
+
23
+ /** The scope a subscription uses until the server names its entity. `?` starts no entity name. */
24
+ export function privateScope(queryName: string): RowScope {
25
+ return `?query:${queryName}`;
26
+ }
27
+
28
+ export type IdentityListener = (changed: ReadonlySet<RowKey>) => void;
29
+
30
+ /**
31
+ * The map. Values are immutable: every write produces a NEW row object, because a projection over
32
+ * this map hands its rows to a signal and a mutated-in-place row is a render that never happens.
33
+ * "One row per id" therefore means one *current value* per id, referenced by every holder at once.
34
+ */
35
+ export class IdentityMap {
36
+ readonly #values = new Map<RowKey, Row>();
37
+ /** How many projections hold each key. A row nobody holds is dropped — a window is not a leak. */
38
+ readonly #holds = new Map<RowKey, number>();
39
+ readonly #listeners = new Set<IdentityListener>();
40
+ /** Non-null while a batch is open; every write inside one notifies exactly once, at the end. */
41
+ #changed: Set<RowKey> | null = null;
42
+
43
+ peek(scope: RowScope, id: string): Row | undefined {
44
+ return this.#values.get(rowKey(scope, id));
45
+ }
46
+
47
+ /** Whole-row write: the value becomes exactly this row. `insert` and a rollback's undo use it. */
48
+ set(scope: RowScope, row: Row): Row {
49
+ const key = rowKey(scope, row.id);
50
+ const current = this.#values.get(key);
51
+ if (current === row) return row;
52
+ this.#values.set(key, row);
53
+ this.#touch(key);
54
+ return row;
55
+ }
56
+
57
+ /**
58
+ * Merge changed columns onto the current value. This is the write every server patch, every
59
+ * snapshot row and every `upsert` takes: a projection that selected fewer columns must not blank
60
+ * the columns another projection is rendering, so a write never removes a key. `undefined` in a
61
+ * patch means "leave it alone", exactly as the local store's contract already says.
62
+ */
63
+ merge(
64
+ scope: RowScope,
65
+ id: string,
66
+ columns: Readonly<Record<string, JsonValue | undefined>>,
67
+ ): Row {
68
+ const key = rowKey(scope, id);
69
+ const current = this.#values.get(key);
70
+ const next: JsonObject = { ...current };
71
+ let changed = current === undefined;
72
+ for (const [column, value] of Object.entries(columns)) {
73
+ if (value === undefined || column === 'id') continue;
74
+ if (current === undefined || current[column] !== value) changed = true;
75
+ next[column] = value;
76
+ }
77
+ const row: Row = { ...next, id };
78
+ // A patch that changed nothing must not re-emit: a no-op write re-rendering every holder is
79
+ // how a live query becomes the most expensive thing on the page.
80
+ if (!changed && current !== undefined) return current;
81
+ this.#values.set(key, row);
82
+ this.#touch(key);
83
+ return row;
84
+ }
85
+
86
+ retain(scope: RowScope, id: string): void {
87
+ const key = rowKey(scope, id);
88
+ this.#holds.set(key, (this.#holds.get(key) ?? 0) + 1);
89
+ }
90
+
91
+ /** The last holder leaving drops the value: an infinite scroll must not retain every row it saw. */
92
+ release(scope: RowScope, id: string): void {
93
+ const key = rowKey(scope, id);
94
+ const holds = this.#holds.get(key);
95
+ if (holds === undefined) return;
96
+ if (holds > 1) {
97
+ this.#holds.set(key, holds - 1);
98
+ return;
99
+ }
100
+ this.#holds.delete(key);
101
+ if (this.#values.delete(key)) this.#touch(key);
102
+ }
103
+
104
+ /** Every write inside `fn` collapses into one notification — one patch frame, one render. */
105
+ batch<T>(fn: () => T): T {
106
+ if (this.#changed !== null) return fn();
107
+ const collected = new Set<RowKey>();
108
+ this.#changed = collected;
109
+ try {
110
+ return fn();
111
+ } finally {
112
+ this.#changed = null;
113
+ if (collected.size > 0) this.#notify(collected);
114
+ }
115
+ }
116
+
117
+ subscribe(listener: IdentityListener): () => void {
118
+ this.#listeners.add(listener);
119
+ return () => {
120
+ this.#listeners.delete(listener);
121
+ };
122
+ }
123
+
124
+ /** Held keys. Tests assert on it; nothing in the client branches on a count. */
125
+ get size(): number {
126
+ return this.#values.size;
127
+ }
128
+
129
+ #touch(key: RowKey): void {
130
+ const batch = this.#changed;
131
+ if (batch !== null) {
132
+ batch.add(key);
133
+ return;
134
+ }
135
+ this.#notify(new Set([key]));
136
+ }
137
+
138
+ #notify(changed: ReadonlySet<RowKey>): void {
139
+ for (const listener of this.#listeners) listener(changed);
140
+ }
141
+ }