@classytic/arc-next 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,538 +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
-
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. 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:**
220
-
221
- | Method | Signature |
222
- |---|---|
223
- | `getAll` | `({ token?, organizationId?, params? }) → PaginatedResponse<T>` |
224
- | `getById` | `({ id, token?, organizationId?, params? }) → ApiResponse<T>` |
225
- | `create` | `({ data, token?, organizationId? }) → ApiResponse<T>` |
226
- | `update` | `({ id, data, token?, organizationId? }) → ApiResponse<T>` |
227
- | `delete` | `({ id, token?, organizationId? }) → DeleteResponse` |
228
- | `upload` | `({ data: FormData, id?, path?, token?, organizationId? }) → ApiResponse<T>` |
229
- | `search` | `({ searchParams?, params?, token?, organizationId? }) → PaginatedResponse<T>` |
230
- | `findBy` | `({ field, value, operator?, token?, organizationId? }) → PaginatedResponse<T>` |
231
- | `request` | `(method, endpoint, { data?, params?, token? }) → T` |
232
-
233
- **`prepareParams(params)`** — processes query params: critical filters (`organizationId`, `ownerId`) preserved as null, arrays → `field[in]`, pagination parsed to int.
234
-
235
- ### `createCrudHooks<T, TCreate, TUpdate>(config)`
112
+ Pass a custom `select` to override auto-unwrap.
236
113
 
237
- 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:
114
+ ## Actions & Custom Routes
238
115
 
239
- ```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?)`
116
+ Two escape hatches when CRUD isn't enough — both `BaseApi` methods, both routed through your configured client/auth:
268
117
 
269
118
  ```ts
270
- 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:
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",
128
+ });
129
+ const recent = await api.invokeRoute<PaginatedResponse<Todo>>({
130
+ method: "GET",
131
+ path: "/recent",
132
+ params: { limit: 5 },
290
133
  });
291
134
  ```
292
135
 
293
- #### `useDetail(id, token, options?)`
136
+ The `useAction` hook (returned from `createCrudHooks`) wraps `api.dispatchAction()` with toast + invalidation. For custom GETs, compose `api.invokeRoute()` with `useApiQuery` — it auto-unwraps the `{ success, data }` envelope:
294
137
 
295
138
  ```ts
296
- const { item, isLoading } = useDetail(productId, token, {
297
- organizationId: "org-123",
139
+ const { data } = useApiQuery({
140
+ queryKey: ["todos", "stats"],
141
+ queryFn: ({ signal }) => api.invokeRoute({ path: "/stats", options: { signal } }),
142
+ freshness: "frequent",
298
143
  });
299
144
  ```
300
145
 
301
- - Disabled when `id` is null (conditional fetching)
302
- - Extracts item from `{ data: T }` wrapper
303
-
304
- **`select` transform:**
146
+ ## Presets Opt-In Methods
305
147
 
306
- ```ts
307
- const { item } = useDetail(productId, token, {
308
- select: (data) => ({ ...data.data, fullName: `${data.data.firstName} ${data.data.lastName}` }),
309
- });
310
- ```
148
+ Vanilla `createCrudApi(...)` ships only the always-on surface (CRUD + `action` + `invokeRoute` + `upload`). Backend presets — soft-delete, bulk, slug-lookup, tree, search — light up extra routes; the SDK mirrors that with **factory wrappers**, so autocomplete only shows what your resource actually exposes and unused code tree-shakes out of the bundle.
311
149
 
312
- #### `useActions()`
150
+ > No separate `search()` / `findBy()` methods — they hit the same `GET /` as `getAll()`. Pass operators directly via params: `getAll({ params: { 'title[contains]': q, 'priority[gte]': 5 } })`. Mongokit URL grammar handles all bracket operators including geo.
313
151
 
