@buoy-gg/storage 7.0.17 → 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.
Files changed (38) hide show
  1. package/lib/commonjs/index.js +12 -0
  2. package/lib/commonjs/storage/components/DiffViewer/modes/ThemedSplitView.js +5 -602
  3. package/lib/commonjs/storage/components/StorageModalWithTabs.js +17 -1
  4. package/lib/commonjs/storage/hooks/useAsyncStorageKeys.js +13 -10
  5. package/lib/commonjs/storage/hooks/useMMKVKeys.js +28 -23
  6. package/lib/commonjs/storage/stores/storageEventStore.js +85 -1
  7. package/lib/commonjs/storage/sync/storageSyncAdapter.js +214 -6
  8. package/lib/commonjs/storage/utils/requiredStorageKeyScope.js +17 -0
  9. package/lib/commonjs/storage/utils/snapshotProvider.js +173 -5
  10. package/lib/commonjs/storage/utils/storageTimeTravelUtils.js +17 -3
  11. package/lib/module/index.js +6 -1
  12. package/lib/module/storage/components/DiffViewer/modes/ThemedSplitView.js +7 -603
  13. package/lib/module/storage/components/StorageModalWithTabs.js +17 -1
  14. package/lib/module/storage/hooks/useAsyncStorageKeys.js +13 -10
  15. package/lib/module/storage/hooks/useMMKVKeys.js +28 -23
  16. package/lib/module/storage/stores/storageEventStore.js +81 -0
  17. package/lib/module/storage/sync/storageSyncAdapter.js +215 -7
  18. package/lib/module/storage/utils/requiredStorageKeyScope.js +13 -0
  19. package/lib/module/storage/utils/snapshotProvider.js +173 -5
  20. package/lib/module/storage/utils/storageTimeTravelUtils.js +17 -3
  21. package/lib/typescript/index.d.ts +2 -1
  22. package/lib/typescript/index.d.ts.map +1 -1
  23. package/lib/typescript/storage/components/DiffViewer/modes/ThemedSplitView.d.ts +7 -18
  24. package/lib/typescript/storage/components/DiffViewer/modes/ThemedSplitView.d.ts.map +1 -1
  25. package/lib/typescript/storage/components/StorageModalWithTabs.d.ts +8 -1
  26. package/lib/typescript/storage/components/StorageModalWithTabs.d.ts.map +1 -1
  27. package/lib/typescript/storage/hooks/useAsyncStorageKeys.d.ts.map +1 -1
  28. package/lib/typescript/storage/hooks/useMMKVKeys.d.ts.map +1 -1
  29. package/lib/typescript/storage/stores/storageEventStore.d.ts +26 -0
  30. package/lib/typescript/storage/stores/storageEventStore.d.ts.map +1 -1
  31. package/lib/typescript/storage/sync/storageSyncAdapter.d.ts +69 -3
  32. package/lib/typescript/storage/sync/storageSyncAdapter.d.ts.map +1 -1
  33. package/lib/typescript/storage/types.d.ts +9 -9
  34. package/lib/typescript/storage/types.d.ts.map +1 -1
  35. package/lib/typescript/storage/utils/requiredStorageKeyScope.d.ts +10 -0
  36. package/lib/typescript/storage/utils/requiredStorageKeyScope.d.ts.map +1 -0
  37. package/lib/typescript/storage/utils/storageTimeTravelUtils.d.ts.map +1 -1
  38. package/package.json +5 -5
@@ -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' && 'expectedValue' in requiredConfig) {
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' && 'expectedType' in requiredConfig) {
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' && 'expectedValue' in requiredConfig && {
79
+ ...(typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined && {
78
80
  expectedValue: requiredConfig.expectedValue
79
81
  }),
80
- ...(typeof requiredConfig === 'object' && 'expectedType' in requiredConfig && {
82
+ ...(typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined && {
81
83
  expectedType: requiredConfig.expectedType
82
84
  }),
83
- ...(typeof requiredConfig === 'object' && 'description' in requiredConfig && {
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' && 'expectedValue' in req && {
104
+ ...(typeof req === 'object' && req.expectedValue !== undefined && {
102
105
  expectedValue: req.expectedValue
103
106
  }),
104
- ...(typeof req === 'object' && 'expectedType' in req && {
107
+ ...(typeof req === 'object' && req.expectedType !== undefined && {
105
108
  expectedType: req.expectedType
106
109
  }),
107
- ...(typeof req === 'object' && 'description' in req && {
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' && 'description' in req ? req.description : undefined;
99
- const expectedValue = typeof req === 'object' && 'expectedValue' in req ? req.expectedValue : undefined;
100
- const expectedType = typeof req === 'object' && 'expectedType' in req ? req.expectedType : undefined;
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' && 'expectedValue' in requiredConfig) {
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' && 'expectedType' in requiredConfig) {
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' && 'expectedValue' in requiredConfig && {
163
+ ...(typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined && {
162
164
  expectedValue: requiredConfig.expectedValue
163
165
  }),
164
- ...(typeof requiredConfig === 'object' && 'expectedType' in requiredConfig && {
166
+ ...(typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined && {
165
167
  expectedType: requiredConfig.expectedType
166
168
  }),
167
- ...(typeof requiredConfig === 'object' && 'description' in requiredConfig && {
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' && 'expectedValue' in req && {
190
+ ...(typeof req === 'object' && req.expectedValue !== undefined && {
188
191
  expectedValue: req.expectedValue
189
192
  }),
190
- ...(typeof req === 'object' && 'expectedType' in req && {
193
+ ...(typeof req === 'object' && req.expectedType !== undefined && {
191
194
  expectedType: req.expectedType
192
195
  }),
193
- ...(typeof req === 'object' && 'description' in req && {
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' && 'expectedValue' in requiredConfig) {
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' && 'expectedType' in requiredConfig) {
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' && 'expectedValue' in requiredConfig && {
310
+ ...(typeof requiredConfig === 'object' && requiredConfig.expectedValue !== undefined && {
307
311
  expectedValue: requiredConfig.expectedValue
308
312
  }),
309
- ...(typeof requiredConfig === 'object' && 'expectedType' in requiredConfig && {
313
+ ...(typeof requiredConfig === 'object' && requiredConfig.expectedType !== undefined && {
310
314
  expectedType: requiredConfig.expectedType
311
315
  }),
312
- ...(typeof requiredConfig === 'object' && 'description' in requiredConfig && {
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' && 'expectedValue' in req && {
341
+ ...(typeof req === 'object' && req.expectedValue !== undefined && {
337
342
  expectedValue: req.expectedValue
338
343
  }),
339
- ...(typeof req === 'object' && 'expectedType' in req && {
344
+ ...(typeof req === 'object' && req.expectedType !== undefined && {
340
345
  expectedType: req.expectedType
341
346
  }),
342
- ...(typeof req === 'object' && 'description' in req && {
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
- version: 1,
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
+ }