@classytic/arc-next 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @classytic/arc-next
2
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
+
3
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.
4
6
 
5
7
  **Peers:** React 19+, TanStack React Query 5+
@@ -37,6 +39,10 @@ configureToast({ success: toast.success, error: toast.error });
37
39
  configureNavigation(useRouter);
38
40
  ```
39
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
+
40
46
  `getToken` **must be synchronous** — cache async tokens out-of-band. Promise returns are dropped + warned in dev.
41
47
 
42
48
  ## Quick Start
@@ -388,6 +394,44 @@ Pure helpers (`createQueryKeys`, `extractItem`, `updateListCache`, etc.) live in
388
394
 
389
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.
390
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
+
391
435
  ## Errors
392
436
 
393
437
  ```ts
@@ -425,11 +469,17 @@ Network resilience for mutations + direct `handleApiRequest` calls (TanStack Que
425
469
  ```ts
426
470
  configureClient({
427
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).
428
475
  retry: {
429
476
  attempts: 3, // 1 initial + 2 retries; default off
430
477
  backoff: 'exponential', // 'exponential' | 'linear' | (attempt) => ms
478
+ jitter: 'full', // randomize delays in [0, computed] — anti-stampede. Default 'none'.
431
479
  // retryOn: [502, 503, 504], // optional whitelist; default = network failures + 5xx, never 4xx, never AbortError
432
480
  },
481
+ // 429/503 responses with a Retry-After header override computed backoff —
482
+ // the parsed value is also exposed as ArcApiError.retryAfterMs.
433
483
  // Mutate outgoing requests (per attempt — retries re-run this)
434
484
  beforeRequest: (ctx) => ({
435
485
  ...ctx,
package/dist/api.d.ts CHANGED
@@ -1,15 +1,26 @@
1
1
  import { ArcClient, NextFetchOptions } from "./client.js";
2
- import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, PaginatedResult } from "@classytic/repo-core/pagination";
2
+ import { BracketOperator, BracketOperator as BracketOperator$1, ParsedPopulate } from "@classytic/repo-core/query-parser";
3
+ import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, PaginatedResult, SortDirection as SortDirection$1 } from "@classytic/repo-core/pagination";
3
4
  import { AggResult, AggRow, BulkCreateResult, DeleteManyResult, DeleteResult, UpdateManyResult } from "@classytic/repo-core/repository";
4
- import { BracketOperator, BracketOperator as BracketOperator$1 } from "@classytic/repo-core/query-parser";
5
5
 
6
6
  //#region src/api.d.ts
7
- interface PopulateOption {
8
- path: string;
9
- select?: string;
10
- match?: Record<string, unknown>;
11
- }
12
- type SortDirection = 1 | -1 | 'asc' | 'desc';
7
+ /**
8
+ * URL-emittable subset of repo-core's canonical {@link ParsedPopulate} —
9
+ * the exact three fields `populate[path][select]` / `populate[path][match]`
10
+ * bracket emission supports. Derived (not re-declared) so a canonical field
11
+ * rename breaks HERE at compile time instead of silently drifting.
12
+ * `options` / nested `populate` are parse-side-only in repo-core and have
13
+ * no URL grammar, so they're intentionally absent.
14
+ */
15
+ type PopulateOption = Pick<ParsedPopulate, "path" | "select" | "match">;
16
+ /**
17
+ * Canonical repo-core sort direction (`1 | -1`) plus the URL-ergonomic
18
+ * string forms. The numeric core comes from `@classytic/repo-core/pagination`
19
+ * so the wire vocabulary can't drift; `'asc' | 'desc'` are an SDK-side
20
+ * convenience serialized to the same URL grammar.
21
+ */
22
+ type SortDirection = SortDirection$1 | "asc" | "desc";
23
+ /** Sort spec: record form (repo-core `SortSpec` widened to string directions) or the raw URL string. */
13
24
  type SortSpec = Record<string, SortDirection> | string;
14
25
  /**
15
26
  * Filter operators supported by arc-next URL emission.
@@ -26,17 +37,23 @@ type SortSpec = Record<string, SortDirection> | string;
26
37
  * don't emit them; the union stays open with `(string & {})` so custom
27
38
  * domain operators still satisfy the type.
28
39
  */
29
- type FilterOperator = BracketOperator$1 | 'size' | 'type' | 'near' | 'nearSphere' | 'geoWithin' | 'withinRadius' | (string & {});
40
+ type FilterOperator = BracketOperator$1 | "size" | "type" | "near" | "nearSphere" | "geoWithin" | "withinRadius" | (string & {});
30
41
  interface QueryParams {
31
42
  page?: number;
32
43
  limit?: number;
44
+ /** Keyset cursor — the CANONICAL wire param (repo-core `STANDARD_RESERVED_PARAMS`). */
33
45
  after?: string;
46
+ /**
47
+ * Alias for {@link QueryParams.after}. The server only knows `after`;
48
+ * `prepareParams` rewrites `cursor` → `after` at emission (explicit
49
+ * `after` wins when both are set). Prefer `after` in new code.
50
+ */
34
51
  cursor?: string;
35
52
  sort?: string;
36
53
  select?: string;
37
54
  populate?: string | string[];
38
55
  populateOptions?: PopulateOption[];
39
- lean?: boolean | 'true' | 'false';
56
+ lean?: boolean | "true" | "false";
40
57
  /** Database-agnostic joins. Maps alias → collection or full lookup config. */
41
58
  lookup?: Record<string, string | {
42
59
  from: string;
@@ -59,7 +76,7 @@ interface RequestOptions {
59
76
  */
60
77
  next?: NextFetchOptions;
61
78
  headerOptions?: Record<string, string>;
62
- responseType?: 'json' | 'blob' | 'text';
79
+ responseType?: "json" | "blob" | "text";
63
80
  signal?: AbortSignal;
64
81
  }
65
82
  /**
@@ -72,7 +89,7 @@ interface RequestOptions {
72
89
  interface ScopedArgs {
73
90
  token?: string | null;
74
91
  organizationId?: string | null;
75
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
92
+ options?: Omit<RequestOptions, "token" | "organizationId">;
76
93
  }
77
94
  interface BaseApiConfig {
78
95
  basePath?: string;
@@ -87,7 +104,7 @@ interface BaseApiConfig {
87
104
  }
88
105
  declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>> {
89
106
  readonly entity: string;
90
- readonly config: Required<Omit<BaseApiConfig, 'client'>>;
107
+ readonly config: Required<Omit<BaseApiConfig, "client">>;
91
108
  readonly baseUrl: string;
92
109
  private readonly requestFn;
93
110
  constructor(entity: string, config?: BaseApiConfig);
@@ -121,7 +138,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
121
138
  token?: string | null;
122
139
  organizationId?: string | null;
123
140
  params?: QueryParams;
124
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
141
+ options?: Omit<RequestOptions, "token" | "organizationId">;
125
142
  }): Promise<PaginatedResult<TDoc>>;
126
143
  /**
127
144
  * Count records matching the filters — arc's list-route dispatch verb
@@ -137,7 +154,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
137
154
  token?: string | null;
138
155
  organizationId?: string | null;
139
156
  params?: QueryParams;
140
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
157
+ options?: Omit<RequestOptions, "token" | "organizationId">;
141
158
  }): Promise<number>;
142
159
  /** Whether ANY record matches the filters (`?_exists=true`). */
143
160
  exists({
@@ -149,7 +166,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
149
166
  token?: string | null;
150
167
  organizationId?: string | null;
151
168
  params?: QueryParams;
152
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
169
+ options?: Omit<RequestOptions, "token" | "organizationId">;
153
170
  }): Promise<boolean>;
154
171
  /** Distinct values of a field across matching records (`?_distinct=field`). */
155
172
  distinct<TValue = unknown>({
@@ -163,7 +180,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
163
180
  organizationId?: string | null;
164
181
  field: string;
165
182
  params?: QueryParams;
166
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
183
+ options?: Omit<RequestOptions, "token" | "organizationId">;
167
184
  }): Promise<TValue[]>;
168
185
  /** Shared raw GET against the list route (dispatch verbs bypass pagination parsing). */
169
186
  private getAllRaw;
@@ -181,7 +198,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
181
198
  select?: string;
182
199
  populate?: string | string[];
183
200
  };
184
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
201
+ options?: Omit<RequestOptions, "token" | "organizationId">;
185
202
  }): Promise<TDoc>;
186
203
  create({
187
204
  token,
@@ -192,7 +209,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
192
209
  token?: string | null;
193
210
  organizationId?: string | null;
194
211
  data: TCreate;
195
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
212
+ options?: Omit<RequestOptions, "token" | "organizationId">;
196
213
  }): Promise<TDoc>;
197
214
  update({
198
215
  token,
@@ -205,7 +222,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
205
222
  organizationId?: string | null;
206
223
  id: string;
207
224
  data: TUpdate;
208
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
225
+ options?: Omit<RequestOptions, "token" | "organizationId">;
209
226
  }): Promise<TDoc>;
210
227
  delete({
211
228
  token,
@@ -216,7 +233,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
216
233
  token?: string | null;
217
234
  organizationId?: string | null;
218
235
  id: string;
219
- options?: Omit<RequestOptions, 'token' | 'organizationId'>;
236
+ options?: Omit<RequestOptions, "token" | "organizationId">;
220
237
  }): Promise<DeleteResult>;
221
238
  upload({
222
239
  token,
@@ -230,7 +247,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
230
247
  id?: string; /** Custom sub-path appended as `baseUrl/{path}`. Takes precedence over `id`. */
231
248
  path?: string;
232
249
  }): Promise<TDoc>;
233
- request<TResponse = unknown>(method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', endpoint: string, {
250
+ request<TResponse = unknown>(method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE", endpoint: string, {
234
251
  token,
235
252
  organizationId,
236
253
  data,
@@ -282,7 +299,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
282
299
  params,
283
300
  options
284
301
  }: ScopedArgs & {
285
- method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; /** Path relative to the resource baseUrl. Leading slash optional. */
302
+ method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; /** Path relative to the resource baseUrl. Leading slash optional. */
286
303
  path: string;
287
304
  data?: unknown;
288
305
  params?: QueryParams;
package/dist/api.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { createQueryString, handleApiRequest } from "./client.js";
2
+ import { STANDARD_RESERVED_PARAMS } from "@classytic/repo-core/query-parser";
2
3
 
3
4
  //#region src/api.ts
5
+ for (const verb of [
6
+ "_count",
7
+ "_exists",
8
+ "_distinct"
9
+ ]) if (!STANDARD_RESERVED_PARAMS.has(verb)) throw new Error(`[arc-next] dispatch verb '${verb}' is not in repo-core STANDARD_RESERVED_PARAMS`);
4
10
  var BaseApi = class {
5
11
  entity;
6
12
  config;
@@ -62,6 +68,13 @@ var BaseApi = class {
62
68
  prepareParams(params = {}) {
63
69
  const result = {};
64
70
  const CRITICAL_FILTERS = ["organizationId", "ownerId"];
71
+ if (params.cursor !== void 0) {
72
+ const { cursor, ...rest } = params;
73
+ params = params.after !== void 0 ? rest : {
74
+ ...rest,
75
+ after: cursor
76
+ };
77
+ }
65
78
  Object.entries(params).forEach(([key, value]) => {
66
79
  if (CRITICAL_FILTERS.includes(key)) {
67
80
  result[key] = value || null;
@@ -84,7 +97,7 @@ var BaseApi = class {
84
97
  });
85
98
  return;
86
99
  }
87
- if (value !== void 0 && value !== "") if (["page", "limit"].includes(key)) result[key] = parseInt(String(value)) || (key === "page" ? 1 : 10);
100
+ if (value !== void 0 && value !== "") if (["page", "limit"].includes(key)) result[key] = parseInt(String(value), 10) || (key === "page" ? 1 : 10);
88
101
  else if (Array.isArray(value)) {
89
102
  if (/\[([^\]]+)\]$/.test(key)) result[key] = value.join(",");
90
103
  else if (value.length > 1) result[`${key}[in]`] = value.join(",");
package/dist/cache.d.ts CHANGED
@@ -3,7 +3,7 @@ import { QueryClient, QueryKey } from "@tanstack/react-query";
3
3
  //#region src/cache.d.ts
4
4
  interface PaginationData {
5
5
  /** Pagination method detected from response (offset | keyset | aggregate) */
6
- method: 'offset' | 'keyset' | 'aggregate' | null;
6
+ method: "offset" | "keyset" | "aggregate" | null;
7
7
  total: number;
8
8
  pages: number;
9
9
  page: number;
@@ -65,6 +65,24 @@ 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
+ * Insert an item at the head of a list cache entry — the optimistic-create
70
+ * primitive. Unlike {@link updateListCache} with a prepending updater (which,
71
+ * on an infinite cache, would insert into EVERY page), this targets exactly
72
+ * one location: the first page of an infinite cache, or the single payload of
73
+ * a flat list. Totals adjust via `updateListCache`'s existing delta logic.
74
+ */
75
+ declare function prependToListCache(listData: unknown, item: unknown): unknown;
76
+ /**
77
+ * Replace an item (matched by id) across a list cache entry — the
78
+ * temp-ID-reconciliation primitive. After a create succeeds, the optimistic
79
+ * placeholder (`temp-…` id) is swapped for the server document in place, so
80
+ * the UI keeps the row (no flicker) while the id becomes real. Works on flat
81
+ * and infinite shapes via {@link updateListCache}.
82
+ */
83
+ declare function replaceItemInListCache(listData: unknown, matchId: string, replacement: unknown, opts?: {
84
+ idField?: string;
85
+ }): unknown;
68
86
  /**
69
87
  * After a detail fetch lands, propagate the fresh values into every cached
70
88
  * list entry that contains this item. The item's ID is resolved via
@@ -163,4 +181,4 @@ interface CacheUtils<T> {
163
181
  */
164
182
  declare function createCacheUtils<T>(KEYS: QueryKeys): CacheUtils<T>;
165
183
  //#endregion
166
- export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache, withOrgParams };
184
+ export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, prependToListCache, replaceItemInListCache, syncDetailToLists, updateListCache, withOrgParams };
package/dist/cache.js CHANGED
@@ -101,6 +101,18 @@ function updateListCache(listData, updater) {
101
101
  if (Array.isArray(listData)) return updater(listData);
102
102
  if (typeof listData !== "object") return listData;
103
103
  const d = listData;
104
+ if (Array.isArray(d.pages) && Array.isArray(d.pageParams)) {
105
+ let changed = false;
106
+ const nextPages = d.pages.map((page) => {
107
+ const next = updateListCache(page, updater);
108
+ if (next !== page) changed = true;
109
+ return next;
110
+ });
111
+ return changed ? {
112
+ ...d,
113
+ pages: nextPages
114
+ } : listData;
115
+ }
104
116
  let arrayField = null;
105
117
  for (const key of LIST_KEYS) if (Array.isArray(d[key])) {
106
118
  arrayField = key;
@@ -127,6 +139,45 @@ function updateListCache(listData, updater) {
127
139
  }
128
140
  return result;
129
141
  }
142
+ /**
143
+ * Insert an item at the head of a list cache entry — the optimistic-create
144
+ * primitive. Unlike {@link updateListCache} with a prepending updater (which,
145
+ * on an infinite cache, would insert into EVERY page), this targets exactly
146
+ * one location: the first page of an infinite cache, or the single payload of
147
+ * a flat list. Totals adjust via `updateListCache`'s existing delta logic.
148
+ */
149
+ function prependToListCache(listData, item) {
150
+ if (!listData) return listData;
151
+ if (typeof listData === "object" && !Array.isArray(listData) && Array.isArray(listData.pages) && Array.isArray(listData.pageParams)) {
152
+ const d = listData;
153
+ if (d.pages.length === 0) return listData;
154
+ const first = updateListCache(d.pages[0], (items) => [item, ...items || []]);
155
+ if (first === d.pages[0]) return listData;
156
+ return {
157
+ ...d,
158
+ pages: [first, ...d.pages.slice(1)]
159
+ };
160
+ }
161
+ return updateListCache(listData, (items) => [item, ...items || []]);
162
+ }
163
+ /**
164
+ * Replace an item (matched by id) across a list cache entry — the
165
+ * temp-ID-reconciliation primitive. After a create succeeds, the optimistic
166
+ * placeholder (`temp-…` id) is swapped for the server document in place, so
167
+ * the UI keeps the row (no flicker) while the id becomes real. Works on flat
168
+ * and infinite shapes via {@link updateListCache}.
169
+ */
170
+ function replaceItemInListCache(listData, matchId, replacement, opts = {}) {
171
+ return updateListCache(listData, (items) => {
172
+ let changed = false;
173
+ const next = items.map((item) => {
174
+ if ((opts.idField ? String(item?.[opts.idField] ?? "") || getItemId(item) : getItemId(item)) !== matchId) return item;
175
+ changed = true;
176
+ return replacement;
177
+ });
178
+ return changed ? next : items;
179
+ });
180
+ }
130
181
  /** Item-identity helper that respects a custom idField, falling back to `_id` / `id`. */
131
182
  function resolveId(item, idField) {
132
183
  if (!item || typeof item !== "object") return null;
@@ -320,4 +371,4 @@ function createCacheUtils(KEYS) {
320
371
  }
321
372
 
322
373
  //#endregion
323
- export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache, withOrgParams };
374
+ export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, prependToListCache, replaceItemInListCache, syncDetailToLists, updateListCache, withOrgParams };
package/dist/client.d.ts CHANGED
@@ -34,7 +34,7 @@ type UseRouterHook = () => {
34
34
  * `(string & {})` keeps the type open so domain packages and custom
35
35
  * `errorMappers` codes still satisfy it.
36
36
  */
37
- declare const KNOWN_ARC_ERROR_CODES: readonly ["validation_error", "not_found", "conflict", "unauthorized", "forbidden", "rate_limited", "idempotency_conflict", "precondition_failed", "internal_error", "service_unavailable", "timeout", "arc.bad_request", "arc.unauthorized", "arc.forbidden", "arc.not_found", "arc.conflict", "arc.unprocessable_entity", "arc.rate_limited", "arc.internal_error", "arc.bad_gateway", "arc.service_unavailable", "arc.gateway_timeout", "arc.validation_error", "arc.invalid_id", "arc.org.selection_required", "arc.org.access_denied", "ORG_CONTEXT_REQUIRED", "ORG_ROLE_REQUIRED", "OWNERSHIP_DENIED", "MIXED_UPDATE_SHAPE", "ALL_FIELDS_STRIPPED", "BEFORE_RESTORE_HOOK_ERROR", "duplicate_key"];
37
+ declare const KNOWN_ARC_ERROR_CODES: readonly [...("validation_error" | "not_found" | "conflict" | "unauthorized" | "forbidden" | "rate_limited" | "idempotency_conflict" | "precondition_failed" | "internal_error" | "service_unavailable" | "timeout")[], "arc.bad_request", "arc.unauthorized", "arc.forbidden", "arc.not_found", "arc.conflict", "arc.unprocessable_entity", "arc.rate_limited", "arc.internal_error", "arc.bad_gateway", "arc.service_unavailable", "arc.gateway_timeout", "arc.validation_error", "arc.invalid_id", "arc.org.selection_required", "arc.org.access_denied", "ORG_CONTEXT_REQUIRED", "ORG_ROLE_REQUIRED", "OWNERSHIP_DENIED", "MIXED_UPDATE_SHAPE", "ALL_FIELDS_STRIPPED", "BEFORE_RESTORE_HOOK_ERROR", "duplicate_key"];
38
38
  /**
39
39
  * Canonical arc error code union. `(string & {})` keeps the type open so
40
40
  * domain packages can extend hierarchically (`'order.cart.locked'`,
@@ -47,6 +47,8 @@ interface ArcApiErrorOptions {
47
47
  json: unknown;
48
48
  endpoint: string;
49
49
  method: HttpMethod;
50
+ /** Parsed `Retry-After` response header in ms (429/503 pacing), if present. */
51
+ retryAfterMs?: number | null;
50
52
  }
51
53
  /**
52
54
  * Rich API error with status code, response payload, and request metadata.
@@ -68,6 +70,13 @@ declare class ArcApiError extends Error {
68
70
  readonly json: unknown;
69
71
  readonly endpoint: string;
70
72
  readonly method: HttpMethod;
73
+ /**
74
+ * Server-directed retry pacing from the `Retry-After` response header
75
+ * (seconds or HTTP-date form, normalized to ms). Populated on 429/503;
76
+ * the SDK's retry loop honors it INSTEAD of computed backoff so clients
77
+ * come back exactly when the server says capacity returns.
78
+ */
79
+ readonly retryAfterMs: number | null;
71
80
  constructor(message: string, options: ArcApiErrorOptions);
72
81
  /**
73
82
  * Canonical error code from arc's wire envelope (`json.code`).
@@ -240,7 +249,7 @@ interface ClientConfig {
240
249
  * - 'bearer' (default): Requires a token for authenticated requests. Queries are disabled until a token is provided.
241
250
  * - 'cookie': Auth is handled via HTTP-only cookies (e.g. Better Auth). Queries are always enabled — no token needed.
242
251
  */
243
- authMode?: 'bearer' | 'cookie' | 'header';
252
+ authMode?: "bearer" | "cookie" | "header";
244
253
  /**
245
254
  * Fetch credentials policy.
246
255
  * - 'include': Always send cookies cross-origin (required for cookie-based auth).
@@ -271,6 +280,17 @@ interface ClientConfig {
271
280
  * Per-request override: set `elevated: true | false` on `RequestOptions`.
272
281
  */
273
282
  elevated?: boolean;
283
+ /**
284
+ * Per-attempt request timeout in ms. When set (> 0), every fetch attempt is
285
+ * aborted after this long and fails with a retryable `TimeoutError` —
286
+ * WITHOUT it, a hung connection pends forever unless the caller wires an
287
+ * `AbortSignal` themselves. Composed with the caller's signal (whichever
288
+ * fires first wins); each retry attempt gets a fresh timeout window.
289
+ * Default: disabled (0) — deliberate, to avoid killing legitimately slow
290
+ * report/export endpoints; set it explicitly (10–30s is typical).
291
+ * Per-request override: `ApiRequestOptions.timeoutMs` (0 disables).
292
+ */
293
+ timeoutMs?: number;
274
294
  /**
275
295
  * Network-failure + 5xx retry policy. Off by default — TanStack Query already
276
296
  * retries query reads (3× by default), but the SDK's mutation flow and any
@@ -330,7 +350,7 @@ interface RetryConfig {
330
350
  * - `'linear'`: `300 * (n + 1)` ms
331
351
  * - `(attempt) => number`: custom delay in ms; receives the 0-indexed retry attempt
332
352
  */
333
- backoff?: 'exponential' | 'linear' | ((attempt: number) => number);
353
+ backoff?: "exponential" | "linear" | ((attempt: number) => number);
334
354
  /**
335
355
  * Whitelist of statuses to retry on, OR a predicate. Defaults to a function
336
356
  * that returns true for: any non-`ArcApiError` (network failure / fetch
@@ -338,6 +358,13 @@ interface RetryConfig {
338
358
  * AbortError or 4xx.
339
359
  */
340
360
  retryOn?: number[] | ((error: unknown) => boolean);
361
+ /**
362
+ * Backoff jitter. `'full'` randomizes each delay uniformly in
363
+ * `[0, computed]` (AWS full-jitter) so a fleet of clients recovering from
364
+ * the same outage doesn't retry in lockstep and re-stampede the backend.
365
+ * Default `'none'` keeps the historical deterministic delays.
366
+ */
367
+ jitter?: "none" | "full";
341
368
  }
342
369
  /** Context handed to {@link ClientConfig.beforeRequest}. Mutate + return to override. */
343
370
  interface BeforeRequestContext {
@@ -384,7 +411,7 @@ declare function configureClient(config: ClientConfig): void;
384
411
  /**
385
412
  * Get the configured auth mode. Returns 'bearer' if not configured.
386
413
  */
387
- declare function getAuthMode(): 'bearer' | 'cookie' | 'header';
414
+ declare function getAuthMode(): "bearer" | "cookie" | "header";
388
415
  /** Get the configured base URL. Returns empty string if not configured. */
389
416
  declare function getBaseUrl(): string;
390
417
  /** Whether auto-idempotency is enabled on the global client. */
@@ -483,7 +510,7 @@ interface AuthErrorContext {
483
510
  setToken: (token: string | null) => void;
484
511
  }
485
512
  /** {@link AuthConfig.onAuthError} signature. */
486
- type AuthErrorHandler = (ctx: AuthErrorContext) => Promise<'retry' | 'skip'>;
513
+ type AuthErrorHandler = (ctx: AuthErrorContext) => Promise<"retry" | "skip">;
487
514
  /**
488
515
  * Configure auth context for automatic token/orgId injection.
489
516
  * When configured, hooks auto-inject these values so you don't need to pass them manually.
@@ -521,7 +548,7 @@ declare function _resetAuthWarnings(): void;
521
548
  * "retry without a token" (rare but valid for public endpoints).
522
549
  */
523
550
  interface AuthRecoveryResult {
524
- decision: 'retry' | 'skip';
551
+ decision: "retry" | "skip";
525
552
  overrideToken: string | null | undefined;
526
553
  }
527
554
  /** @internal — exposed for tests; clears the dedup so they don't bleed. */
@@ -545,7 +572,7 @@ declare function _getAuthErrorHandler(): {
545
572
  * as the fetch path — concurrent callers (XHR upload + WebSocket reconnect
546
573
  * + SSE probe firing at once) collapse to one refresh.
547
574
  */
548
- declare function _runAuthRecovery(handler: AuthErrorHandler, ctx: Omit<AuthErrorContext, 'setToken'>): Promise<AuthRecoveryResult>;
575
+ declare function _runAuthRecovery(handler: AuthErrorHandler, ctx: Omit<AuthErrorContext, "setToken">): Promise<AuthRecoveryResult>;
549
576
  /**
550
577
  * @internal
551
578
  * Resolve the next-attempt token. Mirrors the priority in `executeRequest`'s
@@ -601,10 +628,10 @@ declare function createAuthRefreshHandler(opts: {
601
628
  * Behavior when the `refresh()` call itself throws. Default: `'skip'`
602
629
  * (the original 401 surfaces; consumers handle "session expired" once).
603
630
  */
604
- onRefreshError?: 'skip' | 'throw';
631
+ onRefreshError?: "skip" | "throw";
605
632
  }): AuthErrorHandler;
606
633
  /** Protocol family the URL should target. `http` keeps `getBaseUrl()` as-is; `ws` rewrites `http(s)://` → `ws(s)://`. */
607
- type StreamUrlProtocol = 'http' | 'ws';
634
+ type StreamUrlProtocol = "http" | "ws";
608
635
  /**
609
636
  * Build an auth-aware URL using the global client + auth singletons.
610
637
  *
@@ -618,7 +645,7 @@ type StreamUrlProtocol = 'http' | 'ws';
618
645
  * @param protocol `'http'` (default) or `'ws'` — controls the protocol rewrite.
619
646
  */
620
647
  declare function buildStreamUrl(path: string, params?: Record<string, string | number | boolean | null | undefined>, protocol?: StreamUrlProtocol): string;
621
- type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
648
+ type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
622
649
  /** Returned by `handleApiRequest` for PDF, image, and CSV responses. */
623
650
  interface BlobResponse {
624
651
  data: Blob;
@@ -677,6 +704,8 @@ interface ApiRequestOptions {
677
704
  next?: NextFetchOptions;
678
705
  cache?: RequestCache;
679
706
  signal?: AbortSignal;
707
+ /** Per-request timeout in ms — overrides `ClientConfig.timeoutMs`. `0` disables for this request. */
708
+ timeoutMs?: number;
680
709
  /** Explicit idempotency key for this request. Sent as `Idempotency-Key` header. */
681
710
  idempotencyKey?: string;
682
711
  /**
@@ -762,6 +791,47 @@ declare function createClient(config: ArcClientConfig): ArcClient;
762
791
  * });
763
792
  */
764
793
  declare function createAuthAwareClient(overrides?: Partial<ArcClientConfig>): ArcClient;
794
+ /**
795
+ * Request-scoped server client config — static credentials for exactly one
796
+ * request, no module singletons.
797
+ */
798
+ interface ServerClientConfig extends Omit<ArcClientConfig, "getToken" | "getOrgId" | "toast" | "navigation"> {
799
+ /** Bearer token for this request. `null`/omitted = unauthenticated. */
800
+ token?: string | null;
801
+ /** Tenant/org for this request, sent as `x-organization-id`. */
802
+ organizationId?: string | null;
803
+ }
804
+ /**
805
+ * Create a **request-scoped** client for server environments (Next.js App
806
+ * Router route handlers / Server Components, or any SSR runtime).
807
+ *
808
+ * Unlike `configureClient`/`configureAuth` — which set module-level state
809
+ * that leaks across concurrent server requests — this returns an isolated
810
+ * client whose credentials live only in the returned instance. Construct one
811
+ * per request, read cookies/headers in YOUR framework code, and pass the
812
+ * values in. The SDK stays framework-free: no `next` dependency, no
813
+ * `next/headers` import, no hidden cookie access.
814
+ *
815
+ * @example Next.js App Router (Server Component or route handler)
816
+ * ```ts
817
+ * import { cookies, headers } from 'next/headers'; // host code, not SDK code
818
+ * import { createServerClient } from '@classytic/arc-next/client';
819
+ * import { createCrudApi } from '@classytic/arc-next/api';
820
+ *
821
+ * export async function loadOrders() {
822
+ * const jar = await cookies();
823
+ * const client = createServerClient({
824
+ * baseUrl: process.env.API_URL!,
825
+ * token: jar.get('session')?.value ?? null,
826
+ * organizationId: (await headers()).get('x-organization-id'),
827
+ * });
828
+ * const orders = createCrudApi<Order>('orders', { client });
829
+ * // Next fetch-cache passthrough works per call:
830
+ * return orders.getAll({ options: { next: { revalidate: 60, tags: ['orders'] } } });
831
+ * }
832
+ * ```
833
+ */
834
+ declare function createServerClient(config: ServerClientConfig): ArcClient;
765
835
  /**
766
836
  * Get auth context for a specific client instance, falling back to global.
767
837
  */
@@ -809,7 +879,7 @@ declare function createQueryString<T extends Record<string, unknown>>(params?: T
809
879
  * });
810
880
  */
811
881
  declare function arcAuthHeaders(): Record<string, string>;
812
- interface ArcFetchOptions extends Omit<RequestInit, 'body' | 'headers'> {
882
+ interface ArcFetchOptions extends Omit<RequestInit, "body" | "headers"> {
813
883
  /**
814
884
  * Request body. Plain objects and arrays are auto-`JSON.stringify`d and
815
885
  * sent with `Content-Type: application/json`. Binary bodies (`FormData`,
@@ -903,4 +973,4 @@ declare const arc: {
903
973
  delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
904
974
  };
905
975
  //#endregion
906
- 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, 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, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
976
+ 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, 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, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };