@classytic/arc-next 0.5.0 → 0.7.0

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/dist/hooks.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { ArcClient, UseRouterHook } from "./client.js";
2
- import { ApiResponse, BaseApi } from "./api.js";
2
+ import { AggResult, AggRow, BaseApi } from "./api.js";
3
3
  import { CacheUtils, QueryKeys } from "./cache.js";
4
4
  import { MutationCallbacks, MutationMessages, TransitionMutationReturn } from "./mutation.js";
5
- import { DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult } from "./query.js";
5
+ import { DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, RequestPassthrough } from "./query.js";
6
6
  import { SoftDeleteMethods } from "./presets/soft-delete.js";
7
7
  import { BulkMethods } from "./presets/bulk.js";
8
8
  import { SlugLookupMethods } from "./presets/slug.js";
9
9
  import { TreeMethods } from "./presets/tree.js";
10
10
  import { SearchPresetMethods } from "./presets/search.js";
11
- import { QueryKey } from "@tanstack/react-query";
11
+ import { QueryKey, UseQueryResult } from "@tanstack/react-query";
12
12
 
13
13
  //#region src/hooks.d.ts
14
14
  /**
@@ -24,8 +24,50 @@ import { QueryKey } from "@tanstack/react-query";
24
24
  type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete'> & {
25
25
  upload?: BaseApi<T, TCreate, TUpdate>['upload'];
26
26
  dispatchAction?: BaseApi<T, TCreate, TUpdate>['dispatchAction'];
27
- invokeRoute?: BaseApi<T, TCreate, TUpdate>['invokeRoute'];
27
+ invokeRoute?: BaseApi<T, TCreate, TUpdate>['invokeRoute']; /** Declared aggregations (arc 2.13+). Always available on BaseApi. */
28
+ aggregate?: BaseApi<T, TCreate, TUpdate>['aggregate'];
28
29
  } & Partial<SoftDeleteMethods<T>> & Partial<BulkMethods<T, TCreate, TUpdate>> & Partial<SlugLookupMethods<T>> & Partial<TreeMethods<T>> & Partial<SearchPresetMethods<T>>;
