@buoy-gg/zustand 7.0.29 → 7.0.31

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 (60) hide show
  1. package/lib/commonjs/zustand/components/ZustandActionButton.js +3 -1
  2. package/lib/commonjs/zustand/components/ZustandDetailViewToggle.js +7 -0
  3. package/lib/commonjs/zustand/components/ZustandEventFilterView.js +19 -4
  4. package/lib/commonjs/zustand/components/ZustandModal.js +173 -32
  5. package/lib/commonjs/zustand/components/ZustandStateChangeItem.js +36 -37
  6. package/lib/commonjs/zustand/components/ZustandStateDetailContent.js +22 -5
  7. package/lib/commonjs/zustand/components/ZustandStateInfoView.js +30 -7
  8. package/lib/commonjs/zustand/components/ZustandStoreBrowser.js +23 -126
  9. package/lib/commonjs/zustand/components/ZustandStoreDetailContent.js +471 -0
  10. package/lib/commonjs/zustand/components/ZustandStoreEditorScreen.js +393 -0
  11. package/lib/commonjs/zustand/sync/zustandSyncAdapter.js +54 -0
  12. package/lib/commonjs/zustand/utils/buoyZustandMiddleware.js +71 -7
  13. package/lib/commonjs/zustand/utils/editableState.js +139 -0
  14. package/lib/commonjs/zustand/utils/snapshotProvider.js +10 -37
  15. package/lib/commonjs/zustand/utils/testIDs.js +82 -0
  16. package/lib/commonjs/zustand/utils/zustandStateStore.js +16 -3
  17. package/lib/module/zustand/components/ZustandActionButton.js +3 -1
  18. package/lib/module/zustand/components/ZustandDetailViewToggle.js +7 -0
  19. package/lib/module/zustand/components/ZustandEventFilterView.js +19 -4
  20. package/lib/module/zustand/components/ZustandModal.js +173 -32
  21. package/lib/module/zustand/components/ZustandStateChangeItem.js +36 -37
  22. package/lib/module/zustand/components/ZustandStateDetailContent.js +22 -5
  23. package/lib/module/zustand/components/ZustandStateInfoView.js +30 -7
  24. package/lib/module/zustand/components/ZustandStoreBrowser.js +26 -129
  25. package/lib/module/zustand/components/ZustandStoreDetailContent.js +469 -0
  26. package/lib/module/zustand/components/ZustandStoreEditorScreen.js +389 -0
  27. package/lib/module/zustand/sync/zustandSyncAdapter.js +54 -0
  28. package/lib/module/zustand/utils/buoyZustandMiddleware.js +71 -7
  29. package/lib/module/zustand/utils/editableState.js +131 -0
  30. package/lib/module/zustand/utils/snapshotProvider.js +4 -30
  31. package/lib/module/zustand/utils/testIDs.js +74 -0
  32. package/lib/module/zustand/utils/zustandStateStore.js +16 -3
  33. package/lib/typescript/zustand/components/ZustandActionButton.d.ts +2 -1
  34. package/lib/typescript/zustand/components/ZustandActionButton.d.ts.map +1 -1
  35. package/lib/typescript/zustand/components/ZustandDetailViewToggle.d.ts.map +1 -1
  36. package/lib/typescript/zustand/components/ZustandEventFilterView.d.ts.map +1 -1
  37. package/lib/typescript/zustand/components/ZustandModal.d.ts.map +1 -1
  38. package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts +6 -0
  39. package/lib/typescript/zustand/components/ZustandStateChangeItem.d.ts.map +1 -1
  40. package/lib/typescript/zustand/components/ZustandStateDetailContent.d.ts.map +1 -1
  41. package/lib/typescript/zustand/components/ZustandStateInfoView.d.ts.map +1 -1
  42. package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts +7 -9
  43. package/lib/typescript/zustand/components/ZustandStoreBrowser.d.ts.map +1 -1
  44. package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts +40 -0
  45. package/lib/typescript/zustand/components/ZustandStoreDetailContent.d.ts.map +1 -0
  46. package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts +42 -0
  47. package/lib/typescript/zustand/components/ZustandStoreEditorScreen.d.ts.map +1 -0
  48. package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts +35 -0
  49. package/lib/typescript/zustand/sync/zustandSyncAdapter.d.ts.map +1 -1
  50. package/lib/typescript/zustand/types/index.d.ts +13 -0
  51. package/lib/typescript/zustand/types/index.d.ts.map +1 -1
  52. package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts +31 -3
  53. package/lib/typescript/zustand/utils/buoyZustandMiddleware.d.ts.map +1 -1
  54. package/lib/typescript/zustand/utils/editableState.d.ts +65 -0
  55. package/lib/typescript/zustand/utils/editableState.d.ts.map +1 -0
  56. package/lib/typescript/zustand/utils/testIDs.d.ts +54 -0
  57. package/lib/typescript/zustand/utils/testIDs.d.ts.map +1 -0
  58. package/lib/typescript/zustand/utils/zustandStateStore.d.ts +1 -0
  59. package/lib/typescript/zustand/utils/zustandStateStore.d.ts.map +1 -1
  60. package/package.json +3 -3
