@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.
- package/CLAUDE.md +186 -122
- package/README.md +121 -126
- 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 +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -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 +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- 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 +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- 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 +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- 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 +214 -0
- package/src/use-query.ts +255 -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
|
@@ -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.
|
|
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
|
-
}
|