UNPKG

react-native-onyx

Version:

State management for React Native

996 lines 79.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.clearOnyxUtilsInternals = clearOnyxUtilsInternals; const fast_equals_1 = require("fast-equals"); const underscore_1 = __importDefault(require("underscore")); const DevTools_1 = __importDefault(require("./DevTools")); const Logger = __importStar(require("./Logger")); const OnyxCache_1 = __importStar(require("./OnyxCache")); const OnyxKeys_1 = __importDefault(require("./OnyxKeys")); const StorageCircuitBreaker_1 = __importDefault(require("./StorageCircuitBreaker")); const storage_1 = __importDefault(require("./storage")); const errors_1 = require("./storage/errors"); const utils_1 = __importDefault(require("./utils")); const createDeferredTask_1 = __importDefault(require("./createDeferredTask")); const logMessages_1 = __importDefault(require("./logMessages")); // Method constants const METHOD = { SET: 'set', MERGE: 'merge', MERGE_COLLECTION: 'mergecollection', SET_COLLECTION: 'setcollection', MULTI_SET: 'multiset', CLEAR: 'clear', }; // Max number of retries for failed storage operations const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5; // Key/value store of Onyx key and arrays of values to merge let mergeQueue = {}; let mergeQueuePromise = {}; // Holds a mapping of all the React components that want their state subscribed to a store key let callbackToStateMapping = {}; // Holds a mapping of the connected key to the subscriptionID for faster lookups let onyxKeyToSubscriptionIDs = new Map(); // Optional user-provided key value states set when Onyx initializes or clears let defaultKeyStates = {}; // Used for comparison with a new update to avoid invoking the Onyx.connect callback with the same data. let lastConnectionCallbackData = new Map(); let snapshotKey = null; // Keeps track of the last subscriptionID that was used so we can keep incrementing it let lastSubscriptionID = 0; // Connections can be made before `Onyx.init`. They would wait for this task before resolving const deferredInitTask = (0, createDeferredTask_1.default)(); // Collection member IDs that Onyx should silently ignore across all operations — reads, writes, cache, and subscriber // notifications. This is used to filter out keys formed from invalid/default IDs (e.g. "-1", "0", // "undefined", "null", "NaN") that can appear when an ID variable is accidentally coerced to string. let skippableCollectionMemberIDs = new Set(); // Holds a set of keys that should always be merged into snapshot entries. let snapshotMergeKeys = new Set(); function getSnapshotKey() { return snapshotKey; } /** * Getter - returns the merge queue. */ function getMergeQueue() { return mergeQueue; } /** * Getter - returns the merge queue promise. */ function getMergeQueuePromise() { return mergeQueuePromise; } /** * Getter - returns the default key states. */ function getDefaultKeyStates() { return defaultKeyStates; } /** * Getter - returns the deffered init task. */ function getDeferredInitTask() { return deferredInitTask; } /** * Executes an action after Onyx has been initialized. * If Onyx is already initialized, the action is executed immediately. * Otherwise, it waits for initialization to complete before executing. * * @param action The action to execute after initialization * @returns The result of the action */ function afterInit(action) { if (deferredInitTask.isResolved) { return action(); } return deferredInitTask.promise.then(action); } /** * Getter - returns the skippable collection member IDs. */ function getSkippableCollectionMemberIDs() { return skippableCollectionMemberIDs; } /** * Getter - returns the snapshot merge keys allowlist. */ function getSnapshotMergeKeys() { return snapshotMergeKeys; } /** * Setter - sets the skippable collection member IDs. */ function setSkippableCollectionMemberIDs(ids) { skippableCollectionMemberIDs = ids; } /** * Setter - sets the snapshot merge keys allowlist. */ function setSnapshotMergeKeys(keys) { snapshotMergeKeys = keys; } /** * Sets the initial values for the Onyx store * * @param keys - `ONYXKEYS` constants object from Onyx.init() * @param initialKeyStates - initial data to set when `init()` and `clear()` are called * @param evictableKeys - This is an array of keys (individual or collection patterns) that when provided to Onyx are flagged as "safe" for removal. */ function initStoreValues(keys, initialKeyStates, evictableKeys) { var _a; // We need the value of the collection keys later for checking if a // key is a collection. We store it in a map for faster lookup. const collectionValues = Object.values((_a = keys.COLLECTION) !== null && _a !== void 0 ? _a : {}); const collectionKeySet = collectionValues.reduce((acc, val) => { acc.add(val); return acc; }, new Set()); // Set our default key states to use when initializing and clearing Onyx data defaultKeyStates = initialKeyStates; DevTools_1.default.initState(initialKeyStates); // Let Onyx know about which keys are safe to evict OnyxCache_1.default.setEvictionAllowList(evictableKeys); // Set collection keys in cache for optimized storage OnyxCache_1.default.setCollectionKeys(collectionKeySet); if (typeof keys.COLLECTION === 'object' && typeof keys.COLLECTION.SNAPSHOT === 'string') { snapshotKey = keys.COLLECTION.SNAPSHOT; } } function sendActionToDevTools(method, key, value, mergedValue = undefined) { DevTools_1.default.registerAction(utils_1.default.formatActionName(method, key), value, key ? { [key]: mergedValue || value } : value); } /** * Takes a collection of items (eg. {testKey_1:{a:'a'}, testKey_2:{b:'b'}}) * and runs it through a reducer function to return a subset of the data according to a selector. * The resulting collection will only contain items that are returned by the selector. */ function reduceCollectionWithSelector(collection, selector) { return Object.entries(collection !== null && collection !== void 0 ? collection : {}).reduce((finalCollection, [key, item]) => { // eslint-disable-next-line no-param-reassign finalCollection[key] = selector(item); return finalCollection; }, {}); } /** Get some data from the store */ function get(key) { // When we already have the value in cache - resolve right away if (OnyxCache_1.default.hasCacheForKey(key)) { return Promise.resolve(OnyxCache_1.default.get(key)); } // RAM-only keys should never read from storage (they may have stale persisted data // from before the key was migrated to RAM-only). Mark as nullish so future get() calls // short-circuit via hasCacheForKey and avoid re-running this branch. if (OnyxKeys_1.default.isRamOnlyKey(key)) { OnyxCache_1.default.addNullishStorageKey(key); return Promise.resolve(undefined); } const taskName = `${OnyxCache_1.TASK.GET}:${key}`; // When a value retrieving task for this key is still running hook to it if (OnyxCache_1.default.hasPendingTask(taskName)) { return OnyxCache_1.default.getTaskPromise(taskName); } // Otherwise retrieve the value from storage and capture a promise to aid concurrent usages const promise = storage_1.default.getItem(key) .then((val) => { if (skippableCollectionMemberIDs.size) { try { const [, collectionMemberID] = OnyxKeys_1.default.splitCollectionMemberKey(key); if (skippableCollectionMemberIDs.has(collectionMemberID)) { // The key is a skippable one, so we set the value to undefined. // eslint-disable-next-line no-param-reassign val = undefined; } } catch (e) { // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. } } // Prefer cache over stale storage if a concurrent write populated it during the read. const cachedValue = OnyxCache_1.default.get(key); if (cachedValue !== undefined) { return cachedValue; } if (val === undefined) { OnyxCache_1.default.addNullishStorageKey(key); return undefined; } OnyxCache_1.default.set(key, val); return val; }) .catch((err) => Logger.logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`)); return OnyxCache_1.default.captureTask(taskName, promise); } // multiGet the data first from the cache and then from the storage for the missing keys. function multiGet(keys) { // Keys that are not in the cache const missingKeys = []; // Tasks that are pending const pendingTasks = []; // Keys for the tasks that are pending const pendingKeys = []; // Data to be sent back to the invoker const dataMap = new Map(); /** * We are going to iterate over all the matching keys and check if we have the data in the cache. * If we do then we add it to the data object. If we do not have them, then we check if there is a pending task * for the key. If there is such task, then we add the promise to the pendingTasks array and the key to the pendingKeys * array. If there is no pending task then we add the key to the missingKeys array. * * These missingKeys will be later used to multiGet the data from the storage. */ for (const key of keys) { // RAM-only keys should never read from storage as they may have stale persisted data // from before the key was migrated to RAM-only. if (OnyxKeys_1.default.isRamOnlyKey(key)) { if (OnyxCache_1.default.hasCacheForKey(key)) { dataMap.set(key, OnyxCache_1.default.get(key)); } continue; } // hasCacheForKey catches cached falsy values (0, '', false, null) as cache hits, which // a truthy check on the value would miss. if (OnyxCache_1.default.hasCacheForKey(key)) { dataMap.set(key, OnyxCache_1.default.get(key)); continue; } const pendingKey = `${OnyxCache_1.TASK.GET}:${key}`; if (OnyxCache_1.default.hasPendingTask(pendingKey)) { pendingTasks.push(OnyxCache_1.default.getTaskPromise(pendingKey)); pendingKeys.push(key); } else { missingKeys.push(key); } } return (Promise.all(pendingTasks) // Wait for all the pending tasks to resolve and then add the data to the data map. .then((values) => { for (const [index, value] of values.entries()) { dataMap.set(pendingKeys[index], value); } return Promise.resolve(); }) // Get the missing keys using multiGet from the storage. .then(() => { if (missingKeys.length === 0) { return Promise.resolve(undefined); } return storage_1.default.multiGet(missingKeys); }) // Add the data from the missing keys to the data map and also merge it to the cache. .then((values) => { if (!values || values.length === 0) { return dataMap; } // temp object is used to merge the missing data into the cache const temp = {}; for (const [key, value] of values) { if (skippableCollectionMemberIDs.size) { try { const [, collectionMemberID] = OnyxKeys_1.default.splitCollectionMemberKey(key); if (skippableCollectionMemberIDs.has(collectionMemberID)) { // The key is a skippable one, so we skip this iteration. continue; } } catch (e) { // The key is not a collection one or something went wrong during split, so we proceed with the function's logic. } } // Prefer cache over stale storage if a concurrent write populated it during // the read — otherwise cache.merge(temp) below would resurrect dropped fields. if (OnyxCache_1.default.hasCacheForKey(key)) { dataMap.set(key, OnyxCache_1.default.get(key)); continue; } dataMap.set(key, value); temp[key] = value; } OnyxCache_1.default.merge(temp); return dataMap; })); } /** * This helper exists to map an array of Onyx keys such as `['report_', 'conciergeReportID']` * to the values for those keys (correctly typed) such as `[OnyxCollection<Report>, OnyxEntry<string>]` * * Note: just using `.map`, you'd end up with `Array<OnyxCollection<Report>|OnyxEntry<string>>`, which is not what we want. This preserves the order of the keys provided. */ function tupleGet(keys) { return Promise.all(keys.map((key) => get(key))); } /** * Stores a subscription ID associated with a given key. * * @param subscriptionID - A subscription ID of the subscriber. * @param key - A key that the subscriber is subscribed to. */ function storeKeyBySubscriptions(key, subscriptionID) { if (!onyxKeyToSubscriptionIDs.has(key)) { onyxKeyToSubscriptionIDs.set(key, []); } onyxKeyToSubscriptionIDs.get(key).push(subscriptionID); } /** * Deletes a subscription ID associated with its corresponding key. * * @param subscriptionID - The subscription ID to be deleted. */ function deleteKeyBySubscriptions(subscriptionID) { const subscriber = callbackToStateMapping[subscriptionID]; if (subscriber && onyxKeyToSubscriptionIDs.has(subscriber.key)) { const updatedSubscriptionsIDs = onyxKeyToSubscriptionIDs.get(subscriber.key).filter((id) => id !== subscriptionID); onyxKeyToSubscriptionIDs.set(subscriber.key, updatedSubscriptionsIDs); } lastConnectionCallbackData.delete(subscriptionID); } /** Returns current key names stored in persisted storage */ function getAllKeys() { // When we've already read stored keys, resolve right away const cachedKeys = OnyxCache_1.default.getAllKeys(); if (cachedKeys.size > 0) { return Promise.resolve(cachedKeys); } // When a value retrieving task for all keys is still running hook to it if (OnyxCache_1.default.hasPendingTask(OnyxCache_1.TASK.GET_ALL_KEYS)) { return OnyxCache_1.default.getTaskPromise(OnyxCache_1.TASK.GET_ALL_KEYS); } // Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages const promise = storage_1.default.getAllKeys().then((keys) => { // Filter out RAM-only keys from storage results as they may be stale entries // from before the key was migrated to RAM-only. const filteredKeys = keys.filter((key) => !OnyxKeys_1.default.isRamOnlyKey(key)); OnyxCache_1.default.setAllKeys(filteredKeys); // return the updated set of keys return OnyxCache_1.default.getAllKeys(); }); return OnyxCache_1.default.captureTask(OnyxCache_1.TASK.GET_ALL_KEYS, promise); } /** * Tries to get a value from the cache. If the value is not present in cache it will return the default value or undefined. * If the requested key is a collection, it will return an object with all the collection members. */ function tryGetCachedValue(key) { let val = OnyxCache_1.default.get(key); if (OnyxKeys_1.default.isCollectionKey(key)) { const collectionData = OnyxCache_1.default.getCollectionData(key); if (collectionData !== undefined) { val = collectionData; } else { // If we haven't loaded all keys yet, we can't determine if the collection exists if (OnyxCache_1.default.getAllKeys().size === 0) { return; } // Set an empty collection object for collections that exist but have no data val = {}; } } return val; } function getCachedCollection(collectionKey, collectionMemberKeys) { // Use optimized collection data retrieval when cache is populated const collectionData = OnyxCache_1.default.getCollectionData(collectionKey); const allKeys = collectionMemberKeys || OnyxCache_1.default.getAllKeys(); if (collectionData !== undefined && (Array.isArray(allKeys) ? allKeys.length > 0 : allKeys.size > 0)) { // If we have specific member keys, filter the collection if (collectionMemberKeys) { const filteredCollection = {}; for (const key of collectionMemberKeys) { if (collectionData[key] !== undefined) { filteredCollection[key] = collectionData[key]; } else if (OnyxCache_1.default.hasNullishStorageKey(key)) { filteredCollection[key] = OnyxCache_1.default.get(key); } } return filteredCollection; } // Snapshot is frozen — safe to return by reference return collectionData; } // Fallback to original implementation if collection data not available const collection = {}; // forEach exists on both Set and Array for (const key of allKeys) { // If we don't have collectionMemberKeys array then we have to check whether a key is a collection member key. // Because in that case the keys will be coming from `cache.getAllKeys()` and we need to filter out the keys that // are not part of the collection. if (!collectionMemberKeys && !OnyxKeys_1.default.isCollectionMemberKey(collectionKey, key)) { continue; } const cachedValue = OnyxCache_1.default.get(key); if (cachedValue === undefined && !OnyxCache_1.default.hasNullishStorageKey(key)) { continue; } collection[key] = OnyxCache_1.default.get(key); } return collection; } /** * When a collection of keys change, search for any callbacks matching the collection key and trigger those callbacks */ function keysChanged(collectionKey, partialCollection, partialPreviousCollection) { var _a; const cachedCollection = getCachedCollection(collectionKey); const previousCollection = partialPreviousCollection !== null && partialPreviousCollection !== void 0 ? partialPreviousCollection : {}; const changedMemberKeys = Object.keys(partialCollection !== null && partialCollection !== void 0 ? partialCollection : {}); // Add or remove the keys from the recentlyAccessedKeys list for (const memberKey of changedMemberKeys) { const value = partialCollection === null || partialCollection === void 0 ? void 0 : partialCollection[memberKey]; if (value !== null && value !== undefined) { OnyxCache_1.default.addLastAccessedKey(memberKey, false); } else { OnyxCache_1.default.removeLastAccessedKey(memberKey); } } // Use indexed lookup instead of scanning all subscribers. // We need subscribers for: (1) the collection key itself, and (2) individual changed member keys. const collectionSubscriberIDs = (_a = onyxKeyToSubscriptionIDs.get(collectionKey)) !== null && _a !== void 0 ? _a : []; const memberSubscriberIDs = []; for (const memberKey of changedMemberKeys) { const ids = onyxKeyToSubscriptionIDs.get(memberKey); if (ids) { for (const id of ids) { memberSubscriberIDs.push(id); } } } // Notify collection-level subscribers for (const subID of collectionSubscriberIDs) { const subscriber = callbackToStateMapping[subID]; if (!subscriber || typeof subscriber.callback !== 'function') { continue; } try { lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection, matchedKey: subscriber.key }); subscriber.callback(cachedCollection, subscriber.key); } catch (error) { Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`); } } // Notify member-level subscribers (e.g. subscribed to `report_123`) for (const subID of memberSubscriberIDs) { const subscriber = callbackToStateMapping[subID]; if (!subscriber || typeof subscriber.callback !== 'function') { continue; } if (cachedCollection[subscriber.key] === previousCollection[subscriber.key]) { continue; } try { const subscriberCallback = subscriber.callback; subscriberCallback(cachedCollection[subscriber.key], subscriber.key); lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection[subscriber.key], matchedKey: subscriber.key, }); } catch (error) { Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`); } } } /** * When a key change happens, search for any callbacks matching the key or collection key and trigger those callbacks */ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCollectionUpdate = false) { var _a, _b; // Add or remove this key from the recentlyAccessedKeys list if (value !== null && value !== undefined) { OnyxCache_1.default.addLastAccessedKey(key, OnyxKeys_1.default.isCollectionKey(key)); } else { OnyxCache_1.default.removeLastAccessedKey(key); } // We get the subscribers interested in the key that has just changed. If the subscriber's key is a collection key then we will // notify them if the key that changed is a collection member. Or if it is a regular key notify them when there is an exact match. // Given the amount of times this function is called we need to make sure we are not iterating over all subscribers every time. On the other hand, we don't need to // do the same in keysChanged, because we only call that function when a collection key changes, and it doesn't happen that often. // For performance reason, we look for the given key and later if don't find it we look for the collection key, instead of checking if it is a collection key first. let stateMappingKeys = (_a = onyxKeyToSubscriptionIDs.get(key)) !== null && _a !== void 0 ? _a : []; const collectionKey = OnyxKeys_1.default.getCollectionKey(key); if (collectionKey) { // Getting the collection key from the specific key because only collection keys were stored in the mapping. stateMappingKeys = [...stateMappingKeys, ...((_b = onyxKeyToSubscriptionIDs.get(collectionKey)) !== null && _b !== void 0 ? _b : [])]; if (stateMappingKeys.length === 0) { return; } } // Cache the collection snapshot per dispatch so all subscribers to the same collection // see a consistent view, even if an earlier subscriber's callback synchronously writes // to the same collection. const cachedCollections = {}; for (const stateMappingKey of stateMappingKeys) { const subscriber = callbackToStateMapping[stateMappingKey]; if (!subscriber || !OnyxKeys_1.default.isKeyMatch(subscriber.key, key) || !canUpdateSubscriber(subscriber)) { continue; } // Subscriber is a regular call to connect() and provided a callback if (typeof subscriber.callback === 'function') { try { const lastData = lastConnectionCallbackData.get(subscriber.subscriptionID); if (lastData && lastData.matchedKey === key && lastData.value === value) { continue; } if (OnyxKeys_1.default.isCollectionKey(subscriber.key)) { // Skip individual key changes during collection updates to prevent duplicate // callbacks - the collection update will handle this properly. if (isProcessingCollectionUpdate) { continue; } // Cache once per dispatch to ensure all subscribers see a consistent snapshot // even if a previous callback synchronously wrote to the same collection. let cachedCollection = cachedCollections[subscriber.key]; if (!cachedCollection) { cachedCollection = getCachedCollection(subscriber.key); cachedCollections[subscriber.key] = cachedCollection; } lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection, matchedKey: subscriber.key }); subscriber.callback(cachedCollection, subscriber.key); continue; } const subscriberCallback = subscriber.callback; subscriberCallback(value, key); lastConnectionCallbackData.set(subscriber.subscriptionID, { value, matchedKey: key, }); continue; } catch (error) { Logger.logAlert(`[OnyxUtils.keyChanged] Subscriber callback threw an error for key '${key}': ${error}`); } continue; } console.error('Warning: Found a matching subscriber to a key that changed, but no callback could be found.'); } } /** * Sends the data obtained from the keys to the connection. */ function sendDataToConnection(mapping, matchedKey) { var _a; // If the mapping no longer exists then we should not send any data. // This means our subscriber was disconnected. if (!callbackToStateMapping[mapping.subscriptionID]) { return; } // Always read the latest value from cache to avoid stale or duplicate data. // For collection-root subscribers, read the full collection. // For individual key subscribers, read just that key's value. let value; if (OnyxKeys_1.default.isCollectionKey(mapping.key)) { const collection = getCachedCollection(mapping.key); value = Object.keys(collection).length > 0 ? collection : undefined; } else { value = OnyxCache_1.default.get(matchedKey !== null && matchedKey !== void 0 ? matchedKey : mapping.key); } // For regular callbacks, we never want to pass null values, but always just undefined if a value is not set in cache or storage. value = value === null ? undefined : value; const lastData = lastConnectionCallbackData.get(mapping.subscriptionID); // If the value has not changed for the same key we do not need to trigger the callback. // We compare matchedKey to avoid suppressing callbacks for different collection members // that happen to have shallow-equal values (e.g. during hydration racing with set()). if (lastData && lastData.matchedKey === matchedKey && (0, fast_equals_1.shallowEqual)(lastData.value, value)) { return; } (_a = mapping.callback) === null || _a === void 0 ? void 0 : _a.call(mapping, value, matchedKey); } /** * Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber. */ function getCollectionDataAndSendAsObject(matchingKeys, mapping) { multiGet(matchingKeys).then(() => { sendDataToConnection(mapping, mapping.key); }); } /** * Remove a key from Onyx and update the subscribers */ function remove(key, isProcessingCollectionUpdate) { OnyxCache_1.default.drop(key); keyChanged(key, undefined, undefined, isProcessingCollectionUpdate); if (OnyxKeys_1.default.isRamOnlyKey(key)) { return Promise.resolve(); } return storage_1.default.removeItem(key).then(() => undefined); } function reportStorageQuota(error) { return storage_1.default.getDatabaseSize() .then(({ bytesUsed, bytesRemaining, usageDetails }) => { // `bytesRemaining` comes from navigator.storage.estimate() and is an ORIGIN-WIDE estimate, // not headroom for this database. The browser allocates IndexedDB storage dynamically, so a // QuotaExceededError can legitimately occur even when this number still looks large. Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} originWideBytesRemaining (estimate, not per-DB headroom): ${bytesRemaining}${usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : ''}. Original error: ${error}`); }) .catch((dbSizeError) => { Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`); }); } /** * Handles storage operation failures based on the error class (see lib/storage/errors.ts). * The connection layer (createStore) owns connection/transport recovery; this operation layer owns * capacity recovery (eviction) so that a given failure is retried by exactly one layer: * - INVALID_DATA: logs an alert and throws (the same data will always fail). * - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget * and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly. * - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level * circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making * progress or failures storm — the per-operation budget alone cannot stop a session-wide storm. * - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message + * provider) once so it's visible, then bounded retry without eviction. */ function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlightKeys) { const currentRetryAttempt = retryAttempt !== null && retryAttempt !== void 0 ? retryAttempt : 0; const nextRetryAttempt = currentRetryAttempt + 1; const errorClass = storage_1.default.classifyError(error); // While open (or while a half-open probe is already in flight), drop capacity retries silently — // the breaker already emitted its single alert, and logging per failed write is exactly the storm // we are suppressing. A rejected half-open caller is the in-flight probe failing; record that so // the circuit reopens for another window. (We return before the log line below on purpose.) if (errorClass === errors_1.StorageErrorClass.CAPACITY && !StorageCircuitBreaker_1.default.isAllowed()) { StorageCircuitBreaker_1.default.recordProbeFailure(); return Promise.resolve(); } Logger.logInfo(`Failed to save to storage. Error: ${error}. class: ${errorClass}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`); if (errorClass === errors_1.StorageErrorClass.INVALID_DATA) { Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`); throw error; } if (errorClass === errors_1.StorageErrorClass.TRANSIENT || errorClass === errors_1.StorageErrorClass.FATAL) { Logger.logInfo(`Storage operation skipped retry; ${errorClass} errors are handled by the connection layer. Error: ${error}. onyxMethod: ${onyxMethod.name}.`); return Promise.resolve(); } if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) { Logger.logAlert(`Storage operation failed after ${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS} retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`); return Promise.resolve(); } if (errorClass === errors_1.StorageErrorClass.UNKNOWN) { // UNKNOWN is the blind spot: the active provider's classifier did not recognize this error, so // we cannot route it to a real recovery strategy. Log the full error shape (name + message + // provider) once per operation so telemetry can reveal what lives in UNKNOWN, letting us promote // recurring cases into TRANSIENT/CAPACITY/FATAL. Logged on the first attempt only to avoid the // per-retry amplification this mechanism is trying to kill. Then bounded retry without eviction. if (currentRetryAttempt === 0) { 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}.`); } // @ts-expect-error No overload matches this call. return onyxMethod(defaultParams, nextRetryAttempt); } // CAPACITY: feed the session-level circuit breaker before evicting. The per-operation budget above // cannot stop a session-wide storm — each evicted key triggers an OnyxDerived recompute that spawns // a fresh write with its own budget — so the breaker is what actually halts the meltdown. (The // already-open case returned silently at the top of this function.) if (StorageCircuitBreaker_1.default.recordCapacityFailure()) { // This failure tripped the breaker; it already emitted its single alert. Stop here. return Promise.resolve(); } // Find the least recently accessed evictable key that we can remove. Never evict an in-flight // key — its cache value is the merge base this retry depends on, so dropping it would truncate // the write to just the delta and diverge cache from storage. const keyForRemoval = OnyxCache_1.default.getKeyForEviction(inFlightKeys); if (!keyForRemoval) { // If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case, // then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we // will allow this write to be skipped. Logger.logAlert(`Out of storage. But found no acceptable keys to remove. Error: ${error}`); return reportStorageQuota(error); } // Remove the least recently accessed key and retry. Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`); reportStorageQuota(error); return remove(keyForRemoval).then(() => { // Mark the eviction only once the deletion has actually completed, immediately before the // retry it pairs with. Recording earlier lets a concurrent write's capacity failure consume // the marker as a no-progress cycle while this deletion is still pending and may yet free // space — so the verdict belongs to the retry that follows the deletion, not the eviction call. StorageCircuitBreaker_1.default.recordEviction(); // @ts-expect-error No overload matches this call. return onyxMethod(defaultParams, nextRetryAttempt); }); } /** * Notifies subscribers and writes current value to cache */ function broadcastUpdate(key, value, hasChanged) { if (!hasChanged) { return; } OnyxCache_1.default.set(key, value); keyChanged(key, value); } function hasPendingMergeForKey(key) { return !!mergeQueue[key]; } /** * Storage expects array like: [["@MyApp_user", value_1], ["@MyApp_key", value_2]] * This method transforms an object like {'@MyApp_user': myUserValue, '@MyApp_key': myKeyValue} * to an array of key-value pairs in the above format and removes key-value pairs that are being set to null * * @return an array of key - value pairs <[key, value]> */ function prepareKeyValuePairsForStorage(data, shouldRemoveNestedNulls, replaceNullPatches, isProcessingCollectionUpdate) { const pairs = []; for (const [key, value] of Object.entries(data)) { if (value === null) { remove(key, isProcessingCollectionUpdate); continue; } const valueWithoutNestedNullValues = (shouldRemoveNestedNulls !== null && shouldRemoveNestedNulls !== void 0 ? shouldRemoveNestedNulls : true) ? utils_1.default.removeNestedNullValues(value) : value; if (valueWithoutNestedNullValues !== undefined) { pairs.push([key, valueWithoutNestedNullValues, replaceNullPatches === null || replaceNullPatches === void 0 ? void 0 : replaceNullPatches[key]]); } } return pairs; } /** * Merges an array of changes with an existing value or creates a single change. * * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ function mergeChanges(changes, existingValue) { return mergeInternal('merge', changes, existingValue); } /** * Merges an array of changes with an existing value or creates a single change. * It will also mark deep nested objects that need to be entirely replaced during the merge. * * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ function mergeAndMarkChanges(changes, existingValue) { return mergeInternal('mark', changes, existingValue); } /** * Merges an array of changes with an existing value or creates a single change. * * @param changes Array of changes that should be merged * @param existingValue The existing value that should be merged with the changes */ function mergeInternal(mode, changes, existingValue) { const lastChange = changes === null || changes === void 0 ? void 0 : changes.at(-1); if (Array.isArray(lastChange)) { return { result: lastChange, replaceNullPatches: [] }; } if (changes.some((change) => change && typeof change === 'object')) { // Object values are then merged one after the other return changes.reduce((modifiedData, change) => { const options = mode === 'merge' ? { shouldRemoveNestedNulls: true, objectRemovalMode: 'replace' } : { objectRemovalMode: 'mark' }; const { result, replaceNullPatches } = utils_1.default.fastMerge(modifiedData.result, change, options); // eslint-disable-next-line no-param-reassign modifiedData.result = result; // eslint-disable-next-line no-param-reassign modifiedData.replaceNullPatches = [...modifiedData.replaceNullPatches, ...replaceNullPatches]; return modifiedData; }, { result: (existingValue !== null && existingValue !== void 0 ? existingValue : {}), replaceNullPatches: [], }); } // If we have anything else we can't merge it so we'll // simply return the last value that was queued return { result: lastChange, replaceNullPatches: [] }; } /** * Merge user provided default key value pairs. */ function initializeWithDefaultKeyStates() { // Eagerly load the entire database into cache in a single batch read. // This is faster than lazy-loading individual keys because: // 1. One DB transaction instead of hundreds // 2. All subsequent reads are synchronous cache hits return storage_1.default.getAll() .then((pairs) => { const allDataFromStorage = {}; for (const [key, value] of pairs) { // RAM-only keys should not be cached from storage as they may have stale persisted data // from before the key was migrated to RAM-only. if (OnyxKeys_1.default.isRamOnlyKey(key)) { continue; } // Skip collection members that are marked as skippable if (skippableCollectionMemberIDs.size && OnyxKeys_1.default.getCollectionKey(key)) { const [, collectionMemberID] = OnyxKeys_1.default.splitCollectionMemberKey(key); if (skippableCollectionMemberIDs.has(collectionMemberID)) { continue; } } allDataFromStorage[key] = value; } // Load all storage data into cache silently (no subscriber notifications) OnyxCache_1.default.setAllKeys(Object.keys(allDataFromStorage)); OnyxCache_1.default.merge(allDataFromStorage); // For keys that have a developer-defined default (via `initialKeyStates`), merge the // persisted value with the default so new properties added in code updates are applied // without wiping user data that already exists in storage. const defaultKeysFromStorage = Object.keys(defaultKeyStates).reduce((obj, key) => { if (key in allDataFromStorage) { // eslint-disable-next-line no-param-reassign obj[key] = allDataFromStorage[key]; } return obj; }, {}); const merged = utils_1.default.fastMerge(defaultKeysFromStorage, defaultKeyStates, { shouldRemoveNestedNulls: true, }).result; OnyxCache_1.default.merge(merged !== null && merged !== void 0 ? merged : {}); // Notify subscribers about default key states so that any subscriber that connected // before init (e.g. during module load) receives the merged default values immediately for (const [key, value] of Object.entries(merged !== null && merged !== void 0 ? merged : {})) { keyChanged(key, value); } }) .catch((error) => { Logger.logAlert(`Failed to load data from storage during init. The app will boot with default key states only. Error: ${error}`); // Populate the key index so getAllKeys() returns correct results for default keys. // Without this, subscribers that check getAllKeys() would see an empty set even // though we have default values in cache. OnyxCache_1.default.setAllKeys(Object.keys(defaultKeyStates)); // Boot with defaults so the app renders instead of deadlocking. // Users will get a fresh-install experience but the app won't be bricked. OnyxCache_1.default.merge(defaultKeyStates); // Notify subscribers about default key states so that any subscriber that connected // before init (e.g. during module load) receives the merged default values immediately for (const [key, value] of Object.entries(defaultKeyStates)) { keyChanged(key, value); } }); } /** * Validate the collection is not empty and has a correct type before applying mergeCollection() */ function isValidNonEmptyCollectionForMerge(collection) { return typeof collection === 'object' && !Array.isArray(collection) && !utils_1.default.isEmptyObject(collection); } /** * Verify if all the collection keys belong to the same parent */ function doAllCollectionItemsBelongToSameParent(collectionKey, collectionKeys) { let hasCollectionKeyCheckFailed = false; for (const dataKey of collectionKeys) { if (OnyxKeys_1.default.isKeyMatch(collectionKey, dataKey)) { continue; } if (process.env.NODE_ENV === 'development') { throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`); } hasCollectionKeyCheckFailed = true; Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`); } return !hasCollectionKeyCheckFailed; } /** * Subscribes to an Onyx key and listens to its changes. * * @param connectOptions The options object that will define the behavior of the connection. * @returns The subscription ID to use when calling `OnyxUtils.unsubscribeFromKey()`. */ function subscribeToKey(connectOptions) { const mapping = connectOptions; const subscriptionID = lastSubscriptionID++; callbackToStateMapping[subscriptionID] = mapping; callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID; // When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the subscriptionID // to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key), // We create a mapping from key to lists of subscriptionIDs to access the specific list of subscriptionIDs. storeKeyBySubscriptions(mapping.key, callbackToStateMapping[subscriptionID].subscriptionID); // Commit connection only after init passes deferredInitTask.promise // This first .then() adds a microtask tick for compatibility reasons and // to ensure subscribers don't receive an extra initial callback before Onyx.update() data arrives. .then(() => undefined) .then(() => { // Performance improvement // If the mapping is connected to an onyx key that is not a collection // we can skip the call to getAllKeys() and return an array with a single item if (!!mapping.key && typeof mapping.key === 'string' && !OnyxKeys_1.default.isCollectionKey(mapping.key) && OnyxCache_1.default.getAllKeys().has(mapping.key)) { return new Set([mapping.key]); } return getAllKeys(); }) .then((keys) => { // We search all the keys in storage to see if any are a "match" for the subscriber we are connecting so that we // can send data back to the subscriber. Note that multiple keys can match as a subscriber could either be // subscribed to a "collection key" or a single key. const matchingKeys = []; // Performance optimization: For single key subscriptions, avoid O(n) iteration if (!OnyxKeys_1.default.isCollectionKey(mapping.key)) { if (keys.has(mapping.key)) { matchingKeys.push(mapping.key); } } else { // Collection case - need to iterate through all keys to find matches (O(n)) for (const key of keys) { if (!OnyxKeys_1.default.isKeyMatch(mapping.key, key)) { continue; } matchingKeys.push(key); } } // If the key being connected to does not exist we initialize the value with null. For subscribers that connected // directly via connect() they will simply get a null value sent to them without any information about which key matched // since there are none matched. if (matchingKeys.length === 0) { if (mapping.key) { OnyxCache_1.default.addNullishStorageKey(mapping.key); } const matchedKey = OnyxKeys_1.default.isCollectionKey(mapping.key) ? mapping.key : undefined; // Here we cannot use batching because the nullish value is expected to be set immediately for default props // or they will be undefined. sendDataToConnection(mapping, matchedKey); return; } // When using a callback subscriber, a subscription to a collection key combines all matching // member values into a single object and makes one call with the whole collection object. if (typeof mapping.callback === 'function') { if (OnyxKeys_1.default.isCollectionKey(mapping.key)) { getCollectionDataAndSendAsObject(matchingKeys, mapping); return; } // If we are not subscribed to a collection key then there's only a single key to send an update for. get(mapping.key).then(() => sendDataToConnection(mapping, mapping.key)); return; } console.error('Warning: Onyx.connect() was found without a callback'); }); // The subscriptionID is returned back to the caller so that it can be used to clean up the connection when it's no longer needed // by calling OnyxUtils.unsubscribeFromKey(subscriptionID). return subscriptionID; } /** * Disconnects and removes the listener from the Onyx key. * * @param subscriptionID Subscription ID returned by calling `OnyxUtils.subscribeToKey()`. */ function unsubscribeFromKey(subscriptionID) { if (!callbackToStateMapping[subscriptionID]) { return; } deleteKeyBySubscriptions(subscriptionID); delete callbackToStateMapping[subscriptionID]; } function updateSnapshots(data, me