@buoy-gg/react-query 7.0.34 → 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.
Files changed (28) hide show
  1. package/lib/commonjs/react-query/components/DataEditorMode.js +2 -2
  2. package/lib/commonjs/react-query/components/FilterStatusBadge.js +1 -1
  3. package/lib/commonjs/react-query/components/MutationEditorMode.js +2 -2
  4. package/lib/commonjs/react-query/components/query-browser/MutationButton.js +2 -2
  5. package/lib/commonjs/react-query/components/query-browser/MutationDetails.js +4 -4
  6. package/lib/commonjs/react-query/components/query-browser/MutationDetailsChips.js +1 -1
  7. package/lib/commonjs/react-query/components/query-browser/MutationInformation.js +1 -1
  8. package/lib/commonjs/react-query/components/query-browser/MutationsList.js +1 -1
  9. package/lib/commonjs/react-query/components/query-browser/QueryBrowser.js +1 -1
  10. package/lib/commonjs/react-query/components/query-browser/QueryDetails.js +4 -4
  11. package/lib/commonjs/react-query/components/query-browser/QueryDetailsChip.js +1 -1
  12. package/lib/commonjs/react-query/sync/reactQuerySyncAdapter.js +399 -6
  13. package/lib/module/react-query/components/DataEditorMode.js +3 -3
  14. package/lib/module/react-query/components/FilterStatusBadge.js +2 -2
  15. package/lib/module/react-query/components/MutationEditorMode.js +3 -3
  16. package/lib/module/react-query/components/query-browser/MutationButton.js +3 -3
  17. package/lib/module/react-query/components/query-browser/MutationDetails.js +5 -5
  18. package/lib/module/react-query/components/query-browser/MutationDetailsChips.js +2 -2
  19. package/lib/module/react-query/components/query-browser/MutationInformation.js +2 -2
  20. package/lib/module/react-query/components/query-browser/MutationsList.js +2 -2
  21. package/lib/module/react-query/components/query-browser/QueryBrowser.js +2 -2
  22. package/lib/module/react-query/components/query-browser/QueryDetails.js +5 -5
  23. package/lib/module/react-query/components/query-browser/QueryDetailsChip.js +2 -2
  24. package/lib/module/react-query/sync/reactQuerySyncAdapter.js +400 -6
  25. package/lib/typescript/react-query/components/query-browser/QueryBrowser.d.ts.map +1 -1
  26. package/lib/typescript/react-query/sync/reactQuerySyncAdapter.d.ts +100 -16
  27. package/lib/typescript/react-query/sync/reactQuerySyncAdapter.d.ts.map +1 -1
  28. package/package.json +3 -3
