@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.
Files changed (40) hide show
  1. package/lib/commonjs/index.js +13 -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 +447 -0
  10. package/lib/commonjs/storage/utils/storageTimeTravelUtils.js +17 -3
  11. package/lib/module/index.js +9 -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 +445 -0
  20. package/lib/module/storage/utils/storageTimeTravelUtils.js +17 -3
  21. package/lib/typescript/index.d.ts +3 -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 +71 -5
  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/snapshotProvider.d.ts +2 -0
  38. package/lib/typescript/storage/utils/snapshotProvider.d.ts.map +1 -0
  39. package/lib/typescript/storage/utils/storageTimeTravelUtils.d.ts.map +1 -1
  40. package/package.json +9 -6
@@ -0,0 +1,447 @@
1
+ "use strict";
2
+
3
+ var _asyncStorage = _interopRequireDefault(require("@react-native-async-storage/async-storage"));
4
+ var _sharedUi = require("@buoy-gg/shared-ui");
5
+ var _asyncStorageCompat = require("./asyncStorageCompat");
6
+ var _clearAllStorage = require("./clearAllStorage");
7
+ var _MMKVInstanceRegistry = require("./MMKVInstanceRegistry");
8
+ var _mmkvTypeDetection = require("./mmkvTypeDetection");
9
+ var _SecureStoreRegistry = require("./SecureStoreRegistry");
10
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
+ /**
12
+ * Time Machine snapshot provider for device storage (AsyncStorage + MMKV +
13
+ * SecureStore).
14
+ *
15
+ * Registered into @buoy-gg/shared-ui's stateSnapshotRegistry at module init;
16
+ * consumed by @buoy-gg/time-machine.
17
+ *
18
+ * Buoy's own keys (isDevToolsStorageKey — @react_buoy*, @buoy*, buoy-*,
19
+ * legacy patterns) are excluded on BOTH capture and restore, so a snapshot
20
+ * can never clobber dev-tool settings. Restore is a diff: app keys missing
21
+ * from the snapshot are deleted, snapshot entries are written back.
22
+ *
23
+ * Known-lossy by design (each surfaces a warning/skip):
24
+ * - MMKV ArrayBuffer values are never transported.
25
+ * - SecureStore biometric keys are never read or written (auth prompt).
26
+ * - SecureStore keys outside the registered descriptor list are invisible.
27
+ */
28
+
29
+ function deleteMMKVKey(instance, key) {
30
+ // v4 renamed delete() to remove() — support both
31
+ if (instance.remove) instance.remove(key);else instance.delete?.(key);
32
+ }
33
+ function status() {
34
+ return {
35
+ canCapture: true,
36
+ canRestore: true
37
+ };
38
+ }
39
+
40
+ /** Shared by capture() and the live side of previewItems(). */
41
+ async function captureStorageData() {
42
+ const warnings = [];
43
+ const appKeys = (await _asyncStorage.default.getAllKeys()).filter(k => !(0, _sharedUi.isDevToolsStorageKey)(k));
44
+ const asyncPairs = (await (0, _asyncStorageCompat.readMany)(appKeys)).filter(pair => pair[1] !== null);
45
+ const mmkv = {};
46
+ for (const {
47
+ id,
48
+ instance
49
+ } of _MMKVInstanceRegistry.mmkvInstanceRegistry.getAll()) {
50
+ try {
51
+ mmkv[id] = instance.getAllKeys().filter(key => !(0, _sharedUi.isDevToolsStorageKey)(key)).map(key => {
52
+ const {
53
+ value,
54
+ type: valueType
55
+ } = (0, _mmkvTypeDetection.detectMMKVType)(instance, key);
56
+ if (valueType === "buffer") {
57
+ warnings.push(`mmkv "${id}"/"${key}": ArrayBuffer value not capturable`);
58
+ }
59
+ return {
60
+ key,
61
+ value,
62
+ valueType
63
+ };
64
+ });
65
+ } catch {
66
+ warnings.push(`mmkv "${id}": unreadable, captured without entries`);
67
+ mmkv[id] = [];
68
+ }
69
+ }
70
+ const secure = {};
71
+ const secureModule = _SecureStoreRegistry.secureStoreRegistry.getModule();
72
+ for (const {
73
+ key,
74
+ options
75
+ } of _SecureStoreRegistry.secureStoreRegistry.getAll()) {
76
+ if (options.requireAuthentication) {
77
+ warnings.push(`secure "${key}": biometric-protected, not capturable`);
78
+ continue;
79
+ }
80
+ let value = null;
81
+ if (secureModule) {
82
+ try {
83
+ value = await secureModule.getItemAsync(key, options);
84
+ } catch {
85
+ warnings.push(`secure "${key}": unreadable, captured as unset`);
86
+ }
87
+ }
88
+ secure[key] = value;
89
+ }
90
+ return {
91
+ data: {
92
+ async: asyncPairs,
93
+ mmkv,
94
+ secure
95
+ },
96
+ warnings
97
+ };
98
+ }
99
+ async function capture() {
100
+ const {
101
+ data,
102
+ warnings
103
+ } = await captureStorageData();
104
+ return {
105
+ data,
106
+ warnings
107
+ };
108
+ }
109
+ async function restore(data, options) {
110
+ const snapshot = data;
111
+ const excludedSet = new Set(options?.excludeKeys ?? []);
112
+ const includedSet = options?.includeKeys ? new Set(options.includeKeys) : null;
113
+ /** True when this item may be touched (scope + exclusions agree). */
114
+ const touch = key => !excludedSet.has(key) && (!includedSet || includedSet.has(key));
115
+ let excludedHits = 0;
116
+ const result = {
117
+ ok: true,
118
+ applied: 0,
119
+ skipped: [],
120
+ warnings: []
121
+ };
122
+ if (!snapshot) {
123
+ return {
124
+ ok: false,
125
+ applied: 0,
126
+ skipped: [],
127
+ warnings: ["empty storage snapshot"]
128
+ };
129
+ }
130
+
131
+ // ── AsyncStorage: delete app keys missing from the snapshot, write the rest ──
132
+ try {
133
+ const snapPairs = (snapshot.async ?? []).filter(([k]) => !(0, _sharedUi.isDevToolsStorageKey)(k) && touch(`async:${k}`));
134
+ const snapKeys = new Set(snapPairs.map(([k]) => k));
135
+ const liveKeys = (await _asyncStorage.default.getAllKeys()).filter(k => !(0, _sharedUi.isDevToolsStorageKey)(k));
136
+ const toRemove = liveKeys.filter(k => !snapKeys.has(k) && touch(`async:${k}`));
137
+ excludedHits += (snapshot.async ?? []).length - snapPairs.length;
138
+ if (toRemove.length > 0) await (0, _asyncStorageCompat.removeMany)(toRemove);
139
+ if (snapPairs.length > 0) await (0, _asyncStorageCompat.writeMany)(snapPairs);
140
+ result.applied += snapPairs.length + toRemove.length;
141
+ } catch (e) {
142
+ result.ok = false;
143
+ result.warnings.push(`asyncstorage restore failed: ${String(e)}`);
144
+ }
145
+
146
+ // ── MMKV: per registered writable instance, diff against the snapshot ──
147
+ for (const [id, entries] of Object.entries(snapshot.mmkv ?? {})) {
148
+ const registered = _MMKVInstanceRegistry.mmkvInstanceRegistry.get(id);
149
+ if (!registered) {
150
+ result.skipped.push({
151
+ id: `mmkv:${id}`,
152
+ reason: "instance not registered"
153
+ });
154
+ continue;
155
+ }
156
+ if (registered.instance.isReadOnly) {
157
+ result.skipped.push({
158
+ id: `mmkv:${id}`,
159
+ reason: "instance registered read-only"
160
+ });
161
+ continue;
162
+ }
163
+ const {
164
+ instance
165
+ } = registered;
166
+ try {
167
+ const snapKeys = new Set(entries.map(e => e.key));
168
+ const liveKeys = instance.getAllKeys().filter(key => !(0, _sharedUi.isDevToolsStorageKey)(key));
169
+ for (const key of liveKeys) {
170
+ if (!touch(`mmkv:${id}/${key}`)) {
171
+ excludedHits += 1;
172
+ continue;
173
+ }
174
+ if (!snapKeys.has(key)) {
175
+ deleteMMKVKey(instance, key);
176
+ result.applied += 1;
177
+ }
178
+ }
179
+ for (const entry of entries) {
180
+ if ((0, _sharedUi.isDevToolsStorageKey)(entry.key)) continue;
181
+ if (!touch(`mmkv:${id}/${entry.key}`)) continue;
182
+ if (entry.valueType === "buffer") {
183
+ result.skipped.push({
184
+ id: `mmkv:${id}/${entry.key}`,
185
+ reason: "ArrayBuffer values cannot be restored"
186
+ });
187
+ continue;
188
+ }
189
+ instance.set(entry.key, entry.value);
190
+ result.applied += 1;
191
+ }
192
+ } catch (e) {
193
+ result.ok = false;
194
+ result.warnings.push(`mmkv "${id}" restore failed: ${String(e)}`);
195
+ }
196
+ }
197
+
198
+ // ── SecureStore: diff over the registered, non-biometric descriptor list ──
199
+ const secureSnap = snapshot.secure ?? {};
200
+ for (const {
201
+ key,
202
+ options
203
+ } of _SecureStoreRegistry.secureStoreRegistry.getAll()) {
204
+ if (!touch(`secure:${key}`)) {
205
+ excludedHits += 1;
206
+ continue;
207
+ }
208
+ if (options.requireAuthentication) {
209
+ if (key in secureSnap) {
210
+ result.skipped.push({
211
+ id: `secure:${key}`,
212
+ reason: "biometric-protected keys are never written"
213
+ });
214
+ }
215
+ continue;
216
+ }
217
+ try {
218
+ if (key in secureSnap) {
219
+ const value = secureSnap[key];
220
+ if (value === null) await _SecureStoreRegistry.secureStoreRegistry.deleteValue(key);else await _SecureStoreRegistry.secureStoreRegistry.setValue(key, value);
221
+ } else {
222
+ // Registered key with no snapshot entry — snapshot predates it; unset.
223
+ await _SecureStoreRegistry.secureStoreRegistry.deleteValue(key);
224
+ }
225
+ result.applied += 1;
226
+ } catch (e) {
227
+ result.ok = false;
228
+ result.warnings.push(`secure "${key}" restore failed: ${String(e)}`);
229
+ }
230
+ }
231
+ for (const key of Object.keys(secureSnap)) {
232
+ if (!touch(`secure:${key}`)) continue;
233
+ if (!_SecureStoreRegistry.secureStoreRegistry.getAll().some(e => e.key === key)) {
234
+ result.skipped.push({
235
+ id: `secure:${key}`,
236
+ reason: "key no longer registered with registerSecureStoreKeys"
237
+ });
238
+ }
239
+ }
240
+ if (excludedHits > 0) {
241
+ result.warnings.push(`${excludedHits} item(s) excluded by you`);
242
+ }
243
+ return result;
244
+ }
245
+
246
+ /** Wipe to fresh-install state (Buoy keys preserved). Used by baseline restore. */
247
+ async function clear() {
248
+ const result = {
249
+ ok: true,
250
+ applied: 0,
251
+ skipped: [],
252
+ warnings: []
253
+ };
254
+ try {
255
+ await (0, _clearAllStorage.clearAllAppStorage)();
256
+ result.applied += 1;
257
+ } catch (e) {
258
+ result.ok = false;
259
+ result.warnings.push(`asyncstorage clear failed: ${String(e)}`);
260
+ }
261
+ for (const {
262
+ id,
263
+ instance
264
+ } of _MMKVInstanceRegistry.mmkvInstanceRegistry.getAll()) {
265
+ if (instance.isReadOnly) {
266
+ result.skipped.push({
267
+ id: `mmkv:${id}`,
268
+ reason: "instance registered read-only"
269
+ });
270
+ continue;
271
+ }
272
+ try {
273
+ for (const key of instance.getAllKeys().filter(k => !(0, _sharedUi.isDevToolsStorageKey)(k))) {
274
+ deleteMMKVKey(instance, key);
275
+ result.applied += 1;
276
+ }
277
+ } catch (e) {
278
+ result.ok = false;
279
+ result.warnings.push(`mmkv "${id}" clear failed: ${String(e)}`);
280
+ }
281
+ }
282
+ for (const {
283
+ key,
284
+ options
285
+ } of _SecureStoreRegistry.secureStoreRegistry.getAll()) {
286
+ if (options.requireAuthentication) {
287
+ result.skipped.push({
288
+ id: `secure:${key}`,
289
+ reason: "biometric-protected keys are never written"
290
+ });
291
+ continue;
292
+ }
293
+ try {
294
+ await _SecureStoreRegistry.secureStoreRegistry.deleteValue(key);
295
+ result.applied += 1;
296
+ } catch (e) {
297
+ result.ok = false;
298
+ result.warnings.push(`secure "${key}" clear failed: ${String(e)}`);
299
+ }
300
+ }
301
+ return result;
302
+ }
303
+ const ITEM_RENDER_CHAR_LIMIT = 256 * 1024;
304
+ function classifyValue(key, label, oldValue, newValue, inOld, inNew) {
305
+ if (!inNew) return {
306
+ key,
307
+ label,
308
+ verdict: "removed",
309
+ oldValue
310
+ };
311
+ if (!inOld) return {
312
+ key,
313
+ label,
314
+ verdict: "added",
315
+ newValue
316
+ };
317
+ if (Object.is(oldValue, newValue)) return {
318
+ key,
319
+ label,
320
+ verdict: "unchanged"
321
+ };
322
+ const tooLarge = typeof oldValue === "string" && oldValue.length > ITEM_RENDER_CHAR_LIMIT || typeof newValue === "string" && newValue.length > ITEM_RENDER_CHAR_LIMIT;
323
+ if (tooLarge) {
324
+ return {
325
+ key,
326
+ label,
327
+ verdict: "too-large",
328
+ reason: "value too large to render a diff — restore still applies it"
329
+ };
330
+ }
331
+ return {
332
+ key,
333
+ label,
334
+ verdict: "changed",
335
+ oldValue,
336
+ newValue
337
+ };
338
+ }
339
+
340
+ /** Per-key restore preview across all three backends. */
341
+ async function previewItems(target, base) {
342
+ const targetData = target ?? {
343
+ async: [],
344
+ mmkv: {},
345
+ secure: {}
346
+ };
347
+ const liveMode = base === undefined;
348
+ const baseData = liveMode ? (await captureStorageData()).data : base ?? {
349
+ async: [],
350
+ mmkv: {},
351
+ secure: {}
352
+ };
353
+ const items = [];
354
+ const notes = [];
355
+
356
+ // ── AsyncStorage ──
357
+ const oldAsync = new Map(baseData.async ?? []);
358
+ const newAsync = new Map(targetData.async ?? []);
359
+ for (const key of new Set([...oldAsync.keys(), ...newAsync.keys()])) {
360
+ if ((0, _sharedUi.isDevToolsStorageKey)(key)) continue;
361
+ items.push(classifyValue(`async:${key}`, key, oldAsync.get(key), newAsync.get(key), oldAsync.has(key), newAsync.has(key)));
362
+ }
363
+
364
+ // ── MMKV ──
365
+ const instanceIds = new Set([...Object.keys(baseData.mmkv ?? {}), ...Object.keys(targetData.mmkv ?? {})]);
366
+ for (const id of instanceIds) {
367
+ const registered = liveMode ? _MMKVInstanceRegistry.mmkvInstanceRegistry.get(id) : null;
368
+ const instanceUnavailable = liveMode && !registered;
369
+ const readOnly = liveMode && registered?.instance.isReadOnly === true;
370
+ const oldEntries = new Map((baseData.mmkv?.[id] ?? []).map(e => [e.key, e]));
371
+ const newEntries = new Map((targetData.mmkv?.[id] ?? []).map(e => [e.key, e]));
372
+ for (const key of new Set([...oldEntries.keys(), ...newEntries.keys()])) {
373
+ if ((0, _sharedUi.isDevToolsStorageKey)(key)) continue;
374
+ const itemKey = `mmkv:${id}/${key}`;
375
+ const label = `${id} · ${key}`;
376
+ const oldE = oldEntries.get(key);
377
+ const newE = newEntries.get(key);
378
+ let item = classifyValue(itemKey, label, oldE?.value, newE?.value, oldEntries.has(key), newEntries.has(key));
379
+ if (item.verdict !== "unchanged") {
380
+ if (instanceUnavailable) {
381
+ item = {
382
+ ...item,
383
+ verdict: "wont-apply",
384
+ reason: "MMKV instance not registered"
385
+ };
386
+ } else if (readOnly) {
387
+ item = {
388
+ ...item,
389
+ verdict: "wont-apply",
390
+ reason: "MMKV instance registered read-only"
391
+ };
392
+ } else if (newE?.valueType === "buffer" || oldE?.valueType === "buffer") {
393
+ item = {
394
+ ...item,
395
+ verdict: "wont-apply",
396
+ reason: "ArrayBuffer values cannot be restored"
397
+ };
398
+ }
399
+ }
400
+ items.push(item);
401
+ }
402
+ }
403
+
404
+ // ── SecureStore ──
405
+ const oldSecure = baseData.secure ?? {};
406
+ const newSecure = targetData.secure ?? {};
407
+ const registeredSecure = liveMode ? new Map(_SecureStoreRegistry.secureStoreRegistry.getAll().map(e => [e.key, e])) : null;
408
+ for (const key of new Set([...Object.keys(oldSecure), ...Object.keys(newSecure)])) {
409
+ let item = classifyValue(`secure:${key}`, key, oldSecure[key], newSecure[key], key in oldSecure, key in newSecure);
410
+ if (liveMode && item.verdict !== "unchanged") {
411
+ const entry = registeredSecure?.get(key);
412
+ if (!entry) {
413
+ item = {
414
+ ...item,
415
+ verdict: "wont-apply",
416
+ reason: "key no longer registered with registerSecureStoreKeys"
417
+ };
418
+ } else if (entry.options.requireAuthentication) {
419
+ item = {
420
+ ...item,
421
+ verdict: "wont-apply",
422
+ reason: "biometric-protected keys are never written"
423
+ };
424
+ }
425
+ }
426
+ items.push(item);
427
+ }
428
+ if (liveMode) {
429
+ const biometric = _SecureStoreRegistry.secureStoreRegistry.getAll().filter(e => e.options.requireAuthentication).length;
430
+ if (biometric > 0) {
431
+ notes.push(`${biometric} biometric-protected secure key(s) are never read or written`);
432
+ }
433
+ }
434
+ return {
435
+ items,
436
+ notes
437
+ };
438
+ }
439
+ (0, _sharedUi.registerStateSnapshotProvider)({
440
+ id: "storage",
441
+ label: "Storage",
442
+ status,
443
+ capture,
444
+ restore,
445
+ clear,
446
+ previewItems
447
+ });
@@ -16,6 +16,10 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e
16
16
  * Provides jump and undo functionality for AsyncStorage operations.
