react-native-onyx 3.0.87 → 3.0.88

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.
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * An immutable, type-safe finite state machine.
5
+ * Pass the transition graph with `as const` so `transition` only accepts legal target states.
6
+ *
7
+ * @example
8
+ * const transitions = {
9
+ * idle: ['loading'],
10
+ * loading: ['success', 'error'],
11
+ * success: [],
12
+ * error: ['idle'],
13
+ * } as const;
14
+ *
15
+ * const idleMachine = new StateMachine('idle', transitions);
16
+ * const loadingMachine = idleMachine.transition('loading');
17
+ * loadingMachine.transition('success');
18
+ */
19
+ class StateMachine {
20
+ constructor(currentState, transitions) {
21
+ this.state = currentState;
22
+ this.transitions = transitions;
23
+ Object.freeze(this);
24
+ }
25
+ /**
26
+ * Transition to a new state, returning a new state machine instance.
27
+ * Only transitions declared in the graph for the current state are accepted.
28
+ */
29
+ transition(target) {
30
+ const validTargets = this.transitions[this.state];
31
+ if (!(validTargets === null || validTargets === void 0 ? void 0 : validTargets.includes(target))) {
32
+ throw new Error(`Illegal transition from "${String(this.state)}" to "${String(target)}"`);
33
+ }
34
+ return new StateMachine(target, this.transitions);
35
+ }
36
+ }
37
+ exports.default = StateMachine;
@@ -0,0 +1,66 @@
1
+ import AbstractCircuitBreaker from './CircuitBreaker/AbstractCircuitBreaker';
2
+ /**
3
+ * Process-scoped circuit breaker for storage CAPACITY failures.
4
+ *
5
+ * The per-operation retry budget in `OnyxUtils.retryOperation` cannot stop a session-level storm:
6
+ * each evict -> OnyxDerived recompute -> new write starts its own fresh budget, so a full disk or
7
+ * exhausted quota can drive tens of thousands of evict+retry cycles that never make progress and
8
+ * freeze the app. This breaker is the session-level brake — `retryOperation` consults it before
9
+ * every eviction.
10
+ *
11
+ * It is ONE circuit (closed/open/half-open) fed by TWO failure-counting policies, both evaluated in
12
+ * {@link recordFailureInClosed}. It trips when EITHER:
13
+ * - capacity failures within {@link ROLLING_WINDOW_MS} exceed {@link FAILURE_THRESHOLD}, or
14
+ * - {@link NO_PROGRESS_CAP} consecutive evictions are each immediately followed by another capacity
15
+ * failure (the eviction freed nothing the next write could use — a no-progress cycle). This is a
16
+ * cheap proxy for `getDatabaseSize()`, which is costly and only reports origin-wide usage.
17
+ *
18
+ * Keeping both policies inside a single state machine — rather than composing two independent breakers
19
+ * — is deliberate: two breakers each with their own open/half-open/probe latch cannot share one
20
+ * coherent circuit state without races (stranded half-open probes, storms uncounted while the other
21
+ * probes, cross-contaminated counters).
22
+ *
23
+ * On trip it emits exactly ONE alert per incident (across reopen cycles). After {@link ROLLING_WINDOW_MS}
24
+ * the circuit moves to half-open and admits a single eviction+retry probe; a successful probe closes
25
+ * the circuit, a failed probe reopens it for another window.
26
+ */
27
+ declare class StorageCircuitBreaker extends AbstractCircuitBreaker {
28
+ /** Timestamps of capacity failures still inside the rolling window. */
29
+ private failureTimestamps;
30
+ /** Consecutive evictions that each failed to free usable space. */
31
+ private consecutiveNoProgress;
32
+ /** Set when an eviction's retry is pending, so the next capacity failure counts as no-progress. */
33
+ private evictionAwaitingResult;
34
+ /** Guards the single alert per incident (the open→half-open→open cycle must not re-alert). */
35
+ private hasTripped;
36
+ constructor();
37
+ /**
38
+ * Record a CAPACITY failure. Call once per capacity failure in `retryOperation`, BEFORE deciding
39
+ * whether to evict. Returns `true` when the breaker is open and eviction must not proceed.
40
+ */
41
+ recordCapacityFailure(): boolean;
42
+ /** Record that `retryOperation` just evicted a key, so the next capacity failure counts as no-progress. */
43
+ recordEviction(): void;
44
+ /**
45
+ * Record that a storage write succeeded. Fires on EVERY successful write, so it must only act on the
46
+ * one that carries capacity information: a write whose eviction was awaiting its verdict. Such a
47
+ * success means an eviction's retry actually landed — usable space was freed. In half-open that is
48
+ * the recovery probe succeeding (closes the circuit); in closed it clears the no-progress streak. A
49
+ * plain write that happens to succeed proves nothing about capacity and is a no-op (the common case).
50
+ */
51
+ recordWriteSuccess(): void;
52
+ /**
53
+ * Record that the half-open recovery probe failed. `retryOperation` calls this when a write is
54
+ * rejected while a probe is in flight — the storage is still full, so reopen for another window.
55
+ * No-op while fully open (recordFailure short-circuits) and harmless while closed.
56
+ */
57
+ recordProbeFailure(): void;
58
+ /** Wipe all state back to a fresh closed circuit. Process-scoped, so reset between tests/sessions. */
59
+ reset(): void;
60
+ protected recordFailureInClosed(): string | null;
61
+ protected recordSuccessInClosed(): void;
62
+ protected resetFailureState(): void;
63
+ private handleTrip;
64
+ }
65
+ declare const _default: StorageCircuitBreaker;
66
+ export default _default;
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const AbstractCircuitBreaker_1 = __importDefault(require("./CircuitBreaker/AbstractCircuitBreaker"));
40
+ const Logger = __importStar(require("./Logger"));
41
+ /** Rolling window over which capacity failures are counted, and how long a trip stays open. */
42
+ const ROLLING_WINDOW_MS = 60 * 1000;
43
+ /** Capacity failures within the window above which the breaker trips (storm backstop). */
44
+ const FAILURE_THRESHOLD = 50;
45
+ /** Consecutive no-progress evictions (evict -> still capacity failure) above which the breaker trips. */
46
+ const NO_PROGRESS_CAP = 5;
47
+ /**
48
+ * Process-scoped circuit breaker for storage CAPACITY failures.
49
+ *
50
+ * The per-operation retry budget in `OnyxUtils.retryOperation` cannot stop a session-level storm:
51
+ * each evict -> OnyxDerived recompute -> new write starts its own fresh budget, so a full disk or
52
+ * exhausted quota can drive tens of thousands of evict+retry cycles that never make progress and
53
+ * freeze the app. This breaker is the session-level brake — `retryOperation` consults it before
54
+ * every eviction.
55
+ *
56
+ * It is ONE circuit (closed/open/half-open) fed by TWO failure-counting policies, both evaluated in
57
+ * {@link recordFailureInClosed}. It trips when EITHER:
58
+ * - capacity failures within {@link ROLLING_WINDOW_MS} exceed {@link FAILURE_THRESHOLD}, or
59
+ * - {@link NO_PROGRESS_CAP} consecutive evictions are each immediately followed by another capacity
60
+ * failure (the eviction freed nothing the next write could use — a no-progress cycle). This is a
61
+ * cheap proxy for `getDatabaseSize()`, which is costly and only reports origin-wide usage.
62
+ *
63
+ * Keeping both policies inside a single state machine — rather than composing two independent breakers
64
+ * — is deliberate: two breakers each with their own open/half-open/probe latch cannot share one
65
+ * coherent circuit state without races (stranded half-open probes, storms uncounted while the other
66
+ * probes, cross-contaminated counters).
67
+ *
68
+ * On trip it emits exactly ONE alert per incident (across reopen cycles). After {@link ROLLING_WINDOW_MS}
69
+ * the circuit moves to half-open and admits a single eviction+retry probe; a successful probe closes
70
+ * the circuit, a failed probe reopens it for another window.
71
+ */
72
+ class StorageCircuitBreaker extends AbstractCircuitBreaker_1.default {
73
+ constructor() {
74
+ super({
75
+ resetTimeoutMs: ROLLING_WINDOW_MS,
76
+ onTrip: (reason) => this.handleTrip(reason),
77
+ onClose: () => {
78
+ this.hasTripped = false;
79
+ },
80
+ });
81
+ /** Timestamps of capacity failures still inside the rolling window. */
82
+ this.failureTimestamps = [];
83
+ /** Consecutive evictions that each failed to free usable space. */
84
+ this.consecutiveNoProgress = 0;
85
+ /** Set when an eviction's retry is pending, so the next capacity failure counts as no-progress. */
86
+ this.evictionAwaitingResult = false;
87
+ /** Guards the single alert per incident (the open→half-open→open cycle must not re-alert). */
88
+ this.hasTripped = false;
89
+ }
90
+ /**
91
+ * Record a CAPACITY failure. Call once per capacity failure in `retryOperation`, BEFORE deciding
92
+ * whether to evict. Returns `true` when the breaker is open and eviction must not proceed.
93
+ */
94
+ recordCapacityFailure() {
95
+ // We only get here when isAllowed() admitted this caller. In half-open that means THIS is the
96
+ // single recovery probe: the eviction+retry that follows is the actual test, so the capacity
97
+ // failure that triggered it must not re-trip the circuit. Let it proceed; the probe's outcome
98
+ // is the verdict — recordWriteSuccess (retry landed) closes, recordProbeFailure (retry failed,
99
+ // re-entering retryOperation) reopens.
100
+ if (this.peekState() === 'half-open') {
101
+ return false;
102
+ }
103
+ return this.recordFailure();
104
+ }
105
+ /** Record that `retryOperation` just evicted a key, so the next capacity failure counts as no-progress. */
106
+ recordEviction() {
107
+ this.evictionAwaitingResult = true;
108
+ }
109
+ /**
110
+ * Record that a storage write succeeded. Fires on EVERY successful write, so it must only act on the
111
+ * one that carries capacity information: a write whose eviction was awaiting its verdict. Such a
112
+ * success means an eviction's retry actually landed — usable space was freed. In half-open that is
113
+ * the recovery probe succeeding (closes the circuit); in closed it clears the no-progress streak. A
114
+ * plain write that happens to succeed proves nothing about capacity and is a no-op (the common case).
115
+ */
116
+ recordWriteSuccess() {
117
+ if (!this.evictionAwaitingResult) {
118
+ return;
119
+ }
120
+ this.recordSuccess();
121
+ }
122
+ /**
123
+ * Record that the half-open recovery probe failed. `retryOperation` calls this when a write is
124
+ * rejected while a probe is in flight — the storage is still full, so reopen for another window.
125
+ * No-op while fully open (recordFailure short-circuits) and harmless while closed.
126
+ */
127
+ recordProbeFailure() {
128
+ this.recordFailure();
129
+ }
130
+ /** Wipe all state back to a fresh closed circuit. Process-scoped, so reset between tests/sessions. */
131
+ reset() {
132
+ this.hardReset();
133
+ this.hasTripped = false;
134
+ }
135
+ recordFailureInClosed() {
136
+ const now = Date.now();
137
+ this.failureTimestamps = this.failureTimestamps.filter((timestamp) => now - timestamp < ROLLING_WINDOW_MS);
138
+ // A fresh storm (nothing left in the window) resets the no-progress tracking so a stale eviction
139
+ // from an earlier, unrelated incident can't be miscounted as no-progress for this one.
140
+ if (this.failureTimestamps.length === 0) {
141
+ this.consecutiveNoProgress = 0;
142
+ this.evictionAwaitingResult = false;
143
+ }
144
+ // We evicted on the previous cycle and we're back here with another capacity failure, so that
145
+ // eviction freed no usable space.
146
+ if (this.evictionAwaitingResult) {
147
+ this.consecutiveNoProgress += 1;
148
+ this.evictionAwaitingResult = false;
149
+ }
150
+ this.failureTimestamps.push(now);
151
+ if (this.failureTimestamps.length > FAILURE_THRESHOLD) {
152
+ return `${this.failureTimestamps.length} capacity failures within ${ROLLING_WINDOW_MS / 1000}s`;
153
+ }
154
+ if (this.consecutiveNoProgress >= NO_PROGRESS_CAP) {
155
+ return `${this.consecutiveNoProgress} consecutive evictions freed no usable space`;
156
+ }
157
+ return null;
158
+ }
159
+ recordSuccessInClosed() {
160
+ // An eviction's retry succeeded: the eviction made progress, so break the no-progress streak.
161
+ this.consecutiveNoProgress = 0;
162
+ this.evictionAwaitingResult = false;
163
+ }
164
+ resetFailureState() {
165
+ this.failureTimestamps = [];
166
+ this.consecutiveNoProgress = 0;
167
+ this.evictionAwaitingResult = false;
168
+ }
169
+ handleTrip(reason) {
170
+ if (this.hasTripped) {
171
+ return;
172
+ }
173
+ this.hasTripped = true;
174
+ Logger.logAlert(`Storage circuit breaker tripped: ${reason}. Halting eviction/retry for ${ROLLING_WINDOW_MS / 1000}s to stop a storage failure storm.`);
175
+ }
176
+ }
177
+ exports.default = new StorageCircuitBreaker();
@@ -1,5 +1,15 @@
1
1
  declare const StorageMock: {
2
2
  init: jest.Mock<void, [], any>;
3
+ classifyError: jest.Mock<import("type-fest").ValueOf<{
4
+ readonly TRANSIENT: "transient";
5
+ readonly CAPACITY: "capacity";
6
+ readonly INVALID_DATA: "invalidData";
7
+ readonly FATAL: "fatal";
8
+ readonly UNKNOWN: "unknown";
9
+ }>, [error: unknown], any>;
10
+ getStorageProvider: jest.Mock<import("../providers/types").default<{
11
+ [x: string]: unknown;
12
+ }>, [], any>;
3
13
  getItem: jest.Mock<Promise<unknown>, [key: any], any>;
4
14
  multiGet: jest.Mock<Promise<import("../providers/types").StorageKeyValuePair[]>, [keys: import("../providers/types").StorageKeyList], any>;
5
15
  setItem: jest.Mock<Promise<void>, [key: any, value: unknown], any>;
@@ -32,12 +32,27 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  const MemoryOnlyProvider_1 = __importStar(require("../providers/MemoryOnlyProvider"));
40
+ const classifyError_1 = __importDefault(require("../providers/IDBKeyValProvider/classifyError"));
41
+ const classifySQLiteError_1 = __importDefault(require("../providers/classifySQLiteError"));
42
+ const errors_1 = require("../errors");
37
43
  const init = jest.fn(MemoryOnlyProvider_1.default.init);
38
44
  init();
45
+ // Tests exercise retryOperation against both IndexedDB- and SQLite-shaped errors, so the mock facade
46
+ // classifies with each engine's real (native-dep-free) classifier in turn. Mirrors how the real facade
47
+ // delegates to the active provider; here we cover both engines since one mock stands in for all.
48
+ const classifyError = (error) => {
49
+ const idbClass = (0, classifyError_1.default)(error);
50
+ return idbClass === errors_1.StorageErrorClass.UNKNOWN ? (0, classifySQLiteError_1.default)(error) : idbClass;
51
+ };
39
52
  const StorageMock = {
40
53
  init,
54
+ classifyError: jest.fn(classifyError),
55
+ getStorageProvider: jest.fn(() => MemoryOnlyProvider_1.default),
41
56
  getItem: jest.fn(MemoryOnlyProvider_1.default.getItem),
42
57
  multiGet: jest.fn(MemoryOnlyProvider_1.default.multiGet),
43
58
  setItem: jest.fn(MemoryOnlyProvider_1.default.setItem),
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared vocabulary for storage write failures. The *classes* are engine-agnostic; the *matching*
3
+ * is not — each storage provider knows its own error dialect and owns its classifier (see each
4
+ * provider's `classifyError`). This module deliberately holds NO string matchers: it is the common
5
+ * taxonomy the two reacting layers agree on, while the per-engine knowledge lives with the engine.
6
+ *
7
+ * - the connection layer (`createStore`) recovers TRANSIENT and FATAL errors by reopening the DB, and
8
+ * - the operation layer (`OnyxUtils.retryOperation`) recovers CAPACITY by eviction and retries UNKNOWN.
9
+ *
10
+ * This module has no Onyx dependencies (and no engine dependencies) so it can live in the storage
11
+ * layer, and be imported by every provider, without creating an import cycle.
12
+ */
13
+ declare const StorageErrorClass: {
14
+ /** Connection/transport failure (stale connection). Owner: connection layer — reopen + retry once. */
15
+ readonly TRANSIENT: "transient";
16
+ /** Quota exceeded / disk full. Owner: operation layer — evict and retry. */
17
+ readonly CAPACITY: "capacity";
18
+ /** Non-serializable payload. Never retriable — the same data will always fail. */
19
+ readonly INVALID_DATA: "invalidData";
20
+ /** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */
21
+ readonly FATAL: "fatal";
22
+ /** Unmatched by the active provider. Owner: operation layer — bounded retry, and log the shape so
23
+ * recurring cases can be promoted into one of the classes above. */
24
+ readonly UNKNOWN: "unknown";
25
+ };
26
+ /**
27
+ * Normalizes any thrown value into a lowercased `{name, message}` pair for matching. Shared by every
28
+ * provider's classifier so they all extract the error the same way.
29
+ */
30
+ declare function getErrorParts(error: unknown): {
31
+ name: string;
32
+ message: string;
33
+ };
34
+ export { StorageErrorClass, getErrorParts };
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StorageErrorClass = void 0;
4
+ exports.getErrorParts = getErrorParts;
5
+ /**
6
+ * Shared vocabulary for storage write failures. The *classes* are engine-agnostic; the *matching*
7
+ * is not — each storage provider knows its own error dialect and owns its classifier (see each
8
+ * provider's `classifyError`). This module deliberately holds NO string matchers: it is the common
9
+ * taxonomy the two reacting layers agree on, while the per-engine knowledge lives with the engine.
10
+ *
11
+ * - the connection layer (`createStore`) recovers TRANSIENT and FATAL errors by reopening the DB, and
12
+ * - the operation layer (`OnyxUtils.retryOperation`) recovers CAPACITY by eviction and retries UNKNOWN.
13
+ *
14
+ * This module has no Onyx dependencies (and no engine dependencies) so it can live in the storage
15
+ * layer, and be imported by every provider, without creating an import cycle.
16
+ */
17
+ const StorageErrorClass = {
18
+ /** Connection/transport failure (stale connection). Owner: connection layer — reopen + retry once. */
19
+ TRANSIENT: 'transient',
20
+ /** Quota exceeded / disk full. Owner: operation layer — evict and retry. */
21
+ CAPACITY: 'capacity',
22
+ /** Non-serializable payload. Never retriable — the same data will always fail. */
23
+ INVALID_DATA: 'invalidData',
24
+ /** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */
25
+ FATAL: 'fatal',
26
+ /** Unmatched by the active provider. Owner: operation layer — bounded retry, and log the shape so
27
+ * recurring cases can be promoted into one of the classes above. */
28
+ UNKNOWN: 'unknown',
29
+ };
30
+ exports.StorageErrorClass = StorageErrorClass;
31
+ /**
32
+ * Normalizes any thrown value into a lowercased `{name, message}` pair for matching. Shared by every
33
+ * provider's classifier so they all extract the error the same way.
34
+ */
35
+ function getErrorParts(error) {
36
+ var _a, _b;
37
+ if (error instanceof Error || (typeof DOMException !== 'undefined' && error instanceof DOMException)) {
38
+ return { name: ((_a = error.name) !== null && _a !== void 0 ? _a : '').toLowerCase(), message: ((_b = error.message) !== null && _b !== void 0 ? _b : '').toLowerCase() };
39
+ }
40
+ return { name: '', message: String(error !== null && error !== void 0 ? error : '').toLowerCase() };
41
+ }
@@ -76,6 +76,11 @@ const storage = {
76
76
  getStorageProvider() {
77
77
  return provider;
78
78
  },
79
+ /**
80
+ * Classifies a write error using the active provider's own classifier. Synchronous and pure —
81
+ * never wrapped in tryOrDegradePerformance.
82
+ */
83
+ classifyError: (error) => provider.classifyError(error),
79
84
  /**
80
85
  * Initializes all providers in the list of storage providers
81
86
  * and enables fallback providers if necessary
@@ -0,0 +1,9 @@
1
+ import type { ValueOf } from 'type-fest';
2
+ import { StorageErrorClass } from '../../errors';
3
+ /**
4
+ * Classifies an IndexedDB write failure into the shared storage taxonomy (lib/storage/errors.ts).
5
+ * Matching is done on the lowercased error name and message. This is the IndexedDB engine's own
6
+ * dialect — it is NOT shared with other engines.
7
+ */
8
+ declare function classifyIDBError(error: unknown): ValueOf<typeof StorageErrorClass>;
9
+ export default classifyIDBError;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const errors_1 = require("../../errors");
4
+ /**
5
+ * Classifies an IndexedDB write failure into the shared storage taxonomy (lib/storage/errors.ts).
6
+ * Matching is done on the lowercased error name and message. This is the IndexedDB engine's own
7
+ * dialect — it is NOT shared with other engines.
8
+ */
9
+ function classifyIDBError(error) {
10
+ const { name, message } = (0, errors_1.getErrorParts)(error);
11
+ // Non-serializable data passed to IDBObjectStore.put — retrying is futile.
12
+ if (message.includes("failed to execute 'put' on 'idbobjectstore'")) {
13
+ return errors_1.StorageErrorClass.INVALID_DATA;
14
+ }
15
+ // Browser quota exceeded.
16
+ if (name.includes('quotaexceedederror') || message.includes('quotaexceedederror')) {
17
+ return errors_1.StorageErrorClass.CAPACITY;
18
+ }
19
+ // Backing-store corruption (Chromium LevelDB). Recoverable only via a budgeted reopen.
20
+ if (message.includes('internal error opening backing store')) {
21
+ return errors_1.StorageErrorClass.FATAL;
22
+ }
23
+ // Transient connection/transport failures — the cached connection is stale and a reopen fixes it:
24
+ // - InvalidStateError: connection closed between getDB() resolving and db.transaction().
25
+ // - AbortError: write transaction aborted (connection close / versionchange / sibling abort).
26
+ // - Safari/WebKit IDB server termination for backgrounded tabs.
27
+ if (name.includes('invalidstateerror') ||
28
+ name.includes('aborterror') ||
29
+ message.includes('connection to indexed database server lost') ||
30
+ message.includes('connection is closing') ||
31
+ // This is related to https://github.com/Expensify/react-native-onyx/pull/796 — remove this comment when #796 is merged.
32
+ message.includes('idb write transaction aborted without an error')) {
33
+ return errors_1.StorageErrorClass.TRANSIENT;
34
+ }
35
+ return errors_1.StorageErrorClass.UNKNOWN;
36
+ }
37
+ exports.default = classifyIDBError;
@@ -32,47 +32,15 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  const IDB = __importStar(require("idb-keyval"));
37
40
  const Logger = __importStar(require("../../../Logger"));
41
+ const errors_1 = require("../../errors");
42
+ const classifyError_1 = __importDefault(require("./classifyError"));
38
43
  const HEAL_ATTEMPTS_MAX = 3;
39
- /**
40
- * Detects the Chromium-specific IDB backing store corruption error.
41
- * Fires when LevelDB files backing IndexedDB are corrupted and Chrome's
42
- * internal recovery (RepairDB -> delete -> recreate) also fails.
43
- */
44
- function isBackingStoreError(error) {
45
- return (error instanceof Error || error instanceof DOMException) && error.message.includes('Internal error opening backing store');
46
- }
47
- /**
48
- * Detects Safari/WebKit IDB connection termination errors.
49
- * Fires when Safari kills the IDB server process for backgrounded tabs.
50
- * WebKit bugs: https://bugs.webkit.org/show_bug.cgi?id=197050, https://bugs.webkit.org/show_bug.cgi?id=201483
51
- */
52
- function isConnectionLostError(error) {
53
- if (!(error instanceof Error || error instanceof DOMException))
54
- return false;
55
- const msg = error.message.toLowerCase();
56
- return msg.includes('connection to indexed database server lost') || msg.includes('connection is closing');
57
- }
58
- function isInvalidStateError(error) {
59
- return (error instanceof Error || error instanceof DOMException) && error.name === 'InvalidStateError';
60
- }
61
- /** Errors that trigger a budgeted heal-and-retry in store(). */
62
- function isBudgetedHealError(error) {
63
- return isBackingStoreError(error) || isConnectionLostError(error);
64
- }
65
- function getBudgetedHealErrorLabel(error) {
66
- if (isBackingStoreError(error))
67
- return 'backing store';
68
- if (isConnectionLostError(error))
69
- return 'connection lost';
70
- return 'unknown';
71
- }
72
- /** Union of all error types indicating a stale/dead IDB connection. Used by the visibilitychange probe. */
73
- function isStaleConnectionError(error) {
74
- return isInvalidStateError(error) || isBackingStoreError(error) || isConnectionLostError(error);
75
- }
76
44
  // This is a copy of the createStore function from idb-keyval, we need a custom implementation
77
45
  // because we need to create the database manually in order to ensure that the store exists before we use it.
78
46
  // If the store does not exist, idb-keyval will throw an error
@@ -94,7 +62,10 @@ function createStore(dbName, storeName) {
94
62
  // https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/versionchange_event
95
63
  // eslint-disable-next-line no-param-reassign
96
64
  db.onversionchange = () => {
97
- Logger.logInfo('IDB connection closing due to version change', { dbName, storeName });
65
+ Logger.logInfo('IDB connection closing due to version change', {
66
+ dbName,
67
+ storeName,
68
+ });
98
69
  db.close();
99
70
  dbp = undefined;
100
71
  };
@@ -161,7 +132,11 @@ function createStore(dbName, storeName) {
161
132
  Logger.logInfo('IDB visibilitychange probe: tab became visible, checking connection health', { dbName, storeName });
162
133
  const probePromise = dbp;
163
134
  const dropCacheIfStale = (error) => {
164
- if (dbp !== probePromise || !isStaleConnectionError(error)) {
135
+ // A stale/dead connection surfaces as TRANSIENT (InvalidStateError, Safari connection lost)
136
+ // or FATAL (Chromium backing-store corruption) per the shared taxonomy (lib/storage/errors.ts).
137
+ const errorClass = (0, classifyError_1.default)(error);
138
+ const isStaleConnection = errorClass === errors_1.StorageErrorClass.TRANSIENT || errorClass === errors_1.StorageErrorClass.FATAL;
139
+ if (dbp !== probePromise || !isStaleConnection) {
165
140
  return;
166
141
  }
167
142
  Logger.logAlert('IDB visibilitychange probe: stale connection detected, dropping cached connection', {
@@ -196,21 +171,25 @@ function createStore(dbName, storeName) {
196
171
  // branch. Swallow here so the probe's separate branch doesn't surface an unhandled rejection.
197
172
  });
198
173
  });
199
- // Handles three recoverable error classes:
200
- // 1. InvalidStateError — connection closed between getDB() resolving and db.transaction().
201
- // Retry once with a fresh connection. No budget limit (transient, always worth one reopen).
202
- // 2. Backing store corruption (Chromium UnknownError) drop cached connection and reopen.
203
- // 3. Connection lost (Safari UnknownError) — IDB server terminated for backgrounded tabs.
204
- // Both 2 and 3 share a heal budget (3 attempts, reset on success).
205
- // Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts
206
- // Note: concurrent store() calls share the budget. Under overlapping failures each caller
174
+ // The connection layer owns recovery for connection/transport failures. It reacts per the shared
175
+ // error taxonomy (see lib/storage/errors.ts):
176
+ // - TRANSIENT (InvalidStateError, AbortError, Safari connection lost) the cached connection is
177
+ // stale. Drop it and retry once with a fresh one. Unbudgeted: a single reopen is always worth it
178
+ // and is bounded per operation.
179
+ // - FATAL (Chromium backing-store corruption) reopening can recover transient corruption, but
180
+ // repeating forever is futile, so the heal is budgeted (3 attempts, reset on success).
181
+ // Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts
182
+ // - CAPACITY / UNKNOWN are NOT the connection layer's responsibility — propagate to the operation
183
+ // layer (OnyxUtils.retryOperation) without retrying here, to avoid compounding retries.
184
+ // Note: concurrent store() calls share the heal budget. Under overlapping failures each caller
207
185
  // decrements independently, so the budget may drain faster than one-per-incident. This is
208
186
  // acceptable — same as Dexie's approach — and the budget resets on any success.
209
187
  return (txMode, callback) => executeTransaction(txMode, callback)
210
188
  .then(resetHealBudget)
211
189
  .catch((error) => {
212
- if (isInvalidStateError(error)) {
213
- Logger.logInfo('IDB InvalidStateError dropping cached connection and retrying', {
190
+ const errorClass = (0, classifyError_1.default)(error);
191
+ if (errorClass === errors_1.StorageErrorClass.TRANSIENT) {
192
+ Logger.logInfo('IDB transient error — dropping cached connection and retrying once', {
214
193
  dbName,
215
194
  storeName,
216
195
  txMode,
@@ -219,29 +198,33 @@ function createStore(dbName, storeName) {
219
198
  dbp = undefined;
220
199
  return executeTransaction(txMode, callback).then(resetHealBudget);
221
200
  }
222
- if (isBudgetedHealError(error) && healAttemptsRemaining > 0) {
201
+ if (errorClass === errors_1.StorageErrorClass.FATAL && healAttemptsRemaining > 0) {
223
202
  healAttemptsRemaining--;
224
- const label = getBudgetedHealErrorLabel(error);
225
- Logger.logInfo(`IDB heal: ${label} error detected — dropping cached connection and reopening (${healAttemptsRemaining} attempts left)`, {
203
+ Logger.logInfo(`IDB heal: backing store error detected — dropping cached connection and reopening (${healAttemptsRemaining} attempts left)`, {
226
204
  dbName,
227
205
  storeName,
228
206
  });
229
207
  dbp = undefined;
230
208
  return executeTransaction(txMode, callback).then((result) => {
231
- Logger.logInfo(`IDB heal: successfully recovered after ${label} error`, { dbName, storeName });
209
+ Logger.logInfo('IDB heal: successfully recovered after backing store error', { dbName, storeName });
232
210
  return resetHealBudget(result);
233
211
  });
234
212
  }
235
- if (isBudgetedHealError(error)) {
236
- Logger.logAlert(`IDB heal: ${getBudgetedHealErrorLabel(error)} error — heal budget exhausted, giving up`, {
213
+ if (errorClass === errors_1.StorageErrorClass.FATAL) {
214
+ Logger.logAlert('IDB heal: backing store error — heal budget exhausted, giving up', {
237
215
  dbName,
238
216
  storeName,
239
217
  });
240
218
  }
241
- else {
242
- Logger.logAlert('IDB error is not recoverable, giving up', {
219
+ else if (errorClass === errors_1.StorageErrorClass.UNKNOWN) {
220
+ // UNKNOWN unexpected at this layer; record it so it's visible. CAPACITY is the
221
+ // expected propagation path (the operation layer owns its logging, and suppresses it
222
+ // entirely once the circuit breaker is open), so we do NOT log it here — doing so was a
223
+ // per-failed-write line that dominated the storm.
224
+ Logger.logInfo('IDB error not recoverable at the connection layer, propagating', {
243
225
  dbName,
244
226
  storeName,
227
+ errorClass,
245
228
  errorMessage: error instanceof Error ? error.message : String(error),
246
229
  });
247
230
  }
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  const IDB = __importStar(require("idb-keyval"));
40
40
  const utils_1 = __importDefault(require("../../../utils"));
41
41
  const createStore_1 = __importDefault(require("./createStore"));
42
+ const classifyError_1 = __importDefault(require("./classifyError"));
42
43
  const DB_NAME = 'OnyxDB';
43
44
  const STORE_NAME = 'keyvaluepairs';
44
45
  /**
@@ -60,6 +61,10 @@ const provider = {
60
61
  * The name of the provider that can be printed to the logs
61
62
  */
62
63
  name: 'IDBKeyValProvider',
64
+ /**
65
+ * Classifies an IndexedDB write failure into the shared storage taxonomy.
66
+ */
67
+ classifyError: classifyError_1.default,
63
68
  /**
64
69
  * Initializes the storage provider
65
70
  */