@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.
@@ -2,18 +2,38 @@ import { createIdbKeyValueDriver, } from "./idbDriver.js";
2
2
  export const pendingWriteSchemaVersion = 1;
3
3
  export const pendingWriteOperationTimeoutMs = 2_000;
4
4
  export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = pendingWriteSchemaVersion, }) {
5
+ const listeners = new Set();
6
+ const notify = (change) => {
7
+ for (const listener of listeners) {
8
+ try {
9
+ listener(change);
10
+ }
11
+ catch (error) {
12
+ console.error("[Bayon offline] Pending-write store listener failed.", {
13
+ error,
14
+ type: change.type,
15
+ });
16
+ }
17
+ }
18
+ };
5
19
  return {
6
20
  async clear(id) {
7
21
  await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
22
+ notify({ id, type: "clear" });
8
23
  },
9
24
  async clearAll() {
10
25
  await driver.clearAll();
26
+ notify({ type: "clearAll" });
11
27
  },
12
28
  async clearIfSnapshotId(id, snapshotId) {
13
- return driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
29
+ const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
14
30
  normalizeLegacyRecord,
15
31
  normalizePayload,
16
32
  })?.record.snapshotId === snapshotId);
33
+ if (cleared) {
34
+ notify({ id, type: "clear" });
35
+ }
36
+ return cleared;
17
37
  },
18
38
  async close() {
19
39
  await driver.close?.();
@@ -28,13 +48,18 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
28
48
  normalizePayload,
29
49
  })?.record)
30
50
  .filter((record) => Boolean(record))
31
- .sort((left, right) => left.savedAt.localeCompare(right.savedAt));
51
+ .sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
52
+ left.id.localeCompare(right.id));
32
53
  },
33
54
  async replaceIfSnapshotId(id, snapshotId, record) {
34
- return driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
55
+ const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
35
56
  normalizeLegacyRecord,
36
57
  normalizePayload,
37
58
  })?.record.snapshotId === snapshotId);
59
+ if (replaced) {
60
+ notify({ record, type: "replace" });
61
+ }
62
+ return replaced;
38
63
  },
39
64
  async set(record) {
40
65
  const storedRecord = {
@@ -45,8 +70,13 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
45
70
  record: storedRecord,
46
71
  schemaVersion,
47
72
  });
73
+ notify({ record: storedRecord, type: "set" });
48
74
  return storedRecord;
49
75
  },
76
+ subscribe(listener) {
77
+ listeners.add(listener);
78
+ return () => listeners.delete(listener);
79
+ },
50
80
  };
51
81
  }
