@buoy-gg/react-query 7.0.17 → 7.0.18

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.
@@ -3,13 +3,131 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
+ exports.UNSERIALIZABLE_DATA = exports.SNAPSHOT_DATA_INLINE_LIMIT = exports.MAX_QUERY_DETAIL_BYTES = exports.DATA_ON_DEVICE = void 0;
7
+ exports.capQueryDetail = capQueryDetail;
6
8
  exports.dehydrateQueryClient = dehydrateQueryClient;
9
+ exports.isDataOnDeviceMarker = isDataOnDeviceMarker;
10
+ exports.isUnserializableMarker = isUnserializableMarker;
11
+ exports.isWireDataMarker = isWireDataMarker;
12
+ exports.jsonLongerThan = jsonLongerThan;
13
+ exports.toWireData = toWireData;
14
+ exports.toWireError = toWireError;
7
15
  /**
8
16
  * Dehydration for external sync — ported from buoy-desktop's
9
17
  * react-query-external-sync/hydration.ts so the desktop dashboard can
10
18
  * hydrate an identical mirror of the device's QueryClient.
11
19
  */
12
20
 
21
+ /**
22
+ * Sentinel used in WIRE-FORM snapshots in place of oversized query/mutation
23
+ * data. Same hazard as redux/zustand: a single cached payload can exceed the
24
+ * 2MB emit budget (dashboard goes stale) or freeze JSON.stringify. Dashboards
25
+ * fetch the real value via `getQueryData`.
26
+ */
27
+ const DATA_ON_DEVICE = exports.DATA_ON_DEVICE = Object.freeze({
28
+ __buoyDataOnDevice: true,
29
+ note: "Query data stays on the device — fetched on demand via getQueryData."
30
+ });
31
+
32
+ /** Sentinel when JSON.stringify throws (circular refs, bigint, …). */
33
+ const UNSERIALIZABLE_DATA = exports.UNSERIALIZABLE_DATA = Object.freeze({
34
+ __buoyUnserializable: true,
35
+ note: "Query data is not JSON-serializable (circular or bigint) and stayed on the device."
36
+ });
37
+
38
+ /** Keep small payloads inline; strip anything over this (same as network bodies). */
39
+ const SNAPSHOT_DATA_INLINE_LIMIT = exports.SNAPSHOT_DATA_INLINE_LIMIT = 16 * 1024;
40
+ const MAX_QUERY_DETAIL_BYTES = exports.MAX_QUERY_DETAIL_BYTES = 8 * 1024 * 1024;
41
+ function jsonLongerThan(value, limit) {
42
+ let bytes = 0;
43
+ const stack = [value];
44
+ const seen = new Set();
45
+ while (stack.length > 0) {
46
+ if (bytes > limit) return true;
47
+ const v = stack.pop();
48
+ if (v === null || v === undefined) bytes += 4;else if (typeof v === "string") bytes += v.length + 2;else if (typeof v === "number") bytes += 8;else if (typeof v === "boolean") bytes += 5;else if (typeof v === "object") {
49
+ if (seen.has(v)) continue;
50
+ seen.add(v);
51
+ if (Array.isArray(v)) {
52
+ bytes += 2 + v.length;
53
+ for (let i = 0; i < v.length; i++) stack.push(v[i]);
54
+ } else {
55
+ bytes += 2;
56
+ for (const k in v) {
57
+ bytes += k.length + 4;
58
+ stack.push(v[k]);
59
+ }
60
+ }
61
+ } else bytes += 4;
62
+ }
63
+ return bytes > limit;
64
+ }
65
+ function isDataOnDeviceMarker(value) {
66
+ return !!value && typeof value === "object" && value.__buoyDataOnDevice === true;
67
+ }
68
+ function isUnserializableMarker(value) {
69
+ return !!value && typeof value === "object" && value.__buoyUnserializable === true;
70
+ }
71
+ function isWireDataMarker(value) {
72
+ return isDataOnDeviceMarker(value) || isUnserializableMarker(value);
73
+ }
74
+
75
+ /** Replace unserializable / oversized values with a wire marker. */
76
+ function toWireData(value) {
77
+ if (value === undefined) return undefined;
78
+ try {
79
+ JSON.stringify(value);
80
+ } catch {
81
+ return UNSERIALIZABLE_DATA;
82
+ }
83
+ if (jsonLongerThan(value, SNAPSHOT_DATA_INLINE_LIMIT)) {
84
+ return DATA_ON_DEVICE;
85
+ }
86
+ return value;
87
+ }
88
+
89
+ /**
90
+ * Query/mutation `error` (and `fetchFailureReason` / `failureReason`) often
91
+ * hold the live Axios/fetch error: `request` points at XHR (cyclic),
92
+ * `response.data` is the same fat body we already stripped from `state.data`.
93
+ * Spreading `query.state` left that copy on the wire after capping data.
94
+ * The dashboard only reads `error.message`.
95
+ */
96
+ function toWireError(value) {
97
+ if (value == null) return value;
98
+ if (typeof value === "object") {
99
+ const err = value;
100
+ const name = typeof err.name === "string" ? err.name : undefined;
101
+ const message = typeof err.message === "string" ? err.message : undefined;
102
+ if (name !== undefined || message !== undefined) {
103
+ return toWireData({
104
+ ...(name !== undefined ? {
105
+ name
106
+ } : {}),
107
+ ...(message !== undefined ? {
108
+ message
109
+ } : {})
110
+ });
111
+ }
112
+ }
113
+ return toWireData(value);
114
+ }
115
+
116
+ /** Cap an on-demand detail payload (action result, not the per-tick snapshot). */
117
+ function capQueryDetail(value) {
118
+ try {
119
+ JSON.stringify(value);
120
+ } catch {
121
+ return UNSERIALIZABLE_DATA;
122
+ }
123
+ if (jsonLongerThan(value, MAX_QUERY_DETAIL_BYTES)) {
124
+ return {
125
+ __buoyTruncated: true,
126
+ note: `Exceeds the ${MAX_QUERY_DETAIL_BYTES / (1024 * 1024)}MB detail limit — inspect it on-device.`
127
+ };
128
+ }
129
+ return value;
130
+ }
13
131
  function dehydrateQueryClient(client) {
14
132
  const mutations = client.getMutationCache().getAll().map(mutation => dehydrateMutation(mutation));
15
133
  const queries = client.getQueryCache().getAll().map(query => dehydrateQuery(query));
@@ -19,39 +137,64 @@ function dehydrateQueryClient(client) {
19
137
  };
20
138
  }
