@ultimat3/realtime 1.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/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +36 -0
- package/src/change-buffer.ts +69 -0
- package/src/changefeed-env.ts +146 -0
- package/src/changefeed.ts +191 -0
- package/src/channel.ts +181 -0
- package/src/client.ts +439 -0
- package/src/cursor.ts +188 -0
- package/src/errors.ts +253 -0
- package/src/fanout.ts +159 -0
- package/src/hooks.ts +230 -0
- package/src/index.ts +328 -0
- package/src/json.ts +76 -0
- package/src/live-definition.ts +144 -0
- package/src/live-query.ts +449 -0
- package/src/local-store.ts +188 -0
- package/src/matcher-bridge.ts +169 -0
- package/src/nats-commands.ts +97 -0
- package/src/nats-connection-fixture.ts +105 -0
- package/src/nats-connection.ts +464 -0
- package/src/nats-fake.ts +431 -0
- package/src/nats-jetstream.ts +226 -0
- package/src/nats-kv.ts +157 -0
- package/src/nats-protocol.ts +222 -0
- package/src/nats-socket.ts +236 -0
- package/src/nats-transport.ts +257 -0
- package/src/offline-queue.ts +206 -0
- package/src/pg-advisory-lock.ts +98 -0
- package/src/pg-auth.ts +300 -0
- package/src/pg-bytes.ts +185 -0
- package/src/pg-connection-fixture.ts +215 -0
- package/src/pg-connection.ts +337 -0
- package/src/pg-entity-row.ts +130 -0
- package/src/pg-replication-fixture.ts +261 -0
- package/src/pg-replication.ts +396 -0
- package/src/pg-socket.ts +265 -0
- package/src/pg-wire.ts +192 -0
- package/src/pgoutput.ts +297 -0
- package/src/policy-gate.ts +56 -0
- package/src/presence.ts +219 -0
- package/src/rebase.ts +198 -0
- package/src/replicator.ts +185 -0
- package/src/socket.ts +208 -0
- package/src/sync-node.ts +400 -0
- package/src/sync-protocol.ts +376 -0
- package/src/thundering-herd.ts +141 -0
- package/src/transport-env.ts +104 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
import type { Actor } from '@ultimat3/core';
|
|
8
|
+
import { formatLsn } from './changefeed';
|
|
9
|
+
import { SubscriptionLimitError, TopicForbiddenError } from './errors';
|
|
10
|
+
import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
|
|
11
|
+
import type { JsonObject } from './json';
|
|
12
|
+
import type { SocketRegistry, SyncSocket } from './socket';
|
|
13
|
+
import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
14
|
+
|
|
15
|
+
/** Branded so a raw string can never be published to; `topic()` is the only constructor. */
|
|
16
|
+
export type Topic = string & { readonly __ultimateTopic: unique symbol };
|
|
17
|
+
|
|
18
|
+
const SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
19
|
+
const CHANNEL_SUBJECT_PREFIX = 'x.channel';
|
|
20
|
+
|
|
21
|
+
/** `topic('org', orgId, 'cursors')` -> `org.<orgId>.cursors`. Segments are validated, never escaped. */
|
|
22
|
+
export function topic(...parts: readonly (string | number)[]): Topic {
|
|
23
|
+
const segments = parts.map((part) => String(part));
|
|
24
|
+
for (const segment of segments) {
|
|
25
|
+
if (!SEGMENT.test(segment)) {
|
|
26
|
+
throw new TopicForbiddenError({
|
|
27
|
+
topic: segments.join('.'),
|
|
28
|
+
actorId: null,
|
|
29
|
+
reason: `segment "${segment}" must match ${SEGMENT.source} (dots and wildcards are reserved)`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return segments.join('.') as Topic;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TopicGuardArgs {
|
|
37
|
+
readonly actor: Actor | null;
|
|
38
|
+
readonly topic: Topic;
|
|
39
|
+
readonly segments: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type TopicGuardResult = boolean | { readonly allowed: boolean; readonly reason?: string };
|
|
43
|
+
export type TopicGuard = (args: TopicGuardArgs) => TopicGuardResult | Promise<TopicGuardResult>;
|
|
44
|
+
|
|
45
|
+
export interface ChannelHubOptions {
|
|
46
|
+
readonly transport: Transport;
|
|
47
|
+
readonly sockets: SocketRegistry;
|
|
48
|
+
readonly maxTopicsPerSocket?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deny by default: a topic with no matching guard is forbidden. An authz hole must be a typed
|
|
53
|
+
* error at subscribe time, not a config option someone forgot to set.
|
|
54
|
+
*/
|
|
55
|
+
export class ChannelHub {
|
|
56
|
+
readonly #transport: Transport;
|
|
57
|
+
readonly #sockets: SocketRegistry;
|
|
58
|
+
readonly #guards: Array<{ pattern: string; guard: TopicGuard }> = [];
|
|
59
|
+
readonly #bridges = new Map<string, { sub: TransportSubscription; refs: number }>();
|
|
60
|
+
readonly #maxTopicsPerSocket: number;
|
|
61
|
+
#sequence = 0n;
|
|
62
|
+
|
|
63
|
+
constructor(options: ChannelHubOptions) {
|
|
64
|
+
this.#transport = options.transport;
|
|
65
|
+
this.#sockets = options.sockets;
|
|
66
|
+
this.#maxTopicsPerSocket = options.maxTopicsPerSocket ?? 64;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `pattern` uses NATS wildcards: `org.*.cursors`, `org.>`. First registered match wins. */
|
|
70
|
+
guard(pattern: string, guard: TopicGuard): this {
|
|
71
|
+
this.#guards.push({ pattern, guard });
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async subscribe(socket: SyncSocket, name: Topic): Promise<void> {
|
|
76
|
+
if (socket.topics.has(name)) return;
|
|
77
|
+
if (socket.topics.size >= this.#maxTopicsPerSocket) {
|
|
78
|
+
throw new SubscriptionLimitError({
|
|
79
|
+
scope: 'socket',
|
|
80
|
+
id: socket.id,
|
|
81
|
+
limit: this.#maxTopicsPerSocket,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
await this.#authorize(socket.actor, name);
|
|
85
|
+
await this.#bridge(name);
|
|
86
|
+
socket.subscribeTopic(name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
unsubscribe(socket: SyncSocket, name: Topic): void {
|
|
90
|
+
if (!socket.topics.has(name)) return;
|
|
91
|
+
socket.unsubscribeTopic(name);
|
|
92
|
+
this.#release(name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Called when a socket's session changes (login, logout, role change, token refresh). */
|
|
96
|
+
async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
|
|
97
|
+
socket.actor = actor;
|
|
98
|
+
const dropped: Topic[] = [];
|
|
99
|
+
for (const name of [...socket.topics] as Topic[]) {
|
|
100
|
+
try {
|
|
101
|
+
await this.#authorize(actor, name);
|
|
102
|
+
} catch {
|
|
103
|
+
this.unsubscribe(socket, name);
|
|
104
|
+
dropped.push(name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return dropped;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Publishes to every node. Local delivery happens via the transport bridge, never directly. */
|
|
111
|
+
async publish(name: Topic, message: JsonObject): Promise<void> {
|
|
112
|
+
this.#sequence += 1n;
|
|
113
|
+
const frame = channelFrame(name, formatLsn(this.#sequence), message);
|
|
114
|
+
await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, encode(frame));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Frames already encoded elsewhere (presence, for one) reuse the same bridge. */
|
|
118
|
+
async publishFrame(name: Topic, frame: Frame): Promise<void> {
|
|
119
|
+
await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, encode(frame));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async close(): Promise<void> {
|
|
123
|
+
for (const bridge of this.#bridges.values()) bridge.sub.unsubscribe();
|
|
124
|
+
this.#bridges.clear();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #authorize(actor: Actor | null, name: Topic): Promise<void> {
|
|
128
|
+
const segments = name.split('.');
|
|
129
|
+
const entry = this.#guards.find(({ pattern }) => subjectMatches(pattern, name));
|
|
130
|
+
if (!entry) {
|
|
131
|
+
throw new TopicForbiddenError({
|
|
132
|
+
topic: name,
|
|
133
|
+
actorId: actor === null ? null : actor.id,
|
|
134
|
+
reason: 'no guard declared for this topic',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const result = await entry.guard({ actor, topic: name, segments });
|
|
138
|
+
const allowed = typeof result === 'boolean' ? result : result.allowed;
|
|
139
|
+
if (!allowed) {
|
|
140
|
+
const reason =
|
|
141
|
+
typeof result === 'boolean' ? 'guard denied' : (result.reason ?? 'guard denied');
|
|
142
|
+
throw new TopicForbiddenError({
|
|
143
|
+
topic: name,
|
|
144
|
+
actorId: actor === null ? null : actor.id,
|
|
145
|
+
reason,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One transport subscription per topic per node, refcounted across sockets. */
|
|
151
|
+
async #bridge(name: Topic): Promise<void> {
|
|
152
|
+
const existing = this.#bridges.get(name);
|
|
153
|
+
if (existing) {
|
|
154
|
+
existing.refs += 1;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const sub = await this.#transport.subscribe(`${CHANNEL_SUBJECT_PREFIX}.${name}`, (payload) => {
|
|
158
|
+
this.#sockets.deliver(name, decode(payload));
|
|
159
|
+
});
|
|
160
|
+
this.#bridges.set(name, { sub, refs: 1 });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#release(name: Topic): void {
|
|
164
|
+
const bridge = this.#bridges.get(name);
|
|
165
|
+
if (!bridge) return;
|
|
166
|
+
bridge.refs -= 1;
|
|
167
|
+
if (bridge.refs > 0) return;
|
|
168
|
+
bridge.sub.unsubscribe();
|
|
169
|
+
this.#bridges.delete(name);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function channelFrame(name: Topic, lsn: string, message: JsonObject): Frame {
|
|
174
|
+
return {
|
|
175
|
+
type: 'patch',
|
|
176
|
+
v: PROTOCOL_VERSION,
|
|
177
|
+
sid: name,
|
|
178
|
+
lsn,
|
|
179
|
+
patches: [{ op: 'insert', id: lsn, row: message, lsn }],
|
|
180
|
+
};
|
|
181
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// The client half. Framework-agnostic on purpose: the reactive primitive is injected, so this
|
|
2
|
+
// package never imports solid-js and can be exercised by `bun test` with two closures.
|
|
3
|
+
//
|
|
4
|
+
// One client serves all three tiers. `useLive` is tier 2; passing a `store` + `queue` makes the
|
|
5
|
+
// same call tier 3. Nothing about the subscription changes — that is the ladder's whole promise.
|
|
6
|
+
|
|
7
|
+
import { type Clock, systemClock, uuid } from '@ultimat3/core';
|
|
8
|
+
import type { Topic } from './channel';
|
|
9
|
+
import type { LiveCursor } from './cursor';
|
|
10
|
+
import type { JsonObject, JsonValue, Row, RowPatch } from './json';
|
|
11
|
+
import type { LocalStore, LocalTx, TableMap } from './local-store';
|
|
12
|
+
import { mutateFrame, type OfflineQueue } from './offline-queue';
|
|
13
|
+
import { type ConflictStrategy, type RebaseLog, reconcile } from './rebase';
|
|
14
|
+
import { decode, encode, type Frame, PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
|
|
15
|
+
import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
|
|
16
|
+
|
|
17
|
+
/** Injected reactive primitive. `createSignal` from Solid satisfies this exactly. */
|
|
18
|
+
export type SignalFactory = <T>(initial: T) => [get: () => T, set: (next: T) => void];
|
|
19
|
+
|
|
20
|
+
/** Injected socket, so tests drive the protocol without a network. */
|
|
21
|
+
export interface ClientSocket {
|
|
22
|
+
send(data: string): void;
|
|
23
|
+
close(code?: number, reason?: string): void;
|
|
24
|
+
onOpen(handler: () => void): void;
|
|
25
|
+
onMessage(handler: (data: string) => void): void;
|
|
26
|
+
onClose(handler: (code: number) => void): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type LiveState = 'loading' | 'live' | 'stale' | 'offline';
|
|
30
|
+
|
|
31
|
+
export interface LiveHandle<R extends Row = Row> {
|
|
32
|
+
/** The reactive accessor. In an app this is the Solid signal `useLive` returns. */
|
|
33
|
+
readonly rows: () => readonly R[];
|
|
34
|
+
readonly state: () => LiveState;
|
|
35
|
+
readonly cursor: () => LiveCursor | null;
|
|
36
|
+
unsubscribe(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LiveQueryRef {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MutatorRef<T extends TableMap = TableMap> {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
/** Optimistic twin. Pure — no I/O, no Date.now(), no Math.random(). */
|
|
46
|
+
local?: (tx: LocalTx<T>, input: JsonValue) => void;
|
|
47
|
+
readonly entity?: string;
|
|
48
|
+
readonly conflict?: ConflictStrategy;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LiveClientOptions<T extends TableMap = TableMap> {
|
|
52
|
+
readonly signal: SignalFactory;
|
|
53
|
+
/** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
|
|
54
|
+
readonly connect: () => ClientSocket;
|
|
55
|
+
readonly buildId: string;
|
|
56
|
+
readonly actorId?: string | null;
|
|
57
|
+
/** Tier 3 only. Without these, mutations are server-only and nothing is queued offline. */
|
|
58
|
+
readonly store?: LocalStore<T>;
|
|
59
|
+
readonly queue?: OfflineQueue;
|
|
60
|
+
readonly log?: RebaseLog<T>;
|
|
61
|
+
readonly backoff?: BackoffPolicy;
|
|
62
|
+
readonly rng?: Rng;
|
|
63
|
+
readonly clock?: Clock;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Registration {
|
|
67
|
+
readonly sid: string;
|
|
68
|
+
readonly name: string;
|
|
69
|
+
readonly input: JsonValue;
|
|
70
|
+
readonly setRows: (rows: readonly Row[]) => void;
|
|
71
|
+
readonly setState: (state: LiveState) => void;
|
|
72
|
+
readonly setCursor: (cursor: LiveCursor | null) => void;
|
|
73
|
+
rows: readonly Row[];
|
|
74
|
+
cursor: LiveCursor | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class LiveClient<T extends TableMap = TableMap> {
|
|
78
|
+
readonly #options: LiveClientOptions<T>;
|
|
79
|
+
readonly #clock: Clock;
|
|
80
|
+
readonly #registrations = new Map<string, Registration>();
|
|
81
|
+
readonly #topics = new Map<string, Set<(message: JsonObject) => void>>();
|
|
82
|
+
readonly #setUpdate: (buildId: string | null) => void;
|
|
83
|
+
readonly #setReconnectAt: (at: number | null) => void;
|
|
84
|
+
|
|
85
|
+
readonly appUpdateAvailable: () => string | null;
|
|
86
|
+
readonly reconnectAt: () => number | null;
|
|
87
|
+
/**
|
|
88
|
+
* The reactive primitive the app injected, re-exposed so anything built on this client derives
|
|
89
|
+
* its signals from the same runtime. One reactive runtime per app, never two.
|
|
90
|
+
*/
|
|
91
|
+
readonly signal: SignalFactory;
|
|
92
|
+
/** The durable queue when tier 3 is configured, so a queue count is read off the queue itself. */
|
|
93
|
+
readonly queue: OfflineQueue | undefined;
|
|
94
|
+
|
|
95
|
+
#socket: ClientSocket | null = null;
|
|
96
|
+
#attempt = 0;
|
|
97
|
+
/** A signal, not a field: `connected` is rendered, so a plain boolean would never re-render. */
|
|
98
|
+
readonly #connected: () => boolean;
|
|
99
|
+
readonly #setConnected: (next: boolean) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Subscribers notified after every offline-queue mutation: a manual drain, the automatic drain
|
|
102
|
+
* `connect()` runs on every reconnect, or an async ack/fail frame. `hooks.ts` wires its
|
|
103
|
+
* invalidation signal through `onQueueChange` rather than each call site remembering to bump it
|
|
104
|
+
* itself — see there for why that matters.
|
|
105
|
+
*/
|
|
106
|
+
readonly #queueListeners = new Set<() => void>();
|
|
107
|
+
|
|
108
|
+
constructor(options: LiveClientOptions<T>) {
|
|
109
|
+
this.#options = options;
|
|
110
|
+
this.#clock = options.clock ?? systemClock;
|
|
111
|
+
this.signal = options.signal;
|
|
112
|
+
this.queue = options.queue;
|
|
113
|
+
const [update, setUpdate] = options.signal<string | null>(null);
|
|
114
|
+
const [reconnectAt, setReconnectAt] = options.signal<number | null>(null);
|
|
115
|
+
const [connected, setConnected] = options.signal<boolean>(false);
|
|
116
|
+
this.appUpdateAvailable = update;
|
|
117
|
+
this.#setUpdate = setUpdate;
|
|
118
|
+
this.reconnectAt = reconnectAt;
|
|
119
|
+
this.#setReconnectAt = setReconnectAt;
|
|
120
|
+
this.#connected = connected;
|
|
121
|
+
this.#setConnected = setConnected;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get connected(): boolean {
|
|
125
|
+
return this.#connected();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
connect(): void {
|
|
129
|
+
const socket = this.#options.connect();
|
|
130
|
+
this.#socket = socket;
|
|
131
|
+
socket.onOpen(() => {
|
|
132
|
+
this.#setConnected(true);
|
|
133
|
+
this.#attempt = 0;
|
|
134
|
+
this.#setReconnectAt(null);
|
|
135
|
+
this.#send({
|
|
136
|
+
type: 'hello',
|
|
137
|
+
v: PROTOCOL_VERSION,
|
|
138
|
+
buildId: this.#options.buildId,
|
|
139
|
+
sessionId: null,
|
|
140
|
+
actorId: this.#options.actorId ?? null,
|
|
141
|
+
resume: [...this.#registrations.values()]
|
|
142
|
+
.map((registration) => registration.cursor)
|
|
143
|
+
.filter((cursor): cursor is LiveCursor => cursor !== null),
|
|
144
|
+
});
|
|
145
|
+
for (const registration of this.#registrations.values()) this.#sendSubscribe(registration);
|
|
146
|
+
void this.drain();
|
|
147
|
+
});
|
|
148
|
+
socket.onMessage((data) => {
|
|
149
|
+
this.#onFrame(decode(data));
|
|
150
|
+
});
|
|
151
|
+
socket.onClose(() => {
|
|
152
|
+
this.#setConnected(false);
|
|
153
|
+
for (const registration of this.#registrations.values()) registration.setState('offline');
|
|
154
|
+
this.#scheduleReconnect(null);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tier 2 and tier 3 alike. The returned accessor is the reactive result set. */
|
|
159
|
+
useLive<R extends Row = Row>(query: LiveQueryRef, input: JsonValue): LiveHandle<R> {
|
|
160
|
+
const sid = uuid();
|
|
161
|
+
const [rows, setRows] = this.#options.signal<readonly Row[]>([]);
|
|
162
|
+
const [state, setState] = this.#options.signal<LiveState>('loading');
|
|
163
|
+
const [cursor, setCursor] = this.#options.signal<LiveCursor | null>(null);
|
|
164
|
+
const registration: Registration = {
|
|
165
|
+
sid,
|
|
166
|
+
name: query.name,
|
|
167
|
+
input,
|
|
168
|
+
setRows,
|
|
169
|
+
setState,
|
|
170
|
+
setCursor,
|
|
171
|
+
rows: [],
|
|
172
|
+
cursor: null,
|
|
173
|
+
};
|
|
174
|
+
this.#registrations.set(sid, registration);
|
|
175
|
+
if (this.#connected()) this.#sendSubscribe(registration);
|
|
176
|
+
return {
|
|
177
|
+
rows: rows as () => readonly R[],
|
|
178
|
+
state,
|
|
179
|
+
cursor,
|
|
180
|
+
unsubscribe: () => {
|
|
181
|
+
this.#registrations.delete(sid);
|
|
182
|
+
this.#send({
|
|
183
|
+
type: 'subscribe',
|
|
184
|
+
v: PROTOCOL_VERSION,
|
|
185
|
+
op: 'drop',
|
|
186
|
+
sid,
|
|
187
|
+
target: { kind: 'query', qid: query.name, input, cursor: null },
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
subscribe(name: Topic, handler: (message: JsonObject) => void): () => void {
|
|
194
|
+
const handlers = this.#topics.get(name) ?? new Set<(message: JsonObject) => void>();
|
|
195
|
+
handlers.add(handler);
|
|
196
|
+
this.#topics.set(name, handlers);
|
|
197
|
+
this.#send({
|
|
198
|
+
type: 'subscribe',
|
|
199
|
+
v: PROTOCOL_VERSION,
|
|
200
|
+
op: 'add',
|
|
201
|
+
sid: name,
|
|
202
|
+
target: { kind: 'topic', topic: name },
|
|
203
|
+
});
|
|
204
|
+
return () => {
|
|
205
|
+
handlers.delete(handler);
|
|
206
|
+
if (handlers.size > 0) return;
|
|
207
|
+
this.#topics.delete(name);
|
|
208
|
+
this.#send({
|
|
209
|
+
type: 'subscribe',
|
|
210
|
+
v: PROTOCOL_VERSION,
|
|
211
|
+
op: 'drop',
|
|
212
|
+
sid: name,
|
|
213
|
+
target: { kind: 'topic', topic: name },
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Tier 1 publish. The server re-checks the topic policy; this is a request, not an assertion. */
|
|
219
|
+
publish(name: Topic, message: JsonObject): void {
|
|
220
|
+
this.#send({
|
|
221
|
+
type: 'patch',
|
|
222
|
+
v: PROTOCOL_VERSION,
|
|
223
|
+
sid: name,
|
|
224
|
+
lsn: '',
|
|
225
|
+
patches: [{ op: 'insert', id: uuid(), row: message, lsn: '' }],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The mutator entry point. Applies the optimistic twin, records a rebase entry, queues durably,
|
|
231
|
+
* then drains. Offline, everything but the drain still happens — that is tier 3's one extra
|
|
232
|
+
* property over tier 2.
|
|
233
|
+
*/
|
|
234
|
+
async mutate(mutator: MutatorRef<T>, input: JsonValue, key?: string): Promise<void> {
|
|
235
|
+
const idempotencyKey = key ?? `${mutator.name}:${uuid()}`;
|
|
236
|
+
const store = this.#options.store;
|
|
237
|
+
const queue = this.#options.queue;
|
|
238
|
+
const local = mutator.local;
|
|
239
|
+
const queued = await queue?.enqueue({
|
|
240
|
+
key: idempotencyKey,
|
|
241
|
+
name: mutator.name,
|
|
242
|
+
input,
|
|
243
|
+
at: this.#clock.now().getTime(),
|
|
244
|
+
});
|
|
245
|
+
if (store && local) {
|
|
246
|
+
store.apply(idempotencyKey, (tx) => local(tx, input));
|
|
247
|
+
this.#options.log?.record({
|
|
248
|
+
key: idempotencyKey,
|
|
249
|
+
seq: queued?.seq ?? 0,
|
|
250
|
+
entity: mutator.entity ?? mutator.name,
|
|
251
|
+
strategy: mutator.conflict ?? 'server-wins',
|
|
252
|
+
apply: (tx) => local(tx, input),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (!queue) {
|
|
256
|
+
this.#send(
|
|
257
|
+
mutateFrame({
|
|
258
|
+
key: idempotencyKey,
|
|
259
|
+
seq: 0,
|
|
260
|
+
name: mutator.name,
|
|
261
|
+
input,
|
|
262
|
+
enqueuedAt: this.#clock.now().getTime(),
|
|
263
|
+
attempts: 0,
|
|
264
|
+
status: 'pending',
|
|
265
|
+
error: null,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
await this.drain();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Sends every pending mutation in sequence order. Stops at the first one the socket refuses. */
|
|
274
|
+
async drain(): Promise<void> {
|
|
275
|
+
const queue = this.#options.queue;
|
|
276
|
+
if (!queue || !this.#connected()) return;
|
|
277
|
+
await queue.drain(async (mutation) => {
|
|
278
|
+
this.#send(mutateFrame(mutation));
|
|
279
|
+
});
|
|
280
|
+
this.#notifyQueueChange();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Fires whenever the offline queue changes for any reason: a direct `mutate`/`drain` call, the
|
|
285
|
+
* automatic drain `connect()` runs on every reconnect, or an async ack/fail frame arriving over
|
|
286
|
+
* the socket. `hooks.ts` is the only subscriber today — it bumps its invalidation signal here at
|
|
287
|
+
* `setLiveClient` time, so a component reading `useMutationQueue()` stays live across every
|
|
288
|
+
* transition, not just the ones a hook happens to await directly. Returns an unsubscribe
|
|
289
|
+
* function.
|
|
290
|
+
*/
|
|
291
|
+
onQueueChange(listener: () => void): () => void {
|
|
292
|
+
this.#queueListeners.add(listener);
|
|
293
|
+
return () => {
|
|
294
|
+
this.#queueListeners.delete(listener);
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#sendSubscribe(registration: Registration): void {
|
|
299
|
+
this.#send({
|
|
300
|
+
type: 'subscribe',
|
|
301
|
+
v: PROTOCOL_VERSION,
|
|
302
|
+
op: 'add',
|
|
303
|
+
sid: registration.sid,
|
|
304
|
+
target: {
|
|
305
|
+
kind: 'query',
|
|
306
|
+
qid: registration.name,
|
|
307
|
+
input: registration.input,
|
|
308
|
+
cursor: registration.cursor,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#onFrame(frame: Frame): void {
|
|
314
|
+
switch (frame.type) {
|
|
315
|
+
case 'snapshot': {
|
|
316
|
+
const registration = this.#registrations.get(frame.sid);
|
|
317
|
+
if (!registration) return;
|
|
318
|
+
registration.rows = frame.rows;
|
|
319
|
+
registration.cursor = frame.cursor;
|
|
320
|
+
registration.setRows(frame.rows);
|
|
321
|
+
registration.setCursor(frame.cursor);
|
|
322
|
+
registration.setState('live');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
case 'patch': {
|
|
326
|
+
const registration = this.#registrations.get(frame.sid);
|
|
327
|
+
if (registration) {
|
|
328
|
+
registration.rows = applyPatches(registration.rows, frame.patches);
|
|
329
|
+
registration.setRows(registration.rows);
|
|
330
|
+
registration.setState('live');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// No registration: it is a tier-1 channel message on `sid = topic`.
|
|
334
|
+
const handlers = this.#topics.get(frame.sid);
|
|
335
|
+
if (!handlers) return;
|
|
336
|
+
for (const patch of frame.patches) {
|
|
337
|
+
if (patch.row === null) continue;
|
|
338
|
+
for (const handler of handlers) handler(patch.row);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
case 'ack': {
|
|
343
|
+
const queue = this.#options.queue;
|
|
344
|
+
// `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
|
|
345
|
+
// than notifying right after the call keeps this correct even if that ordering ever
|
|
346
|
+
// changes, and it still fires exactly once the persisted write actually lands.
|
|
347
|
+
const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
|
|
348
|
+
void settled?.then(() => this.#notifyQueueChange());
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
case 'rebase': {
|
|
352
|
+
const store = this.#options.store;
|
|
353
|
+
const log = this.#options.log;
|
|
354
|
+
if (!store || !log) return;
|
|
355
|
+
reconcile({
|
|
356
|
+
store,
|
|
357
|
+
log,
|
|
358
|
+
ack: {
|
|
359
|
+
key: frame.key,
|
|
360
|
+
entity: frame.entity,
|
|
361
|
+
id: frame.row?.id ?? frame.key,
|
|
362
|
+
row: frame.row,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
case 'reconnect': {
|
|
368
|
+
this.#scheduleReconnect(frame.afterMs);
|
|
369
|
+
this.#socket?.close(1001, frame.reason);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
case 'update-available': {
|
|
373
|
+
this.#setUpdate(frame.buildId);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
case 'presence': {
|
|
377
|
+
const handlers = this.#topics.get(frame.topic);
|
|
378
|
+
if (!handlers) return;
|
|
379
|
+
const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
|
|
380
|
+
for (const handler of handlers) handler(message);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
case 'hello':
|
|
384
|
+
case 'subscribe':
|
|
385
|
+
case 'mutate':
|
|
386
|
+
// Client-authored frames: never received. Ignored rather than thrown, so a future
|
|
387
|
+
// bidirectional use of the same kind cannot break an old client.
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Honours a server-assigned delay when there is one; otherwise jittered exponential backoff. */
|
|
393
|
+
#scheduleReconnect(serverDelayMs: number | null): void {
|
|
394
|
+
const rng = this.#options.rng ?? Math.random;
|
|
395
|
+
const delay =
|
|
396
|
+
serverDelayMs ?? backoffDelay(this.#attempt, this.#options.backoff ?? defaultBackoff, rng);
|
|
397
|
+
this.#attempt += 1;
|
|
398
|
+
this.#setReconnectAt(this.#clock.now().getTime() + delay);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#send(frame: Frame): void {
|
|
402
|
+
this.#socket?.send(encode(frame));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#notifyQueueChange(): void {
|
|
406
|
+
for (const listener of this.#queueListeners) listener();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Minimal in-place patch application: the shape a Solid store update maps onto directly. */
|
|
411
|
+
export function applyPatches(rows: readonly Row[], patches: readonly RowPatch[]): readonly Row[] {
|
|
412
|
+
let next = rows;
|
|
413
|
+
for (const patch of patches) {
|
|
414
|
+
if (patch.op === 'delete') {
|
|
415
|
+
next = next.filter((row) => row.id !== patch.id);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (patch.row === null) continue;
|
|
419
|
+
const index = next.findIndex((row) => row.id === patch.id);
|
|
420
|
+
const current = index >= 0 ? next[index] : undefined;
|
|
421
|
+
const merged: Row = { ...(current ?? {}), ...patch.row, id: patch.id };
|
|
422
|
+
if (index >= 0) {
|
|
423
|
+
const copy = [...next];
|
|
424
|
+
copy[index] = merged;
|
|
425
|
+
next = copy;
|
|
426
|
+
} else if (patch.index !== undefined) {
|
|
427
|
+
const copy = [...next];
|
|
428
|
+
copy.splice(patch.index, 0, merged);
|
|
429
|
+
next = copy;
|
|
430
|
+
} else {
|
|
431
|
+
next = [...next, merged];
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return next;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function memberJson(member: PresenceMember): JsonValue {
|
|
438
|
+
return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
|
|
439
|
+
}
|