@bayonai/offline 0.1.0 → 0.3.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.
@@ -6,45 +6,89 @@ exports.createMemoryPendingWriteDriver = createMemoryPendingWriteDriver;
6
6
  exports.createBrowserPendingWriteDriver = createBrowserPendingWriteDriver;
7
7
  exports.createBrowserPendingWriteStore = createBrowserPendingWriteStore;
8
8
  exports.createPendingWriteKey = createPendingWriteKey;
9
+ exports.createPendingWriteSnapshotId = createPendingWriteSnapshotId;
9
10
  exports.syncPendingWrite = syncPendingWrite;
11
+ exports.syncPendingWrites = syncPendingWrites;
12
+ exports.runPendingWrite = runPendingWrite;
13
+ exports.summarizePendingWrites = summarizePendingWrites;
10
14
  const idbDriver_1 = require("./idbDriver");
11
15
  exports.pendingWriteSchemaVersion = 1;
12
16
  exports.pendingWriteOperationTimeoutMs = 2_000;
13
- function createPendingWriteStore(driver, { keyPrefix, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
17
+ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
18
+ const listeners = new Set();
19
+ const notify = (change) => {
20
+ for (const listener of listeners) {
21
+ try {
22
+ listener(change);
23
+ }
24
+ catch (error) {
25
+ console.error("[Bayon offline] Pending-write store listener failed.", {
26
+ error,
27
+ type: change.type,
28
+ });
29
+ }
30
+ }
31
+ };
14
32
  return {
15
33
  async clear(id) {
16
34
  await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
35
+ notify({ id, type: "clear" });
17
36
  },
18
37
  async clearAll() {
19
38
  await driver.clearAll();
39
+ notify({ type: "clearAll" });
20
40
  },
21
- async clearIfSavedAt(id, savedAt) {
22
- const current = await this.get(id);
23
- if (!current || current.savedAt !== savedAt) {
24
- return false;
41
+ async clearIfSnapshotId(id, snapshotId) {
42
+ const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
43
+ normalizeLegacyRecord,
44
+ normalizePayload,
45
+ })?.record.snapshotId === snapshotId);
46
+ if (cleared) {
47
+ notify({ id, type: "clear" });
25
48
  }
26
- await this.clear(id);
27
- return true;
49
+ return cleared;
28
50
  },
29
51
  async close() {
30
52
  await driver.close?.();
31
53
  },
32
54
  async get(id) {
33
- return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizePayload })?.record ?? null);
55
+ return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizeLegacyRecord, normalizePayload })?.record ?? null);
34
56
  },
35
57
  async list() {
36
58
  return (await driver.list())
37
59
  .map((value) => toPendingWriteEnvelope(value, schemaVersion, {
60
+ normalizeLegacyRecord,
38
61
  normalizePayload,
39
62
  })?.record)
40
63
  .filter((record) => Boolean(record))
41
- .sort((left, right) => left.savedAt.localeCompare(right.savedAt));
64
+ .sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
65
+ left.id.localeCompare(right.id));
66
+ },
67
+ async replaceIfSnapshotId(id, snapshotId, record) {
68
+ const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
69
+ normalizeLegacyRecord,
70
+ normalizePayload,
71
+ })?.record.snapshotId === snapshotId);
72
+ if (replaced) {
73
+ notify({ record, type: "replace" });
74
+ }
75
+ return replaced;
42
76
  },
43
77
  async set(record) {
78
+ const storedRecord = {
79
+ ...record,
80
+ snapshotId: record.snapshotId ?? createPendingWriteSnapshotId(),
81
+ };
44
82
  await driver.set(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), {
45
- record,
83
+ record: storedRecord,
46
84
  schemaVersion,
47
85
  });
86
+ notify({ record: storedRecord, type: "set" });
87
+ return storedRecord;
88
+ },
89
+ subscribe(listener) {
90
+ listeners.add(listener);
91
+ return () => listeners.delete(listener);
48
92
  },
49
93
  };
50
94
  }
