@classytic/arc-next 0.6.0 → 0.7.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/README.md +141 -4
- package/dist/cache.d.ts +28 -1
- package/dist/cache.js +101 -9
- package/dist/client.d.ts +277 -4
- package/dist/client.js +427 -18
- package/dist/hooks.js +56 -26
- package/dist/query.d.ts +101 -14
- package/dist/query.js +62 -28
- package/dist/sse.js +71 -5
- package/dist/upload.js +33 -2
- package/dist/ws.js +42 -5
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -85,15 +85,33 @@ function Products() {
|
|
|
85
85
|
## Core Hooks (from `createCrudHooks`)
|
|
86
86
|
|
|
87
87
|
```ts
|
|
88
|
-
const { items, pagination, isLoading, refetch } = useList(
|
|
89
|
-
const { item, isLoading } = useDetail(id,
|
|
88
|
+
const { items, pagination, isLoading, refetch } = useList(params, options);
|
|
89
|
+
const { item, isLoading, isPlaceholderData } = useDetail(id, options);
|
|
90
90
|
const { create, update, remove, isMutating } = useActions();
|
|
91
|
-
const { items, hasNextPage, fetchNextPage } = useInfiniteList(
|
|
91
|
+
const { items, hasNextPage, fetchNextPage } = useInfiniteList(params);
|
|
92
92
|
|
|
93
93
|
await create({ data, organizationId }, { onSuccess: (item) => navigate(...) });
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
All mutations are optimistic with automatic rollback on error.
|
|
96
|
+
- All mutations are optimistic with automatic rollback on error.
|
|
97
|
+
- Cache keys auto-scope by `organizationId` when present.
|
|
98
|
+
- **List → detail handoff:** when a parent `useList` has the entity in cache,
|
|
99
|
+
`useDetail` reads it via TanStack's `placeholderData` factory — instant
|
|
100
|
+
preview, but the real detail GET still fires (rich payload swap, no
|
|
101
|
+
cache pollution). Use `isPlaceholderData` to dim the preview while it
|
|
102
|
+
resolves. See [CHANGELOG 0.7](./CHANGELOG.md#070) for why this replaced
|
|
103
|
+
the old setQueryData-based prefill.
|
|
104
|
+
- **Detail → list pseudo-normalization:** after a `useDetail` GET resolves,
|
|
105
|
+
arc-next shallow-merges the fresh fields into every list cache holding
|
|
106
|
+
this id. The list view stays in sync without a refetch. Direction is
|
|
107
|
+
one-way (detail → list, never the reverse) — see
|
|
108
|
+
[CHANGELOG → "pseudo-normalization"](./CHANGELOG.md#070) for the
|
|
109
|
+
rationale. For true entity-level normalization (one copy per id, field-
|
|
110
|
+
level invalidation), use Apollo Client or Relay; arc-next stays in the
|
|
111
|
+
REST + React Query niche.
|
|
112
|
+
|
|
113
|
+
`useList(token, params, options)` (legacy 3-arg form) still compiles —
|
|
114
|
+
both signatures are kept stable across the 0.x line.
|
|
97
115
|
|
|
98
116
|
### `useApiQuery` — non-CRUD reads
|
|
99
117
|
|
|
@@ -413,13 +431,132 @@ configureClient({
|
|
|
413
431
|
|
|
414
432
|
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.
|
|
415
433
|
|
|
434
|
+
## `arcFetch` — one-line authenticated fetch for non-hook contexts (0.7+)
|
|
435
|
+
|
|
436
|
+
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:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import { arc } from "@classytic/arc-next/client";
|
|
440
|
+
|
|
441
|
+
// Before — 15 lines of header dance + error parse + JSON parse:
|
|
442
|
+
// const { token } = getAuthContext();
|
|
443
|
+
// if (!token) throw ...
|
|
444
|
+
// const res = await fetch(`${apiBaseUrl()}/api/statements`, {
|
|
445
|
+
// method: "POST",
|
|
446
|
+
// headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ... },
|
|
447
|
+
// body: JSON.stringify(statements),
|
|
448
|
+
// });
|
|
449
|
+
// if (!res.ok) throw ...
|
|
450
|
+
// return await res.json();
|
|
451
|
+
//
|
|
452
|
+
// After:
|
|
453
|
+
const result = await arc.post<{ ok: boolean }>("/api/statements", statements);
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
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`.
|
|
457
|
+
|
|
458
|
+
**Method shorthands:**
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
arc.get<T>(path, opts?)
|
|
462
|
+
arc.post<T>(path, body?, opts?)
|
|
463
|
+
arc.put<T>(path, body?, opts?)
|
|
464
|
+
arc.patch<T>(path, body?, opts?)
|
|
465
|
+
arc.delete<T>(path, opts?)
|
|
466
|
+
|
|
467
|
+
// Or call arcFetch directly for full RequestInit control:
|
|
468
|
+
arcFetch<T>(path, { method, body, headers, signal, elevated, idempotencyKey, revalidate, tags, cache, client })
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**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.
|
|
472
|
+
|
|
473
|
+
**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.
|
|
474
|
+
|
|
475
|
+
**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.
|
|
476
|
+
|
|
477
|
+
**Escape hatch.** When you need full `Response` control (rare — streaming downloads, custom redirect logic), use plain `fetch` with `arcAuthHeaders()`:
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
import { arcAuthHeaders, getAuthMode } from "@classytic/arc-next/client";
|
|
481
|
+
|
|
482
|
+
const res = await fetch(url, {
|
|
483
|
+
headers: { ...arcAuthHeaders(), "X-Custom": "1" },
|
|
484
|
+
credentials: getAuthMode() === "cookie" ? "include" : "same-origin",
|
|
485
|
+
});
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
## Auth Recovery (0.7+) — 401 → refresh → retry
|
|
489
|
+
|
|
490
|
+
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:
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
import { configureAuth, createAuthRefreshHandler } from "@classytic/arc-next/client";
|
|
494
|
+
import { authClient } from "@/lib/auth-client";
|
|
495
|
+
|
|
496
|
+
configureAuth({
|
|
497
|
+
getToken: () => authClient.getSession().data?.session.token ?? null,
|
|
498
|
+
onAuthError: createAuthRefreshHandler({
|
|
499
|
+
refresh: async () => {
|
|
500
|
+
// Whatever your auth lib calls to mint a fresh access token.
|
|
501
|
+
const { data } = await authClient.getSession({ disableCookieCache: true });
|
|
502
|
+
return data?.session.token ?? null; // null → session truly expired; original 401 surfaces
|
|
503
|
+
},
|
|
504
|
+
}),
|
|
505
|
+
});
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
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).
|
|
509
|
+
|
|
510
|
+
**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.
|
|
511
|
+
|
|
512
|
+
**Tuning knobs.**
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
configureAuth({
|
|
516
|
+
// ...
|
|
517
|
+
onAuthError,
|
|
518
|
+
retryOn403: true, // also recover from 403 (default: 401 only)
|
|
519
|
+
maxAuthRetries: 1, // cap per individual request (default: 1; prevents loops)
|
|
520
|
+
});
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
**Custom handler.** Bypass `createAuthRefreshHandler` if you need full control over the recovery cycle:
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
configureAuth({
|
|
527
|
+
onAuthError: async ({ error, request, attempt, setToken }) => {
|
|
528
|
+
if (error.code === "session.revoked") return "skip"; // route to /login
|
|
529
|
+
const fresh = await myRefreshFn();
|
|
530
|
+
if (!fresh) return "skip";
|
|
531
|
+
setToken(fresh);
|
|
532
|
+
return "retry";
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
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.
|
|
538
|
+
|
|
539
|
+
**Transport coverage.** Auth recovery fires across every transport arc-next exposes:
|
|
540
|
+
|
|
541
|
+
| Transport | Trigger | Mechanism |
|
|
542
|
+
|---|---|---|
|
|
543
|
+
| Fetch (CRUD hooks, `arcFetch`, `handleApiRequest`) | 401 / 403 response | Inline retry in `executeRequest` |
|
|
544
|
+
| XHR upload (`uploadWithProgress`, `useUploadWithProgress`) | 401 / 403 response | Outer retry loop in `upload.ts` |
|
|
545
|
+
| WebSocket | close code `1008` / `3401` / `4001` / `4401` | `ws.onclose` handler routes through recovery, reconnect with refreshed token |
|
|
546
|
+
| SSE (`subscribeToEvents`, `useEventStream`) | `EventSource` error | Pre-flight `fetch` probe classifies as auth-failure → recovery → reopen |
|
|
547
|
+
|
|
548
|
+
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.
|
|
549
|
+
|
|
416
550
|
## Cache & Keys
|
|
417
551
|
|
|
418
552
|
```ts
|
|
419
553
|
KEYS.detail(id); // ["products", "detail", id]
|
|
420
554
|
KEYS.scopedDetail(id, orgId); // tenant-scoped variant
|
|
421
555
|
|
|
556
|
+
// Writes/reads the raw doc — no `{ data: TDoc }` envelope (0.7+). Matches
|
|
557
|
+
// what useDetail, prefetchDetail, and useNavigation all produce.
|
|
422
558
|
cache.setDetail(qc, id, data);
|
|
559
|
+
cache.getDetail(qc, id); // TDoc | undefined
|
|
423
560
|
cache.invalidateDetail(qc, id); // matches all scoped variants
|
|
424
561
|
cache.invalidateLists(qc);
|
|
425
562
|
```
|
package/dist/cache.d.ts
CHANGED
|
@@ -65,6 +65,27 @@ declare function extractItem<T>(data: unknown): T | null;
|
|
|
65
65
|
* the array length changes.
|
|
66
66
|
*/
|
|
67
67
|
declare function updateListCache<T>(listData: unknown, updater: (items: T[]) => T[]): unknown;
|
|
68
|
+
/**
|
|
69
|
+
* After a detail fetch lands, propagate the fresh values into every cached
|
|
70
|
+
* list entry that contains this item. The item's ID is resolved via
|
|
71
|
+
* `idField`, falling back to `_id` / `id`. Handles both flat list payloads
|
|
72
|
+
* and infinite-query page arrays.
|
|
73
|
+
*
|
|
74
|
+
* Only updates EXISTING list entries — never creates new caches or new items
|
|
75
|
+
* in lists. If the item moved between filter buckets (e.g. status changed),
|
|
76
|
+
* the relevant lists will refetch on their own; we don't try to predict
|
|
77
|
+
* filter membership.
|
|
78
|
+
*
|
|
79
|
+
* Returns the number of cache entries updated (useful for tests + telemetry).
|
|
80
|
+
*
|
|
81
|
+
* @param qc TanStack QueryClient
|
|
82
|
+
* @param listsKey Prefix key for this entity's lists (typically `KEYS.lists()`)
|
|
83
|
+
* @param item Fresh entity to merge into matching list items
|
|
84
|
+
* @param opts.idField Custom ID field name (defaults to `_id` / `id` lookup)
|
|
85
|
+
*/
|
|
86
|
+
declare function syncDetailToLists<TItem extends Record<string, unknown>>(qc: QueryClient, listsKey: QueryKey, item: TItem, opts?: {
|
|
87
|
+
idField?: string;
|
|
88
|
+
}): number;
|
|
68
89
|
interface QueryKeys {
|
|
69
90
|
all: string[];
|
|
70
91
|
lists: () => QueryKey;
|
|
@@ -119,7 +140,13 @@ interface CacheUtils<T> {
|
|
|
119
140
|
* Build cache read/write/invalidate helpers bound to the given key factory.
|
|
120
141
|
* Server-safe — operates on a `QueryClient` instance which can be a per-request
|
|
121
142
|
* server client (during prefetch) or the browser singleton.
|
|
143
|
+
*
|
|
144
|
+
* **Wire shape:** Arc 2.13+ emits raw documents on `GET /:resource/:id` — no
|
|
145
|
+
* `{ data: ... }` envelope. `setDetail` / `getDetail` write and read the raw
|
|
146
|
+
* doc directly so the cache shape matches `useDetail`'s `queryFn` output,
|
|
147
|
+
* `useNavigation`'s pre-populated entries, and `prefetchDetail`'s server seed.
|
|
148
|
+
* All four paths converge on the same shape: TDoc, not `{ data: TDoc }`.
|
|
122
149
|
*/
|
|
123
150
|
declare function createCacheUtils<T>(KEYS: QueryKeys): CacheUtils<T>;
|
|
124
151
|
//#endregion
|
|
125
|
-
export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache };
|
|
152
|
+
export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache };
|
package/dist/cache.js
CHANGED
|
@@ -115,6 +115,7 @@ function updateListCache(listData, updater) {
|
|
|
115
115
|
if (!arrayField) return listData;
|
|
116
116
|
const updated = updater(d[arrayField]);
|
|
117
117
|
const original = d[arrayField];
|
|
118
|
+
if (updated === original) return listData;
|
|
118
119
|
const delta = updated.length - original.length;
|
|
119
120
|
const result = {
|
|
120
121
|
...d,
|
|
@@ -126,6 +127,95 @@ function updateListCache(listData, updater) {
|
|
|
126
127
|
}
|
|
127
128
|
return result;
|
|
128
129
|
}
|
|
130
|
+
/** Item-identity helper that respects a custom idField, falling back to `_id` / `id`. */
|
|
131
|
+
function resolveId(item, idField) {
|
|
132
|
+
if (!item || typeof item !== "object") return null;
|
|
133
|
+
if (idField) {
|
|
134
|
+
const v = item[idField];
|
|
135
|
+
if (v != null) return String(v);
|
|
136
|
+
}
|
|
137
|
+
return getItemId(item);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Shallow-merge updater that preserves the receiver's key set. Detail payloads
|
|
141
|
+
* are often richer than list payloads (populated relations, full body fields).
|
|
142
|
+
* If we replaced wholesale we'd bloat list caches with detail-only fields and
|
|
143
|
+
* force a full re-render on every value change. Merge keeps lists lean while
|
|
144
|
+
* still picking up updates to fields the list already cares about.
|
|
145
|
+
*/
|
|
146
|
+
function shallowMergeKept(receiver, source) {
|
|
147
|
+
let changed = false;
|
|
148
|
+
const next = { ...receiver };
|
|
149
|
+
for (const key of Object.keys(receiver)) if (key in source && !Object.is(receiver[key], source[key])) {
|
|
150
|
+
next[key] = source[key];
|
|
151
|
+
changed = true;
|
|
152
|
+
}
|
|
153
|
+
return changed ? next : receiver;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* After a detail fetch lands, propagate the fresh values into every cached
|
|
157
|
+
* list entry that contains this item. The item's ID is resolved via
|
|
158
|
+
* `idField`, falling back to `_id` / `id`. Handles both flat list payloads
|
|
159
|
+
* and infinite-query page arrays.
|
|
160
|
+
*
|
|
161
|
+
* Only updates EXISTING list entries — never creates new caches or new items
|
|
162
|
+
* in lists. If the item moved between filter buckets (e.g. status changed),
|
|
163
|
+
* the relevant lists will refetch on their own; we don't try to predict
|
|
164
|
+
* filter membership.
|
|
165
|
+
*
|
|
166
|
+
* Returns the number of cache entries updated (useful for tests + telemetry).
|
|
167
|
+
*
|
|
168
|
+
* @param qc TanStack QueryClient
|
|
169
|
+
* @param listsKey Prefix key for this entity's lists (typically `KEYS.lists()`)
|
|
170
|
+
* @param item Fresh entity to merge into matching list items
|
|
171
|
+
* @param opts.idField Custom ID field name (defaults to `_id` / `id` lookup)
|
|
172
|
+
*/
|
|
173
|
+
function syncDetailToLists(qc, listsKey, item, opts = {}) {
|
|
174
|
+
const targetId = resolveId(item, opts.idField);
|
|
175
|
+
if (!targetId) return 0;
|
|
176
|
+
let updates = 0;
|
|
177
|
+
const entries = qc.getQueriesData({ queryKey: listsKey });
|
|
178
|
+
for (const [qKey, raw] of entries) {
|
|
179
|
+
if (!raw) continue;
|
|
180
|
+
if (typeof raw === "object" && raw !== null && Array.isArray(raw.pages)) {
|
|
181
|
+
const inf = raw;
|
|
182
|
+
let pagesChanged = false;
|
|
183
|
+
const nextPages = inf.pages.map((page) => {
|
|
184
|
+
const merged = mergeItemIntoListPage(page, targetId, item, opts.idField);
|
|
185
|
+
if (merged !== page) pagesChanged = true;
|
|
186
|
+
return merged;
|
|
187
|
+
});
|
|
188
|
+
if (pagesChanged) {
|
|
189
|
+
qc.setQueryData(qKey, {
|
|
190
|
+
...inf,
|
|
191
|
+
pages: nextPages
|
|
192
|
+
});
|
|
193
|
+
updates += 1;
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const merged = mergeItemIntoListPage(raw, targetId, item, opts.idField);
|
|
198
|
+
if (merged !== raw) {
|
|
199
|
+
qc.setQueryData(qKey, merged);
|
|
200
|
+
updates += 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return updates;
|
|
204
|
+
}
|
|
205
|
+
/** Internal — merge an item into a single list payload (one filter result or one infinite page). */
|
|
206
|
+
function mergeItemIntoListPage(page, targetId, item, idField) {
|
|
207
|
+
return updateListCache(page, (items) => {
|
|
208
|
+
let changed = false;
|
|
209
|
+
const next = items.map((listItem) => {
|
|
210
|
+
if (!listItem || typeof listItem !== "object") return listItem;
|
|
211
|
+
if (resolveId(listItem, idField) !== targetId) return listItem;
|
|
212
|
+
const merged = shallowMergeKept(listItem, item);
|
|
213
|
+
if (merged !== listItem) changed = true;
|
|
214
|
+
return merged;
|
|
215
|
+
});
|
|
216
|
+
return changed ? next : items;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
129
219
|
/**
|
|
130
220
|
* Build a hierarchical query-key factory for a resource. The returned shape
|
|
131
221
|
* is identical between server (prefetch) and client (hooks), so RSC SSR
|
|
@@ -186,26 +276,28 @@ function createQueryKeys(entityKey) {
|
|
|
186
276
|
* Build cache read/write/invalidate helpers bound to the given key factory.
|
|
187
277
|
* Server-safe — operates on a `QueryClient` instance which can be a per-request
|
|
188
278
|
* server client (during prefetch) or the browser singleton.
|
|
279
|
+
*
|
|
280
|
+
* **Wire shape:** Arc 2.13+ emits raw documents on `GET /:resource/:id` — no
|
|
281
|
+
* `{ data: ... }` envelope. `setDetail` / `getDetail` write and read the raw
|
|
282
|
+
* doc directly so the cache shape matches `useDetail`'s `queryFn` output,
|
|
283
|
+
* `useNavigation`'s pre-populated entries, and `prefetchDetail`'s server seed.
|
|
284
|
+
* All four paths converge on the same shape: TDoc, not `{ data: TDoc }`.
|
|
189
285
|
*/
|
|
190
286
|
function createCacheUtils(KEYS) {
|
|
191
287
|
return {
|
|
192
288
|
invalidateAll: (client) => client.invalidateQueries({ queryKey: KEYS.all }),
|
|
193
289
|
invalidateLists: (client) => client.invalidateQueries({ queryKey: KEYS.lists() }),
|
|
194
290
|
invalidateDetail: (client, id) => client.invalidateQueries({ queryKey: KEYS.detail(id) }),
|
|
195
|
-
setDetail: (client, id, data) => client.setQueryData(KEYS.detail(id),
|
|
196
|
-
getDetail: (client, id) =>
|
|
197
|
-
return client.getQueryData(KEYS.detail(id))?.data;
|
|
198
|
-
},
|
|
291
|
+
setDetail: (client, id, data) => client.setQueryData(KEYS.detail(id), data),
|
|
292
|
+
getDetail: (client, id) => client.getQueryData(KEYS.detail(id)) ?? void 0,
|
|
199
293
|
removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) }),
|
|
200
294
|
invalidateScopedDetail: (client, id, organizationId) => client.invalidateQueries({ queryKey: KEYS.scopedDetail(id, organizationId) }),
|
|
201
|
-
setScopedDetail: (client, id, organizationId, data) => client.setQueryData(KEYS.scopedDetail(id, organizationId),
|
|
202
|
-
getScopedDetail: (client, id, organizationId) =>
|
|
203
|
-
return client.getQueryData(KEYS.scopedDetail(id, organizationId))?.data;
|
|
204
|
-
},
|
|
295
|
+
setScopedDetail: (client, id, organizationId, data) => client.setQueryData(KEYS.scopedDetail(id, organizationId), data),
|
|
296
|
+
getScopedDetail: (client, id, organizationId) => client.getQueryData(KEYS.scopedDetail(id, organizationId)) ?? void 0,
|
|
205
297
|
removeScopedDetail: (client, id, organizationId) => client.removeQueries({ queryKey: KEYS.scopedDetail(id, organizationId) }),
|
|
206
298
|
invalidateAggregations: (client, name) => client.invalidateQueries({ queryKey: name ? KEYS.aggregation(name) : KEYS.aggregations() })
|
|
207
299
|
};
|
|
208
300
|
}
|
|
209
301
|
|
|
210
302
|
//#endregion
|
|
211
|
-
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache };
|
|
303
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache };
|
package/dist/client.d.ts
CHANGED
|
@@ -322,6 +322,17 @@ declare function getAuthMode(): 'bearer' | 'cookie' | 'header';
|
|
|
322
322
|
declare function getBaseUrl(): string;
|
|
323
323
|
/** Whether auto-idempotency is enabled on the global client. */
|
|
324
324
|
declare function isAutoIdempotency(): boolean;
|
|
325
|
+
/**
|
|
326
|
+
* Whether the globally-configured client carries enough auth to satisfy a
|
|
327
|
+
* protected endpoint without a per-request token. True when any of
|
|
328
|
+
* `internalApiKey`, `defaultHeaders`, or `authMode: 'cookie'` is configured.
|
|
329
|
+
*
|
|
330
|
+
* Read by `createCrudHooks` to decide whether queries should be enabled when
|
|
331
|
+
* `getToken()` returns null — without this, an app that authenticates via a
|
|
332
|
+
* global `internalApiKey` or static headers would see every query stuck in
|
|
333
|
+
* a permanently-disabled state, looking like a clean empty success.
|
|
334
|
+
*/
|
|
335
|
+
declare function hasGlobalStaticAuth(): boolean;
|
|
325
336
|
interface AuthConfig {
|
|
326
337
|
/**
|
|
327
338
|
* Returns the current bearer/API token, or `null` if not authenticated.
|
|
@@ -329,15 +340,83 @@ interface AuthConfig {
|
|
|
329
340
|
* **MUST resolve synchronously.** The signature is `() => string | null`, never
|
|
330
341
|
* `Promise<string | null>`. If your auth library exposes an async session getter
|
|
331
342
|
* (Better Auth, NextAuth, Clerk, OAuth flows), refresh the token out-of-band
|
|
332
|
-
* (timer, event listener, lazy 401 retry) and have
|
|
333
|
-
* value. Returning a Promise will be detected and
|
|
334
|
-
* underlying token will be silently treated
|
|
343
|
+
* (timer, event listener, lazy 401 retry via {@link onAuthError}) and have
|
|
344
|
+
* `getToken` return the *cached* value. Returning a Promise will be detected and
|
|
345
|
+
* logged as an Error in dev — and the underlying token will be silently treated
|
|
346
|
+
* as `null`, causing 401s.
|
|
335
347
|
*/
|
|
336
348
|
getToken?: () => string | null;
|
|
337
349
|
getOrgId?: () => string | null;
|
|
338
350
|
/** Custom auth header name. Used when authMode is 'header'. Default: 'x-api-key' */
|
|
339
351
|
headerName?: string;
|
|
352
|
+
/**
|
|
353
|
+
* Lazy 401-recovery hook. Invoked once per request when the backend returns
|
|
354
|
+
* 401 (or 403, if {@link retryOn403} is true) AND a handler is configured.
|
|
355
|
+
*
|
|
356
|
+
* - Return `'retry'` to re-issue the request once with a fresh token.
|
|
357
|
+
* - Return `'skip'` to surface the original error to the caller (e.g. after
|
|
358
|
+
* a refresh attempt that found no valid session — let the consumer route
|
|
359
|
+
* to sign-in).
|
|
360
|
+
* - Throw to short-circuit; the thrown error propagates to the caller.
|
|
361
|
+
*
|
|
362
|
+
* **Concurrent dedup.** When N requests hit 401 at the same time, the handler
|
|
363
|
+
* fires ONCE — every concurrent caller awaits the same refresh promise and
|
|
364
|
+
* retries with the token it produced. Avoids stampeding the refresh endpoint.
|
|
365
|
+
*
|
|
366
|
+
* **Token propagation.** Inside the handler, call `ctx.setToken(newToken)` to
|
|
367
|
+
* inject the refreshed token for the retry. If you've already updated your
|
|
368
|
+
* global auth state out-of-band (e.g. updated a signal or the auth library's
|
|
369
|
+
* cache), you can omit `setToken` — arc-next re-reads via `getToken()` on
|
|
370
|
+
* the retry attempt.
|
|
371
|
+
*
|
|
372
|
+
* @see {@link createAuthRefreshHandler} for the canonical wiring with any
|
|
373
|
+
* `refresh()` function (Better Auth, NextAuth, custom OAuth flows).
|
|
374
|
+
*/
|
|
375
|
+
onAuthError?: AuthErrorHandler;
|
|
376
|
+
/**
|
|
377
|
+
* Treat 403 as auth-recoverable too. Default: `false` (only 401 triggers
|
|
378
|
+
* the handler). Enable for backends that emit 403 on expired sessions
|
|
379
|
+
* instead of 401.
|
|
380
|
+
*/
|
|
381
|
+
retryOn403?: boolean;
|
|
382
|
+
/**
|
|
383
|
+
* Cap on auth-recovery retries per individual request. Default: `1`.
|
|
384
|
+
* Bumping above 1 risks pathological loops if the refresh itself
|
|
385
|
+
* triggers 401s — only do it if you have hard guarantees on `getToken()`.
|
|
386
|
+
*/
|
|
387
|
+
maxAuthRetries?: number;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Context passed to {@link AuthConfig.onAuthError} when a request 401s.
|
|
391
|
+
*
|
|
392
|
+
* Inspect `error` to decide whether to refresh. Call `setToken(t)` to inject
|
|
393
|
+
* the refreshed token for the retry — arc-next prefers this value over
|
|
394
|
+
* re-reading `getToken()`, so it works even when your auth library hasn't
|
|
395
|
+
* yet propagated the new session.
|
|
396
|
+
*/
|
|
397
|
+
interface AuthErrorContext {
|
|
398
|
+
/** The 401/403 thrown by the failing request. Carries `status`, `code`, `json`. */
|
|
399
|
+
error: ArcApiError;
|
|
400
|
+
/** The request that failed — useful for path-based heuristics. */
|
|
401
|
+
request: {
|
|
402
|
+
method: HttpMethod;
|
|
403
|
+
endpoint: string;
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* 1-indexed auth-recovery attempt. First 401 in a request lifecycle is 1.
|
|
407
|
+
* Reaches {@link AuthConfig.maxAuthRetries} → handler is NOT called again
|
|
408
|
+
* and the 401 surfaces.
|
|
409
|
+
*/
|
|
410
|
+
attempt: number;
|
|
411
|
+
/**
|
|
412
|
+
* Inject the refreshed token for the retry. Pass `null` to retry
|
|
413
|
+
* unauthenticated. Optional — omit if `getToken()` already returns the
|
|
414
|
+
* fresh value.
|
|
415
|
+
*/
|
|
416
|
+
setToken: (token: string | null) => void;
|
|
340
417
|
}
|
|
418
|
+
/** {@link AuthConfig.onAuthError} signature. */
|
|
419
|
+
type AuthErrorHandler = (ctx: AuthErrorContext) => Promise<'retry' | 'skip'>;
|
|
341
420
|
/**
|
|
342
421
|
* Configure auth context for automatic token/orgId injection.
|
|
343
422
|
* When configured, hooks auto-inject these values so you don't need to pass them manually.
|
|
@@ -368,6 +447,95 @@ declare function getAuthContext(): {
|
|
|
368
447
|
};
|
|
369
448
|
/** @internal — exposed for tests; resets the dev-warn dedup flag. */
|
|
370
449
|
declare function _resetAuthWarnings(): void;
|
|
450
|
+
/**
|
|
451
|
+
* Outcome of a single auth-recovery cycle. `overrideToken` is the value the
|
|
452
|
+
* handler injected via `setToken`; `undefined` means the handler didn't call
|
|
453
|
+
* `setToken` and we should re-read via `getToken()`. `null` is meaningful —
|
|
454
|
+
* "retry without a token" (rare but valid for public endpoints).
|
|
455
|
+
*/
|
|
456
|
+
interface AuthRecoveryResult {
|
|
457
|
+
decision: 'retry' | 'skip';
|
|
458
|
+
overrideToken: string | null | undefined;
|
|
459
|
+
}
|
|
460
|
+
/** @internal — exposed for tests; clears the dedup so they don't bleed. */
|
|
461
|
+
declare function _resetAuthRecovery(): void;
|
|
462
|
+
/**
|
|
463
|
+
* @internal
|
|
464
|
+
* Cross-transport access to the configured auth-recovery handler.
|
|
465
|
+
* Upload (XHR) and WebSocket / SSE plumbing share the same dedup as the
|
|
466
|
+
* fetch path — they read the handler here and call {@link _runAuthRecovery}
|
|
467
|
+
* when they detect a transport-specific auth failure (XHR 401, WS close
|
|
468
|
+
* code 1008/4401, SSE pre-flight probe 401).
|
|
469
|
+
*/
|
|
470
|
+
declare function _getAuthErrorHandler(): {
|
|
471
|
+
handler: AuthErrorHandler | undefined;
|
|
472
|
+
retryOn403: boolean;
|
|
473
|
+
maxAuthRetries: number;
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* @internal
|
|
477
|
+
* Drive the shared recovery cycle from a non-fetch transport. Same dedup
|
|
478
|
+
* as the fetch path — concurrent callers (XHR upload + WebSocket reconnect
|
|
479
|
+
* + SSE probe firing at once) collapse to one refresh.
|
|
480
|
+
*/
|
|
481
|
+
declare function _runAuthRecovery(handler: AuthErrorHandler, ctx: Omit<AuthErrorContext, 'setToken'>): Promise<AuthRecoveryResult>;
|
|
482
|
+
/**
|
|
483
|
+
* @internal
|
|
484
|
+
* Resolve the next-attempt token. Mirrors the priority in `executeRequest`'s
|
|
485
|
+
* auth loop — `setToken` override beats re-reading `getToken()`. Exported so
|
|
486
|
+
* transports outside the fetch path apply the same precedence.
|
|
487
|
+
*/
|
|
488
|
+
declare function _resolveRefreshedToken(overrideToken: string | null | undefined): string | null;
|
|
489
|
+
/**
|
|
490
|
+
* @internal
|
|
491
|
+
* True for any error a transport should run through `onAuthError`. Matches
|
|
492
|
+
* the fetch path's predicate so XHR / WS / SSE failures classify the same
|
|
493
|
+
* way (401, or 403 when `retryOn403`).
|
|
494
|
+
*/
|
|
495
|
+
declare function _isAuthRecoverable(error: unknown, retryOn403: boolean): boolean;
|
|
496
|
+
/**
|
|
497
|
+
* Build an {@link AuthErrorHandler} from any `refresh()` function that
|
|
498
|
+
* returns the new token (or `null` if the session is truly expired).
|
|
499
|
+
*
|
|
500
|
+
* Catches refresh errors and surfaces them as `'skip'` by default so the
|
|
501
|
+
* original 401 reaches the consumer instead of a misleading "refresh failed"
|
|
502
|
+
* trace — consumers expect to handle "session expired" once, not twice. Pass
|
|
503
|
+
* `onRefreshError: 'throw'` to opt in to propagation.
|
|
504
|
+
*
|
|
505
|
+
* @example Better Auth (or any session-based lib)
|
|
506
|
+
* ```ts
|
|
507
|
+
* import { configureAuth, createAuthRefreshHandler } from '@classytic/arc-next/client';
|
|
508
|
+
* import { authClient } from '@/lib/auth-client';
|
|
509
|
+
*
|
|
510
|
+
* configureAuth({
|
|
511
|
+
* getToken: () => authClient.getSession().data?.session.token ?? null,
|
|
512
|
+
* onAuthError: createAuthRefreshHandler({
|
|
513
|
+
* refresh: async () => {
|
|
514
|
+
* const { data } = await authClient.getSession({ disableCookieCache: true });
|
|
515
|
+
* return data?.session.token ?? null;
|
|
516
|
+
* },
|
|
517
|
+
* }),
|
|
518
|
+
* });
|
|
519
|
+
* ```
|
|
520
|
+
*
|
|
521
|
+
* @example Custom OAuth refresh
|
|
522
|
+
* ```ts
|
|
523
|
+
* configureAuth({
|
|
524
|
+
* getToken: () => tokenStore.getAccessToken(),
|
|
525
|
+
* onAuthError: createAuthRefreshHandler({
|
|
526
|
+
* refresh: () => oauthClient.refresh(tokenStore.getRefreshToken()),
|
|
527
|
+
* }),
|
|
528
|
+
* });
|
|
529
|
+
* ```
|
|
530
|
+
*/
|
|
531
|
+
declare function createAuthRefreshHandler(opts: {
|
|
532
|
+
refresh: () => Promise<string | null>;
|
|
533
|
+
/**
|
|
534
|
+
* Behavior when the `refresh()` call itself throws. Default: `'skip'`
|
|
535
|
+
* (the original 401 surfaces; consumers handle "session expired" once).
|
|
536
|
+
*/
|
|
537
|
+
onRefreshError?: 'skip' | 'throw';
|
|
538
|
+
}): AuthErrorHandler;
|
|
371
539
|
/** Protocol family the URL should target. `http` keeps `getBaseUrl()` as-is; `ws` rewrites `http(s)://` → `ws(s)://`. */
|
|
372
540
|
type StreamUrlProtocol = 'http' | 'ws';
|
|
373
541
|
/**
|
|
@@ -521,5 +689,110 @@ declare function handleApiRequest<T = unknown>(method: HttpMethod, endpoint: str
|
|
|
521
689
|
* // => 'populate[employeeId][select]=name,email'
|
|
522
690
|
*/
|
|
523
691
|
declare function createQueryString<T extends Record<string, unknown>>(params?: T): string;
|
|
692
|
+
/**
|
|
693
|
+
* Returns the auth headers arc-next would inject on a fetch right now —
|
|
694
|
+
* `Authorization` (or the configured custom `headerName` for `authMode:
|
|
695
|
+
* 'header'`), `x-organization-id`, and `x-internal-api-key`. Use for the
|
|
696
|
+
* rare case where you need full `Response` control via plain `fetch` but
|
|
697
|
+
* still want arc-next's auth wiring.
|
|
698
|
+
*
|
|
699
|
+
* @example
|
|
700
|
+
* const res = await fetch(url, {
|
|
701
|
+
* headers: { ...arcAuthHeaders(), 'X-Custom': '1' },
|
|
702
|
+
* credentials: getAuthMode() === 'cookie' ? 'include' : 'same-origin',
|
|
703
|
+
* });
|
|
704
|
+
*/
|
|
705
|
+
declare function arcAuthHeaders(): Record<string, string>;
|
|
706
|
+
interface ArcFetchOptions extends Omit<RequestInit, 'body' | 'headers'> {
|
|
707
|
+
/**
|
|
708
|
+
* Request body. Plain objects and arrays are auto-`JSON.stringify`d and
|
|
709
|
+
* sent with `Content-Type: application/json`. Binary bodies (`FormData`,
|
|
710
|
+
* `Blob`, `ArrayBuffer`, `URLSearchParams`, `ReadableStream`) and strings
|
|
711
|
+
* pass through unchanged — the caller owns `Content-Type` for those.
|
|
712
|
+
*/
|
|
713
|
+
body?: Record<string, unknown> | unknown[] | BodyInit | null;
|
|
714
|
+
/**
|
|
715
|
+
* Extra headers merged on top of arc-next's auto-injected ones. Auth-
|
|
716
|
+
* related headers (`Authorization`, `x-organization-id`,
|
|
717
|
+
* `x-internal-api-key`, and the configured custom-auth header name) are
|
|
718
|
+
* **protected** — passing them here is silently dropped, so a caller can't
|
|
719
|
+
* accidentally strip the bearer token by spreading their own header map.
|
|
720
|
+
*/
|
|
721
|
+
headers?: Record<string, string>;
|
|
722
|
+
/** AbortSignal — same semantics as `fetch`. */
|
|
723
|
+
signal?: AbortSignal;
|
|
724
|
+
/** Per-call elevated-scope override (`x-arc-scope: platform`). */
|
|
725
|
+
elevated?: boolean;
|
|
726
|
+
/** Per-call `Idempotency-Key` header. */
|
|
727
|
+
idempotencyKey?: string;
|
|
728
|
+
/** Next.js fetch `revalidate` pass-through. */
|
|
729
|
+
revalidate?: number;
|
|
730
|
+
/** Next.js fetch `tags` pass-through. */
|
|
731
|
+
tags?: string[];
|
|
732
|
+
/** `cache` pass-through (browser fetch + Next.js). */
|
|
733
|
+
cache?: RequestCache;
|
|
734
|
+
/**
|
|
735
|
+
* Per-call client. Defaults to a lazily-created `createAuthAwareClient()`
|
|
736
|
+
* that bridges global `configureClient` + `configureAuth`. Pass a
|
|
737
|
+
* dedicated `createClient(...)` for multi-backend apps.
|
|
738
|
+
*/
|
|
739
|
+
client?: ArcClient;
|
|
740
|
+
}
|
|
741
|
+
/** @internal — tests reset the default client between cases. */
|
|
742
|
+
declare function _resetArcFetchClient(): void;
|
|
743
|
+
/**
|
|
744
|
+
* Authenticated, tenant-scoped fetch to an arc endpoint — one line for the
|
|
745
|
+
* non-hook contexts where `useQuery` / `useMutation` aren't available
|
|
746
|
+
* (event handlers, service workers, server actions, custom MDX submits,
|
|
747
|
+
* background polls).
|
|
748
|
+
*
|
|
749
|
+
* Auto-injects on every call:
|
|
750
|
+
* - `Authorization: Bearer <token>` (from `configureAuth().getToken`), or
|
|
751
|
+
* the custom header for `authMode: 'header'`
|
|
752
|
+
* - `x-organization-id` (from `configureAuth().getOrgId`)
|
|
753
|
+
* - `Content-Type: application/json` (only for plain object/array bodies)
|
|
754
|
+
* - `x-internal-api-key`, `Accept-Version`, `Idempotency-Key`,
|
|
755
|
+
* `x-arc-scope` when configured
|
|
756
|
+
*
|
|
757
|
+
* Composes with everything else `configureClient` + `configureAuth` do:
|
|
758
|
+
* - `retry` (5xx backoff)
|
|
759
|
+
* - `onAuthError` (401 → refresh → retry, with concurrent dedup)
|
|
760
|
+
* - `beforeRequest` / `afterResponse` interceptors
|
|
761
|
+
* - `cookie` / `bearer` / `header` auth modes
|
|
762
|
+
*
|
|
763
|
+
* Response handling:
|
|
764
|
+
* - 2xx → parsed body (JSON for `application/json`, Blob for binary,
|
|
765
|
+
* text for `text/*`).
|
|
766
|
+
* - non-2xx → throws `ArcApiError` with parsed body, status, endpoint,
|
|
767
|
+
* method. Use `isArcApiError(err)` + `err.code` to discriminate.
|
|
768
|
+
*
|
|
769
|
+
* For full `Response` control (rare), use plain `fetch` with
|
|
770
|
+
* {@link arcAuthHeaders} instead.
|
|
771
|
+
*
|
|
772
|
+
* @example
|
|
773
|
+
* import { arc } from '@classytic/arc-next/client';
|
|
774
|
+
*
|
|
775
|
+
* // Before — 15 lines of header dance + error parse + JSON
|
|
776
|
+
* // After:
|
|
777
|
+
* const result = await arc.post<{ ok: true }>('/api/statements', statements);
|
|
778
|
+
*/
|
|
779
|
+
declare function arcFetch<T = unknown>(path: string, options?: ArcFetchOptions): Promise<T>;
|
|
780
|
+
/**
|
|
781
|
+
* Method-specific shorthands for the 90% case. Each mirrors `arcFetch` with
|
|
782
|
+
* the HTTP verb pre-filled; mutating verbs accept `body` as the second arg
|
|
783
|
+
* so the call reads as a sentence:
|
|
784
|
+
*
|
|
785
|
+
* `arc.post('/path', payload)` instead of
|
|
786
|
+
* `arcFetch('/path', { method: 'POST', body: payload })`.
|
|
787
|
+
*
|
|
788
|
+
* Identical composition with `onAuthError`, retry, and interceptors.
|
|
789
|
+
*/
|
|
790
|
+
declare const arc: {
|
|
791
|
+
get: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
|
|
792
|
+
post: <T = unknown>(path: string, body?: ArcFetchOptions["body"], opts?: ArcFetchOptions) => Promise<T>;
|
|
793
|
+
put: <T = unknown>(path: string, body?: ArcFetchOptions["body"], opts?: ArcFetchOptions) => Promise<T>;
|
|
794
|
+
patch: <T = unknown>(path: string, body?: ArcFetchOptions["body"], opts?: ArcFetchOptions) => Promise<T>;
|
|
795
|
+
delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
|
|
796
|
+
};
|
|
524
797
|
//#endregion
|
|
525
|
-
export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, AuthConfig, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _resetAuthWarnings, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
|
|
798
|
+
export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
|