@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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
|
|
2
|
+
export declare const pendingWriteSchemaVersion = 1;
|
|
3
|
+
export declare const pendingWriteOperationTimeoutMs = 2000;
|
|
4
|
+
export declare const pendingWriteSyncLeaseTimeoutMs = 60000;
|
|
5
|
+
export type PendingWriteStatus = "pending" | "syncing" | "failed";
|
|
6
|
+
export type PendingWriteRecord<Payload = unknown> = {
|
|
7
|
+
error?: string;
|
|
8
|
+
id: string;
|
|
9
|
+
/** Do not execute this accepted command before this ISO timestamp. */
|
|
10
|
+
notBefore?: string;
|
|
11
|
+
payload: Payload;
|
|
12
|
+
savedAt: string;
|
|
13
|
+
snapshotId: string;
|
|
14
|
+
/** Timestamp of the active remote-sync claim, absent when not owned. */
|
|
15
|
+
syncingAt?: string;
|
|
16
|
+
syncStatus: PendingWriteStatus;
|
|
17
|
+
};
|
|
18
|
+
export type PendingWriteRecordInput<Payload = unknown> = Omit<PendingWriteRecord<Payload>, "snapshotId"> & {
|
|
19
|
+
snapshotId?: string;
|
|
20
|
+
};
|
|
21
|
+
export type PendingWritePayloadNormalizer<Payload = unknown> = (payload: unknown) => Payload | null;
|
|
22
|
+
export type PendingWriteDriver = {
|
|
23
|
+
clear: (key: string) => Promise<void>;
|
|
24
|
+
clearIf: (key: string, predicate: PendingWritePredicate) => Promise<boolean>;
|
|
25
|
+
clearAll?: () => Promise<void>;
|
|
26
|
+
close?: () => Promise<void>;
|
|
27
|
+
get: (key: string) => Promise<unknown | null>;
|
|
28
|
+
list: () => Promise<unknown[]>;
|
|
29
|
+
listEntries?: () => Promise<Array<{
|
|
30
|
+
key: string;
|
|
31
|
+
value: unknown;
|
|
32
|
+
}>>;
|
|
33
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
34
|
+
setIf: (key: string, value: unknown, predicate: PendingWritePredicate) => Promise<boolean>;
|
|
35
|
+
};
|
|
36
|
+
export type PendingWritePredicate = (value: unknown | null) => boolean;
|
|
37
|
+
export type PendingWriteStoreChange<Payload = unknown> = {
|
|
38
|
+
record: PendingWriteRecord<Payload>;
|
|
39
|
+
type: "set";
|
|
40
|
+
} | {
|
|
41
|
+
id: string;
|
|
42
|
+
type: "clear";
|
|
43
|
+
} | {
|
|
44
|
+
type: "clearAll";
|
|
45
|
+
} | {
|
|
46
|
+
record: PendingWriteRecord<Payload>;
|
|
47
|
+
type: "replace";
|
|
48
|
+
};
|
|
49
|
+
export type PendingWriteStoreChangeListener<Payload = unknown> = (change: PendingWriteStoreChange<Payload>) => void;
|
|
50
|
+
export type PendingWriteStore<Payload = unknown> = {
|
|
51
|
+
claimIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>, now: string) => Promise<boolean>;
|
|
52
|
+
clear: (id: string) => Promise<void>;
|
|
53
|
+
clearAll: () => Promise<void>;
|
|
54
|
+
clearIfSnapshotId: (id: string, snapshotId: string) => Promise<boolean>;
|
|
55
|
+
close: () => Promise<void>;
|
|
56
|
+
get: (id: string) => Promise<PendingWriteRecord<Payload> | null>;
|
|
57
|
+
list: () => Promise<Array<PendingWriteRecord<Payload>>>;
|
|
58
|
+
replaceIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>) => Promise<boolean>;
|
|
59
|
+
set: (record: PendingWriteRecordInput<Payload>) => Promise<PendingWriteRecord<Payload>>;
|
|
60
|
+
/**
|
|
61
|
+
* Observes changes made through this in-memory store instance. It deliberately
|
|
62
|
+
* does not claim cross-tab/process delivery.
|
|
63
|
+
*/
|
|
64
|
+
subscribe: (listener: PendingWriteStoreChangeListener<Payload>) => () => void;
|
|
65
|
+
};
|
|
66
|
+
export type PendingWriteStoreOptions<Payload = unknown> = {
|
|
67
|
+
keyPrefix: string;
|
|
68
|
+
normalizeLegacyRecord?: PendingWritePayloadNormalizer<PendingWriteRecordInput<Payload>>;
|
|
69
|
+
normalizePayload?: PendingWritePayloadNormalizer<Payload>;
|
|
70
|
+
schemaVersion?: number;
|
|
71
|
+
};
|
|
72
|
+
export type BrowserPendingWriteStoreOptions<Payload = unknown> = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & PendingWriteStoreOptions<Payload> & {
|
|
73
|
+
databaseName: string;
|
|
74
|
+
operationTimeoutMs?: number;
|
|
75
|
+
storeName: string;
|
|
76
|
+
};
|
|
77
|
+
export type PendingWriteKeyOptions = {
|
|
78
|
+
id: string;
|
|
79
|
+
prefix: string;
|
|
80
|
+
};
|
|
81
|
+
export type SyncPendingWriteResult = {
|
|
82
|
+
status: "idle";
|
|
83
|
+
} | {
|
|
84
|
+
status: "synced";
|
|
85
|
+
} | {
|
|
86
|
+
status: "superseded";
|
|
87
|
+
} | {
|
|
88
|
+
error: string;
|
|
89
|
+
failure: unknown;
|
|
90
|
+
status: "failed";
|
|
91
|
+
};
|
|
92
|
+
export type SyncPendingWritesResult = {
|
|
93
|
+
attempted: number;
|
|
94
|
+
failed: number;
|
|
95
|
+
idle: number;
|
|
96
|
+
/** Records still selected after this attempt, including newer superseding writes. */
|
|
97
|
+
remaining: number;
|
|
98
|
+
superseded: number;
|
|
99
|
+
synced: number;
|
|
100
|
+
};
|
|
101
|
+
export type PendingWriteSyncContext = {
|
|
102
|
+
/** Stable durable-record identity; safe to use as a remote idempotency key. */
|
|
103
|
+
recordId: string;
|
|
104
|
+
};
|
|
105
|
+
export type PendingWriteSync<Payload, Result = unknown> = (payload: Payload, context: PendingWriteSyncContext) => Promise<Result>;
|
|
106
|
+
export type RunPendingWriteResult<TResult, TQueued> = {
|
|
107
|
+
result: TResult;
|
|
108
|
+
status: "synced";
|
|
109
|
+
} | {
|
|
110
|
+
queued: TQueued;
|
|
111
|
+
status: "queued";
|
|
112
|
+
};
|
|
113
|
+
export type PendingWriteRunOptions<TResult, TQueued> = {
|
|
114
|
+
enqueue: () => Promise<TQueued>;
|
|
115
|
+
isOnline: () => boolean;
|
|
116
|
+
shouldQueue: (error: unknown) => boolean;
|
|
117
|
+
write: () => Promise<TResult>;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Serializes recovery and new writes for one caller-defined scope.
|
|
121
|
+
*
|
|
122
|
+
* A queued write that cannot be replayed stays ahead of later writes. This
|
|
123
|
+
* preserves remote write order; callers retain ownership of scope selection
|
|
124
|
+
* and idempotent remote mutation semantics.
|
|
125
|
+
*/
|
|
126
|
+
export type OrderedPendingWriteExecutor<Payload> = {
|
|
127
|
+
recover: () => Promise<SyncPendingWritesResult>;
|
|
128
|
+
run: <TResult, TQueued>(options: PendingWriteRunOptions<TResult, TQueued>) => Promise<RunPendingWriteResult<TResult, TQueued>>;
|
|
129
|
+
};
|
|
130
|
+
export type PendingWriteSummary = {
|
|
131
|
+
failedCount: number;
|
|
132
|
+
pendingCount: number;
|
|
133
|
+
syncingCount: number;
|
|
134
|
+
totalCount: number;
|
|
135
|
+
};
|
|
136
|
+
export declare function createPendingWriteStore<Payload = unknown>(driver: PendingWriteDriver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion, }: PendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
|
|
137
|
+
export declare function createMemoryPendingWriteDriver(): PendingWriteDriver;
|
|
138
|
+
export declare function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: Omit<BrowserPendingWriteStoreOptions, "keyPrefix" | "normalizePayload" | "schemaVersion">): PendingWriteDriver | null;
|
|
139
|
+
export declare function createBrowserPendingWriteStore<Payload = unknown>({ databaseName, databaseVersion, indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs, schemaVersion, storeName, }: BrowserPendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
|
|
140
|
+
export declare function createPendingWriteKey({ id, prefix }: PendingWriteKeyOptions): string;
|
|
141
|
+
export declare function createPendingWriteSnapshotId(): string;
|
|
142
|
+
export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
|
|
143
|
+
id: string;
|
|
144
|
+
now?: () => string;
|
|
145
|
+
store: PendingWriteStore<Payload>;
|
|
146
|
+
sync: PendingWriteSync<Payload>;
|
|
147
|
+
}): Promise<SyncPendingWriteResult>;
|
|
148
|
+
/**
|
|
149
|
+
* Replays selected writes oldest first and coalesces overlapping runs for the
|
|
150
|
+
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
151
|
+
* mutation semantics.
|
|
152
|
+
*/
|
|
153
|
+
export declare function syncPendingWrites<Payload>({ now, scopeKey, select, stopOnFailure, store, sync, }: {
|
|
154
|
+
now?: () => string;
|
|
155
|
+
scopeKey?: string;
|
|
156
|
+
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
157
|
+
/** Preserve caller ordering by leaving later records for a future retry. */
|
|
158
|
+
stopOnFailure?: boolean;
|
|
159
|
+
store: PendingWriteStore<Payload>;
|
|
160
|
+
sync: PendingWriteSync<Payload>;
|
|
161
|
+
}): Promise<SyncPendingWritesResult>;
|
|
162
|
+
/**
|
|
163
|
+
* Attempts a remote write now and persists a caller-created durable fallback
|
|
164
|
+
* only when offline or when the supplied retry classifier accepts the error.
|
|
165
|
+
*/
|
|
166
|
+
export declare function runPendingWrite<TResult, TQueued>({ enqueue, isOnline, shouldQueue, write, }: PendingWriteRunOptions<TResult, TQueued>): Promise<RunPendingWriteResult<TResult, TQueued>>;
|
|
167
|
+
export declare function summarizePendingWrites<Payload>(records: Array<PendingWriteRecord<Payload>>): PendingWriteSummary;
|
|
168
|
+
/**
|
|
169
|
+
* Creates a serial execution lane for a durable scope such as one user or
|
|
170
|
+
* workspace. Use its `recover` method for background replay and `run` for new
|
|
171
|
+
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
172
|
+
*/
|
|
173
|
+
export declare function createOrderedPendingWriteExecutor<Payload>({ now, scopeKey, select, store, sync, }: {
|
|
174
|
+
now?: () => string;
|
|
175
|
+
scopeKey?: string;
|
|
176
|
+
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
177
|
+
store: PendingWriteStore<Payload>;
|
|
178
|
+
sync: PendingWriteSync<Payload>;
|
|
179
|
+
}): OrderedPendingWriteExecutor<Payload>;
|
|
@@ -31,6 +31,29 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
|
+
async function listScopedRecords() {
|
|
35
|
+
const readOptions = { normalizeLegacyRecord, normalizePayload };
|
|
36
|
+
const entries = await driver.listEntries?.();
|
|
37
|
+
if (entries) {
|
|
38
|
+
const keyPrefixBoundary = `${keyPrefix}:`;
|
|
39
|
+
return entries
|
|
40
|
+
.filter(({ key }) => key.startsWith(keyPrefixBoundary))
|
|
41
|
+
.map(({ value }) => toPendingWriteEnvelope(value, schemaVersion, readOptions)
|
|
42
|
+
?.record)
|
|
43
|
+
.filter((record) => Boolean(record));
|
|
44
|
+
}
|
|
45
|
+
// Compatibility path for custom drivers created before listEntries was
|
|
46
|
+
// available. Point reads prove that each candidate belongs to this prefix.
|
|
47
|
+
const candidateIds = new Set((await driver.list())
|
|
48
|
+
.map((value) => toPendingWriteEnvelope(value, schemaVersion, readOptions)
|
|
49
|
+
?.record.id)
|
|
50
|
+
.filter((id) => Boolean(id)));
|
|
51
|
+
const records = await Promise.all([...candidateIds].map(async (id) => {
|
|
52
|
+
const value = await driver.get(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
53
|
+
return toPendingWriteEnvelope(value, schemaVersion, readOptions)?.record;
|
|
54
|
+
}));
|
|
55
|
+
return records.filter((record) => Boolean(record));
|
|
56
|
+
}
|
|
34
57
|
return {
|
|
35
58
|
async claimIfSnapshotId(id, snapshotId, record, now) {
|
|
36
59
|
const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
|
|
@@ -51,7 +74,20 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
51
74
|
notify({ id, type: "clear" });
|
|
52
75
|
},
|
|
53
76
|
async clearAll() {
|
|
54
|
-
await driver.
|
|
77
|
+
const entries = await driver.listEntries?.();
|
|
78
|
+
if (entries) {
|
|
79
|
+
const keyPrefixBoundary = `${keyPrefix}:`;
|
|
80
|
+
await Promise.all(entries
|
|
81
|
+
.filter(({ key }) => key.startsWith(keyPrefixBoundary))
|
|
82
|
+
.map(({ key }) => driver.clear(key)));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const records = await listScopedRecords();
|
|
86
|
+
await Promise.all(records.map((record) => driver.clearIf(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
87
|
+
normalizeLegacyRecord,
|
|
88
|
+
normalizePayload,
|
|
89
|
+
})?.record.snapshotId === record.snapshotId)));
|
|
90
|
+
}
|
|
55
91
|
notify({ type: "clearAll" });
|
|
56
92
|
},
|
|
57
93
|
async clearIfSnapshotId(id, snapshotId) {
|
|
@@ -71,13 +107,7 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
71
107
|
return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizeLegacyRecord, normalizePayload })?.record ?? null);
|
|
72
108
|
},
|
|
73
109
|
async list() {
|
|
74
|
-
return (await
|
|
75
|
-
.map((value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
76
|
-
normalizeLegacyRecord,
|
|
77
|
-
normalizePayload,
|
|
78
|
-
})?.record)
|
|
79
|
-
.filter((record) => Boolean(record))
|
|
80
|
-
.sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
110
|
+
return (await listScopedRecords()).sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
81
111
|
left.id.localeCompare(right.id));
|
|
82
112
|
},
|
|
83
113
|
async replaceIfSnapshotId(id, snapshotId, record) {
|
|
@@ -132,6 +162,9 @@ function createMemoryPendingWriteDriver() {
|
|
|
132
162
|
async list() {
|
|
133
163
|
return [...records.values()];
|
|
134
164
|
},
|
|
165
|
+
async listEntries() {
|
|
166
|
+
return [...records].map(([key, value]) => ({ key, value }));
|
|
167
|
+
},
|
|
135
168
|
async set(key, value) {
|
|
136
169
|
records.set(key, value);
|
|
137
170
|
},
|
|
@@ -151,6 +184,7 @@ function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexe
|
|
|
151
184
|
indexedDB,
|
|
152
185
|
operationTimeoutMs,
|
|
153
186
|
storeName,
|
|
187
|
+
readFailureMode: "throw",
|
|
154
188
|
});
|
|
155
189
|
return driver ? toPendingWriteDriver(driver) : null;
|
|
156
190
|
}
|
|
@@ -388,13 +422,12 @@ function createUnavailablePendingWriteDriver() {
|
|
|
388
422
|
async clearIf() {
|
|
389
423
|
return false;
|
|
390
424
|
},
|
|
391
|
-
async clearAll() { },
|
|
392
425
|
async close() { },
|
|
393
426
|
async get() {
|
|
394
|
-
|
|
427
|
+
throw unavailable();
|
|
395
428
|
},
|
|
396
429
|
async list() {
|
|
397
|
-
|
|
430
|
+
throw unavailable();
|
|
398
431
|
},
|
|
399
432
|
async set() {
|
|
400
433
|
throw unavailable();
|
|
@@ -412,6 +445,7 @@ function toPendingWriteDriver(driver) {
|
|
|
412
445
|
close: driver.close,
|
|
413
446
|
get: driver.get,
|
|
414
447
|
list: driver.list,
|
|
448
|
+
listEntries: driver.listEntries,
|
|
415
449
|
set: driver.set,
|
|
416
450
|
setIf: driver.setIf,
|
|
417
451
|
};
|
|
@@ -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,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.assertReadBoundary = assertReadBoundary;
|
|
4
|
+
/**
|
|
5
|
+
* Host contract: supply every reachable local runtime dependency, including
|
|
6
|
+
* re-exports and dynamic imports. Absent nodes are external leaves. Exact
|
|
7
|
+
* trusted adapter IDs stop traversal; hosts verify their cache policy separately.
|
|
8
|
+
* This test utility deliberately owns no parser, framework, or provider policy.
|
|
9
|
+
*/
|
|
10
|
+
function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }) {
|
|
11
|
+
const adapters = new Set(allowedAdapters);
|
|
12
|
+
const visited = new Set();
|
|
13
|
+
function visit(id, path) {
|
|
14
|
+
if (adapters.has(id) || visited.has(id))
|
|
15
|
+
return;
|
|
16
|
+
const nextPath = [...path, id];
|
|
17
|
+
if (isForbidden(id)) {
|
|
18
|
+
throw new Error(`Read boundary violation: ${nextPath.join(" -> ")}`);
|
|
19
|
+
}
|
|
20
|
+
visited.add(id);
|
|
21
|
+
for (const dependency of modules[id] ?? [])
|
|
22
|
+
visit(dependency, nextPath);
|
|
23
|
+
}
|
|
24
|
+
for (const root of roots)
|
|
25
|
+
visit(root, []);
|
|
26
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
|
|
2
|
+
export declare const readCacheSchemaVersion = 1;
|
|
3
|
+
export declare const readCacheFreshnessMaxAgeMs = 30000;
|
|
4
|
+
export declare const durableReadCacheFreshnessMaxAgeMs: number;
|
|
5
|
+
export declare const identityReadCacheFreshnessMaxAgeMs: number;
|
|
6
|
+
export declare const browserReadCacheOperationTimeoutMs = 1000;
|
|
7
|
+
export type ReadCacheHit<T> = {
|
|
8
|
+
fromCache: true;
|
|
9
|
+
savedAt: string;
|
|
10
|
+
value: T;
|
|
11
|
+
};
|
|
12
|
+
export type ReadCacheDriver = {
|
|
13
|
+
clear: () => Promise<void>;
|
|
14
|
+
close?: () => Promise<void>;
|
|
15
|
+
delete: (key: string) => Promise<void>;
|
|
16
|
+
get: (key: string) => Promise<unknown | null>;
|
|
17
|
+
listEntries?: () => Promise<Array<{
|
|
18
|
+
key: string;
|
|
19
|
+
value: unknown;
|
|
20
|
+
}>>;
|
|
21
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export type ReadCache = {
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a stable cache for the duration of a resource load. Dynamic
|
|
26
|
+
* adapters use this to pin the active account before asynchronous work.
|
|
27
|
+
*/
|
|
28
|
+
capture?: () => ReadCache;
|
|
29
|
+
clear: () => Promise<void>;
|
|
30
|
+
close: () => Promise<void>;
|
|
31
|
+
delete: (key: string) => Promise<void>;
|
|
32
|
+
/** Advances a key's generation without deleting its retained value. */
|
|
33
|
+
invalidate?: (key: string) => void;
|
|
34
|
+
get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
|
|
35
|
+
getVersion: (key: string) => string;
|
|
36
|
+
set: <T>(key: string, value: T) => Promise<void>;
|
|
37
|
+
setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
|
|
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";
|
|
50
|
+
export type ReadThroughCacheResult<T> = {
|
|
51
|
+
data: T;
|
|
52
|
+
error: Error | null;
|
|
53
|
+
fromCache: boolean;
|
|
54
|
+
stale: boolean;
|
|
55
|
+
status: ReadCacheStatus;
|
|
56
|
+
};
|
|
57
|
+
export type ReadCacheResourceUpdate<T> = {
|
|
58
|
+
data: T;
|
|
59
|
+
error?: Error;
|
|
60
|
+
fromCache: boolean;
|
|
61
|
+
status: ReadCacheStatus;
|
|
62
|
+
};
|
|
63
|
+
export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
|
|
64
|
+
databaseName: string;
|
|
65
|
+
operationTimeoutMs?: number;
|
|
66
|
+
storeName: string;
|
|
67
|
+
};
|
|
68
|
+
export type ReadCacheKeyOptions = {
|
|
69
|
+
namespace: string;
|
|
70
|
+
params?: Record<string, string | number | boolean | null | undefined>;
|
|
71
|
+
prefix: string;
|
|
72
|
+
scope: string;
|
|
73
|
+
};
|
|
74
|
+
export declare function createReadCache(driver: ReadCacheDriver): ReadCache;
|
|
75
|
+
export declare function createMemoryReadCacheDriver(): ReadCacheDriver;
|
|
76
|
+
export declare function createBrowserReadCache({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserReadCacheOptions): ReadCache;
|
|
77
|
+
/**
|
|
78
|
+
* Deletes only valid read-cache envelopes older than the supplied age. It is
|
|
79
|
+
* opt-in and returns zero for custom cache adapters that cannot enumerate keys.
|
|
80
|
+
*/
|
|
81
|
+
export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
|
|
82
|
+
cache: ReadCache;
|
|
83
|
+
maxAgeMs: number;
|
|
84
|
+
now?: () => number;
|
|
85
|
+
}): Promise<number>;
|
|
86
|
+
export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
|
|
87
|
+
export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, }: {
|
|
88
|
+
cache: ReadCache;
|
|
89
|
+
/**
|
|
90
|
+
* Revalidates after advancing the key generation, retaining the prior value
|
|
91
|
+
* as a possible fallback if refresh fails.
|
|
92
|
+
*/
|
|
93
|
+
forceFresh?: boolean;
|
|
94
|
+
key: string;
|
|
95
|
+
loadFresh: () => Promise<T>;
|
|
96
|
+
maxAgeMs?: number;
|
|
97
|
+
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
98
|
+
policy?: ReadCachePolicy;
|
|
99
|
+
}): Promise<ReadThroughCacheResult<T>>;
|
|
100
|
+
export declare function loadReadCacheResource<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, }: {
|
|
101
|
+
cache: ReadCache;
|
|
102
|
+
/** Revalidate while retaining eligible data as fallback on loader failure. */
|
|
103
|
+
forceFresh?: boolean;
|
|
104
|
+
key: string;
|
|
105
|
+
loadFresh: () => Promise<T>;
|
|
106
|
+
maxAgeMs?: number;
|
|
107
|
+
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
108
|
+
policy?: ReadCachePolicy;
|
|
109
|
+
onCacheWriteError?: (error: Error) => void;
|
|
110
|
+
onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
|
|
111
|
+
skipCache?: boolean;
|
|
112
|
+
}): Promise<T>;
|
package/dist-cjs/readCache.js
CHANGED
|
@@ -26,6 +26,10 @@ function createReadCache(driver) {
|
|
|
26
26
|
function getVersion(key) {
|
|
27
27
|
return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
|
|
28
28
|
}
|
|
29
|
+
function invalidate(key) {
|
|
30
|
+
pendingLoads.delete(key);
|
|
31
|
+
keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
|
|
32
|
+
}
|
|
29
33
|
function enqueue(operation) {
|
|
30
34
|
const next = operationQueue.then(operation, operation);
|
|
31
35
|
operationQueue = next.then(() => undefined, () => undefined);
|
|
@@ -42,10 +46,10 @@ function createReadCache(driver) {
|
|
|
42
46
|
await enqueue(async () => driver.close?.());
|
|
43
47
|
},
|
|
44
48
|
async delete(key) {
|
|
45
|
-
|
|
46
|
-
keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
|
|
49
|
+
invalidate(key);
|
|
47
50
|
await enqueue(() => driver.delete(key));
|
|
48
51
|
},
|
|
52
|
+
invalidate,
|
|
49
53
|
async get(key) {
|
|
50
54
|
const envelope = toReadCacheEnvelope(await driver.get(key));
|
|
51
55
|
return envelope
|
|
@@ -162,13 +166,21 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
|
162
166
|
.filter(Boolean)
|
|
163
167
|
.join(":");
|
|
164
168
|
}
|
|
165
|
-
async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
|
|
169
|
+
async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
|
|
166
170
|
const resourceCache = cache.capture?.() ?? cache;
|
|
171
|
+
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
172
|
+
const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
|
|
167
173
|
if (forceFresh) {
|
|
168
|
-
|
|
169
|
-
//
|
|
174
|
+
reportReadCacheOutcome(onOutcome, "forced-refresh");
|
|
175
|
+
// Built-in caches advance generation without deleting fallback data.
|
|
176
|
+
// Legacy custom adapters retain their old delete behavior.
|
|
170
177
|
try {
|
|
171
|
-
|
|
178
|
+
if (resourceCache.invalidate) {
|
|
179
|
+
resourceCache.invalidate(key);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
await resourceCache.delete(key);
|
|
183
|
+
}
|
|
172
184
|
}
|
|
173
185
|
catch (error) {
|
|
174
186
|
console.error("[Bayon offline] Unable to invalidate the read cache.", {
|
|
@@ -178,17 +190,22 @@ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, max
|
|
|
178
190
|
}
|
|
179
191
|
}
|
|
180
192
|
const version = resourceCache.getVersion(key);
|
|
181
|
-
const cached = await resourceCache.get(key);
|
|
182
|
-
|
|
193
|
+
const cached = forcedFallback ?? (await resourceCache.get(key));
|
|
194
|
+
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
|
|
195
|
+
if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
|
|
196
|
+
reportReadCacheOutcome(onOutcome, "fresh-hit");
|
|
183
197
|
return {
|
|
184
198
|
data: cached.value,
|
|
185
199
|
error: null,
|
|
186
200
|
fromCache: true,
|
|
187
201
|
stale: false,
|
|
202
|
+
status: "fresh",
|
|
188
203
|
};
|
|
189
204
|
}
|
|
205
|
+
reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
|
|
190
206
|
try {
|
|
191
207
|
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
208
|
+
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
192
209
|
try {
|
|
193
210
|
await resourceCache.setIfVersion(key, fresh, version);
|
|
194
211
|
}
|
|
@@ -203,33 +220,74 @@ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, max
|
|
|
203
220
|
error: null,
|
|
204
221
|
fromCache: false,
|
|
205
222
|
stale: false,
|
|
223
|
+
status: retained ? "fresh" : "miss",
|
|
206
224
|
};
|
|
207
225
|
}
|
|
208
226
|
catch (error) {
|
|
209
|
-
|
|
227
|
+
reportReadCacheOutcome(onOutcome, "loader-failure");
|
|
228
|
+
if (retained && resolvedPolicy.refreshFailure === "use-retained") {
|
|
210
229
|
return {
|
|
211
230
|
data: cached.value,
|
|
212
231
|
error: normalizeError(error),
|
|
213
232
|
fromCache: true,
|
|
214
233
|
stale: true,
|
|
234
|
+
status: "stale",
|
|
215
235
|
};
|
|
216
236
|
}
|
|
217
237
|
throw error;
|
|
218
238
|
}
|
|
219
239
|
}
|
|
220
|
-
async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
240
|
+
async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
221
241
|
const resourceCache = cache.capture?.() ?? cache;
|
|
242
|
+
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
243
|
+
const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
|
|
244
|
+
if (forceFresh) {
|
|
245
|
+
reportReadCacheOutcome(onOutcome, "forced-refresh");
|
|
246
|
+
try {
|
|
247
|
+
if (resourceCache.invalidate) {
|
|
248
|
+
resourceCache.invalidate(key);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
await resourceCache.delete(key);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
console.error("[Bayon offline] Unable to invalidate the read cache.", {
|
|
256
|
+
error: normalizeError(error),
|
|
257
|
+
key,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
222
261
|
const version = resourceCache.getVersion(key);
|
|
223
|
-
const cached = skipCache
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
262
|
+
const cached = skipCache
|
|
263
|
+
? null
|
|
264
|
+
: (forcedFallback ?? (await resourceCache.get(key)));
|
|
265
|
+
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
|
|
266
|
+
if (retained) {
|
|
267
|
+
const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
|
|
268
|
+
reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
|
|
269
|
+
onUpdate({
|
|
270
|
+
data: cached.value,
|
|
271
|
+
fromCache: true,
|
|
272
|
+
status: fresh && !forceFresh ? "fresh" : "stale",
|
|
273
|
+
});
|
|
274
|
+
if (fresh && !forceFresh) {
|
|
227
275
|
return cached.value;
|
|
228
276
|
}
|
|
229
277
|
}
|
|
278
|
+
else {
|
|
279
|
+
reportReadCacheOutcome(onOutcome, "cache-miss");
|
|
280
|
+
}
|
|
230
281
|
try {
|
|
231
282
|
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
232
|
-
|
|
283
|
+
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
284
|
+
if (resourceCache.getVersion(key) === version) {
|
|
285
|
+
onUpdate({
|
|
286
|
+
data: fresh,
|
|
287
|
+
fromCache: false,
|
|
288
|
+
status: retained ? "fresh" : "miss",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
233
291
|
void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
|
|
234
292
|
const normalizedError = normalizeError(error);
|
|
235
293
|
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
@@ -241,17 +299,32 @@ async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheW
|
|
|
241
299
|
return fresh;
|
|
242
300
|
}
|
|
243
301
|
catch (error) {
|
|
244
|
-
|
|
302
|
+
reportReadCacheOutcome(onOutcome, "loader-failure");
|
|
303
|
+
if (!retained || resolvedPolicy.refreshFailure === "throw") {
|
|
245
304
|
throw error;
|
|
246
305
|
}
|
|
247
306
|
onUpdate({
|
|
248
307
|
data: cached.value,
|
|
249
308
|
error: normalizeError(error),
|
|
250
309
|
fromCache: true,
|
|
310
|
+
status: "stale",
|
|
251
311
|
});
|
|
252
312
|
return cached.value;
|
|
253
313
|
}
|
|
254
314
|
}
|
|
315
|
+
function reportReadCacheOutcome(onOutcome, outcome) {
|
|
316
|
+
if (!onOutcome)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
onOutcome(outcome);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
console.error("[Bayon offline] Read-cache outcome callback failed.", {
|
|
323
|
+
error: normalizeError(error),
|
|
324
|
+
outcome,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
255
328
|
function createNoopReadCacheDriver() {
|
|
256
329
|
return {
|
|
257
330
|
async clear() { },
|
|
@@ -266,12 +339,33 @@ function createNoopReadCacheDriver() {
|
|
|
266
339
|
async set() { },
|
|
267
340
|
};
|
|
268
341
|
}
|
|
269
|
-
function isCacheFresh(savedAt,
|
|
270
|
-
if (
|
|
342
|
+
function isCacheFresh(savedAt, policy) {
|
|
343
|
+
if (policy.freshForMs <= 0) {
|
|
271
344
|
return false;
|
|
272
345
|
}
|
|
273
346
|
const savedTime = Date.parse(savedAt);
|
|
274
|
-
return Number.isFinite(savedTime) && Date.now() - savedTime <=
|
|
347
|
+
return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.freshForMs);
|
|
348
|
+
}
|
|
349
|
+
function resolvePolicy(policy, maxAgeMs) {
|
|
350
|
+
if (policy) {
|
|
351
|
+
if (!Number.isFinite(policy.freshForMs) ||
|
|
352
|
+
policy.freshForMs < 0 ||
|
|
353
|
+
!Number.isFinite(policy.retainForMs) ||
|
|
354
|
+
policy.retainForMs < policy.freshForMs ||
|
|
355
|
+
!["use-retained", "throw"].includes(policy.refreshFailure)) {
|
|
356
|
+
throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
|
|
357
|
+
}
|
|
358
|
+
return policy;
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
freshForMs: maxAgeMs ?? 0,
|
|
362
|
+
retainForMs: Number.POSITIVE_INFINITY,
|
|
363
|
+
refreshFailure: "use-retained",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function isCacheRetained(savedAt, policy) {
|
|
367
|
+
const savedTime = Date.parse(savedAt);
|
|
368
|
+
return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.retainForMs);
|
|
275
369
|
}
|
|
276
370
|
async function loadSharedFreshResource(cache, key, loadFresh) {
|
|
277
371
|
const pendingLoads = getPendingFreshLoads(cache);
|