react-native-onyx 3.0.87 → 3.0.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +0 -2
- package/README.md +2 -12
- package/dist/CircuitBreaker/AbstractCircuitBreaker.d.ts +91 -0
- package/dist/CircuitBreaker/AbstractCircuitBreaker.js +166 -0
- package/dist/CircuitBreaker/types.d.ts +38 -0
- package/dist/CircuitBreaker/types.js +24 -0
- package/dist/Onyx.d.ts +0 -2
- package/dist/Onyx.js +0 -2
- package/dist/OnyxConnectionManager.js +18 -15
- package/dist/OnyxUtils.d.ts +11 -5
- package/dist/OnyxUtils.js +92 -82
- package/dist/StateMachine.d.ts +45 -0
- package/dist/StateMachine.js +37 -0
- package/dist/StorageCircuitBreaker.d.ts +66 -0
- package/dist/StorageCircuitBreaker.js +177 -0
- package/dist/storage/__mocks__/index.d.ts +10 -0
- package/dist/storage/__mocks__/index.js +15 -0
- package/dist/storage/errors.d.ts +34 -0
- package/dist/storage/errors.js +41 -0
- package/dist/storage/index.js +5 -0
- package/dist/storage/providers/IDBKeyValProvider/classifyError.d.ts +9 -0
- package/dist/storage/providers/IDBKeyValProvider/classifyError.js +37 -0
- package/dist/storage/providers/IDBKeyValProvider/createStore.js +40 -57
- package/dist/storage/providers/IDBKeyValProvider/index.js +5 -0
- package/dist/storage/providers/MemoryOnlyProvider.js +5 -0
- package/dist/storage/providers/NoopProvider.js +5 -0
- package/dist/storage/providers/SQLiteProvider.js +5 -0
- package/dist/storage/providers/classifySQLiteError.d.ts +13 -0
- package/dist/storage/providers/classifySQLiteError.js +21 -0
- package/dist/storage/providers/types.d.ts +8 -0
- package/dist/types.d.ts +11 -27
- package/dist/useOnyx.js +0 -2
- package/package.json +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const AbstractCircuitBreaker_1 = __importDefault(require("./CircuitBreaker/AbstractCircuitBreaker"));
|
|
40
|
+
const Logger = __importStar(require("./Logger"));
|
|
41
|
+
/** Rolling window over which capacity failures are counted, and how long a trip stays open. */
|
|
42
|
+
const ROLLING_WINDOW_MS = 60 * 1000;
|
|
43
|
+
/** Capacity failures within the window above which the breaker trips (storm backstop). */
|
|
44
|
+
const FAILURE_THRESHOLD = 50;
|
|
45
|
+
/** Consecutive no-progress evictions (evict -> still capacity failure) above which the breaker trips. */
|
|
46
|
+
const NO_PROGRESS_CAP = 5;
|
|
47
|
+
/**
|
|
48
|
+
* Process-scoped circuit breaker for storage CAPACITY failures.
|
|
49
|
+
*
|
|
50
|
+
* The per-operation retry budget in `OnyxUtils.retryOperation` cannot stop a session-level storm:
|
|
51
|
+
* each evict -> OnyxDerived recompute -> new write starts its own fresh budget, so a full disk or
|
|
52
|
+
* exhausted quota can drive tens of thousands of evict+retry cycles that never make progress and
|
|
53
|
+
* freeze the app. This breaker is the session-level brake — `retryOperation` consults it before
|
|
54
|
+
* every eviction.
|
|
55
|
+
*
|
|
56
|
+
* It is ONE circuit (closed/open/half-open) fed by TWO failure-counting policies, both evaluated in
|
|
57
|
+
* {@link recordFailureInClosed}. It trips when EITHER:
|
|
58
|
+
* - capacity failures within {@link ROLLING_WINDOW_MS} exceed {@link FAILURE_THRESHOLD}, or
|
|
59
|
+
* - {@link NO_PROGRESS_CAP} consecutive evictions are each immediately followed by another capacity
|
|
60
|
+
* failure (the eviction freed nothing the next write could use — a no-progress cycle). This is a
|
|
61
|
+
* cheap proxy for `getDatabaseSize()`, which is costly and only reports origin-wide usage.
|
|
62
|
+
*
|
|
63
|
+
* Keeping both policies inside a single state machine — rather than composing two independent breakers
|
|
64
|
+
* — is deliberate: two breakers each with their own open/half-open/probe latch cannot share one
|
|
65
|
+
* coherent circuit state without races (stranded half-open probes, storms uncounted while the other
|
|
66
|
+
* probes, cross-contaminated counters).
|
|
67
|
+
*
|
|
68
|
+
* On trip it emits exactly ONE alert per incident (across reopen cycles). After {@link ROLLING_WINDOW_MS}
|
|
69
|
+
* the circuit moves to half-open and admits a single eviction+retry probe; a successful probe closes
|
|
70
|
+
* the circuit, a failed probe reopens it for another window.
|
|
71
|
+
*/
|
|
72
|
+
class StorageCircuitBreaker extends AbstractCircuitBreaker_1.default {
|
|
73
|
+
constructor() {
|
|
74
|
+
super({
|
|
75
|
+
resetTimeoutMs: ROLLING_WINDOW_MS,
|
|
76
|
+
onTrip: (reason) => this.handleTrip(reason),
|
|
77
|
+
onClose: () => {
|
|
78
|
+
this.hasTripped = false;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
/** Timestamps of capacity failures still inside the rolling window. */
|
|
82
|
+
this.failureTimestamps = [];
|
|
83
|
+
/** Consecutive evictions that each failed to free usable space. */
|
|
84
|
+
this.consecutiveNoProgress = 0;
|
|
85
|
+
/** Set when an eviction's retry is pending, so the next capacity failure counts as no-progress. */
|
|
86
|
+
this.evictionAwaitingResult = false;
|
|
87
|
+
/** Guards the single alert per incident (the open→half-open→open cycle must not re-alert). */
|
|
88
|
+
this.hasTripped = false;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Record a CAPACITY failure. Call once per capacity failure in `retryOperation`, BEFORE deciding
|
|
92
|
+
* whether to evict. Returns `true` when the breaker is open and eviction must not proceed.
|
|
93
|
+
*/
|
|
94
|
+
recordCapacityFailure() {
|
|
95
|
+
// We only get here when isAllowed() admitted this caller. In half-open that means THIS is the
|
|
96
|
+
// single recovery probe: the eviction+retry that follows is the actual test, so the capacity
|
|
97
|
+
// failure that triggered it must not re-trip the circuit. Let it proceed; the probe's outcome
|
|
98
|
+
// is the verdict — recordWriteSuccess (retry landed) closes, recordProbeFailure (retry failed,
|
|
99
|
+
// re-entering retryOperation) reopens.
|
|
100
|
+
if (this.peekState() === 'half-open') {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return this.recordFailure();
|
|
104
|
+
}
|
|
105
|
+
/** Record that `retryOperation` just evicted a key, so the next capacity failure counts as no-progress. */
|
|
106
|
+
recordEviction() {
|
|
107
|
+
this.evictionAwaitingResult = true;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Record that a storage write succeeded. Fires on EVERY successful write, so it must only act on the
|
|
111
|
+
* one that carries capacity information: a write whose eviction was awaiting its verdict. Such a
|
|
112
|
+
* success means an eviction's retry actually landed — usable space was freed. In half-open that is
|
|
113
|
+
* the recovery probe succeeding (closes the circuit); in closed it clears the no-progress streak. A
|
|
114
|
+
* plain write that happens to succeed proves nothing about capacity and is a no-op (the common case).
|
|
115
|
+
*/
|
|
116
|
+
recordWriteSuccess() {
|
|
117
|
+
if (!this.evictionAwaitingResult) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.recordSuccess();
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Record that the half-open recovery probe failed. `retryOperation` calls this when a write is
|
|
124
|
+
* rejected while a probe is in flight — the storage is still full, so reopen for another window.
|
|
125
|
+
* No-op while fully open (recordFailure short-circuits) and harmless while closed.
|
|
126
|
+
*/
|
|
127
|
+
recordProbeFailure() {
|
|
128
|
+
this.recordFailure();
|
|
129
|
+
}
|
|
130
|
+
/** Wipe all state back to a fresh closed circuit. Process-scoped, so reset between tests/sessions. */
|
|
131
|
+
reset() {
|
|
132
|
+
this.hardReset();
|
|
133
|
+
this.hasTripped = false;
|
|
134
|
+
}
|
|
135
|
+
recordFailureInClosed() {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
this.failureTimestamps = this.failureTimestamps.filter((timestamp) => now - timestamp < ROLLING_WINDOW_MS);
|
|
138
|
+
// A fresh storm (nothing left in the window) resets the no-progress tracking so a stale eviction
|
|
139
|
+
// from an earlier, unrelated incident can't be miscounted as no-progress for this one.
|
|
140
|
+
if (this.failureTimestamps.length === 0) {
|
|
141
|
+
this.consecutiveNoProgress = 0;
|
|
142
|
+
this.evictionAwaitingResult = false;
|
|
143
|
+
}
|
|
144
|
+
// We evicted on the previous cycle and we're back here with another capacity failure, so that
|
|
145
|
+
// eviction freed no usable space.
|
|
146
|
+
if (this.evictionAwaitingResult) {
|
|
147
|
+
this.consecutiveNoProgress += 1;
|
|
148
|
+
this.evictionAwaitingResult = false;
|
|
149
|
+
}
|
|
150
|
+
this.failureTimestamps.push(now);
|
|
151
|
+
if (this.failureTimestamps.length > FAILURE_THRESHOLD) {
|
|
152
|
+
return `${this.failureTimestamps.length} capacity failures within ${ROLLING_WINDOW_MS / 1000}s`;
|
|
153
|
+
}
|
|
154
|
+
if (this.consecutiveNoProgress >= NO_PROGRESS_CAP) {
|
|
155
|
+
return `${this.consecutiveNoProgress} consecutive evictions freed no usable space`;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
recordSuccessInClosed() {
|
|
160
|
+
// An eviction's retry succeeded: the eviction made progress, so break the no-progress streak.
|
|
161
|
+
this.consecutiveNoProgress = 0;
|
|
162
|
+
this.evictionAwaitingResult = false;
|
|
163
|
+
}
|
|
164
|
+
resetFailureState() {
|
|
165
|
+
this.failureTimestamps = [];
|
|
166
|
+
this.consecutiveNoProgress = 0;
|
|
167
|
+
this.evictionAwaitingResult = false;
|
|
168
|
+
}
|
|
169
|
+
handleTrip(reason) {
|
|
170
|
+
if (this.hasTripped) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.hasTripped = true;
|
|
174
|
+
Logger.logAlert(`Storage circuit breaker tripped: ${reason}. Halting eviction/retry for ${ROLLING_WINDOW_MS / 1000}s to stop a storage failure storm.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
exports.default = new StorageCircuitBreaker();
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
declare const StorageMock: {
|
|
2
2
|
init: jest.Mock<void, [], any>;
|
|
3
|
+
classifyError: jest.Mock<import("type-fest").ValueOf<{
|
|
4
|
+
readonly TRANSIENT: "transient";
|
|
5
|
+
readonly CAPACITY: "capacity";
|
|
6
|
+
readonly INVALID_DATA: "invalidData";
|
|
7
|
+
readonly FATAL: "fatal";
|
|
8
|
+
readonly UNKNOWN: "unknown";
|
|
9
|
+
}>, [error: unknown], any>;
|
|
10
|
+
getStorageProvider: jest.Mock<import("../providers/types").default<{
|
|
11
|
+
[x: string]: unknown;
|
|
12
|
+
}>, [], any>;
|
|
3
13
|
getItem: jest.Mock<Promise<unknown>, [key: any], any>;
|
|
4
14
|
multiGet: jest.Mock<Promise<import("../providers/types").StorageKeyValuePair[]>, [keys: import("../providers/types").StorageKeyList], any>;
|
|
5
15
|
setItem: jest.Mock<Promise<void>, [key: any, value: unknown], any>;
|
|
@@ -32,12 +32,27 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
35
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
39
|
const MemoryOnlyProvider_1 = __importStar(require("../providers/MemoryOnlyProvider"));
|
|
40
|
+
const classifyError_1 = __importDefault(require("../providers/IDBKeyValProvider/classifyError"));
|
|
41
|
+
const classifySQLiteError_1 = __importDefault(require("../providers/classifySQLiteError"));
|
|
42
|
+
const errors_1 = require("../errors");
|
|
37
43
|
const init = jest.fn(MemoryOnlyProvider_1.default.init);
|
|
38
44
|
init();
|
|
45
|
+
// Tests exercise retryOperation against both IndexedDB- and SQLite-shaped errors, so the mock facade
|
|
46
|
+
// classifies with each engine's real (native-dep-free) classifier in turn. Mirrors how the real facade
|
|
47
|
+
// delegates to the active provider; here we cover both engines since one mock stands in for all.
|
|
48
|
+
const classifyError = (error) => {
|
|
49
|
+
const idbClass = (0, classifyError_1.default)(error);
|
|
50
|
+
return idbClass === errors_1.StorageErrorClass.UNKNOWN ? (0, classifySQLiteError_1.default)(error) : idbClass;
|
|
51
|
+
};
|
|
39
52
|
const StorageMock = {
|
|
40
53
|
init,
|
|
54
|
+
classifyError: jest.fn(classifyError),
|
|
55
|
+
getStorageProvider: jest.fn(() => MemoryOnlyProvider_1.default),
|
|
41
56
|
getItem: jest.fn(MemoryOnlyProvider_1.default.getItem),
|
|
42
57
|
multiGet: jest.fn(MemoryOnlyProvider_1.default.multiGet),
|
|
43
58
|
setItem: jest.fn(MemoryOnlyProvider_1.default.setItem),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vocabulary for storage write failures. The *classes* are engine-agnostic; the *matching*
|
|
3
|
+
* is not — each storage provider knows its own error dialect and owns its classifier (see each
|
|
4
|
+
* provider's `classifyError`). This module deliberately holds NO string matchers: it is the common
|
|
5
|
+
* taxonomy the two reacting layers agree on, while the per-engine knowledge lives with the engine.
|
|
6
|
+
*
|
|
7
|
+
* - the connection layer (`createStore`) recovers TRANSIENT and FATAL errors by reopening the DB, and
|
|
8
|
+
* - the operation layer (`OnyxUtils.retryOperation`) recovers CAPACITY by eviction and retries UNKNOWN.
|
|
9
|
+
*
|
|
10
|
+
* This module has no Onyx dependencies (and no engine dependencies) so it can live in the storage
|
|
11
|
+
* layer, and be imported by every provider, without creating an import cycle.
|
|
12
|
+
*/
|
|
13
|
+
declare const StorageErrorClass: {
|
|
14
|
+
/** Connection/transport failure (stale connection). Owner: connection layer — reopen + retry once. */
|
|
15
|
+
readonly TRANSIENT: "transient";
|
|
16
|
+
/** Quota exceeded / disk full. Owner: operation layer — evict and retry. */
|
|
17
|
+
readonly CAPACITY: "capacity";
|
|
18
|
+
/** Non-serializable payload. Never retriable — the same data will always fail. */
|
|
19
|
+
readonly INVALID_DATA: "invalidData";
|
|
20
|
+
/** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */
|
|
21
|
+
readonly FATAL: "fatal";
|
|
22
|
+
/** Unmatched by the active provider. Owner: operation layer — bounded retry, and log the shape so
|
|
23
|
+
* recurring cases can be promoted into one of the classes above. */
|
|
24
|
+
readonly UNKNOWN: "unknown";
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Normalizes any thrown value into a lowercased `{name, message}` pair for matching. Shared by every
|
|
28
|
+
* provider's classifier so they all extract the error the same way.
|
|
29
|
+
*/
|
|
30
|
+
declare function getErrorParts(error: unknown): {
|
|
31
|
+
name: string;
|
|
32
|
+
message: string;
|
|
33
|
+
};
|
|
34
|
+
export { StorageErrorClass, getErrorParts };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StorageErrorClass = void 0;
|
|
4
|
+
exports.getErrorParts = getErrorParts;
|
|
5
|
+
/**
|
|
6
|
+
* Shared vocabulary for storage write failures. The *classes* are engine-agnostic; the *matching*
|
|
7
|
+
* is not — each storage provider knows its own error dialect and owns its classifier (see each
|
|
8
|
+
* provider's `classifyError`). This module deliberately holds NO string matchers: it is the common
|
|
9
|
+
* taxonomy the two reacting layers agree on, while the per-engine knowledge lives with the engine.
|
|
10
|
+
*
|
|
11
|
+
* - the connection layer (`createStore`) recovers TRANSIENT and FATAL errors by reopening the DB, and
|
|
12
|
+
* - the operation layer (`OnyxUtils.retryOperation`) recovers CAPACITY by eviction and retries UNKNOWN.
|
|
13
|
+
*
|
|
14
|
+
* This module has no Onyx dependencies (and no engine dependencies) so it can live in the storage
|
|
15
|
+
* layer, and be imported by every provider, without creating an import cycle.
|
|
16
|
+
*/
|
|
17
|
+
const StorageErrorClass = {
|
|
18
|
+
/** Connection/transport failure (stale connection). Owner: connection layer — reopen + retry once. */
|
|
19
|
+
TRANSIENT: 'transient',
|
|
20
|
+
/** Quota exceeded / disk full. Owner: operation layer — evict and retry. */
|
|
21
|
+
CAPACITY: 'capacity',
|
|
22
|
+
/** Non-serializable payload. Never retriable — the same data will always fail. */
|
|
23
|
+
INVALID_DATA: 'invalidData',
|
|
24
|
+
/** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */
|
|
25
|
+
FATAL: 'fatal',
|
|
26
|
+
/** Unmatched by the active provider. Owner: operation layer — bounded retry, and log the shape so
|
|
27
|
+
* recurring cases can be promoted into one of the classes above. */
|
|
28
|
+
UNKNOWN: 'unknown',
|
|
29
|
+
};
|
|
30
|
+
exports.StorageErrorClass = StorageErrorClass;
|
|
31
|
+
/**
|
|
32
|
+
* Normalizes any thrown value into a lowercased `{name, message}` pair for matching. Shared by every
|
|
33
|
+
* provider's classifier so they all extract the error the same way.
|
|
34
|
+
*/
|
|
35
|
+
function getErrorParts(error) {
|
|
36
|
+
var _a, _b;
|
|
37
|
+
if (error instanceof Error || (typeof DOMException !== 'undefined' && error instanceof DOMException)) {
|
|
38
|
+
return { name: ((_a = error.name) !== null && _a !== void 0 ? _a : '').toLowerCase(), message: ((_b = error.message) !== null && _b !== void 0 ? _b : '').toLowerCase() };
|
|
39
|
+
}
|
|
40
|
+
return { name: '', message: String(error !== null && error !== void 0 ? error : '').toLowerCase() };
|
|
41
|
+
}
|
package/dist/storage/index.js
CHANGED
|
@@ -76,6 +76,11 @@ const storage = {
|
|
|
76
76
|
getStorageProvider() {
|
|
77
77
|
return provider;
|
|
78
78
|
},
|
|
79
|
+
/**
|
|
80
|
+
* Classifies a write error using the active provider's own classifier. Synchronous and pure —
|
|
81
|
+
* never wrapped in tryOrDegradePerformance.
|
|
82
|
+
*/
|
|
83
|
+
classifyError: (error) => provider.classifyError(error),
|
|
79
84
|
/**
|
|
80
85
|
* Initializes all providers in the list of storage providers
|
|
81
86
|
* and enables fallback providers if necessary
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ValueOf } from 'type-fest';
|
|
2
|
+
import { StorageErrorClass } from '../../errors';
|
|
3
|
+
/**
|
|
4
|
+
* Classifies an IndexedDB write failure into the shared storage taxonomy (lib/storage/errors.ts).
|
|
5
|
+
* Matching is done on the lowercased error name and message. This is the IndexedDB engine's own
|
|
6
|
+
* dialect — it is NOT shared with other engines.
|
|
7
|
+
*/
|
|
8
|
+
declare function classifyIDBError(error: unknown): ValueOf<typeof StorageErrorClass>;
|
|
9
|
+
export default classifyIDBError;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const errors_1 = require("../../errors");
|
|
4
|
+
/**
|
|
5
|
+
* Classifies an IndexedDB write failure into the shared storage taxonomy (lib/storage/errors.ts).
|
|
6
|
+
* Matching is done on the lowercased error name and message. This is the IndexedDB engine's own
|
|
7
|
+
* dialect — it is NOT shared with other engines.
|
|
8
|
+
*/
|
|
9
|
+
function classifyIDBError(error) {
|
|
10
|
+
const { name, message } = (0, errors_1.getErrorParts)(error);
|
|
11
|
+
// Non-serializable data passed to IDBObjectStore.put — retrying is futile.
|
|
12
|
+
if (message.includes("failed to execute 'put' on 'idbobjectstore'")) {
|
|
13
|
+
return errors_1.StorageErrorClass.INVALID_DATA;
|
|
14
|
+
}
|
|
15
|
+
// Browser quota exceeded.
|
|
16
|
+
if (name.includes('quotaexceedederror') || message.includes('quotaexceedederror')) {
|
|
17
|
+
return errors_1.StorageErrorClass.CAPACITY;
|
|
18
|
+
}
|
|
19
|
+
// Backing-store corruption (Chromium LevelDB). Recoverable only via a budgeted reopen.
|
|
20
|
+
if (message.includes('internal error opening backing store')) {
|
|
21
|
+
return errors_1.StorageErrorClass.FATAL;
|
|
22
|
+
}
|
|
23
|
+
// Transient connection/transport failures — the cached connection is stale and a reopen fixes it:
|
|
24
|
+
// - InvalidStateError: connection closed between getDB() resolving and db.transaction().
|
|
25
|
+
// - AbortError: write transaction aborted (connection close / versionchange / sibling abort).
|
|
26
|
+
// - Safari/WebKit IDB server termination for backgrounded tabs.
|
|
27
|
+
if (name.includes('invalidstateerror') ||
|
|
28
|
+
name.includes('aborterror') ||
|
|
29
|
+
message.includes('connection to indexed database server lost') ||
|
|
30
|
+
message.includes('connection is closing') ||
|
|
31
|
+
// This is related to https://github.com/Expensify/react-native-onyx/pull/796 — remove this comment when #796 is merged.
|
|
32
|
+
message.includes('idb write transaction aborted without an error')) {
|
|
33
|
+
return errors_1.StorageErrorClass.TRANSIENT;
|
|
34
|
+
}
|
|
35
|
+
return errors_1.StorageErrorClass.UNKNOWN;
|
|
36
|
+
}
|
|
37
|
+
exports.default = classifyIDBError;
|
|
@@ -32,47 +32,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
35
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
39
|
const IDB = __importStar(require("idb-keyval"));
|
|
37
40
|
const Logger = __importStar(require("../../../Logger"));
|
|
41
|
+
const errors_1 = require("../../errors");
|
|
42
|
+
const classifyError_1 = __importDefault(require("./classifyError"));
|
|
38
43
|
const HEAL_ATTEMPTS_MAX = 3;
|
|
39
|
-
/**
|
|
40
|
-
* Detects the Chromium-specific IDB backing store corruption error.
|
|
41
|
-
* Fires when LevelDB files backing IndexedDB are corrupted and Chrome's
|
|
42
|
-
* internal recovery (RepairDB -> delete -> recreate) also fails.
|
|
43
|
-
*/
|
|
44
|
-
function isBackingStoreError(error) {
|
|
45
|
-
return (error instanceof Error || error instanceof DOMException) && error.message.includes('Internal error opening backing store');
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Detects Safari/WebKit IDB connection termination errors.
|
|
49
|
-
* Fires when Safari kills the IDB server process for backgrounded tabs.
|
|
50
|
-
* WebKit bugs: https://bugs.webkit.org/show_bug.cgi?id=197050, https://bugs.webkit.org/show_bug.cgi?id=201483
|
|
51
|
-
*/
|
|
52
|
-
function isConnectionLostError(error) {
|
|
53
|
-
if (!(error instanceof Error || error instanceof DOMException))
|
|
54
|
-
return false;
|
|
55
|
-
const msg = error.message.toLowerCase();
|
|
56
|
-
return msg.includes('connection to indexed database server lost') || msg.includes('connection is closing');
|
|
57
|
-
}
|
|
58
|
-
function isInvalidStateError(error) {
|
|
59
|
-
return (error instanceof Error || error instanceof DOMException) && error.name === 'InvalidStateError';
|
|
60
|
-
}
|
|
61
|
-
/** Errors that trigger a budgeted heal-and-retry in store(). */
|
|
62
|
-
function isBudgetedHealError(error) {
|
|
63
|
-
return isBackingStoreError(error) || isConnectionLostError(error);
|
|
64
|
-
}
|
|
65
|
-
function getBudgetedHealErrorLabel(error) {
|
|
66
|
-
if (isBackingStoreError(error))
|
|
67
|
-
return 'backing store';
|
|
68
|
-
if (isConnectionLostError(error))
|
|
69
|
-
return 'connection lost';
|
|
70
|
-
return 'unknown';
|
|
71
|
-
}
|
|
72
|
-
/** Union of all error types indicating a stale/dead IDB connection. Used by the visibilitychange probe. */
|
|
73
|
-
function isStaleConnectionError(error) {
|
|
74
|
-
return isInvalidStateError(error) || isBackingStoreError(error) || isConnectionLostError(error);
|
|
75
|
-
}
|
|
76
44
|
// This is a copy of the createStore function from idb-keyval, we need a custom implementation
|
|
77
45
|
// because we need to create the database manually in order to ensure that the store exists before we use it.
|
|
78
46
|
// If the store does not exist, idb-keyval will throw an error
|
|
@@ -94,7 +62,10 @@ function createStore(dbName, storeName) {
|
|
|
94
62
|
// https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/versionchange_event
|
|
95
63
|
// eslint-disable-next-line no-param-reassign
|
|
96
64
|
db.onversionchange = () => {
|
|
97
|
-
Logger.logInfo('IDB connection closing due to version change', {
|
|
65
|
+
Logger.logInfo('IDB connection closing due to version change', {
|
|
66
|
+
dbName,
|
|
67
|
+
storeName,
|
|
68
|
+
});
|
|
98
69
|
db.close();
|
|
99
70
|
dbp = undefined;
|
|
100
71
|
};
|
|
@@ -161,7 +132,11 @@ function createStore(dbName, storeName) {
|
|
|
161
132
|
Logger.logInfo('IDB visibilitychange probe: tab became visible, checking connection health', { dbName, storeName });
|
|
162
133
|
const probePromise = dbp;
|
|
163
134
|
const dropCacheIfStale = (error) => {
|
|
164
|
-
|
|
135
|
+
// A stale/dead connection surfaces as TRANSIENT (InvalidStateError, Safari connection lost)
|
|
136
|
+
// or FATAL (Chromium backing-store corruption) per the shared taxonomy (lib/storage/errors.ts).
|
|
137
|
+
const errorClass = (0, classifyError_1.default)(error);
|
|
138
|
+
const isStaleConnection = errorClass === errors_1.StorageErrorClass.TRANSIENT || errorClass === errors_1.StorageErrorClass.FATAL;
|
|
139
|
+
if (dbp !== probePromise || !isStaleConnection) {
|
|
165
140
|
return;
|
|
166
141
|
}
|
|
167
142
|
Logger.logAlert('IDB visibilitychange probe: stale connection detected, dropping cached connection', {
|
|
@@ -196,21 +171,25 @@ function createStore(dbName, storeName) {
|
|
|
196
171
|
// branch. Swallow here so the probe's separate branch doesn't surface an unhandled rejection.
|
|
197
172
|
});
|
|
198
173
|
});
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
174
|
+
// The connection layer owns recovery for connection/transport failures. It reacts per the shared
|
|
175
|
+
// error taxonomy (see lib/storage/errors.ts):
|
|
176
|
+
// - TRANSIENT (InvalidStateError, AbortError, Safari connection lost) — the cached connection is
|
|
177
|
+
// stale. Drop it and retry once with a fresh one. Unbudgeted: a single reopen is always worth it
|
|
178
|
+
// and is bounded per operation.
|
|
179
|
+
// - FATAL (Chromium backing-store corruption) — reopening can recover transient corruption, but
|
|
180
|
+
// repeating forever is futile, so the heal is budgeted (3 attempts, reset on success).
|
|
181
|
+
// Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts
|
|
182
|
+
// - CAPACITY / UNKNOWN are NOT the connection layer's responsibility — propagate to the operation
|
|
183
|
+
// layer (OnyxUtils.retryOperation) without retrying here, to avoid compounding retries.
|
|
184
|
+
// Note: concurrent store() calls share the heal budget. Under overlapping failures each caller
|
|
207
185
|
// decrements independently, so the budget may drain faster than one-per-incident. This is
|
|
208
186
|
// acceptable — same as Dexie's approach — and the budget resets on any success.
|
|
209
187
|
return (txMode, callback) => executeTransaction(txMode, callback)
|
|
210
188
|
.then(resetHealBudget)
|
|
211
189
|
.catch((error) => {
|
|
212
|
-
|
|
213
|
-
|
|
190
|
+
const errorClass = (0, classifyError_1.default)(error);
|
|
191
|
+
if (errorClass === errors_1.StorageErrorClass.TRANSIENT) {
|
|
192
|
+
Logger.logInfo('IDB transient error — dropping cached connection and retrying once', {
|
|
214
193
|
dbName,
|
|
215
194
|
storeName,
|
|
216
195
|
txMode,
|
|
@@ -219,29 +198,33 @@ function createStore(dbName, storeName) {
|
|
|
219
198
|
dbp = undefined;
|
|
220
199
|
return executeTransaction(txMode, callback).then(resetHealBudget);
|
|
221
200
|
}
|
|
222
|
-
if (
|
|
201
|
+
if (errorClass === errors_1.StorageErrorClass.FATAL && healAttemptsRemaining > 0) {
|
|
223
202
|
healAttemptsRemaining--;
|
|
224
|
-
|
|
225
|
-
Logger.logInfo(`IDB heal: ${label} error detected — dropping cached connection and reopening (${healAttemptsRemaining} attempts left)`, {
|
|
203
|
+
Logger.logInfo(`IDB heal: backing store error detected — dropping cached connection and reopening (${healAttemptsRemaining} attempts left)`, {
|
|
226
204
|
dbName,
|
|
227
205
|
storeName,
|
|
228
206
|
});
|
|
229
207
|
dbp = undefined;
|
|
230
208
|
return executeTransaction(txMode, callback).then((result) => {
|
|
231
|
-
Logger.logInfo(
|
|
209
|
+
Logger.logInfo('IDB heal: successfully recovered after backing store error', { dbName, storeName });
|
|
232
210
|
return resetHealBudget(result);
|
|
233
211
|
});
|
|
234
212
|
}
|
|
235
|
-
if (
|
|
236
|
-
Logger.logAlert(
|
|
213
|
+
if (errorClass === errors_1.StorageErrorClass.FATAL) {
|
|
214
|
+
Logger.logAlert('IDB heal: backing store error — heal budget exhausted, giving up', {
|
|
237
215
|
dbName,
|
|
238
216
|
storeName,
|
|
239
217
|
});
|
|
240
218
|
}
|
|
241
|
-
else {
|
|
242
|
-
|
|
219
|
+
else if (errorClass === errors_1.StorageErrorClass.UNKNOWN) {
|
|
220
|
+
// UNKNOWN — unexpected at this layer; record it so it's visible. CAPACITY is the
|
|
221
|
+
// expected propagation path (the operation layer owns its logging, and suppresses it
|
|
222
|
+
// entirely once the circuit breaker is open), so we do NOT log it here — doing so was a
|
|
223
|
+
// per-failed-write line that dominated the storm.
|
|
224
|
+
Logger.logInfo('IDB error not recoverable at the connection layer, propagating', {
|
|
243
225
|
dbName,
|
|
244
226
|
storeName,
|
|
227
|
+
errorClass,
|
|
245
228
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
246
229
|
});
|
|
247
230
|
}
|
|
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
39
39
|
const IDB = __importStar(require("idb-keyval"));
|
|
40
40
|
const utils_1 = __importDefault(require("../../../utils"));
|
|
41
41
|
const createStore_1 = __importDefault(require("./createStore"));
|
|
42
|
+
const classifyError_1 = __importDefault(require("./classifyError"));
|
|
42
43
|
const DB_NAME = 'OnyxDB';
|
|
43
44
|
const STORE_NAME = 'keyvaluepairs';
|
|
44
45
|
/**
|
|
@@ -60,6 +61,10 @@ const provider = {
|
|
|
60
61
|
* The name of the provider that can be printed to the logs
|
|
61
62
|
*/
|
|
62
63
|
name: 'IDBKeyValProvider',
|
|
64
|
+
/**
|
|
65
|
+
* Classifies an IndexedDB write failure into the shared storage taxonomy.
|
|
66
|
+
*/
|
|
67
|
+
classifyError: classifyError_1.default,
|
|
63
68
|
/**
|
|
64
69
|
* Initializes the storage provider
|
|
65
70
|
*/
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.setMockStore = exports.mockStore = exports.mockSet = void 0;
|
|
7
7
|
const underscore_1 = __importDefault(require("underscore"));
|
|
8
8
|
const utils_1 = __importDefault(require("../../utils"));
|
|
9
|
+
const errors_1 = require("../errors");
|
|
9
10
|
const storeInternal = {};
|
|
10
11
|
exports.mockStore = storeInternal;
|
|
11
12
|
const setInternal = (key, value) => {
|
|
@@ -21,6 +22,10 @@ const provider = {
|
|
|
21
22
|
* The name of the provider that can be printed to the logs
|
|
22
23
|
*/
|
|
23
24
|
name: 'MemoryOnlyProvider',
|
|
25
|
+
/**
|
|
26
|
+
* In-memory storage has no quota/connection failure modes, so nothing is classifiable.
|
|
27
|
+
*/
|
|
28
|
+
classifyError: () => errors_1.StorageErrorClass.UNKNOWN,
|
|
24
29
|
/**
|
|
25
30
|
* Initializes the storage provider
|
|
26
31
|
*/
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const errors_1 = require("../errors");
|
|
3
4
|
const provider = {
|
|
4
5
|
store: undefined,
|
|
5
6
|
/**
|
|
6
7
|
* The name of the provider that can be printed to the logs
|
|
7
8
|
*/
|
|
8
9
|
name: 'NoopProvider',
|
|
10
|
+
/**
|
|
11
|
+
* The noop provider never throws, so nothing is classifiable.
|
|
12
|
+
*/
|
|
13
|
+
classifyError: () => errors_1.StorageErrorClass.UNKNOWN,
|
|
9
14
|
/**
|
|
10
15
|
* Initializes the storage provider
|
|
11
16
|
*/
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const react_native_nitro_sqlite_1 = require("react-native-nitro-sqlite");
|
|
7
7
|
const react_native_device_info_1 = require("react-native-device-info");
|
|
8
8
|
const utils_1 = __importDefault(require("../../utils"));
|
|
9
|
+
const classifySQLiteError_1 = __importDefault(require("./classifySQLiteError"));
|
|
9
10
|
const DB_NAME = 'OnyxDB';
|
|
10
11
|
/**
|
|
11
12
|
* Prevents the stringifying of the object markers.
|
|
@@ -31,6 +32,10 @@ const provider = {
|
|
|
31
32
|
* The name of the provider that can be printed to the logs
|
|
32
33
|
*/
|
|
33
34
|
name: 'SQLiteProvider',
|
|
35
|
+
/**
|
|
36
|
+
* Classifies a SQLite write failure into the shared storage taxonomy.
|
|
37
|
+
*/
|
|
38
|
+
classifyError: classifySQLiteError_1.default,
|
|
34
39
|
/**
|
|
35
40
|
* Initializes the storage provider
|
|
36
41
|
*/
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ValueOf } from 'type-fest';
|
|
2
|
+
import { StorageErrorClass } from '../errors';
|
|
3
|
+
/**
|
|
4
|
+
* Classifies a SQLite write failure into the shared storage taxonomy (lib/storage/errors.ts).
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* SQLite surfaces fewer distinct write-failure shapes than IndexedDB. As telemetry from the UNKNOWN
|
|
10
|
+
* bucket (see OnyxUtils.retryOperation) reveals recurring native errors, add matchers here.
|
|
11
|
+
*/
|
|
12
|
+
declare function classifySQLiteError(error: unknown): ValueOf<typeof StorageErrorClass>;
|
|
13
|
+
export default classifySQLiteError;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const errors_1 = require("../errors");
|
|
4
|
+
/**
|
|
5
|
+
* Classifies a SQLite write failure into the shared storage taxonomy (lib/storage/errors.ts).
|
|
6
|
+
* 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.
|
|
9
|
+
*
|
|
10
|
+
* SQLite surfaces fewer distinct write-failure shapes than IndexedDB. As telemetry from the UNKNOWN
|
|
11
|
+
* bucket (see OnyxUtils.retryOperation) reveals recurring native errors, add matchers here.
|
|
12
|
+
*/
|
|
13
|
+
function classifySQLiteError(error) {
|
|
14
|
+
const { message } = (0, errors_1.getErrorParts)(error);
|
|
15
|
+
// Device disk full.
|
|
16
|
+
if (message.includes('database or disk is full')) {
|
|
17
|
+
return errors_1.StorageErrorClass.CAPACITY;
|
|
18
|
+
}
|
|
19
|
+
return errors_1.StorageErrorClass.UNKNOWN;
|
|
20
|
+
}
|
|
21
|
+
exports.default = classifySQLiteError;
|