@buoy-gg/storage 7.0.4 → 7.0.6

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 (148) hide show
  1. package/lib/commonjs/index.js +77 -1
  2. package/lib/commonjs/storage/components/DiffViewer/TreeDiffViewer.js +10 -3
  3. package/lib/commonjs/storage/components/DiffViewer/modes/ThemedSplitView.js +26 -8
  4. package/lib/commonjs/storage/components/GameUIStorageBrowser.js +263 -115
  5. package/lib/commonjs/storage/components/GameUIStorageStats.js +7 -7
  6. package/lib/commonjs/storage/components/MMKVInstanceInfoPanel.js +1 -1
  7. package/lib/commonjs/storage/components/MMKVInstanceSelector.js +1 -1
  8. package/lib/commonjs/storage/components/SelectionActionBar.js +2 -36
  9. package/lib/commonjs/storage/components/StorageActions.js +6 -6
  10. package/lib/commonjs/storage/components/StorageBrowserMode.js +16 -2
  11. package/lib/commonjs/storage/components/StorageEventDetailContent.js +106 -149
  12. package/lib/commonjs/storage/components/StorageEventListener.js +13 -13
  13. package/lib/commonjs/storage/components/StorageFilterCards.js +13 -135
  14. package/lib/commonjs/storage/components/StorageKeyCard.js +1 -1
  15. package/lib/commonjs/storage/components/StorageKeyEditorScreen.js +392 -0
  16. package/lib/commonjs/storage/components/StorageKeyRow.js +53 -15
  17. package/lib/commonjs/storage/components/StorageKeySection.js +10 -4
  18. package/lib/commonjs/storage/components/StorageKeyStats.js +10 -10
  19. package/lib/commonjs/storage/components/StorageModalWithTabs.js +272 -129
  20. package/lib/commonjs/storage/components/StorageScalarKeyEditor.js +46 -0
  21. package/lib/commonjs/storage/components/StorageTypeFilterBar.js +141 -0
  22. package/lib/commonjs/storage/components/StorageValueEditModal.js +361 -0
  23. package/lib/commonjs/storage/hooks/useKeyEditor.js +104 -0
  24. package/lib/commonjs/storage/hooks/useMeasuredWidth.js +74 -0
  25. package/lib/commonjs/storage/hooks/useSecureStoreKeys.js +70 -15
  26. package/lib/commonjs/storage/hooks/useTreeDraft.js +107 -0
  27. package/lib/commonjs/storage/index.js +29 -0
  28. package/lib/commonjs/storage/stores/storageEventStore.js +4 -2
  29. package/lib/commonjs/storage/sync/storageSyncAdapter.js +125 -3
  30. package/lib/commonjs/storage/utils/AsyncStorageListener.js +76 -12
  31. package/lib/commonjs/storage/utils/MMKVInstanceRegistry.js +10 -9
  32. package/lib/commonjs/storage/utils/MMKVListener.js +40 -4
  33. package/lib/commonjs/storage/utils/SecureStoreRegistry.js +52 -1
  34. package/lib/commonjs/storage/utils/deleteStorageKeys.js +53 -0
  35. package/lib/commonjs/storage/utils/index.js +13 -0
  36. package/lib/commonjs/storage/utils/lineDiff.js +21 -354
  37. package/lib/commonjs/storage/utils/setStorageValue.js +218 -0
  38. package/lib/commonjs/storage/utils/storageBrowserRefresh.js +49 -0
  39. package/lib/commonjs/storage/utils/storageOps.js +53 -0
  40. package/lib/commonjs/storage/utils/storageTimeTravelUtils.js +60 -38
  41. package/lib/commonjs/storage/utils/valueType.js +24 -0
  42. package/lib/module/index.js +18 -1
  43. package/lib/module/storage/components/DiffViewer/TreeDiffViewer.js +10 -3
  44. package/lib/module/storage/components/DiffViewer/modes/ThemedSplitView.js +26 -8
  45. package/lib/module/storage/components/GameUIStorageBrowser.js +266 -118
  46. package/lib/module/storage/components/GameUIStorageStats.js +7 -7
  47. package/lib/module/storage/components/MMKVInstanceInfoPanel.js +1 -1
  48. package/lib/module/storage/components/MMKVInstanceSelector.js +1 -1
  49. package/lib/module/storage/components/SelectionActionBar.js +2 -36
  50. package/lib/module/storage/components/StorageActions.js +7 -7
  51. package/lib/module/storage/components/StorageBrowserMode.js +16 -2
  52. package/lib/module/storage/components/StorageEventDetailContent.js +110 -151
  53. package/lib/module/storage/components/StorageEventListener.js +14 -14
  54. package/lib/module/storage/components/StorageFilterCards.js +13 -135
  55. package/lib/module/storage/components/StorageKeyCard.js +1 -1
  56. package/lib/module/storage/components/StorageKeyEditorScreen.js +388 -0
  57. package/lib/module/storage/components/StorageKeyRow.js +55 -17
  58. package/lib/module/storage/components/StorageKeySection.js +11 -5
  59. package/lib/module/storage/components/StorageKeyStats.js +11 -10
  60. package/lib/module/storage/components/StorageModalWithTabs.js +272 -130
  61. package/lib/module/storage/components/StorageScalarKeyEditor.js +42 -0
  62. package/lib/module/storage/components/StorageTypeFilterBar.js +136 -0
  63. package/lib/module/storage/components/StorageValueEditModal.js +357 -0
  64. package/lib/module/storage/hooks/useKeyEditor.js +100 -0
  65. package/lib/module/storage/hooks/useMeasuredWidth.js +71 -0
  66. package/lib/module/storage/hooks/useSecureStoreKeys.js +71 -16
  67. package/lib/module/storage/hooks/useTreeDraft.js +103 -0
  68. package/lib/module/storage/index.js +3 -0
  69. package/lib/module/storage/stores/storageEventStore.js +4 -2
  70. package/lib/module/storage/sync/storageSyncAdapter.js +125 -3
  71. package/lib/module/storage/utils/AsyncStorageListener.js +76 -12
  72. package/lib/module/storage/utils/MMKVInstanceRegistry.js +8 -7
  73. package/lib/module/storage/utils/MMKVListener.js +40 -4
  74. package/lib/module/storage/utils/SecureStoreRegistry.js +52 -1
  75. package/lib/module/storage/utils/deleteStorageKeys.js +49 -0
  76. package/lib/module/storage/utils/index.js +3 -0
  77. package/lib/module/storage/utils/lineDiff.js +7 -354
  78. package/lib/module/storage/utils/setStorageValue.js +209 -0
  79. package/lib/module/storage/utils/storageBrowserRefresh.js +44 -0
  80. package/lib/module/storage/utils/storageOps.js +46 -0
  81. package/lib/module/storage/utils/storageTimeTravelUtils.js +61 -40
  82. package/lib/module/storage/utils/valueType.js +23 -0
  83. package/lib/typescript/index.d.ts +12 -1
  84. package/lib/typescript/index.d.ts.map +1 -1
  85. package/lib/typescript/storage/components/DiffViewer/TreeDiffViewer.d.ts +4 -1
  86. package/lib/typescript/storage/components/DiffViewer/TreeDiffViewer.d.ts.map +1 -1
  87. package/lib/typescript/storage/components/DiffViewer/modes/ThemedSplitView.d.ts +7 -1
  88. package/lib/typescript/storage/components/DiffViewer/modes/ThemedSplitView.d.ts.map +1 -1
  89. package/lib/typescript/storage/components/GameUIStorageBrowser.d.ts +27 -1
  90. package/lib/typescript/storage/components/GameUIStorageBrowser.d.ts.map +1 -1
  91. package/lib/typescript/storage/components/SelectionActionBar.d.ts.map +1 -1
  92. package/lib/typescript/storage/components/StorageBrowserMode.d.ts +17 -1
  93. package/lib/typescript/storage/components/StorageBrowserMode.d.ts.map +1 -1
  94. package/lib/typescript/storage/components/StorageEventDetailContent.d.ts +4 -1
  95. package/lib/typescript/storage/components/StorageEventDetailContent.d.ts.map +1 -1
  96. package/lib/typescript/storage/components/StorageFilterCards.d.ts +9 -3
  97. package/lib/typescript/storage/components/StorageFilterCards.d.ts.map +1 -1
  98. package/lib/typescript/storage/components/StorageKeyEditorScreen.d.ts +42 -0
  99. package/lib/typescript/storage/components/StorageKeyEditorScreen.d.ts.map +1 -0
  100. package/lib/typescript/storage/components/StorageKeyRow.d.ts +17 -0
  101. package/lib/typescript/storage/components/StorageKeyRow.d.ts.map +1 -1
  102. package/lib/typescript/storage/components/StorageKeySection.d.ts +7 -1
  103. package/lib/typescript/storage/components/StorageKeySection.d.ts.map +1 -1
  104. package/lib/typescript/storage/components/StorageKeyStats.d.ts.map +1 -1
  105. package/lib/typescript/storage/components/StorageModalWithTabs.d.ts +28 -1
  106. package/lib/typescript/storage/components/StorageModalWithTabs.d.ts.map +1 -1
  107. package/lib/typescript/storage/components/StorageScalarKeyEditor.d.ts +24 -0
  108. package/lib/typescript/storage/components/StorageScalarKeyEditor.d.ts.map +1 -0
  109. package/lib/typescript/storage/components/StorageTypeFilterBar.d.ts +29 -0
  110. package/lib/typescript/storage/components/StorageTypeFilterBar.d.ts.map +1 -0
  111. package/lib/typescript/storage/components/StorageValueEditModal.d.ts +36 -0
  112. package/lib/typescript/storage/components/StorageValueEditModal.d.ts.map +1 -0
  113. package/lib/typescript/storage/hooks/useKeyEditor.d.ts +51 -0
  114. package/lib/typescript/storage/hooks/useKeyEditor.d.ts.map +1 -0
  115. package/lib/typescript/storage/hooks/useMeasuredWidth.d.ts +37 -0
  116. package/lib/typescript/storage/hooks/useMeasuredWidth.d.ts.map +1 -0
  117. package/lib/typescript/storage/hooks/useSecureStoreKeys.d.ts +8 -1
  118. package/lib/typescript/storage/hooks/useSecureStoreKeys.d.ts.map +1 -1
  119. package/lib/typescript/storage/hooks/useTreeDraft.d.ts +67 -0
  120. package/lib/typescript/storage/hooks/useTreeDraft.d.ts.map +1 -0
  121. package/lib/typescript/storage/index.d.ts +2 -0
  122. package/lib/typescript/storage/index.d.ts.map +1 -1
  123. package/lib/typescript/storage/sync/storageSyncAdapter.d.ts +41 -0
  124. package/lib/typescript/storage/sync/storageSyncAdapter.d.ts.map +1 -1
  125. package/lib/typescript/storage/utils/AsyncStorageListener.d.ts +15 -0
  126. package/lib/typescript/storage/utils/AsyncStorageListener.d.ts.map +1 -1
  127. package/lib/typescript/storage/utils/MMKVInstanceRegistry.d.ts.map +1 -1
  128. package/lib/typescript/storage/utils/MMKVListener.d.ts +7 -0
  129. package/lib/typescript/storage/utils/MMKVListener.d.ts.map +1 -1
  130. package/lib/typescript/storage/utils/SecureStoreRegistry.d.ts +20 -0
  131. package/lib/typescript/storage/utils/SecureStoreRegistry.d.ts.map +1 -1
  132. package/lib/typescript/storage/utils/deleteStorageKeys.d.ts +27 -0
  133. package/lib/typescript/storage/utils/deleteStorageKeys.d.ts.map +1 -0
  134. package/lib/typescript/storage/utils/index.d.ts +1 -0
  135. package/lib/typescript/storage/utils/index.d.ts.map +1 -1
  136. package/lib/typescript/storage/utils/lineDiff.d.ts +7 -31
  137. package/lib/typescript/storage/utils/lineDiff.d.ts.map +1 -1
  138. package/lib/typescript/storage/utils/setStorageValue.d.ts +64 -0
  139. package/lib/typescript/storage/utils/setStorageValue.d.ts.map +1 -0
  140. package/lib/typescript/storage/utils/storageBrowserRefresh.d.ts +28 -0
  141. package/lib/typescript/storage/utils/storageBrowserRefresh.d.ts.map +1 -0
  142. package/lib/typescript/storage/utils/storageOps.d.ts +25 -0
  143. package/lib/typescript/storage/utils/storageOps.d.ts.map +1 -0
  144. package/lib/typescript/storage/utils/storageTimeTravelUtils.d.ts +15 -5
  145. package/lib/typescript/storage/utils/storageTimeTravelUtils.d.ts.map +1 -1
  146. package/lib/typescript/storage/utils/valueType.d.ts +13 -0
  147. package/lib/typescript/storage/utils/valueType.d.ts.map +1 -1
  148. package/package.json +5 -5
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- import { useState, useEffect, useCallback } from "react";
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
4
  import { secureStoreRegistry } from "../utils/SecureStoreRegistry";
