@ultimat3/realtime 30.8.0 → 31.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 (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
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // WHAT IS REAL HERE, and it is everything downstream of the decoder: the matcher, the shared window,
8
8
  // the per-subscriber `visible` gate, the cursor, the frames. This substitutes for the WAL DECODER
9
- // and for nothing else — `@ultimat3/entity`'s `setRowObserver` reports what a repository wrote, in
10
- // this process, and the events are shaped exactly as `PostgresChangeFeed` shapes them.
9
+ // and for nothing else — `@ultimat3/entity`'s `entity.row` / `entity.bulk` events on core's
10
+ // instrumentation bus report what a repository wrote, in this process, and the events are shaped exactly as `PostgresChangeFeed` shapes them.
11
11
  //
12
12
  // WHAT IS NOT: a write another process made is invisible, because nothing here reads a log. That is
13
13
  // the honest bound, and it is why `selectChangeFeed` never picks it — the boot that installs it
@@ -23,9 +23,10 @@
23
23
  // runs in that one process, so the bound above holds by construction — and a real `DATABASE_URL`
24
24
  // gets the decoder instead, never both.
25
25
 
26
- import type { RowBulkChange, RowChange, RowObserver } from '@ultimat3/entity';
26
+ import type { EntityBulkEvent, EntityRowEvent } from '@ultimat3/core';
27
+ import { subscribe } from '@ultimat3/core';
27
28
  import type { ChangeEvent, ChangeOp } from './changefeed';
28
- import type { Row } from './json';
29
+ import type { LiveRow } from './json';
29
30
  import type { LiveQueryRegistry } from './live-query';
30
31
  import { isPublishedTable } from './record-publisher';
31
32
 
@@ -57,13 +58,13 @@ export interface LiveReplicator {
57
58
  }
58
59
 
59
60
  /**
60
- * A `ChangeEvent` row is `Row` — a JSON object carrying an `id`. Every row a repository stores has
61
- * one; the cast is what says so to a compiler that only sees `Record<string, unknown>`, and a row
61
+ * A `ChangeEvent` row is `LiveRow` — a JSON object carrying an `id`. Every row a repository
62
+ * stores has one; the cast is what says so to a compiler that only sees `Record<string, unknown>`, and a row
62
63
  * that genuinely has none fails downstream in `idOf`, with the entity named, exactly as a row off
63
64
  * the wire would.
64
65
  */
65
- const asRow = (value: Readonly<Record<string, unknown>> | null): Row | null =>
66
- value === null ? null : (value as unknown as Row);
66
+ const asRow = (value: Readonly<Record<string, unknown>> | null): LiveRow | null =>
67
+ value === null ? null : (value as unknown as LiveRow);
67
68
 
68
69
  /**
69
70
  * Lexicographically comparable, which is the whole contract of an lsn — `formatLsn` in
@@ -73,13 +74,13 @@ const asRow = (value: Readonly<Record<string, unknown>> | null): Row | null =>
73
74
  const lsnOf = (position: number): string => position.toString(16).padStart(16, '0');
74
75
 
75
76
  /**
76
- * Install the replicator for the length of one test. It takes over the process row observer and
77
- * hands back whatever was installed before, because `bun test` shares one process across files and
78
- * an unconditional clear would take an outer harness's observer with it.
77
+ * Install the replicator for the length of one test. It SUBSCRIBES to the bus, so an outer
78
+ * harness's subscriber — or a second replicator — keeps hearing every write beside it; `stop()`
79
+ * releases this subscription and nobody else's.
79
80
  */
80
81
  export async function startLiveReplicator(options: LiveReplicatorOptions): Promise<LiveReplicator> {
81
- // Awaited BEFORE the observer exists, so installation is the last thing this function does and
82
- // no write between the call and the install can slip past unobserved.
82
+ // Awaited BEFORE the subscription exists, so subscribing is the last thing this function does and
83
+ // no write between the call and the subscribe can slip past unobserved.
83
84
  const entity = await import('@ultimat3/entity');
84
85
  const { registry } = options;
85
86
  const tenant = options.tenantColumn ?? 'orgId';
@@ -93,7 +94,7 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
93
94
  /**
94
95
  * The RELATION an entity's rows live in — what `ChangeEvent.table` carries on every producer:
95
96
  * the WAL decoder reads it off the Relation message, a channel matches `projection.table`, and a
96
- * live shape is `from('<table>', …)`. The row observer reports the entity's declared NAME, which
97
+ * live shape is `from('<table>', …)`. The `entity.row` event reports the entity's declared NAME, which
97
98
  * is the table only until an entity adopts one (`entity('ledger', { table: 'gl_entries' })`);
98
99
  * passed through, such an entity reached no channel, no window and no bulk re-read here.
99
100
  */
@@ -107,8 +108,8 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
107
108
  });
