@bayonai/offline 0.8.0 → 0.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +107 -16
  3. package/RECOVERY.md +64 -0
  4. package/dist/browserFallbackRecovery.d.ts +10 -9
  5. package/dist/browserFallbackRecovery.js +100 -28
  6. package/dist/browserRecovery.d.ts +2 -2
  7. package/dist/durableCommandStore.d.ts +1 -1
  8. package/dist/idbDriver.d.ts +3 -1
  9. package/dist/idbDriver.js +29 -14
  10. package/dist/index.d.ts +10 -7
  11. package/dist/index.js +3 -0
  12. package/dist/pendingWriteSupersession.d.ts +11 -0
  13. package/dist/pendingWriteSupersession.js +25 -0
  14. package/dist/pendingWrites.d.ts +6 -2
  15. package/dist/pendingWrites.js +45 -11
  16. package/dist/readBoundary.d.ts +13 -0
  17. package/dist/readBoundary.js +23 -0
  18. package/dist/readCache.d.ts +26 -5
  19. package/dist/readCache.js +113 -19
  20. package/dist/recoveryPolicy.d.ts +20 -0
  21. package/dist/recoveryPolicy.js +73 -0
  22. package/dist-cjs/browserCoordination.d.ts +23 -0
  23. package/dist-cjs/browserFallbackRecovery.d.ts +33 -0
  24. package/dist-cjs/browserFallbackRecovery.js +100 -28
  25. package/dist-cjs/browserRecovery.d.ts +42 -0
  26. package/dist-cjs/durableCommandStore.d.ts +49 -0
  27. package/dist-cjs/idbDriver.d.ts +28 -0
  28. package/dist-cjs/idbDriver.js +29 -14
  29. package/dist-cjs/index.d.ts +10 -0
  30. package/dist-cjs/index.js +3 -0
  31. package/dist-cjs/pendingWriteSupersession.d.ts +11 -0
  32. package/dist-cjs/pendingWriteSupersession.js +28 -0
  33. package/dist-cjs/pendingWrites.d.ts +179 -0
  34. package/dist-cjs/pendingWrites.js +45 -11
  35. package/dist-cjs/readBoundary.d.ts +13 -0
  36. package/dist-cjs/readBoundary.js +26 -0
  37. package/dist-cjs/readCache.d.ts +112 -0
  38. package/dist-cjs/readCache.js +113 -19
  39. package/dist-cjs/recoveryPolicy.d.ts +20 -0
  40. package/dist-cjs/recoveryPolicy.js +78 -0
  41. package/package.json +16 -7
package/CHANGELOG.md CHANGED
@@ -1,6 +1,44 @@
1
1
  # Changelog
2
2
 
3
- ## 0.8.0 - Unreleased
3
+ ## 0.10.0 - 2026-11-03
4
+
5
+ - Fixed all four audited recovery gaps: strict pending-store reads, retained async
6
+ refresh requests with capped rechecks, conservative transport classification,
7
+ and host-keyed snapshot-fenced retirement of acknowledged setter corrections.
8
+ - Added buttonless pull-down refresh, cancellation fencing and transient database
9
+ reopen recovery while retaining cache read fallback and legacy aliases.
10
+
11
+ - Moved the no-user-retry decision into the package-owned `RECOVERY.md` contract.
12
+ - Added shared `getPendingChangesGuidance`, `offlineRecoveryCopy` and
13
+ `isTransientOfflineError` helpers for truthful background-sync guidance and
14
+ conservative recognition of known transient failures.
15
+ - Added canonical fallback `reload()` semantics, normalized Reload labels and
16
+ a host durability veto for manual/reconnect/gesture navigation. Kept `retry()`
17
+ as an internal compatibility alias; failed navigation preserves the control.
18
+
19
+ - Added the framework-free `assertReadBoundary` architecture-test utility for
20
+ host-supplied dependency graphs and exact trusted adapter boundaries.
21
+ - Added actual IndexedDB close/reopen regression coverage for durable
22
+ create/update/delete commands, ordered connectivity replay, terminal rollback,
23
+ retained failure diagnostics, and account isolation.
24
+
25
+ ## 0.9.0 - 2026-09-26
26
+
27
+ - Added independent read-cache freshness and retention windows with explicit
28
+ `fresh`, `stale`, and `miss` outcomes and selectable refresh-failure behavior.
29
+ - Added retained fallback and stale in-flight response protection to forced
30
+ `loadReadCacheResource` refreshes.
31
+ - Added payload-free read-cache outcome callbacks for host-owned telemetry.
32
+ - Preserved existing `maxAgeMs` behavior for callers that do not opt into the
33
+ new policy. Forced refreshes retain fallback data while invalidating older
34
+ in-flight cache writes.
35
+ - Isolated pending-write listing and clearing by `keyPrefix` when logical
36
+ queues share one physical object store.
37
+ - Fixed NodeNext declaration resolution and supplied matching declarations for
38
+ both ESM and CommonJS consumers.
39
+ - Restored offline retry-control text when a reload attempt throws.
40
+
41
+ ## 0.8.0 - 2026-09-25
4
42
 
