@ultimat3/realtime 24.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 +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- 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 +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- 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 +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- 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 +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- 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-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- 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 +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/changefeed.ts
CHANGED
|
@@ -20,8 +20,13 @@ import type { Rng } from './thundering-herd';
|
|
|
20
20
|
export type ChangeOp = 'insert' | 'update' | 'delete' | 'truncate';
|
|
21
21
|
|
|
22
22
|
export interface ChangeEvent<R extends Row = Row> {
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The RELATION (table) name, on every producer: the WAL decoder reads it off the Relation
|
|
25
|
+
* message, the in-process bridge maps the row observer's entity name onto it, and a
|
|
26
|
+
* `recordPublisher` sends `projection.table`. A channel matches `projection.table`, a live shape
|
|
27
|
+
* is `from('<table>', …)`; the record TYPE (entity name) is derived from it (`recordTypeForTable`).
|
|
28
|
+
*/
|
|
29
|
+
readonly table: string;
|
|
25
30
|
readonly op: ChangeOp;
|
|
26
31
|
readonly before: R | null;
|
|
27
32
|
readonly after: R | null;
|
|
@@ -34,11 +39,13 @@ export interface ChangeEvent<R extends Row = Row> {
|
|
|
34
39
|
readonly at: number;
|
|
35
40
|
/**
|
|
36
41
|
* The write that made this change: `writeDigest` of the idempotency key its request carried
|
|
37
|
-
* (`@ultimat3/core`).
|
|
38
|
-
* Postgres driver writes first in the transaction, or off the request
|
|
39
|
-
* channel stamps it on
|
|
42
|
+
* (`@ultimat3/core`). `null` for a change no keyed request made — a job, a script, raw SQL. Read
|
|
43
|
+
* off the WAL message the Postgres driver writes first in the transaction, or off the request
|
|
44
|
+
* scope in-process; a channel stamps it on its `records` frame and a live query on its `patch`
|
|
45
|
+
* frame, so the writing page settles its own echo in the same batch. REQUIRED, never optional:
|
|
46
|
+
* a producer that forgets it is a type error, not a page that paints truth plus overlay.
|
|
40
47
|
*/
|
|
41
|
-
readonly write
|
|
48
|
+
readonly write: string | null;
|
|
42
49
|
/**
|
|
43
50
|
* Row properties `after` does NOT carry because Postgres did not log them: an UPDATE that left
|
|
44
51
|
* an out-of-line (TOAST) value untouched sends no bytes for it, and under any replica identity
|
|
@@ -86,7 +93,7 @@ export function parseLsn(pgLsn: string): string {
|
|
|
86
93
|
return formatLsn((BigInt(`0x${high}`) << 32n) | BigInt(`0x${low}`));
|
|
87
94
|
}
|
|
88
95
|
|
|
89
|
-
export interface
|
|
96
|
+
export interface MemoryChangeFeedOptions {
|
|
90
97
|
/** Retained events, so a `start({ from })` inside the window replays instead of skipping. */
|
|
91
98
|
readonly retain?: number;
|
|
92
99
|
}
|
|
@@ -96,7 +103,7 @@ export interface InMemoryChangeFeedOptions {
|
|
|
96
103
|
* ordering is the guarantee the whole pipeline is built on, so it is enforced here rather than
|
|
97
104
|
* assumed downstream.
|
|
98
105
|
*/
|
|
99
|
-
export class
|
|
106
|
+
export class MemoryChangeFeed implements ChangeFeed {
|
|
100
107
|
readonly source = 'in-memory';
|
|
101
108
|
readonly #retained: ChangeEvent[] = [];
|
|
102
109
|
readonly #retain: number;
|
|
@@ -105,7 +112,7 @@ export class InMemoryChangeFeed implements ChangeFeed {
|
|
|
105
112
|
#position = 0n;
|
|
106
113
|
#lastLsn: string | null = null;
|
|
107
114
|
|
|
108
|
-
constructor(options:
|
|
115
|
+
constructor(options: MemoryChangeFeedOptions = {}) {
|
|
109
116
|
this.#retain = finiteOption('the change feed', 'retain', options.retain ?? 1024);
|
|
110
117
|
}
|
|
111
118
|
|
|
@@ -142,13 +149,19 @@ export class InMemoryChangeFeed implements ChangeFeed {
|
|
|
142
149
|
|
|
143
150
|
/** Ergonomic emit: assigns the next lsn and txid so tests read as domain events. */
|
|
144
151
|
async push(
|
|
145
|
-
|
|
152
|
+
table: string,
|
|
146
153
|
op: ChangeOp,
|
|
147
|
-
rows: {
|
|
154
|
+
rows: {
|
|
155
|
+
before?: Row | null;
|
|
156
|
+
after?: Row | null;
|
|
157
|
+
orgId?: string | null;
|
|
158
|
+
at?: number;
|
|
159
|
+
write?: string | null;
|
|
160
|
+
},
|
|
148
161
|
): Promise<ChangeEvent> {
|
|
149
162
|
this.#position += 1n;
|
|
150
163
|
const event: ChangeEvent = {
|
|
151
|
-
|
|
164
|
+
table,
|
|
152
165
|
op,
|
|
153
166
|
before: rows.before ?? null,
|
|
154
167
|
after: rows.after ?? null,
|
|
@@ -156,6 +169,7 @@ export class InMemoryChangeFeed implements ChangeFeed {
|
|
|
156
169
|
txid: this.#position.toString(10),
|
|
157
170
|
orgId: rows.orgId ?? null,
|
|
158
171
|
at: rows.at ?? 0,
|
|
172
|
+
write: rows.write ?? null,
|
|
159
173
|
};
|
|
160
174
|
await this.emit(event);
|
|
161
175
|
return event;
|
|
@@ -171,7 +185,7 @@ export class InMemoryChangeFeed implements ChangeFeed {
|
|
|
171
185
|
// The lane chains on a SETTLED shadow, never on `result` — `window-lock.ts` solves the same
|
|
172
186
|
// problem the same way. Chained on the live tail, one rejected link poisoned every link behind
|
|
173
187
|
// it: later changes rejected with the FIRST error, the handler was never called again and
|
|
174
|
-
// `lastLsn()` froze. Reachable on any single-node deployment, because `
|
|
188
|
+
// `lastLsn()` froze. Reachable on any single-node deployment, because `changeFeedReplicator`'s
|
|
175
189
|
// `onChange` awaits `transport.publish(...)` and a closed `InProcessTransport` refuses — one
|
|
176
190
|
// transient publish failure ended change delivery for the life of the process. The rejection
|
|
177
191
|
// still reaches the caller that pushed THAT event, and only it; `stop()` awaits the shadow, so
|
|
@@ -181,10 +195,15 @@ export class InMemoryChangeFeed implements ChangeFeed {
|
|
|
181
195
|
}
|
|
182
196
|
}
|
|
183
197
|
|
|
198
|
+
/** The one way to build the in-process feed — the twin of `postgresChangeFeed()`; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
|
|
199
|
+
export function memoryChangeFeed(options: MemoryChangeFeedOptions = {}): MemoryChangeFeed {
|
|
200
|
+
return new MemoryChangeFeed(options);
|
|
201
|
+
}
|
|
202
|
+
|
|
184
203
|
/** Settles the shadow lane whichever way the delivery went. Nothing observes the value. */
|
|
185
204
|
const ignore = (): void => undefined;
|
|
186
205
|
|
|
187
|
-
export interface
|
|
206
|
+
export interface PostgresChangeFeedOptions {
|
|
188
207
|
/** Connection string for a role with REPLICATION: `postgres://user:pass@host:5432/db`. */
|
|
189
208
|
readonly url: string;
|
|
190
209
|
/** Replication slot name. Exactly one `replicator` process may hold it. */
|
|
@@ -210,19 +229,19 @@ export interface PgLogicalReplicationOptions {
|
|
|
210
229
|
/**
|
|
211
230
|
* The production feed: `pgoutput` decoding off a logical replication slot. Everything about *how*
|
|
212
231
|
* lives in `pg-replication.ts`; what this class adds is the `ChangeFeed` contract the matcher, the
|
|
213
|
-
* replicator and the fanout are written against — so swapping it for `
|
|
232
|
+
* replicator and the fanout are written against — so swapping it for `MemoryChangeFeed` in `x dev`
|
|
214
233
|
* changes nothing downstream.
|
|
215
234
|
*/
|
|
216
|
-
export class
|
|
235
|
+
export class PostgresChangeFeed implements ChangeFeed {
|
|
217
236
|
readonly source = 'pg-logical-replication';
|
|
218
237
|
readonly #stream: PgReplicationStream;
|
|
219
238
|
|
|
220
|
-
constructor(options:
|
|
239
|
+
constructor(options: PostgresChangeFeedOptions) {
|
|
221
240
|
if (options.entities.length === 0) {
|
|
222
241
|
throw new ReplicationFailedError({
|
|
223
242
|
stage: 'preflight',
|
|
224
243
|
detail: 'the feed was given an empty entity list, so no change could ever match',
|
|
225
|
-
fix: 'pass the entities the publication covers:
|
|
244
|
+
fix: 'pass the entities the publication covers: postgresChangeFeed({ entities: [...] })',
|
|
226
245
|
});
|
|
227
246
|
}
|
|
228
247
|
this.#stream = new PgReplicationStream(options);
|
|
@@ -253,3 +272,8 @@ export class PgLogicalReplicationFeed implements ChangeFeed {
|
|
|
253
272
|
return this.#stream.stats();
|
|
254
273
|
}
|
|
255
274
|
}
|
|
275
|
+
|
|
276
|
+
/** The one way to build the WAL-backed feed — the twin of `memoryChangeFeed()`; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
|
|
277
|
+
export function postgresChangeFeed(options: PostgresChangeFeedOptions): PostgresChangeFeed {
|
|
278
|
+
return new PostgresChangeFeed(options);
|
|
279
|
+
}
|
package/src/channel-authz.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// A declared channel's policy, asked on subscribe: the params are the input and the row is what
|
|
2
|
-
// the channel's own loader answered. Through `@ultimat3/query`'s `
|
|
3
|
-
// seam — the same call `policy-gate.ts` makes for a live query.
|
|
2
|
+
// the channel's own loader answered. Through `@ultimat3/query`'s `guardQuery`, the package's one
|
|
3
|
+
// authz seam — the same call `policy-gate.ts` makes for a live query.
|
|
4
4
|
|
|
5
|
-
import { type Actor, type Ctx,
|
|
6
|
-
import {
|
|
5
|
+
import { type Actor, type Ctx, ctxOf, runWithContext } from '@ultimat3/core';
|
|
6
|
+
import { guardQuery, QueryDeniedError } from '@ultimat3/query';
|
|
7
7
|
import type { Channel } from './channel-decl';
|
|
8
8
|
import { isTenancyDenial, TopicForbiddenError } from './errors';
|
|
9
9
|
|
|
@@ -34,7 +34,7 @@ export async function authorizeChannel(
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
// The loader and the rule run AS the subscriber, never as the node. Services are rebuilt for
|
|
37
|
-
// this actor by `
|
|
37
|
+
// this actor by `ctxOf`, the rule `withChildContext` follows.
|
|
38
38
|
const scoped = subscriberContext(ctx, actor);
|
|
39
39
|
let row: unknown;
|
|
40
40
|
try {
|
|
@@ -59,7 +59,7 @@ function decide(
|
|
|
59
59
|
row: unknown,
|
|
60
60
|
): void {
|
|
61
61
|
try {
|
|
62
|
-
|
|
62
|
+
guardQuery(channel.policy, { actor, input: params, row, ctx, query: channel.name }, 'live');
|
|
63
63
|
} catch (error) {
|
|
64
64
|
if (!(error instanceof QueryDeniedError)) throw error;
|
|
65
65
|
throw new TopicForbiddenError({
|
|
@@ -72,7 +72,7 @@ function decide(
|
|
|
72
72
|
|
|
73
73
|
/** The node's context, re-made for one subscriber: same deploy, role and clock, their actor. */
|
|
74
74
|
function subscriberContext(node: Ctx, actor: Actor): Ctx {
|
|
75
|
-
return
|
|
75
|
+
return ctxOf({
|
|
76
76
|
actor,
|
|
77
77
|
role: node.role,
|
|
78
78
|
buildId: node.buildId,
|
package/src/channel-describe.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// flattened the way a query's is. Server-side on purpose — the policy flattening is
|
|
3
3
|
// `@ultimat3/query`'s, and a browser island declaring a channel must not bundle it.
|
|
4
4
|
|
|
5
|
-
import { policyCapability, policyPermissions } from '@ultimat3/
|
|
5
|
+
import { policyCapability, policyPermissions } from '@ultimat3/policy';
|
|
6
6
|
import { registeredChannels } from './channel-registry';
|
|
7
7
|
|
|
8
8
|
export interface ChannelDescription {
|
package/src/channel-logs.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the change → frame path, and the resume a resubscribe `since` asks for. One per `ChannelHub`.
|
|
3
3
|
// Records never cross the bus — each node reads every change itself — so seq is minted here.
|
|
4
4
|
|
|
5
|
-
import { logger, renderThrowable,
|
|
5
|
+
import { logger, renderThrowable, uuidV7 } from '@ultimat3/core';
|
|
6
6
|
import type { ChangeEvent } from './changefeed';
|
|
7
7
|
import type { Channel } from './channel-decl';
|
|
8
8
|
import { carriesTable, updatesFor } from './channel-records';
|
|
@@ -22,7 +22,7 @@ export class ChannelLogs {
|
|
|
22
22
|
readonly #sockets: SocketRegistry;
|
|
23
23
|
readonly #ringSize: number | undefined;
|
|
24
24
|
/** This hub's mark on every epoch it mints: a restarted node can never reuse one. */
|
|
25
|
-
readonly #hubId =
|
|
25
|
+
readonly #hubId = uuidV7();
|
|
26
26
|
#rings = 0;
|
|
27
27
|
readonly #byTopic = new Map<
|
|
28
28
|
string,
|
|
@@ -59,14 +59,14 @@ export class ChannelLogs {
|
|
|
59
59
|
* the change is somebody else's too, and one malformed image must not stop the rest.
|
|
60
60
|
*/
|
|
61
61
|
deliverChange(channels: Iterable<Channel>, change: ChangeEvent): number {
|
|
62
|
-
if (change.op === 'truncate') return this
|
|
62
|
+
if (change.op === 'truncate') return this.invalidate(change.table);
|
|
63
63
|
let frames = 0;
|
|
64
64
|
let updates: ReturnType<typeof updatesFor>;
|
|
65
65
|
try {
|
|
66
66
|
updates = updatesFor(channels, change);
|
|
67
67
|
} catch (error) {
|
|
68
68
|
logger.warn('channel.records_unkeyed', {
|
|
69
|
-
table: change.
|
|
69
|
+
table: change.table,
|
|
70
70
|
error: renderThrowable(error),
|
|
71
71
|
});
|
|
72
72
|
return 0;
|
|
@@ -79,7 +79,7 @@ export class ChannelLogs {
|
|
|
79
79
|
frames += this.#reopen(update.topic, open.target);
|
|
80
80
|
continue;
|
|
81
81
|
}
|
|
82
|
-
const entry = open.ring.append(update.adopt, update.remove, change.write);
|
|
82
|
+
const entry = open.ring.append(update.adopt, update.remove, change.write ?? undefined);
|
|
83
83
|
const frame = renderRecords(update.topic, open.ring.epoch, entry);
|
|
84
84
|
frames += this.#sockets.deliverRecords(update.topic, open.ring.epoch, frame);
|
|
85
85
|
}
|
|
@@ -87,30 +87,21 @@ export class ChannelLogs {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
announced += this.#reopen(topic, open.target);
|
|
100
|
-
}
|
|
101
|
-
return announced;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* This node may have missed a change (the bus dropped, or skipped a sequence). Seq is minted
|
|
106
|
-
* HERE from the changes this node sees, so the hole is invisible to the ring: the next change
|
|
107
|
-
* would be appended as the next seq and a `since` replayed across it as complete history. Every
|
|
108
|
-
* open topic that carries records therefore starts a new epoch and tells its members.
|
|
90
|
+
* This node may have missed a change, and seq is minted HERE from the changes it sees, so the
|
|
91
|
+
* hole is invisible to the ring: the next change would be appended as the next seq and a
|
|
92
|
+
* `since` replayed across it as complete history. Every open topic that carries records — or,
|
|
93
|
+
* given a `table`, every one carrying THAT table — starts a new epoch and tells its members
|
|
94
|
+
* `replay-gap`, which the client answers by re-running the channel's catch-up read.
|
|
95
|
+
*
|
|
96
|
+
* With a table, three callers: a gap on the bus (none), a truncate (every row of it is gone, and
|
|
97
|
+
* no frame can say which ones a member holds), and a bulk write the in-process replicator saw
|
|
98
|
+
* as a fact rather than as rows (`live-replicator.ts`).
|
|
109
99
|
*/
|
|
110
|
-
invalidate(): number {
|
|
100
|
+
invalidate(table?: string): number {
|
|
111
101
|
let announced = 0;
|
|
112
102
|
for (const [topic, open] of [...this.#byTopic]) {
|
|
113
103
|
if (open.target.channel.records.length === 0) continue;
|
|
104
|
+
if (table !== undefined && !carriesTable(open.target.channel, table)) continue;
|
|
114
105
|
announced += this.#reopen(topic, open.target);
|
|
115
106
|
}
|
|
116
107
|
return announced;
|
package/src/channel-presence.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `events: true` joins its presence set, and a roster change is one `events` frame on that topic.
|
|
3
3
|
// Browser-safe — the client reads the same shape back out with `readPresence`.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { isParsedJsonObject, type JsonObject } from './json';
|
|
6
6
|
import { FRAME_LIMITS, type PresenceMember } from './sync-protocol';
|
|
7
7
|
|
|
8
8
|
export type PresenceOp = 'join' | 'leave' | 'update' | 'sync';
|
|
@@ -60,9 +60,9 @@ export function readPresence(event: JsonObject): PresenceEvent | null {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function memberOf(value: unknown): PresenceMember | null {
|
|
63
|
-
if (!
|
|
63
|
+
if (!isParsedJsonObject(value)) return null;
|
|
64
64
|
const { id, actorId, meta, updatedAt } = value;
|
|
65
65
|
if (typeof id !== 'string' || typeof updatedAt !== 'number') return null;
|
|
66
66
|
if (actorId !== null && typeof actorId !== 'string') return null;
|
|
67
|
-
return { id, actorId, meta:
|
|
67
|
+
return { id, actorId, meta: isParsedJsonObject(meta) ? meta : {}, updatedAt };
|
|
68
68
|
}
|
package/src/channel-records.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function skippedRemovals(): number {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* `change.
|
|
40
|
+
* `change.table` is the replicated RELATION name (`pg-replication.ts` reads it off the Relation
|
|
41
41
|
* message), so it is matched against each projection's `table`, never its `type`.
|
|
42
42
|
*
|
|
43
43
|
* - insert/update: the new row is adopted on the topic its params name.
|
|
@@ -53,7 +53,7 @@ export function updatesFor(channels: Iterable<Channel>, change: ChangeEvent): To
|
|
|
53
53
|
// open topic of every channel carrying the relation instead (`truncatedTopics`).
|
|
54
54
|
if (change.op === 'truncate') return updates;
|
|
55
55
|
for (const channel of channels) {
|
|
56
|
-
const projection = channel.records.find((candidate) => candidate.table === change.
|
|
56
|
+
const projection = channel.records.find((candidate) => candidate.table === change.table);
|
|
57
57
|
if (projection === undefined) continue;
|
|
58
58
|
const after = change.op === 'delete' ? null : change.after;
|
|
59
59
|
const before = change.before;
|
|
@@ -62,7 +62,7 @@ export function updatesFor(channels: Iterable<Channel>, change: ChangeEvent): To
|
|
|
62
62
|
if (before !== null && beforeParams === null && channel.params.length > 0) {
|
|
63
63
|
skipped += 1;
|
|
64
64
|
removalsSkipped.add(1);
|
|
65
|
-
logger.warn('channel.removal_skipped', { channel: channel.name, table: change.
|
|
65
|
+
logger.warn('channel.removal_skipped', { channel: channel.name, table: change.table });
|
|
66
66
|
}
|
|
67
67
|
const afterTopic = afterParams === null ? null : channel.topic(afterParams);
|
|
68
68
|
const beforeTopic = beforeParams === null ? null : channel.topic(beforeParams);
|
package/src/channel-ref.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// records and policy with `channel(ref, { … })`, so name and params are written once and a browser
|
|
4
4
|
// chunk never carries `@ultimat3/entity`.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { assertCoded } from '@ultimat3/core/page';
|
|
7
7
|
import { TopicForbiddenError } from './errors';
|
|
8
8
|
|
|
9
9
|
/** Branded so a raw string can never be published to; `topic()` is the only constructor. */
|
|
@@ -49,7 +49,7 @@ export interface ChannelHandle<K extends string = string> {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function refuseChannel(name: string, detail: string, fix: string): never {
|
|
52
|
-
|
|
52
|
+
assertCoded(false, 'X_CHANNEL_DECLARATION_INVALID', `channel("${name}"): ${detail}`, fix);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/** Refuses a bad name or a repeated param, and builds the ref. `channel()` is built on this. */
|
package/src/channel-registry.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
// and the manifest reads the same table (`channel-describe.ts`); a second declaration of one name
|
|
4
4
|
// is refused. Browser-safe: `channel()` runs in islands too.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { assertCoded } from '@ultimat3/core';
|
|
7
7
|
import type { Channel } from './channel-decl';
|
|
8
8
|
|
|
9
9
|
const channels = new Map<string, Channel>();
|
|
10
10
|
|
|
11
11
|
export function registerChannel(declared: Channel): Channel {
|
|
12
|
-
|
|
12
|
+
assertCoded(
|
|
13
13
|
!channels.has(declared.name),
|
|
14
14
|
'X_CHANNEL_DECLARATION_INVALID',
|
|
15
15
|
`two channel() declarations share the name "${declared.name}"`,
|
package/src/channel.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
type Actor,
|
|
8
|
+
assertCoded,
|
|
8
9
|
type Ctx,
|
|
9
|
-
|
|
10
|
+
ctxOf,
|
|
10
11
|
finiteOption,
|
|
11
|
-
invariant,
|
|
12
12
|
logger,
|
|
13
13
|
renderThrowable,
|
|
14
14
|
} from '@ultimat3/core';
|
|
@@ -109,7 +109,7 @@ export class ChannelHub {
|
|
|
109
109
|
options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE,
|
|
110
110
|
);
|
|
111
111
|
this.#latched = new DenialLatch(this.#maxTopicsPerSocket, 'maxTopicsPerSocket');
|
|
112
|
-
this.#ctx = options.ctx ??
|
|
112
|
+
this.#ctx = options.ctx ?? ctxOf();
|
|
113
113
|
this.#logs = new ChannelLogs(options.sockets, options.ringSize);
|
|
114
114
|
this.#only =
|
|
115
115
|
options.channels === undefined ? null : new Map(options.channels.map((c) => [c.name, c]));
|
|
@@ -213,10 +213,11 @@ export class ChannelHub {
|
|
|
213
213
|
* Every open records topic is presumed to have missed a change: a new epoch, and every member
|
|
214
214
|
* told `replay-gap`. The `sync` node calls it beside `LiveQueryRegistry.invalidate()` — seq is
|
|
215
215
|
* minted here from the changes this node SEES, so a change the bus dropped leaves a hole the
|
|
216
|
-
* ring cannot detect and would replay as complete history.
|
|
216
|
+
* ring cannot detect and would replay as complete history. Given an `entity`, only the topics
|
|
217
|
+
* carrying it: what a bulk write the in-process replicator could not shape as rows stales.
|
|
217
218
|
*/
|
|
218
|
-
invalidate(): number {
|
|
219
|
-
return this.#logs.invalidate();
|
|
219
|
+
invalidate(entity?: string): number {
|
|
220
|
+
return this.#logs.invalidate(entity);
|
|
220
221
|
}
|
|
221
222
|
|
|
222
223
|
/**
|
|
@@ -234,7 +235,7 @@ export class ChannelHub {
|
|
|
234
235
|
params: Readonly<Record<K, string>>,
|
|
235
236
|
event: JsonObject,
|
|
236
237
|
): Promise<void> {
|
|
237
|
-
|
|
238
|
+
assertCoded(
|
|
238
239
|
declared.events,
|
|
239
240
|
'X_CHANNEL_DECLARATION_INVALID',
|
|
240
241
|
`channel("${declared.name}") declares no events, so nothing may publish one on it`,
|
package/src/client-channels.ts
CHANGED
|
@@ -292,6 +292,8 @@ export class ChannelBook {
|
|
|
292
292
|
entry.disarm?.();
|
|
293
293
|
entry.disarm = null;
|
|
294
294
|
this.#set(entry, 'catching-up');
|
|
295
|
+
// Names no write (#507 pt 2: owner O-507, default none) — a pending write this read already
|
|
296
|
+
// reflects settles on its own HTTP answer. Where a catch-up names its writes, it goes here.
|
|
295
297
|
this.#deps.catchUp(entry.catchUp, entry.params).then(
|
|
296
298
|
() => this.#drain(entry),
|
|
297
299
|
(error: unknown) => {
|
package/src/client-frames.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface ClientFrameTarget {
|
|
|
38
38
|
setUpdate(buildId: string | null): void;
|
|
39
39
|
/** The node named the beat this socket must keep (`hello.heartbeatMs`). */
|
|
40
40
|
followHeartbeat(intervalMs: number): void;
|
|
41
|
+
/** A refused registration the node said may succeed later: subscribe it again after `afterMs`. */
|
|
42
|
+
retryLater(registration: Registration, afterMs: number): void;
|
|
41
43
|
/** The node assigned this socket its own delay before closing it. */
|
|
42
44
|
scheduleReconnect(afterMs: number | null): void;
|
|
43
45
|
closeSocket(code: number, reason: string): void;
|
|
@@ -77,7 +79,8 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
|
|
|
77
79
|
registration.cursor = frame.cursor;
|
|
78
80
|
registration.state = 'live';
|
|
79
81
|
registration.error = undefined;
|
|
80
|
-
|
|
82
|
+
const { entity, rows, keys, writes } = frame;
|
|
83
|
+
target.windows.snapshot(registration, entity ?? null, rows, keys, writes);
|
|
81
84
|
return;
|
|
82
85
|
}
|
|
83
86
|
case 'patch': {
|
|
@@ -93,7 +96,8 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
|
|
|
93
96
|
registration.cursor = next;
|
|
94
97
|
}
|
|
95
98
|
registration.state = 'live';
|
|
96
|
-
|
|
99
|
+
// The writes the frame names settle in the window's own batch, as a `records` frame's do.
|
|
100
|
+
target.windows.patch(registration, frame.patches, frame.writes);
|
|
97
101
|
return;
|
|
98
102
|
}
|
|
99
103
|
// A patch names a live registration or nothing: a channel's rows ride `records` frames.
|
|
@@ -112,6 +116,9 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
|
|
|
112
116
|
registration.state = 'failed';
|
|
113
117
|
registration.error = refusal;
|
|
114
118
|
registration.notify();
|
|
119
|
+
// A refusal that states when it may succeed (a rate limit's refill) is not final.
|
|
120
|
+
const after = frame.error.retryAfterSeconds;
|
|
121
|
+
if (after !== undefined) target.retryLater(registration, after * 1000);
|
|
115
122
|
return;
|
|
116
123
|
}
|
|
117
124
|
case 'reconnect': {
|
package/src/client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// signal belongs to one bundle's solid-js, so everything here is a plain read plus a listener.
|
|
5
5
|
// Read-only: the socket carries no writes (`useMutation` is HTTP).
|
|
6
6
|
|
|
7
|
-
import { type Clock, finiteOption, systemClock,
|
|
7
|
+
import { type Clock, finiteOption, systemClock, uuidV7 } from '@ultimat3/core/page';
|
|
8
8
|
import {
|
|
9
9
|
ChannelBook,
|
|
10
10
|
type ChannelHandlers,
|
|
@@ -49,6 +49,8 @@ export class LiveClient {
|
|
|
49
49
|
readonly #clock: Clock;
|
|
50
50
|
readonly #onError: (error: unknown) => void;
|
|
51
51
|
readonly #registrations = new Map<string, Registration>();
|
|
52
|
+
/** A refused registration's armed re-subscribe, by sid — the node said when it may succeed. */
|
|
53
|
+
readonly #retries = new Map<string, () => void>();
|
|
52
54
|
readonly #windows: RowWindows;
|
|
53
55
|
readonly #channels: ChannelBook;
|
|
54
56
|
readonly #heartbeat: Heartbeat;
|
|
@@ -194,6 +196,9 @@ export class LiveClient {
|
|
|
194
196
|
*/
|
|
195
197
|
#offline(): void {
|
|
196
198
|
this.#heartbeat.stop();
|
|
199
|
+
// The reopen replays every registration, so a timer left armed would send one twice.
|
|
200
|
+
for (const cancel of this.#retries.values()) cancel();
|
|
201
|
+
this.#retries.clear();
|
|
197
202
|
this.#setStatus({ connected: false });
|
|
198
203
|
// Told once, not two ways: a `useConnection().offline` that flips while a live window still
|
|
199
204
|
// reads 'live' is one dead socket rendered as two states. A refused one stays refused.
|
|
@@ -230,7 +235,7 @@ export class LiveClient {
|
|
|
230
235
|
query: LiveQueryRef,
|
|
231
236
|
input: JsonValue,
|
|
232
237
|
): LiveHandle<R> {
|
|
233
|
-
const sid =
|
|
238
|
+
const sid = uuidV7();
|
|
234
239
|
const listeners = new Set<() => void>();
|
|
235
240
|
const registration: Registration = {
|
|
236
241
|
sid,
|
|
@@ -254,6 +259,8 @@ export class LiveClient {
|
|
|
254
259
|
if (!open) return;
|
|
255
260
|
open = false;
|
|
256
261
|
this.#registrations.delete(sid);
|
|
262
|
+
this.#retries.get(sid)?.();
|
|
263
|
+
this.#retries.delete(sid);
|
|
257
264
|
close();
|
|
258
265
|
listeners.clear();
|
|
259
266
|
this.#send({
|
|
@@ -323,11 +330,31 @@ export class LiveClient {
|
|
|
323
330
|
setUpdate: (buildId) => this.#setStatus({ update: buildId }),
|
|
324
331
|
followHeartbeat: (intervalMs) => this.#heartbeat.follow(intervalMs),
|
|
325
332
|
scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
|
|
333
|
+
retryLater: (registration, afterMs) => this.#retryLater(registration, afterMs),
|
|
326
334
|
closeSocket: (code, reason) => this.#socket?.close(code, reason),
|
|
327
335
|
report: (error) => this.#onError(error),
|
|
328
336
|
};
|
|
329
337
|
}
|
|
330
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Ask again once the node's stated delay has passed — a rate-limited subscribe, whose bucket
|
|
341
|
+
* refills. Only while the registration is still held, still refused and the socket still up:
|
|
342
|
+
* anything else and the reopen's replay, or the unsubscribe, already decided its fate.
|
|
343
|
+
*/
|
|
344
|
+
#retryLater(registration: Registration, afterMs: number): void {
|
|
345
|
+
this.#retries.get(registration.sid)?.();
|
|
346
|
+
const cancel = (this.#options.scheduler ?? timeoutScheduler)(() => {
|
|
347
|
+
this.#retries.delete(registration.sid);
|
|
348
|
+
if (this.#registrations.get(registration.sid) !== registration) return;
|
|
349
|
+
if (!this.#connected || registration.state !== 'failed') return;
|
|
350
|
+
registration.state = 'loading';
|
|
351
|
+
registration.error = undefined;
|
|
352
|
+
registration.notify();
|
|
353
|
+
this.#sendSubscribe(registration);
|
|
354
|
+
}, afterMs);
|
|
355
|
+
this.#retries.set(registration.sid, cancel);
|
|
356
|
+
}
|
|
357
|
+
|
|
331
358
|
/** The opening frame, and the heartbeat's. One shape, because it makes one claim: I am here. */
|
|
332
359
|
#hello(): Frame {
|
|
333
360
|
return {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @ultimat3/realtime's owned codes and their TITLES, registered at import — the one module
|
|
2
|
+
// `sideEffects` names, so `errors.ts` (the sync node's refusal classes) is unlisted and no hook's
|
|
3
|
+
// chunk carries it (`page-errors-bundle.test.ts`): measured on Bun 1.4.2, −1,081 B on `feed`, −1,051 B on `likes-badge`, −1,047 B on
|
|
4
|
+
// `run-console`, every title still registered. Imported bare by `index.ts` (a page
|
|
5
|
+
// rebuilds a node's refusal by code in `refusalError`, and `page-errors.ts` constructs codes from
|
|
6
|
+
// here without importing it) and by `errors.ts`, so constructing a server refusal registers too.
|
|
7
|
+
|
|
8
|
+
import { registerErrorCodes } from '@ultimat3/core';
|
|
9
|
+
|
|
10
|
+
/** Codes this package declares and owns. */
|
|
11
|
+
export const REALTIME_OWNED_ERROR_CODES = [
|
|
12
|
+
'X_TOPIC_FORBIDDEN',
|
|
13
|
+
'X_SUBSCRIPTION_LIMIT',
|
|
14
|
+
'X_SUBSCRIPTION_ID_TAKEN',
|
|
15
|
+
'X_FRAME_RATE_LIMIT',
|
|
16
|
+
'X_PROTOCOL_VERSION',
|
|
17
|
+
'X_CURSOR_STALE',
|
|
18
|
+
'X_REBASE_CONFLICT',
|
|
19
|
+
'X_TRANSPORT_UNAVAILABLE',
|
|
20
|
+
'X_TRANSPORT_PROTOCOL',
|
|
21
|
+
'X_REPLICATION_PROTOCOL',
|
|
22
|
+
'X_REPLICATION_FAILED',
|
|
23
|
+
'X_REPLICATOR_SLOT_HELD',
|
|
24
|
+
// Retired in 21.0.0 and never thrown since — kept registered, because a shipped code is
|
|
25
|
+
// forever: a log line from an older build still resolves through `x errors explain`.
|
|
26
|
+
'X_LIVE_CLIENT_MISSING',
|
|
27
|
+
'X_QUERY_NOT_SUBSCRIBABLE',
|
|
28
|
+
'X_REALTIME_UNINSTALLED',
|
|
29
|
+
'X_SYNC_UNCONFIGURED',
|
|
30
|
+
'X_RECORD_REJECTED',
|
|
31
|
+
'X_CHANNEL_DECLARATION_INVALID',
|
|
32
|
+
'X_LOCAL_STORE_UNAVAILABLE',
|
|
33
|
+
'X_LIVE_SERVER_RENDER',
|
|
34
|
+
'X_LIVE_ROW_UNIDENTIFIED',
|
|
35
|
+
'X_LIVE_QUERY_UNKNOWN',
|
|
36
|
+
'X_LIVE_REPLICA_IDENTITY',
|
|
37
|
+
'X_SOCKET_UNAUTHENTICATED',
|
|
38
|
+
'X_SOCKET_AUTH_UNAVAILABLE',
|
|
39
|
+
'X_REALTIME_TOPOLOGY',
|
|
40
|
+
'X_REPLICATION_TLS',
|
|
41
|
+
'X_SOCKET_ORIGIN_REFUSED',
|
|
42
|
+
'X_OFFLINE_QUEUE_ABANDONED',
|
|
43
|
+
'X_SOCKET_LIMIT',
|
|
44
|
+
'X_REALTIME_PRODUCER_CONFLICT',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type RealtimeOwnedErrorCode = (typeof REALTIME_OWNED_ERROR_CODES)[number];
|
|
48
|
+
|
|
49
|
+
export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, string>> = {
|
|
50
|
+
X_TOPIC_FORBIDDEN: 'the actor may not subscribe to this topic',
|
|
51
|
+
X_SUBSCRIPTION_LIMIT: 'socket, actor, tenant or node hit its subscription cap',
|
|
52
|
+
X_SUBSCRIPTION_ID_TAKEN: 'a subscribe frame reused a sid this socket already holds',
|
|
53
|
+
X_FRAME_RATE_LIMIT: 'one socket sent frames faster than this node will route them',
|
|
54
|
+
X_PROTOCOL_VERSION: 'client and sync node disagree on the wire protocol',
|
|
55
|
+
X_CURSOR_STALE: 'the resume LSN is outside the change buffer',
|
|
56
|
+
X_REBASE_CONFLICT: 'a local mutation could not be rebased',
|
|
57
|
+
X_TRANSPORT_UNAVAILABLE: 'the fanout bus is unreachable',
|
|
58
|
+
X_TRANSPORT_PROTOCOL: 'the bus does not speak the protocol this build speaks',
|
|
59
|
+
X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
|
|
60
|
+
X_REPLICATION_FAILED: 'the replication connection was refused',
|
|
61
|
+
X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
|
|
62
|
+
X_LIVE_CLIENT_MISSING:
|
|
63
|
+
'retired in 21.0.0 (no LiveClient to register; see X_REALTIME_UNINSTALLED): a realtime hook ran in a browser with no LiveClient registered',
|
|
64
|
+
X_QUERY_NOT_SUBSCRIBABLE:
|
|
65
|
+
'retired in 21.0.0 (liveHookFor was deleted; useQuery reads any query): a hook was bound to a query that is not declared live',
|
|
66
|
+
X_REALTIME_UNINSTALLED: 'a realtime hook ran in an island bundle that never installed realtime',
|
|
67
|
+
X_SYNC_UNCONFIGURED: 'a live hook needed the page socket and no sync target was configured',
|
|
68
|
+
X_RECORD_REJECTED: 'a row reached the record store in a shape it cannot hold',
|
|
69
|
+
X_CHANNEL_DECLARATION_INVALID: 'a channel() declaration cannot route its rows',
|
|
70
|
+
X_LOCAL_STORE_UNAVAILABLE: "the page's durable store could not open",
|
|
71
|
+
X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
|
|
72
|
+
X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
|
|
73
|
+
X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
|
|
74
|
+
X_LIVE_REPLICA_IDENTITY: 'a replicated table has no replica identity',
|
|
75
|
+
X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
|
|
76
|
+
X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
|
|
77
|
+
X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
|
|
78
|
+
X_REPLICATION_TLS: 'the replication connection failed TLS',
|
|
79
|
+
X_SOCKET_ORIGIN_REFUSED: 'the websocket upgrade came from another origin',
|
|
80
|
+
X_OFFLINE_QUEUE_ABANDONED: 'a write was queued after its page changed principal',
|
|
81
|
+
X_SOCKET_LIMIT: 'one principal holds too many sockets on this node',
|
|
82
|
+
X_REALTIME_PRODUCER_CONFLICT: 'two kinds of producer carry one table on the change bus',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// One unconditional call, so a second package claiming one of realtime's codes throws
|
|
86
|
+
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
|
|
87
|
+
registerErrorCodes(
|
|
88
|
+
Object.fromEntries(
|
|
89
|
+
Object.entries(REALTIME_ERROR_TITLES).map(([code, title]) => [code, { title }]),
|
|
90
|
+
),
|
|
91
|
+
);
|