@buoy-gg/jotai 7.0.35 → 7.0.36

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.
@@ -12,6 +12,41 @@ var _sharedUi = require("@buoy-gg/shared-ui");
12
12
  * snapshot. Dashboards fetch values via `getChangeDetail`.
13
13
  */
14
14
  const VALUE_ON_DEVICE = exports.VALUE_ON_DEVICE = (0, _sharedUi.onDeviceMarker)("Value", "Atom values stay on the device — fetched on demand via getChangeDetail / getAtomValue.");
15
+ const isPlainObj = v => !!v && typeof v === "object" && !Array.isArray(v);
16
+
17
+ /**
18
+ * Merge a patch into an atom's value.
19
+ *
20
+ * An atom is not always an object — plenty hold a number, a string or a list —
21
+ * so a merge only means anything when BOTH sides are plain objects. Anything
22
+ * else replaces, which is what a caller asking to merge a scalar can only have
23
+ * meant. Arrays replace too: the id-addressed form has already been expanded to
24
+ * a whole array by the time this runs, so merging element-wise here would undo
25
+ * the deletion half of that expansion.
26
+ */
27
+ function deepMergeInto(base, patch) {
28
+ if (!isPlainObj(base) || !isPlainObj(patch)) return patch;
29
+ const out = {
30
+ ...base
31
+ };
32
+ for (const [k, v] of Object.entries(patch)) out[k] = k in base ? deepMergeInto(base[k], v) : v;
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * Does this patch write into the list at `path`? Only then is an
38
+ * unchecked-shape warning about that list worth saying.
39
+ */
40
+ function touchesList(patch, path) {
41
+ let cur = patch;
42
+ for (const seg of path.split(".")) {
43
+ if (!isPlainObj(cur)) return false;
44
+ if (!(seg in cur)) return false;
45
+ cur = cur[seg];
46
+ }
47
+ return Array.isArray(cur) ? cur.length > 0 : !!cur && typeof cur === "object";
48
+ }
49
+
15
50
  /**
16
51
  * Per-change wire cache — the store prepends and never mutates a recorded
17
52
  * change, so identity is a correct, self-invalidating key.
@@ -66,7 +101,8 @@ const jotaiSyncAdapter = exports.jotaiSyncAdapter = (0, _sharedUi.createSyncAdap
66
101
  */
67
102
  getAtomValue: params => {
68
103
  const {
69
- label
104
+ label,
105
+ path
70
106
  } = (0, _sharedUi.readParams)(params);
71
107
  if (!label) return {
72
108
  found: false,
@@ -92,9 +128,32 @@ const jotaiSyncAdapter = exports.jotaiSyncAdapter = (0, _sharedUi.createSyncAdap
92
128
  currentValue: VALUE_ON_DEVICE
93
129
  };
94
130
  }
131
+ // One slice instead of the whole atom, when asked. The reason to want it
132
+ // is the engine's 24,000-character result cap: a caller that never saw the
133
+ // field it means to edit is left inventing a shape.
134
+ if (path) {
135
+ const at = (0, _sharedUi.readPath)(currentValue, path);
136
+ if (!at.ok) return {
137
+ found: false,
138
+ reason: at.error
139
+ };
140
+ return {
141
+ found: true,
142
+ label,
143
+ path,
144
+ shape: (0, _sharedUi.shapeSummary)(at.value),
145
+ currentValue: at.value
146
+ };
147
+ }
148
+ // `shape` BEFORE `currentValue`: a result is truncated by slicing the
149
+ // encoded string, so what is serialised first is what survives the cut,
150
+ // and the sketch does not grow with the data.
95
151
  return {
96
152
  found: true,
97
153
  label,
154
+ ...((0, _sharedUi.shapeSummary)(currentValue) ? {
155
+ shape: (0, _sharedUi.shapeSummary)(currentValue)
156
+ } : {}),
98
157
  currentValue: (0, _sharedUi.capDetail)(currentValue, _sharedUi.WIRE_DETAIL_LIMIT_BYTES)
99
158
  };
100
159
  },
@@ -120,10 +179,22 @@ const jotaiSyncAdapter = exports.jotaiSyncAdapter = (0, _sharedUi.createSyncAdap
120
179
  value = undefined;
121
180
  }
122
181
  }
