@buoy-gg/jotai 7.0.38 → 7.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/index.js +112 -1
- package/lib/commonjs/jotai/components/JotaiAtomBrowser.js +199 -1
- package/lib/commonjs/jotai/components/JotaiAtomChangeItem.js +85 -1
- package/lib/commonjs/jotai/components/JotaiAtomDetailContent.js +715 -1
- package/lib/commonjs/jotai/components/JotaiAtomValueDetailContent.js +444 -1
- package/lib/commonjs/jotai/components/JotaiEventFilterView.js +43 -1
- package/lib/commonjs/jotai/components/JotaiIcon.js +18 -1
- package/lib/commonjs/jotai/components/JotaiModal.js +743 -1
- package/lib/commonjs/jotai/hooks/useJotaiAtomChanges.js +69 -1
- package/lib/commonjs/jotai/sync/jotaiSyncAdapter.js +306 -1
- package/lib/commonjs/jotai/utils/jotaiStateStore.js +321 -1
- package/lib/commonjs/jotai/utils/snapshotProvider.js +215 -1
- package/lib/commonjs/jotai/utils/testIDs.js +90 -1
- package/lib/commonjs/jotai/utils/watchAtoms.js +162 -1
- package/lib/commonjs/jotai/utils/writeSemantics.js +47 -1
- package/lib/commonjs/preset.js +90 -1
- package/lib/module/index.js +85 -1
- package/lib/module/jotai/components/JotaiAtomBrowser.js +195 -1
- package/lib/module/jotai/components/JotaiAtomChangeItem.js +81 -1
- package/lib/module/jotai/components/JotaiAtomDetailContent.js +710 -1
- package/lib/module/jotai/components/JotaiAtomValueDetailContent.js +442 -1
- package/lib/module/jotai/components/JotaiEventFilterView.js +39 -1
- package/lib/module/jotai/components/JotaiIcon.js +11 -1
- package/lib/module/jotai/components/JotaiModal.js +738 -1
- package/lib/module/jotai/hooks/useJotaiAtomChanges.js +65 -1
- package/lib/module/jotai/sync/jotaiSyncAdapter.js +303 -1
- package/lib/module/jotai/utils/jotaiStateStore.js +317 -1
- package/lib/module/jotai/utils/snapshotProvider.js +214 -1
- package/lib/module/jotai/utils/testIDs.js +82 -1
- package/lib/module/jotai/utils/watchAtoms.js +157 -1
- package/lib/module/jotai/utils/writeSemantics.js +43 -1
- package/lib/module/preset.js +87 -1
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/jotai/components/JotaiAtomBrowser.d.ts.map +1 -0
- package/lib/typescript/jotai/components/JotaiAtomChangeItem.d.ts.map +1 -0
- package/lib/typescript/jotai/components/JotaiAtomDetailContent.d.ts.map +1 -0
- package/lib/typescript/jotai/components/JotaiAtomValueDetailContent.d.ts.map +1 -0
- package/lib/typescript/jotai/components/JotaiEventFilterView.d.ts.map +1 -0
- package/lib/typescript/jotai/components/JotaiIcon.d.ts.map +1 -0
- package/lib/typescript/jotai/components/JotaiModal.d.ts.map +1 -0
- package/lib/typescript/jotai/hooks/useJotaiAtomChanges.d.ts.map +1 -0
- package/lib/typescript/jotai/sync/jotaiSyncAdapter.d.ts.map +1 -0
- package/lib/typescript/jotai/types/index.d.ts.map +1 -0
- package/lib/typescript/jotai/utils/jotaiStateStore.d.ts.map +1 -0
- package/lib/typescript/jotai/utils/snapshotProvider.d.ts.map +1 -0
- package/lib/typescript/jotai/utils/testIDs.d.ts.map +1 -0
- package/lib/typescript/jotai/utils/watchAtoms.d.ts.map +1 -0
- package/lib/typescript/jotai/utils/writeSemantics.d.ts.map +1 -0
- package/lib/typescript/preset.d.ts.map +1 -0
- package/package.json +3 -3
|
@@ -1 +1,65 @@
|
|
|
1
|
-
"use strict";
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hook for consuming Jotai atom changes from the store
|
|
5
|
+
*
|
|
6
|
+
* The subscribe / filter / pause / clear plumbing is shared `useChangeLog`;
|
|
7
|
+
* this file owns the jotai-specific filter, stats and atom registry.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useMemo } from "react";
|
|
11
|
+
import { useChangeLog, useSubscribedValue } from "@buoy-gg/shared-ui";
|
|
12
|
+
import { jotaiStateStore } from "../utils/jotaiStateStore";
|
|
13
|
+
function filterChanges(changes, filter) {
|
|
14
|
+
let filtered = changes;
|
|
15
|
+
if (filter.searchText) {
|
|
16
|
+
const search = filter.searchText.toLowerCase();
|
|
17
|
+
filtered = filtered.filter(c => c.atomLabel.toLowerCase().includes(search) || c.valuePreview.toLowerCase().includes(search) || c.changedKeys.some(k => k.toLowerCase().includes(search)));
|
|
18
|
+
}
|
|
19
|
+
if (filter.atomLabels && filter.atomLabels.length > 0) {
|
|
20
|
+
filtered = filtered.filter(c => filter.atomLabels.includes(c.atomLabel));
|
|
21
|
+
}
|
|
22
|
+
if (filter.onlyWithChanges) {
|
|
23
|
+
filtered = filtered.filter(c => c.hasValueChange);
|
|
24
|
+
}
|
|
25
|
+
return filtered;
|
|
26
|
+
}
|
|
27
|
+
const readAtoms = () => jotaiStateStore.getAtoms();
|
|
28
|
+
const subscribeToAtoms = listener => jotaiStateStore.subscribeToAtoms(listener);
|
|
29
|
+
export function useJotaiAtomChanges() {
|
|
30
|
+
const log = useChangeLog(jotaiStateStore, {
|
|
31
|
+
filterItems: filterChanges
|
|
32
|
+
});
|
|
33
|
+
const atoms = useSubscribedValue(readAtoms, subscribeToAtoms);
|
|
34
|
+
const stats = useMemo(() => {
|
|
35
|
+
const total = log.items.length;
|
|
36
|
+
const withChanges = log.items.filter(c => c.hasValueChange).length;
|
|
37
|
+
return {
|
|
38
|
+
totalChanges: total,
|
|
39
|
+
changesWithValueChange: withChanges,
|
|
40
|
+
changesWithoutValueChange: total - withChanges,
|
|
41
|
+
atomCount: atoms.length
|
|
42
|
+
};
|
|
43
|
+
}, [log.items, atoms]);
|
|
44
|
+
const atomLabels = useMemo(() => {
|
|
45
|
+
return jotaiStateStore.getUniqueAtomLabels();
|
|
46
|
+
}, [atoms]);
|
|
47
|
+
const getChangeById = useCallback(id => {
|
|
48
|
+
return jotaiStateStore.getAtomChangeById(id);
|
|
49
|
+
}, []);
|
|
50
|
+
return {
|
|
51
|
+
atomChanges: log.items,
|
|
52
|
+
filteredChanges: log.filteredItems,
|
|
53
|
+
filter: log.filter,
|
|
54
|
+
setFilter: log.setFilter,
|
|
55
|
+
stats,
|
|
56
|
+
atoms,
|
|
57
|
+
// Not the shared log.clear: the store also owns each atom's change
|
|
58
|
+
// count, and only clearAtomChanges resets those with the list.
|
|
59
|
+
clearChanges: jotaiStateStore.clearAtomChanges.bind(jotaiStateStore),
|
|
60
|
+
isEnabled: log.isEnabled,
|
|
61
|
+
toggleCapture: log.toggleCapture,
|
|
62
|
+
atomLabels,
|
|
63
|
+
getChangeById
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -1 +1,303 @@
|
|
|
1
|
-
"use strict";
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { jotaiStateStore } from "../utils/jotaiStateStore";
|
|
4
|
+
import { WIRE_DETAIL_LIMIT_BYTES, createSyncAdapter, capDetail, detailByIdAction, expandKeyedArrays, memoByIdentity, onDeviceMarker, patchFromPath, readParams, readPath, refusalText, shapeSummary, suggestedPatch, toWire, typedEditViolations, unshapedLists } from "@buoy-gg/shared-ui";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sentinel used in WIRE-FORM snapshots in place of raw prevValue/nextValue.
|
|
8
|
+
* Same hazard as redux/zustand: up to 200 changes × 2 values on every
|
|
9
|
+
* snapshot. Dashboards fetch values via `getChangeDetail`.
|
|
10
|
+
*/
|
|
11
|
+
export const VALUE_ON_DEVICE = onDeviceMarker("Value", "Atom values stay on the device — fetched on demand via getChangeDetail / getAtomValue.");
|
|
12
|
+
const isPlainObj = v => !!v && typeof v === "object" && !Array.isArray(v);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Merge a patch into an atom's value.
|
|
16
|
+
*
|
|
17
|
+
* An atom is not always an object — plenty hold a number, a string or a list —
|
|
18
|
+
* so a merge only means anything when BOTH sides are plain objects. Anything
|
|
19
|
+
* else replaces, which is what a caller asking to merge a scalar can only have
|
|
20
|
+
* meant. Arrays replace too: the id-addressed form has already been expanded to
|
|
21
|
+
* a whole array by the time this runs, so merging element-wise here would undo
|
|
22
|
+
* the deletion half of that expansion.
|
|
23
|
+
*/
|
|
24
|
+
function deepMergeInto(base, patch) {
|
|
25
|
+
if (!isPlainObj(base) || !isPlainObj(patch)) return patch;
|
|
26
|
+
const out = {
|
|
27
|
+
...base
|
|
28
|
+
};
|
|
29
|
+
for (const [k, v] of Object.entries(patch)) out[k] = k in base ? deepMergeInto(base[k], v) : v;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Does this patch write into the list at `path`? Only then is an
|
|
35
|
+
* unchecked-shape warning about that list worth saying.
|
|
36
|
+
*/
|
|
37
|
+
function touchesList(patch, path) {
|
|
38
|
+
let cur = patch;
|
|
39
|
+
for (const seg of path.split(".")) {
|
|
40
|
+
if (!isPlainObj(cur)) return false;
|
|
41
|
+
if (!(seg in cur)) return false;
|
|
42
|
+
cur = cur[seg];
|
|
43
|
+
}
|
|
44
|
+
return Array.isArray(cur) ? cur.length > 0 : !!cur && typeof cur === "object";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Per-change wire cache — the store prepends and never mutates a recorded
|
|
49
|
+
* change, so identity is a correct, self-invalidating key.
|
|
50
|
+
*/
|
|
51
|
+
const toWireChange = memoByIdentity(change => ({
|
|
52
|
+
...change,
|
|
53
|
+
prevValue: VALUE_ON_DEVICE,
|
|
54
|
+
nextValue: VALUE_ON_DEVICE
|
|
55
|
+
}));
|
|
56
|
+
const toWireValue = value => toWire(value, VALUE_ON_DEVICE);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sync adapter for the jotai tool, consumed by @buoy-gg/external-sync's
|
|
60
|
+
* `useExternalSync`.
|
|
61
|
+
*
|
|
62
|
+
* Atom changes are captured by watchAtoms(), independent of whether a
|
|
63
|
+
* dashboard is watching — subscribing here only streams what the store
|
|
64
|
+
* already records. The snapshot carries both the change timeline and the
|
|
65
|
+
* atom registry (with each atom's current value) so the dashboard can render
|
|
66
|
+
* the atom browser.
|
|
67
|
+
*
|
|
68
|
+
* v2: change-log prevValue/nextValue are replaced with on-device markers.
|
|
69
|
+
* Dashboards fetch values via `getChangeDetail`. Oversized / unserializable
|
|
70
|
+
* `atoms[].currentValue` uses the same marker and is fetched via `getAtomValue`.
|
|
71
|
+
*/
|
|
72
|
+
export const jotaiSyncAdapter = createSyncAdapter({
|
|
73
|
+
version: 2,
|
|
74
|
+
getSnapshot: () => ({
|
|
75
|
+
changes: jotaiStateStore.getAtomChanges().map(toWireChange),
|
|
76
|
+
atoms: jotaiStateStore.getAtomSnapshots().map(atom => ({
|
|
77
|
+
...atom,
|
|
78
|
+
currentValue: toWireValue(atom.currentValue)
|
|
79
|
+
}))
|
|
80
|
+
}),
|
|
81
|
+
subscribe: [onChange => jotaiStateStore.subscribe(onChange), onChange => jotaiStateStore.subscribeToAtoms(onChange)],
|
|
82
|
+
actions: {
|
|
83
|
+
clearEvents: () => {
|
|
84
|
+
jotaiStateStore.clearAtomChanges();
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* On-demand detail: one change's real prevValue/nextValue.
|
|
88
|
+
* The per-snapshot stream stays value-free; this is the explicit,
|
|
89
|
+
* size-guarded channel for the detail pane.
|
|
90
|
+
*/
|
|
91
|
+
getChangeDetail: detailByIdAction(id => jotaiStateStore.getAtomChangeById(id), (change, cap) => ({
|
|
92
|
+
prevValue: cap(change.prevValue),
|
|
93
|
+
nextValue: cap(change.nextValue)
|
|
94
|
+
})),
|
|
95
|
+
/**
|
|
96
|
+
* On-demand current value for one atom. Wire-form snapshots replace
|
|
97
|
+
* oversized / unserializable `currentValue` with VALUE_ON_DEVICE.
|
|
98
|
+
*/
|
|
99
|
+
getAtomValue: params => {
|
|
100
|
+
const {
|
|
101
|
+
label,
|
|
102
|
+
path
|
|
103
|
+
} = readParams(params);
|
|
104
|
+
if (!label) return {
|
|
105
|
+
found: false,
|
|
106
|
+
reason: "missing label"
|
|
107
|
+
};
|
|
108
|
+
const atom = jotaiStateStore.getAtom(label);
|
|
109
|
+
if (!atom) return {
|
|
110
|
+
found: false,
|
|
111
|
+
reason: "unknown label"
|
|
112
|
+
};
|
|
113
|
+
let currentValue;
|
|
114
|
+
try {
|
|
115
|
+
currentValue = atom.getValue();
|
|
116
|
+
} catch {
|
|
117
|
+
currentValue = undefined;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
JSON.stringify(currentValue);
|
|
121
|
+
} catch {
|
|
122
|
+
return {
|
|
123
|
+
found: true,
|
|
124
|
+
label,
|
|
125
|
+
currentValue: VALUE_ON_DEVICE
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// One slice instead of the whole atom, when asked. The reason to want it
|
|
129
|
+
// is the engine's 24,000-character result cap: a caller that never saw the
|
|
130
|
+
// field it means to edit is left inventing a shape.
|
|
131
|
+
if (path) {
|
|
132
|
+
const at = readPath(currentValue, path);
|
|
133
|
+
if (!at.ok) return {
|
|
134
|
+
found: false,
|
|
135
|
+
reason: at.error
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
found: true,
|
|
139
|
+
label,
|
|
140
|
+
path,
|
|
141
|
+
shape: shapeSummary(at.value),
|
|
142
|
+
currentValue: at.value
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// `shape` BEFORE `currentValue`: a result is truncated by slicing the
|
|
146
|
+
// encoded string, so what is serialised first is what survives the cut,
|
|
147
|
+
// and the sketch does not grow with the data.
|
|
148
|
+
return {
|
|
149
|
+
found: true,
|
|
150
|
+
label,
|
|
151
|
+
...(shapeSummary(currentValue) ? {
|
|
152
|
+
shape: shapeSummary(currentValue)
|
|
153
|
+
} : {}),
|
|
154
|
+
currentValue: capDetail(currentValue, WIRE_DETAIL_LIMIT_BYTES)
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
/**
|
|
158
|
+
* Compact current-value reader for a remote driver (MCP/LLM): registered
|
|
159
|
+
* atoms with light metadata; each atom's `currentValue` stays on the device
|
|
160
|
+
* unless `includeValues` is set. `writable` tells the caller which atoms
|
|
161
|
+
* can be set via `setAtom`.
|
|
162
|
+
*/
|
|
163
|
+
listAtoms: params => {
|
|
164
|
+
const {
|
|
165
|
+
includeValues = false,
|
|
166
|
+
limit
|
|
167
|
+
} = readParams(params);
|
|
168
|
+
const all = jotaiStateStore.getAtoms();
|
|
169
|
+
const capped = typeof limit === "number" && limit > 0 ? all.slice(0, limit) : all;
|
|
170
|
+
const atoms = capped.map(a => {
|
|
171
|
+
let value;
|
|
172
|
+
if (includeValues) {
|
|
173
|
+
try {
|
|
174
|
+
value = a.getValue();
|
|
175
|
+
} catch {
|
|
176
|
+
value = undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// The compact view is what a caller reaches for precisely because it
|
|
180
|
+
// does not want the data; the item shape of a list is the one thing it
|
|
181
|
+
// cannot infer without it, and it costs a line.
|
|
182
|
+
let shape;
|
|
183
|
+
try {
|
|
184
|
+
shape = shapeSummary(a.getValue());
|
|
185
|
+
} catch {
|
|
186
|
+
shape = undefined;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
label: a.label,
|
|
190
|
+
changes: a.changeCount,
|
|
191
|
+
writable: typeof a.setValue === "function",
|
|
192
|
+
...(shape ? {
|
|
193
|
+
shape
|
|
194
|
+
} : {}),
|
|
195
|
+
...(includeValues ? {
|
|
196
|
+
currentValue: value
|
|
197
|
+
} : {})
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
atoms,
|
|
202
|
+
total: all.length,
|
|
203
|
+
returned: atoms.length,
|
|
204
|
+
includedValues: includeValues
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
/**
|
|
208
|
+
* Write to a writable atom (throws for unknown/read-only atoms).
|
|
209
|
+
*
|
|
210
|
+
* THIS USED TO BE FOUR LINES WITH NO GUARD. `setAtom` took whatever it was
|
|
211
|
+
* given and wrote it: a string over a number, `null` over a list the screen
|
|
212
|
+
* maps, a one-item array over a fifty-item one — every failure mode the
|
|
213
|
+
* zustand and react-query adapters refuse, jotai accepted silently. It was
|
|
214
|
+
* the only writable state tool with no protection at all, and it had never
|
|
215
|
+
* had an eval case, so nothing had ever measured it.
|
|
216
|
+
*
|
|
217
|
+
* It now takes the same three forms as `zustand.setState`, backed by the
|
|
218
|
+
* same shared implementations:
|
|
219
|
+
* { value } replace (guarded unless force)
|
|
220
|
+
* { value, merge: true } merge a patch, id-addressed lists allowed
|
|
221
|
+
* { path, value } set one value; no depth to get wrong
|
|
222
|
+
*/
|
|
223
|
+
setAtom: params => {
|
|
224
|
+
const {
|
|
225
|
+
label,
|
|
226
|
+
value,
|
|
227
|
+
merge,
|
|
228
|
+
path,
|
|
229
|
+
force
|
|
230
|
+
} = readParams(params);
|
|
231
|
+
if (!label) throw new Error("setAtom requires a `label`");
|
|
232
|
+
const atom = jotaiStateStore.getAtom(label);
|
|
233
|
+
let current;
|
|
234
|
+
try {
|
|
235
|
+
current = atom?.getValue();
|
|
236
|
+
} catch {
|
|
237
|
+
current = undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A replace with no guard is the old behaviour; keep it reachable via
|
|
241
|
+
// `force`, and keep it the ONLY way to get it.
|
|
242
|
+
let effective = value;
|
|
243
|
+
let keyedNotes = [];
|
|
244
|
+
let unchecked = [];
|
|
245
|
+
const merging = merge === true || path !== undefined;
|
|
246
|
+
if (path !== undefined) {
|
|
247
|
+
if (value === undefined) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
error: `Refused: sent \`path\` without \`value\`. Say what to set "${path}" to.`
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const built = patchFromPath(current, path, value);
|
|
254
|
+
if (!built.ok) return {
|
|
255
|
+
ok: false,
|
|
256
|
+
error: `Refused: ${built.error}`
|
|
257
|
+
};
|
|
258
|
+
effective = built.patch;
|
|
259
|
+
}
|
|
260
|
+
if (!force && merging && current !== undefined) {
|
|
261
|
+
const expanded = expandKeyedArrays(current, effective);
|
|
262
|
+
// See keyedArrays.ts: an added item whose id disagrees with the key it
|
|
263
|
+
// was sent under is ambiguous, and only this layer can say so.
|
|
264
|
+
if (expanded.error) {
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
error: `Refused: ${expanded.error}`
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
effective = expanded.patch;
|
|
271
|
+
keyedNotes = expanded.notes;
|
|
272
|
+
unchecked = unshapedLists(current).filter(p => touchesList(effective, p));
|
|
273
|
+
const violations = typedEditViolations(current, effective, {
|
|
274
|
+
noun: "atom"
|
|
275
|
+
});
|
|
276
|
+
if (violations.length) {
|
|
277
|
+
const suggestion = suggestedPatch(effective, violations);
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
error: refusalText(violations, "atom", suggestion),
|
|
281
|
+
violations,
|
|
282
|
+
...(suggestion ? {
|
|
283
|
+
suggestedValue: suggestion
|
|
284
|
+
} : {})
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
jotaiStateStore.setAtomValue(label, merging ? deepMergeInto(current, effective) : effective);
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
...(path !== undefined ? {
|
|
292
|
+
path
|
|
293
|
+
} : {}),
|
|
294
|
+
...(keyedNotes.length ? {
|
|
295
|
+
keyed: keyedNotes
|
|
296
|
+
} : {}),
|
|
297
|
+
...(unchecked.length ? {
|
|
298
|
+
unchecked: unchecked.map(p => `\`${p}\` was empty, so nothing knows what its items look like and the shape of what you added was NOT checked.`)
|
|
299
|
+
} : {})
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
@@ -1 +1,317 @@
|
|
|
1
|
-
"use strict";
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Jotai state store — captures and stores Jotai atom changes
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the architecture of zustandStateStore.ts from @buoy-gg/zustand
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ChangeLog, entityColor, formatValuePreview as sharedFormatValuePreview, shallowDiffSummary } from "@buoy-gg/shared-ui";
|
|
10
|
+
// ============================================
|
|
11
|
+
// Atom Color Palette
|
|
12
|
+
// ============================================
|
|
13
|
+
|
|
14
|
+
const ATOM_COLORS = {
|
|
15
|
+
count: "#10B981",
|
|
16
|
+
// emerald
|
|
17
|
+
auth: "#8B5CF6",
|
|
18
|
+
// purple
|
|
19
|
+
user: "#3B82F6",
|
|
20
|
+
// blue
|
|
21
|
+
cart: "#EC4899",
|
|
22
|
+
// pink
|
|
23
|
+
app: "#6366F1",
|
|
24
|
+
// indigo
|
|
25
|
+
ui: "#F59E0B",
|
|
26
|
+
// amber
|
|
27
|
+
settings: "#14B8A6",
|
|
28
|
+
// teal
|
|
29
|
+
theme: "#06B6D4",
|
|
30
|
+
// cyan
|
|
31
|
+
nav: "#F97316",
|
|
32
|
+
// orange
|
|
33
|
+
form: "#EF4444",
|
|
34
|
+
// red
|
|
35
|
+
modal: "#A855F7",
|
|
36
|
+
// violet
|
|
37
|
+
filter: "#84CC16" // lime
|
|
38
|
+
};
|
|
39
|
+
function getAtomColor(label) {
|
|
40
|
+
return entityColor(label, ATOM_COLORS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================
|
|
44
|
+
// Helper Functions
|
|
45
|
+
// ============================================
|
|
46
|
+
|
|
47
|
+
function formatValuePreview(value, maxLength = 40) {
|
|
48
|
+
return sharedFormatValuePreview(value, {
|
|
49
|
+
maxLength,
|
|
50
|
+
functionLabel: "(fn)"
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const getValueDiffSummary = shallowDiffSummary;
|
|
54
|
+
|
|
55
|
+
// ============================================
|
|
56
|
+
// Jotai State Store
|
|
57
|
+
// ============================================
|
|
58
|
+
|
|
59
|
+
class JotaiStateStore extends ChangeLog {
|
|
60
|
+
atoms = new Map();
|
|
61
|
+
atomListeners = new Set();
|
|
62
|
+
|
|
63
|
+
// ---- Change Tracking ----
|
|
64
|
+
|
|
65
|
+
addAtomChange(params) {
|
|
66
|
+
if (!this.canRecord()) return;
|
|
67
|
+
const {
|
|
68
|
+
atomLabel,
|
|
69
|
+
prevValue,
|
|
70
|
+
nextValue,
|
|
71
|
+
category = "write"
|
|
72
|
+
} = params;
|
|
73
|
+
const hasValueChange = prevValue !== nextValue;
|
|
74
|
+
const {
|
|
75
|
+
summary,
|
|
76
|
+
changedKeys,
|
|
77
|
+
changedCount
|
|
78
|
+
} = getValueDiffSummary(prevValue, nextValue);
|
|
79
|
+
const valuePreview = formatValuePreview(nextValue);
|
|
80
|
+
const change = {
|
|
81
|
+
id: this.nextId(),
|
|
82
|
+
atomLabel,
|
|
83
|
+
timestamp: Date.now(),
|
|
84
|
+
prevValue,
|
|
85
|
+
nextValue,
|
|
86
|
+
hasValueChange,
|
|
87
|
+
category,
|
|
88
|
+
changedKeys,
|
|
89
|
+
changedKeysCount: changedCount,
|
|
90
|
+
diffSummary: summary,
|
|
91
|
+
valuePreview,
|
|
92
|
+
isSlowUpdate: false
|
|
93
|
+
};
|
|
94
|
+
this.push(change);
|
|
95
|
+
const atomInfo = this.atoms.get(atomLabel);
|
|
96
|
+
if (atomInfo) {
|
|
97
|
+
atomInfo.changeCount++;
|
|
98
|
+
}
|
|
99
|
+
this.notifyListeners();
|
|
100
|
+
this.notifyAtomListeners();
|
|
101
|
+
}
|
|
102
|
+
getAtomChanges() {
|
|
103
|
+
return this.getAll();
|
|
104
|
+
}
|
|
105
|
+
getAtomChangeById(id) {
|
|
106
|
+
return this.getById(id);
|
|
107
|
+
}
|
|
108
|
+
clearAtomChanges() {
|
|
109
|
+
for (const atom of this.atoms.values()) {
|
|
110
|
+
atom.changeCount = 0;
|
|
111
|
+
}
|
|
112
|
+
this.clear();
|
|
113
|
+
this.notifyAtomListeners();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- Remote Mirror Mode ----
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Serializable snapshot of the tracked atoms (registry metadata plus each
|
|
120
|
+
* atom's current value). Used by the sync adapter on the device side —
|
|
121
|
+
* the live `getValue` handles can't go over the wire.
|
|
122
|
+
*/
|
|
123
|
+
getAtomSnapshots() {
|
|
124
|
+
return Array.from(this.atoms.values()).map(atom => {
|
|
125
|
+
let currentValue;
|
|
126
|
+
try {
|
|
127
|
+
currentValue = atom.getValue();
|
|
128
|
+
} catch {
|
|
129
|
+
currentValue = undefined;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
label: atom.label,
|
|
133
|
+
changeCount: atom.changeCount,
|
|
134
|
+
color: atom.color,
|
|
135
|
+
currentValue,
|
|
136
|
+
writeKind: atom.writeKind
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Replace the entire mirror contents from a synced device snapshot. The
|
|
143
|
+
* rebuilt registry entries get stub `getValue` handles serving the
|
|
144
|
+
* snapshotted value. Respects the enabled flag so the UI's pause button
|
|
145
|
+
* freezes the mirror.
|
|
146
|
+
*/
|
|
147
|
+
replaceFromSnapshot(changes, atomSnapshots) {
|
|
148
|
+
if (!this.getEnabled()) return;
|
|
149
|
+
|
|
150
|
+
// Preserve values a remote mirror already hydrated — wire-form snapshots
|
|
151
|
+
// always carry markers, and a later snapshot must not blank the detail pane.
|
|
152
|
+
const hydrated = new Map();
|
|
153
|
+
for (const existing of this.items) {
|
|
154
|
+
const prev = existing.prevValue;
|
|
155
|
+
if (!prev || prev.__buoyValueOnDevice !== true) {
|
|
156
|
+
hydrated.set(existing.id, existing);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const merged = changes.slice(0, this.max).map(change => {
|
|
160
|
+
const kept = hydrated.get(change.id);
|
|
161
|
+
const incoming = change.prevValue;
|
|
162
|
+
return kept && incoming?.__buoyValueOnDevice === true ? {
|
|
163
|
+
...change,
|
|
164
|
+
prevValue: kept.prevValue,
|
|
165
|
+
nextValue: kept.nextValue
|
|
166
|
+
} : change;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Preserve currentValue a remote mirror already hydrated — oversized
|
|
170
|
+
// atoms arrive as VALUE_ON_DEVICE markers on every snapshot.
|
|
171
|
+
const hydratedAtoms = new Map();
|
|
172
|
+
for (const [label, atom] of this.atoms) {
|
|
173
|
+
try {
|
|
174
|
+
const value = atom.getValue();
|
|
175
|
+
const marker = value;
|
|
176
|
+
if (!marker || marker.__buoyValueOnDevice !== true) {
|
|
177
|
+
hydratedAtoms.set(label, value);
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// ignore
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this.atoms = new Map(atomSnapshots.map(snapshot => {
|
|
184
|
+
const incoming = snapshot.currentValue;
|
|
185
|
+
const kept = hydratedAtoms.get(snapshot.label);
|
|
186
|
+
const currentValue = kept !== undefined && incoming?.__buoyValueOnDevice === true ? kept : snapshot.currentValue;
|
|
187
|
+
return [snapshot.label, {
|
|
188
|
+
label: snapshot.label,
|
|
189
|
+
changeCount: snapshot.changeCount,
|
|
190
|
+
color: snapshot.color,
|
|
191
|
+
getValue: () => currentValue,
|
|
192
|
+
// No setValue: the mirror has no wire back to the device from
|
|
193
|
+
// this entry (yet) — the detail screen renders read-only there.
|
|
194
|
+
writeKind: snapshot.writeKind
|
|
195
|
+
}];
|
|
196
|
+
}));
|
|
197
|
+
this.setItems(merged);
|
|
198
|
+
this.notifyAtomListeners();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Hydrate one mirrored change with real values fetched on demand from the
|
|
203
|
+
* device via the adapter's `getChangeDetail`. Remote-mirror (desktop) only.
|
|
204
|
+
*/
|
|
205
|
+
hydrateChange(id, patch) {
|
|
206
|
+
this.hydrateById(id, patch);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Hydrate one mirrored atom's currentValue fetched on demand from the
|
|
211
|
+
* device via the adapter's `getAtomValue`. Remote-mirror (desktop) only.
|
|
212
|
+
*/
|
|
213
|
+
hydrateAtomValue(label, currentValue) {
|
|
214
|
+
const atom = this.atoms.get(label);
|
|
215
|
+
if (!atom) return;
|
|
216
|
+
this.atoms.set(label, {
|
|
217
|
+
...atom,
|
|
218
|
+
getValue: () => currentValue
|
|
219
|
+
});
|
|
220
|
+
this.notifyAtomListeners();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- Atom Registry ----
|
|
224
|
+
|
|
225
|
+
registerAtom(label, getValue, setValue, writeKind) {
|
|
226
|
+
if (this.atoms.has(label)) return;
|
|
227
|
+
const atomInfo = {
|
|
228
|
+
label,
|
|
229
|
+
changeCount: 0,
|
|
230
|
+
color: getAtomColor(label),
|
|
231
|
+
getValue,
|
|
232
|
+
setValue,
|
|
233
|
+
writeKind
|
|
234
|
+
};
|
|
235
|
+
this.atoms.set(label, atomInfo);
|
|
236
|
+
this.notifyAtomListeners();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Write a value to a registered atom (remote write from the dashboard/MCP).
|
|
241
|
+
* Throws if the atom is unknown or read-only (derived atoms have no setter).
|
|
242
|
+
*/
|
|
243
|
+
setAtomValue(label, value) {
|
|
244
|
+
const atom = this.atoms.get(label);
|
|
245
|
+
if (!atom) {
|
|
246
|
+
throw new Error(`Atom "${label}" is not registered`);
|
|
247
|
+
}
|
|
248
|
+
if (!atom.setValue) {
|
|
249
|
+
throw new Error(`Atom "${label}" is read-only (derived) — cannot set`);
|
|
250
|
+
}
|
|
251
|
+
atom.setValue(value);
|
|
252
|
+
}
|
|
253
|
+
unregisterAtom(label) {
|
|
254
|
+
this.atoms.delete(label);
|
|
255
|
+
this.notifyAtomListeners();
|
|
256
|
+
}
|
|
257
|
+
getAtoms() {
|
|
258
|
+
return Array.from(this.atoms.values());
|
|
259
|
+
}
|
|
260
|
+
getAtom(label) {
|
|
261
|
+
return this.atoms.get(label);
|
|
262
|
+
}
|
|
263
|
+
getAtomColor(label) {
|
|
264
|
+
return this.atoms.get(label)?.color ?? getAtomColor(label);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- Filtering ----
|
|
268
|
+
|
|
269
|
+
filterAtomChanges(filter) {
|
|
270
|
+
let filtered = this.getAll();
|
|
271
|
+
if (filter.searchText) {
|
|
272
|
+
const search = filter.searchText.toLowerCase();
|
|
273
|
+
filtered = filtered.filter(c => c.atomLabel.toLowerCase().includes(search) || c.valuePreview.toLowerCase().includes(search) || c.changedKeys.some(k => k.toLowerCase().includes(search)));
|
|
274
|
+
}
|
|
275
|
+
if (filter.atomLabels && filter.atomLabels.length > 0) {
|
|
276
|
+
filtered = filtered.filter(c => filter.atomLabels.includes(c.atomLabel));
|
|
277
|
+
}
|
|
278
|
+
if (filter.onlyWithChanges) {
|
|
279
|
+
filtered = filtered.filter(c => c.hasValueChange);
|
|
280
|
+
}
|
|
281
|
+
return filtered;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- Stats ----
|
|
285
|
+
|
|
286
|
+
getStats() {
|
|
287
|
+
const total = this.items.length;
|
|
288
|
+
const withChanges = this.items.filter(c => c.hasValueChange).length;
|
|
289
|
+
return {
|
|
290
|
+
totalChanges: total,
|
|
291
|
+
changesWithValueChange: withChanges,
|
|
292
|
+
changesWithoutValueChange: total - withChanges,
|
|
293
|
+
atomCount: this.atoms.size
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
getUniqueAtomLabels() {
|
|
297
|
+
return Array.from(this.atoms.keys()).sort();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---- Enable / Disable ----
|
|
301
|
+
|
|
302
|
+
setMaxChanges(max) {
|
|
303
|
+
this.setMax(max);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- Subscriptions ----
|
|
307
|
+
|
|
308
|
+
subscribeToAtoms(listener) {
|
|
309
|
+
this.atomListeners.add(listener);
|
|
310
|
+
return () => this.atomListeners.delete(listener);
|
|
311
|
+
}
|
|
312
|
+
notifyAtomListeners() {
|
|
313
|
+
const atoms = this.getAtoms();
|
|
314
|
+
this.atomListeners.forEach(listener => listener(atoms));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export const jotaiStateStore = new JotaiStateStore();
|