@buoy-gg/storage 7.0.16 → 7.0.18
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/lib/commonjs/index.js +13 -0
- package/lib/commonjs/storage/components/DiffViewer/modes/ThemedSplitView.js +5 -602
- package/lib/commonjs/storage/components/StorageModalWithTabs.js +17 -1
- package/lib/commonjs/storage/hooks/useAsyncStorageKeys.js +13 -10
- package/lib/commonjs/storage/hooks/useMMKVKeys.js +28 -23
- package/lib/commonjs/storage/stores/storageEventStore.js +85 -1
- package/lib/commonjs/storage/sync/storageSyncAdapter.js +214 -6
- package/lib/commonjs/storage/utils/requiredStorageKeyScope.js +17 -0
- package/lib/commonjs/storage/utils/snapshotProvider.js +447 -0
- package/lib/commonjs/storage/utils/storageTimeTravelUtils.js +17 -3
- package/lib/module/index.js +9 -1
- package/lib/module/storage/components/DiffViewer/modes/ThemedSplitView.js +7 -603
- package/lib/module/storage/components/StorageModalWithTabs.js +17 -1
- package/lib/module/storage/hooks/useAsyncStorageKeys.js +13 -10
- package/lib/module/storage/hooks/useMMKVKeys.js +28 -23
- package/lib/module/storage/stores/storageEventStore.js +81 -0
- package/lib/module/storage/sync/storageSyncAdapter.js +215 -7
- package/lib/module/storage/utils/requiredStorageKeyScope.js +13 -0
- package/lib/module/storage/utils/snapshotProvider.js +445 -0
- package/lib/module/storage/utils/storageTimeTravelUtils.js +17 -3
- package/lib/typescript/index.d.ts +3 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/storage/components/DiffViewer/modes/ThemedSplitView.d.ts +7 -18
- package/lib/typescript/storage/components/DiffViewer/modes/ThemedSplitView.d.ts.map +1 -1
- package/lib/typescript/storage/components/StorageModalWithTabs.d.ts +8 -1
- package/lib/typescript/storage/components/StorageModalWithTabs.d.ts.map +1 -1
- package/lib/typescript/storage/hooks/useAsyncStorageKeys.d.ts.map +1 -1
- package/lib/typescript/storage/hooks/useMMKVKeys.d.ts.map +1 -1
- package/lib/typescript/storage/stores/storageEventStore.d.ts +26 -0
- package/lib/typescript/storage/stores/storageEventStore.d.ts.map +1 -1
- package/lib/typescript/storage/sync/storageSyncAdapter.d.ts +71 -5
- package/lib/typescript/storage/sync/storageSyncAdapter.d.ts.map +1 -1
- package/lib/typescript/storage/types.d.ts +9 -9
- package/lib/typescript/storage/types.d.ts.map +1 -1
- package/lib/typescript/storage/utils/requiredStorageKeyScope.d.ts +10 -0
- package/lib/typescript/storage/utils/requiredStorageKeyScope.d.ts.map +1 -0
- package/lib/typescript/storage/utils/snapshotProvider.d.ts +2 -0
- package/lib/typescript/storage/utils/snapshotProvider.d.ts.map +1 -0
- package/lib/typescript/storage/utils/storageTimeTravelUtils.d.ts.map +1 -1
- package/package.json +9 -6
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
5
5
|
import { readMany } from '../utils/asyncStorageCompat';
|
|
6
|
+
import { requiredKeyAppliesTo } from '../utils/requiredStorageKeyScope';
|
|
6
7
|
export function useAsyncStorageKeys(requiredStorageKeys = []) {
|
|
7
8
|
// State management
|
|
8
9
|
const [storageKeys, setStorageKeys] = useState([]);
|
|
@@ -48,8 +49,9 @@ export function useAsyncStorageKeys(requiredStorageKeys = []) {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
// Check if this is a required key
|
|
52
|
+
// Check if this is a required key scoped to AsyncStorage
|
|
52
53
|
const requiredConfig = requiredStorageKeys.find(req => {
|
|
54
|
+
if (!requiredKeyAppliesTo(req, 'async')) return false;
|
|
53
55
|
if (typeof req === 'string') return req === key;
|
|
54
56
|
return req.key === key;
|
|
55
57
|
});
|
|
@@ -59,9 +61,9 @@ export function useAsyncStorageKeys(requiredStorageKeys = []) {
|
|
|
59
61
|
if (requiredConfig) {
|
|
60
62
|
if (parsedValue === undefined || parsedValue === null) {
|
|
61
63
|
status = 'required_missing';
|
|
62
|
-
} else if (typeof requiredConfig === 'object' &&
|
|
64
|
+
} else if (typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined) {
|
|
63
65
|
status = parsedValue === requiredConfig.expectedValue ? 'required_present' : 'required_wrong_value';
|
|
64
|
-
} else if (typeof requiredConfig === 'object' &&
|
|
66
|
+
} else if (typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined) {
|
|
65
67
|
const actualType = parsedValue === null ? 'null' : typeof parsedValue;
|
|
66
68
|
status = actualType.toLowerCase() === requiredConfig.expectedType.toLowerCase() ? 'required_present' : 'required_wrong_type';
|
|
67
69
|
} else {
|
|
@@ -74,21 +76,22 @@ export function useAsyncStorageKeys(requiredStorageKeys = []) {
|
|
|
74
76
|
storageType: 'async',
|
|
75
77
|
status,
|
|
76
78
|
category: requiredConfig ? 'required' : 'optional',
|
|
77
|
-
...(typeof requiredConfig === 'object' &&
|
|
79
|
+
...(typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined && {
|
|
78
80
|
expectedValue: requiredConfig.expectedValue
|
|
79
81
|
}),
|
|
80
|
-
...(typeof requiredConfig === 'object' &&
|
|
82
|
+
...(typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined && {
|
|
81
83
|
expectedType: requiredConfig.expectedType
|
|
82
84
|
}),
|
|
83
|
-
...(typeof requiredConfig === 'object' &&
|
|
85
|
+
...(typeof requiredConfig === 'object' && requiredConfig.description !== undefined && {
|
|
84
86
|
description: requiredConfig.description
|
|
85
87
|
})
|
|
86
88
|
};
|
|
87
89
|
allStorageKeys.push(keyInfo);
|
|
88
90
|
});
|
|
89
91
|
|
|
90
|
-
// 4. Add missing required keys
|
|
92
|
+
// 4. Add missing required keys (Async-scoped only)
|
|
91
93
|
requiredStorageKeys.forEach(req => {
|
|
94
|
+
if (!requiredKeyAppliesTo(req, 'async')) return;
|
|
92
95
|
const key = typeof req === 'string' ? req : req.key;
|
|
93
96
|
const exists = allStorageKeys.some(k => k.key === key);
|
|
94
97
|
if (!exists) {
|
|
@@ -98,13 +101,13 @@ export function useAsyncStorageKeys(requiredStorageKeys = []) {
|
|
|
98
101
|
storageType: 'async',
|
|
99
102
|
status: 'required_missing',
|
|
100
103
|
category: 'required',
|
|
101
|
-
...(typeof req === 'object' &&
|
|
104
|
+
...(typeof req === 'object' && req.expectedValue !== undefined && {
|
|
102
105
|
expectedValue: req.expectedValue
|
|
103
106
|
}),
|
|
104
|
-
...(typeof req === 'object' &&
|
|
107
|
+
...(typeof req === 'object' && req.expectedType !== undefined && {
|
|
105
108
|
expectedType: req.expectedType
|
|
106
109
|
}),
|
|
107
|
-
...(typeof req === 'object' &&
|
|
110
|
+
...(typeof req === 'object' && req.description !== undefined && {
|
|
108
111
|
description: req.description
|
|
109
112
|
})
|
|
110
113
|
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
17
|
+
import { requiredKeyAppliesTo } from '../utils/requiredStorageKeyScope';
|
|
17
18
|
// Pure-JS utilities (no react-native-mmkv dependency) — safe to import
|
|
18
19
|
// unconditionally. They operate on whatever instance objects are registered,
|
|
19
20
|
// including remote-backed wrappers on the desktop dashboard.
|
|
@@ -92,12 +93,12 @@ export function useMMKVKeys(instance, instanceId, requiredStorageKeys = []) {
|
|
|
92
93
|
// 1. Get all keys from MMKV (synchronous)
|
|
93
94
|
const allKeys = instance.getAllKeys();
|
|
94
95
|
if (!allKeys || allKeys.length === 0) {
|
|
95
|
-
// Still need to check for missing required keys
|
|
96
|
-
const missingRequiredKeys = requiredStorageKeys.map(req => {
|
|
96
|
+
// Still need to check for missing required keys scoped to MMKV
|
|
97
|
+
const missingRequiredKeys = requiredStorageKeys.filter(req => requiredKeyAppliesTo(req, 'mmkv')).map(req => {
|
|
97
98
|
const key = typeof req === 'string' ? req : req.key;
|
|
98
|
-
const description = typeof req === 'object'
|
|
99
|
-
const expectedValue = typeof req === 'object'
|
|
100
|
-
const expectedType = typeof req === 'object'
|
|
99
|
+
const description = typeof req === 'object' ? req.description : undefined;
|
|
100
|
+
const expectedValue = typeof req === 'object' ? req.expectedValue : undefined;
|
|
101
|
+
const expectedType = typeof req === 'object' ? req.expectedType : undefined;
|
|
101
102
|
return {
|
|
102
103
|
key,
|
|
103
104
|
value: undefined,
|
|
@@ -125,8 +126,9 @@ export function useMMKVKeys(instance, instanceId, requiredStorageKeys = []) {
|
|
|
125
126
|
type
|
|
126
127
|
} = detectMMKVType(instance, key);
|
|
127
128
|
|
|
128
|
-
// Check if this is a required key
|
|
129
|
+
// Check if this is a required key scoped to MMKV
|
|
129
130
|
const requiredConfig = requiredStorageKeys.find(req => {
|
|
131
|
+
if (!requiredKeyAppliesTo(req, 'mmkv')) return false;
|
|
130
132
|
if (typeof req === 'string') return req === key;
|
|
131
133
|
return req.key === key;
|
|
132
134
|
});
|
|
@@ -136,10 +138,10 @@ export function useMMKVKeys(instance, instanceId, requiredStorageKeys = []) {
|
|
|
136
138
|
if (requiredConfig) {
|
|
137
139
|
if (value === undefined) {
|
|
138
140
|
status = 'required_missing';
|
|
139
|
-
} else if (typeof requiredConfig === 'object' &&
|
|
141
|
+
} else if (typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined) {
|
|
140
142
|
// Value validation
|
|
141
143
|
status = String(value) === String(requiredConfig.expectedValue) ? 'required_present' : 'required_wrong_value';
|
|
142
|
-
} else if (typeof requiredConfig === 'object' &&
|
|
144
|
+
} else if (typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined) {
|
|
143
145
|
// Type validation
|
|
144
146
|
status = isTypeMatch(type, requiredConfig.expectedType) ? 'required_present' : 'required_wrong_type';
|
|
145
147
|
} else {
|
|
@@ -158,13 +160,13 @@ export function useMMKVKeys(instance, instanceId, requiredStorageKeys = []) {
|
|
|
158
160
|
status,
|
|
159
161
|
category: requiredConfig ? 'required' : 'optional',
|
|
160
162
|
lastUpdated: new Date(),
|
|
161
|
-
...(typeof requiredConfig === 'object' &&
|
|
163
|
+
...(typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined && {
|
|
162
164
|
expectedValue: requiredConfig.expectedValue
|
|
163
165
|
}),
|
|
164
|
-
...(typeof requiredConfig === 'object' &&
|
|
166
|
+
...(typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined && {
|
|
165
167
|
expectedType: requiredConfig.expectedType
|
|
166
168
|
}),
|
|
167
|
-
...(typeof requiredConfig === 'object' &&
|
|
169
|
+
...(typeof requiredConfig === 'object' && requiredConfig.description !== undefined && {
|
|
168
170
|
description: requiredConfig.description
|
|
169
171
|
})
|
|
170
172
|
};
|
|
@@ -173,6 +175,7 @@ export function useMMKVKeys(instance, instanceId, requiredStorageKeys = []) {
|
|
|
173
175
|
|
|
174
176
|
// 3. Add missing required keys that weren't found in storage
|
|
175
177
|
requiredStorageKeys.forEach(req => {
|
|
178
|
+
if (!requiredKeyAppliesTo(req, 'mmkv')) return;
|
|
176
179
|
const key = typeof req === 'string' ? req : req.key;
|
|
177
180
|
const exists = allStorageKeys.some(k => k.key === key);
|
|
178
181
|
if (!exists) {
|
|
@@ -184,13 +187,13 @@ export function useMMKVKeys(instance, instanceId, requiredStorageKeys = []) {
|
|
|
184
187
|
instanceId,
|
|
185
188
|
status: 'required_missing',
|
|
186
189
|
category: 'required',
|
|
187
|
-
...(typeof req === 'object' &&
|
|
190
|
+
...(typeof req === 'object' && req.expectedValue !== undefined && {
|
|
188
191
|
expectedValue: req.expectedValue
|
|
189
192
|
}),
|
|
190
|
-
...(typeof req === 'object' &&
|
|
193
|
+
...(typeof req === 'object' && req.expectedType !== undefined && {
|
|
191
194
|
expectedType: req.expectedType
|
|
192
195
|
}),
|
|
193
|
-
...(typeof req === 'object' &&
|
|
196
|
+
...(typeof req === 'object' && req.description !== undefined && {
|
|
194
197
|
description: req.description
|
|
195
198
|
})
|
|
196
199
|
});
|
|
@@ -279,6 +282,7 @@ export function useMultiMMKVKeys(instances, requiredStorageKeys = []) {
|
|
|
279
282
|
type
|
|
280
283
|
} = detectMMKVType(instance, key);
|
|
281
284
|
const requiredConfig = requiredStorageKeys.find(req => {
|
|
285
|
+
if (!requiredKeyAppliesTo(req, 'mmkv')) return false;
|
|
282
286
|
if (typeof req === 'string') return req === key;
|
|
283
287
|
return req.key === key;
|
|
284
288
|
});
|
|
@@ -286,9 +290,9 @@ export function useMultiMMKVKeys(instances, requiredStorageKeys = []) {
|
|
|
286
290
|
if (requiredConfig) {
|
|
287
291
|
if (value === undefined) {
|
|
288
292
|
status = 'required_missing';
|
|
289
|
-
} else if (typeof requiredConfig === 'object' &&
|
|
293
|
+
} else if (typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined) {
|
|
290
294
|
status = String(value) === String(requiredConfig.expectedValue) ? 'required_present' : 'required_wrong_value';
|
|
291
|
-
} else if (typeof requiredConfig === 'object' &&
|
|
295
|
+
} else if (typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined) {
|
|
292
296
|
status = isTypeMatch(type, requiredConfig.expectedType) ? 'required_present' : 'required_wrong_type';
|
|
293
297
|
} else {
|
|
294
298
|
status = 'required_present';
|
|
@@ -303,13 +307,13 @@ export function useMultiMMKVKeys(instances, requiredStorageKeys = []) {
|
|
|
303
307
|
status,
|
|
304
308
|
category: requiredConfig ? 'required' : 'optional',
|
|
305
309
|
lastUpdated: new Date(),
|
|
306
|
-
...(typeof requiredConfig === 'object' &&
|
|
310
|
+
...(typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined && {
|
|
307
311
|
expectedValue: requiredConfig.expectedValue
|
|
308
312
|
}),
|
|
309
|
-
...(typeof requiredConfig === 'object' &&
|
|
313
|
+
...(typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined && {
|
|
310
314
|
expectedType: requiredConfig.expectedType
|
|
311
315
|
}),
|
|
312
|
-
...(typeof requiredConfig === 'object' &&
|
|
316
|
+
...(typeof requiredConfig === 'object' && requiredConfig.description !== undefined && {
|
|
313
317
|
description: requiredConfig.description
|
|
314
318
|
})
|
|
315
319
|
};
|
|
@@ -317,8 +321,9 @@ export function useMultiMMKVKeys(instances, requiredStorageKeys = []) {
|
|
|
317
321
|
});
|
|
318
322
|
});
|
|
319
323
|
|
|
320
|
-
// Add missing required keys
|
|
324
|
+
// Add missing required keys scoped to MMKV
|
|
321
325
|
requiredStorageKeys.forEach(req => {
|
|
326
|
+
if (!requiredKeyAppliesTo(req, 'mmkv')) return;
|
|
322
327
|
const key = typeof req === 'string' ? req : req.key;
|
|
323
328
|
const exists = allStorageKeys.some(k => k.key === key);
|
|
324
329
|
if (!exists) {
|
|
@@ -333,13 +338,13 @@ export function useMultiMMKVKeys(instances, requiredStorageKeys = []) {
|
|
|
333
338
|
instanceId: firstInstanceId,
|
|
334
339
|
status: 'required_missing',
|
|
335
340
|
category: 'required',
|
|
336
|
-
...(typeof req === 'object' &&
|
|
341
|
+
...(typeof req === 'object' && req.expectedValue !== undefined && {
|
|
337
342
|
expectedValue: req.expectedValue
|
|
338
343
|
}),
|
|
339
|
-
...(typeof req === 'object' &&
|
|
344
|
+
...(typeof req === 'object' && req.expectedType !== undefined && {
|
|
340
345
|
expectedType: req.expectedType
|
|
341
346
|
}),
|
|
342
|
-
...(typeof req === 'object' &&
|
|
347
|
+
...(typeof req === 'object' && req.description !== undefined && {
|
|
343
348
|
description: req.description
|
|
344
349
|
})
|
|
345
350
|
});
|
|
@@ -35,6 +35,35 @@ import { detectMMKVType } from "../utils/mmkvTypeDetection";
|
|
|
35
35
|
* millisecond timestamp), and React keys must be unique per render.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
/** True when a field is the wire-form VALUE_ON_DEVICE marker. */
|
|
39
|
+
export function isValueOnDevice(value) {
|
|
40
|
+
return !!value && typeof value === "object" && value.__buoyValueOnDevice === true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `pairs` / `prevPairs` are AsyncStorage-only — they carry the batch
|
|
45
|
+
* operations (multiSet / multiRemove / multiMerge / clear), and MMKV has no
|
|
46
|
+
* batch API, so its `data` has no such fields. Reading them off the
|
|
47
|
+
* `StorageEvent` union needs narrowing, and this is the one place that does
|
|
48
|
+
* it; writing them unconditionally would also graft meaningless keys onto
|
|
49
|
+
* every MMKV event on the wire.
|
|
50
|
+
*/
|
|
51
|
+
export function batchPairsOf(event) {
|
|
52
|
+
if (event.storageType !== "async" || !event.data) return {};
|
|
53
|
+
return {
|
|
54
|
+
pairs: event.data.pairs,
|
|
55
|
+
prevPairs: event.data.prevPairs
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function eventHasOnDeviceValues(event) {
|
|
59
|
+
const data = event.data;
|
|
60
|
+
if (!data) return false;
|
|
61
|
+
const {
|
|
62
|
+
pairs,
|
|
63
|
+
prevPairs
|
|
64
|
+
} = batchPairsOf(event);
|
|
65
|
+
return isValueOnDevice(data.value) || isValueOnDevice(data.prevValue) || isValueOnDevice(pairs) || isValueOnDevice(prevPairs);
|
|
66
|
+
}
|
|
38
67
|
let storageEventIdCounter = 0;
|
|
39
68
|
function nextStorageEventId() {
|
|
40
69
|
return `se-${Date.now()}-${++storageEventIdCounter}`;
|
|
@@ -207,6 +236,58 @@ class StorageEventStore extends BaseEventStore {
|
|
|
207
236
|
getEventsByType(storageType) {
|
|
208
237
|
return this.events.filter(event => event.storageType === storageType);
|
|
209
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Replace the event list, keeping values a remote mirror already hydrated.
|
|
242
|
+
* Wire-form snapshots always carry VALUE_ON_DEVICE markers for oversized
|
|
243
|
+
* payloads; a later snapshot must not blank the detail pane (or wipe
|
|
244
|
+
* `prevValue` that time-travel undo needs).
|
|
245
|
+
*/
|
|
246
|
+
replaceEvents(events) {
|
|
247
|
+
const hydrated = new Map();
|
|
248
|
+
for (const existing of this.events) {
|
|
249
|
+
if (!eventHasOnDeviceValues(existing)) {
|
|
250
|
+
hydrated.set(existing.id, existing);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const merged = events.map(incoming => {
|
|
254
|
+
const kept = hydrated.get(incoming.id);
|
|
255
|
+
if (!kept || !eventHasOnDeviceValues(incoming)) return incoming;
|
|
256
|
+
// Restore only the fields this event's storage type actually has, so an
|
|
257
|
+
// MMKV event never grows `pairs`/`prevPairs` it has no meaning for.
|
|
258
|
+
return {
|
|
259
|
+
...incoming,
|
|
260
|
+
data: {
|
|
261
|
+
...incoming.data,
|
|
262
|
+
value: kept.data?.value,
|
|
263
|
+
prevValue: kept.data?.prevValue,
|
|
264
|
+
...batchPairsOf(kept)
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
super.replaceEvents(merged);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Hydrate one mirrored event with real values fetched on demand from the
|
|
273
|
+
* device via the adapter's `getEventDetail`. Remote-mirror (desktop) only.
|
|
274
|
+
*/
|
|
275
|
+
hydrateEvent(event) {
|
|
276
|
+
const index = this.events.findIndex(e => e.id === event.id);
|
|
277
|
+
if (index === -1) return;
|
|
278
|
+
const current = this.events[index];
|
|
279
|
+
const next = this.events.slice();
|
|
280
|
+
next[index] = {
|
|
281
|
+
...current,
|
|
282
|
+
...event,
|
|
283
|
+
timestamp: current.timestamp,
|
|
284
|
+
data: {
|
|
285
|
+
...current.data,
|
|
286
|
+
...event.data
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
super.replaceEvents(next);
|
|
290
|
+
}
|
|
210
291
|
}
|
|
211
292
|
|
|
212
293
|
// Singleton instance
|
|
@@ -1,15 +1,157 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
4
|
-
import { isDevToolsStorageKey } from "@buoy-gg/shared-ui";
|
|
4
|
+
import { approxJsonSize, isDevToolsStorageKey, isOverWireBudget, isJsonSerializable } from "@buoy-gg/shared-ui";
|
|
5
5
|
import { readMany, writeMany, removeMany } from "../utils/asyncStorageCompat";
|
|
6
|
-
import { storageEventStore } from "../stores/storageEventStore";
|
|
6
|
+
import { batchPairsOf, storageEventStore } from "../stores/storageEventStore";
|
|
7
7
|
import { clearAllAppStorage } from "../utils/clearAllStorage";
|
|
8
8
|
import { undoOperation, jumpToState, canUndo } from "../utils/storageTimeTravelUtils";
|
|
9
9
|
import { mmkvInstanceRegistry } from "../utils/MMKVInstanceRegistry";
|
|
10
10
|
import { detectMMKVType } from "../utils/mmkvTypeDetection";
|
|
11
11
|
import { secureStoreRegistry } from "../utils/SecureStoreRegistry";
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Sentinel used in WIRE-FORM snapshots in place of oversized values.
|
|
15
|
+
* Storage events, MMKV browse dumps, and SecureStore dumps all reuse this
|
|
16
|
+
* marker. Dashboards fetch the real payload via `getEventDetail` / `mmkv.get`
|
|
17
|
+
* / `secure.get`.
|
|
18
|
+
*/
|
|
19
|
+
export const VALUE_ON_DEVICE = Object.freeze({
|
|
20
|
+
__buoyValueOnDevice: true,
|
|
21
|
+
note: "Value stays on the device — fetched on demand via getEventDetail / mmkv.get / secure.get."
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Max serialized bytes of one storage value on the list snapshot. */
|
|
25
|
+
export const SNAPSHOT_VALUE_INLINE_LIMIT = 16 * 1024;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Total inline budget for one snapshot's event list, held under the emit
|
|
29
|
+
* layer's 2MB so a snapshot is never DROPPED (a drop leaves the whole panel
|
|
30
|
+
* stale). Per-value caps alone don't get there: the store keeps 500 events, so
|
|
31
|
+
* 500 writes of an individually-legal 9KB value still total ~4.7MB. Newest
|
|
32
|
+
* events keep their values inline; once the budget is spent, older ones
|
|
33
|
+
* degrade to metadata + fetch-on-demand.
|
|
34
|
+
*/
|
|
35
|
+
export const SNAPSHOT_EVENTS_BUDGET = 1.25 * 1024 * 1024;
|
|
36
|
+
const MAX_EVENT_DETAIL_BYTES = 8 * 1024 * 1024;
|
|
37
|
+
function toWireValue(value) {
|
|
38
|
+
if (value === undefined) return value;
|
|
39
|
+
return isOverWireBudget(value, SNAPSHOT_VALUE_INLINE_LIMIT) ? VALUE_ON_DEVICE : value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Apply `map` to every payload field an event carries, preserving object
|
|
44
|
+
* identity when nothing changed (the desktop mirror feeds these into effect
|
|
45
|
+
* dependencies, so a fresh object per snapshot would re-run them).
|
|
46
|
+
*
|
|
47
|
+
* The cast is deliberate and load-bearing: the wire form intentionally puts a
|
|
48
|
+
* marker OBJECT where the domain type declares a string, because the real
|
|
49
|
+
* value stayed on the device. `StorageEvent` describes on-device truth; this
|
|
50
|
+
* is its wire projection.
|
|
51
|
+
*/
|
|
52
|
+
function mapEventValues(event, map) {
|
|
53
|
+
const data = event.data;
|
|
54
|
+
if (!data) return event;
|
|
55
|
+
const value = map(data.value);
|
|
56
|
+
const prevValue = map(data.prevValue);
|
|
57
|
+
const batch = batchPairsOf(event);
|
|
58
|
+
const pairs = map(batch.pairs);
|
|
59
|
+
const prevPairs = map(batch.prevPairs);
|
|
60
|
+
if (value === data.value && prevValue === data.prevValue && pairs === batch.pairs && prevPairs === batch.prevPairs) {
|
|
61
|
+
return event;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
...event,
|
|
65
|
+
data: {
|
|
66
|
+
...data,
|
|
67
|
+
value,
|
|
68
|
+
prevValue,
|
|
69
|
+
// Only AsyncStorage events have these; see batchPairsOf.
|
|
70
|
+
...(event.storageType === "async" ? {
|
|
71
|
+
pairs,
|
|
72
|
+
prevPairs
|
|
73
|
+
} : {})
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function capDetailValue(value) {
|
|
78
|
+
if (value === undefined) return value;
|
|
79
|
+
if (!isOverWireBudget(value, MAX_EVENT_DETAIL_BYTES)) return value;
|
|
80
|
+
return isJsonSerializable(value) ? {
|
|
81
|
+
__buoyTruncated: true,
|
|
82
|
+
note: `Exceeds the ${MAX_EVENT_DETAIL_BYTES / (1024 * 1024)}MB detail limit — inspect it on-device.`
|
|
83
|
+
} : VALUE_ON_DEVICE;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One event's wire form plus its measured cost, so the budget pass is free. */
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Per-event wire cache. The store never mutates an event in place — `addEvent`
|
|
90
|
+
* prepends and `hydrateEvent` replaces — so keying on identity is correct and
|
|
91
|
+
* self-invalidating. Without it every value is re-measured on all ~5 snapshots
|
|
92
|
+
* per second for as long as it stays in the list, which for storage means
|
|
93
|
+
* re-walking megabytes of strings per tick.
|
|
94
|
+
*/
|
|
95
|
+
const wireCache = new WeakMap();
|
|
96
|
+
const strippedCache = new WeakMap();
|
|
97
|
+
function wireFor(event) {
|
|
98
|
+
const cached = wireCache.get(event);
|
|
99
|
+
if (cached) return cached;
|
|
100
|
+
let entry;
|
|
101
|
+
try {
|
|
102
|
+
const wire = mapEventValues(event, toWireValue);
|
|
103
|
+
entry = {
|
|
104
|
+
event: wire,
|
|
105
|
+
bytes: approxJsonSize(wire, SNAPSHOT_EVENTS_BUDGET).bytes
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
// A hostile getter can blow up the walk itself. Degrading this ONE event
|
|
109
|
+
// beats letting getSnapshot throw and taking the whole panel with it.
|
|
110
|
+
const wire = mapEventValues(event, () => VALUE_ON_DEVICE);
|
|
111
|
+
entry = {
|
|
112
|
+
event: wire,
|
|
113
|
+
bytes: 1024
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
wireCache.set(event, entry);
|
|
117
|
+
return entry;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Metadata-only form for the tail of the list once the budget is spent. */
|
|
121
|
+
function strippedFor(event) {
|
|
122
|
+
const cached = strippedCache.get(event);
|
|
123
|
+
if (cached) return cached;
|
|
124
|
+
const wire = mapEventValues(event, v => v === undefined ? undefined : VALUE_ON_DEVICE);
|
|
125
|
+
const entry = {
|
|
126
|
+
event: wire,
|
|
127
|
+
bytes: approxJsonSize(wire, SNAPSHOT_EVENTS_BUDGET).bytes
|
|
128
|
+
};
|
|
129
|
+
strippedCache.set(event, entry);
|
|
130
|
+
return entry;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Spend the snapshot budget newest-first. The store keeps events newest-first,
|
|
135
|
+
* so the developer keeps full detail on what they are actually looking at and
|
|
136
|
+
* the tail degrades rather than the whole panel being dropped.
|
|
137
|
+
*/
|
|
138
|
+
function fitEventsToBudget(events) {
|
|
139
|
+
const out = new Array(events.length);
|
|
140
|
+
let spent = 0;
|
|
141
|
+
for (let i = 0; i < events.length; i++) {
|
|
142
|
+
const full = wireFor(events[i]);
|
|
143
|
+
if (spent + full.bytes <= SNAPSHOT_EVENTS_BUDGET) {
|
|
144
|
+
out[i] = full.event;
|
|
145
|
+
spent += full.bytes;
|
|
146
|
+
} else {
|
|
147
|
+
const lean = strippedFor(events[i]);
|
|
148
|
+
out[i] = lean.event;
|
|
149
|
+
spent += lean.bytes;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
13
155
|
/** One MMKV entry in a `mmkv.snapshot` result. */
|
|
14
156
|
|
|
15
157
|
/** One MMKV instance in a `mmkv.snapshot` result. */
|
|
@@ -47,22 +189,53 @@ function assertMMKVWritable(instanceId) {
|
|
|
47
189
|
*
|
|
48
190
|
* The `async.*` actions mirror the AsyncStorage API so the desktop dashboard
|
|
49
191
|
* can proxy its browse/edit mode against the device's real storage.
|
|
192
|
+
*
|
|
193
|
+
* v2: oversized / unserializable event values are replaced with
|
|
194
|
+
* VALUE_ON_DEVICE. Dashboards fetch them via `getEventDetail`.
|
|
50
195
|
*/
|
|
51
196
|
export const storageSyncAdapter = {
|
|
52
|
-
|
|
197
|
+
// v3: a total snapshot budget, so a burst of individually-legal values
|
|
198
|
+
// degrades the oldest events instead of the emit layer dropping the panel.
|
|
199
|
+
version: 3,
|
|
53
200
|
// Strip Buoy's OWN devtool keys (@react_buoy*, @buoy*, buoy-*, legacy) at the
|
|
54
201
|
// source so no sync client (desktop, MCP, raw socket) ever sees them as if
|
|
55
202
|
// they were app data — matching the tools' default key filters, but
|
|
56
203
|
// authoritative instead of UI-only.
|
|
57
|
-
getSnapshot: () => storageEventStore.getEvents().filter(e => {
|
|
204
|
+
getSnapshot: () => fitEventsToBudget(storageEventStore.getEvents().filter(e => {
|
|
58
205
|
const key = e.data?.key;
|
|
59
206
|
return !(typeof key === "string" && isDevToolsStorageKey(key));
|
|
60
|
-
}),
|
|
207
|
+
})),
|
|
61
208
|
subscribe: onChange => storageEventStore.subscribeToEvents(onChange),
|
|
62
209
|
actions: {
|
|
63
210
|
clearEvents: () => {
|
|
64
211
|
storageEventStore.clearEvents();
|
|
65
212
|
},
|
|
213
|
+
/**
|
|
214
|
+
* On-demand detail: one event's real value/prevValue/pairs.
|
|
215
|
+
* The per-snapshot stream stays payload-light; this is the explicit,
|
|
216
|
+
* size-guarded channel for the detail pane (and for time-travel undo,
|
|
217
|
+
* which needs `prevValue`).
|
|
218
|
+
*/
|
|
219
|
+
getEventDetail: params => {
|
|
220
|
+
const {
|
|
221
|
+
id
|
|
222
|
+
} = params ?? {};
|
|
223
|
+
if (!id) return {
|
|
224
|
+
found: false,
|
|
225
|
+
reason: "missing id"
|
|
226
|
+
};
|
|
227
|
+
const event = storageEventStore.getEvents().find(e => e.id === id);
|
|
228
|
+
if (!event) return {
|
|
229
|
+
found: false,
|
|
230
|
+
reason: "unknown id"
|
|
231
|
+
};
|
|
232
|
+
const detailed = mapEventValues(event, capDetailValue);
|
|
233
|
+
return {
|
|
234
|
+
found: true,
|
|
235
|
+
id,
|
|
236
|
+
event: detailed
|
|
237
|
+
};
|
|
238
|
+
},
|
|
66
239
|
/** Clears all app storage keys, preserving dev tools settings. */
|
|
67
240
|
clearAppStorage: () => clearAllAppStorage(),
|
|
68
241
|
// ── Time travel (same code path as the modal's UNDO / JUMP buttons) ──
|
|
@@ -159,7 +332,7 @@ export const storageSyncAdapter = {
|
|
|
159
332
|
} = detectMMKVType(instance, key);
|
|
160
333
|
return {
|
|
161
334
|
key,
|
|
162
|
-
value,
|
|
335
|
+
value: toWireValue(value),
|
|
163
336
|
valueType
|
|
164
337
|
};
|
|
165
338
|
});
|
|
@@ -173,6 +346,41 @@ export const storageSyncAdapter = {
|
|
|
173
346
|
entries
|
|
174
347
|
};
|
|
175
348
|
}),
|
|
349
|
+
/**
|
|
350
|
+
* On-demand MMKV value. `mmkv.snapshot` omits oversized strings so a
|
|
351
|
+
* browse poll cannot blow the action-result budget; this is the
|
|
352
|
+
* size-guarded channel for one key.
|
|
353
|
+
*/
|
|
354
|
+
"mmkv.get": params => {
|
|
355
|
+
const {
|
|
356
|
+
instanceId,
|
|
357
|
+
key
|
|
358
|
+
} = params ?? {};
|
|
359
|
+
if (!instanceId || !key) {
|
|
360
|
+
return {
|
|
361
|
+
found: false,
|
|
362
|
+
reason: "missing instanceId/key"
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const registered = mmkvInstanceRegistry.get(instanceId);
|
|
366
|
+
if (!registered) {
|
|
367
|
+
return {
|
|
368
|
+
found: false,
|
|
369
|
+
reason: "unknown instance"
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const {
|
|
373
|
+
value,
|
|
374
|
+
type: valueType
|
|
375
|
+
} = detectMMKVType(registered.instance, key);
|
|
376
|
+
return {
|
|
377
|
+
found: true,
|
|
378
|
+
instanceId,
|
|
379
|
+
key,
|
|
380
|
+
value: capDetailValue(value),
|
|
381
|
+
valueType
|
|
382
|
+
};
|
|
383
|
+
},
|
|
176
384
|
"mmkv.set": params => {
|
|
177
385
|
const {
|
|
178
386
|
instanceId,
|
|
@@ -232,7 +440,7 @@ export const storageSyncAdapter = {
|
|
|
232
440
|
description,
|
|
233
441
|
keychainService: options.keychainService,
|
|
234
442
|
requireAuthentication: options.requireAuthentication ?? false,
|
|
235
|
-
value
|
|
443
|
+
value: value == null ? value : toWireValue(value)
|
|
236
444
|
});
|
|
237
445
|
}
|
|
238
446
|
return result;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a required-key declaration should be enforced by a given backend
|
|
5
|
+
* hook. Explicit `storageType` wins; bare string keys default to AsyncStorage
|
|
6
|
+
* (the historical zero-config backend). Objects without `storageType` still
|
|
7
|
+
* apply to every backend — matching prior behavior for apps that never set it.
|
|
8
|
+
*/
|
|
9
|
+
export function requiredKeyAppliesTo(req, backend) {
|
|
10
|
+
if (typeof req === "string") return backend === "async";
|
|
11
|
+
if (req.storageType) return req.storageType === backend;
|
|
12
|
+
return true;
|
|
13
|
+
}
|