@ultimat3/realtime 23.0.0 → 25.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// One cap that spans sockets: live subscriptions counted per KEY (a tenant, an actor), held plus
|
|
2
|
+
// reserved, with the key each socket was counted under remembered. Split from
|
|
3
|
+
// `subscription-book.ts` so the tenant cap and the actor cap are one mechanism, not two copies.
|
|
4
|
+
|
|
5
|
+
import { SubscriptionLimitError } from './errors';
|
|
6
|
+
import type { SyncSocket } from './socket';
|
|
7
|
+
|
|
8
|
+
export interface CappedCountInit {
|
|
9
|
+
/** Which key a socket's subscriptions count against, or `null` for none (uncapped here). */
|
|
10
|
+
readonly principalOf: (socket: SyncSocket) => string | null;
|
|
11
|
+
/** `undefined` counts without refusing — the tenant cap's opt-out. Screened by the caller. */
|
|
12
|
+
readonly max: number | undefined;
|
|
13
|
+
readonly scope: 'tenant' | 'actor';
|
|
14
|
+
readonly knob: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every question answered from a `Map`, never a scan: the cap is asked on every subscribe frame,
|
|
19
|
+
* and walking the node's subscriptions for it was one socket consuming the node.
|
|
20
|
+
*/
|
|
21
|
+
export class CappedCount {
|
|
22
|
+
readonly #init: CappedCountInit;
|
|
23
|
+
readonly #held = new Map<string, number>();
|
|
24
|
+
readonly #claimed = new Map<string, number>();
|
|
25
|
+
/**
|
|
26
|
+
* The key each socket's subscriptions were counted under. Remembered rather than re-derived,
|
|
27
|
+
* because `socket.actor` is replaced by a re-auth: deriving it again at `delete` time would
|
|
28
|
+
* decrement a key that was never incremented and leave the old one counting forever.
|
|
29
|
+
*/
|
|
30
|
+
readonly #principalOfSocket = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
constructor(init: CappedCountInit) {
|
|
33
|
+
this.#init = init;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
count(key: string): number {
|
|
37
|
+
return this.#held.get(key) ?? 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The key a socket counts under: the remembered one, or what its actor says now. */
|
|
41
|
+
principalFor(socket: SyncSocket): string | null {
|
|
42
|
+
return this.#principalOfSocket.get(socket.id) ?? this.#init.principalOf(socket);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Claims count: a subscribe past this check has already committed the node to its work. */
|
|
46
|
+
assert(socket: SyncSocket): void {
|
|
47
|
+
const max = this.#init.max;
|
|
48
|
+
const key = this.principalFor(socket);
|
|
49
|
+
if (max === undefined || key === null) return;
|
|
50
|
+
if (this.count(key) + (this.#claimed.get(key) ?? 0) >= max) {
|
|
51
|
+
throw new SubscriptionLimitError({
|
|
52
|
+
scope: this.#init.scope,
|
|
53
|
+
id: key,
|
|
54
|
+
limit: max,
|
|
55
|
+
knob: this.#init.knob,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A reservation, given back to the key that took it even if the socket re-keys meanwhile. */
|
|
61
|
+
claim(socket: SyncSocket): { readonly principal: string | null; readonly release: () => void } {
|
|
62
|
+
const key = this.principalFor(socket);
|
|
63
|
+
if (key === null) return { principal: key, release: () => undefined };
|
|
64
|
+
bump(this.#claimed, key, 1);
|
|
65
|
+
return { principal: key, release: () => bump(this.#claimed, key, -1) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A subscribe reserved under `claimedPrincipal` is attaching, and its socket now counts under another
|
|
70
|
+
* key (a re-auth landed while it was in flight): it joins the new key only if that key has room —
|
|
71
|
+
* the check the new key never got at reserve time.
|
|
72
|
+
*/
|
|
73
|
+
assertIfMoved(socket: SyncSocket, claimedPrincipal: string | null): void {
|
|
74
|
+
if (this.principalFor(socket) !== claimedPrincipal) this.assert(socket);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How many of a socket's `held` subscriptions the key it is about to move to cannot take — `0`
|
|
79
|
+
* when it is not moving or there is room. A re-auth refuses that many rather than carrying a key
|
|
80
|
+
* past its cap: moving 128 onto a principal at 1,000 was 1,128, and filling an address then
|
|
81
|
+
* signing in was a way to repeat it.
|
|
82
|
+
*/
|
|
83
|
+
overflowOnRekey(socket: SyncSocket, held: number): number {
|
|
84
|
+
const max = this.#init.max;
|
|
85
|
+
const after = this.#init.principalOf(socket);
|
|
86
|
+
if (max === undefined || after === null || held === 0) return 0;
|
|
87
|
+
if (after === (this.#principalOfSocket.get(socket.id) ?? null)) return 0;
|
|
88
|
+
const room = max - this.count(after) - (this.#claimed.get(after) ?? 0);
|
|
89
|
+
return Math.max(0, held - Math.max(0, room));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The refusal this cap answers for the key `socket` would count under. */
|
|
93
|
+
refusalFor(socket: SyncSocket): SubscriptionLimitError {
|
|
94
|
+
return new SubscriptionLimitError({
|
|
95
|
+
scope: this.#init.scope,
|
|
96
|
+
id: this.#init.principalOf(socket) ?? 'unknown',
|
|
97
|
+
limit: this.#init.max ?? 0,
|
|
98
|
+
knob: this.#init.knob,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The socket closed: nothing it remembers may outlive it. */
|
|
103
|
+
forget(socketId: string): void {
|
|
104
|
+
this.#principalOfSocket.delete(socketId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Sockets this cap still remembers a key for — bounded by sockets that hold a subscription. */
|
|
108
|
+
get remembered(): number {
|
|
109
|
+
return this.#principalOfSocket.size;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
added(socket: SyncSocket): void {
|
|
113
|
+
const key = this.principalFor(socket);
|
|
114
|
+
if (key === null) return;
|
|
115
|
+
this.#principalOfSocket.set(socket.id, key);
|
|
116
|
+
bump(this.#held, key, 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `emptied`: the socket now holds nothing, so its remembered key goes too. */
|
|
120
|
+
deleted(socketId: string, emptied: boolean): void {
|
|
121
|
+
const key = this.#principalOfSocket.get(socketId);
|
|
122
|
+
if (key === undefined) return;
|
|
123
|
+
bump(this.#held, key, -1);
|
|
124
|
+
if (emptied) this.#principalOfSocket.delete(socketId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A re-auth moved the socket to another key: its `held` subscriptions move with it. */
|
|
128
|
+
reassign(socket: SyncSocket, held: number): void {
|
|
129
|
+
// Holding nothing, there is nothing to move and nothing to remember: `keyFor` falls back to
|
|
130
|
+
// the actor's key. Stored anyway, a re-auth on an idle socket was an entry nothing deleted.
|
|
131
|
+
if (held === 0) {
|
|
132
|
+
this.#principalOfSocket.delete(socket.id);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const before = this.#principalOfSocket.get(socket.id) ?? null;
|
|
136
|
+
const after = this.#init.principalOf(socket);
|
|
137
|
+
if (before === after) return;
|
|
138
|
+
if (before !== null) bump(this.#held, before, -held);
|
|
139
|
+
if (after === null) this.#principalOfSocket.delete(socket.id);
|
|
140
|
+
else {
|
|
141
|
+
this.#principalOfSocket.set(socket.id, after);
|
|
142
|
+
if (held > 0) bump(this.#held, after, held);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function bump(map: Map<string, number>, key: string, by: number): void {
|
|
148
|
+
const next = (map.get(key) ?? 0) + by;
|
|
149
|
+
if (next > 0) map.set(key, next);
|
|
150
|
+
else map.delete(key);
|
|
151
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// What a socket's re-decided grant does to it: the hub re-asks every channel seat under the new
|
|
2
|
+
// actor (each one it drops is refused under its sid, and left in presence), and the registry
|
|
3
|
+
// re-decides every live subscription. Split from `sync-node.ts` at its size ceiling.
|
|
4
|
+
|
|
5
|
+
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import type { ChannelHub, Topic } from './channel';
|
|
7
|
+
import type { ChannelSids } from './channel-sids';
|
|
8
|
+
import { detach } from './detach';
|
|
9
|
+
import { TopicForbiddenError } from './errors';
|
|
10
|
+
import type { LiveQueryRegistry } from './live-query';
|
|
11
|
+
import { refuseSubscription } from './live-refusal';
|
|
12
|
+
import type { PresenceRegistry } from './presence';
|
|
13
|
+
import type { SocketRegistry } from './socket';
|
|
14
|
+
|
|
15
|
+
export interface ActorChangeDeps {
|
|
16
|
+
readonly sockets: SocketRegistry;
|
|
17
|
+
readonly hub: ChannelHub;
|
|
18
|
+
readonly registry: LiveQueryRegistry;
|
|
19
|
+
readonly channelSids: ChannelSids;
|
|
20
|
+
readonly presence: PresenceRegistry | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The `onActor` a grant sweep calls once per socket whose grant was refreshed. */
|
|
24
|
+
export function actorChangeHandler(
|
|
25
|
+
deps: ActorChangeDeps,
|
|
26
|
+
): (socketId: string, actor: Actor) => Promise<void> {
|
|
27
|
+
const hasRoster = (name: Topic): boolean => deps.hub.channelOf(name)?.channel.events === true;
|
|
28
|
+
return async (socketId, actor) => {
|
|
29
|
+
const socket = deps.sockets.get(socketId);
|
|
30
|
+
if (!socket) return;
|
|
31
|
+
const rooms = new Set(deps.hub.topicsOf(socket).filter(hasRoster));
|
|
32
|
+
// The hub sets `socket.actor` and drops the topics this actor may no longer read; the
|
|
33
|
+
// registry re-decides every live subscription (refusing each one it drops under its sid)
|
|
34
|
+
// and desyncs the survivors, so the next delivery re-snapshots them under the new
|
|
35
|
+
// authority rather than the old window.
|
|
36
|
+
for (const name of await deps.hub.onActorChange(socket, actor)) {
|
|
37
|
+
// A dropped seat is SAID: unsaid, the client kept rendering the channel as live.
|
|
38
|
+
const sid = deps.channelSids.sidOf(socket, name);
|
|
39
|
+
deps.channelSids.delete(socket, name);
|
|
40
|
+
if (sid !== undefined) {
|
|
41
|
+
refuseSubscription(
|
|
42
|
+
socket,
|
|
43
|
+
sid,
|
|
44
|
+
new TopicForbiddenError({
|
|
45
|
+
topic: name,
|
|
46
|
+
actorId: socket.actorId,
|
|
47
|
+
reason: 'the session changed and the channel policy no longer admits it',
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (deps.presence && rooms.has(name))
|
|
52
|
+
detach(deps.presence.leave(name, socket.id), 'presence.leave', name);
|
|
53
|
+
}
|
|
54
|
+
await deps.registry.reauthorize(socket);
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/sync-auth.ts
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
// holds it, and the pass that re-decides one whose window has closed.
|
|
4
4
|
|
|
5
5
|
import type { Actor, Clock } from '@ultimat3/core';
|
|
6
|
+
import { finiteOption } from '@ultimat3/core';
|
|
7
|
+
import { GrantRefreshTimeoutError } from './errors';
|
|
8
|
+
import { type Scheduler, timeoutScheduler } from './thundering-herd';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How long one grant's `refresh()` may hold the re-auth pass. The pass is serial and one at a
|
|
12
|
+
* time, so a single call that never settles stopped every later grant on the node from being
|
|
13
|
+
* renewed OR revoked. On by default with no "off": a non-positive value takes the default.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_GRANT_REFRESH_DEADLINE_MS = 10_000;
|
|
6
16
|
|
|
7
17
|
/**
|
|
8
18
|
* One connection's identity. A grant, not an `Actor`, because a websocket outlives every credential
|
|
@@ -76,8 +86,12 @@ export interface GrantSweepDeps {
|
|
|
76
86
|
onActor: (socketId: string, actor: Actor) => Promise<void>;
|
|
77
87
|
/** Nobody may hold this socket any longer. The caller closes it. */
|
|
78
88
|
onRevoked: (socketId: string) => void;
|
|
79
|
-
/** `refresh` raised
|
|
89
|
+
/** `refresh` raised, or did not answer in time. The grant is kept and retried on the next pass. */
|
|
80
90
|
onRefreshFailed?: (socketId: string, error: unknown) => void;
|
|
91
|
+
/** See `DEFAULT_GRANT_REFRESH_DEADLINE_MS`. */
|
|
92
|
+
readonly refreshDeadlineMs?: number | undefined;
|
|
93
|
+
/** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
|
|
94
|
+
readonly schedule?: Scheduler | undefined;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
export interface GrantSweepResult {
|
|
@@ -98,18 +112,29 @@ export interface GrantSweepResult {
|
|
|
98
112
|
*/
|
|
99
113
|
export async function sweepGrants(deps: GrantSweepDeps): Promise<GrantSweepResult> {
|
|
100
114
|
const now = deps.clock.now().getTime();
|
|
115
|
+
const declared = finiteOption(
|
|
116
|
+
'sweepGrants',
|
|
117
|
+
'refreshDeadlineMs',
|
|
118
|
+
deps.refreshDeadlineMs ?? DEFAULT_GRANT_REFRESH_DEADLINE_MS,
|
|
119
|
+
);
|
|
120
|
+
const deadlineMs = declared > 0 ? declared : DEFAULT_GRANT_REFRESH_DEADLINE_MS;
|
|
121
|
+
const schedule = deps.schedule ?? timeoutScheduler;
|
|
101
122
|
let refreshed = 0;
|
|
102
123
|
let revoked = 0;
|
|
103
124
|
let failed = 0;
|
|
104
125
|
for (const [socketId, grant] of deps.grants.expired(now)) {
|
|
105
126
|
let next: SyncGrant | null;
|
|
106
127
|
try {
|
|
107
|
-
next = grant.refresh ? await grant.refresh() : null;
|
|
128
|
+
next = grant.refresh ? await within(grant.refresh(), socketId, deadlineMs, schedule) : null;
|
|
108
129
|
} catch (error) {
|
|
109
130
|
failed += 1;
|
|
110
131
|
deps.onRefreshFailed?.(socketId, error);
|
|
111
132
|
continue;
|
|
112
133
|
}
|
|
134
|
+
// Asked again, because `refresh` is the app's and awaiting it is awaiting a token service: the
|
|
135
|
+
// socket may have closed meanwhile (its grant deleted) or been re-granted. Written back
|
|
136
|
+
// regardless, a closed socket's grant stayed in the book and was refreshed forever.
|
|
137
|
+
if (deps.grants.get(socketId) !== grant) continue;
|
|
113
138
|
if (next === null) {
|
|
114
139
|
deps.grants.delete(socketId);
|
|
115
140
|
deps.onRevoked(socketId);
|
|
@@ -122,3 +147,23 @@ export async function sweepGrants(deps: GrantSweepDeps): Promise<GrantSweepResul
|
|
|
122
147
|
}
|
|
123
148
|
return { refreshed, revoked, failed };
|
|
124
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `refresh()`, or a coded timeout. The call itself cannot be cancelled and this does not pretend
|
|
153
|
+
* to: it runs on with nobody listening, and `Promise.race` keeps its late rejection handled.
|
|
154
|
+
*/
|
|
155
|
+
function within(
|
|
156
|
+
refreshing: Promise<SyncGrant | null>,
|
|
157
|
+
socketId: string,
|
|
158
|
+
afterMs: number,
|
|
159
|
+
schedule: Scheduler,
|
|
160
|
+
): Promise<SyncGrant | null> {
|
|
161
|
+
let disarm: () => void = () => undefined;
|
|
162
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
163
|
+
disarm = schedule(() => reject(new GrantRefreshTimeoutError({ socketId, afterMs })), afterMs);
|
|
164
|
+
});
|
|
165
|
+
const settled = Promise.race([refreshing, deadline]);
|
|
166
|
+
// An armed timer per answered refresh is a leak, and keeps a draining process alive.
|
|
167
|
+
void settled.then(disarm, disarm);
|
|
168
|
+
return settled;
|
|
169
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// What a sync node does with the change bus: one change in, fanned to the live queries and the
|
|
2
|
+
// declared channels; a gap or a reconnect, answered by invalidating both; and one producer KIND
|
|
3
|
+
// per table, the other's envelopes dropped. Split out of `sync-node.ts` so the node's lifecycle
|
|
4
|
+
// (start, stop, drain) stays readable in one file.
|
|
5
|
+
|
|
6
|
+
import { logger } from '@ultimat3/core';
|
|
7
|
+
import type { ChannelHub } from './channel';
|
|
8
|
+
import { detach } from './detach';
|
|
9
|
+
import type { LiveQueryRegistry } from './live-query';
|
|
10
|
+
import { RealtimeError } from './realtime-error';
|
|
11
|
+
import {
|
|
12
|
+
type ChangeEnvelope,
|
|
13
|
+
type EnvelopeSource,
|
|
14
|
+
parseEnvelope,
|
|
15
|
+
type SeqGapDetector,
|
|
16
|
+
} from './replicator-envelope';
|
|
17
|
+
|
|
18
|
+
export interface SyncBus {
|
|
19
|
+
readonly registry: LiveQueryRegistry;
|
|
20
|
+
readonly hub: ChannelHub;
|
|
21
|
+
readonly gaps: SeqGapDetector;
|
|
22
|
+
readonly producers: ProducerKinds;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The WAL replicator and a `recordPublisher` both carrying one table is every committed row twice
|
|
27
|
+
* on every channel. Logged, never thrown: the bus handler must keep serving every other table.
|
|
28
|
+
*/
|
|
29
|
+
export class ProducerConflictError extends RealtimeError {
|
|
30
|
+
constructor(args: { table: string; kept: EnvelopeSource; dropped: EnvelopeSource }) {
|
|
31
|
+
super({
|
|
32
|
+
code: 'X_REALTIME_PRODUCER_CONFLICT',
|
|
33
|
+
cause:
|
|
34
|
+
`table "${args.table}" is carried by the ${args.kept} and also by a ${args.dropped}, so ` +
|
|
35
|
+
`every committed row would reach its channels twice; this node keeps the ${args.kept} ` +
|
|
36
|
+
`(seen first) and drops the ${args.dropped}'s changes`,
|
|
37
|
+
// One fix for both orders: the replicator carries every entity table it can see, so the
|
|
38
|
+
// publisher is the half that can let go of one.
|
|
39
|
+
fix: `recordPublisher({ entities: [/* every entity but table ${args.table}'s */] }) # where the replicator runs: it already carries every committed row; then restart the sync node`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Which producer kind carries each table, decided by the first envelope this node reads for it and
|
|
46
|
+
* kept for the node's life. The other kind's envelopes for that table are dropped and the conflict
|
|
47
|
+
* said once per table (`X_REALTIME_PRODUCER_CONFLICT`). The `x dev` bridge is no producer here: it
|
|
48
|
+
* delivers in-process and stands in for the replicator, skipping a claimed table itself.
|
|
49
|
+
*/
|
|
50
|
+
export class ProducerKinds {
|
|
51
|
+
readonly #kinds = new Map<string, EnvelopeSource>();
|
|
52
|
+
readonly #said = new Set<string>();
|
|
53
|
+
|
|
54
|
+
/** `true` when this envelope's producer kind is the one its table is carried by. */
|
|
55
|
+
admit(envelope: ChangeEnvelope): boolean {
|
|
56
|
+
const table = envelope.change.table;
|
|
57
|
+
const kind = envelope.source ?? 'replicator';
|
|
58
|
+
const kept = this.#kinds.get(table);
|
|
59
|
+
if (kept === undefined) {
|
|
60
|
+
this.#kinds.set(table, kind);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (kept === kind) return true;
|
|
64
|
+
if (!this.#said.has(table)) {
|
|
65
|
+
this.#said.add(table);
|
|
66
|
+
const conflict = new ProducerConflictError({ table, kept, dropped: kind });
|
|
67
|
+
// FIELDS, never interpolation, and the message is the CODE alone (`pg-preflight.ts`).
|
|
68
|
+
logger.warn(conflict.code, {
|
|
69
|
+
cause: conflict.cause,
|
|
70
|
+
fix: conflict.fix,
|
|
71
|
+
table,
|
|
72
|
+
kept,
|
|
73
|
+
dropped: kind,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The handler a node subscribes to `CHANGE_SUBJECT_ALL` with. */
|
|
81
|
+
export function changeHandler(bus: SyncBus): (payload: string) => void {
|
|
82
|
+
return (payload) => {
|
|
83
|
+
const envelope = parseEnvelope(payload);
|
|
84
|
+
if (!envelope) return;
|
|
85
|
+
// Before the gap detector: a dropped producer's sequence is no stream this node follows.
|
|
86
|
+
if (!bus.producers.admit(envelope)) return;
|
|
87
|
+
// Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
|
|
88
|
+
// Nothing downstream could notice: no window's lsn moved, so no cursor moved, so nothing
|
|
89
|
+
// ever asked for a re-snapshot. A gap invalidates every window here instead, and the
|
|
90
|
+
// subscribers are re-served on the next change to each query.
|
|
91
|
+
const replicated = envelope.source !== 'publisher';
|
|
92
|
+
if (bus.gaps.observe(envelope)) {
|
|
93
|
+
// A publisher's lost change was never a window's (below), so only the channels re-read.
|
|
94
|
+
const marked = replicated ? bus.registry.invalidate() : 0;
|
|
95
|
+
// Channels too: their seq is minted from what this node sees, so the hole is invisible
|
|
96
|
+
// to a ring that would otherwise replay across it as complete history.
|
|
97
|
+
const channelGaps = bus.hub.invalidate();
|
|
98
|
+
logger.warn('live.change_gap', {
|
|
99
|
+
table: envelope.change.table,
|
|
100
|
+
desynced: marked,
|
|
101
|
+
channelGaps,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Not awaited: the bus handler must return before the next change, and ordering is the
|
|
105
|
+
// registry's — one serial lane per query id. What this call site owes is the failure. An
|
|
106
|
+
// unhandled rejection here is a fanout that reached nobody, reported as a dead process.
|
|
107
|
+
// A publisher's change has no commit position (its lsn orders nothing across processes), and
|
|
108
|
+
// a window refuses a change below its own lsn as stale: fed one, a live query would drop rows
|
|
109
|
+
// silently. Channels mint their own seq, so they take it (`record-publisher.ts`).
|
|
110
|
+
if (replicated) {
|
|
111
|
+
detach(bus.registry.deliver(envelope.change), 'live.deliver', envelope.change.table);
|
|
112
|
+
}
|
|
113
|
+
// The same stream feeds the declared channels: a write names no channel (axiom 2), and the
|
|
114
|
+
// hub turns this change into `records` frames on every channel it touches.
|
|
115
|
+
bus.hub.deliverChange(envelope.change);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A bus that reconnected is changes this node never saw, and the gap detector only notices on the
|
|
121
|
+
* NEXT message: with no later write, every window and cursor here stayed behind and every new
|
|
122
|
+
* subscriber joined the stale window. Said by the transport, repaired now.
|
|
123
|
+
*/
|
|
124
|
+
export function reconnectHandler(bus: SyncBus): () => void {
|
|
125
|
+
return () => {
|
|
126
|
+
bus.gaps.forget();
|
|
127
|
+
const marked = bus.registry.invalidate();
|
|
128
|
+
const channelGaps = bus.hub.invalidate();
|
|
129
|
+
logger.warn('live.bus_reconnected', { desynced: marked, channelGaps });
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/sync-frames.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { logger } from '@ultimat3/core';
|
|
6
6
|
import type { ChannelHub } from './channel';
|
|
7
7
|
import type { Topic } from './channel-decl';
|
|
8
|
-
import {
|
|
8
|
+
import { ChannelSids } from './channel-sids';
|
|
9
|
+
import { FrameRateLimitError, ProtocolVersionError } from './errors';
|
|
9
10
|
import { FrameLanes, laneKeyOf } from './frame-lanes';
|
|
10
11
|
import type { LiveQueryRegistry } from './live-query';
|
|
11
12
|
import { type PresenceRegistry, presenceFrame } from './presence';
|
|
@@ -17,8 +18,20 @@ export interface FrameRouterOptions {
|
|
|
17
18
|
readonly registry: LiveQueryRegistry;
|
|
18
19
|
readonly buildId: string;
|
|
19
20
|
readonly presence?: PresenceRegistry | undefined;
|
|
21
|
+
/** What the `hello` reply tells a client to beat at — `clientHeartbeatMs` on a real node. */
|
|
22
|
+
readonly heartbeatMs?: number | undefined;
|
|
23
|
+
/** Shared with the node, which names a seat's sid when a re-auth drops it. */
|
|
24
|
+
readonly channelSids?: ChannelSids | undefined;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The longest `sid` a subscribe frame may carry. A sid is retained — in the subscription book, in
|
|
29
|
+
* `ChannelSids`, and echoed in every ack — so its size is the client's to choose only up to here.
|
|
30
|
+
* The bundled client sends a uuid for a live query and `channel:<topic>` for a channel, so the
|
|
31
|
+
* ceiling sits well above a topic of several uuid params.
|
|
32
|
+
*/
|
|
33
|
+
export const MAX_SID_LENGTH = 512;
|
|
34
|
+
|
|
22
35
|
export type FrameRouter = (socket: SyncSocket, frame: Frame) => Promise<void>;
|
|
23
36
|
|
|
24
37
|
/**
|
|
@@ -29,26 +42,39 @@ export type FrameRouter = (socket: SyncSocket, frame: Frame) => Promise<void>;
|
|
|
29
42
|
*/
|
|
30
43
|
export function ackRefOf(frame: Frame | null, socketId: string): string {
|
|
31
44
|
if (frame === null) return socketId;
|
|
32
|
-
|
|
45
|
+
// An oversized sid is the refusal's reason, never its echo.
|
|
46
|
+
if (frame.type === 'subscribe' && frame.sid.length <= MAX_SID_LENGTH) return frame.sid;
|
|
33
47
|
return socketId;
|
|
34
48
|
}
|
|
35
49
|
|
|
36
|
-
export function
|
|
50
|
+
export function frameRouter(options: FrameRouterOptions): FrameRouter {
|
|
37
51
|
const presence = options.presence;
|
|
38
|
-
|
|
39
|
-
const channelTopics = new WeakMap<SyncSocket, Map<string, Topic>>();
|
|
52
|
+
const channelSids = options.channelSids ?? new ChannelSids();
|
|
40
53
|
|
|
41
54
|
/**
|
|
42
55
|
* Subscribing to a channel declared `events: true` IS joining its presence set: presence has no
|
|
43
56
|
* frame of its own (it rides that channel's `events`), so a second round trip saying "and I am
|
|
44
|
-
* here" would be a second way to do one thing. A records-only channel has no roster.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
57
|
+
* here" would be a second way to do one thing. A records-only channel has no roster.
|
|
58
|
+
*
|
|
59
|
+
* Repeating the frame is the heartbeat. A beat renews the member's TTL (`heartbeat`) instead of
|
|
60
|
+
* joining again — that was a KV put and a fleet-wide `join` event per member per beat — and is
|
|
61
|
+
* still answered with the whole roster: a presence delta is an ephemeral frame, so the roster a
|
|
62
|
+
* beat brings back is what repairs a client that lost one. A member that had already expired is
|
|
63
|
+
* joined again in full.
|
|
64
|
+
*
|
|
65
|
+
* A node built WITHOUT presence still answers: an events-only channel is sent nothing else, so
|
|
66
|
+
* unanswered its subscriber read `joining` until the first app event. The answer is the roster
|
|
67
|
+
* this node can vouch for — nobody.
|
|
48
68
|
*/
|
|
49
|
-
const joinPresence = async (socket: SyncSocket, name: Topic): Promise<void> => {
|
|
50
|
-
if (
|
|
51
|
-
const roster =
|
|
69
|
+
const joinPresence = async (socket: SyncSocket, name: Topic, beat: boolean): Promise<void> => {
|
|
70
|
+
if (options.hub.channelOf(name)?.channel.events !== true) return;
|
|
71
|
+
const roster = !presence
|
|
72
|
+
? { members: [], total: 0 }
|
|
73
|
+
: beat && (await presence.heartbeat(name, socket.id))
|
|
74
|
+
? await presence.roster(name)
|
|
75
|
+
: await presence.join(name, { id: socket.id, actorId: socket.actorId });
|
|
76
|
+
// Read, though nothing here can repair it: the next beat re-sends the set, and the log is the
|
|
77
|
+
// only trace a dropped one leaves anywhere.
|
|
52
78
|
if (!socket.send(presenceFrame(name, 'sync', roster.members, roster.total))) {
|
|
53
79
|
logger.warn('sync.presence_roster_dropped', { topic: name, socketId: socket.id });
|
|
54
80
|
}
|
|
@@ -90,6 +116,9 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
|
|
|
90
116
|
// The actor the upgrade resolved, so a client can render who the server thinks it is
|
|
91
117
|
// rather than who it thinks it sent.
|
|
92
118
|
actorId: socket.actorId,
|
|
119
|
+
// The beat this node's REAL presence ttl and idle budget need. A client cannot read
|
|
120
|
+
// either, and one beating on its own default under a shorter ttl is a false leave.
|
|
121
|
+
...(options.heartbeatMs === undefined ? {} : { heartbeatMs: options.heartbeatMs }),
|
|
93
122
|
});
|
|
94
123
|
if (socket.skewed) {
|
|
95
124
|
socket.send({ type: 'update-available', v: PROTOCOL_VERSION, buildId: options.buildId });
|
|
@@ -97,23 +126,47 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
|
|
|
97
126
|
return;
|
|
98
127
|
}
|
|
99
128
|
case 'subscribe': {
|
|
129
|
+
if (frame.sid.length > MAX_SID_LENGTH) {
|
|
130
|
+
throw new ProtocolVersionError({
|
|
131
|
+
got: PROTOCOL_VERSION,
|
|
132
|
+
expected: PROTOCOL_VERSION,
|
|
133
|
+
detail: `subscribe.sid is ${frame.sid.length} characters; the limit is ${MAX_SID_LENGTH}`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
100
136
|
if (frame.target.kind === 'channel') {
|
|
101
|
-
// The topic is the DECLARATION's to spell (`channel.topic(params)`), so a drop finds it
|
|
102
|
-
// by the sid its add was answered under — never by re-deriving it from client data.
|
|
103
|
-
const topics = channelTopics.get(socket) ?? new Map<string, Topic>();
|
|
104
|
-
channelTopics.set(socket, topics);
|
|
105
137
|
if (frame.op === 'drop') {
|
|
106
|
-
|
|
138
|
+
// The topic is the DECLARATION's to spell (`channel.topic(params)`), so a drop finds
|
|
139
|
+
// it by the sid its add was answered under — never by re-deriving it from client data.
|
|
140
|
+
const name = channelSids.topicOf(socket, frame.sid);
|
|
107
141
|
if (name === undefined) return;
|
|
108
|
-
|
|
142
|
+
channelSids.delete(socket, name);
|
|
109
143
|
const events = options.hub.channelOf(name)?.channel.events === true;
|
|
110
144
|
options.hub.unsubscribe(socket, name);
|
|
111
145
|
if (presence && events) await presence.leave(name, socket.id);
|
|
112
146
|
return;
|
|
113
147
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
148
|
+
// A seat this sid already names may be DENIED by this ask (a suspended seat re-decided):
|
|
149
|
+
// the hub drops it, and the sid and the presence member must not outlive it.
|
|
150
|
+
const held = channelSids.topicOf(socket, frame.sid);
|
|
151
|
+
const room = held !== undefined && options.hub.channelOf(held)?.channel.events === true;
|
|
152
|
+
let name: Topic;
|
|
153
|
+
try {
|
|
154
|
+
name = await options.hub.subscribeChannel(socket, frame.target);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (held !== undefined && !options.hub.topicsOf(socket).includes(held)) {
|
|
157
|
+
channelSids.delete(socket, held);
|
|
158
|
+
if (presence && room) await presence.leave(held, socket.id);
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
// Not seated: the socket died while the guard was answering (a presence member written
|
|
163
|
+
// now is one no close will ever remove), or the seat is SUSPENDED — its guard could not
|
|
164
|
+
// decide — and a roster is exactly the delivery a suspension withholds.
|
|
165
|
+
if (socket.closed || !socket.topics.has(name)) return;
|
|
166
|
+
// A seat this socket already held makes the frame its beat, never a second join.
|
|
167
|
+
const beat = channelSids.sidOf(socket, name) !== undefined;
|
|
168
|
+
channelSids.set(socket, name, frame.sid);
|
|
169
|
+
await joinPresence(socket, name, beat);
|
|
117
170
|
return;
|
|
118
171
|
}
|
|
119
172
|
if (frame.op === 'drop') {
|
package/src/sync-node-bounds.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// reads false — so `maxConnections: NaN` is a node that accepts without limit and says nothing.
|
|
7
7
|
|
|
8
8
|
import { finiteOption } from '@ultimat3/core';
|
|
9
|
+
import { DEFAULT_HEARTBEAT_MS } from './client-heartbeat';
|
|
9
10
|
|
|
10
11
|
const SUBJECT = 'the sync node';
|
|
11
12
|
|
|
@@ -81,7 +82,7 @@ export function syncNodeBounds(options: SyncNodeNumericOptions): SyncNodeBounds
|
|
|
81
82
|
* that `SyncSocket` — not this module — owns the default of.
|
|
82
83
|
*
|
|
83
84
|
* They were screened only in `SyncSocket`'s constructor, which Bun runs inside `websocket.open`,
|
|
84
|
-
* which Bun runs SYNCHRONOUSLY inside `server.upgrade`. Measured: `
|
|
85
|
+
* which Bun runs SYNCHRONOUSLY inside `server.upgrade`. Measured: `syncNode` did not throw,
|
|
85
86
|
* `/healthz` and `/readyz` both answered, `ready` was true, and every upgrade threw `X_INVARIANT`
|
|
86
87
|
* with the node holding zero sockets — a misconfiguration that fails per connection instead of at
|
|
87
88
|
* boot, and each of those throws leaked the grant `handleUpgrade` records before the upgrade.
|
|
@@ -127,3 +128,17 @@ export function socketCeilings(options: SocketCeilings): SocketCeilings {
|
|
|
127
128
|
/** Per CALL, not per node: `drain({ graceMs })` is an argument, so it is refused where it arrives. */
|
|
128
129
|
export const drainGraceMs = (graceMs: number | undefined): number =>
|
|
129
130
|
finiteOption('the sync node drain', 'graceMs', graceMs ?? DEFAULT_DRAIN_GRACE_MS);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The beat a client is TOLD (`hello.heartbeatMs`): a third of the node's real presence ttl, so one
|
|
134
|
+
* lost beat is never a false leave — and never slower than a quarter of the idle budget, or a
|
|
135
|
+
* long ttl would have this node evict a healthy socket for the silence it asked for. With no
|
|
136
|
+
* presence there is no ttl, and the client's own default is the starting point.
|
|
137
|
+
*/
|
|
138
|
+
export function clientHeartbeatMs(
|
|
139
|
+
presenceBeatMs: number | undefined,
|
|
140
|
+
idleTimeoutMs: number,
|
|
141
|
+
): number {
|
|
142
|
+
const idleBound = Math.max(1_000, Math.floor(idleTimeoutMs / 4));
|
|
143
|
+
return Math.min(presenceBeatMs ?? DEFAULT_HEARTBEAT_MS, idleBound);
|
|
144
|
+
}
|