@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 CHANGED
@@ -1,517 +1,318 @@
1
1
  # @classytic/arc-next
2
2
 
3
- React + TanStack Query SDK for Arc resources. Typed CRUD hooks with optimistic updates, automatic rollback, multi-tenant scoping, pagination normalization, and detail cache prefilling. No separate state management library needed.
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
- **Requires:** React 19+, TanStack React Query 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 the configuration functions once at app init (e.g., in your root providers):
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
- // 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
-
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
- ## Subpath Exports
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
- ```ts
95
- import { createCrudHooks } from "@classytic/arc-next/hooks";
96
- import { productsApi } from "./products-api";
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: productKeys,
100
- cache: productCache,
101
- useList: useProducts,
102
- useDetail: useProduct,
103
- useActions: useProductActions,
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
- export function ProductsPage() {
118
- const { items, pagination, isLoading } = useProducts(null, {
119
- organizationId: "org-123",
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
- ## API Reference
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
- - `authMode: 'bearer'` (default) requires token; queries disabled until provided
163
- - `authMode: 'cookie'` — HTTP-only cookies (e.g. Better Auth); queries always enabled
164
- - `authMode: 'header'` custom header auth (e.g. `x-api-key`); uses `headerName` from `configureAuth`
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
- Must be called before any API requests. Warns if called on the server (SSR safety).
83
+ `sideEffects: false`. No barrel every file is its own entry point.
167
84
 
168
- ### `configureAuth(config)`
85
+ ## Core Hooks (from `createCrudHooks`)
169
86
 
170
87
  ```ts
