@classytic/arc-next 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +272 -660
- package/dist/api.d.ts +115 -194
- package/dist/api.js +69 -116
- package/dist/cache.d.ts +125 -0
- package/dist/cache.js +211 -0
- package/dist/client.d.ts +295 -5
- package/dist/client.js +392 -12
- package/dist/hooks.d.ts +162 -22
- package/dist/hooks.js +249 -89
- package/dist/mutation.d.ts +18 -1
- package/dist/mutation.js +20 -1
- package/dist/prefetch.d.ts +36 -2
- package/dist/prefetch.js +47 -3
- package/dist/presets/bulk.d.ts +43 -0
- package/dist/presets/bulk.js +50 -0
- package/dist/presets/search.d.ts +56 -0
- package/dist/presets/search.js +60 -0
- package/dist/presets/slug.d.ts +28 -0
- package/dist/presets/slug.js +27 -0
- package/dist/presets/soft-delete.d.ts +34 -0
- package/dist/presets/soft-delete.js +45 -0
- package/dist/presets/tree.d.ts +32 -0
- package/dist/presets/tree.js +47 -0
- package/dist/query.d.ts +86 -69
- package/dist/query.js +59 -161
- package/dist/sse.d.ts +115 -36
- package/dist/sse.js +179 -103
- package/dist/upload.d.ts +181 -0
- package/dist/upload.js +346 -0
- package/dist/ws.d.ts +167 -0
- package/dist/ws.js +274 -0
- package/package.json +39 -1
package/dist/query.d.ts
CHANGED
|
@@ -1,18 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
3
|
|
|
3
4
|
//#region src/query.d.ts
|
|
4
|
-
interface PaginationData {
|
|
5
|
-
/** Pagination method detected from response (offset | keyset | aggregate) */
|
|
6
|
-
method: 'offset' | 'keyset' | 'aggregate' | null;
|
|
7
|
-
total: number;
|
|
8
|
-
pages: number;
|
|
9
|
-
page: number;
|
|
10
|
-
limit: number;
|
|
11
|
-
hasNext: boolean;
|
|
12
|
-
hasPrev: boolean;
|
|
13
|
-
/** Keyset cursor for next page (keyset pagination only) */
|
|
14
|
-
next?: string | null;
|
|
15
|
-
}
|
|
16
5
|
/** Request-level options passed through to the fetch call */
|
|
17
6
|
interface RequestPassthrough {
|
|
18
7
|
cache?: RequestCache;
|
|
@@ -80,61 +69,6 @@ interface DetailQueryResult<T> {
|
|
|
80
69
|
refetch: () => Promise<unknown>;
|
|
81
70
|
data: unknown;
|
|
82
71
|
}
|
|
83
|
-
interface QueryKeys {
|
|
84
|
-
all: string[];
|
|
85
|
-
lists: () => QueryKey;
|
|
86
|
-
list: (params?: unknown) => QueryKey;
|
|
87
|
-
details: () => QueryKey;
|
|
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;
|
|
91
|
-
custom: (key: string, ...args: unknown[]) => QueryKey;
|
|
92
|
-
scopedList: (scope: string, params?: unknown) => QueryKey;
|
|
93
|
-
}
|
|
94
|
-
interface CacheUtils<T> {
|
|
95
|
-
invalidateAll: (client: QueryClient) => Promise<void>;
|
|
96
|
-
invalidateLists: (client: QueryClient) => Promise<void>;
|
|
97
|
-
/** Invalidate detail by ID (prefix-matches all scoped/parameterized variants). */
|
|
98
|
-
invalidateDetail: (client: QueryClient, id: string) => Promise<void>;
|
|
99
|
-
setDetail: (client: QueryClient, id: string, data: T) => void;
|
|
100
|
-
getDetail: (client: QueryClient, id: string) => T | undefined;
|
|
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;
|
|
110
|
-
}
|
|
111
|
-
declare const DEFAULT_QUERY_CONFIG: {
|
|
112
|
-
readonly staleTime: number;
|
|
113
|
-
readonly gcTime: number;
|
|
114
|
-
readonly refetchOnWindowFocus: false;
|
|
115
|
-
readonly retry: 0;
|
|
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
|
-
};
|
|
133
|
-
declare function getItemId(item: unknown): string | null;
|
|
134
|
-
declare function extractItem<T>(data: unknown): T | null;
|
|
135
|
-
declare function updateListCache<T>(listData: unknown, updater: (items: T[]) => T[]): unknown;
|
|
136
|
-
declare function createQueryKeys(entityKey: string): QueryKeys;
|
|
137
|
-
declare function createCacheUtils<T>(KEYS: QueryKeys): CacheUtils<T>;
|
|
138
72
|
interface CreateListQueryConfig {
|
|
139
73
|
queryKey: QueryKey;
|
|
140
74
|
queryFn: (context: {
|
|
@@ -232,5 +166,88 @@ declare function useInfiniteListQuery<T>({
|
|
|
232
166
|
getPreviousPageParam,
|
|
233
167
|
maxPages
|
|
234
168
|
}: CreateInfiniteListQueryConfig): InfiniteListQueryResult<T>;
|
|
169
|
+
/** Recognized data-freshness presets. Maps to QUERY_CONFIGS. */
|
|
170
|
+
type QueryFreshness = keyof typeof QUERY_CONFIGS;
|
|
171
|
+
/**
|
|
172
|
+
* Identity passthrough — arc emits raw data on success (no envelope; HTTP
|
|
173
|
+
* status discriminates errors via thrown `ArcApiError`). Kept as a named
|
|
174
|
+
* type for the `useApiQuery` default param so the public signature stays
|
|
175
|
+
* `useApiQuery<TResponse, TData = ExtractData<TResponse>>` for callers
|
|
176
|
+
* that want a custom select projection.
|
|
177
|
+
*/
|
|
178
|
+
type ExtractData<T> = T;
|
|
179
|
+
/** Per-call request pass-through and TanStack Query overrides. */
|
|
180
|
+
interface UseApiQueryOptions {
|
|
181
|
+
staleTime?: number;
|
|
182
|
+
gcTime?: number;
|
|
183
|
+
refetchOnWindowFocus?: boolean;
|
|
184
|
+
refetchInterval?: number | false;
|
|
185
|
+
refetchIntervalInBackground?: boolean;
|
|
186
|
+
retry?: boolean | number;
|
|
187
|
+
/** Limit re-renders to changes in these specific fields (perf optimization). */
|
|
188
|
+
notifyOnChangeProps?: ('data' | 'error' | 'isLoading' | 'isFetching' | 'isError' | 'isSuccess' | 'isStale')[];
|
|
189
|
+
}
|
|
190
|
+
interface UseApiQueryConfig<TResponse, TData> {
|
|
191
|
+
queryKey: QueryKey;
|
|
192
|
+
queryFn: (context: {
|
|
193
|
+
signal: AbortSignal;
|
|
194
|
+
}) => Promise<TResponse>;
|
|
195
|
+
enabled?: boolean;
|
|
196
|
+
/** Freshness preset name (`realtime` | `frequent` | `stable` | `static`). */
|
|
197
|
+
freshness?: QueryFreshness;
|
|
198
|
+
/**
|
|
199
|
+
* Custom projection from the raw response. When omitted, the response is
|
|
200
|
+
* returned unchanged — arc 2.13+ has no wire envelope, so the response
|
|
201
|
+
* already IS the data.
|
|
202
|
+
*/
|
|
203
|
+
select?: (response: TResponse) => TData;
|
|
204
|
+
/** TanStack Query overrides (staleTime, refetchInterval, retry, etc.). */
|
|
205
|
+
options?: UseApiQueryOptions;
|
|
206
|
+
}
|
|
207
|
+
interface UseApiQueryResult<TData> {
|
|
208
|
+
data: TData | null;
|
|
209
|
+
isLoading: boolean;
|
|
210
|
+
isFetching: boolean;
|
|
211
|
+
isError: boolean;
|
|
212
|
+
isSuccess: boolean;
|
|
213
|
+
isStale: boolean;
|
|
214
|
+
error: Error | null;
|
|
215
|
+
refetch: () => Promise<unknown>;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
|
|
219
|
+
* endpoints). Wraps TanStack Query with two ergonomics on top:
|
|
220
|
+
*
|
|
221
|
+
* 1. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
|
|
222
|
+
* 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
|
|
223
|
+
* 2. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
|
|
224
|
+
* isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
|
|
225
|
+
*
|
|
226
|
+
* Arc emits raw data on success (no envelope), so the response IS the data.
|
|
227
|
+
* Use `select` for shape transformations / multi-field projections.
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* // Direct typing — response IS the data
|
|
231
|
+
* const { data } = useApiQuery<DashboardStats>({
|
|
232
|
+
* queryKey: ['dashboard', 'stats'],
|
|
233
|
+
* queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
|
|
234
|
+
* freshness: 'realtime',
|
|
235
|
+
* });
|
|
236
|
+
*
|
|
237
|
+
* // Custom projection (e.g. extract a sub-field)
|
|
238
|
+
* const { data } = useApiQuery({
|
|
239
|
+
* queryKey: ['ledger', accountId],
|
|
240
|
+
* queryFn: ({ signal }) => api.request<{ entries: Entry[] }>('GET', `/ledger/${accountId}`, { options: { signal } }),
|
|
241
|
+
* select: (res) => res.entries,
|
|
242
|
+
* });
|
|
243
|
+
*/
|
|
244
|
+
declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>>({
|
|
245
|
+
queryKey,
|
|
246
|
+
queryFn,
|
|
247
|
+
enabled,
|
|
248
|
+
freshness,
|
|
249
|
+
select,
|
|
250
|
+
options
|
|
251
|
+
}: UseApiQueryConfig<TResponse, TData>): UseApiQueryResult<TData>;
|
|
235
252
|
//#endregion
|
|
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 };
|
|
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 };
|
package/dist/query.js
CHANGED
|
@@ -1,168 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
|
|
3
4
|
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import { useEffect, useMemo } from "react";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
5
6
|
|
|
6
7
|
//#region src/query.ts
|
|
7
|
-
const DEFAULT_QUERY_CONFIG = {
|
|
8
|
-
staleTime: 300 * 1e3,
|
|
9
|
-
gcTime: 1800 * 1e3,
|
|
10
|
-
refetchOnWindowFocus: false,
|
|
11
|
-
retry: 0
|
|
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
|
-
};
|
|
23
|
-
function getItemId(item) {
|
|
24
|
-
if (!item || typeof item !== "object") return null;
|
|
25
|
-
const obj = item;
|
|
26
|
-
const id = obj._id ?? obj.id;
|
|
27
|
-
return typeof id === "string" ? id : id ? String(id) : null;
|
|
28
|
-
}
|
|
29
|
-
function normalizePagination(data) {
|
|
30
|
-
if (!data || typeof data !== "object") return null;
|
|
31
|
-
const d = data;
|
|
32
|
-
const method = d.method ?? null;
|
|
33
|
-
const isKeyset = method === "keyset" || d.hasMore != null && d.total == null && d.pages == null;
|
|
34
|
-
const hasTotal = d.total != null || d.totalDocs != null;
|
|
35
|
-
const hasPages = d.pages != null || d.totalPages != null;
|
|
36
|
-
if (!hasTotal && !hasPages && !isKeyset) return null;
|
|
37
|
-
return {
|
|
38
|
-
method,
|
|
39
|
-
total: Number(d.total ?? d.totalDocs ?? 0),
|
|
40
|
-
pages: Number(d.pages ?? d.totalPages ?? (isKeyset ? 0 : 1)),
|
|
41
|
-
page: Number(d.page ?? d.currentPage ?? (isKeyset ? 0 : 1)),
|
|
42
|
-
limit: Number(d.limit ?? 10),
|
|
43
|
-
hasNext: Boolean(d.hasNext ?? d.hasNextPage ?? d.hasMore ?? false),
|
|
44
|
-
hasPrev: Boolean(d.hasPrev ?? d.hasPrevPage ?? false),
|
|
45
|
-
...isKeyset ? { next: d.next ?? null } : {}
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
/** Well-known keys checked in order for list responses. */
|
|
49
|
-
const LIST_KEYS = [
|
|
50
|
-
"docs",
|
|
51
|
-
"data",
|
|
52
|
-
"items",
|
|
53
|
-
"results"
|
|
54
|
-
];
|
|
55
|
-
/** Well-known keys checked in order for detail responses. */
|
|
56
|
-
const DETAIL_KEYS = [
|
|
57
|
-
"data",
|
|
58
|
-
"doc",
|
|
59
|
-
"item",
|
|
60
|
-
"result"
|
|
61
|
-
];
|
|
62
|
-
function extractItems(data) {
|
|
63
|
-
if (!data) return [];
|
|
64
|
-
if (Array.isArray(data)) return data;
|
|
65
|
-
if (typeof data !== "object") return [];
|
|
66
|
-
const d = data;
|
|
67
|
-
for (const key of LIST_KEYS) if (Array.isArray(d[key])) return d[key];
|
|
68
|
-
for (const value of Object.values(d)) if (Array.isArray(value)) return value;
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
function extractItem(data) {
|
|
72
|
-
if (data == null) return null;
|
|
73
|
-
if (typeof data !== "object") return data;
|
|
74
|
-
const d = data;
|
|
75
|
-
for (const key of DETAIL_KEYS) if (d[key] != null) return d[key];
|
|
76
|
-
return d;
|
|
77
|
-
}
|
|
78
|
-
function updateListCache(listData, updater) {
|
|
79
|
-
if (!listData) return listData;
|
|
80
|
-
if (Array.isArray(listData)) return updater(listData);
|
|
81
|
-
if (typeof listData !== "object") return listData;
|
|
82
|
-
const d = listData;
|
|
83
|
-
let arrayField = null;
|
|
84
|
-
for (const key of LIST_KEYS) if (Array.isArray(d[key])) {
|
|
85
|
-
arrayField = key;
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
|
-
if (!arrayField) {
|
|
89
|
-
for (const [key, value] of Object.entries(d)) if (Array.isArray(value)) {
|
|
90
|
-
arrayField = key;
|
|
91
|
-
break;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (!arrayField) return listData;
|
|
95
|
-
const updated = updater(d[arrayField]);
|
|
96
|
-
const original = d[arrayField];
|
|
97
|
-
const delta = updated.length - original.length;
|
|
98
|
-
const result = {
|
|
99
|
-
...d,
|
|
100
|
-
[arrayField]: updated
|
|
101
|
-
};
|
|
102
|
-
if (delta !== 0) {
|
|
103
|
-
if (d.total != null) result.total = Math.max(0, Number(d.total) + delta);
|
|
104
|
-
if (d.totalDocs != null) result.totalDocs = Math.max(0, Number(d.totalDocs) + delta);
|
|
105
|
-
}
|
|
106
|
-
return result;
|
|
107
|
-
}
|
|
108
|
-
function createQueryKeys(entityKey) {
|
|
109
|
-
return {
|
|
110
|
-
all: [entityKey],
|
|
111
|
-
lists: () => [entityKey, "list"],
|
|
112
|
-
list: (params) => [
|
|
113
|
-
entityKey,
|
|
114
|
-
"list",
|
|
115
|
-
params
|
|
116
|
-
],
|
|
117
|
-
details: () => [entityKey, "detail"],
|
|
118
|
-
detail: (id) => [
|
|
119
|
-
entityKey,
|
|
120
|
-
"detail",
|
|
121
|
-
id
|
|
122
|
-
],
|
|
123
|
-
scopedDetail: (id, organizationId) => organizationId ? [
|
|
124
|
-
entityKey,
|
|
125
|
-
"detail",
|
|
126
|
-
id,
|
|
127
|
-
{ _org: organizationId }
|
|
128
|
-
] : [
|
|
129
|
-
entityKey,
|
|
130
|
-
"detail",
|
|
131
|
-
id
|
|
132
|
-
],
|
|
133
|
-
custom: (key, ...args) => [
|
|
134
|
-
entityKey,
|
|
135
|
-
key,
|
|
136
|
-
...args
|
|
137
|
-
],
|
|
138
|
-
scopedList: (scope, params) => [
|
|
139
|
-
entityKey,
|
|
140
|
-
"list",
|
|
141
|
-
{
|
|
142
|
-
_scope: scope,
|
|
143
|
-
...params
|
|
144
|
-
}
|
|
145
|
-
]
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
function createCacheUtils(KEYS) {
|
|
149
|
-
return {
|
|
150
|
-
invalidateAll: (client) => client.invalidateQueries({ queryKey: KEYS.all }),
|
|
151
|
-
invalidateLists: (client) => client.invalidateQueries({ queryKey: KEYS.lists() }),
|
|
152
|
-
invalidateDetail: (client, id) => client.invalidateQueries({ queryKey: KEYS.detail(id) }),
|
|
153
|
-
setDetail: (client, id, data) => client.setQueryData(KEYS.detail(id), { data }),
|
|
154
|
-
getDetail: (client, id) => {
|
|
155
|
-
return client.getQueryData(KEYS.detail(id))?.data;
|
|
156
|
-
},
|
|
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) })
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
8
|
function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, itemIdResolver, select }) {
|
|
167
9
|
const queryClient = useQueryClient();
|
|
168
10
|
const query = useQuery({
|
|
@@ -255,6 +97,62 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
|
|
|
255
97
|
data: query.data
|
|
256
98
|
};
|
|
257
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
|
|
102
|
+
* endpoints). Wraps TanStack Query with two ergonomics on top:
|
|
103
|
+
*
|
|
104
|
+
* 1. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
|
|
105
|
+
* 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
|
|
106
|
+
* 2. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
|
|
107
|
+
* isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
|
|
108
|
+
*
|
|
109
|
+
* Arc emits raw data on success (no envelope), so the response IS the data.
|
|
110
|
+
* Use `select` for shape transformations / multi-field projections.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* // Direct typing — response IS the data
|
|
114
|
+
* const { data } = useApiQuery<DashboardStats>({
|
|
115
|
+
* queryKey: ['dashboard', 'stats'],
|
|
116
|
+
* queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
|
|
117
|
+
* freshness: 'realtime',
|
|
118
|
+
* });
|
|
119
|
+
*
|
|
120
|
+
* // Custom projection (e.g. extract a sub-field)
|
|
121
|
+
* const { data } = useApiQuery({
|
|
122
|
+
* queryKey: ['ledger', accountId],
|
|
123
|
+
* queryFn: ({ signal }) => api.request<{ entries: Entry[] }>('GET', `/ledger/${accountId}`, { options: { signal } }),
|
|
124
|
+
* select: (res) => res.entries,
|
|
125
|
+
* });
|
|
126
|
+
*/
|
|
127
|
+
function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, options = {} }) {
|
|
128
|
+
const selectRef = useRef(select);
|
|
129
|
+
selectRef.current = select;
|
|
130
|
+
const projection = useCallback((response) => {
|
|
131
|
+
const fn = selectRef.current;
|
|
132
|
+
if (fn) return fn(response);
|
|
133
|
+
return response;
|
|
134
|
+
}, []);
|
|
135
|
+
const preset = freshness ? QUERY_CONFIGS[freshness] : void 0;
|
|
136
|
+
const query = useQuery({
|
|
137
|
+
queryKey,
|
|
138
|
+
queryFn: ({ signal }) => queryFn({ signal }),
|
|
139
|
+
enabled,
|
|
140
|
+
...DEFAULT_QUERY_CONFIG,
|
|
141
|
+
...preset,
|
|
142
|
+
...options,
|
|
143
|
+
select: projection
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
data: query.data ?? null,
|
|
147
|
+
isLoading: query.isLoading,
|
|
148
|
+
isFetching: query.isFetching,
|
|
149
|
+
isError: query.isError,
|
|
150
|
+
isSuccess: query.isSuccess,
|
|
151
|
+
isStale: query.isStale,
|
|
152
|
+
error: query.error,
|
|
153
|
+
refetch: query.refetch
|
|
154
|
+
};
|
|
155
|
+
}
|
|
258
156
|
|
|
259
157
|
//#endregion
|
|
260
|
-
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
|
|
158
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|
package/dist/sse.d.ts
CHANGED
|
@@ -1,69 +1,148 @@
|
|
|
1
1
|
import { QueryKey } from "@tanstack/react-query";
|
|
2
2
|
|
|
3
3
|
//#region src/sse.d.ts
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Build an authenticated SSE URL using the global client + auth singletons.
|
|
6
|
+
*
|
|
7
|
+
* Thin alias for {@link import('./client.js').buildStreamUrl} with the HTTP
|
|
8
|
+
* protocol — kept as a named export so SSE consumers don't have to think about
|
|
9
|
+
* the `protocol` arg.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const es = new EventSource(buildSseUrl('/jobs/stream', { jobId }), {
|
|
13
|
+
* withCredentials: getAuthMode() === 'cookie',
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
declare function buildSseUrl(path: string, params?: Record<string, string | number | boolean | null | undefined>): string;
|
|
17
|
+
/**
|
|
18
|
+
* Generic Arc server event envelope.
|
|
19
|
+
*
|
|
20
|
+
* Defaults to `unknown` payload — narrow with the generic when you control
|
|
21
|
+
* the broadcast shape (`ArcServerEvent<Todo>`). For the canonical CRUD shape,
|
|
22
|
+
* use {@link CrudEvent} which constrains `type` to `<resource>.<operation>`
|
|
23
|
+
* and `operation` to the three lifecycle verbs.
|
|
24
|
+
*/
|
|
25
|
+
interface ArcServerEvent<TData = unknown> {
|
|
5
26
|
type: string;
|
|
6
27
|
resource: string;
|
|
7
|
-
data:
|
|
28
|
+
data: TData;
|
|
8
29
|
timestamp: string;
|
|
9
30
|
id?: string;
|
|
10
31
|
}
|
|
11
|
-
|
|
12
|
-
|
|
32
|
+
/** Lifecycle operations Arc emits on every CRUD broadcast. */
|
|
33
|
+
type CrudOperation = "created" | "updated" | "deleted";
|
|
34
|
+
/**
|
|
35
|
+
* Typed CRUD event narrowed to Arc's `<resource>.<operation>` envelope.
|
|
36
|
+
*
|
|
37
|
+
* Arc auto-emits this shape from `BaseController` for every list/get/create/
|
|
38
|
+
* update/delete. Pass `<TDoc>` so SSE/WS callbacks get inference for free:
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* subscribeToEvents<CrudEvent<Todo>>({
|
|
42
|
+
* resource: 'todo',
|
|
43
|
+
* onEvent: (e) => console.log(e.operation, e.data.title),
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
interface CrudEvent<TDoc = unknown> extends ArcServerEvent<TDoc> {
|
|
48
|
+
/** Always `<resource>.<operation>` (e.g. `'todo.created'`). */
|
|
49
|
+
type: string;
|
|
50
|
+
operation: CrudOperation;
|
|
51
|
+
}
|
|
52
|
+
interface SubscribeToEventsOptions<TData = unknown> {
|
|
53
|
+
/** Full SSE endpoint URL. When set, overrides `path` and `baseUrl`. */
|
|
13
54
|
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
|
-
*/
|
|
55
|
+
/** Resource name for auto pattern filtering + named-event derivation. */
|
|
19
56
|
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
|
-
*/
|
|
57
|
+
/** Endpoint path (default: `/events/stream`). */
|
|
24
58
|
path?: string;
|
|
25
|
-
/** Event patterns to listen for (e.g
|
|
59
|
+
/** Event patterns to listen for (e.g. `['todo.*']`). Empty → all events. */
|
|
26
60
|
patterns?: string[];
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Named SSE event types to subscribe to via `addEventListener`.
|
|
63
|
+
* Falls back to `patterns` (literal entries) or `[<resource>.created|updated|deleted]`.
|
|
64
|
+
* Pass `[]` to opt out of named-event subscription entirely.
|
|
65
|
+
*/
|
|
66
|
+
eventTypes?: string[];
|
|
67
|
+
/** Per-event callback. */
|
|
68
|
+
onEvent?: (event: ArcServerEvent<TData>) => void;
|
|
69
|
+
/** Connection-state callback. */
|
|
32
70
|
onConnectionChange?: (connected: boolean) => void;
|
|
33
|
-
/**
|
|
34
|
-
enabled?: boolean;
|
|
35
|
-
/** Reconnect delay in ms. Default: 3000 */
|
|
71
|
+
/** Reconnect delay in ms. Default: 3000. */
|
|
36
72
|
reconnectDelay?: number;
|
|
37
|
-
/** Maximum reconnect attempts
|
|
73
|
+
/** Maximum reconnect attempts. Default: Infinity. */
|
|
38
74
|
maxReconnectAttempts?: number;
|
|
39
75
|
/** Whether to include credentials (cookies). Derived from authMode when not set. */
|
|
40
76
|
withCredentials?: boolean;
|
|
41
77
|
}
|
|
42
|
-
|
|
43
|
-
|
|
78
|
+
/** Handle returned by {@link subscribeToEvents}. Stable across reconnects. */
|
|
79
|
+
interface SubscribeToEventsHandle {
|
|
80
|
+
close: () => void;
|
|
81
|
+
reconnect: () => void;
|
|
82
|
+
isConnected: () => boolean;
|
|
83
|
+
}
|
|
84
|
+
interface EventStreamOptions<TData = unknown> extends SubscribeToEventsOptions<TData> {
|
|
85
|
+
/** Query keys to invalidate when any matching event arrives. */
|
|
86
|
+
invalidateQueries?: QueryKey[];
|
|
87
|
+
/** Whether the stream is active. Default: true. */
|
|
88
|
+
enabled?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Whether to track `lastEvent` in React state. Default: true.
|
|
91
|
+
*
|
|
92
|
+
* Set to `false` for high-volume streams (telemetry, live tickers) where
|
|
93
|
+
* the consumer uses `onEvent` for fire-and-forget handling and never reads
|
|
94
|
+
* `result.lastEvent`. Each inbound frame skips a `setState` call,
|
|
95
|
+
* eliminating per-event re-renders.
|
|
96
|
+
*/
|
|
97
|
+
trackLastEvent?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Whether to track `eventCount` in React state. Default: true.
|
|
100
|
+
*
|
|
101
|
+
* Same trade-off as `trackLastEvent`: skip the counter `setState` for
|
|
102
|
+
* high-volume streams that don't read it.
|
|
103
|
+
*/
|
|
104
|
+
trackEventCount?: boolean;
|
|
105
|
+
}
|
|
106
|
+
interface EventStreamResult<TData = unknown> {
|
|
44
107
|
isConnected: boolean;
|
|
45
|
-
|
|
46
|
-
lastEvent: ArcServerEvent | null;
|
|
47
|
-
/** Number of events received since connection. */
|
|
108
|
+
lastEvent: ArcServerEvent<TData> | null;
|
|
48
109
|
eventCount: number;
|
|
49
|
-
/** Close the connection manually. */
|
|
50
110
|
close: () => void;
|
|
51
|
-
/** Reconnect after a manual close. */
|
|
52
111
|
reconnect: () => void;
|
|
53
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Subscribe to an Arc SSE stream from any JS context (React, Node, Bun, tests).
|
|
115
|
+
* Pure function — no React hook required. Returns a handle with `close()` /
|
|
116
|
+
* `reconnect()` / `isConnected()`.
|
|
117
|
+
*
|
|
118
|
+
* Reconnect uses exponential backoff (×1.5 per attempt, capped at 30s).
|
|
119
|
+
* Subscriptions persist across reconnect.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* const sub = subscribeToEvents<CrudEvent<Todo>>({
|
|
123
|
+
* resource: 'todo',
|
|
124
|
+
* onEvent: (e) => console.log(e.operation, e.data.title),
|
|
125
|
+
* });
|
|
126
|
+
* // ...later
|
|
127
|
+
* sub.close();
|
|
128
|
+
*/
|
|
129
|
+
declare function subscribeToEvents<TData = unknown>(options: SubscribeToEventsOptions<TData>): SubscribeToEventsHandle;
|
|
54
130
|
/**
|
|
55
131
|
* Subscribe to Arc server-sent events for real-time cache invalidation.
|
|
56
132
|
*
|
|
57
|
-
* Uses the browser's native `EventSource`
|
|
133
|
+
* Uses the browser's native `EventSource` for automatic reconnection
|
|
58
134
|
* and efficient server-push. Events trigger query invalidation so TanStack Query
|
|
59
135
|
* refetches affected data automatically.
|
|
60
136
|
*
|
|
137
|
+
* Internally delegates to {@link subscribeToEvents} — for non-React contexts
|
|
138
|
+
* (Node, tests, plain JS) call that directly.
|
|
139
|
+
*
|
|
61
140
|
* @example
|
|
62
|
-
* const { isConnected } = useEventStream({
|
|
63
|
-
* resource: '
|
|
64
|
-
* invalidateQueries: [
|
|
141
|
+
* const { isConnected } = useEventStream<CrudEvent<Todo>>({
|
|
142
|
+
* resource: 'todo',
|
|
143
|
+
* invalidateQueries: [todoKeys.lists()],
|
|
65
144
|
* });
|
|
66
145
|
*/
|
|
67
|
-
declare function useEventStream(options: EventStreamOptions): EventStreamResult
|
|
146
|
+
declare function useEventStream<TData = unknown>(options: EventStreamOptions<TData>): EventStreamResult<TData>;
|
|
68
147
|
//#endregion
|
|
69
|
-
export { ArcServerEvent, EventStreamOptions, EventStreamResult, useEventStream };
|
|
148
|
+
export { ArcServerEvent, CrudEvent, CrudOperation, EventStreamOptions, EventStreamResult, SubscribeToEventsHandle, SubscribeToEventsOptions, buildSseUrl, subscribeToEvents, useEventStream };
|