@@ -0,0 +1,393 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.ZustandStoreEditorScreen = ZustandStoreEditorScreen;
7
+ var _react = require("react");
8
+ var _reactNative = require("react-native");
9
+ var _sharedUi = require("@buoy-gg/shared-ui");
10
+ var _dataViewer = require("@buoy-gg/shared-ui/dataViewer");
11
+ var _editableState = require("../utils/editableState");
12
+ var _testIDs = require("../utils/testIDs");
13
+ var _jsxRuntime = require("react/jsx-runtime");
14
+ /**
15
+ * Editing a store, on a screen of its own — the Storage key editor's shape,
16
+ * pointed at a live zustand store.
17
+ *
18
+ * Same reasoning as storage's editor screen: a tree, six structural actions
19
+ * and a keyboard don't fit inside an expanded card, so the card offers ONE
20
+ * edit button and the work happens here with the whole viewport. The tree,
21
+ * dock, draft and value modal are the shared-ui editor — one implementation,
22
+ * two tools.
23
+ *
24
+ * What is zustand-specific is the WRITE. Storage saves by replacing one key's
25
+ * serialized value; a store has no such luxury — its state holds the app's
26
+ * action functions, and a full `setState(draft, true)` replace would delete
27
+ * them (see `editableState.ts`). So Save diffs the draft against the state it
28
+ * was seeded from and writes ONE top-level merge:
29
+ *
30
+ * store.api.setState(changedTopLevelKeys, false)
31
+ *
32
+ * which by construction cannot remove a key or touch a function. The two
33
+ * edits a merge cannot express — removing or renaming a top-level key — are
34
+ * disabled in the dock (`rootEntriesLocked`) and refused at save if they
35
+ * arrive through the raw-JSON escape hatch.
36
+ *
37
+ * On a desktop mirror the same line goes over the wire: the mirrored store's
38
+ * `api.setState` forwards to the device's sync adapter (the time-travel
39
+ * path). That write has no ack, so a remote save resolves optimistically and
40
+ * the next device snapshot is the confirmation.
41
+ */
42
+
43
+ const EDITOR_ID = `${_testIDs.ZUSTAND_TEST_ID}-editor`;
44
+ function ZustandStoreEditorScreen({
45
+ store,
46
+ onClose,
47
+ applySafeAreaInset
48
+ }) {
49
+ const insets = (0, _sharedUi.useSafeAreaInsets)();
50
+ const bottomInset = applySafeAreaInset ? insets.bottom : 0;
51
+
52
+ // Seeded ONCE, when the editor opens — the draft is diffed against this at
53
+ // save, so the partial carries exactly what the user changed and a key the
54
+ // app wrote meanwhile (untouched here) keeps the app's value.
55
+ const [seed] = (0, _react.useState)(() => (0, _editableState.editableStateOf)(store.api.getState()));
56
+ const save = (0, _react.useCallback)(async draft => {
57
+ if (seed.data === null) throw new Error("Store is not editable.");
58
+ const partial = (0, _editableState.computeMergePartial)(seed.data, draft);
59
+ // A merge, never a replace — the entire reason this editor can exist.
60
+ // Empty partial = the draft circled back to the seed; nothing to write.
61
+ if (Object.keys(partial).length > 0) {
62
+ store.api.setState(partial, false);
63
+ }
64
+ }, [seed, store]);
65
+ const editor = (0, _dataViewer.useTreeEditor)({
66
+ value: seed.data,
67
+ save,
68
+ onClose,
69
+ confirmDiscard: choices => _reactNative.Alert.alert("Unsaved changes", `"${store.name}" has edits that haven't been written to the store.`, [{
70
+ text: "Keep editing",
71
+ style: "cancel"
72
+ }, {
73
+ text: "Discard",
74
+ style: "destructive",
75
+ onPress: choices.discard
76
+ }, {
77
+ text: "Save",
78
+ onPress: choices.save
79
+ }])
80
+ });
81
+ const {
82
+ draft,
83
+ selection,
84
+ valuePath
85
+ } = editor;
86
+ const keyCount = (0, _react.useMemo)(() => {
87
+ const v = draft.value;
88
+ return v !== null && typeof v === "object" ? Object.keys(v).length : 0;
89
+ }, [draft.value]);
90
+
91
+ // The card's gating means this can't normally be reached un-editable, but a
92
+ // remote mirror can theoretically drop hydration between tap and mount.
93
+ if (seed.data === null) {
94
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
95
+ style: styles.screen,
96
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolBackground, {}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
97
+ style: styles.blockedText,
98
+ children: seed.blockedReason
99
+ })]
100
+ });
101
+ }
102
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
103
+ style: styles.screen,
104
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolBackground, {}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
105
+ style: styles.header,
106
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
107
+ onPress: editor.requestClose,
108
+ testID: `${EDITOR_ID}-close`,
109
+ accessibilityRole: "button",
110
+ accessibilityLabel: "Close editor",
111
+ style: styles.headerButton,
112
+ hitSlop: {
113
+ top: 8,
114
+ bottom: 8,
115
+ left: 8,
116
+ right: 8
117
+ },
118
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.X, {
119
+ size: 16,
120
+ color: _sharedUi.night.color.textSecondary
121
+ })
122
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
123
+ style: styles.headerTitle,
124
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text
125
+ // Which store this editor is pointed at — opening it on the wrong
126
+ // one looks exactly like the right one until a write lands
127
+ // somewhere unexpected.
128
+ , {
129
+ testID: `${EDITOR_ID}-store`,
130
+ style: styles.headerKey,
131
+ numberOfLines: 1,
132
+ children: store.name
133
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
134
+ style: [styles.headerState, draft.isDirty && styles.headerDirty],
135
+ children: draft.isSaving ? "Saving…" : draft.isDirty ? "Draft · unsaved" : "In sync with store"
136
+ })]
137
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
138
+ onPress: editor.openRawEditor,
139
+ testID: `${EDITOR_ID}-raw`,
140
+ accessibilityRole: "button",
141
+ accessibilityLabel: "Edit raw JSON",
142
+ style: styles.headerButton,
143
+ hitSlop: {
144
+ top: 8,
145
+ bottom: 8,
146
+ left: 8,
147
+ right: 8
148
+ },
149
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.FileJson, {
150
+ size: 16,
151
+ color: _sharedUi.night.color.accent
152
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
153
+ style: styles.headerRawText,
154
+ children: "Raw"
155
+ })]
156
+ })]
157
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
158
+ style: styles.metaRow,
159
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PillBadge, {
160
+ color: _sharedUi.buoyColors.info,
161
+ icon: store.isPersisted ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Database, {
162
+ size: 9,
163
+ color: _sharedUi.buoyColors.info
164
+ }) : undefined,
165
+ children: store.isPersisted ? store.persistName || "persisted" : "in-memory"
166
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
167
+ style: styles.metaText,
168
+ children: [`object · ${keyCount} key${keyCount === 1 ? "" : "s"}`, seed.fnPaths.length > 0 ? ` · ${seed.fnPaths.length} action${seed.fnPaths.length === 1 ? "" : "s"} untouched` : ""]
169
+ })]
170
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.ScrollView, {
171
+ testID: `${EDITOR_ID}-scroll`,
172
+ style: styles.treeScroll,
173
+ contentContainerStyle: styles.treeContent,
174
+ keyboardShouldPersistTaps: "handled",
175
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
176
+ style: styles.treeCard,
177
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_dataViewer.DataViewer, {
178
+ data: draft.value,
179
+ title: "",
180
+ rawMode: true,
181
+ initialExpanded: true
182
+ // Type filtering is a READING aid: the filtered view is a
183
+ // projection whose row paths don't address the real document, so
184
+ // the explorer stops reporting selection while one is active. An
185
+ // editor whose actions silently go dead isn't worth the filter.
186
+ ,
187
+ showTypeFilter: false,
188
+ onSelectionChange: editor.setSelection,
189
+ selectPath: draft.selectPath
190
+ // Its own handle root: the card that opened this editor is still
191
+ // mounted underneath, rendering the same value in its preview tree.
192
+ ,
193
+ testIDPrefix: `${EDITOR_ID}-row`,
194
+ onRequestInlineEdit: sel => editor.openValueEditor(sel.path)
195
+ })
196
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
197
+ style: styles.treeHint,
198
+ children: "Tap a row to select it \xB7 double-tap to edit its value \xB7 saves merge into the live store, so actions and untouched keys survive"
199
+ })]
200
+ }), draft.error ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
201
+ style: styles.error,
202
+ numberOfLines: 3,
203
+ testID: `${EDITOR_ID}-error`,
204
+ children: draft.error
205
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
206
+ style: [styles.footer, {
207
+ paddingBottom: bottomInset
208
+ }],
209
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_dataViewer.DataTreeActionDock, {
210
+ root: draft.value,
211
+ selection: selection,
212
+ onEdit: draft.apply,
213
+ onSelectPath: draft.selectAt
214
+ // Booleans never reach this — the dock applies their flip directly.
215
+ ,
216
+ onEditValue: () => editor.openValueEditor(selection?.path ?? [])
217
+ // A merge write can rewrite a top-level key but never delete one, so
218
+ // Remove is disabled at depth 1 — see `editableState.ts`.
219
+ ,
220
+ rootEntriesLocked: true
221
+ }), draft.isDirty ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
222
+ style: styles.commitRow,
223
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
224
+ onPress: draft.discard,
225
+ disabled: draft.isSaving,
226
+ testID: `${EDITOR_ID}-discard`,
227
+ accessibilityRole: "button",
228
+ accessibilityLabel: "Discard changes",
229
+ style: styles.discardButton,
230
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
231
+ style: styles.discardText,
232
+ children: "Discard"
233
+ })
234
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
235
+ onPress: editor.saveAndClose,
236
+ disabled: draft.isSaving,
237
+ testID: `${EDITOR_ID}-save`,
238
+ accessibilityRole: "button",
239
+ accessibilityLabel: "Save changes",
240
+ style: [styles.saveButton, draft.isSaving && styles.saveDisabled],
241
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
242
+ style: styles.saveText,
243
+ children: draft.isSaving ? "Saving…" : "Save changes"
244
+ })
245
+ })]
246
+ }) : null]
247
+ }), valuePath ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_dataViewer.ValueEditModal, {
248
+ path: valuePath,
249
+ value: (0, _dataViewer.getAtPath)(draft.value, valuePath),
250
+ onSave: editor.applyValue,
251
+ onCancel: editor.closeValueEditor
252
+ // It only reaches the draft here — the single write happens on Save.
253
+ ,
254
+ saveLabel: "Apply",
255
+ testIDPrefix: `${_testIDs.ZUSTAND_TEST_ID}-value`
256
+ }) : null]
257
+ });
258
+ }
259
+ const styles = _reactNative.StyleSheet.create({
260
+ screen: {
261
+ flex: 1,
262
+ // Opaque on purpose: transparency here showed the whole store list
263
+ // through the editor, not just the sheet's backdrop. The screen paints
264
+ // its own ToolBackground instead — see the mount site above.
265
+ backgroundColor: _sharedUi.night.color.bg
266
+ },
267
+ blockedText: {
268
+ color: _sharedUi.night.color.textTertiary,
269
+ fontSize: 12,
270
+ padding: 20,
271
+ textAlign: "center",
272
+ lineHeight: 18
273
+ },
274
+ header: {
275
+ flexDirection: "row",
276
+ alignItems: "center",
277
+ gap: 8,
278
+ paddingHorizontal: 10,
279
+ paddingVertical: 8,
280
+ borderBottomWidth: 1,
281
+ borderBottomColor: _sharedUi.night.color.border
282
+ },
283
+ headerButton: {
284
+ flexDirection: "row",
285
+ alignItems: "center",
286
+ gap: 4,
287
+ paddingVertical: 6,
288
+ paddingHorizontal: 4
289
+ },
290
+ headerRawText: {
291
+ color: _sharedUi.night.color.accent,
292
+ fontSize: 12,
293
+ fontWeight: "700"
294
+ },
295
+ headerTitle: {
296
+ flex: 1,
297
+ alignItems: "center",
298
+ gap: 2
299
+ },
300
+ headerKey: {
301
+ color: _sharedUi.night.color.text,
302
+ fontSize: 13,
303
+ fontWeight: "600",
304
+ fontFamily: "monospace"
305
+ },
306
+ headerState: {
307
+ color: _sharedUi.night.color.textTertiary,
308
+ fontSize: 10
309
+ },
310
+ headerDirty: {
311
+ color: _sharedUi.night.color.warning
312
+ },
313
+ metaRow: {
314
+ flexDirection: "row",
315
+ alignItems: "center",
316
+ gap: 8,
317
+ paddingHorizontal: 12,
318
+ paddingVertical: 8
319
+ },
320
+ metaText: {
321
+ color: _sharedUi.night.color.textTertiary,
322
+ fontSize: 11,
323
+ fontFamily: "monospace"
324
+ },
325
+ treeScroll: {
326
+ flex: 1
327
+ },
328
+ treeContent: {
329
+ paddingHorizontal: 12,
330
+ paddingBottom: 16
331
+ },
332
+ treeCard: {
333
+ backgroundColor: _sharedUi.night.color.surface,
334
+ borderRadius: 10,
335
+ borderWidth: 1,
336
+ borderColor: _sharedUi.night.color.border,
337
+ padding: 6
338
+ },
339
+ treeHint: {
340
+ color: _sharedUi.night.color.textTertiary,
341
+ fontSize: 10,
342
+ paddingTop: 8,
343
+ paddingHorizontal: 4
344
+ },
345
+ error: {
346
+ color: _sharedUi.night.color.danger,
347
+ fontSize: 11,
348
+ fontFamily: "monospace",
349
+ paddingHorizontal: 12,
350
+ paddingBottom: 6
351
+ },
352
+ footer: {
353
+ backgroundColor: _sharedUi.night.color.surface
354
+ },
355
+ commitRow: {
356
+ flexDirection: "row",
357
+ gap: 8,
358
+ paddingHorizontal: 12,
359
+ paddingVertical: 10,
360
+ borderTopWidth: 1,
361
+ borderTopColor: _sharedUi.night.color.border
362
+ },
363
+ discardButton: {
364
+ flex: 1,
365
+ minHeight: 44,
366
+ alignItems: "center",
367
+ justifyContent: "center",
368
+ borderRadius: 10,
369
+ borderWidth: 1,
370
+ borderColor: _sharedUi.night.color.border
371
+ },
372
+ discardText: {
373
+ color: _sharedUi.night.color.textSecondary,
374
+ fontSize: 14,
375
+ fontWeight: "600"
376
+ },
377
+ saveButton: {
378
+ flex: 1.4,
379
+ minHeight: 44,
380
+ alignItems: "center",
381
+ justifyContent: "center",
382
+ borderRadius: 10,
383
+ backgroundColor: _sharedUi.night.color.accent
384
+ },
385
+ saveDisabled: {
386
+ opacity: 0.5
387
+ },
388
+ saveText: {
389
+ color: _sharedUi.night.color.onAccent,
390
+ fontSize: 15,
391
+ fontWeight: "700"
392
+ }
393
+ });
@@ -182,6 +182,7 @@ const zustandSyncAdapter = exports.zustandSyncAdapter = {
182
182
  changes: s.stateChangeCount,
183
183
  isPersisted: s.isPersisted,
184
184
  persistName: s.persistName,
185
+ canRehydrate: s.canRehydrate,
185
186
  ...(includeValues ? {
186
187
  currentState: s.currentState
187
188
  } : {
@@ -214,6 +215,59 @@ const zustandSyncAdapter = exports.zustandSyncAdapter = {
214
215
  return {
215
216
  ok: true
216
217
  };
218
+ },
219
+ /**
220
+ * Re-read a persisted store's saved value and merge it into the live store.
221
+ *
222
+ * This is what makes editing a store's storage key mean anything. The app
223
+ * reads that key once at startup and then holds the state in memory, so an
224
+ * outside write — the Storage tool, a scenario, an agent — changes the disk
225
+ * and nothing else, and the next time the store saves it writes its own
226
+ * copy straight back over the edit.
227
+ *
228
+ * `persist.rehydrate()` is plain zustand, not a devtools hook, so unlike
229
+ * reloading the app this also works in a release build. It merges the saved
230
+ * value OVER current state (zustand's default merge is
231
+ * `{...current, ...persisted}`), so the store's action functions survive,
232
+ * and it does not write back to storage unless a version migration ran —
233
+ * the edit stays exactly as it was typed.
234
+ *
235
+ * Two honest limits, both inherent to persist rather than to this action:
236
+ * a key the store's `partialize` excludes is not in the saved value and so
237
+ * cannot be applied, and because the merge is shallow, DELETING a top-level
238
+ * key from the saved JSON does not remove it from the live store.
239
+ */
240
+ rehydrate: async params => {
241
+ const {
242
+ storeName
243
+ } = params ?? {};
244
+ if (!storeName) return {
245
+ ok: false,
246
+ error: "Missing storeName."
247
+ };
248
+ const store = _zustandStateStore.zustandStateStore.getStore(storeName);
249
+ if (!store) return {
250
+ ok: false,
251
+ error: `No store named "${storeName}".`
252
+ };
253
+ if (!store.isPersisted) {
254
+ return {
255
+ ok: false,
256
+ error: `"${storeName}" is not a persisted store — it has no saved copy to re-read.`
257
+ };
258
+ }
259
+ if (!store.rehydrate) {
260
+ return {
261
+ ok: false,
262
+ error: `"${storeName}" is persisted but Buoy has no rehydrate handle for it. It was probably registered before the app's persist middleware attached.`
263
+ };
264
+ }
265
+ await store.rehydrate();
266
+ return {
267
+ ok: true,
268
+ storeName,
269
+ persistName: store.persistName
270
+ };
217
271
  }
218
272
  }
219
273
  };
@@ -24,6 +24,8 @@ var _zustandStateStore = require("./zustandStateStore");
24
24
  /** Symbol to mark stores that have been watched/instrumented */
25
25
  const WATCHED_SYMBOL = Symbol.for("@@buoy-zustand/watched");
26
26
 
27
+ /** The slice of zustand's persist API this module uses. */
28
+
27
29
  /**
28
30
  * Detect if a store is using the persist middleware by checking for persist API
29
31
  */
@@ -33,7 +35,8 @@ function detectPersist(store) {
33
35
  const options = persist.getOptions();
34
36
  return {
35
37
  isPersisted: true,
36
- persistName: options?.name
38
+ persistName: options?.name,
39
+ persistApi: persist
37
40
  };
38
41
  }
39
42
  return {
@@ -84,7 +87,8 @@ function watchStores(stores) {
84
87
  storeAny[WATCHED_SYMBOL] = true;
85
88
  const {
86
89
  isPersisted,
87
- persistName
90
+ persistName,
91
+ persistApi
88
92
  } = detectPersist(store);
89
93
 
90
94
  // Register the store for overview
@@ -95,12 +99,36 @@ function watchStores(stores) {
95
99
  subscribe: store.subscribe
96
100
  }, {
97
101
  isPersisted,
98
- persistName
102
+ persistName,
103
+ // Was detected and thrown away. Without it nothing can re-read the
104
+ // saved copy after an outside edit — see ZustandStoreInfo.rehydrate.
105
+ rehydrate: typeof persistApi?.rehydrate === "function" ? () => persistApi.rehydrate() : undefined
99
106
  });
100
107
 
108
+ // The rehydration is a state change nobody's code asked for — the store
109
+ // simply becomes what the disk said, seconds into the process. Drawn as an
110
+ // ordinary setState it is the most surprising row in the list and the one
111
+ // that looks most like a bug, so it gets its own category. Recognised
112
+ // rather than announced: `hasHydrated()` is still false while the
113
+ // rehydrating `set` is running, and this listener runs inside it.
114
+ let sawHydration = false;
115
+
101
116
  // Subscribe to state changes — completely external, never touches setState
102
117
  const unsubscribe = store.subscribe((state, prevState) => {
103
118
  try {
119
+ let category;
120
+ if (!sawHydration && isPersisted) {
121
+ let hydrating = false;
122
+ try {
123
+ hydrating = persistApi?.hasHydrated?.() === false;
124
+ } catch {
125
+ // An older persist API without hasHydrated — no worse than before.
126
+ }
127
+ if (hydrating) {
128
+ sawHydration = true;
129
+ category = "persist";
130
+ }
131
+ }
104
132
  _zustandStateStore.zustandStateStore.addStateChange({
105
133
  storeName: name,
106
134
  partial: undefined,
@@ -110,6 +138,7 @@ function watchStores(stores) {
110
138
  nextState: state,
111
139
  duration: undefined,
112
140
  // Not available in subscribe-only mode
141
+ category,
113
142
  isPersisted
114
143
  });
115
144
  } catch {
@@ -160,17 +189,36 @@ let unnamedCounter = 0;
160
189
  * );
161
190
  * ```
162
191
  */
192
+ /**
193
+ * Zustand's own `set` shape (v5 types): with `replace: true` the argument must
194
+ * be a FULL state, so a single `(partial, replace?: boolean)` signature is
195
+ * incompatible in both directions — zustand's set is not assignable to it,
196
+ * which made `create(buoyDevTools(...))` fail to typecheck for every strict-TS
197
+ * user. Mirroring the overloads keeps buoyDevTools a drop-in for `create()`.
198
+ */
199
+
200
+ /**
201
+ * The store api object zustand hands a middleware, described structurally so
202
+ * this package never has to import zustand's own types (which differ between
203
+ * v4 and v5). `Record<string, unknown>` was wrong here: `StoreApi<T>` has no
204
+ * index signature, so `create(buoyDevTools(...))` failed to typecheck.
205
+ */
206
+
163
207
  function buoyDevTools(config, options) {
164
208
  return (set, get, api) => {
165
209
  const storeName = options?.name ?? `store-${++unnamedCounter}`;
166
210
  const enabled = options?.enabled !== false;
211
+
212
+ // Forwarding a plain `replace?: boolean` through the overloads needs one
213
+ // widening cast; runtime behavior is unchanged.
214
+ const rawSet = set;
167
215
  const instrumentedSet = (partial, replace) => {
168
216
  if (!enabled) {
169
- return set(partial, replace);
217
+ return rawSet(partial, replace);
170
218
  }
171
219
  const startTime = performance.now();
172
220
  const prevState = get();
173
- set(partial, replace);
221
+ rawSet(partial, replace);
174
222
  const nextState = get();
175
223
  const duration = performance.now() - startTime;
176
224
  const resolvedPartial = typeof partial === "function" ? "(updater fn)" : partial;
@@ -181,7 +229,14 @@ function buoyDevTools(config, options) {
181
229
  prevState,
182
230
  nextState,
183
231
  duration,
184
- isPersisted: false
232
+ // Read back off the registry rather than hardcoded false. Persistence
233
+ // is a property of the STORE, and this middleware can be wrapped
234
+ // around a persisted one — every change on such a store used to be
235
+ // drawn as un-persisted, which is the opposite of what the badge is
236
+ // for. Registration happens a tick after creation, so a set fired in
237
+ // that first tick still reports false; the alternative is re-sniffing
238
+ // `api.persist` on every set.
239
+ isPersisted: _zustandStateStore.zustandStateStore.getStore(storeName)?.isPersisted ?? false
185
240
  });
186
241
  };
187
242
  const result = config(instrumentedSet, get, api);
@@ -197,7 +252,16 @@ function buoyDevTools(config, options) {
197
252
  _zustandStateStore.zustandStateStore.registerStore(storeName, {
198
253
  getState: get,
199
254
  getInitialState: api.getInitialState,
200
- setState: set,
255
+ // The INSTRUMENTED set, not the raw one.
256
+ //
257
+ // This handle is what the tool's own JUMP and RESET STORE call, and
258
+ // registering the raw `set` here made both of them invisible: the
259
+ // store changed, the app re-rendered, and nothing appeared in the
260
+ // log — so the newest row's "state after change" no longer described
261
+ // the store, and the next diff compared against a state the device
262
+ // had never been in. The watch path never had this problem, because
263
+ // it observes the store from outside and cannot be gone around.
264
+ setState: instrumentedSet,
201
265
  subscribe: api.subscribe
202
266
  }, {
203
267
  isPersisted,