@ultimat3/realtime 20.2.0 → 21.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 (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  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 +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -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 +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
@@ -0,0 +1,75 @@
1
+ // One channel topic's record log on THIS node: the epoch, the next seq, and a bounded ring of the
2
+ // last frames' contents so a resubscribe `since` a recent seq replays instead of re-reading.
3
+ // Seq is minted here — at the delivering node — because a records frame never crosses the bus.
4
+
5
+ import { finiteOption, type Row } from '@ultimat3/core';
6
+ import type { ChannelSince } from './channel-wire';
7
+
8
+ /** One committed change on one topic, before it is rendered for a particular socket. */
9
+ export interface RecordsEntry {
10
+ readonly seq: number;
11
+ readonly adopt: readonly RecordPart[];
12
+ readonly remove: readonly { readonly type: string; readonly key: string }[];
13
+ /** The write that produced the change (`ChangeEvent.write`), kept so a replay still names it. */
14
+ readonly write?: string;
15
+ }
16
+
17
+ export interface RecordPart {
18
+ readonly type: string;
19
+ readonly key: string;
20
+ readonly row: Row;
21
+ }
22
+
23
+ /** Frames a ring keeps per topic. A resume further back than this is answered `replay-gap`. */
24
+ export const DEFAULT_CHANNEL_RING = 256;
25
+
26
+ export class ChannelRing {
27
+ /**
28
+ * New per ring, never per hub: a topic whose last subscriber left drops its ring, and the next
29
+ * one restarts seq at 1 — under the SAME epoch a client resuming `since: 50` would read seq 1..49
30
+ * as duplicates and silently drop them. A fresh epoch makes that a reset instead.
31
+ */
32
+ readonly epoch: string;
33
+ readonly #capacity: number;
34
+ readonly #entries: RecordsEntry[] = [];
35
+ #seq = 0;
36
+
37
+ constructor(epoch: string, capacity: number = DEFAULT_CHANNEL_RING) {
38
+ this.epoch = epoch;
39
+ this.#capacity = finiteOption('ChannelRing', 'capacity', capacity);
40
+ }
41
+
42
+ /** The seq the last `append` minted; 0 before the first. */
43
+ get seq(): number {
44
+ return this.#seq;
45
+ }
46
+
47
+ append(
48
+ adopt: readonly RecordPart[],
49
+ remove: RecordsEntry['remove'],
50
+ write?: string,
51
+ ): RecordsEntry {
52
+ this.#seq += 1;
53
+ const entry: RecordsEntry = {
54
+ seq: this.#seq,
55
+ adopt,
56
+ remove,
57
+ ...(write === undefined ? {} : { write }),
58
+ };
59
+ this.#entries.push(entry);
60
+ if (this.#entries.length > this.#capacity) this.#entries.shift();
61
+ return entry;
62
+ }
63
+
64
+ /**
65
+ * Every entry after `since.seq`, oldest first — or `null` when the ring cannot prove it holds
66
+ * all of them: another epoch, a seq from the future, or one older than the ring's oldest.
67
+ */
68
+ since(since: ChannelSince): readonly RecordsEntry[] | null {
69
+ if (since.epoch !== this.epoch || since.seq > this.#seq || since.seq < 0) return null;
70
+ if (since.seq === this.#seq) return [];
71
+ const oldest = this.#entries[0];
72
+ if (oldest === undefined || oldest.seq > since.seq + 1) return null;
73
+ return this.#entries.filter((entry) => entry.seq > since.seq);
74
+ }
75
+ }
@@ -0,0 +1,66 @@
1
+ // The three channel frames a node sends, and the subscribe target a client sends — browser-safe
2
+ // types, members of `sync-protocol.ts`'s `Frame` union since protocol 3. `records` writes the page's store; `events` never does; `replay-gap` is the server's
3
+ // verdict that this socket lost a `records` frame and must re-read the channel's catch-up query.
4
+
5
+ import type { Row } from '@ultimat3/core/page';
6
+ import type { JsonObject } from './json';
7
+
8
+ /** Where a resubscribe resumes: the last `records` frame the client applied on this channel. */
9
+ export interface ChannelSince {
10
+ readonly epoch: string;
11
+ readonly seq: number;
12
+ }
13
+
14
+ /** `subscribe.target` for a declared channel. `channel` is the declaration NAME, never a topic. */
15
+ export interface ChannelSubscribeTarget {
16
+ readonly kind: 'channel';
17
+ readonly channel: string;
18
+ readonly params: Readonly<Record<string, string>>;
19
+ readonly since?: ChannelSince;
20
+ }
21
+
22
+ /** type → record key → row: the same keyed shape as core's `RecordEnvelope.records`. */
23
+ export type ChannelAdopt = Readonly<Record<string, Readonly<Record<string, Row>>>>;
24
+ /** type → record keys to drop. */
25
+ export type ChannelRemove = Readonly<Record<string, readonly string[]>>;
26
+
27
+ /**
28
+ * One committed change on one channel. `channel` is the TOPIC (`name.param1.param2`), which the
29
+ * client derives from the same declaration. `seq` counts up by one per frame within `epoch`; a
30
+ * numeric hole is NOT a gap (a row the socket may not see is skipped for that socket) — only
31
+ * `replay-gap` is.
32
+ */
33
+ export interface ChannelRecordsFrame {
34
+ readonly type: 'records';
35
+ readonly v: number;
36
+ readonly channel: string;
37
+ readonly seq: number;
38
+ readonly epoch: string;
39
+ readonly adopt?: ChannelAdopt;
40
+ readonly remove?: ChannelRemove;
41
+ /**
42
+ * The write that produced this change: `writeDigest` of the idempotency key its request carried
43
+ * (`@ultimat3/core`), never the key. Absent for a write no page keyed — a job, a script, SQL.
44
+ * The page whose pending write this names settles it against these rows in the same
45
+ * notification, so its own echo is never painted under its own optimistic twin.
46
+ */
47
+ readonly write?: string;
48
+ }
49
+
50
+ /** Ephemeral (typing, a cursor, a toast). No seq, never written to the store, never replayed. */
51
+ export interface ChannelEventsFrame {
52
+ readonly type: 'events';
53
+ readonly v: number;
54
+ readonly channel: string;
55
+ readonly event: JsonObject;
56
+ }
57
+
58
+ /** "Your copy of this channel is wrong since `epoch`": re-run its catch-up read, then resume. */
59
+ export interface ReplayGapFrame {
60
+ readonly type: 'replay-gap';
61
+ readonly v: number;
62
+ readonly channel: string;
63
+ readonly epoch: string;
64
+ }
65
+
66
+ export type ChannelWireFrame = ChannelRecordsFrame | ChannelEventsFrame | ReplayGapFrame;
package/src/channel.ts CHANGED
@@ -1,52 +1,39 @@
1
- // Tier 1: channels. Typed topics over Bun's native WS pub/sub, fanned across nodes by `Transport`.
2
- //
3
- // A channel message rides the `patch` frame with `sid = topic` and `op: 'insert'` — a channel is an
4
- // append-only stream, so tier 1 needs no frame of its own. That is why climbing the ladder is a
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 { type Actor, finiteOption, logger, renderThrowable, uuid } from '@ultimat3/core';
8
- import { formatLsn } from './changefeed';
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 { subjectMatches, type Transport, type TransportSubscription } from './fanout';
28
+ import type { Transport } from './fanout';
16
29
  import type { JsonObject } from './json';
17
30
  import type { SocketRegistry, SyncSocket } from './socket';
18
- import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
31
+ import { decode, PROTOCOL_VERSION } from './sync-protocol';
19
32
 
20
- /** Branded so a raw string can never be published to; `topic()` is the only constructor. */
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
- * This node's mark on the patch ids it mints. Defaults to a per-hub random id, which is enough
62
- * to keep two nodes apart; declare it (the pod name, the `sync` instance id) when an operator
63
- * reading one frame should be able to say which node published it.
64
- *
65
- * A blank string is read as OMITTED, never as a mark: `??` only answers for `undefined`, so
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
- * One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
78
- * into the table before it is awaited: looked up before the await and written after it, two sockets
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
- // Trimmed before the emptiness test: `nodeId: ' '` marks a frame with a space, which reads in
132
- // a log as no mark at all and collides with the next hub that does the same.
133
- this.#nodeId = options.nodeId?.trim() || uuid();
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 subscribe(socket: SyncSocket, name: Topic): Promise<void> {
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 this.#authorize(socket.actor, name);
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
- await this.#authorize(actor, name);
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
- }