@ultimat3/realtime 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CLAUDE.md +591 -0
  2. package/README.md +320 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +174 -19
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +96 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +151 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +170 -14
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +284 -243
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. package/src/pg-replication-fixture.ts +0 -261
@@ -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
+ }