@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 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
- Standalone offline documents can reuse retry mechanics while keeping their
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
- control: document.querySelector<HTMLButtonElement>("#reload")!,
213
- // 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(),
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
- The controller disables the control after the first retry and changes its text
221
- to `Reconnecting…`; it then reloads once. It listens to browser `online`, the
222
- configured host event, and (by default) a 72px downward touch gesture beginning
223
- at the top of the page. Set `pullThreshold: null` when the host owns touch
224
- recovery. Static fallback documents must still bundle or inline this package
225
- 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.
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
- /** 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,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
- * 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
- const previousText = control.textContent;
16
- retrying = true;
17
- control.disabled = true;
18
- control.textContent = retryingText;
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
- // A failed navigation must leave the recovery action visible and usable.
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
- const touchY = getSingleTouchY(event, "touches");
34
- pullStartY = environment.getScrollY() === 0 ? touchY : null;
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
- retry();
42
- }
99
+ touchY - pullStartY >= touchThreshold)
100
+ refresh();
101
+ pullStartY = null;
102
+ }
103
+ function handleTouchCancel() {
43
104
  pullStartY = null;
44
105
  }
45
106
  return {
46
- retry,
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", retry);
52
- environment.addEventListener(retryEvent, retry);
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", retry);
63
- environment.removeEventListener(retryEvent, retry);
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
  },
@@ -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,
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
+ }
@@ -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
- return null;
413
+ throw unavailable();
413
414
  },
414
415
  async list() {
415
- return [];
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
+ }