@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
package/src/change-buffer.ts
CHANGED
|
@@ -1,50 +1,109 @@
|
|
|
1
|
-
// The bounded per-query change window that makes reconnect a delta instead of a refetch.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The bounded per-query change window that makes reconnect a delta instead of a refetch. Inside
|
|
2
|
+
// the window a reconnecting client costs zero DB work; outside it, `resumeFrom` takes one bounded
|
|
3
|
+
// snapshot — never WAL traversal.
|
|
4
|
+
//
|
|
5
|
+
// **It is per `sync` node, and a `qid` window can only be.** The header used to say it lives on the
|
|
6
|
+
// replicator; it does not, and it could not — a patch is query-scoped, so producing one needs that
|
|
7
|
+
// query's compiled shape, its matcher and its current window, none of which the replicator has (it
|
|
8
|
+
// is entity-scoped by construction). The consequence is real and is not fixed here: a client that
|
|
9
|
+
// reconnects onto a node that never served its `qid` finds no ring, `shouldResnapshot` answers
|
|
10
|
+
// `out-of-window`, and it takes the snapshot path. What that costs is one *shared* read per
|
|
11
|
+
// (query, node) — `fillWindow` joins every subscriber arriving during a read into it — and not one
|
|
12
|
+
// read per client. Making the delta path work across nodes means an **entity**-keyed window every
|
|
13
|
+
// node fills from the change stream it already subscribes to, which is a `ResumeSource` shape
|
|
14
|
+
// change, not a placement change.
|
|
4
15
|
|
|
5
16
|
import type { ResumeSource } from './cursor';
|
|
6
17
|
import type { RowPatch } from './json';
|
|
7
18
|
|
|
8
19
|
export interface ChangeBufferOptions {
|
|
9
|
-
/** Retained patches per query hash. */
|
|
20
|
+
/** Retained patches per query hash — a REPLAY bound: what a delta resume may cost to fold. */
|
|
10
21
|
readonly capacity?: number;
|
|
11
22
|
/** Retained query hashes; the least-recently-written is dropped first. */
|
|
12
23
|
readonly maxQueries?: number;
|
|
24
|
+
/** Retained bytes per query hash. The memory bound, and the one that actually holds. */
|
|
25
|
+
readonly maxBytesPerQuery?: number;
|
|
26
|
+
/** Retained bytes across every query on this node. */
|
|
27
|
+
readonly maxBytes?: number;
|
|
13
28
|
}
|
|
14
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The node's retained-patch memory ceiling. `packages/cache/src/lru.ts:1-2` states the rule this
|
|
32
|
+
* exists to obey: bounded by BYTES, never by entry count — 4,096 queries x 1,024 patches is 4.19M
|
|
33
|
+
* retained `RowPatch` objects, each holding a whole row, and nothing in that product is memory.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
36
|
+
export const DEFAULT_MAX_BUFFER_BYTES_PER_QUERY = 1024 * 1024;
|
|
37
|
+
|
|
15
38
|
interface Ring {
|
|
16
39
|
patches: RowPatch[];
|
|
40
|
+
bytes: number;
|
|
17
41
|
/** Highest lsn already dropped. A cursor at or after this is still resumable. */
|
|
18
42
|
evictedThrough: string | null;
|
|
19
43
|
}
|
|
20
44
|
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
|
|
47
|
+
/** What one retained patch costs. Its serialised size: the row is the whole of it. */
|
|
48
|
+
function patchBytes(patch: RowPatch): number {
|
|
49
|
+
return encoder.encode(JSON.stringify(patch)).length;
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
export class RingChangeBuffer implements ResumeSource {
|
|
22
53
|
readonly #rings = new Map<string, Ring>();
|
|
23
54
|
readonly #capacity: number;
|
|
24
55
|
readonly #maxQueries: number;
|
|
56
|
+
readonly #maxBytesPerQuery: number;
|
|
57
|
+
readonly #maxBytes: number;
|
|
58
|
+
#bytes = 0;
|
|
25
59
|
|
|
26
60
|
constructor(options: ChangeBufferOptions = {}) {
|
|
27
61
|
this.#capacity = options.capacity ?? 1024;
|
|
28
62
|
this.#maxQueries = options.maxQueries ?? 4096;
|
|
63
|
+
this.#maxBytesPerQuery = options.maxBytesPerQuery ?? DEFAULT_MAX_BUFFER_BYTES_PER_QUERY;
|
|
64
|
+
this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Retained bytes across every query on this node. The number the ceiling is about. */
|
|
68
|
+
get bytes(): number {
|
|
69
|
+
return this.#bytes;
|
|
29
70
|
}
|
|
30
71
|
|
|
31
72
|
append(qid: string, patch: RowPatch): void {
|
|
32
73
|
const existing = this.#rings.get(qid);
|
|
33
|
-
const ring: Ring = existing ?? { patches: [], evictedThrough: null };
|
|
74
|
+
const ring: Ring = existing ?? { patches: [], bytes: 0, evictedThrough: null };
|
|
34
75
|
ring.patches.push(patch);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
76
|
+
const cost = patchBytes(patch);
|
|
77
|
+
ring.bytes += cost;
|
|
78
|
+
this.#bytes += cost;
|
|
79
|
+
// Two ceilings, because they bound two different things: the count bounds what a resume has
|
|
80
|
+
// to fold, the bytes bound what this process holds. Whichever bites first, bites.
|
|
81
|
+
while (ring.patches.length > this.#capacity || ring.bytes > this.#maxBytesPerQuery) {
|
|
82
|
+
if (!this.#shift(ring)) break;
|
|
38
83
|
}
|
|
39
84
|
// Re-insert to move this qid to the tail of the LRU order.
|
|
40
85
|
if (existing) this.#rings.delete(qid);
|
|
41
86
|
this.#rings.set(qid, ring);
|
|
42
|
-
|
|
87
|
+
while (this.#rings.size > this.#maxQueries || this.#bytes > this.#maxBytes) {
|
|
43
88
|
const oldest = this.#rings.keys().next();
|
|
44
|
-
|
|
89
|
+
// The only ring left is the one just written: evicting it would make a node under memory
|
|
90
|
+
// pressure retain nothing at all, and every reconnect a snapshot.
|
|
91
|
+
if (oldest.done || this.#rings.size === 1) break;
|
|
92
|
+
this.forget(oldest.value);
|
|
45
93
|
}
|
|
46
94
|
}
|
|
47
95
|
|
|
96
|
+
/** Drop the oldest patch of a ring, keeping both byte counters honest. Answers what it did. */
|
|
97
|
+
#shift(ring: Ring): boolean {
|
|
98
|
+
const dropped = ring.patches.shift();
|
|
99
|
+
if (!dropped) return false;
|
|
100
|
+
const cost = patchBytes(dropped);
|
|
101
|
+
ring.bytes -= cost;
|
|
102
|
+
this.#bytes -= cost;
|
|
103
|
+
ring.evictedThrough = dropped.lsn;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
48
107
|
since(qid: string, lsn: string): RowPatch[] | null {
|
|
49
108
|
const ring = this.#rings.get(qid);
|
|
50
109
|
if (!ring) return null;
|
|
@@ -58,8 +117,15 @@ export class RingChangeBuffer implements ResumeSource {
|
|
|
58
117
|
return last ? last.lsn : null;
|
|
59
118
|
}
|
|
60
119
|
|
|
61
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Called when the last subscriber of a query goes away, so an idle query stops costing memory.
|
|
122
|
+
* It had no caller until `LiveQueryRegistry.unsubscribe` gained one: the entry was dropped and
|
|
123
|
+
* the ring behind it kept every patch it held until the LRU happened to reach it.
|
|
124
|
+
*/
|
|
62
125
|
forget(qid: string): void {
|
|
126
|
+
const ring = this.#rings.get(qid);
|
|
127
|
+
if (!ring) return;
|
|
128
|
+
this.#bytes -= ring.bytes;
|
|
63
129
|
this.#rings.delete(qid);
|
|
64
130
|
}
|
|
65
131
|
|
package/src/channel.ts
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
// append-only stream, so tier 1 needs no frame of its own. That is why climbing the ladder is a
|
|
5
5
|
// config change: the client's frame handler is the same code at every rung.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type Actor, logger, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import { formatLsn } from './changefeed';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
isPolicyDenial,
|
|
11
|
+
SubscriptionLimitError,
|
|
12
|
+
TopicForbiddenError,
|
|
13
|
+
TransportUnavailableError,
|
|
14
|
+
} from './errors';
|
|
10
15
|
import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
|
|
11
16
|
import type { JsonObject } from './json';
|
|
12
17
|
import type { SocketRegistry, SyncSocket } from './socket';
|
|
@@ -46,6 +51,30 @@ export interface ChannelHubOptions {
|
|
|
46
51
|
readonly transport: Transport;
|
|
47
52
|
readonly sockets: SocketRegistry;
|
|
48
53
|
readonly maxTopicsPerSocket?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Distinct topics this node will bridge at once. Each one is a live transport subscription, and
|
|
56
|
+
* `topic()` admits any `[A-Za-z0-9_-]+` segment — so even a guard as tight as `org.<myorg>.>`
|
|
57
|
+
* admits unbounded distinct names inside one tenant, and a per-socket cap bounds nothing.
|
|
58
|
+
*/
|
|
59
|
+
readonly maxTopicsPerNode?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Distinct topics one node bridges before `X_SUBSCRIPTION_LIMIT`. */
|
|
63
|
+
export const DEFAULT_MAX_TOPICS_PER_NODE = 10_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
|
|
67
|
+
* into the table before it is awaited: looked up before the await and written after it, two sockets
|
|
68
|
+
* reaching one topic at once opened two transport subscriptions — the second replacing the first in
|
|
69
|
+
* the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
|
|
70
|
+
* anything else, delivering every message on that topic a second time for the life of the process.
|
|
71
|
+
*
|
|
72
|
+
* `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
|
|
73
|
+
* runs, so the reservation has to exist before there is anything to reserve it with.
|
|
74
|
+
*/
|
|
75
|
+
interface Bridge {
|
|
76
|
+
sub: Promise<TransportSubscription> | null;
|
|
77
|
+
refs: number;
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
/**
|
|
@@ -56,14 +85,43 @@ export class ChannelHub {
|
|
|
56
85
|
readonly #transport: Transport;
|
|
57
86
|
readonly #sockets: SocketRegistry;
|
|
58
87
|
readonly #guards: Array<{ pattern: string; guard: TopicGuard }> = [];
|
|
59
|
-
readonly #bridges = new Map<string,
|
|
88
|
+
readonly #bridges = new Map<string, Bridge>();
|
|
89
|
+
/**
|
|
90
|
+
* Topics this socket has asked for and not yet joined. Weakly keyed, so it needs no teardown
|
|
91
|
+
* path of its own: a socket that dies mid-subscribe takes its claims with it.
|
|
92
|
+
*/
|
|
93
|
+
readonly #claimed = new WeakMap<SyncSocket, number>();
|
|
60
94
|
readonly #maxTopicsPerSocket: number;
|
|
95
|
+
readonly #maxTopicsPerNode: number;
|
|
96
|
+
#guardFailures = 0;
|
|
61
97
|
#sequence = 0n;
|
|
98
|
+
/** Set by `close()`. Read by `#open`, which is the only thing that can reach a late subscription. */
|
|
99
|
+
#closed = false;
|
|
62
100
|
|
|
63
101
|
constructor(options: ChannelHubOptions) {
|
|
64
102
|
this.#transport = options.transport;
|
|
65
103
|
this.#sockets = options.sockets;
|
|
66
104
|
this.#maxTopicsPerSocket = options.maxTopicsPerSocket ?? 64;
|
|
105
|
+
this.#maxTopicsPerNode = options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Sockets this node will deliver `name` to. The metric the fanout reads. */
|
|
109
|
+
subscriberCount(name: Topic): number {
|
|
110
|
+
return this.#sockets.subscriberCount(name);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Distinct topics bridged from this node — one live transport subscription each. */
|
|
114
|
+
get topicCount(): number {
|
|
115
|
+
return this.#bridges.size;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `channel.guard_failed` for this node: guards that raised instead of deciding, during a re-auth.
|
|
120
|
+
* Never a denial — the same split `LiveQueryRegistry.reauthorize` makes one layer up, and an
|
|
121
|
+
* alert fires on one of them.
|
|
122
|
+
*/
|
|
123
|
+
get guardFailures(): number {
|
|
124
|
+
return this.#guardFailures;
|
|
67
125
|
}
|
|
68
126
|
|
|
69
127
|
/** `pattern` uses NATS wildcards: `org.*.cursors`, `org.>`. First registered match wins. */
|
|
@@ -72,36 +130,90 @@ export class ChannelHub {
|
|
|
72
130
|
return this;
|
|
73
131
|
}
|
|
74
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Both caps and the node's bridge slot are taken SYNCHRONOUSLY, before the guard is awaited: read
|
|
135
|
+
* at the top and acted on after two awaits, one WebSocket write carrying N subscribe frames
|
|
136
|
+
* passed each of them N times, and `maxTopicsPerSocket`/`maxTopicsPerNode` bounded nothing.
|
|
137
|
+
*/
|
|
75
138
|
async subscribe(socket: SyncSocket, name: Topic): Promise<void> {
|
|
76
139
|
if (socket.topics.has(name)) return;
|
|
77
|
-
|
|
140
|
+
const claimed = this.#claimed.get(socket) ?? 0;
|
|
141
|
+
if (socket.topics.size + claimed >= this.#maxTopicsPerSocket) {
|
|
78
142
|
throw new SubscriptionLimitError({
|
|
79
143
|
scope: 'socket',
|
|
80
144
|
id: socket.id,
|
|
81
145
|
limit: this.#maxTopicsPerSocket,
|
|
146
|
+
// Named, never defaulted: the default for this scope is `maxPerSocket`, which is
|
|
147
|
+
// `LiveQueryRegistry`'s cap on live subscriptions — a different ceiling in a different
|
|
148
|
+
// constructor, so an operator following this fix line would have moved the wrong number.
|
|
149
|
+
knob: 'maxTopicsPerSocket',
|
|
82
150
|
});
|
|
83
151
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
152
|
+
// Refused before the guard runs and before a transport subscription is opened: a node that is
|
|
153
|
+
// out of topics has nothing to decide, and the answer must not depend on who asked.
|
|
154
|
+
const bridge = this.#reserve(name);
|
|
155
|
+
this.#claimed.set(socket, claimed + 1);
|
|
156
|
+
try {
|
|
157
|
+
await this.#authorize(socket.actor, name);
|
|
158
|
+
await this.#open(name, bridge);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// The slot this subscribe took, given back on the one path that will never fill it — and
|
|
161
|
+
// given back to the bridge this subscribe actually reserved. `close()` clears the table, so
|
|
162
|
+
// a later subscribe may have put a DIFFERENT bridge under this name in the meantime, and
|
|
163
|
+
// decrementing that one's refs releases a topic somebody else is holding.
|
|
164
|
+
this.#release(name, bridge);
|
|
165
|
+
throw error;
|
|
166
|
+
} finally {
|
|
167
|
+
const held = this.#claimed.get(socket) ?? 1;
|
|
168
|
+
if (held <= 1) this.#claimed.delete(socket);
|
|
169
|
+
else this.#claimed.set(socket, held - 1);
|
|
170
|
+
}
|
|
171
|
+
// A concurrent subscribe for this same socket and topic got there first: it holds the one
|
|
172
|
+
// membership this socket's close will give back, so the reference taken above has to go now or
|
|
173
|
+
// it is a bridge nothing will ever release.
|
|
174
|
+
if (socket.topics.has(name)) {
|
|
175
|
+
this.#release(name, bridge);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Through the registry, never `socket.subscribeTopic` directly: membership and the index the
|
|
179
|
+
// fanout reads are one fact, and two call sites for one fact is the drift that makes an index
|
|
180
|
+
// wrong. The registry owns it because it is the only thing that sees a socket die.
|
|
181
|
+
this.#sockets.joinTopic(socket, name);
|
|
87
182
|
}
|
|
88
183
|
|
|
89
184
|
unsubscribe(socket: SyncSocket, name: Topic): void {
|
|
90
185
|
if (!socket.topics.has(name)) return;
|
|
91
|
-
|
|
186
|
+
this.#sockets.leaveTopic(socket, name);
|
|
92
187
|
this.#release(name);
|
|
93
188
|
}
|
|
94
189
|
|
|
95
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Called when a socket's session changes (login, logout, role change, token refresh).
|
|
192
|
+
*
|
|
193
|
+
* A denial drops the topic; anything else keeps it. A guard is app code and may reach a database,
|
|
194
|
+
* so `catch { unsubscribe }` reported a store that timed out as a revoked grant — during one
|
|
195
|
+
* outage, every topic on every re-authenticated socket on the node, silently, with the client
|
|
196
|
+
* never told to resubscribe. The same split `LiveQueryRegistry.reauthorize` already makes, and
|
|
197
|
+
* for the same reason: a failure is not a decision.
|
|
198
|
+
*/
|
|
96
199
|
async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
|
|
97
200
|
socket.actor = actor;
|
|
98
201
|
const dropped: Topic[] = [];
|
|
99
202
|
for (const name of [...socket.topics] as Topic[]) {
|
|
100
203
|
try {
|
|
101
204
|
await this.#authorize(actor, name);
|
|
102
|
-
} catch {
|
|
103
|
-
|
|
104
|
-
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
|
|
207
|
+
this.unsubscribe(socket, name);
|
|
208
|
+
dropped.push(name);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
this.#guardFailures += 1;
|
|
212
|
+
logger.warn('channel.guard_failed', {
|
|
213
|
+
topic: name,
|
|
214
|
+
socketId: socket.id,
|
|
215
|
+
error: renderThrowable(error),
|
|
216
|
+
});
|
|
105
217
|
}
|
|
106
218
|
}
|
|
107
219
|
return dropped;
|
|
@@ -120,7 +232,14 @@ export class ChannelHub {
|
|
|
120
232
|
}
|
|
121
233
|
|
|
122
234
|
async close(): Promise<void> {
|
|
123
|
-
|
|
235
|
+
// Set BEFORE the table is walked, because the table is not the whole story: a reservation an
|
|
236
|
+
// in-flight `subscribe` has not opened yet is `sub === null`, so `unsubscribeWhenOpen` does
|
|
237
|
+
// nothing to it and `clear()` drops the entry. That open then lands on a `Bridge` nothing can
|
|
238
|
+
// name — `#release` looks the topic up, misses and returns — and its handler keeps calling
|
|
239
|
+
// `deliver` for the life of the process. The same orphan the `Bridge` comment describes, one
|
|
240
|
+
// state earlier, so the open that creates the subscription has to be the thing that closes it.
|
|
241
|
+
this.#closed = true;
|
|
242
|
+
for (const bridge of this.#bridges.values()) unsubscribeWhenOpen(bridge);
|
|
124
243
|
this.#bridges.clear();
|
|
125
244
|
}
|
|
126
245
|
|
|
@@ -147,29 +266,92 @@ export class ChannelHub {
|
|
|
147
266
|
}
|
|
148
267
|
}
|
|
149
268
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
269
|
+
/**
|
|
270
|
+
* The node's slot for this topic, taken synchronously. One bridge per topic per node, refcounted
|
|
271
|
+
* across sockets — and the refcount includes the subscribes still deciding, so the count the node
|
|
272
|
+
* cap reads is the count that will exist.
|
|
273
|
+
*/
|
|
274
|
+
#reserve(name: Topic): Bridge {
|
|
152
275
|
const existing = this.#bridges.get(name);
|
|
153
276
|
if (existing) {
|
|
154
277
|
existing.refs += 1;
|
|
155
|
-
return;
|
|
278
|
+
return existing;
|
|
156
279
|
}
|
|
157
|
-
|
|
280
|
+
if (this.#bridges.size >= this.#maxTopicsPerNode) {
|
|
281
|
+
throw new SubscriptionLimitError({
|
|
282
|
+
scope: 'node',
|
|
283
|
+
id: 'topics',
|
|
284
|
+
limit: this.#maxTopicsPerNode,
|
|
285
|
+
knob: 'maxTopicsPerNode',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const created: Bridge = { sub: null, refs: 1 };
|
|
289
|
+
this.#bridges.set(name, created);
|
|
290
|
+
return created;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Opens the reserved bridge once, and shares the in-flight open with everyone else waiting. */
|
|
294
|
+
async #open(name: Topic, bridge: Bridge): Promise<void> {
|
|
295
|
+
// Published into the bridge before it is awaited: that is what makes a second subscriber join
|
|
296
|
+
// this open instead of starting a second one the table can never reach again.
|
|
297
|
+
bridge.sub ??= this.#transport.subscribe(`${CHANNEL_SUBJECT_PREFIX}.${name}`, (payload) => {
|
|
158
298
|
this.#sockets.deliver(name, decode(payload));
|
|
159
299
|
});
|
|
160
|
-
|
|
300
|
+
await bridge.sub;
|
|
301
|
+
// The hub shut down while the transport was answering. `close()` either never saw this bridge
|
|
302
|
+
// or saw it with nothing to close, so this is the last reference to the subscription: it closes
|
|
303
|
+
// here or never. The entry goes with it, so a second post-close subscribe opens and closes its
|
|
304
|
+
// own rather than double-unsubscribing this one's handle.
|
|
305
|
+
if (this.#closed) {
|
|
306
|
+
unsubscribeWhenOpen(bridge);
|
|
307
|
+
if (this.#bridges.get(name) === bridge) this.#bridges.delete(name);
|
|
308
|
+
// RAISED, not returned. Returning let `subscribe` fall through to `joinTopic`, so the socket
|
|
309
|
+
// became a member of a topic nothing on this node is bridged to: silent for the life of the
|
|
310
|
+
// connection, with no error on either side and nothing telling the client to redial. The
|
|
311
|
+
// same refusal the transport itself answers when it is gone, because from the client's side
|
|
312
|
+
// that is what happened — this node's bus for that topic is closed.
|
|
313
|
+
throw new TransportUnavailableError({
|
|
314
|
+
transport: 'channel',
|
|
315
|
+
reason: `the hub closed while "${name}" was opening`,
|
|
316
|
+
// The reader is a browser websocket client, which cannot run a CLI — so pure command
|
|
317
|
+
// advice would be worse than prose here. The shape that satisfies axiom 4 anyway is
|
|
318
|
+
// `http/src/error-map.ts`'s: a command that SHIPS (`x errors explain`, unlike the planned
|
|
319
|
+
// `x logs tail`) for whoever is holding a terminal, then the instruction as a comment.
|
|
320
|
+
fix: 'x errors explain X_TRANSPORT_UNAVAILABLE --json # then reconnect and resubscribe: this node is draining',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
161
323
|
}
|
|
162
324
|
|
|
163
|
-
|
|
325
|
+
/**
|
|
326
|
+
* `expected` is the bridge the caller reserved. Without it a release looks the topic up by name,
|
|
327
|
+
* and after a `close()` cleared the table that name may hold a bridge a LATER subscribe opened.
|
|
328
|
+
*/
|
|
329
|
+
#release(name: Topic, expected?: Bridge): void {
|
|
164
330
|
const bridge = this.#bridges.get(name);
|
|
165
331
|
if (!bridge) return;
|
|
332
|
+
if (expected !== undefined && bridge !== expected) return;
|
|
166
333
|
bridge.refs -= 1;
|
|
167
334
|
if (bridge.refs > 0) return;
|
|
168
|
-
bridge
|
|
335
|
+
unsubscribeWhenOpen(bridge);
|
|
169
336
|
this.#bridges.delete(name);
|
|
170
337
|
}
|
|
171
338
|
}
|
|
172
339
|
|
|
340
|
+
/**
|
|
341
|
+
* A bridge released while its subscription is still opening still has to be closed — the transport
|
|
342
|
+
* hands the handle back after the caller has gone, and dropping the promise would leave a live
|
|
343
|
+
* subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
|
|
344
|
+
* rejection was already answered to the subscriber that caused it.
|
|
345
|
+
*/
|
|
346
|
+
function unsubscribeWhenOpen(bridge: Bridge): void {
|
|
347
|
+
void bridge.sub?.then(
|
|
348
|
+
(sub) => {
|
|
349
|
+
sub.unsubscribe();
|
|
350
|
+
},
|
|
351
|
+
() => undefined,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
173
355
|
export function channelFrame(name: Topic, lsn: string, message: JsonObject): Frame {
|
|
174
356
|
return {
|
|
175
357
|
type: 'patch',
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// What a client IS, as types: the injected seams, the options, and the handles a subscription
|
|
2
|
+
// gives back. Declared apart from the client that implements them for the same reason
|
|
3
|
+
// `live-contract.ts` is — the hooks, the typed projection, the type pins and the mutation path all
|
|
4
|
+
// need these shapes, and none of them needs the connection lifecycle that runs underneath.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import type { LiveCursor } from './cursor';
|
|
8
|
+
import type { JsonValue, Row } from './json';
|
|
9
|
+
import type { LiveState } from './live-rows';
|
|
10
|
+
import type { LocalStore, LocalTx, TableMap } from './local-store';
|
|
11
|
+
import type { OfflineQueue } from './offline-queue';
|
|
12
|
+
import type { ConflictStrategy, RebaseLog } from './rebase';
|
|
13
|
+
import type { BackoffPolicy, Rng, Scheduler } from './thundering-herd';
|
|
14
|
+
|
|
15
|
+
/** Injected reactive primitive. `createSignal` from Solid satisfies this exactly. */
|
|
16
|
+
export type SignalFactory = <T>(initial: T) => [get: () => T, set: (next: T) => void];
|
|
17
|
+
|
|
18
|
+
/** Injected socket, so tests drive the protocol without a network. */
|
|
19
|
+
export interface ClientSocket {
|
|
20
|
+
send(data: string): void;
|
|
21
|
+
close(code?: number, reason?: string): void;
|
|
22
|
+
onOpen(handler: () => void): void;
|
|
23
|
+
onMessage(handler: (data: string) => void): void;
|
|
24
|
+
onClose(handler: (code: number) => void): void;
|
|
25
|
+
/**
|
|
26
|
+
* Bytes queued but not yet on the wire — `WebSocket.bufferedAmount`. Optional because a socket
|
|
27
|
+
* that cannot answer is treated as never backed up; supplying it is what lets the mutation drain
|
|
28
|
+
* stop instead of pushing a queue the tab is not draining into one it cannot see.
|
|
29
|
+
*/
|
|
30
|
+
readonly bufferedAmount?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LiveHandle<R extends Row = Row> extends Disposable {
|
|
34
|
+
/** The reactive accessor. In an app this is the Solid signal `useLive` returns. */
|
|
35
|
+
readonly rows: () => readonly R[];
|
|
36
|
+
readonly state: () => LiveState;
|
|
37
|
+
readonly cursor: () => LiveCursor | null;
|
|
38
|
+
unsubscribe(): void;
|
|
39
|
+
/** The same call as `unsubscribe`, so `using sub = client.useLive(...)` just works. */
|
|
40
|
+
[Symbol.dispose](): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What `subscribe()` returns for a tier-1 topic: callable to unsubscribe, and `using`-able too. */
|
|
44
|
+
export type Unsubscribe = (() => void) & Disposable;
|
|
45
|
+
|
|
46
|
+
export interface LiveQueryRef {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MutatorRef<T extends TableMap = TableMap> {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
/** Optimistic twin. Pure — no I/O, no Date.now(), no Math.random(). */
|
|
53
|
+
local?: (tx: LocalTx<T>, input: JsonValue) => void;
|
|
54
|
+
readonly entity?: string;
|
|
55
|
+
readonly conflict?: ConflictStrategy;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LiveClientOptions<T extends TableMap = TableMap> {
|
|
59
|
+
readonly signal: SignalFactory;
|
|
60
|
+
/** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
|
|
61
|
+
readonly connect: () => ClientSocket;
|
|
62
|
+
readonly buildId: string;
|
|
63
|
+
readonly actorId?: string | null;
|
|
64
|
+
/** Tier 3 only. Without these, mutations are server-only and nothing is queued offline. */
|
|
65
|
+
readonly store?: LocalStore<T>;
|
|
66
|
+
readonly queue?: OfflineQueue;
|
|
67
|
+
readonly log?: RebaseLog<T>;
|
|
68
|
+
readonly backoff?: BackoffPolicy;
|
|
69
|
+
readonly rng?: Rng;
|
|
70
|
+
readonly clock?: Clock;
|
|
71
|
+
/** How a pending reconnect is armed. Defaults to `setTimeout`; tests fire theirs by hand. */
|
|
72
|
+
readonly scheduler?: Scheduler;
|
|
73
|
+
/**
|
|
74
|
+
* How often a live socket re-announces itself, in ms. `0` disables it. Defaults to
|
|
75
|
+
* `DEFAULT_HEARTBEAT_MS` — the same 15s as the server's `realtime.heartbeatMs`, which browser
|
|
76
|
+
* code cannot read, so it is an option here rather than a value shipped down the wire.
|
|
77
|
+
*/
|
|
78
|
+
readonly heartbeatMs?: number;
|
|
79
|
+
/** Where a dial failure inside the reconnect timer is reported. Defaults to `reportToConsole`. */
|
|
80
|
+
readonly onError?: (error: unknown) => void;
|
|
81
|
+
}
|