@bayonai/offline 0.8.0 → 0.10.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 +39 -1
- package/README.md +107 -16
- package/RECOVERY.md +64 -0
- package/dist/browserFallbackRecovery.d.ts +10 -9
- package/dist/browserFallbackRecovery.js +100 -28
- package/dist/browserRecovery.d.ts +2 -2
- package/dist/durableCommandStore.d.ts +1 -1
- package/dist/idbDriver.d.ts +3 -1
- package/dist/idbDriver.js +29 -14
- package/dist/index.d.ts +10 -7
- package/dist/index.js +3 -0
- package/dist/pendingWriteSupersession.d.ts +11 -0
- package/dist/pendingWriteSupersession.js +25 -0
- package/dist/pendingWrites.d.ts +6 -2
- package/dist/pendingWrites.js +45 -11
- package/dist/readBoundary.d.ts +13 -0
- package/dist/readBoundary.js +23 -0
- package/dist/readCache.d.ts +26 -5
- package/dist/readCache.js +113 -19
- package/dist/recoveryPolicy.d.ts +20 -0
- package/dist/recoveryPolicy.js +73 -0
- package/dist-cjs/browserCoordination.d.ts +23 -0
- package/dist-cjs/browserFallbackRecovery.d.ts +33 -0
- package/dist-cjs/browserFallbackRecovery.js +100 -28
- package/dist-cjs/browserRecovery.d.ts +42 -0
- package/dist-cjs/durableCommandStore.d.ts +49 -0
- package/dist-cjs/idbDriver.d.ts +28 -0
- package/dist-cjs/idbDriver.js +29 -14
- package/dist-cjs/index.d.ts +10 -0
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWriteSupersession.d.ts +11 -0
- package/dist-cjs/pendingWriteSupersession.js +28 -0
- package/dist-cjs/pendingWrites.d.ts +179 -0
- package/dist-cjs/pendingWrites.js +45 -11
- package/dist-cjs/readBoundary.d.ts +13 -0
- package/dist-cjs/readBoundary.js +26 -0
- package/dist-cjs/readCache.d.ts +112 -0
- package/dist-cjs/readCache.js +113 -19
- package/dist-cjs/recoveryPolicy.d.ts +20 -0
- package/dist-cjs/recoveryPolicy.js +78 -0
- package/package.json +16 -7
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
export * from "./readCache";
|
|
2
|
-
export * from "./pendingWrites";
|
|
3
|
-
export * from "./browserRecovery";
|
|
4
|
-
export * from "./browserCoordination";
|
|
5
|
-
export * from "./browserFallbackRecovery";
|
|
6
|
-
export * from "./durableCommandStore";
|
|
7
|
-
export { OfflineStorageError } from "./idbDriver";
|
|
1
|
+
export * from "./readCache.js";
|
|
2
|
+
export * from "./pendingWrites.js";
|
|
3
|
+
export * from "./browserRecovery.js";
|
|
4
|
+
export * from "./browserCoordination.js";
|
|
5
|
+
export * from "./browserFallbackRecovery.js";
|
|
6
|
+
export * from "./durableCommandStore.js";
|
|
7
|
+
export { OfflineStorageError } from "./idbDriver.js";
|
|
8
|
+
export * from "./readBoundary.js";
|
|
9
|
+
export * from "./recoveryPolicy.js";
|
|
10
|
+
export * from "./pendingWriteSupersession.js";
|
package/dist/index.js
CHANGED
|
@@ -5,3 +5,6 @@ export * from "./browserCoordination.js";
|
|
|
5
5
|
export * from "./browserFallbackRecovery.js";
|
|
6
6
|
export * from "./durableCommandStore.js";
|
|
7
7
|
export { OfflineStorageError } from "./idbDriver.js";
|
|
8
|
+
export * from "./readBoundary.js";
|
|
9
|
+
export * from "./recoveryPolicy.js";
|
|
10
|
+
export * from "./pendingWriteSupersession.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PendingWriteRecord, PendingWriteStore } from "./pendingWrites.js";
|
|
2
|
+
/**
|
|
3
|
+
* After remote acknowledgment, retire only rejected snapshots superseded by an
|
|
4
|
+
* explicit host-owned account/entity/field key. Never infer keys from payloads.
|
|
5
|
+
*/
|
|
6
|
+
export declare function clearSupersededPendingWriteFailures<Payload>({ acknowledged, getSupersessionKey, isRejected, store, }: {
|
|
7
|
+
acknowledged: PendingWriteRecord<Payload>;
|
|
8
|
+
getSupersessionKey: (record: PendingWriteRecord<Payload>) => string | null;
|
|
9
|
+
isRejected: (record: PendingWriteRecord<Payload>) => boolean;
|
|
10
|
+
store: PendingWriteStore<Payload>;
|
|
11
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* After remote acknowledgment, retire only rejected snapshots superseded by an
|
|
3
|
+
* explicit host-owned account/entity/field key. Never infer keys from payloads.
|
|
4
|
+
*/
|
|
5
|
+
export async function clearSupersededPendingWriteFailures({ acknowledged, getSupersessionKey, isRejected, store, }) {
|
|
6
|
+
const key = getSupersessionKey(acknowledged);
|
|
7
|
+
if (key === null)
|
|
8
|
+
return 0;
|
|
9
|
+
let cleared = 0;
|
|
10
|
+
for (const previous of await store.list()) {
|
|
11
|
+
const older = previous.savedAt.localeCompare(acknowledged.savedAt) ||
|
|
12
|
+
previous.id.localeCompare(acknowledged.id);
|
|
13
|
+
if (previous.id === acknowledged.id ||
|
|
14
|
+
older >= 0 ||
|
|
15
|
+
previous.syncStatus !== "failed" ||
|
|
16
|
+
previous.syncingAt ||
|
|
17
|
+
!isRejected(previous) ||
|
|
18
|
+
getSupersessionKey(previous) !== key)
|
|
19
|
+
continue;
|
|
20
|
+
// Snapshot fencing protects a correction or claim accepted during the scan.
|
|
21
|
+
if (await store.clearIfSnapshotId(previous.id, previous.snapshotId))
|
|
22
|
+
cleared++;
|
|
23
|
+
}
|
|
24
|
+
return cleared;
|
|
25
|
+
}
|
package/dist/pendingWrites.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BrowserKeyValueDriverOptions } from "./idbDriver";
|
|
1
|
+
import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
|
|
2
2
|
export declare const pendingWriteSchemaVersion = 1;
|
|
3
3
|
export declare const pendingWriteOperationTimeoutMs = 2000;
|
|
4
4
|
export declare const pendingWriteSyncLeaseTimeoutMs = 60000;
|
|
@@ -22,10 +22,14 @@ export type PendingWritePayloadNormalizer<Payload = unknown> = (payload: unknown
|
|
|
22
22
|
export type PendingWriteDriver = {
|
|
23
23
|
clear: (key: string) => Promise<void>;
|
|
24
24
|
clearIf: (key: string, predicate: PendingWritePredicate) => Promise<boolean>;
|
|
25
|
-
clearAll
|
|
25
|
+
clearAll?: () => Promise<void>;
|
|
26
26
|
close?: () => Promise<void>;
|
|
27
27
|
get: (key: string) => Promise<unknown | null>;
|
|
28
28
|
list: () => Promise<unknown[]>;
|
|
29
|
+
listEntries?: () => Promise<Array<{
|
|
30
|
+
key: string;
|
|
31
|
+
value: unknown;
|
|
32
|
+
}>>;
|
|
29
33
|
set: (key: string, value: unknown) => Promise<void>;
|
|
30
34
|
setIf: (key: string, value: unknown, predicate: PendingWritePredicate) => Promise<boolean>;
|
|
31
35
|
};
|
package/dist/pendingWrites.js
CHANGED
|
@@ -17,6 +17,29 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
};
|
|
20
|
+
async function listScopedRecords() {
|
|
21
|
+
const readOptions = { normalizeLegacyRecord, normalizePayload };
|
|
22
|
+
const entries = await driver.listEntries?.();
|
|
23
|
+
if (entries) {
|
|
24
|
+
const keyPrefixBoundary = `${keyPrefix}:`;
|
|
25
|
+
return entries
|
|
26
|
+
.filter(({ key }) => key.startsWith(keyPrefixBoundary))
|
|
27
|
+
.map(({ value }) => toPendingWriteEnvelope(value, schemaVersion, readOptions)
|
|
28
|
+
?.record)
|
|
29
|
+
.filter((record) => Boolean(record));
|
|
30
|
+
}
|
|
31
|
+
// Compatibility path for custom drivers created before listEntries was
|
|
32
|
+
// available. Point reads prove that each candidate belongs to this prefix.
|
|
33
|
+
const candidateIds = new Set((await driver.list())
|
|
34
|
+
.map((value) => toPendingWriteEnvelope(value, schemaVersion, readOptions)
|
|
35
|
+
?.record.id)
|
|
36
|
+
.filter((id) => Boolean(id)));
|
|
37
|
+
const records = await Promise.all([...candidateIds].map(async (id) => {
|
|
38
|
+
const value = await driver.get(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
39
|
+
return toPendingWriteEnvelope(value, schemaVersion, readOptions)?.record;
|
|
40
|
+
}));
|
|
41
|
+
return records.filter((record) => Boolean(record));
|
|
42
|
+
}
|
|
20
43
|
return {
|
|
21
44
|
async claimIfSnapshotId(id, snapshotId, record, now) {
|
|
22
45
|
const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
|
|
@@ -37,7 +60,20 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
|
|
|
37
60
|
notify({ id, type: "clear" });
|
|
38
61
|
},
|
|
39
62
|
async clearAll() {
|
|
40
|
-
await driver.
|
|
63
|
+
const entries = await driver.listEntries?.();
|
|
64
|
+
if (entries) {
|
|
65
|
+
const keyPrefixBoundary = `${keyPrefix}:`;
|
|
66
|
+
await Promise.all(entries
|
|
67
|
+
.filter(({ key }) => key.startsWith(keyPrefixBoundary))
|
|
68
|
+
.map(({ key }) => driver.clear(key)));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const records = await listScopedRecords();
|
|
72
|
+
await Promise.all(records.map((record) => driver.clearIf(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
73
|
+
normalizeLegacyRecord,
|
|
74
|
+
normalizePayload,
|
|
75
|
+
})?.record.snapshotId === record.snapshotId)));
|
|
76
|
+
}
|
|
41
77
|
notify({ type: "clearAll" });
|
|
42
78
|
},
|
|
43
79
|
async clearIfSnapshotId(id, snapshotId) {
|
|
@@ -57,13 +93,7 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
|
|
|
57
93
|
return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizeLegacyRecord, normalizePayload })?.record ?? null);
|
|
58
94
|
},
|
|
59
95
|
async list() {
|
|
60
|
-
return (await
|
|
61
|
-
.map((value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
62
|
-
normalizeLegacyRecord,
|
|
63
|
-
normalizePayload,
|
|
64
|
-
})?.record)
|
|
65
|
-
.filter((record) => Boolean(record))
|
|
66
|
-
.sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
96
|
+
return (await listScopedRecords()).sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
67
97
|
left.id.localeCompare(right.id));
|
|
68
98
|
},
|
|
69
99
|
async replaceIfSnapshotId(id, snapshotId, record) {
|
|
@@ -118,6 +148,9 @@ export function createMemoryPendingWriteDriver() {
|
|
|
118
148
|
async list() {
|
|
119
149
|
return [...records.values()];
|
|
120
150
|
},
|
|
151
|
+
async listEntries() {
|
|
152
|
+
return [...records].map(([key, value]) => ({ key, value }));
|
|
153
|
+
},
|
|
121
154
|
async set(key, value) {
|
|
122
155
|
records.set(key, value);
|
|
123
156
|
},
|
|
@@ -137,6 +170,7 @@ export function createBrowserPendingWriteDriver({ databaseName, databaseVersion,
|
|
|
137
170
|
indexedDB,
|
|
138
171
|
operationTimeoutMs,
|
|
139
172
|
storeName,
|
|
173
|
+
readFailureMode: "throw",
|
|
140
174
|
});
|
|
141
175
|
return driver ? toPendingWriteDriver(driver) : null;
|
|
142
176
|
}
|
|
@@ -374,13 +408,12 @@ function createUnavailablePendingWriteDriver() {
|
|
|
374
408
|
async clearIf() {
|
|
375
409
|
return false;
|
|
376
410
|
},
|
|
377
|
-
async clearAll() { },
|
|
378
411
|
async close() { },
|
|
379
412
|
async get() {
|
|
380
|
-
|
|
413
|
+
throw unavailable();
|
|
381
414
|
},
|
|
382
415
|
async list() {
|
|
383
|
-
|
|
416
|
+
throw unavailable();
|
|
384
417
|
},
|
|
385
418
|
async set() {
|
|
386
419
|
throw unavailable();
|
|
@@ -398,6 +431,7 @@ function toPendingWriteDriver(driver) {
|
|
|
398
431
|
close: driver.close,
|
|
399
432
|
get: driver.get,
|
|
400
433
|
list: driver.list,
|
|
434
|
+
listEntries: driver.listEntries,
|
|
401
435
|
set: driver.set,
|
|
402
436
|
setIf: driver.setIf,
|
|
403
437
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ReadBoundaryOptions = {
|
|
2
|
+
modules: Readonly<Record<string, readonly string[]>>;
|
|
3
|
+
roots: readonly string[];
|
|
4
|
+
allowedAdapters: readonly string[];
|
|
5
|
+
isForbidden: (moduleId: string) => boolean;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Host contract: supply every reachable local runtime dependency, including
|
|
9
|
+
* re-exports and dynamic imports. Absent nodes are external leaves. Exact
|
|
10
|
+
* trusted adapter IDs stop traversal; hosts verify their cache policy separately.
|
|
11
|
+
* This test utility deliberately owns no parser, framework, or provider policy.
|
|
12
|
+
*/
|
|
13
|
+
export declare function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }: ReadBoundaryOptions): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host contract: supply every reachable local runtime dependency, including
|
|
3
|
+
* re-exports and dynamic imports. Absent nodes are external leaves. Exact
|
|
4
|
+
* trusted adapter IDs stop traversal; hosts verify their cache policy separately.
|
|
5
|
+
* This test utility deliberately owns no parser, framework, or provider policy.
|
|
6
|
+
*/
|
|
7
|
+
export function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }) {
|
|
8
|
+
const adapters = new Set(allowedAdapters);
|
|
9
|
+
const visited = new Set();
|
|
10
|
+
function visit(id, path) {
|
|
11
|
+
if (adapters.has(id) || visited.has(id))
|
|
12
|
+
return;
|
|
13
|
+
const nextPath = [...path, id];
|
|
14
|
+
if (isForbidden(id)) {
|
|
15
|
+
throw new Error(`Read boundary violation: ${nextPath.join(" -> ")}`);
|
|
16
|
+
}
|
|
17
|
+
visited.add(id);
|
|
18
|
+
for (const dependency of modules[id] ?? [])
|
|
19
|
+
visit(dependency, nextPath);
|
|
20
|
+
}
|
|
21
|
+
for (const root of roots)
|
|
22
|
+
visit(root, []);
|
|
23
|
+
}
|
package/dist/readCache.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BrowserKeyValueDriverOptions } from "./idbDriver";
|
|
1
|
+
import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
|
|
2
2
|
export declare const readCacheSchemaVersion = 1;
|
|
3
3
|
export declare const readCacheFreshnessMaxAgeMs = 30000;
|
|
4
4
|
export declare const durableReadCacheFreshnessMaxAgeMs: number;
|
|
@@ -29,21 +29,36 @@ export type ReadCache = {
|
|
|
29
29
|
clear: () => Promise<void>;
|
|
30
30
|
close: () => Promise<void>;
|
|
31
31
|
delete: (key: string) => Promise<void>;
|
|
32
|
+
/** Advances a key's generation without deleting its retained value. */
|
|
33
|
+
invalidate?: (key: string) => void;
|
|
32
34
|
get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
|
|
33
35
|
getVersion: (key: string) => string;
|
|
34
36
|
set: <T>(key: string, value: T) => Promise<void>;
|
|
35
37
|
setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
|
|
36
38
|
};
|
|
39
|
+
export type ReadCachePolicy = {
|
|
40
|
+
/** Time that a cached value can suppress a remote read. */
|
|
41
|
+
freshForMs: number;
|
|
42
|
+
/** Maximum age at which cached data remains eligible as an offline fallback. */
|
|
43
|
+
retainForMs: number;
|
|
44
|
+
/** Behavior when revalidation fails while retained data is available. */
|
|
45
|
+
refreshFailure: "use-retained" | "throw";
|
|
46
|
+
};
|
|
47
|
+
export type ReadCacheStatus = "fresh" | "stale" | "miss";
|
|
48
|
+
/** Payload-free cache lifecycle events suitable for host-owned telemetry. */
|
|
49
|
+
export type ReadCacheOutcome = "fresh-hit" | "stale-hit" | "cache-miss" | "forced-refresh" | "loader-success" | "loader-failure";
|
|
37
50
|
export type ReadThroughCacheResult<T> = {
|
|
38
51
|
data: T;
|
|
39
52
|
error: Error | null;
|
|
40
53
|
fromCache: boolean;
|
|
41
54
|
stale: boolean;
|
|
55
|
+
status: ReadCacheStatus;
|
|
42
56
|
};
|
|
43
57
|
export type ReadCacheResourceUpdate<T> = {
|
|
44
58
|
data: T;
|
|
45
59
|
error?: Error;
|
|
46
60
|
fromCache: boolean;
|
|
61
|
+
status: ReadCacheStatus;
|
|
47
62
|
};
|
|
48
63
|
export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
|
|
49
64
|
databaseName: string;
|
|
@@ -69,22 +84,28 @@ export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
|
|
|
69
84
|
now?: () => number;
|
|
70
85
|
}): Promise<number>;
|
|
71
86
|
export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
|
|
72
|
-
export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, }: {
|
|
87
|
+
export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, }: {
|
|
73
88
|
cache: ReadCache;
|
|
74
89
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
90
|
+
* Revalidates after advancing the key generation, retaining the prior value
|
|
91
|
+
* as a possible fallback if refresh fails.
|
|
77
92
|
*/
|
|
78
93
|
forceFresh?: boolean;
|
|
79
94
|
key: string;
|
|
80
95
|
loadFresh: () => Promise<T>;
|
|
81
96
|
maxAgeMs?: number;
|
|
97
|
+
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
98
|
+
policy?: ReadCachePolicy;
|
|
82
99
|
}): Promise<ReadThroughCacheResult<T>>;
|
|
83
|
-
export declare function loadReadCacheResource<T>({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache, }: {
|
|
100
|
+
export declare function loadReadCacheResource<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, }: {
|
|
84
101
|
cache: ReadCache;
|
|
102
|
+
/** Revalidate while retaining eligible data as fallback on loader failure. */
|
|
103
|
+
forceFresh?: boolean;
|
|
85
104
|
key: string;
|
|
86
105
|
loadFresh: () => Promise<T>;
|
|
87
106
|
maxAgeMs?: number;
|
|
107
|
+
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
108
|
+
policy?: ReadCachePolicy;
|
|
88
109
|
onCacheWriteError?: (error: Error) => void;
|
|
89
110
|
onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
|
|
90
111
|
skipCache?: boolean;
|
package/dist/readCache.js
CHANGED
|
@@ -16,6 +16,10 @@ export function createReadCache(driver) {
|
|
|
16
16
|
function getVersion(key) {
|
|
17
17
|
return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
|
|
18
18
|
}
|
|
19
|
+
function invalidate(key) {
|
|
20
|
+
pendingLoads.delete(key);
|
|
21
|
+
keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
|
|
22
|
+
}
|
|
19
23
|
function enqueue(operation) {
|
|
20
24
|
const next = operationQueue.then(operation, operation);
|
|
21
25
|
operationQueue = next.then(() => undefined, () => undefined);
|
|
@@ -32,10 +36,10 @@ export function createReadCache(driver) {
|
|
|
32
36
|
await enqueue(async () => driver.close?.());
|
|
33
37
|
},
|
|
34
38
|
async delete(key) {
|
|
35
|
-
|
|
36
|
-
keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
|
|
39
|
+
invalidate(key);
|
|
37
40
|
await enqueue(() => driver.delete(key));
|
|
38
41
|
},
|
|
42
|
+
invalidate,
|
|
39
43
|
async get(key) {
|
|
40
44
|
const envelope = toReadCacheEnvelope(await driver.get(key));
|
|
41
45
|
return envelope
|
|
@@ -152,13 +156,21 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
|
152
156
|
.filter(Boolean)
|
|
153
157
|
.join(":");
|
|
154
158
|
}
|
|
155
|
-
export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
|
|
159
|
+
export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
|
|
156
160
|
const resourceCache = cache.capture?.() ?? cache;
|
|
161
|
+
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
162
|
+
const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
|
|
157
163
|
if (forceFresh) {
|
|
158
|
-
|
|
159
|
-
//
|
|
164
|
+
reportReadCacheOutcome(onOutcome, "forced-refresh");
|
|
165
|
+
// Built-in caches advance generation without deleting fallback data.
|
|
166
|
+
// Legacy custom adapters retain their old delete behavior.
|
|
160
167
|
try {
|
|
161
|
-
|
|
168
|
+
if (resourceCache.invalidate) {
|
|
169
|
+
resourceCache.invalidate(key);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
await resourceCache.delete(key);
|
|
173
|
+
}
|
|
162
174
|
}
|
|
163
175
|
catch (error) {
|
|
164
176
|
console.error("[Bayon offline] Unable to invalidate the read cache.", {
|
|
@@ -168,17 +180,22 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
168
180
|
}
|
|
169
181
|
}
|
|
170
182
|
const version = resourceCache.getVersion(key);
|
|
171
|
-
const cached = await resourceCache.get(key);
|
|
172
|
-
|
|
183
|
+
const cached = forcedFallback ?? (await resourceCache.get(key));
|
|
184
|
+
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
|
|
185
|
+
if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
|
|
186
|
+
reportReadCacheOutcome(onOutcome, "fresh-hit");
|
|
173
187
|
return {
|
|
174
188
|
data: cached.value,
|
|
175
189
|
error: null,
|
|
176
190
|
fromCache: true,
|
|
177
191
|
stale: false,
|
|
192
|
+
status: "fresh",
|
|
178
193
|
};
|
|
179
194
|
}
|
|
195
|
+
reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
|
|
180
196
|
try {
|
|
181
197
|
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
198
|
+
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
182
199
|
try {
|
|
183
200
|
await resourceCache.setIfVersion(key, fresh, version);
|
|
184
201
|
}
|
|
@@ -193,33 +210,74 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
193
210
|
error: null,
|
|
194
211
|
fromCache: false,
|
|
195
212
|
stale: false,
|
|
213
|
+
status: retained ? "fresh" : "miss",
|
|
196
214
|
};
|
|
197
215
|
}
|
|
198
216
|
catch (error) {
|
|
199
|
-
|
|
217
|
+
reportReadCacheOutcome(onOutcome, "loader-failure");
|
|
218
|
+
if (retained && resolvedPolicy.refreshFailure === "use-retained") {
|
|
200
219
|
return {
|
|
201
220
|
data: cached.value,
|
|
202
221
|
error: normalizeError(error),
|
|
203
222
|
fromCache: true,
|
|
204
223
|
stale: true,
|
|
224
|
+
status: "stale",
|
|
205
225
|
};
|
|
206
226
|
}
|
|
207
227
|
throw error;
|
|
208
228
|
}
|
|
209
229
|
}
|
|
210
|
-
export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
230
|
+
export async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
211
231
|
const resourceCache = cache.capture?.() ?? cache;
|
|
232
|
+
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
233
|
+
const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
|
|
234
|
+
if (forceFresh) {
|
|
235
|
+
reportReadCacheOutcome(onOutcome, "forced-refresh");
|
|
236
|
+
try {
|
|
237
|
+
if (resourceCache.invalidate) {
|
|
238
|
+
resourceCache.invalidate(key);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
await resourceCache.delete(key);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
console.error("[Bayon offline] Unable to invalidate the read cache.", {
|
|
246
|
+
error: normalizeError(error),
|
|
247
|
+
key,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
212
251
|
const version = resourceCache.getVersion(key);
|
|
213
|
-
const cached = skipCache
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
252
|
+
const cached = skipCache
|
|
253
|
+
? null
|
|
254
|
+
: (forcedFallback ?? (await resourceCache.get(key)));
|
|
255
|
+
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
|
|
256
|
+
if (retained) {
|
|
257
|
+
const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
|
|
258
|
+
reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
|
|
259
|
+
onUpdate({
|
|
260
|
+
data: cached.value,
|
|
261
|
+
fromCache: true,
|
|
262
|
+
status: fresh && !forceFresh ? "fresh" : "stale",
|
|
263
|
+
});
|
|
264
|
+
if (fresh && !forceFresh) {
|
|
217
265
|
return cached.value;
|
|
218
266
|
}
|
|
219
267
|
}
|
|
268
|
+
else {
|
|
269
|
+
reportReadCacheOutcome(onOutcome, "cache-miss");
|
|
270
|
+
}
|
|
220
271
|
try {
|
|
221
272
|
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
222
|
-
|
|
273
|
+
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
274
|
+
if (resourceCache.getVersion(key) === version) {
|
|
275
|
+
onUpdate({
|
|
276
|
+
data: fresh,
|
|
277
|
+
fromCache: false,
|
|
278
|
+
status: retained ? "fresh" : "miss",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
223
281
|
void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
|
|
224
282
|
const normalizedError = normalizeError(error);
|
|
225
283
|
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
@@ -231,17 +289,32 @@ export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, o
|
|
|
231
289
|
return fresh;
|
|
232
290
|
}
|
|
233
291
|
catch (error) {
|
|
234
|
-
|
|
292
|
+
reportReadCacheOutcome(onOutcome, "loader-failure");
|
|
293
|
+
if (!retained || resolvedPolicy.refreshFailure === "throw") {
|
|
235
294
|
throw error;
|
|
236
295
|
}
|
|
237
296
|
onUpdate({
|
|
238
297
|
data: cached.value,
|
|
239
298
|
error: normalizeError(error),
|
|
240
299
|
fromCache: true,
|
|
300
|
+
status: "stale",
|
|
241
301
|
});
|
|
242
302
|
return cached.value;
|
|
243
303
|
}
|
|
244
304
|
}
|
|
305
|
+
function reportReadCacheOutcome(onOutcome, outcome) {
|
|
306
|
+
if (!onOutcome)
|
|
307
|
+
return;
|
|
308
|
+
try {
|
|
309
|
+
onOutcome(outcome);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
console.error("[Bayon offline] Read-cache outcome callback failed.", {
|
|
313
|
+
error: normalizeError(error),
|
|
314
|
+
outcome,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
245
318
|
function createNoopReadCacheDriver() {
|
|
246
319
|
return {
|
|
247
320
|
async clear() { },
|
|
@@ -256,12 +329,33 @@ function createNoopReadCacheDriver() {
|
|
|
256
329
|
async set() { },
|
|
257
330
|
};
|
|
258
331
|
}
|
|
259
|
-
function isCacheFresh(savedAt,
|
|
260
|
-
if (
|
|
332
|
+
function isCacheFresh(savedAt, policy) {
|
|
333
|
+
if (policy.freshForMs <= 0) {
|
|
261
334
|
return false;
|
|
262
335
|
}
|
|
263
336
|
const savedTime = Date.parse(savedAt);
|
|
264
|
-
return Number.isFinite(savedTime) && Date.now() - savedTime <=
|
|
337
|
+
return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.freshForMs);
|
|
338
|
+
}
|
|
339
|
+
function resolvePolicy(policy, maxAgeMs) {
|
|
340
|
+
if (policy) {
|
|
341
|
+
if (!Number.isFinite(policy.freshForMs) ||
|
|
342
|
+
policy.freshForMs < 0 ||
|
|
343
|
+
!Number.isFinite(policy.retainForMs) ||
|
|
344
|
+
policy.retainForMs < policy.freshForMs ||
|
|
345
|
+
!["use-retained", "throw"].includes(policy.refreshFailure)) {
|
|
346
|
+
throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
|
|
347
|
+
}
|
|
348
|
+
return policy;
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
freshForMs: maxAgeMs ?? 0,
|
|
352
|
+
retainForMs: Number.POSITIVE_INFINITY,
|
|
353
|
+
refreshFailure: "use-retained",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function isCacheRetained(savedAt, policy) {
|
|
357
|
+
const savedTime = Date.parse(savedAt);
|
|
358
|
+
return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.retainForMs);
|
|
265
359
|
}
|
|
266
360
|
async function loadSharedFreshResource(cache, key, loadFresh) {
|
|
267
361
|
const pendingLoads = getPendingFreshLoads(cache);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Shared product decision: recovery is application work, never a retry prompt. */
|
|
2
|
+
export declare const offlineRecoveryCopy: {
|
|
3
|
+
readonly reload: "Reload";
|
|
4
|
+
readonly reloading: "Reconnecting…";
|
|
5
|
+
readonly preservedChanges: "Some changes could not be saved. Your edits are preserved.";
|
|
6
|
+
};
|
|
7
|
+
export type PendingChangesGuidanceState = {
|
|
8
|
+
hasReadyChanges: boolean;
|
|
9
|
+
hasDrafts: boolean;
|
|
10
|
+
hasErrors: boolean;
|
|
11
|
+
isOffline: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** Hosts project domain records to flags; payloads never enter shared guidance. */
|
|
14
|
+
export declare function getPendingChangesGuidance({ hasReadyChanges, hasDrafts, hasErrors, isOffline, }: PendingChangesGuidanceState): string;
|
|
15
|
+
/**
|
|
16
|
+
* Recognize known transient transport/service failures without a provider SDK.
|
|
17
|
+
* Hosts still own domain validation, idempotency, account scope and replay selection.
|
|
18
|
+
* Unknown failures are not permission to blindly repeat a mutation.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isTransientOfflineError(error: unknown): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Shared product decision: recovery is application work, never a retry prompt. */
|
|
2
|
+
export const offlineRecoveryCopy = {
|
|
3
|
+
reload: "Reload",
|
|
4
|
+
reloading: "Reconnecting…",
|
|
5
|
+
preservedChanges: "Some changes could not be saved. Your edits are preserved.",
|
|
6
|
+
};
|
|
7
|
+
/** Hosts project domain records to flags; payloads never enter shared guidance. */
|
|
8
|
+
export function getPendingChangesGuidance({ hasReadyChanges, hasDrafts, hasErrors, isOffline, }) {
|
|
9
|
+
if (!hasReadyChanges)
|
|
10
|
+
return hasDrafts
|
|
11
|
+
? "Finish editing these drafts to save them."
|
|
12
|
+
: offlineRecoveryCopy.preservedChanges;
|
|
13
|
+
if (!hasDrafts && !hasErrors)
|
|
14
|
+
return isOffline
|
|
15
|
+
? "We’ll sync when you’re back online."
|
|
16
|
+
: "We’ll sync automatically.";
|
|
17
|
+
const sync = isOffline
|
|
18
|
+
? "Changes ready to send will sync when you’re back online."
|
|
19
|
+
: "Changes ready to send will sync automatically.";
|
|
20
|
+
return hasDrafts ? `${sync} Finish editing drafts to save them.` : sync;
|
|
21
|
+
}
|
|
22
|
+
const terminalCodes = new Set([
|
|
23
|
+
"unauthenticated",
|
|
24
|
+
"permission-denied",
|
|
25
|
+
"resource-exhausted",
|
|
26
|
+
"invalid-argument",
|
|
27
|
+
"failed-precondition",
|
|
28
|
+
"not-found",
|
|
29
|
+
"already-exists",
|
|
30
|
+
]);
|
|
31
|
+
const transientCodes = new Set([
|
|
32
|
+
"unavailable",
|
|
33
|
+
"deadline-exceeded",
|
|
34
|
+
"cancelled",
|
|
35
|
+
"network-request-failed",
|
|
36
|
+
"internal",
|
|
37
|
+
"aborted",
|
|
38
|
+
]);
|
|
39
|
+
// Exact transport diagnostics are safe fallbacks only when no structured code
|
|
40
|
+
// exists. Domain messages mentioning a network/offline setting are not signals.
|
|
41
|
+
const connectivityMessages = new Set([
|
|
42
|
+
"failed to fetch",
|
|
43
|
+
"fetch failed",
|
|
44
|
+
"network request failed",
|
|
45
|
+
"load failed",
|
|
46
|
+
"offline",
|
|
47
|
+
"network unavailable",
|
|
48
|
+
"failed to get documents from server. these documents may exist in the local cache",
|
|
49
|
+
"failed to get document because the client is offline",
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* Recognize known transient transport/service failures without a provider SDK.
|
|
53
|
+
* Hosts still own domain validation, idempotency, account scope and replay selection.
|
|
54
|
+
* Unknown failures are not permission to blindly repeat a mutation.
|
|
55
|
+
*/
|
|
56
|
+
export function isTransientOfflineError(error) {
|
|
57
|
+
const record = error && typeof error === "object" ? error : {};
|
|
58
|
+
const rawCode = "code" in record ? record.code : undefined;
|
|
59
|
+
const code = typeof rawCode === "string" ? rawCode.toLowerCase().split("/").at(-1) : "";
|
|
60
|
+
if (terminalCodes.has(code))
|
|
61
|
+
return false;
|
|
62
|
+
if (transientCodes.has(code))
|
|
63
|
+
return true;
|
|
64
|
+
if (code)
|
|
65
|
+
return false;
|
|
66
|
+
const rawMessage = typeof error === "string"
|
|
67
|
+
? error
|
|
68
|
+
: "message" in record
|
|
69
|
+
? record.message
|
|
70
|
+
: undefined;
|
|
71
|
+
const message = typeof rawMessage === "string" ? rawMessage.toLowerCase() : "";
|
|
72
|
+
return connectivityMessages.has(message.trim().replace(/[.!]$/, ""));
|
|
73
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type BrowserRecoveryMessageChannel = {
|
|
2
|
+
addEventListener: (type: "message", listener: () => void) => void;
|
|
3
|
+
close: () => void;
|
|
4
|
+
postMessage: (message: "pending-write-recovery") => void;
|
|
5
|
+
removeEventListener: (type: "message", listener: () => void) => void;
|
|
6
|
+
};
|
|
7
|
+
export type BrowserRecoveryLockManager = {
|
|
8
|
+
request: <T>(name: string, options: {
|
|
9
|
+
ifAvailable: true;
|
|
10
|
+
}, callback: (lock: unknown | null) => Promise<T | null>) => Promise<T | null>;
|
|
11
|
+
};
|
|
12
|
+
export type BrowserPendingWriteRecoveryCoordination = {
|
|
13
|
+
close: () => void;
|
|
14
|
+
notify: () => void;
|
|
15
|
+
runExclusive: <T>(attempt: () => Promise<T>) => Promise<T | null>;
|
|
16
|
+
subscribe: (listener: () => void) => () => void;
|
|
17
|
+
};
|
|
18
|
+
export declare function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel, locks, }: {
|
|
19
|
+
channel?: BrowserRecoveryMessageChannel | null;
|
|
20
|
+
channelName: string;
|
|
21
|
+
leaseName: string;
|
|
22
|
+
locks?: BrowserRecoveryLockManager | null;
|
|
23
|
+
}): BrowserPendingWriteRecoveryCoordination;
|