@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.
- package/CLAUDE.md +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
package/src/client-frames.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
21
|
-
*
|
|
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
|
|
28
|
+
export interface ClientFrameTarget {
|
|
35
29
|
registration(sid: string): Registration | undefined;
|
|
36
|
-
/** The projection every live window renders through. Rows live in
|
|
30
|
+
/** The projection every live window renders through. Rows live in the store, never on a frame. */
|
|
37
31
|
readonly windows: RowWindows;
|
|
38
|
-
|
|
39
|
-
readonly
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
94
|
-
//
|
|
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.
|
|
98
|
-
registration.
|
|
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.
|
|
70
|
+
registration.state = 'live';
|
|
71
|
+
target.windows.patch(registration, frame.patches);
|
|
116
72
|
return;
|
|
117
73
|
}
|
|
118
|
-
//
|
|
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
|
-
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 '
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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;
|