@classytic/arc-next 0.1.2 → 0.2.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 +593 -51
- package/dist/api.d.ts +10 -6
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +24 -11
- package/dist/api.js.map +1 -1
- package/dist/hooks.d.ts +43 -5
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +134 -13
- package/dist/hooks.js.map +1 -1
- package/dist/mutation.d.ts +22 -16
- package/dist/mutation.d.ts.map +1 -1
- package/dist/mutation.js +9 -5
- package/dist/mutation.js.map +1 -1
- package/dist/query.d.ts +12 -4
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +26 -23
- package/dist/query.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @classytic/arc-next
|
|
2
2
|
|
|
3
|
-
React + TanStack Query SDK for Arc resources.
|
|
3
|
+
React + TanStack Query SDK for Arc resources. Typed CRUD hooks with optimistic updates, automatic rollback, multi-tenant scoping, pagination normalization, and detail cache prefilling. No separate state management library needed.
|
|
4
|
+
|
|
5
|
+
**Requires:** React 19+, TanStack React Query 5+
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -19,31 +21,49 @@ npm install react@^19 @tanstack/react-query@^5
|
|
|
19
21
|
Call the configuration functions once at app init (e.g., in your root providers):
|
|
20
22
|
|
|
21
23
|
```ts
|
|
22
|
-
import { configureClient } from "@classytic/arc-next/client";
|
|
24
|
+
import { configureClient, configureAuth } from "@classytic/arc-next/client";
|
|
23
25
|
import { configureToast } from "@classytic/arc-next/mutation";
|
|
24
26
|
import { configureNavigation } from "@classytic/arc-next/hooks";
|
|
25
27
|
import { toast } from "sonner";
|
|
26
28
|
import { useRouter } from "next/navigation";
|
|
27
29
|
|
|
28
|
-
// Required — sets the API base URL
|
|
30
|
+
// Required — sets the API base URL and auth mode
|
|
29
31
|
configureClient({
|
|
30
32
|
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
33
|
+
authMode: "cookie", // 'cookie' for Better Auth, 'bearer' for token auth (default)
|
|
31
34
|
internalApiKey: process.env.NEXT_PUBLIC_INTERNAL_API_KEY, // optional
|
|
32
35
|
});
|
|
33
36
|
|
|
34
|
-
// Optional —
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
// Optional — auto-inject org context into queries/mutations
|
|
38
|
+
configureAuth({
|
|
39
|
+
getOrgId: () => activeOrgId, // return current org ID
|
|
40
|
+
getToken: () => null, // null for cookie auth (token only for bearer)
|
|
38
41
|
});
|
|
39
42
|
|
|
43
|
+
// Optional — pluggable toast (defaults to console)
|
|
44
|
+
configureToast({ success: toast.success, error: toast.error });
|
|
45
|
+
|
|
40
46
|
// Optional — enables useNavigation() routing (defaults to cache-only)
|
|
41
47
|
configureNavigation(useRouter);
|
|
42
48
|
```
|
|
43
49
|
|
|
44
|
-
##
|
|
50
|
+
## Subpath Exports
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
| Import | Purpose | `"use client"` |
|
|
53
|
+
| ----------------------------------- | ---------------------------------------------------------------------------- | :-------------: |
|
|
54
|
+
| `@classytic/arc-next/client` | `configureClient`, `configureAuth`, `createClient`, `handleApiRequest`, `createQueryString`, `ArcApiError`, `isArcApiError`, `getAuthMode`, `getAuthContext` | No |
|
|
55
|
+
| `@classytic/arc-next/api` | `BaseApi`, `createCrudApi`, response types, type guards | No |
|
|
56
|
+
| `@classytic/arc-next/query` | `createQueryKeys`, `createCacheUtils`, `createListQuery`, `createDetailQuery`| Yes |
|
|
57
|
+
| `@classytic/arc-next/mutation` | `configureToast`, `useMutationWithTransition`, `createOptimisticMutation` | Yes |
|
|
58
|
+
| `@classytic/arc-next/hooks` | `createCrudHooks`, `configureNavigation` | Yes |
|
|
59
|
+
| `@classytic/arc-next/query-client` | `getQueryClient` (SSR-safe singleton) | No |
|
|
60
|
+
| `@classytic/arc-next/prefetch` | `createCrudPrefetcher`, `dehydrate` (SSR prefetch) | No |
|
|
61
|
+
|
|
62
|
+
No barrel index — every file is its own entry point. Tree-shakeable (`sideEffects: false`).
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
### 1. Define API
|
|
47
67
|
|
|
48
68
|
```ts
|
|
49
69
|
import { createCrudApi } from "@classytic/arc-next/api";
|
|
@@ -98,21 +118,20 @@ export function ProductsPage() {
|
|
|
98
118
|
|
|
99
119
|
const { create, remove, isCreating } = useProductActions();
|
|
100
120
|
|
|
101
|
-
const handleCreate = async () => {
|
|
102
|
-
await create({ data: { name: "New Product", price: 29.99 } });
|
|
103
|
-
};
|
|
104
|
-
|
|
105
121
|
if (isLoading) return <div>Loading...</div>;
|
|
106
122
|
|
|
107
123
|
return (
|
|
108
124
|
<div>
|
|
109
|
-
<button
|
|
125
|
+
<button
|
|
126
|
+
onClick={() => create({ data: { name: "Widget", price: 9.99 } })}
|
|
127
|
+
disabled={isCreating}
|
|
128
|
+
>
|
|
110
129
|
Add Product
|
|
111
130
|
</button>
|
|
112
|
-
{items.map((
|
|
113
|
-
<div key={
|
|
114
|
-
{
|
|
115
|
-
<button onClick={() => remove({ id:
|
|
131
|
+
{items.map((p) => (
|
|
132
|
+
<div key={p._id}>
|
|
133
|
+
{p.name} — ${p.price}
|
|
134
|
+
<button onClick={() => remove({ id: p._id })}>Delete</button>
|
|
116
135
|
</div>
|
|
117
136
|
))}
|
|
118
137
|
{pagination && <span>{pagination.total} total</span>}
|
|
@@ -121,50 +140,282 @@ export function ProductsPage() {
|
|
|
121
140
|
}
|
|
122
141
|
```
|
|
123
142
|
|
|
124
|
-
##
|
|
143
|
+
## API Reference
|
|
125
144
|
|
|
126
|
-
|
|
127
|
-
|---|---|:-:|
|
|
128
|
-
| `@classytic/arc-next/client` | `configureClient`, `handleApiRequest`, `createQueryString` | No |
|
|
129
|
-
| `@classytic/arc-next/api` | `BaseApi`, `createCrudApi`, response types, type guards | No |
|
|
130
|
-
| `@classytic/arc-next/query` | `createQueryKeys`, `createCacheUtils`, `createListQuery`, `createDetailQuery` | Yes |
|
|
131
|
-
| `@classytic/arc-next/mutation` | `configureToast`, `useMutationWithTransition`, `createOptimisticMutation` | Yes |
|
|
132
|
-
| `@classytic/arc-next/hooks` | `createCrudHooks`, `configureNavigation` | Yes |
|
|
133
|
-
| `@classytic/arc-next/query-client` | `getQueryClient` (SSR-safe singleton) | No |
|
|
145
|
+
### `configureClient(config)`
|
|
134
146
|
|
|
135
|
-
|
|
147
|
+
```ts
|
|
148
|
+
configureClient({
|
|
149
|
+
baseUrl: string; // Required — API base URL
|
|
150
|
+
authMode?: 'cookie' | 'bearer'; // Default: 'bearer'
|
|
151
|
+
internalApiKey?: string; // Optional — sent as x-internal-api-key header
|
|
152
|
+
defaultHeaders?: Record<string, string>; // Optional — merged into every request
|
|
153
|
+
});
|
|
154
|
+
```
|
|
136
155
|
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
- **Multi-Tenant Scoping** — `organizationId` in headers + scoped query keys
|
|
140
|
-
- **Pagination Normalization** — Handles `docs`/`data`/`items` response formats, offset/keyset/aggregate pagination
|
|
141
|
-
- **Detail Cache Prefilling** — List results auto-populate detail query cache
|
|
142
|
-
- **React 19 Transitions** — `useMutationWithTransition` wraps invalidation in `startTransition`
|
|
143
|
-
- **Pluggable Toast** — `configureToast()` — use sonner, react-hot-toast, or anything
|
|
144
|
-
- **Pluggable Navigation** — `configureNavigation()` — use Next.js, React Router, or any router
|
|
145
|
-
- **SSR-Safe QueryClient** — `getQueryClient()` — singleton in browser, new per request on server
|
|
146
|
-
- **Framework-Agnostic** — No hard dependency on Next.js
|
|
147
|
-
- **Tree-Shakeable** — `sideEffects: false`, flat files, no barrels
|
|
156
|
+
- `authMode: 'bearer'` (default) — requires a token for authenticated requests; queries are disabled until a token is provided
|
|
157
|
+
- `authMode: 'cookie'` — auth via HTTP-only cookies (e.g. Better Auth); queries are always enabled, no token needed
|
|
148
158
|
|
|
149
|
-
|
|
159
|
+
Must be called before any API requests. Throws if not configured.
|
|
150
160
|
|
|
151
|
-
|
|
161
|
+
### `configureAuth(config)`
|
|
152
162
|
|
|
153
163
|
```ts
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
configureAuth({
|
|
165
|
+
getToken?: () => string | null; // For bearer auth — return access token
|
|
166
|
+
getOrgId?: () => string | null; // Return active organization ID
|
|
167
|
+
});
|
|
168
|
+
```
|
|
156
169
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
Auto-injects `token` and `organizationId` into queries/mutations. Hooks use the new signature (no explicit token param) — legacy signature still works.
|
|
171
|
+
|
|
172
|
+
### `handleApiRequest<T>(method, endpoint, options?)`
|
|
173
|
+
|
|
174
|
+
Universal fetch wrapper. Handles JSON, PDF, image, CSV, and text responses.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const result = await handleApiRequest<ApiResponse<User>>("GET", "/api/users/me");
|
|
178
|
+
const list = await handleApiRequest<PaginatedResponse<Product>>("GET", "/api/products?page=1");
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Options:**
|
|
182
|
+
- `body` — request body (auto-serializes JSON, passes FormData as-is)
|
|
183
|
+
- `token` — Bearer token
|
|
184
|
+
- `organizationId` — sent as `x-organization-id` header
|
|
185
|
+
- `headerOptions` — additional headers merged into request
|
|
186
|
+
- `signal` — AbortSignal for request cancellation
|
|
187
|
+
- `revalidate` / `tags` / `cache` — Next.js fetch extensions
|
|
188
|
+
|
|
189
|
+
### `createQueryString(params)`
|
|
190
|
+
|
|
191
|
+
MongoKit-compatible query string builder:
|
|
192
|
+
- Arrays → `field[in]=a,b,c`
|
|
193
|
+
- `populateOptions` → `populate[path][select]=field1,field2`
|
|
194
|
+
- `null` → `field=null`
|
|
195
|
+
|
|
196
|
+
### `createCrudApi<TDoc, TCreate, TUpdate>(entity, config?)`
|
|
197
|
+
|
|
198
|
+
Creates a typed API client instance.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const api = createCrudApi<Product, CreateProduct>("products", {
|
|
202
|
+
basePath: "/api", // default: "/api/v1"
|
|
203
|
+
defaultParams: { limit: 20 },
|
|
204
|
+
cache: "no-store", // default
|
|
205
|
+
headers: { // optional — sent with every request from this instance
|
|
206
|
+
"x-arc-scope": "platform", // e.g. for superadmin elevation
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Methods:**
|
|
212
|
+
|
|
213
|
+
| Method | Signature |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `getAll` | `({ token?, organizationId?, params? }) → PaginatedResponse<T>` |
|
|
216
|
+
| `getById` | `({ id, token?, organizationId?, params? }) → ApiResponse<T>` |
|
|
217
|
+
| `create` | `({ data, token?, organizationId? }) → ApiResponse<T>` |
|
|
218
|
+
| `update` | `({ id, data, token?, organizationId? }) → ApiResponse<T>` |
|
|
219
|
+
| `delete` | `({ id, token?, organizationId? }) → DeleteResponse` |
|
|
220
|
+
| `upload` | `({ data: FormData, id?, path?, token?, organizationId? }) → ApiResponse<T>` |
|
|
221
|
+
| `search` | `({ searchParams?, params?, token?, organizationId? }) → PaginatedResponse<T>` |
|
|
222
|
+
| `findBy` | `({ field, value, operator?, token?, organizationId? }) → PaginatedResponse<T>` |
|
|
223
|
+
| `request` | `(method, endpoint, { data?, params?, token? }) → T` |
|
|
224
|
+
|
|
225
|
+
**`prepareParams(params)`** — processes query params: critical filters (`organizationId`, `ownerId`) preserved as null, arrays → `field[in]`, pagination parsed to int.
|
|
226
|
+
|
|
227
|
+
### `createCrudHooks<T, TCreate, TUpdate>(config)`
|
|
228
|
+
|
|
229
|
+
Factory that returns everything you need:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const {
|
|
233
|
+
KEYS, cache,
|
|
234
|
+
useList, useDetail, useInfiniteList,
|
|
235
|
+
useActions, useUpload, useSearch, useCustomMutation,
|
|
236
|
+
useNavigation,
|
|
237
|
+
} = createCrudHooks<Product, CreateProduct>({
|
|
238
|
+
api: productsApi, // from createCrudApi()
|
|
239
|
+
entityKey: "products", // TanStack Query key prefix
|
|
240
|
+
singular: "Product", // for toast messages
|
|
241
|
+
defaults: { // optional
|
|
242
|
+
staleTime: 60_000,
|
|
243
|
+
messages: { createSuccess: "Product added!" },
|
|
244
|
+
},
|
|
245
|
+
callbacks: { // optional
|
|
246
|
+
onCreate: {
|
|
247
|
+
onSuccess: (data) => console.log("Created:", data),
|
|
248
|
+
onSettled: (data, error) => console.log("Done"),
|
|
249
|
+
},
|
|
250
|
+
},
|
|
163
251
|
});
|
|
164
|
-
}
|
|
165
252
|
```
|
|
166
253
|
|
|
167
|
-
|
|
254
|
+
**Returned hooks:**
|
|
255
|
+
|
|
256
|
+
#### `useList(token, params?, options?)`
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const { items, pagination, isLoading, isFetching, refetch } = useList(
|
|
260
|
+
token,
|
|
261
|
+
{ organizationId: "org-123", status: "active" },
|
|
262
|
+
{ public: true, staleTime: 30_000, prefillDetailCache: true }
|
|
263
|
+
);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
- Auto-scopes query keys by `organizationId` (tenant vs super-admin)
|
|
267
|
+
- Normalizes pagination from `docs`/`data`/`items`/`results` formats
|
|
268
|
+
- Prefills detail cache from list results (skips re-fetch on navigate)
|
|
269
|
+
- `options.public: true` — enables query without token
|
|
270
|
+
|
|
271
|
+
**`select` transform** — transform raw API data before it reaches your component:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const { items } = useList(token, { organizationId }, {
|
|
275
|
+
select: (data) => ({
|
|
276
|
+
...data,
|
|
277
|
+
docs: data.docs.map((p) => ({ ...p, displayName: `${p.name} ($${p.price})` })),
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
#### `useDetail(id, token, options?)`
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const { item, isLoading } = useDetail(productId, token, {
|
|
286
|
+
organizationId: "org-123",
|
|
287
|
+
});
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- Disabled when `id` is null (conditional fetching)
|
|
291
|
+
- Extracts item from `{ data: T }` wrapper
|
|
292
|
+
|
|
293
|
+
**`select` transform:**
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
const { item } = useDetail(productId, token, {
|
|
297
|
+
select: (data) => ({ ...data.data, fullName: `${data.data.firstName} ${data.data.lastName}` }),
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
#### `useActions()`
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
const { create, update, remove, isCreating, isUpdating, isDeleting, isMutating } =
|
|
305
|
+
useActions();
|
|
306
|
+
|
|
307
|
+
// All mutations have optimistic updates + automatic rollback on error
|
|
308
|
+
await create({ data: { name: "New" }, organizationId: "org-123" });
|
|
309
|
+
await update({ id: "123", data: { name: "Updated" } });
|
|
310
|
+
await remove({ id: "123" });
|
|
311
|
+
|
|
312
|
+
// Per-call callbacks
|
|
313
|
+
await create(
|
|
314
|
+
{ data: { name: "New" } },
|
|
315
|
+
{ onSuccess: (item) => navigate(`/products/${item._id}`) }
|
|
316
|
+
);
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- **Create** — optimistic: prepends to list with temp ID
|
|
320
|
+
- **Update** — optimistic: patches item in list + detail cache
|
|
321
|
+
- **Delete** — optimistic: removes from list + detail cache
|
|
322
|
+
- All roll back automatically on error
|
|
323
|
+
|
|
324
|
+
#### `useNavigation()`
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
const navigate = useNavigation();
|
|
328
|
+
navigate(`/products/${id}`, product); // push + cache prefill
|
|
329
|
+
navigate(`/products/${id}`, product, { replace: true }); // replace
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Sets detail cache before navigation (instant page load, no loading spinner).
|
|
333
|
+
Requires `configureNavigation(useRouter)` — without it, only sets cache (no routing).
|
|
334
|
+
|
|
335
|
+
#### `useInfiniteList(token, params?, options?)`
|
|
336
|
+
|
|
337
|
+
Cursor-based infinite scrolling with automatic page aggregation:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
const { items, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading } =
|
|
341
|
+
useInfiniteList(token, { organizationId: "org-123", limit: 20 });
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- Supports both keyset (`hasMore`/`next`) and offset (`hasNext`/`page`) pagination
|
|
345
|
+
- Returns flattened `items` across all pages
|
|
346
|
+
- Auto-scopes query keys like `useList`
|
|
347
|
+
|
|
348
|
+
#### `useUpload(options?)`
|
|
349
|
+
|
|
350
|
+
Upload FormData with cache invalidation:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
const { mutateAsync: upload, isPending } = useUpload({
|
|
354
|
+
messages: { success: "Uploaded!", error: "Upload failed" },
|
|
355
|
+
onSuccess: (data) => console.log("Uploaded:", data),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Post to base collection URL
|
|
359
|
+
await upload({ data: formData });
|
|
360
|
+
// Post to /products/{id}/upload
|
|
361
|
+
await upload({ data: formData, id: "doc-123" });
|
|
362
|
+
// Post to /products/bulk-import (custom path takes precedence over id)
|
|
363
|
+
await upload({ data: formData, path: "bulk-import" });
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Requires `api.upload` to be defined. Throws if not available.
|
|
367
|
+
|
|
368
|
+
#### `useSearch(query, params?, options?)`
|
|
369
|
+
|
|
370
|
+
Search with automatic query key scoping:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
const { items, pagination, isLoading } = useSearch("widget", {
|
|
374
|
+
organizationId: "org-123",
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
- Disabled when `query` is empty
|
|
379
|
+
- Requires `api.search` to be defined
|
|
380
|
+
|
|
381
|
+
#### `useCustomMutation<TData, TVariables>(config)`
|
|
382
|
+
|
|
383
|
+
Build custom mutations that share the entity's toast and invalidation patterns:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
const { mutateAsync: publish, isPending } = useCustomMutation({
|
|
387
|
+
mutationFn: (id: string) => api.request("POST", `${api.baseUrl}/${id}/publish`),
|
|
388
|
+
invalidateQueries: [productKeys.lists()],
|
|
389
|
+
messages: { success: "Published!", error: "Failed to publish" },
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Query Keys (`KEYS`)
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
KEYS.all // ["products"]
|
|
397
|
+
KEYS.lists() // ["products", "list"]
|
|
398
|
+
KEYS.list(params) // ["products", "list", params]
|
|
399
|
+
KEYS.details() // ["products", "detail"]
|
|
400
|
+
KEYS.detail(id) // ["products", "detail", id]
|
|
401
|
+
KEYS.custom("stats", orgId) // ["products", "stats", orgId]
|
|
402
|
+
KEYS.scopedList("tenant", params) // ["products", "list", { _scope: "tenant", ...params }]
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Cache Utilities (`cache`)
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
await cache.invalidateAll(queryClient);
|
|
409
|
+
await cache.invalidateLists(queryClient);
|
|
410
|
+
await cache.invalidateDetail(queryClient, id);
|
|
411
|
+
cache.setDetail(queryClient, id, data);
|
|
412
|
+
cache.getDetail(queryClient, id); // T | undefined
|
|
413
|
+
cache.removeDetail(queryClient, id);
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### `getQueryClient(overrides?)`
|
|
417
|
+
|
|
418
|
+
SSR-safe singleton. Server: new per request. Browser: reuses singleton.
|
|
168
419
|
|
|
169
420
|
```ts
|
|
170
421
|
import { getQueryClient } from "@classytic/arc-next/query-client";
|
|
@@ -180,6 +431,297 @@ function Providers({ children }) {
|
|
|
180
431
|
}
|
|
181
432
|
```
|
|
182
433
|
|
|
434
|
+
Defaults: `staleTime: 5min`, `gcTime: 30min`, `retry: 0`, `refetchOnWindowFocus: false`.
|
|
435
|
+
|
|
436
|
+
## SSR Prefetch (Server Components)
|
|
437
|
+
|
|
438
|
+
Pre-populate the query cache on the server to avoid loading spinners:
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
// products-prefetch.ts
|
|
442
|
+
import { createCrudPrefetcher } from "@classytic/arc-next/prefetch";
|
|
443
|
+
import { productsApi } from "@/api/products-api";
|
|
444
|
+
|
|
445
|
+
export const productsPrefetcher = createCrudPrefetcher(productsApi, "products");
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
```tsx
|
|
449
|
+
// app/products/page.tsx (server component)
|
|
450
|
+
import { getQueryClient } from "@classytic/arc-next/query-client";
|
|
451
|
+
import { dehydrate } from "@classytic/arc-next/prefetch";
|
|
452
|
+
import { HydrationBoundary } from "@tanstack/react-query";
|
|
453
|
+
import { productsPrefetcher } from "@/prefetch/products-prefetch";
|
|
454
|
+
|
|
455
|
+
export default async function ProductsPage() {
|
|
456
|
+
const queryClient = getQueryClient();
|
|
457
|
+
await productsPrefetcher.prefetchList(queryClient, { limit: 20 });
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
461
|
+
<ProductsList />
|
|
462
|
+
</HydrationBoundary>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**Methods:** `prefetchList(queryClient, params?, options?)`, `prefetchDetail(queryClient, id, options?)`
|
|
468
|
+
|
|
469
|
+
## Custom Mutations
|
|
470
|
+
|
|
471
|
+
For operations beyond CRUD (publish, schedule, upload):
|
|
472
|
+
|
|
473
|
+
### `useMutationWithTransition(config)`
|
|
474
|
+
|
|
475
|
+
Mutation + React 19 `useTransition` for smooth cache invalidation:
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
import { useMutationWithTransition } from "@classytic/arc-next/mutation";
|
|
479
|
+
|
|
480
|
+
export function usePublishPost() {
|
|
481
|
+
return useMutationWithTransition({
|
|
482
|
+
mutationFn: (id: string) =>
|
|
483
|
+
postsApi.request("POST", `${postsApi.baseUrl}/${id}/publish`),
|
|
484
|
+
invalidateQueries: [postKeys.all],
|
|
485
|
+
messages: { success: "Published!", error: "Failed to publish" },
|
|
486
|
+
useTransition: true, // default
|
|
487
|
+
showToast: true, // default
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Returns: `{ mutate, mutateAsync, isPending, isSuccess, isError, error, data, reset }`
|
|
493
|
+
|
|
494
|
+
### `useMutationWithOptimistic(config)`
|
|
495
|
+
|
|
496
|
+
Mutation + optimistic updates + automatic rollback:
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
import { useMutationWithOptimistic } from "@classytic/arc-next/mutation";
|
|
500
|
+
|
|
501
|
+
export function useToggleFavorite() {
|
|
502
|
+
return useMutationWithOptimistic({
|
|
503
|
+
mutationFn: ({ id, isFav }) =>
|
|
504
|
+
api.request("PATCH", `/api/products/${id}`, {
|
|
505
|
+
data: { favorite: !isFav },
|
|
506
|
+
}),
|
|
507
|
+
queryKeys: [productKeys.lists()],
|
|
508
|
+
optimisticUpdate: (old, { id, isFav }) =>
|
|
509
|
+
updateListCache(old, (items) =>
|
|
510
|
+
items.map((i) => (getItemId(i) === id ? { ...i, favorite: !isFav } : i))
|
|
511
|
+
),
|
|
512
|
+
messages: { success: "Updated!" },
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### Query Config Presets
|
|
518
|
+
|
|
519
|
+
```ts
|
|
520
|
+
import { QUERY_CONFIGS } from "@classytic/arc-next/mutation";
|
|
521
|
+
|
|
522
|
+
// Use in useList options:
|
|
523
|
+
useProducts(token, {}, { ...QUERY_CONFIGS.realtime });
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
| Preset | `staleTime` | `refetchInterval` |
|
|
527
|
+
| ---------- | ----------- | ------------------ |
|
|
528
|
+
| `realtime` | 20s | 30s |
|
|
529
|
+
| `frequent` | 1min | — |
|
|
530
|
+
| `stable` | 5min | — |
|
|
531
|
+
| `static` | 10min | — |
|
|
532
|
+
|
|
533
|
+
## Low-Level Utilities
|
|
534
|
+
|
|
535
|
+
### `updateListCache(listData, updater)`
|
|
536
|
+
|
|
537
|
+
Transforms list cache regardless of format (`docs[]`, `data[]`, `items[]`, `results[]`, or raw array).
|
|
538
|
+
Automatically adjusts `total`/`totalDocs` counts when items are added or removed (optimistic add/delete).
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
import { updateListCache } from "@classytic/arc-next/query";
|
|
542
|
+
|
|
543
|
+
queryClient.setQueryData(KEYS.lists(), (old) =>
|
|
544
|
+
updateListCache(old, (items) => items.filter((i) => i.status !== "archived"))
|
|
545
|
+
);
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
### `getItemId(item)`
|
|
549
|
+
|
|
550
|
+
Extracts `_id` or `id` from any item. Returns `string | null`.
|
|
551
|
+
|
|
552
|
+
### `normalizePagination(data)`
|
|
553
|
+
|
|
554
|
+
Converts any pagination response format to a normalized `PaginationData` object. Handles `total`/`totalDocs`, `pages`/`totalPages`, `page`/`currentPage`, `hasNext`/`hasNextPage`/`hasMore`, `hasPrev`/`hasPrevPage`.
|
|
555
|
+
|
|
556
|
+
### `extractItems<T>(data)`
|
|
557
|
+
|
|
558
|
+
Extracts the items array from any response format — looks for `docs`, `data`, `items`, `results` fields, or returns the data directly if it's already an array.
|
|
559
|
+
|
|
560
|
+
## Multi-Client (Multiple APIs)
|
|
561
|
+
|
|
562
|
+
By default, `configureClient()` sets a single global `baseUrl`. Use `createClient()` when your app talks to multiple backends.
|
|
563
|
+
|
|
564
|
+
### Create isolated clients
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
import { createClient } from "@classytic/arc-next/client";
|
|
568
|
+
import { toast } from "sonner";
|
|
569
|
+
import { useRouter } from "next/navigation";
|
|
570
|
+
|
|
571
|
+
const analyticsClient = createClient({
|
|
572
|
+
baseUrl: "https://analytics.example.com",
|
|
573
|
+
internalApiKey: "analytics-key",
|
|
574
|
+
toast: { success: toast.success, error: toast.error },
|
|
575
|
+
navigation: useRouter,
|
|
576
|
+
});
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### Use with createCrudApi
|
|
580
|
+
|
|
581
|
+
Pass `client` in the config — requests go through the client's `baseUrl` instead of the global one:
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
const eventsApi = createCrudApi("events", {
|
|
585
|
+
basePath: "/api",
|
|
586
|
+
client: analyticsClient,
|
|
587
|
+
});
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Use with createCrudHooks
|
|
591
|
+
|
|
592
|
+
Pass `client` — toast and navigation use the client's handlers instead of globals:
|
|
593
|
+
|
|
594
|
+
```ts
|
|
595
|
+
const { useList, useActions } = createCrudHooks({
|
|
596
|
+
api: eventsApi,
|
|
597
|
+
entityKey: "events",
|
|
598
|
+
singular: "Event",
|
|
599
|
+
client: analyticsClient,
|
|
600
|
+
});
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### Direct requests
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
const data = await analyticsClient.request("GET", "/api/stats");
|
|
607
|
+
const result = await analyticsClient.request("POST", "/api/events", {
|
|
608
|
+
body: { type: "page_view" },
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
## Response Types
|
|
613
|
+
|
|
614
|
+
```ts
|
|
615
|
+
import type {
|
|
616
|
+
ApiResponse, // { success, data?, message? }
|
|
617
|
+
PaginatedResponse, // OffsetPaginationResponse | KeysetPaginationResponse | AggregatePaginationResponse
|
|
618
|
+
OffsetPaginationResponse, // { docs[], page, limit, total, pages, hasNext, hasPrev }
|
|
619
|
+
KeysetPaginationResponse, // { docs[], limit, hasMore, next }
|
|
620
|
+
AggregatePaginationResponse, // same shape as offset
|
|
621
|
+
DeleteResponse, // { success, data?: { message?, id?, soft? } }
|
|
622
|
+
} from "@classytic/arc-next/api";
|
|
623
|
+
|
|
624
|
+
// Type guards
|
|
625
|
+
import {
|
|
626
|
+
isOffsetPagination,
|
|
627
|
+
isKeysetPagination,
|
|
628
|
+
isAggregatePagination,
|
|
629
|
+
} from "@classytic/arc-next/api";
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
## Error Handling
|
|
633
|
+
|
|
634
|
+
All API errors throw `ArcApiError`:
|
|
635
|
+
|
|
636
|
+
```ts
|
|
637
|
+
import { ArcApiError, isArcApiError } from "@classytic/arc-next/client";
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
await productsApi.create({ data: { name: "" } });
|
|
641
|
+
} catch (err) {
|
|
642
|
+
if (isArcApiError(err)) {
|
|
643
|
+
console.log(err.status); // HTTP status code
|
|
644
|
+
console.log(err.message); // Error message from server
|
|
645
|
+
console.log(err.fieldErrors); // { field: "message" } or null
|
|
646
|
+
console.log(err.endpoint); // Request endpoint
|
|
647
|
+
console.log(err.method); // HTTP method
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
## Common Patterns
|
|
653
|
+
|
|
654
|
+
### Multi-tenant data fetching
|
|
655
|
+
|
|
656
|
+
```ts
|
|
657
|
+
// organizationId in params → scoped query key → isolated cache per tenant
|
|
658
|
+
const { items } = useProducts(token, { organizationId: currentOrg });
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
### Public endpoints (no auth)
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
const { items } = useProducts(null, {}, { public: true });
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Conditional fetching
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
const { item } = useProduct(selectedId, token); // disabled when selectedId is null
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Per-call callbacks
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
await create(
|
|
677
|
+
{ data: formData, organizationId: org },
|
|
678
|
+
{
|
|
679
|
+
onSuccess: (product) => router.push(`/products/${product._id}`),
|
|
680
|
+
onError: (err) => setFieldErrors(err),
|
|
681
|
+
onSettled: (data, error) => setSubmitting(false), // fires after success or error
|
|
682
|
+
}
|
|
683
|
+
);
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
### Navigate with cache prefill
|
|
687
|
+
|
|
688
|
+
```ts
|
|
689
|
+
const navigate = useProductNavigation();
|
|
690
|
+
// Prefills detail cache → no loading spinner on detail page
|
|
691
|
+
navigate(`/products/${product._id}`, product);
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
### Per-instance headers
|
|
695
|
+
|
|
696
|
+
```ts
|
|
697
|
+
// All requests from this API include x-arc-scope header
|
|
698
|
+
const adminApi = createCrudApi("users", {
|
|
699
|
+
headers: { "x-arc-scope": "platform" },
|
|
700
|
+
});
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
## Features
|
|
704
|
+
|
|
705
|
+
- **CRUD Factory** — `createCrudApi` + `createCrudHooks` generates typed API clients and React Query hooks
|
|
706
|
+
- **Optimistic Updates** — Create, update, delete with instant UI feedback and automatic rollback
|
|
707
|
+
- **Multi-Tenant Scoping** — `organizationId` in headers + scoped query keys
|
|
708
|
+
- **Pagination Normalization** — Handles `docs`/`data`/`items`/`results` response formats, offset/keyset/aggregate pagination
|
|
709
|
+
- **Detail Cache Prefilling** — List results auto-populate detail query cache
|
|
710
|
+
- **React 19 Transitions** — `useMutationWithTransition` wraps invalidation in `startTransition`
|
|
711
|
+
- **Cookie & Bearer Auth** — `authMode: 'cookie'` for Better Auth, `'bearer'` for token auth
|
|
712
|
+
- **SSR Prefetch** — `createCrudPrefetcher` + `dehydrate` for server component data loading
|
|
713
|
+
- **Multi-Client** — `createClient()` for multiple API backends side by side
|
|
714
|
+
- **Pluggable Toast** — `configureToast()` — use sonner, react-hot-toast, or anything
|
|
715
|
+
- **Pluggable Navigation** — `configureNavigation()` — use Next.js, React Router, or any router
|
|
716
|
+
- **SSR-Safe QueryClient** — `getQueryClient()` — singleton in browser, new per request on server
|
|
717
|
+
- **Per-Instance Headers** — `config.headers` on `createCrudApi` merged into every request
|
|
718
|
+
- **`select` Transform** — Transform raw API data before it reaches components (`useList`, `useDetail`)
|
|
719
|
+
- **`onSettled` Lifecycle** — Callback that fires after both success and error, at factory and per-call level
|
|
720
|
+
- **Automatic Request Cancellation** — AbortSignal passthrough — unmounted components cancel in-flight requests automatically
|
|
721
|
+
- **Query Config Presets** — `QUERY_CONFIGS.realtime/frequent/stable/static`
|
|
722
|
+
- **Framework-Agnostic** — No hard dependency on Next.js
|
|
723
|
+
- **Tree-Shakeable** — `sideEffects: false`, flat files, no barrels
|
|
724
|
+
|
|
183
725
|
## License
|
|
184
726
|
|
|
185
727
|
MIT
|