@classytic/arc-next 0.3.1 → 0.4.1
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/README.md +139 -32
- package/dist/api.d.ts +120 -3
- package/dist/api.js +109 -5
- package/dist/client.d.ts +63 -16
- package/dist/client.js +87 -18
- package/dist/hooks.d.ts +56 -2
- package/dist/hooks.js +360 -46
- package/dist/mutation.d.ts +5 -22
- package/dist/mutation.js +14 -15
- package/dist/prefetch.d.ts +56 -2
- package/dist/prefetch.js +70 -24
- package/dist/query.d.ts +41 -8
- package/dist/query.js +33 -11
- package/dist/sse.d.ts +69 -0
- package/dist/sse.js +145 -0
- package/package.json +22 -13
package/dist/mutation.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { isArcApiError } from "./client.js";
|
|
3
|
+
import { isArcApiError, isAutoIdempotency } from "./client.js";
|
|
4
4
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
-
import { useTransition } from "react";
|
|
5
|
+
import { useCallback, useRef, useTransition } from "react";
|
|
6
6
|
|
|
7
7
|
//#region src/mutation.ts
|
|
8
8
|
let toastHandler = {
|
|
@@ -43,8 +43,18 @@ function useMutationWithTransition(config) {
|
|
|
43
43
|
const { mutationFn, invalidateQueries = [], onSuccess, onError, onSettled, messages, useTransition: withTransition = true, showToast: toast = true, toastHandler: instanceToast } = config;
|
|
44
44
|
const queryClient = useQueryClient();
|
|
45
45
|
const [isTransitioning, startTransition] = useTransition();
|
|
46
|
+
const idempotencyKeyRef = useRef(null);
|
|
46
47
|
const mutation = useMutation({
|
|
47
|
-
mutationFn
|
|
48
|
+
mutationFn: (variables) => {
|
|
49
|
+
if (idempotencyKeyRef.current && typeof variables === "object" && variables !== null) {
|
|
50
|
+
const vars = variables;
|
|
51
|
+
if (vars.options && typeof vars.options === "object") vars.options.idempotencyKey ??= idempotencyKeyRef.current;
|
|
52
|
+
}
|
|
53
|
+
return mutationFn(variables);
|
|
54
|
+
},
|
|
55
|
+
onMutate: () => {
|
|
56
|
+
idempotencyKeyRef.current = isAutoIdempotency() ? globalThis.crypto.randomUUID() : null;
|
|
57
|
+
},
|
|
48
58
|
onSuccess: (data, variables) => {
|
|
49
59
|
const invalidate = () => {
|
|
50
60
|
invalidateQueries.forEach((key) => queryClient.invalidateQueries({ queryKey: key }));
|
|
@@ -151,17 +161,6 @@ function useOptimisticMutation(config) {
|
|
|
151
161
|
}
|
|
152
162
|
});
|
|
153
163
|
}
|
|
154
|
-
const QUERY_CONFIGS = {
|
|
155
|
-
realtime: {
|
|
156
|
-
staleTime: 2e4,
|
|
157
|
-
refetchInterval: 3e4
|
|
158
|
-
},
|
|
159
|
-
frequent: { staleTime: 6e4 },
|
|
160
|
-
stable: { staleTime: 3e5 },
|
|
161
|
-
static: { staleTime: 6e5 }
|
|
162
|
-
};
|
|
163
|
-
/** @deprecated Use `useOptimisticMutation` */
|
|
164
|
-
const createOptimisticMutation = useOptimisticMutation;
|
|
165
164
|
|
|
166
165
|
//#endregion
|
|
167
|
-
export {
|
|
166
|
+
export { configureToast, useMutationWithOptimistic, useMutationWithTransition, useOptimisticMutation };
|
package/dist/prefetch.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { QueryClient, dehydrate } from "@tanstack/react-query";
|
|
2
2
|
|
|
3
3
|
//#region src/prefetch.d.ts
|
|
4
|
-
interface
|
|
4
|
+
interface PrefetchAuthContext {
|
|
5
|
+
/** Auth token for protected endpoints. Required for bearer/header auth on server. */
|
|
6
|
+
token?: string | null;
|
|
7
|
+
/** Organization ID for multi-tenant prefetch. Sent as x-organization-id header. */
|
|
8
|
+
organizationId?: string | null;
|
|
9
|
+
/** Additional headers (e.g., x-api-key for header auth). */
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
interface PrefetchOptions extends PrefetchAuthContext {
|
|
5
13
|
staleTime?: number;
|
|
6
14
|
}
|
|
7
15
|
interface PrefetchDetailOptions extends PrefetchOptions {
|
|
@@ -28,6 +36,21 @@ interface CrudPrefetcher {
|
|
|
28
36
|
* await productsPrefetcher.prefetchDetail(queryClient, productId);
|
|
29
37
|
*/
|
|
30
38
|
prefetchDetail: (queryClient: QueryClient, id: string, options?: PrefetchDetailOptions) => Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Prefetch a detail-by-slug query. Uses the same query keys as useDetailBySlug.
|
|
41
|
+
* Only available when the API has a `getBySlug` method (slugLookup preset).
|
|
42
|
+
*/
|
|
43
|
+
prefetchBySlug: (queryClient: QueryClient, slug: string, options?: PrefetchDetailOptions) => Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Prefetch soft-deleted items. Uses the same query keys as useDeleted.
|
|
46
|
+
* Only available when the API has a `getDeleted` method (softDelete preset).
|
|
47
|
+
*/
|
|
48
|
+
prefetchDeleted: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Prefetch a tree query. Uses the same query keys as useTree.
|
|
51
|
+
* Only available when the API has a `getTree` method (tree preset).
|
|
52
|
+
*/
|
|
53
|
+
prefetchTree: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
|
|
31
54
|
}
|
|
32
55
|
/**
|
|
33
56
|
* Create server-safe prefetch helpers for CRUD queries.
|
|
@@ -59,12 +82,43 @@ declare function createCrudPrefetcher(api: {
|
|
|
59
82
|
params?: Record<string, unknown>;
|
|
60
83
|
token?: string | null;
|
|
61
84
|
organizationId?: string | null;
|
|
85
|
+
options?: {
|
|
86
|
+
headerOptions?: Record<string, string>;
|
|
87
|
+
};
|
|
62
88
|
}) => Promise<unknown>;
|
|
63
89
|
getById: (opts: {
|
|
64
90
|
id: string;
|
|
65
91
|
token?: string | null;
|
|
66
92
|
organizationId?: string | null;
|
|
93
|
+
options?: {
|
|
94
|
+
headerOptions?: Record<string, string>;
|
|
95
|
+
};
|
|
96
|
+
}) => Promise<unknown>;
|
|
97
|
+
getBySlug?: (opts: {
|
|
98
|
+
slug: string;
|
|
99
|
+
token?: string | null;
|
|
100
|
+
organizationId?: string | null;
|
|
101
|
+
params?: Record<string, unknown>;
|
|
102
|
+
options?: {
|
|
103
|
+
headerOptions?: Record<string, string>;
|
|
104
|
+
};
|
|
105
|
+
}) => Promise<unknown>;
|
|
106
|
+
getDeleted?: (opts: {
|
|
107
|
+
params?: Record<string, unknown>;
|
|
108
|
+
token?: string | null;
|
|
109
|
+
organizationId?: string | null;
|
|
110
|
+
options?: {
|
|
111
|
+
headerOptions?: Record<string, string>;
|
|
112
|
+
};
|
|
113
|
+
}) => Promise<unknown>;
|
|
114
|
+
getTree?: (opts: {
|
|
115
|
+
params?: Record<string, unknown>;
|
|
116
|
+
token?: string | null;
|
|
117
|
+
organizationId?: string | null;
|
|
118
|
+
options?: {
|
|
119
|
+
headerOptions?: Record<string, string>;
|
|
120
|
+
};
|
|
67
121
|
}) => Promise<unknown>;
|
|
68
122
|
}, entityKey: string): CrudPrefetcher;
|
|
69
123
|
//#endregion
|
|
70
|
-
export { CrudPrefetcher, PrefetchDetailOptions, PrefetchOptions, createCrudPrefetcher, dehydrate };
|
|
124
|
+
export { CrudPrefetcher, PrefetchAuthContext, 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,34 +28,96 @@ 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
|
-
const { organizationId, ...restParams } = params;
|
|
50
|
-
const
|
|
51
|
-
|
|
34
|
+
const { organizationId: paramOrgId, ...restParams } = params;
|
|
35
|
+
const orgId = paramOrgId ?? options.organizationId ?? null;
|
|
36
|
+
const scope = orgId ? "tenant" : "super-admin";
|
|
37
|
+
const queryKey = KEYS.scopedList(scope, {
|
|
38
|
+
...orgId ? { organizationId: orgId } : {},
|
|
52
39
|
...restParams
|
|
53
40
|
});
|
|
54
41
|
await queryClient.prefetchQuery({
|
|
55
42
|
queryKey,
|
|
56
43
|
queryFn: () => api.getAll({
|
|
57
44
|
params: restParams,
|
|
58
|
-
|
|
45
|
+
token: options.token ?? null,
|
|
46
|
+
organizationId: orgId,
|
|
47
|
+
...options.headers ? { options: { headerOptions: options.headers } } : {}
|
|
59
48
|
}),
|
|
60
49
|
staleTime: options.staleTime
|
|
61
50
|
});
|
|
62
51
|
},
|
|
63
52
|
async prefetchDetail(queryClient, id, options = {}) {
|
|
64
|
-
const { params, staleTime } = options;
|
|
65
|
-
const baseKey =
|
|
53
|
+
const { params, staleTime, token, organizationId } = options;
|
|
54
|
+
const baseKey = KEYS.detail(id);
|
|
66
55
|
const queryKey = params ? [...baseKey, params] : baseKey;
|
|
67
56
|
await queryClient.prefetchQuery({
|
|
68
57
|
queryKey,
|
|
69
58
|
queryFn: () => api.getById({
|
|
70
59
|
id,
|
|
71
|
-
|
|
60
|
+
token: token ?? null,
|
|
61
|
+
organizationId: organizationId ?? null,
|
|
62
|
+
...params ? { params } : {},
|
|
63
|
+
...options.headers ? { options: { headerOptions: options.headers } } : {}
|
|
64
|
+
}),
|
|
65
|
+
staleTime
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
async prefetchBySlug(queryClient, slug, options = {}) {
|
|
69
|
+
if (!api.getBySlug) throw new Error(`[arc-next] prefetchBySlug requires an api with getBySlug (slugLookup preset)`);
|
|
70
|
+
const { params, staleTime, token, organizationId } = options;
|
|
71
|
+
const queryKey = params ? KEYS.custom("slug", slug, params) : KEYS.custom("slug", slug);
|
|
72
|
+
await queryClient.prefetchQuery({
|
|
73
|
+
queryKey,
|
|
74
|
+
queryFn: () => api.getBySlug({
|
|
75
|
+
slug,
|
|
76
|
+
token: token ?? null,
|
|
77
|
+
organizationId: organizationId ?? null,
|
|
78
|
+
...params ? { params } : {},
|
|
79
|
+
...options.headers ? { options: { headerOptions: options.headers } } : {}
|
|
72
80
|
}),
|
|
73
81
|
staleTime
|
|
74
82
|
});
|
|
83
|
+
},
|
|
84
|
+
async prefetchDeleted(queryClient, params = {}, options = {}) {
|
|
85
|
+
if (!api.getDeleted) throw new Error(`[arc-next] prefetchDeleted requires an api with getDeleted (softDelete preset)`);
|
|
86
|
+
const { organizationId: paramOrgId, ...restParams } = params;
|
|
87
|
+
const orgId = paramOrgId ?? options.organizationId ?? null;
|
|
88
|
+
const queryKey = KEYS.custom("deleted", {
|
|
89
|
+
...orgId ? { organizationId: orgId } : {},
|
|
90
|
+
...restParams
|
|
91
|
+
});
|
|
92
|
+
await queryClient.prefetchQuery({
|
|
93
|
+
queryKey,
|
|
94
|
+
queryFn: () => api.getDeleted({
|
|
95
|
+
params: restParams,
|
|
96
|
+
token: options.token ?? null,
|
|
97
|
+
organizationId: orgId,
|
|
98
|
+
...options.headers ? { options: { headerOptions: options.headers } } : {}
|
|
99
|
+
}),
|
|
100
|
+
staleTime: options.staleTime
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
async prefetchTree(queryClient, params = {}, options = {}) {
|
|
104
|
+
if (!api.getTree) throw new Error(`[arc-next] prefetchTree requires an api with getTree (tree preset)`);
|
|
105
|
+
const { organizationId: paramOrgId, ...restParams } = params;
|
|
106
|
+
const orgId = paramOrgId ?? options.organizationId ?? null;
|
|
107
|
+
const queryKey = KEYS.custom("tree", {
|
|
108
|
+
...orgId ? { organizationId: orgId } : {},
|
|
109
|
+
...restParams
|
|
110
|
+
});
|
|
111
|
+
await queryClient.prefetchQuery({
|
|
112
|
+
queryKey,
|
|
113
|
+
queryFn: () => api.getTree({
|
|
114
|
+
params: restParams,
|
|
115
|
+
token: options.token ?? null,
|
|
116
|
+
organizationId: orgId,
|
|
117
|
+
...options.headers ? { options: { headerOptions: options.headers } } : {}
|
|
118
|
+
}),
|
|
119
|
+
staleTime: options.staleTime
|
|
120
|
+
});
|
|
75
121
|
}
|
|
76
122
|
};
|
|
77
123
|
}
|
package/dist/query.d.ts
CHANGED
|
@@ -86,16 +86,27 @@ interface QueryKeys {
|
|
|
86
86
|
list: (params?: unknown) => QueryKey;
|
|
87
87
|
details: () => QueryKey;
|
|
88
88
|
detail: (id: string) => QueryKey;
|
|
89
|
+
/** Tenant-scoped detail key. Use when IDs are only unique within an org. */
|
|
90
|
+
scopedDetail: (id: string, organizationId: string | null) => QueryKey;
|
|
89
91
|
custom: (key: string, ...args: unknown[]) => QueryKey;
|
|
90
92
|
scopedList: (scope: string, params?: unknown) => QueryKey;
|
|
91
93
|
}
|
|
92
94
|
interface CacheUtils<T> {
|
|
93
95
|
invalidateAll: (client: QueryClient) => Promise<void>;
|
|
94
96
|
invalidateLists: (client: QueryClient) => Promise<void>;
|
|
97
|
+
/** Invalidate detail by ID (prefix-matches all scoped/parameterized variants). */
|
|
95
98
|
invalidateDetail: (client: QueryClient, id: string) => Promise<void>;
|
|
96
99
|
setDetail: (client: QueryClient, id: string, data: T) => void;
|
|
97
100
|
getDetail: (client: QueryClient, id: string) => T | undefined;
|
|
98
101
|
removeDetail: (client: QueryClient, id: string) => void;
|
|
102
|
+
/** Invalidate tenant-scoped detail (prefix-matches parameterized variants within org). */
|
|
103
|
+
invalidateScopedDetail: (client: QueryClient, id: string, organizationId: string | null) => Promise<void>;
|
|
104
|
+
/** Set tenant-scoped detail cache. */
|
|
105
|
+
setScopedDetail: (client: QueryClient, id: string, organizationId: string | null, data: T) => void;
|
|
106
|
+
/** Get tenant-scoped detail from cache. */
|
|
107
|
+
getScopedDetail: (client: QueryClient, id: string, organizationId: string | null) => T | undefined;
|
|
108
|
+
/** Remove tenant-scoped detail from cache. */
|
|
109
|
+
removeScopedDetail: (client: QueryClient, id: string, organizationId: string | null) => void;
|
|
99
110
|
}
|
|
100
111
|
declare const DEFAULT_QUERY_CONFIG: {
|
|
101
112
|
readonly staleTime: number;
|
|
@@ -103,6 +114,22 @@ declare const DEFAULT_QUERY_CONFIG: {
|
|
|
103
114
|
readonly refetchOnWindowFocus: false;
|
|
104
115
|
readonly retry: 0;
|
|
105
116
|
};
|
|
117
|
+
/** Pre-built query config presets for common data freshness patterns. */
|
|
118
|
+
declare const QUERY_CONFIGS: {
|
|
119
|
+
/** Live data: 20s stale, 30s polling */readonly realtime: {
|
|
120
|
+
readonly staleTime: 20000;
|
|
121
|
+
readonly refetchInterval: 30000;
|
|
122
|
+
}; /** Frequently updated: 60s stale */
|
|
123
|
+
readonly frequent: {
|
|
124
|
+
readonly staleTime: 60000;
|
|
125
|
+
}; /** Stable data: 5min stale (same as default) */
|
|
126
|
+
readonly stable: {
|
|
127
|
+
readonly staleTime: 300000;
|
|
128
|
+
}; /** Rarely changes: 10min stale */
|
|
129
|
+
readonly static: {
|
|
130
|
+
readonly staleTime: 600000;
|
|
131
|
+
};
|
|
132
|
+
};
|
|
106
133
|
declare function getItemId(item: unknown): string | null;
|
|
107
134
|
declare function extractItem<T>(data: unknown): T | null;
|
|
108
135
|
declare function updateListCache<T>(listData: unknown, updater: (items: T[]) => T[]): unknown;
|
|
@@ -117,6 +144,8 @@ interface CreateListQueryConfig {
|
|
|
117
144
|
options?: Record<string, unknown>;
|
|
118
145
|
prefillDetailCache?: boolean;
|
|
119
146
|
detailKeyBuilder?: (id: string) => QueryKey;
|
|
147
|
+
/** Custom ID extractor for cache prefill. Falls back to getItemId (_id → id). */
|
|
148
|
+
itemIdResolver?: (item: unknown) => string | null;
|
|
120
149
|
select?: (data: unknown) => unknown;
|
|
121
150
|
}
|
|
122
151
|
declare function useListQuery<T>({
|
|
@@ -126,6 +155,7 @@ declare function useListQuery<T>({
|
|
|
126
155
|
options,
|
|
127
156
|
prefillDetailCache,
|
|
128
157
|
detailKeyBuilder,
|
|
158
|
+
itemIdResolver,
|
|
129
159
|
select
|
|
130
160
|
}: CreateListQueryConfig): ListQueryResult<T>;
|
|
131
161
|
interface CreateDetailQueryConfig {
|
|
@@ -155,6 +185,12 @@ interface InfiniteListQueryOptions {
|
|
|
155
185
|
refetchIntervalInBackground?: boolean;
|
|
156
186
|
_scope?: string;
|
|
157
187
|
request?: RequestPassthrough;
|
|
188
|
+
/**
|
|
189
|
+
* Max pages to keep in memory. Old pages are evicted and re-fetched on scroll-back.
|
|
190
|
+
* Requires `getPreviousPageParam` for backward re-fetching.
|
|
191
|
+
* When unset, all fetched pages are retained (default TanStack Query behavior).
|
|
192
|
+
*/
|
|
193
|
+
maxPages?: number;
|
|
158
194
|
}
|
|
159
195
|
interface InfiniteListQueryResult<T> {
|
|
160
196
|
items: T[];
|
|
@@ -183,6 +219,8 @@ interface CreateInfiniteListQueryConfig {
|
|
|
183
219
|
initialPageParam?: unknown;
|
|
184
220
|
getNextPageParam: (lastPage: unknown) => unknown;
|
|
185
221
|
getPreviousPageParam?: (firstPage: unknown) => unknown;
|
|
222
|
+
/** Max pages to keep in memory. Old pages are evicted when exceeded. */
|
|
223
|
+
maxPages?: number;
|
|
186
224
|
}
|
|
187
225
|
declare function useInfiniteListQuery<T>({
|
|
188
226
|
queryKey,
|
|
@@ -191,13 +229,8 @@ declare function useInfiniteListQuery<T>({
|
|
|
191
229
|
options,
|
|
192
230
|
initialPageParam,
|
|
193
231
|
getNextPageParam,
|
|
194
|
-
getPreviousPageParam
|
|
232
|
+
getPreviousPageParam,
|
|
233
|
+
maxPages
|
|
195
234
|
}: CreateInfiniteListQueryConfig): InfiniteListQueryResult<T>;
|
|
196
|
-
/** @deprecated Use `useListQuery` */
|
|
197
|
-
declare const createListQuery: typeof useListQuery;
|
|
198
|
-
/** @deprecated Use `useDetailQuery` */
|
|
199
|
-
declare const createDetailQuery: typeof useDetailQuery;
|
|
200
|
-
/** @deprecated Use `useInfiniteListQuery` */
|
|
201
|
-
declare const createInfiniteListQuery: typeof useInfiniteListQuery;
|
|
202
235
|
//#endregion
|
|
203
|
-
export { CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, PaginationData, QueryKeys, RequestPassthrough, createCacheUtils,
|
|
236
|
+
export { CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, PaginationData, QUERY_CONFIGS, QueryKeys, RequestPassthrough, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
|
package/dist/query.js
CHANGED
|
@@ -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;
|
|
@@ -110,6 +120,16 @@ function createQueryKeys(entityKey) {
|
|
|
110
120
|
"detail",
|
|
111
121
|
id
|
|
112
122
|
],
|
|
123
|
+
scopedDetail: (id, organizationId) => organizationId ? [
|
|
124
|
+
entityKey,
|
|
125
|
+
"detail",
|
|
126
|
+
id,
|
|
127
|
+
{ _org: organizationId }
|
|
128
|
+
] : [
|
|
129
|
+
entityKey,
|
|
130
|
+
"detail",
|
|
131
|
+
id
|
|
132
|
+
],
|
|
113
133
|
custom: (key, ...args) => [
|
|
114
134
|
entityKey,
|
|
115
135
|
key,
|
|
@@ -134,10 +154,16 @@ function createCacheUtils(KEYS) {
|
|
|
134
154
|
getDetail: (client, id) => {
|
|
135
155
|
return client.getQueryData(KEYS.detail(id))?.data;
|
|
136
156
|
},
|
|
137
|
-
removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) })
|
|
157
|
+
removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) }),
|
|
158
|
+
invalidateScopedDetail: (client, id, organizationId) => client.invalidateQueries({ queryKey: KEYS.scopedDetail(id, organizationId) }),
|
|
159
|
+
setScopedDetail: (client, id, organizationId, data) => client.setQueryData(KEYS.scopedDetail(id, organizationId), { data }),
|
|
160
|
+
getScopedDetail: (client, id, organizationId) => {
|
|
161
|
+
return client.getQueryData(KEYS.scopedDetail(id, organizationId))?.data;
|
|
162
|
+
},
|
|
163
|
+
removeScopedDetail: (client, id, organizationId) => client.removeQueries({ queryKey: KEYS.scopedDetail(id, organizationId) })
|
|
138
164
|
};
|
|
139
165
|
}
|
|
140
|
-
function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, select }) {
|
|
166
|
+
function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, itemIdResolver, select }) {
|
|
141
167
|
const queryClient = useQueryClient();
|
|
142
168
|
const query = useQuery({
|
|
143
169
|
queryKey,
|
|
@@ -152,8 +178,9 @@ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefill
|
|
|
152
178
|
const pagination = useMemo(() => normalizePagination(query.data), [query.data]);
|
|
153
179
|
useEffect(() => {
|
|
154
180
|
if (!prefillDetailCache || !detailKeyBuilder || items.length === 0) return;
|
|
181
|
+
const resolveId = itemIdResolver ?? getItemId;
|
|
155
182
|
items.forEach((item) => {
|
|
156
|
-
const id =
|
|
183
|
+
const id = resolveId(item);
|
|
157
184
|
if (id) queryClient.setQueryData(detailKeyBuilder(id), { data: item });
|
|
158
185
|
});
|
|
159
186
|
}, [
|
|
@@ -196,7 +223,7 @@ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, selec
|
|
|
196
223
|
data: query.data
|
|
197
224
|
};
|
|
198
225
|
}
|
|
199
|
-
function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam }) {
|
|
226
|
+
function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam, maxPages }) {
|
|
200
227
|
const query = useInfiniteQuery({
|
|
201
228
|
queryKey,
|
|
202
229
|
queryFn: ({ pageParam, signal }) => queryFn({
|
|
@@ -207,6 +234,7 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
|
|
|
207
234
|
initialPageParam,
|
|
208
235
|
getNextPageParam,
|
|
209
236
|
getPreviousPageParam,
|
|
237
|
+
...maxPages != null ? { maxPages } : {},
|
|
210
238
|
...DEFAULT_QUERY_CONFIG,
|
|
211
239
|
...options
|
|
212
240
|
});
|
|
@@ -227,12 +255,6 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
|
|
|
227
255
|
data: query.data
|
|
228
256
|
};
|
|
229
257
|
}
|
|
230
|
-
/** @deprecated Use `useListQuery` */
|
|
231
|
-
const createListQuery = useListQuery;
|
|
232
|
-
/** @deprecated Use `useDetailQuery` */
|
|
233
|
-
const createDetailQuery = useDetailQuery;
|
|
234
|
-
/** @deprecated Use `useInfiniteListQuery` */
|
|
235
|
-
const createInfiniteListQuery = useInfiniteListQuery;
|
|
236
258
|
|
|
237
259
|
//#endregion
|
|
238
|
-
export { DEFAULT_QUERY_CONFIG,
|
|
260
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
/** Full SSE endpoint URL. When set, overrides `path` and `baseUrl`. Use for non-Arc backends or custom URLs. */
|
|
13
|
+
url?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Resource name for automatic pattern filtering.
|
|
16
|
+
* When set and `patterns` is empty, auto-generates `['resource.*']` filter.
|
|
17
|
+
* Does NOT affect the endpoint URL — use `path` for that.
|
|
18
|
+
*/
|
|
19
|
+
resource?: string;
|
|
20
|
+
/**
|
|
21
|
+
* SSE endpoint path (appended to baseUrl). Default: '/events/stream'.
|
|
22
|
+
* Matches Arc's ssePlugin default. Override if your backend uses a custom path.
|
|
23
|
+
*/
|
|
24
|
+
path?: string;
|
|
25
|
+
/** Event patterns to listen for (e.g., ['agents.created', 'agents.updated']). When empty, all events are received. */
|
|
26
|
+
patterns?: string[];
|
|
27
|
+
/** Query keys to invalidate when any event is received. */
|
|
28
|
+
invalidateQueries?: QueryKey[];
|
|
29
|
+
/** Callback for each event. */
|
|
30
|
+
onEvent?: (event: ArcServerEvent) => void;
|
|
31
|
+
/** Callback for connection state changes. */
|
|
32
|
+
onConnectionChange?: (connected: boolean) => void;
|
|
33
|
+
/** Whether the stream is enabled. Default: true */
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
/** Reconnect delay in ms. Default: 3000 */
|
|
36
|
+
reconnectDelay?: number;
|
|
37
|
+
/** Maximum reconnect attempts before giving up. Default: Infinity */
|
|
38
|
+
maxReconnectAttempts?: number;
|
|
39
|
+
/** Whether to include credentials (cookies). Derived from authMode when not set. */
|
|
40
|
+
withCredentials?: boolean;
|
|
41
|
+
}
|
|
42
|
+
interface EventStreamResult {
|
|
43
|
+
/** Whether the EventSource is currently connected. */
|
|
44
|
+
isConnected: boolean;
|
|
45
|
+
/** The most recently received event. */
|
|
46
|
+
lastEvent: ArcServerEvent | null;
|
|
47
|
+
/** Number of events received since connection. */
|
|
48
|
+
eventCount: number;
|
|
49
|
+
/** Close the connection manually. */
|
|
50
|
+
close: () => void;
|
|
51
|
+
/** Reconnect after a manual close. */
|
|
52
|
+
reconnect: () => void;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe to Arc server-sent events for real-time cache invalidation.
|
|
56
|
+
*
|
|
57
|
+
* Uses the browser's native `EventSource` API for automatic reconnection
|
|
58
|
+
* and efficient server-push. Events trigger query invalidation so TanStack Query
|
|
59
|
+
* refetches affected data automatically.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const { isConnected } = useEventStream({
|
|
63
|
+
* resource: 'agents',
|
|
64
|
+
* invalidateQueries: [agentKeys.lists()],
|
|
65
|
+
* });
|
|
66
|
+
*/
|
|
67
|
+
declare function useEventStream(options: EventStreamOptions): EventStreamResult;
|
|
68
|
+
//#endregion
|
|
69
|
+
export { ArcServerEvent, EventStreamOptions, EventStreamResult, useEventStream };
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { getAuthContext, getAuthMode, getBaseUrl } from "./client.js";
|
|
4
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
|
|
7
|
+
//#region src/sse.ts
|
|
8
|
+
/**
|
|
9
|
+
* Subscribe to Arc server-sent events for real-time cache invalidation.
|
|
10
|
+
*
|
|
11
|
+
* Uses the browser's native `EventSource` API for automatic reconnection
|
|
12
|
+
* and efficient server-push. Events trigger query invalidation so TanStack Query
|
|
13
|
+
* refetches affected data automatically.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const { isConnected } = useEventStream({
|
|
17
|
+
* resource: 'agents',
|
|
18
|
+
* invalidateQueries: [agentKeys.lists()],
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
function useEventStream(options) {
|
|
22
|
+
const { url, resource, path: ssePath = "/events/stream", enabled = true, reconnectDelay = 3e3, maxReconnectAttempts = Infinity, withCredentials } = options;
|
|
23
|
+
const queryClient = useQueryClient();
|
|
24
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
25
|
+
const [lastEvent, setLastEvent] = useState(null);
|
|
26
|
+
const [eventCount, setEventCount] = useState(0);
|
|
27
|
+
const esRef = useRef(null);
|
|
28
|
+
const reconnectAttemptsRef = useRef(0);
|
|
29
|
+
const reconnectTimerRef = useRef(null);
|
|
30
|
+
const manualCloseRef = useRef(false);
|
|
31
|
+
const onEventRef = useRef(options.onEvent);
|
|
32
|
+
onEventRef.current = options.onEvent;
|
|
33
|
+
const onConnectionChangeRef = useRef(options.onConnectionChange);
|
|
34
|
+
onConnectionChangeRef.current = options.onConnectionChange;
|
|
35
|
+
const patternsRef = useRef(options.patterns ?? []);
|
|
36
|
+
patternsRef.current = options.patterns ?? [];
|
|
37
|
+
const invalidateKeysRef = useRef(options.invalidateQueries ?? []);
|
|
38
|
+
invalidateKeysRef.current = options.invalidateQueries ?? [];
|
|
39
|
+
const buildUrl = useCallback(() => {
|
|
40
|
+
if (url) return url;
|
|
41
|
+
const auth = getAuthContext();
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
const patterns = patternsRef.current;
|
|
44
|
+
const effectivePatterns = patterns.length > 0 ? patterns : resource ? [`${resource}.*`] : [];
|
|
45
|
+
if (effectivePatterns.length > 0) params.set("patterns", effectivePatterns.join(","));
|
|
46
|
+
if (auth.organizationId) params.set("organizationId", auth.organizationId);
|
|
47
|
+
if (auth.token) params.set("token", auth.token);
|
|
48
|
+
const qs = params.toString();
|
|
49
|
+
const base = `${getBaseUrl()}${ssePath}`;
|
|
50
|
+
return qs ? `${base}?${qs}` : base;
|
|
51
|
+
}, [
|
|
52
|
+
url,
|
|
53
|
+
resource,
|
|
54
|
+
ssePath
|
|
55
|
+
]);
|
|
56
|
+
const connect = useCallback(() => {
|
|
57
|
+
if (esRef.current) esRef.current.close();
|
|
58
|
+
manualCloseRef.current = false;
|
|
59
|
+
const eventUrl = buildUrl();
|
|
60
|
+
const authMode = getAuthMode();
|
|
61
|
+
const es = new EventSource(eventUrl, { withCredentials: withCredentials ?? authMode === "cookie" });
|
|
62
|
+
esRef.current = es;
|
|
63
|
+
es.onopen = () => {
|
|
64
|
+
reconnectAttemptsRef.current = 0;
|
|
65
|
+
setIsConnected(true);
|
|
66
|
+
onConnectionChangeRef.current?.(true);
|
|
67
|
+
};
|
|
68
|
+
es.onmessage = (event) => {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(event.data);
|
|
71
|
+
const patterns = patternsRef.current;
|
|
72
|
+
if (patterns.length > 0 && !patterns.includes(parsed.type)) return;
|
|
73
|
+
setLastEvent(parsed);
|
|
74
|
+
setEventCount((c) => c + 1);
|
|
75
|
+
onEventRef.current?.(parsed);
|
|
76
|
+
const keys = invalidateKeysRef.current;
|
|
77
|
+
for (const key of keys) queryClient.invalidateQueries({ queryKey: key });
|
|
78
|
+
} catch {}
|
|
79
|
+
};
|
|
80
|
+
es.onerror = () => {
|
|
81
|
+
es.close();
|
|
82
|
+
setIsConnected(false);
|
|
83
|
+
onConnectionChangeRef.current?.(false);
|
|
84
|
+
if (manualCloseRef.current) return;
|
|
85
|
+
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
|
86
|
+
reconnectAttemptsRef.current += 1;
|
|
87
|
+
const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttemptsRef.current - 1), 3e4);
|
|
88
|
+
reconnectTimerRef.current = setTimeout(connect, delay);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}, [
|
|
92
|
+
buildUrl,
|
|
93
|
+
queryClient,
|
|
94
|
+
withCredentials,
|
|
95
|
+
reconnectDelay,
|
|
96
|
+
maxReconnectAttempts
|
|
97
|
+
]);
|
|
98
|
+
const close = useCallback(() => {
|
|
99
|
+
manualCloseRef.current = true;
|
|
100
|
+
if (reconnectTimerRef.current) {
|
|
101
|
+
clearTimeout(reconnectTimerRef.current);
|
|
102
|
+
reconnectTimerRef.current = null;
|
|
103
|
+
}
|
|
104
|
+
if (esRef.current) {
|
|
105
|
+
esRef.current.close();
|
|
106
|
+
esRef.current = null;
|
|
107
|
+
}
|
|
108
|
+
setIsConnected(false);
|
|
109
|
+
onConnectionChangeRef.current?.(false);
|
|
110
|
+
}, []);
|
|
111
|
+
const reconnect = useCallback(() => {
|
|
112
|
+
reconnectAttemptsRef.current = 0;
|
|
113
|
+
manualCloseRef.current = false;
|
|
114
|
+
connect();
|
|
115
|
+
}, [connect]);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!enabled) {
|
|
118
|
+
close();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
connect();
|
|
122
|
+
return () => {
|
|
123
|
+
manualCloseRef.current = true;
|
|
124
|
+
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
|
125
|
+
if (esRef.current) {
|
|
126
|
+
esRef.current.close();
|
|
127
|
+
esRef.current = null;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}, [
|
|
131
|
+
enabled,
|
|
132
|
+
connect,
|
|
133
|
+
close
|
|
134
|
+
]);
|
|
135
|
+
return {
|
|
136
|
+
isConnected,
|
|
137
|
+
lastEvent,
|
|
138
|
+
eventCount,
|
|
139
|
+
close,
|
|
140
|
+
reconnect
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
//#endregion
|
|
145
|
+
export { useEventStream };
|