@classytic/arc-next 0.13.0 → 0.14.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/dist/api.js CHANGED
@@ -98,12 +98,14 @@ var BaseApi = class {
98
98
  });
99
99
  return;
100
100
  }
101
- if (value !== void 0 && value !== "") if (["page", "limit"].includes(key)) result[key] = parseInt(String(value), 10) || (key === "page" ? 1 : 10);
102
- else if (Array.isArray(value)) {
103
- if (/\[([^\]]+)\]$/.test(key)) result[key] = value.join(",");
104
- else if (value.length > 1) result[`${key}[in]`] = value.join(",");
105
- else if (value.length === 1) result[key] = value[0];
106
- } else result[key] = value;
101
+ if (value !== void 0 && value !== "") {
102
+ if (["page", "limit"].includes(key)) result[key] = parseInt(String(value), 10) || (key === "page" ? 1 : 10);
103
+ else if (Array.isArray(value)) {
104
+ if (/\[([^\]]+)\]$/.test(key)) result[key] = value.join(",");
105
+ else if (value.length > 1) result[`${key}[in]`] = value.join(",");
106
+ else if (value.length === 1) result[key] = value[0];
107
+ } else result[key] = value;
108
+ }
107
109
  });
108
110
  return result;
109
111
  }
package/dist/cache.js CHANGED
@@ -1,7 +1,7 @@
1
1
  //#region src/cache.ts
2
2
  const DEFAULT_QUERY_CONFIG = {
3
- staleTime: 300 * 1e3,
4
- gcTime: 1800 * 1e3,
3
+ staleTime: 3e5,
4
+ gcTime: 18e5,
5
5
  refetchOnWindowFocus: false,
6
6
  retry: 0
7
7
  };
