@classytic/arc-next 0.4.1 → 0.5.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 +271 -661
- package/dist/api.d.ts +64 -127
- package/dist/api.js +50 -116
- package/dist/cache.d.ts +108 -0
- package/dist/cache.js +197 -0
- package/dist/client.d.ts +298 -3
- package/dist/client.js +357 -10
- package/dist/hooks.d.ts +96 -20
- package/dist/hooks.js +195 -81
- package/dist/mutation.d.ts +18 -1
- package/dist/mutation.js +20 -1
- package/dist/prefetch.d.ts +13 -2
- package/dist/prefetch.js +27 -3
- package/dist/presets/bulk.d.ts +42 -0
- package/dist/presets/bulk.js +50 -0
- package/dist/presets/search.d.ts +55 -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 +33 -0
- package/dist/presets/soft-delete.js +45 -0
- package/dist/presets/tree.d.ts +31 -0
- package/dist/presets/tree.js +47 -0
- package/dist/query.d.ts +86 -69
- package/dist/query.js +69 -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 +37 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/presets/slug.ts
|
|
2
|
+
/**
|
|
3
|
+
* Adds slug-lookup preset methods to a BaseApi.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors arc's `slugLookup` preset — exposes `getBySlug` for resources keyed
|
|
6
|
+
* by URL-friendly slugs alongside the canonical id.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { withSlugLookup } from '@classytic/arc-next/presets/slug';
|
|
10
|
+
* const categories = withSlugLookup(createCrudApi<Category>('categories'));
|
|
11
|
+
*
|
|
12
|
+
* const cat = await categories.getBySlug({ slug: 'engineering' });
|
|
13
|
+
*/
|
|
14
|
+
function withSlugLookup(api) {
|
|
15
|
+
return Object.assign(api, { async getBySlug({ token = null, organizationId = null, slug, params = {}, options = {} }) {
|
|
16
|
+
if (!slug) throw new Error("Slug is required");
|
|
17
|
+
return api.request("GET", `${api.baseUrl}/slug/${slug}`, {
|
|
18
|
+
token,
|
|
19
|
+
organizationId,
|
|
20
|
+
params,
|
|
21
|
+
options
|
|
22
|
+
});
|
|
23
|
+
} });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { withSlugLookup };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ApiResponse, BaseApi, PaginatedResponse, QueryParams, ScopedArgs } from "../api.js";
|
|
2
|
+
|
|
3
|
+
//#region src/presets/soft-delete.d.ts
|
|
4
|
+
interface SoftDeleteMethods<TDoc> {
|
|
5
|
+
/** List soft-deleted docs. Backend mounts `GET /:resource/deleted`. */
|
|
6
|
+
getDeleted(args?: ScopedArgs & {
|
|
7
|
+
params?: QueryParams;
|
|
8
|
+
}): Promise<PaginatedResponse<TDoc>>;
|
|
9
|
+
/** Undo a soft-delete. Backend mounts `POST /:resource/:id/restore`. */
|
|
10
|
+
restore(args: ScopedArgs & {
|
|
11
|
+
id: string;
|
|
12
|
+
}): Promise<ApiResponse<TDoc>>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Adds soft-delete preset methods to a BaseApi.
|
|
16
|
+
*
|
|
17
|
+
* Mirrors arc's server-side `softDelete` preset routes. Use when your resource
|
|
18
|
+
* is registered with `presets: ['softDelete']`. With this preset, `delete()`
|
|
19
|
+
* becomes soft-delete on the backend and {@link SoftDeleteMethods.restore}
|
|
20
|
+
* undoes it; {@link SoftDeleteMethods.getDeleted} lists the tombstones.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* import { createCrudApi } from '@classytic/arc-next/api';
|
|
24
|
+
* import { withSoftDelete } from '@classytic/arc-next/presets/soft-delete';
|
|
25
|
+
*
|
|
26
|
+
* const todos = withSoftDelete(createCrudApi<Todo>('todos'));
|
|
27
|
+
* await todos.delete({ id }); // soft delete (backend)
|
|
28
|
+
* await todos.restore({ id }); // undo
|
|
29
|
+
* const trash = await todos.getDeleted();
|
|
30
|
+
*/
|
|
31
|
+
declare function withSoftDelete<TDoc, TCreate, TUpdate>(api: BaseApi<TDoc, TCreate, TUpdate>): BaseApi<TDoc, TCreate, TUpdate> & SoftDeleteMethods<TDoc>;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { SoftDeleteMethods, withSoftDelete };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/presets/soft-delete.ts
|
|
2
|
+
/**
|
|
3
|
+
* Adds soft-delete preset methods to a BaseApi.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors arc's server-side `softDelete` preset routes. Use when your resource
|
|
6
|
+
* is registered with `presets: ['softDelete']`. With this preset, `delete()`
|
|
7
|
+
* becomes soft-delete on the backend and {@link SoftDeleteMethods.restore}
|
|
8
|
+
* undoes it; {@link SoftDeleteMethods.getDeleted} lists the tombstones.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { createCrudApi } from '@classytic/arc-next/api';
|
|
12
|
+
* import { withSoftDelete } from '@classytic/arc-next/presets/soft-delete';
|
|
13
|
+
*
|
|
14
|
+
* const todos = withSoftDelete(createCrudApi<Todo>('todos'));
|
|
15
|
+
* await todos.delete({ id }); // soft delete (backend)
|
|
16
|
+
* await todos.restore({ id }); // undo
|
|
17
|
+
* const trash = await todos.getDeleted();
|
|
18
|
+
*/
|
|
19
|
+
function withSoftDelete(api) {
|
|
20
|
+
return Object.assign(api, {
|
|
21
|
+
async getDeleted({ token = null, organizationId = null, params = {}, options = {} } = {}) {
|
|
22
|
+
const merged = {
|
|
23
|
+
...api.config.defaultParams,
|
|
24
|
+
...params
|
|
25
|
+
};
|
|
26
|
+
return api.request("GET", `${api.baseUrl}/deleted`, {
|
|
27
|
+
token,
|
|
28
|
+
organizationId,
|
|
29
|
+
params: merged,
|
|
30
|
+
options
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
async restore({ token = null, organizationId = null, id, options = {} }) {
|
|
34
|
+
if (!id) throw new Error("ID is required");
|
|
35
|
+
return api.request("POST", `${api.baseUrl}/${id}/restore`, {
|
|
36
|
+
token,
|
|
37
|
+
organizationId,
|
|
38
|
+
options
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { withSoftDelete };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ApiResponse, BaseApi, PaginatedResponse, QueryParams, ScopedArgs } from "../api.js";
|
|
2
|
+
|
|
3
|
+
//#region src/presets/tree.d.ts
|
|
4
|
+
interface TreeMethods<TDoc> {
|
|
5
|
+
/** Fetch the full hierarchy. Backend mounts `GET /:resource/tree`. */
|
|
6
|
+
getTree(args?: ScopedArgs & {
|
|
7
|
+
params?: QueryParams;
|
|
8
|
+
}): Promise<ApiResponse<TDoc[]>>;
|
|
9
|
+
/** Fetch direct children of a node. Backend mounts `GET /:resource/:id/children`. */
|
|
10
|
+
getChildren(args: ScopedArgs & {
|
|
11
|
+
parentId: string;
|
|
12
|
+
params?: QueryParams;
|
|
13
|
+
}): Promise<PaginatedResponse<TDoc>>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Adds tree preset methods to a BaseApi.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors arc's `tree` preset — for resources with a `parentId` field, exposes
|
|
19
|
+
* `getTree` (full hierarchy) and `getChildren` (one level deep). Backend caps
|
|
20
|
+
* tree depth via the preset config.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* import { withTree } from '@classytic/arc-next/presets/tree';
|
|
24
|
+
* const categories = withTree(createCrudApi<Category>('categories'));
|
|
25
|
+
*
|
|
26
|
+
* const root = await categories.getTree();
|
|
27
|
+
* const kids = await categories.getChildren({ parentId: 'engineering' });
|
|
28
|
+
*/
|
|
29
|
+
declare function withTree<TDoc, TCreate, TUpdate>(api: BaseApi<TDoc, TCreate, TUpdate>): BaseApi<TDoc, TCreate, TUpdate> & TreeMethods<TDoc>;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { TreeMethods, withTree };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/presets/tree.ts
|
|
2
|
+
/**
|
|
3
|
+
* Adds tree preset methods to a BaseApi.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors arc's `tree` preset — for resources with a `parentId` field, exposes
|
|
6
|
+
* `getTree` (full hierarchy) and `getChildren` (one level deep). Backend caps
|
|
7
|
+
* tree depth via the preset config.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { withTree } from '@classytic/arc-next/presets/tree';
|
|
11
|
+
* const categories = withTree(createCrudApi<Category>('categories'));
|
|
12
|
+
*
|
|
13
|
+
* const root = await categories.getTree();
|
|
14
|
+
* const kids = await categories.getChildren({ parentId: 'engineering' });
|
|
15
|
+
*/
|
|
16
|
+
function withTree(api) {
|
|
17
|
+
return Object.assign(api, {
|
|
18
|
+
async getTree({ token = null, organizationId = null, params = {}, options = {} } = {}) {
|
|
19
|
+
const merged = {
|
|
20
|
+
...api.config.defaultParams,
|
|
21
|
+
...params
|
|
22
|
+
};
|
|
23
|
+
return api.request("GET", `${api.baseUrl}/tree`, {
|
|
24
|
+
token,
|
|
25
|
+
organizationId,
|
|
26
|
+
params: merged,
|
|
27
|
+
options
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
async getChildren({ token = null, organizationId = null, parentId, params = {}, options = {} }) {
|
|
31
|
+
if (!parentId) throw new Error("Parent ID is required");
|
|
32
|
+
const merged = {
|
|
33
|
+
...api.config.defaultParams,
|
|
34
|
+
...params
|
|
35
|
+
};
|
|
36
|
+
return api.request("GET", `${api.baseUrl}/${parentId}/children`, {
|
|
37
|
+
token,
|
|
38
|
+
organizationId,
|
|
39
|
+
params: merged,
|
|
40
|
+
options
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
export { withTree };
|
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
|
+
* Extracts the inner type of an Arc envelope `{ success, data: T }`.
|
|
173
|
+
* Falls through to `T` itself when the response isn't an envelope.
|
|
174
|
+
*/
|
|
175
|
+
type ExtractData<T> = T extends {
|
|
176
|
+
success: unknown;
|
|
177
|
+
data: infer D;
|
|
178
|
+
} ? D : 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 hook auto-unwraps
|
|
200
|
+
* Arc envelopes (`{ success, data }`) and returns `data`. Non-envelope responses
|
|
201
|
+
* pass through unchanged.
|
|
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 three ergonomics on top:
|
|
220
|
+
*
|
|
221
|
+
* 1. **Envelope auto-unwrap.** `{ success, data: T }` responses are projected to
|
|
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' |
|
|
225
|
+
* 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
|
|
226
|
+
* 3. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
|
|
227
|
+
* isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* // Auto-unwrap envelope
|
|
231
|
+
* const { data } = useApiQuery<ApiResponse<DashboardStats>>({
|
|
232
|
+
* queryKey: ['dashboard', 'stats'],
|
|
233
|
+
* queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
|
|
234
|
+
* freshness: 'realtime',
|
|
235
|
+
* });
|
|
236
|
+
*
|
|
237
|
+
* // Custom projection
|
|
238
|
+
* const { data } = useApiQuery({
|
|
239
|
+
* queryKey: ['ledger', accountId],
|
|
240
|
+
* queryFn: ({ signal }) => api.request('GET', `/ledger/${accountId}`, { options: { signal } }),
|
|
241
|
+
* select: (res) => res.data?.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,72 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
|
|
|
255
97
|
data: query.data
|
|
256
98
|
};
|
|
257
99
|
}
|
|
100
|
+
/**
|
|
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
|
+
* Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
|
|
112
|
+
* endpoints). Wraps TanStack Query with three ergonomics on top:
|
|
113
|
+
*
|
|
114
|
+
* 1. **Envelope auto-unwrap.** `{ success, data: T }` responses are projected to
|
|
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' |
|
|
118
|
+
* 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
|
|
119
|
+
* 3. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
|
|
120
|
+
* isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* // Auto-unwrap envelope
|
|
124
|
+
* const { data } = useApiQuery<ApiResponse<DashboardStats>>({
|
|
125
|
+
* queryKey: ['dashboard', 'stats'],
|
|
126
|
+
* queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
|
|
127
|
+
* freshness: 'realtime',
|
|
128
|
+
* });
|
|
129
|
+
*
|
|
130
|
+
* // Custom projection
|
|
131
|
+
* const { data } = useApiQuery({
|
|
132
|
+
* queryKey: ['ledger', accountId],
|
|
133
|
+
* queryFn: ({ signal }) => api.request('GET', `/ledger/${accountId}`, { options: { signal } }),
|
|
134
|
+
* select: (res) => res.data?.entries ?? [],
|
|
135
|
+
* });
|
|
136
|
+
*/
|
|
137
|
+
function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, options = {} }) {
|
|
138
|
+
const selectRef = useRef(select);
|
|
139
|
+
selectRef.current = select;
|
|
140
|
+
const projection = useCallback((response) => {
|
|
141
|
+
const fn = selectRef.current;
|
|
142
|
+
if (fn) return fn(response);
|
|
143
|
+
return isArcEnvelope(response) ? response.data : response;
|
|
144
|
+
}, []);
|
|
145
|
+
const preset = freshness ? QUERY_CONFIGS[freshness] : void 0;
|
|
146
|
+
const query = useQuery({
|
|
147
|
+
queryKey,
|
|
148
|
+
queryFn: ({ signal }) => queryFn({ signal }),
|
|
149
|
+
enabled,
|
|
150
|
+
...DEFAULT_QUERY_CONFIG,
|
|
151
|
+
...preset,
|
|
152
|
+
...options,
|
|
153
|
+
select: projection
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
data: query.data ?? null,
|
|
157
|
+
isLoading: query.isLoading,
|
|
158
|
+
isFetching: query.isFetching,
|
|
159
|
+
isError: query.isError,
|
|
160
|
+
isSuccess: query.isSuccess,
|
|
161
|
+
isStale: query.isStale,
|
|
162
|
+
error: query.error,
|
|
163
|
+
refetch: query.refetch
|
|
164
|
+
};
|
|
165
|
+
}
|
|
258
166
|
|
|
259
167
|
//#endregion
|
|
260
|
-
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
|
|
168
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|