@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 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
+ [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](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
- readonly config: Required<Omit<BaseApiConfig, "client">>;
118
- readonly baseUrl: string;
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 ?? "/api/v1",
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-next",
3
- "version": "0.14.1",
3
+ "version": "0.15.1",
4
4
  "description": "React + TanStack Query SDK for Arc resources",
5
5
  "type": "module",
6
6
  "sideEffects": false,