@ultimat3/realtime 24.0.0 → 25.1.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 (88) hide show
  1. package/CLAUDE.md +61 -63
  2. package/README.md +131 -46
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +10 -5
  5. package/src/boot.ts +14 -9
  6. package/src/changefeed-env.ts +8 -8
  7. package/src/changefeed.ts +42 -18
  8. package/src/channel-authz.ts +7 -7
  9. package/src/channel-describe.ts +1 -1
  10. package/src/channel-logs.ts +16 -25
  11. package/src/channel-presence.ts +3 -3
  12. package/src/channel-records.ts +3 -3
  13. package/src/channel-ref.ts +2 -2
  14. package/src/channel-registry.ts +2 -2
  15. package/src/channel.ts +8 -7
  16. package/src/client-channels.ts +2 -0
  17. package/src/client-frames.ts +9 -2
  18. package/src/client.ts +29 -2
  19. package/src/error-titles.ts +91 -0
  20. package/src/errors.ts +47 -90
  21. package/src/first-paint-hold.ts +49 -0
  22. package/src/idb-types.ts +2 -2
  23. package/src/index.ts +24 -6
  24. package/src/json.ts +16 -3
  25. package/src/live-contract.ts +14 -0
  26. package/src/live-definition.ts +12 -2
  27. package/src/live-deliver.ts +55 -0
  28. package/src/live-fanout.ts +81 -24
  29. package/src/live-query-options.ts +6 -0
  30. package/src/live-query.ts +66 -144
  31. package/src/live-reauth.ts +127 -0
  32. package/src/live-replicator.ts +28 -8
  33. package/src/live-resume.ts +44 -8
  34. package/src/live-rows.ts +32 -3
  35. package/src/live-spend.ts +50 -0
  36. package/src/local-store-idb.ts +5 -0
  37. package/src/matcher-bridge.ts +11 -5
  38. package/src/nats-client.ts +26 -4
  39. package/src/nats-lib-client.ts +36 -7
  40. package/src/nats-subscriptions.ts +63 -0
  41. package/src/nats-transport.ts +127 -15
  42. package/src/offline-queue.ts +5 -0
  43. package/src/page-errors.ts +8 -9
  44. package/src/page-outbox.ts +5 -5
  45. package/src/page-runtime-wait.ts +72 -0
  46. package/src/page-runtime.ts +32 -0
  47. package/src/page-socket.ts +7 -16
  48. package/src/page-store.ts +83 -16
  49. package/src/pg-advisory-lock.ts +9 -4
  50. package/src/pg-connection.ts +7 -1
  51. package/src/pg-replication.ts +6 -6
  52. package/src/pg-socket.ts +20 -3
  53. package/src/pg-values.ts +29 -14
  54. package/src/policy-gate.ts +7 -7
  55. package/src/presence.ts +2 -2
  56. package/src/principal-sockets.ts +75 -0
  57. package/src/query-window.ts +1 -1
  58. package/src/record-publisher.ts +178 -0
  59. package/src/record-store.ts +25 -4
  60. package/src/record-synced.ts +43 -1
  61. package/src/replication-errors.ts +3 -3
  62. package/src/replicator-envelope.ts +61 -11
  63. package/src/replicator-row.ts +1 -1
  64. package/src/replicator.ts +12 -23
  65. package/src/server.ts +36 -18
  66. package/src/socket-defaults.ts +20 -0
  67. package/src/socket.ts +14 -19
  68. package/src/subscription-book.ts +129 -71
  69. package/src/subscription-count.ts +151 -0
  70. package/src/sync-actor-change.ts +56 -0
  71. package/src/sync-bus-handlers.ts +131 -0
  72. package/src/sync-frames.ts +1 -1
  73. package/src/sync-node-bounds.ts +1 -1
  74. package/src/sync-node-contract.ts +16 -2
  75. package/src/sync-node.ts +115 -109
  76. package/src/sync-origin.ts +1 -1
  77. package/src/sync-protocol.ts +73 -13
  78. package/src/sync-upgrade.ts +39 -0
  79. package/src/type-pins.ts +9 -1
  80. package/src/use-channel.ts +19 -14
  81. package/src/use-connection.ts +3 -3
  82. package/src/use-mutation.ts +9 -16
  83. package/src/use-query.ts +11 -11
  84. package/src/use-record.ts +89 -10
  85. package/src/wire-channel.ts +5 -5
  86. package/src/wire-read.ts +2 -2
  87. package/src/idb-fake.ts +0 -133
  88. package/src/policy-fake.ts +0 -14