@@ -276,7 +276,7 @@ const styles = _reactNative.StyleSheet.create({
276
276
  borderBottomColor: _sharedUi.night.color.accent + "33",
277
277
  letterSpacing: 0.5,
278
278
  textTransform: "uppercase",
279
- fontFamily: "monospace"
279
+ fontFamily: _sharedUi.monoFont
280
280
  },
281
281
  queryExplorerContent: {
282
282
  padding: 8
@@ -309,7 +309,7 @@ const styles = _reactNative.StyleSheet.create({
309
309
  borderBottomColor: _sharedUi.night.color.accent + "33",
310
310
  letterSpacing: 0.5,
311
311
  textTransform: "uppercase",
312
- fontFamily: "monospace"
312
+ fontFamily: _sharedUi.monoFont
313
313
  },
314
314
  dataContent: {
315
315
  padding: 8
@@ -50,6 +50,6 @@ const styles = _reactNative.StyleSheet.create({
50
50
  text: {
51
51
  fontSize: 9,
52
52
  color: _sharedUi.night.color.textTertiary,
53
- fontFamily: "monospace"
53
+ fontFamily: _sharedUi.monoFont
54
54
  }
55
55
  });
@@ -236,7 +236,7 @@ const styles = _reactNative.StyleSheet.create({
236
236
  borderBottomColor: _sharedUi.night.color.accent + "33",
237
237
  letterSpacing: 0.5,
238
238
  textTransform: "uppercase",
239
- fontFamily: "monospace"
239
+ fontFamily: _sharedUi.monoFont
240
240
  },
241
241
  mutationExplorerContent: {
242
242
  padding: 8
@@ -268,7 +268,7 @@ const styles = _reactNative.StyleSheet.create({
268
268
  borderBottomColor: _sharedUi.night.color.accent + "33",
269
269
  letterSpacing: 0.5,
270
270
  textTransform: "uppercase",
271
- fontFamily: "monospace"
271
+ fontFamily: _sharedUi.monoFont
272
272
  },
273
273
  dataContent: {
274
274
  padding: 8
@@ -172,7 +172,7 @@ const styles = _reactNative.StyleSheet.create({
172
172
  paddingHorizontal: 12
173
173
  },
174
174
  mutationKey: {
175
- fontFamily: "monospace",
175
+ fontFamily: _sharedUi.monoFont,
176
176
  fontSize: 12,
177
177
  color: _sharedUi.night.color.text,
178
178
  lineHeight: 16
@@ -185,6 +185,6 @@ const styles = _reactNative.StyleSheet.create({
185
185
  bottomRightText: {
186
186
  fontSize: 9,
187
187
  color: _sharedUi.night.color.textTertiary,
188
- fontFamily: "monospace"
188
+ fontFamily: _sharedUi.monoFont
189
189
  }
190
190
  });
@@ -78,7 +78,7 @@ const styles = _reactNative.StyleSheet.create({
78
78
  borderBottomColor: _sharedUi.night.color.accent + "33",
79
79
  letterSpacing: 0.5,
80
80
  textTransform: "uppercase",
81
- fontFamily: "monospace"
81
+ fontFamily: _sharedUi.monoFont
82
82
  },
83
83
  row: {
84
84
  flexDirection: "row",
@@ -96,7 +96,7 @@ const styles = _reactNative.StyleSheet.create({
96
96
  flexWrap: {
97
97
  fontSize: 12,
98
98
  color: _sharedUi.night.color.text,
99
- fontFamily: "monospace",
99
+ fontFamily: _sharedUi.monoFont,
100
100
  lineHeight: 18,
101
101
  flexShrink: 1,
102
102
  backgroundColor: _sharedUi.night.color.accent + "15",
@@ -112,13 +112,13 @@ const styles = _reactNative.StyleSheet.create({
112
112
  fontWeight: "600",
113
113
  letterSpacing: 0.5,
114
114
  textTransform: "uppercase",
115
- fontFamily: "monospace"
115
+ fontFamily: _sharedUi.monoFont
116
116
  },
117
117
  valueText: {
118
118
  fontSize: 12,
119
119
  color: _sharedUi.night.color.text,
120
120
  fontWeight: "500",
121
121
  fontVariant: ["tabular-nums"],
122
- fontFamily: "monospace"
122
+ fontFamily: _sharedUi.monoFont
123
123
  }
124
124
  });
@@ -63,6 +63,6 @@ const styles = _reactNative.StyleSheet.create({
63
63
  fontWeight: "600",
64
64
  textTransform: "uppercase",
65
65
  letterSpacing: 0.5,
66
- fontFamily: "monospace"
66
+ fontFamily: _sharedUi.monoFont
67
67
  }
68
68
  });
@@ -102,7 +102,7 @@ const styles = _reactNative.StyleSheet.create({
102
102
  borderTopRightRadius: 8,
103
103
  borderBottomWidth: 1,
104
104
  borderBottomColor: _sharedUi.night.color.accent + "33",
105
- fontFamily: "monospace",
105
+ fontFamily: _sharedUi.monoFont,
106
106
  letterSpacing: 1,
107
107
  textTransform: "uppercase"
108
108
  },
@@ -214,7 +214,7 @@ const styles = _reactNative.StyleSheet.create({
214
214
  color: _sharedUi.night.color.textTertiary,
215
215
  fontSize: 14,
216
216
  textAlign: "center",
217
- fontFamily: "monospace",
217
+ fontFamily: _sharedUi.monoFont,
218
218
  letterSpacing: 0.5
219
219
  }
220
220
  });
@@ -175,7 +175,7 @@ const styles = _reactNative.StyleSheet.create({
175
175
  color: _sharedUi.night.color.textTertiary,
176
176
  fontSize: 14,
177
177
  textAlign: "center",
178
- fontFamily: "monospace",
178
+ fontFamily: _sharedUi.monoFont,
179
179
  letterSpacing: 0.5
180
180
  }
181
181
  });
@@ -95,7 +95,7 @@ const styles = _reactNative.StyleSheet.create({
95
95
  borderBottomColor: _sharedUi.night.color.accent + "33",
96
96
  letterSpacing: 0.5,
97
97
  textTransform: "uppercase",
98
- fontFamily: "monospace"
98
+ fontFamily: _sharedUi.monoFont
99
99
  },
100
100
  row: {
101
101
  flexDirection: "row",
@@ -113,7 +113,7 @@ const styles = _reactNative.StyleSheet.create({
113
113
  queryKeyText: {
114
114
  fontSize: 12,
115
115
  color: _sharedUi.night.color.text,
116
- fontFamily: "monospace",
116
+ fontFamily: _sharedUi.monoFont,
117
117
  lineHeight: 18,
118
118
  flexShrink: 1,
119
119
  backgroundColor: _sharedUi.night.color.accent + "15",
@@ -129,13 +129,13 @@ const styles = _reactNative.StyleSheet.create({
129
129
  fontWeight: "600",
130
130
  letterSpacing: 0.5,
131
131
  textTransform: "uppercase",
132
- fontFamily: "monospace"
132
+ fontFamily: _sharedUi.monoFont
133
133
  },
134
134
  valueText: {
135
135
  fontSize: 12,
136
136
  color: _sharedUi.night.color.text,
137
137
  fontWeight: "500",
138
138
  fontVariant: ["tabular-nums"],
139
- fontFamily: "monospace"
139
+ fontFamily: _sharedUi.monoFont
140
140
  }
141
141
  });
@@ -73,6 +73,6 @@ const styles = _reactNative.StyleSheet.create({
73
73
  fontWeight: "600",
74
74
  textTransform: "uppercase",
75
75
  letterSpacing: 0.5,
76
- fontFamily: "monospace"
76
+ fontFamily: _sharedUi.monoFont
77
77
  }
78
78
  });
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.createReactQuerySyncAdapter = createReactQuerySyncAdapter;
7
+ var _sharedUi = require("@buoy-gg/shared-ui");
7
8
  var _reactQuery = require("@tanstack/react-query");
8
9
  var _dehydrate = require("./dehydrate");
9
10
  var _getQueryStatusLabel = require("../utils/getQueryStatusLabel");
@@ -21,12 +22,123 @@ var _getQueryStatusLabel = require("../utils/getQueryStatusLabel");
21
22
  * on-device markers. Dashboards fetch real data via `getQueryData`.
22
23
  */
23
24
 
25
+ /**
26
+ * Does this patch write into the list at `path`? Only then is an
27
+ * unchecked-shape warning about that list worth saying — a patch that changes
28
+ * `promoCode` should not be told about an empty `results`. Mirrors the same
29
+ * helper in the zustand adapter, which has warned about this since it shipped.
30
+ */
31
+ function touchesList(patch, path) {
32
+ let cur = patch;
33
+ for (const seg of path.split(".")) {
34
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) return false;
35
+ if (!(seg in cur)) return false;
36
+ cur = cur[seg];
37
+ }
38
+ // An empty list stays empty; there is no new item to be unsure about.
39
+ return Array.isArray(cur) ? cur.length > 0 : !!cur && typeof cur === "object";
40
+ }
41
+ const isPlainObject = v => !!v && typeof v === "object" && !Array.isArray(v);
42
+ /** The shape category the screen depends on: array vs object vs the rest. */
43
+ function shapeKind(v) {
44
+ if (Array.isArray(v)) return "array";
45
+ if (v === null) return "null";
46
+ if (typeof v === "object") return "object";
47
+ return typeof v;
48
+ }
49
+
50
+ /**
51
+ * Objects merge key by key; arrays and primitives are replaced. Two dangers a
52
+ * remote caller (an LLM most of all) hits are collected instead of silently
53
+ * written:
54
+ *
55
+ * - `shrunk`: a non-empty array shorter than the one it replaces — "change
56
+ * item 0" sent as only item 0, dropping the rest.
57
+ * - `breaks`: a field whose SHAPE changes — an array or object overwritten by
58
+ * null or a primitive. This is the crash we actually shipped: a model set
59
+ * the siblings it wasn't changing (`sizes:null, store:null`) thinking it had
60
+ * to name them, and the screen's `sizes.map(s => s.multiplier)` threw. Merge
61
+ * KEEPS untouched fields, so nulling them is never necessary and never safe.
62
+ *
63
+ * The handler refuses either unless forced. An empty array (explicit clear)
64
+ * and null→value / value→value-of-same-kind are fine.
65
+ */
66
+ function deepMerge(base, patch, path = "", issues = {
67
+ shrunk: [],
68
+ breaks: []
69
+ }) {
70
+ if (Array.isArray(base) && Array.isArray(patch)) {
71
+ if (patch.length > 0 && patch.length < base.length) {
72
+ issues.shrunk.push({
73
+ path: path || "(root)",
74
+ have: base.length,
75
+ sent: patch.length
76
+ });
77
+ }
78
+ return patch;
79
+ }
80
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
81
+ const out = {
82
+ ...base
83
+ };
84
+ for (const [k, v] of Object.entries(patch)) {
85
+ const p = path ? `${path}.${k}` : k;
86
+ if (k in base) {
87
+ const from = shapeKind(base[k]);
88
+ const to = shapeKind(v);
89
+ // A populated array/object being replaced by null or a primitive breaks
90
+ // the shape the screen renders. (object→object and array→array recurse
91
+ // or replace normally; null/primitive→anything is a real value change.)
92
+ if ((from === "array" || from === "object") && from !== to) {
93
+ issues.breaks.push({
94
+ path: p,
95
+ from,
96
+ to
97
+ });
98
+ }
99
+ out[k] = deepMerge(base[k], v, p, issues);
100
+ } else {
101
+ out[k] = v;
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ /** Parse a loose, non-hash key spelling into comparable string parts, or null. */
107
+ function looseKeyParts(raw) {
108
+ const trimmed = raw.trim();
109
+ try {
110
+ const parsed = JSON.parse(trimmed);
111
+ if (Array.isArray(parsed)) {
112
+ return parsed.map(k => typeof k === "object" && k !== null ? JSON.stringify(k) : String(k));
113
+ }
114
+ } catch {
115
+ /* not JSON — fall through to comma splitting */
116
+ }
117
+ if (trimmed.includes(",") && !trimmed.startsWith("[")) {
118
+ return trimmed.split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
119
+ }
120
+ return null;
121
+ }
24
122
  function createReactQuerySyncAdapter(queryClient) {
25
123
  const getQuery = params => {
26
124
  const {
27
125
  queryHash
28
126
  } = params;
29
- const query = queryClient.getQueryCache().get(queryHash);
127
+ const cache = queryClient.getQueryCache();
128
+ let query = queryHash ? cache.get(queryHash) : undefined;
129
+ // Tolerate a key that is not an exact hash. Models routinely send a
130
+ // comma-joined key ("pokemon-detail,mewtwo,fetch") or a JSON array where a
131
+ // hash is wanted; resolve it against the live cache by comparing each
132
+ // query's key parts, so one malformed hash does not cost a turn.
133
+ if (!query && typeof queryHash === "string") {
134
+ const wanted = looseKeyParts(queryHash);
135
+ if (wanted) {
136
+ query = cache.getAll().find(q => {
137
+ const parts = q.queryKey.map(k => typeof k === "object" ? JSON.stringify(k) : String(k));
138
+ return parts.length === wanted.length && parts.every((p, i) => p === wanted[i]);
139
+ });
140
+ }
141
+ }
30
142
  if (!query) {
31
143
  throw new Error(`Query with hash "${queryHash}" not found`);
32
144
  }
@@ -56,7 +168,8 @@ function createReactQuerySyncAdapter(queryClient) {
56
168
  */
57
169
  getQueryData: params => {
58
170
  const {
59
- queryHash
171
+ queryHash,
172
+ path
60
173
  } = params ?? {};
61
174
  if (!queryHash) return {
62
175
  found: false,
@@ -67,9 +180,55 @@ function createReactQuerySyncAdapter(queryClient) {
67
180
  found: false,
68
181
  reason: "unknown queryHash"
69
182
  };
183
+ if (path) {
184
+ const at = (0, _sharedUi.readPath)(query.state.data, path);
185
+ if (!at.ok) return {
186
+ found: false,
187
+ reason: at.error
188
+ };
189
+ return {
190
+ found: true,
191
+ queryHash,
192
+ path,
193
+ shape: (0, _sharedUi.shapeSummary)(at.value),
194
+ data: at.value
195
+ };
196
+ }
197
+ // Before the data, not after: a long payload is sliced at the engine's
198
+ // 24,000-character cap, and the shape is what a caller needs in order to
199
+ // send a correctly NESTED edit back. Ten of eleven measured `query`
200
+ // write failures were one level too shallow — `{name}` for a payload
201
+ // that is `{item: {name}}` — and the sketch is where that is visible in
202
+ // one line even when the data is not.
203
+ // A query with no data is not the same as a query holding nothing, and
204
+ // the two used to be indistinguishable: an errored query, a disabled
205
+ // one and an empty one all came back as a bare `{found:true}`. A reader
206
+ // — an LLM most of all — then reports "the query is empty" when the
207
+ // truth is "it failed with X". `listQueries` has always known; this is
208
+ // the call where anyone actually asks.
209
+ if (query.state.data === undefined) {
210
+ const status = query.state.status;
211
+ const err = query.state.error;
212
+ return {
213
+ found: true,
214
+ queryHash,
215
+ hasData: false,
216
+ status,
217
+ fetchStatus: query.state.fetchStatus,
218
+ ...(err ? {
219
+ error: err instanceof Error ? err.message : String(err)
220
+ } : {}),
221
+ reason: status === "error" ? "This query has no data because it FAILED. Report the error, not an empty result." : query.state.fetchStatus === "fetching" ? "No data yet — this query is still fetching." : "This query has never resolved (it may be disabled, or never enabled). It is not empty — it has nothing."
222
+ };
223
+ }
224
+ const shape = (0, _sharedUi.shapeSummary)(query.state.data);
70
225
  return {
71
226
  found: true,
72
227
  queryHash,
228
+ hasData: true,
229
+ ...(shape ? {
230
+ shape
231
+ } : {}),
73
232
  data: (0, _dehydrate.capQueryDetail)(query.state.data)
74
233
  };
75
234
  },
@@ -88,7 +247,17 @@ function createReactQuerySyncAdapter(queryClient) {
88
247
  } = params ?? {};
89
248
  const all = queryClient.getQueryCache().getAll();
90
249
  const filtered = staleOnly ? all.filter(q => q.isStale()) : all;
91
- const sorted = [...filtered].sort((a, b) => (b.state.dataUpdatedAt ?? 0) - (a.state.dataUpdatedAt ?? 0));
250
+ // MOUNTED FIRST, then most-recently-updated. `limit` exists to keep
251
+ // this cheap for a remote reader, and sorting by recency alone made it
252
+ // lossy in the one way that matters: a detail screen's query settles
253
+ // once and is then older than every background refetch, so on a cache
254
+ // with more live queries than the limit the query the user is actually
255
+ // LOOKING AT was the first thing dropped. Nothing downstream can undo
256
+ // that — a caller re-sorting the response can only reorder survivors.
257
+ const sorted = [...filtered].sort((a, b) => {
258
+ const mounted = Number(b.getObserversCount() > 0) - Number(a.getObserversCount() > 0);
259
+ return mounted !== 0 ? mounted : (b.state.dataUpdatedAt ?? 0) - (a.state.dataUpdatedAt ?? 0);
260
+ });
92
261
  const capped = typeof limit === "number" && limit > 0 ? sorted.slice(0, limit) : sorted;
93
262
  const queries = capped.map(q => {
94
263
  const err = q.state.error;
@@ -103,6 +272,12 @@ function createReactQuerySyncAdapter(queryClient) {
103
272
  error: err ? err.message ?? String(err) : undefined,
104
273
  ...(includeData ? {
105
274
  data: q.state.data
275
+ } : {}),
276
+ // The compact view exists so a caller can survey the cache without
277
+ // pulling payloads; the shape is the part of a payload it actually
278
+ // needs to write a correct edit, and it costs one line.
279
+ ...((0, _sharedUi.shapeSummary)(q.state.data) ? {
280
+ shape: (0, _sharedUi.shapeSummary)(q.state.data)
106
281
  } : {})
107
282
  };
108
283
  });
@@ -120,17 +295,226 @@ function createReactQuerySyncAdapter(queryClient) {
120
295
  },
121
296
  invalidate: params => queryClient.invalidateQueries(getQuery(params)),
122
297
  reset: params => queryClient.resetQueries(getQuery(params)),
298
+ /**
299
+ * Dropping a query does NOT re-drive the screen watching it.
300
+ *
301
+ * React Query removes the cache entry; a mounted observer keeps its last
302
+ * result in local state until something makes it fetch again. So the app
303
+ * carries on rendering a value that now exists nowhere — including one
304
+ * the agent itself wrote a moment earlier. Measured on device: `remove`
305
+ * on the live menu query left the storefront showing a single fabricated
306
+ * item, with no skeleton, no refetch and no error, while the cache held
307
+ * no menu at all. Returning a bare null let that be reported as "done".
308
+ *
309
+ * `reset` is the action that actually re-drives an observed query, so
310
+ * the note names it rather than leaving the caller to find out from a
311
+ * screen that looks fine.
312
+ */
123
313
  remove: params => {
124
- queryClient.removeQueries(getQuery(params));
314
+ const target = getQuery(params);
315
+ const watched = queryClient.getQueryCache().findAll(target).some(q => q.getObserversCount() > 0);
316
+ queryClient.removeQueries(target);
317
+ return {
318
+ ok: true,
319
+ ...(watched ? {
320
+ note: "Removed — but a screen is still watching this query, and React Query leaves that screen rendering its LAST value (including anything written into the cache before this) until something refetches. Nothing on screen will change on its own. Use `reset` instead when you want the screen to go back to loading and fetch again."
321
+ } : {})
322
+ };
125
323
  },
126
324
  setQueryData: params => {
127
325
  const {
128
- queryKey,
129
- data
326
+ data,
327
+ merge,
328
+ force,
329
+ path,
330
+ value
130
331
  } = params;
332
+ /**
333
+ * Address the query the way EVERY OTHER action here does.
334
+ *
335
+ * This action took `queryKey` and silently ignored `queryHash` — while
336
+ * `listQueries` hands out `queryHash` and getQueryData / invalidate /
337
+ * refetch / reset / remove / triggerError / triggerLoading all take it.
338
+ * A caller that used the hash got `getQueryData(undefined)` →
339
+ * `undefined`, and so a refusal about "(root): undefined → object"
340
+ * that named the wrong problem. With `force:true` — which that refusal
341
+ * literally suggests — it wrote under an undefined key and still
342
+ * returned `{ok:true, changedKeys:[…]}`: a success report for a write
343
+ * that touched nothing, which is the one outcome this codebase refuses
344
+ * to ship. Ask Buoy itself was covered because the ENGINE aliases hash
345
+ * → key before dispatch; the MCP server, the desktop dashboard and
346
+ * Scenarios all call straight in here and were not.
347
+ */
348
+ const {
349
+ queryKey: rawKey,
350
+ queryHash
351
+ } = params;
352
+ let queryKey = rawKey;
353
+ if (queryKey === undefined && typeof queryHash === "string") {
354
+ const found = queryClient.getQueryCache().get(queryHash);
355
+ if (!found) {
356
+ return {
357
+ ok: false,
358
+ error: `Refused: no query with hash ${queryHash} is in the cache. Read listQueries for the current hashes — a query that has been removed or whose key includes changing params (a search term, a cart payload) will not match an older hash.`
359
+ };
360
+ }
361
+ queryKey = found.queryKey;
362
+ }
363
+ if (queryKey === undefined) {
364
+ return {
365
+ ok: false,
366
+ error: `Refused: no query was named. Send \`queryKey\` (the array from listQueries) or \`queryHash\` (its string form).`
367
+ };
368
+ }
369
+ const current = queryClient.getQueryData(queryKey);
370
+ /**
371
+ * There is nothing to merge INTO.
372
+ *
373
+ * The typed-edit guard answers this with "`(root)`: undefined →
374
+ * object", which describes the symptom and not the cause — and it is
375
+ * the same sentence a caller used to get when the query simply could
376
+ * not be found, so it taught nothing. A query with no data is usually
377
+ * one that FAILED or was never enabled, and that is what the caller
378
+ * needs to hear.
379
+ */
380
+ const existing = queryClient.getQueryCache().find({
381
+ queryKey,
382
+ exact: true
383
+ });
384
+ // Only when the query is THERE and holds nothing. A key that is not in
385
+ // the cache at all is a deliberate seed — `setQueryData(["new"], …)`
386
+ // creates the entry, and that has always worked.
387
+ if (current === undefined && existing && (data !== undefined || path !== undefined)) {
388
+ const q = existing;
389
+ const status = q?.state.status;
390
+ const why = status === "error" ? `it FAILED${q?.state.error ? ` (${q.state.error instanceof Error ? q.state.error.message : String(q.state.error)})` : ""}` : status === "pending" ? "it has never resolved (it may be disabled, or still loading)" : "it holds no data";
391
+ return {
392
+ ok: false,
393
+ error: `Refused: this query has no data to edit — ${why}. There is nothing to merge into. Fix the cause first (restoreError, or let it fetch), or send force:true with the COMPLETE value if you mean to seed the cache from nothing.`,
394
+ status
395
+ };
396
+ }
397
+ // `path` is sugar over a merge, resolved against the data as it is now.
398
+ // It only ever produces a patch; everything below — the keyed expansion,
399
+ // the typed-edit guard, the receipt — then runs unchanged, so the guard
400
+ // stays the single place that decides what a write may do.
401
+ // `data` stopped being schema-required when `path` became an
402
+ // alternative, so the handler has to reject NEITHER — the same hole that
403
+ // let a bare `setState` fall through to a whole-store replace. Here it
404
+ // would write `undefined` over a cached value.
405
+ if (data === undefined && path === undefined) {
406
+ return {
407
+ ok: false,
408
+ error: `Refused: no change was described. Send \`data\` (with merge:true for a partial edit), or \`path\` and \`value\` to set one value.`
409
+ };
410
+ }
411
+ let patch = data;
412
+ if (path !== undefined) {
413
+ if (value === undefined) {
414
+ return {
415
+ ok: false,
416
+ error: `Refused: sent \`path\` without \`value\`. Say what to set "${path}" to; if you mean to clear it, send value:null and the field must already be nullable.`
417
+ };
418
+ }
419
+ const built = (0, _sharedUi.patchFromPath)(current, path, value);
420
+ if (!built.ok) return {
421
+ ok: false,
422
+ error: `Refused: ${built.error}`
423
+ };
424
+ patch = built.patch;
425
+ }
426
+ if (merge || path !== undefined) {
427
+ // Address list items by their own id instead of resending the list:
428
+ // {"results": {"pikachu": {"name": "test123"}}}
429
+ // Expanded BEFORE the guard below, so the typed-edit rule still holds
430
+ // over the result. This is the only form that works when the read was
431
+ // truncated — and a capped read is the normal case here, not the edge
432
+ // one. See packages/shared/src/sync/keyedArrays.ts for the measurement.
433
+ const expandedKeyed = force ? {
434
+ patch,
435
+ notes: [],
436
+ error: undefined
437
+ } : (0, _sharedUi.expandKeyedArrays)(current, patch);
438
+ // An id that contradicts its own address is refused HERE, where we
439
+ // still know both halves; the typed-edit guard downstream would only
440
+ // see the item we picked and could not name the ambiguity.
441
+ if (expandedKeyed.error) {
442
+ return {
443
+ ok: false,
444
+ error: `Refused: ${expandedKeyed.error}`
445
+ };
446
+ }
447
+ const effective = expandedKeyed.patch;
448
+ // Edit like the devtools value editor: only same-type changes to
449
+ // fields that already exist. One rule replaces every ad-hoc guard —
450
+ // no new keys, no type changes, no null-ing a rendered array/object,
451
+ // no resizing a list. This is what makes a crash structurally
452
+ // impossible. `force` drops to a raw write for the rare real case.
453
+ if (!force) {
454
+ const violations = (0, _sharedUi.typedEditViolations)(current, effective, {
455
+ noun: "data"
456
+ });
457
+ if (violations.length > 0) {
458
+ const suggestion = (0, _sharedUi.suggestedPatch)(effective, violations);
459
+ return {
460
+ ok: false,
461
+ error: (0, _sharedUi.refusalText)(violations, "data", suggestion),
462
+ violations,
463
+ ...(suggestion ? {
464
+ suggestedData: suggestion
465
+ } : {})
466
+ };
467
+ }
468
+ }
469
+ const next = deepMerge(current, effective);
470
+ // A list that was EMPTY had no item shape for anything to check the
471
+ // new item against — not the guard above, not the reader, not the
472
+ // app. The zustand tool has always said so; this one stayed silent
473
+ // while the system prompt told the model the note always arrives, so
474
+ // a write of an invented shape into an empty list came back a clean
475
+ // success and got reported as one.
476
+ const unchecked = (0, _sharedUi.unshapedLists)(current).filter(p => touchesList(effective, p));
477
+ queryClient.setQueryData(queryKey, next, {
478
+ updatedAt: Date.now()
479
+ });
480
+ return {
481
+ ok: true,
482
+ mode: "merge",
483
+ ...(path !== undefined ? {
484
+ path
485
+ } : {}),
486
+ changedKeys: isPlainObject(effective) ? Object.keys(effective) : undefined,
487
+ ...(expandedKeyed.notes.length ? {
488
+ keyed: expandedKeyed.notes
489
+ } : {}),
490
+ ...(unchecked.length ? {
491
+ unchecked: unchecked.map(p => `\`${p}\` was empty, so nothing in the app knows what its items look like and the shape of what you added was NOT checked. If the screen renders a field you left out, it will render undefined — read the query back and compare against how the screen uses it.`)
492
+ } : {})
493
+ };
494
+ }
495
+ // THE GUARD. A remote caller — an LLM most of all — reads the cached
496
+ // value through a wire that truncates big payloads, edits one field,
497
+ // and writes the torn object back. The screen then renders
498
+ // `data.stats.map` over a field that no longer exists and the app
499
+ // dies. If the replacement silently drops top-level keys the cache
500
+ // has, that is almost never intended, so refuse and name the fix.
501
+ if (!force && isPlainObject(current) && isPlainObject(data)) {
502
+ const missing = Object.keys(current).filter(k => !(k in data));
503
+ if (missing.length > 0) {
504
+ return {
505
+ ok: false,
506
+ error: `Refused: the new value drops ${missing.length} top-level field(s) the cached value has (${missing.slice(0, 8).join(", ")}${missing.length > 8 ? ", …" : ""}). A screen rendering those fields would crash. To change some fields, send merge:true with ONLY the fields that change. To really replace the whole value, send force:true.`,
507
+ missingKeys: missing
508
+ };
509
+ }
510
+ }
131
511
  queryClient.setQueryData(queryKey, data, {
132
512
  updatedAt: Date.now()
133
513
  });
514
+ return {
515
+ ok: true,
516
+ mode: "replace"
517
+ };
134
518
  },
135
519
  triggerError: params => {
136
520
  const query = getQuery(params);
@@ -184,7 +568,16 @@ function createReactQuerySyncAdapter(queryClient) {
184
568
  }
185
569
  },
186
570
  clearQueryCache: () => {
571
+ // Same trap as `remove`, across every screen at once. See its note.
572
+ const watched = queryClient.getQueryCache().getAll().filter(q => q.getObserversCount() > 0).length;
187
573
  queryClient.getQueryCache().clear();
574
+ return {
575
+ ok: true,
576
+ cleared: true,
577
+ ...(watched ? {
578
+ note: `Cleared — but ${watched} quer${watched === 1 ? "y is" : "ies are"} still being watched by a screen, and React Query leaves those screens rendering their LAST value until something refetches. The cache is empty; the app on screen is not. Use \`reset\` on a query when you want its screen to reload.`
579
+ } : {})
580
+ };
188
581
  },
189
582
  clearMutationCache: () => {
190
583
  queryClient.getMutationCache().clear();