314
152
  ```ts
315
- 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
- ```
153
+ import { withSoftDelete } from "@classytic/arc-next/presets/soft-delete";
154
+ import { withBulk } from "@classytic/arc-next/presets/bulk";
155
+ import { withSlugLookup } from "@classytic/arc-next/presets/slug";
156
+ import { withTree } from "@classytic/arc-next/presets/tree";
157
+ import { withSearchPreset } from "@classytic/arc-next/presets/search";
329
158
 
330
- - **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
159
+ // Stack only what the backend has registered
160
+ const todosApi = withBulk(withSoftDelete(createCrudApi<Todo>("todos")));
161
+ const placesApi = withSearchPreset(createCrudApi<Place>("places"));
162
+ const categoriesApi = withTree(withSlugLookup(createCrudApi<Category>("categories")));
334
163
 
335
- #### `useNavigation()`
336
-
337
- ```ts
338
- const navigate = useNavigation();
339
- navigate(`/products/${id}`, product); // push + cache prefill
340
- navigate(`/products/${id}`, product, { replace: true }); // replace
164
+ // Only categoriesApi has getBySlug + getTree + getChildren in autocomplete.
165
+ // `placesApi.embed` won't show up. `todosApi.searchEngine` is a type error.
166
+ await todosApi.bulkCreate({ data: [{ title: "A" }, { title: "B" }] });
167
+ await placesApi.searchEngine({ query: "park", body: { topK: 10 } });
168
+ await categoriesApi.getBySlug({ slug: "engineering" });
341
169
  ```
342
170
 
343
- 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?)`
171
+ | Preset | Adds methods | Backend route |
172
+ |---|---|---|
173
+ | `withSoftDelete` | `getDeleted`, `restore` | `softDelete` preset |
174
+ | `withBulk` | `bulkCreate`, `bulkUpdate`, `bulkDelete` | `bulk` preset |
175
+ | `withSlugLookup` | `getBySlug` | `slugLookup` preset |
176
+ | `withTree` | `getTree`, `getChildren` | `tree` preset |
177
+ | `withSearchPreset` | `searchEngine`, `searchSimilar`, `embed` | `searchPreset()` |
347
178
 
348
- Cursor-based infinite scrolling with automatic page aggregation:
179
+ The hook variants (`useDeleted`, `useBulkActions`, `useDetailBySlug`, `useTree`, `useChildren`, `useSearchEngine`, `useSearchSimilar`, `useEmbed`) are returned from `createCrudHooks` and gracefully throw at call time when the api wasn't wrapped with the matching preset.
349
180
 
350
- ```ts
351
- const { items, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading } =
352
- useInfiniteList(token, { organizationId: "org-123", limit: 20 });
353
- ```
181
+ ## Filter operators (mongokit URL grammar)
354
182
 
355
- - Supports both keyset (`hasMore`/`next`) and offset (`hasNext`/`page`) pagination
356
- - Returns flattened `items` across all pages
357
- - Auto-scopes query keys like `useList`
183
+ Pass any operator via bracket-key params `prepareParams` keeps operator-keyed arrays as comma-joined tuples (no `[in]` rewriting), so you get the exact wire shape mongokit's `QueryParser` expects.
358
184
 
359
- #### `useUpload(options?)`
185
+ ```ts
186
+ // Range / comparison
187
+ await api.getAll({ params: { 'priority[gte]': 5, 'price[between]': '10,100' } });
360
188
 
361
- Upload FormData with cache invalidation:
189
+ // Pattern matching
190
+ await api.getAll({ params: { 'title[contains]': 'urgent' } });
362
191
 
