@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.
Files changed (88) hide show
  1. package/CLAUDE.md +61 -63
  2. package/README.md +131 -46
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +10 -5
  5. package/src/boot.ts +14 -9
  6. package/src/changefeed-env.ts +8 -8
  7. package/src/changefeed.ts +42 -18
  8. package/src/channel-authz.ts +7 -7
  9. package/src/channel-describe.ts +1 -1
  10. package/src/channel-logs.ts +16 -25
  11. package/src/channel-presence.ts +3 -3
  12. package/src/channel-records.ts +3 -3
  13. package/src/channel-ref.ts +2 -2
  14. package/src/channel-registry.ts +2 -2
  15. package/src/channel.ts +8 -7
  16. package/src/client-channels.ts +2 -0
  17. package/src/client-frames.ts +9 -2
  18. package/src/client.ts +29 -2
  19. package/src/error-titles.ts +91 -0
  20. package/src/errors.ts +47 -90
  21. package/src/first-paint-hold.ts +49 -0
  22. package/src/idb-types.ts +2 -2
  23. package/src/index.ts +24 -6
  24. package/src/json.ts +16 -3
  25. package/src/live-contract.ts +14 -0
  26. package/src/live-definition.ts +12 -2
  27. package/src/live-deliver.ts +55 -0
  28. package/src/live-fanout.ts +81 -24
  29. package/src/live-query-options.ts +6 -0
  30. package/src/live-query.ts +66 -144
  31. package/src/live-reauth.ts +127 -0
  32. package/src/live-replicator.ts +28 -8
  33. package/src/live-resume.ts +44 -8
  34. package/src/live-rows.ts +32 -3
  35. package/src/live-spend.ts +50 -0
  36. package/src/local-store-idb.ts +5 -0
  37. package/src/matcher-bridge.ts +11 -5
  38. package/src/nats-client.ts +26 -4
  39. package/src/nats-lib-client.ts +36 -7
  40. package/src/nats-subscriptions.ts +63 -0
  41. package/src/nats-transport.ts +127 -15
  42. package/src/offline-queue.ts +5 -0
  43. package/src/page-errors.ts +8 -9
  44. package/src/page-outbox.ts +5 -5
  45. package/src/page-runtime-wait.ts +72 -0
  46. package/src/page-runtime.ts +32 -0
  47. package/src/page-socket.ts +7 -16
  48. package/src/page-store.ts +83 -16
  49. package/src/pg-advisory-lock.ts +9 -4
  50. package/src/pg-connection.ts +7 -1
  51. package/src/pg-replication.ts +6 -6
  52. package/src/pg-socket.ts +20 -3
  53. package/src/pg-values.ts +29 -14
  54. package/src/policy-gate.ts +7 -7
  55. package/src/presence.ts +2 -2
  56. package/src/principal-sockets.ts +75 -0
  57. package/src/query-window.ts +1 -1
  58. package/src/record-publisher.ts +178 -0
  59. package/src/record-store.ts +25 -4
  60. package/src/record-synced.ts +43 -1
  61. package/src/replication-errors.ts +3 -3
  62. package/src/replicator-envelope.ts +61 -11
  63. package/src/replicator-row.ts +1 -1
  64. package/src/replicator.ts +12 -23
  65. package/src/server.ts +36 -18
  66. package/src/socket-defaults.ts +20 -0
  67. package/src/socket.ts +14 -19
  68. package/src/subscription-book.ts +129 -71
  69. package/src/subscription-count.ts +151 -0
  70. package/src/sync-actor-change.ts +56 -0
  71. package/src/sync-bus-handlers.ts +131 -0
  72. package/src/sync-frames.ts +1 -1
  73. package/src/sync-node-bounds.ts +1 -1
  74. package/src/sync-node-contract.ts +16 -2
  75. package/src/sync-node.ts +115 -109
  76. package/src/sync-origin.ts +1 -1
  77. package/src/sync-protocol.ts +73 -13
  78. package/src/sync-upgrade.ts +39 -0
  79. package/src/type-pins.ts +9 -1
  80. package/src/use-channel.ts +19 -14
  81. package/src/use-connection.ts +3 -3
  82. package/src/use-mutation.ts +9 -16
  83. package/src/use-query.ts +11 -11
  84. package/src/use-record.ts +89 -10
  85. package/src/wire-channel.ts +5 -5
  86. package/src/wire-read.ts +2 -2
  87. package/src/idb-fake.ts +0 -133
  88. 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