5
5
 
6
6
  /**
@@ -9,6 +9,23 @@ import { secureStoreRegistry } from "../utils/SecureStoreRegistry";
9
9
  * auto-reads them — it just shows they exist.
10
10
  */
11
11
  const BIOMETRIC_PLACEHOLDER = "•••••• (biometric protected)";
12
+
13
+ /**
14
+ * How often to re-read registered SecureStore values.
15
+ *
16
+ * SecureStore is the one backend with no change feed at all: AsyncStorage and
17
+ * MMKV writes are intercepted (and MMKV has a native value-changed listener),
18
+ * but the keychain gives no notification, so a value written by the app while
19
+ * the tool is open would otherwise stay stale until the browser remounts.
20
+ * Polling is the only option — kept cheap by re-reading a handful of registered
21
+ * keys and only committing state when a value actually changed.
22
+ */
23
+ const DEFAULT_POLL_MS = 3000;
24
+ /** Identity signature used to skip no-op state updates. */
25
+ function signature(keys) {
26
+ return JSON.stringify(keys);
27
+ }
28
+
12
29
  /**
13
30
  * Read all registered SecureStore keys into StorageKeyInfo[] for the browser.
14
31
  *
@@ -16,22 +33,47 @@ const BIOMETRIC_PLACEHOLDER = "•••••• (biometric protected)";
16
33
  * `registerSecureStoreKeys()` (see SecureStoreRegistry). Values are read with
17
34
  * the options each key was registered with (keychainService must match), and
18
35
  * biometric-protected keys are listed without being read.
36
+ *
37
+ * Values are re-read on a poll and whenever the registry changes, so the
38
+ * browser tracks writes the app makes while the tool is open.
19
39
  */