package/dist/client.js CHANGED
@@ -872,8 +872,10 @@ function withTimeoutSignal(signal, timeoutMs) {
872
872
  const controller = new AbortController();
873
873
  let timedOut = false;
874
874
  const onCallerAbort = () => controller.abort();
875
- if (signal) if (signal.aborted) controller.abort();
876
- else signal.addEventListener("abort", onCallerAbort, { once: true });
875
+ if (signal) {
876
+ if (signal.aborted) controller.abort();
877
+ else signal.addEventListener("abort", onCallerAbort, { once: true });
878
+ }
877
879
  const timer = setTimeout(() => {
878
880
  timedOut = true;
879
881
  controller.abort();
package/dist/hooks.d.ts CHANGED
@@ -90,6 +90,30 @@ interface CrudHooksConfig<T, TCreate = Partial<T>, TUpdate = Partial<T>> {
90
90
  gcTime?: number;
91
91
  refetchOnWindowFocus?: boolean;
92
92
  structuralSharing?: boolean;
93
+ /**
94
+ * Poll this resource's list/detail while mounted, in ms.
95
+ *
96
+ * Already supported PER CALL (`useList(params, { refetchInterval })`); this
97
+ * is the resource-level default, so a resource whose freshness matters
98
+ * declares it once instead of at every call site — and cannot be forgotten
99
+ * at one of them.
100
+ *
101
+ * The case that motivated it: an orders dashboard left open on a shop
102
+ * counter. Push notifications are best-effort, so the list is the
103
+ * AUTHORITATIVE path to a new order — but with no interval it relied on
104
+ * `refetchOnMount` / `refetchOnWindowFocus`, and a window that never blurs
105
+ * never refocuses, so it may never refetch. A missed push then reads as a
106
+ * missing record.
107
+ */
108
+ refetchInterval?: number | false;
109
+ /**
110
+ * Keep polling while the tab is hidden. Defaults to TanStack's `false`.
111
+ *
112
+ * Leave it off unless the data must be current the instant the operator
113
+ * returns — a background tab polling forever is load nobody is reading, and
114
+ * `refetchOnWindowFocus` already catches up on return.
115
+ */
116
+ refetchIntervalInBackground?: boolean;
93
117
  /**
94
118
  * Declare this resource's read endpoints PUBLIC (`allowPublic` on the
95
119
  * server — e.g. a storefront catalog). Read hooks then enable token-less
package/dist/hooks.js CHANGED
@@ -146,8 +146,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
146
146
  gcTime: queryOpts.gcTime ?? config.gcTime,
147
147
  refetchOnWindowFocus: queryOpts.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
148
148
  structuralSharing: queryOpts.structuralSharing ?? config.structuralSharing,
149
- refetchInterval: queryOpts.refetchInterval,
150
- refetchIntervalInBackground: queryOpts.refetchIntervalInBackground
149
+ refetchInterval: queryOpts.refetchInterval ?? config.refetchInterval,
150
+ refetchIntervalInBackground: queryOpts.refetchIntervalInBackground ?? config.refetchIntervalInBackground
151
151
  },
152
152
  select: queryOpts.select
153
153
  });
@@ -236,8 +236,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
236
236
  gcTime: queryOpts.gcTime ?? config.gcTime,
237
237
  refetchOnWindowFocus: queryOpts.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
238
238
  structuralSharing: queryOpts.structuralSharing ?? config.structuralSharing,
239
- refetchInterval: queryOpts.refetchInterval,
240
- refetchIntervalInBackground: queryOpts.refetchIntervalInBackground
239
+ refetchInterval: queryOpts.refetchInterval ?? config.refetchInterval,
240
+ refetchIntervalInBackground: queryOpts.refetchIntervalInBackground ?? config.refetchIntervalInBackground
241
241
  },
242
242
  select: queryOpts.select
243
243
  });
@@ -305,11 +305,12 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
305
305
  tempId = resolveItemId(data) ?? `temp-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`;
306
306
  tempIdsRef.current.set(variables, tempId);
307
307
  }
308
- return prependToListCache(oldData, {
308
+ const optimisticItem = {
309
309
  ...data,
310
310
  _optimistic: true,
311
311
  [idField ?? (resolveItemId(data) ? "id" : "_id")]: tempId
312
- });
312
+ };
313
+ return prependToListCache(oldData, optimisticItem);
313
314
  },
314
315
  reconcile: (raw, variables) => {
315
316
  const serverDoc = extractItem(raw);
@@ -462,7 +463,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
462
463
  create: useCallback(async (params, options) => {
463
464
  silentRef.current = options?.silent ?? false;
464
465
  try {
465
- const entity = extractItem(await createMutation.mutateAsync(resolveActionAuth(params)));
466
+ const raw = await createMutation.mutateAsync(resolveActionAuth(params));
467
+ const entity = extractItem(raw);
466
468
  options?.onSuccess?.(entity);
467
469
  options?.onSettled?.(entity, null);
468
470
  return entity;
@@ -477,7 +479,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
477
479
  update: useCallback(async (params, options) => {
478
480
  silentRef.current = options?.silent ?? false;
479
481
  try {
480
- const entity = extractItem(await enqueueWrite(params.id, () => updateMutation.mutateAsync(resolveActionAuth(params))));
482
+ const raw = await enqueueWrite(params.id, () => updateMutation.mutateAsync(resolveActionAuth(params)));
483
+ const entity = extractItem(raw);
481
484
  options?.onSuccess?.(entity);
482
485
  options?.onSettled?.(entity, null);
483
486
  return entity;
@@ -507,7 +510,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
507
510
  restore: useCallback(async (params, options) => {
508
511
  silentRef.current = options?.silent ?? false;
509
512
  try {
510
- const entity = extractItem(await enqueueWrite(params.id, () => restoreMutation.mutateAsync(resolveActionAuth(params))));
513
+ const raw = await enqueueWrite(params.id, () => restoreMutation.mutateAsync(resolveActionAuth(params)));
514
+ const entity = extractItem(raw);
511
515
  options?.onSuccess?.(entity);
512
516
  options?.onSettled?.(entity, null);
513
517
  return entity;
@@ -585,8 +589,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
585
589
  gcTime: queryOpts.gcTime ?? config.gcTime,
586
590
  refetchOnWindowFocus: queryOpts.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
587
591
  structuralSharing: queryOpts.structuralSharing ?? config.structuralSharing,
588
- refetchInterval: queryOpts.refetchInterval,
589
- refetchIntervalInBackground: queryOpts.refetchIntervalInBackground
592
+ refetchInterval: queryOpts.refetchInterval ?? config.refetchInterval,
593
+ refetchIntervalInBackground: queryOpts.refetchIntervalInBackground ?? config.refetchIntervalInBackground
590
594
  }
591
595
  });
592
596
  }
package/dist/mutation.js CHANGED
@@ -82,8 +82,10 @@ function useMutationWithTransition(config) {
82
82
  queryClient.invalidateQueries({ queryKey: key });
83
83
  });
84
84
  };
85
- if (invalidateQueries.length > 0 && (config.shouldInvalidate?.(data) ?? true)) if (withTransition) startTransition(invalidate);
86
- else invalidate();
85
+ if (invalidateQueries.length > 0 && (config.shouldInvalidate?.(data) ?? true)) {
86
+ if (withTransition) startTransition(invalidate);
87
+ else invalidate();
88
+ }
87
89
  if (toast && (config.shouldToast?.() ?? true)) showToast("success", messages, data, variables, void 0, instanceToast);
88
90
  onSuccess?.(data, variables);
89
91
  },
@@ -3,8 +3,8 @@ import { QueryClient, defaultShouldDehydrateQuery, isServer } from "@tanstack/re
3
3
 
4
4
  //#region src/query-client.ts
5
5
  const DEFAULTS = {
6
- staleTime: 300 * 1e3,
7
- gcTime: 1800 * 1e3,
6
+ staleTime: 3e5,
7
+ gcTime: 18e5,
8
8
  retry: 0,
9
9
  refetchOnWindowFocus: false
10
10
  };
package/dist/upload.js CHANGED
@@ -181,7 +181,8 @@ function uploadAttempt(options) {
181
181
  resolve(body);
182
182
  return;
183
183
  }
184
- reject(new ArcApiError(extractErrorMessage(body, statusText), {
184
+ const message = extractErrorMessage(body, statusText);
185
+ reject(new ArcApiError(message, {
185
186
  status,
186
187
  statusText,
187
188
  json: body,
package/dist/ws.js CHANGED
@@ -123,18 +123,19 @@ function connectWs(options = {}) {
123
123
  const isAuthClose = event.code === 1008 || event.code === 3401 || event.code === 4001 || event.code === 4401;
124
124
  if (handler && isAuthClose && wsAuthRetries < maxAuthRetries) {
125
125
  wsAuthRetries += 1;
126
+ const synthError = new ArcApiError(event.reason || `WebSocket closed with auth code ${event.code}`, {
127
+ status: 401,
128
+ statusText: "WebSocket auth failure",
129
+ json: {
130
+ code: "arc.websocket.unauthorized",
131
+ wsCloseCode: event.code,
132
+ reason: event.reason
133
+ },
134
+ endpoint: url ?? path,
135
+ method: "GET"
136
+ });
126
137
  _runAuthRecovery(handler, {
127
- error: new ArcApiError(event.reason || `WebSocket closed with auth code ${event.code}`, {
128
- status: 401,
129
- statusText: "WebSocket auth failure",
130
- json: {
131
- code: "arc.websocket.unauthorized",
132
- wsCloseCode: event.code,
133
- reason: event.reason
134
- },
135
- endpoint: url ?? path,
136
- method: "GET"
137
- }),
138
+ error: synthError,
138
139
  request: {
139
140
  method: "GET",
140
141
  endpoint: url ?? path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-next",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "React + TanStack Query SDK for Arc resources",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -148,7 +148,7 @@
148
148
  "check:dead-code": "knip"
149
149
  },
150
150
  "peerDependencies": {
151
- "@classytic/repo-core": ">=0.14.0",
151
+ "@classytic/repo-core": ">=0.24.0",
152
152
  "@tanstack/react-query": ">=5.62.0",
153
153
  "jose": ">=5.0.0",
154
154
  "react": ">=19.0.0"
@@ -171,7 +171,7 @@
171
171
  "@arethetypeswrong/cli": "^0.18.5",
172
172
  "@biomejs/biome": "^2.5.5",
173
173
  "@classytic/dev-tools": "^0.2.0",
174
- "@classytic/repo-core": "^0.19.0",
174
+ "@classytic/repo-core": ">=0.24.0",
175
175
  "@tanstack/react-query": "^5.97.0",
176
176
  "@testing-library/react": "^16.3.2",
177
177
  "@types/react": "^19.2.14",
package/LICENSE DELETED
@@ -1,21 +0,0 @@
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 DELETED
@@ -1,659 +0,0 @@
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.