@classytic/arc-next 0.14.1 → 0.15.1
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/LICENSE +21 -0
- package/README.md +659 -0
- package/dist/api.d.ts +26 -2
- package/dist/api.js +31 -4
- package/dist/client.d.ts +27 -1
- package/dist/client.js +12 -1
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Classytic LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
# @classytic/arc-next
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sponsors/classytic)
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
**Peers:** React 19+, TanStack React Query 5+
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @classytic/arc-next
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Type flow — use WIRE types for `T`
|
|
14
|
+
|
|
15
|
+
`createCrudApi<T>`'s generic should be the kernel/module's exported **wire type**
|
|
16
|
+
(plain JSON shape) — never a mongoose-flavored document type. Kernel → API →
|
|
17
|
+
frontend then stays one type flow with zero casts:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import type { OrderWire } from '@classytic/order/wire'; // plain JSON shape
|
|
21
|
+
const orders = createCrudApi<OrderWire>('orders');
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Server-side counterpart: arc-* modules export their wire types per the
|
|
25
|
+
module-publishing convention.
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
Call once at app init from a `"use client"` provider:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { configureClient, configureAuth, createAuthAwareClient } from "@classytic/arc-next/client";
|
|
33
|
+
import { configureToast } from "@classytic/arc-next/mutation";
|
|
34
|
+
import { configureNavigation } from "@classytic/arc-next/hooks";
|
|
35
|
+
|
|
36
|
+
configureClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL!, authMode: "cookie" });
|
|
37
|
+
configureAuth({ getToken: () => session?.token ?? null, getOrgId: () => org?.id ?? null });
|
|
38
|
+
configureToast({ success: toast.success, error: toast.error });
|
|
39
|
+
configureNavigation(useRouter);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Without `configureToast`, mutation feedback is a silent no-op (0.12+) — the SDK never writes to your console; errors still reach `onError` / the rejected promise.
|
|
43
|
+
|
|
44
|
+
> **Targets:** React 19 browser apps + Next.js App Router. React Native is NOT officially supported — the fetch client may work, but SSE needs an `EventSource` polyfill, uploads depend on RN's XHR/FormData behavior, and field encryption needs Web Crypto. File an issue if you need an RN adapter.
|
|
45
|
+
|
|
46
|
+
`getToken` **must be synchronous** — cache async tokens out-of-band. Promise returns are dropped + warned in dev.
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { createCrudApi } from "@classytic/arc-next/api";
|
|
52
|
+
import { createCrudHooks } from "@classytic/arc-next/hooks";
|
|
53
|
+
import { withSoftDelete } from "@classytic/arc-next/presets/soft-delete";
|
|
54
|
+
import { withBulk } from "@classytic/arc-next/presets/bulk";
|
|
55
|
+
|
|
56
|
+
interface Product { _id: string; name: string; price: number; }
|
|
57
|
+
|
|
58
|
+
// Compose only the presets your backend actually mounts.
|
|
59
|
+
// Vanilla `createCrudApi` ships CRUD + action + invokeRoute + upload only;
|
|
60
|
+
// add presets via factory wrappers (matches arc's server-side `presets: [...]`).
|
|
61
|
+
const productsApi = withBulk(withSoftDelete(
|
|
62
|
+
createCrudApi<Product>("products", { basePath: "/api" }),
|
|
63
|
+
));
|
|
64
|
+
|
|
65
|
+
export const {
|
|
66
|
+
KEYS, cache,
|
|
67
|
+
useList, useDetail, useActions, useNavigation,
|
|
68
|
+
useInfiniteList, useUpload, useCustomMutation,
|
|
69
|
+
useDeleted, useBulkActions, useDetailBySlug, useTree, useChildren,
|
|
70
|
+
} = createCrudHooks<Product>({ api: productsApi, entityKey: "products", singular: "Product" });
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
"use client";
|
|
75
|
+
function Products() {
|
|
76
|
+
const { items, pagination, isLoading } = useList(null, { organizationId: orgId });
|
|
77
|
+
const { create, update, remove, isCreating } = useActions();
|
|
78
|
+
// ...
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Subpath Exports
|
|
83
|
+
|
|
84
|
+
| Import | Server-safe | Exports |
|
|
85
|
+
|---|:-:|---|
|
|
86
|
+
| `/client` | yes | `configureClient`, `configureAuth`, `createClient`, `createAuthAwareClient`, `handleApiRequest`, `ArcApiError`, `isArcApiError`, `isAbortError`, `isArcErrorCode`, `KNOWN_TOP_LEVEL_CODES`, `KNOWN_DETAILS_CODES`, `getAuthMode`, `getAuthContext`, `getBaseUrl`, `createQueryString` |
|
|
87
|
+
| `/api` | yes | `BaseApi`, `createCrudApi`, response types + type guards |
|
|
88
|
+
| `/cache` | yes | `createQueryKeys`, `createCacheUtils`, `extractItem`, `extractItems`, `getItemId`, `updateListCache`, `normalizePagination`, `QUERY_CONFIGS`, `DEFAULT_QUERY_CONFIG` — server-safe utilities for RSC prefetch + Server Component imports |
|
|
89
|
+
| `/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 |
|
|
90
|
+
| `/mutation` | client | `configureToast`, `useMutationWithTransition`, `useMutationWithOptimistic` |
|
|
91
|
+
| `/hooks` | client | `createCrudHooks`, `configureNavigation` (also default export) |
|
|
92
|
+
| `/query-client` | yes | `getQueryClient` (SSR-safe singleton) |
|
|
93
|
+
| `/prefetch` | yes | `createCrudPrefetcher`, `dehydrate` |
|
|
94
|
+
| `/sse` | client | `useEventStream`, `buildSseUrl`, `subscribeToEvents` |
|
|
95
|
+
| `/ws` | client | `useWebSocket`, `buildWsUrl`, `connectWs` |
|
|
96
|
+
| `/upload` | client | `useUploadWithProgress`, `uploadWithProgress` — XHR-based uploads with native progress events |
|
|
97
|
+
| `/presets/soft-delete` | yes | `withSoftDelete` — adds `getDeleted`, `restore` |
|
|
98
|
+
| `/presets/bulk` | yes | `withBulk` — adds `bulkCreate`, `bulkUpdate`, `bulkDelete` |
|
|
99
|
+
| `/presets/slug` | yes | `withSlugLookup` — adds `getBySlug` |
|
|
100
|
+
| `/presets/tree` | yes | `withTree` — adds `getTree`, `getChildren` |
|
|
101
|
+
| `/presets/search` | yes | `withSearchPreset` — adds `searchEngine`, `searchSimilar`, `embed` |
|
|
102
|
+
|
|
103
|
+
`sideEffects: false`. No barrel — every file is its own entry point.
|
|
104
|
+
|
|
105
|
+
## Core Hooks (from `createCrudHooks`)
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const { items, pagination, isLoading, refetch } = useList(params, options);
|
|
109
|
+
const { item, isLoading, isPlaceholderData } = useDetail(id, options);
|
|
110
|
+
const { create, update, remove, isMutating } = useActions();
|
|
111
|
+
const { items, hasNextPage, fetchNextPage } = useInfiniteList(params);
|
|
112
|
+
|
|
113
|
+
await create({ data, organizationId }, { onSuccess: (item) => navigate(...) });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- All mutations are optimistic with automatic rollback on error.
|
|
117
|
+
- Cache keys auto-scope by `organizationId` when present.
|
|
118
|
+
- **List → detail handoff:** when a parent `useList` has the entity in cache,
|
|
119
|
+
`useDetail` reads it via TanStack's `placeholderData` factory — instant
|
|
120
|
+
preview, but the real detail GET still fires (rich payload swap, no
|
|
121
|
+
cache pollution). Use `isPlaceholderData` to dim the preview while it
|
|
122
|
+
resolves. See [CHANGELOG 0.7](./CHANGELOG.md#070) for why this replaced
|
|
123
|
+
the old setQueryData-based prefill.
|
|
124
|
+
- **Detail → list pseudo-normalization:** after a `useDetail` GET resolves,
|
|
125
|
+
arc-next shallow-merges the fresh fields into every list cache holding
|
|
126
|
+
this id. The list view stays in sync without a refetch. Direction is
|
|
127
|
+
one-way (detail → list, never the reverse) — see
|
|
128
|
+
[CHANGELOG → "pseudo-normalization"](./CHANGELOG.md#070) for the
|
|
129
|
+
rationale. For true entity-level normalization (one copy per id, field-
|
|
130
|
+
level invalidation), use Apollo Client or Relay; arc-next stays in the
|
|
131
|
+
REST + React Query niche.
|
|
132
|
+
|
|
133
|
+
`useList(token, params, options)` (legacy 3-arg form) still compiles —
|
|
134
|
+
both signatures are kept stable across the 0.x line.
|
|
135
|
+
|
|
136
|
+
### `useApiQuery` — non-CRUD reads
|
|
137
|
+
|
|
138
|
+
For reports, aggregates, RPC-style endpoints. Response IS the data — arc 2.13+ has no envelope:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { useApiQuery } from "@classytic/arc-next/query";
|
|
142
|
+
|
|
143
|
+
const { data, isLoading } = useApiQuery<DashboardStats>({
|
|
144
|
+
queryKey: ["dashboard", "stats"],
|
|
145
|
+
queryFn: ({ signal }) => api.request("GET", "/dashboard/stats", { options: { signal } }),
|
|
146
|
+
freshness: "realtime", // 'realtime' | 'frequent' | 'stable' | 'static'
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Pass a custom `select` to project a sub-field from the response.
|
|
151
|
+
|
|
152
|
+
## Actions & Custom Routes
|
|
153
|
+
|
|
154
|
+
Two escape hatches when CRUD isn't enough — both `BaseApi` methods, both routed through your configured client/auth:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// POST /:id/action — discriminator-style state transitions
|
|
158
|
+
// Named `dispatchAction` so consumer subclasses can keep their own `action()` method.
|
|
159
|
+
await api.dispatchAction({ id, action: "complete" });
|
|
160
|
+
await api.dispatchAction({ id, action: "prioritize", data: { priority: 7 } });
|
|
161
|
+
|
|
162
|
+
// Resource-relative custom routes (defineResource({ routes: [...] }))
|
|
163
|
+
const stats = await api.invokeRoute<{ data: { total: number } }>({
|
|
164
|
+
method: "GET",
|
|
165
|
+
path: "/stats",
|
|
166
|
+
});
|
|
167
|
+
import type { OffsetPaginationResult } from "@classytic/repo-core/pagination";
|
|
168
|
+
|
|
169
|
+
const recent = await api.invokeRoute<OffsetPaginationResult<Todo>>({
|
|
170
|
+
method: "GET",
|
|
171
|
+
path: "/recent",
|
|
172
|
+
params: { limit: 5 },
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
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):
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const { data } = useApiQuery({
|
|
180
|
+
queryKey: ["todos", "stats"],
|
|
181
|
+
queryFn: ({ signal }) => api.invokeRoute({ path: "/stats", options: { signal } }),
|
|
182
|
+
freshness: "frequent",
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Presets — Opt-In Methods
|
|
187
|
+
|
|
188
|
+
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.
|
|
189
|
+
|
|
190
|
+
> 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.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { withSoftDelete } from "@classytic/arc-next/presets/soft-delete";
|
|
194
|
+
import { withBulk } from "@classytic/arc-next/presets/bulk";
|
|
195
|
+
import { withSlugLookup } from "@classytic/arc-next/presets/slug";
|
|
196
|
+
import { withTree } from "@classytic/arc-next/presets/tree";
|
|
197
|
+
import { withSearchPreset } from "@classytic/arc-next/presets/search";
|
|
198
|
+
|
|
199
|
+
// Stack only what the backend has registered
|
|
200
|
+
const todosApi = withBulk(withSoftDelete(createCrudApi<Todo>("todos")));
|
|
201
|
+
const placesApi = withSearchPreset(createCrudApi<Place>("places"));
|
|
202
|
+
const categoriesApi = withTree(withSlugLookup(createCrudApi<Category>("categories")));
|
|
203
|
+
|
|
204
|
+
// Only categoriesApi has getBySlug + getTree + getChildren in autocomplete.
|
|
205
|
+
// `placesApi.embed` won't show up. `todosApi.searchEngine` is a type error.
|
|
206
|
+
await todosApi.bulkCreate({ data: [{ title: "A" }, { title: "B" }] });
|
|
207
|
+
await placesApi.searchEngine({ query: "park", body: { topK: 10 } });
|
|
208
|
+
await categoriesApi.getBySlug({ slug: "engineering" });
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
| Preset | Adds methods | Backend route |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `withSoftDelete` | `getDeleted`, `restore` | `softDelete` preset |
|
|
214
|
+
| `withBulk` | `bulkCreate`, `bulkUpdate`, `bulkDelete` | `bulk` preset |
|
|
215
|
+
| `withSlugLookup` | `getBySlug` | `slugLookup` preset |
|
|
216
|
+
| `withTree` | `getTree`, `getChildren` | `tree` preset |
|
|
217
|
+
| `withSearchPreset` | `searchEngine`, `searchSimilar`, `embed` | `searchPreset()` |
|
|
218
|
+
|
|
219
|
+
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.
|
|
220
|
+
|
|
221
|
+
## Filter operators (mongokit URL grammar)
|
|
222
|
+
|
|
223
|
+
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.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// Range / comparison
|
|
227
|
+
await api.getAll({ params: { 'priority[gte]': 5, 'price[between]': '10,100' } });
|
|
228
|
+
|
|
229
|
+
// Pattern matching
|
|
230
|
+
await api.getAll({ params: { 'title[contains]': 'urgent' } });
|
|
231
|
+
|
|
232
|
+
// IN list (auto-rewritten from plain array on plain field name)
|
|
233
|
+
await api.getAll({ params: { status: ['active', 'pending'] } });
|
|
234
|
+
// → status[in]=active,pending
|
|
235
|
+
|
|
236
|
+
// Geo — coordinate tuples preserved as-is
|
|
237
|
+
await api.getAll({ params: { 'location[withinRadius]': [-73.98, 40.75, 5_000] } });
|
|
238
|
+
await api.getAll({ params: { 'location[near]': [-73.98, 40.75, 4_000] } });
|
|
239
|
+
await api.getAll({ params: { 'location[geoWithin]': [-74.02, 40.7, -73.93, 40.79] } });
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `regex`, `like`, `exists`, `between`, `near`, `nearSphere`, `withinRadius`, `geoWithin`.
|
|
243
|
+
|
|
244
|
+
## SSE — Real-Time
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { useEventStream, buildSseUrl } from "@classytic/arc-next/sse";
|
|
248
|
+
|
|
249
|
+
useEventStream({
|
|
250
|
+
resource: "agents", // auto-derives [agents.created, agents.updated, agents.deleted]
|
|
251
|
+
invalidateQueries: [agentKeys.lists()], // refetch on every event
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Or explicit named events (Arc ssePlugin emits `event: <type>` frames):
|
|
255
|
+
useEventStream({
|
|
256
|
+
eventTypes: ["sync-job.phase", "sync-job.completed"],
|
|
257
|
+
onEvent: (event) => { /* ... */ },
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Build authenticated SSE URLs for ad-hoc EventSource consumers:
|
|
261
|
+
const url = buildSseUrl("/jobs/stream", { jobId });
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## WebSocket — Real-Time + Bidirectional
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { useWebSocket, buildWsUrl } from "@classytic/arc-next/ws";
|
|
268
|
+
|
|
269
|
+
const { isConnected, lastMessage, send, subscribe, unsubscribe } = useWebSocket({
|
|
270
|
+
subscribe: ["todo"], // sends {type:'subscribe', resource:'todo'} on open
|
|
271
|
+
invalidateQueries: [todoKeys.lists()], // refetch on every broadcast
|
|
272
|
+
patterns: ["todo.", "order.completed"], // filter — prefix match (`x.`) or exact
|
|
273
|
+
onMessage: (msg) => console.log(msg.type, msg.data),
|
|
274
|
+
heartbeatInterval: 30_000, // optional app-level ping
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Send any JSON payload — returns false if not connected
|
|
278
|
+
send({ type: "chat.message", text: "hi" });
|
|
279
|
+
|
|
280
|
+
// Build the URL for a raw WebSocket consumer (Node, worker, etc.)
|
|
281
|
+
const url = buildWsUrl("/ws", { roomId: "r-1" });
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Subscriptions persist across reconnects — anything passed in `subscribe` (or via `subscribe()`) is auto-resent after the socket re-opens.
|
|
285
|
+
|
|
286
|
+
## Uploads with Progress
|
|
287
|
+
|
|
288
|
+
`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:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
import { useUploadWithProgress } from "@classytic/arc-next/upload";
|
|
292
|
+
|
|
293
|
+
const { upload, progress, isUploading, cancel, error } = useUploadWithProgress<
|
|
294
|
+
{ url: string },
|
|
295
|
+
{ file: File; folder?: string }
|
|
296
|
+
>({
|
|
297
|
+
url: "/api/v1/media/upload",
|
|
298
|
+
buildFormData: ({ file, folder }) => {
|
|
299
|
+
const fd = new FormData();
|
|
300
|
+
if (folder) fd.append("folder", folder);
|
|
301
|
+
fd.append("file", file);
|
|
302
|
+
return fd;
|
|
303
|
+
},
|
|
304
|
+
invalidateQueries: [mediaKeys.lists()],
|
|
305
|
+
messages: { success: "Uploaded" },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Bind progress.percent to a <ProgressBar /> — every tick re-renders.
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
For non-React consumers, `uploadWithProgress({ url, formData, onProgress, signal })` returns a Promise.
|
|
312
|
+
|
|
313
|
+
> **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.
|
|
314
|
+
|
|
315
|
+
## Multi-Client
|
|
316
|
+
|
|
317
|
+
Each `createClient` call is independent — its own `baseUrl`, auth, headers:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
import { createClient } from "@classytic/arc-next/client";
|
|
321
|
+
|
|
322
|
+
const analytics = createClient({
|
|
323
|
+
baseUrl: "https://analytics.example.com",
|
|
324
|
+
authMode: "header",
|
|
325
|
+
getToken: () => env.ANALYTICS_KEY,
|
|
326
|
+
headerName: "x-api-key",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const eventsApi = createCrudApi("events", { client: analytics });
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
For consumer SDKs that just need to bridge the global auth singleton:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { createAuthAwareClient } from "@classytic/arc-next/client";
|
|
336
|
+
|
|
337
|
+
const api = createCrudApi("products", { client: createAuthAwareClient() });
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## SSR Prefetch (Next.js App Router / Server Components)
|
|
341
|
+
|
|
342
|
+
`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`.
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
// app/products/page.tsx — Server Component (no "use client")
|
|
346
|
+
import { createCrudPrefetcher, dehydrate, HydrationBoundary } from "@classytic/arc-next/prefetch";
|
|
347
|
+
import { getQueryClient } from "@classytic/arc-next/query-client";
|
|
348
|
+
import { productsApi } from "@/api/products-api";
|
|
349
|
+
import { ProductsList } from "./products-list"; // "use client"
|
|
350
|
+
|
|
351
|
+
const prefetcher = createCrudPrefetcher(productsApi, "products");
|
|
352
|
+
|
|
353
|
+
export default async function ProductsPage() {
|
|
354
|
+
const queryClient = getQueryClient(); // per-request on server
|
|
355
|
+
await prefetcher.prefetchList(queryClient, { limit: 20 }, { token, organizationId });
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
359
|
+
<ProductsList />
|
|
360
|
+
</HydrationBoundary>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Methods: `prefetchList`, `prefetchDetail`, `prefetchBySlug`, `prefetchDeleted`, `prefetchTree`, `prefetchInfiniteList`.
|
|
366
|
+
|
|
367
|
+
> `prefetchInfiniteList` seeds the `{ pages, pageParams }` cache shape `useInfiniteQuery` expects — a flat `prefetchQuery` won't match and the hook would re-fetch from scratch.
|
|
368
|
+
|
|
369
|
+
### Streaming with promise-pending dehydration (TanStack Query 5.40+)
|
|
370
|
+
|
|
371
|
+
`getQueryClient()` ships a default `dehydrate.shouldDehydrateQuery` that includes pending queries, so you can fire-and-forget prefetches inside Suspense boundaries:
|
|
372
|
+
|
|
373
|
+
```tsx
|
|
374
|
+
export default function ProductsPage() {
|
|
375
|
+
const queryClient = getQueryClient();
|
|
376
|
+
// No await — prefetch streams to client when ready
|
|
377
|
+
prefetcher.prefetchList(queryClient, { limit: 20 });
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
381
|
+
<Suspense fallback={<ListSkeleton />}>
|
|
382
|
+
<ProductsList />
|
|
383
|
+
</Suspense>
|
|
384
|
+
</HydrationBoundary>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Server-safe utilities
|
|
390
|
+
|
|
391
|
+
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`.
|
|
392
|
+
|
|
393
|
+
### Next.js 16 `cacheComponents` + `'use cache'`
|
|
394
|
+
|
|
395
|
+
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.
|
|
396
|
+
|
|
397
|
+
## Request-Scoped Server Clients (0.12+)
|
|
398
|
+
|
|
399
|
+
`configureClient` / `configureAuth` set module singletons — correct for the browser, wrong for servers where concurrent requests would share state. On the server, build a **request-scoped** client instead. The SDK never imports `next` or reads cookies itself: your framework code reads the request, the SDK gets plain values.
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
// app/orders/page.tsx (Server Component) — host reads cookies, SDK stays framework-free
|
|
403
|
+
import { cookies } from 'next/headers';
|
|
404
|
+
import { createServerClient } from '@classytic/arc-next/client';
|
|
405
|
+
import { createCrudApi } from '@classytic/arc-next/api';
|
|
406
|
+
|
|
407
|
+
export default async function OrdersPage() {
|
|
408
|
+
const client = createServerClient({
|
|
409
|
+
baseUrl: process.env.API_URL!,
|
|
410
|
+
token: (await cookies()).get('session')?.value ?? null,
|
|
411
|
+
organizationId: null,
|
|
412
|
+
});
|
|
413
|
+
const orders = createCrudApi<Order>('orders', { client });
|
|
414
|
+
const page = await orders.getAll({
|
|
415
|
+
options: { next: { revalidate: 60, tags: ['orders'] } }, // Next fetch-cache passthrough
|
|
416
|
+
});
|
|
417
|
+
// render...
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`next: { tags, revalidate }` and `cache:` are typed pass-throughs to `fetch` — inert on non-Next runtimes, no `next` peer dependency.
|
|
422
|
+
|
|
423
|
+
## Optimistic Updates — the guarantees (0.12+)
|
|
424
|
+
|
|
425
|
+
`useActions()` mutations uphold, in order:
|
|
426
|
+
|
|
427
|
+
1. **Cancel-before-write** — in-flight refetches for affected keys are cancelled before the snapshot, so a late response can't be captured as "previous" state.
|
|
428
|
+
2. **Every affected cache** — detail (bare + org-scoped + parameterized), flat lists, **infinite lists** (per-page; a create inserts into the first page only), while aggregation caches are never optimistically mutated (refetch-only).
|
|
429
|
+
3. **Exact rollback** — on failure every touched entry is restored to its snapshot; untouched entries are never rewritten.
|
|
430
|
+
4. **Temp-ID reconciliation** — `create` inserts a `_optimistic` placeholder with a `temp-…` id, then swaps it in place for the server document on success (and seeds `KEYS.detail(realId)`), so the row never flickers and the real id is immediately navigable.
|
|
431
|
+
5. **Per-record ordering** — sequential `update`/`remove`/`restore` calls to the same record are chained (call order = server order); different records stay parallel.
|
|
432
|
+
6. **Last-standing invalidation** — rapid sequential writes trigger ONE settled refetch (from the last pending write), so an early write's refetch can never overwrite a later write's optimistic state.
|
|
433
|
+
7. **Bulk partial success** — `bulkUpdate`/`bulkRemove` reporting zero changes skip invalidation entirely; `bulkCreate` seeds detail caches from the returned documents.
|
|
434
|
+
|
|
435
|
+
## Errors
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
import { isArcApiError, isAbortError, isArcErrorCode } from "@classytic/arc-next/client";
|
|
439
|
+
|
|
440
|
+
try { await api.create({ data, options: { signal } }); }
|
|
441
|
+
catch (err) {
|
|
442
|
+
if (isAbortError(err)) return; // user navigated away — silence
|
|
443
|
+
if (isArcApiError(err)) {
|
|
444
|
+
err.status; // 422
|
|
445
|
+
err.fieldErrors; // { email: "already taken" } | null
|
|
446
|
+
err.endpoint; // '/api/products'
|
|
447
|
+
}
|
|
448
|
+
if (isArcErrorCode(err, 'DUPLICATE_KEY')) showRetryUI();
|
|
449
|
+
if (isArcErrorCode(err, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
`fieldErrors` reads three shapes: `{ errors: { field: msg } }`, `{ details: { errors: [{ field, message }] } }`, raw AJV `{ instancePath, message }`.
|
|
454
|
+
|
|
455
|
+
`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):
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
import { KNOWN_TOP_LEVEL_CODES } from "@classytic/arc-next/client";
|
|
459
|
+
|
|
460
|
+
const ERROR_MESSAGES = Object.fromEntries(
|
|
461
|
+
KNOWN_TOP_LEVEL_CODES.map((code) => [code, t(`error.${code}`)])
|
|
462
|
+
);
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
## Retry + Interceptors
|
|
466
|
+
|
|
467
|
+
Network resilience for mutations + direct `handleApiRequest` calls (TanStack Query already retries reads). Off by default — opt in via `configureClient`:
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
configureClient({
|
|
471
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
472
|
+
timeoutMs: 15_000, // per-attempt request timeout; hung fetches fail
|
|
473
|
+
// with a RETRYABLE TimeoutError. Default: disabled.
|
|
474
|
+
// Per-request override: options.timeoutMs (0 disables).
|
|
475
|
+
retry: {
|
|
476
|
+
attempts: 3, // 1 initial + 2 retries; default off
|
|
477
|
+
backoff: 'exponential', // 'exponential' | 'linear' | (attempt) => ms
|
|
478
|
+
jitter: 'full', // randomize delays in [0, computed] — anti-stampede. Default 'none'.
|
|
479
|
+
// retryOn: [502, 503, 504], // optional whitelist; default = network failures + 5xx, never 4xx, never AbortError
|
|
480
|
+
},
|
|
481
|
+
// 429/503 responses with a Retry-After header override computed backoff —
|
|
482
|
+
// the parsed value is also exposed as ArcApiError.retryAfterMs.
|
|
483
|
+
// Mutate outgoing requests (per attempt — retries re-run this)
|
|
484
|
+
beforeRequest: (ctx) => ({
|
|
485
|
+
...ctx,
|
|
486
|
+
headers: { ...ctx.headers, 'x-correlation-id': crypto.randomUUID() },
|
|
487
|
+
}),
|
|
488
|
+
// Inspect / transform successful responses (4xx/5xx throw before this)
|
|
489
|
+
afterResponse: (ctx) => {
|
|
490
|
+
console.log(`[arc] ${ctx.method} ${ctx.endpoint} ${ctx.status} ${ctx.durationMs}ms`);
|
|
491
|
+
return ctx;
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
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.
|
|
497
|
+
|
|
498
|
+
## `arcFetch` — one-line authenticated fetch for non-hook contexts (0.7+)
|
|
499
|
+
|
|
500
|
+
When you need to hit an arc endpoint from outside a hook — event handler, service worker, server action, custom MDX submit, background poll — `arcFetch` collapses the auth/org/content-type/error/parse boilerplate into one call:
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
import { arc } from "@classytic/arc-next/client";
|
|
504
|
+
|
|
505
|
+
// Before — 15 lines of header dance + error parse + JSON parse:
|
|
506
|
+
// const { token } = getAuthContext();
|
|
507
|
+
// if (!token) throw ...
|
|
508
|
+
// const res = await fetch(`${apiBaseUrl()}/api/statements`, {
|
|
509
|
+
// method: "POST",
|
|
510
|
+
// headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ... },
|
|
511
|
+
// body: JSON.stringify(statements),
|
|
512
|
+
// });
|
|
513
|
+
// if (!res.ok) throw ...
|
|
514
|
+
// return await res.json();
|
|
515
|
+
//
|
|
516
|
+
// After:
|
|
517
|
+
const result = await arc.post<{ ok: boolean }>("/api/statements", statements);
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Auto-injects `Authorization` (or your `headerName` for `authMode: "header"`), `x-organization-id`, `x-internal-api-key`, `Idempotency-Key`, `x-arc-scope`, and `Content-Type: application/json` (only for plain object/array bodies). Composes with everything else — `retry`, `onAuthError`, `beforeRequest`, `afterResponse`.
|
|
521
|
+
|
|
522
|
+
**Method shorthands:**
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
arc.get<T>(path, opts?)
|
|
526
|
+
arc.post<T>(path, body?, opts?)
|
|
527
|
+
arc.put<T>(path, body?, opts?)
|
|
528
|
+
arc.patch<T>(path, body?, opts?)
|
|
529
|
+
arc.delete<T>(path, opts?)
|
|
530
|
+
|
|
531
|
+
// Or call arcFetch directly for full RequestInit control:
|
|
532
|
+
arcFetch<T>(path, { method, body, headers, signal, elevated, idempotencyKey, revalidate, tags, cache, client })
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**Body sniffing.** `FormData`, `Blob`, `URLSearchParams`, `ArrayBuffer`, `ReadableStream`, and `string` pass through unchanged — caller controls `Content-Type` for those. Plain objects and arrays get `JSON.stringify`d and the JSON content-type header.
|
|
536
|
+
|
|
537
|
+
**Protected headers.** `Authorization`, `x-organization-id`, `x-internal-api-key`, and the custom header for `authMode: "header"` cannot be overridden by `options.headers`. A caller can't accidentally strip the bearer token by spreading their own header map. Non-auth headers (`X-Trace-Id`, `Accept-Version`, etc.) pass through normally.
|
|
538
|
+
|
|
539
|
+
**Error handling.** Non-2xx throws `ArcApiError` with parsed body, status, endpoint, method — same contract as the CRUD hooks. Use `isArcApiError(err)` + `err.code` to discriminate.
|
|
540
|
+
|
|
541
|
+
**Escape hatch.** When you need full `Response` control (rare — streaming downloads, custom redirect logic), use plain `fetch` with `arcAuthHeaders()`:
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
import { arcAuthHeaders, getAuthMode } from "@classytic/arc-next/client";
|
|
545
|
+
|
|
546
|
+
const res = await fetch(url, {
|
|
547
|
+
headers: { ...arcAuthHeaders(), "X-Custom": "1" },
|
|
548
|
+
credentials: getAuthMode() === "cookie" ? "include" : "same-origin",
|
|
549
|
+
});
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
## Auth Recovery (0.7+) — 401 → refresh → retry
|
|
553
|
+
|
|
554
|
+
When a session token expires mid-page, the SDK transparently refreshes and retries — no flash of unauthenticated UI, no manual reload. Wire it once at app boot:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
import { configureAuth, createAuthRefreshHandler } from "@classytic/arc-next/client";
|
|
558
|
+
import { authClient } from "@/lib/auth-client";
|
|
559
|
+
|
|
560
|
+
configureAuth({
|
|
561
|
+
getToken: () => authClient.getSession().data?.session.token ?? null,
|
|
562
|
+
onAuthError: createAuthRefreshHandler({
|
|
563
|
+
refresh: async () => {
|
|
564
|
+
// Whatever your auth lib calls to mint a fresh access token.
|
|
565
|
+
const { data } = await authClient.getSession({ disableCookieCache: true });
|
|
566
|
+
return data?.session.token ?? null; // null → session truly expired; original 401 surfaces
|
|
567
|
+
},
|
|
568
|
+
}),
|
|
569
|
+
});
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Every `useList`, `useDetail`, `useActions`, and any code path going through `createAuthAwareClient()` or `createClient(...)` now survives token expiry transparently. Apps that don't wire `onAuthError` see the original behavior (401 surfaces immediately).
|
|
573
|
+
|
|
574
|
+
**Concurrent-refresh dedup.** When N requests hit 401 at the same time, the handler fires **once**. All N concurrent callers await the same refresh promise and retry with the token it produces — no stampeding the refresh endpoint under burst auth-expiry.
|
|
575
|
+
|
|
576
|
+
**Tuning knobs.**
|
|
577
|
+
|
|
578
|
+
```ts
|
|
579
|
+
configureAuth({
|
|
580
|
+
// ...
|
|
581
|
+
onAuthError,
|
|
582
|
+
retryOn403: true, // also recover from 403 (default: 401 only)
|
|
583
|
+
maxAuthRetries: 1, // cap per individual request (default: 1; prevents loops)
|
|
584
|
+
});
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
**Custom handler.** Bypass `createAuthRefreshHandler` if you need full control over the recovery cycle:
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
configureAuth({
|
|
591
|
+
onAuthError: async ({ error, request, attempt, setToken }) => {
|
|
592
|
+
if (error.code === "session.revoked") return "skip"; // route to /login
|
|
593
|
+
const fresh = await myRefreshFn();
|
|
594
|
+
if (!fresh) return "skip";
|
|
595
|
+
setToken(fresh);
|
|
596
|
+
return "retry";
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
The handler receives the full `ArcApiError`, the failing request descriptor, the 1-indexed attempt counter, and a `setToken(value)` callback that supplies the refreshed token for the retry. Throwing from the handler short-circuits — the thrown error propagates instead of the 401.
|
|
602
|
+
|
|
603
|
+
**Transport coverage.** Auth recovery fires across every transport arc-next exposes:
|
|
604
|
+
|
|
605
|
+
| Transport | Trigger | Mechanism |
|
|
606
|
+
|---|---|---|
|
|
607
|
+
| Fetch (CRUD hooks, `arcFetch`, `handleApiRequest`) | 401 / 403 response | Inline retry in `executeRequest` |
|
|
608
|
+
| XHR upload (`uploadWithProgress`, `useUploadWithProgress`) | 401 / 403 response | Outer retry loop in `upload.ts` |
|
|
609
|
+
| WebSocket | close code `1008` / `3401` / `4001` / `4401` | `ws.onclose` handler routes through recovery, reconnect with refreshed token |
|
|
610
|
+
| SSE (`subscribeToEvents`, `useEventStream`) | `EventSource` error | Pre-flight `fetch` probe classifies as auth-failure → recovery → reopen |
|
|
611
|
+
|
|
612
|
+
All four transports share **one** dedup'd refresh promise — concurrent failures across mixed transports (5 in-flight uploads + 3 WebSocket reconnects + 10 fetch calls, all 401 at once) collapse to a single `onAuthError` call.
|
|
613
|
+
|
|
614
|
+
## Cache & Keys
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
KEYS.detail(id); // ["products", "detail", id]
|
|
618
|
+
KEYS.scopedDetail(id, orgId); // tenant-scoped variant
|
|
619
|
+
|
|
620
|
+
// Writes/reads the raw doc — no `{ data: TDoc }` envelope (0.7+). Matches
|
|
621
|
+
// what useDetail, prefetchDetail, and useNavigation all produce.
|
|
622
|
+
cache.setDetail(qc, id, data);
|
|
623
|
+
cache.getDetail(qc, id); // TDoc | undefined
|
|
624
|
+
cache.invalidateDetail(qc, id); // matches all scoped variants
|
|
625
|
+
cache.invalidateLists(qc);
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
## Custom Mutations
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
import { useMutationWithTransition } from "@classytic/arc-next/mutation";
|
|
632
|
+
|
|
633
|
+
const { mutateAsync: publish, isPending } = useMutationWithTransition({
|
|
634
|
+
mutationFn: (id: string) => api.request("POST", `${api.baseUrl}/${id}/publish`),
|
|
635
|
+
invalidateQueries: [productKeys.all],
|
|
636
|
+
messages: { success: "Published!" },
|
|
637
|
+
});
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
`useMutationWithOptimistic` adds optimistic cache updates with rollback.
|
|
641
|
+
|
|
642
|
+
## Auth Modes
|
|
643
|
+
|
|
644
|
+
| Mode | When | Notes |
|
|
645
|
+
|---|---|---|
|
|
646
|
+
| `bearer` (default) | JWT / opaque token | `getToken()` returns the token |
|
|
647
|
+
| `cookie` | Better Auth, session cookies | No token needed; `credentials: 'include'` automatic |
|
|
648
|
+
| `header` | API keys (`x-api-key`, etc.) | Set `headerName` on `configureAuth` or `createClient` |
|
|
649
|
+
|
|
650
|
+
## License
|
|
651
|
+
|
|
652
|
+
MIT
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
## Trademark
|
|
656
|
+
|
|
657
|
+
The code is MIT-licensed. **"Classytic", "arc", and the logos are trademarks of
|
|
658
|
+
Classytic LLC** and are **not** licensed under MIT — see [TRADEMARK.md](TRADEMARK.md).
|
|
659
|
+
Forks must be renamed; the license covers the code, not the brand.
|
package/dist/api.d.ts
CHANGED
|
@@ -114,9 +114,33 @@ interface BaseApiConfig {
|
|
|
114
114
|
}
|
|
115
115
|
declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>> {
|
|
116
116
|
readonly entity: string;
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
/**
|
|
118
|
+
* `basePath` stays OPTIONAL here — it holds the explicit per-instance
|
|
119
|
+
* override and nothing else. The resolved value is {@link basePath}, which is
|
|
120
|
+
* computed per read; baking it in would defeat the whole point (see below).
|
|
121
|
+
*/
|
|
122
|
+
readonly config: Required<Omit<BaseApiConfig, "client" | "basePath">> & Pick<BaseApiConfig, "basePath">;
|
|
119
123
|
private readonly requestFn;
|
|
124
|
+
/**
|
|
125
|
+
* Per-instance override → the deployment's declared prefix → `/api/v1`.
|
|
126
|
+
*
|
|
127
|
+
* The middle step is what lets a package construct its own API internally and
|
|
128
|
+
* still land on a host mounted elsewhere. Without it the only options were
|
|
129
|
+
* "every consumer passes `basePath`" — impossible for an instance a package
|
|
130
|
+
* owns — or "every host mounts at `/api/v1`".
|
|
131
|
+
*
|
|
132
|
+
* ## Why this is a GETTER and not resolved in the constructor
|
|
133
|
+
*
|
|
134
|
+
* `configureClient()` runs inside a `"use client"` provider, which is LATER
|
|
135
|
+
* than module evaluation. A package's API instance is created at import time,
|
|
136
|
+
* so a constructor-time read would capture `/api/v1` before the deployment
|
|
137
|
+
* ever declared `/api`, and the fallback would win permanently — producing a
|
|
138
|
+
* 404 that renders as an empty list, which is the failure this was meant to
|
|
139
|
+
* fix. An explicit `basePath` is unaffected either way.
|
|
140
|
+
*/
|
|
141
|
+
get basePath(): string;
|
|
142
|
+
/** `{basePath}/{entity}` — the resource root every request is built from. */
|
|
143
|
+
get baseUrl(): string;
|
|
120
144
|
constructor(entity: string, config?: BaseApiConfig);
|
|
121
145
|
/** Merge per-instance headers into request options */
|
|
122
146
|
private withHeaders;
|
package/dist/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createQueryString, handleApiRequest } from "./client.js";
|
|
1
|
+
import { createQueryString, getBasePath, handleApiRequest } from "./client.js";
|
|
2
2
|
import { STANDARD_RESERVED_PARAMS } from "@classytic/repo-core/query-parser";
|
|
3
3
|
|
|
4
4
|
//#region src/api.ts
|
|
@@ -9,15 +9,43 @@ for (const verb of [
|
|
|
9
9
|
]) if (!STANDARD_RESERVED_PARAMS.has(verb)) throw new Error(`[arc-next] dispatch verb '${verb}' is not in repo-core STANDARD_RESERVED_PARAMS`);
|
|
10
10
|
var BaseApi = class {
|
|
11
11
|
entity;
|
|
12
|
+
/**
|
|
13
|
+
* `basePath` stays OPTIONAL here — it holds the explicit per-instance
|
|
14
|
+
* override and nothing else. The resolved value is {@link basePath}, which is
|
|
15
|
+
* computed per read; baking it in would defeat the whole point (see below).
|
|
16
|
+
*/
|
|
12
17
|
config;
|
|
13
|
-
baseUrl;
|
|
14
18
|
requestFn;
|
|
19
|
+
/**
|
|
20
|
+
* Per-instance override → the deployment's declared prefix → `/api/v1`.
|
|
21
|
+
*
|
|
22
|
+
* The middle step is what lets a package construct its own API internally and
|
|
23
|
+
* still land on a host mounted elsewhere. Without it the only options were
|
|
24
|
+
* "every consumer passes `basePath`" — impossible for an instance a package
|
|
25
|
+
* owns — or "every host mounts at `/api/v1`".
|
|
26
|
+
*
|
|
27
|
+
* ## Why this is a GETTER and not resolved in the constructor
|
|
28
|
+
*
|
|
29
|
+
* `configureClient()` runs inside a `"use client"` provider, which is LATER
|
|
30
|
+
* than module evaluation. A package's API instance is created at import time,
|
|
31
|
+
* so a constructor-time read would capture `/api/v1` before the deployment
|
|
32
|
+
* ever declared `/api`, and the fallback would win permanently — producing a
|
|
33
|
+
* 404 that renders as an empty list, which is the failure this was meant to
|
|
34
|
+
* fix. An explicit `basePath` is unaffected either way.
|
|
35
|
+
*/
|
|
36
|
+
get basePath() {
|
|
37
|
+
return this.config.basePath ?? getBasePath() ?? "/api/v1";
|
|
38
|
+
}
|
|
39
|
+
/** `{basePath}/{entity}` — the resource root every request is built from. */
|
|
40
|
+
get baseUrl() {
|
|
41
|
+
return `${this.basePath}/${this.entity}`;
|
|
42
|
+
}
|
|
15
43
|
constructor(entity, config = {}) {
|
|
16
44
|
this.entity = entity;
|
|
17
45
|
const client = config.client;
|
|
18
46
|
this.requestFn = typeof client === "function" ? (method, endpoint, options) => client().request(method, endpoint, options) : client?.request ?? handleApiRequest;
|
|
19
47
|
this.config = {
|
|
20
|
-
basePath: config.basePath
|
|
48
|
+
...config.basePath !== void 0 ? { basePath: config.basePath } : {},
|
|
21
49
|
defaultParams: {
|
|
22
50
|
limit: 10,
|
|
23
51
|
page: 1,
|
|
@@ -26,7 +54,6 @@ var BaseApi = class {
|
|
|
26
54
|
cache: config.cache ?? "no-store",
|
|
27
55
|
headers: { ...config.headers || {} }
|
|
28
56
|
};
|
|
29
|
-
this.baseUrl = `${this.config.basePath}/${this.entity}`;
|
|
30
57
|
}
|
|
31
58
|
/** Merge per-instance headers into request options */
|
|
32
59
|
withHeaders(options) {
|
package/dist/client.d.ts
CHANGED
|
@@ -278,6 +278,23 @@ interface ClientEncryptionConfig {
|
|
|
278
278
|
}
|
|
279
279
|
interface ClientConfig {
|
|
280
280
|
baseUrl: string;
|
|
281
|
+
/**
|
|
282
|
+
* Route prefix every API is mounted under, when it is not `/api/v1`.
|
|
283
|
+
*
|
|
284
|
+
* `BaseApi` defaults each instance to `/api/v1` and takes a per-instance
|
|
285
|
+
* `basePath` override. That covers an app's OWN api classes and nothing else:
|
|
286
|
+
* a package that constructs its own API internally — erp-shell's permission
|
|
287
|
+
* `platformApi`, an SDK preset — has no seam to be told, so on a host mounted
|
|
288
|
+
* anywhere but `/api/v1` it silently requests a URL that does not exist and
|
|
289
|
+
* the feature reads as "no data" rather than as a misconfiguration.
|
|
290
|
+
*
|
|
291
|
+
* Setting it here makes the mount point a property of the DEPLOYMENT, stated
|
|
292
|
+
* once, which is what it actually is. A per-instance `basePath` still wins,
|
|
293
|
+
* so nothing that already passes one changes.
|
|
294
|
+
*
|
|
295
|
+
* @example configureClient({ baseUrl, basePath: '/api' }) // host mounts at /api
|
|
296
|
+
*/
|
|
297
|
+
basePath?: string;
|
|
281
298
|
internalApiKey?: string;
|
|
282
299
|
defaultHeaders?: Record<string, string>;
|
|
283
300
|
/**
|
|
@@ -457,6 +474,15 @@ declare function configureClient(config: ClientConfig): void;
|
|
|
457
474
|
declare function getAuthMode(): "bearer" | "cookie" | "header";
|
|
458
475
|
/** Get the configured base URL. Returns empty string if not configured. */
|
|
459
476
|
declare function getBaseUrl(): string;
|
|
477
|
+
/**
|
|
478
|
+
* The deployment's route prefix, or `null` when it has not declared one.
|
|
479
|
+
*
|
|
480
|
+
* `null` rather than the `/api/v1` default on purpose: the default belongs to
|
|
481
|
+
* `BaseApi`, which is the one place that should own it. Returning it here would
|
|
482
|
+
* put the same literal in two files, and the next person to change one would
|
|
483
|
+
* have no way to know about the other.
|
|
484
|
+
*/
|
|
485
|
+
declare function getBasePath(): string | null;
|
|
460
486
|
/** Whether auto-idempotency is enabled on the global client. */
|
|
461
487
|
declare function isAutoIdempotency(): boolean;
|
|
462
488
|
/**
|
|
@@ -1016,4 +1042,4 @@ declare const arc: {
|
|
|
1016
1042
|
delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
|
|
1017
1043
|
};
|
|
1018
1044
|
//#endregion
|
|
1019
|
-
export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, QuotaDetails, RetryConfig, ServerClientConfig, StreamUrlProtocol, TextResponse, TierRequirement, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, getTierRequirement, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isTierRequiredError, isValidationError };
|
|
1045
|
+
export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, QuotaDetails, RetryConfig, ServerClientConfig, StreamUrlProtocol, TextResponse, TierRequirement, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBasePath, getBaseUrl, getClientAuthContext, getQuotaDetails, getTierRequirement, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isTierRequiredError, isValidationError };
|
package/dist/client.js
CHANGED
|
@@ -337,6 +337,17 @@ function getAuthMode() {
|
|
|
337
337
|
function getBaseUrl() {
|
|
338
338
|
return clientConfig?.baseUrl ?? "";
|
|
339
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* The deployment's route prefix, or `null` when it has not declared one.
|
|
342
|
+
*
|
|
343
|
+
* `null` rather than the `/api/v1` default on purpose: the default belongs to
|
|
344
|
+
* `BaseApi`, which is the one place that should own it. Returning it here would
|
|
345
|
+
* put the same literal in two files, and the next person to change one would
|
|
346
|
+
* have no way to know about the other.
|
|
347
|
+
*/
|
|
348
|
+
function getBasePath() {
|
|
349
|
+
return clientConfig?.basePath ?? null;
|
|
350
|
+
}
|
|
340
351
|
/** Whether auto-idempotency is enabled on the global client. */
|
|
341
352
|
function isAutoIdempotency() {
|
|
342
353
|
return clientConfig?.autoIdempotency ?? false;
|
|
@@ -1276,4 +1287,4 @@ const arc = {
|
|
|
1276
1287
|
};
|
|
1277
1288
|
|
|
1278
1289
|
//#endregion
|
|
1279
|
-
export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, getTierRequirement, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isTierRequiredError, isValidationError };
|
|
1290
|
+
export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBasePath, getBaseUrl, getClientAuthContext, getQuotaDetails, getTierRequirement, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isTierRequiredError, isValidationError };
|