20
- export function useSecureStoreKeys() {
40
+ export function useSecureStoreKeys(options = {}) {
41
+ const {
42
+ pollMs = DEFAULT_POLL_MS
43
+ } = options;
21
44
  const [storageKeys, setStorageKeys] = useState([]);
22
45
  const [isLoading, setIsLoading] = useState(true);
23
46
  const [error, setError] = useState(null);
47
+
48
+ // Last committed content, so a poll that finds nothing new costs zero
49
+ // renders (this hook feeds a non-virtualized list of every storage key).
50
+ const signatureRef = useRef(signature([]));
51
+ const hasLoadedRef = useRef(false);
52
+ const inFlightRef = useRef(false);
53
+ const commit = useCallback(keys => {
54
+ const next = signature(keys);
55
+ if (next === signatureRef.current) return;
56
+ signatureRef.current = next;
57
+ setStorageKeys(keys);
58
+ }, []);
24
59
  const fetchSecureStoreData = useCallback(async () => {
25
- const module = secureStoreRegistry.getModule();
26
- const entries = secureStoreRegistry.getAll();
27
- if (!module || entries.length === 0) {
28
- setStorageKeys([]);
29
- setIsLoading(false);
30
- return;
31
- }
32
- setIsLoading(true);
33
- setError(null);
60
+ // Reads are async and the poll is unconditional — never stack them.
61
+ if (inFlightRef.current) return;
62
+ inFlightRef.current = true;
34
63
  try {
64
+ const module = secureStoreRegistry.getModule();
65
+ const entries = secureStoreRegistry.getAll();
66
+ if (!module || entries.length === 0) {
67
+ commit([]);
68
+ hasLoadedRef.current = true;
69
+ setIsLoading(false);
70
+ return;
71
+ }
72
+
73
+ // Only the first read is a "loading" state; polls refresh in place so the
74
+ // browser never flashes its spinner over data it already has.
75
+ if (!hasLoadedRef.current) setIsLoading(true);
76
+ setError(null);
35
77
  const keys = [];
36
78
  for (const entry of entries) {
37
79
  if (entry.options.requireAuthentication) {
@@ -79,19 +121,32 @@ export function useSecureStoreKeys() {
79
121
  })
80
122
  });
81
123
  }
82
- setStorageKeys(keys);
124
+ commit(keys);
83
125
  } catch (err) {
84
126
  setError(err instanceof Error ? err : new Error("Failed to read SecureStore"));
85
- setStorageKeys([]);
127
+ commit([]);
86
128
  } finally {
129
+ hasLoadedRef.current = true;
130
+ inFlightRef.current = false;
87
131
  setIsLoading(false);
88
132
  }
89
- }, []);
133
+ }, [commit]);
90
134
 
