@ultimat3/realtime 23.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 (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -1,7 +1,8 @@
1
1
  // Single responsibility: the production `Transport` — core NATS for fanout, JetStream KV for the
2
2
  // shared presence sets. The client underneath owns the wire and the reconnect, including
3
- // re-establishing subscriptions, which is what makes a `sync` node stateless: a lost connection is
4
- // re-dialled and re-subscribed underneath the caller, and this file keeps no socket state at all.
3
+ // re-establishing subscriptions, which is what makes a `sync` node stateless. The one loss it does
4
+ // not own is its own end: a client closed for good once its reconnect budget is spent is replaced
5
+ // here, on our backoff, and every kept subscription is bound again on the new one.
5
6
 
6
7
  import {
7
8
  type Clock,
@@ -18,6 +19,7 @@ import { parseNatsUrl } from './nats-client';
18
19
  import { ensureKvBucket } from './nats-jetstream';
19
20
  import { NatsKvSet } from './nats-kv';
20
21
  import { openNatsClient } from './nats-open';
22
+ import { NatsSubscriptions } from './nats-subscriptions';
21
23
  import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
22
24
 
23
25
  const encoder = new TextEncoder();
@@ -55,6 +57,11 @@ export class NatsTransport implements Transport {
55
57
  #dialing: Promise<NatsClient> | undefined;
56
58
  #retries = 0;
57
59
  #closed = false;
60
+ /** Set while a client the library gave up on is being replaced; the attempt in flight. */
61
+ #redialing: number | undefined;
62
+ #wakeRedial: (() => void) | undefined;
63
+ readonly #subscriptions = new NatsSubscriptions();
64
+ readonly #reconnectListeners = new Set<() => void>();
58
65
 
59
66
  constructor(options: NatsTransportOptions) {
60
67
  // Parsed here rather than at the first publish: a malformed NATS_URL is a boot-time fault, and
@@ -97,27 +104,44 @@ export class NatsTransport implements Transport {
97
104
  }
98
105
 
99
106
  /**
100
- * The subscription is the client's to keep: it survives a drop and comes back with the reconnect,
101
- * so there is no intent map here to re-bind from — and therefore no way for a re-bind to run
102
- * twice and double every change on the subject.
107
+ * A drop is the client's to recover: the subscription comes back with the library's reconnect,
108
+ * untouched by this file. It is also KEPT here, for the one loss the library does not recover —
109
+ * a client closed for good — and bound once on its replacement, never twice on one client.
103
110
  */
104
111
  async subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription> {
105
112
  const client = await this.#ensure();
106
113
  // Same seam as `publish`: a permissions violation on the subject is refused here, not later.
107
- const live = this.#translating(`subscribe to ${subject}`, () =>
108
- client.subscribe(subject, (message) => {
114
+ const release = this.#subscriptions.add(
115
+ client,
116
+ subject,
117
+ (message) => {
109
118
  try {
110
119
  handler(decoder.decode(message.payload), message.subject);
111
120
  } catch (error) {
112
121
  this.#report(error, message.subject);
113
122
  }
114
- }),
123
+ },
124
+ (call) => this.#translating(`subscribe to ${subject}`, call),
115
125
  );
116
- return { subject, unsubscribe: () => live.unsubscribe() };
126
+ return { subject, unsubscribe: release };
127
+ }
128
+
129
+ /**
130
+ * Told every time the client comes back from a drop. Fanout is at-most-once, so the gap between
131
+ * the drop and this call is changes the subscriber never saw — and with no later message there
132
+ * is no sequence number to notice it by. A `sync` node invalidates its windows on it. Returns
133
+ * the unsubscribe.
134
+ */
135
+ onReconnect(listener: () => void): () => void {
136
+ this.#reconnectListeners.add(listener);
137
+ return () => {
138
+ this.#reconnectListeners.delete(listener);
139
+ };
117
140
  }
118
141
 
119
142
  async close(): Promise<void> {
120
143
  this.#closed = true;
144
+ this.#wakeRedial?.();
121
145
  const client = this.#client;
122
146
  this.#client = undefined;
123
147
  await client?.close();
@@ -127,7 +151,9 @@ export class NatsTransport implements Transport {
127
151
  * One dial, shared by every caller that races it. A client that has already been handed out is
128
152
  * reused whatever its state: while it is reconnecting the library is re-establishing that same
129
153
  * connection and its subscriptions, and a second dial alongside it would double every delivery.
130
- * A budget that ran out is a readiness failure, not a reason to start an unbounded retry here.
154
+ * A client closed for good is not handed out: `#lost` clears it and `#redial` replaces it, and a
155
+ * caller that lands meanwhile is refused at once rather than parked behind a dial that may take
156
+ * the library's whole connect budget.
131
157
  */
132
158
  #ensure(): Promise<NatsClient> {
133
159
  if (this.#closed) {
@@ -137,6 +163,19 @@ export class NatsTransport implements Transport {
137
163
  }
138
164
  const current = this.#client;
139
165
  if (current !== undefined) return Promise.resolve(current);
166
+ if (this.#redialing !== undefined) {
167
+ return Promise.reject(
168
+ new TransportUnavailableError({
169
+ transport: this.name,
170
+ reason: `the connection closed once its reconnect budget was spent, and re-dial attempt ${this.#redialing} is in progress`,
171
+ }),
172
+ );
173
+ }
174
+ return this.#dialOnce();
175
+ }
176
+
177
+ /** One dial, shared by every caller that races it. */
178
+ #dialOnce(): Promise<NatsClient> {
140
179
  this.#dialing ??= this.#dial().finally(() => {
141
180
  this.#dialing = undefined;
142
181
  });
@@ -150,6 +189,10 @@ export class NatsTransport implements Transport {
150
189
  * leaking one per retry.
151
190
  */
152
191
  async #dial(): Promise<NatsClient> {
192
+ // The client is only known once the dial resolves, and the library may give up on it before
193
+ // the bucket is asserted: both cases are caught by identity, never by a flag on the transport.
194
+ let opened: NatsClient | undefined;
195
+ let closedEarly = false;
153
196
  const client = await this.#connect({
154
197
  url: this.#options.url,
155
198
  name: 'ultimate',
@@ -159,9 +202,19 @@ export class NatsTransport implements Transport {
159
202
  reconnectDelay: () => policyDelay(this.#backoff, ++this.#retries, this.#rng),
160
203
  onError: (error) => this.#report(error, this.name),
161
204
  onReconnect: () => this.#recovered(),
205
+ onClosed: () => {
206
+ if (opened === undefined) closedEarly = true;
207
+ else this.#lost(opened);
208
+ },
162
209
  });
163
210
  try {
164
211
  await this.#ensureBucket(client);
212
+ if (closedEarly) {
213
+ throw new TransportUnavailableError({
214
+ transport: this.name,
215
+ reason: 'the connection closed for good while the KV bucket was being asserted',
216
+ });
217
+ }
165
218
  // `close()` can land while a dial is in flight, and it only closes what it can see:
166
219
  // publishing now would leave a connection open that nothing will ever close again.
167
220
  if (this.#closed) {
@@ -174,11 +227,76 @@ export class NatsTransport implements Transport {
174
227
  await client.close();
175
228
  throw error;
176
229
  }
230
+ opened = client;
177
231
  this.#client = client;
178
232
  this.#retries = 0;
179
233
  return client;
180
234
  }
181
235
 
236
+ /**
237
+ * The library spent its reconnect budget and closed the client. Handing it out again is a node
238
+ * that refuses every publish until somebody restarts it — and the liveness probe never asks the
239
+ * bus — so it is forgotten here and replaced in the background, subscriptions included.
240
+ */
241
+ #lost(client: NatsClient): void {
242
+ if (this.#closed || this.#client !== client) return;
243
+ this.#client = undefined;
244
+ this.#subscriptions.orphan();
245
+ this.#report(
246
+ new TransportUnavailableError({
247
+ transport: this.name,
248
+ reason: 'the connection closed once its reconnect budget was spent; re-dialling',
249
+ }),
250
+ this.name,
251
+ );
252
+ void this.#redial();
253
+ }
254
+
255
+ /**
256
+ * Re-dial until a client is up or the transport is closed. Bounded by our backoff between
257
+ * attempts (capped at the policy's `maxMs`), and each attempt is itself a library dial with its
258
+ * own budget, so a long outage costs one dial per interval rather than a spin. A dial that lands
259
+ * re-binds every kept subscription and is announced: changes published in the gap are gone.
260
+ */
261
+ async #redial(): Promise<void> {
262
+ if (this.#redialing !== undefined) return;
263
+ try {
264
+ for (let attempt = 1; !this.#closed && this.#client === undefined; attempt += 1) {
265
+ this.#redialing = attempt;
266
+ try {
267
+ const client = await this.#dialOnce();
268
+ // Coded like a first subscribe's refusal; the subscription stays kept for the next client.
269
+ for (const failure of this.#subscriptions.bindAll(client)) {
270
+ this.#report(
271
+ this.#coded(`re-subscribe to ${failure.subject}`, failure.error),
272
+ failure.subject,
273
+ );
274
+ }
275
+ this.#announce();
276
+ } catch (error) {
277
+ if (this.#closed) return;
278
+ this.#report(error, this.name);
279
+ await this.#pause(policyDelay(this.#backoff, attempt, this.#rng));
280
+ }
281
+ }
282
+ } finally {
283
+ this.#redialing = undefined;
284
+ }
285
+ }
286
+
287
+ /** A wait `close()` can cut short, so a closed transport never holds a timer for `maxMs`. */
288
+ #pause(ms: number): Promise<void> {
289
+ return new Promise((resolve) => {
290
+ const done = (): void => {
291
+ clearTimeout(timer);
292
+ this.#wakeRedial = undefined;
293
+ resolve();
294
+ };
295
+ const timer = setTimeout(done, ms);
296
+ this.#wakeRedial = done;
297
+ });
298
+ }
299
+
182
300
  #ensureBucket(client: NatsClient): Promise<void> {
183
301
  return ensureKvBucket(
184
302
  client,
@@ -200,7 +318,19 @@ export class NatsTransport implements Transport {
200
318
  this.#retries = 0;
201
319
  const client = this.#client;
202
320
  if (client === undefined) return;
321
+ // Not awaited: the library calls this from its status loop. The failure has one place to go.
203
322
  void this.#ensureBucket(client).catch((error: unknown) => this.#report(error, this.name));
323
+ this.#announce();
324
+ }
325
+
326
+ #announce(): void {
327
+ for (const listener of [...this.#reconnectListeners]) {
328
+ try {
329
+ listener();
330
+ } catch (error) {
331
+ this.#report(error, this.name);
332
+ }
333
+ }
204
334
  }
205
335
 
206
336
  /**
@@ -214,14 +344,18 @@ export class NatsTransport implements Transport {
214
344
  try {
215
345
  return call();
216
346
  } catch (error) {
217
- if (isUltimateError(error)) throw error;
218
- throw new TransportUnavailableError({
219
- transport: this.name,
220
- reason: `${what} was refused: ${renderThrowable(error)}`,
221
- });
347
+ throw this.#coded(what, error);
222
348
  }
223
349
  }
224
350
 
351
+ #coded(what: string, error: unknown): unknown {
352
+ if (isUltimateError(error)) return error;
353
+ return new TransportUnavailableError({
354
+ transport: this.name,
355
+ reason: `${what} was refused: ${renderThrowable(error)}`,
356
+ });
357
+ }
358
+
225
359
  /**
226
360
  * Every background failure lands here — a lost connection, a throwing subscriber, an exhausted
227
361
  * reconnect. Dropping it when the caller passed no handler is what turns "no changes arrive"
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { renderThrowable, stringField } from '@ultimat3/core/page';
13
13
  import type { JsonValue } from './json';
14
+ import { OfflineQueueAbandonedError } from './page-errors';
14
15
  import type { WireError } from './sync-protocol';
15
16
 
16
17
  export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
@@ -68,6 +69,11 @@ export class MemoryQueueStore implements QueueStore {
68
69
  }
69
70
  }
70
71
 
72
+ /** The one way to build the in-process queue store; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
73
+ export function memoryQueueStore(): MemoryQueueStore {
74
+ return new MemoryQueueStore();
75
+ }
76
+
71
77
  export interface DrainReport {
72
78
  readonly sent: number;
73
79
  readonly collapsed: number;
@@ -91,6 +97,8 @@ export class OfflineQueue {
91
97
  * is parked inside `send`. Bumped by every loss so a pass that resumes afterwards claims nothing.
92
98
  */
93
99
  #epoch = 0;
100
+ /** Set by `abandon`, never cleared: this queue's principal is gone. */
101
+ #abandoned = false;
94
102
 
95
103
  private constructor(store: QueueStore, state: QueueState) {
96
104
  this.#store = store;
@@ -103,8 +111,10 @@ export class OfflineQueue {
103
111
  *
104
112
  * An `inflight` entry on disk belonged to a page that is gone, so it goes back to `pending`. Left
105
113
  * as it was, `#sendable` skipped it forever — no ack was coming to a page that no longer exists —
106
- * and every later write overtook it. The replay carries its idempotency key, so a write the old
107
- * page did get through is answered from the action's idempotency store, never applied twice.
114
+ * and every later write overtook it. The replay carries its idempotency key, and a `mutator()`
115
+ * is REQUIRED to declare `idempotent: true` (`X_MUTATOR_NOT_IDEMPOTENT` at declaration), so a
116
+ * write the old page did get through is answered from the action's idempotency store and is
117
+ * never applied twice. That requirement is what makes this resend safe, not the key alone.
108
118
  */
109
119
  static async open(store: QueueStore): Promise<OfflineQueue> {
110
120
  const queue = new OfflineQueue(store, await store.load());
@@ -119,6 +129,7 @@ export class OfflineQueue {
119
129
  * other pass can have one on the wire.
120
130
  */
121
131
  async reload(): Promise<void> {
132
+ if (this.#abandoned) return;
122
133
  const state = await this.#store.load();
123
134
  this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
124
135
  this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
@@ -167,6 +178,17 @@ export class OfflineQueue {
167
178
  input: JsonValue;
168
179
  at?: number;
169
180
  }): Promise<QueuedMutation> {
181
+ // Read BEFORE the key is looked up, never between the lookup and the push: with the await in
182
+ // between, two enqueues of one key both found nothing and both pushed — one intent, two
183
+ // entries under one sequence number. Another tab of the same user may have taken sequence
184
+ // numbers since this one loaded, which is what the read is for.
185
+ //
186
+ // An abandoned queue REFUSES, before the read and after it (the principal can leave during
187
+ // it): a write accepted here would sit in memory, shown as queued, with nothing to send it.
188
+ if (this.#abandoned) throw new OfflineQueueAbandonedError({ name: args.name });
189
+ const floor = (await this.#store.load()).nextSeq;
190
+ if (this.#abandoned) throw new OfflineQueueAbandonedError({ name: args.name });
191
+ this.#nextSeq = Math.max(this.#nextSeq, floor);
170
192
  const existing = this.find(args.key);
171
193
  if (existing && existing.status !== 'failed') {
172
194
  this.#collapsed += 1;
@@ -178,8 +200,6 @@ export class OfflineQueue {
178
200
  // entry is dropped and this one takes a new sequence at the back of the queue.
179
201
  // By identity: `existing` IS the entry for this key, found above — no second key comparison.
180
202
  if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
181
- // Another tab of the same user may have taken sequence numbers since this one loaded.
182
- this.#nextSeq = Math.max(this.#nextSeq, (await this.#store.load()).nextSeq);
183
203
  const mutation: QueuedMutation = {
184
204
  key: args.key,
185
205
  seq: this.#nextSeq,
@@ -248,6 +268,22 @@ export class OfflineQueue {
248
268
  return returned;
249
269
  }
250
270
 
271
+ /**
272
+ * The principal this queue belongs to is gone (`page-outbox.ts` calls it on a `rescope`), and it
273
+ * is final: nothing more is SENT, WRITTEN or ACCEPTED (`enqueue` refuses). The epoch bump stops a pass parked
274
+ * inside `send` before its next entry — which would otherwise leave under the next principal's
275
+ * session — and the flag stops a pass that starts later, and every write-back: the store was
276
+ * wiped for this principal, and a pass that then saved what it touched put the writes back.
277
+ */
278
+ abandon(): void {
279
+ this.#epoch += 1;
280
+ this.#abandoned = true;
281
+ }
282
+
283
+ get abandoned(): boolean {
284
+ return this.#abandoned;
285
+ }
286
+
251
287
  /** Server acknowledged: the mutation leaves the queue and its rebase entry can be committed. */
252
288
  async ack(key: string): Promise<void> {
253
289
  const mutation = this.find(key);
@@ -294,7 +330,7 @@ export class OfflineQueue {
294
330
  /** One drain pass. Never called concurrently with itself — `drain` owns that. */
295
331
  async #pass(send: MutationSender): Promise<DrainReport> {
296
332
  const epoch = this.#epoch;
297
- const sendable = this.#sendable();
333
+ const sendable = this.#abandoned ? [] : this.#sendable();
298
334
  // Nothing to do: a pass chained behind one that already sent everything must not rewrite the
299
335
  // durable state for the privilege of reporting zero.
300
336
  if (sendable.length === 0) {
@@ -326,6 +362,12 @@ export class OfflineQueue {
326
362
  // `WebSocket.send` on a CLOSING socket discards it and returns normally — so calling that
327
363
  // an ack drops the mutation on exactly the socket death this queue exists to survive.
328
364
  // Only `ack`/`fail` (the server) or `requeueInflight` (a lost connection) moves it on.
365
+ //
366
+ // A sender may settle the write itself before it returns — the page outbox does, over
367
+ // HTTP. One it REFUSED keeps the error `fail` just recorded and is not counted as sent:
368
+ // clearing it here put `{ status: 'failed', error: null }` on disk and reported a refused
369
+ // write as a sent one.
370
+ if (statusOf(mutation) === 'failed') continue;
329
371
  mutation.error = null;
330
372
  sent += 1;
331
373
  } catch (error) {
@@ -364,6 +406,7 @@ export class OfflineQueue {
364
406
  * queue: a second tab's entries are not this tab's to overwrite or delete.
365
407
  */
366
408
  async #persist(puts: readonly QueuedMutation[], deletes: readonly string[] = []): Promise<void> {
409
+ if (this.#abandoned) return;
367
410
  await this.#store.write({
368
411
  puts: puts.map((mutation) => ({ ...mutation })),
369
412
  deletes,
@@ -372,6 +415,11 @@ export class OfflineQueue {
372
415
  }
373
416
  }
374
417
 
418
+ /** Read through a call: `send` awaited in between, and the compiler still narrows to `inflight`. */
419
+ function statusOf(mutation: QueuedMutation): MutationStatus {
420
+ return mutation.status;
421
+ }
422
+
375
423
  /** A thrown value as the queue records it — `code`, `cause`, `fix`, never a throw of its own. */
376
424
  export function toQueueError(error: unknown): WireError {
377
425
  return {
@@ -1,9 +1,8 @@
1
1
  // The refusals a BROWSER can reach — the page store, the hooks, the socket's wire check, the local
2
- // store. Apart from `errors.ts` for bytes: that module registers the whole code table at import,
3
- // and an island that renders one record has no use for sixty titles. The codes stay in `errors.ts`
4
- // (its `registerErrorCodes()` is what `package.json`'s `sideEffects` names, anchored by the
5
- // barrel); in a browser that loaded no table a code titles itself from its name, and `code`,
6
- // `cause` and `fix` — what a reader acts on — are unchanged. This module runs nothing at import.
2
+ // store. Apart from `errors.ts` for bytes: that module holds the sync node's refusal classes, which
3
+ // no island constructs. The codes and their titles are `error-titles.ts` (the module
4
+ // `package.json`'s `sideEffects` names, imported bare by the barrel), so a code constructed here is
5
+ // titled wherever the barrel was reached. This module runs nothing at import.
7
6
 
8
7
  import { renderFixShellArg } from '@ultimat3/core/page';
9
8
  import { RealtimeError } from './realtime-error';
@@ -36,8 +35,8 @@ export class RebaseConflictError extends RealtimeError {
36
35
  }
37
36
 
38
37
  /**
39
- * A hook ran IN A BROWSER in an island bundle whose bootstrap never called `installRealtime()`.
40
- * Per BUNDLE, not per page: every island carries its own copy of solid-js, so the signal factory a
38
+ * A hook ran IN A BROWSER in an island bundle whose bootstrap never called `installRealtime()`,
39
+ * or before the page runtime (`page-runtime.ts`, carried by the page boot) was installed. Per BUNDLE, not per page: every island carries its own copy of solid-js, so the signal factory a
41
40
  * hook renders through has to be that island's own — the page-wide store cannot hold one.
42
41
  *
43
42
  * A server render is deliberately not this error: no DOM means no reactive runtime to install,
@@ -47,8 +46,8 @@ export class RealtimeUninstalledError extends RealtimeError {
47
46
  constructor(args: { hook: string }) {
48
47
  super({
49
48
  code: 'X_REALTIME_UNINSTALLED',
50
- cause: `${args.hook}() ran in a browser island whose bundle never called installRealtime()`,
51
- fix: "x build # the island bootstrap installs it; a hand-built island calls installRealtime({ signal: createSignal }) from '@ultimat3/realtime' before its first render",
49
+ cause: `${args.hook}() ran in a browser island whose bundle never called installRealtime(), or on a page whose runtime (@ultimat3/realtime/boot) never loaded`,
50
+ fix: "x build # the island bootstrap installs it; a hand-built island imports '@ultimat3/realtime/boot' (the page runtime) and calls installRealtime({ signal: createSignal }) from '@ultimat3/realtime' before its first render",
52
51
  });
53
52
  }
54
53
  }
@@ -93,6 +92,23 @@ export class LocalStoreUnavailableError extends RealtimeError {
93
92
  }
94
93
  }
95
94
 
95
+ /**
96
+ * A write reached a queue whose principal is gone: the page signed out, or in as someone else,
97
+ * while the write was being queued. It is not stored and never sent — a write issued under one
98
+ * session must not leave under the next — and it is RAISED, because a user's write dropped in
99
+ * silence is a loss nobody is told about. `name` is the mutator's registered name; the write's
100
+ * idempotency key is deliberately not rendered.
101
+ */
102
+ export class OfflineQueueAbandonedError extends RealtimeError {
103
+ constructor(args: { name: string }) {
104
+ super({
105
+ code: 'X_OFFLINE_QUEUE_ABANDONED',
106
+ cause: `the write "${args.name}" was queued after its page changed principal, so it was not stored and will not be sent — call the mutation again under the current session`,
107
+ fix: 'x errors explain X_OFFLINE_QUEUE_ABANDONED --json',
108
+ });
109
+ }
110
+ }
111
+
96
112
  /**
97
113
  * Something that can only mean "talk to the socket" ran on the server client — a mutation, a
98
114
  * publish, a topic subscription, a dial. There is no socket during a server render and there never