5
43
  - Added authoritative-result reconciliation to `createDurableCommandStore`,
6
44
  including rebasing remaining optimistic commands without a transient revert.
package/README.md CHANGED
@@ -25,8 +25,15 @@ import {
25
25
  createReadCacheKey,
26
26
  loadReadCacheResource,
27
27
  pruneReadCache,
28
+ type ReadCachePolicy,
28
29
  } from "@bayonai/offline";
29
30
 
31
+ const policy: ReadCachePolicy = {
32
+ freshForMs: 30_000,
33
+ retainForMs: 7 * 24 * 60 * 60_000,
34
+ refreshFailure: "use-retained",
35
+ };
36
+
30
37
  const cache = createBrowserReadCache({
31
38
  databaseName: "my-app:v1",
32
39
  storeName: "read-cache",
@@ -43,13 +50,41 @@ const projects = await loadReadCacheResource({
43
50
  cache,
44
51
  key,
45
52
  loadFresh: () => fetchProjects(),
46
- maxAgeMs: 30_000,
47
- onUpdate: ({ data, fromCache }) => {
48
- renderProjects(data, { fromCache });
53
+ onOutcome: (outcome) => telemetry.record("offline-read", { outcome }),
54
+ policy,
55
+ onUpdate: ({ data, status, fromCache }) => {
56
+ renderProjects(data, { fromCache, status });
49
57
  },
50
58
  });
51
59
  ```
52
60
 
61
+ `freshForMs` controls how long a cached value can avoid a remote read.
62
+ `retainForMs` independently controls how long it can be shown as an offline
63
+ fallback. A stale-but-retained value is emitted immediately and revalidated;
64
+ an expired value is not emitted and cannot mask a loader failure. The
65
+ `status` is `fresh`, `stale`, or `miss`: a stale update is retained data while
66
+ refreshing or after a failed refresh, and a miss is a successful loader result
67
+ with no retained cache. Successful remote results are `fresh` when replacing
68
+ stale data and `miss` when there was no usable retained data.
69
+
70
+ `onOutcome` emits only a fixed event name (`fresh-hit`, `stale-hit`,
71
+ `cache-miss`, `forced-refresh`, `loader-success`, or `loader-failure`). It never
72
+ includes cache keys, user identifiers, payloads, or error messages. Callback
73
+ exceptions are logged and isolated from the read.
74
+
75
+ When refresh fails, `refreshFailure: "use-retained"` returns retained data
76
+ with `status: "stale"` and the error; `"throw"` propagates the loader error.
77
+ This also applies to forced refresh. Set `forceFresh: true` on
78
+ `loadReadCacheResource` or `readThroughCache` to bypass freshness, retain
79
+ eligible data as the failure fallback, and advance the cache generation so an
80
+ older in-flight load cannot overwrite the refresh. `readThroughCache` returns
81
+ the `status` field alongside its existing `fromCache`, `stale`, and `error`
82
+ fields.
83
+
84
+ For compatibility, callers that omit `policy` retain the previous `maxAgeMs`
85
+ freshness behavior and have no age-based fallback expiry. New callers should
86
+ use `policy`; when supplied it takes precedence over the legacy `maxAgeMs`.
87
+
53
88
  After a successful write, use `readThroughCache({ forceFresh: true })` when
54
89
  the next read must be server-confirmed rather than a pre-write cached or
55
90
  in-flight response. Concurrent ordinary reads for the same cache/key share one
@@ -102,6 +137,11 @@ Failed sync results include a safe persisted `error` string and the original
102
137
  non-persisted `failure` value. Hosts may use `failure` for structured logging or
103
138
  domain error classification; it is never written into the pending-write store.
104
139
 
140
+ `keyPrefix` is an isolation boundary inside a shared object store. Listing,
141
+ recovery, and `clearAll()` affect only that prefix, so independent logical
142
+ queues may safely share the same IndexedDB database and store when their
143
+ prefixes are distinct.
144
+
105
145
  For a browser application that wants durable, silent recovery, mount the
106
146
  headless controller once for the active account or workspace:
107
147
 
@@ -160,29 +200,55 @@ the current tab as before. A contested lease retries silently with bounded
160
200
  backoff. Remote write handlers must remain idempotent because leases do not
161
201
  cover other devices or every browser failure mode.
162
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
+
163
214
  ## Static Offline Fallback Recovery
164
215
 
165
- Standalone offline documents can reuse retry mechanics while keeping their
166
- own markup, branding, copy, and host-native connectivity event name:
216
+ Pull-down and reconnect request a safe refresh; no refresh button is required.
167
217
 
168
218
  ```ts
169
219
  import { createBrowserOfflineFallbackRecovery } from "@bayonai/offline";
170
220
 
171
221
  const recovery = createBrowserOfflineFallbackRecovery({
172
- control: document.querySelector<HTMLButtonElement>("#reload")!,
173
- // Dispatch only after the host platform has validated connectivity.
222
+ // Read current storage health on every attempt. Never discard work to refresh.
223
+ canReload: async () => await hostCanProveLocalDurability(),
174
224
  retryEvent: "my-app:network-restored",
175
225
  });
176
-
177
226
  recovery.start();
227
+ // On unmount:
228
+ // recovery.stop();
178
229
  ```
179
230
 
180
- The controller disables the control after the first retry and changes its text
181
- to `Reconnecting…`; it then reloads once. It listens to browser `online`, the
182
- configured host event, and (by default) a 72px downward touch gesture beginning
183
- at the top of the page. Set `pullThreshold: null` when the host owns touch
184
- recovery. Static fallback documents must still bundle or inline this package
185
- code; a browser cannot resolve a bare npm package specifier from `public/`.
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.
186
252
 
187
253
  Use `createOrderedPendingWriteExecutor` when a new write must never overtake a
188
254
  previously queued write in the same user, workspace, or other durable scope.
@@ -306,15 +372,16 @@ opened at the same version.
306
372
 
307
373
  ```bash
308
374
  pnpm --filter @bayonai/offline test
375
+ pnpm --filter @bayonai/offline test:coverage
309
376
  pnpm --filter @bayonai/offline run build
310
- pnpm --filter @bayonai/offline pack --dry-run
377
+ pnpm --filter @bayonai/offline pack:smoke
311
378
  ```
312
379
 
313
380
  ## Publish Checklist
314
381
 
315
382
  1. Run the package tests and build.
316
383
  2. Run the 100% coverage gate and production dependency audit.
317
- 3. Run `pnpm --filter @bayonai/offline pack --dry-run` and confirm the tarball
384
+ 3. Run `pnpm --filter @bayonai/offline pack:smoke` and confirm the tarball
318
385
  includes only `package.json`, `dist`, `dist-cjs`, `README.md`,
319
386
  `CHANGELOG.md`, and `LICENSE`.
320
387
  4. Install the generated tarball into an isolated consumer and verify ESM,
@@ -323,3 +390,27 @@ pnpm --filter @bayonai/offline pack --dry-run
323
390
  `publishConfig.access` are correct.
324
391
  6. Publish from the package directory or with the package filter after the
325
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,64 @@
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.
@@ -11,22 +11,23 @@ export type OfflineFallbackRetryControl = {
11
11
  textContent: string | null;
12
12
  };
13
13
  export type BrowserOfflineFallbackRecovery = {
14
- /** Attempts recovery once. Repeated calls are ignored while navigation starts. */
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
- control: OfflineFallbackRetryControl;
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
- * 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;
32
+ /** Owns pull/reconnect recovery, including safe asynchronous durability proof. */
33
+ export declare function createBrowserOfflineFallbackRecovery({ control, canReload, environment, pullThreshold, retryEvent, retryingText, }: BrowserOfflineFallbackRecoveryOptions): BrowserOfflineFallbackRecovery;
@@ -1,67 +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
- * 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…", }) {
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 retrying = false;
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 ? Number.POSITIVE_INFINITY : pullThreshold;
12
- function retry() {
13
- if (retrying)
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
- retrying = true;
16
- control.disabled = true;
17
- control.textContent = retryingText;
48
+ }
49
+ clearTimer();
50
+ navigating = true;
51
+ if (control) {
52
+ control.disabled = true;
53
+ control.textContent = retryingText;
54
+ }
18
55
  try {
19
56
  environment.reload();
57
+ requested = false;
20
58
  }
21
59
  catch (error) {
22
- // A failed navigation must leave the recovery action visible and usable.
23
- console.error("[Bayon offline] Offline fallback reload failed.", {
24
- error,
25
- });
26
- retrying = false;
27
- control.disabled = false;
60
+ failed(error);
28
61
  }
29
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
+ }
30
91
  function handleTouchStart(event) {
31
- const touchY = getSingleTouchY(event, "touches");
32
- pullStartY = environment.getScrollY() === 0 ? touchY : null;
92
+ pullStartY =
93
+ environment.getScrollY() === 0 ? getSingleTouchY(event, "touches") : null;
33
94
  }
34
95
  function handleTouchEnd(event) {
35
96
  const touchY = getSingleTouchY(event, "changedTouches");
36
97
  if (pullStartY !== null &&
37
98
  touchY !== null &&
38
- touchY - pullStartY >= touchThreshold) {
39
- retry();
40
- }
99
+ touchY - pullStartY >= touchThreshold)
100
+ refresh();
101
+ pullStartY = null;
102
+ }
103
+ function handleTouchCancel() {
41
104
  pullStartY = null;
42
105
  }
43
106
  return {
44
- retry,
107
+ refresh,
108
+ reload: refresh,
109
+ retry: refresh,
45
110
  start() {
46
111
  if (active)
47
112
  return;
48
113
  active = true;
49
- environment.addEventListener("online", retry);
50
- environment.addEventListener(retryEvent, retry);
114
+ environment.addEventListener("online", refresh);
115
+ environment.addEventListener(retryEvent, refresh);
51
116
  if (pullThreshold !== null) {
52
117
  environment.addEventListener("touchstart", handleTouchStart);
53
118
  environment.addEventListener("touchend", handleTouchEnd);
119
+ environment.addEventListener("touchcancel", handleTouchCancel);
54
120
  }
55
121
  },
56
122
  stop() {
123
+ // Fence a proof completing after unmount, including manual pre-start calls.
124
+ generation++;
125
+ requested = false;
126
+ checking = false;
127
+ clearTimer();
57
128
  if (!active)
58
129
  return;
59
130
  active = false;
60
- environment.removeEventListener("online", retry);
61
- environment.removeEventListener(retryEvent, retry);
131
+ environment.removeEventListener("online", refresh);
132
+ environment.removeEventListener(retryEvent, refresh);
62
133
  if (pullThreshold !== null) {
63
134
  environment.removeEventListener("touchstart", handleTouchStart);
64
135
  environment.removeEventListener("touchend", handleTouchEnd);
136
+ environment.removeEventListener("touchcancel", handleTouchCancel);
65
137
  }
66
138
  pullStartY = null;
67
139
  },
@@ -1,5 +1,5 @@
1
- import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites";
2
- import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination";
1
+ import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites.js";
2
+ import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination.js";
3
3
  export declare const initialPendingWriteRetryDelayMs = 2000;
4
4
  export declare const maxPendingWriteRetryDelayMs = 60000;
5
5
  export type BrowserPendingWriteRecoveryEnvironment = {
@@ -1,4 +1,4 @@
1
- import { type PendingWriteRecord, type PendingWriteStore } from "./pendingWrites";
1
+ import { type PendingWriteRecord, type PendingWriteStore } from "./pendingWrites.js";
2
2
  export type DurableCommandErrorCategory = "auth" | "conflict" | "connectivity" | "local-storage" | "permission" | "unexpected" | "validation";
3
3
  export type DurableCommandOperation<Command> = {
4
4
  command: Command;
@@ -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
- const databasePromise = openKeyValueDatabase({
10
- databaseName,
11
- databaseVersion,
12
- indexedDB,
13
- storeName,
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 databasePromise;
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 databasePromise;
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 databasePromise;
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 databasePromise;
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 databasePromise;
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,