363
- ```ts
364
- const { mutateAsync: upload, isPending } = useUpload({
365
- messages: { success: "Uploaded!", error: "Upload failed" },
366
- onSuccess: (data) => console.log("Uploaded:", data),
367
- });
192
+ // IN list (auto-rewritten from plain array on plain field name)
193
+ await api.getAll({ params: { status: ['active', 'pending'] } });
194
+ // → status[in]=active,pending
368
195
 
369
- // 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" });
196
+ // Geo coordinate tuples preserved as-is
197
+ await api.getAll({ params: { 'location[withinRadius]': [-73.98, 40.75, 5_000] } });
198
+ await api.getAll({ params: { 'location[near]': [-73.98, 40.75, 4_000] } });
199
+ await api.getAll({ params: { 'location[geoWithin]': [-74.02, 40.7, -73.93, 40.79] } });
375
200
  ```
376
201
 
377
- Requires `api.upload` to be defined. Throws if not available.
378
-
379
- #### `useSearch(query, params?, options?)`
202
+ Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `regex`, `like`, `exists`, `between`, `near`, `nearSphere`, `withinRadius`, `geoWithin`.
380
203
 
381
- Search with automatic query key scoping:
204
+ ## SSE Real-Time
382
205
 
383
206
  ```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
207
+ import { useEventStream, buildSseUrl } from "@classytic/arc-next/sse";
391
208
 
392
- #### `useCustomMutation<TData, TVariables>(config)`
393
-
394
- Build custom mutations that share the entity's toast and invalidation patterns:
209
+ useEventStream({
210
+ resource: "agents", // auto-derives [agents.created, agents.updated, agents.deleted]
211
+ invalidateQueries: [agentKeys.lists()], // refetch on every event
212
+ });
395
213
 
396
- ```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" },
214
+ // Or explicit named events (Arc ssePlugin emits `event: <type>` frames):
215
+ useEventStream({
216
+ eventTypes: ["sync-job.phase", "sync-job.completed"],
217
+ onEvent: (event) => { /* ... */ },
401
218
  });
219
+
220
+ // Build authenticated SSE URLs for ad-hoc EventSource consumers:
221
+ const url = buildSseUrl("/jobs/stream", { jobId });
402
222
  ```
403
223
 
404
- #### `useDeleted(params?, options?)`
224
+ ## WebSocket — Real-Time + Bidirectional
405
225
 
406
- List soft-deleted items. Requires `softDelete` preset on the Arc resource.
226
+ ```ts
227
+ import { useWebSocket, buildWsUrl } from "@classytic/arc-next/ws";
407
228
 
408
- #### `useDetailBySlug(slug, 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
+ });
409
236
 
410
- Fetch a single item by slug (`GET /slug/:slug`). Requires `slugLookup` preset.
237
+ // Send any JSON payload returns false if not connected
238
+ send({ type: "chat.message", text: "hi" });
411
239
 
412
- #### `useTree(params?, options?)`
240
+ // Build the URL for a raw WebSocket consumer (Node, worker, etc.)
241
+ const url = buildWsUrl("/ws", { roomId: "r-1" });
242
+ ```
413
243
 
414
- Fetch hierarchical tree data (`GET /tree`). Requires `tree` preset.
244
+ Subscriptions persist across reconnects anything passed in `subscribe` (or via `subscribe()`) is auto-resent after the socket re-opens.
415
245
 
416
- #### `useChildren(parentId, params?, options?)`
246
+ ## Uploads with Progress
417
247
 
418
- Fetch children of a parent node (`GET /:parentId/children`). Requires `tree` preset.
248
+ `fetch()` lacks a cross-browser upload-progress API, so arc-next ships a separate XHR-based pipeline at `/upload`. Same auth + error envelope as the fetch path:
419
249
 
420
- #### `useFindBy(field, value, options?)`
250
+ ```ts
251
+ import { useUploadWithProgress } from "@classytic/arc-next/upload";
421
252
 
422
- Query by a single field with optional operator:
253
+ const { upload, progress, isUploading, cancel, error } = useUploadWithProgress<
254
+ { url: string },
255
+ { file: File; folder?: string }
256
+ >({
257
+ url: "/api/v1/media/upload",
258
+ buildFormData: ({ file, folder }) => {
259
+ const fd = new FormData();
260
+ if (folder) fd.append("folder", folder);
261
+ fd.append("file", file);
262
+ return fd;
263
+ },
264
+ invalidateQueries: [mediaKeys.lists()],
265
+ messages: { success: "Uploaded" },
266
+ });
423
267
 
