@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
@@ -0,0 +1,187 @@
1
+ // The shared, pre-policy result window one query id is served from: how it is built, how it is
2
+ // read once for N subscribers, and how one that is known to be wrong is replaced. The authz
3
+ // decision is never here — `live-query.ts` owns that, once per subscriber, over what this returns.
4
+
5
+ import type { JsonValue, Row } from './json';
6
+ import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
7
+ import type { IncrementalMatcher, SubscriptionShape } from './matcher-bridge';
8
+ import { WindowLock } from './window-lock';
9
+
10
+ export interface QueryEntry {
11
+ readonly qid: string;
12
+ readonly definition: LiveQueryDefinition;
13
+ readonly input: JsonValue;
14
+ /** Told to the client on every snapshot: the identity scope its rows belong under. */
15
+ readonly rowEntity: string | null;
16
+ readonly shape: SubscriptionShape;
17
+ readonly matcher: IncrementalMatcher;
18
+ readonly subscribers: Map<string, LiveSubscription>;
19
+ /**
20
+ * The shared, *pre-policy* result window. One per query id, bounded by the query's `limit`, and
21
+ * the reason the matcher can run once for N subscribers: the read is shared, the authz is not.
22
+ */
23
+ rows: readonly Row[];
24
+ lsn: string;
25
+ /**
26
+ * This window is known to have missed at least one change, so patching it would compound the
27
+ * error. Set when the change stream skipped a sequence and when a matcher reports it lost the
28
+ * window's tail; cleared by the read that replaces the rows. It is *not* a subscriber's desync —
29
+ * that is per socket, and this is the window every one of them shares.
30
+ */
31
+ stale: boolean;
32
+ /** Serial lane over `rows`/`lsn`. Every fanout and every window assignment takes its turn here. */
33
+ readonly lock: WindowLock;
34
+ /** The read in flight, shared by every subscriber that arrives during it. `null` between reads. */
35
+ reading: PendingRead | null;
36
+ /**
37
+ * Reads issued against this entry, ever. It is the ORDER of two reads, which nothing else here
38
+ * can answer: a definition with no lsn provider returns `''` from every snapshot.
39
+ */
40
+ generation: number;
41
+ /** The generation of the newest read whose rows are in `rows`. `0` before the first one lands. */
42
+ applied: number;
43
+ }
44
+
45
+ /**
46
+ * One read and which read it is. They are one fact — a joiner needs the promise AND the generation
47
+ * it will have to compare against when it lands — and two fields on the entry is two writes a later
48
+ * edit can separate.
49
+ */
50
+ export interface PendingRead {
51
+ readonly generation: number;
52
+ readonly result: Promise<SnapshotResult>;
53
+ }
54
+
55
+ export function createEntry(
56
+ qid: string,
57
+ definition: LiveQueryDefinition,
58
+ input: JsonValue,
59
+ matcher: IncrementalMatcher,
60
+ ): QueryEntry {
61
+ return {
62
+ qid,
63
+ definition,
64
+ input,
65
+ // Resolved with the matcher, from the same build: `prepare` has already run, so a definition
66
+ // that compiles its shape per input can answer.
67
+ rowEntity: definition.rowEntity?.(input) ?? null,
68
+ shape: {
69
+ qid,
70
+ // The matcher knows the dependency set this *input* produced; `definition.entities` is the
71
+ // static declaration and can only be a superset of it. Preferring the matcher is what lets a
72
+ // definition built from a real query carry no static list at all.
73
+ entities: matcher.entities.length > 0 ? matcher.entities : definition.entities,
74
+ orgId: orgIdOf(input),
75
+ ...(definition.columns ? { columns: definition.columns } : {}),
76
+ },
77
+ matcher,
78
+ subscribers: new Map(),
79
+ rows: [],
80
+ lsn: '',
81
+ stale: false,
82
+ lock: new WindowLock(),
83
+ reading: null,
84
+ generation: 0,
85
+ applied: 0,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The window this subscriber is served from, read once per entry. A subscriber arriving while
91
+ * another's read is in flight joins that read rather than issuing its own — N cold subscribers on
92
+ * one query id being N reads is the shared window not existing.
93
+ *
94
+ * The result lands in the lane, and never backwards. A snapshot that resolved after a newer change
95
+ * had already been fanned out would rewind every later subscriber to rows the window has moved
96
+ * past, so a stale read is discarded and its caller is served from the newer window instead.
97
+ */
98
+ export async function fillWindow(
99
+ entry: QueryEntry,
100
+ ): Promise<{ rows: readonly Row[]; lsn: string }> {
101
+ // Read before `startRead` clears it: a second caller arriving during the read joins it and is
102
+ // not the one that forced it, which is what keeps one forced read from becoming N.
103
+ const forced = entry.stale;
104
+ const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
105
+ const result = await pending.result;
106
+ return await entry.lock.run(async () => {
107
+ // Two rules, and neither can stand in for the other. Against another READ it is identity —
108
+ // the same check `startRead` makes on `entry.reading` one function down, and the one
109
+ // `packages/cache/src/single-flight.ts` makes for the same reason — because an lsn cannot
110
+ // order two reads at all: a definition with no lsn provider answers `''` for both, and
111
+ // `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
112
+ // `stale` already cleared by its issue and therefore nothing left to re-read. Against a
113
+ // CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
114
+ // in flight and rewinding to what the read saw hands that subscriber rows the fanout has
115
+ // moved past — except for a forced read, which was issued *because* what is under it is
116
+ // wrong.
117
+ if (isNewestRead(entry, pending) && (forced || result.lsn >= entry.lsn)) {
118
+ applyRead(entry, pending, result);
119
+ }
120
+ return { rows: entry.rows, lsn: entry.lsn };
121
+ });
122
+ }
123
+
124
+ /**
125
+ * The same replacement, for a caller that is already holding the lane. A fanout cannot call
126
+ * `fillWindow` — that takes the entry's own lane, and a lane is not reentrant — so the one path
127
+ * that repairs a stale window mid-fanout is spelled here rather than deadlocking on the other.
128
+ */
129
+ export async function refillWindowInLane(entry: QueryEntry): Promise<void> {
130
+ const pending = startRead(entry);
131
+ const result = await pending.result;
132
+ // Same identity rule as `fillWindow`: a read issued before this one may still be in flight, and
133
+ // whichever was issued LAST is the one the window keeps.
134
+ if (isNewestRead(entry, pending)) applyRead(entry, pending, result);
135
+ }
136
+
137
+ /** Is this the newest read to have landed? An older one's rows are behind the window, not on it. */
138
+ function isNewestRead(entry: QueryEntry, pending: PendingRead): boolean {
139
+ return pending.generation > entry.applied;
140
+ }
141
+
142
+ function applyRead(entry: QueryEntry, pending: PendingRead, result: SnapshotResult): void {
143
+ entry.applied = pending.generation;
144
+ entry.rows = result.rows;
145
+ if (result.lsn > entry.lsn) entry.lsn = result.lsn;
146
+ }
147
+
148
+ /** Publishes the in-flight read, and clears it as it settles — the share is per read, not a cache. */
149
+ function startRead(entry: QueryEntry): PendingRead {
150
+ // Cleared here rather than when the read lands: the read about to be issued is the one that
151
+ // answers the staleness, so a second caller must join it instead of forcing another.
152
+ entry.stale = false;
153
+ entry.generation += 1;
154
+ const reading: PendingRead = { generation: entry.generation, result: readSnapshot(entry) };
155
+ entry.reading = reading;
156
+ const done = (): void => {
157
+ if (entry.reading === reading) entry.reading = null;
158
+ };
159
+ void reading.result.then(done, done);
160
+ return reading;
161
+ }
162
+
163
+ /**
164
+ * The definition's read, with the staleness put back when it does not answer.
165
+ *
166
+ * Clearing the mark on the way in and never restoring it was the gap repair happening once and
167
+ * never again: the snapshot that was going to replace an invalidated window rejects — the pool is
168
+ * exhausted by the same incident that caused the gap — and the entry is left unmarked over rows it
169
+ * is known to have missed a change on. Nothing re-reads, `#resnapshot` serves every desynced
170
+ * subscriber out of that divergent window and clears their marks, and the divergence `stale` exists
171
+ * to prevent is now permanent and silent. `async` so a definition that throws synchronously takes
172
+ * the same path as one that rejects.
173
+ */
174
+ async function readSnapshot(entry: QueryEntry): Promise<SnapshotResult> {
175
+ try {
176
+ return await entry.definition.snapshot({ input: entry.input });
177
+ } catch (error) {
178
+ entry.stale = true;
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ export function orgIdOf(input: JsonValue): string | null {
184
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
185
+ const value = input['orgId'];
186
+ return typeof value === 'string' ? value : null;
187
+ }
package/src/rebase.ts CHANGED
@@ -108,26 +108,86 @@ export function reconcile<T extends TableMap = TableMap>(
108
108
  const table = store.table(ack.entity);
109
109
  const local = table.get(ack.id);
110
110
 
111
- // Everything at or after the acked sequence is optimistic and must be undone newest-first.
112
- const affected = log.pending().filter((candidate) => candidate.seq >= (entry?.seq ?? 0));
111
+ const { affected, rolledBack } = undoFrom(store, log, entry?.seq ?? 0);
112
+
113
+ const base = store.table(ack.entity).get(ack.id);
114
+ const winner = land(store, ack, strategy, { local, base }, options);
115
+ log.drop(ack.key);
116
+
117
+ return {
118
+ strategy: strategyName(strategy),
119
+ rolledBack,
120
+ reapplied: replayExcept(store, affected, ack.key),
121
+ winner,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Everything at or after `from` is optimistic, so it is undone newest-first — and `reconcile` and
127
+ * `rollbackMutation` are one rule with different middles, not two. Spelled twice, the next change
128
+ * to the replay order has to be made twice, and the half that is missed diverges silently.
129
+ */
130
+ function undoFrom<T extends TableMap>(
131
+ store: LocalStore<T>,
132
+ log: RebaseLog<T>,
133
+ from: number,
134
+ ): { affected: readonly RebaseEntry<T>[]; rolledBack: string[] } {
135
+ const affected = log.pending().filter((candidate) => candidate.seq >= from);
113
136
  const rolledBack: string[] = [];
114
137
  for (const candidate of [...affected].reverse()) {
115
138
  store.rollback(candidate.key);
116
139
  rolledBack.push(candidate.key);
117
140
  }
141
+ return { affected, rolledBack };
142
+ }
118
143
 
119
- const base = store.table(ack.entity).get(ack.id);
120
- const winner = land(store, ack, strategy, { local, base }, options);
121
- log.drop(ack.key);
122
-
144
+ /**
145
+ * The other half: replay in sequence order, skipping the one the server has now settled. `local` is
146
+ * pure, which is what makes replaying it deterministic and therefore safe to do at all.
147
+ */
148
+ function replayExcept<T extends TableMap>(
149
+ store: LocalStore<T>,
150
+ affected: readonly RebaseEntry<T>[],
151
+ settled: string,
152
+ ): string[] {
123
153
  const reapplied: string[] = [];
124
154
  for (const candidate of affected) {
125
- if (candidate.key === ack.key) continue;
155
+ if (candidate.key === settled) continue;
126
156
  store.apply(candidate.key, (tx) => candidate.apply(tx));
127
157
  reapplied.push(candidate.key);
128
158
  }
159
+ return reapplied;
160
+ }
161
+
162
+ export interface RollbackResult {
163
+ readonly rolledBack: readonly string[];
164
+ readonly reapplied: readonly string[];
165
+ }
166
+
167
+ /**
168
+ * The other half of `reconcile`: the server **refused** a mutation, so there is no server truth to
169
+ * land — only an optimistic write to take back. Same shape as a reconcile, and for the same reason:
170
+ * the writes made after it may depend on it, so everything from its sequence onward is undone
171
+ * newest-first and then replayed without it. Replay is deterministic because `local` is pure.
172
+ *
173
+ * Idempotent for a key the log does not hold: a denial can arrive twice, and tier 2 records nothing
174
+ * to undo in the first place.
175
+ */
176
+ export function rollbackMutation<T extends TableMap = TableMap>(args: {
177
+ store: LocalStore<T>;
178
+ log: RebaseLog<T>;
179
+ key: string;
180
+ }): RollbackResult {
181
+ const { store, log, key } = args;
182
+ const entry = log.get(key);
183
+ if (!entry) return { rolledBack: [], reapplied: [] };
129
184
 
130
- return { strategy: strategyName(strategy), rolledBack, reapplied, winner };
185
+ const { affected, rolledBack } = undoFrom(store, log, entry.seq);
186
+ // The one thing that differs from `reconcile`'s middle: there is no server truth to land. Dropped
187
+ // and never retried — a denial is a decision about this intent, so replaying it on the next
188
+ // reconcile would put the write the server refused back on the screen.
189
+ log.drop(key);
190
+ return { rolledBack, reapplied: replayExcept(store, affected, key) };
131
191
  }
132
192
 
133
193
  function land<T extends TableMap>(
package/src/replicator.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // its feed and reports `/readyz` false; it retries with jittered backoff and takes over the moment
12
12
  // the holder dies. Scaling the replicator is therefore always vertical, and that is by design.
13
13
 
14
- import { logger, withSpan } from '@ultimat3/core';
14
+ import { logger, uuid, withSpan } from '@ultimat3/core';
15
15
  import type { ChangeEvent, ChangeFeed } from './changefeed';
16
16
  import type { Transport } from './fanout';
17
17
  import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
@@ -72,6 +72,26 @@ export interface ReplicatorStats {
72
72
  readonly outOfOrder: number;
73
73
  }
74
74
 
75
+ /**
76
+ * What the bus actually carries: one change, plus who published it and where in that publisher's
77
+ * stream it sits.
78
+ *
79
+ * Fanout is core NATS — `publish`, no ack, at most once — so a consumer that reads only the change
80
+ * cannot tell "nothing happened" from "eleven changes went past while I was reconnecting". An lsn
81
+ * cannot answer it either: a WAL position is a byte offset, so a legitimate next change is an
82
+ * arbitrary jump forwards. A per-publisher counter is the one number a gap is visible in.
83
+ *
84
+ * Both fields are optional on the wire, so a node reading a publisher that predates them simply
85
+ * detects nothing — the same rule the `snapshot.entity` field follows, on a subject no client sees.
86
+ */
87
+ export interface ChangeEnvelope {
88
+ readonly change: ChangeEvent;
89
+ /** Monotonic within `producer`, from 1. `null` from a publisher that does not sequence. */
90
+ readonly seq: number | null;
91
+ /** Identifies one replicator *run*. A new one restarts `seq`, and that is not a gap. */
92
+ readonly producer: string | null;
93
+ }
94
+
75
95
  export interface Replicator {
76
96
  /** `false` = another replicator holds the lock; this process must stay `/readyz` false. */
77
97
  start(): Promise<boolean>;
@@ -91,6 +111,11 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
91
111
  let published = 0;
92
112
  let skipped = 0;
93
113
  let outOfOrder = 0;
114
+ // One id per run, not per process: a replicator that took the lock back after a crash publishes
115
+ // from its persisted lsn, and a consumer must read that as a new stream rather than as a gap in
116
+ // the old one.
117
+ let producer = uuid();
118
+ let seq = 0;
94
119
 
95
120
  const onChange = async (raw: ChangeEvent): Promise<void> => {
96
121
  const change = normalize(raw);
@@ -104,7 +129,13 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
104
129
  return;
105
130
  }
106
131
  await withSpan('realtime.replicate', async () => {
107
- await options.transport.publish(subjectOf(change), JSON.stringify(change));
132
+ seq += 1;
133
+ // The envelope is the change plus two fields, flat, so a consumer that only knows about the
134
+ // change reads it unchanged — `parseChange` still answers on the same payload.
135
+ await options.transport.publish(
136
+ subjectOf(change),
137
+ JSON.stringify({ ...change, seq, producer }),
138
+ );
108
139
  lastLsn = change.lsn;
109
140
  published += 1;
110
141
  });
@@ -118,6 +149,8 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
118
149
  return false;
119
150
  }
120
151
  running = true;
152
+ producer = uuid();
153
+ seq = 0;
121
154
  await options.feed.start(
122
155
  options.from === undefined ? { onChange } : { from: options.from, onChange },
123
156
  );
@@ -163,23 +196,63 @@ export function normalize(change: ChangeEvent): ChangeEvent | null {
163
196
 
164
197
  /** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
165
198
  export function parseChange(payload: string): ChangeEvent | null {
199
+ return parseEnvelope(payload)?.change ?? null;
200
+ }
201
+
202
+ /** The same decode, keeping the two fields a gap is visible in. `parseChange` is this, narrowed. */
203
+ export function parseEnvelope(payload: string): ChangeEnvelope | null {
166
204
  try {
167
205
  const parsed: unknown = JSON.parse(payload);
168
206
  if (typeof parsed !== 'object' || parsed === null) return null;
169
- const shape = parsed as Partial<ChangeEvent>;
207
+ const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
170
208
  if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
171
209
  if (shape.op !== 'insert' && shape.op !== 'update' && shape.op !== 'delete') return null;
172
210
  return {
173
- entity: shape.entity,
174
- op: shape.op,
175
- before: shape.before ?? null,
176
- after: shape.after ?? null,
177
- lsn: shape.lsn,
178
- txid: typeof shape.txid === 'string' ? shape.txid : '',
179
- orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
180
- at: typeof shape.at === 'number' ? shape.at : 0,
211
+ change: {
212
+ entity: shape.entity,
213
+ op: shape.op,
214
+ before: shape.before ?? null,
215
+ after: shape.after ?? null,
216
+ lsn: shape.lsn,
217
+ txid: typeof shape.txid === 'string' ? shape.txid : '',
218
+ orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
219
+ at: typeof shape.at === 'number' ? shape.at : 0,
220
+ },
221
+ seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
222
+ producer: typeof shape.producer === 'string' ? shape.producer : null,
181
223
  };
182
224
  } catch {
183
225
  return null;
184
226
  }
185
227
  }
228
+
229
+ /**
230
+ * The consume-side twin of the publisher's counter, and the only thing on a `sync` node that can
231
+ * say "this node missed changes". Publish-side duplicate and out-of-order guards already existed;
232
+ * there was no equivalent here, so a NATS blip during a rolling restart was eleven changes that
233
+ * simply never happened as far as every subscriber on this node could tell.
234
+ *
235
+ * Per producer, because a replicator restart legitimately rewinds the counter. A repeat or a
236
+ * reordering is *not* reported as a gap — the window's own lsn guard refuses those — and neither is
237
+ * the first message of a stream: a node that joined late has missed everything by definition, and
238
+ * every subscription it holds started after it did.
239
+ */
240
+ export class SeqGapDetector {
241
+ readonly #next = new Map<string, number>();
242
+
243
+ /** `true` when at least one message between the last one and this one was never delivered. */
244
+ observe(envelope: ChangeEnvelope): boolean {
245
+ const { producer, seq } = envelope;
246
+ if (producer === null || seq === null) return false;
247
+ const expected = this.#next.get(producer);
248
+ // Never backwards: a redelivery must not lower the bar and turn the next legitimate message
249
+ // into a gap of its own.
250
+ this.#next.set(producer, Math.max(expected ?? 0, seq + 1));
251
+ return expected !== undefined && seq > expected;
252
+ }
253
+
254
+ /** Producers this node has read. Bounded by replicator restarts, so it is swept on a drain. */
255
+ forget(): void {
256
+ this.#next.clear();
257
+ }
258
+ }