@bayonai/offline 0.4.0 → 0.5.1
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 +13 -0
- package/README.md +29 -0
- package/dist/browserFallbackRecovery.d.ts +32 -0
- package/dist/browserFallbackRecovery.js +95 -0
- package/dist/idbDriver.js +53 -27
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pendingWrites.js +4 -1
- package/dist-cjs/browserFallbackRecovery.js +99 -0
- package/dist-cjs/idbDriver.js +52 -26
- package/dist-cjs/index.js +1 -0
- package/dist-cjs/pendingWrites.js +4 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 - 2026-09-12
|
|
4
|
+
|
|
5
|
+
- Prevented timed-out conditional IndexedDB replacements and deletions from
|
|
6
|
+
committing after the operation has already reported failure.
|
|
7
|
+
- Preserved strict chronological replay when an older record has an active
|
|
8
|
+
claim by leaving it and all later records for a future ordered run.
|
|
9
|
+
|
|
10
|
+
## 0.5.0 - 2026-09-12
|
|
11
|
+
|
|
12
|
+
- Added `createBrowserOfflineFallbackRecovery`, a framework-free controller
|
|
13
|
+
for one-shot retry controls, browser reconnects, host-native reconnect
|
|
14
|
+
events, and optional pull-to-retry on standalone offline fallbacks.
|
|
15
|
+
|
|
3
16
|
## 0.4.0 - 2026-09-01
|
|
4
17
|
|
|
5
18
|
- Fixed cross-context replay claiming: a remote sync now runs only after an
|
package/README.md
CHANGED
|
@@ -156,6 +156,30 @@ the current tab as before. A contested lease retries silently with bounded
|
|
|
156
156
|
backoff. Remote write handlers must remain idempotent because leases do not
|
|
157
157
|
cover other devices or every browser failure mode.
|
|
158
158
|
|
|
159
|
+
## Static Offline Fallback Recovery
|
|
160
|
+
|
|
161
|
+
Standalone offline documents can reuse retry mechanics while keeping their
|
|
162
|
+
own markup, branding, copy, and host-native connectivity event name:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { createBrowserOfflineFallbackRecovery } from "@bayonai/offline";
|
|
166
|
+
|
|
167
|
+
const recovery = createBrowserOfflineFallbackRecovery({
|
|
168
|
+
control: document.querySelector<HTMLButtonElement>("#reload")!,
|
|
169
|
+
// Dispatch only after the host platform has validated connectivity.
|
|
170
|
+
retryEvent: "my-app:network-restored",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
recovery.start();
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The controller disables the control after the first retry and changes its text
|
|
177
|
+
to `Reconnecting…`; it then reloads once. It listens to browser `online`, the
|
|
178
|
+
configured host event, and (by default) a 72px downward touch gesture beginning
|
|
179
|
+
at the top of the page. Set `pullThreshold: null` when the host owns touch
|
|
180
|
+
recovery. Static fallback documents must still bundle or inline this package
|
|
181
|
+
code; a browser cannot resolve a bare npm package specifier from `public/`.
|
|
182
|
+
|
|
159
183
|
Use `createOrderedPendingWriteExecutor` when a new write must never overtake a
|
|
160
184
|
previously queued write in the same user, workspace, or other durable scope.
|
|
161
185
|
Its `recover` and `run` methods share one lane: an unresolved predecessor keeps
|
|
@@ -178,6 +202,11 @@ Interrupted sync claims expire after one minute so recovery can resume. Records
|
|
|
178
202
|
created before snapshot identities were introduced remain readable; they receive
|
|
179
203
|
a stable compatibility identity and are upgraded when next updated.
|
|
180
204
|
|
|
205
|
+
Conditional IndexedDB mutations are cancelled when their storage deadline is
|
|
206
|
+
exceeded, so an operation cannot report failure and then commit later. Ordered
|
|
207
|
+
replay also stops at an active older claim; it never delivers a later record
|
|
208
|
+
whose remote effect could subsequently be overwritten by that predecessor.
|
|
209
|
+
|
|
181
210
|
Host apps that change their pending-write payload shape can normalize current
|
|
182
211
|
records while keeping domain logic outside the package:
|
|
183
212
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const defaultOfflineFallbackRetryEvent = "app:network-restored";
|
|
2
|
+
export declare const defaultOfflineFallbackPullThreshold = 72;
|
|
3
|
+
export type BrowserOfflineFallbackRecoveryEnvironment = {
|
|
4
|
+
addEventListener: (type: string, listener: (event: unknown) => void) => void;
|
|
5
|
+
getScrollY: () => number;
|
|
6
|
+
reload: () => void;
|
|
7
|
+
removeEventListener: (type: string, listener: (event: unknown) => void) => void;
|
|
8
|
+
};
|
|
9
|
+
export type OfflineFallbackRetryControl = {
|
|
10
|
+
disabled: boolean;
|
|
11
|
+
textContent: string | null;
|
|
12
|
+
};
|
|
13
|
+
export type BrowserOfflineFallbackRecovery = {
|
|
14
|
+
/** Attempts recovery once. Repeated calls are ignored while navigation starts. */
|
|
15
|
+
retry: () => void;
|
|
16
|
+
start: () => void;
|
|
17
|
+
stop: () => void;
|
|
18
|
+
};
|
|
19
|
+
export type BrowserOfflineFallbackRecoveryOptions = {
|
|
20
|
+
control: OfflineFallbackRetryControl;
|
|
21
|
+
environment?: BrowserOfflineFallbackRecoveryEnvironment;
|
|
22
|
+
/** A host-native validation callback may dispatch this event after connectivity returns. */
|
|
23
|
+
retryEvent?: string;
|
|
24
|
+
retryingText?: string;
|
|
25
|
+
/** Set to `null` to omit touch pull-to-retry. */
|
|
26
|
+
pullThreshold?: number | null;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Owns generic recovery mechanics for a static offline fallback. Hosts retain
|
|
30
|
+
* all markup, copy, styling, and the choice of a native reconnect event.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createBrowserOfflineFallbackRecovery({ control, environment, pullThreshold, retryEvent, retryingText, }: BrowserOfflineFallbackRecoveryOptions): BrowserOfflineFallbackRecovery;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export const defaultOfflineFallbackRetryEvent = "app:network-restored";
|
|
2
|
+
export const defaultOfflineFallbackPullThreshold = 72;
|
|
3
|
+
/**
|
|
4
|
+
* Owns generic recovery mechanics for a static offline fallback. Hosts retain
|
|
5
|
+
* all markup, copy, styling, and the choice of a native reconnect event.
|
|
6
|
+
*/
|
|
7
|
+
export function createBrowserOfflineFallbackRecovery({ control, environment = createDefaultBrowserOfflineFallbackRecoveryEnvironment(), pullThreshold = defaultOfflineFallbackPullThreshold, retryEvent = defaultOfflineFallbackRetryEvent, retryingText = "Reconnecting…", }) {
|
|
8
|
+
let active = false;
|
|
9
|
+
let retrying = false;
|
|
10
|
+
let pullStartY = null;
|
|
11
|
+
function retry() {
|
|
12
|
+
if (retrying)
|
|
13
|
+
return;
|
|
14
|
+
retrying = true;
|
|
15
|
+
control.disabled = true;
|
|
16
|
+
control.textContent = retryingText;
|
|
17
|
+
try {
|
|
18
|
+
environment.reload();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
// A failed navigation must leave the recovery action visible and usable.
|
|
22
|
+
console.error("[Bayon offline] Offline fallback reload failed.", { error });
|
|
23
|
+
retrying = false;
|
|
24
|
+
control.disabled = false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function handleTouchStart(event) {
|
|
28
|
+
const touchY = getSingleTouchY(event, "touches");
|
|
29
|
+
pullStartY = environment.getScrollY() === 0 ? touchY : null;
|
|
30
|
+
}
|
|
31
|
+
function handleTouchEnd(event) {
|
|
32
|
+
const touchY = getSingleTouchY(event, "changedTouches");
|
|
33
|
+
if (pullStartY !== null &&
|
|
34
|
+
touchY !== null &&
|
|
35
|
+
touchY - pullStartY >= (pullThreshold ?? Number.POSITIVE_INFINITY)) {
|
|
36
|
+
retry();
|
|
37
|
+
}
|
|
38
|
+
pullStartY = null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
retry,
|
|
42
|
+
start() {
|
|
43
|
+
if (active)
|
|
44
|
+
return;
|
|
45
|
+
active = true;
|
|
46
|
+
environment.addEventListener("online", retry);
|
|
47
|
+
environment.addEventListener(retryEvent, retry);
|
|
48
|
+
if (pullThreshold !== null) {
|
|
49
|
+
environment.addEventListener("touchstart", handleTouchStart);
|
|
50
|
+
environment.addEventListener("touchend", handleTouchEnd);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
stop() {
|
|
54
|
+
if (!active)
|
|
55
|
+
return;
|
|
56
|
+
active = false;
|
|
57
|
+
environment.removeEventListener("online", retry);
|
|
58
|
+
environment.removeEventListener(retryEvent, retry);
|
|
59
|
+
if (pullThreshold !== null) {
|
|
60
|
+
environment.removeEventListener("touchstart", handleTouchStart);
|
|
61
|
+
environment.removeEventListener("touchend", handleTouchEnd);
|
|
62
|
+
}
|
|
63
|
+
pullStartY = null;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function createDefaultBrowserOfflineFallbackRecoveryEnvironment() {
|
|
68
|
+
const target = typeof window === "undefined" ? null : window;
|
|
69
|
+
return {
|
|
70
|
+
addEventListener(type, listener) {
|
|
71
|
+
target?.addEventListener(type, listener);
|
|
72
|
+
},
|
|
73
|
+
getScrollY() {
|
|
74
|
+
return target?.scrollY ?? 0;
|
|
75
|
+
},
|
|
76
|
+
reload() {
|
|
77
|
+
target?.location.reload();
|
|
78
|
+
},
|
|
79
|
+
removeEventListener(type, listener) {
|
|
80
|
+
target?.removeEventListener(type, listener);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function getSingleTouchY(event, key) {
|
|
85
|
+
if (!event || typeof event !== "object")
|
|
86
|
+
return null;
|
|
87
|
+
const touches = Reflect.get(event, key);
|
|
88
|
+
if (!touches || typeof touches !== "object")
|
|
89
|
+
return null;
|
|
90
|
+
const touch = Reflect.get(touches, 0);
|
|
91
|
+
if (!touch || typeof touch !== "object")
|
|
92
|
+
return null;
|
|
93
|
+
const clientY = Reflect.get(touch, "clientY");
|
|
94
|
+
return typeof clientY === "number" ? clientY : null;
|
|
95
|
+
}
|
package/dist/idbDriver.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { wrap } from "idb";
|
|
1
|
+
import { wrap, } from "idb";
|
|
2
2
|
export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
|
|
3
3
|
if (!indexedDB) {
|
|
4
4
|
return null;
|
|
@@ -44,25 +44,18 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
44
44
|
});
|
|
45
45
|
},
|
|
46
46
|
async deleteIf(key, predicate) {
|
|
47
|
-
return
|
|
48
|
-
|
|
47
|
+
return runConditionalStorageOperation({
|
|
48
|
+
databasePromise,
|
|
49
49
|
operationName: "conditionally delete key",
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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);
|
|
50
|
+
storeName,
|
|
51
|
+
timeoutMs: operationTimeoutMs,
|
|
52
|
+
apply: async (objectStore) => {
|
|
56
53
|
const current = (await objectStore.get(key)) ?? null;
|
|
57
|
-
if (!predicate(current))
|
|
58
|
-
await transaction.done;
|
|
54
|
+
if (!predicate(current))
|
|
59
55
|
return false;
|
|
60
|
-
}
|
|
61
56
|
await objectStore.delete(key);
|
|
62
|
-
await transaction.done;
|
|
63
57
|
return true;
|
|
64
58
|
},
|
|
65
|
-
timeoutMs: operationTimeoutMs,
|
|
66
59
|
});
|
|
67
60
|
},
|
|
68
61
|
async get(key) {
|
|
@@ -123,23 +116,18 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
123
116
|
});
|
|
124
117
|
},
|
|
125
118
|
async setIf(key, value, predicate) {
|
|
126
|
-
return
|
|
127
|
-
|
|
119
|
+
return runConditionalStorageOperation({
|
|
120
|
+
databasePromise,
|
|
128
121
|
operationName: "conditionally replace key",
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const objectStore = transaction.objectStore(storeName);
|
|
122
|
+
storeName,
|
|
123
|
+
timeoutMs: operationTimeoutMs,
|
|
124
|
+
apply: async (objectStore) => {
|
|
133
125
|
const current = (await objectStore.get(key)) ?? null;
|
|
134
|
-
if (!predicate(current))
|
|
135
|
-
await transaction.done;
|
|
126
|
+
if (!predicate(current))
|
|
136
127
|
return false;
|
|
137
|
-
}
|
|
138
128
|
await objectStore.put(value, key);
|
|
139
|
-
await transaction.done;
|
|
140
129
|
return true;
|
|
141
130
|
},
|
|
142
|
-
timeoutMs: operationTimeoutMs,
|
|
143
131
|
});
|
|
144
132
|
},
|
|
145
133
|
};
|
|
@@ -158,7 +146,7 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
|
|
|
158
146
|
}
|
|
159
147
|
return database;
|
|
160
148
|
}
|
|
161
|
-
async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
|
|
149
|
+
async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
|
|
162
150
|
let timeoutId = null;
|
|
163
151
|
try {
|
|
164
152
|
return await Promise.race([
|
|
@@ -168,7 +156,9 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
|
|
|
168
156
|
console.error(`IndexedDB timed out while trying to ${operationName}.`, {
|
|
169
157
|
timeoutMs,
|
|
170
158
|
});
|
|
171
|
-
|
|
159
|
+
if (!cancelOnTimeout || cancelOnTimeout()) {
|
|
160
|
+
resolve(fallback);
|
|
161
|
+
}
|
|
172
162
|
}, timeoutMs);
|
|
173
163
|
}),
|
|
174
164
|
]);
|
|
@@ -183,3 +173,39 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
|
|
|
183
173
|
}
|
|
184
174
|
}
|
|
185
175
|
}
|
|
176
|
+
async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
|
|
177
|
+
let transaction;
|
|
178
|
+
let cancelled = false;
|
|
179
|
+
return runBrowserStorageOperation({
|
|
180
|
+
fallback: false,
|
|
181
|
+
operationName,
|
|
182
|
+
timeoutMs,
|
|
183
|
+
cancelOnTimeout() {
|
|
184
|
+
cancelled = true;
|
|
185
|
+
if (!transaction)
|
|
186
|
+
return true;
|
|
187
|
+
try {
|
|
188
|
+
transaction.abort();
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// Product decision: when commit has already started, report its actual
|
|
193
|
+
// result instead of returning a false failure that can later mutate data.
|
|
194
|
+
console.warn("[Bayon offline] Conditional transaction already settling at timeout.", { error, operationName });
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
operation: async () => {
|
|
199
|
+
const database = await databasePromise;
|
|
200
|
+
if (cancelled)
|
|
201
|
+
return false;
|
|
202
|
+
transaction = database.transaction(storeName, "readwrite");
|
|
203
|
+
// Aborting can reject both a request and transaction.done. Observe the
|
|
204
|
+
// latter immediately so cancellation never creates an unhandled rejection.
|
|
205
|
+
void transaction.done.catch(() => undefined);
|
|
206
|
+
const result = await apply(transaction.objectStore(storeName));
|
|
207
|
+
await transaction.done;
|
|
208
|
+
return result;
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/pendingWrites.js
CHANGED
|
@@ -277,7 +277,10 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
|
|
|
277
277
|
});
|
|
278
278
|
summary.attempted += 1;
|
|
279
279
|
summary[result.status] += 1;
|
|
280
|
-
|
|
280
|
+
// Product decision: a superseded result can represent an older live claim.
|
|
281
|
+
// Preserve strict ordering by leaving every later record for a future run.
|
|
282
|
+
if (stopOnFailure &&
|
|
283
|
+
(result.status === "failed" || result.status === "superseded")) {
|
|
281
284
|
break;
|
|
282
285
|
}
|
|
283
286
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defaultOfflineFallbackPullThreshold = exports.defaultOfflineFallbackRetryEvent = void 0;
|
|
4
|
+
exports.createBrowserOfflineFallbackRecovery = createBrowserOfflineFallbackRecovery;
|
|
5
|
+
exports.defaultOfflineFallbackRetryEvent = "app:network-restored";
|
|
6
|
+
exports.defaultOfflineFallbackPullThreshold = 72;
|
|
7
|
+
/**
|
|
8
|
+
* Owns generic recovery mechanics for a static offline fallback. Hosts retain
|
|
9
|
+
* all markup, copy, styling, and the choice of a native reconnect event.
|
|
10
|
+
*/
|
|
11
|
+
function createBrowserOfflineFallbackRecovery({ control, environment = createDefaultBrowserOfflineFallbackRecoveryEnvironment(), pullThreshold = exports.defaultOfflineFallbackPullThreshold, retryEvent = exports.defaultOfflineFallbackRetryEvent, retryingText = "Reconnecting…", }) {
|
|
12
|
+
let active = false;
|
|
13
|
+
let retrying = false;
|
|
14
|
+
let pullStartY = null;
|
|
15
|
+
function retry() {
|
|
16
|
+
if (retrying)
|
|
17
|
+
return;
|
|
18
|
+
retrying = true;
|
|
19
|
+
control.disabled = true;
|
|
20
|
+
control.textContent = retryingText;
|
|
21
|
+
try {
|
|
22
|
+
environment.reload();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
// A failed navigation must leave the recovery action visible and usable.
|
|
26
|
+
console.error("[Bayon offline] Offline fallback reload failed.", { error });
|
|
27
|
+
retrying = false;
|
|
28
|
+
control.disabled = false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function handleTouchStart(event) {
|
|
32
|
+
const touchY = getSingleTouchY(event, "touches");
|
|
33
|
+
pullStartY = environment.getScrollY() === 0 ? touchY : null;
|
|
34
|
+
}
|
|
35
|
+
function handleTouchEnd(event) {
|
|
36
|
+
const touchY = getSingleTouchY(event, "changedTouches");
|
|
37
|
+
if (pullStartY !== null &&
|
|
38
|
+
touchY !== null &&
|
|
39
|
+
touchY - pullStartY >= (pullThreshold ?? Number.POSITIVE_INFINITY)) {
|
|
40
|
+
retry();
|
|
41
|
+
}
|
|
42
|
+
pullStartY = null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
retry,
|
|
46
|
+
start() {
|
|
47
|
+
if (active)
|
|
48
|
+
return;
|
|
49
|
+
active = true;
|
|
50
|
+
environment.addEventListener("online", retry);
|
|
51
|
+
environment.addEventListener(retryEvent, retry);
|
|
52
|
+
if (pullThreshold !== null) {
|
|
53
|
+
environment.addEventListener("touchstart", handleTouchStart);
|
|
54
|
+
environment.addEventListener("touchend", handleTouchEnd);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
stop() {
|
|
58
|
+
if (!active)
|
|
59
|
+
return;
|
|
60
|
+
active = false;
|
|
61
|
+
environment.removeEventListener("online", retry);
|
|
62
|
+
environment.removeEventListener(retryEvent, retry);
|
|
63
|
+
if (pullThreshold !== null) {
|
|
64
|
+
environment.removeEventListener("touchstart", handleTouchStart);
|
|
65
|
+
environment.removeEventListener("touchend", handleTouchEnd);
|
|
66
|
+
}
|
|
67
|
+
pullStartY = null;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function createDefaultBrowserOfflineFallbackRecoveryEnvironment() {
|
|
72
|
+
const target = typeof window === "undefined" ? null : window;
|
|
73
|
+
return {
|
|
74
|
+
addEventListener(type, listener) {
|
|
75
|
+
target?.addEventListener(type, listener);
|
|
76
|
+
},
|
|
77
|
+
getScrollY() {
|
|
78
|
+
return target?.scrollY ?? 0;
|
|
79
|
+
},
|
|
80
|
+
reload() {
|
|
81
|
+
target?.location.reload();
|
|
82
|
+
},
|
|
83
|
+
removeEventListener(type, listener) {
|
|
84
|
+
target?.removeEventListener(type, listener);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function getSingleTouchY(event, key) {
|
|
89
|
+
if (!event || typeof event !== "object")
|
|
90
|
+
return null;
|
|
91
|
+
const touches = Reflect.get(event, key);
|
|
92
|
+
if (!touches || typeof touches !== "object")
|
|
93
|
+
return null;
|
|
94
|
+
const touch = Reflect.get(touches, 0);
|
|
95
|
+
if (!touch || typeof touch !== "object")
|
|
96
|
+
return null;
|
|
97
|
+
const clientY = Reflect.get(touch, "clientY");
|
|
98
|
+
return typeof clientY === "number" ? clientY : null;
|
|
99
|
+
}
|
package/dist-cjs/idbDriver.js
CHANGED
|
@@ -47,25 +47,18 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
47
47
|
});
|
|
48
48
|
},
|
|
49
49
|
async deleteIf(key, predicate) {
|
|
50
|
-
return
|
|
51
|
-
|
|
50
|
+
return runConditionalStorageOperation({
|
|
51
|
+
databasePromise,
|
|
52
52
|
operationName: "conditionally delete key",
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// them can delete a newer offline snapshot written by another trigger.
|
|
57
|
-
const transaction = database.transaction(storeName, "readwrite");
|
|
58
|
-
const objectStore = transaction.objectStore(storeName);
|
|
53
|
+
storeName,
|
|
54
|
+
timeoutMs: operationTimeoutMs,
|
|
55
|
+
apply: async (objectStore) => {
|
|
59
56
|
const current = (await objectStore.get(key)) ?? null;
|
|
60
|
-
if (!predicate(current))
|
|
61
|
-
await transaction.done;
|
|
57
|
+
if (!predicate(current))
|
|
62
58
|
return false;
|
|
63
|
-
}
|
|
64
59
|
await objectStore.delete(key);
|
|
65
|
-
await transaction.done;
|
|
66
60
|
return true;
|
|
67
61
|
},
|
|
68
|
-
timeoutMs: operationTimeoutMs,
|
|
69
62
|
});
|
|
70
63
|
},
|
|
71
64
|
async get(key) {
|
|
@@ -126,23 +119,18 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
126
119
|
});
|
|
127
120
|
},
|
|
128
121
|
async setIf(key, value, predicate) {
|
|
129
|
-
return
|
|
130
|
-
|
|
122
|
+
return runConditionalStorageOperation({
|
|
123
|
+
databasePromise,
|
|
131
124
|
operationName: "conditionally replace key",
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const objectStore = transaction.objectStore(storeName);
|
|
125
|
+
storeName,
|
|
126
|
+
timeoutMs: operationTimeoutMs,
|
|
127
|
+
apply: async (objectStore) => {
|
|
136
128
|
const current = (await objectStore.get(key)) ?? null;
|
|
137
|
-
if (!predicate(current))
|
|
138
|
-
await transaction.done;
|
|
129
|
+
if (!predicate(current))
|
|
139
130
|
return false;
|
|
140
|
-
}
|
|
141
131
|
await objectStore.put(value, key);
|
|
142
|
-
await transaction.done;
|
|
143
132
|
return true;
|
|
144
133
|
},
|
|
145
|
-
timeoutMs: operationTimeoutMs,
|
|
146
134
|
});
|
|
147
135
|
},
|
|
148
136
|
};
|
|
@@ -161,7 +149,7 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
|
|
|
161
149
|
}
|
|
162
150
|
return database;
|
|
163
151
|
}
|
|
164
|
-
async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
|
|
152
|
+
async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
|
|
165
153
|
let timeoutId = null;
|
|
166
154
|
try {
|
|
167
155
|
return await Promise.race([
|
|
@@ -171,7 +159,9 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
|
|
|
171
159
|
console.error(`IndexedDB timed out while trying to ${operationName}.`, {
|
|
172
160
|
timeoutMs,
|
|
173
161
|
});
|
|
174
|
-
|
|
162
|
+
if (!cancelOnTimeout || cancelOnTimeout()) {
|
|
163
|
+
resolve(fallback);
|
|
164
|
+
}
|
|
175
165
|
}, timeoutMs);
|
|
176
166
|
}),
|
|
177
167
|
]);
|
|
@@ -186,3 +176,39 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
|
|
|
186
176
|
}
|
|
187
177
|
}
|
|
188
178
|
}
|
|
179
|
+
async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
|
|
180
|
+
let transaction;
|
|
181
|
+
let cancelled = false;
|
|
182
|
+
return runBrowserStorageOperation({
|
|
183
|
+
fallback: false,
|
|
184
|
+
operationName,
|
|
185
|
+
timeoutMs,
|
|
186
|
+
cancelOnTimeout() {
|
|
187
|
+
cancelled = true;
|
|
188
|
+
if (!transaction)
|
|
189
|
+
return true;
|
|
190
|
+
try {
|
|
191
|
+
transaction.abort();
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
// Product decision: when commit has already started, report its actual
|
|
196
|
+
// result instead of returning a false failure that can later mutate data.
|
|
197
|
+
console.warn("[Bayon offline] Conditional transaction already settling at timeout.", { error, operationName });
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
operation: async () => {
|
|
202
|
+
const database = await databasePromise;
|
|
203
|
+
if (cancelled)
|
|
204
|
+
return false;
|
|
205
|
+
transaction = database.transaction(storeName, "readwrite");
|
|
206
|
+
// Aborting can reject both a request and transaction.done. Observe the
|
|
207
|
+
// latter immediately so cancellation never creates an unhandled rejection.
|
|
208
|
+
void transaction.done.catch(() => undefined);
|
|
209
|
+
const result = await apply(transaction.objectStore(storeName));
|
|
210
|
+
await transaction.done;
|
|
211
|
+
return result;
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
package/dist-cjs/index.js
CHANGED
|
@@ -18,3 +18,4 @@ __exportStar(require("./readCache"), exports);
|
|
|
18
18
|
__exportStar(require("./pendingWrites"), exports);
|
|
19
19
|
__exportStar(require("./browserRecovery"), exports);
|
|
20
20
|
__exportStar(require("./browserCoordination"), exports);
|
|
21
|
+
__exportStar(require("./browserFallbackRecovery"), exports);
|
|
@@ -291,7 +291,10 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
|
|
|
291
291
|
});
|
|
292
292
|
summary.attempted += 1;
|
|
293
293
|
summary[result.status] += 1;
|
|
294
|
-
|
|
294
|
+
// Product decision: a superseded result can represent an older live claim.
|
|
295
|
+
// Preserve strict ordering by leaving every later record for a future run.
|
|
296
|
+
if (stopOnFailure &&
|
|
297
|
+
(result.status === "failed" || result.status === "superseded")) {
|
|
295
298
|
break;
|
|
296
299
|
}
|
|
297
300
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bayonai/offline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bayon AI",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"idb": "^8.0.3"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
+
"fake-indexeddb": "^6.2.5",
|
|
58
59
|
"typescript": "^5",
|
|
59
60
|
"vitest": "^4.1.6"
|
|
60
61
|
}
|