@bayonai/offline 0.1.1 → 0.4.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.
@@ -90,6 +90,30 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
90
90
  timeoutMs: operationTimeoutMs,
91
91
  });
92
92
  },
93
+ async listEntries() {
94
+ return runBrowserStorageOperation({
95
+ fallback: [],
96
+ operationName: "list object-store entries",
97
+ operation: async () => {
98
+ const database = await databasePromise;
99
+ // Keys and values must come from one transaction. Separate database
100
+ // reads could pair a key from one cross-tab storage state with a
101
+ // value from another, causing retention code to delete the wrong key.
102
+ const transaction = database.transaction(storeName, "readonly");
103
+ const objectStore = transaction.objectStore(storeName);
104
+ const [keys, values] = await Promise.all([
105
+ objectStore.getAllKeys(),
106
+ objectStore.getAll(),
107
+ ]);
108
+ await transaction.done;
109
+ return keys.map((key, index) => ({
110
+ key: String(key),
111
+ value: values[index],
112
+ }));
113
+ },
114
+ timeoutMs: operationTimeoutMs,
115
+ });
116
+ },
93
117
  async set(key, value) {
94
118
  await runBrowserStorageOperation({
95
119
  fallback: undefined,
package/dist-cjs/index.js CHANGED
@@ -16,3 +16,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./readCache"), exports);
18
18
  __exportStar(require("./pendingWrites"), exports);
19
+ __exportStar(require("./browserRecovery"), exports);
20
+ __exportStar(require("./browserCoordination"), exports);
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pendingWriteOperationTimeoutMs = exports.pendingWriteSchemaVersion = void 0;
3
+ exports.pendingWriteSyncLeaseTimeoutMs = exports.pendingWriteOperationTimeoutMs = exports.pendingWriteSchemaVersion = void 0;
4
4
  exports.createPendingWriteStore = createPendingWriteStore;
5
5
  exports.createMemoryPendingWriteDriver = createMemoryPendingWriteDriver;
6
6
  exports.createBrowserPendingWriteDriver = createBrowserPendingWriteDriver;
@@ -8,22 +8,61 @@ exports.createBrowserPendingWriteStore = createBrowserPendingWriteStore;
8
8
  exports.createPendingWriteKey = createPendingWriteKey;
9
9
  exports.createPendingWriteSnapshotId = createPendingWriteSnapshotId;
10
10
  exports.syncPendingWrite = syncPendingWrite;
11
+ exports.syncPendingWrites = syncPendingWrites;
12
+ exports.runPendingWrite = runPendingWrite;
13
+ exports.summarizePendingWrites = summarizePendingWrites;
14
+ exports.createOrderedPendingWriteExecutor = createOrderedPendingWriteExecutor;
11
15
  const idbDriver_1 = require("./idbDriver");
12
16
  exports.pendingWriteSchemaVersion = 1;
13
17
  exports.pendingWriteOperationTimeoutMs = 2_000;
18
+ exports.pendingWriteSyncLeaseTimeoutMs = 60_000;
14
19
  function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
20
+ const listeners = new Set();
21
+ const notify = (change) => {
22
+ for (const listener of listeners) {
23
+ try {
24
+ listener(change);
25
+ }
26
+ catch (error) {
27
+ console.error("[Bayon offline] Pending-write store listener failed.", {
28
+ error,
29
+ type: change.type,
30
+ });
31
+ }
32
+ }
33
+ };
15
34
  return {
35
+ async claimIfSnapshotId(id, snapshotId, record, now) {
36
+ const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
37
+ const current = toPendingWriteEnvelope(value, schemaVersion, {
38
+ normalizeLegacyRecord,
39
+ normalizePayload,
40
+ })?.record;
41
+ return (current?.snapshotId === snapshotId &&
42
+ isPendingWriteSyncClaimAvailable(current, now));
43
+ });
44
+ if (claimed) {
45
+ notify({ record, type: "replace" });
46
+ }
47
+ return claimed;
48
+ },
16
49
  async clear(id) {
17
50
  await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
51
+ notify({ id, type: "clear" });
18
52
  },
19
53
  async clearAll() {
20
54
  await driver.clearAll();
55
+ notify({ type: "clearAll" });
21
56
  },
22
57
  async clearIfSnapshotId(id, snapshotId) {
23
- return driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
58
+ const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
24
59
  normalizeLegacyRecord,
25
60
  normalizePayload,
26
61
  })?.record.snapshotId === snapshotId);
