@classytic/arc-next 0.4.1 → 0.6.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,30 +1,73 @@
1
1
  import { ArcClient, UseRouterHook } from "./client.js";
2
- import { BaseApi, FilterOperator } from "./api.js";
2
+ import { AggResult, AggRow, BaseApi } from "./api.js";
3
+ import { CacheUtils, QueryKeys } from "./cache.js";
3
4
  import { MutationCallbacks, MutationMessages, TransitionMutationReturn } from "./mutation.js";
4
- import { CacheUtils, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, QueryKeys } from "./query.js";
5
- import { QueryKey } from "@tanstack/react-query";
5
+ import { DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, RequestPassthrough } from "./query.js";
6
+ import { SoftDeleteMethods } from "./presets/soft-delete.js";
7
+ import { BulkMethods } from "./presets/bulk.js";
8
+ import { SlugLookupMethods } from "./presets/slug.js";
9
+ import { TreeMethods } from "./presets/tree.js";
10
+ import { SearchPresetMethods } from "./presets/search.js";
11
+ import { QueryKey, UseQueryResult } from "@tanstack/react-query";
6
12
 
7
13
  //#region src/hooks.d.ts
8
14
  /**
9
15
  * CRUD API interface accepted by createCrudHooks.
10
16
  *
11
- * Derived from BaseApi via Pick so the types are always in sync.
12
- * BaseApi instances satisfy this exactly (same source of truth).
13
- * Custom implementations just need to match BaseApi's method signatures.
17
+ * Always-on surface (CRUD + universal helpers) is derived from BaseApi via Pick
18
+ * so types stay in sync. Preset surfaces are optional intersections of the
19
+ * corresponding `XxxMethods` interfaces, so a `withSearchPreset(api)` result
20
+ * satisfies CrudApi & gets `searchEngine`/`searchSimilar`/`embed` typed without
21
+ * any cast — yet a vanilla `createCrudApi('todos')` instance has none of them
22
+ * in autocomplete unless you opt in.
14
23
  */
15
24
  type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete'> & {
16
25
  upload?: BaseApi<T, TCreate, TUpdate>['upload'];
