react-native-onyx 3.0.86 → 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,91 @@
1
+ import type { CircuitBreakerOptions, CircuitBreakerState } from './types';
2
+ /**
3
+ * Generic circuit breaker built on {@link StateMachine}.
4
+ *
5
+ * - **closed**: requests are allowed; failures are counted.
6
+ * - **open**: requests are rejected until {@link resetTimeoutMs} elapses.
7
+ * - **half-open**: the recovery-probe state. After the open timeout, the breaker admits exactly ONE
8
+ * probe request: success means the dependency recovered, so the circuit closes. Failure means it's
9
+ * still down, so the circuit reopens. This single-request probe prevents a "thundering herd" where
10
+ * every caller fails loudly when the service hasn't recovered yet.
11
+ *
12
+ * Subclasses implement the failure-counting policy by overriding {@link recordFailureInClosed} (and
13
+ * friends) — e.g. counting consecutive failures, or failures within a rolling time window, or any
14
+ * combination of those.
15
+ *
16
+ * @example
17
+ * class MyBreaker extends AbstractCircuitBreaker {
18
+ * private failures = 0;
19
+ * protected recordFailureInClosed() {
20
+ * this.failures += 1;
21
+ * return this.failures >= 3 ? `${this.failures} failures` : null;
22
+ * }
23
+ * protected recordSuccessInClosed() { this.failures = 0; }
24
+ * protected resetFailureState() { this.failures = 0; }
25
+ * }
26
+ *
27
+ * const breaker = new MyBreaker({resetTimeoutMs: 30_000});
28
+ * if (breaker.isAllowed()) {
29
+ * try {
30
+ * doWork();
31
+ * breaker.recordSuccess();
32
+ * } catch {
33
+ * breaker.recordFailure();
34
+ * }
35
+ * }
36
+ */
37
+ declare abstract class AbstractCircuitBreaker {
38
+ private machine;
39
+ private openedAt;
40
+ private isProbeInFlight;
41
+ private readonly resetTimeoutMs;
42
+ private readonly onTrip?;
43
+ private readonly onClose?;
44
+ constructor(options?: CircuitBreakerOptions);
45
+ /** Record a failure while the circuit is closed. Returns a trip reason when the threshold is exceeded. */
46
+ protected abstract recordFailureInClosed(): string | null;
47
+ /** Update failure state after a successful request while the circuit is closed. */
48
+ protected abstract recordSuccessInClosed(): void;
49
+ /** Clear accumulated failure state without changing circuit state. */
50
+ protected abstract resetFailureState(): void;
51
+ /**
52
+ * Whether a request may proceed.
53
+ *
54
+ * Returns `false` while open. In half-open, the FIRST caller is admitted as the recovery probe and
55
+ * `isProbeInFlight` is latched so every subsequent caller is rejected until that probe resolves
56
+ * (via {@link recordSuccess} → close, or {@link recordFailure} → reopen). That single-probe gate is
57
+ * the whole point of half-open: it tests recovery with one request instead of letting a herd of
58
+ * waiting callers stampede a dependency that may still be down.
59
+ */
60
+ isAllowed(): boolean;
61
+ /**
62
+ * Record a failed request. May open the circuit from closed or half-open.
63
+ * @returns `true` when the circuit is open after recording (the request must not proceed).
64
+ */
65
+ recordFailure(): boolean;
66
+ /** Record a successful request. Closes the circuit from half-open and clears failure counts. */
67
+ recordSuccess(): void;
68
+ /**
69
+ * The current state WITHOUT advancing recovery — a pure query, safe to call without side effects.
70
+ * The open→half-open transition is applied only at the admission point ({@link isAllowed}); by the
71
+ * time a caller queries state after being admitted, that transition has already happened.
72
+ */
73
+ peekState(): CircuitBreakerState;
74
+ /**
75
+ * Force the circuit back to closed from ANY state. This is a reset, not a transition, so it
76
+ * deliberately bypasses the transition graph (and does not fire {@link onClose}). Use only to wipe
77
+ * all state — e.g. between tests or sessions.
78
+ */
79
+ protected hardReset(): void;
80
+ private getCurrentState;
81
+ private trip;
82
+ private close;
83
+ /**
84
+ * Lazily advance open → half-open once the reset timeout has elapsed. This is checked on read
85
+ * (via {@link getCurrentState}) rather than on a timer, so there's nothing to schedule or clean up:
86
+ * the transition simply becomes visible to the next caller after the window. Entering half-open
87
+ * clears `isProbeInFlight` so the next admitted request becomes the recovery probe.
88
+ */
89
+ private maybeRecover;
90
+ }
91
+ export default AbstractCircuitBreaker;
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const StateMachine_1 = __importDefault(require("../StateMachine"));
7
+ const types_1 = require("./types");
8
+ /**
9
+ * Generic circuit breaker built on {@link StateMachine}.
10
+ *
11
+ * - **closed**: requests are allowed; failures are counted.
12
+ * - **open**: requests are rejected until {@link resetTimeoutMs} elapses.
13
+ * - **half-open**: the recovery-probe state. After the open timeout, the breaker admits exactly ONE
14
+ * probe request: success means the dependency recovered, so the circuit closes. Failure means it's
15
+ * still down, so the circuit reopens. This single-request probe prevents a "thundering herd" where
16
+ * every caller fails loudly when the service hasn't recovered yet.
17
+ *
18
+ * Subclasses implement the failure-counting policy by overriding {@link recordFailureInClosed} (and
19
+ * friends) — e.g. counting consecutive failures, or failures within a rolling time window, or any
20
+ * combination of those.
21
+ *
22
+ * @example
23
+ * class MyBreaker extends AbstractCircuitBreaker {
24
+ * private failures = 0;
25
+ * protected recordFailureInClosed() {
26
+ * this.failures += 1;
27
+ * return this.failures >= 3 ? `${this.failures} failures` : null;
28
+ * }
29
+ * protected recordSuccessInClosed() { this.failures = 0; }
30
+ * protected resetFailureState() { this.failures = 0; }
31
+ * }
32
+ *
33
+ * const breaker = new MyBreaker({resetTimeoutMs: 30_000});
34
+ * if (breaker.isAllowed()) {
35
+ * try {
36
+ * doWork();
37
+ * breaker.recordSuccess();
38
+ * } catch {
39
+ * breaker.recordFailure();
40
+ * }
41
+ * }
42
+ */
43
+ class AbstractCircuitBreaker {
44
+ constructor(options = {}) {
45
+ var _a;
46
+ this.openedAt = 0;
47
+ this.isProbeInFlight = false;
48
+ this.resetTimeoutMs = (_a = options.resetTimeoutMs) !== null && _a !== void 0 ? _a : 60000;
49
+ this.onTrip = options.onTrip;
50
+ this.onClose = options.onClose;
51
+ this.machine = new StateMachine_1.default('closed', types_1.CIRCUIT_BREAKER_TRANSITIONS);
52
+ }
53
+ /**
54
+ * Whether a request may proceed.
55
+ *
56
+ * Returns `false` while open. In half-open, the FIRST caller is admitted as the recovery probe and
57
+ * `isProbeInFlight` is latched so every subsequent caller is rejected until that probe resolves
58
+ * (via {@link recordSuccess} → close, or {@link recordFailure} → reopen). That single-probe gate is
59
+ * the whole point of half-open: it tests recovery with one request instead of letting a herd of
60
+ * waiting callers stampede a dependency that may still be down.
61
+ */
62
+ isAllowed() {
63
+ const currentState = this.getCurrentState();
64
+ if (currentState === 'open') {
65
+ return false;
66
+ }
67
+ if (currentState === 'half-open') {
68
+ if (this.isProbeInFlight) {
69
+ return false;
70
+ }
71
+ this.isProbeInFlight = true;
72
+ }
73
+ return true;
74
+ }
75
+ /**
76
+ * Record a failed request. May open the circuit from closed or half-open.
77
+ * @returns `true` when the circuit is open after recording (the request must not proceed).
78
+ */
79
+ recordFailure() {
80
+ if (this.machine.state === 'open') {
81
+ return true;
82
+ }
83
+ if (this.machine.state === 'half-open') {
84
+ this.trip();
85
+ return true;
86
+ }
87
+ const reason = this.recordFailureInClosed();
88
+ if (reason) {
89
+ this.trip(reason);
90
+ return true;
91
+ }
92
+ return false;
93
+ }
94
+ /** Record a successful request. Closes the circuit from half-open and clears failure counts. */
95
+ recordSuccess() {
96
+ if (this.machine.state === 'half-open') {
97
+ this.close();
98
+ return;
99
+ }
100
+ if (this.machine.state === 'closed') {
101
+ this.recordSuccessInClosed();
102
+ }
103
+ }
104
+ /**
105
+ * The current state WITHOUT advancing recovery — a pure query, safe to call without side effects.
106
+ * The open→half-open transition is applied only at the admission point ({@link isAllowed}); by the
107
+ * time a caller queries state after being admitted, that transition has already happened.
108
+ */
109
+ peekState() {
110
+ return this.machine.state;
111
+ }
112
+ /**
113
+ * Force the circuit back to closed from ANY state. This is a reset, not a transition, so it
114
+ * deliberately bypasses the transition graph (and does not fire {@link onClose}). Use only to wipe
115
+ * all state — e.g. between tests or sessions.
116
+ */
117
+ hardReset() {
118
+ this.machine = new StateMachine_1.default('closed', types_1.CIRCUIT_BREAKER_TRANSITIONS);
119
+ this.openedAt = 0;
120
+ this.isProbeInFlight = false;
121
+ this.resetFailureState();
122
+ }
123
+ getCurrentState() {
124
+ this.maybeRecover();
125
+ return this.machine.state;
126
+ }
127
+ trip(reason = '') {
128
+ var _a;
129
+ if (this.machine.state === 'open') {
130
+ return;
131
+ }
132
+ this.machine = this.machine.transition('open');
133
+ this.openedAt = Date.now();
134
+ this.isProbeInFlight = false;
135
+ this.resetFailureState();
136
+ (_a = this.onTrip) === null || _a === void 0 ? void 0 : _a.call(this, reason);
137
+ }
138
+ close() {
139
+ var _a;
140
+ // close() only ever runs from half-open (see recordSuccess), and half-open → closed is the one
141
+ // legal closing transition — so go through transition() to keep the illegal open → closed jump
142
+ // an error rather than silently constructing a fresh closed machine.
143
+ this.machine = this.machine.transition('closed');
144
+ this.openedAt = 0;
145
+ this.isProbeInFlight = false;
146
+ this.resetFailureState();
147
+ (_a = this.onClose) === null || _a === void 0 ? void 0 : _a.call(this);
148
+ }
149
+ /**
150
+ * Lazily advance open → half-open once the reset timeout has elapsed. This is checked on read
151
+ * (via {@link getCurrentState}) rather than on a timer, so there's nothing to schedule or clean up:
152
+ * the transition simply becomes visible to the next caller after the window. Entering half-open
153
+ * clears `isProbeInFlight` so the next admitted request becomes the recovery probe.
154
+ */
155
+ maybeRecover() {
156
+ if (this.machine.state !== 'open') {
157
+ return;
158
+ }
159
+ if (Date.now() - this.openedAt < this.resetTimeoutMs) {
160
+ return;
161
+ }
162
+ this.machine = this.machine.transition('half-open');
163
+ this.isProbeInFlight = false;
164
+ }
165
+ }
166
+ exports.default = AbstractCircuitBreaker;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * States of the circuit breaker.
3
+ *
4
+ * - **closed**: normal operation; requests flow and failures are counted.
5
+ * - **open**: tripped; requests are rejected outright so a known-bad dependency isn't hammered.
6
+ * - **half-open**: a trial state entered after the open timeout — see {@link CIRCUIT_BREAKER_TRANSITIONS}.
7
+ */
8
+ type CircuitBreakerState = 'closed' | 'open' | 'half-open';
9
+ /**
10
+ * Legal state transitions. The flow is closed → open → half-open → (closed | open).
11
+ *
12
+ * The **half-open** state exists so the breaker can test whether the dependency has recovered WITHOUT
13
+ * flipping straight back to fully closed. Going open → closed blindly would, on a dependency that is
14
+ * still down, immediately re-admit the full load and re-trip — flapping between open and closed every
15
+ * window. Instead, after the open timeout the breaker moves to half-open and admits a single trial
16
+ * ("probe") request:
17
+ * - probe succeeds → the dependency is healthy again → transition to **closed** (resume normal flow).
18
+ * - probe fails → still broken → transition back to **open** for another timeout window.
19
+ *
20
+ * Admitting exactly one probe (rather than reopening the floodgates) is also what prevents the
21
+ * "thundering herd": many callers retrying at once the instant the timeout elapses, re-overwhelming a
22
+ * dependency that was just starting to recover.
23
+ */
24
+ declare const CIRCUIT_BREAKER_TRANSITIONS: {
25
+ readonly closed: readonly ["open"];
26
+ readonly open: readonly ["half-open"];
27
+ readonly 'half-open': readonly ["closed", "open"];
28
+ };
29
+ type CircuitBreakerOptions = {
30
+ /** Time in milliseconds the circuit stays open before moving to half-open. */
31
+ resetTimeoutMs?: number;
32
+ /** Called once each time the circuit opens. */
33
+ onTrip?: (reason: string) => void;
34
+ /** Called when the circuit closes. */
35
+ onClose?: () => void;
36
+ };
37
+ export type { CircuitBreakerOptions, CircuitBreakerState };
38
+ export { CIRCUIT_BREAKER_TRANSITIONS };
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CIRCUIT_BREAKER_TRANSITIONS = void 0;
4
+ /**
5
+ * Legal state transitions. The flow is closed → open → half-open → (closed | open).
6
+ *
7
+ * The **half-open** state exists so the breaker can test whether the dependency has recovered WITHOUT
8
+ * flipping straight back to fully closed. Going open → closed blindly would, on a dependency that is
9
+ * still down, immediately re-admit the full load and re-trip — flapping between open and closed every
10
+ * window. Instead, after the open timeout the breaker moves to half-open and admits a single trial
11
+ * ("probe") request:
12
+ * - probe succeeds → the dependency is healthy again → transition to **closed** (resume normal flow).
13
+ * - probe fails → still broken → transition back to **open** for another timeout window.
14
+ *
15
+ * Admitting exactly one probe (rather than reopening the floodgates) is also what prevents the
16
+ * "thundering herd": many callers retrying at once the instant the timeout elapses, re-overwhelming a
17
+ * dependency that was just starting to recover.
18
+ */
19
+ const CIRCUIT_BREAKER_TRANSITIONS = {
20
+ closed: ['open'],
21
+ open: ['half-open'],
22
+ 'half-open': ['closed', 'open'],
23
+ };
24
+ exports.CIRCUIT_BREAKER_TRANSITIONS = CIRCUIT_BREAKER_TRANSITIONS;
@@ -134,11 +134,17 @@ declare function getCollectionDataAndSendAsObject<TKey extends OnyxKey>(matching
134
134
  declare function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?: boolean): Promise<void>;
135
135
  declare function reportStorageQuota(error?: Error): Promise<void>;
136
136
  /**
137
- * Handles storage operation failures based on the error type:
138
- * - Storage capacity errors: evicts data and retries the operation
139
- * - Invalid data errors: logs an alert and throws an error
140
- * - Non-retriable errors: logs an alert and resolves without retrying
141
- * - Other errors: retries the operation
137
+ * Handles storage operation failures based on the error class (see lib/storage/errors.ts).
138
+ * The connection layer (createStore) owns connection/transport recovery; this operation layer owns
139
+ * capacity recovery (eviction) so that a given failure is retried by exactly one layer:
140
+ * - INVALID_DATA: logs an alert and throws (the same data will always fail).
141
+ * - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
142
+ * and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
143
+ * - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
144
+ * circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
145
+ * progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
146
+ * - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
147
+ * provider) once so it's visible, then bounded retry without eviction.
142
148
  */
143
149
  declare function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, onyxMethod: TMethod, defaultParams: Parameters<TMethod>[0], retryAttempt: number | undefined, inFlightKeys?: Set<OnyxKey>): Promise<void>;
144
150
  /**
package/dist/OnyxUtils.js CHANGED
@@ -43,8 +43,9 @@ const DevTools_1 = __importDefault(require("./DevTools"));
43
43
  const Logger = __importStar(require("./Logger"));
44
44
  const OnyxCache_1 = __importStar(require("./OnyxCache"));
45
45
  const OnyxKeys_1 = __importDefault(require("./OnyxKeys"));
46
- const Str = __importStar(require("./Str"));
46
+ const StorageCircuitBreaker_1 = __importDefault(require("./StorageCircuitBreaker"));
47
47
  const storage_1 = __importDefault(require("./storage"));
48
+ const errors_1 = require("./storage/errors");
48
49
  const utils_1 = __importDefault(require("./utils"));
49
50
  const createDeferredTask_1 = __importDefault(require("./createDeferredTask"));
50
51
  const logMessages_1 = __importDefault(require("./logMessages"));
@@ -57,21 +58,6 @@ const METHOD = {
57
58
  MULTI_SET: 'multiset',
58
59
  CLEAR: 'clear',
59
60
  };
60
- // IndexedDB errors that indicate storage capacity issues where eviction can help
61
- const IDB_STORAGE_ERRORS = [
62
- 'quotaexceedederror', // Browser storage quota exceeded
63
- ];
64
- // SQLite errors that indicate storage capacity issues where eviction can help
65
- const SQLITE_STORAGE_ERRORS = [
66
- 'database or disk is full', // Device storage is full
67
- ];
68
- const STORAGE_ERRORS = [...IDB_STORAGE_ERRORS, ...SQLITE_STORAGE_ERRORS];
69
- // IndexedDB errors where retrying is futile because the underlying connection/store is broken.
70
- // The healing path (separate from retryOperation) is responsible for recovery.
71
- const IDB_NON_RETRIABLE_ERRORS = [
72
- 'internal error opening backing store', // LevelDB backing store is broken at the filesystem level
73
- ];
74
- const NON_RETRIABLE_ERRORS = [...IDB_NON_RETRIABLE_ERRORS];
75
61
  // Max number of retries for failed storage operations
76
62
  const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5;
77
63
  // Key/value store of Onyx key and arrays of values to merge
@@ -496,7 +482,10 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
496
482
  continue;
497
483
  }
498
484
  try {
499
- lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection, matchedKey: subscriber.key });
485
+ lastConnectionCallbackData.set(subscriber.subscriptionID, {
486
+ value: cachedCollection,
487
+ matchedKey: subscriber.key,
488
+ });
500
489
  if (subscriber.waitForCollectionCallback) {
501
490
  subscriber.callback(cachedCollection, subscriber.key, partialCollection);
502
491
  continue;
@@ -533,7 +522,10 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
533
522
  try {
534
523
  const subscriberCallback = subscriber.callback;
535
524
  subscriberCallback(cachedCollection[subscriber.key], subscriber.key);
536
- lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection[subscriber.key], matchedKey: subscriber.key });
525
+ lastConnectionCallbackData.set(subscriber.subscriptionID, {
526
+ value: cachedCollection[subscriber.key],
527
+ matchedKey: subscriber.key,
528
+ });
537
529
  }
538
530
  catch (error) {
539
531
  Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
@@ -595,13 +587,21 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
595
587
  cachedCollection = getCachedCollection(subscriber.key);
596
588
  cachedCollections[subscriber.key] = cachedCollection;
597
589
  }
598
- lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection, matchedKey: subscriber.key });
599
- subscriber.callback(cachedCollection, subscriber.key, { [key]: value });
590
+ lastConnectionCallbackData.set(subscriber.subscriptionID, {
591
+ value: cachedCollection,
592
+ matchedKey: subscriber.key,
593
+ });
594
+ subscriber.callback(cachedCollection, subscriber.key, {
595
+ [key]: value,
596
+ });
600
597
  continue;
601
598
  }
602
599
  const subscriberCallback = subscriber.callback;
603
600
  subscriberCallback(value, key);
604
- lastConnectionCallbackData.set(subscriber.subscriptionID, { value, matchedKey: key });
601
+ lastConnectionCallbackData.set(subscriber.subscriptionID, {
602
+ value,
603
+ matchedKey: key,
604
+ });
605
605
  continue;
606
606
  }
607
607
  catch (error) {
@@ -666,44 +666,73 @@ function remove(key, isProcessingCollectionUpdate) {
666
666
  function reportStorageQuota(error) {
667
667
  return storage_1.default.getDatabaseSize()
668
668
  .then(({ bytesUsed, bytesRemaining, usageDetails }) => {
669
- Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}${usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : ''}. Original error: ${error}`);
669
+ // `bytesRemaining` comes from navigator.storage.estimate() and is an ORIGIN-WIDE estimate,
670
+ // not headroom for this database. The browser allocates IndexedDB storage dynamically, so a
671
+ // QuotaExceededError can legitimately occur even when this number still looks large.
672
+ Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} originWideBytesRemaining (estimate, not per-DB headroom): ${bytesRemaining}${usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : ''}. Original error: ${error}`);
670
673
  })
671
674
  .catch((dbSizeError) => {
672
675
  Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`);
673
676
  });
674
677
  }
675
678
  /**
676
- * Handles storage operation failures based on the error type:
677
- * - Storage capacity errors: evicts data and retries the operation
678
- * - Invalid data errors: logs an alert and throws an error
679
- * - Non-retriable errors: logs an alert and resolves without retrying
680
- * - Other errors: retries the operation
679
+ * Handles storage operation failures based on the error class (see lib/storage/errors.ts).
680
+ * The connection layer (createStore) owns connection/transport recovery; this operation layer owns
681
+ * capacity recovery (eviction) so that a given failure is retried by exactly one layer:
682
+ * - INVALID_DATA: logs an alert and throws (the same data will always fail).
683
+ * - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
684
+ * and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
685
+ * - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
686
+ * circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
687
+ * progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
688
+ * - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
689
+ * provider) once so it's visible, then bounded retry without eviction.
681
690
  */