62
+ if (cleared) {
63
+ notify({ id, type: "clear" });
64
+ }
65
+ return cleared;
27
66
  },
28
67
  async close() {
29
68
  await driver.close?.();
@@ -38,13 +77,18 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
38
77
  normalizePayload,
39
78
  })?.record)
40
79
  .filter((record) => Boolean(record))
41
- .sort((left, right) => left.savedAt.localeCompare(right.savedAt));
80
+ .sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
81
+ left.id.localeCompare(right.id));
42
82
  },
43
83
  async replaceIfSnapshotId(id, snapshotId, record) {
44
- return driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
84
+ const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
45
85
  normalizeLegacyRecord,
46
86
  normalizePayload,
47
87
  })?.record.snapshotId === snapshotId);
88
+ if (replaced) {
89
+ notify({ record, type: "replace" });
90
+ }
91
+ return replaced;
48
92
  },
49
93
  async set(record) {
50
94
  const storedRecord = {
@@ -55,8 +99,13 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
55
99
  record: storedRecord,
56
100
  schemaVersion,
57
101
  });
102
+ notify({ record: storedRecord, type: "set" });
58
103
  return storedRecord;
59
104
  },
105
+ subscribe(listener) {
106
+ listeners.add(listener);
107
+ return () => listeners.delete(listener);
108
+ },
60
109
  };
61
110
  }