@@ -0,0 +1,127 @@
1
+ // A socket whose actor changed mid-connection: every subscription it holds re-decided under the
2
+ // new actor, re-seated onto the new tenant's window, and — new principal's cap first — refused
3
+ // rather than carried past a ceiling. Split from `live-query.ts` at its size ceiling: the registry
4
+ // owns the entry table, this owns what a re-authorization does to one socket's subscriptions.
5
+
6
+ import { queryHash } from '@ultimat3/query';
7
+ import { isPolicyDenial } from './errors';
8
+ import type { LiveSubscription } from './live-contract';
9
+ import { refuseSubscription } from './live-refusal';
10
+ import type { SubscribeArgs } from './live-spend';
11
+ import { liveTenantOf, windowId } from './live-tenant';
12
+ import type { SyncSocket } from './socket';
13
+ import type { SubscriberGate } from './subscriber-gate';
14
+ import type { SubscriptionBook } from './subscription-book';
15
+ import type { Frame } from './sync-protocol';
16
+
17
+ /** What a re-authorization reaches on the registry, and nothing else. */
18
+ export interface ReauthDeps {
19
+ readonly book: SubscriptionBook;
20
+ readonly gate: SubscriberGate;
21
+ unsubscribe(socketId: string, sid: string): void;
22
+ /** Subscribe again under the same sid, uncharged — a re-seat is not a new subscription. */
23
+ resubscribe(args: SubscribeArgs): Promise<{ readonly frame: Frame }>;
24
+ }
25
+
26
+ /**
27
+ * Actor changed mid-connection (login, logout, role change): re-run subscribe-time authz and drop
28
+ * what is no longer allowed. Survivors are marked desynced so the next flush re-snapshots them
29
+ * under the new actor's row policy. Returns the sids that were dropped — a denial and nothing
30
+ * else, so a caller may tell the client "you may no longer see this" and be right.
31
+ *
32
+ * A survivor whose actor now belongs to ANOTHER tenant cannot stay where it is: its window is the
33
+ * old org's. It is re-seated — dropped, subscribed again under the new tenant, and sent that
34
+ * window's snapshot under the same sid — or, when that cannot complete, refused on the socket
35
+ * under the sid (`refuseSubscription`), never kept on the old org's window.
36
+ */
37
+ export async function reauthorizeSocket(
38
+ deps: ReauthDeps,
39
+ socket: SyncSocket,
40
+ ): Promise<readonly string[]> {
41
+ const dropped: string[] = [];
42
+ // The actor changed, so what tenant this socket's subscriptions count against may have too.
43
+ // Told here rather than derived per lookup: the per-tenant cap is an index now, and an index
44
+ // nobody updates is a count that drifts from the book for the rest of the process.
45
+ refuseOverflow(deps, socket);
46
+ deps.book.retenant(socket);
47
+ for (const subscription of deps.book.ofSocket(socket.id)) {
48
+ let failed: { readonly error: unknown } | undefined;
49
+ try {
50
+ await subscription.definition.authorize?.({
51
+ actor: socket.actor,
52
+ input: subscription.input,
53
+ });
54
+ } catch (error) {
55
+ if (isPolicyDenial(error)) {
56
+ deps.unsubscribe(socket.id, subscription.sid);
57
+ // Said under the sid, as a refused subscribe is: unsaid, the client kept the rows on
58
+ // screen in state `live` for a subscription this node no longer serves.
59
+ refuseSubscription(socket, subscription.sid, error);
60
+ dropped.push(subscription.sid);
61
+ continue;
62
+ }
63
+ // Not a decision — the gate never reached one. Destroying the subscription would report a
64
+ // database timeout as a revoked grant, and a client does not resubscribe to a denial. It
65
+ // survives, desynced: nothing is delivered from the window built under the old actor, and
66
+ // the row gate still decides every row under the new one, from the same policy `authorize`
67
+ // consults. The failure is counted and reported rather than silently absorbed.
68
+ deps.gate.failedAuthorize(
69
+ subscription.qid,
70
+ { sid: subscription.sid, actor: socket.actor },
71
+ error,
72
+ );
73
+ failed = { error };
74
+ }
75
+ if (await reseat(deps, socket, subscription, failed)) continue;
76
+ socket.markDesynced(subscription.sid);
77
+ }
78
+ return dropped;
79
+ }
80
+
81
+ /**
82
+ * Move one subscription onto its actor's CURRENT tenant's window. `false` when it is already
83
+ * there. A re-seat that cannot complete leaves the subscription dropped — never attached to the
84
+ * window of an org its actor has left — and REFUSED on the socket under its sid, the frame a
85
+ * refused subscribe gets, so the client's window renders `failed` instead of going quiet.
86
+ *
87
+ * `failed` is the `authorize` this pass already saw fail: it is not asked again — the store that
88
+ * just timed out is the one `subscribe` would ask — and it is not counted twice.
89
+ */
90
+ async function reseat(
91
+ deps: ReauthDeps,
92
+ socket: SyncSocket,
93
+ subscription: LiveSubscription,
94
+ failed: { readonly error: unknown } | undefined,
95
+ ): Promise<boolean> {
96
+ const { sid, input, definition } = subscription;
97
+ const wanted = windowId(queryHash(definition.name, input), liveTenantOf(socket.actor));
98
+ if (wanted === subscription.qid) return false;
99
+ deps.unsubscribe(socket.id, sid);
100
+ if (failed !== undefined) {
101
+ refuseSubscription(socket, sid, failed.error);
102
+ return true;
103
+ }
104
+ try {
105
+ const { frame } = await deps.resubscribe({ socket, name: definition.name, input, sid });
106
+ if (!socket.send(frame)) socket.markDesynced(sid);
107
+ } catch (error) {
108
+ deps.gate.failedAuthorize(wanted, { sid, actor: socket.actor }, error);
109
+ refuseSubscription(socket, sid, error);
110
+ }
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * The subscriptions the socket's NEW tenant or actor has no room for, refused under their sids
116
+ * before the move — so the move never carries a key past its cap, and filling an address then
117
+ * signing in cannot be repeated to stack one actor past it. The newest go first: the client's
118
+ * oldest windows are the ones it has been rendering longest. Not a denial, so not in `dropped`.
119
+ */
120
+ function refuseOverflow(deps: ReauthDeps, socket: SyncSocket): void {
121
+ const overflow = deps.book.overflowOnRekey(socket);
122
+ if (overflow === null) return;
123
+ for (const subscription of deps.book.ofSocket(socket.id).slice(-overflow.excess)) {
124
+ deps.unsubscribe(socket.id, subscription.sid);
125
+ refuseSubscription(socket, subscription.sid, overflow.error);
126
+ }
127
+ }
@@ -7,7 +7,7 @@
7
7
  // WHAT IS REAL HERE, and it is everything downstream of the decoder: the matcher, the shared window,
8
8
  // the per-subscriber `visible` gate, the cursor, the frames. This substitutes for the WAL DECODER
9
9
  // and for nothing else — `@ultimat3/entity`'s `setRowObserver` reports what a repository wrote, in
10
- // this process, and the events are shaped exactly as `PgLogicalReplicationFeed` shapes them.
10
+ // this process, and the events are shaped exactly as `PostgresChangeFeed` shapes them.
11
11
  //
12
12
  // WHAT IS NOT: a write another process made is invisible, because nothing here reads a log. That is
13
13
  // the honest bound, and it is why `selectChangeFeed` never picks it — the boot that installs it
@@ -27,6 +27,7 @@ import type { RowBulkChange, RowChange, RowObserver } from '@ultimat3/entity';
27
27
  import type { ChangeEvent, ChangeOp } from './changefeed';
28
28
  import type { Row } from './json';
29
29
  import type { LiveQueryRegistry } from './live-query';
30
+ import { isPublishedTable } from './record-publisher';
30
31
 
31
32
  /** What a caller does with a change nobody could deliver. */
32
33
  export interface LiveReplicatorOptions {
@@ -35,8 +36,13 @@ export interface LiveReplicatorOptions {
35
36
  * The node's declared channels, fed the same `ChangeEvent` — what a real node's change
36
37
  * subscription does beside `registry.deliver` (`sync-node.ts`). Without it a channel's `records`
37
38
  * frames never carried a write made under `x dev`, and every other tab stayed on the old row.
39
+ * `invalidate` is the other half, called beside `registry.invalidate` as the node pairs them: a
40
+ * bulk write reaches no `records` frame, so the topics carrying its table must re-read instead.
38
41
  */
39
- readonly channels?: { deliverChange(change: ChangeEvent): unknown };
42
+ readonly channels?: {
43
+ deliverChange(change: ChangeEvent): unknown;
44
+ invalidate(entity?: string): unknown;
45
+ };
40
46
  /** Tenant column, hoisted out of the row so fanout filters without parsing it. */
41
47
  readonly tenantColumn?: string;
42
48
  readonly onError?: (error: unknown) => void;
@@ -80,10 +86,19 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
80
86
  let position = 0;
81
87
  let delivered = 0;
82
88
  // One promise chain, because ORDERING is the guarantee the whole pipeline is built on — the same
83
- // reason `InMemoryChangeFeed` serializes its deliveries rather than firing them concurrently.
89
+ // reason `MemoryChangeFeed` serializes its deliveries rather than firing them concurrently.
84
90
  let tail: Promise<void> = Promise.resolve();
85
91
  let stopped = false;
86
92
 
93
+ /**
94
+ * The RELATION an entity's rows live in — what `ChangeEvent.table` carries on every producer:
95
+ * the WAL decoder reads it off the Relation message, a channel matches `projection.table`, and a
96
+ * live shape is `from('<table>', …)`. The row observer reports the entity's declared NAME, which
97
+ * is the table only until an entity adopts one (`entity('ledger', { table: 'gl_entries' })`);
98
+ * passed through, such an entity reached no channel, no window and no bulk re-read here.
99
+ */
100
+ const tableOf = (name: string): string => entity.getEntity(name)?.tableName ?? name;
101
+
87
102
  const enqueue = (work: () => Promise<void>): void => {
88
103
  tail = tail.then(work).catch((error: unknown) => {
89
104
  // Never rethrown into the chain: one failed fanout must not silence every change behind it,
@@ -100,7 +115,7 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
100
115
  const row = change.after ?? change.before;
101
116
  const orgId = typeof row?.[tenant] === 'string' ? (row[tenant] as string) : null;
102
117
  const event: ChangeEvent = {
103
- entity: change.entity,
118
+ table: tableOf(change.entity),
104
119
  // A repository's three row ops are three of the feed's four; a truncate never comes this way.
105
120
  op: change.op satisfies ChangeOp,
106
121
  before: asRow(change.before),
@@ -113,12 +128,14 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
113
128
  at,
114
129
  // The keyed write it belongs to, read off the request scope — what the WAL decoder reads
115
130
  // off the transaction's opening message — so a channel frame names it here as it would there.
116
- ...(change.write === undefined ? {} : { write: change.write }),
131
+ write: change.write ?? null,
117
132
  };
118
133
  enqueue(async () => {
119
134
  // Channels first, as the node does: a live query's fanout that throws must not also cost
120
- // every declared channel the change.
121
- options.channels?.deliverChange(event);
135
+ // every declared channel the change. Not an entity a `recordPublisher` claims: the app's
136
+ // own publish is its one channel delivery, as in production with no replicator, and both
137
+ // was every insert twice (#682). Asked per change, so a publisher built later counts.
138
+ if (!isPublishedTable(event.table)) options.channels?.deliverChange(event);
122
139
  delivered += await registry.deliver(event);
123
140
  });
124
141
  },
@@ -132,7 +149,10 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
132
149
  */
133
150
  onBulk(change: RowBulkChange): void {
134
151
  if (stopped) return;
135
- registry.invalidate(change.entity);
152
+ const table = tableOf(change.entity);
153
+ registry.invalidate(table);
154
+ // Channels too, or a tab seated on one held the pre-update rows with nothing to re-read.
155
+ options.channels?.invalidate(table);
136
156
  },
137
157
  };
138
158
 
@@ -11,11 +11,12 @@ import {
11
11
  type ResumeSource,
12
12
  resumeFrom,
13
13
  } from './cursor';
14
+ import type { RowPatch } from './json';
14
15
  import type { SnapshotResult } from './live-contract';
15
- import { snapshotFrame } from './live-fanout';
16
+ import { type NamedWrite, patchFrame, snapshotFrame } from './live-fanout';
16
17
  import { fillWindow, type QueryEntry } from './query-window';
17
18
  import type { Subscriber, SubscriberGate } from './subscriber-gate';
18
- import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
19
+ import type { Frame } from './sync-protocol';
19
20
 
20
21
  export interface ResumeOntoDeps {
21
22
  readonly source: ResumeSource;
@@ -24,6 +25,8 @@ export interface ResumeOntoDeps {
24
25
  readonly gate: SubscriberGate;
25
26
  /** The window, read once for the entry and filtered for THIS subscriber. */
26
27
  read(): Promise<SnapshotResult>;
28
+ /** Called before the delta path fills an unread entry — a database read the caller pays for. */
29
+ beforeFill?(): Promise<void>;
27
30
  }
28
31
 
29
32
  /**
@@ -45,16 +48,23 @@ export async function resumeOnto(
45
48
  snapshot: () => deps.read(),
46
49
  });
47
50
  if (resumed.kind !== 'delta') {
51
+ // Read after every retained patch, so it holds their writes; `snapshotFrame` names only those
52
+ // behind a row this subscriber was given. Out of the ring's window there is nothing to name,
53
+ // and a write left unnamed settles on its own HTTP answer.
54
+ const named = retainedWrites(deps.source.since(entry.qid, cursor.lsn) ?? []);
48
55
  return {
49
56
  cursor: resumed.cursor,
50
- frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor),
57
+ frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor, named),
51
58
  };
52
59
  }
53
60
  // The gate decides about whole rows out of the shared window, and an entry nothing has read
54
61
  // yet has none — every patch would meet an empty window and be withheld. Filling is
55
62
  // conditional on purpose: a restart storm resumes onto entries that already hold a live
56
63
  // window, and re-reading per resuming subscriber is the cost a delta resume exists to skip.
57
- if (entry.lsn === '') await fillWindow(entry);
64
+ if (entry.lsn === '') {
65
+ await deps.beforeFill?.();
66
+ await fillWindow(entry);
67
+ }
58
68
  // The live entry on purpose: a resume runs outside the lane, so the window under it may
59
69
  // have moved on — always forwards, and a row whose grant was revoked in the meantime is
60
70
  // one this pass must refuse rather than replay from the state it had at the cursor's lsn.
@@ -67,8 +77,34 @@ export async function resumeOnto(
67
77
  // another tenant's row id and the instant it went. The leak that branch closes, re-opened
68
78
  // one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
69
79
  const seated = advance(cursor, patches, resumed.cursor.lsn, now);
70
- return {
71
- cursor: seated,
72
- frame: { type: 'patch', v: PROTOCOL_VERSION, sid: who.sid, patches, lsn: seated.lsn },
73
- };
80
+ const { bare, writes } = namedWrites(patches);
81
+ return { cursor: seated, frame: patchFrame(who.sid, bare, seated.lsn, writes) };
82
+ }
83
+
84
+ /**
85
+ * The writes behind the patches this subscriber is sent, each named once, and the patches as the
86
+ * wire spells them. Read off the FILTERED list: a write behind a row the gate withheld is another
87
+ * actor's, and naming it here would tell this page that write happened.
88
+ */
89
+ function namedWrites(patches: readonly RowPatch[]): {
90
+ readonly bare: readonly RowPatch[];
91
+ readonly writes: readonly string[];
92
+ } {
93
+ const writes = new Set<string>();
94
+ const bare = patches.map((patch) => {
95
+ if (patch.write === undefined) return patch;
96
+ writes.add(patch.write);
97
+ const { write: _named, ...rest } = patch;
98
+ return rest;
99
+ });
100
+ return { bare, writes: [...writes] };
101
+ }
102
+
103
+ /** Each retained write, by the record key of the row its patch made. */
104
+ function retainedWrites(patches: readonly RowPatch[]): readonly NamedWrite[] {
105
+ const named: NamedWrite[] = [];
106
+ for (const patch of patches) {
107
+ if (patch.write !== undefined) named.push({ write: patch.write, key: patch.key ?? patch.id });
108
+ }
109
+ return named;
74
110
  }
package/src/live-rows.ts CHANGED
@@ -70,6 +70,7 @@ export class RowWindows {
70
70
  type: string | null,
71
71
  rows: readonly Row[],
72
72
  keys?: readonly string[],
73
+ writes: readonly string[] = [],
73
74
  ): void {
74
75
  const next = type ?? registration.type;
75
76
  // The server's record key where it sent one; a row's `id` IS its key everywhere else.
@@ -80,12 +81,27 @@ export class RowWindows {
80
81
  keyed.map(([id]) => id),
81
82
  () => {
82
83
  for (const [id, row] of keyed) this.#store.merge(next, id, row);
84
+ // Settled in this batch, as `patch` settles: truth that holds the write, never under it.
85
+ settle(
86
+ this.#store,
87
+ writes,
88
+ keyed.map(([id]) => recordKey(next, id)),
89
+ );
83
90
  },
84
91
  );
85
92
  }
86
93
 
87
- /** A patch list: values merged into the store, membership and order folded over the ids. */
88
- patch(registration: Registration, sent: readonly RowPatch[]): void {
94
+ /**
95
+ * A patch list: values merged into the store, membership and order folded over the ids. `writes`
96
+ * are the writes the frame names (`PatchFrame.writes`): each this page still holds is settled
97
+ * against the rows merged here, INSIDE the same batch — so no holder ever renders the server's
98
+ * row with that write's twin replayed over it (truth plus overlay: one like counted twice).
99
+ */
100
+ patch(
101
+ registration: Registration,
102
+ sent: readonly RowPatch[],
103
+ writes: readonly string[] = [],
104
+ ): void {
89
105
  const type = registration.type;
90
106
  // A window holds RECORD keys: a patch the server keyed is folded under its key, not its id.
91
107
  const patches = sent.map((patch) =>
@@ -93,10 +109,16 @@ export class RowWindows {
93
109
  );
94
110
  // A `delete` is this window losing the row, never the store losing it: another holder keeps it.
95
111
  this.#reseat(registration, type, orderAfterPatches(registration.ids, patches), (held) => {
112
+ // Only a row merged here is truth this frame carried. A `delete` is membership, not the row:
113
+ // a write it names keeps its overlay on that row until the server's next word on it.
114
+ const carried = new Set<RecordKey>();
96
115
  for (const patch of patches) {
97
116
  if (patch.op === 'delete' || patch.row === null) continue;
98
- if (held.has(patch.id)) this.#store.merge(type, patch.id, patch.row);
117
+ if (!held.has(patch.id)) continue;
118
+ this.#store.merge(type, patch.id, patch.row);
119
+ carried.add(recordKey(type, patch.id));
99
120
  }
121
+ settle(this.#store, writes, carried);
100
122
  });
101
123
  }
102
124
 
@@ -144,3 +166,10 @@ function holds(registration: Registration, changed: ReadonlySet<RecordKey>): boo
144
166
  }
145
167
  return false;
146
168
  }
169
+
170
+ /** Each named write this page still holds, settled against the rows the frame merged. */
171
+ function settle(store: RecordStore, writes: readonly string[], merged: Iterable<RecordKey>): void {
172
+ if (writes.length === 0) return;
173
+ const carried = new Set(merged);
174
+ for (const write of writes) store.settleWrite(write, carried);
175
+ }
@@ -0,0 +1,50 @@
1
+ // When a live subscribe spends the read's declared `rateLimit:`, and against whom. Split from
2
+ // `live-query.ts` at its size ceiling: the registry decides WHAT is served, this decides what it
3
+ // costs — once per subscribe that reads, on the node's clock, keyed by the socket's own address.
4
+
5
+ import type { Clock } from '@ultimat3/core';
6
+ import type { LiveCursor } from './cursor';
7
+ import type { JsonValue } from './json';
8
+ import type { LiveQueryDefinition } from './live-contract';
9
+ import type { SyncSocket } from './socket';
10
+
11
+ export interface SubscribeArgs {
12
+ readonly socket: SyncSocket;
13
+ readonly name: string;
14
+ readonly input: JsonValue;
15
+ readonly sid?: string;
16
+ readonly cursor?: LiveCursor | null;
17
+ }
18
+
19
+ /**
20
+ * One subscribe's bill, shared by every pass of it. `subscribe` serves AGAIN when the socket's
21
+ * actor is replaced under it, and that second pass is the same subscription: spending there
22
+ * charged one subscribe twice.
23
+ */
24
+ export interface Charge {
25
+ due: boolean;
26
+ }
27
+
28
+ /**
29
+ * Spend once, then never again for this subscribe — called by the registry immediately before
30
+ * any DATABASE READ this subscriber causes: a cold snapshot, a resume that fell back to one, or a
31
+ * delta resume onto an entry nothing has read yet. Only a real in-window delta served out of the
32
+ * ring is free. Never decided from the cursor: its `qid` is computable from the query, the input
33
+ * and the org, so a cursor is a client's claim to a window, not proof it held one — trusting it
34
+ * made every subscribe+unsubscribe cycle a free read. `due` drops before the await, so a pass
35
+ * racing this one (the re-auth retry) cannot pay as well.
36
+ */
37
+ export async function spendOnce(
38
+ definition: LiveQueryDefinition,
39
+ socket: SyncSocket,
40
+ charge: Charge,
41
+ clock: Clock,
42
+ ): Promise<void> {
43
+ if (!charge.due) return;
44
+ charge.due = false;
45
+ await definition.spend?.({
46
+ actor: socket.actor,
47
+ clientAddress: socket.clientAddress,
48
+ nowMs: clock.now().getTime(),
49
+ });
50
+ }
@@ -115,6 +115,11 @@ export class MemoryLocalStore implements LocalStore {
115
115
  }
116
116
  }
117
117
 
118
+ /** The one way to build the in-process store (tests, a browser with no IndexedDB); the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
119
+ export function memoryLocalStore(): MemoryLocalStore {
120
+ return new MemoryLocalStore();
121
+ }
122
+
118
123
  class IdbLocalStore implements LocalStore {
119
124
  readonly kind = 'indexeddb';
120
125
  constructor(private readonly db: IdbDatabaseLike) {}
@@ -6,7 +6,13 @@
6
6
 
7
7
  import { type LiveQuery, match, type Patch } from '@ultimat3/query';
8
8
  import type { ChangeEvent } from './changefeed';
9
- import { changedColumns, isJsonObject, type JsonObject, type Row, type RowPatch } from './json';
9
+ import {
10
+ changedColumns,
11
+ isParsedJsonObject,
12
+ type JsonObject,
13
+ type Row,
14
+ type RowPatch,
15
+ } from './json';
10
16
 
11
17
  export interface SubscriptionShape {
12
18
  readonly qid: string;
@@ -37,7 +43,7 @@ export interface IncrementalMatcher {
37
43
 
38
44
  /** The cheap pre-filter. Pure, and the only thing that runs for a change nobody subscribed to. */
39
45
  export function canAffect(shape: SubscriptionShape, change: ChangeEvent): boolean {
40
- if (!shape.entities.includes(change.entity)) return false;
46
+ if (!shape.entities.includes(change.table)) return false;
41
47
  if (shape.orgId !== null && change.orgId !== null && shape.orgId !== change.orgId) return false;
42
48
  if (shape.columns && change.op === 'update' && change.after !== null) {
43
49
  const touched = Object.keys(changedColumns(change.before, change.after));
@@ -89,7 +95,7 @@ export function matcherFor(live: LiveQuery, projection?: () => Projection): Incr
89
95
  const row = change.after ?? change.before;
90
96
  if (!row) return NO_CHANGE;
91
97
  const patches = match<Row>(live.name, live.shape, rows, {
92
- entity: change.entity,
98
+ entity: change.table,
93
99
  op: change.op,
94
100
  row,
95
101
  ...(change.before === null ? {} : { before: change.before }),
@@ -200,7 +206,7 @@ export function applyToWindow(rows: readonly Row[], patches: readonly RowPatch[]
200
206
  export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatch | null {
201
207
  if (candidate === null || candidate === undefined || candidate === false) return null;
202
208
  if (candidate === true) return patchFromChange(change);
203
- if (!isJsonObject(candidate)) return null;
209
+ if (!isParsedJsonObject(candidate)) return null;
204
210
  const op = candidate['op'];
205
211
  if (op !== 'insert' && op !== 'update' && op !== 'delete') return patchFromChange(change);
206
212
  const id = candidate['id'];
@@ -209,7 +215,7 @@ export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatc
209
215
  const base: RowPatch = {
210
216
  op,
211
217
  id: typeof id === 'string' ? id : (change.after?.id ?? change.before?.id ?? ''),
212
- row: op === 'delete' ? null : isJsonObject(rowValue) ? rowValue : (change.after ?? null),
218
+ row: op === 'delete' ? null : isParsedJsonObject(rowValue) ? rowValue : (change.after ?? null),
213
219
  lsn: typeof candidate['lsn'] === 'string' ? candidate['lsn'] : change.lsn,
214
220
  };
215
221
  if (base.id === '') return null;
@@ -33,8 +33,8 @@ export interface NatsRequestManyOptions {
33
33
 
34
34
  /**
35
35
  * What the bus needs from a NATS connection, and nothing more. A subscription outlives a reconnect —
36
- * the client re-establishes it underneath the caller — which is why nothing above this port keeps
37
- * subscription bookkeeping of its own.
36
+ * the client re-establishes it underneath the caller. It does not outlive the client: once the
37
+ * reconnect budget is spent (`onClosed`), the caller dials a new client and binds again.
38
38
  */
39
39
  export interface NatsClient {
40
40
  /** The connected server's version, as `assertServerVersion` reads it. */
@@ -75,6 +75,11 @@ export interface NatsClientOptions {
75
75
  readonly onError?: ((error: unknown) => void) | undefined;
76
76
  /** The library re-established the connection. The cluster behind it may be a different one. */
77
77
  readonly onReconnect?: (() => void) | undefined;
78
+ /**
79
+ * The library gave up: its reconnect budget is spent and this connection is closed for good,
80
+ * subscriptions included. Never called for the caller's own `close()`. The caller re-dials.
81
+ */
82
+ readonly onClosed?: (() => void) | undefined;
78
83
  }
79
84
 
80
85
  /** The one seam a test replaces: a client that never touches a socket. */
@@ -85,6 +90,23 @@ export const DEFAULT_NATS_PORT = 4222;
85
90
  const NATS_URL_FIX = 'export NATS_URL=nats://user:password@host:4222';
86
91
  export const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
87
92
 
93
+ /**
94
+ * One URL credential, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a stray
95
+ * `%` (`50%off`), so a secret pasted unencoded failed boot with no code and no fix. Refused under
96
+ * the part's NAME and the host — never the value: it is the credential, and a cause reaches a log.
97
+ */
98
+ function decodeCredential(raw: string, part: 'user' | 'password', host: string): string {
99
+ try {
100
+ return decodeURIComponent(raw);
101
+ } catch {
102
+ throw new TransportUnavailableError({
103
+ transport: 'nats',
104
+ reason: `the connection URL for ${host} has a malformed percent-escape in its ${part}`,
105
+ fix: `bun -e 'console.log(encodeURIComponent(process.argv[1]))' '<the raw value>' # then put the result in the ${part} of NATS_URL`,
106
+ });
107
+ }
108
+ }
109
+
88
110
  /**
89
111
  * `nats://user:pass@host:4222`. The one place a bus URL is read — the library takes a bare
90
112
  * `host:port` plus credentials as options, and never looks at a URL's userinfo.
@@ -127,8 +149,8 @@ export function parseNatsUrl(url: string): NatsTarget {
127
149
  fix: 'set the URL to nats://<user>:<pass>@host:4222, or the bare-token form nats://<token>@host:4222',
128
150
  });
129
151
  }
130
- const user = hasUser ? decodeURIComponent(parsed.username) : undefined;
131
- const pass = hasPass ? decodeURIComponent(parsed.password) : undefined;
152
+ const user = hasUser ? decodeCredential(parsed.username, 'user', parsed.hostname) : undefined;
153
+ const pass = hasPass ? decodeCredential(parsed.password, 'password', parsed.hostname) : undefined;
132
154
  return {
133
155
  host: parsed.hostname,
134
156
  port: parsed.port === '' ? DEFAULT_NATS_PORT : Number.parseInt(parsed.port, 10),
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // WHERE FAILURES ARE TRANSLATED, and it is deliberately not all here. This file coded the calls
6
6
  // that have no synchronous caller frame to catch them: `request`, `requestMany`, the dial, and the
7
- // background `#watch` that is the only place a lost connection is announced. `publish` and
7
+ // background `#watch`/`#watchClosed`, the only places a lost connection is announced. `publish` and
8
8
  // `subscribe` are synchronous and stay raw — `NatsTransport.#translating` codes them, because
9
9
  // `NatsTransportOptions.connect` is a PUBLIC injection seam: translating in this class would cover
10
10
  // the one client the repo ships and leave every app-supplied one uncovered, and translating in both
@@ -72,6 +72,8 @@ class LibNatsClient implements NatsClient {
72
72
  readonly #timeoutMs: number;
73
73
  readonly #report: (error: unknown) => void;
74
74
  #connected = true;
75
+ /** Our own `close()` ends the status stream too, and that end is not the library giving up. */
76
+ #closing = false;
75
77
 
76
78
  constructor(connection: NatsConnection, target: NatsTarget, options: NatsClientOptions) {
77
79
  this.#connection = connection;
@@ -155,19 +157,22 @@ class LibNatsClient implements NatsClient {
155
157
  }
156
158
 
157
159
  async close(): Promise<void> {
160
+ this.#closing = true;
158
161
  this.#connected = false;
159
162
  await this.#connection.close();
160
163
  }
161
164
 
162
165
  /**
163
- * The library's own status stream is the only place a background loss is announced. Nothing
166
+ * The library's own status stream is the only place a drop or a recovery is announced. Nothing
164
167
  * awaits it, so it can neither throw nor end the process: a drop reports and flips `connected`,
165
168
  * a reconnect flips it back and tells the transport its cluster may be a new one.
166
169
  */
167
170
  async #watch(options: NatsClientOptions): Promise<void> {
168
171
  const report = options.onError ?? ((): void => undefined);
172
+ const stream = this.#connection.status();
173
+ void this.#watchClosed(options, stream);
169
174
  try {
170
- for await (const status of this.#connection.status()) {
175
+ for await (const status of stream) {
171
176
  if (status.type === Events.Disconnect) {
172
177
  this.#connected = false;
173
178
  report(unavailable(this.#target, 'the connection dropped'));
@@ -178,17 +183,41 @@ class LibNatsClient implements NatsClient {
178
183
  report(unavailable(this.#target, `the server reported ${String(status.data)}`));
179
184
  }
180
185
  }
181
- // The iterator ends when the connection is done: either `close()` or a reconnect budget spent.
182
- this.#connected = false;
183
- const failure = await this.#connection.closed();
184
- if (failure !== undefined) report(unavailable(this.#target, describe(failure)));
185
186
  } catch (error) {
186
187
  this.#connected = false;
187
188
  report(unavailable(this.#target, describe(error)));
188
189
  }
189
190
  }
191
+
192
+ /**
193
+ * The end of the connection, read off `closed()` — never off the status stream, which in
194
+ * nats@2.29.3 does NOT end when the connection closes (the library stops its protocol's
195
+ * listeners, not the connection's). A close we did not ask for is the library giving up on its
196
+ * reconnect budget: `onClosed`, so the transport replaces this client rather than handing a dead
197
+ * one out forever. The stream is stopped here, or every replaced client leaks a pending loop.
198
+ */
199
+ async #watchClosed(options: NatsClientOptions, stream: unknown): Promise<void> {
200
+ let failure: unknown;
201
+ try {
202
+ failure = await this.#connection.closed();
203
+ } catch (error) {
204
+ failure = error;
205
+ }
206
+ this.#connected = false;
207
+ stopStream(stream);
208
+ if (this.#closing) return;
209
+ if (failure !== undefined) options.onError?.(unavailable(this.#target, describe(failure)));
210
+ options.onClosed?.();
211
+ }
190
212
  }
191
213
 
214
+ /** `QueuedIterator.stop()` is the library's, and missing from the `AsyncIterable` it is typed as. */
215
+ const stopStream = (stream: unknown): void => {
216
+ if (typeof stream !== 'object' || stream === null || !('stop' in stream)) return;
217
+ const stop: unknown = stream.stop;
218
+ if (typeof stop === 'function') stop.call(stream);
219
+ };
220
+
192
221
  /**
193
222
  * The production `NatsConnect`. The first dial retries on the same budget as a later loss
194
223
  * (`waitOnFirstConnect`), so a `sync` container that raced the bus into readiness recovers on its