UNPKG

react-native-onyx

Version:

State management for React Native

235 lines (234 loc) 11.5 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 }); const IDB = __importStar(require("idb-keyval")); const Logger = __importStar(require("../../../Logger")); const errors_1 = require("../../errors"); const classifyError_1 = __importDefault(require("./classifyError")); const HEAL_ATTEMPTS_MAX = 3; // This is a copy of the createStore function from idb-keyval, we need a custom implementation // because we need to create the database manually in order to ensure that the store exists before we use it. // If the store does not exist, idb-keyval will throw an error // source: https://github.com/jakearchibald/idb-keyval/blob/9d19315b4a83897df1e0193dccdc29f78466a0f3/src/index.ts#L12 function createStore(dbName, storeName) { let dbp; let healAttemptsRemaining = HEAL_ATTEMPTS_MAX; const attachHandlers = (db) => { // Browsers may close idle IDB connections at any time, especially Safari. // We clear the cached promise so the next operation opens a fresh connection. // https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/close_event // eslint-disable-next-line no-param-reassign db.onclose = () => { Logger.logInfo('IDB connection closed by browser', { dbName, storeName }); dbp = undefined; }; // When another tab triggers a DB version upgrade, we must close the connection // to unblock the upgrade; otherwise the other tab's open request hangs indefinitely. // https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/versionchange_event // eslint-disable-next-line no-param-reassign db.onversionchange = () => { Logger.logInfo('IDB connection closing due to version change', { dbName, storeName, }); db.close(); dbp = undefined; }; }; // Cache the open promise and attach handlers + rejection cleanup. // On rejection, clears dbp so the next operation retries with a fresh indexedDB.open() // instead of returning the same rejected promise. // Guard: only clear if dbp hasn't been replaced by a concurrent heal/retry. function cacheOpenPromise(openPromise) { dbp = openPromise; const currentPromise = openPromise; openPromise.then(attachHandlers, () => { if (dbp !== currentPromise) { return; } dbp = undefined; }); return openPromise; } const getDB = () => { if (dbp) return dbp; const request = indexedDB.open(dbName); request.onupgradeneeded = () => request.result.createObjectStore(storeName); return cacheOpenPromise(IDB.promisifyRequest(request)); }; // Ensures the store exists in the DB. If missing, bumps the version to trigger // onupgradeneeded, recreates the store, and returns a promise to the new DB. const verifyStoreExists = (db) => { if (db.objectStoreNames.contains(storeName)) { return db; } Logger.logInfo(`Store ${storeName} does not exist in database ${dbName}.`); const nextVersion = db.version + 1; db.close(); const request = indexedDB.open(dbName, nextVersion); request.onupgradeneeded = () => { const updatedDatabase = request.result; if (updatedDatabase.objectStoreNames.contains(storeName)) { return; } Logger.logInfo(`Creating store ${storeName} in database ${dbName}.`); updatedDatabase.createObjectStore(storeName); }; return cacheOpenPromise(IDB.promisifyRequest(request)); }; function executeTransaction(txMode, callback) { return getDB() .then(verifyStoreExists) .then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName))); } function resetHealBudget(result) { healAttemptsRemaining = HEAL_ATTEMPTS_MAX; return result; } // Proactive IDB health check when tab returns to foreground. // Safari kills IDB connections for backgrounded tabs. By probing as soon as // the tab becomes visible, we drop the stale dbp early so the first real // operation opens a fresh connection instead of failing. document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible' || !dbp) { return; } Logger.logInfo('IDB visibilitychange probe: tab became visible, checking connection health', { dbName, storeName }); const probePromise = dbp; const dropCacheIfStale = (error) => { // A stale/dead connection surfaces as TRANSIENT (InvalidStateError, Safari connection lost) // or FATAL (Chromium backing-store corruption) per the shared taxonomy (lib/storage/errors.ts). const errorClass = (0, classifyError_1.default)(error); const isStaleConnection = errorClass === errors_1.StorageErrorClass.TRANSIENT || errorClass === errors_1.StorageErrorClass.FATAL; if (dbp !== probePromise || !isStaleConnection) { return; } Logger.logAlert('IDB visibilitychange probe: stale connection detected, dropping cached connection', { dbName, storeName, errorMessage: error instanceof Error ? error.message : String(error), }); dbp = undefined; }; probePromise .then((db) => { if (dbp !== probePromise) { return; } try { const tx = db.transaction(storeName, 'readonly'); const probeStore = tx.objectStore(storeName); const req = probeStore.count(); req.onsuccess = () => { Logger.logInfo('IDB visibilitychange probe: connection is healthy', { dbName, storeName }); }; req.onerror = () => { dropCacheIfStale(req.error); }; } catch (error) { dropCacheIfStale(error); } }) .catch(() => { // The cached open promise rejected; cacheOpenPromise already cleared dbp on its own // branch. Swallow here so the probe's separate branch doesn't surface an unhandled rejection. }); }); // The connection layer owns recovery for connection/transport failures. It reacts per the shared // error taxonomy (see lib/storage/errors.ts): // - TRANSIENT (InvalidStateError, AbortError, Safari connection lost) — the cached connection is // stale. Drop it and retry once with a fresh one. Unbudgeted: a single reopen is always worth it // and is bounded per operation. // - FATAL (Chromium backing-store corruption) — reopening can recover transient corruption, but // repeating forever is futile, so the heal is budgeted (3 attempts, reset on success). // Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts // - CAPACITY / UNKNOWN are NOT the connection layer's responsibility — propagate to the operation // layer (OnyxUtils.retryOperation) without retrying here, to avoid compounding retries. // Note: concurrent store() calls share the heal budget. Under overlapping failures each caller // decrements independently, so the budget may drain faster than one-per-incident. This is // acceptable — same as Dexie's approach — and the budget resets on any success. return (txMode, callback) => executeTransaction(txMode, callback) .then(resetHealBudget) .catch((error) => { const errorClass = (0, classifyError_1.default)(error); if (errorClass === errors_1.StorageErrorClass.TRANSIENT) { Logger.logInfo('IDB transient error — dropping cached connection and retrying once', { dbName, storeName, txMode, errorMessage: error instanceof Error ? error.message : String(error), }); dbp = undefined; return executeTransaction(txMode, callback).then(resetHealBudget); } if (errorClass === errors_1.StorageErrorClass.FATAL && healAttemptsRemaining > 0) { healAttemptsRemaining--; Logger.logInfo(`IDB heal: backing store error detected — dropping cached connection and reopening (${healAttemptsRemaining} attempts left)`, { dbName, storeName, }); dbp = undefined; return executeTransaction(txMode, callback).then((result) => { Logger.logInfo('IDB heal: successfully recovered after backing store error', { dbName, storeName }); return resetHealBudget(result); }); } if (errorClass === errors_1.StorageErrorClass.FATAL) { Logger.logAlert('IDB heal: backing store error — heal budget exhausted, giving up', { dbName, storeName, }); } else if (errorClass === errors_1.StorageErrorClass.UNKNOWN) { // UNKNOWN — unexpected at this layer; record it so it's visible. CAPACITY is the // expected propagation path (the operation layer owns its logging, and suppresses it // entirely once the circuit breaker is open), so we do NOT log it here — doing so was a // per-failed-write line that dominated the storm. Logger.logInfo('IDB error not recoverable at the connection layer, propagating', { dbName, storeName, errorClass, errorMessage: error instanceof Error ? error.message : String(error), }); } throw error; }); } exports.default = createStore;