@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/channel.ts
CHANGED
|
@@ -1,52 +1,39 @@
|
|
|
1
|
-
// Tier 1: channels.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// config change: the client's frame handler is the same code at every rung.
|
|
1
|
+
// Tier 1: channels. A declared `channel()` is subscribed by name + params — there is no other way
|
|
2
|
+
// to spell a topic. Its `records` are derived from the change feed on the node that delivers them
|
|
3
|
+
// (seq, epoch, ring, `replay-gap` — plan 101, slices 09-10), and its ephemeral `events` (presence
|
|
4
|
+
// included) fan out across nodes over the `Transport` bridge.
|
|
6
5
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
6
|
+
import {
|
|
7
|
+
type Actor,
|
|
8
|
+
type Ctx,
|
|
9
|
+
createContext,
|
|
10
|
+
finiteOption,
|
|
11
|
+
invariant,
|
|
12
|
+
logger,
|
|
13
|
+
renderThrowable,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
import type { ChangeEvent } from './changefeed';
|
|
16
|
+
import { authorizeChannel } from './channel-authz';
|
|
17
|
+
import { type Bridge, unsubscribeWhenOpen } from './channel-bridge';
|
|
18
|
+
import type { Channel, Topic } from './channel-decl';
|
|
19
|
+
import { ChannelLogs } from './channel-logs';
|
|
20
|
+
import { getChannel, registeredChannels } from './channel-registry';
|
|
21
|
+
import type { ChannelEventsFrame, ChannelSubscribeTarget } from './channel-wire';
|
|
9
22
|
import {
|
|
10
23
|
isPolicyDenial,
|
|
11
24
|
SubscriptionLimitError,
|
|
12
25
|
TopicForbiddenError,
|
|
13
26
|
TransportUnavailableError,
|
|
14
27
|
} from './errors';
|
|
15
|
-
import {
|
|
28
|
+
import type { Transport } from './fanout';
|
|
16
29
|
import type { JsonObject } from './json';
|
|
17
30
|
import type { SocketRegistry, SyncSocket } from './socket';
|
|
18
|
-
import { decode,
|
|
31
|
+
import { decode, PROTOCOL_VERSION } from './sync-protocol';
|
|
19
32
|
|
|
20
|
-
|
|
21
|
-
export type Topic = string & { readonly __ultimateTopic: unique symbol };
|
|
33
|
+
export { type Topic, topic } from './channel-decl';
|
|
22
34
|
|
|
23
|
-
const SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
24
35
|
const CHANNEL_SUBJECT_PREFIX = 'x.channel';
|
|
25
36
|
|
|
26
|
-
/** `topic('org', orgId, 'cursors')` -> `org.<orgId>.cursors`. Segments are validated, never escaped. */
|
|
27
|
-
export function topic(...parts: readonly (string | number)[]): Topic {
|
|
28
|
-
const segments = parts.map((part) => String(part));
|
|
29
|
-
for (const segment of segments) {
|
|
30
|
-
if (!SEGMENT.test(segment)) {
|
|
31
|
-
throw new TopicForbiddenError({
|
|
32
|
-
topic: segments.join('.'),
|
|
33
|
-
actorId: null,
|
|
34
|
-
reason: `segment "${segment}" must match ${SEGMENT.source} (dots and wildcards are reserved)`,
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return segments.join('.') as Topic;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface TopicGuardArgs {
|
|
42
|
-
readonly actor: Actor | null;
|
|
43
|
-
readonly topic: Topic;
|
|
44
|
-
readonly segments: readonly string[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export type TopicGuardResult = boolean | { readonly allowed: boolean; readonly reason?: string };
|
|
48
|
-
export type TopicGuard = (args: TopicGuardArgs) => TopicGuardResult | Promise<TopicGuardResult>;
|
|
49
|
-
|
|
50
37
|
export interface ChannelHubOptions {
|
|
51
38
|
readonly transport: Transport;
|
|
52
39
|
readonly sockets: SocketRegistry;
|
|
@@ -57,45 +44,24 @@ export interface ChannelHubOptions {
|
|
|
57
44
|
* admits unbounded distinct names inside one tenant, and a per-socket cap bounds nothing.
|
|
58
45
|
*/
|
|
59
46
|
readonly maxTopicsPerNode?: number;
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* `nodeId: ''` — which is what an unset `POD_NAME` interpolates to — stored the empty mark and
|
|
67
|
-
* two hubs then minted the SAME first patch id, `:0000000000000001`. That is precisely the
|
|
68
|
-
* collision this field exists to prevent, arriving through the field itself.
|
|
69
|
-
*/
|
|
70
|
-
readonly nodeId?: string;
|
|
47
|
+
/** Scope the hub to these declarations. Omitted = every `channel()` registered in the process. */
|
|
48
|
+
readonly channels?: readonly Channel[];
|
|
49
|
+
/** The node context a channel policy is evaluated under, as a live query's is. */
|
|
50
|
+
readonly ctx?: Ctx;
|
|
51
|
+
/** `records` frames kept per topic for a `since` resume. See `DEFAULT_CHANNEL_RING`. */
|
|
52
|
+
readonly ringSize?: number;
|
|
71
53
|
}
|
|
72
54
|
|
|
73
55
|
/** Distinct topics one node bridges before `X_SUBSCRIPTION_LIMIT`. */
|
|
74
56
|
export const DEFAULT_MAX_TOPICS_PER_NODE = 10_000;
|
|
75
57
|
|
|
76
58
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* reaching one topic at once opened two transport subscriptions — the second replacing the first in
|
|
80
|
-
* the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
|
|
81
|
-
* anything else, delivering every message on that topic a second time for the life of the process.
|
|
82
|
-
*
|
|
83
|
-
* `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
|
|
84
|
-
* runs, so the reservation has to exist before there is anything to reserve it with.
|
|
85
|
-
*/
|
|
86
|
-
interface Bridge {
|
|
87
|
-
sub: Promise<TransportSubscription> | null;
|
|
88
|
-
refs: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Deny by default: a topic with no matching guard is forbidden. An authz hole must be a typed
|
|
93
|
-
* error at subscribe time, not a config option someone forgot to set.
|
|
59
|
+
* Deny by default: a channel name no `channel()` declared is refused, so an authz hole is a typed
|
|
60
|
+
* error at subscribe time, never a topic somebody forgot to guard.
|
|
94
61
|
*/
|
|
95
62
|
export class ChannelHub {
|
|
96
63
|
readonly #transport: Transport;
|
|
97
64
|
readonly #sockets: SocketRegistry;
|
|
98
|
-
readonly #guards: Array<{ pattern: string; guard: TopicGuard }> = [];
|
|
99
65
|
readonly #bridges = new Map<string, Bridge>();
|
|
100
66
|
/**
|
|
101
67
|
* Topics this socket has asked for and not yet joined. Weakly keyed, so it needs no teardown
|
|
@@ -105,15 +71,21 @@ export class ChannelHub {
|
|
|
105
71
|
readonly #maxTopicsPerSocket: number;
|
|
106
72
|
readonly #maxTopicsPerNode: number;
|
|
107
73
|
#guardFailures = 0;
|
|
108
|
-
#sequence = 0n;
|
|
109
|
-
/**
|
|
110
|
-
* `#sequence` counts within one PROCESS, so it cannot identify a message across nodes: two
|
|
111
|
-
* `sync` replicas publishing to one topic minted the same id for the same subscriber, and a
|
|
112
|
-
* channel has no cursor and no re-snapshot, so nothing downstream could repair the collision.
|
|
113
|
-
*/
|
|
114
|
-
readonly #nodeId: string;
|
|
115
74
|
/** Set by `close()`. Read by `#open`, which is the only thing that can reach a late subscription. */
|
|
116
75
|
#closed = false;
|
|
76
|
+
/**
|
|
77
|
+
* `null` serves every registered channel — the default, so a host passes nothing. A list scopes
|
|
78
|
+
* this hub to exactly those declarations (a test, a node that serves a subset).
|
|
79
|
+
*/
|
|
80
|
+
readonly #only: ReadonlyMap<string, Channel> | null;
|
|
81
|
+
readonly #logs: ChannelLogs;
|
|
82
|
+
readonly #ctx: Ctx;
|
|
83
|
+
/**
|
|
84
|
+
* Topics a socket's policy refused, latched until its actor changes: a denial is a decision, so
|
|
85
|
+
* a client re-asking in a loop is answered without re-running the policy each time — and only
|
|
86
|
+
* THAT channel is refused, every other one on the socket keeps flowing.
|
|
87
|
+
*/
|
|
88
|
+
readonly #latched = new WeakMap<SyncSocket, Set<string>>();
|
|
117
89
|
|
|
118
90
|
constructor(options: ChannelHubOptions) {
|
|
119
91
|
this.#transport = options.transport;
|
|
@@ -128,9 +100,99 @@ export class ChannelHub {
|
|
|
128
100
|
'maxTopicsPerNode',
|
|
129
101
|
options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE,
|
|
130
102
|
);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
this.#
|
|
103
|
+
this.#ctx = options.ctx ?? createContext();
|
|
104
|
+
this.#logs = new ChannelLogs(options.sockets, options.ringSize);
|
|
105
|
+
this.#only =
|
|
106
|
+
options.channels === undefined ? null : new Map(options.channels.map((c) => [c.name, c]));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Join a declared channel by name + params. The policy runs with the params as input; a denial
|
|
111
|
+
* is latched per (socket, topic). With `since`, the ring replays what was missed or a
|
|
112
|
+
* `replay-gap` says to re-read. Answers the topic, which presence keys its set by.
|
|
113
|
+
*/
|
|
114
|
+
async subscribeChannel(socket: SyncSocket, target: ChannelSubscribeTarget): Promise<Topic> {
|
|
115
|
+
const declared =
|
|
116
|
+
this.#only === null ? getChannel(target.channel) : this.#only.get(target.channel);
|
|
117
|
+
if (declared === undefined) {
|
|
118
|
+
throw new TopicForbiddenError({
|
|
119
|
+
topic: target.channel,
|
|
120
|
+
actorId: socket.actorId,
|
|
121
|
+
reason: 'no channel() is declared with this name on this node',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const params: Record<string, string> = {};
|
|
125
|
+
for (const param of declared.params) {
|
|
126
|
+
params[param] = Object.hasOwn(target.params, param) ? (target.params[param] ?? '') : '';
|
|
127
|
+
}
|
|
128
|
+
const name = declared.topic(params);
|
|
129
|
+
if (this.#latched.get(socket)?.has(name) === true) {
|
|
130
|
+
throw new TopicForbiddenError({
|
|
131
|
+
topic: name,
|
|
132
|
+
actorId: socket.actorId,
|
|
133
|
+
reason: 'denied earlier on this connection; it is re-decided when the session changes',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Asked before the join seats it: a repeated `add` (the presence beat) is not a fresh seat.
|
|
137
|
+
const fresh = !socket.topics.has(name);
|
|
138
|
+
await this.#join(socket, name, async () => {
|
|
139
|
+
try {
|
|
140
|
+
await authorizeChannel(declared, this.#ctx, socket.actor, name, params);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof TopicForbiddenError) this.#latch(socket, name);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
this.#logs.open(name, { channel: declared, params });
|
|
147
|
+
this.#logs.resume(socket, name, target.since, fresh);
|
|
148
|
+
return name;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One committed change from the feed, turned into `records` frames on every declared channel it
|
|
153
|
+
* touches and delivered on THIS node. Called for every change the node receives — the same
|
|
154
|
+
* stream `LiveQueryRegistry.deliver` is fed — so a write names no channel (axiom 2).
|
|
155
|
+
*/
|
|
156
|
+
deliverChange(change: ChangeEvent): number {
|
|
157
|
+
return this.#logs.deliverChange(this.#only?.values() ?? registeredChannels(), change);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** An ephemeral event (typing, a cursor) to every node's members of that topic. Never stored. */
|
|
161
|
+
async publishEvent<K extends string>(
|
|
162
|
+
declared: Channel<K>,
|
|
163
|
+
params: Readonly<Record<K, string>>,
|
|
164
|
+
event: JsonObject,
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
invariant(
|
|
167
|
+
declared.events,
|
|
168
|
+
'X_CHANNEL_DECLARATION_INVALID',
|
|
169
|
+
`channel("${declared.name}") declares no events, so nothing may publish one on it`,
|
|
170
|
+
`declare it with events: true: channel('${declared.name}', { …, events: true })`,
|
|
171
|
+
);
|
|
172
|
+
await this.emit(declared.topic(params), event);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* An `events` frame on a topic this node already resolved — `publishEvent`'s second half, and
|
|
177
|
+
* presence's one way out: a roster change is an event on the channel the member joined. Never
|
|
178
|
+
* a topic a caller spelled; every `Topic` comes from a declaration.
|
|
179
|
+
*/
|
|
180
|
+
async emit(name: Topic, event: JsonObject): Promise<void> {
|
|
181
|
+
const frame: ChannelEventsFrame = { type: 'events', v: PROTOCOL_VERSION, channel: name, event };
|
|
182
|
+
await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, JSON.stringify(frame));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The declaration and params a topic joined on this node resolves to — `undefined` if none. */
|
|
186
|
+
channelOf(
|
|
187
|
+
name: Topic,
|
|
188
|
+
): { readonly channel: Channel; readonly params: Readonly<Record<string, string>> } | undefined {
|
|
189
|
+
return this.#logs.target(name);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#latch(socket: SyncSocket, name: string): void {
|
|
193
|
+
const latched = this.#latched.get(socket) ?? new Set<string>();
|
|
194
|
+
latched.add(name);
|
|
195
|
+
this.#latched.set(socket, latched);
|
|
134
196
|
}
|
|
135
197
|
|
|
136
198
|
/** Sockets this node will deliver `name` to. The metric the fanout reads. */
|
|
@@ -152,18 +214,12 @@ export class ChannelHub {
|
|
|
152
214
|
return this.#guardFailures;
|
|
153
215
|
}
|
|
154
216
|
|
|
155
|
-
/** `pattern` uses NATS wildcards: `org.*.cursors`, `org.>`. First registered match wins. */
|
|
156
|
-
guard(pattern: string, guard: TopicGuard): this {
|
|
157
|
-
this.#guards.push({ pattern, guard });
|
|
158
|
-
return this;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
217
|
/**
|
|
162
218
|
* Both caps and the node's bridge slot are taken SYNCHRONOUSLY, before the guard is awaited: read
|
|
163
219
|
* at the top and acted on after two awaits, one WebSocket write carrying N subscribe frames
|
|
164
220
|
* passed each of them N times, and `maxTopicsPerSocket`/`maxTopicsPerNode` bounded nothing.
|
|
165
221
|
*/
|
|
166
|
-
async
|
|
222
|
+
async #join(socket: SyncSocket, name: Topic, authorize: () => Promise<void>): Promise<void> {
|
|
167
223
|
if (socket.topics.has(name)) return;
|
|
168
224
|
const claimed = this.#claimed.get(socket) ?? 0;
|
|
169
225
|
if (socket.topics.size + claimed >= this.#maxTopicsPerSocket) {
|
|
@@ -182,7 +238,7 @@ export class ChannelHub {
|
|
|
182
238
|
const bridge = this.#reserve(name);
|
|
183
239
|
this.#claimed.set(socket, claimed + 1);
|
|
184
240
|
try {
|
|
185
|
-
await
|
|
241
|
+
await authorize();
|
|
186
242
|
await this.#open(name, bridge);
|
|
187
243
|
} catch (error) {
|
|
188
244
|
// The slot this subscribe took, given back on the one path that will never fill it — and
|
|
@@ -226,10 +282,15 @@ export class ChannelHub {
|
|
|
226
282
|
*/
|
|
227
283
|
async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
|
|
228
284
|
socket.actor = actor;
|
|
285
|
+
// A new session re-decides everything, the latched denials included.
|
|
286
|
+
this.#latched.delete(socket);
|
|
229
287
|
const dropped: Topic[] = [];
|
|
230
288
|
for (const name of [...socket.topics] as Topic[]) {
|
|
231
289
|
try {
|
|
232
|
-
|
|
290
|
+
const target = this.#logs.target(name);
|
|
291
|
+
if (target !== undefined) {
|
|
292
|
+
await authorizeChannel(target.channel, this.#ctx, actor, name, target.params);
|
|
293
|
+
}
|
|
233
294
|
} catch (error) {
|
|
234
295
|
if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
|
|
235
296
|
this.unsubscribe(socket, name);
|
|
@@ -247,21 +308,6 @@ export class ChannelHub {
|
|
|
247
308
|
return dropped;
|
|
248
309
|
}
|
|
249
310
|
|
|
250
|
-
/** Publishes to every node. Local delivery happens via the transport bridge, never directly. */
|
|
251
|
-
async publish(name: Topic, message: JsonObject): Promise<void> {
|
|
252
|
-
this.#sequence += 1n;
|
|
253
|
-
const lsn = formatLsn(this.#sequence);
|
|
254
|
-
// The lsn stays this node's own counter — nothing reads a channel frame's lsn as an order
|
|
255
|
-
// across nodes — but the patch ID is what a client keys by, so it carries the node too.
|
|
256
|
-
const frame = channelFrame(name, lsn, message, `${this.#nodeId}:${lsn}`);
|
|
257
|
-
await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, encode(frame));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** Frames already encoded elsewhere (presence, for one) reuse the same bridge. */
|
|
261
|
-
async publishFrame(name: Topic, frame: Frame): Promise<void> {
|
|
262
|
-
await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, encode(frame));
|
|
263
|
-
}
|
|
264
|
-
|
|
265
311
|
async close(): Promise<void> {
|
|
266
312
|
// Set BEFORE the table is walked, because the table is not the whole story: a reservation an
|
|
267
313
|
// in-flight `subscribe` has not opened yet is `sub === null`, so `unsubscribeWhenOpen` does
|
|
@@ -274,29 +320,6 @@ export class ChannelHub {
|
|
|
274
320
|
this.#bridges.clear();
|
|
275
321
|
}
|
|
276
322
|
|
|
277
|
-
async #authorize(actor: Actor | null, name: Topic): Promise<void> {
|
|
278
|
-
const segments = name.split('.');
|
|
279
|
-
const entry = this.#guards.find(({ pattern }) => subjectMatches(pattern, name));
|
|
280
|
-
if (!entry) {
|
|
281
|
-
throw new TopicForbiddenError({
|
|
282
|
-
topic: name,
|
|
283
|
-
actorId: actor === null ? null : actor.id,
|
|
284
|
-
reason: 'no guard declared for this topic',
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
const result = await entry.guard({ actor, topic: name, segments });
|
|
288
|
-
const allowed = typeof result === 'boolean' ? result : result.allowed;
|
|
289
|
-
if (!allowed) {
|
|
290
|
-
const reason =
|
|
291
|
-
typeof result === 'boolean' ? 'guard denied' : (result.reason ?? 'guard denied');
|
|
292
|
-
throw new TopicForbiddenError({
|
|
293
|
-
topic: name,
|
|
294
|
-
actorId: actor === null ? null : actor.id,
|
|
295
|
-
reason,
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
323
|
/**
|
|
301
324
|
* The node's slot for this topic, taken synchronously. One bridge per topic per node, refcounted
|
|
302
325
|
* across sockets — and the refcount includes the subscribes still deciding, so the count the node
|
|
@@ -365,40 +388,7 @@ export class ChannelHub {
|
|
|
365
388
|
if (bridge.refs > 0) return;
|
|
366
389
|
unsubscribeWhenOpen(bridge);
|
|
367
390
|
this.#bridges.delete(name);
|
|
391
|
+
// The ring goes with the last local member; a later subscriber starts a new epoch.
|
|
392
|
+
this.#logs.close(name);
|
|
368
393
|
}
|
|
369
394
|
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* A bridge released while its subscription is still opening still has to be closed — the transport
|
|
373
|
-
* hands the handle back after the caller has gone, and dropping the promise would leave a live
|
|
374
|
-
* subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
|
|
375
|
-
* rejection was already answered to the subscriber that caused it.
|
|
376
|
-
*/
|
|
377
|
-
function unsubscribeWhenOpen(bridge: Bridge): void {
|
|
378
|
-
void bridge.sub?.then(
|
|
379
|
-
(sub) => {
|
|
380
|
-
sub.unsubscribe();
|
|
381
|
-
},
|
|
382
|
-
() => undefined,
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* `id` identifies the MESSAGE and defaults to the lsn, which is what every caller outside this
|
|
388
|
-
* file already passes as one. `ChannelHub.publish` gives it the publishing node's mark instead:
|
|
389
|
-
* an lsn is a per-process counter, and two nodes on one topic mint the same one.
|
|
390
|
-
*/
|
|
391
|
-
export function channelFrame(
|
|
392
|
-
name: Topic,
|
|
393
|
-
lsn: string,
|
|
394
|
-
message: JsonObject,
|
|
395
|
-
id: string = lsn,
|
|
396
|
-
): Frame {
|
|
397
|
-
return {
|
|
398
|
-
type: 'patch',
|
|
399
|
-
v: PROTOCOL_VERSION,
|
|
400
|
-
sid: name,
|
|
401
|
-
lsn,
|
|
402
|
-
patches: [{ op: 'insert', id, row: message, lsn }],
|
|
403
|
-
};
|
|
404
|
-
}
|