@@ -54,6 +98,14 @@ function createMemoryPendingWriteDriver() {
54
98
  async clear(key) {
55
99
  records.delete(key);
56
100
  },
101
+ async clearIf(key, predicate) {
102
+ const current = records.get(key) ?? null;
103
+ if (!predicate(current)) {
104
+ return false;
105
+ }
106
+ records.delete(key);
107
+ return true;
108
+ },
57
109
  async clearAll() {
58
110
  records.clear();
59
111
  },
@@ -67,6 +119,13 @@ function createMemoryPendingWriteDriver() {
67
119
  async set(key, value) {
68
120
  records.set(key, value);
69
121
  },
122
+ async setIf(key, value, predicate) {
123
+ if (!predicate(records.get(key) ?? null)) {
124
+ return false;
125
+ }
126
+ records.set(key, value);
127
+ return true;
128
+ },
70
129
  };
71
130
  }
72
131
  function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, storeName, }) {
@@ -79,7 +138,7 @@ function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexe
79
138
  });
80
139
  return driver ? toPendingWriteDriver(driver) : null;
81
140
  }
82
- function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizePayload, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, schemaVersion = exports.pendingWriteSchemaVersion, storeName, }) {
141
+ function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, schemaVersion = exports.pendingWriteSchemaVersion, storeName, }) {
83
142
  const driver = createBrowserPendingWriteDriver({
84
143
  databaseName,
85
144
  databaseVersion,
@@ -87,11 +146,14 @@ function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexed
87
146
  operationTimeoutMs,
88
147
  storeName,
89
148
  });
90
- return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizePayload, schemaVersion });
149
+ return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
91
150
  }
92
151
  function createPendingWriteKey({ id, prefix }) {
93
152
  return `${prefix}:${encodeURIComponent(id)}`;
94
153
  }
