@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,175 @@
1
+ // What a RECEIVED frame does to client state — the mirror of `sync-node.ts`'s inbound handler,
2
+ // and the only inbound surface `client.ts` exposes. `ClientFrameTarget` is the point: it names
3
+ // every piece of the client a frame may touch, so the blast radius of a new frame kind is a
4
+ // reviewable list rather than "whatever the router could reach through `this`".
5
+
6
+ import { advance } from './cursor';
7
+ import type { JsonObject, JsonValue } from './json';
8
+ import type { Registration, RowWindows } from './live-rows';
9
+ import type { LocalStore, TableMap } from './local-store';
10
+ import type { OfflineQueue } from './offline-queue';
11
+ import { type RebaseLog, reconcile, rollbackMutation } from './rebase';
12
+ import type { Frame, PresenceMember } from './sync-protocol';
13
+
14
+ /** Declared with the window it projects; re-exported here because the router is what writes it. */
15
+ export type { LiveState, Registration } from './live-rows';
16
+
17
+ /**
18
+ * Everything an inbound frame is allowed to reach. Narrow on purpose — a router that took the
19
+ * client itself could touch the reconnect timer, the socket and the outbound path, none of which
20
+ * a received frame has any business writing.
21
+ */
22
+ export interface ClientFrameTarget<T extends TableMap = TableMap> {
23
+ registration(sid: string): Registration | undefined;
24
+ /** The projection every live window renders through. Rows live in its map, never on a frame. */
25
+ readonly windows: RowWindows;
26
+ topicHandlers(topic: string): ReadonlySet<(message: JsonObject) => void> | undefined;
27
+ readonly queue: OfflineQueue | undefined;
28
+ readonly store: LocalStore<T> | undefined;
29
+ readonly log: RebaseLog<T> | undefined;
30
+ /** The client's clock. A cursor carries `at`, and nothing here may read `Date.now()`. */
31
+ now(): number;
32
+ /** A newer build is live; the app decides when to reload. */
33
+ setUpdate(buildId: string | null): void;
34
+ /** The node assigned this socket its own delay before closing it. */
35
+ scheduleReconnect(afterMs: number | null): void;
36
+ closeSocket(code: number, reason: string): void;
37
+ notifyQueueChange(): void;
38
+ /** Where a promise nobody awaits reports its failure. The client's `onError`, never a swallow. */
39
+ detach(work: Promise<unknown>): void;
40
+ }
41
+
42
+ /**
43
+ * The server refused a mutation: undo its optimistic half. Tier 2 has neither a store nor a log,
44
+ * so there is nothing optimistic to undo and the queue entry is the whole record.
45
+ */
46
+ function rollbackFailed<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
47
+ const store = target.store;
48
+ const log = target.log;
49
+ if (!store || !log) return;
50
+ // One batch for the whole undo: the rollback and every mutator replayed behind it are one
51
+ // frame's worth of change, so a live window holding those rows renders once.
52
+ store.identity.batch(() => {
53
+ rollbackMutation({ store, log, key });
54
+ });
55
+ }
56
+
57
+ /**
58
+ * The server took it, so the write is no longer optimistic: the journal goes (there is nothing to
59
+ * roll back TO any more — this write is what the server has) and the rebase entry goes with it, or
60
+ * every later reconcile replays a mutation the server already applied, over rows that have moved
61
+ * on. The row itself stays exactly as the twin left it — an accepted write does not flicker.
62
+ *
63
+ * Both calls are no-ops for a key nothing holds, which is what makes this safe as the tail of the
64
+ * `rebase` + `ack` pair: the rebase in front of it has already reconciled and dropped the same key.
65
+ */
66
+ function commitAccepted<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
67
+ target.store?.commit(key);
68
+ target.log?.drop(key);
69
+ }
70
+
71
+ /** Presence members cross the topic channel as plain JSON, like every other channel message. */
72
+ function memberJson(member: PresenceMember): JsonValue {
73
+ return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
74
+ }
75
+
76
+ export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrameTarget<T>): void {
77
+ switch (frame.type) {
78
+ case 'snapshot': {
79
+ const registration = target.registration(frame.sid);
80
+ if (!registration) return;
81
+ // The entity is the server's, and it is what upgrades this window from its own private scope
82
+ // to the one every other query over the same entity shares.
83
+ target.windows.snapshot(registration, frame.entity ?? null, frame.rows);
84
+ registration.cursor = frame.cursor;
85
+ registration.setCursor(frame.cursor);
86
+ registration.setState('live');
87
+ return;
88
+ }
89
+ case 'patch': {
90
+ const registration = target.registration(frame.sid);
91
+ if (registration) {
92
+ target.windows.patch(registration, frame.patches);
93
+ // The cursor moves with the patches, not only with a snapshot. Left behind, `cursor.at`
94
+ // froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for
95
+ // every client connected longer than `maxLagMs` — the delta resume the retained change
96
+ // window exists for, dead exactly during the deploy storm it was built for. An empty lsn
97
+ // is a tier-1 channel frame's, so it never rewinds one.
98
+ if (registration.cursor && frame.lsn !== '') {
99
+ const next = advance(registration.cursor, frame.patches, frame.lsn, target.now());
100
+ registration.cursor = next;
101
+ registration.setCursor(next);
102
+ }
103
+ registration.setState('live');
104
+ return;
105
+ }
106
+ // No registration: it is a tier-1 channel message on `sid = topic`.
107
+ const handlers = target.topicHandlers(frame.sid);
108
+ if (!handlers) return;
109
+ for (const patch of frame.patches) {
110
+ if (patch.row === null) continue;
111
+ for (const handler of handlers) handler(patch.row);
112
+ }
113
+ return;
114
+ }
115
+ case 'ack': {
116
+ const queue = target.queue;
117
+ // A refused mutation is not a mutation: its optimistic twin has to come off the screen, and
118
+ // its rebase entry has to leave the log, or a denied write stays rendered forever and every
119
+ // later reconcile replays it. `ref` is the mutation key — the same key the `mutate` frame
120
+ // carried — which is what makes both halves reachable from one frame.
121
+ if (frame.error) rollbackFailed(frame.ref, target);
122
+ else commitAccepted(frame.ref, target);
123
+ // `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
124
+ // than notifying right after the call keeps this correct even if that ordering ever
125
+ // changes, and it still fires exactly once the persisted write actually lands.
126
+ const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
127
+ if (settled) target.detach(settled.then(() => target.notifyQueueChange()));
128
+ return;
129
+ }
130
+ case 'rebase': {
131
+ const store = target.store;
132
+ const log = target.log;
133
+ if (!store || !log) return;
134
+ // One batch for the whole reconcile — a rollback, server truth and every replayed mutator
135
+ // are one frame's worth of change, so a live window holding those rows renders once.
136
+ store.identity.batch(() => {
137
+ reconcile({
138
+ store,
139
+ log,
140
+ ack: {
141
+ key: frame.key,
142
+ entity: frame.entity,
143
+ id: frame.row?.id ?? frame.key,
144
+ row: frame.row,
145
+ },
146
+ });
147
+ });
148
+ return;
149
+ }
150
+ case 'reconnect': {
151
+ // Order is load-bearing: arming first is what makes the close this triggers keep the delay
152
+ // the node assigned to *this* socket instead of falling back to a local backoff.
153
+ target.scheduleReconnect(frame.afterMs);
154
+ target.closeSocket(1001, frame.reason);
155
+ return;
156
+ }
157
+ case 'update-available': {
158
+ target.setUpdate(frame.buildId);
159
+ return;
160
+ }
161
+ case 'presence': {
162
+ const handlers = target.topicHandlers(frame.topic);
163
+ if (!handlers) return;
164
+ const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
165
+ for (const handler of handlers) handler(message);
166
+ return;
167
+ }
168
+ case 'hello':
169
+ case 'subscribe':
170
+ case 'mutate':
171
+ // Client-authored frames: never received. Ignored rather than thrown, so a future
172
+ // bidirectional use of the same kind cannot break an old client.
173
+ return;
174
+ }
175
+ }
@@ -0,0 +1,77 @@
1
+ // The client's liveness pass: re-announce this socket before the node forgets it, and notice a
2
+ // socket that has stopped answering. A policy (when to beat, when to give up), not a wire detail —
3
+ // which is why it is here and not inside `client.ts`'s connection lifecycle.
4
+
5
+ import type { Scheduler } from './thundering-herd';
6
+
7
+ /**
8
+ * The same 15s as `realtime.heartbeatMs` in `@ultimat3/core`'s config, restated rather than read:
9
+ * that value is server configuration and this is browser code, so the client cannot reach it. The
10
+ * two are kept equal on purpose — a node's presence TTL is sized against this interval.
11
+ */
12
+ export const DEFAULT_HEARTBEAT_MS = 15_000;
13
+
14
+ export interface HeartbeatOptions {
15
+ /** `0` (or less) disables the pass entirely — the shape a test that owns the clock wants. */
16
+ readonly intervalMs: number;
17
+ readonly schedule: Scheduler;
18
+ readonly now: () => number;
19
+ /** Re-announces this socket. Called only while the socket is still answering. */
20
+ readonly beat: () => void;
21
+ /** Two windows of silence: the socket is half-open and only this client can end it. */
22
+ readonly onSilence: () => void;
23
+ }
24
+
25
+ /**
26
+ * One armed tick at a time, re-armed by itself. It is deliberately NOT an interval: the reconnect
27
+ * timer is the same injected `Scheduler` seam, and a client is either beating on a live socket or
28
+ * backing off towards a new one — never both, so one armed timer is the whole mechanism.
29
+ */
30
+ export class Heartbeat {
31
+ readonly #options: HeartbeatOptions;
32
+ #cancel: (() => void) | null = null;
33
+ #lastSeen = 0;
34
+
35
+ constructor(options: HeartbeatOptions) {
36
+ this.#options = options;
37
+ }
38
+
39
+ /** The socket is up. `now` seeds the silence window, so the first tick judges this connection. */
40
+ start(now: number): void {
41
+ this.stop();
42
+ if (this.#options.intervalMs <= 0) return;
43
+ this.#lastSeen = now;
44
+ this.#arm();
45
+ }
46
+
47
+ /** A frame arrived. Anything counts: the point is that bytes still cross in this direction. */
48
+ saw(now: number): void {
49
+ this.#lastSeen = now;
50
+ }
51
+
52
+ stop(): void {
53
+ const cancel = this.#cancel;
54
+ this.#cancel = null;
55
+ cancel?.();
56
+ }
57
+
58
+ #arm(): void {
59
+ this.#cancel = this.#options.schedule(() => {
60
+ this.#cancel = null;
61
+ this.#tick();
62
+ }, this.#options.intervalMs);
63
+ }
64
+
65
+ #tick(): void {
66
+ const now = this.#options.now();
67
+ // Two windows, not one: a beat and the answer to it share the window they were sent in, so a
68
+ // single quiet interval is a slow round trip and not a dead socket. Nothing is re-armed after
69
+ // a silence — `onSilence` drops the socket, and the next `start()` is the next connection's.
70
+ if (now - this.#lastSeen > this.#options.intervalMs * 2) {
71
+ this.#options.onSilence();
72
+ return;
73
+ }
74
+ this.#options.beat();
75
+ this.#arm();
76
+ }
77
+ }
@@ -0,0 +1,114 @@
1
+ // The outbound mutation path: the optimistic twin, the durable queue entry, and the sender the
2
+ // drain hands each frame to. One file because those are the three places a single intent is
3
+ // recorded, and an intent that reaches two of them is the divergence tier 3 exists to prevent.
4
+
5
+ import { uuid } from '@ultimat3/core';
6
+ import type { ClientSocket, MutatorRef } from './client-contract';
7
+ import { TransportUnavailableError } from './errors';
8
+ import type { JsonValue } from './json';
9
+ import type { LocalStore, TableMap } from './local-store';
10
+ import { type MutationSender, mutateFrame, type OfflineQueue } from './offline-queue';
11
+ import type { RebaseLog } from './rebase';
12
+ import { encode, type Frame } from './sync-protocol';
13
+
14
+ /**
15
+ * Queued bytes past which the drain stops rather than adds. The same number the node uses at its
16
+ * end of the socket (`DEFAULT_MAX_BUFFERED_BYTES`), and deliberately NOT imported from it: this is
17
+ * browser code, and `socket.ts` is the node's socket registry, its metrics and its close codes —
18
+ * one import pulls the whole server half into the tab's bundle to read an integer. The node's two
19
+ * spellings were merged because they configure one buffer on one side; these are two sides.
20
+ */
21
+ export const MAX_BUFFERED_BYTES = 1024 * 1024;
22
+
23
+ /** Everything the mutation path touches. Narrow on purpose, exactly like `ClientFrameTarget`. */
24
+ export interface MutationDeps<T extends TableMap = TableMap> {
25
+ readonly store: LocalStore<T> | undefined;
26
+ readonly queue: OfflineQueue | undefined;
27
+ readonly log: RebaseLog<T> | undefined;
28
+ readonly now: () => number;
29
+ /** Read per send, never captured: the socket a drain started on may already be gone. */
30
+ socket(): ClientSocket | null;
31
+ send(frame: Frame): void;
32
+ }
33
+
34
+ /**
35
+ * Record one intent everywhere it has to be recorded: the local store (so the UI moves now), the
36
+ * rebase log (so it can be taken back) and the durable queue (so it survives the tab). Nothing is
37
+ * sent here — `drain` is the only thing that puts a mutation on a socket, and with no queue at all
38
+ * (tier 2) the frame goes straight out because there is nothing to drain it from later.
39
+ */
40
+ export async function recordMutation<T extends TableMap>(
41
+ deps: MutationDeps<T>,
42
+ mutator: MutatorRef<T>,
43
+ input: JsonValue,
44
+ key?: string,
45
+ ): Promise<void> {
46
+ const idempotencyKey = key ?? `${mutator.name}:${uuid()}`;
47
+ const { store, queue } = deps;
48
+ const local = mutator.local;
49
+ const existing = queue?.find(idempotencyKey);
50
+ const queued = await queue?.enqueue({
51
+ key: idempotencyKey,
52
+ name: mutator.name,
53
+ input,
54
+ at: deps.now(),
55
+ });
56
+ // Identity, not a second copy of the queue's collapse rule: `enqueue` hands back the SAME entry
57
+ // when it collapses and a new one when it does not. A repeated key is ONE intent whose twin is
58
+ // already applied — applying it again double-counts the write (a like becomes two) and replaces
59
+ // the log entry a rollback would have undone to the pre-mutation row.
60
+ const collapsed = existing !== undefined && queued === existing;
61
+ if (store && local && !collapsed) {
62
+ store.apply(idempotencyKey, (tx) => local(tx, input));
63
+ deps.log?.record({
64
+ key: idempotencyKey,
65
+ seq: queued?.seq ?? 0,
66
+ entity: mutator.entity ?? mutator.name,
67
+ strategy: mutator.conflict ?? 'server-wins',
68
+ apply: (tx) => local(tx, input),
69
+ });
70
+ }
71
+ if (queue) return;
72
+ deps.send(
73
+ mutateFrame({
74
+ key: idempotencyKey,
75
+ seq: 0,
76
+ name: mutator.name,
77
+ input,
78
+ enqueuedAt: deps.now(),
79
+ attempts: 0,
80
+ status: 'pending',
81
+ error: null,
82
+ }),
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The queue's sender. Throwing is how a sender declines: the queue keeps that mutation pending,
88
+ * stops the pass rather than reordering the ones behind it, and the next drain resumes there.
89
+ *
90
+ * Backpressure is a decline and not a failure — the frames already queued in the tab are ones the
91
+ * socket has not managed to write, so adding to them is how a client sends a burst it will never
92
+ * see acknowledged. A socket that does not report `bufferedAmount` is treated as never backed up.
93
+ */
94
+ export function mutationSender<T extends TableMap>(deps: MutationDeps<T>): MutationSender {
95
+ return async (mutation) => {
96
+ const socket = deps.socket();
97
+ if (!socket) {
98
+ throw new TransportUnavailableError({
99
+ transport: 'websocket',
100
+ reason: 'the socket went away before this mutation reached it',
101
+ fix: 'it stays queued: await useMutationQueue().drain() once useConnection().online',
102
+ });
103
+ }
104
+ const buffered = socket.bufferedAmount ?? 0;
105
+ if (buffered > MAX_BUFFERED_BYTES) {
106
+ throw new TransportUnavailableError({
107
+ transport: 'websocket',
108
+ reason: `${buffered} bytes are already queued on this socket, over the ${MAX_BUFFERED_BYTES} ceiling`,
109
+ fix: 'it stays queued: await useMutationQueue().drain() once the socket has caught up',
110
+ });
111
+ }
112
+ socket.send(encode(mutateFrame(mutation)));
113
+ };
114
+ }
@@ -0,0 +1,54 @@
1
+ // The client's channel book: which handlers hold which topic, and the one frame that announces a
2
+ // membership. Split out of `client.ts` because the announcement has two callers that must never
3
+ // disagree — `subscribe()` and the reconnect replay — and one of them was missing.
4
+
5
+ import type { Topic } from './channel';
6
+ import type { JsonObject } from './json';
7
+ import { PROTOCOL_VERSION, type SubscribeFrame } from './sync-protocol';
8
+
9
+ export type TopicHandler = (message: JsonObject) => void;
10
+
11
+ /**
12
+ * The membership frame. `sid` is the topic itself: a channel subscription is identified by what it
13
+ * is subscribed to, so re-sending it after a reconnect re-establishes the same membership rather
14
+ * than a second one — and on the node, sending it again IS the presence heartbeat.
15
+ */
16
+ export function topicSubscribeFrame(name: string, op: 'add' | 'drop'): SubscribeFrame {
17
+ return {
18
+ type: 'subscribe',
19
+ v: PROTOCOL_VERSION,
20
+ op,
21
+ sid: name,
22
+ target: { kind: 'topic', topic: name },
23
+ };
24
+ }
25
+
26
+ /** Topic -> the handlers holding it. One entry per topic, however many components subscribed. */
27
+ export class TopicBook {
28
+ readonly #topics = new Map<string, Set<TopicHandler>>();
29
+
30
+ add(name: Topic, handler: TopicHandler): void {
31
+ const handlers = this.#topics.get(name) ?? new Set<TopicHandler>();
32
+ handlers.add(handler);
33
+ this.#topics.set(name, handlers);
34
+ }
35
+
36
+ /** True when that was the last holder, so the caller is the one that sends the drop frame. */
37
+ remove(name: Topic, handler: TopicHandler): boolean {
38
+ const handlers = this.#topics.get(name);
39
+ if (!handlers) return false;
40
+ handlers.delete(handler);
41
+ if (handlers.size > 0) return false;
42
+ this.#topics.delete(name);
43
+ return true;
44
+ }
45
+
46
+ handlers(name: string): ReadonlySet<TopicHandler> | undefined {
47
+ return this.#topics.get(name);
48
+ }
49
+
50
+ /** Every membership this client still holds — what a reconnect has to re-announce. */
51
+ names(): readonly string[] {
52
+ return [...this.#topics.keys()];
53
+ }
54
+ }