@bayonai/offline 0.7.0 → 0.9.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.
@@ -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.clearAll();
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 driver.list())
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
  },
@@ -162,14 +195,24 @@ function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexed
162
195
  operationTimeoutMs,
163
196
  storeName,
164
197
  });
165
- return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
198
+ return createPendingWriteStore(driver ?? createUnavailablePendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
166
199
  }
167
200
  function createPendingWriteKey({ id, prefix }) {
168
201
  return `${prefix}:${encodeURIComponent(id)}`;
169
202
  }
170
203
  function createPendingWriteSnapshotId() {
171
- return globalThis.crypto.randomUUID();
204
+ const randomId = globalThis.crypto?.randomUUID?.();
205
+ if (randomId)
206
+ return randomId;
207
+ pendingWriteSnapshotSequence += 1;
208
+ return [
209
+ "snapshot",
210
+ Date.now().toString(36),
211
+ pendingWriteSnapshotSequence.toString(36),
212
+ Math.random().toString(36).slice(2),
213
+ ].join("-");
172
214
  }
215
+ let pendingWriteSnapshotSequence = 0;
173
216
  async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
174
217
  const pending = await store.get(id);
175
218
  if (!pending) {
@@ -177,8 +220,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
177
220
  }
178
221
  // A delayed command remains in its ordered lane until its grace period has
179
222
  // elapsed. This makes cancellation survive UI unmounts and app restarts.
180
- if (pending.notBefore &&
181
- Date.parse(pending.notBefore) > Date.parse(now())) {
223
+ if (pending.notBefore && Date.parse(pending.notBefore) > Date.parse(now())) {
182
224
  return { status: "idle" };
183
225
  }
184
226
  const claimedSnapshotId = createPendingWriteSnapshotId();
@@ -193,7 +235,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
193
235
  return { status: "superseded" };
194
236
  }
195
237
  try {
196
- await sync(pending.payload);
238
+ await sync(pending.payload, { recordId: pending.id });
197
239
  }
