@ultimat3/realtime 20.2.1 → 22.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 (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
@@ -3,42 +3,34 @@
3
3
  // every piece of the client a frame may touch, so the blast radius of a new frame kind is a
4
4
  // reviewable list rather than "whatever the router could reach through `this`".
5
5
 
6
+ import type { ChannelBook } from './client-channels';
6
7
  import { CLOSE } from './close-codes';
7
8
  import { advance } from './cursor';
8
- import type { JsonObject, JsonValue } from './json';
9
9
  import type { Registration, RowWindows } from './live-rows';
10
- import type { LocalStore, TableMap } from './local-store';
11
- import type { OfflineQueue } from './offline-queue';
12
- import { type RebaseLog, reconcile, rollbackMutation } from './rebase';
13
- import type { Frame, PresenceMember } from './sync-protocol';
10
+ import type { Frame } from './sync-protocol';
14
11
 
15
12
  /** Declared with the window it projects; re-exported here because the router is what writes it. */
16
13
  export type { LiveState, Registration } from './live-rows';
17
14
 
18
15
  /**
19
16
  * The code a `reconnect` frame closes with: `CLOSE.drain`, the same number the node uses for a
20
- * drain it closes itself, so a log reads one code for one event whichever side closed first. It
21
- * was 1001, and a browser refuses that from script: `WebSocket.close()` throws
22
- * `InvalidAccessError: The close code must be either 1000, or between 3000 and 4999` — measured
23
- * in Chrome, an uncaught exception in every tab on every node drain. The reconnect still happened,
24
- * because the node closed the socket a moment later; the exception was the only trace.
25
- * `HEARTBEAT_TIMEOUT_CODE` in `client.ts` is the sibling, for the other close the client makes.
17
+ * drain it closes itself, so a log reads one code for one event whichever side closed first. A
18
+ * browser refuses 1001 from script (`InvalidAccessError`), which is why it is not that.
26
19
  */
27
20
  export const RECONNECT_CODE = CLOSE.drain;
28
21
 
29
22
  /**
30
23
  * Everything an inbound frame is allowed to reach. Narrow on purpose — a router that took the
31
24
  * client itself could touch the reconnect timer, the socket and the outbound path, none of which
32
- * a received frame has any business writing.
25
+ * a received frame has any business writing. There is no write path here at all: the socket is
26
+ * read-only, and a client write is an HTTP call (`useMutation`).
33
27
  */
34
- export interface ClientFrameTarget<T extends TableMap = TableMap> {
28
+ export interface ClientFrameTarget {
35
29
  registration(sid: string): Registration | undefined;
36
- /** The projection every live window renders through. Rows live in its map, never on a frame. */
30
+ /** The projection every live window renders through. Rows live in the store, never on a frame. */
37
31
  readonly windows: RowWindows;
38
- topicHandlers(topic: string): ReadonlySet<(message: JsonObject) => void> | undefined;
39
- readonly queue: OfflineQueue | undefined;
40
- readonly store: LocalStore<T> | undefined;
41
- readonly log: RebaseLog<T> | undefined;
32
+ /** The declared channels this client holds: their cursors, their handlers, their catch-up. */
33
+ readonly channels: ChannelBook;
42
34
  /** The client's clock. A cursor carries `at`, and nothing here may read `Date.now()`. */
43
35
  now(): number;
44
36
  /** A newer build is live; the app decides when to reload. */
@@ -46,62 +38,26 @@ export interface ClientFrameTarget<T extends TableMap = TableMap> {
46
38
  /** The node assigned this socket its own delay before closing it. */
47
39
  scheduleReconnect(afterMs: number | null): void;
48
40
  closeSocket(code: number, reason: string): void;
49
- notifyQueueChange(): void;
50
- /** Where a promise nobody awaits reports its failure. The client's `onError`, never a swallow. */
51
- detach(work: Promise<unknown>): void;
41
+ /** Where a refusal that names nothing this client holds is reported. */
42
+ report(error: unknown): void;
52
43
  }
53
44
 
54
- /**
55
- * The server refused a mutation: undo its optimistic half. Tier 2 has neither a store nor a log,
56
- * so there is nothing optimistic to undo and the queue entry is the whole record.
57
- */
58
- function rollbackFailed<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
59
- const store = target.store;
60
- const log = target.log;
61
- if (!store || !log) return;
62
- // One batch for the whole undo: the rollback and every mutator replayed behind it are one
63
- // frame's worth of change, so a live window holding those rows renders once.
64
- store.identity.batch(() => {
65
- rollbackMutation({ store, log, key });
66
- });
67
- }
68
-
69
- /**
70
- * The server took it, so the write is no longer optimistic: the journal goes (there is nothing to
71
- * roll back TO any more — this write is what the server has) and the rebase entry goes with it, or
72
- * every later reconcile replays a mutation the server already applied, over rows that have moved
73
- * on. The row itself stays exactly as the twin left it — an accepted write does not flicker.
74
- *
75
- * Both calls are no-ops for a key nothing holds, which is what makes this safe as the tail of the
76
- * `rebase` + `ack` pair: the rebase in front of it has already reconciled and dropped the same key.
77
- */
78
- function commitAccepted<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
79
- target.store?.commit(key);
80
- target.log?.drop(key);
81
- }
82
-
83
- /** Presence members cross the topic channel as plain JSON, like every other channel message. */
84
- function memberJson(member: PresenceMember): JsonValue {
85
- return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
86
- }
87
-
88
- export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrameTarget<T>): void {
45
+ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
89
46
  switch (frame.type) {
90
47
  case 'snapshot': {
91
48
  const registration = target.registration(frame.sid);
92
49
  if (!registration) return;
93
- // The entity is the server's, and it is what upgrades this window from its own private scope
94
- // to the one every other query over the same entity shares.
95
- target.windows.snapshot(registration, frame.entity ?? null, frame.rows);
50
+ // State first: the window notifies once, after it has moved, and a reader must see `live`
51
+ // beside the rows it is handed. The record type is the server's — a browser cannot derive it.
96
52
  registration.cursor = frame.cursor;
97
- registration.setCursor(frame.cursor);
98
- registration.setState('live');
53
+ registration.state = 'live';
54
+ registration.error = undefined;
55
+ target.windows.snapshot(registration, frame.entity ?? null, frame.rows, frame.keys);
99
56
  return;
100
57
  }
101
58
  case 'patch': {
102
59
  const registration = target.registration(frame.sid);
103
60
  if (registration) {
104
- target.windows.patch(registration, frame.patches);
105
61
  // The cursor moves with the patches, not only with a snapshot. Left behind, `cursor.at`
106
62
  // froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for
107
63
  // every client connected longer than `maxLagMs` — the delta resume the retained change
@@ -110,53 +66,26 @@ export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrame
110
66
  if (registration.cursor && frame.lsn !== '') {
111
67
  const next = advance(registration.cursor, frame.patches, frame.lsn, target.now());
112
68
  registration.cursor = next;
113
- registration.setCursor(next);
114
69
  }
115
- registration.setState('live');
70
+ registration.state = 'live';
71
+ target.windows.patch(registration, frame.patches);
116
72
  return;
117
73
  }
118
- // No registration: it is a tier-1 channel message on `sid = topic`.
119
- const handlers = target.topicHandlers(frame.sid);
120
- if (!handlers) return;
121
- for (const patch of frame.patches) {
122
- if (patch.row === null) continue;
123
- for (const handler of handlers) handler(patch.row);
124
- }
74
+ // A patch names a live registration or nothing: a channel's rows ride `records` frames.
125
75
  return;
126
76
  }
127
77
  case 'ack': {
128
- const queue = target.queue;
129
- // A refused mutation is not a mutation: its optimistic twin has to come off the screen, and
130
- // its rebase entry has to leave the log, or a denied write stays rendered forever and every
131
- // later reconcile replays it. `ref` is the mutation key — the same key the `mutate` frame
132
- // carried — which is what makes both halves reachable from one frame.
133
- if (frame.error) rollbackFailed(frame.ref, target);
134
- else commitAccepted(frame.ref, target);
135
- // `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
136
- // than notifying right after the call keeps this correct even if that ordering ever
137
- // changes, and it still fires exactly once the persisted write actually lands.
138
- const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
139
- if (settled) target.detach(settled.then(() => target.notifyQueueChange()));
140
- return;
141
- }
142
- case 'rebase': {
143
- const store = target.store;
144
- const log = target.log;
145
- if (!store || !log) return;
146
- // One batch for the whole reconcile — a rollback, server truth and every replayed mutator
147
- // are one frame's worth of change, so a live window holding those rows renders once.
148
- store.identity.batch(() => {
149
- reconcile({
150
- store,
151
- log,
152
- ack: {
153
- key: frame.key,
154
- entity: frame.entity,
155
- id: frame.row?.id ?? frame.key,
156
- row: frame.row,
157
- },
158
- });
159
- });
78
+ // The socket carries no writes, so an `ack` is only ever a refusal: of a subscription (its
79
+ // `ref` is the sid) or of a frame the node could not read at all (its `ref` is the socket).
80
+ if (frame.error === null) return;
81
+ const registration = target.registration(frame.ref);
82
+ if (registration === undefined) {
83
+ if (!target.channels.refused(frame.ref, frame.error)) target.report(frame.error);
84
+ return;
85
+ }
86
+ registration.state = 'failed';
87
+ registration.error = frame.error;
88
+ registration.notify();
160
89
  return;
161
90
  }
162
91
  case 'reconnect': {
@@ -170,16 +99,19 @@ export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrame
170
99
  target.setUpdate(frame.buildId);
171
100
  return;
172
101
  }
173
- case 'presence': {
174
- const handlers = target.topicHandlers(frame.topic);
175
- if (!handlers) return;
176
- const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
177
- for (const handler of handlers) handler(message);
102
+ case 'records':
103
+ // The channel's cursor decides new from duplicate, and a new epoch re-reads the channel;
104
+ // the records go to the store and nowhere else.
105
+ target.channels.records(frame);
106
+ return;
107
+ case 'events':
108
+ target.channels.event(frame);
109
+ return;
110
+ case 'replay-gap':
111
+ target.channels.gap(frame);
178
112
  return;
179
- }
180
113
  case 'hello':
181
114
  case 'subscribe':
182
- case 'mutate':
183
115
  // Client-authored frames: never received. Ignored rather than thrown, so a future
184
116
  // bidirectional use of the same kind cannot break an old client.
185
117
  return;