108
109
  };
109
110
 
110
- const observer: RowObserver = {
111
- onChange(change: RowChange): void {
111
+ const handlers = {
112
+ onChange(change: EntityRowEvent): void {
112
113
  if (stopped) return;
113
114
  position += 1;
114
115
  const at = position;
@@ -147,7 +148,7 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
147
148
  * something", used here for the one write that genuinely does. Silence would be the
148
149
  * alternative, and a subscriber told nothing happened diverges with nobody ever asking again.
149
150
  */
150
- onBulk(change: RowBulkChange): void {
151
+ onBulk(change: EntityBulkEvent): void {
151
152
  if (stopped) return;
152
153
  const table = tableOf(change.entity);
153
154
  registry.invalidate(table);
@@ -159,7 +160,9 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
159
160
  },
160
161
  };
161
162
 
162
- const previous = entity.setRowObserver(observer);
163
+ // Handlers never throw: a strict subscriber's throw would reach the repository write.
164
+ const offRow = subscribe('entity.row', (event) => handlers.onChange(event.payload));
165
+ const offBulk = subscribe('entity.bulk', (event) => handlers.onBulk(event.payload));
163
166
 
164
167
  return {
165
168
  get delivered() {
@@ -175,9 +178,9 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
175
178
  },
176
179
  stop: () => {
177
180
  stopped = true;
178
- // Restored, never cleared: one process runs every test file, and an outer harness's observer
179
- // must survive an inner fixture finishing.
180
- entity.setRowObserver(previous);
181
+ // This replicator's subscriptions only: an outer harness's survive an inner fixture finishing.
182
+ offRow();
183
+ offBulk();
181
184
  },
182
185
  };
183
186
  }
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import type { ClientScope, RecordRows, Row } from '@ultimat3/core/page';
9
- import { isJsonObject, renderThrowable, type UltimateError } from '@ultimat3/core/page';
9
+ import {
10
+ finiteOption,
11
+ isJsonObject,
12
+ renderThrowable,
13
+ type UltimateError,
14
+ } from '@ultimat3/core/page';
10
15
  import type { IdbDatabaseLike, IdbFactoryLike, IdbRequestLike } from './idb-types';
11
16
  import type { QueueChange, QueuedMutation, QueueState } from './offline-queue';
12
17
  import { LocalStoreUnavailableError } from './page-errors';
@@ -239,8 +244,19 @@ export interface OpenLocalStoreOptions {
239
244
  /** Where the one fallback warning goes. Default `console.warn`. */
240
245
  readonly warn?: ((error: UltimateError) => void) | undefined;
241
246
  readonly name?: string | undefined;
247
+ /**
248
+ * How long an open `blocked` by another tab's connection waits for that tab to close it before
249
+ * falling back to memory. Default `DEFAULT_BLOCKED_TIMEOUT_MS`.
250
+ */
251
+ readonly blockedTimeoutMs?: number | undefined;
242
252
  }
243
253
 
254
+ /**
255
+ * Every tab running this module closes its connection on `versionchange` at once, so a block that
256
+ * outlives this is a tab on code that does not — one the user has to close.
257
+ */
258
+ const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
259
+
244
260
  /** IndexedDB when the page has it and it opens; memory, warned once, when it does not. */
245
261
  export async function openLocalStore(options: OpenLocalStoreOptions = {}): Promise<LocalStore> {
246
262
  // The browser's `IDBFactory` IS this shape at runtime; its DOM typings (event-typed handlers)
@@ -248,12 +264,18 @@ export async function openLocalStore(options: OpenLocalStoreOptions = {}): Promi
248
264
  const ambient: unknown = Reflect.get(globalThis, 'indexedDB');
249
265
  const factory = options.indexedDB ?? (ambient as IdbFactoryLike | undefined);
250
266
  const warn = options.warn ?? ((error: UltimateError) => console.warn(error));
267
+ const blockedMs = finiteOption(
268
+ 'openLocalStore',
269
+ 'blockedTimeoutMs',
270
+ options.blockedTimeoutMs ?? DEFAULT_BLOCKED_TIMEOUT_MS,
271
+ );
251
272
  if (factory === undefined) {
252
273
  warn(new LocalStoreUnavailableError({ reason: 'this runtime has no indexedDB' }));
253
274
  return new MemoryLocalStore();
254
275
  }
255
276
  try {
256
- return new IdbLocalStore(await openDatabase(factory, options.name ?? 'ultimate-client'));
277
+ const name = options.name ?? 'ultimate-client';
278
+ return new IdbLocalStore(await openDatabase(factory, name, blockedMs));
257
279
  } catch (error) {
258
280
  warn(
259
281
  new LocalStoreUnavailableError({
@@ -264,17 +286,55 @@ export async function openLocalStore(options: OpenLocalStoreOptions = {}): Promi
264
286
  }
265
287
  }
266
288
 
267
- function openDatabase(factory: IdbFactoryLike, name: string): Promise<IdbDatabaseLike> {
289
+ /**
290
+ * `blocked` is not a failure: it means another tab still holds an older connection, and the open
291
+ * goes on to `upgradeneeded` and `success` the moment that tab closes it. Refusing there sent every
292
+ * new tab to memory on any schema bump, and its offline writes died with it. So the open WAITS,
293
+ * bounded; and the connection it yields closes itself when a later tab asks for a newer version.
294
+ */
295
+ function openDatabase(
296
+ factory: IdbFactoryLike,
297
+ name: string,
298
+ blockedMs: number,
299
+ ): Promise<IdbDatabaseLike> {
268
300
  return new Promise((resolve, reject) => {
269
301
  const request = factory.open(name, 1);
302
+ let settled = false;
303
+ let timer: ReturnType<typeof setTimeout> | undefined;
304
+ const settle = (): boolean => {
305
+ if (settled) return false;
306
+ settled = true;
307
+ if (timer !== undefined) clearTimeout(timer);
308
+ return true;
309
+ };
270
310
  request.onupgradeneeded = (): void => {
271
311
  const db = request.result;
272
312
  if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS);
273
313
  if (!db.objectStoreNames.contains(OUTBOX)) db.createObjectStore(OUTBOX);
274
314
  };
275
- request.onsuccess = (): void => resolve(request.result);
276
- request.onerror = (): void => reject(request.error);
277
- request.onblocked = (): void => reject(request.error ?? 'blocked by another open tab');
315
+ request.onsuccess = (): void => {
316
+ const db = request.result;
317
+ // Past the bound this page already runs on memory: a connection nobody uses, held open,
318
+ // would block the next tab's upgrade exactly as this one was blocked.
319
+ if (!settle()) {
320
+ db.close();
321
+ return;
322
+ }
323
+ // Holding on would block that tab; this tab's later writes fail and reject instead.
324
+ db.onversionchange = (): void => db.close();
325
+ resolve(db);
326
+ };
327
+ request.onerror = (): void => {
328
+ if (settle()) reject(request.error);
329
+ };
330
+ request.onblocked = (): void => {
331
+ if (settled || timer !== undefined) return;
332
+ timer = setTimeout(() => {
333
+ if (!settle()) return;
334
+ const reason = `blocked by another open tab for ${blockedMs} ms — close the other tabs of this site`;
335
+ reject(new DOMException(reason, 'TimeoutError'));
336
+ }, blockedMs);
337
+ };
278
338
  });
279
339
  }
280
340
 
@@ -10,7 +10,7 @@ import {
10
10
  changedColumns,
11
11
  isParsedJsonObject,
12
12
  type JsonObject,
13
- type Row,
13
+ type LiveRow,
14
14
  type RowPatch,
15
15
  } from './json';
16
16
 
@@ -38,7 +38,7 @@ export const NO_CHANGE: BridgeResult = { patches: [], refill: false };
38
38
  export interface IncrementalMatcher {
39
39
  readonly entities: readonly string[];
40
40
  /** `rows` is the query's current *pre-policy* window, shared by every subscriber of the qid. */
41
- match(change: ChangeEvent, rows: readonly Row[]): BridgeResult;
41
+ match(change: ChangeEvent, rows: readonly LiveRow[]): BridgeResult;
42
42
  }
43
43
 
44
44
  /** The cheap pre-filter. Pure, and the only thing that runs for a change nobody subscribed to. */
@@ -58,7 +58,7 @@ export function bridgeChange(
58
58
  shape: SubscriptionShape,
59
59
  incremental: IncrementalMatcher,
60
60
  change: ChangeEvent,
61
- rows: readonly Row[],
61
+ rows: readonly LiveRow[],
62
62
  ): BridgeResult | null {
63
63
  if (!canAffect(shape, change)) return null;
64
64
  const result = incremental.match(change, rows);
@@ -94,7 +94,7 @@ export function matcherFor(live: LiveQuery, projection?: () => Projection): Incr
94
94
  if (change.op === 'truncate') return { patches: [], refill: true };
95
95
  const row = change.after ?? change.before;
96
96
  if (!row) return NO_CHANGE;
97
- const patches = match<Row>(live.name, live.shape, rows, {
97
+ const patches = match<LiveRow>(live.name, live.shape, rows, {
98
98
  entity: change.table,
99
99
  op: change.op,
100
100
  row,
@@ -116,18 +116,18 @@ export function matcherFor(live: LiveQuery, projection?: () => Projection): Incr
116
116
  export type Projection = ReadonlySet<string> | undefined;
117
117
 
118
118
  /** The projection a window's own rows describe. Empty window, no answer. */
119
- export const projectionOf = (rows: readonly Row[]): Projection =>
119
+ export const projectionOf = (rows: readonly LiveRow[]): Projection =>
120
120
  rows[0] === undefined ? undefined : new Set(Object.keys(rows[0]));
121
121
 
122
122
  /**
123
123
  * One row, restricted to the columns the result set carries.
124
124
  *
125
- * A `ChangeEvent` carries the whole TABLE row — that is what logical replication emits and what
126
- * `setRowObserver` emits — while a live query's result set is whatever its `sql` returned. Sending
127
- * the change row through unnarrowed put every column of the table on the socket, including the ones
128
- * a projection exists to withhold: `examples/dummy`'s feed projects ten columns and a single publish
129
- * delivered `body` to every subscriber. The per-subscriber gate cannot help — it decides whether a
130
- * ROW is delivered, never which of its columns.
125
+ * A `ChangeEvent` carries the whole TABLE row — that is what logical replication emits and what an
126
+ * `entity.row` event carries — while a live query's result set is whatever its `sql` returned.
127
+ * Sending the change row through unnarrowed put every column of the table on the socket, including
128
+ * the ones a projection exists to withhold: `examples/dummy`'s feed projects ten columns and a
129
+ * single publish delivered `body` to every subscriber. The per-subscriber gate cannot help — it
130
+ * decides whether a ROW is delivered, never which of its columns.
131
131
  *
132
132
  * `id` always survives: it is the row's identity on the wire, and `applyToWindow` and every client
133
133
  * store key by it.
@@ -141,9 +141,9 @@ export function narrowRow(row: JsonObject, projection: Projection): JsonObject {
141
141
  return out;
142
142
  }
143
143
 
144
- /** `Patch<Row>` (add/update/remove/refill, positional) -> the wire's `RowPatch`. */
144
+ /** `Patch<LiveRow>` (add/update/remove/refill, positional) -> the wire's `RowPatch`. */
145
145
  export function toBridgeResult(
146
- patches: readonly Patch<Row>[],
146
+ patches: readonly Patch<LiveRow>[],
147
147
  change: ChangeEvent,
148
148
  projection?: Projection,
149
149
  ): BridgeResult {
@@ -184,7 +184,7 @@ export function toBridgeResult(
184
184
  }
185
185
 
186
186
  /** Applies patches to the shared pre-policy window so the matcher sees the current result set. */
187
- export function applyToWindow(rows: readonly Row[], patches: readonly RowPatch[]): Row[] {
187
+ export function applyToWindow(rows: readonly LiveRow[], patches: readonly RowPatch[]): LiveRow[] {
188
188
  const next = [...rows];
189
189
  for (const patch of patches) {
190
190
  const index = next.findIndex((row) => row.id === patch.id);
@@ -194,7 +194,7 @@ export function applyToWindow(rows: readonly Row[], patches: readonly RowPatch[]
194
194
  }
195
195
  if (patch.row === null) continue;
196
196
  const current = index >= 0 ? next[index] : undefined;
197
- const merged: Row = { ...(current ?? {}), ...patch.row, id: patch.id };
197
+ const merged: LiveRow = { ...(current ?? {}), ...patch.row, id: patch.id };
198
198
  if (index >= 0) next[index] = merged;
199
199
  else if (patch.index !== undefined) next.splice(patch.index, 0, merged);
200
200
  else next.push(merged);
@@ -26,7 +26,7 @@ import { NatsKvSet } from './nats-kv';
26
26
  import { openNatsClient } from './nats-open';
27
27
  import { NatsReports } from './nats-reports';
28
28
  import { NatsSubscriptions } from './nats-subscriptions';
29
- import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
29
+ import { defaultBackoff, policyDelay, type ReconnectBackoff, type Rng } from './thundering-herd';
30
30
 
31
31
  const encoder = new TextEncoder();
32
32
  const decoder = new TextDecoder();
@@ -36,7 +36,7 @@ export interface NatsTransportOptions {
36
36
  /** KV bucket backing `shared`. Created on first connect when the cluster has none. */
37
37
  readonly bucket: string;
38
38
  readonly maxReconnectAttempts?: number;
39
- readonly backoff?: BackoffPolicy;
39
+ readonly backoff?: ReconnectBackoff;
40
40
  readonly clock?: Clock;
41
41
  /** Presence TTL, only as the floor for the bucket's whole-stream age limit. */
42
42
  readonly presenceTtlMs?: number;
@@ -70,7 +70,7 @@ export class NatsTransport implements Transport {
70
70
  readonly shared: TransportSet;
71
71
  readonly #options: NatsTransportOptions;
72
72
  readonly #connect: NatsConnect;
73
- readonly #backoff: BackoffPolicy;
73
+ readonly #backoff: ReconnectBackoff;
74
74
  readonly #attempts: number;
75
75
  readonly #rng: Rng;
76
76
  #client: NatsClient | undefined;
@@ -355,7 +355,7 @@ export class NatsTransport implements Transport {
355
355
  /**
356
356
  * Dial until a client is up or the transport is closed — the first dial of a process that does
357
357
  * not wait for the bus, and the replacement of a client the library gave up on. One loop however
358
- * many callers ask. Bounded by our backoff between attempts (capped at the policy's `maxMs`), so
358
+ * many callers ask. Bounded by our backoff between attempts (capped at the policy's `max`), so
359
359
  * a long outage costs one dial per interval rather than a spin. A dial that lands re-binds every
360
360
  * kept subscription and is announced: changes published in the gap are gone.
361
361
  */
@@ -398,7 +398,7 @@ export class NatsTransport implements Transport {
398
398
  }
399
399
  }
400
400
 
401
- /** A wait `close()` can cut short, so a closed transport never holds a timer for `maxMs`. */
401
+ /** A wait `close()` can cut short, so a closed transport never holds a timer for `max`. */
402
402
  #pause(ms: number): Promise<void> {
403
403
  return new Promise((resolve) => {
404
404
  const done = (): void => {
@@ -0,0 +1,69 @@
1
+ // Single responsibility: what the durable mutation queue IS on disk — an entry, a queue's state,
2
+ // one keyed change to it — and the in-memory store tests and SSR run it on. The queue's rules
3
+ // (order, dedupe, only the server removes) are `offline-queue.ts`'s, which re-exports these.
4
+
5
+ import type { ActionPin } from '@ultimat3/core/page';
6
+ import type { JsonValue } from './json';
7
+ import type { WireError } from './sync-protocol';
8
+
9
+ export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
10
+
11
+ export interface QueuedMutation {
12
+ /** Idempotency key. Supplied by the mutator, stable across retries and reloads. */
13
+ readonly key: string;
14
+ /** Monotonic per client. Never renumbered — rebase replays in this order. */
15
+ readonly seq: number;
16
+ readonly name: string;
17
+ readonly input: JsonValue;
18
+ /** The action's `http` pin, when the write was made against one (`pinned-wire.ts`). */
19
+ readonly http?: ActionPin | undefined;
20
+ readonly enqueuedAt: number;
21
+ attempts: number;
22
+ status: MutationStatus;
23
+ error: WireError | null;
24
+ }
25
+
26
+ export interface QueueState {
27
+ readonly mutations: readonly QueuedMutation[];
28
+ readonly nextSeq: number;
29
+ }
30
+
31
+ /**
32
+ * One change to the durable queue: these entries written BY KEY, these keys deleted, and the
33
+ * sequence floor. Never the whole queue — two tabs of one user share the store, and a whole-queue
34
+ * save let the last tab to save erase the other's queued write.
35
+ */
36
+ export interface QueueChange {
37
+ readonly puts: readonly QueuedMutation[];
38
+ readonly deletes: readonly string[];
39
+ readonly nextSeq: number;
40
+ }
41
+
42
+ /** Durability seam: IndexedDB in the browser (`page-outbox.ts`), memory in tests. */
43
+ export interface QueueStore {
44
+ load(): Promise<QueueState>;
45
+ write(change: QueueChange): Promise<void>;
46
+ }
47
+
48
+ export class MemoryQueueStore implements QueueStore {
49
+ readonly #mutations = new Map<string, QueuedMutation>();
50
+ #nextSeq = 1;
51
+
52
+ async load(): Promise<QueueState> {
53
+ return {
54
+ mutations: [...this.#mutations.values()].map((m) => ({ ...m })),
55
+ nextSeq: this.#nextSeq,
56
+ };
57
+ }
58
+
59
+ async write(change: QueueChange): Promise<void> {
60
+ for (const key of change.deletes) this.#mutations.delete(key);
61
+ for (const mutation of change.puts) this.#mutations.set(mutation.key, { ...mutation });
62
+ this.#nextSeq = Math.max(this.#nextSeq, change.nextSeq);
63
+ }
64
+ }
65
+
66
+ /** The one way to build the in-process queue store; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
67
+ export function memoryQueueStore(): MemoryQueueStore {
68
+ return new MemoryQueueStore();
69
+ }