react-native-onyx 3.0.119 → 3.0.121

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.
@@ -122,16 +122,16 @@ declare function reportStorageQuota(error?: Error): Promise<void>;
122
122
  * The connection layer (createStore) owns connection/transport recovery; this operation layer owns
123
123
  * capacity recovery (eviction) so that a given failure is retried by exactly one layer:
124
124
  * - INVALID_DATA: logs an alert and throws (the same data will always fail).
125
- * - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
126
- * and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
125
+ * - TRANSIENT / FATAL: the connection layer already retried (transient) or spent a heal attempt
126
+ * (fatal). Retrying here would only re-amplify, so we skip the write quietly.
127
127
  * - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
128
128
  * circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
129
129
  * progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
130
130
  * - DISK_PRESSURE: the device disk itself is full (or the database files are unreadable), so neither
131
131
  * retries nor in-DB eviction can free space — the write is dropped (cache stays authoritative) with
132
132
  * a single throttled alert + quota snapshot per burst.
133
- * - UNAVAILABLE: the storage engine does not exist in this environment, so the storage layer has
134
- * already degraded to the in-memory provider. No retry.
133
+ * - UNAVAILABLE: the storage engine does not exist in this environment or its heal budget ran out, so
134
+ * the storage layer has already degraded to the in-memory provider. No retry.
135
135
  * - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
136
136
  * provider) once so it's visible, then bounded retry without eviction.
137
137
  */
package/dist/OnyxUtils.js CHANGED
@@ -469,16 +469,16 @@ function reportStorageQuota(error) {
469
469
  * The connection layer (createStore) owns connection/transport recovery; this operation layer owns
470
470
  * capacity recovery (eviction) so that a given failure is retried by exactly one layer:
471
471
  * - INVALID_DATA: logs an alert and throws (the same data will always fail).
472
- * - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
473
- * and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
472
+ * - TRANSIENT / FATAL: the connection layer already retried (transient) or spent a heal attempt
473
+ * (fatal). Retrying here would only re-amplify, so we skip the write quietly.
474
474
  * - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
475
475
  * circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
476
476
  * progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
477
477
  * - DISK_PRESSURE: the device disk itself is full (or the database files are unreadable), so neither
478
478
  * retries nor in-DB eviction can free space — the write is dropped (cache stays authoritative) with
479
479
  * a single throttled alert + quota snapshot per burst.
480
- * - UNAVAILABLE: the storage engine does not exist in this environment, so the storage layer has
481
- * already degraded to the in-memory provider. No retry.
480
+ * - UNAVAILABLE: the storage engine does not exist in this environment or its heal budget ran out, so
481
+ * the storage layer has already degraded to the in-memory provider. No retry.
482
482
  * - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
483
483
  * provider) once so it's visible, then bounded retry without eviction.
484
484
  */
@@ -752,6 +752,9 @@ function initializeWithDefaultKeyStates() {
752
752
  })