30
+ /** Args + options for `useAggregation`. Mirrors `ListQueryOptions` for DX consistency. */
31
+ interface AggregationQueryOptions<TRow extends AggRow = AggRow, TData = AggResult<TRow>> {
32
+ /** Bypass auth gate (for public dashboards behind `allowPublic` permissions). */
33
+ public?: boolean;
34
+ /** Skip the query (e.g. while a parent filter is being assembled). */
35
+ enabled?: boolean;
36
+ /** Per-call staleTime override. Defaults to factory's `defaults.staleTime`. */
37
+ staleTime?: number;
38
+ /** Per-call gcTime override. */
39
+ gcTime?: number;
40
+ /**
41
+ * Refetch on window focus. Aggregations / dashboards usually want this OFF —
42
+ * long-running compute. Defaults to `false` regardless of factory config.
43
+ */
44
+ refetchOnWindowFocus?: boolean;
45
+ /** Periodic refetch (ms or `false`). Set for live dashboards. */
46
+ refetchInterval?: number | false;
47
+ /** Continue polling while the tab is in the background. */
48
+ refetchIntervalInBackground?: boolean;
49
+ /**
50
+ * Transform `AggResult<TRow>` before exposing it. Common pattern is to
51
+ * pluck the rows array directly:
52
+ * `select: (r) => r.rows`
53
+ * Or compute a derived value:
54
+ * `select: (r) => r.rows.reduce((acc, x) => acc + x.total, 0)`
55
+ * Runs on each render after structural-sharing dedupe.
56
+ */
57
+ select?: (data: AggResult<TRow>) => TData;
58
+ /**
59
+ * Pass-through to the underlying fetch call. Use for Next.js ISR — pass
60
+ * `request: { revalidate: 60, tags: ['orders'] }` so the server-side fetch
61
+ * cache participates in `revalidateTag('orders')` invalidations alongside
62
+ * TanStack's client cache.
63
+ */
64
+ request?: RequestPassthrough;
65
+ /**
66
+ * Show this data while the real query is loading / re-fetching with
67
+ * different filters. Smooths fast filter switches on dashboards.
68
+ */
69
+ placeholderData?: AggResult<TRow> | ((prev: AggResult<TRow> | undefined) => AggResult<TRow> | undefined);
70
+ }
29
71
  interface CrudHooksConfig<T, TCreate = Partial<T>, TUpdate = Partial<T>> {
30
72
  api: CrudApi<T, TCreate, TUpdate>;
31
73
  entityKey: string;
@@ -157,7 +199,7 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
157
199
  invalidateQueries?: QueryKey[]; /** Default action name. Can be overridden per-call via `mutate({ action })`. */
158
200
  action?: string;
159
201
  messages?: MutationMessages;
160
- onSuccess?: (data: ApiResponse<TResult>, variables: {
202
+ onSuccess?: (data: TResult, variables: {
161
203
  id: string;
162
204
  action: string;
163
205
  data?: TBody;
@@ -167,12 +209,12 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
167
209
  action: string;
168
210
  data?: TBody;
169
211
  }) => void;
170
- onSettled?: (data: ApiResponse<TResult> | undefined, error: Error | null, variables: {
212
+ onSettled?: (data: TResult | undefined, error: Error | null, variables: {
171
213
  id: string;
172
214
  action: string;
173
215
  data?: TBody;
174
216
  }) => void;
175
- }) => TransitionMutationReturn<ApiResponse<TResult>, {
217
+ }) => TransitionMutationReturn<TResult, {
176
218
  id: string;
177
219
  action?: string;
178
220
  data?: TBody;
@@ -205,6 +247,28 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
205
247
  input: string | string[];
206
248
  body?: Record<string, unknown>;
207
249
  }>;
250
+ /**
251
+ * Query against arc's declarative aggregations (arc v2.13+).
252
+ * `GET /:resource/aggregations/:name` — wire shape `{ rows: TRow[] }`.
253
+ *
254
+ * Cached under `KEYS.aggregation(name, filter)` so multiple call sites with
255
+ * the same args share one cache entry. Mutation hooks
256
+ * (`useActions`/`useBulkActions`) auto-invalidate `KEYS.aggregations()` so
257
+ * dashboards refresh after CRUD writes — opt out via the mutation's
258
+ * `invalidateQueries` override if you want stale-on-write.
259
+ *
260
+ * @example
261
+ * const { data } = useAggregation<{ day: string; total: number }>({
262
+ * name: 'salesByDay',
263
+ * filter: { from: '2025-01-01' },
264
+ * refetchOnWindowFocus: false,
265
+ * });
266
+ * // data.rows: Array<{ day: string; total: number }>
267
+ */
268
+ useAggregation: <TRow extends AggRow = AggRow, TData = AggResult<TRow>>(args: {
269
+ name: string;
270
+ filter?: Record<string, unknown>;
271
+ } & AggregationQueryOptions<TRow, TData>) => UseQueryResult<TData>;
208
272
  useUpload: (options?: {
209
273
  invalidateQueries?: QueryKey[];
210
274
  messages?: MutationMessages;
@@ -269,4 +333,4 @@ declare function createCrudHooks<T, TCreate = Partial<T>, TUpdate = Partial<T>>(
269
333
  client
270
334
  }: CrudHooksConfig<T, TCreate, TUpdate>): CrudHooksReturn<T, TCreate, TUpdate>;
271
335
  //#endregion
272
- export { BulkActions, CallOptions, CrudActions, CrudApi, CrudHooksConfig, CrudHooksReturn, DeleteParams, MutationParams, NavigateFn, NavigationOptions, UpdateParams, configureNavigation, createCrudHooks };
336
+ export { AggregationQueryOptions, BulkActions, CallOptions, CrudActions, CrudApi, CrudHooksConfig, CrudHooksReturn, DeleteParams, MutationParams, NavigateFn, NavigationOptions, UpdateParams, configureNavigation, createCrudHooks };
package/dist/hooks.js CHANGED
@@ -1,13 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { getAuthMode, getClientAuthContext } from "./client.js";
3
+ import { getAuthMode, getClientAuthContext, hasGlobalStaticAuth } from "./client.js";
4
4
  import { isKeysetPagination, isOffsetPagination } from "./api.js";
5
- import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache } from "./cache.js";
6
- import { useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
5
+ import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, syncDetailToLists, updateListCache } from "./cache.js";
6
+ import { findItemInListCache, useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
7
7
  import { useMutationWithTransition, useOptimisticMutation } from "./mutation.js";
8
8
  import { subscribeToEvents } from "./sse.js";
9
9
  import { connectWs } from "./ws.js";
10
- import { useQueryClient } from "@tanstack/react-query";
10
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
11
11
  import { useCallback, useEffect, useRef, useState } from "react";
12
12
 
13
13
  //#region src/hooks.ts
@@ -33,8 +33,19 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
33
33
  const pluralName = plural ?? `${singular}s`;
34
34
  /** Resolve auth context — per-client auth takes priority over global */
35
35
  const resolveAuth = () => getClientAuthContext(client);
36
- /** Whether auth is provided via static config (headers, internalApiKey, per-client auth) — no token needed for enablement */
37
- const hasStaticAuth = !!(client?.config?.defaultHeaders || client?.config?.internalApiKey || client?.auth);
36
+ /**
37
+ * Whether auth is provided via static config no per-request token needed.
38
+ * Sources (in priority order):
39
+ * 1. Per-client `defaultHeaders` / `internalApiKey` (when `client` is passed in).
40
+ * 2. Per-client custom auth (e.g. `createClient({ getToken })`).
41
+ * 3. Global `configureClient({ internalApiKey | defaultHeaders | authMode: 'cookie' })`.
42
+ *
43
+ * Resolved lazily on every render so that an app calling `configureClient`
44
+ * inside a "use client" provider after factory creation still picks up the
45
+ * global static-auth signal — previously a global `internalApiKey` was
46
+ * ignored, leaving every protected query stuck in a permanently-disabled state.
47
+ */
48
+ const resolveHasStaticAuth = () => !!(client?.config?.defaultHeaders || client?.config?.internalApiKey || client?.auth) || hasGlobalStaticAuth();
38
49
  /** Extract ID from an item using configured idField, falling back to _id → id */
39
50
  function resolveItemId(item) {
40
51
  if (!item || typeof item !== "object") return null;
@@ -99,7 +110,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
99
110
  ...requestOpts
100
111
  }
101
112
  }),
102
- enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
113
+ enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
103
114
  options: {
104
115
  staleTime: queryOpts.staleTime ?? config.staleTime,
105
116
  gcTime: queryOpts.gcTime ?? config.gcTime,
@@ -108,9 +119,6 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
108
119
  refetchInterval: queryOpts.refetchInterval,
109
120
  refetchIntervalInBackground: queryOpts.refetchIntervalInBackground
110
121
  },
111
- prefillDetailCache: queryOpts.prefillDetailCache ?? true,
112
- detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
113
- itemIdResolver: resolveItemId,
114
122
  select: queryOpts.select
115
123
  });