62
111
  function createMemoryPendingWriteDriver() {
@@ -121,34 +170,193 @@ function createPendingWriteKey({ id, prefix }) {
121
170
  function createPendingWriteSnapshotId() {
122
171
  return globalThis.crypto.randomUUID();
123
172
  }
124
- async function syncPendingWrite({ id, store, sync, }) {
173
+ async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
125
174
  const pending = await store.get(id);
126
175
  if (!pending) {
127
176
  return { status: "idle" };
128
177
  }
129
- await markPendingSnapshot({
130
- id,
131
- nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
132
- snapshotId: pending.snapshotId,
133
- store,
134
- });
178
+ const claimedSnapshotId = createPendingWriteSnapshotId();
179
+ const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
180
+ ...pending,
181
+ error: undefined,
182
+ snapshotId: claimedSnapshotId,
183
+ syncingAt: now(),
184
+ syncStatus: "syncing",
185
+ }, now());
186
+ if (!claimed) {
187
+ return { status: "superseded" };
188
+ }
135
189
  try {
136
190
  await sync(pending.payload);
137
191
  }
138
192
  catch (error) {
139
193
  const message = error instanceof Error ? error.message : "Unable to sync pending write.";
140
- await markPendingSnapshot({
194
+ const { syncingAt: _syncingAt, ...pendingWithoutSyncClaim } = pending;
195
+ const markedFailed = await markPendingSnapshot({
141
196
  id,
142
- nextRecord: { ...pending, error: message, syncStatus: "failed" },
143
- snapshotId: pending.snapshotId,
197
+ nextRecord: {
198
+ ...pendingWithoutSyncClaim,
199
+ error: message,
200
+ snapshotId: claimedSnapshotId,
201
+ syncStatus: "failed",
202
+ },
203
+ snapshotId: claimedSnapshotId,
144
204
  store,
145
205
  });
146
- return { error: message, status: "failed" };
206
+ return markedFailed
207
+ ? { error: message, status: "failed" }
208
+ : { status: "superseded" };
147
209
  }
148
- return (await store.clearIfSnapshotId(id, pending.snapshotId))
210
+ return (await store.clearIfSnapshotId(id, claimedSnapshotId))
149
211
  ? { status: "synced" }
150
212
  : { status: "superseded" };
151
213
  }
214
+ /**
215
+ * Replays selected writes oldest first and coalesces overlapping runs for the
216
+ * same store/scope. Callers own the selection predicate and idempotent remote
217
+ * mutation semantics.
218
+ */
219
+ function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
220
+ let inFlightByScope = pendingWriteSyncs.get(store);
221
+ if (!inFlightByScope) {
222
+ inFlightByScope = new Map();
223
+ pendingWriteSyncs.set(store, inFlightByScope);
224
+ }
225
+ const existing = inFlightByScope.get(scopeKey);
226
+ if (existing) {
227
+ return existing;
228
+ }
229
+ const syncRun = performPendingWriteSync({
230
+ select,
231
+ stopOnFailure,
232
+ store,
233
+ sync,
234
+ });
235
+ inFlightByScope.set(scopeKey, syncRun);
236
+ const clearInFlight = () => {
237
+ inFlightByScope.delete(scopeKey);
238
+ };
239
+ void syncRun.then(clearInFlight, clearInFlight);
240
+ return syncRun;
241
+ }
242
+ /**
243
+ * Attempts a remote write now and persists a caller-created durable fallback
244
+ * only when offline or when the supplied retry classifier accepts the error.
245
+ */
246
+ async function runPendingWrite({ enqueue, isOnline, shouldQueue, write, }) {
247
+ if (!isOnline()) {
248
+ return { queued: await enqueue(), status: "queued" };
249
+ }
250
+ try {
251
+ return { result: await write(), status: "synced" };
252
+ }
253
+ catch (error) {
254
+ if (!shouldQueue(error)) {
255
+ throw error;
256
+ }
257
+ return { queued: await enqueue(), status: "queued" };
258
+ }
259
+ }
260
+ function summarizePendingWrites(records) {
261
+ return records.reduce((summary, record) => {
262
+ summary.totalCount += 1;
263
+ if (record.syncStatus === "failed") {
264
+ summary.failedCount += 1;
265
+ }
266
+ else if (record.syncStatus === "syncing") {
267
+ summary.syncingCount += 1;
268
+ }
269
+ else {
270
+ summary.pendingCount += 1;
271
+ }
272
+ return summary;
273
+ }, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
274
+ }
275
+ const pendingWriteSyncs = new WeakMap();
276
+ async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
277
+ const summary = {
278
+ attempted: 0,
279
+ failed: 0,
280
+ idle: 0,
281
+ remaining: 0,
282
+ superseded: 0,
283
+ synced: 0,
284
+ };
285
+ const records = (await store.list()).filter(select);
286
+ for (const record of records) {
287
+ const result = await syncPendingWrite({
288
+ id: record.id,
289
+ store,
290
+ sync,
291
+ });
292
+ summary.attempted += 1;
293
+ summary[result.status] += 1;
294
+ if (stopOnFailure && result.status === "failed") {
295
+ break;
296
+ }
297
+ }
298
+ summary.remaining = (await store.list()).filter(select).length;
299
+ return summary;
300
+ }
301
+ function isPendingWriteSyncClaimAvailable(record, now) {
302
+ if (record.syncStatus !== "syncing") {
303
+ return true;
304
+ }
305
+ if (!record.syncingAt) {
306
+ return true;
307
+ }
308
+ const startedAt = Date.parse(record.syncingAt);
309
+ const currentTime = Date.parse(now);
310
+ return (!Number.isFinite(startedAt) ||
311
+ !Number.isFinite(currentTime) ||
312
+ currentTime - startedAt >= exports.pendingWriteSyncLeaseTimeoutMs);
313
+ }
314
+ /**
315
+ * Creates a serial execution lane for a durable scope such as one user or
316
+ * workspace. Use its `recover` method for background replay and `run` for new
317
+ * writes so a newer remote mutation cannot overtake an older queued mutation.
318
+ */
319
+ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
320
+ let execution = Promise.resolve();
321
+ function inOrder(operation) {
322
+ const result = execution.then(operation, operation);
323
+ execution = result.then(() => undefined, () => undefined);
324
+ return result;
325
+ }
326
+ function recover() {
327
+ return inOrder(() => syncPendingWrites({
328
+ scopeKey,
329
+ select,
330
+ stopOnFailure: true,
331
+ store,
332
+ sync,
333
+ }));
334
+ }
335
+ return {
336
+ recover,
337
+ run(options) {
338
+ return inOrder(async () => {
339
+ if (!options.isOnline()) {
340
+ return runPendingWrite(options);
341
+ }
342
+ const replay = await syncPendingWrites({
343
+ scopeKey,
344
+ select,
345
+ stopOnFailure: true,
346
+ store,
347
+ sync,
348
+ });
349
+ // Product decision: unresolved older records retain their position in
350
+ // this scope. Queue the later write rather than letting it overtake a
351
+ // replay whose remote effect may be ambiguous.
352
+ if (replay.remaining > 0) {
353
+ return { queued: await options.enqueue(), status: "queued" };
354
+ }
355
+ return runPendingWrite(options);
356
+ });
357
+ },
358
+ };
359
+ }
152
360
  function createNoopPendingWriteDriver() {
153
361
  return {
154
362
  async clear() { },
@@ -208,6 +416,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
208
416
  payload,
209
417
  savedAt: record.savedAt,
210
418
  snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
419
+ ...(typeof record.syncingAt === "string"
420
+ ? { syncingAt: record.syncingAt }
421
+ : {}),
211
422
  syncStatus: record.syncStatus,
212
423
  };
213
424
  }
