@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 +272 -660
- package/dist/api.d.ts +115 -194
- package/dist/api.js +69 -116
- package/dist/cache.d.ts +125 -0
- package/dist/cache.js +211 -0
- package/dist/client.d.ts +295 -5
- package/dist/client.js +392 -12
- package/dist/hooks.d.ts +162 -22
- package/dist/hooks.js +249 -89
- package/dist/mutation.d.ts +18 -1
- package/dist/mutation.js +20 -1
- package/dist/prefetch.d.ts +36 -2
- package/dist/prefetch.js +47 -3
- package/dist/presets/bulk.d.ts +43 -0
- package/dist/presets/bulk.js +50 -0
- package/dist/presets/search.d.ts +56 -0
- package/dist/presets/search.js +60 -0
- package/dist/presets/slug.d.ts +28 -0
- package/dist/presets/slug.js +27 -0
- package/dist/presets/soft-delete.d.ts +34 -0
- package/dist/presets/soft-delete.js +45 -0
- package/dist/presets/tree.d.ts +32 -0
- package/dist/presets/tree.js +47 -0
- package/dist/query.d.ts +86 -69
- package/dist/query.js +59 -161
- package/dist/sse.d.ts +115 -36
- package/dist/sse.js +179 -103
- package/dist/upload.d.ts +181 -0
- package/dist/upload.js +346 -0
- package/dist/ws.d.ts +167 -0
- package/dist/ws.js +274 -0
- package/package.json +39 -1
package/README.md
CHANGED
|
@@ -1,538 +1,320 @@
|
|
|
1
1
|
# @classytic/arc-next
|
|
2
2
|
|
|
3
|
-
React + TanStack Query SDK for Arc
|
|
3
|
+
React + TanStack Query SDK for the Arc backend framework. Typed CRUD hooks, optimistic updates with rollback, multi-tenant cache scoping, pagination normalization, real-time SSE.
|
|
4
4
|
|
|
5
|
-
**
|
|
6
|
-
|
|
7
|
-
## Install
|
|
5
|
+
**Peers:** React 19+, TanStack React Query 5+
|
|
8
6
|
|
|
9
7
|
```bash
|
|
10
8
|
npm install @classytic/arc-next
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
**Peer dependencies:**
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm install react@^19 @tanstack/react-query@^5
|
|
17
|
-
```
|
|
18
|
-
|
|
19
11
|
## Setup
|
|
20
12
|
|
|
21
|
-
Call
|
|
13
|
+
Call once at app init from a `"use client"` provider:
|
|
22
14
|
|
|
23
15
|
```ts
|
|
24
|
-
import { configureClient, configureAuth } from "@classytic/arc-next/client";
|
|
16
|
+
import { configureClient, configureAuth, createAuthAwareClient } from "@classytic/arc-next/client";
|
|
25
17
|
import { configureToast } from "@classytic/arc-next/mutation";
|
|
26
18
|
import { configureNavigation } from "@classytic/arc-next/hooks";
|
|
27
|
-
import { toast } from "sonner";
|
|
28
|
-
import { useRouter } from "next/navigation";
|
|
29
|
-
|
|
30
|
-
// Required — sets the API base URL and auth mode
|
|
31
|
-
configureClient({
|
|
32
|
-
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
33
|
-
authMode: "cookie", // 'cookie' | 'bearer' (default) | 'header'
|
|
34
|
-
// apiVersion: '2', // sends Accept-Version header
|
|
35
|
-
// autoIdempotency: true, // auto Idempotency-Key on mutations (retry-safe)
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// Optional — auto-inject tenant context into queries/mutations
|
|
39
|
-
configureAuth({
|
|
40
|
-
getOrgId: () => activeTenantId, // return current tenant/org/workspace ID
|
|
41
|
-
getToken: () => null, // null for cookie auth (token only for bearer)
|
|
42
|
-
});
|
|
43
19
|
|
|
44
|
-
|
|
20
|
+
configureClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL!, authMode: "cookie" });
|
|
21
|
+
configureAuth({ getToken: () => session?.token ?? null, getOrgId: () => org?.id ?? null });
|
|
45
22
|
configureToast({ success: toast.success, error: toast.error });
|
|
46
|
-
|
|
47
|
-
// Optional — enables useNavigation() routing (defaults to cache-only)
|
|
48
23
|
configureNavigation(useRouter);
|
|
49
24
|
```
|
|
50
25
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
| Import | Purpose | `"use client"` |
|
|
54
|
-
| ----------------------------------- | ---------------------------------------------------------------------------- | :-------------: |
|
|
55
|
-
| `@classytic/arc-next` | Root — same as `/hooks` (`createCrudHooks`, `configureNavigation`) | Yes |
|
|
56
|
-
| `@classytic/arc-next/client` | `configureClient`, `configureAuth`, `createClient`, `handleApiRequest`, `createQueryString`, `ArcApiError`, `isArcApiError`, `getAuthMode`, `getAuthContext` | No |
|
|
57
|
-
| `@classytic/arc-next/api` | `BaseApi`, `createCrudApi`, response types, type guards | No |
|
|
58
|
-
| `@classytic/arc-next/query` | `createQueryKeys`, `createCacheUtils`, `useListQuery`, `useDetailQuery` | Yes |
|
|
59
|
-
| `@classytic/arc-next/mutation` | `configureToast`, `useMutationWithTransition`, `useOptimisticMutation` | Yes |
|
|
60
|
-
| `@classytic/arc-next/hooks` | `createCrudHooks`, `configureNavigation` | Yes |
|
|
61
|
-
| `@classytic/arc-next/query-client` | `getQueryClient` (SSR-safe singleton) | No |
|
|
62
|
-
| `@classytic/arc-next/prefetch` | `createCrudPrefetcher`, `dehydrate` (SSR prefetch) | No |
|
|
63
|
-
| `@classytic/arc-next/sse` | `useEventStream` (Server-Sent Events for real-time cache invalidation) | Yes |
|
|
64
|
-
|
|
65
|
-
No barrel index — every file is its own entry point. Tree-shakeable (`sideEffects: false`).
|
|
26
|
+
`getToken` **must be synchronous** — cache async tokens out-of-band. Promise returns are dropped + warned in dev.
|
|
66
27
|
|
|
67
28
|
## Quick Start
|
|
68
29
|
|
|
69
|
-
### 1. Define API
|
|
70
|
-
|
|
71
30
|
```ts
|
|
72
31
|
import { createCrudApi } from "@classytic/arc-next/api";
|
|
32
|
+
import { createCrudHooks } from "@classytic/arc-next/hooks";
|
|
33
|
+
import { withSoftDelete } from "@classytic/arc-next/presets/soft-delete";
|
|
34
|
+
import { withBulk } from "@classytic/arc-next/presets/bulk";
|
|
73
35
|
|
|
74
|
-
interface Product {
|
|
75
|
-
_id: string;
|
|
76
|
-
name: string;
|
|
77
|
-
price: number;
|
|
78
|
-
organizationId: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface CreateProduct {
|
|
82
|
-
name: string;
|
|
83
|
-
price: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export const productsApi = createCrudApi<Product, CreateProduct>(
|
|
87
|
-
"products",
|
|
88
|
-
{ basePath: "/api" }
|
|
89
|
-
);
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
### 2. Create hooks
|
|
36
|
+
interface Product { _id: string; name: string; price: number; }
|
|
93
37
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
38
|
+
// Compose only the presets your backend actually mounts.
|
|
39
|
+
// Vanilla `createCrudApi` ships CRUD + action + invokeRoute + upload only;
|
|
40
|
+
// add presets via factory wrappers (matches arc's server-side `presets: [...]`).
|
|
41
|
+
const productsApi = withBulk(withSoftDelete(
|
|
42
|
+
createCrudApi<Product>("products", { basePath: "/api" }),
|
|
43
|
+
));
|
|
97
44
|
|
|
98
45
|
export const {
|
|
99
|
-
KEYS
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
useNavigation: useProductNavigation,
|
|
105
|
-
} = createCrudHooks<Product, CreateProduct>({
|
|
106
|
-
api: productsApi,
|
|
107
|
-
entityKey: "products",
|
|
108
|
-
singular: "Product",
|
|
109
|
-
});
|
|
46
|
+
KEYS, cache,
|
|
47
|
+
useList, useDetail, useActions, useNavigation,
|
|
48
|
+
useInfiniteList, useUpload, useCustomMutation,
|
|
49
|
+
useDeleted, useBulkActions, useDetailBySlug, useTree, useChildren,
|
|
50
|
+
} = createCrudHooks<Product>({ api: productsApi, entityKey: "products", singular: "Product" });
|
|
110
51
|
```
|
|
111
52
|
|
|
112
|
-
### 3. Use in components
|
|
113
|
-
|
|
114
53
|
```tsx
|
|
115
54
|
"use client";
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const {
|
|
119
|
-
|
|
120
|
-
}, { public: true });
|
|
121
|
-
|
|
122
|
-
const { create, remove, isCreating } = useProductActions();
|
|
123
|
-
|
|
124
|
-
if (isLoading) return <div>Loading...</div>;
|
|
125
|
-
|
|
126
|
-
return (
|
|
127
|
-
<div>
|
|
128
|
-
<button
|
|
129
|
-
onClick={() => create({ data: { name: "Widget", price: 9.99 } })}
|
|
130
|
-
disabled={isCreating}
|
|
131
|
-
>
|
|
132
|
-
Add Product
|
|
133
|
-
</button>
|
|
134
|
-
{items.map((p) => (
|
|
135
|
-
<div key={p._id}>
|
|
136
|
-
{p.name} — ${p.price}
|
|
137
|
-
<button onClick={() => remove({ id: p._id })}>Delete</button>
|
|
138
|
-
</div>
|
|
139
|
-
))}
|
|
140
|
-
{pagination && <span>{pagination.total} total</span>}
|
|
141
|
-
</div>
|
|
142
|
-
);
|
|
55
|
+
function Products() {
|
|
56
|
+
const { items, pagination, isLoading } = useList(null, { organizationId: orgId });
|
|
57
|
+
const { create, update, remove, isCreating } = useActions();
|
|
58
|
+
// ...
|
|
143
59
|
}
|
|
144
60
|
```
|
|
145
61
|
|
|
146
|
-
##
|
|
147
|
-
|
|
148
|
-
### `configureClient(config)`
|
|
149
|
-
|
|
150
|
-
```ts
|
|
151
|
-
configureClient({
|
|
152
|
-
baseUrl: string; // Required — API base URL
|
|
153
|
-
authMode?: 'bearer' | 'cookie' | 'header'; // Default: 'bearer'
|
|
154
|
-
credentials?: RequestCredentials; // Default: derived from authMode
|
|
155
|
-
internalApiKey?: string; // Sent as x-internal-api-key header
|
|
156
|
-
defaultHeaders?: Record<string, string>; // Merged into every request
|
|
157
|
-
apiVersion?: string; // Sent as Accept-Version header
|
|
158
|
-
autoIdempotency?: boolean; // Auto Idempotency-Key on mutations (retry-safe)
|
|
159
|
-
});
|
|
160
|
-
```
|
|
62
|
+
## Subpath Exports
|
|
161
63
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
64
|
+
| Import | Server-safe | Exports |
|
|
65
|
+
|---|:-:|---|
|
|
66
|
+
| `/client` | yes | `configureClient`, `configureAuth`, `createClient`, `createAuthAwareClient`, `handleApiRequest`, `ArcApiError`, `isArcApiError`, `isAbortError`, `isArcErrorCode`, `KNOWN_TOP_LEVEL_CODES`, `KNOWN_DETAILS_CODES`, `getAuthMode`, `getAuthContext`, `getBaseUrl`, `createQueryString` |
|
|
67
|
+
| `/api` | yes | `BaseApi`, `createCrudApi`, response types + type guards |
|
|
68
|
+
| `/cache` | yes | `createQueryKeys`, `createCacheUtils`, `extractItem`, `extractItems`, `getItemId`, `updateListCache`, `normalizePagination`, `QUERY_CONFIGS`, `DEFAULT_QUERY_CONFIG` — server-safe utilities for RSC prefetch + Server Component imports |
|
|
69
|
+
| `/query` | client | `useApiQuery`, `useListQuery`, `useDetailQuery`, `useInfiniteListQuery` — React hooks. Re-exports the cache utilities for back-compat, but new code should import server-safe utils from `/cache` directly |
|
|
70
|
+
| `/mutation` | client | `configureToast`, `useMutationWithTransition`, `useMutationWithOptimistic` |
|
|
71
|
+
| `/hooks` | client | `createCrudHooks`, `configureNavigation` (also default export) |
|
|
72
|
+
| `/query-client` | yes | `getQueryClient` (SSR-safe singleton) |
|
|
73
|
+
| `/prefetch` | yes | `createCrudPrefetcher`, `dehydrate` |
|
|
74
|
+
| `/sse` | client | `useEventStream`, `buildSseUrl`, `subscribeToEvents` |
|
|
75
|
+
| `/ws` | client | `useWebSocket`, `buildWsUrl`, `connectWs` |
|
|
76
|
+
| `/upload` | client | `useUploadWithProgress`, `uploadWithProgress` — XHR-based uploads with native progress events |
|
|
77
|
+
| `/presets/soft-delete` | yes | `withSoftDelete` — adds `getDeleted`, `restore` |
|
|
78
|
+
| `/presets/bulk` | yes | `withBulk` — adds `bulkCreate`, `bulkUpdate`, `bulkDelete` |
|
|
79
|
+
| `/presets/slug` | yes | `withSlugLookup` — adds `getBySlug` |
|
|
80
|
+
| `/presets/tree` | yes | `withTree` — adds `getTree`, `getChildren` |
|
|
81
|
+
| `/presets/search` | yes | `withSearchPreset` — adds `searchEngine`, `searchSimilar`, `embed` |
|
|
165
82
|
|
|
166
|
-
|
|
83
|
+
`sideEffects: false`. No barrel — every file is its own entry point.
|
|
167
84
|
|
|
168
|
-
|
|
85
|
+
## Core Hooks (from `createCrudHooks`)
|
|
169
86
|
|
|
170
87
|
```ts
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
});
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
Auto-injects `token` and tenant ID (sent as `x-organization-id` header) into queries/mutations. The header name is a convention — your backend controls how it's read and which field it maps to (`organizationId`, `workspaceId`, `teamId`, etc.). Hooks use the new signature (no explicit token param) — legacy signature still works.
|
|
179
|
-
|
|
180
|
-
### `handleApiRequest<T>(method, endpoint, options?)`
|
|
88
|
+
const { items, pagination, isLoading, refetch } = useList(token, params, options);
|
|
89
|
+
const { item, isLoading } = useDetail(id, token, options);
|
|
90
|
+
const { create, update, remove, isMutating } = useActions();
|
|
91
|
+
const { items, hasNextPage, fetchNextPage } = useInfiniteList(token, params);
|
|
181
92
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
```ts
|
|
185
|
-
const result = await handleApiRequest<ApiResponse<User>>("GET", "/api/users/me");
|
|
186
|
-
const list = await handleApiRequest<PaginatedResponse<Product>>("GET", "/api/products?page=1");
|
|
93
|
+
await create({ data, organizationId }, { onSuccess: (item) => navigate(...) });
|
|
187
94
|
```
|
|
188
95
|
|
|
189
|
-
|
|
190
|
-
- `body` — request body (auto-serializes JSON, passes FormData as-is)
|
|
191
|
-
- `token` — Bearer token
|
|
192
|
-
- `organizationId` — sent as `x-organization-id` header
|
|
193
|
-
- `headerOptions` — additional headers merged into request
|
|
194
|
-
- `signal` — AbortSignal for request cancellation
|
|
195
|
-
- `revalidate` / `tags` / `cache` — Next.js fetch extensions
|
|
196
|
-
|
|
197
|
-
### `createQueryString(params)`
|
|
96
|
+
All mutations are optimistic with automatic rollback on error. Lists prefill the detail cache. Cache keys auto-scope by `organizationId` when present.
|
|
198
97
|
|
|
199
|
-
|
|
200
|
-
- Arrays → `field[in]=a,b,c`
|
|
201
|
-
- `populateOptions` → `populate[path][select]=field1,field2`
|
|
202
|
-
- `null` → `field=null`
|
|
98
|
+
### `useApiQuery` — non-CRUD reads
|
|
203
99
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
Creates a typed API client instance.
|
|
100
|
+
For reports, aggregates, RPC-style endpoints. Response IS the data — arc 2.13+ has no envelope:
|
|
207
101
|
|
|
208
102
|
```ts
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
},
|
|
103
|
+
import { useApiQuery } from "@classytic/arc-next/query";
|
|
104
|
+
|
|
105
|
+
const { data, isLoading } = useApiQuery<DashboardStats>({
|
|
106
|
+
queryKey: ["dashboard", "stats"],
|
|
107
|
+
queryFn: ({ signal }) => api.request("GET", "/dashboard/stats", { options: { signal } }),
|
|
108
|
+
freshness: "realtime", // 'realtime' | 'frequent' | 'stable' | 'static'
|
|
216
109
|
});
|
|
217
110
|
```
|
|
218
111
|
|
|
219
|
-
|
|
112
|
+
Pass a custom `select` to project a sub-field from the response.
|
|
220
113
|
|
|
221
|
-
|
|
222
|
-
|---|---|
|
|
223
|
-
| `getAll` | `({ token?, organizationId?, params? }) → PaginatedResponse<T>` |
|
|
224
|
-
| `getById` | `({ id, token?, organizationId?, params? }) → ApiResponse<T>` |
|
|
225
|
-
| `create` | `({ data, token?, organizationId? }) → ApiResponse<T>` |
|
|
226
|
-
| `update` | `({ id, data, token?, organizationId? }) → ApiResponse<T>` |
|
|
227
|
-
| `delete` | `({ id, token?, organizationId? }) → DeleteResponse` |
|
|
228
|
-
| `upload` | `({ data: FormData, id?, path?, token?, organizationId? }) → ApiResponse<T>` |
|
|
229
|
-
| `search` | `({ searchParams?, params?, token?, organizationId? }) → PaginatedResponse<T>` |
|
|
230
|
-
| `findBy` | `({ field, value, operator?, token?, organizationId? }) → PaginatedResponse<T>` |
|
|
231
|
-
| `request` | `(method, endpoint, { data?, params?, token? }) → T` |
|
|
114
|
+
## Actions & Custom Routes
|
|
232
115
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
### `createCrudHooks<T, TCreate, TUpdate>(config)`
|
|
236
|
-
|
|
237
|
-
Factory that returns everything you need. The `api` parameter accepts any `createCrudApi()` result directly — no casts needed. Types are derived from `BaseApi` via `Pick`, so generics thread through automatically:
|
|
116
|
+
Two escape hatches when CRUD isn't enough — both `BaseApi` methods, both routed through your configured client/auth:
|
|
238
117
|
|
|
239
118
|
```ts
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
useDeleted, useDetailBySlug, useTree, useChildren, useFindBy,
|
|
245
|
-
useUpload, useSearch, useCustomMutation,
|
|
246
|
-
useNavigation,
|
|
247
|
-
} = createCrudHooks<Product, CreateProduct>({
|
|
248
|
-
api: productsApi, // from createCrudApi() — types inferred, no cast
|
|
249
|
-
entityKey: "products", // TanStack Query key prefix
|
|
250
|
-
singular: "Product", // for toast messages
|
|
251
|
-
idField: "sku", // optional — custom ID field for cache keys (default: _id → id)
|
|
252
|
-
defaults: { // optional
|
|
253
|
-
staleTime: 60_000,
|
|
254
|
-
messages: { createSuccess: "Product added!" },
|
|
255
|
-
},
|
|
256
|
-
callbacks: { // optional
|
|
257
|
-
onCreate: {
|
|
258
|
-
onSuccess: (data) => console.log("Created:", data),
|
|
259
|
-
onSettled: (data, error) => console.log("Done"),
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
```
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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 {
|
|
297
|
-
|
|
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
|
-
|
|
302
|
-
- Extracts item from `{ data: T }` wrapper
|
|
148
|
+
## Presets — Opt-In Methods
|
|
303
149
|
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
await update({ id: "123", data: { name: "Updated" } });
|
|
321
|
-
await remove({ id: "123" });
|
|
322
|
-
|
|
323
|
-
// Per-call callbacks
|
|
324
|
-
await create(
|
|
325
|
-
{ data: { name: "New" } },
|
|
326
|
-
{ onSuccess: (item) => navigate(`/products/${item._id}`) }
|
|
327
|
-
);
|
|
328
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
+
## Filter operators (mongokit URL grammar)
|
|
349
184
|
|
|
350
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
#### `useUpload(options?)`
|
|
187
|
+
```ts
|
|
188
|
+
// Range / comparison
|
|
189
|
+
await api.getAll({ params: { 'priority[gte]': 5, 'price[between]': '10,100' } });
|
|
360
190
|
|
|
361
|
-
|
|
191
|
+
// Pattern matching
|
|
192
|
+
await api.getAll({ params: { 'title[contains]': 'urgent' } });
|
|
362
193
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
//
|
|
370
|
-
await
|
|
371
|
-
|
|
372
|
-
await
|
|
373
|
-
// Post to /products/bulk-import (custom path takes precedence over id)
|
|
374
|
-
await upload({ data: formData, path: "bulk-import" });
|
|
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
|
-
|
|
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
|
-
|
|
206
|
+
## SSE — Real-Time
|
|
382
207
|
|
|
383
208
|
```ts
|
|
384
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
226
|
+
## WebSocket — Real-Time + Bidirectional
|
|
405
227
|
|
|
406
|
-
|
|
228
|
+
```ts
|
|
229
|
+
import { useWebSocket, buildWsUrl } from "@classytic/arc-next/ws";
|
|
407
230
|
|
|
408
|
-
|
|
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
|
-
|
|
239
|
+
// Send any JSON payload — returns false if not connected
|
|
240
|
+
send({ type: "chat.message", text: "hi" });
|
|
411
241
|
|
|
412
|
-
|
|
242
|
+
// Build the URL for a raw WebSocket consumer (Node, worker, etc.)
|
|
243
|
+
const url = buildWsUrl("/ws", { roomId: "r-1" });
|
|
244
|
+
```
|
|
413
245
|
|
|
414
|
-
|
|
246
|
+
Subscriptions persist across reconnects — anything passed in `subscribe` (or via `subscribe()`) is auto-resent after the socket re-opens.
|
|
415
247
|
|
|
416
|
-
|
|
248
|
+
## Uploads with Progress
|
|
417
249
|
|
|
418
|
-
|
|
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
|
-
|
|
252
|
+
```ts
|
|
253
|
+
import { useUploadWithProgress } from "@classytic/arc-next/upload";
|
|
421
254
|
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
+
For non-React consumers, `uploadWithProgress({ url, formData, onProgress, signal })` returns a Promise.
|
|
430
274
|
|
|
431
|
-
|
|
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
|
-
|
|
277
|
+
## Multi-Client
|
|
437
278
|
|
|
438
|
-
|
|
279
|
+
Each `createClient` call is independent — its own `baseUrl`, auth, headers:
|
|
439
280
|
|
|
440
281
|
```ts
|
|
441
|
-
import {
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
+
For consumer SDKs that just need to bridge the global auth singleton:
|
|
462
295
|
|
|
463
296
|
```ts
|
|
464
|
-
|
|
465
|
-
KEYS.lists() // ["products", "list"]
|
|
466
|
-
KEYS.list(params) // ["products", "list", params]
|
|
467
|
-
KEYS.details() // ["products", "detail"]
|
|
468
|
-
KEYS.detail(id) // ["products", "detail", id]
|
|
469
|
-
KEYS.scopedDetail(id, orgId) // ["products", "detail", id, { _org: orgId }] (or bare when null)
|
|
470
|
-
KEYS.custom("stats", orgId) // ["products", "stats", orgId]
|
|
471
|
-
KEYS.scopedList("tenant", params) // ["products", "list", { _scope: "tenant", ...params }]
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
### Cache Utilities (`cache`)
|
|
297
|
+
import { createAuthAwareClient } from "@classytic/arc-next/client";
|
|
475
298
|
|
|
476
|
-
|
|
477
|
-
// Bare (single-tenant or public)
|
|
478
|
-
cache.setDetail(queryClient, id, data);
|
|
479
|
-
cache.getDetail(queryClient, id);
|
|
480
|
-
cache.removeDetail(queryClient, id);
|
|
481
|
-
await cache.invalidateDetail(queryClient, id); // prefix-matches ALL scoped variants
|
|
482
|
-
|
|
483
|
-
// Tenant-scoped (multi-tenant — isolated per org)
|
|
484
|
-
cache.setScopedDetail(queryClient, id, orgId, data);
|
|
485
|
-
cache.getScopedDetail(queryClient, id, orgId);
|
|
486
|
-
cache.removeScopedDetail(queryClient, id, orgId);
|
|
487
|
-
await cache.invalidateScopedDetail(queryClient, id, orgId);
|
|
488
|
-
|
|
489
|
-
// Global
|
|
490
|
-
await cache.invalidateAll(queryClient);
|
|
491
|
-
await cache.invalidateLists(queryClient);
|
|
299
|
+
const api = createCrudApi("products", { client: createAuthAwareClient() });
|
|
492
300
|
```
|
|
493
301
|
|
|
494
|
-
|
|
302
|
+
## SSR Prefetch (Next.js App Router / Server Components)
|
|
495
303
|
|
|
496
|
-
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
331
|
+
### Streaming with promise-pending dehydration (TanStack Query 5.40+)
|
|
562
332
|
|
|
563
|
-
|
|
333
|
+
`getQueryClient()` ships a default `dehydrate.shouldDehydrateQuery` that includes pending queries, so you can fire-and-forget prefetches inside Suspense boundaries:
|
|
564
334
|
|
|
565
|
-
```
|
|
566
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
351
|
+
### Server-safe utilities
|
|
581
352
|
|
|
582
|
-
|
|
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
|
-
|
|
355
|
+
### Next.js 16 `cacheComponents` + `'use cache'`
|
|
585
356
|
|
|
586
|
-
|
|
587
|
-
import { useMutationWithOptimistic } from "@classytic/arc-next/mutation";
|
|
588
|
-
|
|
589
|
-
export function useToggleFavorite() {
|
|
590
|
-
return useMutationWithOptimistic({
|
|
591
|
-
mutationFn: ({ id, isFav }) =>
|
|
592
|
-
api.request("PATCH", `/api/products/${id}`, {
|
|
593
|
-
data: { favorite: !isFav },
|
|
594
|
-
}),
|
|
595
|
-
queryKeys: [productKeys.lists()],
|
|
596
|
-
optimisticUpdate: (old, { id, isFav }) =>
|
|
597
|
-
updateListCache(old, (items) =>
|
|
598
|
-
items.map((i) => (getItemId(i) === id ? { ...i, favorite: !isFav } : i))
|
|
599
|
-
),
|
|
600
|
-
messages: { success: "Updated!" },
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
```
|
|
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
|
-
|
|
359
|
+
## Errors
|
|
606
360
|
|
|
607
361
|
```ts
|
|
608
|
-
import {
|
|
362
|
+
import { isArcApiError, isAbortError, isArcErrorCode } from "@classytic/arc-next/client";
|
|
609
363
|
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
382
|
+
import { KNOWN_TOP_LEVEL_CODES } from "@classytic/arc-next/client";
|
|
630
383
|
|
|
631
|
-
|
|
632
|
-
|
|
384
|
+
const ERROR_MESSAGES = Object.fromEntries(
|
|
385
|
+
KNOWN_TOP_LEVEL_CODES.map((code) => [code, t(`error.${code}`)])
|
|
633
386
|
);
|
|
634
387
|
```
|
|
635
388
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
Extracts `_id` or `id` from any item. Returns `string | null`.
|
|
639
|
-
|
|
640
|
-
### `normalizePagination(data)`
|
|
641
|
-
|
|
642
|
-
Converts any pagination response format to a normalized `PaginationData` object. Detects pagination method (`offset`, `keyset`, `aggregate`) and normalizes all fields: `total`/`totalDocs`, `pages`/`totalPages`, `page`/`currentPage`, `hasNext`/`hasNextPage`/`hasMore`, `hasPrev`/`hasPrevPage`, `next` (keyset cursor).
|
|
389
|
+
## Retry + Interceptors
|
|
643
390
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
Extracts the items array from any response format. Checks well-known keys first (`docs`, `data`, `items`, `results`), then falls back to finding the first top-level array — so `{ products: [...] }` or `{ users: [...] }` works without configuration.
|
|
647
|
-
|
|
648
|
-
## Multi-Client (Multiple APIs)
|
|
649
|
-
|
|
650
|
-
By default, `configureClient()` sets a single global `baseUrl`. Use `createClient()` when your app talks to multiple backends.
|
|
651
|
-
|
|
652
|
-
### Create isolated clients
|
|
653
|
-
|
|
654
|
-
Each client gets its own `baseUrl`, auth, and headers — fully independent from the global config:
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
// Cookie auth for auth service
|
|
675
|
-
const authClient = createClient({
|
|
676
|
-
baseUrl: "https://auth.example.com",
|
|
677
|
-
authMode: "cookie",
|
|
678
|
-
});
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
### Use with createCrudApi
|
|
682
|
-
|
|
683
|
-
Pass `client` in the config — requests go through the client's `baseUrl` instead of the global one:
|
|
684
|
-
|
|
685
|
-
```ts
|
|
686
|
-
const eventsApi = createCrudApi("events", {
|
|
687
|
-
basePath: "/api",
|
|
688
|
-
client: analyticsClient,
|
|
689
|
-
});
|
|
690
|
-
```
|
|
691
|
-
|
|
692
|
-
### Use with createCrudHooks
|
|
693
|
-
|
|
694
|
-
Pass `client` — toast and navigation use the client's handlers instead of globals:
|
|
695
|
-
|
|
696
|
-
```ts
|
|
697
|
-
const { useList, useActions } = createCrudHooks({
|
|
698
|
-
api: eventsApi,
|
|
699
|
-
entityKey: "events",
|
|
700
|
-
singular: "Event",
|
|
701
|
-
client: analyticsClient,
|
|
702
|
-
});
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
### Direct requests
|
|
706
|
-
|
|
707
|
-
```ts
|
|
708
|
-
const data = await analyticsClient.request("GET", "/api/stats");
|
|
709
|
-
const result = await analyticsClient.request("POST", "/api/events", {
|
|
710
|
-
body: { type: "page_view" },
|
|
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
|
-
|
|
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
|
-
|
|
416
|
+
## Cache & Keys
|
|
757
417
|
|
|
758
418
|
```ts
|
|
759
|
-
//
|
|
760
|
-
//
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
439
|
+
`useMutationWithOptimistic` adds optimistic cache updates with rollback.
|
|
799
440
|
|
|
800
|
-
|
|
801
|
-
// All requests from this API include x-arc-scope header
|
|
802
|
-
const adminApi = createCrudApi("users", {
|
|
803
|
-
headers: { "x-arc-scope": "platform" },
|
|
804
|
-
});
|
|
805
|
-
```
|
|
441
|
+
## Auth Modes
|
|
806
442
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
- **Pagination Normalization** — Handles `docs`/`data`/`items`/`results` + any custom key, offset/keyset/aggregate pagination
|
|
813
|
-
- **Detail Cache Prefilling** — List results auto-populate detail query cache
|
|
814
|
-
- **React 19 Transitions** — `useMutationWithTransition` wraps invalidation in `startTransition`
|
|
815
|
-
- **Cookie, Bearer & Header Auth** — `authMode: 'cookie'` / `'bearer'` / `'header'` (custom header like `x-api-key`)
|
|
816
|
-
- **Custom ID Fields** — `idField` on `createCrudHooks` for resources keyed by `sku`, `slug`, `code`, etc.
|
|
817
|
-
- **Preset Hooks** — `useDeleted`, `useBulkActions`, `useDetailBySlug`, `useTree`, `useChildren`, `useFindBy`
|
|
818
|
-
- **SSE Real-Time** — `useEventStream` with auto-reconnect, auto-pattern derivation from `resource`, query invalidation. Works with zero config or custom `path`.
|
|
819
|
-
- **Infinite Scroll** — `maxPages` for memory management with automatic scroll-back support
|
|
820
|
-
- **Idempotency** — `autoIdempotency` generates retry-safe keys at mutation level
|
|
821
|
-
- **API Versioning** — `apiVersion` sends `Accept-Version` header
|
|
822
|
-
- **SSR Prefetch** — `createCrudPrefetcher` + `prefetchBySlug` / `prefetchDeleted` / `prefetchTree`
|
|
823
|
-
- **SSR Safety** — warns when `configureClient`/`configureAuth` called on the server
|
|
824
|
-
- **Per-Client Auth** — `createClient({ getToken, getOrgId, headerName })` — each backend gets its own auth, queries auto-enable
|
|
825
|
-
- **Multi-Client** — `createClient()` for multiple API backends side by side
|
|
826
|
-
- **Pluggable Toast** — `configureToast()` — use sonner, react-hot-toast, or anything
|
|
827
|
-
- **Pluggable Navigation** — `configureNavigation()` — use Next.js, React Router, or any router
|
|
828
|
-
- **SSR-Safe QueryClient** — `getQueryClient()` — singleton in browser, new per request on server
|
|
829
|
-
- **Per-Instance Headers** — `config.headers` on `createCrudApi` merged into every request
|
|
830
|
-
- **`select` Transform** — Transform raw API data before it reaches components (`useList`, `useDetail`)
|
|
831
|
-
- **`onSettled` Lifecycle** — Callback that fires after both success and error, at factory and per-call level
|
|
832
|
-
- **Automatic Request Cancellation** — AbortSignal passthrough — unmounted components cancel in-flight requests automatically
|
|
833
|
-
- **Query Config Presets** — `QUERY_CONFIGS.realtime/frequent/stable/static`
|
|
834
|
-
- **Framework-Agnostic** — No hard dependency on Next.js
|
|
835
|
-
- **Tree-Shakeable** — `sideEffects: false`, flat files, no barrels
|
|
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
|
|