@classytic/arc-next 0.2.1 → 0.4.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.
@@ -1,5 +1,6 @@
1
1
  import { ToastHandler } from "./client.js";
2
- import * as _tanstack_react_query0 from "@tanstack/react-query";
2
+ import { QUERY_CONFIGS } from "./query.js";
3
+ import * as _$_tanstack_react_query0 from "@tanstack/react-query";
3
4
  import { QueryClient, QueryKey, UseMutateAsyncFunction, UseMutateFunction } from "@tanstack/react-query";
4
5
 
5
6
  //#region src/mutation.d.ts
@@ -26,6 +27,8 @@ interface TransitionMutationReturn<TData, TVariables> {
26
27
  /**
27
28
  * Configure toast handler. Call once at app init.
28
29
  *
30
+ * **SSR safety:** This sets module-level state. Call only in client-side code.
31
+ *
29
32
  * @example
30
33
  * import { toast } from "sonner";
31
34
  * configureToast({ success: toast.success, error: toast.error });
@@ -45,8 +48,8 @@ interface TransitionMutationConfig<TData, TVariables> {
45
48
  toastHandler?: ToastHandler;
46
49
  }
47
50
  declare function useMutationWithTransition<TData, TVariables>(config: TransitionMutationConfig<TData, TVariables>): {
48
- mutate: UseMutateFunction<TData, Error, TVariables, unknown>;
49
- mutateAsync: UseMutateAsyncFunction<TData, Error, TVariables, unknown>;
51
+ mutate: UseMutateFunction<TData, Error, TVariables, void>;
52
+ mutateAsync: UseMutateAsyncFunction<TData, Error, TVariables, void>;
50
53
  isPending: boolean;
51
54
  isSuccess: boolean;
52
55
  isError: boolean;
@@ -98,27 +101,13 @@ interface CreateOptimisticMutationConfig<TData, TVariables> {
98
101
  shouldToast?: () => boolean;
99
102
  toastHandler?: ToastHandler;
100
103
  }
101
- declare function createOptimisticMutation<TData, TVariables>(config: CreateOptimisticMutationConfig<TData, TVariables>): _tanstack_react_query0.UseMutationResult<TData, Error, TVariables, {
104
+ declare function useOptimisticMutation<TData, TVariables>(config: CreateOptimisticMutationConfig<TData, TVariables>): _$_tanstack_react_query0.UseMutationResult<TData, Error, TVariables, {
102
105
  previous: {
103
106
  key: readonly unknown[];
104
107
  data: [readonly unknown[], unknown][];
105
108
  }[];
106
109
  }>;
107
- declare const QUERY_CONFIGS: {
108
- readonly realtime: {
109
- readonly staleTime: 20000;
110
- readonly refetchInterval: 30000;
111
- };
112
- readonly frequent: {
113
- readonly staleTime: 60000;
114
- };
115
- readonly stable: {
116
- readonly staleTime: 300000;
117
- };
118
- readonly static: {
119
- readonly staleTime: 600000;
120
- };
121
- };
110
+ /** @deprecated Use `useOptimisticMutation` */
111
+ declare const createOptimisticMutation: typeof useOptimisticMutation;
122
112
  //#endregion
123
- export { CreateOptimisticMutationConfig, MutationCallbacks, MutationMessages, OptimisticMutationConfig, QUERY_CONFIGS, type ToastHandler, TransitionMutationConfig, TransitionMutationReturn, configureToast, createOptimisticMutation, useMutationWithOptimistic, useMutationWithTransition };
124
- //# sourceMappingURL=mutation.d.ts.map
113
+ export { CreateOptimisticMutationConfig, MutationCallbacks, MutationMessages, OptimisticMutationConfig, QUERY_CONFIGS, TransitionMutationConfig, TransitionMutationReturn, configureToast, createOptimisticMutation, useMutationWithOptimistic, useMutationWithTransition, useOptimisticMutation };
package/dist/mutation.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { isArcApiError } from "./client.js";
3
+ import { isArcApiError, isAutoIdempotency } from "./client.js";
4
+ import { QUERY_CONFIGS } from "./query.js";
4
5
  import { useMutation, useQueryClient } from "@tanstack/react-query";
5
- import { useTransition } from "react";
6
+ import { useCallback, useRef, useTransition } from "react";
6
7
 
7
8
  //#region src/mutation.ts
8
9
  let toastHandler = {
@@ -12,6 +13,8 @@ let toastHandler = {
12
13
  /**
13
14
  * Configure toast handler. Call once at app init.
14
15
  *
16
+ * **SSR safety:** This sets module-level state. Call only in client-side code.
17
+ *
15
18
  * @example
16
19
  * import { toast } from "sonner";
17
20
  * configureToast({ success: toast.success, error: toast.error });
@@ -41,8 +44,18 @@ function useMutationWithTransition(config) {
41
44
  const { mutationFn, invalidateQueries = [], onSuccess, onError, onSettled, messages, useTransition: withTransition = true, showToast: toast = true, toastHandler: instanceToast } = config;
42
45
  const queryClient = useQueryClient();
43
46
  const [isTransitioning, startTransition] = useTransition();
47
+ const idempotencyKeyRef = useRef(null);
44
48
  const mutation = useMutation({
45
- mutationFn,
49
+ mutationFn: (variables) => {
50
+ if (idempotencyKeyRef.current && typeof variables === "object" && variables !== null) {
51
+ const vars = variables;
52
+ if (vars.options && typeof vars.options === "object") vars.options.idempotencyKey ??= idempotencyKeyRef.current;
53
+ }
54
+ return mutationFn(variables);
55
+ },
56
+ onMutate: () => {
57
+ idempotencyKeyRef.current = isAutoIdempotency() ? globalThis.crypto.randomUUID() : null;
58
+ },
46
59
  onSuccess: (data, variables) => {
47
60
  const invalidate = () => {
48
61
  invalidateQueries.forEach((key) => queryClient.invalidateQueries({ queryKey: key }));
@@ -112,7 +125,7 @@ function useMutationWithOptimistic(config) {
112
125
  reset: mutation.reset
113
126
  };
114
127
  }
115
- function createOptimisticMutation(config) {
128
+ function useOptimisticMutation(config) {
116
129
  const { mutationFn, queryClient, queryKeys, optimisticUpdate, onSuccess, onError, onSettled, messages, toastHandler: instanceToast } = config;
117
130
  return useMutation({
118
131
  mutationFn,
@@ -149,16 +162,8 @@ function createOptimisticMutation(config) {
149
162
  }
150
163
  });
151
164
  }
152
- const QUERY_CONFIGS = {
153
- realtime: {
154
- staleTime: 2e4,
155
- refetchInterval: 3e4
156
- },
157
- frequent: { staleTime: 6e4 },
158
- stable: { staleTime: 3e5 },
159
- static: { staleTime: 6e5 }
160
- };
165
+ /** @deprecated Use `useOptimisticMutation` */
166
+ const createOptimisticMutation = useOptimisticMutation;
161
167
 
162
168
  //#endregion
163
- export { QUERY_CONFIGS, configureToast, createOptimisticMutation, useMutationWithOptimistic, useMutationWithTransition };
164
- //# sourceMappingURL=mutation.js.map
169
+ export { QUERY_CONFIGS, configureToast, createOptimisticMutation, useMutationWithOptimistic, useMutationWithTransition, useOptimisticMutation };
@@ -4,6 +4,13 @@ import { QueryClient, dehydrate } from "@tanstack/react-query";
4
4
  interface PrefetchOptions {
5
5
  staleTime?: number;
6
6
  }
7
+ interface PrefetchDetailOptions extends PrefetchOptions {
8
+ /** Query params (select, populate) — key must match useDetail's params to share cache */
9
+ params?: {
10
+ select?: string;
11
+ populate?: string | string[];
12
+ };
13
+ }
7
14
  interface CrudPrefetcher {
8
15
  /**
9
16
  * Prefetch a list query on the server. Uses the same query keys as useList.
@@ -20,7 +27,22 @@ interface CrudPrefetcher {
20
27
  * const queryClient = getQueryClient();
21
28
  * await productsPrefetcher.prefetchDetail(queryClient, productId);
22
29
  */
23
- prefetchDetail: (queryClient: QueryClient, id: string, options?: PrefetchOptions) => Promise<void>;
30
+ prefetchDetail: (queryClient: QueryClient, id: string, options?: PrefetchDetailOptions) => Promise<void>;
31
+ /**
32
+ * Prefetch a detail-by-slug query. Uses the same query keys as useDetailBySlug.
33
+ * Only available when the API has a `getBySlug` method (slugLookup preset).
34
+ */
35
+ prefetchBySlug: (queryClient: QueryClient, slug: string, options?: PrefetchDetailOptions) => Promise<void>;
36
+ /**
37
+ * Prefetch soft-deleted items. Uses the same query keys as useDeleted.
38
+ * Only available when the API has a `getDeleted` method (softDelete preset).
39
+ */
40
+ prefetchDeleted: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
41
+ /**
42
+ * Prefetch a tree query. Uses the same query keys as useTree.
43
+ * Only available when the API has a `getTree` method (tree preset).
44
+ */
45
+ prefetchTree: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
24
46
  }
25
47
  /**
26
48
  * Create server-safe prefetch helpers for CRUD queries.
@@ -58,7 +80,22 @@ declare function createCrudPrefetcher(api: {
58
80
  token?: string | null;
59
81
  organizationId?: string | null;
60
82
  }) => Promise<unknown>;
83
+ getBySlug?: (opts: {
84
+ slug: string;
85
+ token?: string | null;
86
+ organizationId?: string | null;
87
+ params?: Record<string, unknown>;
88
+ }) => Promise<unknown>;
89
+ getDeleted?: (opts: {
90
+ params?: Record<string, unknown>;
91
+ token?: string | null;
92
+ organizationId?: string | null;
93
+ }) => Promise<unknown>;
94
+ getTree?: (opts: {
95
+ params?: Record<string, unknown>;
96
+ token?: string | null;
97
+ organizationId?: string | null;
98
+ }) => Promise<unknown>;
61
99
  }, entityKey: string): CrudPrefetcher;
62
100
  //#endregion
63
- export { CrudPrefetcher, PrefetchOptions, createCrudPrefetcher, dehydrate };
64
- //# sourceMappingURL=prefetch.d.ts.map
101
+ export { CrudPrefetcher, PrefetchDetailOptions, PrefetchOptions, createCrudPrefetcher, dehydrate };
package/dist/prefetch.js CHANGED
@@ -1,23 +1,7 @@
1
+ import { createQueryKeys } from "./query.js";
1
2
  import { dehydrate } from "@tanstack/react-query";
2
3
 
3
4
  //#region src/prefetch.ts
4
- function scopedListKey(entityKey, scope, params) {
5
- return [
6
- entityKey,
7
- "list",
8
- {
9
- _scope: scope,
10
- ...params || {}
11
- }
12
- ];
13
- }
14
- function detailKey(entityKey, id) {
15
- return [
16
- entityKey,
17
- "detail",
18
- id
19
- ];
20
- }
21
5
  /**
22
6
  * Create server-safe prefetch helpers for CRUD queries.
23
7
  * Use in Next.js server components to pre-populate the query cache before rendering.
@@ -44,10 +28,12 @@ function detailKey(entityKey, id) {
44
28
  * }
45
29
  */
46
30
  function createCrudPrefetcher(api, entityKey) {
31
+ const KEYS = createQueryKeys(entityKey);
47
32
  return {
48
33
  async prefetchList(queryClient, params = {}, options = {}) {
49
34
  const { organizationId, ...restParams } = params;
50
- const queryKey = scopedListKey(entityKey, organizationId ? "tenant" : "super-admin", {
35
+ const scope = organizationId ? "tenant" : "super-admin";
36
+ const queryKey = KEYS.scopedList(scope, {
51
37
  organizationId,
52
38
  ...restParams
53
39
  });
@@ -61,10 +47,60 @@ function createCrudPrefetcher(api, entityKey) {
61
47
  });
62
48
  },
63
49
  async prefetchDetail(queryClient, id, options = {}) {
64
- const queryKey = detailKey(entityKey, id);
50
+ const { params, staleTime } = options;
51
+ const baseKey = KEYS.detail(id);
52
+ const queryKey = params ? [...baseKey, params] : baseKey;
53
+ await queryClient.prefetchQuery({
54
+ queryKey,
55
+ queryFn: () => api.getById({
56
+ id,
57
+ ...params ? { params } : {}
58
+ }),
59
+ staleTime
60
+ });
61
+ },
62
+ async prefetchBySlug(queryClient, slug, options = {}) {
63
+ if (!api.getBySlug) throw new Error(`[arc-next] prefetchBySlug requires an api with getBySlug (slugLookup preset)`);
64
+ const { params, staleTime } = options;
65
+ const queryKey = params ? KEYS.custom("slug", slug, params) : KEYS.custom("slug", slug);
66
+ await queryClient.prefetchQuery({
67
+ queryKey,
68
+ queryFn: () => api.getBySlug({
69
+ slug,
70
+ ...params ? { params } : {}
71
+ }),
72
+ staleTime
73
+ });
74
+ },
75
+ async prefetchDeleted(queryClient, params = {}, options = {}) {
76
+ if (!api.getDeleted) throw new Error(`[arc-next] prefetchDeleted requires an api with getDeleted (softDelete preset)`);
77
+ const { organizationId, ...restParams } = params;
78
+ const queryKey = KEYS.custom("deleted", {
79
+ organizationId,
80
+ ...restParams
81
+ });
82
+ await queryClient.prefetchQuery({
83
+ queryKey,
84
+ queryFn: () => api.getDeleted({
85
+ params: restParams,
86
+ organizationId: organizationId ?? null
87
+ }),
88
+ staleTime: options.staleTime
89
+ });
90
+ },
91
+ async prefetchTree(queryClient, params = {}, options = {}) {
92
+ if (!api.getTree) throw new Error(`[arc-next] prefetchTree requires an api with getTree (tree preset)`);
93
+ const { organizationId, ...restParams } = params;
94
+ const queryKey = KEYS.custom("tree", {
95
+ organizationId,
96
+ ...restParams
97
+ });
65
98
  await queryClient.prefetchQuery({
66
99
  queryKey,
67
- queryFn: () => api.getById({ id }),
100
+ queryFn: () => api.getTree({
101
+ params: restParams,
102
+ organizationId: organizationId ?? null
103
+ }),
68
104
  staleTime: options.staleTime
69
105
  });
70
106
  }
@@ -72,5 +108,4 @@ function createCrudPrefetcher(api, entityKey) {
72
108
  }
73
109
 
74
110
  //#endregion
75
- export { createCrudPrefetcher, dehydrate };
76
- //# sourceMappingURL=prefetch.js.map
111
+ export { createCrudPrefetcher, dehydrate };
@@ -21,5 +21,4 @@ interface QueryClientOverrides {
21
21
  */
22
22
  declare function getQueryClient(overrides?: QueryClientOverrides): QueryClient;
23
23
  //#endregion
24
- export { QueryClientOverrides, getQueryClient };
25
- //# sourceMappingURL=query-client.d.ts.map
24
+ export { QueryClientOverrides, getQueryClient };
@@ -38,9 +38,9 @@ let browserQueryClient;
38
38
  function getQueryClient(overrides) {
39
39
  if (isServer) return makeQueryClient(overrides);
40
40
  if (!browserQueryClient) browserQueryClient = makeQueryClient(overrides);
41
+ else if (overrides) console.warn("[arc-next] getQueryClient(): Browser singleton already exists — overrides are ignored. Pass overrides only on the first call.");
41
42
  return browserQueryClient;
42
43
  }
43
44
 
44
45
  //#endregion
45
- export { getQueryClient };
46
- //# sourceMappingURL=query-client.js.map
46
+ export { getQueryClient };
package/dist/query.d.ts CHANGED
@@ -2,12 +2,16 @@ import { InfiniteData, QueryClient, QueryKey } from "@tanstack/react-query";
2
2
 
3
3
  //#region src/query.d.ts
4
4
  interface PaginationData {
5
+ /** Pagination method detected from response (offset | keyset | aggregate) */
6
+ method: 'offset' | 'keyset' | 'aggregate' | null;
5
7
  total: number;
6
8
  pages: number;
7
9
  page: number;
8
10
  limit: number;
9
11
  hasNext: boolean;
10
12
  hasPrev: boolean;
13
+ /** Keyset cursor for next page (keyset pagination only) */
14
+ next?: string | null;
11
15
  }
12
16
  /** Request-level options passed through to the fetch call */
13
17
  interface RequestPassthrough {
@@ -39,6 +43,7 @@ interface DetailQueryOptions<TData = unknown> {
39
43
  enabled?: boolean;
40
44
  staleTime?: number;
41
45
  gcTime?: number;
46
+ refetchOnWindowFocus?: boolean;
42
47
  structuralSharing?: boolean;
43
48
  refetchInterval?: number | false;
44
49
  refetchIntervalInBackground?: boolean;
@@ -98,7 +103,24 @@ declare const DEFAULT_QUERY_CONFIG: {
98
103
  readonly refetchOnWindowFocus: false;
99
104
  readonly retry: 0;
100
105
  };
106
+ /** Pre-built query config presets for common data freshness patterns. */
107
+ declare const QUERY_CONFIGS: {
108
+ /** Live data: 20s stale, 30s polling */readonly realtime: {
109
+ readonly staleTime: 20000;
110
+ readonly refetchInterval: 30000;
111
+ }; /** Frequently updated: 60s stale */
112
+ readonly frequent: {
113
+ readonly staleTime: 60000;
114
+ }; /** Stable data: 5min stale (same as default) */
115
+ readonly stable: {
116
+ readonly staleTime: 300000;
117
+ }; /** Rarely changes: 10min stale */
118
+ readonly static: {
119
+ readonly staleTime: 600000;
120
+ };
121
+ };
101
122
  declare function getItemId(item: unknown): string | null;
123
+ declare function extractItem<T>(data: unknown): T | null;
102
124
  declare function updateListCache<T>(listData: unknown, updater: (items: T[]) => T[]): unknown;
103
125
  declare function createQueryKeys(entityKey: string): QueryKeys;
104
126
  declare function createCacheUtils<T>(KEYS: QueryKeys): CacheUtils<T>;
@@ -111,15 +133,18 @@ interface CreateListQueryConfig {
111
133
  options?: Record<string, unknown>;
112
134
  prefillDetailCache?: boolean;
113
135
  detailKeyBuilder?: (id: string) => QueryKey;
136
+ /** Custom ID extractor for cache prefill. Falls back to getItemId (_id → id). */
137
+ itemIdResolver?: (item: unknown) => string | null;
114
138
  select?: (data: unknown) => unknown;
115
139
  }
116
- declare function createListQuery<T>({
140
+ declare function useListQuery<T>({
117
141
  queryKey,
118
142
  queryFn,
119
143
  enabled,
120
144
  options,
121
145
  prefillDetailCache,
122
146
  detailKeyBuilder,
147
+ itemIdResolver,
123
148
  select
124
149
  }: CreateListQueryConfig): ListQueryResult<T>;
125
150
  interface CreateDetailQueryConfig {
@@ -131,7 +156,7 @@ interface CreateDetailQueryConfig {
131
156
  options?: Record<string, unknown>;
132
157
  select?: (data: unknown) => unknown;
133
158
  }
134
- declare function createDetailQuery<T>({
159
+ declare function useDetailQuery<T>({
135
160
  queryKey,
136
161
  queryFn,
137
162
  enabled,
@@ -145,8 +170,16 @@ interface InfiniteListQueryOptions {
145
170
  gcTime?: number;
146
171
  refetchOnWindowFocus?: boolean;
147
172
  structuralSharing?: boolean;
173
+ refetchInterval?: number | false;
174
+ refetchIntervalInBackground?: boolean;
148
175
  _scope?: string;
149
176
  request?: RequestPassthrough;
177
+ /**
178
+ * Max pages to keep in memory. Old pages are evicted and re-fetched on scroll-back.
179
+ * Requires `getPreviousPageParam` for backward re-fetching.
180
+ * When unset, all fetched pages are retained (default TanStack Query behavior).
181
+ */
182
+ maxPages?: number;
150
183
  }
151
184
  interface InfiniteListQueryResult<T> {
152
185
  items: T[];
@@ -175,16 +208,24 @@ interface CreateInfiniteListQueryConfig {
175
208
  initialPageParam?: unknown;
176
209
  getNextPageParam: (lastPage: unknown) => unknown;
177
210
  getPreviousPageParam?: (firstPage: unknown) => unknown;
211
+ /** Max pages to keep in memory. Old pages are evicted when exceeded. */
212
+ maxPages?: number;
178
213
  }
179
- declare function createInfiniteListQuery<T>({
214
+ declare function useInfiniteListQuery<T>({
180
215
  queryKey,
181
216
  queryFn,
182
217
  enabled,
183
218
  options,
184
219
  initialPageParam,
185
220
  getNextPageParam,
186
- getPreviousPageParam
221
+ getPreviousPageParam,
222
+ maxPages
187
223
  }: CreateInfiniteListQueryConfig): InfiniteListQueryResult<T>;
224
+ /** @deprecated Use `useListQuery` */
225
+ declare const createListQuery: typeof useListQuery;
226
+ /** @deprecated Use `useDetailQuery` */
227
+ declare const createDetailQuery: typeof useDetailQuery;
228
+ /** @deprecated Use `useInfiniteListQuery` */
229
+ declare const createInfiniteListQuery: typeof useInfiniteListQuery;
188
230
  //#endregion
189
- export { CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, PaginationData, QueryKeys, RequestPassthrough, createCacheUtils, createDetailQuery, createInfiniteListQuery, createListQuery, createQueryKeys, getItemId, updateListCache };
190
- //# sourceMappingURL=query.d.ts.map
231
+ export { CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, PaginationData, QUERY_CONFIGS, QueryKeys, RequestPassthrough, createCacheUtils, createDetailQuery, createInfiniteListQuery, createListQuery, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/query.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
4
- import { useEffect } from "react";
4
+ import { useEffect, useMemo } from "react";
5
5
 
6
6
  //#region src/query.ts
7
7
  const DEFAULT_QUERY_CONFIG = {
@@ -10,6 +10,16 @@ const DEFAULT_QUERY_CONFIG = {
10
10
  refetchOnWindowFocus: false,
11
11
  retry: 0
12
12
  };
13
+ /** Pre-built query config presets for common data freshness patterns. */
14
+ const QUERY_CONFIGS = {
15
+ realtime: {
16
+ staleTime: 2e4,
17
+ refetchInterval: 3e4
18
+ },
19
+ frequent: { staleTime: 6e4 },
20
+ stable: { staleTime: 3e5 },
21
+ static: { staleTime: 6e5 }
22
+ };
13
23
  function getItemId(item) {
14
24
  if (!item || typeof item !== "object") return null;
15
25
  const obj = item;
@@ -19,38 +29,68 @@ function getItemId(item) {
19
29
  function normalizePagination(data) {
20
30
  if (!data || typeof data !== "object") return null;
21
31
  const d = data;
32
+ const method = d.method ?? null;
33
+ const isKeyset = method === "keyset" || d.hasMore != null && d.total == null && d.pages == null;
22
34
  const hasTotal = d.total != null || d.totalDocs != null;
23
35
  const hasPages = d.pages != null || d.totalPages != null;
24
- if (!hasTotal && !hasPages) return null;
36
+ if (!hasTotal && !hasPages && !isKeyset) return null;
25
37
  return {
38
+ method,
26
39
  total: Number(d.total ?? d.totalDocs ?? 0),
27
- pages: Number(d.pages ?? d.totalPages ?? 1),
28
- page: Number(d.page ?? d.currentPage ?? 1),
40
+ pages: Number(d.pages ?? d.totalPages ?? (isKeyset ? 0 : 1)),
41
+ page: Number(d.page ?? d.currentPage ?? (isKeyset ? 0 : 1)),
29
42
  limit: Number(d.limit ?? 10),
30
43
  hasNext: Boolean(d.hasNext ?? d.hasNextPage ?? d.hasMore ?? false),
31
- hasPrev: Boolean(d.hasPrev ?? d.hasPrevPage ?? false)
44
+ hasPrev: Boolean(d.hasPrev ?? d.hasPrevPage ?? false),
45
+ ...isKeyset ? { next: d.next ?? null } : {}
32
46
  };
33
47
  }
48
+ /** Well-known keys checked in order for list responses. */
49
+ const LIST_KEYS = [
50
+ "docs",
51
+ "data",
52
+ "items",
53
+ "results"
54
+ ];
55
+ /** Well-known keys checked in order for detail responses. */
56
+ const DETAIL_KEYS = [
57
+ "data",
58
+ "doc",
59
+ "item",
60
+ "result"
61
+ ];
34
62
  function extractItems(data) {
35
63
  if (!data) return [];
36
64
  if (Array.isArray(data)) return data;
37
65
  if (typeof data !== "object") return [];
38
66
  const d = data;
39
- const items = d.docs ?? d.data ?? d.items ?? d.results;
40
- return Array.isArray(items) ? items : [];
67
+ for (const key of LIST_KEYS) if (Array.isArray(d[key])) return d[key];
68
+ for (const value of Object.values(d)) if (Array.isArray(value)) return value;
69
+ return [];
41
70
  }
42
71
  function extractItem(data) {
43
- if (!data) return null;
44
- if (typeof data !== "object") return null;
72
+ if (data == null) return null;
73
+ if (typeof data !== "object") return data;
45
74
  const d = data;
46
- return d.data ?? d;
75
+ for (const key of DETAIL_KEYS) if (d[key] != null) return d[key];
76
+ return d;
47
77
  }
48
78
  function updateListCache(listData, updater) {
49
79
  if (!listData) return listData;
50
80
  if (Array.isArray(listData)) return updater(listData);
51
81
  if (typeof listData !== "object") return listData;
52
82
  const d = listData;
53
- const arrayField = "docs" in d ? "docs" : Array.isArray(d.data) ? "data" : "items" in d && Array.isArray(d.items) ? "items" : "results" in d && Array.isArray(d.results) ? "results" : null;
83
+ let arrayField = null;
84
+ for (const key of LIST_KEYS) if (Array.isArray(d[key])) {
85
+ arrayField = key;
86
+ break;
87
+ }
88
+ if (!arrayField) {
89
+ for (const [key, value] of Object.entries(d)) if (Array.isArray(value)) {
90
+ arrayField = key;
91
+ break;
92
+ }
93
+ }
54
94
  if (!arrayField) return listData;
55
95
  const updated = updater(d[arrayField]);
56
96
  const original = d[arrayField];
@@ -107,7 +147,7 @@ function createCacheUtils(KEYS) {
107
147
  removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) })
108
148
  };
109
149
  }
110
- function createListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, select }) {
150
+ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, itemIdResolver, select }) {
111
151
  const queryClient = useQueryClient();
112
152
  const query = useQuery({
113
153
  queryKey,
@@ -118,12 +158,13 @@ function createListQuery({ queryKey, queryFn, enabled = true, options = {}, pref
118
158
  ...select ? { select } : {},
119
159
  placeholderData: keepPreviousData
120
160
  });
121
- const items = extractItems(query.data);
122
- const pagination = normalizePagination(query.data);
161
+ const items = useMemo(() => extractItems(query.data), [query.data]);
162
+ const pagination = useMemo(() => normalizePagination(query.data), [query.data]);
123
163
  useEffect(() => {
124
164
  if (!prefillDetailCache || !detailKeyBuilder || items.length === 0) return;
165
+ const resolveId = itemIdResolver ?? getItemId;
125
166
  items.forEach((item) => {
126
- const id = getItemId(item);
167
+ const id = resolveId(item);
127
168
  if (id) queryClient.setQueryData(detailKeyBuilder(id), { data: item });
128
169
  });
129
170
  }, [
@@ -145,7 +186,7 @@ function createListQuery({ queryKey, queryFn, enabled = true, options = {}, pref
145
186
  data: query.data
146
187
  };
147
188
  }
148
- function createDetailQuery({ queryKey, queryFn, enabled = true, options = {}, select }) {
189
+ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, select }) {
149
190
  const query = useQuery({
150
191
  queryKey,
151
192
  queryFn: ({ signal }) => queryFn({ signal }),
@@ -166,7 +207,7 @@ function createDetailQuery({ queryKey, queryFn, enabled = true, options = {}, se
166
207
  data: query.data
167
208
  };
168
209
  }
169
- function createInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam }) {
210
+ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam, maxPages }) {
170
211
  const query = useInfiniteQuery({
171
212
  queryKey,
172
213
  queryFn: ({ pageParam, signal }) => queryFn({
@@ -177,11 +218,12 @@ function createInfiniteListQuery({ queryKey, queryFn, enabled = true, options =
177
218
  initialPageParam,
178
219
  getNextPageParam,
179
220
  getPreviousPageParam,
221
+ ...maxPages != null ? { maxPages } : {},
180
222
  ...DEFAULT_QUERY_CONFIG,
181
223
  ...options
182
224
  });
183
225
  return {
184
- items: query.data?.pages.flatMap((page) => extractItems(page)) ?? [],
226
+ items: useMemo(() => query.data?.pages.flatMap((page) => extractItems(page)) ?? [], [query.data]),
185
227
  hasNextPage: query.hasNextPage,
186
228
  hasPreviousPage: query.hasPreviousPage,
187
229
  isFetchingNextPage: query.isFetchingNextPage,
@@ -197,7 +239,12 @@ function createInfiniteListQuery({ queryKey, queryFn, enabled = true, options =
197
239
  data: query.data
198
240
  };
199
241
  }
242
+ /** @deprecated Use `useListQuery` */
243
+ const createListQuery = useListQuery;
244
+ /** @deprecated Use `useDetailQuery` */
245
+ const createDetailQuery = useDetailQuery;
246
+ /** @deprecated Use `useInfiniteListQuery` */
247
+ const createInfiniteListQuery = useInfiniteListQuery;
200
248
 
201
249
  //#endregion
202
- export { DEFAULT_QUERY_CONFIG, createCacheUtils, createDetailQuery, createInfiniteListQuery, createListQuery, createQueryKeys, getItemId, updateListCache };
203
- //# sourceMappingURL=query.js.map
250
+ export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createDetailQuery, createInfiniteListQuery, createListQuery, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/sse.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { QueryKey } from "@tanstack/react-query";
2
+
3
+ //#region src/sse.d.ts
4
+ interface ArcServerEvent {
5
+ type: string;
6
+ resource: string;
7
+ data: unknown;
8
+ timestamp: string;
9
+ id?: string;
10
+ }
11
+ interface EventStreamOptions {
12
+ /** SSE endpoint URL (absolute or relative to baseUrl). Default: `/{basePath}/{resource}/events/stream` */
13
+ url?: string;
14
+ /** Resource name used for the default endpoint path and event filtering. */
15
+ resource?: string;
16
+ /** Base path for the events endpoint. Default: '/api/v1' */
17
+ basePath?: string;
18
+ /** Event patterns to listen for (e.g., ['agents.created', 'agents.updated']). When empty, all events are received. */
19
+ patterns?: string[];
20
+ /** Query keys to invalidate when any event is received. */
21
+ invalidateQueries?: QueryKey[];
22
+ /** Callback for each event. */
23
+ onEvent?: (event: ArcServerEvent) => void;
24
+ /** Callback for connection state changes. */
25
+ onConnectionChange?: (connected: boolean) => void;
26
+ /** Whether the stream is enabled. Default: true */
27
+ enabled?: boolean;
28
+ /** Reconnect delay in ms. Default: 3000 */
29
+ reconnectDelay?: number;
30
+ /** Maximum reconnect attempts before giving up. Default: Infinity */
31
+ maxReconnectAttempts?: number;
32
+ /** Whether to include credentials (cookies). Derived from authMode when not set. */
33
+ withCredentials?: boolean;
34
+ }
35
+ interface EventStreamResult {
36
+ /** Whether the EventSource is currently connected. */
37
+ isConnected: boolean;
38
+ /** The most recently received event. */
39
+ lastEvent: ArcServerEvent | null;
40
+ /** Number of events received since connection. */
41
+ eventCount: number;
42
+ /** Close the connection manually. */
43
+ close: () => void;
44
+ /** Reconnect after a manual close. */
45
+ reconnect: () => void;
46
+ }
47
+ /**
48
+ * Subscribe to Arc server-sent events for real-time cache invalidation.
49
+ *
50
+ * Uses the browser's native `EventSource` API for automatic reconnection
51
+ * and efficient server-push. Events trigger query invalidation so TanStack Query
52
+ * refetches affected data automatically.
53
+ *
54
+ * @example
55
+ * const { isConnected } = useEventStream({
56
+ * resource: 'agents',
57
+ * invalidateQueries: [agentKeys.lists()],
58
+ * });
59
+ */
60
+ declare function useEventStream(options: EventStreamOptions): EventStreamResult;
61
+ //#endregion
62
+ export { ArcServerEvent, EventStreamOptions, EventStreamResult, useEventStream };