198
240
  catch (error) {
199
241
  const message = error instanceof Error ? error.message : "Unable to sync pending write.";
@@ -222,7 +264,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
222
264
  * same store/scope. Callers own the selection predicate and idempotent remote
223
265
  * mutation semantics.
224
266
  */
225
- function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
267
+ function syncPendingWrites({ now, scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
226
268
  let inFlightByScope = pendingWriteSyncs.get(store);
227
269
  if (!inFlightByScope) {
228
270
  inFlightByScope = new Map();
@@ -233,6 +275,7 @@ function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailur
233
275
  return existing;
234
276
  }
235
277
  const syncRun = performPendingWriteSync({
278
+ now,
236
279
  select,
237
280
  stopOnFailure,
238
281
  store,
@@ -279,7 +322,7 @@ function summarizePendingWrites(records) {
279
322
  }, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
280
323
  }
281
324
  const pendingWriteSyncs = new WeakMap();
282
- async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
325
+ async function performPendingWriteSync({ now, select, stopOnFailure, store, sync, }) {
283
326
  const summary = {
284
327
  attempted: 0,
285
328
  failed: 0,
@@ -292,6 +335,7 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
292
335
  for (const record of records) {
293
336
  const result = await syncPendingWrite({
294
337
  id: record.id,
338
+ now,
295
339
  store,
296
340
  sync,
297
341
  });
@@ -327,7 +371,7 @@ function isPendingWriteSyncClaimAvailable(record, now) {
327
371
  * workspace. Use its `recover` method for background replay and `run` for new
328
372
  * writes so a newer remote mutation cannot overtake an older queued mutation.
329
373
  */
330
- function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
374
+ function createOrderedPendingWriteExecutor({ now, scopeKey = "all", select = () => true, store, sync, }) {
331
375
  let execution = Promise.resolve();
332
376
  function inOrder(operation) {
333
377
  const result = execution.then(operation, operation);
@@ -336,6 +380,7 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
336
380
  }
337
381
  function recover() {
338
382
  return inOrder(() => syncPendingWrites({
383
+ now,
339
384
  scopeKey,
340
385
  select,
341
386
  stopOnFailure: true,
@@ -351,6 +396,7 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
351
396
  return runPendingWrite(options);
352
397
  }
353
398
  const replay = await syncPendingWrites({
399
+ now,
354
400
  scopeKey,
355
401
  select,
356
402
  stopOnFailure: true,
@@ -368,13 +414,13 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
368
414
  },
369
415
  };
370
416
  }
371
- function createNoopPendingWriteDriver() {
417
+ function createUnavailablePendingWriteDriver() {
418
+ const unavailable = () => new idbDriver_1.OfflineStorageError("IndexedDB is unavailable; the pending write was not stored.");
372
419
  return {
373
420
  async clear() { },
374
421
  async clearIf() {
375
422
  return false;
376
423
  },
377
- async clearAll() { },
378
424
  async close() { },
379
425
  async get() {
380
426
  return null;
@@ -382,9 +428,11 @@ function createNoopPendingWriteDriver() {
382
428
  async list() {
383
429
  return [];
384
430
  },
385
- async set() { },
431
+ async set() {
432
+ throw unavailable();
433
+ },
386
434
  async setIf() {
387
- return false;
435
+ throw unavailable();
388
436
  },
389
437
  };
390
438
  }
@@ -396,6 +444,7 @@ function toPendingWriteDriver(driver) {
396
444
  close: driver.close,
397
445
  get: driver.get,
398
446
  list: driver.list,
447
+ listEntries: driver.listEntries,
399
448
  set: driver.set,
400
449
  setIf: driver.setIf,
401
450
  };
@@ -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>;
@@ -13,7 +13,9 @@ exports.readCacheSchemaVersion = 1;
13
13
  exports.readCacheFreshnessMaxAgeMs = 30_000;
14
14
  exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
15
15
  exports.identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
16
- exports.browserReadCacheOperationTimeoutMs = 2_000;
16
+ // Cache reads are optional and should yield to the fresh loader quickly when
17
+ // IndexedDB is blocked by a browser/WebView lifecycle or another tab.
18
+ exports.browserReadCacheOperationTimeoutMs = 1_000;
17
19
  const pendingFreshLoads = new WeakMap();
18
20
  const cachePruners = new WeakMap();
19
21
  function createReadCache(driver) {
@@ -24,6 +26,10 @@ function createReadCache(driver) {
24
26
  function getVersion(key) {
25
27
  return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
26
28
  }
29
+ function invalidate(key) {
30
+ pendingLoads.delete(key);
31
+ keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
32
+ }
27
33
  function enqueue(operation) {
28
34
  const next = operationQueue.then(operation, operation);
29
35
  operationQueue = next.then(() => undefined, () => undefined);
@@ -40,10 +46,10 @@ function createReadCache(driver) {
40
46
  await enqueue(async () => driver.close?.());
41
47
  },
42
48
  async delete(key) {
43
- pendingLoads.delete(key);
44
- keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
49
+ invalidate(key);
45
50
  await enqueue(() => driver.delete(key));
46
51
  },
52
+ invalidate,
47
53
  async get(key) {
48
54
  const envelope = toReadCacheEnvelope(await driver.get(key));
49
55
  return envelope
@@ -143,8 +149,7 @@ function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = glo
143
149
  */
144
150
  function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
145
151
  const resourceCache = cache.capture?.() ?? cache;
146
- return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
147
- Promise.resolve(0);
152
+ return (cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ?? Promise.resolve(0));
148
153
  }
149
154
  function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
150
155
  const serializedParams = Object.entries(params)
@@ -161,58 +166,128 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
161
166
  .filter(Boolean)
162
167
  .join(":");
163
168
  }
164
- async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
169
+ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
165
170
  const resourceCache = cache.capture?.() ?? cache;
171
+ const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
172
+ const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
166
173
  if (forceFresh) {
167
- // Deletion advances the cache version, so a request started before this
168
- // post-write refresh cannot restore stale data after it finishes.
169
- await resourceCache.delete(key);
174
+ reportReadCacheOutcome(onOutcome, "forced-refresh");
175
+ // Built-in caches advance generation without deleting fallback data.
176
+ // Legacy custom adapters retain their old delete behavior.
177
+ try {
178
+ if (resourceCache.invalidate) {
179
+ resourceCache.invalidate(key);
180
+ }
181
+ else {
182
+ await resourceCache.delete(key);
183
+ }
184
+ }
185
+ catch (error) {
186
+ console.error("[Bayon offline] Unable to invalidate the read cache.", {
187
+ error: normalizeError(error),
188
+ key,
189
+ });
190
+ }
170
191
  }
171
192
  const version = resourceCache.getVersion(key);
172
- const cached = await resourceCache.get(key);
173
- if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
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");
174
197
  return {
175
198
  data: cached.value,
176
199
  error: null,
177
200
  fromCache: true,
178
201
  stale: false,
202
+ status: "fresh",
179
203
  };
180
204
  }
205
+ reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
181
206
  try {
182
207
  const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
183
- await resourceCache.setIfVersion(key, fresh, version);
208
+ reportReadCacheOutcome(onOutcome, "loader-success");
209
+ try {
210
+ await resourceCache.setIfVersion(key, fresh, version);
211
+ }
212
+ catch (error) {
213
+ console.error("[Bayon offline] Unable to refresh the read cache.", {
214
+ error: normalizeError(error),
215
+ key,
216
+ });
217
+ }
184
218
  return {
185
219
  data: fresh,
186
220
  error: null,
187
221
  fromCache: false,
188
222
  stale: false,
223
+ status: retained ? "fresh" : "miss",
189
224
  };
190
225
  }
191
226
  catch (error) {
192
- if (cached) {
227
+ reportReadCacheOutcome(onOutcome, "loader-failure");
228
+ if (retained && resolvedPolicy.refreshFailure === "use-retained") {
193
229
  return {
194
230
  data: cached.value,
195
231
  error: normalizeError(error),
196
232
  fromCache: true,
197
233
  stale: true,
234
+ status: "stale",
198
235
  };
199
236
  }
200
237
  throw error;
201
238
  }
202
239
  }
203
- 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, }) {
204
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
+ }
205
261
  const version = resourceCache.getVersion(key);
206
- const cached = skipCache ? null : await resourceCache.get(key);
207
- if (cached) {
208
- onUpdate({ data: cached.value, fromCache: true });
209
- if (isCacheFresh(cached.savedAt, maxAgeMs)) {
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) {
210
275
  return cached.value;
211
276
  }
212
277
  }
278
+ else {
279
+ reportReadCacheOutcome(onOutcome, "cache-miss");
280
+ }
213
281
  try {
214
282
  const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
215
- onUpdate({ data: fresh, fromCache: false });
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
+ }
216
291
  void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
217
292
  const normalizedError = normalizeError(error);
218
293
  console.error("[Bayon offline] Unable to refresh the read cache.", {
@@ -224,17 +299,32 @@ async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheW
224
299
  return fresh;
225
300
  }
226
301
  catch (error) {
227
- if (!cached) {
302
+ reportReadCacheOutcome(onOutcome, "loader-failure");
303
+ if (!retained || resolvedPolicy.refreshFailure === "throw") {
228
304
  throw error;
229
305
  }
230
306
  onUpdate({
231
307
  data: cached.value,
232
308
  error: normalizeError(error),
233
309
  fromCache: true,
310
+ status: "stale",
234
311
  });
235
312
  return cached.value;
236
313
  }
237
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
+ }
238
328
  function createNoopReadCacheDriver() {
239
329
  return {
240
330
  async clear() { },
@@ -249,12 +339,33 @@ function createNoopReadCacheDriver() {
249
339
  async set() { },
250
340
  };
251
341
  }
252
- function isCacheFresh(savedAt, maxAgeMs) {
253
- if (!maxAgeMs || maxAgeMs <= 0) {
342
+ function isCacheFresh(savedAt, policy) {
343
+ if (policy.freshForMs <= 0) {
254
344
  return false;
255
345
  }
256
346
  const savedTime = Date.parse(savedAt);
257
- return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
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);
258
369
  }
259
370
  async function loadSharedFreshResource(cache, key, loadFresh) {
260
371
  const pendingLoads = getPendingFreshLoads(cache);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",
@@ -21,6 +21,9 @@
21
21
  "idb",
22
22
  "typescript"
23
23
  ],
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
24
27
  "sideEffects": false,
25
28
  "type": "module",
26
29
  "main": "./dist-cjs/index.js",
@@ -28,9 +31,14 @@
28
31
  "types": "./dist/index.d.ts",
29
32
  "exports": {
30
33
  ".": {
31
- "types": "./dist/index.d.ts",
32
- "import": "./dist/index.js",
33
- "require": "./dist-cjs/index.js"
34
+ "import": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "require": {
39
+ "types": "./dist-cjs/index.d.ts",
40
+ "default": "./dist-cjs/index.js"
41
+ }
34
42
  }
35
43
  },
36
44
  "files": [
@@ -44,7 +52,7 @@
44
52
  "access": "public"
45
53
  },
46
54
  "scripts": {
47
- "build": "tsc -p tsconfig.json && node scripts/write-esm-extensions.mjs && tsc -p tsconfig.cjs.json && node scripts/write-cjs.mjs",
55
+ "build": "tsc -p tsconfig.json && node scripts/write-esm-extensions.mjs && tsc -p tsconfig.cjs.json && node scripts/write-cjs.mjs && node scripts/verify-dist.mjs",
48
56
  "lint": "tsc --noEmit -p tsconfig.json",
49
57
  "pack:smoke": "corepack pnpm run build && npm pack --dry-run --access public",
50
58
  "prepack": "corepack pnpm run build",
@@ -56,7 +64,7 @@
56
64
  },
57
65
  "devDependencies": {
58
66
  "fake-indexeddb": "^6.2.5",
59
- "typescript": "^5",
60
- "vitest": "^4.1.6"
67
+ "typescript": "^6.0.3",
68
+ "vitest": "^5.0.1"
61
69
  }
62
70
  }