@ultimat3/realtime 30.9.0 → 31.1.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 +72 -82
- package/README.md +36 -40
- package/package.json +8 -6
- package/src/adapter.ts +14 -0
- package/src/apply-patches.ts +8 -5
- package/src/boot.ts +15 -2
- package/src/changefeed.ts +11 -4
- package/src/channel-decl.ts +29 -1
- package/src/channel-publish.ts +17 -19
- package/src/channel-ref.ts +22 -2
- package/src/client-channels.ts +11 -5
- package/src/client-contract.ts +2 -2
- package/src/cursor.ts +5 -5
- package/src/device-agent.ts +2 -2
- package/src/device-errors.ts +1 -1
- package/src/device-socket.ts +1 -1
- package/src/error-titles-server.ts +40 -0
- package/src/error-titles.ts +12 -21
- package/src/errors.ts +43 -10
- package/src/idb-types.ts +6 -0
- package/src/index.ts +5 -27
- package/src/json.ts +7 -3
- package/src/live-contract.ts +4 -4
- package/src/live-definition.ts +9 -6
- package/src/live-deliver.ts +40 -2
- package/src/live-entries.ts +73 -0
- package/src/live-fanout.ts +25 -9
- package/src/live-patch-encode.ts +30 -0
- package/src/live-query-options.ts +11 -0
- package/src/live-query.ts +26 -11
- package/src/live-replicator.ts +24 -21
- package/src/local-store-idb.ts +66 -6
- package/src/matcher-bridge.ts +15 -15
- package/src/nats-transport.ts +5 -5
- package/src/offline-queue-store.ts +69 -0
- package/src/offline-queue.ts +91 -91
- package/src/page-errors.ts +15 -0
- package/src/page-outbox.ts +18 -3
- package/src/pg-connection.ts +9 -1
- package/src/pg-preflight.ts +21 -5
- package/src/pg-replication-dial.ts +95 -0
- package/src/pg-replication-liveness.ts +84 -0
- package/src/pg-replication-position.ts +42 -0
- package/src/pg-replication-row.ts +6 -6
- package/src/pg-replication-stats.ts +28 -0
- package/src/pg-replication.ts +106 -125
- package/src/pg-slot-release.ts +40 -0
- package/src/policy-gate.ts +2 -2
- package/src/query-window.ts +4 -4
- package/src/record-publisher.ts +2 -2
- package/src/record-store.ts +7 -5
- package/src/replication-errors.ts +40 -2
- package/src/replication-resync.ts +38 -0
- package/src/replicator-deadline.ts +33 -0
- package/src/replicator-reconnect.ts +85 -0
- package/src/replicator-resync.ts +30 -0
- package/src/replicator-row.ts +5 -5
- package/src/replicator-subject.ts +28 -0
- package/src/replicator.ts +79 -69
- package/src/server.ts +8 -6
- package/src/socket-engine.ts +2 -2
- package/src/subscriber-gate.ts +14 -10
- package/src/sync-bus-handlers.ts +20 -4
- package/src/sync-protocol.ts +3 -3
- package/src/thundering-herd.ts +17 -24
- package/src/transport-env.ts +20 -48
- package/src/window-lock.ts +15 -3
package/src/changefeed.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { Clock } from '@ultimat3/core';
|
|
6
6
|
import { finiteOption } from '@ultimat3/core';
|
|
7
7
|
import { ReplicationFailedError } from './errors';
|
|
8
|
-
import type {
|
|
8
|
+
import type { LiveRow } from './json';
|
|
9
9
|
import { PgReplicationStream, type ReplicationStreamStats } from './pg-replication';
|
|
10
10
|
import type { PgTarget } from './pg-socket';
|
|
11
11
|
import type { PgStream } from './pg-wire';
|
|
@@ -19,7 +19,7 @@ import type { Rng } from './thundering-herd';
|
|
|
19
19
|
*/
|
|
20
20
|
export type ChangeOp = 'insert' | 'update' | 'delete' | 'truncate';
|
|
21
21
|
|
|
22
|
-
export interface ChangeEvent<R extends
|
|
22
|
+
export interface ChangeEvent<R extends LiveRow = LiveRow> {
|
|
23
23
|
/**
|
|
24
24
|
* The RELATION (table) name, on every producer: the WAL decoder reads it off the Relation
|
|
25
25
|
* message, the in-process bridge maps the row observer's entity name onto it, and a
|
|
@@ -66,6 +66,12 @@ export interface ChangeFeedStartOptions {
|
|
|
66
66
|
* was only recorded was a death nobody saw.
|
|
67
67
|
*/
|
|
68
68
|
readonly onEnd?: (reason: string) => void;
|
|
69
|
+
/**
|
|
70
|
+
* The stream is no longer continuous with `from`: changes were committed that it will never
|
|
71
|
+
* deliver (a replication slot recreated under a resume position). Every reader must re-read
|
|
72
|
+
* now. Called at most once per `start()`, before its first change.
|
|
73
|
+
*/
|
|
74
|
+
readonly onResync?: (reason: string) => void;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
export interface ChangeFeed {
|
|
@@ -152,8 +158,8 @@ export class MemoryChangeFeed implements ChangeFeed {
|
|
|
152
158
|
table: string,
|
|
153
159
|
op: ChangeOp,
|
|
154
160
|
rows: {
|
|
155
|
-
before?:
|
|
156
|
-
after?:
|
|
161
|
+
before?: LiveRow | null;
|
|
162
|
+
after?: LiveRow | null;
|
|
157
163
|
orgId?: string | null;
|
|
158
164
|
at?: number;
|
|
159
165
|
write?: string | null;
|
|
@@ -252,6 +258,7 @@ export class PostgresChangeFeed implements ChangeFeed {
|
|
|
252
258
|
from: options.from,
|
|
253
259
|
onChange: options.onChange,
|
|
254
260
|
onEnd: options.onEnd,
|
|
261
|
+
onResync: options.onResync,
|
|
255
262
|
});
|
|
256
263
|
}
|
|
257
264
|
|
package/src/channel-decl.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// same declaration for a ref an island already holds (`channel-ref.ts`): the browser chunk then
|
|
4
4
|
// carries the name and params, never `@ultimat3/entity` (plan 101, slice 09).
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { assertCoded, type Ctx, type Row } from '@ultimat3/core';
|
|
7
7
|
import {
|
|
8
8
|
type ProjectedEntity,
|
|
9
9
|
type RecordProjection,
|
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
} from '@ultimat3/entity/record';
|
|
12
12
|
import type { QueryPolicy } from '@ultimat3/query';
|
|
13
13
|
import {
|
|
14
|
+
type CatchUpRead,
|
|
14
15
|
type ChannelHandle,
|
|
15
16
|
type ChannelParams,
|
|
16
17
|
type ChannelRefInit,
|
|
18
|
+
catchUpReadOf,
|
|
17
19
|
channelRef,
|
|
18
20
|
refuseChannel,
|
|
19
21
|
SEGMENT,
|
|
@@ -39,6 +41,31 @@ function projectionFor(name: string, entity: ChannelEntity): RecordProjection {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A catch-up read is served on its own route (`GET /_x/query/<name>`) with the channel params as
|
|
46
|
+
* input, so a read deciding with a WIDER policy hands any caller what the channel refuses — the
|
|
47
|
+
* demo's inbox did (plan 101 R9). Identity, not equivalence: two rules cannot be compared, one
|
|
48
|
+
* object can. A name-only read (a browser ref) carries no policy to hold.
|
|
49
|
+
*/
|
|
50
|
+
function assertCatchUpPolicy(
|
|
51
|
+
name: string,
|
|
52
|
+
read: CatchUpRead | undefined,
|
|
53
|
+
policy: QueryPolicy,
|
|
54
|
+
): void {
|
|
55
|
+
if (read === undefined || !('policy' in read)) return;
|
|
56
|
+
if (read.policy === policy) return;
|
|
57
|
+
const label = (value: unknown): string =>
|
|
58
|
+
typeof value === 'object' && value !== null && typeof Reflect.get(value, 'label') === 'string'
|
|
59
|
+
? String(Reflect.get(value, 'label'))
|
|
60
|
+
: 'an unlabelled policy';
|
|
61
|
+
assertCoded(
|
|
62
|
+
false,
|
|
63
|
+
'X_CHANNEL_CATCHUP_POLICY_MISMATCH',
|
|
64
|
+
`channel("${name}"): its catch-up read "${read.name || '(named at boot)'}" decides with ${label(read.policy)}, the channel with ${label(policy)} — the read is served with the channel params as input, so it would answer what the subscribe refuses`,
|
|
65
|
+
`set the catch-up query's policy: to the same object channel("${name}", { policy }) passes — export the rule from the feature's policy.ts and import it in both files`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
/** What only the server half declares: the rows it carries and who may join. */
|
|
43
70
|
export interface ChannelServerInit {
|
|
44
71
|
/**
|
|
@@ -114,6 +141,7 @@ export function channel<K extends string>(
|
|
|
114
141
|
"add policy: can('<resource>:read') to the channel() call, or policy: allow('public') for a channel anyone may join",
|
|
115
142
|
);
|
|
116
143
|
}
|
|
144
|
+
assertCatchUpPolicy(name, catchUpReadOf(ref), policy);
|
|
117
145
|
const records = (init.records ?? []).map((entity) => {
|
|
118
146
|
const projection = projectionFor(name, entity);
|
|
119
147
|
for (const param of ref.params) {
|
package/src/channel-publish.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// The frame and its subject are spelled here once; the hub's own `emit` publishes through them.
|
|
5
5
|
|
|
6
6
|
import { assertCoded } from '@ultimat3/core';
|
|
7
|
+
import { adapterSlot } from '@ultimat3/core/adapter';
|
|
7
8
|
import type { Channel, Topic } from './channel-decl';
|
|
8
9
|
import type { ChannelEventsFrame } from './channel-wire';
|
|
9
10
|
import { InProcessTransport, type Transport } from './fanout';
|
|
@@ -26,28 +27,25 @@ export async function sendChannelEvent(
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
* The
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* The buses installed, newest last, over the heap bus a process boots with — nobody subscribes to
|
|
31
|
+
* that one, so a unit test or a script that booted nothing publishes to nobody instead of throwing
|
|
32
|
+
* in the middle of the write it follows. Core's one frame stack: a release puts back what was there
|
|
33
|
+
* before it, and one transport installed twice is two frames.
|
|
32
34
|
*/
|
|
33
|
-
|
|
35
|
+
const booted: Transport = new InProcessTransport();
|
|
36
|
+
const installed = adapterSlot<Transport>(() => booted);
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
|
-
* Called by the boot (`startServices`) with the transport `
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
39
|
+
* Called by the boot (`startServices`) with the process's transport — `apps/<app>/runtime.ts`'s
|
|
40
|
+
* `transport`, else the one `selectTransport` chose — in EVERY role: the same object the `sync`
|
|
41
|
+
* role's hub subscribes on, so under `x dev` a publish reaches this process's own sockets and under
|
|
42
|
+
* NATS every node's. Answers the release, which takes THIS install off the stack and so restores
|
|
43
|
+
* whatever was current before it (the boot's bus under a test's override); idempotent, and a
|
|
44
|
+
* release out of order removes only its own entry. Public on `@ultimat3/realtime/adapter` only: an
|
|
45
|
+
* app swaps the bus in `runtime.ts`, never by calling this.
|
|
40
46
|
*/
|
|
41
|
-
export function
|
|
42
|
-
|
|
43
|
-
return () => {
|
|
44
|
-
if (ambient === transport) resetChannelTransport();
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Test seam: back to a fresh heap bus, the state a process boots in. */
|
|
49
|
-
export function resetChannelTransport(): void {
|
|
50
|
-
ambient = new InProcessTransport();
|
|
47
|
+
export function installChannelTransport(transport: Transport): () => void {
|
|
48
|
+
return installed.install(transport);
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
/**
|
|
@@ -71,5 +69,5 @@ export async function publishChannelEvent<K extends string>(
|
|
|
71
69
|
`channel("${declared.name}") declares no events, so nothing may publish one on it`,
|
|
72
70
|
`declare it with events: true: channel('${declared.name}', { …, events: true })`,
|
|
73
71
|
);
|
|
74
|
-
await sendChannelEvent(
|
|
72
|
+
await sendChannelEvent(installed.current(), declared.topic(params), event);
|
|
75
73
|
}
|
package/src/channel-ref.ts
CHANGED
|
@@ -35,7 +35,17 @@ export interface ChannelRefInit<K extends string> {
|
|
|
35
35
|
* The read a client re-runs on `replay-gap` or a new epoch — a query, by name. Read on every
|
|
36
36
|
* access, never at declaration: `registerQueries()` stamps a declared query's name at boot.
|
|
37
37
|
*/
|
|
38
|
-
readonly catchUp:
|
|
38
|
+
readonly catchUp: CatchUpRead;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The read a client re-runs, as the declaration passed it: a declared query carries its `policy`,
|
|
43
|
+
* which `channel()` holds to the channel's own (`X_CHANNEL_CATCHUP_POLICY_MISMATCH`); a browser
|
|
44
|
+
* ref passes the name alone, and the server half cannot second-guess what it was not handed.
|
|
45
|
+
*/
|
|
46
|
+
export interface CatchUpRead {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly policy?: unknown;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
/** What a browser subscribes by — `useChannel(ref, params)`. */
|
|
@@ -48,6 +58,14 @@ export interface ChannelHandle<K extends string = string> {
|
|
|
48
58
|
topic(params: ChannelParams<K>): Topic;
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
/** Each ref's catch-up read as declared — held off the frozen ref so the ref's shape stays its own. */
|
|
62
|
+
const READS = new WeakMap<ChannelHandle, CatchUpRead>();
|
|
63
|
+
|
|
64
|
+
/** The catch-up read a ref was built with; `undefined` for a handle `channelRef` did not build. */
|
|
65
|
+
export function catchUpReadOf(ref: ChannelHandle): CatchUpRead | undefined {
|
|
66
|
+
return READS.get(ref);
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
export function refuseChannel(name: string, detail: string, fix: string): never {
|
|
52
70
|
assertCoded(false, 'X_CHANNEL_DECLARATION_INVALID', `channel("${name}"): ${detail}`, fix);
|
|
53
71
|
}
|
|
@@ -71,7 +89,7 @@ export function channelRef<const K extends string>(
|
|
|
71
89
|
// inherited member would spell a topic nobody declared. Absent is `''`, which `topic()` refuses.
|
|
72
90
|
const topicOf = (params: ChannelParams<K>): Topic =>
|
|
73
91
|
topic(name, ...init.params.map((param) => (Object.hasOwn(params, param) ? params[param] : '')));
|
|
74
|
-
|
|
92
|
+
const ref: ChannelHandle<K> = Object.freeze({
|
|
75
93
|
kind: 'channel-ref' as const,
|
|
76
94
|
name,
|
|
77
95
|
params: init.params,
|
|
@@ -80,4 +98,6 @@ export function channelRef<const K extends string>(
|
|
|
80
98
|
},
|
|
81
99
|
topic: topicOf,
|
|
82
100
|
});
|
|
101
|
+
READS.set(ref, init.catchUp);
|
|
102
|
+
return ref;
|
|
83
103
|
}
|
package/src/client-channels.ts
CHANGED
|
@@ -67,7 +67,12 @@ interface Entry {
|
|
|
67
67
|
readonly name: string;
|
|
68
68
|
readonly catchUp: string;
|
|
69
69
|
readonly params: Readonly<Record<string, string>>;
|
|
70
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Keyed by a token minted per `hold`, never by the handlers object: two holders passing ONE
|
|
72
|
+
* object (a module-level const) are two holds, and keyed by it the first release dropped the
|
|
73
|
+
* topic under the second.
|
|
74
|
+
*/
|
|
75
|
+
readonly holders: Map<object, ChannelHandlers>;
|
|
71
76
|
readonly listeners: Set<() => void>;
|
|
72
77
|
state: ChannelState;
|
|
73
78
|
error: unknown;
|
|
@@ -124,7 +129,7 @@ export class ChannelBook {
|
|
|
124
129
|
name: ref.name,
|
|
125
130
|
catchUp: ref.catchUp,
|
|
126
131
|
params,
|
|
127
|
-
holders: new
|
|
132
|
+
holders: new Map(),
|
|
128
133
|
listeners: new Set(),
|
|
129
134
|
state: this.#deps.connected() ? 'joining' : 'offline',
|
|
130
135
|
error: undefined,
|
|
@@ -142,12 +147,13 @@ export class ChannelBook {
|
|
|
142
147
|
if (this.#deps.connected()) this.#deps.send(this.#frame(entry, 'add', true));
|
|
143
148
|
}
|
|
144
149
|
const held = entry;
|
|
145
|
-
|
|
150
|
+
const token = {};
|
|
151
|
+
held.holders.set(token, handlers);
|
|
146
152
|
let open = true;
|
|
147
153
|
const release = (): void => {
|
|
148
154
|
if (!open) return;
|
|
149
155
|
open = false;
|
|
150
|
-
held.holders.delete(
|
|
156
|
+
held.holders.delete(token);
|
|
151
157
|
if (held.holders.size > 0) return;
|
|
152
158
|
this.#byTopic.delete(topic);
|
|
153
159
|
held.disarm?.();
|
|
@@ -247,7 +253,7 @@ export class ChannelBook {
|
|
|
247
253
|
// (no rows, so no `records` and no `replay-gap`) the roster is the one answer a subscribe
|
|
248
254
|
// gets. Only from `joining`: a read in flight, a refusal and a dead socket are not undone.
|
|
249
255
|
if (entry?.state === 'joining') this.#set(entry, 'live');
|
|
250
|
-
for (const holder of entry?.holders ?? []) {
|
|
256
|
+
for (const holder of entry?.holders.values() ?? []) {
|
|
251
257
|
if (presence !== null) holder.onPresence?.(presence);
|
|
252
258
|
else holder.onEvent?.(frame.event);
|
|
253
259
|
}
|
package/src/client-contract.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { CatchUpRecords } from './channel-catch-up';
|
|
|
7
7
|
import type { LiveCursor } from './cursor';
|
|
8
8
|
import type { LiveState } from './live-rows';
|
|
9
9
|
import type { RecordStore } from './record-store';
|
|
10
|
-
import type {
|
|
10
|
+
import type { ReconnectBackoff, Rng, Scheduler } from './thundering-herd';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* A reactive primitive: `createSignal` narrowed to two functions. Installed PER ISLAND BUNDLE
|
|
@@ -65,7 +65,7 @@ export interface LiveClientOptions {
|
|
|
65
65
|
/** The page's record store every window renders out of. A client builds its own when absent. */
|
|
66
66
|
readonly store?: RecordStore;
|
|
67
67
|
/** Default `browserBackoff`: capped at `BROWSER_RECONNECT_MAX_MS`, never the server's 30s. */
|
|
68
|
-
readonly backoff?:
|
|
68
|
+
readonly backoff?: ReconnectBackoff;
|
|
69
69
|
readonly rng?: Rng;
|
|
70
70
|
readonly clock?: Clock;
|
|
71
71
|
/** How a pending reconnect is armed. Defaults to `setTimeout`; tests fire theirs by hand. */
|
package/src/cursor.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// accidental one. See README "Reconnect is the hard part".
|
|
5
5
|
|
|
6
6
|
import { type Clock, systemClock } from '@ultimat3/core/page';
|
|
7
|
-
import type {
|
|
7
|
+
import type { LiveRow, RowPatch } from './json';
|
|
8
8
|
import { CursorStaleError } from './page-errors';
|
|
9
9
|
|
|
10
10
|
/** Ids are bounded so a cursor stays small enough to ship on every `subscribe` frame. */
|
|
@@ -89,11 +89,11 @@ export interface ResumeSource {
|
|
|
89
89
|
): void;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export type ResumeResult<R extends
|
|
92
|
+
export type ResumeResult<R extends LiveRow = LiveRow> =
|
|
93
93
|
| { readonly kind: 'delta'; readonly patches: readonly RowPatch[]; readonly cursor: LiveCursor }
|
|
94
94
|
| { readonly kind: 'snapshot'; readonly rows: readonly R[]; readonly cursor: LiveCursor };
|
|
95
95
|
|
|
96
|
-
export interface ResumeDeps<R extends
|
|
96
|
+
export interface ResumeDeps<R extends LiveRow = LiveRow> {
|
|
97
97
|
readonly source: ResumeSource;
|
|
98
98
|
/** Bounded re-read of the query at a current lsn. Omit only if a stale cursor should throw. */
|
|
99
99
|
readonly snapshot?: (qid: string) => Promise<{ rows: readonly R[]; lsn: string }>;
|
|
@@ -104,7 +104,7 @@ export interface ResumeDeps<R extends Row = Row> {
|
|
|
104
104
|
export function makeCursor(
|
|
105
105
|
qid: string,
|
|
106
106
|
lsn: string,
|
|
107
|
-
rows: readonly
|
|
107
|
+
rows: readonly LiveRow[],
|
|
108
108
|
now: number,
|
|
109
109
|
): LiveCursor {
|
|
110
110
|
return {
|
|
@@ -139,7 +139,7 @@ export function shouldResnapshot(
|
|
|
139
139
|
* snapshot. There is deliberately no third path: WAL history traversal is what turns a rolling
|
|
140
140
|
* restart into a self-inflicted outage.
|
|
141
141
|
*/
|
|
142
|
-
export async function resumeFrom<R extends
|
|
142
|
+
export async function resumeFrom<R extends LiveRow = LiveRow>(
|
|
143
143
|
cursor: LiveCursor,
|
|
144
144
|
deps: ResumeDeps<R>,
|
|
145
145
|
): Promise<ResumeResult<R>> {
|
package/src/device-agent.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { reportError } from '@ultimat3/core';
|
|
|
6
6
|
import { bytesOverLimit, type DeviceInbound, decodeNodeFrame } from './device-wire';
|
|
7
7
|
import type { JsonValue } from './json';
|
|
8
8
|
import {
|
|
9
|
-
type BackoffPolicy,
|
|
10
9
|
defaultBackoff,
|
|
11
10
|
policyDelay,
|
|
11
|
+
type ReconnectBackoff,
|
|
12
12
|
type Rng,
|
|
13
13
|
type Scheduler,
|
|
14
14
|
timeoutScheduler,
|
|
@@ -48,7 +48,7 @@ export interface DeviceAgentOptions {
|
|
|
48
48
|
readonly onRequest: (frame: JsonValue) => Promise<JsonValue> | JsonValue;
|
|
49
49
|
/** What `onFrame` on the node answered, and anything else it pushes. */
|
|
50
50
|
readonly onFrame?: ((frame: JsonValue) => void) | undefined;
|
|
51
|
-
readonly backoff?:
|
|
51
|
+
readonly backoff?: ReconnectBackoff | undefined;
|
|
52
52
|
readonly rng?: Rng | undefined;
|
|
53
53
|
readonly schedule?: Scheduler | undefined;
|
|
54
54
|
readonly dial?: AgentDial | undefined;
|
package/src/device-errors.ts
CHANGED
|
@@ -21,7 +21,7 @@ export class DeviceOfflineError extends RealtimeError {
|
|
|
21
21
|
super({
|
|
22
22
|
code: 'X_DEVICE_OFFLINE',
|
|
23
23
|
cause: `deviceSocket("${args.path}") holds no open socket for device ${JSON.stringify(args.deviceId)} under the caller's tenant: ${args.reason}`,
|
|
24
|
-
fix: `x doctor --json # then retry deviceLink.request(ctx, '${idArg(args.deviceId)}', frame) once the device redials; across replicas
|
|
24
|
+
fix: `x doctor --json # then retry deviceLink.request(ctx, '${idArg(args.deviceId)}', frame) once the device redials; across replicas every role needs NATS_URL set (or one transport in apps/<app>/runtime.ts)`,
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
}
|
package/src/device-socket.ts
CHANGED
|
@@ -123,7 +123,7 @@ export function beginDeviceSocketGeneration(): void {
|
|
|
123
123
|
generation += 1;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/** Test seam: forget every declaration, the state a process boots in
|
|
126
|
+
/** Test seam: forget every declaration, the state a process boots in. */
|
|
127
127
|
export function forgetDeviceSockets(): void {
|
|
128
128
|
declared.clear();
|
|
129
129
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @ultimat3/realtime's SERVER-ONLY codes and their titles, registered at import: raised by the
|
|
2
|
+
// replicator, the change feed and the node's boot, never by a page and never inside the frame
|
|
3
|
+
// router that writes a refusal onto the wire. Kept out of `error-titles.ts`, which is in every
|
|
4
|
+
// island chunk; imported bare by `errors.ts` and `server.ts`, and listed in `sideEffects`.
|
|
5
|
+
|
|
6
|
+
import { registerErrorCodes } from '@ultimat3/core/page';
|
|
7
|
+
|
|
8
|
+
/** Codes this package owns that no browser can raise or receive. */
|
|
9
|
+
export const REALTIME_SERVER_OWNED_ERROR_CODES = [
|
|
10
|
+
'X_REPLICATION_PROTOCOL',
|
|
11
|
+
'X_REPLICATION_FAILED',
|
|
12
|
+
'X_REPLICATOR_SLOT_HELD',
|
|
13
|
+
'X_LIVE_REPLICA_IDENTITY',
|
|
14
|
+
'X_REALTIME_TOPOLOGY',
|
|
15
|
+
'X_REPLICATION_TLS',
|
|
16
|
+
'X_REALTIME_PRODUCER_CONFLICT',
|
|
17
|
+
'X_REPLICATION_SLOT_INVALIDATED',
|
|
18
|
+
'X_REPLICATION_SLOT_RECREATED',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type RealtimeServerErrorCode = (typeof REALTIME_SERVER_OWNED_ERROR_CODES)[number];
|
|
22
|
+
|
|
23
|
+
export const REALTIME_SERVER_ERROR_TITLES: Readonly<Record<RealtimeServerErrorCode, string>> = {
|
|
24
|
+
X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
|
|
25
|
+
X_REPLICATION_FAILED: 'the replication connection was refused',
|
|
26
|
+
X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
|
|
27
|
+
X_LIVE_REPLICA_IDENTITY: 'a replicated table has no replica identity',
|
|
28
|
+
X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
|
|
29
|
+
X_REPLICATION_TLS: 'the replication connection failed TLS',
|
|
30
|
+
X_REALTIME_PRODUCER_CONFLICT: 'two kinds of producer carry one table on the change bus',
|
|
31
|
+
X_REPLICATION_SLOT_INVALIDATED: 'the replication slot was invalidated',
|
|
32
|
+
X_REPLICATION_SLOT_RECREATED: 'the replication slot was recreated',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// One unconditional call, as in `error-titles.ts`: a second claimant throws X_ERROR_CODE_DUPLICATE.
|
|
36
|
+
registerErrorCodes(
|
|
37
|
+
Object.fromEntries(
|
|
38
|
+
Object.entries(REALTIME_SERVER_ERROR_TITLES).map(([code, title]) => [code, { title }]),
|
|
39
|
+
),
|
|
40
|
+
);
|
package/src/error-titles.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// @ultimat3/realtime's
|
|
2
|
-
//
|
|
1
|
+
// @ultimat3/realtime's PAGE codes and their TITLES — every code a browser can raise or receive on
|
|
2
|
+
// the wire — registered at import. Server-only codes are `error-titles-server.ts`. Listed in
|
|
3
|
+
// `sideEffects`, so `errors.ts` (the sync node's refusal classes) is unlisted and no hook's
|
|
3
4
|
// 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
5
|
// `run-console`, every title still registered. Imported bare by `index.ts` (a page
|
|
5
6
|
// rebuilds a node's refusal by code in `refusalError`, and `page-errors.ts` constructs codes from
|
|
@@ -10,8 +11,8 @@
|
|
|
10
11
|
// no realtime island reads (`page-errors-bundle.test.ts`). The registry is one module either way.
|
|
11
12
|
import { registerErrorCodes } from '@ultimat3/core/page';
|
|
12
13
|
|
|
13
|
-
/** Codes this package
|
|
14
|
-
export const
|
|
14
|
+
/** Codes this package owns that a page can raise or receive; the rest are `error-titles-server.ts`'s. */
|
|
15
|
+
export const REALTIME_PAGE_OWNED_ERROR_CODES = [
|
|
15
16
|
'X_TOPIC_FORBIDDEN',
|
|
16
17
|
'X_SUBSCRIPTION_LIMIT',
|
|
17
18
|
'X_SUBSCRIPTION_ID_TAKEN',
|
|
@@ -21,9 +22,6 @@ export const REALTIME_OWNED_ERROR_CODES = [
|
|
|
21
22
|
'X_REBASE_CONFLICT',
|
|
22
23
|
'X_TRANSPORT_UNAVAILABLE',
|
|
23
24
|
'X_TRANSPORT_PROTOCOL',
|
|
24
|
-
'X_REPLICATION_PROTOCOL',
|
|
25
|
-
'X_REPLICATION_FAILED',
|
|
26
|
-
'X_REPLICATOR_SLOT_HELD',
|
|
27
25
|
// Retired in 21.0.0 and never thrown since — kept registered, because a shipped code is
|
|
28
26
|
// forever: a log line from an older build still resolves through `x errors explain`.
|
|
29
27
|
'X_LIVE_CLIENT_MISSING',
|
|
@@ -36,24 +34,22 @@ export const REALTIME_OWNED_ERROR_CODES = [
|
|
|
36
34
|
'X_LIVE_SERVER_RENDER',
|
|
37
35
|
'X_LIVE_ROW_UNIDENTIFIED',
|
|
38
36
|
'X_LIVE_QUERY_UNKNOWN',
|
|
39
|
-
'X_LIVE_REPLICA_IDENTITY',
|
|
40
37
|
'X_SOCKET_UNAUTHENTICATED',
|
|
41
38
|
'X_SOCKET_AUTH_UNAVAILABLE',
|
|
42
|
-
'X_REALTIME_TOPOLOGY',
|
|
43
|
-
'X_REPLICATION_TLS',
|
|
44
39
|
'X_SOCKET_ORIGIN_REFUSED',
|
|
45
40
|
'X_OFFLINE_QUEUE_ABANDONED',
|
|
46
41
|
'X_SOCKET_LIMIT',
|
|
47
|
-
'X_REALTIME_PRODUCER_CONFLICT',
|
|
48
42
|
'X_MUTATION_PATH_UNFILLED',
|
|
49
43
|
'X_DEVICE_OFFLINE',
|
|
50
44
|
'X_DEVICE_REQUEST_TIMEOUT',
|
|
51
45
|
'X_DEVICE_FRAME_TOO_LARGE',
|
|
46
|
+
'X_CHANNEL_CATCHUP_POLICY_MISMATCH',
|
|
47
|
+
'X_OFFLINE_QUEUE_FULL',
|
|
52
48
|
] as const;
|
|
53
49
|
|
|
54
|
-
export type
|
|
50
|
+
export type RealtimePageErrorCode = (typeof REALTIME_PAGE_OWNED_ERROR_CODES)[number];
|
|
55
51
|
|
|
56
|
-
export const
|
|
52
|
+
export const REALTIME_PAGE_ERROR_TITLES: Readonly<Record<RealtimePageErrorCode, string>> = {
|
|
57
53
|
X_TOPIC_FORBIDDEN: 'the actor may not subscribe to this topic',
|
|
58
54
|
X_SUBSCRIPTION_LIMIT: 'socket, actor, tenant or node hit its subscription cap',
|
|
59
55
|
X_SUBSCRIPTION_ID_TAKEN: 'a subscribe frame reused a sid this socket already holds',
|
|
@@ -63,9 +59,6 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
|
|
|
63
59
|
X_REBASE_CONFLICT: 'a local mutation could not be rebased',
|
|
64
60
|
X_TRANSPORT_UNAVAILABLE: 'the fanout bus is unreachable',
|
|
65
61
|
X_TRANSPORT_PROTOCOL: 'the bus does not speak the protocol this build speaks',
|
|
66
|
-
X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
|
|
67
|
-
X_REPLICATION_FAILED: 'the replication connection was refused',
|
|
68
|
-
X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
|
|
69
62
|
X_LIVE_CLIENT_MISSING:
|
|
70
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',
|
|
71
64
|
X_QUERY_NOT_SUBSCRIBABLE:
|
|
@@ -78,25 +71,23 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
|
|
|
78
71
|
X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
|
|
79
72
|
X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
|
|
80
73
|
X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
|
|
81
|
-
X_LIVE_REPLICA_IDENTITY: 'a replicated table has no replica identity',
|
|
82
74
|
X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
|
|
83
75
|
X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
|
|
84
|
-
X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
|
|
85
|
-
X_REPLICATION_TLS: 'the replication connection failed TLS',
|
|
86
76
|
X_SOCKET_ORIGIN_REFUSED: 'the websocket upgrade came from another origin',
|
|
87
77
|
X_OFFLINE_QUEUE_ABANDONED: 'a write was queued after its page changed principal',
|
|
88
78
|
X_SOCKET_LIMIT: 'one principal holds too many sockets on this node',
|
|
89
|
-
X_REALTIME_PRODUCER_CONFLICT: 'two kinds of producer carry one table on the change bus',
|
|
90
79
|
X_MUTATION_PATH_UNFILLED: "a pinned action's :param had no value in the write's input",
|
|
91
80
|
X_DEVICE_OFFLINE: 'no socket is open for the device a request addressed',
|
|
92
81
|
X_DEVICE_REQUEST_TIMEOUT: 'a device did not answer a request before its deadline',
|
|
93
82
|
X_DEVICE_FRAME_TOO_LARGE: 'a device socket frame is over the framework frame limit',
|
|
83
|
+
X_CHANNEL_CATCHUP_POLICY_MISMATCH: "a channel's catch-up read decides with another policy",
|
|
84
|
+
X_OFFLINE_QUEUE_FULL: 'the offline queue is full',
|
|
94
85
|
};
|
|
95
86
|
|
|
96
87
|
// One unconditional call, so a second package claiming one of realtime's codes throws
|
|
97
88
|
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
|
|
98
89
|
registerErrorCodes(
|
|
99
90
|
Object.fromEntries(
|
|
100
|
-
Object.entries(
|
|
91
|
+
Object.entries(REALTIME_PAGE_ERROR_TITLES).map(([code, title]) => [code, { title }]),
|
|
101
92
|
),
|
|
102
93
|
);
|
package/src/errors.ts
CHANGED
|
@@ -2,18 +2,48 @@
|
|
|
2
2
|
// the same string renders in the terminal, the browser overlay, and `--json`.
|
|
3
3
|
|
|
4
4
|
import { isFixShellSafe, renderFixShellArg } from '@ultimat3/core';
|
|
5
|
-
// The owned
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The owned codes and their titles are two LISTED modules (`sideEffects`), imported bare here and
|
|
6
|
+
// from the barrels: `error-titles.ts` (every code a page can raise or receive, in every island
|
|
7
|
+
// chunk) and `error-titles-server.ts` (the replicator's, the feed's, the boot's). So this module is
|
|
8
|
+
// side-effect-free and a chunk keeps only the classes it constructs; the union is re-exported.
|
|
8
9
|
import './error-titles';
|
|
9
|
-
import
|
|
10
|
+
import './error-titles-server';
|
|
11
|
+
import {
|
|
12
|
+
REALTIME_PAGE_ERROR_TITLES,
|
|
13
|
+
REALTIME_PAGE_OWNED_ERROR_CODES,
|
|
14
|
+
type RealtimePageErrorCode,
|
|
15
|
+
} from './error-titles';
|
|
16
|
+
import {
|
|
17
|
+
REALTIME_SERVER_ERROR_TITLES,
|
|
18
|
+
REALTIME_SERVER_OWNED_ERROR_CODES,
|
|
19
|
+
type RealtimeServerErrorCode,
|
|
20
|
+
} from './error-titles-server';
|
|
10
21
|
import { RealtimeError } from './realtime-error';
|
|
11
22
|
|
|
12
23
|
export {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
type
|
|
24
|
+
REALTIME_PAGE_ERROR_TITLES,
|
|
25
|
+
REALTIME_PAGE_OWNED_ERROR_CODES,
|
|
26
|
+
type RealtimePageErrorCode,
|
|
16
27
|
} from './error-titles';
|
|
28
|
+
export {
|
|
29
|
+
REALTIME_SERVER_ERROR_TITLES,
|
|
30
|
+
REALTIME_SERVER_OWNED_ERROR_CODES,
|
|
31
|
+
type RealtimeServerErrorCode,
|
|
32
|
+
} from './error-titles-server';
|
|
33
|
+
|
|
34
|
+
/** Every code this package declares and owns: the page's and the server's. */
|
|
35
|
+
export const REALTIME_OWNED_ERROR_CODES = [
|
|
36
|
+
...REALTIME_PAGE_OWNED_ERROR_CODES,
|
|
37
|
+
...REALTIME_SERVER_OWNED_ERROR_CODES,
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
export type RealtimeOwnedErrorCode = RealtimePageErrorCode | RealtimeServerErrorCode;
|
|
41
|
+
|
|
42
|
+
/** Every owned code's title. Composed, never declared here: a new code goes in one of the two. */
|
|
43
|
+
export const REALTIME_ERROR_TITLES = Object.freeze<Record<RealtimeOwnedErrorCode, string>>({
|
|
44
|
+
...REALTIME_PAGE_ERROR_TITLES,
|
|
45
|
+
...REALTIME_SERVER_ERROR_TITLES,
|
|
46
|
+
});
|
|
17
47
|
|
|
18
48
|
/**
|
|
19
49
|
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s, and `X_FORBIDDEN` — thrown by the surface denials this
|
|
@@ -113,6 +143,7 @@ export {
|
|
|
113
143
|
CursorStaleError,
|
|
114
144
|
LocalStoreUnavailableError,
|
|
115
145
|
OfflineQueueAbandonedError,
|
|
146
|
+
OfflineQueueFullError,
|
|
116
147
|
ProtocolVersionError,
|
|
117
148
|
RealtimeUninstalledError,
|
|
118
149
|
RebaseConflictError,
|
|
@@ -130,6 +161,8 @@ export {
|
|
|
130
161
|
ReplicaIdentityError,
|
|
131
162
|
ReplicationFailedError,
|
|
132
163
|
ReplicationProtocolError,
|
|
164
|
+
ReplicationSlotInvalidatedError,
|
|
165
|
+
ReplicationSlotRecreatedError,
|
|
133
166
|
ReplicationTlsError,
|
|
134
167
|
ReplicatorSlotHeldError,
|
|
135
168
|
} from './replication-errors';
|
|
@@ -233,9 +266,9 @@ export class RealtimeTopologyError extends RealtimeError {
|
|
|
233
266
|
code: 'X_REALTIME_TOPOLOGY',
|
|
234
267
|
cause:
|
|
235
268
|
'role sync runs on an external database over the in-process transport with no replicator in this process, so no committed change can reach it',
|
|
236
|
-
// Since
|
|
237
|
-
//
|
|
238
|
-
fix:
|
|
269
|
+
// Since 31.0.0 the env is the bus (`selectTransport`): NATS_URL set is NATS. A bus of the
|
|
270
|
+
// app's own is `transport` in its runtime.ts — the line to paste, never a setter to call.
|
|
271
|
+
fix: 'set NATS_URL for every realtime role (web, sync, replicator) — or, for a bus of your own, add transport: myTransport to export const runtime = { … } in apps/<app>/runtime.ts — or run the replicator in this process: x dev --role sync,replicator',
|
|
239
272
|
});
|
|
240
273
|
}
|
|
241
274
|
}
|
package/src/idb-types.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface IdbTransactionLike {
|
|
|
36
36
|
|
|
37
37
|
export interface IdbDatabaseLike {
|
|
38
38
|
readonly objectStoreNames: { contains(name: string): boolean };
|
|
39
|
+
/**
|
|
40
|
+
* Another tab asked for a NEWER version. Until every open connection closes, that tab's open is
|
|
41
|
+
* `blocked` — so a connection that ignores this holds every later schema off this origin.
|
|
42
|
+
*/
|
|
43
|
+
onversionchange: (() => void) | null;
|
|
44
|
+
close(): void;
|
|
39
45
|
createObjectStore(name: string): unknown;
|
|
40
46
|
transaction(
|
|
41
47
|
names: string | readonly string[],
|