@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.
- package/CLAUDE.md +641 -0
- package/README.md +336 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +202 -20
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +99 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +187 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +225 -34
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +324 -248
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- 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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
}
|