@bayonai/offline 0.9.0 → 0.10.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 +26 -0
- package/README.md +61 -11
- package/RECOVERY.md +67 -0
- package/dist/browserFallbackRecovery.d.ts +10 -9
- package/dist/browserFallbackRecovery.js +100 -30
- package/dist/idbDriver.d.ts +3 -1
- package/dist/idbDriver.js +29 -14
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/pendingWriteSupersession.d.ts +11 -0
- package/dist/pendingWriteSupersession.js +25 -0
- package/dist/pendingWrites.js +3 -2
- package/dist/readBoundary.d.ts +13 -0
- package/dist/readBoundary.js +23 -0
- package/dist/recoveryPolicy.d.ts +20 -0
- package/dist/recoveryPolicy.js +75 -0
- package/dist-cjs/browserFallbackRecovery.d.ts +10 -9
- package/dist-cjs/browserFallbackRecovery.js +100 -30
- package/dist-cjs/idbDriver.d.ts +3 -1
- package/dist-cjs/idbDriver.js +29 -14
- package/dist-cjs/index.d.ts +3 -0
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWriteSupersession.d.ts +11 -0
- package/dist-cjs/pendingWriteSupersession.js +28 -0
- package/dist-cjs/pendingWrites.js +3 -2
- package/dist-cjs/readBoundary.d.ts +13 -0
- package/dist-cjs/readBoundary.js +26 -0
- package/dist-cjs/recoveryPolicy.d.ts +20 -0
- package/dist-cjs/recoveryPolicy.js +80 -0
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.1 - 2026-11-04
|
|
4
|
+
|
|
5
|
+
- Recognize two exact Firestore server-only cache diagnostics as transient while retaining terminal-code precedence.
|
|
6
|
+
|
|
7
|
+
## 0.10.0 - 2026-11-03
|
|
8
|
+
|
|
9
|
+
- Fixed all four audited recovery gaps: strict pending-store reads, retained async
|
|
10
|
+
refresh requests with capped rechecks, conservative transport classification,
|
|
11
|
+
and host-keyed snapshot-fenced retirement of acknowledged setter corrections.
|
|
12
|
+
- Added buttonless pull-down refresh, cancellation fencing and transient database
|
|
13
|
+
reopen recovery while retaining cache read fallback and legacy aliases.
|
|
14
|
+
|
|
15
|
+
- Moved the no-user-retry decision into the package-owned `RECOVERY.md` contract.
|
|
16
|
+
- Added shared `getPendingChangesGuidance`, `offlineRecoveryCopy` and
|
|
17
|
+
`isTransientOfflineError` helpers for truthful background-sync guidance and
|
|
18
|
+
conservative recognition of known transient failures.
|
|
19
|
+
- Added canonical fallback `reload()` semantics, normalized Reload labels and
|
|
20
|
+
a host durability veto for manual/reconnect/gesture navigation. Kept `retry()`
|
|
21
|
+
as an internal compatibility alias; failed navigation preserves the control.
|
|
22
|
+
|
|
23
|
+
- Added the framework-free `assertReadBoundary` architecture-test utility for
|
|
24
|
+
host-supplied dependency graphs and exact trusted adapter boundaries.
|
|
25
|
+
- Added actual IndexedDB close/reopen regression coverage for durable
|
|
26
|
+
create/update/delete commands, ordered connectivity replay, terminal rollback,
|
|
27
|
+
retained failure diagnostics, and account isolation.
|
|
28
|
+
|
|
3
29
|
## 0.9.0 - 2026-09-26
|
|
4
30
|
|
|
5
31
|
- Added independent read-cache freshness and retention windows with explicit
|
package/README.md
CHANGED
|
@@ -200,29 +200,55 @@ the current tab as before. A contested lease retries silently with bounded
|
|
|
200
200
|
backoff. Remote write handlers must remain idempotent because leases do not
|
|
201
201
|
cover other devices or every browser failure mode.
|
|
202
202
|
|
|
203
|
+
## Recovery is application work
|
|
204
|
+
|
|
205
|
+
Follow the package-owned [automatic recovery contract](RECOVERY.md). Never ask
|
|
206
|
+
users to Retry/Try again or review and retry unchanged operations. Keep normal
|
|
207
|
+
pending writes silent through the existing bounded background recovery owner.
|
|
208
|
+
Use `getPendingChangesGuidance` with host-projected `hasReadyChanges`, `hasDrafts`,
|
|
209
|
+
`hasErrors` and `isOffline` flags for shared status copy. Use
|
|
210
|
+
`isTransientOfflineError` for known transport/service failures, retaining host
|
|
211
|
+
validation, account scoping and idempotency policy. Unknown and explicit terminal
|
|
212
|
+
errors must not become blind replay.
|
|
213
|
+
|
|
203
214
|
## Static Offline Fallback Recovery
|
|
204
215
|
|
|
205
|
-
|
|
206
|
-
own markup, branding, copy, and host-native connectivity event name:
|
|
216
|
+
Pull-down and reconnect request a safe refresh; no refresh button is required.
|
|
207
217
|
|
|
208
218
|
```ts
|
|
209
219
|
import { createBrowserOfflineFallbackRecovery } from "@bayonai/offline";
|
|
210
220
|
|
|
211
221
|
const recovery = createBrowserOfflineFallbackRecovery({
|
|
212
|
-
|
|
213
|
-
|
|
222
|
+
// Read current storage health on every attempt. Never discard work to refresh.
|
|
223
|
+
canReload: async () => await hostCanProveLocalDurability(),
|
|
214
224
|
retryEvent: "my-app:network-restored",
|
|
215
225
|
});
|
|
216
|
-
|
|
217
226
|
recovery.start();
|
|
227
|
+
// On unmount:
|
|
228
|
+
// recovery.stop();
|
|
218
229
|
```
|
|
219
230
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
231
|
+
An unsafe/rejected proof retains the same request and rechecks with capped
|
|
232
|
+
backoff, so a person never needs to repeat the gesture. Only one proof and one
|
|
233
|
+
timer may run at once. Stop cancels rechecks and fences late proof results.
|
|
234
|
+
The default top-of-page pull threshold is 72px; `pullThreshold: null` lets a host
|
|
235
|
+
own gesture recognition and call `recovery.refresh()` instead. Supply the actual
|
|
236
|
+
scroll region through the environment when the fallback has a scrolling modal.
|
|
237
|
+
Static documents must bundle this package code rather than use bare npm imports.
|
|
238
|
+
The optional legacy `control`, `reload()` and `retry()` APIs remain compatible.
|
|
239
|
+
|
|
240
|
+
### Strict pending reads and acknowledged corrections
|
|
241
|
+
|
|
242
|
+
Browser pending stores throw `OfflineStorageError` on read failure or timeout;
|
|
243
|
+
never interpret uncertainty as an empty queue. Cache reads remain best-effort.
|
|
244
|
+
Custom pending-write drivers must throw on failed reads too. Retain last-known
|
|
245
|
+
pending state while the existing browser recovery controller rechecks storage.
|
|
246
|
+
|
|
247
|
+
Use `clearSupersededPendingWriteFailures({ acknowledged, store,
|
|
248
|
+
getSupersessionKey, isRejected })` only after confirmed remote acknowledgment.
|
|
249
|
+
Hosts map an explicit account/entity/field key for supersedable setters and null
|
|
250
|
+
for effectful operations. The helper clears only older failed unclaimed matching
|
|
251
|
+
snapshots through compare-and-clear; it never blindly removes all rejected work.
|
|
226
252
|
|
|
227
253
|
Use `createOrderedPendingWriteExecutor` when a new write must never overtake a
|
|
228
254
|
previously queued write in the same user, workspace, or other durable scope.
|
|
@@ -364,3 +390,27 @@ pnpm --filter @bayonai/offline pack:smoke
|
|
|
364
390
|
`publishConfig.access` are correct.
|
|
365
391
|
6. Publish from the package directory or with the package filter after the
|
|
366
392
|
workspace lockfile is current.
|
|
393
|
+
|
|
394
|
+
### Enforcing consumer read boundaries
|
|
395
|
+
|
|
396
|
+
`assertReadBoundary` is a framework-free architecture-test utility. The host
|
|
397
|
+
parses its actual source into a module graph and supplies provider policy:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
assertReadBoundary({
|
|
401
|
+
modules: parsedRuntimeDependencies,
|
|
402
|
+
roots: featureEntryModuleIds,
|
|
403
|
+
allowedAdapters: trustedCacheAdapterModuleIds,
|
|
404
|
+
isForbidden: (id) => forbiddenReadProviders.has(id),
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Supply all reachable local runtime imports, re-exports and dynamic imports;
|
|
409
|
+
reject unresolved or non-static local dependencies in the host parser. Absent
|
|
410
|
+
nodes are external leaves. Exact allowed adapter IDs stop traversal; verify
|
|
411
|
+
those adapters' cache-first behavior separately. Cycles terminate safely, and
|
|
412
|
+
a bypass throws an error containing its dependency path. The package imports
|
|
413
|
+
no TypeScript parser, Firebase SDK, or host-domain module. Bounded's real catalog
|
|
414
|
+
read-family recipe lives in `src/lib/lists/catalogReadBoundary.test.ts` and works
|
|
415
|
+
with its existing cache behavior tests. This locally added export requires an
|
|
416
|
+
intentional future package release before registry consumers can adopt it.
|
package/RECOVERY.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Automatic recovery is application work
|
|
2
|
+
|
|
3
|
+
This is the package-owned product contract for all offline consumers, established
|
|
4
|
+
2026-11-03 from the owner's Thunderlist requirement.
|
|
5
|
+
|
|
6
|
+
## Observable behavior
|
|
7
|
+
|
|
8
|
+
- Never ask a person to Retry, Try again, or review and retry an unchanged operation.
|
|
9
|
+
A generic error is not a reason to make the user repeat work.
|
|
10
|
+
- `createBrowserPendingWriteRecovery` owns silent startup, focus, reconnect and
|
|
11
|
+
capped exponential-backoff recovery. Reuse its coordination and ordered executor;
|
|
12
|
+
never add a second replay owner, a billable job or a recurring server service.
|
|
13
|
+
- `getPendingChangesGuidance` supplies common guidance without domain payloads.
|
|
14
|
+
Ready work syncs automatically; unfinished drafts need editing before submission.
|
|
15
|
+
Rejected work stays preserved. No retry button is part of pending inspection.
|
|
16
|
+
- `isTransientOfflineError` recognizes known connectivity, internal-service and
|
|
17
|
+
aborted-transaction failures without importing provider SDKs. Explicit terminal
|
|
18
|
+
auth/access/validation/quota/not-found/already-exists codes override messages.
|
|
19
|
+
Unrecognized structured codes are not retried; only exact recognized transport
|
|
20
|
+
diagnostics may classify an uncoded error. Unknown failures do not authorize automatic replay. Hosts remain
|
|
21
|
+
responsible for domain classification, idempotency and authorized replay selection.
|
|
22
|
+
- The static fallback controller exposes `refresh()` and owns pull-down/reconnect
|
|
23
|
+
navigation. New hosts do not render refresh buttons; `control` is an optional
|
|
24
|
+
compatibility surface. `reload()`/`retry()` remain internal aliases.
|
|
25
|
+
- A host `canReload` proof may return a boolean or Promise<boolean>. Each attempt
|
|
26
|
+
must read fresh, account-scoped storage health. A veto or failed proof retains
|
|
27
|
+
the requested refresh and rechecks through one capped-backoff timer, starting
|
|
28
|
+
at two seconds and capped at 60 seconds. Stop cancels timers and fences late
|
|
29
|
+
asynchronous results. Concurrent gestures/reconnects coalesce.
|
|
30
|
+
- Browser pending-store reads reject with OfflineStorageError on failed reads,
|
|
31
|
+
unavailable storage and timeouts. Empty means a successful read. Read caches
|
|
32
|
+
retain their best-effort default. Custom pending drivers must also throw on
|
|
33
|
+
uncertainty. A failed database open can be retried by a later operation.
|
|
34
|
+
- `clearSupersededPendingWriteFailures` retires older rejected snapshots only
|
|
35
|
+
after acknowledgment and an exact host-supplied account/entity/field key match.
|
|
36
|
+
It skips live claims, newer records and effectful operations with null keys;
|
|
37
|
+
atomic snapshot ownership protects changes accepted during its scan.
|
|
38
|
+
- Never automatically reload a live app in response to each failed mutation.
|
|
39
|
+
Reload belongs to explicit application/fallback recovery, not normal queue replay.
|
|
40
|
+
- Ask for user action only to resolve a specific condition the application cannot
|
|
41
|
+
resolve itself: correct invalid input, restore access/authentication, adjust device
|
|
42
|
+
settings, or observe a documented rate-limit wait. Describe that resolution.
|
|
43
|
+
|
|
44
|
+
## Compatibility and ownership
|
|
45
|
+
|
|
46
|
+
`retry()` remains an internal compatibility alias for `reload()`. Technical retry
|
|
47
|
+
identifiers and diagnostics are permitted; they must not become user instructions.
|
|
48
|
+
Hosts still own framework components, branding, layout, operation labels, task/list
|
|
49
|
+
identity and grouping, names/scopes/migrations, authentication and conflict handling.
|
|
50
|
+
The package does not depend on Thunderlist, Bounded domain types, React or Firebase.
|
|
51
|
+
No queue record is discarded or reclassified merely because the app reopened.
|
|
52
|
+
|
|
53
|
+
## Regression coverage
|
|
54
|
+
|
|
55
|
+
- `src/recoveryPolicy.test.ts`: user guidance and known transient/terminal taxonomy.
|
|
56
|
+
- `src/browserFallbackRecovery.test.ts`: Reload semantics, navigation deduplication,
|
|
57
|
+
durability vetoes, failed navigation and reconnect/gesture lifecycle.
|
|
58
|
+
- `src/browserRecovery.test.ts`: silent capped-backoff, reconnect, offline pause,
|
|
59
|
+
coordination and lifecycle behavior.
|
|
60
|
+
- `src/pendingWrites.test.ts`, `src/durableCommandStore.persistence.test.ts`:
|
|
61
|
+
persistent intent, ownership, ordering and restart safety.
|
|
62
|
+
|
|
63
|
+
- Additional regression coverage: `src/recoveryRemediation.test.ts` exercises the
|
|
64
|
+
four audited failure compositions with real IndexedDB reads and safe cleanup.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Firestore server-only reads may report either exact “Failed to get documents from server.” or the exact SDK cache-retrieval guidance. These known messages classify as transient only when no structured code is present; terminal and unknown structured codes remain authoritative. Arbitrary messages containing connectivity words do not qualify.
|
|
@@ -11,22 +11,23 @@ export type OfflineFallbackRetryControl = {
|
|
|
11
11
|
textContent: string | null;
|
|
12
12
|
};
|
|
13
13
|
export type BrowserOfflineFallbackRecovery = {
|
|
14
|
-
/**
|
|
14
|
+
/** Requests one safe refresh; unsafe or failed proof is rechecked automatically. */
|
|
15
|
+
refresh: () => void;
|
|
16
|
+
/** Compatibility for hosts that label navigation as Reload. */
|
|
17
|
+
reload: () => void;
|
|
18
|
+
/** @deprecated Internal compatibility alias; never a user retry instruction. */
|
|
15
19
|
retry: () => void;
|
|
16
20
|
start: () => void;
|
|
17
21
|
stop: () => void;
|
|
18
22
|
};
|
|
19
23
|
export type BrowserOfflineFallbackRecoveryOptions = {
|
|
20
|
-
|
|
24
|
+
/** Optional legacy UI; new hosts use pull-down without a refresh button. */
|
|
25
|
+
control?: OfflineFallbackRetryControl;
|
|
26
|
+
canReload?: () => boolean | Promise<boolean>;
|
|
21
27
|
environment?: BrowserOfflineFallbackRecoveryEnvironment;
|
|
22
|
-
/** A host-native validation callback may dispatch this event after connectivity returns. */
|
|
23
28
|
retryEvent?: string;
|
|
24
29
|
retryingText?: string;
|
|
25
|
-
/** Set to `null` to omit touch pull-to-retry. */
|
|
26
30
|
pullThreshold?: number | null;
|
|
27
31
|
};
|
|
28
|
-
/**
|
|
29
|
-
|
|
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;
|
|
32
|
+
/** Owns pull/reconnect recovery, including safe asynchronous durability proof. */
|
|
33
|
+
export declare function createBrowserOfflineFallbackRecovery({ control, canReload, environment, pullThreshold, retryEvent, retryingText, }: BrowserOfflineFallbackRecoveryOptions): BrowserOfflineFallbackRecovery;
|
|
@@ -1,69 +1,139 @@
|
|
|
1
|
+
import { offlineRecoveryCopy } from "./recoveryPolicy.js";
|
|
2
|
+
import { initialPendingWriteRetryDelayMs, maxPendingWriteRetryDelayMs, } from "./browserRecovery.js";
|
|
1
3
|
export const defaultOfflineFallbackRetryEvent = "app:network-restored";
|
|
2
4
|
export const defaultOfflineFallbackPullThreshold = 72;
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export function createBrowserOfflineFallbackRecovery({ control, environment = createDefaultBrowserOfflineFallbackRecoveryEnvironment(), pullThreshold = defaultOfflineFallbackPullThreshold, retryEvent = defaultOfflineFallbackRetryEvent, retryingText = "Reconnecting…", }) {
|
|
5
|
+
/** Owns pull/reconnect recovery, including safe asynchronous durability proof. */
|
|
6
|
+
export function createBrowserOfflineFallbackRecovery({ control, canReload, environment = createDefaultBrowserOfflineFallbackRecoveryEnvironment(), pullThreshold = defaultOfflineFallbackPullThreshold, retryEvent = defaultOfflineFallbackRetryEvent, retryingText = offlineRecoveryCopy.reloading, }) {
|
|
7
|
+
if (control)
|
|
8
|
+
control.textContent = offlineRecoveryCopy.reload;
|
|
8
9
|
let active = false;
|
|
9
|
-
let
|
|
10
|
+
let navigating = false;
|
|
11
|
+
let checking = false;
|
|
12
|
+
let requested = false;
|
|
13
|
+
let generation = 0;
|
|
14
|
+
let timer;
|
|
15
|
+
let delay = initialPendingWriteRetryDelayMs;
|
|
10
16
|
let pullStartY = null;
|
|
11
|
-
const touchThreshold = pullThreshold === null ?
|
|
12
|
-
function
|
|
13
|
-
if (
|
|
17
|
+
const touchThreshold = pullThreshold === null ? Infinity : pullThreshold;
|
|
18
|
+
function clearTimer() {
|
|
19
|
+
if (timer !== undefined)
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
timer = undefined;
|
|
22
|
+
}
|
|
23
|
+
function schedule() {
|
|
24
|
+
if (!active || !requested || timer !== undefined)
|
|
25
|
+
return;
|
|
26
|
+
timer = setTimeout(() => {
|
|
27
|
+
timer = undefined;
|
|
28
|
+
attempt();
|
|
29
|
+
}, delay);
|
|
30
|
+
delay = Math.min(delay * 2, maxPendingWriteRetryDelayMs);
|
|
31
|
+
}
|
|
32
|
+
function failed(error) {
|
|
33
|
+
console.error("[Bayon offline] Offline fallback refresh could not complete; local work retained.", { error });
|
|
34
|
+
navigating = false;
|
|
35
|
+
if (control) {
|
|
36
|
+
control.disabled = false;
|
|
37
|
+
control.textContent = offlineRecoveryCopy.reload;
|
|
38
|
+
}
|
|
39
|
+
schedule();
|
|
40
|
+
}
|
|
41
|
+
function complete(safe, currentGeneration) {
|
|
42
|
+
if (currentGeneration !== generation)
|
|
43
|
+
return;
|
|
44
|
+
checking = false;
|
|
45
|
+
if (!safe) {
|
|
46
|
+
schedule();
|
|
14
47
|
return;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
control
|
|
48
|
+
}
|
|
49
|
+
clearTimer();
|
|
50
|
+
navigating = true;
|
|
51
|
+
if (control) {
|
|
52
|
+
control.disabled = true;
|
|
53
|
+
control.textContent = retryingText;
|
|
54
|
+
}
|
|
19
55
|
try {
|
|
20
56
|
environment.reload();
|
|
57
|
+
requested = false;
|
|
21
58
|
}
|
|
22
59
|
catch (error) {
|
|
23
|
-
|
|
24
|
-
console.error("[Bayon offline] Offline fallback reload failed.", {
|
|
25
|
-
error,
|
|
26
|
-
});
|
|
27
|
-
retrying = false;
|
|
28
|
-
control.disabled = false;
|
|
29
|
-
control.textContent = previousText;
|
|
60
|
+
failed(error);
|
|
30
61
|
}
|
|
31
62
|
}
|
|
63
|
+
function attempt() {
|
|
64
|
+
if (!requested || checking || navigating)
|
|
65
|
+
return;
|
|
66
|
+
checking = true;
|
|
67
|
+
const currentGeneration = generation;
|
|
68
|
+
try {
|
|
69
|
+
const proof = canReload ? canReload() : true;
|
|
70
|
+
if (typeof proof === "boolean")
|
|
71
|
+
complete(proof, currentGeneration);
|
|
72
|
+
else
|
|
73
|
+
void proof.then((safe) => complete(safe, currentGeneration), (error) => {
|
|
74
|
+
if (currentGeneration !== generation)
|
|
75
|
+
return;
|
|
76
|
+
checking = false;
|
|
77
|
+
failed(error);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
checking = false;
|
|
82
|
+
failed(error);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function refresh() {
|
|
86
|
+
requested = true;
|
|
87
|
+
clearTimer();
|
|
88
|
+
delay = initialPendingWriteRetryDelayMs;
|
|
89
|
+
attempt();
|
|
90
|
+
}
|
|
32
91
|
function handleTouchStart(event) {
|
|
33
|
-
|
|
34
|
-
|
|
92
|
+
pullStartY =
|
|
93
|
+
environment.getScrollY() === 0 ? getSingleTouchY(event, "touches") : null;
|
|
35
94
|
}
|
|
36
95
|
function handleTouchEnd(event) {
|
|
37
96
|
const touchY = getSingleTouchY(event, "changedTouches");
|
|
38
97
|
if (pullStartY !== null &&
|
|
39
98
|
touchY !== null &&
|
|
40
|
-
touchY - pullStartY >= touchThreshold)
|
|
41
|
-
|
|
42
|
-
|
|
99
|
+
touchY - pullStartY >= touchThreshold)
|
|
100
|
+
refresh();
|
|
101
|
+
pullStartY = null;
|
|
102
|
+
}
|
|
103
|
+
function handleTouchCancel() {
|
|
43
104
|
pullStartY = null;
|
|
44
105
|
}
|
|
45
106
|
return {
|
|
46
|
-
|
|
107
|
+
refresh,
|
|
108
|
+
reload: refresh,
|
|
109
|
+
retry: refresh,
|
|
47
110
|
start() {
|
|
48
111
|
if (active)
|
|
49
112
|
return;
|
|
50
113
|
active = true;
|
|
51
|
-
environment.addEventListener("online",
|
|
52
|
-
environment.addEventListener(retryEvent,
|
|
114
|
+
environment.addEventListener("online", refresh);
|
|
115
|
+
environment.addEventListener(retryEvent, refresh);
|
|
53
116
|
if (pullThreshold !== null) {
|
|
54
117
|
environment.addEventListener("touchstart", handleTouchStart);
|
|
55
118
|
environment.addEventListener("touchend", handleTouchEnd);
|
|
119
|
+
environment.addEventListener("touchcancel", handleTouchCancel);
|
|
56
120
|
}
|
|
57
121
|
},
|
|
58
122
|
stop() {
|
|
123
|
+
// Fence a proof completing after unmount, including manual pre-start calls.
|
|
124
|
+
generation++;
|
|
125
|
+
requested = false;
|
|
126
|
+
checking = false;
|
|
127
|
+
clearTimer();
|
|
59
128
|
if (!active)
|
|
60
129
|
return;
|
|
61
130
|
active = false;
|
|
62
|
-
environment.removeEventListener("online",
|
|
63
|
-
environment.removeEventListener(retryEvent,
|
|
131
|
+
environment.removeEventListener("online", refresh);
|
|
132
|
+
environment.removeEventListener(retryEvent, refresh);
|
|
64
133
|
if (pullThreshold !== null) {
|
|
65
134
|
environment.removeEventListener("touchstart", handleTouchStart);
|
|
66
135
|
environment.removeEventListener("touchend", handleTouchEnd);
|
|
136
|
+
environment.removeEventListener("touchcancel", handleTouchCancel);
|
|
67
137
|
}
|
|
68
138
|
pullStartY = null;
|
|
69
139
|
},
|
package/dist/idbDriver.d.ts
CHANGED
|
@@ -18,9 +18,11 @@ export type BrowserKeyValueDriverOptions = {
|
|
|
18
18
|
databaseVersion?: number;
|
|
19
19
|
indexedDB?: IDBFactory;
|
|
20
20
|
operationTimeoutMs: number;
|
|
21
|
+
/** Queue reads must throw; read caches may retain best-effort fallback. */
|
|
22
|
+
readFailureMode?: "fallback" | "throw";
|
|
21
23
|
storeName: string;
|
|
22
24
|
};
|
|
23
25
|
export declare class OfflineStorageError extends Error {
|
|
24
26
|
name: string;
|
|
25
27
|
}
|
|
26
|
-
export declare function createIdbKeyValueDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserKeyValueDriverOptions): BrowserKeyValueDriver | null;
|
|
28
|
+
export declare function createIdbKeyValueDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, readFailureMode, storeName, }: BrowserKeyValueDriverOptions): BrowserKeyValueDriver | null;
|
package/dist/idbDriver.js
CHANGED
|
@@ -2,16 +2,28 @@ import { wrap, } from "idb";
|
|
|
2
2
|
export class OfflineStorageError extends Error {
|
|
3
3
|
name = "OfflineStorageError";
|
|
4
4
|
}
|
|
5
|
-
export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
|
|
5
|
+
export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, readFailureMode = "fallback", storeName, }) {
|
|
6
6
|
if (!indexedDB) {
|
|
7
7
|
return null;
|
|
8
8
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
let databasePromise = null;
|
|
10
|
+
function getDatabase() {
|
|
11
|
+
if (!databasePromise) {
|
|
12
|
+
const attempt = openKeyValueDatabase({
|
|
13
|
+
databaseName,
|
|
14
|
+
databaseVersion,
|
|
15
|
+
indexedDB: indexedDB,
|
|
16
|
+
storeName,
|
|
17
|
+
});
|
|
18
|
+
databasePromise = attempt;
|
|
19
|
+
// A transient failed open must not poison every later recovery read.
|
|
20
|
+
void attempt.catch(() => {
|
|
21
|
+
databasePromise = null;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return databasePromise;
|
|
25
|
+
}
|
|
26
|
+
getDatabase();
|
|
15
27
|
function runMutation(operationName, mutate) {
|
|
16
28
|
return runTransactionalStorageOperation({
|
|
17
29
|
failureMode: "throw",
|
|
@@ -19,7 +31,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
19
31
|
operationName,
|
|
20
32
|
storeName,
|
|
21
33
|
operation: async (setTransaction, isCancelled) => {
|
|
22
|
-
const database = await
|
|
34
|
+
const database = await getDatabase();
|
|
23
35
|
if (isCancelled())
|
|
24
36
|
return;
|
|
25
37
|
const transaction = database.transaction(storeName, "readwrite");
|
|
@@ -40,7 +52,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
40
52
|
operationName: "close database",
|
|
41
53
|
storeName,
|
|
42
54
|
operation: async () => {
|
|
43
|
-
const database = await
|
|
55
|
+
const database = await getDatabase();
|
|
44
56
|
database.close();
|
|
45
57
|
},
|
|
46
58
|
timeoutMs: operationTimeoutMs,
|
|
@@ -51,7 +63,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
51
63
|
},
|
|
52
64
|
async deleteIf(key, predicate) {
|
|
53
65
|
return runConditionalStorageOperation({
|
|
54
|
-
databasePromise,
|
|
66
|
+
databasePromise: getDatabase(),
|
|
55
67
|
operationName: "conditionally delete key",
|
|
56
68
|
storeName,
|
|
57
69
|
timeoutMs: operationTimeoutMs,
|
|
@@ -66,11 +78,12 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
66
78
|
},
|
|
67
79
|
async get(key) {
|
|
68
80
|
return runBrowserStorageOperation({
|
|
81
|
+
failureMode: readFailureMode,
|
|
69
82
|
fallback: null,
|
|
70
83
|
operationName: "read key",
|
|
71
84
|
storeName,
|
|
72
85
|
operation: async () => {
|
|
73
|
-
const database = await
|
|
86
|
+
const database = await getDatabase();
|
|
74
87
|
return (await database.get(storeName, key)) ?? null;
|
|
75
88
|
},
|
|
76
89
|
timeoutMs: operationTimeoutMs,
|
|
@@ -78,11 +91,12 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
78
91
|
},
|
|
79
92
|
async list() {
|
|
80
93
|
return runBrowserStorageOperation({
|
|
94
|
+
failureMode: readFailureMode,
|
|
81
95
|
fallback: [],
|
|
82
96
|
operationName: "list object store",
|
|
83
97
|
storeName,
|
|
84
98
|
operation: async () => {
|
|
85
|
-
const database = await
|
|
99
|
+
const database = await getDatabase();
|
|
86
100
|
return database.getAll(storeName);
|
|
87
101
|
},
|
|
88
102
|
timeoutMs: operationTimeoutMs,
|
|
@@ -90,11 +104,12 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
90
104
|
},
|
|
91
105
|
async listEntries() {
|
|
92
106
|
return runBrowserStorageOperation({
|
|
107
|
+
failureMode: readFailureMode,
|
|
93
108
|
fallback: [],
|
|
94
109
|
operationName: "list object-store entries",
|
|
95
110
|
storeName,
|
|
96
111
|
operation: async () => {
|
|
97
|
-
const database = await
|
|
112
|
+
const database = await getDatabase();
|
|
98
113
|
// Keys and values must come from one transaction. Separate database
|
|
99
114
|
// reads could pair a key from one cross-tab storage state with a
|
|
100
115
|
// value from another, causing retention code to delete the wrong key.
|
|
@@ -118,7 +133,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
118
133
|
},
|
|
119
134
|
async setIf(key, value, predicate) {
|
|
120
135
|
return runConditionalStorageOperation({
|
|
121
|
-
databasePromise,
|
|
136
|
+
databasePromise: getDatabase(),
|
|
122
137
|
operationName: "conditionally replace key",
|
|
123
138
|
storeName,
|
|
124
139
|
timeoutMs: operationTimeoutMs,
|
package/dist/index.d.ts
CHANGED
|
@@ -5,3 +5,6 @@ export * from "./browserCoordination.js";
|
|
|
5
5
|
export * from "./browserFallbackRecovery.js";
|
|
6
6
|
export * from "./durableCommandStore.js";
|
|
7
7
|
export { OfflineStorageError } from "./idbDriver.js";
|
|
8
|
+
export * from "./readBoundary.js";
|
|
9
|
+
export * from "./recoveryPolicy.js";
|
|
10
|
+
export * from "./pendingWriteSupersession.js";
|
package/dist/index.js
CHANGED
|
@@ -5,3 +5,6 @@ export * from "./browserCoordination.js";
|
|
|
5
5
|
export * from "./browserFallbackRecovery.js";
|
|
6
6
|
export * from "./durableCommandStore.js";
|
|
7
7
|
export { OfflineStorageError } from "./idbDriver.js";
|
|
8
|
+
export * from "./readBoundary.js";
|
|
9
|
+
export * from "./recoveryPolicy.js";
|
|
10
|
+
export * from "./pendingWriteSupersession.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PendingWriteRecord, PendingWriteStore } from "./pendingWrites.js";
|
|
2
|
+
/**
|
|
3
|
+
* After remote acknowledgment, retire only rejected snapshots superseded by an
|
|
4
|
+
* explicit host-owned account/entity/field key. Never infer keys from payloads.
|
|
5
|
+
*/
|
|
6
|
+
export declare function clearSupersededPendingWriteFailures<Payload>({ acknowledged, getSupersessionKey, isRejected, store, }: {
|
|
7
|
+
acknowledged: PendingWriteRecord<Payload>;
|
|
8
|
+
getSupersessionKey: (record: PendingWriteRecord<Payload>) => string | null;
|
|
9
|
+
isRejected: (record: PendingWriteRecord<Payload>) => boolean;
|
|
10
|
+
store: PendingWriteStore<Payload>;
|
|
11
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* After remote acknowledgment, retire only rejected snapshots superseded by an
|
|
3
|
+
* explicit host-owned account/entity/field key. Never infer keys from payloads.
|
|
4
|
+
*/
|
|
5
|
+
export async function clearSupersededPendingWriteFailures({ acknowledged, getSupersessionKey, isRejected, store, }) {
|
|
6
|
+
const key = getSupersessionKey(acknowledged);
|
|
7
|
+
if (key === null)
|
|
8
|
+
return 0;
|
|
9
|
+
let cleared = 0;
|
|
10
|
+
for (const previous of await store.list()) {
|
|
11
|
+
const older = previous.savedAt.localeCompare(acknowledged.savedAt) ||
|
|
12
|
+
previous.id.localeCompare(acknowledged.id);
|
|
13
|
+
if (previous.id === acknowledged.id ||
|
|
14
|
+
older >= 0 ||
|
|
15
|
+
previous.syncStatus !== "failed" ||
|
|
16
|
+
previous.syncingAt ||
|
|
17
|
+
!isRejected(previous) ||
|
|
18
|
+
getSupersessionKey(previous) !== key)
|
|
19
|
+
continue;
|
|
20
|
+
// Snapshot fencing protects a correction or claim accepted during the scan.
|
|
21
|
+
if (await store.clearIfSnapshotId(previous.id, previous.snapshotId))
|
|
22
|
+
cleared++;
|
|
23
|
+
}
|
|
24
|
+
return cleared;
|
|
25
|
+
}
|
package/dist/pendingWrites.js
CHANGED
|
@@ -170,6 +170,7 @@ export function createBrowserPendingWriteDriver({ databaseName, databaseVersion,
|
|
|
170
170
|
indexedDB,
|
|
171
171
|
operationTimeoutMs,
|
|
172
172
|
storeName,
|
|
173
|
+
readFailureMode: "throw",
|
|
173
174
|
});
|
|
174
175
|
return driver ? toPendingWriteDriver(driver) : null;
|
|
175
176
|
}
|
|
@@ -409,10 +410,10 @@ function createUnavailablePendingWriteDriver() {
|
|
|
409
410
|
},
|
|
410
411
|
async close() { },
|
|
411
412
|
async get() {
|
|
412
|
-
|
|
413
|
+
throw unavailable();
|
|
413
414
|
},
|
|
414
415
|
async list() {
|
|
415
|
-
|
|
416
|
+
throw unavailable();
|
|
416
417
|
},
|
|
417
418
|
async set() {
|
|
418
419
|
throw unavailable();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ReadBoundaryOptions = {
|
|
2
|
+
modules: Readonly<Record<string, readonly string[]>>;
|
|
3
|
+
roots: readonly string[];
|
|
4
|
+
allowedAdapters: readonly string[];
|
|
5
|
+
isForbidden: (moduleId: string) => boolean;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Host contract: supply every reachable local runtime dependency, including
|
|
9
|
+
* re-exports and dynamic imports. Absent nodes are external leaves. Exact
|
|
10
|
+
* trusted adapter IDs stop traversal; hosts verify their cache policy separately.
|
|
11
|
+
* This test utility deliberately owns no parser, framework, or provider policy.
|
|
12
|
+
*/
|
|
13
|
+
export declare function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }: ReadBoundaryOptions): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host contract: supply every reachable local runtime dependency, including
|
|
3
|
+
* re-exports and dynamic imports. Absent nodes are external leaves. Exact
|
|
4
|
+
* trusted adapter IDs stop traversal; hosts verify their cache policy separately.
|
|
5
|
+
* This test utility deliberately owns no parser, framework, or provider policy.
|
|
6
|
+
*/
|
|
7
|
+
export function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }) {
|
|
8
|
+
const adapters = new Set(allowedAdapters);
|
|
9
|
+
const visited = new Set();
|
|
10
|
+
function visit(id, path) {
|
|
11
|
+
if (adapters.has(id) || visited.has(id))
|
|
12
|
+
return;
|
|
13
|
+
const nextPath = [...path, id];
|
|
14
|
+
if (isForbidden(id)) {
|
|
15
|
+
throw new Error(`Read boundary violation: ${nextPath.join(" -> ")}`);
|
|
16
|
+
}
|
|
17
|
+
visited.add(id);
|
|
18
|
+
for (const dependency of modules[id] ?? [])
|
|
19
|
+
visit(dependency, nextPath);
|
|
20
|
+
}
|
|
21
|
+
for (const root of roots)
|
|
22
|
+
visit(root, []);
|
|
23
|
+
}
|