@classytic/arc-next 0.6.0 → 0.7.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 +141 -4
- package/dist/cache.d.ts +28 -1
- package/dist/cache.js +101 -9
- package/dist/client.d.ts +277 -4
- package/dist/client.js +427 -18
- package/dist/hooks.js +56 -26
- package/dist/query.d.ts +101 -14
- package/dist/query.js +62 -28
- package/dist/sse.js +71 -5
- package/dist/upload.js +33 -2
- package/dist/ws.js +42 -5
- package/package.json +8 -3
package/dist/hooks.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { getAuthMode, getClientAuthContext } from "./client.js";
|
|
3
|
+
import { getAuthMode, getClientAuthContext, hasGlobalStaticAuth } from "./client.js";
|
|
4
4
|
import { isKeysetPagination, isOffsetPagination } from "./api.js";
|
|
5
|
-
import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache } from "./cache.js";
|
|
6
|
-
import { useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
|
|
5
|
+
import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, syncDetailToLists, updateListCache } from "./cache.js";
|
|
6
|
+
import { findItemInListCache, useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
|
|
7
7
|
import { useMutationWithTransition, useOptimisticMutation } from "./mutation.js";
|
|
8
8
|
import { subscribeToEvents } from "./sse.js";
|
|
9
9
|
import { connectWs } from "./ws.js";
|
|
@@ -33,8 +33,19 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
33
33
|
const pluralName = plural ?? `${singular}s`;
|
|
34
34
|
/** Resolve auth context — per-client auth takes priority over global */
|
|
35
35
|
const resolveAuth = () => getClientAuthContext(client);
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Whether auth is provided via static config — no per-request token needed.
|
|
38
|
+
* Sources (in priority order):
|
|
39
|
+
* 1. Per-client `defaultHeaders` / `internalApiKey` (when `client` is passed in).
|
|
40
|
+
* 2. Per-client custom auth (e.g. `createClient({ getToken })`).
|
|
41
|
+
* 3. Global `configureClient({ internalApiKey | defaultHeaders | authMode: 'cookie' })`.
|
|
42
|
+
*
|
|
43
|
+
* Resolved lazily on every render so that an app calling `configureClient`
|
|
44
|
+
* inside a "use client" provider after factory creation still picks up the
|
|
45
|
+
* global static-auth signal — previously a global `internalApiKey` was
|
|
46
|
+
* ignored, leaving every protected query stuck in a permanently-disabled state.
|
|
47
|
+
*/
|
|
48
|
+
const resolveHasStaticAuth = () => !!(client?.config?.defaultHeaders || client?.config?.internalApiKey || client?.auth) || hasGlobalStaticAuth();
|
|
38
49
|
/** Extract ID from an item using configured idField, falling back to _id → id */
|
|
39
50
|
function resolveItemId(item) {
|
|
40
51
|
if (!item || typeof item !== "object") return null;
|
|
@@ -99,7 +110,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
99
110
|
...requestOpts
|
|
100
111
|
}
|
|
101
112
|
}),
|
|
102
|
-
enabled: createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
113
|
+
enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
103
114
|
options: {
|
|
104
115
|
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
105
116
|
gcTime: queryOpts.gcTime ?? config.gcTime,
|
|
@@ -108,9 +119,6 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
108
119
|
refetchInterval: queryOpts.refetchInterval,
|
|
109
120
|
refetchIntervalInBackground: queryOpts.refetchIntervalInBackground
|
|
110
121
|
},
|
|
111
|
-
prefillDetailCache: queryOpts.prefillDetailCache ?? true,
|
|
112
|
-
detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
|
|
113
|
-
itemIdResolver: resolveItemId,
|
|
114
122
|
select: queryOpts.select
|
|
115
123
|
});
|
|
116
124
|
}
|
|
@@ -131,8 +139,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
131
139
|
}
|
|
132
140
|
const { organizationId, params: queryParams, request: requestOpts, ...restOptions } = options;
|
|
133
141
|
const detailKey = KEYS.scopedDetail(id || "", organizationId ?? null);
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
const fullDetailKey = queryParams ? [...detailKey, queryParams] : detailKey;
|
|
143
|
+
const queryClient = useQueryClient();
|
|
144
|
+
const listPlaceholder = useCallback(() => id ? findItemInListCache(queryClient, KEYS.lists(), id, idField) : void 0, [queryClient, id]);
|
|
145
|
+
const detailResult = useDetailQuery({
|
|
146
|
+
queryKey: fullDetailKey,
|
|
136
147
|
queryFn: ({ signal }) => api.getById({
|
|
137
148
|
id,
|
|
138
149
|
token,
|
|
@@ -143,7 +154,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
143
154
|
...requestOpts
|
|
144
155
|
}
|
|
145
156
|
}),
|
|
146
|
-
enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(),
|
|
157
|
+
enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
|
|
147
158
|
options: {
|
|
148
159
|
staleTime: restOptions.staleTime ?? config.staleTime,
|
|
149
160
|
gcTime: restOptions.gcTime ?? config.gcTime,
|
|
@@ -152,8 +163,18 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
152
163
|
refetchInterval: restOptions.refetchInterval,
|
|
153
164
|
refetchIntervalInBackground: restOptions.refetchIntervalInBackground
|
|
154
165
|
},
|
|
155
|
-
select: restOptions.select
|
|
166
|
+
select: restOptions.select,
|
|
167
|
+
placeholderData: listPlaceholder
|
|
156
168
|
});
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!detailResult.item || detailResult.isPlaceholderData) return;
|
|
171
|
+
syncDetailToLists(queryClient, KEYS.lists(), detailResult.item, idField ? { idField } : {});
|
|
172
|
+
}, [
|
|
173
|
+
detailResult.item,
|
|
174
|
+
detailResult.isPlaceholderData,
|
|
175
|
+
queryClient
|
|
176
|
+
]);
|
|
177
|
+
return detailResult;
|
|
157
178
|
}
|
|
158
179
|
function useActions() {
|
|
159
180
|
const queryClient = useQueryClient();
|
|
@@ -413,7 +434,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
413
434
|
}
|
|
414
435
|
});
|
|
415
436
|
},
|
|
416
|
-
enabled: createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
437
|
+
enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
417
438
|
initialPageParam: restParams.after ? restParams.after : 1,
|
|
418
439
|
getNextPageParam: (lastPage) => {
|
|
419
440
|
const page = lastPage;
|
|
@@ -498,7 +519,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
498
519
|
}
|
|
499
520
|
});
|
|
500
521
|
},
|
|
501
|
-
enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
522
|
+
enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
502
523
|
options: {
|
|
503
524
|
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
504
525
|
gcTime: queryOpts.gcTime ?? config.gcTime
|
|
@@ -512,7 +533,9 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
512
533
|
const resolvedOptions = options ?? {};
|
|
513
534
|
const organizationId = resolvedOptions.organizationId ?? auth.organizationId;
|
|
514
535
|
const { params: queryParams, request: requestOpts, ...restOptions } = resolvedOptions;
|
|
515
|
-
|
|
536
|
+
const queryClient = useQueryClient();
|
|
537
|
+
const listPlaceholder = useCallback(() => slug ? findItemInListCache(queryClient, KEYS.lists(), slug, "slug") : void 0, [queryClient, slug]);
|
|
538
|
+
const slugResult = useDetailQuery({
|
|
516
539
|
queryKey: queryParams ? KEYS.custom("slug", slug, queryParams) : KEYS.custom("slug", slug),
|
|
517
540
|
queryFn: ({ signal }) => {
|
|
518
541
|
if (!api.getBySlug) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a getBySlug method`));
|
|
@@ -527,15 +550,25 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
527
550
|
}
|
|
528
551
|
});
|
|
529
552
|
},
|
|
530
|
-
enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(),
|
|
553
|
+
enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
|
|
531
554
|
options: {
|
|
532
555
|
staleTime: restOptions.staleTime ?? config.staleTime,
|
|
533
556
|
gcTime: restOptions.gcTime ?? config.gcTime,
|
|
534
557
|
refetchOnWindowFocus: restOptions.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
|
|
535
558
|
structuralSharing: restOptions.structuralSharing ?? config.structuralSharing
|
|
536
559
|
},
|
|
537
|
-
select: restOptions.select
|
|
560
|
+
select: restOptions.select,
|
|
561
|
+
placeholderData: listPlaceholder
|
|
538
562
|
});
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
if (!slugResult.item || slugResult.isPlaceholderData) return;
|
|
565
|
+
syncDetailToLists(queryClient, KEYS.lists(), slugResult.item, idField ? { idField } : {});
|
|
566
|
+
}, [
|
|
567
|
+
slugResult.item,
|
|
568
|
+
slugResult.isPlaceholderData,
|
|
569
|
+
queryClient
|
|
570
|
+
]);
|
|
571
|
+
return slugResult;
|
|
539
572
|
}
|
|
540
573
|
function useTree(params, options) {
|
|
541
574
|
const auth = resolveAuth();
|
|
@@ -561,7 +594,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
561
594
|
}
|
|
562
595
|
});
|
|
563
596
|
},
|
|
564
|
-
enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
597
|
+
enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
565
598
|
options: {
|
|
566
599
|
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
567
600
|
gcTime: queryOpts.gcTime ?? config.gcTime
|
|
@@ -594,14 +627,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
594
627
|
}
|
|
595
628
|
});
|
|
596
629
|
},
|
|
597
|
-
enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
630
|
+
enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
598
631
|
options: {
|
|
599
632
|
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
600
633
|
gcTime: queryOpts.gcTime ?? config.gcTime
|
|
601
634
|
},
|
|
602
|
-
prefillDetailCache: queryOpts.prefillDetailCache ?? true,
|
|
603
|
-
detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
|
|
604
|
-
itemIdResolver: resolveItemId,
|
|
605
635
|
select: queryOpts.select
|
|
606
636
|
});
|
|
607
637
|
}
|
|
@@ -806,7 +836,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
806
836
|
enabled: !!name && !!api.aggregate && createEnabledRule(auth.token, {
|
|
807
837
|
public: isPublic,
|
|
808
838
|
enabled
|
|
809
|
-
}, resolveAuthMode(),
|
|
839
|
+
}, resolveAuthMode(), resolveHasStaticAuth()),
|
|
810
840
|
staleTime,
|
|
811
841
|
gcTime,
|
|
812
842
|
refetchOnWindowFocus,
|
|
@@ -912,8 +942,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
912
942
|
const id = resolveItemId(item);
|
|
913
943
|
if (id) {
|
|
914
944
|
const orgId = resolveAuth().organizationId;
|
|
915
|
-
queryClient.setQueryData(KEYS.scopedDetail(id, orgId),
|
|
916
|
-
if (orgId) queryClient.setQueryData(KEYS.detail(id),
|
|
945
|
+
queryClient.setQueryData(KEYS.scopedDetail(id, orgId), item);
|
|
946
|
+
if (orgId) queryClient.setQueryData(KEYS.detail(id), item);
|
|
917
947
|
}
|
|
918
948
|
if (!router) return;
|
|
919
949
|
const { scroll = true, replace = false } = options;
|
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;
|
|
@@ -46,8 +57,28 @@ interface DetailQueryOptions<TData = unknown> {
|
|
|
46
57
|
/** Pass-through options for the underlying fetch request (cache, revalidate, tags, headers) */
|
|
47
58
|
request?: RequestPassthrough;
|
|
48
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Result of `useList` (and `useListQuery`). Matches the same typed-end-to-end
|
|
62
|
+
* shape `repo-core` uses for its server-side result interfaces (`AggResult`,
|
|
63
|
+
* `OffsetPaginationResult`, etc.) — no raw-cache escape hatch on the public
|
|
64
|
+
* surface, just the typed extracted view.
|
|
65
|
+
*
|
|
66
|
+
* Need the raw cache entry? Use `useQueryClient().getQueryData(KEYS.list(...))`
|
|
67
|
+
* — it's typed against whatever `setQueryData` last wrote, and it makes the
|
|
68
|
+
* "I'm reaching past the SDK" intent explicit.
|
|
69
|
+
*/
|
|
49
70
|
interface ListQueryResult<T> {
|
|
71
|
+
/**
|
|
72
|
+
* Extracted items array — `T[]`. Always typed, always flat (infinite-list
|
|
73
|
+
* pages are pre-flattened), always consistent across response-shape
|
|
74
|
+
* variants (`data` / `items` / `results` / any-array fallback).
|
|
75
|
+
*/
|
|
50
76
|
items: T[];
|
|
77
|
+
/**
|
|
78
|
+
* Normalized pagination snapshot — offset / keyset / aggregate all map
|
|
79
|
+
* onto the same `{ method, total, pages, page, limit, hasNext, hasPrev, next? }`
|
|
80
|
+
* shape. `null` when the response carries no pagination signal.
|
|
81
|
+
*/
|
|
51
82
|
pagination: PaginationData | null;
|
|
52
83
|
isLoading: boolean;
|
|
53
84
|
isFetching: boolean;
|
|
@@ -56,18 +87,37 @@ interface ListQueryResult<T> {
|
|
|
56
87
|
isStale: boolean;
|
|
57
88
|
error: Error | null;
|
|
58
89
|
refetch: () => Promise<unknown>;
|
|
59
|
-
data: unknown;
|
|
60
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Result of `useDetail` (and `useDetailQuery`). Matches the typed-end-to-end
|
|
93
|
+
* shape repo-core uses for its result interfaces — no raw-cache escape
|
|
94
|
+
* hatch on the public surface, just the typed extracted view.
|
|
95
|
+
*
|
|
96
|
+
* Need the raw cache entry? `useQueryClient().getQueryData(KEYS.detail(id))`
|
|
97
|
+
* — typed against whatever `setQueryData` last wrote, and the explicit
|
|
98
|
+
* `getQueryData` call signals "I'm reaching past the SDK" at the call site.
|
|
99
|
+
*/
|
|
61
100
|
interface DetailQueryResult<T> {
|
|
101
|
+
/**
|
|
102
|
+
* The extracted entity — `T | null`. Always typed, always the raw doc
|
|
103
|
+
* (not a wrapper), always consistent with what `prefetchDetail` /
|
|
104
|
+
* `cache.getDetail` / `useNavigation` write back.
|
|
105
|
+
*/
|
|
62
106
|
item: T | null;
|
|
63
107
|
isLoading: boolean;
|
|
64
108
|
isFetching: boolean;
|
|
65
109
|
isError: boolean;
|
|
66
110
|
isSuccess: boolean;
|
|
67
111
|
isStale: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* `true` while the detail GET is in flight and the consumer is seeing a
|
|
114
|
+
* preview derived from a list cache entry (or any other `placeholderData`).
|
|
115
|
+
* Use to dim or label the preview, e.g. `<article aria-busy={isPlaceholderData}>`.
|
|
116
|
+
* Flips to `false` once the real detail payload resolves.
|
|
117
|
+
*/
|
|
118
|
+
isPlaceholderData: boolean;
|
|
68
119
|
error: Error | null;
|
|
69
120
|
refetch: () => Promise<unknown>;
|
|
70
|
-
data: unknown;
|
|
71
121
|
}
|
|
72
122
|
interface CreateListQueryConfig {
|
|
73
123
|
queryKey: QueryKey;
|
|
@@ -76,23 +126,52 @@ interface CreateListQueryConfig {
|
|
|
76
126
|
}) => Promise<unknown>;
|
|
77
127
|
enabled?: boolean;
|
|
78
128
|
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
129
|
select?: (data: unknown) => unknown;
|
|
84
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* List query hook. Returns the canonical `{ items, pagination, ... }` shape
|
|
133
|
+
* derived from arc's `PaginatedResult` wire envelope.
|
|
134
|
+
*
|
|
135
|
+
* **No detail-cache prefill.** Older revisions of this hook wrote each list
|
|
136
|
+
* item into the detail cache via `setQueryData`. That pattern was wrong on
|
|
137
|
+
* three counts: (1) it stored a `{ data: ... }` envelope that didn't match
|
|
138
|
+
* arc 2.13+'s raw doc wire shape, (2) the fresh-default `staleTime` made
|
|
139
|
+
* subsequent `useDetail` calls reuse the partial list payload and never fetch
|
|
140
|
+
* the rich detail response, and (3) the unstable `detailKeyBuilder` ref made
|
|
141
|
+
* the prefill effect re-run on every render, clobbering successful detail
|
|
142
|
+
* fetches. The clean fix is on the read side: `useDetail` now reads list cache
|
|
143
|
+
* directly via `placeholderData`, so the detail GET still fires while the
|
|
144
|
+
* consumer sees an instant list-shaped preview. See `findItemInListCache`.
|
|
145
|
+
*/
|
|
85
146
|
declare function useListQuery<T>({
|
|
86
147
|
queryKey,
|
|
87
148
|
queryFn,
|
|
88
149
|
enabled,
|
|
89
150
|
options,
|
|
90
|
-
prefillDetailCache,
|
|
91
|
-
detailKeyBuilder,
|
|
92
|
-
itemIdResolver,
|
|
93
151
|
select
|
|
94
152
|
}: CreateListQueryConfig): ListQueryResult<T>;
|
|
95
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Find an item in any list cache for this entity by ID.
|
|
155
|
+
*
|
|
156
|
+
* Walks every `[entity, 'list', ...]` query (including scoped variants and
|
|
157
|
+
* infinite-list page arrays) and extracts items via the permissive list
|
|
158
|
+
* detector. Returns the first match — list payloads are subsets of detail
|
|
159
|
+
* payloads, so the consumer sees an instant preview while the real detail
|
|
160
|
+
* GET runs in the background.
|
|
161
|
+
*
|
|
162
|
+
* This is the canonical TanStack pattern for "show list data while fetching
|
|
163
|
+
* detail": pass the returned value as `placeholderData` to `useDetail`. The
|
|
164
|
+
* value isn't persisted to the detail cache (no pollution), `staleTime`
|
|
165
|
+
* doesn't apply to it (always refetches), and `isPlaceholderData` is `true`
|
|
166
|
+
* until the GET resolves.
|
|
167
|
+
*
|
|
168
|
+
* @param qc TanStack QueryClient (typically from `useQueryClient()`)
|
|
169
|
+
* @param listsKey Prefix key for this entity's lists (e.g. `KEYS.lists()`)
|
|
170
|
+
* @param id Item ID being requested
|
|
171
|
+
* @param idField Optional custom ID field (matches `createCrudHooks({ idField })`)
|
|
172
|
+
*/
|
|
173
|
+
declare function findItemInListCache<T>(qc: QueryClient, listsKey: QueryKey, id: string, idField?: string): T | undefined;
|
|
174
|
+
interface CreateDetailQueryConfig<T = unknown> {
|
|
96
175
|
queryKey: QueryKey;
|
|
97
176
|
queryFn: (context: {
|
|
98
177
|
signal?: AbortSignal;
|
|
@@ -100,14 +179,22 @@ interface CreateDetailQueryConfig {
|
|
|
100
179
|
enabled?: boolean;
|
|
101
180
|
options?: Record<string, unknown>;
|
|
102
181
|
select?: (data: unknown) => unknown;
|
|
182
|
+
/**
|
|
183
|
+
* Lazy placeholder. Returns a value (or `undefined`) on each render to show
|
|
184
|
+
* before the detail GET resolves. Use with `findItemInListCache` to derive
|
|
185
|
+
* an instant preview from list cache without polluting the detail cache —
|
|
186
|
+
* the canonical TanStack pattern for "show list data while fetching detail."
|
|
187
|
+
*/
|
|
188
|
+
placeholderData?: () => T | undefined;
|
|
103
189
|
}
|
|
104
190
|
declare function useDetailQuery<T>({
|
|
105
191
|
queryKey,
|
|
106
192
|
queryFn,
|
|
107
193
|
enabled,
|
|
108
194
|
options,
|
|
109
|
-
select
|
|
110
|
-
|
|
195
|
+
select,
|
|
196
|
+
placeholderData
|
|
197
|
+
}: CreateDetailQueryConfig<T>): DetailQueryResult<T>;
|
|
111
198
|
interface InfiniteListQueryOptions {
|
|
112
199
|
public?: boolean;
|
|
113
200
|
enabled?: boolean;
|
|
@@ -250,4 +337,4 @@ declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>
|
|
|
250
337
|
options
|
|
251
338
|
}: UseApiQueryConfig<TResponse, TData>): UseApiQueryResult<TData>;
|
|
252
339
|
//#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 };
|
|
340
|
+
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,42 +30,62 @@ 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,
|
|
40
39
|
isSuccess: query.isSuccess,
|
|
41
40
|
isStale: query.isStale,
|
|
42
41
|
error: query.error,
|
|
43
|
-
refetch: query.refetch
|
|
44
|
-
data: query.data
|
|
42
|
+
refetch: query.refetch
|
|
45
43
|
};
|
|
46
44
|
}
|
|
47
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Find an item in any list cache for this entity by ID.
|
|
47
|
+
*
|
|
48
|
+
* Walks every `[entity, 'list', ...]` query (including scoped variants and
|
|
49
|
+
* infinite-list page arrays) and extracts items via the permissive list
|
|
50
|
+
* detector. Returns the first match — list payloads are subsets of detail
|
|
51
|
+
* payloads, so the consumer sees an instant preview while the real detail
|
|
52
|
+
* GET runs in the background.
|
|
53
|
+
*
|
|
54
|
+
* This is the canonical TanStack pattern for "show list data while fetching
|
|
55
|
+
* detail": pass the returned value as `placeholderData` to `useDetail`. The
|
|
56
|
+
* value isn't persisted to the detail cache (no pollution), `staleTime`
|
|
57
|
+
* doesn't apply to it (always refetches), and `isPlaceholderData` is `true`
|
|
58
|
+
* until the GET resolves.
|
|
59
|
+
*
|
|
60
|
+
* @param qc TanStack QueryClient (typically from `useQueryClient()`)
|
|
61
|
+
* @param listsKey Prefix key for this entity's lists (e.g. `KEYS.lists()`)
|
|
62
|
+
* @param id Item ID being requested
|
|
63
|
+
* @param idField Optional custom ID field (matches `createCrudHooks({ idField })`)
|
|
64
|
+
*/
|
|
65
|
+
function findItemInListCache(qc, listsKey, id, idField) {
|
|
66
|
+
if (!id) return void 0;
|
|
67
|
+
const entries = qc.getQueriesData({ queryKey: listsKey });
|
|
68
|
+
for (const [, raw] of entries) {
|
|
69
|
+
if (!raw) continue;
|
|
70
|
+
const pages = typeof raw === "object" && raw !== null && Array.isArray(raw.pages) ? raw.pages : [raw];
|
|
71
|
+
for (const page of pages) {
|
|
72
|
+
const items = extractItems(page);
|
|
73
|
+
for (const item of items) {
|
|
74
|
+
const got = idField ? item?.[idField] : getItemId(item);
|
|
75
|
+
if (got != null && String(got) === id) return item;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, select, placeholderData }) {
|
|
48
81
|
const query = useQuery({
|
|
49
82
|
queryKey,
|
|
50
83
|
queryFn: ({ signal }) => queryFn({ signal }),
|
|
51
84
|
enabled,
|
|
52
85
|
...DEFAULT_QUERY_CONFIG,
|
|
53
86
|
...options,
|
|
54
|
-
...select ? { select } : {}
|
|
87
|
+
...select ? { select } : {},
|
|
88
|
+
...placeholderData ? { placeholderData } : {}
|
|
55
89
|
});
|
|
56
90
|
return {
|
|
57
91
|
item: extractItem(query.data),
|
|
@@ -60,9 +94,9 @@ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, selec
|
|
|
60
94
|
isError: query.isError,
|
|
61
95
|
isSuccess: query.isSuccess,
|
|
62
96
|
isStale: query.isStale,
|
|
97
|
+
isPlaceholderData: query.isPlaceholderData,
|
|
63
98
|
error: query.error,
|
|
64
|
-
refetch: query.refetch
|
|
65
|
-
data: query.data
|
|
99
|
+
refetch: query.refetch
|
|
66
100
|
};
|
|
67
101
|
}
|
|
68
102
|
function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam, maxPages }) {
|
|
@@ -155,4 +189,4 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
|
|
|
155
189
|
}
|
|
156
190
|
|
|
157
191
|
//#endregion
|
|
158
|
-
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|
|
192
|
+
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) {
|