91
- // Initial fetch (registration happens at app startup, before the tool opens)
135
+ // Initial fetch + poll. Registration usually happens at app startup, but the
136
+ // registry is also refilled on the dashboard every time a device syncs, so
137
+ // subscribe as well rather than assuming the key list is final at mount.
92
138
  useEffect(() => {
93
139
  fetchSecureStoreData();
94
- }, [fetchSecureStoreData]);
140
+ const unsubscribe = secureStoreRegistry.subscribe(() => {
141
+ fetchSecureStoreData();
142
+ });
143
+ if (pollMs <= 0) return unsubscribe;
144
+ const interval = setInterval(fetchSecureStoreData, pollMs);
145
+ return () => {
146
+ unsubscribe();
147
+ clearInterval(interval);
148
+ };
149
+ }, [fetchSecureStoreData, pollMs]);
95
150
  return {
96
151
  storageKeys,
97
152
  isLoading,
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * A local draft of a storage value, edited in place and written ONCE.
5
+ *
6
+ * Applying each tree op straight to the device was the obvious first cut and it
7
+ * behaves badly for two compounding reasons:
8
+ *
9
+ * 1. **Every op is a round trip.** The write lands, the device emits a storage
10
+ * event, a snapshot comes back, and the browser re-reads every backend — so
11
+ * a six-tap reorder is six writes and six full refreshes. The tree rebuilds
12
+ * under the user mid-gesture.
13
+ * 2. **The highlight and the value fall out of step.** Selection is local and
14
+ * moves instantly; the value it points at only catches up when the round
15
+ * trip completes, so for a beat the highlight sits on the old contents.
16
+ *
17
+ * Optimistic updates — the React Query pattern of rendering the expected result
18
+ * and reconciling later — fix (2) but not (1): they still write per change.
19
+ * Buffering fixes both, and it's what every editor that owns a document does
20
+ * (a form's dirty state, an IDE's unsaved buffer, JSON Editor's in-memory doc
21
+ * with an explicit save).
22
+ *
23
+ * The trade is that the buffer can go stale if the app writes the same key
24
+ * while you're editing. That's the same last-write-wins a text editor has, and
25
+ * `isDirty` at least makes it visible that you're holding unsaved changes.
26
+ */
27
+
28
+ import { useCallback, useState } from "react";
29
+ import { applyJsonOp, selectionPathAfterOp } from "@buoy-gg/shared-ui/dataViewer";
30
+ /**
31
+ * @param liveValue the value as storage currently holds it (already parsed)
32
+ * @param save persists the serialized draft; rejecting keeps edit mode open
33
+ */
34
+ export function useTreeDraft(liveValue, save) {
35
+ const [draft, setDraft] = useState(undefined);
36
+ const [isEditing, setIsEditing] = useState(false);
37
+ const [isSaving, setIsSaving] = useState(false);
38
+ const [error, setError] = useState(null);
39
+ const [selectPath, setSelectPath] = useState();
40
+ /**
41
+ * The value as it was when editing began. Compared against for dirtiness
42
+ * rather than against `liveValue`, which can move underneath us if the app
43
+ * writes the same key — that would otherwise flip a clean draft to dirty.
44
+ */
45
+ const [baseline, setBaseline] = useState("");
46
+ const value = isEditing && draft !== undefined ? draft : liveValue;
47
+ const isDirty = isEditing && JSON.stringify(draft) !== baseline;
48
+ const begin = useCallback(() => {
49
+ setDraft(liveValue);
50
+ setBaseline(JSON.stringify(liveValue));
51
+ setSelectPath(undefined);
52
+ setError(null);
53
+ setIsEditing(true);
54
+ }, [liveValue]);
55
+ const apply = useCallback(op => {
56
+ // Purely local — this is the whole point. No write, so no event, no
57
+ // snapshot, no refresh, and the tree re-renders from state synchronously.
58
+ setDraft(prev => applyJsonOp(prev, op));
59
+ const next = selectionPathAfterOp(op);
60
+ if (next !== undefined) setSelectPath(next);
61
+ }, []);
62
+ const selectAt = useCallback(path => {
63
+ setSelectPath(path);
64
+ }, []);
65
+ const reset = useCallback(() => {
66
+ setIsEditing(false);
67
+ setDraft(undefined);
68
+ setSelectPath(undefined);
69
+ setError(null);
70
+ }, []);
71
+ const commit = useCallback(async () => {
72
+ if (!isDirty || !save) {
73
+ reset();
74
+ return true;
75
+ }
76
+ setIsSaving(true);
77
+ setError(null);
78
+ try {
79
+ await save(JSON.stringify(draft));
80
+ reset();
81
+ return true;
82
+ } catch (e) {
83
+ // Stay in edit mode so the work isn't lost to a failed write.
84
+ setError(e instanceof Error ? e.message : String(e));
85
+ return false;
86
+ } finally {
87
+ setIsSaving(false);
88
+ }
89
+ }, [draft, isDirty, reset, save]);
90
+ return {
91
+ isEditing,
92
+ value,
93
+ isDirty,
94
+ isSaving,
95
+ error,
96
+ selectPath,
97
+ begin,
98
+ apply,
99
+ selectAt,
100
+ commit,
101
+ discard: reset
102
+ };
103
+ }
@@ -8,6 +8,9 @@ export { StorageKeyStatsSection } from "./components/StorageKeyStats";
8
8
  export { StorageKeySection } from "./components/StorageKeySection";
9
9
  export { StorageBrowserMode } from "./components/StorageBrowserMode";
10
10
  export { StorageEventsSection } from "./components/StorageEventsSection";
11
+ // The five display operations a storage event collapses onto — semantics, not
12
+ // presentation, so hosts can filter or group by the same vocabulary the UI uses.
13
+ export { STORAGE_OPS, storageOpOf, storageOpDot, isInitialScan } from "./utils/storageOps";
11
14
  export { StorageEventCard, getValueType } from "./components/StorageEventCard";
12
15
  export { StorageEventDetailContent, StorageEventDetailFooter } from "./components/StorageEventDetailContent";
13
16
 
@@ -98,7 +98,8 @@ class StorageEventStore extends BaseEventStore {
98
98
  data: {
99
99
  key,
100
100
  value,
101
- valueType
101
+ valueType,
102
+ initialScan: true
102
103
  },
103
104
  storageType: "mmkv",
104
105
  id: nextStorageEventId()
@@ -125,7 +126,8 @@ class StorageEventStore extends BaseEventStore {
125
126
  timestamp: now,
126
127
  data: {
127
128
  key,
128
- value: rawValue ?? undefined
129
+ value: rawValue ?? undefined,
130
+ initialScan: true
129
131
  },
130
132
  storageType: "async",
131
133
  id: nextStorageEventId()
@@ -5,6 +5,7 @@ import { isDevToolsStorageKey } from "@buoy-gg/shared-ui";
5
5
  import { readMany, writeMany, removeMany } from "../utils/asyncStorageCompat";
6
6
  import { storageEventStore } from "../stores/storageEventStore";
7
7
  import { clearAllAppStorage } from "../utils/clearAllStorage";
8
+ import { undoOperation, jumpToState, canUndo } from "../utils/storageTimeTravelUtils";
8
9
  import { mmkvInstanceRegistry } from "../utils/MMKVInstanceRegistry";
9
10
  import { detectMMKVType } from "../utils/mmkvTypeDetection";
10
11
  import { secureStoreRegistry } from "../utils/SecureStoreRegistry";
@@ -13,6 +14,28 @@ import { secureStoreRegistry } from "../utils/SecureStoreRegistry";
13
14
 
14
15
  /** One MMKV instance in a `mmkv.snapshot` result. */
15
16
 
17
+ /** One registered key in a `secure.snapshot` result. */
18
+
19
+ /**
20
+ * Resolve an MMKV instance for WRITING, refusing the ones the app registered
21
+ * read-only.
22
+ *
23
+ * The registry has always tracked `readOnly`, but nothing enforced it: any sync
24
+ * client — dashboard or MCP — could write to an instance the app explicitly
25
+ * declared immutable. That was harmless while the tool could only read; it
26
+ * stops being harmless the moment there's an edit UI pointed at it.
27
+ */
28
+ function assertMMKVWritable(instanceId) {
29
+ const registered = mmkvInstanceRegistry.get(instanceId);
30
+ if (!registered) {
31
+ throw new Error(`No MMKV instance registered as "${instanceId}"`);
32
+ }
33
+ if (registered.instance?.isReadOnly) {
34
+ throw new Error(`MMKV instance "${instanceId}" is registered read-only`);
35
+ }
36
+ return registered.instance;
37
+ }
38
+
16
39
  /**
17
40
  * Sync adapter for the storage tool, consumed by @buoy-gg/external-sync's
18
41
  * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
@@ -42,6 +65,58 @@ export const storageSyncAdapter = {
42
65
  },
43
66
  /** Clears all app storage keys, preserving dev tools settings. */
44
67
  clearAppStorage: () => clearAllAppStorage(),
68
+ // ── Time travel (same code path as the modal's UNDO / JUMP buttons) ──
69
+ // The UI swallows failures silently; these THROW so a caller (desktop, MCP)
70
+ // gets the actual reason back instead of a no-op.
71
+
72
+ /** Undo one event, addressed by the `id` the store assigned it. */
73
+ "timeTravel.undo": async params => {
74
+ const id = params?.id;
75
+ if (!id) throw new Error("timeTravel.undo requires an event `id`");
76
+ const event = storageEventStore.getEvents().find(e => e.id === id);
77
+ if (!event) throw new Error(`No storage event with id "${id}"`);
78
+ if (event.storageType === "mmkv") {
79
+ throw new Error("Time travel supports AsyncStorage events only");
80
+ }
81
+ const asyncEvent = event;
82
+ if (!canUndo(asyncEvent)) {
83
+ throw new Error(`Event "${id}" (${asyncEvent.action}) has no previous value captured, so it cannot be undone`);
84
+ }
85
+ await undoOperation(asyncEvent);
86
+ return {
87
+ undone: true,
88
+ action: asyncEvent.action,
89
+ key: asyncEvent.data?.key
90
+ };
91
+ },
92
+ /**
93
+ * Jump storage back to the state as of one event. `scope: "key"` (default)
94
+ * replays only that key's history, matching the modal's JUMP button;
95
+ * `scope: "all"` replays every captured AsyncStorage event.
96
+ */
97
+ "timeTravel.jump": async params => {
98
+ const {
99
+ id,
100
+ scope = "key"
101
+ } = params ?? {};
102
+ if (!id) throw new Error("timeTravel.jump requires an event `id`");
103
+
104
+ // Oldest-first, which is the order jumpToState replays in.
105
+ const all = storageEventStore.getEvents().filter(e => e.storageType !== "mmkv").slice().reverse();
106
+ const target = all.find(e => e.id === id);
107
+ if (!target) throw new Error(`No AsyncStorage event with id "${id}"`);
108
+ const timeline = scope === "all" ? all : all.filter(e => e.data?.key === target.data?.key);
109
+ const index = timeline.findIndex(e => e.id === id);
110
+ if (index === -1) throw new Error(`Event "${id}" is not in the timeline`);
111
+ await jumpToState(timeline, index);
112
+ return {
113
+ jumped: true,
114
+ scope,
115
+ replayed: index + 1,
116
+ of: timeline.length,
117
+ key: target.data?.key
118
+ };
119
+ },
45
120
  // ── Remote AsyncStorage proxy (desktop browse/edit mode) ──
46
121
  // Single-item methods share the same signature across async-storage v2/v3;
47
122
  // batch methods go through the compat helpers (which translate to v3's
@@ -104,16 +179,16 @@ export const storageSyncAdapter = {
104
179
  key,
105
180
  value
106
181
  } = params;
107
- mmkvInstanceRegistry.get(instanceId)?.instance.set(key, value);
182
+ assertMMKVWritable(instanceId).set(key, value);
108
183
  },
109
184
  "mmkv.remove": params => {
110
185
  const {
111
186
  instanceId,
112
187
  key
113
188
  } = params;
114
- const instance = mmkvInstanceRegistry.get(instanceId)?.instance;
189
+ const instance = assertMMKVWritable(instanceId);
115
190
  // v4 renamed delete() to remove() — support both
116
- if (instance?.remove) instance.remove(key);else instance?.delete?.(key);
191
+ if (instance.remove) instance.remove(key);else instance.delete?.(key);
117
192
  },
118
193
  // ── Remote SecureStore proxy ──
119
194
  // Only registered keys are known (SecureStore has no enumeration API).
@@ -128,6 +203,40 @@ export const storageSyncAdapter = {
128
203
  keychainService: options.keychainService,
129
204
  requireAuthentication: options.requireAuthentication ?? false
130
205
  })),