17
17
  */
18
18
 
19
+ function isOmittedStorageValue(value) {
20
+ return !!value && typeof value === "object" && value.__buoyValueOnDevice === true;
21
+ }
22
+
19
23
  /**
20
24
  * Undo a single storage operation
21
25
  *
@@ -35,6 +39,9 @@ async function undoOperation(event) {
35
39
  action,
36
40
  data
37
41
  } = event;
42
+ if (isOmittedStorageValue(data?.prevValue) || isOmittedStorageValue(data?.prevPairs)) {
43
+ throw new Error("Cannot undo: previous value is still on the device — fetch getEventDetail first");
44
+ }
38
45
  {
39
46
  switch (action) {
40
47
  case "setItem":
@@ -113,6 +120,12 @@ async function jumpToState(events, targetEventIndex) {
113
120
  if (targetEventIndex < 0 || targetEventIndex >= events.length) {
114
121
  throw new Error(`Invalid event index: ${targetEventIndex}`);
115
122
  }
123
+ for (const event of events) {
124
+ const data = event.data;
125
+ if (isOmittedStorageValue(data?.value) || isOmittedStorageValue(data?.prevValue) || isOmittedStorageValue(data?.pairs) || isOmittedStorageValue(data?.prevPairs)) {
126
+ throw new Error("Cannot jump: some event values are still on the device — fetch getEventDetail first");
127
+ }
128
+ }
116
129
 
117
130
  // Build up the state by applying each event up to and including the target.
118
131
  // We use the final values from each event, not the operations themselves.
@@ -260,14 +273,15 @@ function canUndo(event) {
260
273
  case "setItem":
261
274
  case "mergeItem":
262
275
  case "removeItem":
263
- // Can undo if we have the key
264
- return !!data?.key;
276
+ // Can undo if we have the key — but not while prevValue is still a
277
+ // wire-form on-device marker (restoring that would write garbage).
278
+ return !!data?.key && !isOmittedStorageValue(data.prevValue);
265
279
  case "multiSet":
266
280
  case "multiRemove":
267
281
  case "multiMerge":
268
282
  case "clear":
269
283
  // Can undo if we have previous pairs
270
- return !!data?.prevPairs && data.prevPairs.length > 0;
284
+ return !!data?.prevPairs && data.prevPairs.length > 0 && !isOmittedStorageValue(data.prevPairs);
271
285
  default:
272
286
  return false;
273
287
  }
@@ -11,6 +11,9 @@
11
11
  * bypassing the tool's intended usage patterns.
12
12
  */
13
13
 
14
+ // Side-effect: registers the Time Machine snapshot provider
15
+ import "./storage/utils/snapshotProvider";
16
+
14
17
  // =============================================================================
15
18
  // PRESET (Primary entry point for users)
16
19
  // =============================================================================
@@ -79,7 +82,12 @@ export { storageSyncAdapter } from "./storage/sync/storageSyncAdapter";
79
82
  // INTERNAL EXPORTS (For @buoy-gg/* packages only - not part of public API)
80
83
  // =============================================================================
81
84
  /** @internal */
82
- export { storageEventStore } from "./storage/stores/storageEventStore";
85
+ export { storageEventStore,
86
+ // Exported so remote mirrors (Buoy Desktop) can ask "did the device withhold
87
+ // this value?" without re-deriving the wire contract — and, in the case of
88
+ // `batchPairsOf`, without re-introducing the union-narrowing bug that reading
89
+ // `pairs`/`prevPairs` off a StorageEvent invites.
90
+ isValueOnDevice, batchPairsOf } from "./storage/stores/storageEventStore";
83
91
  /**
84
92
  * Tell a mounted storage browser to re-read every backend. Used by the desktop
85
93
  * dashboard, where local storage listeners are suppressed and device data