424
- ```ts
425
- const { items } = useFindBy("status", "active");
426
- const { items } = useFindBy("price", 50, { operator: "gte" });
268
+ // Bind progress.percent to a <ProgressBar /> — every tick re-renders.
427
269
  ```
428
270
 
429
- #### `useBulkActions()`
271
+ For non-React consumers, `uploadWithProgress({ url, formData, onProgress, signal })` returns a Promise.
430
272
 
431
- ```ts
432
- const { bulkCreate, bulkUpdate, bulkRemove } = useBulkActions();
433
- await bulkCreate({ data: [{ name: "A" }, { name: "B" }] });
434
- ```
273
+ > **Divergence from the fetch path:** `ClientConfig.retry`, `beforeRequest`, and `afterResponse` do **not** propagate to uploads. Re-trying multi-MB bodies is rarely wanted (re-encoding cost, duplicate-write risk) and bridging XHR progress into the fetch interceptor pipeline would conflict with the upload-progress contract. Trace/correlation headers, latency loggers, and other interceptor logic must be passed explicitly via the `headers` option (or the `headers` factory on `useUploadWithProgress`). Auth, error parsing, `Idempotency-Key`, `x-arc-scope`, and `Accept-Version` all DO carry over.
435
274
 
436
- #### `useEventStream(options)` (from `./sse`)
275
+ ## Multi-Client
437
276
 
438
- Subscribe to Arc SSE events with auto-reconnect and query invalidation:
277
+ Each `createClient` call is independent its own `baseUrl`, auth, headers:
439
278
 
440
279
  ```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
- });
280
+ import { createClient } from "@classytic/arc-next/client";
447
281
 
448
- // Filtered by resource — auto-generates patterns: ['agents.*']
449
- const { lastEvent } = useEventStream({
450
- resource: "agents",
451
- invalidateQueries: [agentKeys.lists()],
282
+ const analytics = createClient({
283
+ baseUrl: "https://analytics.example.com",
284
+ authMode: "header",
285
+ getToken: () => env.ANALYTICS_KEY,
286
+ headerName: "x-api-key",
452
287
  });
453
288
 
454
- // Custom SSE path or explicit patterns
455
- const { isConnected } = useEventStream({
456
- path: "/api/v2/events",
457
- patterns: ["orders.created", "orders.updated"],
458
- });
289
+ const eventsApi = createCrudApi("events", { client: analytics });
459
290
  ```
460
291
 
461
- ### Query Keys (`KEYS`)
292
+ For consumer SDKs that just need to bridge the global auth singleton:
462
293
 
463
294
  ```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`)