206
+ /**
207
+ * Every registered key WITH its value, in one round trip — the SecureStore
208
+ * counterpart to `mmkv.snapshot`. The dashboard polls this (the keychain
209
+ * has no change feed) and serves the browser from the result, instead of
210
+ * firing one `secure.get` per key per refresh.
211
+ */
212
+ "secure.snapshot": async () => {
213
+ const module = secureStoreRegistry.getModule();
214
+ const entries = secureStoreRegistry.getAll();
215
+ const result = [];
216
+ for (const {
217
+ key,
218
+ options,
219
+ description
220
+ } of entries) {
221
+ let value = null;
222
+ // Biometric-protected keys are never read — that would fire an auth prompt.
223
+ if (module && !options.requireAuthentication) {
224
+ try {
225
+ value = await module.getItemAsync(key, options);
226
+ } catch {
227
+ // Unreadable (e.g. keychain state changed) — report as unset
228
+ }
229
+ }
230
+ result.push({
231
+ key,
232
+ description,
233
+ keychainService: options.keychainService,
234
+ requireAuthentication: options.requireAuthentication ?? false,
235
+ value
236
+ });
237
+ }
238
+ return result;
239
+ },
131
240
  "secure.get": params => {
132
241
  const {
133
242
  key
@@ -139,6 +248,19 @@ export const storageSyncAdapter = {
139
248
  }
140
249
  return module.getItemAsync(key, entry.options);
141
250
  },
251
+ /**
252
+ * Write a registered SecureStore key. Goes through the registry so the
253
+ * value is written with the SAME options it was registered with — writing
254
+ * with different `keychainService` options creates a second entry the app
255
+ * can't read. Biometric-protected keys throw rather than prompting.
256
+ */
257
+ "secure.set": params => {
258
+ const {
259
+ key,
260
+ value
261
+ } = params;
262
+ return secureStoreRegistry.setValue(key, value);
263
+ },
142
264
  "secure.delete": params => secureStoreRegistry.deleteValue(params.key)
143
265
  }
