@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.
- package/CHANGELOG.md +25 -1
- package/README.md +89 -1
- package/dist/browserCoordination.d.ts +23 -0
- package/dist/browserCoordination.js +57 -0
- package/dist/browserRecovery.d.ts +34 -0
- package/dist/browserRecovery.js +172 -0
- package/dist/idbDriver.d.ts +7 -0
- package/dist/idbDriver.js +81 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pendingWrites.d.ts +78 -5
- package/dist/pendingWrites.js +189 -35
- package/dist/readCache.d.ts +34 -2
- package/dist/readCache.js +138 -20
- package/dist-cjs/browserCoordination.js +60 -0
- package/dist-cjs/browserRecovery.js +176 -0
- package/dist-cjs/idbDriver.js +81 -3
- package/dist-cjs/index.js +2 -0
- package/dist-cjs/pendingWrites.js +193 -35
- package/dist-cjs/readCache.js +140 -21
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.3.0 - 2026-08-31
|
|
4
4
|
|
|
5
|
+
- Added optional `BroadcastChannel` wake-ups and `navigator.locks` replay
|
|
6
|
+
leases so browser tabs can avoid concurrent recovery attempts while retaining
|
|
7
|
+
a safe local fallback where either API is unavailable.
|
|
8
|
+
- Added `summarizePendingWrites` for framework-neutral pending/syncing/failed
|
|
9
|
+
counts without exposing any product UI policy.
|
|
10
|
+
- Added opt-in `pruneReadCache`, which deletes only valid read-cache envelopes
|
|
11
|
+
older than a host-supplied retention age. Pending writes are never pruned.
|
|
12
|
+
|
|
13
|
+
## 0.2.0 - 2026-08-31
|
|
14
|
+
|
|
15
|
+
- Added forced-fresh, per-key single-flight `readThroughCache` loads so a
|
|
16
|
+
post-mutation read cannot reuse or restore an earlier cached response.
|
|
17
|
+
- Added generic write-or-queue and chronological, scoped batch replay helpers.
|
|
18
|
+
- Added local pending-store subscriptions and a framework-free browser recovery
|
|
19
|
+
controller with silent startup/focus/reconnect/new-write replay plus bounded
|
|
20
|
+
exponential retry.
|
|
21
|
+
- Documented the IndexedDB database/store-version topology constraint for host
|
|
22
|
+
applications that initialize more than one object store.
|
|
23
|
+
|
|
24
|
+
## 0.1.1 - 2026-08-10
|
|
25
|
+
|
|
26
|
+
- Added unique pending-write snapshot identities and atomic conditional
|
|
27
|
+
replacement/deletion, preventing overlapping sync and autosave operations
|
|
28
|
+
from deleting newer local data. Existing records remain compatible.
|
|
5
29
|
- Added the initial workspace package with generic read-cache and pending-write
|
|
6
30
|
primitives backed by `idb`.
|
|
7
31
|
- Replaced app-specific test fixtures with neutral package examples.
|
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @bayonai/offline
|
|
2
2
|
|
|
3
|
-
Reusable offline read-cache
|
|
3
|
+
Reusable offline read-cache, pending-write, and silent browser-recovery
|
|
4
|
+
primitives.
|
|
4
5
|
|
|
5
6
|
This package owns generic browser and memory storage behavior. Host apps own
|
|
6
7
|
their domain records, remote APIs, cache prefixes, database names, and sync
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
createBrowserReadCache,
|
|
24
25
|
createReadCacheKey,
|
|
25
26
|
loadReadCacheResource,
|
|
27
|
+
pruneReadCache,
|
|
26
28
|
} from "@bayonai/offline";
|
|
27
29
|
|
|
28
30
|
const cache = createBrowserReadCache({
|
|
@@ -48,6 +50,21 @@ const projects = await loadReadCacheResource({
|
|
|
48
50
|
});
|
|
49
51
|
```
|
|
50
52
|
|
|
53
|
+
After a successful write, use `readThroughCache({ forceFresh: true })` when
|
|
54
|
+
the next read must be server-confirmed rather than a pre-write cached or
|
|
55
|
+
in-flight response. Concurrent ordinary reads for the same cache/key share one
|
|
56
|
+
fresh load; a forced-fresh read starts a new load after invalidating the key.
|
|
57
|
+
|
|
58
|
+
Read-cache retention is deliberately opt-in. It removes only valid cache
|
|
59
|
+
envelopes older than the chosen lifetime; it never affects pending writes.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const removed = await pruneReadCache({
|
|
63
|
+
cache,
|
|
64
|
+
maxAgeMs: 7 * 24 * 60 * 60_000,
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
51
68
|
## Pending Writes
|
|
52
69
|
|
|
53
70
|
```ts
|
|
@@ -81,6 +98,72 @@ await syncPendingWrite({
|
|
|
81
98
|
});
|
|
82
99
|
```
|
|
83
100
|
|
|
101
|
+
For a browser application that wants durable, silent recovery, mount the
|
|
102
|
+
headless controller once for the active account or workspace:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import {
|
|
106
|
+
createBrowserPendingWriteRecovery,
|
|
107
|
+
createBrowserPendingWriteRecoveryCoordination,
|
|
108
|
+
runPendingWrite,
|
|
109
|
+
summarizePendingWrites,
|
|
110
|
+
} from "@bayonai/offline";
|
|
111
|
+
|
|
112
|
+
const coordination = createBrowserPendingWriteRecoveryCoordination({
|
|
113
|
+
// Scope names must be stable per user/workspace and unique to this app.
|
|
114
|
+
channelName: `my-app:pending-recovery:${currentUser.id}`,
|
|
115
|
+
leaseName: `my-app:pending-recovery:${currentUser.id}`,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const recovery = createBrowserPendingWriteRecovery({
|
|
119
|
+
coordination,
|
|
120
|
+
scopeKey: currentUser.id,
|
|
121
|
+
select: (record) => record.payload.userId === currentUser.id,
|
|
122
|
+
store,
|
|
123
|
+
sync: (mutation) => replayMutation(mutation),
|
|
124
|
+
onError: (error) => console.error("Pending replay failed", { error }),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
recovery.start(); // silent initial, focus, online, and local-set recovery
|
|
128
|
+
|
|
129
|
+
const result = await runPendingWrite({
|
|
130
|
+
isOnline: () => navigator.onLine,
|
|
131
|
+
write: () => saveRemotely(),
|
|
132
|
+
shouldQueue: isRetryableNetworkError,
|
|
133
|
+
enqueue: () => store.set(createPendingMutation()),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// On unmount or account change:
|
|
137
|
+
recovery.stop();
|
|
138
|
+
coordination.close();
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The controller never creates product UI. It preserves failed writes locally,
|
|
142
|
+
retries remaining selected writes with bounded exponential backoff, and exposes
|
|
143
|
+
only `onResult`/`onError` callbacks for host diagnostics or telemetry. Stores
|
|
144
|
+
also expose a local-instance `subscribe` API; it is not a cross-tab delivery
|
|
145
|
+
mechanism.
|
|
146
|
+
|
|
147
|
+
When coordination is supplied, a queued write wakes peer tabs through
|
|
148
|
+
`BroadcastChannel`, and each recovery attempt acquires a non-blocking
|
|
149
|
+
`navigator.locks` lease when supported. Browsers without either API recover in
|
|
150
|
+
the current tab as before. Remote write handlers must remain idempotent because
|
|
151
|
+
leases do not cover other devices or every browser failure mode.
|
|
152
|
+
|
|
153
|
+
Use generic record summaries when an app needs to render its own status:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const summary = summarizePendingWrites(await store.list());
|
|
157
|
+
// { pendingCount, syncingCount, failedCount, totalCount }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Each call to `set` assigns a unique `snapshotId`. Sync status updates and
|
|
161
|
+
successful cleanup compare that identity inside a single storage transaction,
|
|
162
|
+
so a newer local write cannot be overwritten or deleted by an older sync.
|
|
163
|
+
Records created before snapshot identities were introduced remain readable;
|
|
164
|
+
they receive a stable compatibility identity and are upgraded when next
|
|
165
|
+
updated.
|
|
166
|
+
|
|
84
167
|
Host apps that change their pending-write payload shape can normalize current
|
|
85
168
|
records while keeping domain logic outside the package:
|
|
86
169
|
|
|
@@ -108,6 +191,11 @@ Host apps are also responsible for:
|
|
|
108
191
|
- Deciding when to clear browser storage, such as on sign-out.
|
|
109
192
|
- Providing remote read and write functions.
|
|
110
193
|
|
|
194
|
+
Use a separate IndexedDB database for independently initialized stores unless
|
|
195
|
+
the host coordinates a database-version upgrade that creates every required
|
|
196
|
+
object store. IndexedDB cannot add a missing store when an existing database is
|
|
197
|
+
opened at the same version.
|
|
198
|
+
|
|
111
199
|
## Package Scripts
|
|
112
200
|
|
|
113
201
|
```bash
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type BrowserRecoveryMessageChannel = {
|
|
2
|
+
addEventListener: (type: "message", listener: () => void) => void;
|
|
3
|
+
close: () => void;
|
|
4
|
+
postMessage: (message: "pending-write-recovery") => void;
|
|
5
|
+
removeEventListener: (type: "message", listener: () => void) => void;
|
|
6
|
+
};
|
|
7
|
+
export type BrowserRecoveryLockManager = {
|
|
8
|
+
request: <T>(name: string, options: {
|
|
9
|
+
ifAvailable: true;
|
|
10
|
+
}, callback: (lock: unknown | null) => Promise<T | null>) => Promise<T | null>;
|
|
11
|
+
};
|
|
12
|
+
export type BrowserPendingWriteRecoveryCoordination = {
|
|
13
|
+
close: () => void;
|
|
14
|
+
notify: () => void;
|
|
15
|
+
runExclusive: <T>(attempt: () => Promise<T>) => Promise<T | null>;
|
|
16
|
+
subscribe: (listener: () => void) => () => void;
|
|
17
|
+
};
|
|
18
|
+
export declare function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel, locks, }: {
|
|
19
|
+
channel?: BrowserRecoveryMessageChannel | null;
|
|
20
|
+
channelName: string;
|
|
21
|
+
leaseName: string;
|
|
22
|
+
locks?: BrowserRecoveryLockManager | null;
|
|
23
|
+
}): BrowserPendingWriteRecoveryCoordination;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel = createDefaultChannel(channelName), locks = readDefaultLockManager(), }) {
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
const handleMessage = () => {
|
|
4
|
+
for (const listener of listeners) {
|
|
5
|
+
try {
|
|
6
|
+
listener();
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
console.error("[Bayon offline] Cross-tab recovery listener failed.", {
|
|
10
|
+
channelName,
|
|
11
|
+
error,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
channel?.addEventListener("message", handleMessage);
|
|
17
|
+
return {
|
|
18
|
+
close() {
|
|
19
|
+
channel?.removeEventListener("message", handleMessage);
|
|
20
|
+
channel?.close();
|
|
21
|
+
listeners.clear();
|
|
22
|
+
},
|
|
23
|
+
notify() {
|
|
24
|
+
try {
|
|
25
|
+
channel?.postMessage("pending-write-recovery");
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
console.error("[Bayon offline] Unable to wake cross-tab recovery.", {
|
|
29
|
+
channelName,
|
|
30
|
+
error,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
async runExclusive(attempt) {
|
|
35
|
+
if (!locks) {
|
|
36
|
+
return attempt();
|
|
37
|
+
}
|
|
38
|
+
return locks.request(leaseName, { ifAvailable: true }, (lock) => lock ? attempt() : Promise.resolve(null));
|
|
39
|
+
},
|
|
40
|
+
subscribe(listener) {
|
|
41
|
+
listeners.add(listener);
|
|
42
|
+
return () => listeners.delete(listener);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function createDefaultChannel(name) {
|
|
47
|
+
if (typeof BroadcastChannel === "undefined") {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return new BroadcastChannel(name);
|
|
51
|
+
}
|
|
52
|
+
function readDefaultLockManager() {
|
|
53
|
+
if (typeof navigator === "undefined") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return navigator.locks ?? null;
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites";
|
|
2
|
+
import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination";
|
|
3
|
+
export declare const initialPendingWriteRetryDelayMs = 2000;
|
|
4
|
+
export declare const maxPendingWriteRetryDelayMs = 60000;
|
|
5
|
+
export type BrowserPendingWriteRecoveryEnvironment = {
|
|
6
|
+
addEventListener: (type: "focus" | "offline" | "online", listener: () => void) => void;
|
|
7
|
+
clearTimeout: (timer: ReturnType<typeof setTimeout>) => void;
|
|
8
|
+
isOnline: () => boolean;
|
|
9
|
+
removeEventListener: (type: "focus" | "offline" | "online", listener: () => void) => void;
|
|
10
|
+
setTimeout: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
11
|
+
};
|
|
12
|
+
export type BrowserPendingWriteRecovery<Payload> = {
|
|
13
|
+
/** Signals that a caller has persisted a record outside the store subscription. */
|
|
14
|
+
notifyQueued: () => void;
|
|
15
|
+
/** Runs one immediate, silent recovery attempt when the browser is online. */
|
|
16
|
+
recoverNow: () => Promise<SyncPendingWritesResult | null>;
|
|
17
|
+
start: () => void;
|
|
18
|
+
stop: () => void;
|
|
19
|
+
};
|
|
20
|
+
export declare function createBrowserPendingWriteRecovery<Payload>({ environment, coordination, initialRetryDelayMs, maxRetryDelayMs, onError, onResult, scopeKey, select, store, sync, }: {
|
|
21
|
+
/** Optional BroadcastChannel/Web Locks coordination supplied by the package factory. */
|
|
22
|
+
coordination?: BrowserPendingWriteRecoveryCoordination;
|
|
23
|
+
environment?: BrowserPendingWriteRecoveryEnvironment;
|
|
24
|
+
initialRetryDelayMs?: number;
|
|
25
|
+
maxRetryDelayMs?: number;
|
|
26
|
+
/** Diagnostic callback only. Recovery never renders or presents product UI. */
|
|
27
|
+
onError?: (error: unknown) => void;
|
|
28
|
+
/** Diagnostic callback only. Recovery never renders or presents product UI. */
|
|
29
|
+
onResult?: (result: SyncPendingWritesResult) => void;
|
|
30
|
+
scopeKey?: string;
|
|
31
|
+
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
32
|
+
store: PendingWriteStore<Payload>;
|
|
33
|
+
sync: PendingWriteSync<Payload>;
|
|
34
|
+
}): BrowserPendingWriteRecovery<Payload>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { syncPendingWrites, } from "./pendingWrites.js";
|
|
2
|
+
export const initialPendingWriteRetryDelayMs = 2_000;
|
|
3
|
+
export const maxPendingWriteRetryDelayMs = 60_000;
|
|
4
|
+
export function createBrowserPendingWriteRecovery({ environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = initialPendingWriteRetryDelayMs, maxRetryDelayMs = maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, sync, }) {
|
|
5
|
+
let active = false;
|
|
6
|
+
let retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
7
|
+
let retryTimer;
|
|
8
|
+
let recoveryPromise;
|
|
9
|
+
let immediateRecoveryRequested = false;
|
|
10
|
+
let unsubscribePeerWakeups;
|
|
11
|
+
let unsubscribe;
|
|
12
|
+
function clearRetryTimer() {
|
|
13
|
+
if (retryTimer) {
|
|
14
|
+
environment.clearTimeout(retryTimer);
|
|
15
|
+
}
|
|
16
|
+
retryTimer = undefined;
|
|
17
|
+
}
|
|
18
|
+
function emitResult(result) {
|
|
19
|
+
try {
|
|
20
|
+
onResult?.(result);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
console.error("[Bayon offline] Pending-write recovery result listener failed.", {
|
|
24
|
+
error,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function emitError(error) {
|
|
29
|
+
console.error("[Bayon offline] Pending-write recovery failed.", {
|
|
30
|
+
error,
|
|
31
|
+
scopeKey,
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
onError?.(error);
|
|
35
|
+
}
|
|
36
|
+
catch (callbackError) {
|
|
37
|
+
console.error("[Bayon offline] Pending-write recovery error listener failed.", {
|
|
38
|
+
callbackError,
|
|
39
|
+
scopeKey,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function scheduleRetry() {
|
|
44
|
+
if (!active || !environment.isOnline() || retryTimer) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
retryTimer = environment.setTimeout(() => {
|
|
48
|
+
retryTimer = undefined;
|
|
49
|
+
void recoverNow();
|
|
50
|
+
}, retryDelayMs);
|
|
51
|
+
retryDelayMs = Math.min(Math.max(initialRetryDelayMs, retryDelayMs * 2), Math.max(initialRetryDelayMs, maxRetryDelayMs));
|
|
52
|
+
}
|
|
53
|
+
function recoverNow() {
|
|
54
|
+
if (!environment.isOnline()) {
|
|
55
|
+
clearRetryTimer();
|
|
56
|
+
return Promise.resolve(null);
|
|
57
|
+
}
|
|
58
|
+
if (recoveryPromise) {
|
|
59
|
+
return recoveryPromise;
|
|
60
|
+
}
|
|
61
|
+
const attempt = (async () => {
|
|
62
|
+
try {
|
|
63
|
+
const run = () => syncPendingWrites({
|
|
64
|
+
scopeKey,
|
|
65
|
+
select,
|
|
66
|
+
store,
|
|
67
|
+
sync,
|
|
68
|
+
});
|
|
69
|
+
const result = coordination
|
|
70
|
+
? await coordination.runExclusive(run)
|
|
71
|
+
: await run();
|
|
72
|
+
if (!result) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
emitResult(result);
|
|
76
|
+
if (result.remaining > 0) {
|
|
77
|
+
scheduleRetry();
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
emitError(error);
|
|
86
|
+
scheduleRetry();
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
recoveryPromise = attempt.finally(() => {
|
|
91
|
+
recoveryPromise = undefined;
|
|
92
|
+
if (active && immediateRecoveryRequested) {
|
|
93
|
+
immediateRecoveryRequested = false;
|
|
94
|
+
triggerImmediateRecovery();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return recoveryPromise;
|
|
98
|
+
}
|
|
99
|
+
function triggerImmediateRecovery() {
|
|
100
|
+
if (!active) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
immediateRecoveryRequested ||= Boolean(recoveryPromise);
|
|
104
|
+
clearRetryTimer();
|
|
105
|
+
retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
106
|
+
void recoverNow();
|
|
107
|
+
}
|
|
108
|
+
function handleOffline() {
|
|
109
|
+
clearRetryTimer();
|
|
110
|
+
}
|
|
111
|
+
function handleStoreChange(change) {
|
|
112
|
+
if (change.type === "set") {
|
|
113
|
+
coordination?.notify();
|
|
114
|
+
triggerImmediateRecovery();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
notifyQueued() {
|
|
119
|
+
coordination?.notify();
|
|
120
|
+
triggerImmediateRecovery();
|
|
121
|
+
},
|
|
122
|
+
recoverNow,
|
|
123
|
+
start() {
|
|
124
|
+
if (active) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
active = true;
|
|
128
|
+
unsubscribe = store.subscribe(handleStoreChange);
|
|
129
|
+
unsubscribePeerWakeups = coordination?.subscribe(triggerImmediateRecovery);
|
|
130
|
+
environment.addEventListener("focus", triggerImmediateRecovery);
|
|
131
|
+
environment.addEventListener("online", triggerImmediateRecovery);
|
|
132
|
+
environment.addEventListener("offline", handleOffline);
|
|
133
|
+
if (environment.isOnline()) {
|
|
134
|
+
triggerImmediateRecovery();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
stop() {
|
|
138
|
+
if (!active) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
active = false;
|
|
142
|
+
clearRetryTimer();
|
|
143
|
+
unsubscribe?.();
|
|
144
|
+
unsubscribe = undefined;
|
|
145
|
+
unsubscribePeerWakeups?.();
|
|
146
|
+
unsubscribePeerWakeups = undefined;
|
|
147
|
+
environment.removeEventListener("focus", triggerImmediateRecovery);
|
|
148
|
+
environment.removeEventListener("online", triggerImmediateRecovery);
|
|
149
|
+
environment.removeEventListener("offline", handleOffline);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function createDefaultBrowserRecoveryEnvironment() {
|
|
154
|
+
const target = typeof window === "undefined" ? null : window;
|
|
155
|
+
return {
|
|
156
|
+
addEventListener(type, listener) {
|
|
157
|
+
target?.addEventListener(type, listener);
|
|
158
|
+
},
|
|
159
|
+
clearTimeout(timer) {
|
|
160
|
+
globalThis.clearTimeout(timer);
|
|
161
|
+
},
|
|
162
|
+
isOnline() {
|
|
163
|
+
return typeof navigator !== "undefined" && navigator.onLine;
|
|
164
|
+
},
|
|
165
|
+
removeEventListener(type, listener) {
|
|
166
|
+
target?.removeEventListener(type, listener);
|
|
167
|
+
},
|
|
168
|
+
setTimeout(callback, delayMs) {
|
|
169
|
+
return globalThis.setTimeout(callback, delayMs);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
package/dist/idbDriver.d.ts
CHANGED
|
@@ -2,10 +2,17 @@ export type BrowserKeyValueDriver = {
|
|
|
2
2
|
clear: () => Promise<void>;
|
|
3
3
|
close: () => Promise<void>;
|
|
4
4
|
delete: (key: string) => Promise<void>;
|
|
5
|
+
deleteIf: (key: string, predicate: KeyValuePredicate) => Promise<boolean>;
|
|
5
6
|
get: (key: string) => Promise<unknown | null>;
|
|
6
7
|
list: () => Promise<unknown[]>;
|
|
8
|
+
listEntries: () => Promise<Array<{
|
|
9
|
+
key: string;
|
|
10
|
+
value: unknown;
|
|
11
|
+
}>>;
|
|
7
12
|
set: (key: string, value: unknown) => Promise<void>;
|
|
13
|
+
setIf: (key: string, value: unknown, predicate: KeyValuePredicate) => Promise<boolean>;
|
|
8
14
|
};
|
|
15
|
+
export type KeyValuePredicate = (value: unknown | null) => boolean;
|
|
9
16
|
export type BrowserKeyValueDriverOptions = {
|
|
10
17
|
databaseName: string;
|
|
11
18
|
databaseVersion?: number;
|
package/dist/idbDriver.js
CHANGED
|
@@ -13,6 +13,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
13
13
|
async clear() {
|
|
14
14
|
await runBrowserStorageOperation({
|
|
15
15
|
fallback: undefined,
|
|
16
|
+
operationName: "clear object store",
|
|
16
17
|
operation: async () => {
|
|
17
18
|
const database = await databasePromise;
|
|
18
19
|
await database.clear(storeName);
|
|
@@ -23,6 +24,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
23
24
|
async close() {
|
|
24
25
|
await runBrowserStorageOperation({
|
|
25
26
|
fallback: undefined,
|
|
27
|
+
operationName: "close database",
|
|
26
28
|
operation: async () => {
|
|
27
29
|
const database = await databasePromise;
|
|
28
30
|
database.close();
|
|
@@ -33,6 +35,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
33
35
|
async delete(key) {
|
|
34
36
|
await runBrowserStorageOperation({
|
|
35
37
|
fallback: undefined,
|
|
38
|
+
operationName: "delete key",
|
|
36
39
|
operation: async () => {
|
|
37
40
|
const database = await databasePromise;
|
|
38
41
|
await database.delete(storeName, key);
|
|
@@ -40,9 +43,32 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
40
43
|
timeoutMs: operationTimeoutMs,
|
|
41
44
|
});
|
|
42
45
|
},
|
|
46
|
+
async deleteIf(key, predicate) {
|
|
47
|
+
return runBrowserStorageOperation({
|
|
48
|
+
fallback: false,
|
|
49
|
+
operationName: "conditionally delete key",
|
|
50
|
+
operation: async () => {
|
|
51
|
+
const database = await databasePromise;
|
|
52
|
+
// The ownership check and deletion must share a transaction; splitting
|
|
53
|
+
// them can delete a newer offline snapshot written by another trigger.
|
|
54
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
55
|
+
const objectStore = transaction.objectStore(storeName);
|
|
56
|
+
const current = (await objectStore.get(key)) ?? null;
|
|
57
|
+
if (!predicate(current)) {
|
|
58
|
+
await transaction.done;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
await objectStore.delete(key);
|
|
62
|
+
await transaction.done;
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
timeoutMs: operationTimeoutMs,
|
|
66
|
+
});
|
|
67
|
+
},
|
|
43
68
|
async get(key) {
|
|
44
69
|
return runBrowserStorageOperation({
|
|
45
70
|
fallback: null,
|
|
71
|
+
operationName: "read key",
|
|
46
72
|
operation: async () => {
|
|
47
73
|
const database = await databasePromise;
|
|
48
74
|
return (await database.get(storeName, key)) ?? null;
|
|
@@ -53,6 +79,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
53
79
|
async list() {
|
|
54
80
|
return runBrowserStorageOperation({
|
|
55
81
|
fallback: [],
|
|
82
|
+
operationName: "list object store",
|
|
56
83
|
operation: async () => {
|
|
57
84
|
const database = await databasePromise;
|
|
58
85
|
return database.getAll(storeName);
|
|
@@ -60,9 +87,34 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
60
87
|
timeoutMs: operationTimeoutMs,
|
|
61
88
|
});
|
|
62
89
|
},
|
|
90
|
+
async listEntries() {
|
|
91
|
+
return runBrowserStorageOperation({
|
|
92
|
+
fallback: [],
|
|
93
|
+
operationName: "list object-store entries",
|
|
94
|
+
operation: async () => {
|
|
95
|
+
const database = await databasePromise;
|
|
96
|
+
// Keys and values must come from one transaction. Separate database
|
|
97
|
+
// reads could pair a key from one cross-tab storage state with a
|
|
98
|
+
// value from another, causing retention code to delete the wrong key.
|
|
99
|
+
const transaction = database.transaction(storeName, "readonly");
|
|
100
|
+
const objectStore = transaction.objectStore(storeName);
|
|
101
|
+
const [keys, values] = await Promise.all([
|
|
102
|
+
objectStore.getAllKeys(),
|
|
103
|
+
objectStore.getAll(),
|
|
104
|
+
]);
|
|
105
|
+
await transaction.done;
|
|
106
|
+
return keys.map((key, index) => ({
|
|
107
|
+
key: String(key),
|
|
108
|
+
value: values[index],
|
|
109
|
+
}));
|
|
110
|
+
},
|
|
111
|
+
timeoutMs: operationTimeoutMs,
|
|
112
|
+
});
|
|
113
|
+
},
|
|
63
114
|
async set(key, value) {
|
|
64
115
|
await runBrowserStorageOperation({
|
|
65
116
|
fallback: undefined,
|
|
117
|
+
operationName: "write key",
|
|
66
118
|
operation: async () => {
|
|
67
119
|
const database = await databasePromise;
|
|
68
120
|
await database.put(storeName, value, key);
|
|
@@ -70,6 +122,26 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
70
122
|
timeoutMs: operationTimeoutMs,
|
|
71
123
|
});
|
|
72
124
|
},
|
|
125
|
+
async setIf(key, value, predicate) {
|
|
126
|
+
return runBrowserStorageOperation({
|
|
127
|
+
fallback: false,
|
|
128
|
+
operationName: "conditionally replace key",
|
|
129
|
+
operation: async () => {
|
|
130
|
+
const database = await databasePromise;
|
|
131
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
132
|
+
const objectStore = transaction.objectStore(storeName);
|
|
133
|
+
const current = (await objectStore.get(key)) ?? null;
|
|
134
|
+
if (!predicate(current)) {
|
|
135
|
+
await transaction.done;
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
await objectStore.put(value, key);
|
|
139
|
+
await transaction.done;
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
timeoutMs: operationTimeoutMs,
|
|
143
|
+
});
|
|
144
|
+
},
|
|
73
145
|
};
|
|
74
146
|
}
|
|
75
147
|
async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB, storeName, }) {
|
|
@@ -86,17 +158,23 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
|
|
|
86
158
|
}
|
|
87
159
|
return database;
|
|
88
160
|
}
|
|
89
|
-
async function runBrowserStorageOperation({ fallback, operation, timeoutMs, }) {
|
|
161
|
+
async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
|
|
90
162
|
let timeoutId = null;
|
|
91
163
|
try {
|
|
92
164
|
return await Promise.race([
|
|
93
165
|
operation(),
|
|
94
166
|
new Promise((resolve) => {
|
|
95
|
-
timeoutId = setTimeout(() =>
|
|
167
|
+
timeoutId = setTimeout(() => {
|
|
168
|
+
console.error(`IndexedDB timed out while trying to ${operationName}.`, {
|
|
169
|
+
timeoutMs,
|
|
170
|
+
});
|
|
171
|
+
resolve(fallback);
|
|
172
|
+
}, timeoutMs);
|
|
96
173
|
}),
|
|
97
174
|
]);
|
|
98
175
|
}
|
|
99
|
-
catch {
|
|
176
|
+
catch (error) {
|
|
177
|
+
console.error(`IndexedDB failed to ${operationName}.`, { error });
|
|
100
178
|
return fallback;
|
|
101
179
|
}
|
|
102
180
|
finally {
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED