react-native-onyx 3.0.87 → 3.0.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/API.md +0 -2
  2. package/README.md +2 -12
  3. package/dist/CircuitBreaker/AbstractCircuitBreaker.d.ts +91 -0
  4. package/dist/CircuitBreaker/AbstractCircuitBreaker.js +166 -0
  5. package/dist/CircuitBreaker/types.d.ts +38 -0
  6. package/dist/CircuitBreaker/types.js +24 -0
  7. package/dist/Onyx.d.ts +0 -2
  8. package/dist/Onyx.js +0 -2
  9. package/dist/OnyxConnectionManager.js +18 -15
  10. package/dist/OnyxUtils.d.ts +11 -5
  11. package/dist/OnyxUtils.js +92 -82
  12. package/dist/StateMachine.d.ts +45 -0
  13. package/dist/StateMachine.js +37 -0
  14. package/dist/StorageCircuitBreaker.d.ts +66 -0
  15. package/dist/StorageCircuitBreaker.js +177 -0
  16. package/dist/storage/__mocks__/index.d.ts +10 -0
  17. package/dist/storage/__mocks__/index.js +15 -0
  18. package/dist/storage/errors.d.ts +34 -0
  19. package/dist/storage/errors.js +41 -0
  20. package/dist/storage/index.js +5 -0
  21. package/dist/storage/providers/IDBKeyValProvider/classifyError.d.ts +9 -0
  22. package/dist/storage/providers/IDBKeyValProvider/classifyError.js +37 -0
  23. package/dist/storage/providers/IDBKeyValProvider/createStore.js +40 -57
  24. package/dist/storage/providers/IDBKeyValProvider/index.js +5 -0
  25. package/dist/storage/providers/MemoryOnlyProvider.js +5 -0
  26. package/dist/storage/providers/NoopProvider.js +5 -0
  27. package/dist/storage/providers/SQLiteProvider.js +5 -0
  28. package/dist/storage/providers/classifySQLiteError.d.ts +13 -0
  29. package/dist/storage/providers/classifySQLiteError.js +21 -0
  30. package/dist/storage/providers/types.d.ts +8 -0
  31. package/dist/types.d.ts +11 -27
  32. package/dist/useOnyx.js +0 -2
  33. package/package.json +1 -1
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
@@ -497,25 +483,7 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
497
483
  }
498
484
  try {
499
485
  lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection, matchedKey: subscriber.key });
500
- if (subscriber.waitForCollectionCallback) {
501
- subscriber.callback(cachedCollection, subscriber.key, partialCollection);
502
- continue;
503
- }
504
- // Not using waitForCollectionCallback — notify per changed key.
505
- // Re-check the subscription on each iteration because the callback may
506
- // synchronously disconnect itself (removing it from callbackToStateMapping),
507
- // in which case we must stop firing further callbacks for this subscriber.
508
- for (const dataKey of changedMemberKeys) {
509
- const currentSubscriber = callbackToStateMapping[subID];
510
- if (!currentSubscriber || typeof currentSubscriber.callback !== 'function') {
511
- break;
512
- }
513
- if (cachedCollection[dataKey] === previousCollection[dataKey]) {
514
- continue;
515
- }
516
- const currentSubscriberCallback = currentSubscriber.callback;
517
- currentSubscriberCallback(cachedCollection[dataKey], dataKey);
518
- }
486
+ subscriber.callback(cachedCollection, subscriber.key, partialCollection);
519
487
  }
520
488
  catch (error) {
521
489
  Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
@@ -533,7 +501,10 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
533
501
  try {
534
502
  const subscriberCallback = subscriber.callback;
535
503
  subscriberCallback(cachedCollection[subscriber.key], subscriber.key);
536
- lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection[subscriber.key], matchedKey: subscriber.key });
504
+ lastConnectionCallbackData.set(subscriber.subscriptionID, {
505
+ value: cachedCollection[subscriber.key],
506
+ matchedKey: subscriber.key,
507
+ });
537
508
  }
538
509
  catch (error) {
539
510
  Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
@@ -582,9 +553,9 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
582
553
  if (lastData && lastData.matchedKey === key && lastData.value === value) {
583
554
  continue;
584
555
  }
585
- if (OnyxKeys_1.default.isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) {
586
- // Skip individual key changes for collection callbacks during collection updates
587
- // to prevent duplicate callbacks - the collection update will handle this properly
556
+ if (OnyxKeys_1.default.isCollectionKey(subscriber.key)) {
557
+ // Skip individual key changes during collection updates to prevent duplicate
558
+ // callbacks - the collection update will handle this properly.
588
559
  if (isProcessingCollectionUpdate) {
589
560
  continue;
590
561
  }
@@ -601,7 +572,10 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
601
572
  }
602
573
  const subscriberCallback = subscriber.callback;
603
574
  subscriberCallback(value, key);
604
- lastConnectionCallbackData.set(subscriber.subscriptionID, { value, matchedKey: key });
575
+ lastConnectionCallbackData.set(subscriber.subscriptionID, {
576
+ value,
577
+ matchedKey: key,
578
+ });
605
579
  continue;
606
580
  }
607
581
  catch (error) {
@@ -616,17 +590,17 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
616
590
  * Sends the data obtained from the keys to the connection.
617
591
  */
618
592
  function sendDataToConnection(mapping, matchedKey) {
619
- var _a, _b;
593
+ var _a;
620
594
  // If the mapping no longer exists then we should not send any data.
621
595
  // This means our subscriber was disconnected.
622
596
  if (!callbackToStateMapping[mapping.subscriptionID]) {
623
597
  return;
624
598
  }
625
599
  // Always read the latest value from cache to avoid stale or duplicate data.
626
- // For collection subscribers with waitForCollectionCallback, read the full collection.
600
+ // For collection-root subscribers, read the full collection.
627
601
  // For individual key subscribers, read just that key's value.
628
602
  let value;
629
- if (OnyxKeys_1.default.isCollectionKey(mapping.key) && mapping.waitForCollectionCallback) {
603
+ if (OnyxKeys_1.default.isCollectionKey(mapping.key)) {
630
604
  const collection = getCachedCollection(mapping.key);
631
605
  value = Object.keys(collection).length > 0 ? collection : undefined;
632
606
  }
@@ -642,7 +616,7 @@ function sendDataToConnection(mapping, matchedKey) {
642
616
  if (lastData && lastData.matchedKey === matchedKey && (0, fast_equals_1.shallowEqual)(lastData.value, value)) {
643
617
  return;
644
618
  }
645
- (_b = (_a = mapping).callback) === null || _b === void 0 ? void 0 : _b.call(_a, value, matchedKey);
619
+ (_a = mapping.callback) === null || _a === void 0 ? void 0 : _a.call(mapping, value, matchedKey);
646
620
  }
647
621
  /**
648
622
  * Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber.
@@ -666,44 +640,73 @@ function remove(key, isProcessingCollectionUpdate) {
666
640
  function reportStorageQuota(error) {
667
641
  return storage_1.default.getDatabaseSize()
668
642
  .then(({ bytesUsed, bytesRemaining, usageDetails }) => {
669
- Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}${usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : ''}. Original error: ${error}`);
643
+ // `bytesRemaining` comes from navigator.storage.estimate() and is an ORIGIN-WIDE estimate,
644
+ // not headroom for this database. The browser allocates IndexedDB storage dynamically, so a
645
+ // QuotaExceededError can legitimately occur even when this number still looks large.
646
+ Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} originWideBytesRemaining (estimate, not per-DB headroom): ${bytesRemaining}${usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : ''}. Original error: ${error}`);
670
647
  })
671
648
  .catch((dbSizeError) => {
672
649
  Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`);
673
650
  });
674
651
  }
675
652
  /**
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
653
+ * Handles storage operation failures based on the error class (see lib/storage/errors.ts).
654
+ * The connection layer (createStore) owns connection/transport recovery; this operation layer owns
655
+ * capacity recovery (eviction) so that a given failure is retried by exactly one layer:
656
+ * - INVALID_DATA: logs an alert and throws (the same data will always fail).
657
+ * - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
658
+ * and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
659
+ * - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
660
+ * circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
661
+ * progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
662
+ * - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
663
+ * provider) once so it's visible, then bounded retry without eviction.
681
664
  */
682
665
  function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlightKeys) {
683
- var _a, _b, _c, _d;
684
666
  const currentRetryAttempt = retryAttempt !== null && retryAttempt !== void 0 ? retryAttempt : 0;
685
667
  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'")) {
668
+ const errorClass = storage_1.default.classifyError(error);
669
+ // While open (or while a half-open probe is already in flight), drop capacity retries silently —
670
+ // the breaker already emitted its single alert, and logging per failed write is exactly the storm
671
+ // we are suppressing. A rejected half-open caller is the in-flight probe failing; record that so
672
+ // the circuit reopens for another window. (We return before the log line below on purpose.)
673
+ if (errorClass === errors_1.StorageErrorClass.CAPACITY && !StorageCircuitBreaker_1.default.isAllowed()) {
674
+ StorageCircuitBreaker_1.default.recordProbeFailure();
675
+ return Promise.resolve();
676
+ }
677
+ Logger.logInfo(`Failed to save to storage. Error: ${error}. class: ${errorClass}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`);
678
+ if (errorClass === errors_1.StorageErrorClass.INVALID_DATA) {
688
679
  Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`);
689
680
  throw error;
690
681
  }
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}.`);
682
+ if (errorClass === errors_1.StorageErrorClass.TRANSIENT || errorClass === errors_1.StorageErrorClass.FATAL) {
683
+ Logger.logInfo(`Storage operation skipped retry; ${errorClass} errors are handled by the connection layer. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
697
684
  return Promise.resolve();
698
685
  }
699
686
  if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) {
700
- Logger.logAlert(`Storage operation failed after 5 retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
687
+ Logger.logAlert(`Storage operation failed after ${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS} retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
701
688
  return Promise.resolve();
702
689
  }
703
- if (!isStorageCapacityError) {
690
+ if (errorClass === errors_1.StorageErrorClass.UNKNOWN) {
691
+ // UNKNOWN is the blind spot: the active provider's classifier did not recognize this error, so
692
+ // we cannot route it to a real recovery strategy. Log the full error shape (name + message +
693
+ // provider) once per operation so telemetry can reveal what lives in UNKNOWN, letting us promote
694
+ // recurring cases into TRANSIENT/CAPACITY/FATAL. Logged on the first attempt only to avoid the
695
+ // per-retry amplification this mechanism is trying to kill. Then bounded retry without eviction.
696
+ if (currentRetryAttempt === 0) {
697
+ 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}.`);
698
+ }
704
699
  // @ts-expect-error No overload matches this call.
705
700
  return onyxMethod(defaultParams, nextRetryAttempt);
706
701
  }
702
+ // CAPACITY: feed the session-level circuit breaker before evicting. The per-operation budget above
703
+ // cannot stop a session-wide storm — each evicted key triggers an OnyxDerived recompute that spawns
704
+ // a fresh write with its own budget — so the breaker is what actually halts the meltdown. (The
705
+ // already-open case returned silently at the top of this function.)
706
+ if (StorageCircuitBreaker_1.default.recordCapacityFailure()) {
707
+ // This failure tripped the breaker; it already emitted its single alert. Stop here.
708
+ return Promise.resolve();
709
+ }
707
710
  // Find the least recently accessed evictable key that we can remove. Never evict an in-flight
708
711
  // key — its cache value is the merge base this retry depends on, so dropping it would truncate
709
712
  // the write to just the delta and diverge cache from storage.
@@ -718,8 +721,15 @@ function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlight
718
721
  // Remove the least recently accessed key and retry.
719
722
  Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
720
723
  reportStorageQuota(error);
721
- // @ts-expect-error No overload matches this call.
722
- return remove(keyForRemoval).then(() => onyxMethod(defaultParams, nextRetryAttempt));
724
+ return remove(keyForRemoval).then(() => {
725
+ // Mark the eviction only once the deletion has actually completed, immediately before the
726
+ // retry it pairs with. Recording earlier lets a concurrent write's capacity failure consume
727
+ // the marker as a no-progress cycle while this deletion is still pending and may yet free
728
+ // space — so the verdict belongs to the retry that follows the deletion, not the eviction call.
729
+ StorageCircuitBreaker_1.default.recordEviction();
730
+ // @ts-expect-error No overload matches this call.
731
+ return onyxMethod(defaultParams, nextRetryAttempt);
732
+ });
723
733
  }
724
734
  /**
725
735
  * Notifies subscribers and writes current value to cache
@@ -948,27 +958,17 @@ function subscribeToKey(connectOptions) {
948
958
  if (mapping.key) {
949
959
  OnyxCache_1.default.addNullishStorageKey(mapping.key);
950
960
  }
951
- const matchedKey = OnyxKeys_1.default.isCollectionKey(mapping.key) && mapping.waitForCollectionCallback ? mapping.key : undefined;
961
+ const matchedKey = OnyxKeys_1.default.isCollectionKey(mapping.key) ? mapping.key : undefined;
952
962
  // Here we cannot use batching because the nullish value is expected to be set immediately for default props
953
963
  // or they will be undefined.
954
964
  sendDataToConnection(mapping, matchedKey);
955
965
  return;
956
966
  }
957
- // When using a callback subscriber we will either trigger the provided callback for each key we find or combine all values
958
- // into an object and just make a single call. The latter behavior is enabled by providing a waitForCollectionCallback key
959
- // combined with a subscription to a collection key.
967
+ // When using a callback subscriber, a subscription to a collection key combines all matching
968
+ // member values into a single object and makes one call with the whole collection object.
960
969
  if (typeof mapping.callback === 'function') {
961
970
  if (OnyxKeys_1.default.isCollectionKey(mapping.key)) {
962
- if (mapping.waitForCollectionCallback) {
963
- getCollectionDataAndSendAsObject(matchingKeys, mapping);
964
- return;
965
- }
966
- // We did not opt into using waitForCollectionCallback mode so the callback is called for every matching key.
967
- multiGet(matchingKeys).then(() => {
968
- for (const key of matchingKeys) {
969
- sendDataToConnection(mapping, key);
970
- }
971
- });
971
+ getCollectionDataAndSendAsObject(matchingKeys, mapping);
972
972
  return;
973
973
  }
974
974
  // If we are not subscribed to a collection key then there's only a single key to send an update for.
@@ -1117,6 +1117,7 @@ function setWithRetry({ key, value, options }, retryAttempt) {
1117
1117
  return Promise.resolve();
1118
1118
  }
1119
1119
  return storage_1.default.setItem(key, valueWithoutNestedNullValues)
1120
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1120
1121
  .catch((error) => OnyxUtils.retryOperation(error, setWithRetry, { key, value: valueWithoutNestedNullValues, options }, retryAttempt))
1121
1122
  .then(() => {
1122
1123
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
@@ -1180,7 +1181,7 @@ function multiSetWithRetry(data, retryAttempt) {
1180
1181
  // and subscriber state, matching the original per-key notification semantics.
1181
1182
  OnyxCache_1.default.set(key, value);
1182
1183
  // Skip subscriber notification on retry — already notified on attempt 0.
1183
- // waitForCollectionCallback subscribers re-fire on every keyChanged by contract.
1184
+ // Collection-root subscribers re-fire on every keyChanged by contract.
1184
1185
  if (!retryAttempt) {
1185
1186
  keyChanged(key, value);
1186
1187
  }
@@ -1201,6 +1202,7 @@ function multiSetWithRetry(data, retryAttempt) {
1201
1202
  });
1202
1203
  const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key));
1203
1204
  return storage_1.default.multiSet(keyValuePairsToStore)
1205
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1204
1206
  .catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys))
1205
1207
  .then(() => {
1206
1208
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
@@ -1258,7 +1260,7 @@ function setCollectionWithRetry({ collectionKey, collection }, retryAttempt) {
1258
1260
  for (const [key, value] of keyValuePairs)
1259
1261
  OnyxCache_1.default.set(key, value);
1260
1262
  // Skip subscriber notification on retry — already notified on attempt 0.
1261
- // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1263
+ // Collection-root subscribers re-fire on every keysChanged by contract.
1262
1264
  if (!retryAttempt) {
1263
1265
  keysChanged(collectionKey, mutableCollection, previousCollection);
1264
1266
  }
@@ -1269,6 +1271,7 @@ function setCollectionWithRetry({ collectionKey, collection }, retryAttempt) {
1269
1271
  }
1270
1272
  const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
1271
1273
  return storage_1.default.multiSet(keyValuePairs)
1274
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1272
1275
  .catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, { collectionKey, collection }, retryAttempt, inFlightKeys))
1273
1276
  .then(() => {
1274
1277
  OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
@@ -1380,7 +1383,7 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
1380
1383
  const previousCollection = getCachedCollection(collectionKey, existingKeys);
1381
1384
  OnyxCache_1.default.merge(finalMergedCollection);
1382
1385
  // Skip subscriber notification on retry — already notified on attempt 0.
1383
- // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1386
+ // Collection-root subscribers re-fire on every keysChanged by contract.
1384
1387
  if (!retryAttempt) {
1385
1388
  keysChanged(collectionKey, finalMergedCollection, previousCollection);
1386
1389
  }
@@ -1398,7 +1401,13 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
1398
1401
  }
1399
1402
  const inFlightKeys = new Set(Object.keys(finalMergedCollection));
1400
1403
  return Promise.all(promises)
1401
- .catch((error) => retryOperation(error, mergeCollectionWithPatches, { collectionKey, collection: resultCollection, mergeReplaceNullPatches, isProcessingCollectionUpdate }, retryAttempt, inFlightKeys))
1404
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1405
+ .catch((error) => retryOperation(error, mergeCollectionWithPatches, {
1406
+ collectionKey,
1407
+ collection: resultCollection,
1408
+ mergeReplaceNullPatches,
1409
+ isProcessingCollectionUpdate,
1410
+ }, retryAttempt, inFlightKeys))
1402
1411
  .then(() => {
1403
1412
  sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
1404
1413
  });
@@ -1449,7 +1458,7 @@ function partialSetCollection({ collectionKey, collection }, retryAttempt) {
1449
1458
  for (const [key, value] of keyValuePairs)
1450
1459
  OnyxCache_1.default.set(key, value);
1451
1460
  // Skip subscriber notification on retry — already notified on attempt 0.
1452
- // waitForCollectionCallback subscribers re-fire on every keysChanged by contract.
1461
+ // Collection-root subscribers re-fire on every keysChanged by contract.
1453
1462
  if (!retryAttempt) {
1454
1463
  keysChanged(collectionKey, mutableCollection, previousCollection);
1455
1464
  }
@@ -1459,6 +1468,7 @@ function partialSetCollection({ collectionKey, collection }, retryAttempt) {
1459
1468
  }
1460
1469
  const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
1461
1470
  return storage_1.default.multiSet(keyValuePairs)
1471
+ .then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
1462
1472
  .catch((error) => retryOperation(error, partialSetCollection, { collectionKey, collection }, retryAttempt, inFlightKeys))
1463
1473
  .then(() => {
1464
1474
  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 };
@@ -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;