182
+ // The compact view is what a caller reaches for precisely because it
183
+ // does not want the data; the item shape of a list is the one thing it
184
+ // cannot infer without it, and it costs a line.
185
+ let shape;
186
+ try {
187
+ shape = (0, _sharedUi.shapeSummary)(a.getValue());
188
+ } catch {
189
+ shape = undefined;
190
+ }
123
191
  return {
124
192
  label: a.label,
125
193
  changes: a.changeCount,
126
194
  writable: typeof a.setValue === "function",
195
+ ...(shape ? {
196
+ shape
197
+ } : {}),
127
198
  ...(includeValues ? {
128
199
  currentValue: value
129
200
  } : {})
@@ -136,14 +207,100 @@ const jotaiSyncAdapter = exports.jotaiSyncAdapter = (0, _sharedUi.createSyncAdap
136
207
  includedValues: includeValues
137
208
  };
138
209
  },
139
- /** Write a value to a writable atom (throws for unknown/read-only atoms). */
210
+ /**
211
+ * Write to a writable atom (throws for unknown/read-only atoms).
212
+ *
213
+ * THIS USED TO BE FOUR LINES WITH NO GUARD. `setAtom` took whatever it was
214
+ * given and wrote it: a string over a number, `null` over a list the screen
215
+ * maps, a one-item array over a fifty-item one — every failure mode the
216
+ * zustand and react-query adapters refuse, jotai accepted silently. It was
217
+ * the only writable state tool with no protection at all, and it had never
218
+ * had an eval case, so nothing had ever measured it.
219
+ *
220
+ * It now takes the same three forms as `zustand.setState`, backed by the
221
+ * same shared implementations:
222
+ * { value } replace (guarded unless force)
223
+ * { value, merge: true } merge a patch, id-addressed lists allowed
224
+ * { path, value } set one value; no depth to get wrong
225
+ */
140
226
  setAtom: params => {
141
227
  const {
142
228
  label,
143
- value
229
+ value,
230
+ merge,
231
+ path,
232
+ force
144
233
  } = (0, _sharedUi.readParams)(params);
145
234
  if (!label) throw new Error("setAtom requires a `label`");
146
- _jotaiStateStore.jotaiStateStore.setAtomValue(label, value);
235
+ const atom = _jotaiStateStore.jotaiStateStore.getAtom(label);
236
+ let current;
237
+ try {
238
+ current = atom?.getValue();
239
+ } catch {
240
+ current = undefined;
241
+ }
242
+
243
+ // A replace with no guard is the old behaviour; keep it reachable via
244
+ // `force`, and keep it the ONLY way to get it.
245
+ let effective = value;
246
+ let keyedNotes = [];
247
+ let unchecked = [];
248
+ const merging = merge === true || path !== undefined;
249
+ if (path !== undefined) {
250
+ if (value === undefined) {
251
+ return {
252
+ ok: false,
253
+ error: `Refused: sent \`path\` without \`value\`. Say what to set "${path}" to.`
254
+ };
255
+ }
256
+ const built = (0, _sharedUi.patchFromPath)(current, path, value);
257
+ if (!built.ok) return {
258
+ ok: false,
259
+ error: `Refused: ${built.error}`
260
+ };
261
+ effective = built.patch;
262
+ }
263
+ if (!force && merging && current !== undefined) {
264
+ const expanded = (0, _sharedUi.expandKeyedArrays)(current, effective);
265
+ // See keyedArrays.ts: an added item whose id disagrees with the key it
266
+ // was sent under is ambiguous, and only this layer can say so.
267
+ if (expanded.error) {
268
+ return {
269
+ ok: false,
270
+ error: `Refused: ${expanded.error}`
271
+ };
272
+ }
273
+ effective = expanded.patch;
274
+ keyedNotes = expanded.notes;
275
+ unchecked = (0, _sharedUi.unshapedLists)(current).filter(p => touchesList(effective, p));
276
+ const violations = (0, _sharedUi.typedEditViolations)(current, effective, {
277
+ noun: "atom"
278
+ });
279
+ if (violations.length) {
280
+ const suggestion = (0, _sharedUi.suggestedPatch)(effective, violations);
281
+ return {
282
+ ok: false,
283
+ error: (0, _sharedUi.refusalText)(violations, "atom", suggestion),
284
+ violations,
285
+ ...(suggestion ? {
286
+ suggestedValue: suggestion
287
+ } : {})
288
+ };
289
+ }
290
+ }
291
+ _jotaiStateStore.jotaiStateStore.setAtomValue(label, merging ? deepMergeInto(current, effective) : effective);
292
+ return {
293
+ ok: true,
294
+ ...(path !== undefined ? {
295
+ path
296
+ } : {}),
297
+ ...(keyedNotes.length ? {
298
+ keyed: keyedNotes
299
+ } : {}),
300
+ ...(unchecked.length ? {
301
+ 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.`)
302
+ } : {})
303
+ };
147
304
  }
148
305
  }
149
306
  });
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  import { jotaiStateStore } from "../utils/jotaiStateStore";
4
- import { WIRE_DETAIL_LIMIT_BYTES, createSyncAdapter, capDetail, detailByIdAction, memoByIdentity, onDeviceMarker, readParams, toWire } from "@buoy-gg/shared-ui";
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
5
 
6
6
  /**
7
7
  * Sentinel used in WIRE-FORM snapshots in place of raw prevValue/nextValue.
@@ -9,6 +9,41 @@ import { WIRE_DETAIL_LIMIT_BYTES, createSyncAdapter, capDetail, detailByIdAction
9
9
  * snapshot. Dashboards fetch values via `getChangeDetail`.
10
10
  */
11
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
+
12
47
  /**
13
48
  * Per-change wire cache — the store prepends and never mutates a recorded
14
49
  * change, so identity is a correct, self-invalidating key.
@@ -63,7 +98,8 @@ export const jotaiSyncAdapter = createSyncAdapter({
63
98
  */
64
99
  getAtomValue: params => {
65
100
  const {
66
- label
101
+ label,
102
+ path
67
103
  } = readParams(params);
68
104
  if (!label) return {
69
105
  found: false,
@@ -89,9 +125,32 @@ export const jotaiSyncAdapter = createSyncAdapter({
89
125
  currentValue: VALUE_ON_DEVICE
90
126
  };
91
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.
92
148
  return {
93
149
  found: true,
94
150
  label,
151
+ ...(shapeSummary(currentValue) ? {
152
+ shape: shapeSummary(currentValue)
153
+ } : {}),
95
154
  currentValue: capDetail(currentValue, WIRE_DETAIL_LIMIT_BYTES)
96
155
  };
97
156
  },
@@ -117,10 +176,22 @@ export const jotaiSyncAdapter = createSyncAdapter({
117
176
  value = undefined;
118
177
  }
119
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
+ }
120
188
  return {
121
189
  label: a.label,
122
190
  changes: a.changeCount,
123
191
  writable: typeof a.setValue === "function",
192
+ ...(shape ? {
193
+ shape
194
+ } : {}),
124
195
  ...(includeValues ? {
125
196
  currentValue: value
126
197
  } : {})
@@ -133,14 +204,100 @@ export const jotaiSyncAdapter = createSyncAdapter({
133
204
  includedValues: includeValues
134
205
  };
135
206
  },
136
- /** Write a value to a writable atom (throws for unknown/read-only atoms). */
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
+ */
137
223
  setAtom: params => {
138
224
  const {
139
225
  label,
140
- value
226
+ value,
227
+ merge,
228
+ path,
229
+ force
141
230
  } = readParams(params);
142
231
  if (!label) throw new Error("setAtom requires a `label`");
143
- jotaiStateStore.setAtomValue(label, value);
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
+ };
144
301
  }
145
302
  }
146
303
  });
@@ -51,12 +51,29 @@ export declare const jotaiSyncAdapter: import("@buoy-gg/shared-ui").SyncAdapter<
51
51
  found: false;
52
52
  reason: string;
53
53
  label?: undefined;
54
+ path?: undefined;
55
+ shape?: undefined;
54
56
  currentValue?: undefined;
55
57
  } | {
56
58
  found: true;
57
59
  label: string;
60
+ path: string;
61
+ shape: {
62
+ shape: string;
63
+ lists?: import("@buoy-gg/shared-ui").ListShape[];
64
+ } | undefined;
58
65
  currentValue: unknown;
59
66
  reason?: undefined;
67
+ } | {
68
+ currentValue: unknown;
69
+ shape?: {
70
+ shape: string;
71
+ lists?: import("@buoy-gg/shared-ui").ListShape[];
72
+ } | undefined;
73
+ found: true;
74
+ label: string;
75
+ reason?: undefined;
76
+ path?: undefined;
60
77
  };
61
78
  /**
62
79
  * Compact current-value reader for a remote driver (MCP/LLM): registered
@@ -67,6 +84,10 @@ export declare const jotaiSyncAdapter: import("@buoy-gg/shared-ui").SyncAdapter<
67
84
  listAtoms: (params?: unknown) => {
68
85
  atoms: {
69
86
  currentValue?: unknown;
87
+ shape?: {
88
+ shape: string;
89
+ lists?: import("@buoy-gg/shared-ui").ListShape[];
90
+ } | undefined;
70
91
  label: string;
71
92
  changes: number;
72
93
  writable: boolean;
@@ -75,7 +96,36 @@ export declare const jotaiSyncAdapter: import("@buoy-gg/shared-ui").SyncAdapter<
75
96
  returned: number;
76
97
  includedValues: boolean;
77
98
  };
78
- /** Write a value to a writable atom (throws for unknown/read-only atoms). */
79
- setAtom: (params?: unknown) => void;
99
+ /**
100
+ * Write to a writable atom (throws for unknown/read-only atoms).
101
+ *
102
+ * THIS USED TO BE FOUR LINES WITH NO GUARD. `setAtom` took whatever it was
103
+ * given and wrote it: a string over a number, `null` over a list the screen
104
+ * maps, a one-item array over a fifty-item one — every failure mode the
105
+ * zustand and react-query adapters refuse, jotai accepted silently. It was
106
+ * the only writable state tool with no protection at all, and it had never
107
+ * had an eval case, so nothing had ever measured it.
108
+ *
109
+ * It now takes the same three forms as `zustand.setState`, backed by the
110
+ * same shared implementations:
111
+ * { value } replace (guarded unless force)
112
+ * { value, merge: true } merge a patch, id-addressed lists allowed
113
+ * { path, value } set one value; no depth to get wrong
114
+ */
115
+ setAtom: (params?: unknown) => {
116
+ ok: false;
117
+ error: string;
118
+ } | {
119
+ suggestedValue?: Record<string, unknown> | undefined;
120
+ ok: false;
121
+ error: string;
122
+ violations: import("@buoy-gg/shared-ui").EditViolation[];
123
+ } | {
124
+ unchecked?: string[] | undefined;
125
+ keyed?: string[] | undefined;
126
+ path?: string | undefined;
127
+ ok: true;
128
+ error?: undefined;
129
+ };
80
130
  }>;
81
131
  //# sourceMappingURL=jotaiSyncAdapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"jotaiSyncAdapter.d.ts","sourceRoot":"","sources":["../../../../src/jotai/sync/jotaiSyncAdapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAahD;;;;GAIG;AACH,eAAO,MAAM,eAAe;;;;EAG3B,CAAC;AA4BF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;IAiBzB;;;;OAIG;;;;;IAQH;;;OAGG;4BACqB,OAAO;;;;;;;;;;;IA0B/B;;;;;OAKG;yBACkB,OAAO;;;;;;;;;;;IA4B5B,6EAA6E;uBAC1D,OAAO;EAMF,CAAC"}
1
+ {"version":3,"file":"jotaiSyncAdapter.d.ts","sourceRoot":"","sources":["../../../../src/jotai/sync/jotaiSyncAdapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAqBhD;;;;GAIG;AACH,eAAO,MAAM,eAAe;;;;EAG3B,CAAC;AAmFF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;IAiBzB;;;;OAIG;;;;;IAQH;;;OAGG;4BACqB,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAsC/B;;;;;OAKG;yBACkB,OAAO;;;;;;;;;;;;;;;IAsC5B;;;;;;;;;;;;;;;OAeG;uBACgB,OAAO;;;;;;;;;;;;;;;EAiEF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buoy-gg/jotai",
3
- "version": "7.0.35",
3
+ "version": "7.0.36",
4
4
  "description": "Jotai devtools inside your React Native app — every atom write shows prev → next, with per-atom history & live values. Part of Buoy devtools.",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -29,8 +29,8 @@
29
29
  "**/snapshotProvider.ts"
30
30
  ],
31
31
  "dependencies": {
32
- "@buoy-gg/shared-ui": "^7.0.35",
33
- "@buoy-gg/license": "^7.0.35"
32
+ "@buoy-gg/license": "^7.0.36",
33
+ "@buoy-gg/shared-ui": "^7.0.36"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "jotai": ">=2.0.0",