17
- search?: BaseApi<T, TCreate, TUpdate>['search'];
18
- getDeleted?: BaseApi<T, TCreate, TUpdate>['getDeleted'];
19
- restore?: BaseApi<T, TCreate, TUpdate>['restore'];
20
- bulkCreate?: BaseApi<T, TCreate, TUpdate>['bulkCreate'];
21
- bulkUpdate?: BaseApi<T, TCreate, TUpdate>['bulkUpdate'];
22
- bulkDelete?: BaseApi<T, TCreate, TUpdate>['bulkDelete'];
23
- getBySlug?: BaseApi<T, TCreate, TUpdate>['getBySlug'];
24
- getTree?: BaseApi<T, TCreate, TUpdate>['getTree'];
25
- getChildren?: BaseApi<T, TCreate, TUpdate>['getChildren'];
26
- findBy?: BaseApi<T, TCreate, TUpdate>['findBy'];
27
- };
26
+ dispatchAction?: BaseApi<T, TCreate, TUpdate>['dispatchAction'];
27
+ invokeRoute?: BaseApi<T, TCreate, TUpdate>['invokeRoute']; /** Declared aggregations (arc 2.13+). Always available on BaseApi. */
28
+ aggregate?: BaseApi<T, TCreate, TUpdate>['aggregate'];
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
+ }
28
71
  interface CrudHooksConfig<T, TCreate = Partial<T>, TUpdate = Partial<T>> {
29
72
  api: CrudApi<T, TCreate, TUpdate>;
30
73
  entityKey: string;
@@ -147,9 +190,85 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
147
190
  useDetailBySlug: (slug: string | null, options?: DetailQueryOptions<T>) => DetailQueryResult<T>;
148
191
  useTree: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
149
192
  useChildren: (parentId: string | null, params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
150
- useFindBy: (field: string, value: unknown, options?: ListQueryOptions<T> & {
151
- operator?: FilterOperator;
152
- }) => ListQueryResult<T>;
193
+ /**
194
+ * Mutation against arc's unified action router (`POST /:id/action`).
195
+ * Server discriminates on `body.action`. Use for state transitions
196
+ * (approve/cancel/dispatch) instead of bespoke routes.
197
+ */
198
+ useAction: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
199
+ invalidateQueries?: QueryKey[]; /** Default action name. Can be overridden per-call via `mutate({ action })`. */
200
+ action?: string;
201
+ messages?: MutationMessages;
202
+ onSuccess?: (data: TResult, variables: {
203
+ id: string;
204
+ action: string;
205
+ data?: TBody;
206
+ }) => void;
207
+ onError?: (error: Error, variables: {
208
+ id: string;
209
+ action: string;
210
+ data?: TBody;
211
+ }) => void;
212
+ onSettled?: (data: TResult | undefined, error: Error | null, variables: {
213
+ id: string;
214
+ action: string;
215
+ data?: TBody;
216
+ }) => void;
217
+ }) => TransitionMutationReturn<TResult, {
218
+ id: string;
219
+ action?: string;
220
+ data?: TBody;
221
+ }>;
222
+ /** Mutation against the search-preset POST `/search` route. */
223
+ useSearchEngine: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
224
+ path?: string;
225
+ messages?: MutationMessages;
226
+ invalidateQueries?: QueryKey[];
227
+ }) => TransitionMutationReturn<unknown, {
228
+ query?: string;
229
+ body?: TBody;
230
+ }>;
231
+ /** Mutation against the search-preset POST `/search-similar` route. */
232
+ useSearchSimilar: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
233
+ path?: string;
234
+ messages?: MutationMessages;
235
+ invalidateQueries?: QueryKey[];
236
+ }) => TransitionMutationReturn<unknown, {
237
+ query?: string;
238
+ vector?: number[];
239
+ body?: TBody;
240
+ }>;
241
+ /** Mutation against the search-preset POST `/embed` route. */
242
+ useEmbed: (options?: {
243
+ path?: string;
244
+ messages?: MutationMessages;
245
+ invalidateQueries?: QueryKey[];
246
+ }) => TransitionMutationReturn<unknown, {
247
+ input: string | string[];
248
+ body?: Record<string, unknown>;
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>;
153
272
  useUpload: (options?: {
154
273
  invalidateQueries?: QueryKey[];
155
274
  messages?: MutationMessages;
@@ -161,7 +280,6 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
161
280
  id?: string;
162
281
  path?: string;
163
282
  }>;
164
- useSearch: (query: string, params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
165
283
  useCustomMutation: <TData = unknown, TVariables = unknown>(config: {
166
284
  mutationFn: (variables: TVariables) => Promise<TData>;
167
285
  invalidateQueries?: QueryKey[];
@@ -170,6 +288,28 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
170
288
  onError?: (error: Error, variables: TVariables) => void;
171
289
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
172
290
  }) => TransitionMutationReturn<TData, TVariables>;
291
+ /**
292
+ * Subscribe to live CRUD broadcasts and auto-invalidate this resource's
293
+ * cache. Drop in alongside `createCrudHooks` and every `useList` /
294
+ * `useDetail` rerenders when the backend emits `<resource>.<op>`.
295
+ *
296
+ * `source: 'ws'` uses arc's `websocketPlugin` (`/ws`); `source: 'sse'` uses
297
+ * `ssePlugin` (`/events/stream`). Pass `enabled: false` to opt out.
298
+ */
299
+ useResourceSync: (options?: {
300
+ source?: 'ws' | 'sse'; /** Override resource name. Defaults to the factory's `entityKey`. */
301
+ resource?: string; /** Override path (default: `/ws` or `/events/stream`). */
302
+ path?: string; /** Whether the connection is active. Default: true. */
303
+ enabled?: boolean; /** Per-event hook fired AFTER cache invalidation. */
304
+ onEvent?: (event: {
305
+ operation: 'created' | 'updated' | 'deleted';
306
+ id?: string;
307
+ data: unknown;
308
+ }) => void; /** Connection-state listener. */
309
+ onConnectionChange?: (connected: boolean) => void;
310
+ }) => {
311
+ isConnected: boolean;
312
+ };
173
313
  useNavigation: () => NavigateFn<T>;
174
314
  }
175
315
  /**
@@ -193,4 +333,4 @@ declare function createCrudHooks<T, TCreate = Partial<T>, TUpdate = Partial<T>>(
193
333
  client
194
334
  }: CrudHooksConfig<T, TCreate, TUpdate>): CrudHooksReturn<T, TCreate, TUpdate>;
195
335
  //#endregion
196
- 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
@@ -2,10 +2,13 @@
2
2
 
3
3
  import { getAuthMode, getClientAuthContext } from "./client.js";
4
4
  import { isKeysetPagination, isOffsetPagination } from "./api.js";
5
- import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
5
+ import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache } from "./cache.js";
6
+ import { useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
6
7
  import { useMutationWithTransition, useOptimisticMutation } from "./mutation.js";
7
- import { useQueryClient } from "@tanstack/react-query";
8
- import { useCallback, useRef } from "react";
8
+ import { subscribeToEvents } from "./sse.js";
9
+ import { connectWs } from "./ws.js";
10
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
11
+ import { useCallback, useEffect, useRef, useState } from "react";
9
12
 
10
13
  //#region src/hooks.ts
11
14
  let useRouterHook = null;
@@ -163,7 +166,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
163
166
  data
164
167
  }),
165
168
  queryClient,
166
- queryKeys: [KEYS.lists()],
169
+ queryKeys: [KEYS.lists(), KEYS.aggregations()],
167
170
  shouldToast,
168
171
  optimisticUpdate: (oldData, { data }) => {
169
172
  const optimisticItem = {
@@ -196,7 +199,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
196
199
  data
197
200
  }),
198
201
  queryClient,
199
- queryKeys: [KEYS.lists(), KEYS.details()],
202
+ queryKeys: [
203
+ KEYS.lists(),
204
+ KEYS.details(),
205
+ KEYS.aggregations()
206
+ ],
200
207
  shouldToast,
201
208
  optimisticUpdate: (oldData, { id, data }) => {
202
209
  const updated = updateListCache(oldData, (arr) => (arr || []).map((item) => resolveItemId(item) === id ? {
@@ -247,7 +254,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
247
254
  id
248
255
  }),
249
256
  queryClient,
250
- queryKeys: [KEYS.lists()],
257
+ queryKeys: [KEYS.lists(), KEYS.aggregations()],
251
258
  shouldToast,
252
259
  optimisticUpdate: (oldData, { id }) => {
253
260
  return updateListCache(oldData, (arr) => (arr || []).filter((item) => resolveItemId(item) !== id));
@@ -277,7 +284,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
277
284
  id
278
285
  });
279
286
  },
280
- invalidateQueries: [KEYS.lists(), KEYS.custom("deleted")],
287
+ invalidateQueries: [
288
+ KEYS.lists(),
289
+ KEYS.custom("deleted"),
290
+ KEYS.aggregations()
291
+ ],
281
292
  shouldToast,
282
293
  messages: {
283
294
  success: `${singular} restored successfully`,
@@ -452,46 +463,6 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
452
463
  toastHandler: instanceToast
453
464
  });
454
465
  }
455
- function useSearch(query, params, options) {
456
- const auth = resolveAuth();
457
- const token = params?.token ?? auth.token;
458
- const organizationId = params?.organizationId ?? auth.organizationId;
459
- const { organizationId: _, token: _t, ...restParams } = params ?? {};
460
- const searchParams = {
461
- q: query,
462
- ...restParams
463
- };
464
- const { request: requestOpts, ...queryOpts } = options ?? {};
465
- const searchKeyParams = organizationId ? {
466
- organizationId,
467
- ...searchParams
468
- } : searchParams;
469
- return useListQuery({
470
- queryKey: [
471
- ...KEYS.lists(),
472
- "search",
473
- searchKeyParams
474
- ],
475
- queryFn: ({ signal }) => {
476
- if (!api.search) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a search method`));
477
- return api.search({
478
- token,
479
- organizationId,
480
- params: searchParams,
481
- options: {
482
- signal,
483
- ...requestOpts
484
- }
485
- });
486
- },
487
- enabled: !!api.search && query.length > 0 && createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
488
- options: {
489
- staleTime: queryOpts.staleTime ?? config.staleTime,
490
- gcTime: queryOpts.gcTime ?? config.gcTime
491
- },
492
- select: queryOpts.select
493
- });
494
- }
495
466
  function useCustomMutation(mutationConfig) {
496
467
  return useMutationWithTransition({
497
468
  mutationFn: mutationConfig.mutationFn,
@@ -634,43 +605,6 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
634
605
  select: queryOpts.select
635
606
  });
636
607
  }
637
- function useFindBy(field, value, options) {
638
- const auth = resolveAuth();
639
- const token = auth.token;
640
- const organizationId = auth.organizationId;
641
- const { operator, request: requestOpts, ...queryOpts } = options ?? {};
642
- return useListQuery({
643
- queryKey: KEYS.custom("findBy", {
644
- field,
645
- value,
646
- operator,
647
- organizationId
648
- }),
649
- queryFn: ({ signal }) => {
650
- if (!api.findBy) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a findBy method`));
651
- return api.findBy({
652
- token,
653
- organizationId,
654
- field,
655
- value,
656
- operator,
657
- options: {
658
- signal,
659
- ...requestOpts
660
- }
661
- });
662
- },
663
- enabled: !!api.findBy && value !== void 0 && value !== null && createEnabledRule(token, queryOpts, resolveAuthMode(), hasStaticAuth),
664
- options: {
665
- staleTime: queryOpts.staleTime ?? config.staleTime,
666
- gcTime: queryOpts.gcTime ?? config.gcTime
667
- },
668
- prefillDetailCache: queryOpts.prefillDetailCache ?? true,
669
- detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
670
- itemIdResolver: resolveItemId,
671
- select: queryOpts.select
672
- });
673
- }
674
608
  function useBulkActions() {
675
609
  const bulkCreateMutation = useMutationWithTransition({
676
610
  mutationFn: (vars) => {
@@ -682,7 +616,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
682
616
  data: vars.data
683
617
  });
684
618
  },
685
- invalidateQueries: [KEYS.lists()],
619
+ invalidateQueries: [KEYS.lists(), KEYS.aggregations()],
686
620
  messages: {
687
621
  success: `${pluralName} created successfully`,
688
622
  error: `Failed to create ${pluralName.toLowerCase()}`
@@ -700,7 +634,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
700
634
  data: vars.data
701
635
  });
702
636
  },
703
- invalidateQueries: [KEYS.lists(), KEYS.details()],
637
+ invalidateQueries: [
638
+ KEYS.lists(),
639
+ KEYS.details(),
640
+ KEYS.aggregations()
641
+ ],
704
642
  messages: {
705
643
  success: `${pluralName} updated successfully`,
706
644
  error: `Failed to update ${pluralName.toLowerCase()}`
@@ -717,7 +655,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
717
655
  filter: vars.filter
718
656
  });
719
657
  },
720
- invalidateQueries: [KEYS.lists()],
658
+ invalidateQueries: [KEYS.lists(), KEYS.aggregations()],
721
659
  messages: {
722
660
  success: `${pluralName} deleted successfully`,
723
661
  error: `Failed to delete ${pluralName.toLowerCase()}`
@@ -745,6 +683,224 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
745
683
  isBulkDeleting: bulkDeleteMutation.isPending
746
684
  };
747
685
  }
686
+ function useAction(options) {
687
+ const queryClient = useQueryClient();
688
+ return useMutationWithTransition({
689
+ mutationFn: (vars) => {
690
+ if (!api.dispatchAction) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a dispatchAction method`));
691
+ const action = vars.action ?? options?.action;
692
+ if (!action) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] useAction: action name required (pass via mutate({ action }) or factory options)`));
693
+ const auth = resolveAuth();
694
+ return api.dispatchAction({
695
+ token: auth.token,
696
+ organizationId: auth.organizationId,
697
+ id: vars.id,
698
+ action,
699
+ data: vars.data
700
+ });
701
+ },
702
+ invalidateQueries: options?.invalidateQueries ?? [KEYS.lists(), KEYS.details()],
703
+ onSuccess: (data, vars) => {
704
+ const action = vars.action ?? options?.action ?? "";
705
+ if (vars.id) queryClient.invalidateQueries({ queryKey: KEYS.detail(vars.id) });
706
+ options?.onSuccess?.(data, {
707
+ id: vars.id,
708
+ action,
709
+ data: vars.data
710
+ });
711
+ },
712
+ onError: (error, vars) => {
713
+ const action = vars.action ?? options?.action ?? "";
714
+ options?.onError?.(error, {
715
+ id: vars.id,
716
+ action,
717
+ data: vars.data
718
+ });
719
+ },
720
+ onSettled: (data, error, vars) => {
721
+ const action = vars.action ?? options?.action ?? "";
722
+ options?.onSettled?.(data, error, {
723
+ id: vars.id,
724
+ action,
725
+ data: vars.data
726
+ });
727
+ },
728
+ messages: options?.messages,
729
+ toastHandler: instanceToast
730
+ });
731
+ }
732
+ function useSearchEngine(options) {
733
+ return useMutationWithTransition({
734
+ mutationFn: (vars) => {
735
+ if (!api.searchEngine) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a searchEngine method`));
736
+ const auth = resolveAuth();
737
+ return api.searchEngine({
738
+ token: auth.token,
739
+ organizationId: auth.organizationId,
740
+ query: vars.query,
741
+ body: vars.body,
742
+ path: options?.path
743
+ });
744
+ },
745
+ invalidateQueries: options?.invalidateQueries ?? [],
746
+ messages: options?.messages,
747
+ toastHandler: instanceToast
748
+ });
749
+ }
750
+ function useSearchSimilar(options) {
751
+ return useMutationWithTransition({
752
+ mutationFn: (vars) => {
753
+ if (!api.searchSimilar) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a searchSimilar method`));
754
+ const auth = resolveAuth();
755
+ return api.searchSimilar({
756
+ token: auth.token,
757
+ organizationId: auth.organizationId,
758
+ query: vars.query,
759
+ vector: vars.vector,
760
+ body: vars.body,
761
+ path: options?.path
762
+ });
763
+ },
764
+ invalidateQueries: options?.invalidateQueries ?? [],
765
+ messages: options?.messages,
766
+ toastHandler: instanceToast
767
+ });
768
+ }
769
+ function useEmbed(options) {
770
+ return useMutationWithTransition({
771
+ mutationFn: (vars) => {
772
+ if (!api.embed) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define an embed method`));
773
+ const auth = resolveAuth();
774
+ return api.embed({
775
+ token: auth.token,
776
+ organizationId: auth.organizationId,
777
+ input: vars.input,
778
+ body: vars.body,
779
+ path: options?.path
780
+ });
781
+ },
782
+ invalidateQueries: options?.invalidateQueries ?? [],
783
+ messages: options?.messages,
784
+ toastHandler: instanceToast
785
+ });
786
+ }
787
+ function useAggregation(args) {
788
+ const { name, filter, public: isPublic, enabled = true, staleTime = config.staleTime, gcTime = config.gcTime, refetchOnWindowFocus = false, refetchInterval, refetchIntervalInBackground, select, request, placeholderData } = args;
789
+ const auth = resolveAuth();
790
+ const filterKey = auth.organizationId ? {
791
+ _org: auth.organizationId,
792
+ ...filter ?? {}
793
+ } : filter ?? {};
794
+ return useQuery({
795
+ queryKey: KEYS.aggregation(name, filterKey),
796
+ queryFn: () => {
797
+ if (!api.aggregate) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define an aggregate method (requires arc 2.13+)`));
798
+ return api.aggregate({
799
+ name,
800
+ filter,
801
+ token: auth.token,
802
+ organizationId: auth.organizationId,
803
+ ...request ? { options: request } : {}
804
+ });
805
+ },
806
+ enabled: !!name && !!api.aggregate && createEnabledRule(auth.token, {
807
+ public: isPublic,
808
+ enabled
809
+ }, resolveAuthMode(), hasStaticAuth),
810
+ staleTime,
811
+ gcTime,
812
+ refetchOnWindowFocus,
813
+ ...select ? { select } : {},
814
+ ...refetchInterval !== void 0 ? { refetchInterval } : {},
815
+ ...refetchIntervalInBackground !== void 0 ? { refetchIntervalInBackground } : {},
816
+ ...placeholderData !== void 0 ? { placeholderData } : {}
817
+ });
818
+ }
819
+ /**
820
+ * Subscribe to live `<resource>.<operation>` broadcasts from arc and
821
+ * auto-invalidate this entity's TanStack Query cache.
822
+ *
823
+ * - `source: 'ws'` (default) → arc's `websocketPlugin` at `/ws`. Sends a
824
+ * `{ type: 'subscribe', resource }` handshake on connect.
825
+ * - `source: 'sse'` → arc's `ssePlugin` at `/events/stream`. Auto-derives
826
+ * patterns from the resource name.
827
+ *
828
+ * Both transports invalidate `KEYS.lists()` on `<resource>.created` and
829
+ * `<resource>.deleted`, and `KEYS.detail(id)` (prefix-matches scoped /
830
+ * parameterized variants) on `<resource>.updated` / `.deleted`.
831
+ */
832
+ function useResourceSync(options) {
833
+ const queryClient = useQueryClient();
834
+ const [isConnected, setIsConnected] = useState(false);
835
+ const source = options?.source ?? "ws";
836
+ const resource = options?.resource ?? entityKey;
837
+ const enabled = options?.enabled ?? true;
838
+ const path = options?.path;
839
+ const onEventRef = useRef(options?.onEvent);
840
+ onEventRef.current = options?.onEvent;
841
+ const onConnRef = useRef(options?.onConnectionChange);
842
+ onConnRef.current = options?.onConnectionChange;
843
+ useEffect(() => {
844
+ if (!enabled) return;
845
+ const handleBroadcast = (incomingType, payload) => {
846
+ const dot = incomingType.lastIndexOf(".");
847
+ const operation = dot >= 0 ? incomingType.slice(dot + 1) : incomingType;
848
+ if (operation !== "created" && operation !== "updated" && operation !== "deleted") return;
849
+ let doc = payload;
850
+ if (payload && typeof payload === "object" && !Array.isArray(payload) && "data" in payload) {
851
+ const inner = payload.data;
852
+ if (inner !== void 0) doc = inner;
853
+ }
854
+ const id = typeof doc === "object" && doc !== null ? (() => {
855
+ const o = doc;
856
+ const raw = idField ? o[idField] : o._id ?? o.id;
857
+ return raw != null ? String(raw) : void 0;
858
+ })() : void 0;
859
+ queryClient.invalidateQueries({ queryKey: KEYS.lists() });
860
+ queryClient.invalidateQueries({ queryKey: KEYS.aggregations() });
861
+ if (id && (operation === "updated" || operation === "deleted")) queryClient.invalidateQueries({ queryKey: KEYS.detail(id) });
862
+ onEventRef.current?.({
863
+ operation,
864
+ id,
865
+ data: doc
866
+ });
867
+ };
868
+ if (source === "sse") {
869
+ const handle = subscribeToEvents({
870
+ resource,
871
+ path,
872
+ onConnectionChange: (c) => {
873
+ setIsConnected(c);
874
+ onConnRef.current?.(c);
875
+ },
876
+ onEvent: (event) => {
877
+ handleBroadcast(event.type, event.data);
878
+ }
879
+ });
880
+ return () => handle.close();
881
+ }
882
+ const handle = connectWs({
883
+ path,
884
+ subscribe: [resource],
885
+ patterns: [`${resource}.`],
886
+ onConnectionChange: (c) => {
887
+ setIsConnected(c);
888
+ onConnRef.current?.(c);
889
+ },
890
+ onMessage: (message) => {
891
+ handleBroadcast(message.type, message.data);
892
+ }
893
+ });
894
+ return () => handle.close();
895
+ }, [
896
+ enabled,
897
+ source,
898
+ resource,
899
+ path,
900
+ queryClient
901
+ ]);
902
+ return { isConnected };
903
+ }
748
904
  const resolvedRouterHook = instanceNavigation ?? useRouterHook ?? (() => ({
749
905
  push: () => {},
750
906
  replace: () => {}
@@ -777,10 +933,14 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
777
933
  useDetailBySlug,
778
934
  useTree,
779
935
  useChildren,
780
- useFindBy,
781
936
  useUpload,
782
- useSearch,
783
937
  useCustomMutation,
938
+ useAction,
939
+ useSearchEngine,
940
+ useSearchSimilar,
941
+ useEmbed,
942
+ useAggregation,
943
+ useResourceSync,
784
944
  useNavigation
785
945
  };
786
946
  }
@@ -33,6 +33,23 @@ interface TransitionMutationReturn<TData, TVariables> {
33
33
  * configureToast({ success: toast.success, error: toast.error });
34
34
  */
35
35
  declare function configureToast(handler: ToastHandler): void;
36
+ /**
37
+ * Get the configured toast handler. Returns the console-based default when
38
+ * no handler has been configured yet.
39
+ *
40
+ * Use this when domain code outside the react-query lifecycle needs to fire
41
+ * ad-hoc success/error toasts using the same handler the SDK uses internally
42
+ * — avoids the need for consumer SDKs to keep a parallel cache of the handler.
43
+ *
44
+ * @example
45
+ * // In a domain helper outside any mutation lifecycle:
46
+ * import { getToastHandler } from '@classytic/arc-next/mutation';
47
+ *
48
+ * function notifySaved(label: string) {
49
+ * getToastHandler().success(`${label} saved`);
50
+ * }
51
+ */
52
+ declare function getToastHandler(): ToastHandler;
36
53
  interface TransitionMutationConfig<TData, TVariables> {
37
54
  mutationFn: (variables: TVariables) => Promise<TData>;
38
55
  invalidateQueries?: QueryKey[];
@@ -107,4 +124,4 @@ declare function useOptimisticMutation<TData, TVariables>(config: CreateOptimist
107
124
  }[];
108
125
  }>;
109
126
  //#endregion
110
- export { CreateOptimisticMutationConfig, MutationCallbacks, MutationMessages, OptimisticMutationConfig, TransitionMutationConfig, TransitionMutationReturn, configureToast, useMutationWithOptimistic, useMutationWithTransition, useOptimisticMutation };
127
+ export { CreateOptimisticMutationConfig, MutationCallbacks, MutationMessages, OptimisticMutationConfig, TransitionMutationConfig, TransitionMutationReturn, configureToast, getToastHandler, useMutationWithOptimistic, useMutationWithTransition, useOptimisticMutation };