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