@@ -233,6 +444,7 @@ function isPendingWriteRecord(value) {
233
444
  "savedAt" in value &&
234
445
  typeof value.savedAt === "string" &&
235
446
  (!("snapshotId" in value) || typeof value.snapshotId === "string") &&
447
+ (!("syncingAt" in value) || typeof value.syncingAt === "string") &&
236
448
  "syncStatus" in value &&
237
449
  (value.syncStatus === "pending" ||
238
450
  value.syncStatus === "syncing" ||
@@ -4,6 +4,7 @@ exports.browserReadCacheOperationTimeoutMs = exports.identityReadCacheFreshnessM
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;
@@ -14,6 +15,7 @@ exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
14
15
  exports.identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
15
16
  exports.browserReadCacheOperationTimeoutMs = 2_000;
16
17
  const pendingFreshLoads = new WeakMap();
18
+ const cachePruners = new WeakMap();
17
19
  function createReadCache(driver) {
18
20
  let globalVersion = 0;
19
21
  const keyVersions = new Map();
@@ -78,6 +80,21 @@ function createReadCache(driver) {
78
80
  },
79
81
  };
80
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
+ });
81
98
  return cache;
82
99
  }
83
100
  function createMemoryReadCacheDriver() {
@@ -93,6 +110,9 @@ function createMemoryReadCacheDriver() {
93
110
  async get(key) {
94
111
  return records.get(key) ?? null;
95
112
  },
113
+ async listEntries() {
114
+ return [...records.entries()].map(([key, value]) => ({ key, value }));
115
+ },
96
116
  async set(key, value) {
97
117
  records.set(key, value);
98
118
  },
@@ -106,7 +126,25 @@ function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = glo
106
126
  operationTimeoutMs,
107
127
  storeName,
108
128
  });
109
- 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);
110
148
  }
111
149
  function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
112
150
  const serializedParams = Object.entries(params)
@@ -123,8 +161,13 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
123
161
  .filter(Boolean)
124
162
  .join(":");
125
163
  }
126
- async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
164
+ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
127
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
+ }
128
171
  const version = resourceCache.getVersion(key);
129
172
  const cached = await resourceCache.get(key);
130
173
  if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
@@ -136,7 +179,7 @@ async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
136
179
  };
137
180
  }
138
181
  try {
139
- const fresh = await loadFresh();
182
+ const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
140
183
  await resourceCache.setIfVersion(key, fresh, version);
141
184
  return {
142
185
  data: fresh,
@@ -200,6 +243,9 @@ function createNoopReadCacheDriver() {
200
243
  async get() {
201
244
  return null;
202
245
  },
246
+ async listEntries() {
247
+ return [];
248
+ },
203
249
  async set() { },
204
250
  };
205
251
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.1.1",
4
- "description": "Reusable offline read-cache and pending-write primitives.",
3
+ "version": "0.4.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",