144
266
  };
@@ -6,6 +6,28 @@ import { asyncStorage as AsyncStorage, asyncStorageCaps, readMany } from "./asyn
6
6
 
7
7
  // v3-only batch methods (replace multiSet/multiRemove)
8
8
 
9
+ /**
10
+ * The pristine (un-swizzled) AsyncStorage methods, stashed on globalThis under a
11
+ * cross-realm Symbol.
12
+ *
13
+ * Why this exists: the listener below is a module-scope singleton. Metro Fast
14
+ * Refresh re-evaluates the module and builds a NEW instance whose `original*`
15
+ * fields are all null — but `AsyncStorage.setItem` still points at the OLD
16
+ * instance's `swizzled_setItem`. Without a stash, `initialize()` sees the
17
+ * existing swizzle, has no way to recover the real methods, and bails; the
18
+ * listener then never attaches, so storage events stop for the rest of the
19
+ * session (writes still work, they just go unrecorded). Keeping the originals
20
+ * off the instance lets a fresh instance re-attach cleanly.
21
+ */
22
+
23
+ const ORIGINALS_KEY = Symbol.for("@buoy-gg/storage.asyncStorageOriginals");
24
+ function readStashedOriginals() {
25
+ return globalThis[ORIGINALS_KEY] ?? null;
26
+ }
27
+ function stashOriginals(originals) {
28
+ globalThis[ORIGINALS_KEY] = originals;
29
+ }
30
+
9
31
  // Event types for AsyncStorage operations