116
124
  }
@@ -131,8 +139,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
131
139
  }
132
140
  const { organizationId, params: queryParams, request: requestOpts, ...restOptions } = options;
133
141
  const detailKey = KEYS.scopedDetail(id || "", organizationId ?? null);
134
- return useDetailQuery({
135
- queryKey: queryParams ? [...detailKey, queryParams] : detailKey,
142
+ const fullDetailKey = queryParams ? [...detailKey, queryParams] : detailKey;
143
+ const queryClient = useQueryClient();
144
+ const listPlaceholder = useCallback(() => id ? findItemInListCache(queryClient, KEYS.lists(), id, idField) : void 0, [queryClient, id]);
145
+ const detailResult = useDetailQuery({
146
+ queryKey: fullDetailKey,
136
147
  queryFn: ({ signal }) => api.getById({
137
148
  id,
138
149
  token,
@@ -143,7 +154,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
143
154
  ...requestOpts
144
155
  }
145
156
  }),
146
- enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(), hasStaticAuth),
157
+ enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
147
158
  options: {
148
159
  staleTime: restOptions.staleTime ?? config.staleTime,
149
160
  gcTime: restOptions.gcTime ?? config.gcTime,
@@ -152,8 +163,18 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
152
163
  refetchInterval: restOptions.refetchInterval,
153
164
  refetchIntervalInBackground: restOptions.refetchIntervalInBackground
154
165
  },
155
- select: restOptions.select
166
+ select: restOptions.select,
167
+ placeholderData: listPlaceholder
156
168
  });
