@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 +50 -0
- package/dist/api.d.ts +40 -23
- package/dist/api.js +14 -1
- package/dist/cache.d.ts +20 -2
- package/dist/cache.js +52 -1
- package/dist/client.d.ts +82 -12
- package/dist/client.js +110 -22
- package/dist/encryption.d.ts +1 -1
- package/dist/hooks.d.ts +28 -16
- package/dist/hooks.js +99 -27
- package/dist/mutation.d.ts +42 -7
- package/dist/mutation.js +28 -11
- package/dist/presets/history.d.ts +1 -1
- package/dist/query.d.ts +1 -1
- package/dist/sse.js +1 -1
- package/dist/upload.d.ts +9 -0
- package/dist/upload.js +6 -3
- package/dist/ws.d.ts +1 -1
- package/dist/ws.js +1 -1
- package/package.json +189 -178
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @classytic/arc-next
|
|
2
2
|
|
|
3
|
+
[](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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 |
|
|
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 |
|
|
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?:
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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:
|
|
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?:
|
|
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:
|
|
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"
|
|
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?:
|
|
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?:
|
|
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():
|
|
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<
|
|
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:
|
|
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,
|
|
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?:
|
|
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 =
|
|
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 =
|
|
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,
|
|
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 };
|