- /** Entity name, not table name — the matcher's dependency sets are declared in entity terms. */
24
- readonly entity: string;
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`). Absent for a change no keyed request made. Read off the WAL message the
38
- * Postgres driver writes first in the transaction, or off the request scope in-process; a
39
- * channel stamps it on the `records` frame so the writing page recognises its own echo.
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?: string;
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 InMemoryChangeFeedOptions {
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 InMemoryChangeFeed implements ChangeFeed {
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: InMemoryChangeFeedOptions = {}) {
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
- entity: string,
152
+ table: string,
146
153
  op: ChangeOp,
147
- rows: { before?: Row | null; after?: Row | null; orgId?: string | null; at?: number },
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
- entity,
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 `createReplicator`'s
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 PgLogicalReplicationOptions {
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 `InMemoryChangeFeed` in `x dev`
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 PgLogicalReplicationFeed implements ChangeFeed {
235
+ export class PostgresChangeFeed implements ChangeFeed {
217
236
  readonly source = 'pg-logical-replication';
218
237
  readonly #stream: PgReplicationStream;
219
238
 
220
- constructor(options: PgLogicalReplicationOptions) {
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: new PgLogicalReplicationFeed({ entities: [...] })',
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
+ }
@@ -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 `guard`, the package's one authz
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, createContext, runWithContext } from '@ultimat3/core';
6
- import { guard, QueryDeniedError } from '@ultimat3/query';
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 `createContext`, the rule `withChildContext` follows.
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
- guard(channel.policy, { actor, input: params, row, ctx, query: channel.name }, 'live');
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 createContext({
75
+ return ctxOf({
76
76
  actor,
77
77
  role: node.role,
78
78
  buildId: node.buildId,
@@ -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/query';
5
+ import { policyCapability, policyPermissions } from '@ultimat3/policy';
6
6
  import { registeredChannels } from './channel-registry';
7
7
 
8
8
  export interface ChannelDescription {
@@ -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, uuid } from '@ultimat3/core';
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 = uuid();
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.#truncated(change.entity);
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.entity,
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
- * Every row of `table` is gone, and no frame can say which ones a member holds. Each open topic
91
- * of a channel carrying it starts a NEW epoch — nothing in the old ring may be replayed onto a
92
- * table that no longer has those rows — and every member is told `replay-gap` at it, which the
93
- * client answers by re-running the channel's catch-up read.
94
- */
95
- #truncated(table: string): number {
96
- let announced = 0;
97
- for (const [topic, open] of [...this.#byTopic]) {
98
- if (!carriesTable(open.target.channel, table)) continue;
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;
@@ -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 { isJsonObject, type JsonObject } from './json';
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 (!isJsonObject(value)) return null;
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: isJsonObject(meta) ? meta : {}, updatedAt };
67
+ return { id, actorId, meta: isParsedJsonObject(meta) ? meta : {}, updatedAt };
68
68
  }
@@ -37,7 +37,7 @@ export function skippedRemovals(): number {
37
37
  }
38
38
 
39
39
  /**
40
- * `change.entity` is the replicated RELATION name (`pg-replication.ts` reads it off the Relation
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.entity);
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.entity });
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);
@@ -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 { invariant } from '@ultimat3/core/page';
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
- invariant(false, 'X_CHANNEL_DECLARATION_INVALID', `channel("${name}"): ${detail}`, fix);
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. */
@@ -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 { invariant } from '@ultimat3/core';
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
- invariant(
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
- createContext,
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 ?? createContext();
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
- invariant(
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`,
@@ -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) => {
@@ -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
- target.windows.snapshot(registration, frame.entity ?? null, frame.rows, frame.keys);
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
- target.windows.patch(registration, frame.patches);
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, uuid } from '@ultimat3/core/page';
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 = uuid();
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
+ );