295
+ import { createAuthAwareClient } from "@classytic/arc-next/client";
475
296
 
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);
297
+ const api = createCrudApi("products", { client: createAuthAwareClient() });
492
298
  ```
493
299
 
494
- ### `getQueryClient(overrides?)`
300
+ ## SSR Prefetch (Next.js App Router / Server Components)
495
301
 
496
- SSR-safe singleton. Server: new per request. Browser: reuses singleton.
302
+ `createCrudPrefetcher` plus `getQueryClient` give you the canonical TanStack Query × Next.js App Router pattern: per-request `QueryClient` on the server, prefetch on the route, hydrate into a `"use client"` child via `HydrationBoundary`.
497
303
 
498
- ```ts
304
+ ```tsx
305
+ // app/products/page.tsx — Server Component (no "use client")
306
+ import { createCrudPrefetcher, dehydrate, HydrationBoundary } from "@classytic/arc-next/prefetch";
499
307
  import { getQueryClient } from "@classytic/arc-next/query-client";
500
- import { QueryClientProvider } from "@tanstack/react-query";
501
-
502
- function Providers({ children }) {
503
- const queryClient = getQueryClient();
504
- return (
505
- <QueryClientProvider client={queryClient}>
506
- {children}
507
- </QueryClientProvider>
508
- );
509
- }
510
- ```
511
-
512
- Defaults: `staleTime: 5min`, `gcTime: 30min`, `retry: 0`, `refetchOnWindowFocus: false`.
513
-
514
- ## SSR Prefetch (Server Components)
515
-
516
- Pre-populate the query cache on the server to avoid loading spinners:
517
-
518
- ```ts
519
- // products-prefetch.ts
520
- import { createCrudPrefetcher } from "@classytic/arc-next/prefetch";
521
308
  import { productsApi } from "@/api/products-api";
309
+ import { ProductsList } from "./products-list"; // "use client"
522
310
 
523
- 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";
311
+ const prefetcher = createCrudPrefetcher(productsApi, "products");
532
312
 
533
313
  export default async function ProductsPage() {
534
- const queryClient = getQueryClient();
535
- await productsPrefetcher.prefetchList(queryClient, { limit: 20 });
314
+ const queryClient = getQueryClient(); // per-request on server
315
+ await prefetcher.prefetchList(queryClient, { limit: 20 }, { token, organizationId });
536
316
 
537
317
  return (
538
318
  <HydrationBoundary state={dehydrate(queryClient)}>
@@ -542,297 +322,127 @@ export default async function ProductsPage() {
542
322
  }
543
323
  ```
544
324
 
545
- **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
325
+ Methods: `prefetchList`, `prefetchDetail`, `prefetchBySlug`, `prefetchDeleted`, `prefetchTree`, `prefetchInfiniteList`.
558
326
 
559
- 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.
560
328
 
561
- ### `useMutationWithTransition(config)`
329
+ ### Streaming with promise-pending dehydration (TanStack Query 5.40+)
562
330
 
563
- 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:
564
332
 
565
- ```ts
566
- 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 });
567
338
 
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
- });
339
+ return (
340
+ <HydrationBoundary state={dehydrate(queryClient)}>
341
+ <Suspense fallback={<ListSkeleton />}>
342
+ <ProductsList />
343
+ </Suspense>
344
+ </HydrationBoundary>
345
+ );
577
346
  }
578
347
  ```
579
348
 
580
- Returns: `{ mutate, mutateAsync, isPending, isSuccess, isError, error, data, reset }`
349
+ ### Server-safe utilities
581
350
 
582
- ### `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`.
583
352
 
584
- Mutation + optimistic updates + automatic rollback:
353
+ ### Next.js 16 `cacheComponents` + `'use cache'`
585
354
 
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
- ```
355
+ TanStack Query manages a client-side cache; data fetched through arc-next hooks should NOT be wrapped in a Server Component's `'use cache'` directive (which would bake the hook output into the static render). Use `'use cache'` for non-arc Server Component fetches (e.g., direct DB queries, third-party APIs). The two layers compose cleanly because they target different cache tiers.
604
356
 
605
- ### Query Config Presets
357
+ ## Errors
606
358
 
607
359
  ```ts
608
- import { QUERY_CONFIGS } from "@classytic/arc-next/mutation";
360
+ import { isArcApiError, isAbortError, isArcErrorCode } from "@classytic/arc-next/client";
609
361
 
610
- // Use in useList options:
611
- 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
+ }
612
373
  ```
613
374
 
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)`
375
+ `fieldErrors` reads three shapes: `{ errors: { field: msg } }`, `{ details: { errors: [{ field, message }] } }`, raw AJV `{ instancePath, message }`.
624
376
 
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).
377
+ `KNOWN_TOP_LEVEL_CODES` and `KNOWN_DETAILS_CODES` are exported as `as const` arrays useful for runtime iteration (i18n lookup, retry whitelist, code-mapped UI):
627
378
 
