@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.
Files changed (67) hide show
  1. package/CLAUDE.md +72 -82
  2. package/README.md +36 -40
  3. package/package.json +8 -6
  4. package/src/adapter.ts +14 -0
  5. package/src/apply-patches.ts +8 -5
  6. package/src/boot.ts +15 -2
  7. package/src/changefeed.ts +11 -4
  8. package/src/channel-decl.ts +29 -1
  9. package/src/channel-publish.ts +17 -19
  10. package/src/channel-ref.ts +22 -2
  11. package/src/client-channels.ts +11 -5
  12. package/src/client-contract.ts +2 -2
  13. package/src/cursor.ts +5 -5
  14. package/src/device-agent.ts +2 -2
  15. package/src/device-errors.ts +1 -1
  16. package/src/device-socket.ts +1 -1
  17. package/src/error-titles-server.ts +40 -0
  18. package/src/error-titles.ts +12 -21
  19. package/src/errors.ts +43 -10
  20. package/src/idb-types.ts +6 -0
  21. package/src/index.ts +5 -27
  22. package/src/json.ts +7 -3
  23. package/src/live-contract.ts +4 -4
  24. package/src/live-definition.ts +9 -6
  25. package/src/live-deliver.ts +40 -2
  26. package/src/live-entries.ts +73 -0
  27. package/src/live-fanout.ts +25 -9
  28. package/src/live-patch-encode.ts +30 -0
  29. package/src/live-query-options.ts +11 -0
  30. package/src/live-query.ts +26 -11
  31. package/src/live-replicator.ts +24 -21
  32. package/src/local-store-idb.ts +66 -6
  33. package/src/matcher-bridge.ts +15 -15
  34. package/src/nats-transport.ts +5 -5
  35. package/src/offline-queue-store.ts +69 -0
  36. package/src/offline-queue.ts +91 -91
  37. package/src/page-errors.ts +15 -0
  38. package/src/page-outbox.ts +18 -3
  39. package/src/pg-connection.ts +9 -1
  40. package/src/pg-preflight.ts +21 -5
  41. package/src/pg-replication-dial.ts +95 -0
  42. package/src/pg-replication-liveness.ts +84 -0
  43. package/src/pg-replication-position.ts +42 -0
  44. package/src/pg-replication-row.ts +6 -6
  45. package/src/pg-replication-stats.ts +28 -0
  46. package/src/pg-replication.ts +106 -125
  47. package/src/pg-slot-release.ts +40 -0
  48. package/src/policy-gate.ts +2 -2
  49. package/src/query-window.ts +4 -4
  50. package/src/record-publisher.ts +2 -2
  51. package/src/record-store.ts +7 -5
  52. package/src/replication-errors.ts +40 -2
  53. package/src/replication-resync.ts +38 -0
  54. package/src/replicator-deadline.ts +33 -0
  55. package/src/replicator-reconnect.ts +85 -0
  56. package/src/replicator-resync.ts +30 -0
  57. package/src/replicator-row.ts +5 -5
  58. package/src/replicator-subject.ts +28 -0
  59. package/src/replicator.ts +79 -69
  60. package/src/server.ts +8 -6
  61. package/src/socket-engine.ts +2 -2
  62. package/src/subscriber-gate.ts +14 -10
  63. package/src/sync-bus-handlers.ts +20 -4
  64. package/src/sync-protocol.ts +3 -3
  65. package/src/thundering-herd.ts +17 -24
  66. package/src/transport-env.ts +20 -48
  67. 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 { Row } from './json';
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 Row = Row> {
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?: Row | null;
156
- after?: Row | null;
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
 
@@ -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 { Ctx, Row } from '@ultimat3/core';
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) {
@@ -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 bus this process publishes on. A heap bus nobody subscribes to until a boot installs the
30
- * real one — `jobs`' `setEventBus` arrangement — so a unit test, or a script that booted nothing,
31
- * publishes to nobody instead of throwing in the middle of the write it follows.
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
- let ambient: Transport = new InProcessTransport();
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 `selectTransport` chose, in EVERY role:
37
- * the same object the `sync` role's hub subscribes on, so under `x dev` a publish reaches this
38
- * process's own sockets and under NATS every node's. Answers the release, which puts a fresh heap
39
- * bus back only while this install is still the current one.
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 setChannelTransport(transport: Transport): () => void {
42
- ambient = transport;
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(ambient, declared.topic(params), event);
72
+ await sendChannelEvent(installed.current(), declared.topic(params), event);
75
73
  }
@@ -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: { readonly name: string };
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
- return Object.freeze({
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
  }
@@ -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
- readonly holders: Set<ChannelHandlers>;
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 Set(),
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
- held.holders.add(handlers);
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(handlers);
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
  }
@@ -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 { BackoffPolicy, Rng, Scheduler } from './thundering-herd';
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?: BackoffPolicy;
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 { Row, RowPatch } from './json';
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 Row = Row> =
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 Row = Row> {
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 Row[],
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 Row = Row>(
142
+ export async function resumeFrom<R extends LiveRow = LiveRow>(
143
143
  cursor: LiveCursor,
144
144
  deps: ResumeDeps<R>,
145
145
  ): Promise<ResumeResult<R>> {
@@ -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?: BackoffPolicy | undefined;
51
+ readonly backoff?: ReconnectBackoff | undefined;
52
52
  readonly rng?: Rng | undefined;
53
53
  readonly schedule?: Scheduler | undefined;
54
54
  readonly dial?: AgentDial | undefined;
@@ -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 realtime.transport must be 'nats'`,
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
  }
@@ -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 — `resetChannelTransport`'s twin. */
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
+ );
@@ -1,5 +1,6 @@
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
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 declares and owns. */
14
- export const REALTIME_OWNED_ERROR_CODES = [
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 RealtimeOwnedErrorCode = (typeof REALTIME_OWNED_ERROR_CODES)[number];
50
+ export type RealtimePageErrorCode = (typeof REALTIME_PAGE_OWNED_ERROR_CODES)[number];
55
51
 
56
- export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, string>> = {
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(REALTIME_ERROR_TITLES).map(([code, title]) => [code, { title }]),
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 code list and its titles are `error-titles.ts` — the one module `sideEffects` lists,
6
- // imported bare here and from the barrel — so this module is side-effect-free and a chunk keeps
7
- // only the classes it constructs. Re-exported, so every name is still importable from `./errors`.
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 { REALTIME_OWNED_ERROR_CODES } from './error-titles';
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
- REALTIME_ERROR_TITLES,
14
- REALTIME_OWNED_ERROR_CODES,
15
- type RealtimeOwnedErrorCode,
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 22.0.0 NATS_URL alone selects nothing: `realtime.transport` does, and a set NATS_URL
237
- // under `'memory'` is refused, so the fix has to name both halves.
238
- fix: "set realtime: { transport: 'nats', urlEnv: 'NATS_URL' } in app.config.ts and NATS_URL for every realtime role (web, sync, replicator), or run ROLE=sync with the replicator in one process: x dev --role sync,replicator",
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[],