171
- configureAuth({
172
- getToken?: () => string | null; // For bearer/header auth — return access token or API key
173
- getOrgId?: () => string | null; // Return active organization ID
174
- headerName?: string; // Custom header name for authMode: 'header' (default: 'x-api-key')
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
- Universal fetch wrapper. Handles JSON, PDF, image, CSV, and text responses.
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
- **Options:**
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
- MongoKit-compatible query string builder:
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
- ### `createCrudApi<TDoc, TCreate, TUpdate>(entity, config?)`
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
- const api = createCrudApi<Product, CreateProduct>("products", {
210
- basePath: "/api", // default: "/api/v1"
211
- defaultParams: { limit: 20 },
212
- cache: "no-store", // default
213
- headers: { // optional sent with every request from this instance
214
- "x-arc-scope": "platform", // e.g. for superadmin elevation
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
- **Methods:**
112
+ Pass a custom `select` to override auto-unwrap.
220
113
 
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` |
114
+ ## Actions & Custom Routes
232
115
 
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)`
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
- 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
- ```
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
- **Returned hooks:**
266
-
267
- #### `useList(token, params?, options?)`
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
- #### `useDetail(id, token, options?)`
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
- - Disabled when `id` is null (conditional fetching)
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 { item } = useDetail(productId, token, {
308
- select: (data) => ({ ...data.data, fullName: `${data.data.firstName} ${data.data.lastName}` }),
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
- #### `useActions()`
146
+ ## Presets — Opt-In Methods
313
147
 
314
- ```ts
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
- #### `useNavigation()`
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
- const navigate = useNavigation();
339
- navigate(`/products/${id}`, product); // push + cache prefill
340
- navigate(`/products/${id}`, product, { replace: true }); // replace
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
- Cursor-based infinite scrolling with automatic page aggregation:
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
- ```ts
351
- const { items, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading } =
352
- useInfiniteList(token, { organizationId: "org-123", limit: 20 });
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
- - Supports both keyset (`hasMore`/`next`) and offset (`hasNext`/`page`) pagination
356
- - Returns flattened `items` across all pages
357
- - Auto-scopes query keys like `useList`
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
- #### `useUpload(options?)`
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
- Upload FormData with cache invalidation:
181
+ ## Filter operators (mongokit URL grammar)
362
182
 
363
- ```ts
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
- // Post to base collection URL
370
- await upload({ data: formData });
371
- // Post to /products/{id}/upload
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
- #### `useSearch(query, params?, options?)`
189
+ // Pattern matching
190
+ await api.getAll({ params: { 'title[contains]': 'urgent' } });
380
191
 
381
- Search with automatic query key scoping:
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
- ```ts
384
- const { items, pagination, isLoading } = useSearch("widget", {
385
- organizationId: "org-123",
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
- - Disabled when `query` is empty
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
- Build custom mutations that share the entity's toast and invalidation patterns:
204
+ ## SSE Real-Time
395
205
 
396
206
  ```ts
397
- const { mutateAsync: publish, isPending } = useCustomMutation({
398
- mutationFn: (id: string) => api.request("POST", `${api.baseUrl}/${id}/publish`),
399
- invalidateQueries: [productKeys.lists()],
400
- messages: { success: "Published!", error: "Failed to publish" },
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
- #### `useDeleted(params?, options?)`
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
- List soft-deleted items. Requires `softDelete` preset on the Arc resource.
220
+ // Build authenticated SSE URLs for ad-hoc EventSource consumers:
221
+ const url = buildSseUrl("/jobs/stream", { jobId });
222
+ ```
407
223
 
408
- #### `useDetailBySlug(slug, options?)`
224
+ ## WebSocket — Real-Time + Bidirectional
409
225
 
410
- Fetch a single item by slug (`GET /slug/:slug`). Requires `slugLookup` preset.
226
+ ```ts
227
+ import { useWebSocket, buildWsUrl } from "@classytic/arc-next/ws";
411
228
 
412
- #### `useTree(params?, options?)`
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
- Fetch hierarchical tree data (`GET /tree`). Requires `tree` preset.
237
+ // Send any JSON payload returns false if not connected
238
+ send({ type: "chat.message", text: "hi" });
415
239
 
416
- #### `useChildren(parentId, params?, options?)`
240
+ // Build the URL for a raw WebSocket consumer (Node, worker, etc.)
241
+ const url = buildWsUrl("/ws", { roomId: "r-1" });
242
+ ```
417
243
 
418
- Fetch children of a parent node (`GET /:parentId/children`). Requires `tree` preset.
244
+ Subscriptions persist across reconnects anything passed in `subscribe` (or via `subscribe()`) is auto-resent after the socket re-opens.
419
245
 
420
- #### `useFindBy(field, value, options?)`
246
+ ## Uploads with Progress
421
247
 
422
- Query by a single field with optional operator:
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
- const { items } = useFindBy("status", "active");
426
- const { items } = useFindBy("price", 50, { operator: "gte" });
427
- ```
251
+ import { useUploadWithProgress } from "@classytic/arc-next/upload";
428
252
 
429
- #### `useBulkActions()`
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
- ```ts
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
- #### `useEventStream(options)` (from `./sse`)
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
- ```ts
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
- const { isConnected, lastEvent } = useEventStream({
444
- resource: "agents",
445
- patterns: ["agents.created", "agents.updated"],
446
- invalidateQueries: [agentKeys.lists()],
447
- });
448
- ```
275
+ ## Multi-Client
449
276
 
450
- ### Query Keys (`KEYS`)
277
+ Each `createClient` call is independent — its own `baseUrl`, auth, headers:
451
278
 
452
279
  ```ts
453
- KEYS.all // ["products"]
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
- ### Cache Utilities (`cache`)
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
- ```ts
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
- ### `getQueryClient(overrides?)`
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 { getQueryClient } from "@classytic/arc-next/query-client";
479
- import { QueryClientProvider } from "@tanstack/react-query";
295
+ import { createAuthAwareClient } from "@classytic/arc-next/client";
480
296
 
481
- function Providers({ children }) {
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
- Defaults: `staleTime: 5min`, `gcTime: 30min`, `retry: 0`, `refetchOnWindowFocus: false`.
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
- ```ts
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 (server component)
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 { dehydrate } from "@classytic/arc-next/prefetch";
509
- import { HydrationBoundary } from "@tanstack/react-query";
510
- import { productsPrefetcher } from "@/prefetch/products-prefetch";
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 productsPrefetcher.prefetchList(queryClient, { limit: 20 });
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
- **Methods:** `prefetchList(queryClient, params?, options?)`, `prefetchDetail(queryClient, id, options?)`
325
+ Methods: `prefetchList`, `prefetchDetail`, `prefetchBySlug`, `prefetchDeleted`, `prefetchTree`, `prefetchInfiniteList`.
525
326
 
526
- ## Custom Mutations
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
- ### `useMutationWithTransition(config)`
329
+ ### Streaming with promise-pending dehydration (TanStack Query 5.40+)
531
330
 
532
- Mutation + React 19 `useTransition` for smooth cache invalidation:
331
+ `getQueryClient()` ships a default `dehydrate.shouldDehydrateQuery` that includes pending queries, so you can fire-and-forget prefetches inside Suspense boundaries:
533
332
 
534
- ```ts
535
- import { useMutationWithTransition } from "@classytic/arc-next/mutation";
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
- export function usePublishPost() {
538
- return useMutationWithTransition({
539
- mutationFn: (id: string) =>
540
- postsApi.request("POST", `${postsApi.baseUrl}/${id}/publish`),
541
- invalidateQueries: [postKeys.all],
542
- messages: { success: "Published!", error: "Failed to publish" },
543
- useTransition: true, // default
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
- Returns: `{ mutate, mutateAsync, isPending, isSuccess, isError, error, data, reset }`
349
+ ### Server-safe utilities
550
350
 
551
- ### `useMutationWithOptimistic(config)`
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
- Mutation + optimistic updates + automatic rollback:
353
+ ### Next.js 16 `cacheComponents` + `'use cache'`
554
354
 
555
- ```ts
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
- ### Query Config Presets
357
+ ## Errors
575
358
 
576
359
  ```ts
577
- import { QUERY_CONFIGS } from "@classytic/arc-next/mutation";
360
+ import { isArcApiError, isAbortError, isArcErrorCode } from "@classytic/arc-next/client";
578
361
 
579
- // Use in useList options:
580
- useProducts(token, {}, { ...QUERY_CONFIGS.realtime });
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
- | Preset | `staleTime` | `refetchInterval` |
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
- ## Low-Level Utilities
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 { updateListCache } from "@classytic/arc-next/query";
380
+ import { KNOWN_TOP_LEVEL_CODES } from "@classytic/arc-next/client";
599
381
 
600
- queryClient.setQueryData(KEYS.lists(), (old) =>
601
- updateListCache(old, (items) => items.filter((i) => i.status !== "archived"))
382
+ const ERROR_MESSAGES = Object.fromEntries(
383
+ KNOWN_TOP_LEVEL_CODES.map((code) => [code, t(`error.${code}`)])
602
384
  );
603
385
  ```
604
386
 
605
- ### `getItemId(item)`
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
- ### Direct requests
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
- const data = await analyticsClient.request("GET", "/api/stats");
664
- const result = await analyticsClient.request("POST", "/api/events", {
665
- 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
+ },
666
409
  });
667
410
  ```
668
411
 
669
- ## Response Types
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
- ```ts
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
- import { ArcApiError, isArcApiError } from "@classytic/arc-next/client";
417
+ KEYS.detail(id); // ["products", "detail", id]
418
+ KEYS.scopedDetail(id, orgId); // tenant-scoped variant
695
419
 
696
- try {
697
- await productsApi.create({ data: { name: "" } });
698
- } catch (err) {
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
- ## Common Patterns
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
- await create(
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
- ```ts
748
- const navigate = useProductNavigation();
749
- // Prefills detail cache → no loading spinner on detail page
750
- navigate(`/products/${product._id}`, product);
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
- ### Per-instance headers
437
+ `useMutationWithOptimistic` adds optimistic cache updates with rollback.
754
438
 
755
- ```ts
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
- ## Features
763
-
764
- - **CRUD Factory** `createCrudApi` + `createCrudHooks` generates typed API clients and React Query hooks
765
- - **Optimistic Updates** Create, update, delete with instant UI feedback and automatic rollback
766
- - **Multi-Tenant Scoping** Tenant ID sent via `x-organization-id` header + scoped list query keys. Backend controls the tenant field name and access enforcement.
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