@classytic/arc-next 0.4.0 → 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 +272 -616
- package/dist/api.d.ts +64 -127
- package/dist/api.js +55 -121
- package/dist/cache.d.ts +108 -0
- package/dist/cache.js +197 -0
- package/dist/client.d.ts +340 -8
- package/dist/client.js +427 -22
- package/dist/hooks.d.ts +96 -20
- package/dist/hooks.js +247 -115
- package/dist/mutation.d.ts +18 -4
- package/dist/mutation.js +20 -4
- package/dist/prefetch.d.ts +37 -3
- package/dist/prefetch.js +56 -17
- 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 -64
- package/dist/query.js +69 -151
- package/dist/sse.d.ts +116 -30
- package/dist/sse.js +179 -102
- 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,517 +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
19
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
44
|
-
// Optional — pluggable toast (defaults to console)
|
|
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
|
-
|
|
112
|
+
Pass a custom `select` to override auto-unwrap.
|
|
220
113
|
|
|
221
|
-
|
|
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` |
|
|
114
|
+
## Actions & Custom Routes
|
|
232
115
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
### `createCrudHooks<T, TCreate, TUpdate>(config)`
|
|
236
|
-
|
|
237
|
-
Factory that returns everything you need. The `api` parameter accepts any `createCrudApi()` result directly — no casts needed. Types are derived from `BaseApi` via `Pick`, so generics thread through automatically:
|
|
116
|
+
Two escape hatches when CRUD isn't enough — both `BaseApi` methods, both routed through your configured client/auth:
|
|
238
117
|
|
|
239
118
|
```ts
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
```
|
|
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 } });
|
|
264
123
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
```ts
|
|
270
|
-
const { items, pagination, isLoading, isFetching, refetch } = useList(
|
|
271
|
-
token,
|
|
272
|
-
{ organizationId: "org-123", status: "active" },
|
|
273
|
-
{ public: true, staleTime: 30_000, prefillDetailCache: true }
|
|
274
|
-
);
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
- Auto-scopes list query keys by tenant context (when present → `tenant` scope, otherwise → `super-admin`)
|
|
278
|
-
- Normalizes pagination from `docs`/`data`/`items`/`results` formats
|
|
279
|
-
- Prefills detail cache from list results (skips re-fetch on navigate)
|
|
280
|
-
- `options.public: true` — enables query without token
|
|
281
|
-
|
|
282
|
-
**`select` transform** — transform raw API data before it reaches your component:
|
|
283
|
-
|
|
284
|
-
```ts
|
|
285
|
-
const { items } = useList(token, { organizationId }, {
|
|
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",
|
|
290
128
|
});
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
```ts
|
|
296
|
-
const { item, isLoading } = useDetail(productId, token, {
|
|
297
|
-
organizationId: "org-123",
|
|
129
|
+
const recent = await api.invokeRoute<PaginatedResponse<Todo>>({
|
|
130
|
+
method: "GET",
|
|
131
|
+
path: "/recent",
|
|
132
|
+
params: { limit: 5 },
|
|
298
133
|
});
|
|
299
134
|
```
|
|
300
135
|
|
|
301
|
-
|
|
302
|
-
- Extracts item from `{ data: T }` wrapper
|
|
303
|
-
|
|
304
|
-
**`select` transform:**
|
|
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:
|
|
305
137
|
|
|
306
138
|
```ts
|
|
307
|
-
const {
|
|
308
|
-
|
|
139
|
+
const { data } = useApiQuery({
|
|
140
|
+
queryKey: ["todos", "stats"],
|
|
141
|
+
queryFn: ({ signal }) => api.invokeRoute({ path: "/stats", options: { signal } }),
|
|
142
|
+
freshness: "frequent",
|
|
309
143
|
});
|
|
310
144
|
```
|
|
311
145
|
|
|
312
|
-
|
|
146
|
+
## Presets — Opt-In Methods
|
|
313
147
|
|
|
314
|
-
|
|
315
|
-
const { create, update, remove, isCreating, isUpdating, isDeleting, isMutating } =
|
|
316
|
-
useActions();
|
|
317
|
-
|
|
318
|
-
// All mutations have optimistic updates + automatic rollback on error
|
|
319
|
-
await create({ data: { name: "New" }, organizationId: "org-123" });
|
|
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
|
-
```
|
|
329
|
-
|
|
330
|
-
- **Create** — optimistic: prepends to list with temp ID
|
|
331
|
-
- **Update** — optimistic: patches item in list + detail cache
|
|
332
|
-
- **Delete** — optimistic: removes from list + detail cache
|
|
333
|
-
- All roll back automatically on error
|
|
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.
|
|
334
149
|
|
|
335
|
-
|
|
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.
|
|
336
151
|
|
|
337
152
|
```ts
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
Sets detail cache before navigation (instant page load, no loading spinner).
|
|
344
|
-
Requires `configureNavigation(useRouter)` — without it, only sets cache (no routing).
|
|
345
|
-
|
|
346
|
-
#### `useInfiniteList(token, params?, options?)`
|
|
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";
|
|
347
158
|
|
|
348
|
-
|
|
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")));
|
|
349
163
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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" });
|
|
353
169
|
```
|
|
354
170
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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()` |
|
|
358
178
|
|
|
359
|
-
|
|
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.
|
|
360
180
|
|
|
361
|
-
|
|
181
|
+
## Filter operators (mongokit URL grammar)
|
|
362
182
|
|
|
363
|
-
|
|
364
|
-
const { mutateAsync: upload, isPending } = useUpload({
|
|
365
|
-
messages: { success: "Uploaded!", error: "Upload failed" },
|
|
366
|
-
onSuccess: (data) => console.log("Uploaded:", data),
|
|
367
|
-
});
|
|
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.
|
|
368
184
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
await upload({ data: formData, id: "doc-123" });
|
|
373
|
-
// Post to /products/bulk-import (custom path takes precedence over id)
|
|
374
|
-
await upload({ data: formData, path: "bulk-import" });
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
Requires `api.upload` to be defined. Throws if not available.
|
|
185
|
+
```ts
|
|
186
|
+
// Range / comparison
|
|
187
|
+
await api.getAll({ params: { 'priority[gte]': 5, 'price[between]': '10,100' } });
|
|
378
188
|
|
|
379
|
-
|
|
189
|
+
// Pattern matching
|
|
190
|
+
await api.getAll({ params: { 'title[contains]': 'urgent' } });
|
|
380
191
|
|
|
381
|
-
|
|
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
|
|
382
195
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
});
|
|
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] } });
|
|
387
200
|
```
|
|
388
201
|
|
|
389
|
-
|
|
390
|
-
- Requires `api.search` to be defined
|
|
391
|
-
|
|
392
|
-
#### `useCustomMutation<TData, TVariables>(config)`
|
|
202
|
+
Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `regex`, `like`, `exists`, `between`, `near`, `nearSphere`, `withinRadius`, `geoWithin`.
|
|
393
203
|
|
|
394
|
-
|
|
204
|
+
## SSE — Real-Time
|
|
395
205
|
|
|
396
206
|
```ts
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
207
|
+
import { useEventStream, buildSseUrl } from "@classytic/arc-next/sse";
|
|
208
|
+
|
|
209
|
+
useEventStream({
|
|
210
|
+
resource: "agents", // auto-derives [agents.created, agents.updated, agents.deleted]
|
|
211
|
+
invalidateQueries: [agentKeys.lists()], // refetch on every event
|
|
401
212
|
});
|
|
402
|
-
```
|
|
403
213
|
|
|
404
|
-
|
|
214
|
+
// Or explicit named events (Arc ssePlugin emits `event: <type>` frames):
|
|
215
|
+
useEventStream({
|
|
216
|
+
eventTypes: ["sync-job.phase", "sync-job.completed"],
|
|
217
|
+
onEvent: (event) => { /* ... */ },
|
|
218
|
+
});
|
|
405
219
|
|
|
406
|
-
|
|
220
|
+
// Build authenticated SSE URLs for ad-hoc EventSource consumers:
|
|
221
|
+
const url = buildSseUrl("/jobs/stream", { jobId });
|
|
222
|
+
```
|
|
407
223
|
|
|
408
|
-
|
|
224
|
+
## WebSocket — Real-Time + Bidirectional
|
|
409
225
|
|
|
410
|
-
|
|
226
|
+
```ts
|
|
227
|
+
import { useWebSocket, buildWsUrl } from "@classytic/arc-next/ws";
|
|
411
228
|
|
|
412
|
-
|
|
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
|
+
});
|
|
413
236
|
|
|
414
|
-
|
|
237
|
+
// Send any JSON payload — returns false if not connected
|
|
238
|
+
send({ type: "chat.message", text: "hi" });
|
|
415
239
|
|
|
416
|
-
|
|
240
|
+
// Build the URL for a raw WebSocket consumer (Node, worker, etc.)
|
|
241
|
+
const url = buildWsUrl("/ws", { roomId: "r-1" });
|
|
242
|
+
```
|
|
417
243
|
|
|
418
|
-
|
|
244
|
+
Subscriptions persist across reconnects — anything passed in `subscribe` (or via `subscribe()`) is auto-resent after the socket re-opens.
|
|
419
245
|
|
|
420
|
-
|
|
246
|
+
## Uploads with Progress
|
|
421
247
|
|
|
422
|
-
|
|
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:
|
|
423
249
|
|
|
424
250
|
```ts
|
|
425
|
-
|
|
426
|
-
const { items } = useFindBy("price", 50, { operator: "gte" });
|
|
427
|
-
```
|
|
251
|
+
import { useUploadWithProgress } from "@classytic/arc-next/upload";
|
|
428
252
|
|
|
429
|
-
|
|
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
|
+
});
|
|
430
267
|
|
|
431
|
-
|
|
432
|
-
const { bulkCreate, bulkUpdate, bulkRemove } = useBulkActions();
|
|
433
|
-
await bulkCreate({ data: [{ name: "A" }, { name: "B" }] });
|
|
268
|
+
// Bind progress.percent to a <ProgressBar /> — every tick re-renders.
|
|
434
269
|
```
|
|
435
270
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
Subscribe to Arc SSE events with auto-reconnect and query invalidation:
|
|
271
|
+
For non-React consumers, `uploadWithProgress({ url, formData, onProgress, signal })` returns a Promise.
|
|
439
272
|
|
|
440
|
-
|
|
441
|
-
import { useEventStream } from "@classytic/arc-next/sse";
|
|
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.
|
|
442
274
|
|
|
443
|
-
|
|
444
|
-
resource: "agents",
|
|
445
|
-
patterns: ["agents.created", "agents.updated"],
|
|
446
|
-
invalidateQueries: [agentKeys.lists()],
|
|
447
|
-
});
|
|
448
|
-
```
|
|
275
|
+
## Multi-Client
|
|
449
276
|
|
|
450
|
-
|
|
277
|
+
Each `createClient` call is independent — its own `baseUrl`, auth, headers:
|
|
451
278
|
|
|
452
279
|
```ts
|
|
453
|
-
|
|
454
|
-
KEYS.lists() // ["products", "list"]
|
|
455
|
-
KEYS.list(params) // ["products", "list", params]
|
|
456
|
-
KEYS.details() // ["products", "detail"]
|
|
457
|
-
KEYS.detail(id) // ["products", "detail", id]
|
|
458
|
-
KEYS.custom("stats", orgId) // ["products", "stats", orgId]
|
|
459
|
-
KEYS.scopedList("tenant", params) // ["products", "list", { _scope: "tenant", ...params }]
|
|
460
|
-
```
|
|
280
|
+
import { createClient } from "@classytic/arc-next/client";
|
|
461
281
|
|
|
462
|
-
|
|
282
|
+
const analytics = createClient({
|
|
283
|
+
baseUrl: "https://analytics.example.com",
|
|
284
|
+
authMode: "header",
|
|
285
|
+
getToken: () => env.ANALYTICS_KEY,
|
|
286
|
+
headerName: "x-api-key",
|
|
287
|
+
});
|
|
463
288
|
|
|
464
|
-
|
|
465
|
-
await cache.invalidateAll(queryClient);
|
|
466
|
-
await cache.invalidateLists(queryClient);
|
|
467
|
-
await cache.invalidateDetail(queryClient, id);
|
|
468
|
-
cache.setDetail(queryClient, id, data);
|
|
469
|
-
cache.getDetail(queryClient, id); // T | undefined
|
|
470
|
-
cache.removeDetail(queryClient, id);
|
|
289
|
+
const eventsApi = createCrudApi("events", { client: analytics });
|
|
471
290
|
```
|
|
472
291
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
SSR-safe singleton. Server: new per request. Browser: reuses singleton.
|
|
292
|
+
For consumer SDKs that just need to bridge the global auth singleton:
|
|
476
293
|
|
|
477
294
|
```ts
|
|
478
|
-
import {
|
|
479
|
-
import { QueryClientProvider } from "@tanstack/react-query";
|
|
295
|
+
import { createAuthAwareClient } from "@classytic/arc-next/client";
|
|
480
296
|
|
|
481
|
-
|
|
482
|
-
const queryClient = getQueryClient();
|
|
483
|
-
return (
|
|
484
|
-
<QueryClientProvider client={queryClient}>
|
|
485
|
-
{children}
|
|
486
|
-
</QueryClientProvider>
|
|
487
|
-
);
|
|
488
|
-
}
|
|
297
|
+
const api = createCrudApi("products", { client: createAuthAwareClient() });
|
|
489
298
|
```
|
|
490
299
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
## SSR Prefetch (Server Components)
|
|
494
|
-
|
|
495
|
-
Pre-populate the query cache on the server to avoid loading spinners:
|
|
300
|
+
## SSR Prefetch (Next.js App Router / Server Components)
|
|
496
301
|
|
|
497
|
-
|
|
498
|
-
// products-prefetch.ts
|
|
499
|
-
import { createCrudPrefetcher } from "@classytic/arc-next/prefetch";
|
|
500
|
-
import { productsApi } from "@/api/products-api";
|
|
501
|
-
|
|
502
|
-
export const productsPrefetcher = createCrudPrefetcher(productsApi, "products");
|
|
503
|
-
```
|
|
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`.
|
|
504
303
|
|
|
505
304
|
```tsx
|
|
506
|
-
// app/products/page.tsx (
|
|
305
|
+
// app/products/page.tsx — Server Component (no "use client")
|
|
306
|
+
import { createCrudPrefetcher, dehydrate, HydrationBoundary } from "@classytic/arc-next/prefetch";
|
|
507
307
|
import { getQueryClient } from "@classytic/arc-next/query-client";
|
|
508
|
-
import {
|
|
509
|
-
import {
|
|
510
|
-
|
|
308
|
+
import { productsApi } from "@/api/products-api";
|
|
309
|
+
import { ProductsList } from "./products-list"; // "use client"
|
|
310
|
+
|
|
311
|
+
const prefetcher = createCrudPrefetcher(productsApi, "products");
|
|
511
312
|
|
|
512
313
|
export default async function ProductsPage() {
|
|
513
|
-
const queryClient = getQueryClient();
|
|
514
|
-
await
|
|
314
|
+
const queryClient = getQueryClient(); // per-request on server
|
|
315
|
+
await prefetcher.prefetchList(queryClient, { limit: 20 }, { token, organizationId });
|
|
515
316
|
|
|
516
317
|
return (
|
|
517
318
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
@@ -521,272 +322,127 @@ export default async function ProductsPage() {
|
|
|
521
322
|
}
|
|
522
323
|
```
|
|
523
324
|
|
|
524
|
-
|
|
325
|
+
Methods: `prefetchList`, `prefetchDetail`, `prefetchBySlug`, `prefetchDeleted`, `prefetchTree`, `prefetchInfiniteList`.
|
|
525
326
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
For operations beyond CRUD (publish, schedule, upload):
|
|
327
|
+
> `prefetchInfiniteList` seeds the `{ pages, pageParams }` cache shape `useInfiniteQuery` expects — a flat `prefetchQuery` won't match and the hook would re-fetch from scratch.
|
|
529
328
|
|
|
530
|
-
###
|
|
329
|
+
### Streaming with promise-pending dehydration (TanStack Query 5.40+)
|
|
531
330
|
|
|
532
|
-
|
|
331
|
+
`getQueryClient()` ships a default `dehydrate.shouldDehydrateQuery` that includes pending queries, so you can fire-and-forget prefetches inside Suspense boundaries:
|
|
533
332
|
|
|
534
|
-
```
|
|
535
|
-
|
|
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 });
|
|
536
338
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
showToast: true, // default
|
|
545
|
-
});
|
|
339
|
+
return (
|
|
340
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
341
|
+
<Suspense fallback={<ListSkeleton />}>
|
|
342
|
+
<ProductsList />
|
|
343
|
+
</Suspense>
|
|
344
|
+
</HydrationBoundary>
|
|
345
|
+
);
|
|
546
346
|
}
|
|
547
347
|
```
|
|
548
348
|
|
|
549
|
-
|
|
349
|
+
### Server-safe utilities
|
|
550
350
|
|
|
551
|
-
|
|
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`.
|
|
552
352
|
|
|
553
|
-
|
|
353
|
+
### Next.js 16 `cacheComponents` + `'use cache'`
|
|
554
354
|
|
|
555
|
-
|
|
556
|
-
import { useMutationWithOptimistic } from "@classytic/arc-next/mutation";
|
|
557
|
-
|
|
558
|
-
export function useToggleFavorite() {
|
|
559
|
-
return useMutationWithOptimistic({
|
|
560
|
-
mutationFn: ({ id, isFav }) =>
|
|
561
|
-
api.request("PATCH", `/api/products/${id}`, {
|
|
562
|
-
data: { favorite: !isFav },
|
|
563
|
-
}),
|
|
564
|
-
queryKeys: [productKeys.lists()],
|
|
565
|
-
optimisticUpdate: (old, { id, isFav }) =>
|
|
566
|
-
updateListCache(old, (items) =>
|
|
567
|
-
items.map((i) => (getItemId(i) === id ? { ...i, favorite: !isFav } : i))
|
|
568
|
-
),
|
|
569
|
-
messages: { success: "Updated!" },
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
```
|
|
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.
|
|
573
356
|
|
|
574
|
-
|
|
357
|
+
## Errors
|
|
575
358
|
|
|
576
359
|
```ts
|
|
577
|
-
import {
|
|
360
|
+
import { isArcApiError, isAbortError, isArcErrorCode } from "@classytic/arc-next/client";
|
|
578
361
|
|
|
579
|
-
|
|
580
|
-
|
|
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
|
+
}
|
|
581
373
|
```
|
|
582
374
|
|
|
583
|
-
|
|
584
|
-
| ---------- | ----------- | ------------------ |
|
|
585
|
-
| `realtime` | 20s | 30s |
|
|
586
|
-
| `frequent` | 1min | — |
|
|
587
|
-
| `stable` | 5min | — |
|
|
588
|
-
| `static` | 10min | — |
|
|
375
|
+
`fieldErrors` reads three shapes: `{ errors: { field: msg } }`, `{ details: { errors: [{ field, message }] } }`, raw AJV `{ instancePath, message }`.
|
|
589
376
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
### `updateListCache(listData, updater)`
|
|
593
|
-
|
|
594
|
-
Transforms list cache regardless of format — well-known keys (`docs[]`, `data[]`, `items[]`, `results[]`), custom keys (`products[]`, `users[]`, etc.), or raw arrays.
|
|
595
|
-
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):
|
|
596
378
|
|
|
597
379
|
```ts
|
|
598
|
-
import {
|
|
380
|
+
import { KNOWN_TOP_LEVEL_CODES } from "@classytic/arc-next/client";
|
|
599
381
|
|
|
600
|
-
|
|
601
|
-
|
|
382
|
+
const ERROR_MESSAGES = Object.fromEntries(
|
|
383
|
+
KNOWN_TOP_LEVEL_CODES.map((code) => [code, t(`error.${code}`)])
|
|
602
384
|
);
|
|
603
385
|
```
|
|
604
386
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
Extracts `_id` or `id` from any item. Returns `string | null`.
|
|
608
|
-
|
|
609
|
-
### `normalizePagination(data)`
|
|
610
|
-
|
|
611
|
-
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).
|
|
612
|
-
|
|
613
|
-
### `extractItems<T>(data)`
|
|
614
|
-
|
|
615
|
-
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.
|
|
616
|
-
|
|
617
|
-
## Multi-Client (Multiple APIs)
|
|
618
|
-
|
|
619
|
-
By default, `configureClient()` sets a single global `baseUrl`. Use `createClient()` when your app talks to multiple backends.
|
|
620
|
-
|
|
621
|
-
### Create isolated clients
|
|
622
|
-
|
|
623
|
-
```ts
|
|
624
|
-
import { createClient } from "@classytic/arc-next/client";
|
|
625
|
-
import { toast } from "sonner";
|
|
626
|
-
import { useRouter } from "next/navigation";
|
|
627
|
-
|
|
628
|
-
const analyticsClient = createClient({
|
|
629
|
-
baseUrl: "https://analytics.example.com",
|
|
630
|
-
internalApiKey: "analytics-key",
|
|
631
|
-
toast: { success: toast.success, error: toast.error },
|
|
632
|
-
navigation: useRouter,
|
|
633
|
-
});
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
### Use with createCrudApi
|
|
637
|
-
|
|
638
|
-
Pass `client` in the config — requests go through the client's `baseUrl` instead of the global one:
|
|
639
|
-
|
|
640
|
-
```ts
|
|
641
|
-
const eventsApi = createCrudApi("events", {
|
|
642
|
-
basePath: "/api",
|
|
643
|
-
client: analyticsClient,
|
|
644
|
-
});
|
|
645
|
-
```
|
|
646
|
-
|
|
647
|
-
### Use with createCrudHooks
|
|
648
|
-
|
|
649
|
-
Pass `client` — toast and navigation use the client's handlers instead of globals:
|
|
650
|
-
|
|
651
|
-
```ts
|
|
652
|
-
const { useList, useActions } = createCrudHooks({
|
|
653
|
-
api: eventsApi,
|
|
654
|
-
entityKey: "events",
|
|
655
|
-
singular: "Event",
|
|
656
|
-
client: analyticsClient,
|
|
657
|
-
});
|
|
658
|
-
```
|
|
387
|
+
## Retry + Interceptors
|
|
659
388
|
|
|
660
|
-
|
|
389
|
+
Network resilience for mutations + direct `handleApiRequest` calls (TanStack Query already retries reads). Off by default — opt in via `configureClient`:
|
|
661
390
|
|
|
662
391
|
```ts
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
+
},
|
|
666
409
|
});
|
|
667
410
|
```
|
|
668
411
|
|
|
669
|
-
|
|
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.
|
|
670
413
|
|
|
671
|
-
|
|
672
|
-
import type {
|
|
673
|
-
ApiResponse, // { success, data?, message? }
|
|
674
|
-
PaginatedResponse, // OffsetPaginationResponse | KeysetPaginationResponse | AggregatePaginationResponse
|
|
675
|
-
OffsetPaginationResponse, // { docs[], page, limit, total, pages, hasNext, hasPrev }
|
|
676
|
-
KeysetPaginationResponse, // { docs[], limit, hasMore, next }
|
|
677
|
-
AggregatePaginationResponse, // same shape as offset
|
|
678
|
-
DeleteResponse, // { success, data?: { message?, id?, soft? } }
|
|
679
|
-
} from "@classytic/arc-next/api";
|
|
680
|
-
|
|
681
|
-
// Type guards
|
|
682
|
-
import {
|
|
683
|
-
isOffsetPagination,
|
|
684
|
-
isKeysetPagination,
|
|
685
|
-
isAggregatePagination,
|
|
686
|
-
} from "@classytic/arc-next/api";
|
|
687
|
-
```
|
|
688
|
-
|
|
689
|
-
## Error Handling
|
|
690
|
-
|
|
691
|
-
All API errors throw `ArcApiError`:
|
|
414
|
+
## Cache & Keys
|
|
692
415
|
|
|
693
416
|
```ts
|
|
694
|
-
|
|
417
|
+
KEYS.detail(id); // ["products", "detail", id]
|
|
418
|
+
KEYS.scopedDetail(id, orgId); // tenant-scoped variant
|
|
695
419
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
if (isArcApiError(err)) {
|
|
700
|
-
console.log(err.status); // HTTP status code
|
|
701
|
-
console.log(err.message); // Error message from server
|
|
702
|
-
console.log(err.fieldErrors); // { field: "message" } or null
|
|
703
|
-
console.log(err.endpoint); // Request endpoint
|
|
704
|
-
console.log(err.method); // HTTP method
|
|
705
|
-
}
|
|
706
|
-
}
|
|
420
|
+
cache.setDetail(qc, id, data);
|
|
421
|
+
cache.invalidateDetail(qc, id); // matches all scoped variants
|
|
422
|
+
cache.invalidateLists(qc);
|
|
707
423
|
```
|
|
708
424
|
|
|
709
|
-
##
|
|
710
|
-
|
|
711
|
-
### Multi-tenant data fetching
|
|
712
|
-
|
|
713
|
-
```ts
|
|
714
|
-
// Tenant ID in params → scoped query key → isolated cache per tenant
|
|
715
|
-
// The param name is up to you — arc-next sends it as x-organization-id header,
|
|
716
|
-
// your backend maps it to whatever tenant field your schema uses.
|
|
717
|
-
const { items } = useProducts(token, { organizationId: currentTenantId });
|
|
718
|
-
```
|
|
719
|
-
|
|
720
|
-
### Public endpoints (no auth)
|
|
721
|
-
|
|
722
|
-
```ts
|
|
723
|
-
const { items } = useProducts(null, {}, { public: true });
|
|
724
|
-
```
|
|
725
|
-
|
|
726
|
-
### Conditional fetching
|
|
727
|
-
|
|
728
|
-
```ts
|
|
729
|
-
const { item } = useProduct(selectedId, token); // disabled when selectedId is null
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
### Per-call callbacks
|
|
425
|
+
## Custom Mutations
|
|
733
426
|
|
|
734
427
|
```ts
|
|
735
|
-
|
|
736
|
-
{ data: formData, organizationId: org },
|
|
737
|
-
{
|
|
738
|
-
onSuccess: (product) => router.push(`/products/${product._id}`),
|
|
739
|
-
onError: (err) => setFieldErrors(err),
|
|
740
|
-
onSettled: (data, error) => setSubmitting(false), // fires after success or error
|
|
741
|
-
}
|
|
742
|
-
);
|
|
743
|
-
```
|
|
744
|
-
|
|
745
|
-
### Navigate with cache prefill
|
|
428
|
+
import { useMutationWithTransition } from "@classytic/arc-next/mutation";
|
|
746
429
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
+
});
|
|
751
435
|
```
|
|
752
436
|
|
|
753
|
-
|
|
437
|
+
`useMutationWithOptimistic` adds optimistic cache updates with rollback.
|
|
754
438
|
|
|
755
|
-
|
|
756
|
-
// All requests from this API include x-arc-scope header
|
|
757
|
-
const adminApi = createCrudApi("users", {
|
|
758
|
-
headers: { "x-arc-scope": "platform" },
|
|
759
|
-
});
|
|
760
|
-
```
|
|
439
|
+
## Auth Modes
|
|
761
440
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
- **Pagination Normalization** — Handles `docs`/`data`/`items`/`results` + any custom key, offset/keyset/aggregate pagination
|
|
768
|
-
- **Detail Cache Prefilling** — List results auto-populate detail query cache
|
|
769
|
-
- **React 19 Transitions** — `useMutationWithTransition` wraps invalidation in `startTransition`
|
|
770
|
-
- **Cookie, Bearer & Header Auth** — `authMode: 'cookie'` / `'bearer'` / `'header'` (custom header like `x-api-key`)
|
|
771
|
-
- **Custom ID Fields** — `idField` on `createCrudHooks` for resources keyed by `sku`, `slug`, `code`, etc.
|
|
772
|
-
- **Preset Hooks** — `useDeleted`, `useBulkActions`, `useDetailBySlug`, `useTree`, `useChildren`, `useFindBy`
|
|
773
|
-
- **SSE Real-Time** — `useEventStream` with auto-reconnect, pattern filtering, query invalidation
|
|
774
|
-
- **Infinite Scroll** — `maxPages` for memory management with automatic scroll-back support
|
|
775
|
-
- **Idempotency** — `autoIdempotency` generates retry-safe keys at mutation level
|
|
776
|
-
- **API Versioning** — `apiVersion` sends `Accept-Version` header
|
|
777
|
-
- **SSR Prefetch** — `createCrudPrefetcher` + `prefetchBySlug` / `prefetchDeleted` / `prefetchTree`
|
|
778
|
-
- **SSR Safety** — warns when `configureClient`/`configureAuth` called on the server
|
|
779
|
-
- **Multi-Client** — `createClient()` for multiple API backends side by side
|
|
780
|
-
- **Pluggable Toast** — `configureToast()` — use sonner, react-hot-toast, or anything
|
|
781
|
-
- **Pluggable Navigation** — `configureNavigation()` — use Next.js, React Router, or any router
|
|
782
|
-
- **SSR-Safe QueryClient** — `getQueryClient()` — singleton in browser, new per request on server
|
|
783
|
-
- **Per-Instance Headers** — `config.headers` on `createCrudApi` merged into every request
|
|
784
|
-
- **`select` Transform** — Transform raw API data before it reaches components (`useList`, `useDetail`)
|
|
785
|
-
- **`onSettled` Lifecycle** — Callback that fires after both success and error, at factory and per-call level
|
|
786
|
-
- **Automatic Request Cancellation** — AbortSignal passthrough — unmounted components cancel in-flight requests automatically
|
|
787
|
-
- **Query Config Presets** — `QUERY_CONFIGS.realtime/frequent/stable/static`
|
|
788
|
-
- **Framework-Agnostic** — No hard dependency on Next.js
|
|
789
|
-
- **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` |
|
|
790
446
|
|
|
791
447
|
## License
|
|
792
448
|
|