21
139
  function dehydrateMutation(mutation) {
140
+ const state = mutation.state;
22
141
  return {
23
142
  mutationId: mutation.mutationId,
24
143
  mutationKey: mutation.options.mutationKey,
25
- state: mutation.state,
144
+ state: {
145
+ ...state,
146
+ data: toWireData(state.data),
147
+ variables: toWireData(state.variables),
148
+ error: toWireError(state.error),
149
+ failureReason: toWireError(state.failureReason)
150
+ },
26
151
  ...(mutation.options.scope && {
27
152
  scope: mutation.options.scope
28
153
  }),
29
154
  ...(mutation.meta && {
30
- meta: mutation.meta
155
+ meta: toWireData(mutation.meta)
31
156
  })
32
157
  };
33
158
  }
159
+ function toWireObserverOptions(options) {
160
+ // queryFn / select / persister are functions. initialData and
161
+ // placeholderData are often the SAME fat tree as state.data (apps seed
162
+ // the cache with it). Capping state.data still left those copies on the
163
+ // wire and dropped the Query panel.
164
+ const {
165
+ queryFn: _queryFn,
166
+ select: _select,
167
+ persister: _persister,
168
+ initialData: _initialData,
169
+ placeholderData: _placeholderData,
170
+ ...light
171
+ } = options;
172
+ return {
173
+ ...light,
174
+ queryFn: undefined,
175
+ ...(options.meta ? {
176
+ meta: toWireData(options.meta)
177
+ } : {})
178
+ };
179
+ }
34
180
  function dehydrateQuery(query) {
35
181
  const observerStates = query.observers.map(observer => ({
36
182
  queryHash: query.queryHash,
37
- // Remove queryFn from observer options so the dashboard can't accidentally
38
- // run device fetch functions (they aren't serializable anyway)
39
- options: {
40
- ...observer.options,
41
- queryFn: undefined
42
- }
183
+ options: toWireObserverOptions(observer.options)
43
184
  }));
44
185
  return {
45
186
  state: {
46
187
  ...query.state,
47
188
  ...(query.state.data !== undefined && {
48
- data: query.state.data
49
- })
189
+ data: toWireData(query.state.data)
190
+ }),
191
+ error: toWireError(query.state.error),
192
+ fetchFailureReason: toWireError(query.state.fetchFailureReason)
50
193
  },
51
194
  queryKey: query.queryKey,
52
195
  queryHash: query.queryHash,
53
196
  ...(query.meta && {
54
- meta: query.meta
197
+ meta: toWireData(query.meta)
55
198
  }),
56
199
  observers: observerStates
57
200
  };
@@ -16,6 +16,9 @@ var _getQueryStatusLabel = require("../utils/getQueryStatusLabel");
16
16
  * (useSyncQueries.ts) so the dashboard keeps identical behavior, including
17
17
  * the trigger/restore loading and error simulations used by the TanStack
18
18
  * devtools UI.
19
+ *
20
+ * v2: oversized / unserializable query (and mutation) data is replaced with
21
+ * on-device markers. Dashboards fetch real data via `getQueryData`.
19
22
  */
20
23
 
