react-native-onyx
Version:
State management for React Native
235 lines (234 loc) • 11.5 kB
JavaScript
;
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;