628
379
  ```ts
629
- import { updateListCache } from "@classytic/arc-next/query";
380
+ import { KNOWN_TOP_LEVEL_CODES } from "@classytic/arc-next/client";
630
381
 
631
- queryClient.setQueryData(KEYS.lists(), (old) =>
632
- 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}`)])
633
384
  );
634
385
  ```
635
386
 
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).
387
+ ## Retry + Interceptors
643
388
 
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:
389
+ Network resilience for mutations + direct `handleApiRequest` calls (TanStack Query already retries reads). Off by default — opt in via `configureClient`:
655
390
 
656
391
  ```ts
657
- 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" },
392
+ configureClient({
393
+ baseUrl: process.env.NEXT_PUBLIC_API_URL!,
394
+ retry: {
395
+ attempts: 3, // 1 initial + 2 retries; default off
396
+ backoff: 'exponential', // 'exponential' | 'linear' | (attempt) => ms
397
+ // retryOn: [502, 503, 504], // optional whitelist; default = network failures + 5xx, never 4xx, never AbortError
398
+ },
399
+ // Mutate outgoing requests (per attempt — retries re-run this)
400
+ beforeRequest: (ctx) => ({
401
+ ...ctx,
402
+ headers: { ...ctx.headers, 'x-correlation-id': crypto.randomUUID() },
403
+ }),
404
+ // Inspect / transform successful responses (4xx/5xx throw before this)
405
+ afterResponse: (ctx) => {
406
+ console.log(`[arc] ${ctx.method} ${ctx.endpoint} ${ctx.status} ${ctx.durationMs}ms`);
407
+ return ctx;
408
+ },
711
409
  });
712
410
  ```
713
411
 
714
- ## 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
412
+ Interceptors are async-supported and compose with retry — `beforeRequest` re-runs each attempt (so a refreshed token mid-flight is picked up). Aborting via `AbortSignal` cancels both the pending fetch AND any in-flight backoff sleep.
755
413
 
756
- ### Multi-tenant data fetching
414
+ ## Cache & Keys
757
415
 
758
416
  ```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
- ```
417
+ KEYS.detail(id); // ["products", "detail", id]
418
+ KEYS.scopedDetail(id, orgId); // tenant-scoped variant
764
419
 
765
- ### Public endpoints (no auth)
766
-
767
- ```ts
768
- const { items } = useProducts(null, {}, { public: true });
420
+ cache.setDetail(qc, id, data);
421
+ cache.invalidateDetail(qc, id); // matches all scoped variants
422
+ cache.invalidateLists(qc);
769
423
  ```
770
424
 
771
- ### Conditional fetching
772
-
773
- ```ts
774
- const { item } = useProduct(selectedId, token); // disabled when selectedId is null
775
- ```
776
-
777
- ### Per-call callbacks
425
+ ## Custom Mutations
778
426
 
779
427
  ```ts
780
- 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
428
+ import { useMutationWithTransition } from "@classytic/arc-next/mutation";
791
429
 
792
- ```ts
793
- const navigate = useProductNavigation();
794
- // Prefills detail cache → no loading spinner on detail page
795
- 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
+ });
796
435
  ```
797
436
 
798
- ### Per-instance headers
437
+ `useMutationWithOptimistic` adds optimistic cache updates with rollback.
799
438
 
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
- ```
439
+ ## Auth Modes
806
440
 
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
441
+ | Mode | When | Notes |
442
+ |---|---|---|
443
+ | `bearer` (default) | JWT / opaque token | `getToken()` returns the token |
444
+ | `cookie` | Better Auth, session cookies | No token needed; `credentials: 'include'` automatic |
445
+ | `header` | API keys (`x-api-key`, etc.) | Set `headerName` on `configureAuth` or `createClient` |
836
446
 
837
447
  ## License
838
448