682
691
  function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlightKeys) {
683
- var _a, _b, _c, _d;
684
692
  const currentRetryAttempt = retryAttempt !== null && retryAttempt !== void 0 ? retryAttempt : 0;
685
693
  const nextRetryAttempt = currentRetryAttempt + 1;
686
- Logger.logInfo(`Failed to save to storage. Error: ${error}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`);
687
- if (error && Str.startsWith(error.message, "Failed to execute 'put' on 'IDBObjectStore'")) {
694
+ const errorClass = storage_1.default.classifyError(error);
695
+ // While open (or while a half-open probe is already in flight), drop capacity retries silently —
696
+ // the breaker already emitted its single alert, and logging per failed write is exactly the storm
697
+ // we are suppressing. A rejected half-open caller is the in-flight probe failing; record that so
698
+ // the circuit reopens for another window. (We return before the log line below on purpose.)
699
+ if (errorClass === errors_1.StorageErrorClass.CAPACITY && !StorageCircuitBreaker_1.default.isAllowed()) {
700
+ StorageCircuitBreaker_1.default.recordProbeFailure();
701
+ return Promise.resolve();
702
+ }
703
+ Logger.logInfo(`Failed to save to storage. Error: ${error}. class: ${errorClass}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`);
704
+ if (errorClass === errors_1.StorageErrorClass.INVALID_DATA) {
688
705
  Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`);
689
706
  throw error;
690
707
  }
691
- const errorMessage = (_b = (_a = error === null || error === void 0 ? void 0 : error.message) === null || _a === void 0 ? void 0 : _a.toLowerCase) === null || _b === void 0 ? void 0 : _b.call(_a);
692
- const errorName = (_d = (_c = error === null || error === void 0 ? void 0 : error.name) === null || _c === void 0 ? void 0 : _c.toLowerCase) === null || _d === void 0 ? void 0 : _d.call(_c);
693
- const isStorageCapacityError = STORAGE_ERRORS.some((storageError) => (errorName === null || errorName === void 0 ? void 0 : errorName.includes(storageError)) || (errorMessage === null || errorMessage === void 0 ? void 0 : errorMessage.includes(storageError)));
694
- const isNonRetriableError = NON_RETRIABLE_ERRORS.some((nonRetriableError) => (errorName === null || errorName === void 0 ? void 0 : errorName.includes(nonRetriableError)) || (errorMessage === null || errorMessage === void 0 ? void 0 : errorMessage.includes(nonRetriableError)));
695
- if (isNonRetriableError) {
696
- Logger.logAlert(`Storage operation skipped retry for non-retriable error. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
708
+ if (errorClass === errors_1.StorageErrorClass.TRANSIENT || errorClass === errors_1.StorageErrorClass.FATAL) {
709
+ Logger.logInfo(`Storage operation skipped retry; ${errorClass} errors are handled by the connection layer. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
697
710
  return Promise.resolve();
698
711
  }
699
712
  if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) {
700
- Logger.logAlert(`Storage operation failed after 5 retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
713
+ Logger.logAlert(`Storage operation failed after ${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS} retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
701
714
  return Promise.resolve();
702
715
  }
703
- if (!isStorageCapacityError) {
716
+ if (errorClass === errors_1.StorageErrorClass.UNKNOWN) {
717
+ // UNKNOWN is the blind spot: the active provider's classifier did not recognize this error, so
718
+ // we cannot route it to a real recovery strategy. Log the full error shape (name + message +
719
+ // provider) once per operation so telemetry can reveal what lives in UNKNOWN, letting us promote
720
+ // recurring cases into TRANSIENT/CAPACITY/FATAL. Logged on the first attempt only to avoid the
721
+ // per-retry amplification this mechanism is trying to kill. Then bounded retry without eviction.
722
+ if (currentRetryAttempt === 0) {
723
+ Logger.logAlert(`Unclassified storage error. provider: ${storage_1.default.getStorageProvider().name}. name: ${error === null || error === void 0 ? void 0 : error.name}. message: ${error === null || error === void 0 ? void 0 : error.message}. onyxMethod: ${onyxMethod.name}.`);
724
+ }
704
725
  // @ts-expect-error No overload matches this call.
705
726
  return onyxMethod(defaultParams, nextRetryAttempt);
706
727
  }
728
+ // CAPACITY: feed the session-level circuit breaker before evicting. The per-operation budget above
729
+ // cannot stop a session-wide storm — each evicted key triggers an OnyxDerived recompute that spawns
730
+ // a fresh write with its own budget — so the breaker is what actually halts the meltdown. (The
731
+ // already-open case returned silently at the top of this function.)
732
+ if (StorageCircuitBreaker_1.default.recordCapacityFailure()) {
733
+ // This failure tripped the breaker; it already emitted its single alert. Stop here.
734
+ return Promise.resolve();
735
+ }
707
736
  // Find the least recently accessed evictable key that we can remove. Never evict an in-flight
708
737
  // key — its cache value is the merge base this retry depends on, so dropping it would truncate
709
738
  // the write to just the delta and diverge cache from storage.
@@ -718,8 +747,15 @@ function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlight
718
747
  // Remove the least recently accessed key and retry.
719
748
  Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
720
749
  reportStorageQuota(error);
721
- // @ts-expect-error No overload matches this call.
722
- return remove(keyForRemoval).then(() => onyxMethod(defaultParams, nextRetryAttempt));
750
+ return remove(keyForRemoval).then(() => {
751
+ // Mark the eviction only once the deletion has actually completed, immediately before the
752
+ // retry it pairs with. Recording earlier lets a concurrent write's capacity failure consume
753
+ // the marker as a no-progress cycle while this deletion is still pending and may yet free
754
+ // space — so the verdict belongs to the retry that follows the deletion, not the eviction call.
755
+ StorageCircuitBreaker_1.default.recordEviction();
756
+ // @ts-expect-error No overload matches this call.
757
+ return onyxMethod(defaultParams, nextRetryAttempt);
758
+ });
723
759
  }
724
760
  /**
725
761
  * Notifies subscribers and writes current value to cache
@@ -1117,6 +1153,7 @@ function setWithRetry({ key, value, options }, retryAttempt) {
1117
1153
  return Promise.resolve();
1118
1154
  }
1119
1155
  return storage_1.default.setItem(key, valueWithoutNestedNullValues)
1156
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1120
1157
  .catch((error) => OnyxUtils.retryOperation(error, setWithRetry, { key, value: valueWithoutNestedNullValues, options }, retryAttempt))
1121
1158
  .then(() => {
1122
1159
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
@@ -1201,6 +1238,7 @@ function multiSetWithRetry(data, retryAttempt) {
1201
1238
  });
1202
1239
  const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key));
1203
1240
  return storage_1.default.multiSet(keyValuePairsToStore)
1241
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1204
1242
  .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys))
1205
1243
  .then(() => {
1206
1244
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
@@ -1269,6 +1307,7 @@ function setCollectionWithRetry({ collectionKey, collection }, retryAttempt) {
1269
1307
  }
1270
1308
  const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
1271
1309
  return storage_1.default.multiSet(keyValuePairs)
1310
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1272
1311
  .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, { collectionKey, collection }, retryAttempt, inFlightKeys))
1273
1312
  .then(() => {
1274
1313
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
@@ -1398,7 +1437,13 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
1398
1437
  }
1399
1438
  const inFlightKeys = new Set(Object.keys(finalMergedCollection));
1400
1439
  return Promise.all(promises)
1401
- .catch((error) => retryOperation(error, mergeCollectionWithPatches, { collectionKey, collection: resultCollection, mergeReplaceNullPatches, isProcessingCollectionUpdate }, retryAttempt, inFlightKeys))
1440
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1441
+ .catch((error) => retryOperation(error, mergeCollectionWithPatches, {
1442
+ collectionKey,
1443
+ collection: resultCollection,
1444
+ mergeReplaceNullPatches,
1445
+ isProcessingCollectionUpdate,
1446
+ }, retryAttempt, inFlightKeys))
1402
1447
  .then(() => {
1403
1448
  sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1404
1449
  });
@@ -1459,6 +1504,7 @@ function partialSetCollection({ collectionKey, collection }, retryAttempt) {
1459
1504
  }
1460
1505
  const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
1461
1506
  return storage_1.default.multiSet(keyValuePairs)
1507
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1462
1508
  .catch((error) => retryOperation(error, partialSetCollection, { collectionKey, collection }, retryAttempt, inFlightKeys))
1463
1509
  .then(() => {
1464
1510
  sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
@@ -0,0 +1,45 @@
1
+ import type { ReadonlyDeep } from 'type-fest';
2
+ /**
3
+ * A directed transition graph keyed by state name.
4
+ * Use `as const` when defining a graph so illegal transitions are caught at compile time.
5
+ *
6
+ * @example
7
+ * const transitions = {
8
+ * idle: ['loading'],
9
+ * loading: ['success', 'error'],
10
+ * success: [],
11
+ * error: ['idle'],
12
+ * } as const;
13
+ */
14
+ type TransitionGraph = Readonly<Record<string, readonly string[]>>;
15
+ /** Target states reachable from `Current` according to `Graph`. */
16
+ type TransitionsFrom<Graph extends TransitionGraph, Current extends keyof Graph & string> = Graph[Current] extends ReadonlyArray<infer Target extends string> ? Target : never;
17
+ /**
18
+ * An immutable, type-safe finite state machine.
19
+ * Pass the transition graph with `as const` so `transition` only accepts legal target states.
20
+ *
21
+ * @example
22
+ * const transitions = {
23
+ * idle: ['loading'],
24
+ * loading: ['success', 'error'],
25
+ * success: [],
26
+ * error: ['idle'],
27
+ * } as const;
28
+ *
29
+ * const idleMachine = new StateMachine('idle', transitions);
30
+ * const loadingMachine = idleMachine.transition('loading');
31
+ * loadingMachine.transition('success');
32
+ */
33
+ declare class StateMachine<const Graph extends TransitionGraph, Current extends keyof Graph & string> {
34
+ /** The current state. Deeply readonly and owned by this state machine instance. */
35
+ readonly state: ReadonlyDeep<Current>;
36
+ private readonly transitions;
37
+ constructor(currentState: Current, transitions: Graph);
38
+ /**
39
+ * Transition to a new state, returning a new state machine instance.
40
+ * Only transitions declared in the graph for the current state are accepted.
41
+ */
42
+ transition<Target extends TransitionsFrom<Graph, Current>>(target: Target): StateMachine<Graph, Target>;
43
+ }
44
+ export default StateMachine;
45
+ export type { TransitionGraph, TransitionsFrom };