10
32
 
11
33
  /**
@@ -90,6 +112,25 @@ class AsyncStorageListener {
90
112
  return false;
91
113
  }
92
114
 
115
+ /**
116
+ * Copy a set of pristine methods onto this instance's `original*` fields.
117
+ *
118
+ * Used both when capturing them for the first time and when adopting the
119
+ * global stash after a Fast Refresh, so `restoreOriginalMethods()` and the
120
+ * swizzles always have real methods to call through to.
121
+ */
122
+ adoptOriginals(originals) {
123
+ this.originalSetItem = originals.setItem;
124
+ this.originalRemoveItem = originals.removeItem;
125
+ this.originalClear = originals.clear;
126
+ this.originalMergeItem = originals.mergeItem;
127
+ this.originalMultiSet = originals.multiSet;
128
+ this.originalMultiRemove = originals.multiRemove;
129
+ this.originalMultiMerge = originals.multiMerge;
130
+ this.originalSetMany = originals.setMany;
131
+ this.originalRemoveMany = originals.removeMany;
132
+ }
133
+
93
134
  /**
94
135
  * Initialize the listener by loading AsyncStorage and storing original methods
95
136
  *
@@ -106,29 +147,52 @@ class AsyncStorageListener {
106
147
  return true;
107
148
  }
108
149
 
109
- // Check if methods are already swizzled by checking the function name
150
+ // Already swizzled — by a previous instance of this class that Fast Refresh
151
+ // replaced, or by a duplicate copy of the package. Adopt the stashed
152
+ // originals so this instance can restore + re-swizzle. Bailing here is what
153
+ // used to kill storage events until a full app reload.
110
154
  if (AsyncStorage.setItem.name === "swizzled_setItem") {
111
- // Don't store swizzled methods as originals
112
- return false;
155
+ const stashed = readStashedOriginals();
156
+ if (!stashed) {
157
+ // Swizzled by something we didn't install and whose originals we never
158
+ // captured — taking the current methods as "originals" would bake the
159
+ // foreign swizzle in permanently, so stay out of the way.
160
+ return false;
161
+ }
162
+ this.adoptOriginals(stashed);
163
+ this.isInitialized = true;
164
+ return true;
113
165
  }
114
166
 
115
167
  // Store original methods (these should be the real AsyncStorage methods).
116
168
  // Single-item + clear are identical across v2/v3.
117
- this.originalSetItem = AsyncStorage.setItem.bind(AsyncStorage);
118
- this.originalRemoveItem = AsyncStorage.removeItem.bind(AsyncStorage);
119
- this.originalClear = AsyncStorage.clear.bind(AsyncStorage);
169
+ const originals = {
170
+ setItem: AsyncStorage.setItem.bind(AsyncStorage),
171
+ removeItem: AsyncStorage.removeItem.bind(AsyncStorage),
172
+ clear: AsyncStorage.clear.bind(AsyncStorage),
173
+ mergeItem: null,
174
+ multiSet: null,
175
+ multiRemove: null,
176
+ multiMerge: null,
177
+ setMany: null,
178
+ removeMany: null
179
+ };
120
180
  if (asyncStorageCaps.hasLegacyMultiApi) {
121
181
  // v2: multiSet/multiRemove + merge API
122
- this.originalMultiSet = AsyncStorage.multiSet.bind(AsyncStorage);
123
- this.originalMultiRemove = AsyncStorage.multiRemove.bind(AsyncStorage);
124
- this.originalMergeItem = AsyncStorage.mergeItem.bind(AsyncStorage);
125
- this.originalMultiMerge = AsyncStorage.multiMerge ? AsyncStorage.multiMerge.bind(AsyncStorage) : null;
182
+ originals.multiSet = AsyncStorage.multiSet.bind(AsyncStorage);
183
+ originals.multiRemove = AsyncStorage.multiRemove.bind(AsyncStorage);
184
+ originals.mergeItem = AsyncStorage.mergeItem.bind(AsyncStorage);
185
+ originals.multiMerge = AsyncStorage.multiMerge ? AsyncStorage.multiMerge.bind(AsyncStorage) : null;
126
186
  } else {
127
187
  // v3: setMany/removeMany; merge API was removed (nothing to capture)
128
- this.originalSetMany = AsyncStorage.setMany.bind(AsyncStorage);
129
- this.originalRemoveMany = AsyncStorage.removeMany.bind(AsyncStorage);
188
+ originals.setMany = AsyncStorage.setMany.bind(AsyncStorage);
189
+ originals.removeMany = AsyncStorage.removeMany.bind(AsyncStorage);
130
190
  }
131
191
 
192
+ // Keep them reachable across a module re-evaluation, then adopt.
193
+ stashOriginals(originals);
194
+ this.adoptOriginals(originals);
195
+
132
196
  // Original methods stored successfully
133
197
  this.isInitialized = true;
134
198
  return true;
@@ -10,6 +10,14 @@
10
10
  * @since 1.0.0
11
11
  */
