@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
package/README.md
CHANGED
|
@@ -1,538 +1,318 @@
|
|
|
1
1
|
# @classytic/arc-next
|
|
2
2
|
|
|
3
|
-
React + TanStack Query SDK for Arc
|
|
3
|
+
React + TanStack Query SDK for the Arc backend framework. Typed CRUD hooks, optimistic updates with rollback, multi-tenant cache scoping, pagination normalization, real-time SSE.
|
|
4
4
|
|
|
5
|
-
**
|
|
6
|
-
|
|
7
|
-
## Install
|
|
5
|
+
**Peers:** React 19+, TanStack React Query 5+
|
|
8
6
|
|
|
9
7
|
```bash
|
|
10
8
|
npm install @classytic/arc-next
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
**Peer dependencies:**
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm install react@^19 @tanstack/react-query@^5
|
|
17
|
-
```
|
|
18
|
-
|
|
19
11
|
## Setup
|
|
20
12
|
|
|
21
|
-
Call
|
|
13
|
+
Call once at app init from a `"use client"` provider:
|
|
22
14
|
|
|
23
15
|
```ts
|
|
24
|
-
import { configureClient, configureAuth } from "@classytic/arc-next/client";
|
|
16
|
+
import { configureClient, configureAuth, createAuthAwareClient } from "@classytic/arc-next/client";
|
|
25
17
|
import { configureToast } from "@classytic/arc-next/mutation";
|
|
26
18
|
import { configureNavigation } from "@classytic/arc-next/hooks";
|
|
27
|
-
import { toast } from "sonner";
|
|
28
|
-
import { useRouter } from "next/navigation";
|
|
29
|
-
|
|
30
|
-
// Required — sets the API base URL and auth mode
|
|
31
|
-
configureClient({
|
|
32
|
-
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
33
|
-
authMode: "cookie", // 'cookie' | 'bearer' (default) | 'header'
|
|
34
|
-
// apiVersion: '2', // sends Accept-Version header
|
|
35
|
-
// autoIdempotency: true, // auto Idempotency-Key on mutations (retry-safe)
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// Optional — auto-inject tenant context into queries/mutations
|
|
39
|
-
configureAuth({
|
|
40
|
-
getOrgId: () => activeTenantId, // return current tenant/org/workspace ID
|
|
41
|
-
getToken: () => null, // null for cookie auth (token only for bearer)
|
|
42
|
-
});
|
|
43
19
|
|
|
44
|
-
|
|
20
|
+
configureClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL!, authMode: "cookie" });
|
|
21
|
+
configureAuth({ getToken: () => session?.token ?? null, getOrgId: () => org?.id ?? null });
|
|
45
22
|
configureToast({ success: toast.success, error: toast.error });
|
|
46
|
-
|
|
47
|
-
// Optional — enables useNavigation() routing (defaults to cache-only)
|
|
48
23
|
configureNavigation(useRouter);
|
|
49
24
|
```
|
|
50
25
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
| Import | Purpose | `"use client"` |
|
|
54
|
-
| ----------------------------------- | ---------------------------------------------------------------------------- | :-------------: |
|
|
55
|
-
| `@classytic/arc-next` | Root — same as `/hooks` (`createCrudHooks`, `configureNavigation`) | Yes |
|
|
56
|
-
| `@classytic/arc-next/client` | `configureClient`, `configureAuth`, `createClient`, `handleApiRequest`, `createQueryString`, `ArcApiError`, `isArcApiError`, `getAuthMode`, `getAuthContext` | No |
|
|
57
|
-
| `@classytic/arc-next/api` | `BaseApi`, `createCrudApi`, response types, type guards | No |
|
|
58
|
-
| `@classytic/arc-next/query` | `createQueryKeys`, `createCacheUtils`, `useListQuery`, `useDetailQuery` | Yes |
|
|
59
|
-
| `@classytic/arc-next/mutation` | `configureToast`, `useMutationWithTransition`, `useOptimisticMutation` | Yes |
|
|
60
|
-
| `@classytic/arc-next/hooks` | `createCrudHooks`, `configureNavigation` | Yes |
|
|
61
|
-
| `@classytic/arc-next/query-client` | `getQueryClient` (SSR-safe singleton) | No |
|
|
62
|
-
| `@classytic/arc-next/prefetch` | `createCrudPrefetcher`, `dehydrate` (SSR prefetch) | No |
|
|
63
|
-
| `@classytic/arc-next/sse` | `useEventStream` (Server-Sent Events for real-time cache invalidation) | Yes |
|
|
64
|
-
|
|
65
|
-
No barrel index — every file is its own entry point. Tree-shakeable (`sideEffects: false`).
|
|
26
|
+
`getToken` **must be synchronous** — cache async tokens out-of-band. Promise returns are dropped + warned in dev.
|
|
66
27
|
|
|
67
28
|
## Quick Start
|
|
68
29
|
|
|
69
|
-
### 1. Define API
|
|
70
|
-
|
|
71
30
|
```ts
|
|
72
31
|
import { createCrudApi } from "@classytic/arc-next/api";
|
|
32
|
+
import { createCrudHooks } from "@classytic/arc-next/hooks";
|
|
33
|
+
import { withSoftDelete } from "@classytic/arc-next/presets/soft-delete";
|
|
34
|
+
import { withBulk } from "@classytic/arc-next/presets/bulk";
|
|
73
35
|
|
|
74
|
-
interface Product {
|
|
75
|
-
_id: string;
|
|
76
|
-
name: string;
|
|
77
|
-
price: number;
|
|
78
|
-
organizationId: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface CreateProduct {
|
|
82
|
-
name: string;
|
|
83
|
-
price: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export const productsApi = createCrudApi<Product, CreateProduct>(
|
|
87
|
-
"products",
|
|
88
|
-
{ basePath: "/api" }
|
|
89
|
-
);
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
### 2. Create hooks
|
|
36
|
+
interface Product { _id: string; name: string; price: number; }
|
|
93
37
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
38
|
+
// Compose only the presets your backend actually mounts.
|
|
39
|
+
// Vanilla `createCrudApi` ships CRUD + action + invokeRoute + upload only;
|
|
40
|
+
// add presets via factory wrappers (matches arc's server-side `presets: [...]`).
|
|
41
|
+
const productsApi = withBulk(withSoftDelete(
|
|
42
|
+
createCrudApi<Product>("products", { basePath: "/api" }),
|
|
43
|
+
));
|
|
97
44
|
|
|
98
45
|
export const {
|
|
99
|
-
KEYS
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
useNavigation: useProductNavigation,
|
|
105
|
-
} = createCrudHooks<Product, CreateProduct>({
|
|
106
|
-
api: productsApi,
|
|
107
|
-
entityKey: "products",
|
|
108
|
-
singular: "Product",
|
|
109
|
-
});
|
|
46
|
+
KEYS, cache,
|
|
47
|
+
useList, useDetail, useActions, useNavigation,
|
|
48
|
+
useInfiniteList, useUpload, useCustomMutation,
|
|
49
|
+
useDeleted, useBulkActions, useDetailBySlug, useTree, useChildren,
|
|
50
|
+
} = createCrudHooks<Product>({ api: productsApi, entityKey: "products", singular: "Product" });
|
|
110
51
|
```
|
|
111
52
|
|
|
112
|
-
### 3. Use in components
|
|
113
|
-
|
|
114
53
|
```tsx
|
|
115
54
|
"use client";
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const {
|
|
119
|
-
|
|
120
|
-
}, { public: true });
|
|
121
|
-
|
|
122
|
-
const { create, remove, isCreating } = useProductActions();
|
|
123
|
-
|
|
124
|
-
if (isLoading) return <div>Loading...</div>;
|
|
125
|
-
|
|
126
|
-
return (
|
|
127
|
-
<div>
|
|
128
|
-
<button
|
|
129
|
-
onClick={() => create({ data: { name: "Widget", price: 9.99 } })}
|
|
130
|
-
disabled={isCreating}
|
|
131
|
-
>
|
|
132
|
-
Add Product
|
|
133
|
-
</button>
|
|
134
|
-
{items.map((p) => (
|
|
135
|
-
<div key={p._id}>
|
|
136
|
-
{p.name} — ${p.price}
|
|
137
|
-
<button onClick={() => remove({ id: p._id })}>Delete</button>
|
|
138
|
-
</div>
|
|
139
|
-
))}
|
|
140
|
-
{pagination && <span>{pagination.total} total</span>}
|
|
141
|
-
</div>
|
|
142
|
-
);
|
|
55
|
+
function Products() {
|
|
56
|
+
const { items, pagination, isLoading } = useList(null, { organizationId: orgId });
|
|
57
|
+
const { create, update, remove, isCreating } = useActions();
|
|
58
|
+
// ...
|
|
143
59
|
}
|
|
144
60
|
```
|
|
145
61
|
|
|
146
|
-
##
|
|
147
|
-
|
|
148
|
-
### `configureClient(config)`
|
|
149
|
-
|
|
150
|
-
```ts
|
|
151
|
-
configureClient({
|
|
152
|
-
baseUrl: string; // Required — API base URL
|
|
153
|
-
authMode?: 'bearer' | 'cookie' | 'header'; // Default: 'bearer'
|
|
154
|
-
credentials?: RequestCredentials; // Default: derived from authMode
|
|
155
|
-
internalApiKey?: string; // Sent as x-internal-api-key header
|
|
156
|
-
defaultHeaders?: Record<string, string>; // Merged into every request
|
|
157
|
-
apiVersion?: string; // Sent as Accept-Version header
|
|
158
|
-
autoIdempotency?: boolean; // Auto Idempotency-Key on mutations (retry-safe)
|
|
159
|
-
});
|
|
160
|
-
```
|
|
62
|
+
## Subpath Exports
|
|
161
63
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
64
|
+
| Import | Server-safe | Exports |
|
|
65
|
+
|---|:-:|---|
|
|
66
|
+
| `/client` | yes | `configureClient`, `configureAuth`, `createClient`, `createAuthAwareClient`, `handleApiRequest`, `ArcApiError`, `isArcApiError`, `isAbortError`, `isArcErrorCode`, `KNOWN_TOP_LEVEL_CODES`, `KNOWN_DETAILS_CODES`, `getAuthMode`, `getAuthContext`, `getBaseUrl`, `createQueryString` |
|
|
67
|
+
| `/api` | yes | `BaseApi`, `createCrudApi`, response types + type guards |
|
|
68
|
+
| `/cache` | yes | `createQueryKeys`, `createCacheUtils`, `extractItem`, `extractItems`, `getItemId`, `updateListCache`, `normalizePagination`, `QUERY_CONFIGS`, `DEFAULT_QUERY_CONFIG` — server-safe utilities for RSC prefetch + Server Component imports |
|
|
69
|
+
| `/query` | client | `useApiQuery`, `useListQuery`, `useDetailQuery`, `useInfiniteListQuery` — React hooks. Re-exports the cache utilities for back-compat, but new code should import server-safe utils from `/cache` directly |
|
|
70
|
+
| `/mutation` | client | `configureToast`, `useMutationWithTransition`, `useMutationWithOptimistic` |
|
|
71
|
+
| `/hooks` | client | `createCrudHooks`, `configureNavigation` (also default export) |
|
|
72
|
+
| `/query-client` | yes | `getQueryClient` (SSR-safe singleton) |
|
|
73
|
+
| `/prefetch` | yes | `createCrudPrefetcher`, `dehydrate` |
|
|
74
|
+
| `/sse` | client | `useEventStream`, `buildSseUrl`, `subscribeToEvents` |
|
|
75
|
+
| `/ws` | client | `useWebSocket`, `buildWsUrl`, `connectWs` |
|
|
76
|
+
| `/upload` | client | `useUploadWithProgress`, `uploadWithProgress` — XHR-based uploads with native progress events |
|
|
77
|
+
| `/presets/soft-delete` | yes | `withSoftDelete` — adds `getDeleted`, `restore` |
|
|
78
|
+
| `/presets/bulk` | yes | `withBulk` — adds `bulkCreate`, `bulkUpdate`, `bulkDelete` |
|
|
79
|
+
| `/presets/slug` | yes | `withSlugLookup` — adds `getBySlug` |
|
|
80
|
+
| `/presets/tree` | yes | `withTree` — adds `getTree`, `getChildren` |
|
|
81
|
+
| `/presets/search` | yes | `withSearchPreset` — adds `searchEngine`, `searchSimilar`, `embed` |
|
|
165
82
|
|
|
166
|
-
|
|
83
|
+
`sideEffects: false`. No barrel — every file is its own entry point.
|
|
167
84
|
|
|
168
|
-
|
|
85
|
+
## Core Hooks (from `createCrudHooks`)
|
|
169
86
|
|
|
170
87
|
```ts
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
});
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
Auto-injects `token` and tenant ID (sent as `x-organization-id` header) into queries/mutations. The header name is a convention — your backend controls how it's read and which field it maps to (`organizationId`, `workspaceId`, `teamId`, etc.). Hooks use the new signature (no explicit token param) — legacy signature still works.
|
|
179
|
-
|
|
180
|
-
### `handleApiRequest<T>(method, endpoint, options?)`
|
|
88
|
+
const { items, pagination, isLoading, refetch } = useList(token, params, options);
|
|
89
|
+
const { item, isLoading } = useDetail(id, token, options);
|
|
90
|
+
const { create, update, remove, isMutating } = useActions();
|
|
91
|
+
const { items, hasNextPage, fetchNextPage } = useInfiniteList(token, params);
|
|
181
92
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
```ts
|
|
185
|
-
const result = await handleApiRequest<ApiResponse<User>>("GET", "/api/users/me");
|
|
186
|
-
const list = await handleApiRequest<PaginatedResponse<Product>>("GET", "/api/products?page=1");
|
|
93
|
+
await create({ data, organizationId }, { onSuccess: (item) => navigate(...) });
|
|
187
94
|
```
|
|
188
95
|
|
|
189
|
-
|
|
190
|
-
- `body` — request body (auto-serializes JSON, passes FormData as-is)
|
|
191
|
-
- `token` — Bearer token
|
|
192
|
-
- `organizationId` — sent as `x-organization-id` header
|
|
193
|
-
- `headerOptions` — additional headers merged into request
|
|
194
|
-
- `signal` — AbortSignal for request cancellation
|
|
195
|
-
- `revalidate` / `tags` / `cache` — Next.js fetch extensions
|
|
196
|
-
|
|
197
|
-
### `createQueryString(params)`
|
|
96
|
+
All mutations are optimistic with automatic rollback on error. Lists prefill the detail cache. Cache keys auto-scope by `organizationId` when present.
|
|
198
97
|
|
|
199
|
-
|
|
200
|
-
- Arrays → `field[in]=a,b,c`
|
|
201
|
-
- `populateOptions` → `populate[path][select]=field1,field2`
|
|
202
|
-
- `null` → `field=null`
|
|
98
|
+
### `useApiQuery` — non-CRUD reads
|
|
203
99
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
Creates a typed API client instance.
|
|
100
|
+
For reports, aggregates, RPC-style endpoints. Auto-unwraps `{ success, data }`:
|
|
207
101
|
|
|
208
102
|
```ts
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
},
|
|
103
|
+
import { useApiQuery } from "@classytic/arc-next/query";
|
|
104
|
+
|
|
105
|
+
const { data, isLoading } = useApiQuery<ApiResponse<DashboardStats>>({
|
|
106
|
+
queryKey: ["dashboard", "stats"],
|
|
107
|
+
queryFn: ({ signal }) => api.request("GET", "/dashboard/stats", { options: { signal } }),
|
|
108
|
+
freshness: "realtime", // 'realtime' | 'frequent' | 'stable' | 'static'
|
|
216
109
|
});
|
|
217
110
|
```
|
|
218
111
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
| Method | Signature |
|
|
222
|
-
|---|---|
|
|
223
|
-
| `getAll` | `({ token?, organizationId?, params? }) → PaginatedResponse<T>` |
|
|
224
|
-
| `getById` | `({ id, token?, organizationId?, params? }) → ApiResponse<T>` |
|
|
225
|
-
| `create` | `({ data, token?, organizationId? }) → ApiResponse<T>` |
|
|
226
|
-
| `update` | `({ id, data, token?, organizationId? }) → ApiResponse<T>` |
|
|
227
|
-
| `delete` | `({ id, token?, organizationId? }) → DeleteResponse` |
|
|
228
|
-
| `upload` | `({ data: FormData, id?, path?, token?, organizationId? }) → ApiResponse<T>` |
|
|
229
|
-
| `search` | `({ searchParams?, params?, token?, organizationId? }) → PaginatedResponse<T>` |
|
|
230
|
-
| `findBy` | `({ field, value, operator?, token?, organizationId? }) → PaginatedResponse<T>` |
|
|
231
|
-
| `request` | `(method, endpoint, { data?, params?, token? }) → T` |
|
|
232
|
-
|
|
233
|
-
**`prepareParams(params)`** — processes query params: critical filters (`organizationId`, `ownerId`) preserved as null, arrays → `field[in]`, pagination parsed to int.
|
|
234
|
-
|
|
235
|
-
### `createCrudHooks<T, TCreate, TUpdate>(config)`
|
|
112
|
+
Pass a custom `select` to override auto-unwrap.
|
|
236
113
|
|
|
237
|
-
|
|
114
|
+
## Actions & Custom Routes
|
|
238
115
|
|
|
239
|
-
|
|
240
|
-
const {
|
|
241
|
-
KEYS, cache,
|
|
242
|
-
useList, useDetail, useInfiniteList,
|
|
243
|
-
useActions, useBulkActions,
|
|
244
|
-
useDeleted, useDetailBySlug, useTree, useChildren, useFindBy,
|
|
245
|
-
useUpload, useSearch, useCustomMutation,
|
|
246
|
-
useNavigation,
|
|
247
|
-
} = createCrudHooks<Product, CreateProduct>({
|
|
248
|
-
api: productsApi, // from createCrudApi() — types inferred, no cast
|
|
249
|
-
entityKey: "products", // TanStack Query key prefix
|
|
250
|
-
singular: "Product", // for toast messages
|
|
251
|
-
idField: "sku", // optional — custom ID field for cache keys (default: _id → id)
|
|
252
|
-
defaults: { // optional
|
|
253
|
-
staleTime: 60_000,
|
|
254
|
-
messages: { createSuccess: "Product added!" },
|
|
255
|
-
},
|
|
256
|
-
callbacks: { // optional
|
|
257
|
-
onCreate: {
|
|
258
|
-
onSuccess: (data) => console.log("Created:", data),
|
|
259
|
-
onSettled: (data, error) => console.log("Done"),
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
**Returned hooks:**
|
|
266
|
-
|
|
267
|
-
#### `useList(token, params?, options?)`
|
|
116
|
+
Two escape hatches when CRUD isn't enough — both `BaseApi` methods, both routed through your configured client/auth:
|
|
268
117
|
|
|
269
118
|
```ts
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
);
|
|
275
|
-
```
|
|
119
|
+
// POST /:id/action — discriminator-style state transitions
|
|
120
|
+
// Named `dispatchAction` so consumer subclasses can keep their own `action()` method.
|
|
121
|
+
await api.dispatchAction({ id, action: "complete" });
|
|
122
|
+
await api.dispatchAction({ id, action: "prioritize", data: { priority: 7 } });
|
|
276
123
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
select: (data) => ({
|
|
287
|
-
...data,
|
|
288
|
-
docs: data.docs.map((p) => ({ ...p, displayName: `${p.name} ($${p.price})` })),
|
|
289
|
-
}),
|
|
124
|
+
// Resource-relative custom routes (defineResource({ routes: [...] }))
|
|
125
|
+
const stats = await api.invokeRoute<{ data: { total: number } }>({
|
|
126
|
+
method: "GET",
|
|
127
|
+
path: "/stats",
|
|
128
|
+
});
|
|
129
|
+
const recent = await api.invokeRoute<PaginatedResponse<Todo>>({
|
|
130
|
+
method: "GET",
|
|
131
|
+
path: "/recent",
|
|
132
|
+
params: { limit: 5 },
|
|
290
133
|
});
|
|
291
134
|
```
|
|
292
135
|
|
|
293
|
-
|
|
136
|
+
The `useAction` hook (returned from `createCrudHooks`) wraps `api.dispatchAction()` with toast + invalidation. For custom GETs, compose `api.invokeRoute()` with `useApiQuery` — it auto-unwraps the `{ success, data }` envelope:
|
|
294
137
|
|
|
295
138
|
```ts
|
|
296
|
-
const {
|
|
297
|
-
|
|
139
|
+
const { data } = useApiQuery({
|
|
140
|
+
queryKey: ["todos", "stats"],
|
|
141
|
+
queryFn: ({ signal }) => api.invokeRoute({ path: "/stats", options: { signal } }),
|
|
142
|
+
freshness: "frequent",
|
|
298
143
|
});
|
|
299
144
|
```
|
|
300
145
|
|
|
301
|
-
|
|
302
|
-
- Extracts item from `{ data: T }` wrapper
|
|
303
|
-
|
|
304
|
-
**`select` transform:**
|
|
146
|
+
## Presets — Opt-In Methods
|
|
305
147
|
|
|
306
|
-
|
|
307
|
-
const { item } = useDetail(productId, token, {
|
|
308
|
-
select: (data) => ({ ...data.data, fullName: `${data.data.firstName} ${data.data.lastName}` }),
|
|
309
|
-
});
|
|
310
|
-
```
|
|
148
|
+
Vanilla `createCrudApi(...)` ships only the always-on surface (CRUD + `action` + `invokeRoute` + `upload`). Backend presets — soft-delete, bulk, slug-lookup, tree, search — light up extra routes; the SDK mirrors that with **factory wrappers**, so autocomplete only shows what your resource actually exposes and unused code tree-shakes out of the bundle.
|
|
311
149
|
|
|
312
|
-
|
|
150
|
+
> No separate `search()` / `findBy()` methods — they hit the same `GET /` as `getAll()`. Pass operators directly via params: `getAll({ params: { 'title[contains]': q, 'priority[gte]': 5 } })`. Mongokit URL grammar handles all bracket operators including geo.
|
|
313
151
|
|
|
314
152
|
```ts
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
await update({ id: "123", data: { name: "Updated" } });
|
|
321
|
-
await remove({ id: "123" });
|
|
322
|
-
|
|
323
|
-
// Per-call callbacks
|
|
324
|
-
await create(
|
|
325
|
-
{ data: { name: "New" } },
|
|
326
|
-
{ onSuccess: (item) => navigate(`/products/${item._id}`) }
|
|
327
|
-
);
|
|
328
|
-
```
|
|
153
|
+
import { withSoftDelete } from "@classytic/arc-next/presets/soft-delete";
|
|
154
|
+
import { withBulk } from "@classytic/arc-next/presets/bulk";
|
|
155
|
+
import { withSlugLookup } from "@classytic/arc-next/presets/slug";
|
|
156
|
+
import { withTree } from "@classytic/arc-next/presets/tree";
|
|
157
|
+
import { withSearchPreset } from "@classytic/arc-next/presets/search";
|
|
329
158
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
159
|
+
// Stack only what the backend has registered
|
|
160
|
+
const todosApi = withBulk(withSoftDelete(createCrudApi<Todo>("todos")));
|
|
161
|
+
const placesApi = withSearchPreset(createCrudApi<Place>("places"));
|
|
162
|
+
const categoriesApi = withTree(withSlugLookup(createCrudApi<Category>("categories")));
|
|
334
163
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
navigate(`/products/${id}`, product, { replace: true }); // replace
|
|
164
|
+
// Only categoriesApi has getBySlug + getTree + getChildren in autocomplete.
|
|
165
|
+
// `placesApi.embed` won't show up. `todosApi.searchEngine` is a type error.
|
|
166
|
+
await todosApi.bulkCreate({ data: [{ title: "A" }, { title: "B" }] });
|
|
167
|
+
await placesApi.searchEngine({ query: "park", body: { topK: 10 } });
|
|
168
|
+
await categoriesApi.getBySlug({ slug: "engineering" });
|
|
341
169
|
```
|
|
342
170
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
171
|
+
| Preset | Adds methods | Backend route |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `withSoftDelete` | `getDeleted`, `restore` | `softDelete` preset |
|
|
174
|
+
| `withBulk` | `bulkCreate`, `bulkUpdate`, `bulkDelete` | `bulk` preset |
|
|
175
|
+
| `withSlugLookup` | `getBySlug` | `slugLookup` preset |
|
|
176
|
+
| `withTree` | `getTree`, `getChildren` | `tree` preset |
|
|
177
|
+
| `withSearchPreset` | `searchEngine`, `searchSimilar`, `embed` | `searchPreset()` |
|
|
347
178
|
|
|
348
|
-
|
|
179
|
+
The hook variants (`useDeleted`, `useBulkActions`, `useDetailBySlug`, `useTree`, `useChildren`, `useSearchEngine`, `useSearchSimilar`, `useEmbed`) are returned from `createCrudHooks` and gracefully throw at call time when the api wasn't wrapped with the matching preset.
|
|
349
180
|
|
|
350
|
-
|
|
351
|
-
const { items, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading } =
|
|
352
|
-
useInfiniteList(token, { organizationId: "org-123", limit: 20 });
|
|
353
|
-
```
|
|
181
|
+
## Filter operators (mongokit URL grammar)
|
|
354
182
|
|
|
355
|
-
-
|
|
356
|
-
- Returns flattened `items` across all pages
|
|
357
|
-
- Auto-scopes query keys like `useList`
|
|
183
|
+
Pass any operator via bracket-key params — `prepareParams` keeps operator-keyed arrays as comma-joined tuples (no `[in]` rewriting), so you get the exact wire shape mongokit's `QueryParser` expects.
|
|
358
184
|
|
|
359
|
-
|
|
185
|
+
```ts
|
|
186
|
+
// Range / comparison
|
|
187
|
+
await api.getAll({ params: { 'priority[gte]': 5, 'price[between]': '10,100' } });
|
|
360
188
|
|
|
361
|
-
|
|
189
|
+
// Pattern matching
|
|
190
|
+
await api.getAll({ params: { 'title[contains]': 'urgent' } });
|
|
362
191
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
onSuccess: (data) => console.log("Uploaded:", data),
|
|
367
|
-
});
|
|
192
|
+
// IN list (auto-rewritten from plain array on plain field name)
|
|
193
|
+
await api.getAll({ params: { status: ['active', 'pending'] } });
|
|
194
|
+
// → status[in]=active,pending
|
|
368
195
|
|
|
369
|
-
//
|
|
370
|
-
await
|
|
371
|
-
|
|
372
|
-
await
|
|
373
|
-
// Post to /products/bulk-import (custom path takes precedence over id)
|
|
374
|
-
await upload({ data: formData, path: "bulk-import" });
|
|
196
|
+
// Geo — coordinate tuples preserved as-is
|
|
197
|
+
await api.getAll({ params: { 'location[withinRadius]': [-73.98, 40.75, 5_000] } });
|
|
198
|
+
await api.getAll({ params: { 'location[near]': [-73.98, 40.75, 4_000] } });
|
|
199
|
+
await api.getAll({ params: { 'location[geoWithin]': [-74.02, 40.7, -73.93, 40.79] } });
|
|
375
200
|
```
|
|
376
201
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
#### `useSearch(query, params?, options?)`
|
|
202
|
+
Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `regex`, `like`, `exists`, `between`, `near`, `nearSphere`, `withinRadius`, `geoWithin`.
|
|
380
203
|
|
|
381
|
-
|
|
204
|
+
## SSE — Real-Time
|
|
382
205
|
|
|
383
206
|
```ts
|
|
384
|
-
|
|
385
|
-
organizationId: "org-123",
|
|
386
|
-
});
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
- Disabled when `query` is empty
|
|
390
|
-
- Requires `api.search` to be defined
|
|
207
|
+
import { useEventStream, buildSseUrl } from "@classytic/arc-next/sse";
|
|
391
208
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
209
|
+
useEventStream({
|
|
210
|
+
resource: "agents", // auto-derives [agents.created, agents.updated, agents.deleted]
|
|
211
|
+
invalidateQueries: [agentKeys.lists()], // refetch on every event
|
|
212
|
+
});
|
|
395
213
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
messages: { success: "Published!", error: "Failed to publish" },
|
|
214
|
+
// Or explicit named events (Arc ssePlugin emits `event: <type>` frames):
|
|
215
|
+
useEventStream({
|
|
216
|
+
eventTypes: ["sync-job.phase", "sync-job.completed"],
|
|
217
|
+
onEvent: (event) => { /* ... */ },
|
|
401
218
|
});
|
|
219
|
+
|
|
220
|
+
// Build authenticated SSE URLs for ad-hoc EventSource consumers:
|
|
221
|
+
const url = buildSseUrl("/jobs/stream", { jobId });
|
|
402
222
|
```
|
|
403
223
|
|
|
404
|
-
|
|
224
|
+
## WebSocket — Real-Time + Bidirectional
|
|
405
225
|
|
|
406
|
-
|
|
226
|
+
```ts
|
|
227
|
+
import { useWebSocket, buildWsUrl } from "@classytic/arc-next/ws";
|
|
407
228
|
|
|
408
|
-
|
|
229
|
+
const { isConnected, lastMessage, send, subscribe, unsubscribe } = useWebSocket({
|
|
230
|
+
subscribe: ["todo"], // sends {type:'subscribe', resource:'todo'} on open
|
|
231
|
+
invalidateQueries: [todoKeys.lists()], // refetch on every broadcast
|
|
232
|
+
patterns: ["todo.", "order.completed"], // filter — prefix match (`x.`) or exact
|
|
233
|
+
onMessage: (msg) => console.log(msg.type, msg.data),
|
|
234
|
+
heartbeatInterval: 30_000, // optional app-level ping
|
|
235
|
+
});
|
|
409
236
|
|
|
410
|
-
|
|
237
|
+
// Send any JSON payload — returns false if not connected
|
|
238
|
+
send({ type: "chat.message", text: "hi" });
|
|
411
239
|
|
|
412
|
-
|
|
240
|
+
// Build the URL for a raw WebSocket consumer (Node, worker, etc.)
|
|
241
|
+
const url = buildWsUrl("/ws", { roomId: "r-1" });
|
|
242
|
+
```
|
|
413
243
|
|
|
414
|
-
|
|
244
|
+
Subscriptions persist across reconnects — anything passed in `subscribe` (or via `subscribe()`) is auto-resent after the socket re-opens.
|
|
415
245
|
|
|
416
|
-
|
|
246
|
+
## Uploads with Progress
|
|
417
247
|
|
|
418
|
-
|
|
248
|
+
`fetch()` lacks a cross-browser upload-progress API, so arc-next ships a separate XHR-based pipeline at `/upload`. Same auth + error envelope as the fetch path:
|
|
419
249
|
|
|
420
|
-
|
|
250
|
+
```ts
|
|
251
|
+
import { useUploadWithProgress } from "@classytic/arc-next/upload";
|
|
421
252
|
|
|
422
|
-
|
|
253
|
+
const { upload, progress, isUploading, cancel, error } = useUploadWithProgress<
|
|
254
|
+
{ url: string },
|
|
255
|
+
{ file: File; folder?: string }
|
|
256
|
+
>({
|
|
257
|
+
url: "/api/v1/media/upload",
|
|
258
|
+
buildFormData: ({ file, folder }) => {
|
|
259
|
+
const fd = new FormData();
|
|
260
|
+
if (folder) fd.append("folder", folder);
|
|
261
|
+
fd.append("file", file);
|
|
262
|
+
return fd;
|
|
263
|
+
},
|
|
264
|
+
invalidateQueries: [mediaKeys.lists()],
|
|
265
|
+
messages: { success: "Uploaded" },
|
|
266
|
+
});
|
|
423
267
|
|
|
424
|
-
|
|
425
|
-
const { items } = useFindBy("status", "active");
|
|
426
|
-
const { items } = useFindBy("price", 50, { operator: "gte" });
|
|
268
|
+
// Bind progress.percent to a <ProgressBar /> — every tick re-renders.
|
|
427
269
|
```
|
|
428
270
|
|
|
429
|
-
|
|
271
|
+
For non-React consumers, `uploadWithProgress({ url, formData, onProgress, signal })` returns a Promise.
|
|
430
272
|
|
|
431
|
-
|
|
432
|
-
const { bulkCreate, bulkUpdate, bulkRemove } = useBulkActions();
|
|
433
|
-
await bulkCreate({ data: [{ name: "A" }, { name: "B" }] });
|
|
434
|
-
```
|
|
273
|
+
> **Divergence from the fetch path:** `ClientConfig.retry`, `beforeRequest`, and `afterResponse` do **not** propagate to uploads. Re-trying multi-MB bodies is rarely wanted (re-encoding cost, duplicate-write risk) and bridging XHR progress into the fetch interceptor pipeline would conflict with the upload-progress contract. Trace/correlation headers, latency loggers, and other interceptor logic must be passed explicitly via the `headers` option (or the `headers` factory on `useUploadWithProgress`). Auth, error parsing, `Idempotency-Key`, `x-arc-scope`, and `Accept-Version` all DO carry over.
|
|
435
274
|
|
|
436
|
-
|
|
275
|
+
## Multi-Client
|
|
437
276
|
|
|
438
|
-
|
|
277
|
+
Each `createClient` call is independent — its own `baseUrl`, auth, headers:
|
|
439
278
|
|
|
440
279
|
```ts
|
|
441
|
-
import {
|
|
442
|
-
|
|
443
|
-
// Global stream — all events (matches Arc's /events/stream)
|
|
444
|
-
const { isConnected } = useEventStream({
|
|
445
|
-
invalidateQueries: [agentKeys.lists()],
|
|
446
|
-
});
|
|
280
|
+
import { createClient } from "@classytic/arc-next/client";
|
|
447
281
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
282
|
+
const analytics = createClient({
|
|
283
|
+
baseUrl: "https://analytics.example.com",
|
|
284
|
+
authMode: "header",
|
|
285
|
+
getToken: () => env.ANALYTICS_KEY,
|
|
286
|
+
headerName: "x-api-key",
|
|
452
287
|
});
|
|
453
288
|
|
|
454
|
-
|
|
455
|
-
const { isConnected } = useEventStream({
|
|
456
|
-
path: "/api/v2/events",
|
|
457
|
-
patterns: ["orders.created", "orders.updated"],
|
|
458
|
-
});
|
|
289
|
+
const eventsApi = createCrudApi("events", { client: analytics });
|
|
459
290
|
```
|
|
460
291
|
|
|
461
|
-
|
|
292
|
+
For consumer SDKs that just need to bridge the global auth singleton:
|
|
462
293
|
|
|
463
294
|
```ts
|
|
464
|
-
|
|
465
|
-
KEYS.lists() // ["products", "list"]
|
|
466
|
-
KEYS.list(params) // ["products", "list", params]
|
|
467
|
-
KEYS.details() // ["products", "detail"]
|
|
468
|
-
KEYS.detail(id) // ["products", "detail", id]
|
|
469
|
-
KEYS.scopedDetail(id, orgId) // ["products", "detail", id, { _org: orgId }] (or bare when null)
|
|
470
|
-
KEYS.custom("stats", orgId) // ["products", "stats", orgId]
|
|
471
|
-
KEYS.scopedList("tenant", params) // ["products", "list", { _scope: "tenant", ...params }]
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
### Cache Utilities (`cache`)
|
|
295
|
+
import { createAuthAwareClient } from "@classytic/arc-next/client";
|
|
475
296
|
|
|
476
|
-
|
|
477
|
-
// Bare (single-tenant or public)
|
|
478
|
-
cache.setDetail(queryClient, id, data);
|
|
479
|
-
cache.getDetail(queryClient, id);
|
|
480
|
-
cache.removeDetail(queryClient, id);
|
|
481
|
-
await cache.invalidateDetail(queryClient, id); // prefix-matches ALL scoped variants
|
|
482
|
-
|
|
483
|
-
// Tenant-scoped (multi-tenant — isolated per org)
|
|
484
|
-
cache.setScopedDetail(queryClient, id, orgId, data);
|
|
485
|
-
cache.getScopedDetail(queryClient, id, orgId);
|
|
486
|
-
cache.removeScopedDetail(queryClient, id, orgId);
|
|
487
|
-
await cache.invalidateScopedDetail(queryClient, id, orgId);
|
|
488
|
-
|
|
489
|
-
// Global
|
|
490
|
-
await cache.invalidateAll(queryClient);
|
|
491
|
-
await cache.invalidateLists(queryClient);
|
|
297
|
+
const api = createCrudApi("products", { client: createAuthAwareClient() });
|
|
492
298
|
```
|
|
493
299
|
|
|
494
|
-
|
|
300
|
+
## SSR Prefetch (Next.js App Router / Server Components)
|
|
495
301
|
|
|
496
|
-
|
|
302
|
+
`createCrudPrefetcher` plus `getQueryClient` give you the canonical TanStack Query × Next.js App Router pattern: per-request `QueryClient` on the server, prefetch on the route, hydrate into a `"use client"` child via `HydrationBoundary`.
|
|
497
303
|
|
|
498
|
-
```
|
|
304
|
+
```tsx
|
|
305
|
+
// app/products/page.tsx — Server Component (no "use client")
|
|
306
|
+
import { createCrudPrefetcher, dehydrate, HydrationBoundary } from "@classytic/arc-next/prefetch";
|
|
499
307
|
import { getQueryClient } from "@classytic/arc-next/query-client";
|
|
500
|
-
import { QueryClientProvider } from "@tanstack/react-query";
|
|
501
|
-
|
|
502
|
-
function Providers({ children }) {
|
|
503
|
-
const queryClient = getQueryClient();
|
|
504
|
-
return (
|
|
505
|
-
<QueryClientProvider client={queryClient}>
|
|
506
|
-
{children}
|
|
507
|
-
</QueryClientProvider>
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
Defaults: `staleTime: 5min`, `gcTime: 30min`, `retry: 0`, `refetchOnWindowFocus: false`.
|
|
513
|
-
|
|
514
|
-
## SSR Prefetch (Server Components)
|
|
515
|
-
|
|
516
|
-
Pre-populate the query cache on the server to avoid loading spinners:
|
|
517
|
-
|
|
518
|
-
```ts
|
|
519
|
-
// products-prefetch.ts
|
|
520
|
-
import { createCrudPrefetcher } from "@classytic/arc-next/prefetch";
|
|
521
308
|
import { productsApi } from "@/api/products-api";
|
|
309
|
+
import { ProductsList } from "./products-list"; // "use client"
|
|
522
310
|
|
|
523
|
-
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
```tsx
|
|
527
|
-
// app/products/page.tsx (server component)
|
|
528
|
-
import { getQueryClient } from "@classytic/arc-next/query-client";
|
|
529
|
-
import { dehydrate } from "@classytic/arc-next/prefetch";
|
|
530
|
-
import { HydrationBoundary } from "@tanstack/react-query";
|
|
531
|
-
import { productsPrefetcher } from "@/prefetch/products-prefetch";
|
|
311
|
+
const prefetcher = createCrudPrefetcher(productsApi, "products");
|
|
532
312
|
|
|
533
313
|
export default async function ProductsPage() {
|
|
534
|
-
const queryClient = getQueryClient();
|
|
535
|
-
await
|
|
314
|
+
const queryClient = getQueryClient(); // per-request on server
|
|
315
|
+
await prefetcher.prefetchList(queryClient, { limit: 20 }, { token, organizationId });
|
|
536
316
|
|
|
537
317
|
return (
|
|
538
318
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
@@ -542,297 +322,127 @@ export default async function ProductsPage() {
|
|
|
542
322
|
}
|
|
543
323
|
```
|
|
544
324
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
All accept auth options for protected routes:
|
|
548
|
-
|
|
549
|
-
```ts
|
|
550
|
-
await prefetcher.prefetchList(queryClient, { limit: 20 }, {
|
|
551
|
-
token: serverToken, // for bearer/header auth
|
|
552
|
-
organizationId: "org-1", // for multi-tenant
|
|
553
|
-
headers: { "x-api-key": apiKey }, // for custom header auth
|
|
554
|
-
});
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
## Custom Mutations
|
|
325
|
+
Methods: `prefetchList`, `prefetchDetail`, `prefetchBySlug`, `prefetchDeleted`, `prefetchTree`, `prefetchInfiniteList`.
|
|
558
326
|
|
|
559
|
-
|
|
327
|
+
> `prefetchInfiniteList` seeds the `{ pages, pageParams }` cache shape `useInfiniteQuery` expects — a flat `prefetchQuery` won't match and the hook would re-fetch from scratch.
|
|
560
328
|
|
|
561
|
-
###
|
|
329
|
+
### Streaming with promise-pending dehydration (TanStack Query 5.40+)
|
|
562
330
|
|
|
563
|
-
|
|
331
|
+
`getQueryClient()` ships a default `dehydrate.shouldDehydrateQuery` that includes pending queries, so you can fire-and-forget prefetches inside Suspense boundaries:
|
|
564
332
|
|
|
565
|
-
```
|
|
566
|
-
|
|
333
|
+
```tsx
|
|
334
|
+
export default function ProductsPage() {
|
|
335
|
+
const queryClient = getQueryClient();
|
|
336
|
+
// No await — prefetch streams to client when ready
|
|
337
|
+
prefetcher.prefetchList(queryClient, { limit: 20 });
|
|
567
338
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
showToast: true, // default
|
|
576
|
-
});
|
|
339
|
+
return (
|
|
340
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
341
|
+
<Suspense fallback={<ListSkeleton />}>
|
|
342
|
+
<ProductsList />
|
|
343
|
+
</Suspense>
|
|
344
|
+
</HydrationBoundary>
|
|
345
|
+
);
|
|
577
346
|
}
|
|
578
347
|
```
|
|
579
348
|
|
|
580
|
-
|
|
349
|
+
### Server-safe utilities
|
|
581
350
|
|
|
582
|
-
|
|
351
|
+
Pure helpers (`createQueryKeys`, `extractItem`, `updateListCache`, etc.) live in `@classytic/arc-next/cache` — no `"use client"` directive, so they're safe to import from Server Components for custom prefetch flows. The matching React hooks live in `/query`.
|
|
583
352
|
|
|
584
|
-
|
|
353
|
+
### Next.js 16 `cacheComponents` + `'use cache'`
|
|
585
354
|
|
|
586
|
-
|
|
587
|
-
import { useMutationWithOptimistic } from "@classytic/arc-next/mutation";
|
|
588
|
-
|
|
589
|
-
export function useToggleFavorite() {
|
|
590
|
-
return useMutationWithOptimistic({
|
|
591
|
-
mutationFn: ({ id, isFav }) =>
|
|
592
|
-
api.request("PATCH", `/api/products/${id}`, {
|
|
593
|
-
data: { favorite: !isFav },
|
|
594
|
-
}),
|
|
595
|
-
queryKeys: [productKeys.lists()],
|
|
596
|
-
optimisticUpdate: (old, { id, isFav }) =>
|
|
597
|
-
updateListCache(old, (items) =>
|
|
598
|
-
items.map((i) => (getItemId(i) === id ? { ...i, favorite: !isFav } : i))
|
|
599
|
-
),
|
|
600
|
-
messages: { success: "Updated!" },
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
```
|
|
355
|
+
TanStack Query manages a client-side cache; data fetched through arc-next hooks should NOT be wrapped in a Server Component's `'use cache'` directive (which would bake the hook output into the static render). Use `'use cache'` for non-arc Server Component fetches (e.g., direct DB queries, third-party APIs). The two layers compose cleanly because they target different cache tiers.
|
|
604
356
|
|
|
605
|
-
|
|
357
|
+
## Errors
|
|
606
358
|
|
|
607
359
|
```ts
|
|
608
|
-
import {
|
|
360
|
+
import { isArcApiError, isAbortError, isArcErrorCode } from "@classytic/arc-next/client";
|
|
609
361
|
|
|
610
|
-
|
|
611
|
-
|
|
362
|
+
try { await api.create({ data, options: { signal } }); }
|
|
363
|
+
catch (err) {
|
|
364
|
+
if (isAbortError(err)) return; // user navigated away — silence
|
|
365
|
+
if (isArcApiError(err)) {
|
|
366
|
+
err.status; // 422
|
|
367
|
+
err.fieldErrors; // { email: "already taken" } | null
|
|
368
|
+
err.endpoint; // '/api/products'
|
|
369
|
+
}
|
|
370
|
+
if (isArcErrorCode(err, 'DUPLICATE_KEY')) showRetryUI();
|
|
371
|
+
if (isArcErrorCode(err, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
|
|
372
|
+
}
|
|
612
373
|
```
|
|
613
374
|
|
|
614
|
-
|
|
615
|
-
| ---------- | ----------- | ------------------ |
|
|
616
|
-
| `realtime` | 20s | 30s |
|
|
617
|
-
| `frequent` | 1min | — |
|
|
618
|
-
| `stable` | 5min | — |
|
|
619
|
-
| `static` | 10min | — |
|
|
620
|
-
|
|
621
|
-
## Low-Level Utilities
|
|
622
|
-
|
|
623
|
-
### `updateListCache(listData, updater)`
|
|
375
|
+
`fieldErrors` reads three shapes: `{ errors: { field: msg } }`, `{ details: { errors: [{ field, message }] } }`, raw AJV `{ instancePath, message }`.
|
|
624
376
|
|
|
625
|
-
|
|
626
|
-
Automatically adjusts `total`/`totalDocs` counts when items are added or removed (optimistic add/delete).
|
|
377
|
+
`KNOWN_TOP_LEVEL_CODES` and `KNOWN_DETAILS_CODES` are exported as `as const` arrays — useful for runtime iteration (i18n lookup, retry whitelist, code-mapped UI):
|
|
627
378
|
|
|
628
379
|
```ts
|
|
629
|
-
import {
|
|
380
|
+
import { KNOWN_TOP_LEVEL_CODES } from "@classytic/arc-next/client";
|
|
630
381
|
|
|
631
|
-
|
|
632
|
-
|
|
382
|
+
const ERROR_MESSAGES = Object.fromEntries(
|
|
383
|
+
KNOWN_TOP_LEVEL_CODES.map((code) => [code, t(`error.${code}`)])
|
|
633
384
|
);
|
|
634
385
|
```
|
|
635
386
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
Extracts `_id` or `id` from any item. Returns `string | null`.
|
|
639
|
-
|
|
640
|
-
### `normalizePagination(data)`
|
|
641
|
-
|
|
642
|
-
Converts any pagination response format to a normalized `PaginationData` object. Detects pagination method (`offset`, `keyset`, `aggregate`) and normalizes all fields: `total`/`totalDocs`, `pages`/`totalPages`, `page`/`currentPage`, `hasNext`/`hasNextPage`/`hasMore`, `hasPrev`/`hasPrevPage`, `next` (keyset cursor).
|
|
387
|
+
## Retry + Interceptors
|
|
643
388
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
Extracts the items array from any response format. Checks well-known keys first (`docs`, `data`, `items`, `results`), then falls back to finding the first top-level array — so `{ products: [...] }` or `{ users: [...] }` works without configuration.
|
|
647
|
-
|
|
648
|
-
## Multi-Client (Multiple APIs)
|
|
649
|
-
|
|
650
|
-
By default, `configureClient()` sets a single global `baseUrl`. Use `createClient()` when your app talks to multiple backends.
|
|
651
|
-
|
|
652
|
-
### Create isolated clients
|
|
653
|
-
|
|
654
|
-
Each client gets its own `baseUrl`, auth, and headers — fully independent from the global config:
|
|
389
|
+
Network resilience for mutations + direct `handleApiRequest` calls (TanStack Query already retries reads). Off by default — opt in via `configureClient`:
|
|
655
390
|
|
|
656
391
|
```ts
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
// Cookie auth for auth service
|
|
675
|
-
const authClient = createClient({
|
|
676
|
-
baseUrl: "https://auth.example.com",
|
|
677
|
-
authMode: "cookie",
|
|
678
|
-
});
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
### Use with createCrudApi
|
|
682
|
-
|
|
683
|
-
Pass `client` in the config — requests go through the client's `baseUrl` instead of the global one:
|
|
684
|
-
|
|
685
|
-
```ts
|
|
686
|
-
const eventsApi = createCrudApi("events", {
|
|
687
|
-
basePath: "/api",
|
|
688
|
-
client: analyticsClient,
|
|
689
|
-
});
|
|
690
|
-
```
|
|
691
|
-
|
|
692
|
-
### Use with createCrudHooks
|
|
693
|
-
|
|
694
|
-
Pass `client` — toast and navigation use the client's handlers instead of globals:
|
|
695
|
-
|
|
696
|
-
```ts
|
|
697
|
-
const { useList, useActions } = createCrudHooks({
|
|
698
|
-
api: eventsApi,
|
|
699
|
-
entityKey: "events",
|
|
700
|
-
singular: "Event",
|
|
701
|
-
client: analyticsClient,
|
|
702
|
-
});
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
### Direct requests
|
|
706
|
-
|
|
707
|
-
```ts
|
|
708
|
-
const data = await analyticsClient.request("GET", "/api/stats");
|
|
709
|
-
const result = await analyticsClient.request("POST", "/api/events", {
|
|
710
|
-
body: { type: "page_view" },
|
|
392
|
+
configureClient({
|
|
393
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
394
|
+
retry: {
|
|
395
|
+
attempts: 3, // 1 initial + 2 retries; default off
|
|
396
|
+
backoff: 'exponential', // 'exponential' | 'linear' | (attempt) => ms
|
|
397
|
+
// retryOn: [502, 503, 504], // optional whitelist; default = network failures + 5xx, never 4xx, never AbortError
|
|
398
|
+
},
|
|
399
|
+
// Mutate outgoing requests (per attempt — retries re-run this)
|
|
400
|
+
beforeRequest: (ctx) => ({
|
|
401
|
+
...ctx,
|
|
402
|
+
headers: { ...ctx.headers, 'x-correlation-id': crypto.randomUUID() },
|
|
403
|
+
}),
|
|
404
|
+
// Inspect / transform successful responses (4xx/5xx throw before this)
|
|
405
|
+
afterResponse: (ctx) => {
|
|
406
|
+
console.log(`[arc] ${ctx.method} ${ctx.endpoint} ${ctx.status} ${ctx.durationMs}ms`);
|
|
407
|
+
return ctx;
|
|
408
|
+
},
|
|
711
409
|
});
|
|
712
410
|
```
|
|
713
411
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
```ts
|
|
717
|
-
import type {
|
|
718
|
-
ApiResponse, // { success, data?, message? }
|
|
719
|
-
PaginatedResponse, // OffsetPaginationResponse | KeysetPaginationResponse | AggregatePaginationResponse
|
|
720
|
-
OffsetPaginationResponse, // { docs[], page, limit, total, pages, hasNext, hasPrev }
|
|
721
|
-
KeysetPaginationResponse, // { docs[], limit, hasMore, next }
|
|
722
|
-
AggregatePaginationResponse, // same shape as offset
|
|
723
|
-
DeleteResponse, // { success, data?: { message?, id?, soft? } }
|
|
724
|
-
} from "@classytic/arc-next/api";
|
|
725
|
-
|
|
726
|
-
// Type guards
|
|
727
|
-
import {
|
|
728
|
-
isOffsetPagination,
|
|
729
|
-
isKeysetPagination,
|
|
730
|
-
isAggregatePagination,
|
|
731
|
-
} from "@classytic/arc-next/api";
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
## Error Handling
|
|
735
|
-
|
|
736
|
-
All API errors throw `ArcApiError`:
|
|
737
|
-
|
|
738
|
-
```ts
|
|
739
|
-
import { ArcApiError, isArcApiError } from "@classytic/arc-next/client";
|
|
740
|
-
|
|
741
|
-
try {
|
|
742
|
-
await productsApi.create({ data: { name: "" } });
|
|
743
|
-
} catch (err) {
|
|
744
|
-
if (isArcApiError(err)) {
|
|
745
|
-
console.log(err.status); // HTTP status code
|
|
746
|
-
console.log(err.message); // Error message from server
|
|
747
|
-
console.log(err.fieldErrors); // { field: "message" } or null
|
|
748
|
-
console.log(err.endpoint); // Request endpoint
|
|
749
|
-
console.log(err.method); // HTTP method
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
## Common Patterns
|
|
412
|
+
Interceptors are async-supported and compose with retry — `beforeRequest` re-runs each attempt (so a refreshed token mid-flight is picked up). Aborting via `AbortSignal` cancels both the pending fetch AND any in-flight backoff sleep.
|
|
755
413
|
|
|
756
|
-
|
|
414
|
+
## Cache & Keys
|
|
757
415
|
|
|
758
416
|
```ts
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
// your backend maps it to whatever tenant field your schema uses.
|
|
762
|
-
const { items } = useProducts(token, { organizationId: currentTenantId });
|
|
763
|
-
```
|
|
417
|
+
KEYS.detail(id); // ["products", "detail", id]
|
|
418
|
+
KEYS.scopedDetail(id, orgId); // tenant-scoped variant
|
|
764
419
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const { items } = useProducts(null, {}, { public: true });
|
|
420
|
+
cache.setDetail(qc, id, data);
|
|
421
|
+
cache.invalidateDetail(qc, id); // matches all scoped variants
|
|
422
|
+
cache.invalidateLists(qc);
|
|
769
423
|
```
|
|
770
424
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
```ts
|
|
774
|
-
const { item } = useProduct(selectedId, token); // disabled when selectedId is null
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
### Per-call callbacks
|
|
425
|
+
## Custom Mutations
|
|
778
426
|
|
|
779
427
|
```ts
|
|
780
|
-
|
|
781
|
-
{ data: formData, organizationId: org },
|
|
782
|
-
{
|
|
783
|
-
onSuccess: (product) => router.push(`/products/${product._id}`),
|
|
784
|
-
onError: (err) => setFieldErrors(err),
|
|
785
|
-
onSettled: (data, error) => setSubmitting(false), // fires after success or error
|
|
786
|
-
}
|
|
787
|
-
);
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
### Navigate with cache prefill
|
|
428
|
+
import { useMutationWithTransition } from "@classytic/arc-next/mutation";
|
|
791
429
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
430
|
+
const { mutateAsync: publish, isPending } = useMutationWithTransition({
|
|
431
|
+
mutationFn: (id: string) => api.request("POST", `${api.baseUrl}/${id}/publish`),
|
|
432
|
+
invalidateQueries: [productKeys.all],
|
|
433
|
+
messages: { success: "Published!" },
|
|
434
|
+
});
|
|
796
435
|
```
|
|
797
436
|
|
|
798
|
-
|
|
437
|
+
`useMutationWithOptimistic` adds optimistic cache updates with rollback.
|
|
799
438
|
|
|
800
|
-
|
|
801
|
-
// All requests from this API include x-arc-scope header
|
|
802
|
-
const adminApi = createCrudApi("users", {
|
|
803
|
-
headers: { "x-arc-scope": "platform" },
|
|
804
|
-
});
|
|
805
|
-
```
|
|
439
|
+
## Auth Modes
|
|
806
440
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
- **Pagination Normalization** — Handles `docs`/`data`/`items`/`results` + any custom key, offset/keyset/aggregate pagination
|
|
813
|
-
- **Detail Cache Prefilling** — List results auto-populate detail query cache
|
|
814
|
-
- **React 19 Transitions** — `useMutationWithTransition` wraps invalidation in `startTransition`
|
|
815
|
-
- **Cookie, Bearer & Header Auth** — `authMode: 'cookie'` / `'bearer'` / `'header'` (custom header like `x-api-key`)
|
|
816
|
-
- **Custom ID Fields** — `idField` on `createCrudHooks` for resources keyed by `sku`, `slug`, `code`, etc.
|
|
817
|
-
- **Preset Hooks** — `useDeleted`, `useBulkActions`, `useDetailBySlug`, `useTree`, `useChildren`, `useFindBy`
|
|
818
|
-
- **SSE Real-Time** — `useEventStream` with auto-reconnect, auto-pattern derivation from `resource`, query invalidation. Works with zero config or custom `path`.
|
|
819
|
-
- **Infinite Scroll** — `maxPages` for memory management with automatic scroll-back support
|
|
820
|
-
- **Idempotency** — `autoIdempotency` generates retry-safe keys at mutation level
|
|
821
|
-
- **API Versioning** — `apiVersion` sends `Accept-Version` header
|
|
822
|
-
- **SSR Prefetch** — `createCrudPrefetcher` + `prefetchBySlug` / `prefetchDeleted` / `prefetchTree`
|
|
823
|
-
- **SSR Safety** — warns when `configureClient`/`configureAuth` called on the server
|
|
824
|
-
- **Per-Client Auth** — `createClient({ getToken, getOrgId, headerName })` — each backend gets its own auth, queries auto-enable
|
|
825
|
-
- **Multi-Client** — `createClient()` for multiple API backends side by side
|
|
826
|
-
- **Pluggable Toast** — `configureToast()` — use sonner, react-hot-toast, or anything
|
|
827
|
-
- **Pluggable Navigation** — `configureNavigation()` — use Next.js, React Router, or any router
|
|
828
|
-
- **SSR-Safe QueryClient** — `getQueryClient()` — singleton in browser, new per request on server
|
|
829
|
-
- **Per-Instance Headers** — `config.headers` on `createCrudApi` merged into every request
|
|
830
|
-
- **`select` Transform** — Transform raw API data before it reaches components (`useList`, `useDetail`)
|
|
831
|
-
- **`onSettled` Lifecycle** — Callback that fires after both success and error, at factory and per-call level
|
|
832
|
-
- **Automatic Request Cancellation** — AbortSignal passthrough — unmounted components cancel in-flight requests automatically
|
|
833
|
-
- **Query Config Presets** — `QUERY_CONFIGS.realtime/frequent/stable/static`
|
|
834
|
-
- **Framework-Agnostic** — No hard dependency on Next.js
|
|
835
|
-
- **Tree-Shakeable** — `sideEffects: false`, flat files, no barrels
|
|
441
|
+
| Mode | When | Notes |
|
|
442
|
+
|---|---|---|
|
|
443
|
+
| `bearer` (default) | JWT / opaque token | `getToken()` returns the token |
|
|
444
|
+
| `cookie` | Better Auth, session cookies | No token needed; `credentials: 'include'` automatic |
|
|
445
|
+
| `header` | API keys (`x-api-key`, etc.) | Set `headerName` on `configureAuth` or `createClient` |
|
|
836
446
|
|
|
837
447
|
## License
|
|
838
448
|
|