@bayonai/offline 0.5.1 → 0.8.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/CHANGELOG.md +34 -0
- package/README.md +69 -3
- package/dist/browserFallbackRecovery.js +5 -2
- package/dist/durableCommandStore.d.ts +49 -0
- package/dist/durableCommandStore.js +223 -0
- package/dist/idbDriver.d.ts +3 -0
- package/dist/idbDriver.js +80 -31
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pendingWrites.d.ts +13 -4
- package/dist/pendingWrites.js +40 -12
- package/dist/readCache.d.ts +1 -1
- package/dist/readCache.js +22 -5
- package/dist-cjs/browserFallbackRecovery.js +5 -2
- package/dist-cjs/durableCommandStore.js +226 -0
- package/dist-cjs/idbDriver.js +82 -31
- package/dist-cjs/index.js +4 -0
- package/dist-cjs/pendingWrites.js +39 -11
- package/dist-cjs/readCache.js +22 -5
- package/package.json +3 -3
package/dist/pendingWrites.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type PendingWriteStatus = "pending" | "syncing" | "failed";
|
|
|
6
6
|
export type PendingWriteRecord<Payload = unknown> = {
|
|
7
7
|
error?: string;
|
|
8
8
|
id: string;
|
|
9
|
+
/** Do not execute this accepted command before this ISO timestamp. */
|
|
10
|
+
notBefore?: string;
|
|
9
11
|
payload: Payload;
|
|
10
12
|
savedAt: string;
|
|
11
13
|
snapshotId: string;
|
|
@@ -80,6 +82,7 @@ export type SyncPendingWriteResult = {
|
|
|
80
82
|
status: "superseded";
|
|
81
83
|
} | {
|
|
82
84
|
error: string;
|
|
85
|
+
failure: unknown;
|
|
83
86
|
status: "failed";
|
|
84
87
|
};
|
|
85
88
|
export type SyncPendingWritesResult = {
|
|
@@ -91,7 +94,11 @@ export type SyncPendingWritesResult = {
|
|
|
91
94
|
superseded: number;
|
|
92
95
|
synced: number;
|
|
93
96
|
};
|
|
94
|
-
export type
|
|
97
|
+
export type PendingWriteSyncContext = {
|
|
98
|
+
/** Stable durable-record identity; safe to use as a remote idempotency key. */
|
|
99
|
+
recordId: string;
|
|
100
|
+
};
|
|
101
|
+
export type PendingWriteSync<Payload, Result = unknown> = (payload: Payload, context: PendingWriteSyncContext) => Promise<Result>;
|
|
95
102
|
export type RunPendingWriteResult<TResult, TQueued> = {
|
|
96
103
|
result: TResult;
|
|
97
104
|
status: "synced";
|
|
@@ -127,7 +134,7 @@ export declare function createMemoryPendingWriteDriver(): PendingWriteDriver;
|
|
|
127
134
|
export declare function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: Omit<BrowserPendingWriteStoreOptions, "keyPrefix" | "normalizePayload" | "schemaVersion">): PendingWriteDriver | null;
|
|
128
135
|
export declare function createBrowserPendingWriteStore<Payload = unknown>({ databaseName, databaseVersion, indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs, schemaVersion, storeName, }: BrowserPendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
|
|
129
136
|
export declare function createPendingWriteKey({ id, prefix }: PendingWriteKeyOptions): string;
|
|
130
|
-
export declare function createPendingWriteSnapshotId():
|
|
137
|
+
export declare function createPendingWriteSnapshotId(): string;
|
|
131
138
|
export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
|
|
132
139
|
id: string;
|
|
133
140
|
now?: () => string;
|
|
@@ -139,7 +146,8 @@ export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
|
|
|
139
146
|
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
140
147
|
* mutation semantics.
|
|
141
148
|
*/
|
|
142
|
-
export declare function syncPendingWrites<Payload>({ scopeKey, select, stopOnFailure, store, sync, }: {
|
|
149
|
+
export declare function syncPendingWrites<Payload>({ now, scopeKey, select, stopOnFailure, store, sync, }: {
|
|
150
|
+
now?: () => string;
|
|
143
151
|
scopeKey?: string;
|
|
144
152
|
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
145
153
|
/** Preserve caller ordering by leaving later records for a future retry. */
|
|
@@ -158,7 +166,8 @@ export declare function summarizePendingWrites<Payload>(records: Array<PendingWr
|
|
|
158
166
|
* workspace. Use its `recover` method for background replay and `run` for new
|
|
159
167
|
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
160
168
|
*/
|
|
161
|
-
export declare function createOrderedPendingWriteExecutor<Payload>({ scopeKey, select, store, sync, }: {
|
|
169
|
+
export declare function createOrderedPendingWriteExecutor<Payload>({ now, scopeKey, select, store, sync, }: {
|
|
170
|
+
now?: () => string;
|
|
162
171
|
scopeKey?: string;
|
|
163
172
|
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
164
173
|
store: PendingWriteStore<Payload>;
|
package/dist/pendingWrites.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createIdbKeyValueDriver, } from "./idbDriver.js";
|
|
1
|
+
import { createIdbKeyValueDriver, OfflineStorageError, } from "./idbDriver.js";
|
|
2
2
|
export const pendingWriteSchemaVersion = 1;
|
|
3
3
|
export const pendingWriteOperationTimeoutMs = 2_000;
|
|
4
4
|
export const pendingWriteSyncLeaseTimeoutMs = 60_000;
|
|
@@ -148,19 +148,34 @@ export function createBrowserPendingWriteStore({ databaseName, databaseVersion,
|
|
|
148
148
|
operationTimeoutMs,
|
|
149
149
|
storeName,
|
|
150
150
|
});
|
|
151
|
-
return createPendingWriteStore(driver ??
|
|
151
|
+
return createPendingWriteStore(driver ?? createUnavailablePendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
|
|
152
152
|
}
|
|
153
153
|
export function createPendingWriteKey({ id, prefix }) {
|
|
154
154
|
return `${prefix}:${encodeURIComponent(id)}`;
|
|
155
155
|
}
|
|
156
156
|
export function createPendingWriteSnapshotId() {
|
|
157
|
-
|
|
157
|
+
const randomId = globalThis.crypto?.randomUUID?.();
|
|
158
|
+
if (randomId)
|
|
159
|
+
return randomId;
|
|
160
|
+
pendingWriteSnapshotSequence += 1;
|
|
161
|
+
return [
|
|
162
|
+
"snapshot",
|
|
163
|
+
Date.now().toString(36),
|
|
164
|
+
pendingWriteSnapshotSequence.toString(36),
|
|
165
|
+
Math.random().toString(36).slice(2),
|
|
166
|
+
].join("-");
|
|
158
167
|
}
|
|
168
|
+
let pendingWriteSnapshotSequence = 0;
|
|
159
169
|
export async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
|
|
160
170
|
const pending = await store.get(id);
|
|
161
171
|
if (!pending) {
|
|
162
172
|
return { status: "idle" };
|
|
163
173
|
}
|
|
174
|
+
// A delayed command remains in its ordered lane until its grace period has
|
|
175
|
+
// elapsed. This makes cancellation survive UI unmounts and app restarts.
|
|
176
|
+
if (pending.notBefore && Date.parse(pending.notBefore) > Date.parse(now())) {
|
|
177
|
+
return { status: "idle" };
|
|
178
|
+
}
|
|
164
179
|
const claimedSnapshotId = createPendingWriteSnapshotId();
|
|
165
180
|
const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
|
|
166
181
|
...pending,
|
|
@@ -173,7 +188,7 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
|
|
|
173
188
|
return { status: "superseded" };
|
|
174
189
|
}
|
|
175
190
|
try {
|
|
176
|
-
await sync(pending.payload);
|
|
191
|
+
await sync(pending.payload, { recordId: pending.id });
|
|
177
192
|
}
|
|
178
193
|
catch (error) {
|
|
179
194
|
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
@@ -190,7 +205,7 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
|
|
|
190
205
|
store,
|
|
191
206
|
});
|
|
192
207
|
return markedFailed
|
|
193
|
-
? { error: message, status: "failed" }
|
|
208
|
+
? { error: message, failure: error, status: "failed" }
|
|
194
209
|
: { status: "superseded" };
|
|
195
210
|
}
|
|
196
211
|
return (await store.clearIfSnapshotId(id, claimedSnapshotId))
|
|
@@ -202,7 +217,7 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
|
|
|
202
217
|
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
203
218
|
* mutation semantics.
|
|
204
219
|
*/
|
|
205
|
-
export function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
220
|
+
export function syncPendingWrites({ now, scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
206
221
|
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
207
222
|
if (!inFlightByScope) {
|
|
208
223
|
inFlightByScope = new Map();
|
|
@@ -213,6 +228,7 @@ export function syncPendingWrites({ scopeKey = "all", select = () => true, stopO
|
|
|
213
228
|
return existing;
|
|
214
229
|
}
|
|
215
230
|
const syncRun = performPendingWriteSync({
|
|
231
|
+
now,
|
|
216
232
|
select,
|
|
217
233
|
stopOnFailure,
|
|
218
234
|
store,
|
|
@@ -259,7 +275,7 @@ export function summarizePendingWrites(records) {
|
|
|
259
275
|
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
260
276
|
}
|
|
261
277
|
const pendingWriteSyncs = new WeakMap();
|
|
262
|
-
async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
|
|
278
|
+
async function performPendingWriteSync({ now, select, stopOnFailure, store, sync, }) {
|
|
263
279
|
const summary = {
|
|
264
280
|
attempted: 0,
|
|
265
281
|
failed: 0,
|
|
@@ -272,6 +288,7 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
|
|
|
272
288
|
for (const record of records) {
|
|
273
289
|
const result = await syncPendingWrite({
|
|
274
290
|
id: record.id,
|
|
291
|
+
now,
|
|
275
292
|
store,
|
|
276
293
|
sync,
|
|
277
294
|
});
|
|
@@ -280,7 +297,9 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
|
|
|
280
297
|
// Product decision: a superseded result can represent an older live claim.
|
|
281
298
|
// Preserve strict ordering by leaving every later record for a future run.
|
|
282
299
|
if (stopOnFailure &&
|
|
283
|
-
(result.status === "failed" ||
|
|
300
|
+
(result.status === "failed" ||
|
|
301
|
+
result.status === "idle" ||
|
|
302
|
+
result.status === "superseded")) {
|
|
284
303
|
break;
|
|
285
304
|
}
|
|
286
305
|
}
|
|
@@ -305,7 +324,7 @@ function isPendingWriteSyncClaimAvailable(record, now) {
|
|
|
305
324
|
* workspace. Use its `recover` method for background replay and `run` for new
|
|
306
325
|
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
307
326
|
*/
|
|
308
|
-
export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
327
|
+
export function createOrderedPendingWriteExecutor({ now, scopeKey = "all", select = () => true, store, sync, }) {
|
|
309
328
|
let execution = Promise.resolve();
|
|
310
329
|
function inOrder(operation) {
|
|
311
330
|
const result = execution.then(operation, operation);
|
|
@@ -314,6 +333,7 @@ export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = (
|
|
|
314
333
|
}
|
|
315
334
|
function recover() {
|
|
316
335
|
return inOrder(() => syncPendingWrites({
|
|
336
|
+
now,
|
|
317
337
|
scopeKey,
|
|
318
338
|
select,
|
|
319
339
|
stopOnFailure: true,
|
|
@@ -329,6 +349,7 @@ export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = (
|
|
|
329
349
|
return runPendingWrite(options);
|
|
330
350
|
}
|
|
331
351
|
const replay = await syncPendingWrites({
|
|
352
|
+
now,
|
|
332
353
|
scopeKey,
|
|
333
354
|
select,
|
|
334
355
|
stopOnFailure: true,
|
|
@@ -346,7 +367,8 @@ export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = (
|
|
|
346
367
|
},
|
|
347
368
|
};
|
|
348
369
|
}
|
|
349
|
-
function
|
|
370
|
+
function createUnavailablePendingWriteDriver() {
|
|
371
|
+
const unavailable = () => new OfflineStorageError("IndexedDB is unavailable; the pending write was not stored.");
|
|
350
372
|
return {
|
|
351
373
|
async clear() { },
|
|
352
374
|
async clearIf() {
|
|
@@ -360,9 +382,11 @@ function createNoopPendingWriteDriver() {
|
|
|
360
382
|
async list() {
|
|
361
383
|
return [];
|
|
362
384
|
},
|
|
363
|
-
async set() {
|
|
385
|
+
async set() {
|
|
386
|
+
throw unavailable();
|
|
387
|
+
},
|
|
364
388
|
async setIf() {
|
|
365
|
-
|
|
389
|
+
throw unavailable();
|
|
366
390
|
},
|
|
367
391
|
};
|
|
368
392
|
}
|
|
@@ -402,6 +426,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
|
|
|
402
426
|
return {
|
|
403
427
|
...(typeof record.error === "string" ? { error: record.error } : {}),
|
|
404
428
|
id: record.id,
|
|
429
|
+
...(typeof record.notBefore === "string"
|
|
430
|
+
? { notBefore: record.notBefore }
|
|
431
|
+
: {}),
|
|
405
432
|
payload,
|
|
406
433
|
savedAt: record.savedAt,
|
|
407
434
|
snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
|
|
@@ -430,6 +457,7 @@ function isPendingWriteRecord(value) {
|
|
|
430
457
|
typeof value === "object" &&
|
|
431
458
|
"id" in value &&
|
|
432
459
|
typeof value.id === "string" &&
|
|
460
|
+
(!("notBefore" in value) || typeof value.notBefore === "string") &&
|
|
433
461
|
"savedAt" in value &&
|
|
434
462
|
typeof value.savedAt === "string" &&
|
|
435
463
|
(!("snapshotId" in value) || typeof value.snapshotId === "string") &&
|
package/dist/readCache.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export declare const readCacheSchemaVersion = 1;
|
|
|
3
3
|
export declare const readCacheFreshnessMaxAgeMs = 30000;
|
|
4
4
|
export declare const durableReadCacheFreshnessMaxAgeMs: number;
|
|
5
5
|
export declare const identityReadCacheFreshnessMaxAgeMs: number;
|
|
6
|
-
export declare const browserReadCacheOperationTimeoutMs =
|
|
6
|
+
export declare const browserReadCacheOperationTimeoutMs = 1000;
|
|
7
7
|
export type ReadCacheHit<T> = {
|
|
8
8
|
fromCache: true;
|
|
9
9
|
savedAt: string;
|
package/dist/readCache.js
CHANGED
|
@@ -3,7 +3,9 @@ export const readCacheSchemaVersion = 1;
|
|
|
3
3
|
export const readCacheFreshnessMaxAgeMs = 30_000;
|
|
4
4
|
export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
|
|
5
5
|
export const identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
|
|
6
|
-
|
|
6
|
+
// Cache reads are optional and should yield to the fresh loader quickly when
|
|
7
|
+
// IndexedDB is blocked by a browser/WebView lifecycle or another tab.
|
|
8
|
+
export const browserReadCacheOperationTimeoutMs = 1_000;
|
|
7
9
|
const pendingFreshLoads = new WeakMap();
|
|
8
10
|
const cachePruners = new WeakMap();
|
|
9
11
|
export function createReadCache(driver) {
|
|
@@ -133,8 +135,7 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
|
|
|
133
135
|
*/
|
|
134
136
|
export function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
|
|
135
137
|
const resourceCache = cache.capture?.() ?? cache;
|
|
136
|
-
return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
|
|
137
|
-
Promise.resolve(0);
|
|
138
|
+
return (cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ?? Promise.resolve(0));
|
|
138
139
|
}
|
|
139
140
|
export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
140
141
|
const serializedParams = Object.entries(params)
|
|
@@ -156,7 +157,15 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
156
157
|
if (forceFresh) {
|
|
157
158
|
// Deletion advances the cache version, so a request started before this
|
|
158
159
|
// post-write refresh cannot restore stale data after it finishes.
|
|
159
|
-
|
|
160
|
+
try {
|
|
161
|
+
await resourceCache.delete(key);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.error("[Bayon offline] Unable to invalidate the read cache.", {
|
|
165
|
+
error: normalizeError(error),
|
|
166
|
+
key,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
160
169
|
}
|
|
161
170
|
const version = resourceCache.getVersion(key);
|
|
162
171
|
const cached = await resourceCache.get(key);
|
|
@@ -170,7 +179,15 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
170
179
|
}
|
|
171
180
|
try {
|
|
172
181
|
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
173
|
-
|
|
182
|
+
try {
|
|
183
|
+
await resourceCache.setIfVersion(key, fresh, version);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
187
|
+
error: normalizeError(error),
|
|
188
|
+
key,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
174
191
|
return {
|
|
175
192
|
data: fresh,
|
|
176
193
|
error: null,
|
|
@@ -12,6 +12,7 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
|
|
|
12
12
|
let active = false;
|
|
13
13
|
let retrying = false;
|
|
14
14
|
let pullStartY = null;
|
|
15
|
+
const touchThreshold = pullThreshold === null ? Number.POSITIVE_INFINITY : pullThreshold;
|
|
15
16
|
function retry() {
|
|
16
17
|
if (retrying)
|
|
17
18
|
return;
|
|
@@ -23,7 +24,9 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
|
|
|
23
24
|
}
|
|
24
25
|
catch (error) {
|
|
25
26
|
// A failed navigation must leave the recovery action visible and usable.
|
|
26
|
-
console.error("[Bayon offline] Offline fallback reload failed.", {
|
|
27
|
+
console.error("[Bayon offline] Offline fallback reload failed.", {
|
|
28
|
+
error,
|
|
29
|
+
});
|
|
27
30
|
retrying = false;
|
|
28
31
|
control.disabled = false;
|
|
29
32
|
}
|
|
@@ -36,7 +39,7 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
|
|
|
36
39
|
const touchY = getSingleTouchY(event, "changedTouches");
|
|
37
40
|
if (pullStartY !== null &&
|
|
38
41
|
touchY !== null &&
|
|
39
|
-
touchY - pullStartY >=
|
|
42
|
+
touchY - pullStartY >= touchThreshold) {
|
|
40
43
|
retry();
|
|
41
44
|
}
|
|
42
45
|
pullStartY = null;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createDurableCommandStore = createDurableCommandStore;
|
|
4
|
+
const pendingWrites_1 = require("./pendingWrites");
|
|
5
|
+
/**
|
|
6
|
+
* Hosts provide domain projection and transport; this package only owns the
|
|
7
|
+
* durable command lifecycle. A command becomes visible immediately, but is
|
|
8
|
+
* never sent remotely until IndexedDB has accepted it.
|
|
9
|
+
*/
|
|
10
|
+
function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, reconcile, shouldRetry = () => true, store, }) {
|
|
11
|
+
let baseState = initialState;
|
|
12
|
+
let baseStateGeneration = 0;
|
|
13
|
+
let savedAtMillis = 0;
|
|
14
|
+
let publishGeneration = 0;
|
|
15
|
+
let storedRecords = [];
|
|
16
|
+
let deferredRecoveryTimer = null;
|
|
17
|
+
const provisionalRecords = new Map();
|
|
18
|
+
const localStorageFailures = new Map();
|
|
19
|
+
let snapshot = {
|
|
20
|
+
operations: [],
|
|
21
|
+
state: initialState,
|
|
22
|
+
};
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
const executor = (0, pendingWrites_1.createOrderedPendingWriteExecutor)({
|
|
25
|
+
now,
|
|
26
|
+
scopeKey: "durable-command-store",
|
|
27
|
+
select: isActiveCommand,
|
|
28
|
+
store,
|
|
29
|
+
sync: async (command, { recordId }) => {
|
|
30
|
+
const result = await execute(command, { operationId: recordId });
|
|
31
|
+
// A completed remote write is newer than any read already in flight.
|
|
32
|
+
// Reconciliation is local-only: a projector bug must never retry a
|
|
33
|
+
// command whose remote effect may already have committed.
|
|
34
|
+
baseStateGeneration += 1;
|
|
35
|
+
if (reconcile) {
|
|
36
|
+
try {
|
|
37
|
+
baseState = reconcile(baseState, command, result);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error("[Bayon offline] Unable to reconcile a confirmed durable command.", { error, operationId: recordId });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
function notify() {
|
|
46
|
+
for (const listener of listeners) {
|
|
47
|
+
try {
|
|
48
|
+
listener();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.error("[Bayon offline] Durable-command listener failed.", {
|
|
52
|
+
error,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function classifyStoredError(error) {
|
|
58
|
+
return classifyError(new Error(error ?? "Unable to sync durable command."));
|
|
59
|
+
}
|
|
60
|
+
function isActiveCommand(record) {
|
|
61
|
+
return (record.syncStatus !== "failed" ||
|
|
62
|
+
shouldRetry(classifyStoredError(record.error), record.payload));
|
|
63
|
+
}
|
|
64
|
+
function scheduleDeferredRecovery(records) {
|
|
65
|
+
if (deferredRecoveryTimer) {
|
|
66
|
+
clearTimeout(deferredRecoveryTimer);
|
|
67
|
+
deferredRecoveryTimer = null;
|
|
68
|
+
}
|
|
69
|
+
const currentTime = Date.parse(now());
|
|
70
|
+
const nextDeadline = records.reduce((earliest, record) => {
|
|
71
|
+
const deadline = record.notBefore ? Date.parse(record.notBefore) : NaN;
|
|
72
|
+
if (!Number.isFinite(deadline) || deadline <= currentTime)
|
|
73
|
+
return earliest;
|
|
74
|
+
return earliest === null ? deadline : Math.min(earliest, deadline);
|
|
75
|
+
}, null);
|
|
76
|
+
if (nextDeadline === null)
|
|
77
|
+
return;
|
|
78
|
+
deferredRecoveryTimer = setTimeout(recoverInBackground, Math.max(0, nextDeadline - currentTime));
|
|
79
|
+
}
|
|
80
|
+
function currentRecords() {
|
|
81
|
+
const records = new Map(storedRecords.map((record) => [record.id, record]));
|
|
82
|
+
for (const record of provisionalRecords.values()) {
|
|
83
|
+
records.set(record.id, record);
|
|
84
|
+
}
|
|
85
|
+
return [...records.values()].sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
86
|
+
left.id.localeCompare(right.id));
|
|
87
|
+
}
|
|
88
|
+
function applySnapshot(records) {
|
|
89
|
+
snapshot = {
|
|
90
|
+
operations: [
|
|
91
|
+
...records.map((record) => ({
|
|
92
|
+
command: record.payload,
|
|
93
|
+
...(record.syncStatus === "failed"
|
|
94
|
+
? { error: classifyStoredError(record.error) }
|
|
95
|
+
: {}),
|
|
96
|
+
id: record.id,
|
|
97
|
+
status: record.syncStatus === "failed"
|
|
98
|
+
? "failed"
|
|
99
|
+
: record.syncStatus === "syncing"
|
|
100
|
+
? "syncing"
|
|
101
|
+
: "accepted",
|
|
102
|
+
})),
|
|
103
|
+
...[...localStorageFailures].map(([id, command]) => ({
|
|
104
|
+
command,
|
|
105
|
+
error: "local-storage",
|
|
106
|
+
id,
|
|
107
|
+
status: "failed",
|
|
108
|
+
})),
|
|
109
|
+
],
|
|
110
|
+
state: project(baseState, records.filter(isActiveCommand)),
|
|
111
|
+
};
|
|
112
|
+
scheduleDeferredRecovery(records);
|
|
113
|
+
}
|
|
114
|
+
async function publish() {
|
|
115
|
+
const generation = ++publishGeneration;
|
|
116
|
+
const records = await store.list();
|
|
117
|
+
if (generation !== publishGeneration)
|
|
118
|
+
return;
|
|
119
|
+
storedRecords = records;
|
|
120
|
+
applySnapshot(currentRecords());
|
|
121
|
+
notify();
|
|
122
|
+
}
|
|
123
|
+
async function recover() {
|
|
124
|
+
await executor.recover();
|
|
125
|
+
await publish();
|
|
126
|
+
}
|
|
127
|
+
function recoverInBackground() {
|
|
128
|
+
void recover().catch((error) => {
|
|
129
|
+
console.error("[Bayon offline] Unable to recover durable commands.", {
|
|
130
|
+
error,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const unsubscribe = store.subscribe(() => {
|
|
135
|
+
void publish().catch((error) => {
|
|
136
|
+
console.error("[Bayon offline] Unable to publish durable commands.", {
|
|
137
|
+
error,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
async cancel(operationId) {
|
|
143
|
+
if (localStorageFailures.delete(operationId)) {
|
|
144
|
+
applySnapshot(currentRecords());
|
|
145
|
+
notify();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
const record = await store.get(operationId);
|
|
149
|
+
if (!record ||
|
|
150
|
+
record.syncStatus === "syncing" ||
|
|
151
|
+
!canCancel(record.payload, now())) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const cancelled = await store.clearIfSnapshotId(record.id, record.snapshotId);
|
|
155
|
+
if (cancelled)
|
|
156
|
+
await publish();
|
|
157
|
+
return cancelled;
|
|
158
|
+
},
|
|
159
|
+
close() {
|
|
160
|
+
if (deferredRecoveryTimer)
|
|
161
|
+
clearTimeout(deferredRecoveryTimer);
|
|
162
|
+
publishGeneration += 1;
|
|
163
|
+
unsubscribe();
|
|
164
|
+
},
|
|
165
|
+
dispatch(command, options = {}) {
|
|
166
|
+
const operationId = createId();
|
|
167
|
+
const timestamp = Math.max(Date.parse(now()), savedAtMillis + 1);
|
|
168
|
+
savedAtMillis = timestamp;
|
|
169
|
+
const record = {
|
|
170
|
+
id: operationId,
|
|
171
|
+
...(options.notBefore ? { notBefore: options.notBefore } : {}),
|
|
172
|
+
payload: command,
|
|
173
|
+
savedAt: new Date(timestamp).toISOString(),
|
|
174
|
+
snapshotId: operationId,
|
|
175
|
+
syncStatus: "pending",
|
|
176
|
+
};
|
|
177
|
+
// Product decision: provisional records remain part of the same ordered
|
|
178
|
+
// projection as persisted retryable failures while storage settles.
|
|
179
|
+
provisionalRecords.set(operationId, record);
|
|
180
|
+
localStorageFailures.delete(operationId);
|
|
181
|
+
applySnapshot(currentRecords());
|
|
182
|
+
notify();
|
|
183
|
+
const accepted = store.set(record).then(async () => {
|
|
184
|
+
provisionalRecords.delete(operationId);
|
|
185
|
+
await publish();
|
|
186
|
+
recoverInBackground();
|
|
187
|
+
}, async (error) => {
|
|
188
|
+
provisionalRecords.delete(operationId);
|
|
189
|
+
localStorageFailures.set(operationId, command);
|
|
190
|
+
applySnapshot(currentRecords());
|
|
191
|
+
notify();
|
|
192
|
+
throw error;
|
|
193
|
+
});
|
|
194
|
+
return { accepted, operationId };
|
|
195
|
+
},
|
|
196
|
+
getSnapshot: () => snapshot,
|
|
197
|
+
async recover() {
|
|
198
|
+
await recover();
|
|
199
|
+
},
|
|
200
|
+
async refresh(scope) {
|
|
201
|
+
const generation = ++baseStateGeneration;
|
|
202
|
+
const loaded = await load(scope);
|
|
203
|
+
if (generation !== baseStateGeneration)
|
|
204
|
+
return;
|
|
205
|
+
baseState = loaded;
|
|
206
|
+
await publish();
|
|
207
|
+
},
|
|
208
|
+
subscribe(listener) {
|
|
209
|
+
listeners.add(listener);
|
|
210
|
+
return () => listeners.delete(listener);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function createDurableCommandId() {
|
|
215
|
+
return globalThis.crypto?.randomUUID?.() ?? createFallbackDurableCommandId();
|
|
216
|
+
}
|
|
217
|
+
let fallbackDurableCommandSequence = 0;
|
|
218
|
+
function createFallbackDurableCommandId() {
|
|
219
|
+
fallbackDurableCommandSequence += 1;
|
|
220
|
+
return [
|
|
221
|
+
"command",
|
|
222
|
+
Date.now().toString(36),
|
|
223
|
+
fallbackDurableCommandSequence.toString(36),
|
|
224
|
+
Math.random().toString(36).slice(2),
|
|
225
|
+
].join("-");
|
|
226
|
+
}
|