169
+ useEffect(() => {
170
+ if (!detailResult.item || detailResult.isPlaceholderData) return;
171
+ syncDetailToLists(queryClient, KEYS.lists(), detailResult.item, idField ? { idField } : {});
172
+ }, [
173
+ detailResult.item,
174
+ detailResult.isPlaceholderData,
175
+ queryClient
176
+ ]);
177
+ return detailResult;
157
178
  }
158
179
  function useActions() {
159
180
  const queryClient = useQueryClient();
@@ -166,7 +187,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
166
187
  data
167
188
  }),
168
189
  queryClient,
169
- queryKeys: [KEYS.lists()],
190
+ queryKeys: [KEYS.lists(), KEYS.aggregations()],
170
191
  shouldToast,
171
192
  optimisticUpdate: (oldData, { data }) => {
172
193
  const optimisticItem = {
@@ -199,7 +220,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
199
220
  data
200
221
  }),
201
222
  queryClient,
202
- queryKeys: [KEYS.lists(), KEYS.details()],
223
+ queryKeys: [
224
+ KEYS.lists(),
225
+ KEYS.details(),
226
+ KEYS.aggregations()
227
+ ],
203
228
  shouldToast,
204
229
  optimisticUpdate: (oldData, { id, data }) => {
205
230
  const updated = updateListCache(oldData, (arr) => (arr || []).map((item) => resolveItemId(item) === id ? {
@@ -250,7 +275,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
250
275
  id
251
276
  }),
252
277
  queryClient,
253
- queryKeys: [KEYS.lists()],
278
+ queryKeys: [KEYS.lists(), KEYS.aggregations()],
254
279
  shouldToast,
255
280
  optimisticUpdate: (oldData, { id }) => {
256
281
  return updateListCache(oldData, (arr) => (arr || []).filter((item) => resolveItemId(item) !== id));
@@ -280,7 +305,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
280
305
  id
281
306
  });
282
307
  },
283
- invalidateQueries: [KEYS.lists(), KEYS.custom("deleted")],
308
+ invalidateQueries: [
309
+ KEYS.lists(),
310
+ KEYS.custom("deleted"),
311
+ KEYS.aggregations()
312
+ ],
284
313
  shouldToast,
285
314
  messages: {
286
315
  success: `${singular} restored successfully`,
@@ -405,7 +434,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
405
434
  }
406
435
  });
407
436
  },
408
- enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
437
+ enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
409
438
  initialPageParam: restParams.after ? restParams.after : 1,
410
439
  getNextPageParam: (lastPage) => {
411
440
  const page = lastPage;
@@ -490,7 +519,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
490
519
  }
491
520
  });
492
521
  },
