@classytic/arc-next 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -9
- package/dist/api.d.ts +59 -75
- package/dist/api.js +25 -6
- package/dist/cache.d.ts +48 -4
- package/dist/cache.js +132 -26
- package/dist/client.d.ts +340 -72
- package/dist/client.js +500 -91
- package/dist/hooks.d.ts +72 -8
- package/dist/hooks.js +109 -33
- package/dist/prefetch.d.ts +23 -0
- package/dist/prefetch.js +20 -0
- package/dist/presets/bulk.d.ts +5 -4
- package/dist/presets/search.d.ts +5 -4
- package/dist/presets/slug.d.ts +2 -2
- package/dist/presets/soft-delete.d.ts +4 -3
- package/dist/presets/tree.d.ts +4 -3
- package/dist/query.d.ts +87 -32
- package/dist/query.js +72 -46
- package/dist/sse.js +71 -5
- package/dist/upload.js +33 -2
- package/dist/ws.js +42 -5
- package/package.json +9 -2
package/dist/query.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
|
|
2
|
-
import { InfiniteData, QueryKey } from "@tanstack/react-query";
|
|
2
|
+
import { InfiniteData, QueryClient, QueryKey } from "@tanstack/react-query";
|
|
3
3
|
|
|
4
4
|
//#region src/query.d.ts
|
|
5
5
|
/** Request-level options passed through to the fetch call */
|
|
@@ -17,6 +17,17 @@ interface ListQueryOptions<TData = unknown> {
|
|
|
17
17
|
gcTime?: number;
|
|
18
18
|
refetchOnWindowFocus?: boolean;
|
|
19
19
|
structuralSharing?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* @deprecated No-op since 0.7. The old setQueryData-based prefill was
|
|
22
|
+
* removed because it (1) stored a `{ data: ... }` envelope that didn't
|
|
23
|
+
* match arc 2.13+'s raw doc wire, (2) blocked subsequent detail GETs from
|
|
24
|
+
* firing (fresh staleTime), and (3) clobbered detail responses every
|
|
25
|
+
* render due to an unstable effect dep. `useDetail` / `useDetailBySlug`
|
|
26
|
+
* now read list cache via `placeholderData` — the canonical TanStack
|
|
27
|
+
* pattern — so the GET always fires while consumers see an instant
|
|
28
|
+
* preview. Safe to remove from caller code. Retained for compile-time
|
|
29
|
+
* compatibility; will be deleted in a future major.
|
|
30
|
+
*/
|
|
20
31
|
prefillDetailCache?: boolean;
|
|
21
32
|
refetchInterval?: number | false;
|
|
22
33
|
refetchIntervalInBackground?: boolean;
|
|
@@ -65,6 +76,13 @@ interface DetailQueryResult<T> {
|
|
|
65
76
|
isError: boolean;
|
|
66
77
|
isSuccess: boolean;
|
|
67
78
|
isStale: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* `true` while the detail GET is in flight and the consumer is seeing a
|
|
81
|
+
* preview derived from a list cache entry (or any other `placeholderData`).
|
|
82
|
+
* Use to dim or label the preview, e.g. `<article aria-busy={isPlaceholderData}>`.
|
|
83
|
+
* Flips to `false` once the real detail payload resolves.
|
|
84
|
+
*/
|
|
85
|
+
isPlaceholderData: boolean;
|
|
68
86
|
error: Error | null;
|
|
69
87
|
refetch: () => Promise<unknown>;
|
|
70
88
|
data: unknown;
|
|
@@ -76,23 +94,52 @@ interface CreateListQueryConfig {
|
|
|
76
94
|
}) => Promise<unknown>;
|
|
77
95
|
enabled?: boolean;
|
|
78
96
|
options?: Record<string, unknown>;
|
|
79
|
-
prefillDetailCache?: boolean;
|
|
80
|
-
detailKeyBuilder?: (id: string) => QueryKey;
|
|
81
|
-
/** Custom ID extractor for cache prefill. Falls back to getItemId (_id → id). */
|
|
82
|
-
itemIdResolver?: (item: unknown) => string | null;
|
|
83
97
|
select?: (data: unknown) => unknown;
|
|
84
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* List query hook. Returns the canonical `{ items, pagination, ... }` shape
|
|
101
|
+
* derived from arc's `PaginatedResult` wire envelope.
|
|
102
|
+
*
|
|
103
|
+
* **No detail-cache prefill.** Older revisions of this hook wrote each list
|
|
104
|
+
* item into the detail cache via `setQueryData`. That pattern was wrong on
|
|
105
|
+
* three counts: (1) it stored a `{ data: ... }` envelope that didn't match
|
|
106
|
+
* arc 2.13+'s raw doc wire shape, (2) the fresh-default `staleTime` made
|
|
107
|
+
* subsequent `useDetail` calls reuse the partial list payload and never fetch
|
|
108
|
+
* the rich detail response, and (3) the unstable `detailKeyBuilder` ref made
|
|
109
|
+
* the prefill effect re-run on every render, clobbering successful detail
|
|
110
|
+
* fetches. The clean fix is on the read side: `useDetail` now reads list cache
|
|
111
|
+
* directly via `placeholderData`, so the detail GET still fires while the
|
|
112
|
+
* consumer sees an instant list-shaped preview. See `findItemInListCache`.
|
|
113
|
+
*/
|
|
85
114
|
declare function useListQuery<T>({
|
|
86
115
|
queryKey,
|
|
87
116
|
queryFn,
|
|
88
117
|
enabled,
|
|
89
118
|
options,
|
|
90
|
-
prefillDetailCache,
|
|
91
|
-
detailKeyBuilder,
|
|
92
|
-
itemIdResolver,
|
|
93
119
|
select
|
|
94
120
|
}: CreateListQueryConfig): ListQueryResult<T>;
|
|
95
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Find an item in any list cache for this entity by ID.
|
|
123
|
+
*
|
|
124
|
+
* Walks every `[entity, 'list', ...]` query (including scoped variants and
|
|
125
|
+
* infinite-list page arrays) and extracts items via the permissive list
|
|
126
|
+
* detector. Returns the first match — list payloads are subsets of detail
|
|
127
|
+
* payloads, so the consumer sees an instant preview while the real detail
|
|
128
|
+
* GET runs in the background.
|
|
129
|
+
*
|
|
130
|
+
* This is the canonical TanStack pattern for "show list data while fetching
|
|
131
|
+
* detail": pass the returned value as `placeholderData` to `useDetail`. The
|
|
132
|
+
* value isn't persisted to the detail cache (no pollution), `staleTime`
|
|
133
|
+
* doesn't apply to it (always refetches), and `isPlaceholderData` is `true`
|
|
134
|
+
* until the GET resolves.
|
|
135
|
+
*
|
|
136
|
+
* @param qc TanStack QueryClient (typically from `useQueryClient()`)
|
|
137
|
+
* @param listsKey Prefix key for this entity's lists (e.g. `KEYS.lists()`)
|
|
138
|
+
* @param id Item ID being requested
|
|
139
|
+
* @param idField Optional custom ID field (matches `createCrudHooks({ idField })`)
|
|
140
|
+
*/
|
|
141
|
+
declare function findItemInListCache<T>(qc: QueryClient, listsKey: QueryKey, id: string, idField?: string): T | undefined;
|
|
142
|
+
interface CreateDetailQueryConfig<T = unknown> {
|
|
96
143
|
queryKey: QueryKey;
|
|
97
144
|
queryFn: (context: {
|
|
98
145
|
signal?: AbortSignal;
|
|
@@ -100,14 +147,22 @@ interface CreateDetailQueryConfig {
|
|
|
100
147
|
enabled?: boolean;
|
|
101
148
|
options?: Record<string, unknown>;
|
|
102
149
|
select?: (data: unknown) => unknown;
|
|
150
|
+
/**
|
|
151
|
+
* Lazy placeholder. Returns a value (or `undefined`) on each render to show
|
|
152
|
+
* before the detail GET resolves. Use with `findItemInListCache` to derive
|
|
153
|
+
* an instant preview from list cache without polluting the detail cache —
|
|
154
|
+
* the canonical TanStack pattern for "show list data while fetching detail."
|
|
155
|
+
*/
|
|
156
|
+
placeholderData?: () => T | undefined;
|
|
103
157
|
}
|
|
104
158
|
declare function useDetailQuery<T>({
|
|
105
159
|
queryKey,
|
|
106
160
|
queryFn,
|
|
107
161
|
enabled,
|
|
108
162
|
options,
|
|
109
|
-
select
|
|
110
|
-
|
|
163
|
+
select,
|
|
164
|
+
placeholderData
|
|
165
|
+
}: CreateDetailQueryConfig<T>): DetailQueryResult<T>;
|
|
111
166
|
interface InfiniteListQueryOptions {
|
|
112
167
|
public?: boolean;
|
|
113
168
|
enabled?: boolean;
|
|
@@ -169,13 +224,13 @@ declare function useInfiniteListQuery<T>({
|
|
|
169
224
|
/** Recognized data-freshness presets. Maps to QUERY_CONFIGS. */
|
|
170
225
|
type QueryFreshness = keyof typeof QUERY_CONFIGS;
|
|
171
226
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
227
|
+
* Identity passthrough — arc emits raw data on success (no envelope; HTTP
|
|
228
|
+
* status discriminates errors via thrown `ArcApiError`). Kept as a named
|
|
229
|
+
* type for the `useApiQuery` default param so the public signature stays
|
|
230
|
+
* `useApiQuery<TResponse, TData = ExtractData<TResponse>>` for callers
|
|
231
|
+
* that want a custom select projection.
|
|
174
232
|
*/
|
|
175
|
-
type ExtractData<T> = T
|
|
176
|
-
success: unknown;
|
|
177
|
-
data: infer D;
|
|
178
|
-
} ? D : T;
|
|
233
|
+
type ExtractData<T> = T;
|
|
179
234
|
/** Per-call request pass-through and TanStack Query overrides. */
|
|
180
235
|
interface UseApiQueryOptions {
|
|
181
236
|
staleTime?: number;
|
|
@@ -196,9 +251,9 @@ interface UseApiQueryConfig<TResponse, TData> {
|
|
|
196
251
|
/** Freshness preset name (`realtime` | `frequent` | `stable` | `static`). */
|
|
197
252
|
freshness?: QueryFreshness;
|
|
198
253
|
/**
|
|
199
|
-
* Custom projection from the raw response. When omitted, the
|
|
200
|
-
*
|
|
201
|
-
*
|
|
254
|
+
* Custom projection from the raw response. When omitted, the response is
|
|
255
|
+
* returned unchanged — arc 2.13+ has no wire envelope, so the response
|
|
256
|
+
* already IS the data.
|
|
202
257
|
*/
|
|
203
258
|
select?: (response: TResponse) => TData;
|
|
204
259
|
/** TanStack Query overrides (staleTime, refetchInterval, retry, etc.). */
|
|
@@ -216,29 +271,29 @@ interface UseApiQueryResult<TData> {
|
|
|
216
271
|
}
|
|
217
272
|
/**
|
|
218
273
|
* Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
|
|
219
|
-
* endpoints). Wraps TanStack Query with
|
|
274
|
+
* endpoints). Wraps TanStack Query with two ergonomics on top:
|
|
220
275
|
*
|
|
221
|
-
* 1. **
|
|
222
|
-
* `T` automatically. Override with a custom `select` for shape changes or
|
|
223
|
-
* multi-field projections.
|
|
224
|
-
* 2. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
|
|
276
|
+
* 1. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
|
|
225
277
|
* 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
|
|
226
|
-
*
|
|
278
|
+
* 2. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
|
|
227
279
|
* isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
|
|
228
280
|
*
|
|
281
|
+
* Arc emits raw data on success (no envelope), so the response IS the data.
|
|
282
|
+
* Use `select` for shape transformations / multi-field projections.
|
|
283
|
+
*
|
|
229
284
|
* @example
|
|
230
|
-
* //
|
|
231
|
-
* const { data } = useApiQuery<
|
|
285
|
+
* // Direct typing — response IS the data
|
|
286
|
+
* const { data } = useApiQuery<DashboardStats>({
|
|
232
287
|
* queryKey: ['dashboard', 'stats'],
|
|
233
288
|
* queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
|
|
234
289
|
* freshness: 'realtime',
|
|
235
290
|
* });
|
|
236
291
|
*
|
|
237
|
-
* // Custom projection
|
|
292
|
+
* // Custom projection (e.g. extract a sub-field)
|
|
238
293
|
* const { data } = useApiQuery({
|
|
239
294
|
* queryKey: ['ledger', accountId],
|
|
240
|
-
* queryFn: ({ signal }) => api.request('GET', `/ledger/${accountId}`, { options: { signal } }),
|
|
241
|
-
* select: (res) => res.
|
|
295
|
+
* queryFn: ({ signal }) => api.request<{ entries: Entry[] }>('GET', `/ledger/${accountId}`, { options: { signal } }),
|
|
296
|
+
* select: (res) => res.entries,
|
|
242
297
|
* });
|
|
243
298
|
*/
|
|
244
299
|
declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>>({
|
|
@@ -250,4 +305,4 @@ declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>
|
|
|
250
305
|
options
|
|
251
306
|
}: UseApiQueryConfig<TResponse, TData>): UseApiQueryResult<TData>;
|
|
252
307
|
//#endregion
|
|
253
|
-
export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|
|
308
|
+
export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|
package/dist/query.js
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
|
|
4
|
-
import { keepPreviousData, useInfiniteQuery, useQuery
|
|
5
|
-
import { useCallback,
|
|
4
|
+
import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
6
6
|
|
|
7
7
|
//#region src/query.ts
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* List query hook. Returns the canonical `{ items, pagination, ... }` shape
|
|
10
|
+
* derived from arc's `PaginatedResult` wire envelope.
|
|
11
|
+
*
|
|
12
|
+
* **No detail-cache prefill.** Older revisions of this hook wrote each list
|
|
13
|
+
* item into the detail cache via `setQueryData`. That pattern was wrong on
|
|
14
|
+
* three counts: (1) it stored a `{ data: ... }` envelope that didn't match
|
|
15
|
+
* arc 2.13+'s raw doc wire shape, (2) the fresh-default `staleTime` made
|
|
16
|
+
* subsequent `useDetail` calls reuse the partial list payload and never fetch
|
|
17
|
+
* the rich detail response, and (3) the unstable `detailKeyBuilder` ref made
|
|
18
|
+
* the prefill effect re-run on every render, clobbering successful detail
|
|
19
|
+
* fetches. The clean fix is on the read side: `useDetail` now reads list cache
|
|
20
|
+
* directly via `placeholderData`, so the detail GET still fires while the
|
|
21
|
+
* consumer sees an instant list-shaped preview. See `findItemInListCache`.
|
|
22
|
+
*/
|
|
23
|
+
function useListQuery({ queryKey, queryFn, enabled = true, options = {}, select }) {
|
|
10
24
|
const query = useQuery({
|
|
11
25
|
queryKey,
|
|
12
26
|
queryFn: ({ signal }) => queryFn({ signal }),
|
|
@@ -16,24 +30,9 @@ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefill
|
|
|
16
30
|
...select ? { select } : {},
|
|
17
31
|
placeholderData: keepPreviousData
|
|
18
32
|
});
|
|
19
|
-
const items = useMemo(() => extractItems(query.data), [query.data]);
|
|
20
|
-
const pagination = useMemo(() => normalizePagination(query.data), [query.data]);
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
if (!prefillDetailCache || !detailKeyBuilder || items.length === 0) return;
|
|
23
|
-
const resolveId = itemIdResolver ?? getItemId;
|
|
24
|
-
items.forEach((item) => {
|
|
25
|
-
const id = resolveId(item);
|
|
26
|
-
if (id) queryClient.setQueryData(detailKeyBuilder(id), { data: item });
|
|
27
|
-
});
|
|
28
|
-
}, [
|
|
29
|
-
items,
|
|
30
|
-
prefillDetailCache,
|
|
31
|
-
detailKeyBuilder,
|
|
32
|
-
queryClient
|
|
33
|
-
]);
|
|
34
33
|
return {
|
|
35
|
-
items,
|
|
36
|
-
pagination,
|
|
34
|
+
items: useMemo(() => extractItems(query.data), [query.data]),
|
|
35
|
+
pagination: useMemo(() => normalizePagination(query.data), [query.data]),
|
|
37
36
|
isLoading: query.isLoading,
|
|
38
37
|
isFetching: query.isFetching,
|
|
39
38
|
isError: query.isError,
|
|
@@ -44,14 +43,50 @@ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefill
|
|
|
44
43
|
data: query.data
|
|
45
44
|
};
|
|
46
45
|
}
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Find an item in any list cache for this entity by ID.
|
|
48
|
+
*
|
|
49
|
+
* Walks every `[entity, 'list', ...]` query (including scoped variants and
|
|
50
|
+
* infinite-list page arrays) and extracts items via the permissive list
|
|
51
|
+
* detector. Returns the first match — list payloads are subsets of detail
|
|
52
|
+
* payloads, so the consumer sees an instant preview while the real detail
|
|
53
|
+
* GET runs in the background.
|
|
54
|
+
*
|
|
55
|
+
* This is the canonical TanStack pattern for "show list data while fetching
|
|
56
|
+
* detail": pass the returned value as `placeholderData` to `useDetail`. The
|
|
57
|
+
* value isn't persisted to the detail cache (no pollution), `staleTime`
|
|
58
|
+
* doesn't apply to it (always refetches), and `isPlaceholderData` is `true`
|
|
59
|
+
* until the GET resolves.
|
|
60
|
+
*
|
|
61
|
+
* @param qc TanStack QueryClient (typically from `useQueryClient()`)
|
|
62
|
+
* @param listsKey Prefix key for this entity's lists (e.g. `KEYS.lists()`)
|
|
63
|
+
* @param id Item ID being requested
|
|
64
|
+
* @param idField Optional custom ID field (matches `createCrudHooks({ idField })`)
|
|
65
|
+
*/
|
|
66
|
+
function findItemInListCache(qc, listsKey, id, idField) {
|
|
67
|
+
if (!id) return void 0;
|
|
68
|
+
const entries = qc.getQueriesData({ queryKey: listsKey });
|
|
69
|
+
for (const [, raw] of entries) {
|
|
70
|
+
if (!raw) continue;
|
|
71
|
+
const pages = typeof raw === "object" && raw !== null && Array.isArray(raw.pages) ? raw.pages : [raw];
|
|
72
|
+
for (const page of pages) {
|
|
73
|
+
const items = extractItems(page);
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
const got = idField ? item?.[idField] : getItemId(item);
|
|
76
|
+
if (got != null && String(got) === id) return item;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, select, placeholderData }) {
|
|
48
82
|
const query = useQuery({
|
|
49
83
|
queryKey,
|
|
50
84
|
queryFn: ({ signal }) => queryFn({ signal }),
|
|
51
85
|
enabled,
|
|
52
86
|
...DEFAULT_QUERY_CONFIG,
|
|
53
87
|
...options,
|
|
54
|
-
...select ? { select } : {}
|
|
88
|
+
...select ? { select } : {},
|
|
89
|
+
...placeholderData ? { placeholderData } : {}
|
|
55
90
|
});
|
|
56
91
|
return {
|
|
57
92
|
item: extractItem(query.data),
|
|
@@ -60,6 +95,7 @@ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, selec
|
|
|
60
95
|
isError: query.isError,
|
|
61
96
|
isSuccess: query.isSuccess,
|
|
62
97
|
isStale: query.isStale,
|
|
98
|
+
isPlaceholderData: query.isPlaceholderData,
|
|
63
99
|
error: query.error,
|
|
64
100
|
refetch: query.refetch,
|
|
65
101
|
data: query.data
|
|
@@ -98,40 +134,30 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
|
|
|
98
134
|
};
|
|
99
135
|
}
|
|
100
136
|
/**
|
|
101
|
-
* Strict Arc envelope detector — requires BOTH `success` and `data` keys.
|
|
102
|
-
*
|
|
103
|
-
* Stricter than checking only `data` so we don't mis-unwrap responses that happen
|
|
104
|
-
* to use a `data` field for unrelated semantics (e.g. a chart that returns
|
|
105
|
-
* `{ data: number[], labels: [] }`).
|
|
106
|
-
*/
|
|
107
|
-
function isArcEnvelope(value) {
|
|
108
|
-
return typeof value === "object" && value !== null && "success" in value && "data" in value;
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
137
|
* Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
|
|
112
|
-
* endpoints). Wraps TanStack Query with
|
|
138
|
+
* endpoints). Wraps TanStack Query with two ergonomics on top:
|
|
113
139
|
*
|
|
114
|
-
* 1. **
|
|
115
|
-
* `T` automatically. Override with a custom `select` for shape changes or
|
|
116
|
-
* multi-field projections.
|
|
117
|
-
* 2. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
|
|
140
|
+
* 1. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
|
|
118
141
|
* 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
|
|
119
|
-
*
|
|
142
|
+
* 2. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
|
|
120
143
|
* isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
|
|
121
144
|
*
|
|
145
|
+
* Arc emits raw data on success (no envelope), so the response IS the data.
|
|
146
|
+
* Use `select` for shape transformations / multi-field projections.
|
|
147
|
+
*
|
|
122
148
|
* @example
|
|
123
|
-
* //
|
|
124
|
-
* const { data } = useApiQuery<
|
|
149
|
+
* // Direct typing — response IS the data
|
|
150
|
+
* const { data } = useApiQuery<DashboardStats>({
|
|
125
151
|
* queryKey: ['dashboard', 'stats'],
|
|
126
152
|
* queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
|
|
127
153
|
* freshness: 'realtime',
|
|
128
154
|
* });
|
|
129
155
|
*
|
|
130
|
-
* // Custom projection
|
|
156
|
+
* // Custom projection (e.g. extract a sub-field)
|
|
131
157
|
* const { data } = useApiQuery({
|
|
132
158
|
* queryKey: ['ledger', accountId],
|
|
133
|
-
* queryFn: ({ signal }) => api.request('GET', `/ledger/${accountId}`, { options: { signal } }),
|
|
134
|
-
* select: (res) => res.
|
|
159
|
+
* queryFn: ({ signal }) => api.request<{ entries: Entry[] }>('GET', `/ledger/${accountId}`, { options: { signal } }),
|
|
160
|
+
* select: (res) => res.entries,
|
|
135
161
|
* });
|
|
136
162
|
*/
|
|
137
163
|
function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, options = {} }) {
|
|
@@ -140,7 +166,7 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
|
|
|
140
166
|
const projection = useCallback((response) => {
|
|
141
167
|
const fn = selectRef.current;
|
|
142
168
|
if (fn) return fn(response);
|
|
143
|
-
return
|
|
169
|
+
return response;
|
|
144
170
|
}, []);
|
|
145
171
|
const preset = freshness ? QUERY_CONFIGS[freshness] : void 0;
|
|
146
172
|
const query = useQuery({
|
|
@@ -165,4 +191,4 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
|
|
|
165
191
|
}
|
|
166
192
|
|
|
167
193
|
//#endregion
|
|
168
|
-
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|
|
194
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|
package/dist/sse.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { buildStreamUrl, getAuthMode } from "./client.js";
|
|
3
|
+
import { ArcApiError, _getAuthErrorHandler, _runAuthRecovery, buildStreamUrl, getAuthMode } from "./client.js";
|
|
4
4
|
import { useQueryClient } from "@tanstack/react-query";
|
|
5
5
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
|
|
@@ -21,6 +21,35 @@ function buildSseUrl(path, params = {}) {
|
|
|
21
21
|
return buildStreamUrl(path, params, "http");
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
+
* Send a minimal HEAD/GET probe to the SSE URL to classify an EventSource
|
|
25
|
+
* failure as either an auth failure (401, or 403 when `retryOn403`) or
|
|
26
|
+
* something transient (network, 5xx, CORS). EventSource itself doesn't
|
|
27
|
+
* expose status codes — this is the only cross-browser way to route SSE
|
|
28
|
+
* close events through `onAuthError`.
|
|
29
|
+
*
|
|
30
|
+
* The probe uses HEAD when supported (cheaper); falls back to GET with
|
|
31
|
+
* `Range: bytes=0-0` for servers that 405 on HEAD. Either way, the body
|
|
32
|
+
* is never read — only the status code matters.
|
|
33
|
+
*/
|
|
34
|
+
async function probeForAuthFailure(url, retryOn403) {
|
|
35
|
+
try {
|
|
36
|
+
let res = await fetch(url, {
|
|
37
|
+
method: "HEAD",
|
|
38
|
+
credentials: "include"
|
|
39
|
+
});
|
|
40
|
+
if (res.status === 405) res = await fetch(url, {
|
|
41
|
+
method: "GET",
|
|
42
|
+
credentials: "include",
|
|
43
|
+
headers: { Range: "bytes=0-0" }
|
|
44
|
+
});
|
|
45
|
+
if (res.status === 401) return "auth-failure";
|
|
46
|
+
if (retryOn403 && res.status === 403) return "auth-failure";
|
|
47
|
+
return "not-auth";
|
|
48
|
+
} catch {
|
|
49
|
+
return "not-auth";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
24
53
|
* Subscribe to an Arc SSE stream from any JS context (React, Node, Bun, tests).
|
|
25
54
|
* Pure function — no React hook required. Returns a handle with `close()` /
|
|
26
55
|
* `reconnect()` / `isConnected()`.
|
|
@@ -68,6 +97,7 @@ function subscribeToEvents(options) {
|
|
|
68
97
|
es = new EventSource(buildUrl(), { withCredentials: credentials });
|
|
69
98
|
es.onopen = () => {
|
|
70
99
|
reconnectAttempts = 0;
|
|
100
|
+
sseAuthRetries = 0;
|
|
71
101
|
connected = true;
|
|
72
102
|
options.onConnectionChange?.(true);
|
|
73
103
|
};
|
|
@@ -98,13 +128,49 @@ function subscribeToEvents(options) {
|
|
|
98
128
|
connected = false;
|
|
99
129
|
options.onConnectionChange?.(false);
|
|
100
130
|
if (manualClose) return;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
131
|
+
const { handler, retryOn403, maxAuthRetries } = _getAuthErrorHandler();
|
|
132
|
+
if (handler && sseAuthRetries < maxAuthRetries) {
|
|
133
|
+
sseAuthRetries += 1;
|
|
134
|
+
probeForAuthFailure(buildUrl(), retryOn403).then(async (status) => {
|
|
135
|
+
if (status === "auth-failure") {
|
|
136
|
+
const { decision } = await _runAuthRecovery(handler, {
|
|
137
|
+
error: new ArcApiError("SSE pre-flight auth failure", {
|
|
138
|
+
status: 401,
|
|
139
|
+
statusText: "SSE auth failure",
|
|
140
|
+
json: { code: "arc.sse.unauthorized" },
|
|
141
|
+
endpoint: ssePath,
|
|
142
|
+
method: "GET"
|
|
143
|
+
}),
|
|
144
|
+
request: {
|
|
145
|
+
method: "GET",
|
|
146
|
+
endpoint: ssePath
|
|
147
|
+
},
|
|
148
|
+
attempt: sseAuthRetries
|
|
149
|
+
});
|
|
150
|
+
if (decision === "retry") {
|
|
151
|
+
reconnectAttempts = 0;
|
|
152
|
+
connect();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
scheduleReconnect();
|
|
157
|
+
}).catch(() => {
|
|
158
|
+
scheduleReconnect();
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
105
161
|
}
|
|
162
|
+
scheduleReconnect();
|
|
106
163
|
};
|
|
107
164
|
};
|
|
165
|
+
/** Standard backoff-reconnect — shared between non-auth errors and skipped recoveries. */
|
|
166
|
+
const scheduleReconnect = () => {
|
|
167
|
+
if (reconnectAttempts < maxReconnectAttempts) {
|
|
168
|
+
reconnectAttempts += 1;
|
|
169
|
+
const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
|
|
170
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
let sseAuthRetries = 0;
|
|
108
174
|
connect();
|
|
109
175
|
return {
|
|
110
176
|
close: () => {
|
package/dist/upload.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArcApiError, getAuthMode, getBaseUrl, getClientAuthContext } from "./client.js";
|
|
1
|
+
import { ArcApiError, _getAuthErrorHandler, _isAuthRecoverable, _resolveRefreshedToken, _runAuthRecovery, getAuthMode, getBaseUrl, getClientAuthContext } from "./client.js";
|
|
2
2
|
import { getToastHandler } from "./mutation.js";
|
|
3
3
|
import { useQueryClient } from "@tanstack/react-query";
|
|
4
4
|
import { useCallback, useRef, useState } from "react";
|
|
@@ -78,7 +78,38 @@ import { useCallback, useRef, useState } from "react";
|
|
|
78
78
|
* onProgress: ({ percent }) => setUiProgress(percent),
|
|
79
79
|
* });
|
|
80
80
|
*/
|
|
81
|
-
function uploadWithProgress(options) {
|
|
81
|
+
async function uploadWithProgress(options) {
|
|
82
|
+
const { handler, retryOn403, maxAuthRetries } = _getAuthErrorHandler();
|
|
83
|
+
if (!handler) return uploadAttempt(options);
|
|
84
|
+
let currentOptions = options;
|
|
85
|
+
for (let attempt = 0; attempt <= maxAuthRetries; attempt++) try {
|
|
86
|
+
return await uploadAttempt(currentOptions);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (attempt >= maxAuthRetries || !_isAuthRecoverable(error, retryOn403)) throw error;
|
|
89
|
+
if (currentOptions.signal?.aborted) throw error;
|
|
90
|
+
const { decision, overrideToken } = await _runAuthRecovery(handler, {
|
|
91
|
+
error,
|
|
92
|
+
request: {
|
|
93
|
+
method: currentOptions.method ?? "POST",
|
|
94
|
+
endpoint: currentOptions.url
|
|
95
|
+
},
|
|
96
|
+
attempt: attempt + 1
|
|
97
|
+
});
|
|
98
|
+
if (decision !== "retry") throw error;
|
|
99
|
+
currentOptions = {
|
|
100
|
+
...currentOptions,
|
|
101
|
+
token: _resolveRefreshedToken(overrideToken)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
throw new Error("arc-next: upload auth retry loop terminated without resolution");
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Single XHR attempt. Identical to the pre-0.7 body of `uploadWithProgress`
|
|
108
|
+
* — extracted so the auth-retry loop above can re-run it with a refreshed
|
|
109
|
+
* token without duplicating the XHR setup. All auth-recovery semantics live
|
|
110
|
+
* in the outer loop; this function just performs one upload.
|
|
111
|
+
*/
|
|
112
|
+
function uploadAttempt(options) {
|
|
82
113
|
const { url, formData, method = "POST", onProgress, signal, client, headers: extraHeaders, elevated, idempotencyKey, responseType = "json" } = options;
|
|
83
114
|
return new Promise((resolve, reject) => {
|
|
84
115
|
if (signal?.aborted) {
|
package/dist/ws.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { buildStreamUrl } from "./client.js";
|
|
3
|
+
import { ArcApiError, _getAuthErrorHandler, _runAuthRecovery, buildStreamUrl } from "./client.js";
|
|
4
4
|
import { useQueryClient } from "@tanstack/react-query";
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
|
|
@@ -69,9 +69,15 @@ function connectWs(options = {}) {
|
|
|
69
69
|
if (wildcard) for (const fn of wildcard) fn(message);
|
|
70
70
|
};
|
|
71
71
|
const connect = () => {
|
|
72
|
-
if (ws)
|
|
73
|
-
ws.
|
|
74
|
-
|
|
72
|
+
if (ws) {
|
|
73
|
+
ws.onclose = null;
|
|
74
|
+
ws.onerror = null;
|
|
75
|
+
ws.onmessage = null;
|
|
76
|
+
ws.onopen = null;
|
|
77
|
+
try {
|
|
78
|
+
ws.close();
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
75
81
|
if (heartbeatTimer) {
|
|
76
82
|
clearInterval(heartbeatTimer);
|
|
77
83
|
heartbeatTimer = null;
|
|
@@ -81,6 +87,7 @@ function connectWs(options = {}) {
|
|
|
81
87
|
ws = protocols !== void 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl);
|
|
82
88
|
ws.onopen = () => {
|
|
83
89
|
reconnectAttempts = 0;
|
|
90
|
+
wsAuthRetries = 0;
|
|
84
91
|
connected = true;
|
|
85
92
|
options.onConnectionChange?.(true);
|
|
86
93
|
for (const resource of subscriptions) sendRaw({
|
|
@@ -104,7 +111,7 @@ function connectWs(options = {}) {
|
|
|
104
111
|
dispatch(parsed);
|
|
105
112
|
};
|
|
106
113
|
ws.onerror = () => {};
|
|
107
|
-
ws.onclose = () => {
|
|
114
|
+
ws.onclose = (event) => {
|
|
108
115
|
connected = false;
|
|
109
116
|
options.onConnectionChange?.(false);
|
|
110
117
|
if (heartbeatTimer) {
|
|
@@ -112,6 +119,35 @@ function connectWs(options = {}) {
|
|
|
112
119
|
heartbeatTimer = null;
|
|
113
120
|
}
|
|
114
121
|
if (manualClose) return;
|
|
122
|
+
const { handler, maxAuthRetries } = _getAuthErrorHandler();
|
|
123
|
+
const isAuthClose = event.code === 1008 || event.code === 3401 || event.code === 4001 || event.code === 4401;
|
|
124
|
+
if (handler && isAuthClose && wsAuthRetries < maxAuthRetries) {
|
|
125
|
+
wsAuthRetries += 1;
|
|
126
|
+
_runAuthRecovery(handler, {
|
|
127
|
+
error: new ArcApiError(event.reason || `WebSocket closed with auth code ${event.code}`, {
|
|
128
|
+
status: 401,
|
|
129
|
+
statusText: "WebSocket auth failure",
|
|
130
|
+
json: {
|
|
131
|
+
code: "arc.websocket.unauthorized",
|
|
132
|
+
wsCloseCode: event.code,
|
|
133
|
+
reason: event.reason
|
|
134
|
+
},
|
|
135
|
+
endpoint: url ?? path,
|
|
136
|
+
method: "GET"
|
|
137
|
+
}),
|
|
138
|
+
request: {
|
|
139
|
+
method: "GET",
|
|
140
|
+
endpoint: url ?? path
|
|
141
|
+
},
|
|
142
|
+
attempt: wsAuthRetries
|
|
143
|
+
}).then(({ decision }) => {
|
|
144
|
+
if (decision === "retry") {
|
|
145
|
+
reconnectAttempts = 0;
|
|
146
|
+
connect();
|
|
147
|
+
}
|
|
148
|
+
}).catch(() => {});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
115
151
|
if (reconnectAttempts < maxReconnectAttempts) {
|
|
116
152
|
reconnectAttempts += 1;
|
|
117
153
|
const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
|
|
@@ -119,6 +155,7 @@ function connectWs(options = {}) {
|
|
|
119
155
|
}
|
|
120
156
|
};
|
|
121
157
|
};
|
|
158
|
+
let wsAuthRetries = 0;
|
|
122
159
|
connect();
|
|
123
160
|
return {
|
|
124
161
|
isConnected: () => connected,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "React + TanStack Query SDK for Arc resources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -122,11 +122,18 @@
|
|
|
122
122
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
123
123
|
},
|
|
124
124
|
"peerDependencies": {
|
|
125
|
-
"@
|
|
125
|
+
"@classytic/repo-core": ">=0.4.0",
|
|
126
|
+
"@tanstack/react-query": ">=5.62.0",
|
|
126
127
|
"react": ">=19.0.0"
|
|
127
128
|
},
|
|
129
|
+
"peerDependenciesMeta": {
|
|
130
|
+
"react": { "optional": false },
|
|
131
|
+
"@tanstack/react-query": { "optional": false },
|
|
132
|
+
"@classytic/repo-core": { "optional": false }
|
|
133
|
+
},
|
|
128
134
|
"devDependencies": {
|
|
129
135
|
"@classytic/dev-tools": "^0.2.0",
|
|
136
|
+
"@classytic/repo-core": "^0.5.0",
|
|
130
137
|
"@tanstack/react-query": "^5.97.0",
|
|
131
138
|
"@testing-library/jest-dom": "^6.9.1",
|
|
132
139
|
"@testing-library/react": "^16.3.2",
|