@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.
- package/CHANGELOG.md +39 -1
- package/README.md +107 -16
- package/RECOVERY.md +64 -0
- package/dist/browserFallbackRecovery.d.ts +10 -9
- package/dist/browserFallbackRecovery.js +100 -28
- package/dist/browserRecovery.d.ts +2 -2
- package/dist/durableCommandStore.d.ts +1 -1
- package/dist/idbDriver.d.ts +3 -1
- package/dist/idbDriver.js +29 -14
- package/dist/index.d.ts +10 -7
- package/dist/index.js +3 -0
- package/dist/pendingWriteSupersession.d.ts +11 -0
- package/dist/pendingWriteSupersession.js +25 -0
- package/dist/pendingWrites.d.ts +6 -2
- package/dist/pendingWrites.js +45 -11
- package/dist/readBoundary.d.ts +13 -0
- package/dist/readBoundary.js +23 -0
- package/dist/readCache.d.ts +26 -5
- package/dist/readCache.js +113 -19
- package/dist/recoveryPolicy.d.ts +20 -0
- package/dist/recoveryPolicy.js +73 -0
- package/dist-cjs/browserCoordination.d.ts +23 -0
- package/dist-cjs/browserFallbackRecovery.d.ts +33 -0
- package/dist-cjs/browserFallbackRecovery.js +100 -28
- package/dist-cjs/browserRecovery.d.ts +42 -0
- package/dist-cjs/durableCommandStore.d.ts +49 -0
- package/dist-cjs/idbDriver.d.ts +28 -0
- package/dist-cjs/idbDriver.js +29 -14
- package/dist-cjs/index.d.ts +10 -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.d.ts +179 -0
- package/dist-cjs/pendingWrites.js +45 -11
- package/dist-cjs/readBoundary.d.ts +13 -0
- package/dist-cjs/readBoundary.js +26 -0
- package/dist-cjs/readCache.d.ts +112 -0
- package/dist-cjs/readCache.js +113 -19
- package/dist-cjs/recoveryPolicy.d.ts +20 -0
- package/dist-cjs/recoveryPolicy.js +78 -0
- package/package.json +16 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
99
|
+
touchY - pullStartY >= touchThreshold)
|
|
100
|
+
refresh();
|
|
101
|
+
pullStartY = null;
|
|
102
|
+
}
|
|
103
|
+
function handleTouchCancel() {
|
|
41
104
|
pullStartY = null;
|
|
42
105
|
}
|
|
43
106
|
return {
|
|
44
|
-
|
|
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",
|
|
50
|
-
environment.addEventListener(retryEvent,
|
|
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",
|
|
61
|
-
environment.removeEventListener(retryEvent,
|
|
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;
|
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,
|