493
- enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
522
+ enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
494
523
  options: {
495
524
  staleTime: queryOpts.staleTime ?? config.staleTime,
496
525
  gcTime: queryOpts.gcTime ?? config.gcTime
@@ -504,7 +533,9 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
504
533
  const resolvedOptions = options ?? {};
505
534
  const organizationId = resolvedOptions.organizationId ?? auth.organizationId;
506
535
  const { params: queryParams, request: requestOpts, ...restOptions } = resolvedOptions;
507
- return useDetailQuery({
536
+ const queryClient = useQueryClient();
537
+ const listPlaceholder = useCallback(() => slug ? findItemInListCache(queryClient, KEYS.lists(), slug, "slug") : void 0, [queryClient, slug]);
538
+ const slugResult = useDetailQuery({
508
539
  queryKey: queryParams ? KEYS.custom("slug", slug, queryParams) : KEYS.custom("slug", slug),
509
540
  queryFn: ({ signal }) => {
510
541
  if (!api.getBySlug) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a getBySlug method`));
@@ -519,15 +550,25 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
519
550
  }
520
551
  });
521
552
  },
522
- enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(), hasStaticAuth),
553
+ enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
523
554
  options: {
524
555
  staleTime: restOptions.staleTime ?? config.staleTime,
525
556
  gcTime: restOptions.gcTime ?? config.gcTime,
526
557
  refetchOnWindowFocus: restOptions.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
527
558
  structuralSharing: restOptions.structuralSharing ?? config.structuralSharing
528
559
  },
529
- select: restOptions.select
560
+ select: restOptions.select,
561
+ placeholderData: listPlaceholder
530
562
  });
563
+ useEffect(() => {
564
+ if (!slugResult.item || slugResult.isPlaceholderData) return;
565
+ syncDetailToLists(queryClient, KEYS.lists(), slugResult.item, idField ? { idField } : {});
566
+ }, [
567
+ slugResult.item,
568
+ slugResult.isPlaceholderData,
569
+ queryClient
570
+ ]);
571
+ return slugResult;
531
572
  }
532
573
  function useTree(params, options) {
533
574
  const auth = resolveAuth();
@@ -553,7 +594,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
553
594
  }
554
595
  });
555
596
  },
556
- enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
597
+ enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
557
598
  options: {
558
599
  staleTime: queryOpts.staleTime ?? config.staleTime,
559
600
  gcTime: queryOpts.gcTime ?? config.gcTime
@@ -586,14 +627,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
586
627
  }
587
628
  });
588
629
  },
589
- enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
630
+ enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
590
631
  options: {
591
632
  staleTime: queryOpts.staleTime ?? config.staleTime,
592
633
  gcTime: queryOpts.gcTime ?? config.gcTime
593
634
  },
594
- prefillDetailCache: queryOpts.prefillDetailCache ?? true,
595
- detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
596
- itemIdResolver: resolveItemId,
597
635
  select: queryOpts.select
598
636
  });
599
637
  }
@@ -608,7 +646,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
608
646
  data: vars.data
609
647
  });
610
648
  },
611
- invalidateQueries: [KEYS.lists()],
649
+ invalidateQueries: [KEYS.lists(), KEYS.aggregations()],
612
650
  messages: {
613
651
  success: `${pluralName} created successfully`,
614
652
  error: `Failed to create ${pluralName.toLowerCase()}`
@@ -626,7 +664,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
626
664
  data: vars.data
627
665
  });
628
666
  },
629
- invalidateQueries: [KEYS.lists(), KEYS.details()],
667
+ invalidateQueries: [
668
+ KEYS.lists(),
669
+ KEYS.details(),
670
+ KEYS.aggregations()
671
+ ],
630
672
  messages: {
631
673
  success: `${pluralName} updated successfully`,
632
674
  error: `Failed to update ${pluralName.toLowerCase()}`
@@ -643,7 +685,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
643
685
  filter: vars.filter
644
686
  });
645
687
  },
646
- invalidateQueries: [KEYS.lists()],
688
+ invalidateQueries: [KEYS.lists(), KEYS.aggregations()],
647
689
  messages: {
648
690
  success: `${pluralName} deleted successfully`,
649
691
  error: `Failed to delete ${pluralName.toLowerCase()}`
@@ -772,6 +814,38 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
772
814
  toastHandler: instanceToast
773
815
  });
774
816
  }
817
+ function useAggregation(args) {
818
+ const { name, filter, public: isPublic, enabled = true, staleTime = config.staleTime, gcTime = config.gcTime, refetchOnWindowFocus = false, refetchInterval, refetchIntervalInBackground, select, request, placeholderData } = args;
819
+ const auth = resolveAuth();
820
+ const filterKey = auth.organizationId ? {
821
+ _org: auth.organizationId,
822
+ ...filter ?? {}
823
+ } : filter ?? {};
824
+ return useQuery({
825
+ queryKey: KEYS.aggregation(name, filterKey),
826
+ queryFn: () => {
827
+ if (!api.aggregate) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define an aggregate method (requires arc 2.13+)`));
828
+ return api.aggregate({
829
+ name,
830
+ filter,
831
+ token: auth.token,
832
+ organizationId: auth.organizationId,
833
+ ...request ? { options: request } : {}
834
+ });
835
+ },
836
+ enabled: !!name && !!api.aggregate && createEnabledRule(auth.token, {
837
+ public: isPublic,
838
+ enabled
839
+ }, resolveAuthMode(), resolveHasStaticAuth()),
840
+ staleTime,
841
+ gcTime,
842
+ refetchOnWindowFocus,
843
+ ...select ? { select } : {},
844
+ ...refetchInterval !== void 0 ? { refetchInterval } : {},
845
+ ...refetchIntervalInBackground !== void 0 ? { refetchIntervalInBackground } : {},
846
+ ...placeholderData !== void 0 ? { placeholderData } : {}
847
+ });
848
+ }
775
849
  /**
776
850
  * Subscribe to live `<resource>.<operation>` broadcasts from arc and
777
851
  * auto-invalidate this entity's TanStack Query cache.
@@ -813,6 +887,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
813
887
  return raw != null ? String(raw) : void 0;
814
888
  })() : void 0;
815
889
  queryClient.invalidateQueries({ queryKey: KEYS.lists() });
890
+ queryClient.invalidateQueries({ queryKey: KEYS.aggregations() });
816
891
  if (id && (operation === "updated" || operation === "deleted")) queryClient.invalidateQueries({ queryKey: KEYS.detail(id) });
817
892
  onEventRef.current?.({
818
893
  operation,
@@ -867,8 +942,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
867
942
  const id = resolveItemId(item);
868
943
  if (id) {
869
944
  const orgId = resolveAuth().organizationId;
870
- queryClient.setQueryData(KEYS.scopedDetail(id, orgId), { data: item });
871
- if (orgId) queryClient.setQueryData(KEYS.detail(id), { data: item });
945
+ queryClient.setQueryData(KEYS.scopedDetail(id, orgId), item);
946
+ if (orgId) queryClient.setQueryData(KEYS.detail(id), item);
872
947
  }
873
948
  if (!router) return;
874
949
  const { scroll = true, replace = false } = options;
@@ -894,6 +969,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
894
969
  useSearchEngine,
895
970
  useSearchSimilar,
896
971
  useEmbed,
972
+ useAggregation,
897
973
  useResourceSync,
898
974
  useNavigation
899
975
  };
@@ -61,6 +61,20 @@ interface CrudPrefetcher {
61
61
  * @example
62
62
  * await productsPrefetcher.prefetchInfiniteList(queryClient, { limit: 20 });
63
63
  */
