@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.
- package/CLAUDE.md +72 -82
- package/README.md +36 -40
- package/package.json +8 -6
- package/src/adapter.ts +14 -0
- package/src/apply-patches.ts +8 -5
- package/src/boot.ts +15 -2
- package/src/changefeed.ts +11 -4
- package/src/channel-decl.ts +29 -1
- package/src/channel-publish.ts +17 -19
- package/src/channel-ref.ts +22 -2
- package/src/client-channels.ts +11 -5
- package/src/client-contract.ts +2 -2
- package/src/cursor.ts +5 -5
- package/src/device-agent.ts +2 -2
- package/src/device-errors.ts +1 -1
- package/src/device-socket.ts +1 -1
- package/src/error-titles-server.ts +40 -0
- package/src/error-titles.ts +12 -21
- package/src/errors.ts +43 -10
- package/src/idb-types.ts +6 -0
- package/src/index.ts +5 -27
- package/src/json.ts +7 -3
- package/src/live-contract.ts +4 -4
- package/src/live-definition.ts +9 -6
- package/src/live-deliver.ts +40 -2
- package/src/live-entries.ts +73 -0
- package/src/live-fanout.ts +25 -9
- package/src/live-patch-encode.ts +30 -0
- package/src/live-query-options.ts +11 -0
- package/src/live-query.ts +26 -11
- package/src/live-replicator.ts +24 -21
- package/src/local-store-idb.ts +66 -6
- package/src/matcher-bridge.ts +15 -15
- package/src/nats-transport.ts +5 -5
- package/src/offline-queue-store.ts +69 -0
- package/src/offline-queue.ts +91 -91
- package/src/page-errors.ts +15 -0
- package/src/page-outbox.ts +18 -3
- package/src/pg-connection.ts +9 -1
- package/src/pg-preflight.ts +21 -5
- package/src/pg-replication-dial.ts +95 -0
- package/src/pg-replication-liveness.ts +84 -0
- package/src/pg-replication-position.ts +42 -0
- package/src/pg-replication-row.ts +6 -6
- package/src/pg-replication-stats.ts +28 -0
- package/src/pg-replication.ts +106 -125
- package/src/pg-slot-release.ts +40 -0
- package/src/policy-gate.ts +2 -2
- package/src/query-window.ts +4 -4
- package/src/record-publisher.ts +2 -2
- package/src/record-store.ts +7 -5
- package/src/replication-errors.ts +40 -2
- package/src/replication-resync.ts +38 -0
- package/src/replicator-deadline.ts +33 -0
- package/src/replicator-reconnect.ts +85 -0
- package/src/replicator-resync.ts +30 -0
- package/src/replicator-row.ts +5 -5
- package/src/replicator-subject.ts +28 -0
- package/src/replicator.ts +79 -69
- package/src/server.ts +8 -6
- package/src/socket-engine.ts +2 -2
- package/src/subscriber-gate.ts +14 -10
- package/src/sync-bus-handlers.ts +20 -4
- package/src/sync-protocol.ts +3 -3
- package/src/thundering-herd.ts +17 -24
- package/src/transport-env.ts +20 -48
- package/src/window-lock.ts +15 -3
package/src/live-replicator.ts
CHANGED
|
@@ -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 `
|
|
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 {
|
|
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 {
|
|
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 `
|
|
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):
|
|
66
|
-
value === null ? null : (value as unknown as
|
|
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
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
82
|
-
// no write between the call and the
|
|
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
|
|
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
|
|
111
|
-
onChange(change:
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
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
|
}
|
package/src/local-store-idb.ts
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ClientScope, RecordRows, Row } from '@ultimat3/core/page';
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
package/src/matcher-bridge.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
changedColumns,
|
|
11
11
|
isParsedJsonObject,
|
|
12
12
|
type JsonObject,
|
|
13
|
-
type
|
|
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
|
|
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
|
|
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<
|
|
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
|
|
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
|
-
* `
|
|
127
|
-
* the change row through unnarrowed put every column of the table on the socket, including
|
|
128
|
-
* a projection exists to withhold: `examples/dummy`'s feed projects ten columns and a
|
|
129
|
-
* delivered `body` to every subscriber. The per-subscriber gate cannot help — it
|
|
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<
|
|
144
|
+
/** `Patch<LiveRow>` (add/update/remove/refill, positional) -> the wire's `RowPatch`. */
|
|
145
145
|
export function toBridgeResult(
|
|
146
|
-
patches: readonly Patch<
|
|
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
|
|
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:
|
|
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);
|
package/src/nats-transport.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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:
|
|
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 `
|
|
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 `
|
|
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
|
+
}
|