154
+ function createPendingWriteSnapshotId() {
155
+ return globalThis.crypto.randomUUID();
156
+ }
95
157
  async function syncPendingWrite({ id, store, sync, }) {
96
158
  const pending = await store.get(id);
97
159
  if (!pending) {
@@ -100,7 +162,7 @@ async function syncPendingWrite({ id, store, sync, }) {
100
162
  await markPendingSnapshot({
101
163
  id,
102
164
  nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
103
- savedAt: pending.savedAt,
165
+ snapshotId: pending.snapshotId,
104
166
  store,
105
167
  });
106
168
  try {
@@ -111,18 +173,100 @@ async function syncPendingWrite({ id, store, sync, }) {
111
173
  await markPendingSnapshot({
112
174
  id,
113
175
  nextRecord: { ...pending, error: message, syncStatus: "failed" },
114
- savedAt: pending.savedAt,
176
+ snapshotId: pending.snapshotId,
115
177
  store,
116
178
  });
117
179
  return { error: message, status: "failed" };
118
180
  }
119
- return (await store.clearIfSavedAt(id, pending.savedAt))
181
+ return (await store.clearIfSnapshotId(id, pending.snapshotId))
120
182
  ? { status: "synced" }
121
183
  : { status: "superseded" };
122
184
  }
185
+ /**
186
+ * Replays selected writes oldest first and coalesces overlapping runs for the
187
+ * same store/scope. Callers own the selection predicate and idempotent remote
188
+ * mutation semantics.
189
+ */
190
+ function syncPendingWrites({ scopeKey = "all", select = () => true, store, sync, }) {
191
+ let inFlightByScope = pendingWriteSyncs.get(store);
192
+ if (!inFlightByScope) {
193
+ inFlightByScope = new Map();
194
+ pendingWriteSyncs.set(store, inFlightByScope);
195
+ }
196
+ const existing = inFlightByScope.get(scopeKey);
197
+ if (existing) {
198
+ return existing;
199
+ }
200
+ const syncRun = performPendingWriteSync({ select, store, sync });
201
+ inFlightByScope.set(scopeKey, syncRun);
202
+ const clearInFlight = () => {
203
+ inFlightByScope.delete(scopeKey);
204
+ };
205
+ void syncRun.then(clearInFlight, clearInFlight);
206
+ return syncRun;
207
+ }
208
+ /**
209
+ * Attempts a remote write now and persists a caller-created durable fallback
210
+ * only when offline or when the supplied retry classifier accepts the error.
211
+ */
212
+ async function runPendingWrite({ enqueue, isOnline, shouldQueue, write, }) {
213
+ if (!isOnline()) {
214
+ return { queued: await enqueue(), status: "queued" };
215
+ }
216
+ try {
217
+ return { result: await write(), status: "synced" };
218
+ }
219
+ catch (error) {
220
+ if (!shouldQueue(error)) {
221
+ throw error;
222
+ }
223
+ return { queued: await enqueue(), status: "queued" };
224
+ }
225
+ }
226
+ function summarizePendingWrites(records) {
227
+ return records.reduce((summary, record) => {
228
+ summary.totalCount += 1;
229
+ if (record.syncStatus === "failed") {
230
+ summary.failedCount += 1;
231
+ }
232
+ else if (record.syncStatus === "syncing") {
233
+ summary.syncingCount += 1;
234
+ }
235
+ else {
236
+ summary.pendingCount += 1;
237
+ }
238
+ return summary;
239
+ }, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
240
+ }
241
+ const pendingWriteSyncs = new WeakMap();
242
+ async function performPendingWriteSync({ select, store, sync, }) {
243
+ const summary = {
244
+ attempted: 0,
245
+ failed: 0,
246
+ idle: 0,
247
+ remaining: 0,
248
+ superseded: 0,
249
+ synced: 0,
250
+ };
251
+ const records = (await store.list()).filter(select);
252
+ for (const record of records) {
253
+ const result = await syncPendingWrite({
254
+ id: record.id,
255
+ store,
256
+ sync,
257
+ });
258
+ summary.attempted += 1;
259
+ summary[result.status] += 1;
260
+ }
261
+ summary.remaining = (await store.list()).filter(select).length;
262
+ return summary;
263
+ }
123
264
  function createNoopPendingWriteDriver() {
124
265
  return {
125
266
  async clear() { },
267
+ async clearIf() {
268
+ return false;
269
+ },
126
270
  async clearAll() { },
127
271
  async close() { },
128
272
  async get() {
@@ -132,43 +276,41 @@ function createNoopPendingWriteDriver() {
132
276
  return [];
133
277
  },
134
278
  async set() { },
279
+ async setIf() {
280
+ return false;
281
+ },
135
282
  };
136
283
  }
137
284
  function toPendingWriteDriver(driver) {
138
285
  return {
139
286
  clear: driver.delete,
287
+ clearIf: driver.deleteIf,
140
288
  clearAll: driver.clear,
141
289
  close: driver.close,
142
290
  get: driver.get,
143
291
  list: driver.list,
144
292
  set: driver.set,
293
+ setIf: driver.setIf,
145
294
  };
146
295
  }
147
- async function markPendingSnapshot({ id, nextRecord, savedAt, store, }) {
148
- const current = await store.get(id);
149
- if (current?.savedAt !== savedAt) {
150
- return false;
151
- }
152
- await store.set(nextRecord);
153
- return true;
296
+ async function markPendingSnapshot({ id, nextRecord, snapshotId, store, }) {
297
+ return store.replaceIfSnapshotId(id, snapshotId, nextRecord);
154
298
  }
155
- function toPendingWriteEnvelope(value, schemaVersion, { normalizePayload }) {
156
- if (!value ||
157
- typeof value !== "object" ||
158
- !("schemaVersion" in value) ||
159
- value.schemaVersion !== schemaVersion ||
160
- !("record" in value) ||
161
- !isPendingWriteRecord(value.record)) {
162
- return null;
163
- }
164
- const record = normalizePendingWriteRecord(value.record, normalizePayload);
299
+ function toPendingWriteEnvelope(value, schemaVersion, { normalizeLegacyRecord, normalizePayload }) {
300
+ const envelopeRecord = readPendingWriteEnvelopeRecord(value, schemaVersion);
301
+ const legacyRecord = envelopeRecord
302
+ ? null
303
+ : (normalizeLegacyRecord?.(value) ?? null);
304
+ const record = normalizePendingWriteRecord(envelopeRecord ?? legacyRecord, normalizePayload);
165
305
  return record ? { record, schemaVersion } : null;
166
306
  }
167
307
  function normalizePendingWriteRecord(record, normalizePayload) {
168
- if (!normalizePayload) {
169
- return record;
308
+ if (!record) {
309
+ return null;
170
310
  }
171
- const payload = normalizePayload(record.payload);
311
+ const payload = normalizePayload
312
+ ? normalizePayload(record.payload)
313
+ : record.payload;
172
314
  if (payload === null) {
173
315
  return null;
174
316
  }
@@ -177,9 +319,24 @@ function normalizePendingWriteRecord(record, normalizePayload) {
177
319
  id: record.id,
178
320
  payload,
179
321
  savedAt: record.savedAt,
322
+ snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
180
323
  syncStatus: record.syncStatus,
181
324
  };
182
325
  }
326
+ function readPendingWriteEnvelopeRecord(value, schemaVersion) {
327
+ if (!value ||
328
+ typeof value !== "object" ||
329
+ !("schemaVersion" in value) ||
330
+ value.schemaVersion !== schemaVersion ||
331
+ !("record" in value) ||
332
+ !isPendingWriteRecord(value.record)) {
333
+ return null;
334
+ }
335
+ return value.record;
336
+ }
337
+ function createLegacyPendingWriteSnapshotId(record) {
338
+ return `legacy:${encodeURIComponent(record.id)}:${encodeURIComponent(record.savedAt)}`;
339
+ }
183
340
  function isPendingWriteRecord(value) {
184
341
  return (value !== null &&
185
342
  typeof value === "object" &&
@@ -187,6 +344,7 @@ function isPendingWriteRecord(value) {
187
344
  typeof value.id === "string" &&
188
345
  "savedAt" in value &&
189
346
  typeof value.savedAt === "string" &&
347
+ (!("snapshotId" in value) || typeof value.snapshotId === "string") &&
190
348
  "syncStatus" in value &&
191
349
  (value.syncStatus === "pending" ||
192
350
  value.syncStatus === "syncing" ||
@@ -1,25 +1,48 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.browserReadCacheOperationTimeoutMs = exports.readCacheFreshnessMaxAgeMs = exports.readCacheSchemaVersion = void 0;
3
+ exports.browserReadCacheOperationTimeoutMs = exports.identityReadCacheFreshnessMaxAgeMs = exports.durableReadCacheFreshnessMaxAgeMs = exports.readCacheFreshnessMaxAgeMs = exports.readCacheSchemaVersion = void 0;
4
4
  exports.createReadCache = createReadCache;
5
5
  exports.createMemoryReadCacheDriver = createMemoryReadCacheDriver;
6
6
  exports.createBrowserReadCache = createBrowserReadCache;
7
+ exports.pruneReadCache = pruneReadCache;
7
8
  exports.createReadCacheKey = createReadCacheKey;
8
9
  exports.readThroughCache = readThroughCache;
9
10
  exports.loadReadCacheResource = loadReadCacheResource;
10
11
  const idbDriver_1 = require("./idbDriver");
11
12
  exports.readCacheSchemaVersion = 1;
12
13
  exports.readCacheFreshnessMaxAgeMs = 30_000;
14
+ exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
15
+ exports.identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
13
16
  exports.browserReadCacheOperationTimeoutMs = 2_000;
14
- const pendingFreshLoads = new Map();
17
+ const pendingFreshLoads = new WeakMap();
18
+ const cachePruners = new WeakMap();
15
19
  function createReadCache(driver) {
16
- return {
20
+ let globalVersion = 0;
21
+ const keyVersions = new Map();
22
+ let operationQueue = Promise.resolve();
23
+ const pendingLoads = new Map();
24
+ function getVersion(key) {
25
+ return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
26
+ }
27
+ function enqueue(operation) {
28
+ const next = operationQueue.then(operation, operation);
29
+ operationQueue = next.then(() => undefined, () => undefined);
30
+ return next;
31
+ }
32
+ const cache = {
17
33
  async clear() {
18
- pendingFreshLoads.clear();
19
- await driver.clear();
34
+ pendingLoads.clear();
35
+ globalVersion += 1;
36
+ keyVersions.clear();
37
+ await enqueue(() => driver.clear());
20
38
  },
21
39
  async close() {
22
- await driver.close?.();
40
+ await enqueue(async () => driver.close?.());
41
+ },
42
+ async delete(key) {
43
+ pendingLoads.delete(key);
44
+ keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
45
+ await enqueue(() => driver.delete(key));
23
46
  },
24
47
  async get(key) {
25
48
  const envelope = toReadCacheEnvelope(await driver.get(key));
@@ -31,14 +54,48 @@ function createReadCache(driver) {
31
54
  }
32
55
  : null;
33
56
  },
57
+ getVersion,
34
58
  async set(key, value) {
35
- await driver.set(key, {
59
+ await enqueue(() => driver.set(key, {
36
60
  savedAt: new Date().toISOString(),
37
61
  schemaVersion: exports.readCacheSchemaVersion,
38
62
  value,
63
+ }));
64
+ },
65
+ async setIfVersion(key, value, version) {
66
+ if (getVersion(key) !== version) {
67
+ return false;
68
+ }
69
+ return enqueue(async () => {
70
+ if (getVersion(key) !== version) {
71
+ return false;
72
+ }
73
+ await driver.set(key, {
74
+ savedAt: new Date().toISOString(),
75
+ schemaVersion: exports.readCacheSchemaVersion,
76
+ value,
77
+ });
78
+ return true;
39
79
  });
40
80
  },
41
81
  };
82
+ pendingFreshLoads.set(cache, pendingLoads);
83
+ cachePruners.set(cache, async ({ maxAgeMs, now }) => {
84
+ if (!driver.listEntries || maxAgeMs < 0) {
85
+ return 0;
86
+ }
87
+ const cutoff = now() - maxAgeMs;
88
+ const expiredKeys = (await driver.listEntries())
89
+ .filter(({ value }) => {
90
+ const envelope = toReadCacheEnvelope(value);
91
+ const savedAt = envelope ? Date.parse(envelope.savedAt) : Number.NaN;
92
+ return Number.isFinite(savedAt) && savedAt < cutoff;
93
+ })
94
+ .map(({ key }) => key);
95
+ await Promise.all(expiredKeys.map((key) => cache.delete(key)));
96
+ return expiredKeys.length;
97
+ });
98
+ return cache;
42
99
  }
43
100
  function createMemoryReadCacheDriver() {
44
101
  const records = new Map();
@@ -47,9 +104,15 @@ function createMemoryReadCacheDriver() {
47
104
  records.clear();
48
105
  },
49
106
  async close() { },
107
+ async delete(key) {
108
+ records.delete(key);
109
+ },
50
110
  async get(key) {
51
111
  return records.get(key) ?? null;
52
112
  },
113
+ async listEntries() {
114
+ return [...records.entries()].map(([key, value]) => ({ key, value }));
115
+ },
53
116
  async set(key, value) {
54
117
  records.set(key, value);
55
118
  },
@@ -63,7 +126,25 @@ function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = glo
63
126
  operationTimeoutMs,
64
127
  storeName,
65
128
  });
66
- return createReadCache(driver ?? createNoopReadCacheDriver());
129
+ return createReadCache(driver
130
+ ? {
131
+ clear: driver.clear,
132
+ close: driver.close,
133
+ delete: driver.delete,
134
+ get: driver.get,
135
+ listEntries: driver.listEntries,
136
+ set: driver.set,
137
+ }
138
+ : createNoopReadCacheDriver());
139
+ }
140
+ /**
141
+ * Deletes only valid read-cache envelopes older than the supplied age. It is
142
+ * opt-in and returns zero for custom cache adapters that cannot enumerate keys.
143
+ */
144
+ function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
145
+ const resourceCache = cache.capture?.() ?? cache;
146
+ return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
147
+ Promise.resolve(0);
67
148
  }
68
149
  function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
69
150
  const serializedParams = Object.entries(params)
@@ -80,11 +161,26 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
80
161
  .filter(Boolean)
81
162
  .join(":");
82
163
  }
83
- async function readThroughCache({ cache, key, loadFresh, }) {
84
- const cached = await cache.get(key);
164
+ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
165
+ const resourceCache = cache.capture?.() ?? cache;
166
+ 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);
170
+ }
171
+ const version = resourceCache.getVersion(key);
172
+ const cached = await resourceCache.get(key);
173
+ if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
174
+ return {
175
+ data: cached.value,
176
+ error: null,
177
+ fromCache: true,
178
+ stale: false,
179
+ };
180
+ }
85
181
  try {
86
- const fresh = await loadFresh();
87
- await cache.set(key, fresh);
182
+ const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
183
+ await resourceCache.setIfVersion(key, fresh, version);
88
184
  return {
89
185
  data: fresh,
90
186
  error: null,
@@ -104,8 +200,10 @@ async function readThroughCache({ cache, key, loadFresh, }) {
104
200
  throw error;
105
201
  }
106
202
  }
107
- async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate, }) {
108
- const cached = await cache.get(key);
203
+ async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
204
+ const resourceCache = cache.capture?.() ?? cache;
205
+ const version = resourceCache.getVersion(key);
206
+ const cached = skipCache ? null : await resourceCache.get(key);
109
207
  if (cached) {
110
208
  onUpdate({ data: cached.value, fromCache: true });
111
209
  if (isCacheFresh(cached.savedAt, maxAgeMs)) {
@@ -113,9 +211,16 @@ async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate
113
211
  }
114
212
  }
115
213
  try {
116
- const fresh = await loadSharedFreshResource(key, loadFresh);
214
+ const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
117
215
  onUpdate({ data: fresh, fromCache: false });
118
- void cache.set(key, fresh).catch(() => undefined);
216
+ void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
217
+ const normalizedError = normalizeError(error);
218
+ console.error("[Bayon offline] Unable to refresh the read cache.", {
219
+ error: normalizedError,
220
+ key,
221
+ });
222
+ onCacheWriteError?.(normalizedError);
223
+ });
119
224
  return fresh;
120
225
  }
121
226
  catch (error) {
@@ -134,9 +239,13 @@ function createNoopReadCacheDriver() {
134
239
  return {
135
240
  async clear() { },
136
241
  async close() { },
242
+ async delete() { },
137
243
  async get() {
138
244
  return null;
139
245
  },
246
+ async listEntries() {
247
+ return [];
248
+ },
140
249
  async set() { },
141
250
  };
142
251
  }
@@ -147,22 +256,32 @@ function isCacheFresh(savedAt, maxAgeMs) {
147
256
  const savedTime = Date.parse(savedAt);
148
257
  return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
149
258
  }
150
- async function loadSharedFreshResource(key, loadFresh) {
151
- const pendingLoad = pendingFreshLoads.get(key);
259
+ async function loadSharedFreshResource(cache, key, loadFresh) {
260
+ const pendingLoads = getPendingFreshLoads(cache);
261
+ const pendingLoad = pendingLoads.get(key);
152
262
  if (pendingLoad) {
153
263
  return (await pendingLoad);
154
264
  }
155
265
  const nextLoad = loadFresh();
156
- pendingFreshLoads.set(key, nextLoad);
266
+ pendingLoads.set(key, nextLoad);
157
267
  try {
158
268
  return await nextLoad;
159
269
  }
160
270
  finally {
161
- if (pendingFreshLoads.get(key) === nextLoad) {
162
- pendingFreshLoads.delete(key);
271
+ if (pendingLoads.get(key) === nextLoad) {
272
+ pendingLoads.delete(key);
163
273
  }
164
274
  }
165
275
  }
276
+ function getPendingFreshLoads(cache) {
277
+ const existing = pendingFreshLoads.get(cache);
278
+ if (existing) {
279
+ return existing;
280
+ }
281
+ const next = new Map();
282
+ pendingFreshLoads.set(cache, next);
283
+ return next;
284
+ }
166
285
  function toReadCacheEnvelope(value) {
167
286
  if (!value || typeof value !== "object") {
168
287
  return null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.1.0",
4
- "description": "Reusable offline read-cache and pending-write primitives.",
3
+ "version": "0.3.0",
4
+ "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",
7
7
  "homepage": "https://github.com/toledoroy/bounded/tree/main/packages/offline#readme",
@@ -46,8 +46,10 @@
46
46
  "scripts": {
47
47
  "build": "tsc -p tsconfig.json && node scripts/write-esm-extensions.mjs && tsc -p tsconfig.cjs.json && node scripts/write-cjs.mjs",
48
48
  "lint": "tsc --noEmit -p tsconfig.json",
49
- "prepack": "pnpm run build",
50
- "test": "vitest run"
49
+ "pack:smoke": "corepack pnpm run build && npm pack --dry-run --access public",
50
+ "prepack": "corepack pnpm run build",
51
+ "test": "vitest run",
52
+ "test:coverage": "vitest run --coverage"
51
53
  },
52
54
  "dependencies": {
53
55
  "idb": "^8.0.3"