64
+ /**
65
+ * Prefetch a declared aggregation (arc 2.13+). Uses the same query key as
66
+ * `useAggregation` so RSC-pre-rendered dashboard rows hydrate without a
67
+ * client refetch.
68
+ *
69
+ * @example
70
+ * await ordersPrefetcher.prefetchAggregation(
71
+ * queryClient,
72
+ * 'salesByDay',
73
+ * { from: '2025-01-01', to: '2025-12-31' },
74
+ * { token: jwt, organizationId: orgId, staleTime: 60_000 },
75
+ * );
76
+ */
77
+ prefetchAggregation: (queryClient: QueryClient, name: string, filter?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
64
78
  prefetchInfiniteList: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
65
79
  }
66
80
  /**
@@ -122,6 +136,15 @@ declare function createCrudPrefetcher(api: {
122
136
  headerOptions?: Record<string, string>;
123
137
  };
124
138
  }) => Promise<unknown>;
139
+ aggregate?: (opts: {
140
+ name: string;
141
+ filter?: Record<string, unknown>;
142
+ token?: string | null;
143
+ organizationId?: string | null;
144
+ options?: {
145
+ headerOptions?: Record<string, string>;
146
+ };
147
+ }) => Promise<unknown>;
125
148
  getTree?: (opts: {
126
149
  params?: Record<string, unknown>;
127
150
  token?: string | null;
package/dist/prefetch.js CHANGED
@@ -100,6 +100,26 @@ function createCrudPrefetcher(api, entityKey) {
100
100
  staleTime: options.staleTime
101
101
  });
102
102
  },
103
+ async prefetchAggregation(queryClient, name, filter, options = {}) {
104
+ if (!api.aggregate) throw new Error(`[arc-next] prefetchAggregation requires an api with aggregate (arc 2.13+)`);
105
+ if (!name) throw new Error("[arc-next] prefetchAggregation: aggregation name is required");
106
+ const orgId = options.organizationId ?? null;
107
+ const filterKey = orgId ? {
108
+ _org: orgId,
109
+ ...filter ?? {}
110
+ } : filter ?? {};
111
+ await queryClient.prefetchQuery({
112
+ queryKey: KEYS.aggregation(name, filterKey),
113
+ queryFn: () => api.aggregate({
114
+ name,
115
+ filter,
116
+ token: options.token ?? null,
117
+ organizationId: orgId,
118
+ ...options.headers ? { options: { headerOptions: options.headers } } : {}
119
+ }),
120
+ staleTime: options.staleTime
121
+ });
122
+ },
103
123
  async prefetchTree(queryClient, params = {}, options = {}) {
104
124
  if (!api.getTree) throw new Error(`[arc-next] prefetchTree requires an api with getTree (tree preset)`);
105
125
  const { organizationId: paramOrgId, ...restParams } = params;
@@ -1,11 +1,12 @@
1
- import { BaseApi, BulkCreateResponse, BulkDeleteResponse, BulkUpdateResponse, ScopedArgs } from "../api.js";
1
+ import { BaseApi, ScopedArgs } from "../api.js";
2
+ import { BulkCreateResult, DeleteManyResult, UpdateManyResult } from "@classytic/repo-core/repository";
2
3
 
3
4
  //#region src/presets/bulk.d.ts
4
5
  interface BulkMethods<TDoc, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>> {
5
6
  /** Insert many docs in one round-trip. Backend mounts `POST /:resource/bulk`. */