21
24
  function createReactQuerySyncAdapter(queryClient) {
@@ -30,7 +33,7 @@ function createReactQuerySyncAdapter(queryClient) {
30
33
  return query;
31
34
  };
32
35
  return {
33
- version: 1,
36
+ version: 2,
34
37
  getSnapshot: () => ({
35
38
  dehydratedState: (0, _dehydrate.dehydrateQueryClient)(queryClient),
36
39
  isOnlineManagerOnline: _reactQuery.onlineManager.isOnline()
@@ -46,6 +49,30 @@ function createReactQuerySyncAdapter(queryClient) {
46
49
  };
47
50
  },
48
51
  actions: {
52
+ /**
53
+ * On-demand detail: one query's real cached `data`. Wire-form snapshots
54
+ * replace oversized / unserializable data with markers; this is the
55
+ * explicit, size-guarded channel for the detail pane.
56
+ */
57
+ getQueryData: params => {
58
+ const {
59
+ queryHash
60
+ } = params ?? {};
61
+ if (!queryHash) return {
62
+ found: false,
63
+ reason: "missing queryHash"
64
+ };
65
+ const query = queryClient.getQueryCache().get(queryHash);
66
+ if (!query) return {
67
+ found: false,
68
+ reason: "unknown queryHash"
69
+ };
70
+ return {
71
+ found: true,
72
+ queryHash,
73
+ data: (0, _dehydrate.capQueryDetail)(query.state.data)
74
+ };
75
+ },
49
76
  /**
50
77
  * Compact, token-bounded cache reader for a remote driver (MCP/LLM):
51
78
  * projects each live query to light fields (hash, key, status label,
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.createQuerySnapshotProvider = createQuerySnapshotProvider;
7
7
  exports.registerQuerySnapshotProvider = registerQuerySnapshotProvider;
8
8
  var _sharedUi = require("@buoy-gg/shared-ui");
9
+ var _dataViewer = require("@buoy-gg/shared-ui/dataViewer");
9
10
  var _dehydrate = require("./dehydrate");
10
11
  /**
11
12
  * Time Machine snapshot provider for React Query.
@@ -32,6 +33,28 @@ function normalizeState(state) {
32
33
  fetchMeta: null
33
34
  };
34
35
  }
36
+
37
+ /**
38
+ * The comparable projection of a query for previews: status/error/data ONLY.
39
+ * Timestamps (dataUpdatedAt etc.) are excluded — including them makes every
40
+ * query read as "changed" the moment anything refetches.
41
+ */
42
+ function comparableQuery(q) {
43
+ return {
44
+ status: q.state.status,
45
+ error: q.state.error ?? null,
46
+ data: q.state.data
47
+ };
48
+ }
49
+ function queryLabel(queryKey, queryHash) {
50
+ try {
51
+ const s = JSON.stringify(queryKey);
52
+ return s.length > 60 ? `${s.slice(0, 57)}…` : s;
53
+ } catch {
54
+ return queryHash;
55
+ }
56
+ }
57
+ const ITEM_RENDER_BYTE_LIMIT = 256 * 1024;
35
58
  function createQuerySnapshotProvider(queryClient) {
36
59
  return {
37
60
  id: "query",
@@ -62,8 +85,12 @@ function createQuerySnapshotProvider(queryClient) {
62
85
  warnings
63
86
  };
64
87
  },
65
- async restore(data) {
88
+ async restore(data, options) {
66
89
  const snapshot = data;
90
+ const excludedSet = new Set(options?.excludeKeys ?? []);
91
+ const includedSet = options?.includeKeys ? new Set(options.includeKeys) : null;
92
+ const touch = key => !excludedSet.has(key) && (!includedSet || includedSet.has(key));
93
+ let excludedHits = 0;
67
94
  const result = {
68
95
  ok: true,
69
96
  applied: 0,
@@ -81,6 +108,10 @@ function createQuerySnapshotProvider(queryClient) {
81
108
  const cache = queryClient.getQueryCache();
82
109
  const snapHashes = new Set(snapshot.queries.map(q => q.queryHash));
83
110
  for (const snap of snapshot.queries) {
111
+ if (!touch(snap.queryHash)) {
112
+ excludedHits += 1;
113
+ continue;
114
+ }
84
115
  try {
85
116
  const state = normalizeState(snap.state);
86
117
  const existing = cache.get(snap.queryHash);
@@ -110,6 +141,10 @@ function createQuerySnapshotProvider(queryClient) {
110
141
  // Cache entries the snapshot doesn't know about get removed; a mounted
111
142
  // observer will rebuild + refetch its query on next interaction.
112
143
  for (const query of cache.getAll().slice()) {
144
+ if (!touch(query.queryHash)) {
145
+ excludedHits += 1;
146
+ continue;
147
+ }
113
148
  if (!snapHashes.has(query.queryHash)) {
114
149
  try {
115
150
  cache.remove(query);
@@ -127,7 +162,80 @@ function createQuerySnapshotProvider(queryClient) {
127
162
  queryClient.getMutationCache().clear();
128
163
  result.warnings.push(`mutation cache cleared (${mutationCount} mutation(s))`);
129
164
  }
165
+ if (excludedHits > 0) {
166
+ result.warnings.push(`${excludedHits} quer(y/ies) excluded by you`);
167
+ }
130
168
  return result;
169
+ },
170
+ /** Per-query restore preview. `base` omitted = compare against live cache. */
171
+ async previewItems(target, base) {
172
+ const targetQueries = target?.queries ?? [];
173
+ const liveMode = base === undefined;
174
+ const baseQueries = liveMode ? (0, _dehydrate.dehydrateQueryClient)(queryClient).queries.map(({
175
+ observers: _o,
176
+ ...q
177
+ }) => q) : base?.queries ?? [];
178
+ const baseByHash = new Map(baseQueries.map(q => [q.queryHash, q]));
179
+ const targetByHash = new Map(targetQueries.map(q => [q.queryHash, q]));
180
+ const items = [];
181
+ const notes = [];
182
+ for (const q of targetQueries) {
183
+ const label = queryLabel(q.queryKey, q.queryHash);
184
+ const baseQ = baseByHash.get(q.queryHash);
185
+ if (!baseQ) {
186
+ items.push({
187
+ key: q.queryHash,
188
+ label,
189
+ verdict: "added",
190
+ newValue: comparableQuery(q)
191
+ });
192
+ continue;
193
+ }
194
+ const oldVal = comparableQuery(baseQ);
195
+ const newVal = comparableQuery(q);
196
+ if ((0, _dataViewer.boundedDeepEqual)(oldVal, newVal)) {
197
+ items.push({
198
+ key: q.queryHash,
199
+ label,
200
+ verdict: "unchanged"
201
+ });
202
+ } else if ((0, _dataViewer.jsonLikelyLargerThan)(oldVal, ITEM_RENDER_BYTE_LIMIT) || (0, _dataViewer.jsonLikelyLargerThan)(newVal, ITEM_RENDER_BYTE_LIMIT)) {
203
+ items.push({
204
+ key: q.queryHash,
205
+ label,
206
+ verdict: "too-large",
207
+ reason: "query data too large to render a diff — restore still applies it"
208
+ });
209
+ } else {
210
+ items.push({
211
+ key: q.queryHash,
212
+ label,
213
+ verdict: "changed",
214
+ oldValue: oldVal,
215
+ newValue: newVal
216
+ });
217
+ }
218
+ }
219
+ for (const q of baseQueries) {
220
+ if (!targetByHash.has(q.queryHash)) {
221
+ items.push({
222
+ key: q.queryHash,
223
+ label: queryLabel(q.queryKey, q.queryHash),
224
+ verdict: "removed",
225
+ oldValue: comparableQuery(q)
226
+ });
227
+ }
228
+ }
229
+ if (liveMode) {
230
+ const mutationCount = queryClient.getMutationCache().getAll().length;
231
+ if (mutationCount > 0) {
232
+ notes.push(`${mutationCount} in-memory mutation record(s) will be cleared`);
233
+ }
234
+ }
235
+ return {
236
+ items,
237
+ notes
238
+ };
131
239
  }
132
240
  };
133
241
  }
@@ -6,6 +6,116 @@
6
6
  * hydrate an identical mirror of the device's QueryClient.
7
7
  */
8
8
 
9
+ /**
10
+ * Sentinel used in WIRE-FORM snapshots in place of oversized query/mutation
11
+ * data. Same hazard as redux/zustand: a single cached payload can exceed the
12
+ * 2MB emit budget (dashboard goes stale) or freeze JSON.stringify. Dashboards
13
+ * fetch the real value via `getQueryData`.
14
+ */
15
+ export const DATA_ON_DEVICE = Object.freeze({
16
+ __buoyDataOnDevice: true,
17
+ note: "Query data stays on the device — fetched on demand via getQueryData."
18
+ });
19
+
20
+ /** Sentinel when JSON.stringify throws (circular refs, bigint, …). */
21
+ export const UNSERIALIZABLE_DATA = Object.freeze({
22
+ __buoyUnserializable: true,
23
+ note: "Query data is not JSON-serializable (circular or bigint) and stayed on the device."
24
+ });
25
+
26
+ /** Keep small payloads inline; strip anything over this (same as network bodies). */
27
+ export const SNAPSHOT_DATA_INLINE_LIMIT = 16 * 1024;
28
+ export const MAX_QUERY_DETAIL_BYTES = 8 * 1024 * 1024;
29
+ export function jsonLongerThan(value, limit) {
30
+ let bytes = 0;
31
+ const stack = [value];
32
+ const seen = new Set();
33
+ while (stack.length > 0) {
34
+ if (bytes > limit) return true;
35
+ const v = stack.pop();
36
+ if (v === null || v === undefined) bytes += 4;else if (typeof v === "string") bytes += v.length + 2;else if (typeof v === "number") bytes += 8;else if (typeof v === "boolean") bytes += 5;else if (typeof v === "object") {
37
+ if (seen.has(v)) continue;
38
+ seen.add(v);
39
+ if (Array.isArray(v)) {
40
+ bytes += 2 + v.length;
41
+ for (let i = 0; i < v.length; i++) stack.push(v[i]);
42
+ } else {
43
+ bytes += 2;
44
+ for (const k in v) {
45
+ bytes += k.length + 4;
46
+ stack.push(v[k]);
47
+ }
48
+ }
49
+ } else bytes += 4;
50
+ }
51
+ return bytes > limit;
52
+ }
53
+ export function isDataOnDeviceMarker(value) {
54
+ return !!value && typeof value === "object" && value.__buoyDataOnDevice === true;
55
+ }
56
+ export function isUnserializableMarker(value) {
57
+ return !!value && typeof value === "object" && value.__buoyUnserializable === true;
58
+ }
59
+ export function isWireDataMarker(value) {
60
+ return isDataOnDeviceMarker(value) || isUnserializableMarker(value);
61
+ }
62
+
63
+ /** Replace unserializable / oversized values with a wire marker. */
64
+ export function toWireData(value) {
65
+ if (value === undefined) return undefined;
66
+ try {
67
+ JSON.stringify(value);
68
+ } catch {
69
+ return UNSERIALIZABLE_DATA;
70
+ }
71
+ if (jsonLongerThan(value, SNAPSHOT_DATA_INLINE_LIMIT)) {
72
+ return DATA_ON_DEVICE;
73
+ }
74
+ return value;
75
+ }
76
+
77
+ /**
78
+ * Query/mutation `error` (and `fetchFailureReason` / `failureReason`) often
79
+ * hold the live Axios/fetch error: `request` points at XHR (cyclic),
80
+ * `response.data` is the same fat body we already stripped from `state.data`.
81
+ * Spreading `query.state` left that copy on the wire after capping data.
82
+ * The dashboard only reads `error.message`.
83
+ */
84
+ export function toWireError(value) {
85
+ if (value == null) return value;
86
+ if (typeof value === "object") {
87
+ const err = value;
88
+ const name = typeof err.name === "string" ? err.name : undefined;
89
+ const message = typeof err.message === "string" ? err.message : undefined;
90
+ if (name !== undefined || message !== undefined) {
91
+ return toWireData({
92
+ ...(name !== undefined ? {
93
+ name
94
+ } : {}),
95
+ ...(message !== undefined ? {
96
+ message
97
+ } : {})
98
+ });
99
+ }
100
+ }
101
+ return toWireData(value);
102
+ }
103
+
104
+ /** Cap an on-demand detail payload (action result, not the per-tick snapshot). */
105
+ export function capQueryDetail(value) {
106
+ try {
107
+ JSON.stringify(value);
108
+ } catch {
109
+ return UNSERIALIZABLE_DATA;
110
+ }
111
+ if (jsonLongerThan(value, MAX_QUERY_DETAIL_BYTES)) {
112
+ return {
113
+ __buoyTruncated: true,
114
+ note: `Exceeds the ${MAX_QUERY_DETAIL_BYTES / (1024 * 1024)}MB detail limit — inspect it on-device.`
115
+ };
116
+ }
117
+ return value;
118
+ }
9
119
  export function dehydrateQueryClient(client) {
10
120
  const mutations = client.getMutationCache().getAll().map(mutation => dehydrateMutation(mutation));
11
121
  const queries = client.getQueryCache().getAll().map(query => dehydrateQuery(query));
@@ -15,39 +125,64 @@ export function dehydrateQueryClient(client) {
15
125
  };
16
126
  }
17
127
  function dehydrateMutation(mutation) {
128
+ const state = mutation.state;
18
129
  return {
19
130
  mutationId: mutation.mutationId,
20
131
  mutationKey: mutation.options.mutationKey,
21
- state: mutation.state,
132
+ state: {
133
+ ...state,
134
+ data: toWireData(state.data),
135
+ variables: toWireData(state.variables),
136
+ error: toWireError(state.error),
137
+ failureReason: toWireError(state.failureReason)
138
+ },
22
139
  ...(mutation.options.scope && {
23
140
  scope: mutation.options.scope
24
141
  }),
25
142
  ...(mutation.meta && {
26
- meta: mutation.meta
143
+ meta: toWireData(mutation.meta)
27
144
  })
28
145
  };
29
146
  }
147
+ function toWireObserverOptions(options) {
148
+ // queryFn / select / persister are functions. initialData and
149
+ // placeholderData are often the SAME fat tree as state.data (apps seed
150
+ // the cache with it). Capping state.data still left those copies on the
151
+ // wire and dropped the Query panel.
152
+ const {
153
+ queryFn: _queryFn,
154
+ select: _select,
155
+ persister: _persister,
156
+ initialData: _initialData,
157
+ placeholderData: _placeholderData,
158
+ ...light
159
+ } = options;
160
+ return {
161
+ ...light,
162
+ queryFn: undefined,
163
+ ...(options.meta ? {
164
+ meta: toWireData(options.meta)
165
+ } : {})
166
+ };
167
+ }
30
168
  function dehydrateQuery(query) {
31
169
  const observerStates = query.observers.map(observer => ({
32
170
  queryHash: query.queryHash,
33
- // Remove queryFn from observer options so the dashboard can't accidentally
34
- // run device fetch functions (they aren't serializable anyway)
35
- options: {
36
- ...observer.options,
37
- queryFn: undefined
38
- }
171
+ options: toWireObserverOptions(observer.options)
39
172
  }));
40
173
  return {
41
174
  state: {
42
175
  ...query.state,
43
176
  ...(query.state.data !== undefined && {
44
- data: query.state.data
45
- })
177
+ data: toWireData(query.state.data)
178
+ }),
179
+ error: toWireError(query.state.error),
180
+ fetchFailureReason: toWireError(query.state.fetchFailureReason)
46
181
  },
47
182
  queryKey: query.queryKey,
48
183
  queryHash: query.queryHash,
49
184
  ...(query.meta && {
50
- meta: query.meta
185
+ meta: toWireData(query.meta)
51
186
  }),
52
187
  observers: observerStates
53
188
  };
@@ -9,9 +9,12 @@
9
9
  * (useSyncQueries.ts) so the dashboard keeps identical behavior, including
10
10
  * the trigger/restore loading and error simulations used by the TanStack
11
11
  * devtools UI.
12
+ *
13
+ * v2: oversized / unserializable query (and mutation) data is replaced with
14
+ * on-device markers. Dashboards fetch real data via `getQueryData`.
12
15
  */
13
16
  import { onlineManager } from "@tanstack/react-query";
14
- import { dehydrateQueryClient } from "./dehydrate";
17
+ import { capQueryDetail, dehydrateQueryClient } from "./dehydrate";
15
18
  import { getQueryStatusLabel } from "../utils/getQueryStatusLabel";
16
19
  export function createReactQuerySyncAdapter(queryClient) {
17
20
  const getQuery = params => {
@@ -25,7 +28,7 @@ export function createReactQuerySyncAdapter(queryClient) {
25
28
  return query;
26
29
  };
27
30
  return {
28
- version: 1,
31
+ version: 2,
29
32
  getSnapshot: () => ({
30
33
  dehydratedState: dehydrateQueryClient(queryClient),
31
34
  isOnlineManagerOnline: onlineManager.isOnline()
@@ -41,6 +44,30 @@ export function createReactQuerySyncAdapter(queryClient) {
41
44
  };
42
45
  },
43
46
  actions: {
47
+ /**
48
+ * On-demand detail: one query's real cached `data`. Wire-form snapshots
49
+ * replace oversized / unserializable data with markers; this is the
50
+ * explicit, size-guarded channel for the detail pane.
51
+ */
52
+ getQueryData: params => {
53
+ const {
54
+ queryHash
55
+ } = params ?? {};
56
+ if (!queryHash) return {
57
+ found: false,
58
+ reason: "missing queryHash"
59
+ };
60
+ const query = queryClient.getQueryCache().get(queryHash);
61
+ if (!query) return {
62
+ found: false,
63
+ reason: "unknown queryHash"
64
+ };
65
+ return {
66
+ found: true,
67
+ queryHash,
68
+ data: capQueryDetail(query.state.data)
69
+ };
70
+ },
44
71
  /**
45
72
  * Compact, token-bounded cache reader for a remote driver (MCP/LLM):
46
73
  * projects each live query to light fields (hash, key, status label,
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { registerStateSnapshotProvider } from "@buoy-gg/shared-ui";
21
+ import { boundedDeepEqual, jsonLikelyLargerThan } from "@buoy-gg/shared-ui/dataViewer";
21
22
  import { dehydrateQueryClient } from "./dehydrate";
22
23
  /** A restored query must never claim an in-flight fetch it doesn't have. */
23
24
  function normalizeState(state) {
@@ -27,6 +28,28 @@ function normalizeState(state) {
27
28
  fetchMeta: null
28
29
  };
29
30
  }
31
+
32
+ /**
33
+ * The comparable projection of a query for previews: status/error/data ONLY.
34
+ * Timestamps (dataUpdatedAt etc.) are excluded — including them makes every
35
+ * query read as "changed" the moment anything refetches.
36
+ */
37
+ function comparableQuery(q) {
38
+ return {
39
+ status: q.state.status,
40
+ error: q.state.error ?? null,
41
+ data: q.state.data
42
+ };
43
+ }
44
+ function queryLabel(queryKey, queryHash) {
45
+ try {
46
+ const s = JSON.stringify(queryKey);
47
+ return s.length > 60 ? `${s.slice(0, 57)}…` : s;
48
+ } catch {
49
+ return queryHash;
50
+ }
51
+ }
52
+ const ITEM_RENDER_BYTE_LIMIT = 256 * 1024;
30
53
  export function createQuerySnapshotProvider(queryClient) {
31
54
  return {
32
55
  id: "query",
@@ -57,8 +80,12 @@ export function createQuerySnapshotProvider(queryClient) {
57
80
  warnings
58
81
  };
59
82
  },
60
- async restore(data) {
83
+ async restore(data, options) {
61
84
  const snapshot = data;
85
+ const excludedSet = new Set(options?.excludeKeys ?? []);
86
+ const includedSet = options?.includeKeys ? new Set(options.includeKeys) : null;
87
+ const touch = key => !excludedSet.has(key) && (!includedSet || includedSet.has(key));
88
+ let excludedHits = 0;
62
89
  const result = {
63
90
  ok: true,
64
91
  applied: 0,
@@ -76,6 +103,10 @@ export function createQuerySnapshotProvider(queryClient) {
76
103
  const cache = queryClient.getQueryCache();
77
104
  const snapHashes = new Set(snapshot.queries.map(q => q.queryHash));
78
105
  for (const snap of snapshot.queries) {
106
+ if (!touch(snap.queryHash)) {
107
+ excludedHits += 1;
108
+ continue;
109
+ }
79
110
  try {
80
111
  const state = normalizeState(snap.state);
81
112
  const existing = cache.get(snap.queryHash);
@@ -105,6 +136,10 @@ export function createQuerySnapshotProvider(queryClient) {
105
136
  // Cache entries the snapshot doesn't know about get removed; a mounted
106
137
  // observer will rebuild + refetch its query on next interaction.
107
138
  for (const query of cache.getAll().slice()) {
139
+ if (!touch(query.queryHash)) {
140
+ excludedHits += 1;
141
+ continue;
142
+ }
108
143
  if (!snapHashes.has(query.queryHash)) {
109
144
  try {
110
145
  cache.remove(query);
@@ -122,7 +157,80 @@ export function createQuerySnapshotProvider(queryClient) {
122
157
  queryClient.getMutationCache().clear();
123
158
  result.warnings.push(`mutation cache cleared (${mutationCount} mutation(s))`);
124
159
  }
160
+ if (excludedHits > 0) {
161
+ result.warnings.push(`${excludedHits} quer(y/ies) excluded by you`);
162
+ }
125
163
  return result;
164
+ },
165
+ /** Per-query restore preview. `base` omitted = compare against live cache. */
166
+ async previewItems(target, base) {
167
+ const targetQueries = target?.queries ?? [];
168
+ const liveMode = base === undefined;
169
+ const baseQueries = liveMode ? dehydrateQueryClient(queryClient).queries.map(({
170
+ observers: _o,
171
+ ...q
172
+ }) => q) : base?.queries ?? [];
173
+ const baseByHash = new Map(baseQueries.map(q => [q.queryHash, q]));
174
+ const targetByHash = new Map(targetQueries.map(q => [q.queryHash, q]));
175
+ const items = [];
176
+ const notes = [];
177
+ for (const q of targetQueries) {
178
+ const label = queryLabel(q.queryKey, q.queryHash);
179
+ const baseQ = baseByHash.get(q.queryHash);
180
+ if (!baseQ) {
181
+ items.push({
182
+ key: q.queryHash,
183
+ label,
184
+ verdict: "added",
185
+ newValue: comparableQuery(q)
186
+ });
187
+ continue;
188
+ }
189
+ const oldVal = comparableQuery(baseQ);
190
+ const newVal = comparableQuery(q);
191
+ if (boundedDeepEqual(oldVal, newVal)) {
192
+ items.push({
193
+ key: q.queryHash,
194
+ label,
195
+ verdict: "unchanged"
196
+ });
197
+ } else if (jsonLikelyLargerThan(oldVal, ITEM_RENDER_BYTE_LIMIT) || jsonLikelyLargerThan(newVal, ITEM_RENDER_BYTE_LIMIT)) {
198
+ items.push({
199
+ key: q.queryHash,
200
+ label,
201
+ verdict: "too-large",
202
+ reason: "query data too large to render a diff — restore still applies it"
203
+ });
204
+ } else {
205
+ items.push({
206
+ key: q.queryHash,
207
+ label,
208
+ verdict: "changed",
209
+ oldValue: oldVal,
210
+ newValue: newVal
211
+ });
212
+ }
213
+ }
214
+ for (const q of baseQueries) {
215
+ if (!targetByHash.has(q.queryHash)) {
216
+ items.push({
217
+ key: q.queryHash,
218
+ label: queryLabel(q.queryKey, q.queryHash),
219
+ verdict: "removed",
220
+ oldValue: comparableQuery(q)
221
+ });
222
+ }
223
+ }
224
+ if (liveMode) {
225
+ const mutationCount = queryClient.getMutationCache().getAll().length;
226
+ if (mutationCount > 0) {
227
+ notes.push(`${mutationCount} in-memory mutation record(s) will be cleared`);
228
+ }
229
+ }
230
+ return {
231
+ items,
232
+ notes
233
+ };
126
234
  }
127
235
  };
128
236
  }
@@ -4,6 +4,40 @@
4
4
  * hydrate an identical mirror of the device's QueryClient.
5
5
  */
6
6
  import type { DefaultError, MutationKey, MutationMeta, MutationScope, MutationState, QueryClient, QueryKey, QueryMeta, QueryObserverOptions, QueryState } from "@tanstack/react-query";
7
+ /**
8
+ * Sentinel used in WIRE-FORM snapshots in place of oversized query/mutation
9
+ * data. Same hazard as redux/zustand: a single cached payload can exceed the
10
+ * 2MB emit budget (dashboard goes stale) or freeze JSON.stringify. Dashboards
11
+ * fetch the real value via `getQueryData`.
12
+ */
13
+ export declare const DATA_ON_DEVICE: Readonly<{
14
+ __buoyDataOnDevice: true;
15
+ note: "Query data stays on the device — fetched on demand via getQueryData.";
16
+ }>;
17
+ /** Sentinel when JSON.stringify throws (circular refs, bigint, …). */
18
+ export declare const UNSERIALIZABLE_DATA: Readonly<{
19
+ __buoyUnserializable: true;
20
+ note: "Query data is not JSON-serializable (circular or bigint) and stayed on the device.";
21
+ }>;
22
+ /** Keep small payloads inline; strip anything over this (same as network bodies). */
23
+ export declare const SNAPSHOT_DATA_INLINE_LIMIT: number;
24
+ export declare const MAX_QUERY_DETAIL_BYTES: number;
25
+ export declare function jsonLongerThan(value: unknown, limit: number): boolean;
26
+ export declare function isDataOnDeviceMarker(value: unknown): boolean;
27
+ export declare function isUnserializableMarker(value: unknown): boolean;
28
+ export declare function isWireDataMarker(value: unknown): boolean;
29
+ /** Replace unserializable / oversized values with a wire marker. */
30
+ export declare function toWireData(value: unknown): unknown;
31
+ /**
32
+ * Query/mutation `error` (and `fetchFailureReason` / `failureReason`) often
33
+ * hold the live Axios/fetch error: `request` points at XHR (cyclic),
34
+ * `response.data` is the same fat body we already stripped from `state.data`.
35
+ * Spreading `query.state` left that copy on the wire after capping data.
36
+ * The dashboard only reads `error.message`.
37
+ */
38
+ export declare function toWireError(value: unknown): unknown;
39
+ /** Cap an on-demand detail payload (action result, not the per-tick snapshot). */
40
+ export declare function capQueryDetail(value: unknown): unknown;
7
41
  export interface DehydratedState {
8
42
  mutations: DehydratedMutation[];
9
43
  queries: DehydratedQuery[];
@@ -1 +1 @@
1
- {"version":3,"file":"dehydrate.d.ts","sourceRoot":"","sources":["../../../../src/react-query/sync/dehydrate.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EACV,YAAY,EAEZ,WAAW,EACX,YAAY,EACZ,aAAa,EACb,aAAa,EAEb,WAAW,EAEX,QAAQ,EACR,SAAS,EACT,oBAAoB,EACpB,UAAU,EACX,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,kBAAkB,EAAE,CAAC;IAChC,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,KAAK,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,aAAa,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,aAAa,CAC5B,YAAY,GAAG,OAAO,EACtB,MAAM,GAAG,YAAY,EACrB,KAAK,GAAG,YAAY,EACpB,UAAU,GAAG,YAAY,EACzB,SAAS,SAAS,QAAQ,GAAG,QAAQ;IAErC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,oBAAoB,CAC3B,YAAY,EACZ,MAAM,EACN,KAAK,EACL,UAAU,EACV,SAAS,CACV,CAAC;CACH;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,eAAe,CAYzE"}
1
+ {"version":3,"file":"dehydrate.d.ts","sourceRoot":"","sources":["../../../../src/react-query/sync/dehydrate.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EACV,YAAY,EAEZ,WAAW,EACX,YAAY,EACZ,aAAa,EACb,aAAa,EAEb,WAAW,EAEX,QAAQ,EACR,SAAS,EACT,oBAAoB,EACpB,UAAU,EACX,MAAM,uBAAuB,CAAC;AAE/B;;;;;GAKG;AACH,eAAO,MAAM,cAAc;;;EAGzB,CAAC;AAEH,sEAAsE;AACtE,eAAO,MAAM,mBAAmB;;;EAG9B,CAAC;AAEH,qFAAqF;AACrF,eAAO,MAAM,0BAA0B,QAAY,CAAC;AAEpD,eAAO,MAAM,sBAAsB,QAAkB,CAAC;AAEtD,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CA2BrE;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAM5D;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAM9D;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAExD;AAED,oEAAoE;AACpE,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAWlD;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAcnD;AAED,kFAAkF;AAClF,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAatD;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,kBAAkB,EAAE,CAAC;IAChC,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,KAAK,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,aAAa,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,aAAa,CAC5B,YAAY,GAAG,OAAO,EACtB,MAAM,GAAG,YAAY,EACrB,KAAK,GAAG,YAAY,EACpB,UAAU,GAAG,YAAY,EACzB,SAAS,SAAS,QAAQ,GAAG,QAAQ;IAErC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,oBAAoB,CAC3B,YAAY,EACZ,MAAM,EACN,KAAK,EACL,UAAU,EACV,SAAS,CACV,CAAC;CACH;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,eAAe,CAYzE"}
@@ -7,6 +7,9 @@
7
7
  * (useSyncQueries.ts) so the dashboard keeps identical behavior, including
8
8
  * the trigger/restore loading and error simulations used by the TanStack
9
9
  * devtools UI.
10
+ *
11
+ * v2: oversized / unserializable query (and mutation) data is replaced with
12
+ * on-device markers. Dashboards fetch real data via `getQueryData`.
10
13
  */
11
14
  import { type QueryClient } from "@tanstack/react-query";
12
15
  export declare function createReactQuerySyncAdapter(queryClient: QueryClient): {
@@ -17,6 +20,22 @@ export declare function createReactQuerySyncAdapter(queryClient: QueryClient): {
17
20
  };
18
21
  subscribe: (onChange: () => void) => () => void;
19
22
  actions: {
23
+ /**
24
+ * On-demand detail: one query's real cached `data`. Wire-form snapshots
25
+ * replace oversized / unserializable data with markers; this is the
26
+ * explicit, size-guarded channel for the detail pane.
27
+ */
28
+ getQueryData: (params: unknown) => {
29
+ found: false;
30
+ reason: string;
31
+ queryHash?: undefined;
32
+ data?: undefined;
33
+ } | {
34
+ found: true;
35
+ queryHash: string;
36
+ data: unknown;
37
+ reason?: undefined;
38
+ };
20
39
  /**
21
40
  * Compact, token-bounded cache reader for a remote driver (MCP/LLM):
22
41
  * projects each live query to light fields (hash, key, status label,
@@ -1 +1 @@
1
- {"version":3,"file":"reactQuerySyncAdapter.d.ts","sourceRoot":"","sources":["../../../../src/react-query/sync/reactQuerySyncAdapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAiB,KAAK,WAAW,EAAiB,MAAM,uBAAuB,CAAC;AA0BvF,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,WAAW;;;;;;0BAgB1C,MAAM,IAAI;;QAa9B;;;;;;WAMG;8BACmB,OAAO;;;;;;;;;;;;;;;;0BAkCX,OAAO;6BAKJ,OAAO;wBAEZ,OAAO;yBACN,OAAO;+BAGD,OAAO;+BAIP,OAAO;+BAaP,OAAO;iCAEL,OAAO;iCAwBP,OAAO;;;4BA4BZ,OAAO;;EAMhC"}
1
+ {"version":3,"file":"reactQuerySyncAdapter.d.ts","sourceRoot":"","sources":["../../../../src/react-query/sync/reactQuerySyncAdapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAiB,KAAK,WAAW,EAAiB,MAAM,uBAAuB,CAAC;AA0BvF,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,WAAW;;;;;;0BAgB1C,MAAM,IAAI;;QAa9B;;;;WAIG;+BACoB,OAAO;;;;;;;;;;;QAY9B;;;;;;WAMG;8BACmB,OAAO;;;;;;;;;;;;;;;;0BAkCX,OAAO;6BAKJ,OAAO;wBAEZ,OAAO;yBACN,OAAO;+BAGD,OAAO;+BAIP,OAAO;+BAaP,OAAO;iCAEL,OAAO;iCAwBP,OAAO;;;4BA4BZ,OAAO;;EAMhC"}
@@ -1 +1 @@
1
- {"version":3,"file":"snapshotProvider.d.ts","sourceRoot":"","sources":["../../../../src/react-query/sync/snapshotProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,WAAW,EAAc,MAAM,uBAAuB,CAAC;AACrE,OAAO,EAEL,KAAK,qBAAqB,EAG3B,MAAM,oBAAoB,CAAC;AAc5B,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,WAAW,GACvB,qBAAqB,CAsFvB;AAED,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAC3C,WAAW,EAAE,WAAW,GACvB,MAAM,IAAI,CAEZ"}
1
+ {"version":3,"file":"snapshotProvider.d.ts","sourceRoot":"","sources":["../../../../src/react-query/sync/snapshotProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,WAAW,EAAc,MAAM,uBAAuB,CAAC;AACrE,OAAO,EAEL,KAAK,qBAAqB,EAK3B,MAAM,oBAAoB,CAAC;AAuC5B,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,WAAW,GACvB,qBAAqB,CAuLvB;AAED,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAC3C,WAAW,EAAE,WAAW,GACvB,MAAM,IAAI,CAEZ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buoy-gg/react-query",
3
- "version": "7.0.17",
3
+ "version": "7.0.18",
4
4
  "description": "TanStack Query devtools inside your React Native app — cache inspection, refetch & invalidate, offline toggle. 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.17",
33
- "@buoy-gg/floating-tools-core": "^7.0.17"
32
+ "@buoy-gg/floating-tools-core": "^7.0.18",
33
+ "@buoy-gg/shared-ui": "^7.0.18"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@tanstack/react-query": ">=5.0.0",