52
82
  export function createMemoryPendingWriteDriver() {
@@ -139,6 +169,85 @@ export async function syncPendingWrite({ id, store, sync, }) {
139
169
  ? { status: "synced" }
140
170
  : { status: "superseded" };
141
171
  }
172
+ /**
173
+ * Replays selected writes oldest first and coalesces overlapping runs for the
174
+ * same store/scope. Callers own the selection predicate and idempotent remote
175
+ * mutation semantics.
176
+ */
177
+ export function syncPendingWrites({ scopeKey = "all", select = () => true, store, sync, }) {
178
+ let inFlightByScope = pendingWriteSyncs.get(store);
179
+ if (!inFlightByScope) {
180
+ inFlightByScope = new Map();
181
+ pendingWriteSyncs.set(store, inFlightByScope);
182
+ }
183
+ const existing = inFlightByScope.get(scopeKey);
184
+ if (existing) {
185
+ return existing;
186
+ }
187
+ const syncRun = performPendingWriteSync({ select, store, sync });
188
+ inFlightByScope.set(scopeKey, syncRun);
189
+ const clearInFlight = () => {
190
+ inFlightByScope.delete(scopeKey);
191
+ };
192
+ void syncRun.then(clearInFlight, clearInFlight);
193
+ return syncRun;
194
+ }
195
+ /**
196
+ * Attempts a remote write now and persists a caller-created durable fallback
197
+ * only when offline or when the supplied retry classifier accepts the error.
198
+ */
199
+ export async function runPendingWrite({ enqueue, isOnline, shouldQueue, write, }) {
200
+ if (!isOnline()) {
201
+ return { queued: await enqueue(), status: "queued" };
202
+ }
203
+ try {
204
+ return { result: await write(), status: "synced" };
205
+ }
206
+ catch (error) {
207
+ if (!shouldQueue(error)) {
208
+ throw error;
209
+ }
210
+ return { queued: await enqueue(), status: "queued" };
211
+ }
212
+ }
213
+ export function summarizePendingWrites(records) {
214
+ return records.reduce((summary, record) => {
215
+ summary.totalCount += 1;
216
+ if (record.syncStatus === "failed") {
217
+ summary.failedCount += 1;
218
+ }
219
+ else if (record.syncStatus === "syncing") {
220
+ summary.syncingCount += 1;
221
+ }
222
+ else {
223
+ summary.pendingCount += 1;
224
+ }
225
+ return summary;
226
+ }, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
227
+ }
228
+ const pendingWriteSyncs = new WeakMap();
229
+ async function performPendingWriteSync({ select, store, sync, }) {
230
+ const summary = {
231
+ attempted: 0,
232
+ failed: 0,
233
+ idle: 0,
234
+ remaining: 0,
235
+ superseded: 0,
236
+ synced: 0,
237
+ };
238
+ const records = (await store.list()).filter(select);
239
+ for (const record of records) {
240
+ const result = await syncPendingWrite({
241
+ id: record.id,
242
+ store,
243
+ sync,
244
+ });
245
+ summary.attempted += 1;
246
+ summary[result.status] += 1;
247
+ }
248
+ summary.remaining = (await store.list()).filter(select).length;
249
+ return summary;
250
+ }
142
251
  function createNoopPendingWriteDriver() {
143
252
  return {
144
253
  async clear() { },
@@ -14,6 +14,10 @@ export type ReadCacheDriver = {
14
14
  close?: () => Promise<void>;
15
15
  delete: (key: string) => Promise<void>;
16
16
  get: (key: string) => Promise<unknown | null>;
17
+ listEntries?: () => Promise<Array<{
18
+ key: string;
19
+ value: unknown;
20
+ }>>;
17
21
  set: (key: string, value: unknown) => Promise<void>;
18
22
  };
19
23
  export type ReadCache = {
@@ -55,9 +59,23 @@ export type ReadCacheKeyOptions = {
55
59
  export declare function createReadCache(driver: ReadCacheDriver): ReadCache;
56
60
  export declare function createMemoryReadCacheDriver(): ReadCacheDriver;
57
61
  export declare function createBrowserReadCache({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserReadCacheOptions): ReadCache;
62
+ /**
63
+ * Deletes only valid read-cache envelopes older than the supplied age. It is
64
+ * opt-in and returns zero for custom cache adapters that cannot enumerate keys.
65
+ */
66
+ export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
67
+ cache: ReadCache;
68
+ maxAgeMs: number;
69
+ now?: () => number;
70
+ }): Promise<number>;
58
71
  export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
59
- export declare function readThroughCache<T>({ cache, key, loadFresh, maxAgeMs, }: {
72
+ export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, }: {
60
73
  cache: ReadCache;
74
+ /**
75
+ * Invalidates the current value before loading. Use after a successful write
76
+ * when a cached or earlier in-flight response must not become authoritative.
77
+ */
78
+ forceFresh?: boolean;
61
79
  key: string;
62
80
  loadFresh: () => Promise<T>;
63
81
  maxAgeMs?: number;
package/dist/readCache.js CHANGED
@@ -5,6 +5,7 @@ export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
5
5
  export const identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
6
6
  export const browserReadCacheOperationTimeoutMs = 2_000;
7
7
  const pendingFreshLoads = new WeakMap();
8
+ const cachePruners = new WeakMap();
8
9
  export function createReadCache(driver) {
9
10
  let globalVersion = 0;
10
11
  const keyVersions = new Map();
@@ -69,6 +70,21 @@ export function createReadCache(driver) {
69
70
  },
70
71
  };
71
72
  pendingFreshLoads.set(cache, pendingLoads);
73
+ cachePruners.set(cache, async ({ maxAgeMs, now }) => {
74
+ if (!driver.listEntries || maxAgeMs < 0) {
75
+ return 0;
76
+ }
77
+ const cutoff = now() - maxAgeMs;
78
+ const expiredKeys = (await driver.listEntries())
79
+ .filter(({ value }) => {
80
+ const envelope = toReadCacheEnvelope(value);
81
+ const savedAt = envelope ? Date.parse(envelope.savedAt) : Number.NaN;
82
+ return Number.isFinite(savedAt) && savedAt < cutoff;
83
+ })
84
+ .map(({ key }) => key);
85
+ await Promise.all(expiredKeys.map((key) => cache.delete(key)));
86
+ return expiredKeys.length;
87
+ });
72
88
  return cache;
73
89
  }
74
90
  export function createMemoryReadCacheDriver() {
@@ -84,6 +100,9 @@ export function createMemoryReadCacheDriver() {
84
100
  async get(key) {
85
101
  return records.get(key) ?? null;
86
102
  },
103
+ async listEntries() {
104
+ return [...records.entries()].map(([key, value]) => ({ key, value }));
105
+ },
87
106
  async set(key, value) {
88
107
  records.set(key, value);
89
108
  },
@@ -97,7 +116,25 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
97
116
  operationTimeoutMs,
98
117
  storeName,
99
118
  });
100
- return createReadCache(driver ?? createNoopReadCacheDriver());
119
+ return createReadCache(driver
120
+ ? {
121
+ clear: driver.clear,
122
+ close: driver.close,
123
+ delete: driver.delete,
124
+ get: driver.get,
125
+ listEntries: driver.listEntries,
126
+ set: driver.set,
127
+ }
128
+ : createNoopReadCacheDriver());
129
+ }
130
+ /**
131
+ * Deletes only valid read-cache envelopes older than the supplied age. It is
132
+ * opt-in and returns zero for custom cache adapters that cannot enumerate keys.
133
+ */
134
+ export function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
135
+ const resourceCache = cache.capture?.() ?? cache;
136
+ return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
137
+ Promise.resolve(0);
101
138
  }
102
139
  export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
103
140
  const serializedParams = Object.entries(params)
@@ -114,8 +151,13 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
114
151
  .filter(Boolean)
115
152
  .join(":");
116
153
  }
117
- export async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
154
+ export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
118
155
  const resourceCache = cache.capture?.() ?? cache;
156
+ if (forceFresh) {
157
+ // Deletion advances the cache version, so a request started before this
158
+ // post-write refresh cannot restore stale data after it finishes.
159
+ await resourceCache.delete(key);
160
+ }
119
161
  const version = resourceCache.getVersion(key);
120
162
  const cached = await resourceCache.get(key);
121
163
  if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
@@ -127,7 +169,7 @@ export async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
127
169
  };
128
170
  }
129
171
  try {
130
- const fresh = await loadFresh();
172
+ const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
131
173
  await resourceCache.setIfVersion(key, fresh, version);
132
174
  return {
133
175
  data: fresh,
@@ -191,6 +233,9 @@ function createNoopReadCacheDriver() {
191
233
  async get() {
192
234
  return null;
193
235
  },
236
+ async listEntries() {
237
+ return [];
238
+ },
194
239
  async set() { },
195
240
  };
196
241
  }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createBrowserPendingWriteRecoveryCoordination = createBrowserPendingWriteRecoveryCoordination;
4
+ function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel = createDefaultChannel(channelName), locks = readDefaultLockManager(), }) {
5
+ const listeners = new Set();
6
+ const handleMessage = () => {
7
+ for (const listener of listeners) {
8
+ try {
9
+ listener();
10
+ }
11
+ catch (error) {
12
+ console.error("[Bayon offline] Cross-tab recovery listener failed.", {
13
+ channelName,
14
+ error,
15
+ });
16
+ }
17
+ }
18
+ };
19
+ channel?.addEventListener("message", handleMessage);
20
+ return {
21
+ close() {
22
+ channel?.removeEventListener("message", handleMessage);
23
+ channel?.close();
24
+ listeners.clear();
25
+ },
26
+ notify() {
27
+ try {
28
+ channel?.postMessage("pending-write-recovery");
29
+ }
30
+ catch (error) {
31
+ console.error("[Bayon offline] Unable to wake cross-tab recovery.", {
32
+ channelName,
33
+ error,
34
+ });
35
+ }
36
+ },
37
+ async runExclusive(attempt) {
38
+ if (!locks) {
39
+ return attempt();
40
+ }
41
+ return locks.request(leaseName, { ifAvailable: true }, (lock) => lock ? attempt() : Promise.resolve(null));
42
+ },
43
+ subscribe(listener) {
44
+ listeners.add(listener);
45
+ return () => listeners.delete(listener);
46
+ },
47
+ };
48
+ }
49
+ function createDefaultChannel(name) {
50
+ if (typeof BroadcastChannel === "undefined") {
51
+ return null;
52
+ }
53
+ return new BroadcastChannel(name);
54
+ }
55
+ function readDefaultLockManager() {
56
+ if (typeof navigator === "undefined") {
57
+ return null;
58
+ }
59
+ return navigator.locks ?? null;
60
+ }
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.maxPendingWriteRetryDelayMs = exports.initialPendingWriteRetryDelayMs = void 0;
4
+ exports.createBrowserPendingWriteRecovery = createBrowserPendingWriteRecovery;
5
+ const pendingWrites_1 = require("./pendingWrites");
6
+ exports.initialPendingWriteRetryDelayMs = 2_000;
7
+ exports.maxPendingWriteRetryDelayMs = 60_000;
8
+ function createBrowserPendingWriteRecovery({ environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = exports.initialPendingWriteRetryDelayMs, maxRetryDelayMs = exports.maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, sync, }) {
9
+ let active = false;
10
+ let retryDelayMs = Math.max(0, initialRetryDelayMs);
11
+ let retryTimer;
12
+ let recoveryPromise;
13
+ let immediateRecoveryRequested = false;
14
+ let unsubscribePeerWakeups;
15
+ let unsubscribe;
16
+ function clearRetryTimer() {
17
+ if (retryTimer) {
18
+ environment.clearTimeout(retryTimer);
19
+ }
20
+ retryTimer = undefined;
21
+ }
22
+ function emitResult(result) {
23
+ try {
24
+ onResult?.(result);
25
+ }
26
+ catch (error) {
27
+ console.error("[Bayon offline] Pending-write recovery result listener failed.", {
28
+ error,
29
+ });
30
+ }
31
+ }
32
+ function emitError(error) {
33
+ console.error("[Bayon offline] Pending-write recovery failed.", {
34
+ error,
35
+ scopeKey,
36
+ });
37
+ try {
38
+ onError?.(error);
39
+ }
40
+ catch (callbackError) {
41
+ console.error("[Bayon offline] Pending-write recovery error listener failed.", {
42
+ callbackError,
43
+ scopeKey,
44
+ });
45
+ }
46
+ }
47
+ function scheduleRetry() {
48
+ if (!active || !environment.isOnline() || retryTimer) {
49
+ return;
50
+ }
51
+ retryTimer = environment.setTimeout(() => {
52
+ retryTimer = undefined;
53
+ void recoverNow();
54
+ }, retryDelayMs);
55
+ retryDelayMs = Math.min(Math.max(initialRetryDelayMs, retryDelayMs * 2), Math.max(initialRetryDelayMs, maxRetryDelayMs));
56
+ }
57
+ function recoverNow() {
58
+ if (!environment.isOnline()) {
59
+ clearRetryTimer();
60
+ return Promise.resolve(null);
61
+ }
62
+ if (recoveryPromise) {
63
+ return recoveryPromise;
64
+ }
65
+ const attempt = (async () => {
66
+ try {
67
+ const run = () => (0, pendingWrites_1.syncPendingWrites)({
68
+ scopeKey,
69
+ select,
70
+ store,
71
+ sync,
72
+ });
73
+ const result = coordination
74
+ ? await coordination.runExclusive(run)
75
+ : await run();
76
+ if (!result) {
77
+ return null;
78
+ }
79
+ emitResult(result);
80
+ if (result.remaining > 0) {
81
+ scheduleRetry();
82
+ }
83
+ else {
84
+ retryDelayMs = Math.max(0, initialRetryDelayMs);
85
+ }
86
+ return result;
87
+ }
88
+ catch (error) {
89
+ emitError(error);
90
+ scheduleRetry();
91
+ return null;
92
+ }
93
+ })();
94
+ recoveryPromise = attempt.finally(() => {
95
+ recoveryPromise = undefined;
96
+ if (active && immediateRecoveryRequested) {
97
+ immediateRecoveryRequested = false;
98
+ triggerImmediateRecovery();
99
+ }
100
+ });
101
+ return recoveryPromise;
102
+ }
103
+ function triggerImmediateRecovery() {
104
+ if (!active) {
105
+ return;
106
+ }
107
+ immediateRecoveryRequested ||= Boolean(recoveryPromise);
108
+ clearRetryTimer();
109
+ retryDelayMs = Math.max(0, initialRetryDelayMs);
110
+ void recoverNow();
111
+ }
112
+ function handleOffline() {
113
+ clearRetryTimer();
114
+ }
115
+ function handleStoreChange(change) {
116
+ if (change.type === "set") {
117
+ coordination?.notify();
118
+ triggerImmediateRecovery();
119
+ }
120
+ }
121
+ return {
122
+ notifyQueued() {
123
+ coordination?.notify();
124
+ triggerImmediateRecovery();
125
+ },
126
+ recoverNow,
127
+ start() {
128
+ if (active) {
129
+ return;
130
+ }
131
+ active = true;
132
+ unsubscribe = store.subscribe(handleStoreChange);
133
+ unsubscribePeerWakeups = coordination?.subscribe(triggerImmediateRecovery);
134
+ environment.addEventListener("focus", triggerImmediateRecovery);
135
+ environment.addEventListener("online", triggerImmediateRecovery);
136
+ environment.addEventListener("offline", handleOffline);
137
+ if (environment.isOnline()) {
138
+ triggerImmediateRecovery();
139
+ }
140
+ },
141
+ stop() {
142
+ if (!active) {
143
+ return;
144
+ }
145
+ active = false;
146
+ clearRetryTimer();
147
+ unsubscribe?.();
148
+ unsubscribe = undefined;
149
+ unsubscribePeerWakeups?.();
150
+ unsubscribePeerWakeups = undefined;
151
+ environment.removeEventListener("focus", triggerImmediateRecovery);
152
+ environment.removeEventListener("online", triggerImmediateRecovery);
153
+ environment.removeEventListener("offline", handleOffline);
154
+ },
155
+ };
156
+ }
157
+ function createDefaultBrowserRecoveryEnvironment() {
158
+ const target = typeof window === "undefined" ? null : window;
159
+ return {
160
+ addEventListener(type, listener) {
161
+ target?.addEventListener(type, listener);
162
+ },
163
+ clearTimeout(timer) {
164
+ globalThis.clearTimeout(timer);
165
+ },
166
+ isOnline() {
167
+ return typeof navigator !== "undefined" && navigator.onLine;
168
+ },
169
+ removeEventListener(type, listener) {
170
+ target?.removeEventListener(type, listener);
171
+ },
172
+ setTimeout(callback, delayMs) {
173
+ return globalThis.setTimeout(callback, delayMs);
174
+ },
175
+ };
176
+ }
@@ -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);