6
7
  bulkCreate(args: ScopedArgs & {
7
8
  data: TCreate[];
8
- }): Promise<BulkCreateResponse<TDoc>>;
9
+ }): Promise<BulkCreateResult<TDoc>>;
9
10
  /**
10
11
  * Update all docs matching `filter` with `data`.
11
12
  * Backend mounts `PATCH /:resource/bulk`.
@@ -13,14 +14,14 @@ interface BulkMethods<TDoc, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>> {
13
14
  bulkUpdate(args: ScopedArgs & {
14
15
  filter: Record<string, unknown>;
15
16
  data: TUpdate;
16
- }): Promise<BulkUpdateResponse>;
17
+ }): Promise<UpdateManyResult>;
17
18
  /**
18
19
  * Delete all docs matching `filter`.
19
20
  * Backend mounts `DELETE /:resource/bulk`.
20
21
  */
21
22
  bulkDelete(args: ScopedArgs & {
22
23
  filter: Record<string, unknown>;
23
- }): Promise<BulkDeleteResponse>;
24
+ }): Promise<DeleteManyResult>;
24
25
  }
25
26
  /**
26
27
  * Adds bulk preset methods to a BaseApi.
@@ -1,4 +1,5 @@
1
- import { ApiResponse, BaseApi, PaginatedResponse, ScopedArgs } from "../api.js";
1
+ import { BaseApi, ScopedArgs } from "../api.js";
2
+ import { PaginatedResult } from "@classytic/repo-core/pagination";
2
3
 
3
4
  //#region src/presets/search.d.ts
4
5
  interface SearchPresetMethods<TDoc> {
@@ -10,7 +11,7 @@ interface SearchPresetMethods<TDoc> {
10
11
  /** Free-text query forwarded as `body.query`. */query?: string; /** Engine-specific options merged into the request body. */
11
12
  body?: TBody; /** Override path (default `/search`). */
12
13
  path?: string;
13
- }): Promise<ApiResponse<TResult[]> | PaginatedResponse<TResult>>;
14
+ }): Promise<TResult[] | PaginatedResult<TResult>>;
14
15
  /**
15
16
  * Vector / semantic similarity (Atlas, Pinecone, Qdrant...).
16
17
  * Backend mounts `POST /:resource/search-similar`.
@@ -20,7 +21,7 @@ interface SearchPresetMethods<TDoc> {
20
21
  vector?: number[]; /** Vector-engine options (`topK`, `filter`, `index`, ...). */