12
12
 
13
+ // Static ESM import. This was previously a dynamic `require('./MMKVListener')`
14
+ // inside register/unregister "to avoid a circular dependency" — but MMKVListener
15
+ // imports only ./mmkvTypeDetection, never this file, so there is no cycle. The
16
+ // require() form threw `ReferenceError: require is not defined` when this source
17
+ // is bundled as ESM (the desktop dashboard imports it through Vite), crashing on
18
+ // StorageInline unmount. A static import behaves identically on Metro and Vite.
19
+ import { mmkvListener } from "./MMKVListener";
20
+
13
21
  /**
14
22
  * Type-safe representation of an MMKV instance.
15
23
  * Uses conditional typing to avoid hard dependency on react-native-mmkv.
@@ -161,10 +169,6 @@ export function registerMMKVInstance(id, instance, config) {
161
169
 
162
170
  // Add listener for real-time monitoring (CRITICAL: enables event emission)
163
171
  // v4: Use ONLY native listener (addOnValueChangedListener), skip method wrapping
164
- // Import mmkvListener dynamically to avoid circular dependency
165
- const {
166
- mmkvListener
167
- } = require('./MMKVListener');
168
172
  mmkvListener.addInstance(instance, id);
169
173
  }
170
174
 
@@ -186,9 +190,6 @@ export function registerMMKVInstance(id, instance, config) {
186
190
  */
187
191
  export function unregisterMMKVInstance(id) {
188
192
  // Remove listener first
189
- const {
190
- mmkvListener
191
- } = require('./MMKVListener');
192
193
  mmkvListener.removeInstance(id);
193
194
 
194
195
  // Then remove from registry