@bayonai/offline 0.1.1 → 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.
@@ -8,22 +8,45 @@ 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;
11
14
  const idbDriver_1 = require("./idbDriver");
12
15
  exports.pendingWriteSchemaVersion = 1;
13
16
  exports.pendingWriteOperationTimeoutMs = 2_000;
14
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
+ };
15
32
  return {
16
33
  async clear(id) {
17
34
  await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
35
+ notify({ id, type: "clear" });
18
36
  },
19
37
  async clearAll() {
20
38
  await driver.clearAll();
39
+ notify({ type: "clearAll" });
21
40
  },
22
41
  async clearIfSnapshotId(id, snapshotId) {
23
- return driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
42
+ const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
24
43
  normalizeLegacyRecord,
25
44
  normalizePayload,
26
45
  })?.record.snapshotId === snapshotId);
46
+ if (cleared) {
47
+ notify({ id, type: "clear" });
48
+ }
49
+ return cleared;
27
50
  },
28
51
  async close() {
29
52
  await driver.close?.();
@@ -38,13 +61,18 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
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));
42
66
  },
43
67
  async replaceIfSnapshotId(id, snapshotId, record) {
44
- return driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
68
+ const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
45
69
  normalizeLegacyRecord,
46
70
  normalizePayload,
47
71
  })?.record.snapshotId === snapshotId);
72
+ if (replaced) {
73
+ notify({ record, type: "replace" });
74
+ }
75
+ return replaced;
48
76
  },
49
77
  async set(record) {
50
78
  const storedRecord = {
@@ -55,8 +83,13 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
55
83
  record: storedRecord,
56
84
  schemaVersion,
57
85
  });
86
+ notify({ record: storedRecord, type: "set" });
58
87
  return storedRecord;
59
88
  },
89
+ subscribe(listener) {
90
+ listeners.add(listener);
91
+ return () => listeners.delete(listener);
92
+ },
60
93
  };
61
94
  }
62
95
  function createMemoryPendingWriteDriver() {
@@ -149,6 +182,85 @@ async function syncPendingWrite({ id, store, sync, }) {
149
182
  ? { status: "synced" }
150
183
  : { status: "superseded" };
151
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
+ }
152
264
  function createNoopPendingWriteDriver() {
153
265
  return {
154
266
  async clear() { },
@@ -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.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",