753
753
  .catch((error) => {
754
754
  Logger.logAlert(`Failed to load data from storage during init. The app will boot with default key states only. Error: ${error}`);
755
+ if (storage_1.default.classifyError(error) === errors_1.StorageErrorClass.FATAL) {
756
+ storage_1.default.degradeToMemoryOnly(error);
757
+ }
755
758
  // Populate the key index so getAllKeys() returns correct results for default keys.
756
759
  // Without this, subscribers that check getAllKeys() would see an empty set even
757
760
  // though we have default values in cache.
@@ -12,6 +12,7 @@ declare const StorageMock: {
12
12
  getStorageProvider: jest.Mock<import("../providers/types").default<{
13
13
  [x: string]: unknown;
14
14
  }>, [], any>;
15
+ degradeToMemoryOnly: jest.Mock<any, any, any>;
15
16
  getItem: jest.Mock<Promise<unknown>, [key: any], any>;
16
17
  multiGet: jest.Mock<Promise<import("../providers/types").StorageKeyValuePair[]>, [keys: import("../providers/types").StorageKeyList], any>;
17
18
  setItem: jest.Mock<Promise<void>, [key: any, value: unknown], any>;
@@ -53,6 +53,7 @@ const StorageMock = {
53
53
  init,
54
54
  classifyError: jest.fn(classifyError),
55
55
  getStorageProvider: jest.fn(() => MemoryOnlyProvider_1.default),
56
+ degradeToMemoryOnly: jest.fn(),
56
57
  getItem: jest.fn(MemoryOnlyProvider_1.default.getItem),
57
58
  multiGet: jest.fn(MemoryOnlyProvider_1.default.multiGet),
58
59
  setItem: jest.fn(MemoryOnlyProvider_1.default.setItem),
@@ -22,12 +22,12 @@ declare const StorageErrorClass: {
22
22
  readonly DISK_PRESSURE: "diskPressure";
23
23
  /** Non-serializable payload. Never retriable — the same data will always fail. */
24
24
  readonly INVALID_DATA: "invalidData";
25
- /** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */
25
+ /** Backing-store corruption. Owner: connection layer — budgeted heal, then rethrow as UNAVAILABLE. */
26
26
  readonly FATAL: "fatal";
27
27
  /** The storage engine itself does not exist in this environment (for example `indexedDB` is an
28
- * undeclared global in Chrome for iOS private tabs and in Lockdown Mode). Never retriable — the
29
- * engine cannot appear mid-session. Owner: the storage layer — degrade
30
- * to the in-memory provider. */
28
+ * undeclared global in Chrome for iOS private tabs and in Lockdown Mode), or a FATAL heal budget
29
+ * ran out. Never retriable — the engine cannot recover mid-session. Owner: the storage layer —
30
+ * degrade to the in-memory provider. */
31
31
  readonly UNAVAILABLE: "unavailable";
32
32
  /** Unmatched by the active provider. Owner: operation layer — bounded retry, and log the shape so
33
33
  * recurring cases can be promoted into one of the classes above. */
@@ -26,12 +26,12 @@ const StorageErrorClass = {
26
26
  DISK_PRESSURE: 'diskPressure',
27
27
  /** Non-serializable payload. Never retriable — the same data will always fail. */
28
28
  INVALID_DATA: 'invalidData',
29
- /** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */
29
+ /** Backing-store corruption. Owner: connection layer — budgeted heal, then rethrow as UNAVAILABLE. */
30
30
  FATAL: 'fatal',
31
31
  /** The storage engine itself does not exist in this environment (for example `indexedDB` is an
32
- * undeclared global in Chrome for iOS private tabs and in Lockdown Mode). Never retriable — the
33
- * engine cannot appear mid-session. Owner: the storage layer — degrade
34
- * to the in-memory provider. */
32
+ * undeclared global in Chrome for iOS private tabs and in Lockdown Mode), or a FATAL heal budget
33
+ * ran out. Never retriable — the engine cannot recover mid-session. Owner: the storage layer —
34
+ * degrade to the in-memory provider. */
35
35
  UNAVAILABLE: 'unavailable',
36
36
  /** Unmatched by the active provider. Owner: operation layer — bounded retry, and log the shape so
37
37
  * recurring cases can be promoted into one of the classes above. */
@@ -1,6 +1,7 @@
1
1
  import type StorageProvider from './providers/types';
2
2
  type Storage = {
3
3
  getStorageProvider: () => StorageProvider<unknown>;
4
+ degradeToMemoryOnly: (error: unknown) => void;
4
5
  } & Omit<StorageProvider<unknown>, 'name' | 'store'>;
5
6
  declare const storage: Storage;
6
7
  export default storage;
@@ -56,6 +56,9 @@ const initPromise = new Promise((resolve) => {
56
56
  * Degrade performance by removing the storage provider and only using cache
57
57
  */
58
58
  function degradePerformance(error) {
59
+ if (provider === MemoryOnlyProvider_1.default) {
60
+ return;
61
+ }
59
62
  let causeMessage = '';
60
63
  if (error.cause instanceof Error) {
61
64
  causeMessage = error.cause.message;
@@ -95,6 +98,7 @@ const storage = {
95
98
  getStorageProvider() {
96
99
  return provider;
97
100
  },
101
+ degradeToMemoryOnly: (error) => degradePerformance(error instanceof Error ? error : new Error(String(error))),
98
102
  /**
99
103
  * Classifies a write error using the platform provider's own classifier. Synchronous and pure —
100
104
  * never wrapped in tryOrDegradePerformance.
@@ -212,7 +216,12 @@ const storage = {
212
216
  if (!InstanceSync_1.default.shouldBeUsed)
213
217
  return;
214
218
  shouldKeepInstancesSync = true;
215
- InstanceSync_1.default.init(onStorageKeysChanged, this);
219
+ InstanceSync_1.default.init((pairs) => {
220
+ if (provider === MemoryOnlyProvider_1.default) {
221
+ return;
222
+ }
223
+ onStorageKeysChanged(pairs);
224
+ }, this);
216
225
  },
217
226
  };
218
227
  exports.default = storage;
@@ -1,7 +1,40 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  const errors_1 = require("../../errors");
4
- const isIndexedDBAvailable_1 = require("./isIndexedDBAvailable");
37
+ const errorMessages_1 = __importStar(require("./errorMessages"));
5
38
  /**
6
39
  * Classifies an IndexedDB write failure into the shared storage taxonomy (lib/storage/errors.ts).
7
40
  * Matching is done on the lowercased error name and message. This is the IndexedDB engine's own
@@ -10,7 +43,11 @@ const isIndexedDBAvailable_1 = require("./isIndexedDBAvailable");
10
43
  function classifyIDBError(error) {
11
44
  const { name, message } = (0, errors_1.getErrorParts)(error);
12
45
  // The engine is absent, not broken e.g. private tabs and Lockdown Mode on WebKit.
13
- if (message.includes("can't find variable: indexeddb") || message.includes('indexeddb is not defined') || message.includes(isIndexedDBAvailable_1.INDEXED_DB_UNAVAILABLE_MESSAGE.toLowerCase())) {
46
+ if (message.includes("can't find variable: indexeddb") || message.includes('indexeddb is not defined') || message.includes(errorMessages_1.default.UNAVAILABLE.toLowerCase())) {
47
+ return errors_1.StorageErrorClass.UNAVAILABLE;
48
+ }
49
+ // Reopen budget spent on FATAL errors, so the store is treated as gone for the rest of the session.
50
+ if (name === errorMessages_1.IDBErrorName.HEAL_EXHAUSTED.toLowerCase()) {
14
51
  return errors_1.StorageErrorClass.UNAVAILABLE;
15
52
  }
16
53
  // Non-serializable data passed to IDBObjectStore.put — retrying is futile.
@@ -40,7 +40,8 @@ const IDB = __importStar(require("idb-keyval"));
40
40
  const Logger = __importStar(require("../../../Logger"));
41
41
  const errors_1 = require("../../errors");
42
42
  const classifyError_1 = __importDefault(require("./classifyError"));
43
- const isIndexedDBAvailable_1 = __importStar(require("./isIndexedDBAvailable"));
43
+ const errorMessages_1 = __importStar(require("./errorMessages"));
44
+ const isIndexedDBAvailable_1 = __importDefault(require("./isIndexedDBAvailable"));
44
45
  const HEAL_ATTEMPTS_MAX = 3;
45
46
  // This is a copy of the createStore function from idb-keyval, we need a custom implementation
46
47
  // because we need to create the database manually in order to ensure that the store exists before we use it.
@@ -90,7 +91,7 @@ function createStore(dbName, storeName) {
90
91
  if (dbp)
91
92
  return dbp;
92
93
  if (!(0, isIndexedDBAvailable_1.default)()) {
93
- return Promise.reject(new Error(isIndexedDBAvailable_1.INDEXED_DB_UNAVAILABLE_MESSAGE));
94
+ return Promise.reject(new Error(errorMessages_1.default.UNAVAILABLE));
94
95
  }
95
96
  const request = indexedDB.open(dbName);
96
97
  request.onupgradeneeded = () => request.result.createObjectStore(storeName);
@@ -218,12 +219,17 @@ function createStore(dbName, storeName) {
218
219
  });
219
220
  }
220
221
  if (errorClass === errors_1.StorageErrorClass.FATAL) {
222
+ const errorMessage = error instanceof Error ? error.message : String(error);
221
223
  Logger.logAlert('IDB heal: backing store error — heal budget exhausted, giving up', {
222
224
  dbName,
223
225
  storeName,
226
+ errorMessage,
224
227
  });
228
+ const healExhaustedError = new Error(`${errorMessages_1.default.HEAL_EXHAUSTED}: ${errorMessage}`, { cause: error });
229
+ healExhaustedError.name = errorMessages_1.IDBErrorName.HEAL_EXHAUSTED;
230
+ throw healExhaustedError;
225
231
  }
226
- else if (errorClass === errors_1.StorageErrorClass.UNKNOWN) {
232
+ if (errorClass === errors_1.StorageErrorClass.UNKNOWN) {
227
233
  // UNKNOWN — unexpected at this layer; record it so it's visible. CAPACITY is the
228
234
  // expected propagation path (the operation layer owns its logging, and suppresses it
229
235
  // entirely once the circuit breaker is open), so we do NOT log it here — doing so was a
@@ -0,0 +1,11 @@
1
+ /** Messages of errors thrown by the IndexedDB provider itself. `classifyIDBError` maps `UNAVAILABLE` to `StorageErrorClass.UNAVAILABLE`. */
2
+ declare const IDBErrorMessage: {
3
+ readonly UNAVAILABLE: "IndexedDB is not available in this environment";
4
+ readonly HEAL_EXHAUSTED: "IndexedDB heal budget exhausted";
5
+ };
6
+ /** Names of errors thrown by the IndexedDB provider itself. `classifyIDBError` maps `HEAL_EXHAUSTED` to `StorageErrorClass.UNAVAILABLE`. */
7
+ declare const IDBErrorName: {
8
+ readonly HEAL_EXHAUSTED: "IDBHealExhaustedError";
9
+ };
10
+ export default IDBErrorMessage;
11
+ export { IDBErrorName };
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IDBErrorName = void 0;
4
+ /** Messages of errors thrown by the IndexedDB provider itself. `classifyIDBError` maps `UNAVAILABLE` to `StorageErrorClass.UNAVAILABLE`. */
5
+ const IDBErrorMessage = {
6
+ UNAVAILABLE: 'IndexedDB is not available in this environment',
7
+ HEAL_EXHAUSTED: 'IndexedDB heal budget exhausted',
8
+ };
9
+ /** Names of errors thrown by the IndexedDB provider itself. `classifyIDBError` maps `HEAL_EXHAUSTED` to `StorageErrorClass.UNAVAILABLE`. */
10
+ const IDBErrorName = {
11
+ HEAL_EXHAUSTED: 'IDBHealExhaustedError',
12
+ };
13
+ exports.IDBErrorName = IDBErrorName;
14
+ exports.default = IDBErrorMessage;
@@ -40,7 +40,8 @@ const IDB = __importStar(require("idb-keyval"));
40
40
  const utils_1 = __importDefault(require("../../../utils"));
41
41
  const createStore_1 = __importDefault(require("./createStore"));
42
42
  const classifyError_1 = __importDefault(require("./classifyError"));
43
- const isIndexedDBAvailable_1 = __importStar(require("./isIndexedDBAvailable"));
43
+ const errorMessages_1 = __importDefault(require("./errorMessages"));
44
+ const isIndexedDBAvailable_1 = __importDefault(require("./isIndexedDBAvailable"));
44
45
  const DB_NAME = 'OnyxDB';
45
46
  const STORE_NAME = 'keyvaluepairs';
46
47
  /**
@@ -71,7 +72,7 @@ const provider = {
71
72
  */
72
73
  init() {
73
74
  if (!(0, isIndexedDBAvailable_1.default)()) {
74
- throw new Error(`IDBKeyVal store could not be created: ${isIndexedDBAvailable_1.INDEXED_DB_UNAVAILABLE_MESSAGE}`);
75
+ throw new Error(`IDBKeyVal store could not be created: ${errorMessages_1.default.UNAVAILABLE}`);
75
76
  }
76
77
  const newIdbKeyValStore = (0, createStore_1.default)(DB_NAME, STORE_NAME);
77
78
  if (newIdbKeyValStore == null) {
@@ -1,9 +1,6 @@
1
- /** Matched by `classifyIDBError` so a guarded failure classifies as UNAVAILABLE like an unguarded one. */
2
- declare const INDEXED_DB_UNAVAILABLE_MESSAGE = "indexedDB is not available in this environment";
3
1
  /**
4
2
  * `typeof` instead of `indexedDB === undefined`: on WebKit the global is undeclared in private tabs and
5
3
  * Lockdown Mode, so reading it throws a `ReferenceError` instead of evaluating to `undefined`.
6
4
  */
7
5
  declare function isIndexedDBAvailable(): boolean;
8
6
  export default isIndexedDBAvailable;
9
- export { INDEXED_DB_UNAVAILABLE_MESSAGE };
@@ -1,9 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.INDEXED_DB_UNAVAILABLE_MESSAGE = void 0;
4
- /** Matched by `classifyIDBError` so a guarded failure classifies as UNAVAILABLE like an unguarded one. */
5
- const INDEXED_DB_UNAVAILABLE_MESSAGE = 'indexedDB is not available in this environment';
6
- exports.INDEXED_DB_UNAVAILABLE_MESSAGE = INDEXED_DB_UNAVAILABLE_MESSAGE;
7
3
  /**
8
4
  * `typeof` instead of `indexedDB === undefined`: on WebKit the global is undeclared in private tabs and
9
5
  * Lockdown Mode, so reading it throws a `ReferenceError` instead of evaluating to `undefined`.
@@ -1,10 +1,44 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  const react_native_nitro_sqlite_1 = require("react-native-nitro-sqlite");
7
40
  const react_native_device_info_1 = require("react-native-device-info");
41
+ const Logger = __importStar(require("../../Logger"));
8
42
  const utils_1 = __importDefault(require("../../utils"));
9
43
  const classifySQLiteError_1 = __importDefault(require("./classifySQLiteError"));
10
44
  const DB_NAME = 'OnyxDB';
@@ -14,6 +48,28 @@ const COMPILE_OPTIONS = {
14
48
  };
15
49
  /** SQLite's maximum number of bound parameters per statement, read once from PRAGMA compile_options in init(). */
16
50
  let sqliteMaxVariableNumber = SQLITE_MAX_VARIABLE_NUMBER;
51
+ let readStore;
52
+ let getItemStatement;
53
+ let setItemStatement;
54
+ let removeItemStatement;
55
+ let pendingWrite = Promise.resolve();
56
+ let pendingWriteCount = 0;
57
+ function trackWrite(operation) {
58
+ // All writes use the same NitroSQLite connection queue and finish in call order.
59
+ // A failed write still releases later reads; its caller receives the rejection.
60
+ pendingWriteCount++;
61
+ pendingWrite = operation.then(() => {
62
+ pendingWriteCount--;
63
+ }, () => {
64
+ pendingWriteCount--;
65
+ });
66
+ return operation;
67
+ }
68
+ function readAfterPendingWrites(read) {
69
+ // Capture the writes already queued when the read was requested. New writes can run
70
+ // alongside this read on the separate connection once those writes have committed.
71
+ return pendingWriteCount === 0 ? read() : pendingWrite.then(read);
72
+ }
17
73
  /**
18
74
  * Returns the value of a compile option from the rows returned by `PRAGMA compile_options`.
19
75
  * For flag-only options (e.g. `ENABLE_FTS3`), returns an empty string when the option is present.
@@ -61,13 +117,16 @@ const provider = {
61
117
  */
62
118
  name: 'SQLiteProvider',
63
119
  /**
64
- * Classifies a SQLite write failure into the shared storage taxonomy.
120
+ * Classifies a SQLite failure into the shared storage taxonomy.
65
121
  */
66
122
  classifyError: classifySQLiteError_1.default,
67
123
  /**
68
124
  * Initializes the storage provider
69
125
  */
70
126
  init() {
127
+ if (provider.store) {
128
+ return;
129
+ }
71
130
  provider.store = (0, react_native_nitro_sqlite_1.open)({ name: DB_NAME });
72
131
  provider.store.execute('CREATE TABLE IF NOT EXISTS keyvaluepairs (record_key TEXT NOT NULL PRIMARY KEY , valueJSON JSON NOT NULL) WITHOUT ROWID;');
73
132
  // All of the 3 pragmas below were suggested by SQLite team.
@@ -80,12 +139,45 @@ const provider = {
80
139
  // stores it in a global variable, that is going to be used during runtime.
81
140
  const maxVariableNumber = Number(getCompileOptionValue(compileOptionsResult, COMPILE_OPTIONS.MAX_VARIABLE_NUMBER));
82
141
  sqliteMaxVariableNumber = maxVariableNumber > 0 ? maxVariableNumber : SQLITE_MAX_VARIABLE_NUMBER;
142
+ setItemStatement = provider.store.prepare('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);');
143
+ removeItemStatement = provider.store.prepare('DELETE FROM keyvaluepairs WHERE record_key = ?;');
144
+ let independentReader;
145
+ try {
146
+ // WAL lets the read-only handle read committed data while the writer is active.
147
+ independentReader = (0, react_native_nitro_sqlite_1.open)({ name: DB_NAME, connection: 'independent', readOnly: true });
148
+ // Cache size is connection-local. WAL is shared, and synchronous only controls writes.
149
+ independentReader.execute('PRAGMA CACHE_SIZE=-8000;');
150
+ // Reads are not retried by Onyx; allow temporary WAL locks to clear.
151
+ independentReader.execute('PRAGMA busy_timeout=2000;');
152
+ getItemStatement = independentReader.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;');
153
+ readStore = independentReader;
154
+ }
155
+ catch (error) {
156
+ // SQLite can report failures on first use, so protect all reader setup.
157
+ Logger.logInfo('SQLite independent reader unavailable, reading through the writer.', {
158
+ error: error instanceof Error ? error.message : String(error),
159
+ });
160
+ try {
161
+ independentReader === null || independentReader === void 0 ? void 0 : independentReader.close();
162
+ }
163
+ catch (cleanupError) {
164
+ // Reader cleanup must not prevent the writer from serving reads.
165
+ Logger.logInfo('SQLite independent reader cleanup failed.', {
166
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
167
+ });
168
+ }
169
+ readStore = provider.store;
170
+ getItemStatement = provider.store.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;');
171
+ }
172
+ pendingWrite = Promise.resolve();
173
+ pendingWriteCount = 0;
83
174
  },
84
175
  getItem(key) {
85
- if (!provider.store) {
176
+ const statement = getItemStatement;
177
+ if (!statement) {
86
178
  throw new Error('Store is not initialized!');
87
179
  }
88
- return provider.store.executeAsync('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;', [key]).then(({ rows }) => {
180
+ return readAfterPendingWrites(() => statement.executeAsync([key])).then(({ rows }) => {
89
181
  if (!rows || (rows === null || rows === void 0 ? void 0 : rows.length) === 0) {
90
182
  return null;
91
183
  }
@@ -97,21 +189,27 @@ const provider = {
97
189
  });
98
190
  },
99
191
  multiGet(keys) {
100
- if (!provider.store) {
192
+ if (!readStore) {
101
193
  throw new Error('Store is not initialized!');
102
194
  }
103
195
  if (keys.length === 0) {
104
196
  return Promise.resolve([]);
105
197
  }
106
198
  const keyChunks = utils_1.default.chunkArray(keys, sqliteMaxVariableNumber);
107
- return Promise.all(keyChunks.map((keyChunk) => {
108
- if (!provider.store) {
109
- throw new Error('Store is not initialized!');
199
+ const reader = readStore;
200
+ const commands = keyChunks.map((keyChunk) => ({
201
+ query: `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${keyChunk.map(() => '?').join(',')});`,
202
+ params: keyChunk,
203
+ }));
204
+ const readChunks = () => {
205
+ if (commands.length === 1) {
206
+ const { query, params } = commands[0];
207
+ return reader.executeAsync(query, params).then((result) => [result]);
110
208
  }
111
- const placeholders = keyChunk.map(() => '?').join(',');
112
- const command = `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${placeholders});`;
113
- return provider.store.executeAsync(command, keyChunk);
114
- })).then((results) => {
209
+ // Keep all chunks on one snapshot even if the writer commits between queries.
210
+ return reader.transaction((tx) => Promise.all(commands.map(({ query, params }) => tx.executeAsync(query, params))));
211
+ };
212
+ return readAfterPendingWrites(readChunks).then((results) => {
115
213
  const result = results.flatMap(({ rows }) => { var _a;
116
214
  // eslint-disable-next-line no-underscore-dangle
117
215
  return (_a = rows === null || rows === void 0 ? void 0 : rows._array.map((row) => [row.record_key, JSON.parse(row.valueJSON)])) !== null && _a !== void 0 ? _a : []; });
@@ -119,10 +217,10 @@ const provider = {
119
217
  });
120
218
  },
121
219
  setItem(key, value) {
122
- if (!provider.store) {
220
+ if (!setItemStatement) {
123
221
  throw new Error('Store is not initialized!');
124
222
  }
125
- return provider.store.executeAsync('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);', [key, JSON.stringify(value)]).then(() => undefined);
223
+ return trackWrite(setItemStatement.executeAsync([key, JSON.stringify(value)]).then(() => undefined));
126
224
  },
127
225
  multiSet(pairs) {
128
226
  if (!provider.store) {
@@ -133,7 +231,7 @@ const provider = {
133
231
  if (utils_1.default.isEmptyObject(params)) {
134
232
  return Promise.resolve();
135
233
  }
136
- return provider.store.executeBatchAsync([{ query, params }]).then(() => undefined);
234
+ return trackWrite(provider.store.executeBatchAsync([{ query, params }]).then(() => undefined));
137
235
  },
138
236
  multiMerge(pairs) {
139
237
  if (!provider.store) {
@@ -173,31 +271,31 @@ const provider = {
173
271
  if (replaceQueryArguments.length > 0) {
174
272
  commands.push({ query: replaceQuery, params: replaceQueryArguments });
175
273
  }
176
- return provider.store.executeBatchAsync(commands).then(() => undefined);
274
+ return trackWrite(provider.store.executeBatchAsync(commands).then(() => undefined));
177
275
  },
178
276
  mergeItem(key, change, replaceNullPatches) {
179
277
  // Since Onyx already merged the existing value with the changes, we can just set the value directly.
180
278
  return provider.multiMerge([[key, change, replaceNullPatches]]);
181
279
  },
182
280
  getAllKeys() {
183
- if (!provider.store) {
281
+ if (!readStore) {
184
282
  throw new Error('Store is not initialized!');
185
283
  }
186
- return provider.store.executeAsync('SELECT record_key FROM keyvaluepairs;').then(({ rows }) => {
284
+ const reader = readStore;
285
+ return readAfterPendingWrites(() => reader.executeAsync('SELECT record_key FROM keyvaluepairs;')).then(({ rows }) => {
187
286
  // eslint-disable-next-line no-underscore-dangle
188
287
  const result = rows === null || rows === void 0 ? void 0 : rows._array.map((row) => row.record_key);
189
288
  return (result !== null && result !== void 0 ? result : []);
190
289
  });
191
290
  },
192
291
  getAll() {
193
- if (!provider.store) {
292
+ if (!readStore) {
194
293
  throw new Error('Store is not initialized!');
195
294
  }
196
295
  // Aggregate the whole table into a single JSON string in SQLite so we only run JSON.parse
197
296
  // once, instead of returning every row and parsing each one individually in JavaScript.
198
- return provider.store
199
- .executeAsync('SELECT json_group_array(json_array(record_key, json(valueJSON))) AS aggregated FROM keyvaluepairs;')
200
- .then(({ rows }) => {
297
+ const reader = readStore;
298
+ return readAfterPendingWrites(() => reader.executeAsync('SELECT json_group_array(json_array(record_key, json(valueJSON))) AS aggregated FROM keyvaluepairs;')).then(({ rows }) => {
201
299
  var _a;
202
300
  const aggregated = (_a = rows === null || rows === void 0 ? void 0 : rows.item(0)) === null || _a === void 0 ? void 0 : _a.aggregated;
203
301
  if (aggregated == null) {
@@ -207,10 +305,10 @@ const provider = {
207
305
  });
208
306
  },
209
307
  removeItem(key) {
210
- if (!provider.store) {
308
+ if (!removeItemStatement) {
211
309
  throw new Error('Store is not initialized!');
212
310
  }
213
- return provider.store.executeAsync('DELETE FROM keyvaluepairs WHERE record_key = ?;', [key]).then(() => undefined);
311
+ return trackWrite(removeItemStatement.executeAsync([key]).then(() => undefined));
214
312
  },
215
313
  removeItems(keys) {
216
314
  if (!provider.store) {
@@ -226,19 +324,24 @@ const provider = {
226
324
  };
227
325
  if (keyChunks.length === 1) {
228
326
  const keyChunk = keyChunks[0];
229
- return provider.store.executeAsync(buildDeleteQuery(keyChunk), keyChunk).then(() => undefined);
327
+ return trackWrite(provider.store.executeAsync(buildDeleteQuery(keyChunk), keyChunk).then(() => undefined));
230
328
  }
231
- const commands = keyChunks.map((keyChunk) => ({
232
- query: buildDeleteQuery(keyChunk),
233
- params: keyChunk,
234
- }));
235
- return provider.store.executeBatchAsync(commands).then(() => undefined);
329
+ const firstChunk = keyChunks[0];
330
+ const lastChunk = keyChunks[keyChunks.length - 1];
331
+ const hasSmallerLastChunk = lastChunk.length < firstChunk.length;
332
+ // NitroSQLite reuses one prepared statement for each grouped parameter set.
333
+ // Only the final partial chunk needs a different number of placeholders.
334
+ const commands = [{ query: buildDeleteQuery(firstChunk), params: hasSmallerLastChunk ? keyChunks.slice(0, -1) : keyChunks }];
335
+ if (hasSmallerLastChunk) {
336
+ commands.push({ query: buildDeleteQuery(lastChunk), params: lastChunk });
337
+ }
338
+ return trackWrite(provider.store.executeBatchAsync(commands).then(() => undefined));
236
339
  },
237
340
  clear() {
238
341
  if (!provider.store) {
239
342
  throw new Error('Store is not initialized!');
240
343
  }
241
- return provider.store.executeAsync('DELETE FROM keyvaluepairs;', []).then(() => undefined);
344
+ return trackWrite(provider.store.executeAsync('DELETE FROM keyvaluepairs;', []).then(() => undefined));
242
345
  },
243
346
  getDatabaseSize() {
244
347
  if (!provider.store) {
@@ -1,10 +1,10 @@
1
1
  import type { ValueOf } from 'type-fest';
2
2
  import { StorageErrorClass } from '../errors';
3
3
  /**
4
- * Classifies a SQLite write failure into the shared storage taxonomy (lib/storage/errors.ts).
4
+ * Classifies a SQLite failure into the shared storage taxonomy (lib/storage/errors.ts).
5
5
  * This is the SQLite engine's own dialect — it is NOT shared with other engines. It lives in a
6
- * standalone module (no `react-native-nitro-sqlite` import) so it can be reused without pulling in
7
- * native dependencies.
6
+ * standalone module that uses NitroSQLite's native-free errors export without initializing the
7
+ * native module.
8
8
  *
9
9
  * SQLite surfaces fewer distinct write-failure shapes than IndexedDB. As telemetry from the UNKNOWN
10
10
  * bucket (see OnyxUtils.retryOperation) reveals recurring native errors, add matchers here.
@@ -1,24 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- const errors_1 = require("../errors");
3
+ const errors_1 = require("react-native-nitro-sqlite/errors");
4
+ const errors_2 = require("../errors");
4
5
  /**
5
- * Classifies a SQLite write failure into the shared storage taxonomy (lib/storage/errors.ts).
6
+ * Classifies a SQLite failure into the shared storage taxonomy (lib/storage/errors.ts).
6
7
  * This is the SQLite engine's own dialect — it is NOT shared with other engines. It lives in a
7
- * standalone module (no `react-native-nitro-sqlite` import) so it can be reused without pulling in
8
- * native dependencies.
8
+ * standalone module that uses NitroSQLite's native-free errors export without initializing the
9
+ * native module.
9
10
  *
10
11
  * SQLite surfaces fewer distinct write-failure shapes than IndexedDB. As telemetry from the UNKNOWN
11
12
  * bucket (see OnyxUtils.retryOperation) reveals recurring native errors, add matchers here.
12
13
  */
13
14
  function classifySQLiteError(error) {
14
- const { message } = (0, errors_1.getErrorParts)(error);
15
+ // If the database cannot open, initialization must fall back to memory storage.
16
+ if (isDatabaseOpenError(error)) {
17
+ return errors_2.StorageErrorClass.UNAVAILABLE;
18
+ }
19
+ const { message } = (0, errors_2.getErrorParts)(error);
15
20
  // Device disk full.
16
21
  if (message.includes('database or disk is full')) {
17
- return errors_1.StorageErrorClass.CAPACITY;
22
+ return errors_2.StorageErrorClass.CAPACITY;
18
23
  }
19
24
  if (message.includes('disk i/o error') || message.includes('unable to open database file')) {
20
- return errors_1.StorageErrorClass.DISK_PRESSURE;
25
+ return errors_2.StorageErrorClass.DISK_PRESSURE;
21
26
  }
22
- return errors_1.StorageErrorClass.UNKNOWN;
27
+ return errors_2.StorageErrorClass.UNKNOWN;
28
+ }
29
+ function isDatabaseOpenError(error) {
30
+ return typeof error === 'object' && error !== null && 'type' in error && error.type === errors_1.NitroSQLiteExceptionType.DatabaseCannotBeOpened;
23
31
  }
24
32
  exports.default = classifySQLiteError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-onyx",
3
- "version": "3.0.119",
3
+ "version": "3.0.121",
4
4
  "author": "Expensify, Inc.",
5
5
  "homepage": "https://expensify.com",
6
6
  "description": "State management for React Native",
@@ -75,7 +75,7 @@
75
75
  "@typescript-eslint/eslint-plugin": "^8.51.0",
76
76
  "@typescript-eslint/parser": "^8.51.0",
77
77
  "@vercel/ncc": "0.38.1",
78
- "better-sqlite3": "^12.10.0",
78
+ "better-sqlite3": "^12.11.1",
79
79
  "date-fns": "^4.1.0",
80
80
  "eslint": "^9.39.2",
81
81
  "eslint-config-expensify": "2.0.108",
@@ -97,7 +97,7 @@
97
97
  "react-native": "0.76.3",
98
98
  "react-native-device-info": "^10.3.0",
99
99
  "react-native-nitro-modules": "^0.35.0",
100
- "react-native-nitro-sqlite": "^9.6.0",
100
+ "react-native-nitro-sqlite": "10.2.0",
101
101
  "react-test-renderer": "18.2.0",
102
102
  "reassure": "1.4.0",
103
103
  "ts-node": "^10.9.2",
@@ -110,7 +110,7 @@
110
110
  "react-native": ">=0.75.0",
111
111
  "react-native-device-info": "^10.3.0",
112
112
  "react-native-nitro-modules": ">=0.35.0",
113
- "react-native-nitro-sqlite": "^9.6.0"
113
+ "react-native-nitro-sqlite": "^10.2.0"
114
114
  },
115
115
  "peerDependenciesMeta": {
116
116
  "idb-keyval": {