21
22
  body?: TBody; /** Override path (default `/search-similar`). */
22
23
  path?: string;
23
- }): Promise<ApiResponse<TResult[]>>;
24
+ }): Promise<TResult[]>;
24
25
  /**
25
26
  * Convert text/media to a vector embedding via the engine the resource is wired to.
26
27
  * Backend mounts `POST /:resource/embed`.
@@ -29,7 +30,7 @@ interface SearchPresetMethods<TDoc> {
29
30
  /** Text or array of texts to embed. */input: string | string[]; /** Embed-engine options (`model`, `dimensions`, ...). */
30
31
  body?: Record<string, unknown>; /** Override path (default `/embed`). */
31
32
  path?: string;
32
- }): Promise<ApiResponse<number[] | number[][]>>;
33
+ }): Promise<number[] | number[][]>;
33
34
  }
34
35
  /**
35
36
  * Adds search preset methods to a BaseApi.
@@ -1,4 +1,4 @@
1
- import { ApiResponse, BaseApi, ScopedArgs } from "../api.js";
1
+ import { BaseApi, ScopedArgs } from "../api.js";
2
2
 
3
3
  //#region src/presets/slug.d.ts
4
4
  interface SlugLookupMethods<TDoc> {
@@ -9,7 +9,7 @@ interface SlugLookupMethods<TDoc> {
9
9
  select?: string;
10
10
  populate?: string | string[];
11
11
  };
12
- }): Promise<ApiResponse<TDoc>>;
12
+ }): Promise<TDoc>;
13
13
  }
14
14
  /**
15
15
  * Adds slug-lookup preset methods to a BaseApi.
@@ -1,15 +1,16 @@
1
- import { ApiResponse, BaseApi, PaginatedResponse, QueryParams, ScopedArgs } from "../api.js";
1
+ import { BaseApi, QueryParams, ScopedArgs } from "../api.js";
2
+ import { PaginatedResult } from "@classytic/repo-core/pagination";
2
3
 
3
4
  //#region src/presets/soft-delete.d.ts
4
5
  interface SoftDeleteMethods<TDoc> {
5
6
  /** List soft-deleted docs. Backend mounts `GET /:resource/deleted`. */
6
7
  getDeleted(args?: ScopedArgs & {
7
8
  params?: QueryParams;
8
- }): Promise<PaginatedResponse<TDoc>>;
9
+ }): Promise<PaginatedResult<TDoc>>;
9
10
  /** Undo a soft-delete. Backend mounts `POST /:resource/:id/restore`. */
10
11
  restore(args: ScopedArgs & {
11
12
  id: string;
12
- }): Promise<ApiResponse<TDoc>>;
13
+ }): Promise<TDoc>;
13
14
  }
14
15
  /**
15
16
  * Adds soft-delete preset methods to a BaseApi.
@@ -1,16 +1,17 @@
1
- import { ApiResponse, BaseApi, PaginatedResponse, QueryParams, ScopedArgs } from "../api.js";
1
+ import { BaseApi, QueryParams, ScopedArgs } from "../api.js";
2
+ import { PaginatedResult } from "@classytic/repo-core/pagination";
2
3
 
3
4
  //#region src/presets/tree.d.ts
4
5
  interface TreeMethods<TDoc> {
5
6
  /** Fetch the full hierarchy. Backend mounts `GET /:resource/tree`. */
6
7
  getTree(args?: ScopedArgs & {
7
8
  params?: QueryParams;
8
- }): Promise<ApiResponse<TDoc[]>>;
9
+ }): Promise<TDoc[]>;
9
10
  /** Fetch direct children of a node. Backend mounts `GET /:resource/:id/children`. */
10
11
  getChildren(args: ScopedArgs & {
11
12
  parentId: string;
12
13
  params?: QueryParams;
13
- }): Promise<PaginatedResponse<TDoc>>;
14
+ }): Promise<PaginatedResult<TDoc>>;
14
15
  }
15
16
  /**
16
17
  * Adds tree preset methods to a BaseApi.