@classytic/arc-next 0.4.0 → 0.5.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 +272 -616
- package/dist/api.d.ts +64 -127
- package/dist/api.js +55 -121
- package/dist/cache.d.ts +108 -0
- package/dist/cache.js +197 -0
- package/dist/client.d.ts +340 -8
- package/dist/client.js +427 -22
- package/dist/hooks.d.ts +96 -20
- package/dist/hooks.js +247 -115
- package/dist/mutation.d.ts +18 -4
- package/dist/mutation.js +20 -4
- package/dist/prefetch.d.ts +37 -3
- package/dist/prefetch.js +56 -17
- package/dist/presets/bulk.d.ts +42 -0
- package/dist/presets/bulk.js +50 -0
- package/dist/presets/search.d.ts +55 -0
- package/dist/presets/search.js +60 -0
- package/dist/presets/slug.d.ts +28 -0
- package/dist/presets/slug.js +27 -0
- package/dist/presets/soft-delete.d.ts +33 -0
- package/dist/presets/soft-delete.js +45 -0
- package/dist/presets/tree.d.ts +31 -0
- package/dist/presets/tree.js +47 -0
- package/dist/query.d.ts +86 -64
- package/dist/query.js +69 -151
- package/dist/sse.d.ts +116 -30
- package/dist/sse.js +179 -102
- package/dist/upload.d.ts +181 -0
- package/dist/upload.js +346 -0
- package/dist/ws.d.ts +167 -0
- package/dist/ws.js +274 -0
- package/package.json +37 -1
package/dist/cache.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
//#region src/cache.ts
|
|
2
|
+
const DEFAULT_QUERY_CONFIG = {
|
|
3
|
+
staleTime: 300 * 1e3,
|
|
4
|
+
gcTime: 1800 * 1e3,
|
|
5
|
+
refetchOnWindowFocus: false,
|
|
6
|
+
retry: 0
|
|
7
|
+
};
|
|
8
|
+
/** Pre-built query config presets for common data freshness patterns. */
|
|
9
|
+
const QUERY_CONFIGS = {
|
|
10
|
+
realtime: {
|
|
11
|
+
staleTime: 2e4,
|
|
12
|
+
refetchInterval: 3e4
|
|
13
|
+
},
|
|
14
|
+
frequent: { staleTime: 6e4 },
|
|
15
|
+
stable: { staleTime: 3e5 },
|
|
16
|
+
static: { staleTime: 6e5 }
|
|
17
|
+
};
|
|
18
|
+
/** Well-known keys checked in order for list responses. */
|
|
19
|
+
const LIST_KEYS = [
|
|
20
|
+
"docs",
|
|
21
|
+
"data",
|
|
22
|
+
"items",
|
|
23
|
+
"results"
|
|
24
|
+
];
|
|
25
|
+
/** Well-known keys checked in order for detail responses. */
|
|
26
|
+
const DETAIL_KEYS = [
|
|
27
|
+
"data",
|
|
28
|
+
"doc",
|
|
29
|
+
"item",
|
|
30
|
+
"result"
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Extract `_id` or `id` from any item. Returns `null` if neither exists.
|
|
34
|
+
* Coerces numeric IDs to strings so cache keys stay consistent.
|
|
35
|
+
*/
|
|
36
|
+
function getItemId(item) {
|
|
37
|
+
if (!item || typeof item !== "object") return null;
|
|
38
|
+
const obj = item;
|
|
39
|
+
const id = obj._id ?? obj.id;
|
|
40
|
+
return typeof id === "string" ? id : id ? String(id) : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Normalize any pagination response shape (offset / keyset / aggregate) to a
|
|
44
|
+
* uniform `PaginationData` object. Returns `null` when no pagination signal
|
|
45
|
+
* is present.
|
|
46
|
+
*/
|
|
47
|
+
function normalizePagination(data) {
|
|
48
|
+
if (!data || typeof data !== "object") return null;
|
|
49
|
+
const d = data;
|
|
50
|
+
const method = d.method ?? null;
|
|
51
|
+
const isKeyset = method === "keyset" || d.hasMore != null && d.total == null && d.pages == null;
|
|
52
|
+
const hasTotal = d.total != null || d.totalDocs != null;
|
|
53
|
+
const hasPages = d.pages != null || d.totalPages != null;
|
|
54
|
+
if (!hasTotal && !hasPages && !isKeyset) return null;
|
|
55
|
+
return {
|
|
56
|
+
method,
|
|
57
|
+
total: Number(d.total ?? d.totalDocs ?? 0),
|
|
58
|
+
pages: Number(d.pages ?? d.totalPages ?? (isKeyset ? 0 : 1)),
|
|
59
|
+
page: Number(d.page ?? d.currentPage ?? (isKeyset ? 0 : 1)),
|
|
60
|
+
limit: Number(d.limit ?? 10),
|
|
61
|
+
hasNext: Boolean(d.hasNext ?? d.hasNextPage ?? d.hasMore ?? false),
|
|
62
|
+
hasPrev: Boolean(d.hasPrev ?? d.hasPrevPage ?? false),
|
|
63
|
+
...isKeyset ? { next: d.next ?? null } : {}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Permissive list extractor. Checks well-known keys (`docs`, `data`, `items`,
|
|
68
|
+
* `results`) then falls back to *any* top-level array — so `{ products: [...] }`
|
|
69
|
+
* and `{ users: [...] }` work without per-resource configuration.
|
|
70
|
+
*/
|
|
71
|
+
function extractItems(data) {
|
|
72
|
+
if (!data) return [];
|
|
73
|
+
if (Array.isArray(data)) return data;
|
|
74
|
+
if (typeof data !== "object") return [];
|
|
75
|
+
const d = data;
|
|
76
|
+
for (const key of LIST_KEYS) if (Array.isArray(d[key])) return d[key];
|
|
77
|
+
for (const value of Object.values(d)) if (Array.isArray(value)) return value;
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Strict detail extractor. Checks well-known keys (`data`, `doc`, `item`,
|
|
82
|
+
* `result`); falls back to returning the response as-is. Primitive responses
|
|
83
|
+
* (string/number/boolean) pass through.
|
|
84
|
+
*/
|
|
85
|
+
function extractItem(data) {
|
|
86
|
+
if (data == null) return null;
|
|
87
|
+
if (typeof data !== "object") return data;
|
|
88
|
+
const d = data;
|
|
89
|
+
for (const key of DETAIL_KEYS) if (d[key] != null) return d[key];
|
|
90
|
+
return d;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Optimistic-update helper that mutates the items array of a list cache
|
|
94
|
+
* regardless of which key holds it. Auto-adjusts `total`/`totalDocs` when
|
|
95
|
+
* the array length changes.
|
|
96
|
+
*/
|
|
97
|
+
function updateListCache(listData, updater) {
|
|
98
|
+
if (!listData) return listData;
|
|
99
|
+
if (Array.isArray(listData)) return updater(listData);
|
|
100
|
+
if (typeof listData !== "object") return listData;
|
|
101
|
+
const d = listData;
|
|
102
|
+
let arrayField = null;
|
|
103
|
+
for (const key of LIST_KEYS) if (Array.isArray(d[key])) {
|
|
104
|
+
arrayField = key;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
if (!arrayField) {
|
|
108
|
+
for (const [key, value] of Object.entries(d)) if (Array.isArray(value)) {
|
|
109
|
+
arrayField = key;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!arrayField) return listData;
|
|
114
|
+
const updated = updater(d[arrayField]);
|
|
115
|
+
const original = d[arrayField];
|
|
116
|
+
const delta = updated.length - original.length;
|
|
117
|
+
const result = {
|
|
118
|
+
...d,
|
|
119
|
+
[arrayField]: updated
|
|
120
|
+
};
|
|
121
|
+
if (delta !== 0) {
|
|
122
|
+
if (d.total != null) result.total = Math.max(0, Number(d.total) + delta);
|
|
123
|
+
if (d.totalDocs != null) result.totalDocs = Math.max(0, Number(d.totalDocs) + delta);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Build a hierarchical query-key factory for a resource. The returned shape
|
|
129
|
+
* is identical between server (prefetch) and client (hooks), so RSC SSR
|
|
130
|
+
* hydration matches what client-side `useList`/`useDetail` produce.
|
|
131
|
+
*/
|
|
132
|
+
function createQueryKeys(entityKey) {
|
|
133
|
+
return {
|
|
134
|
+
all: [entityKey],
|
|
135
|
+
lists: () => [entityKey, "list"],
|
|
136
|
+
list: (params) => [
|
|
137
|
+
entityKey,
|
|
138
|
+
"list",
|
|
139
|
+
params
|
|
140
|
+
],
|
|
141
|
+
details: () => [entityKey, "detail"],
|
|
142
|
+
detail: (id) => [
|
|
143
|
+
entityKey,
|
|
144
|
+
"detail",
|
|
145
|
+
id
|
|
146
|
+
],
|
|
147
|
+
scopedDetail: (id, organizationId) => organizationId ? [
|
|
148
|
+
entityKey,
|
|
149
|
+
"detail",
|
|
150
|
+
id,
|
|
151
|
+
{ _org: organizationId }
|
|
152
|
+
] : [
|
|
153
|
+
entityKey,
|
|
154
|
+
"detail",
|
|
155
|
+
id
|
|
156
|
+
],
|
|
157
|
+
custom: (key, ...args) => [
|
|
158
|
+
entityKey,
|
|
159
|
+
key,
|
|
160
|
+
...args
|
|
161
|
+
],
|
|
162
|
+
scopedList: (scope, params) => [
|
|
163
|
+
entityKey,
|
|
164
|
+
"list",
|
|
165
|
+
{
|
|
166
|
+
_scope: scope,
|
|
167
|
+
...params
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Build cache read/write/invalidate helpers bound to the given key factory.
|
|
174
|
+
* Server-safe — operates on a `QueryClient` instance which can be a per-request
|
|
175
|
+
* server client (during prefetch) or the browser singleton.
|
|
176
|
+
*/
|
|
177
|
+
function createCacheUtils(KEYS) {
|
|
178
|
+
return {
|
|
179
|
+
invalidateAll: (client) => client.invalidateQueries({ queryKey: KEYS.all }),
|
|
180
|
+
invalidateLists: (client) => client.invalidateQueries({ queryKey: KEYS.lists() }),
|
|
181
|
+
invalidateDetail: (client, id) => client.invalidateQueries({ queryKey: KEYS.detail(id) }),
|
|
182
|
+
setDetail: (client, id, data) => client.setQueryData(KEYS.detail(id), { data }),
|
|
183
|
+
getDetail: (client, id) => {
|
|
184
|
+
return client.getQueryData(KEYS.detail(id))?.data;
|
|
185
|
+
},
|
|
186
|
+
removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) }),
|
|
187
|
+
invalidateScopedDetail: (client, id, organizationId) => client.invalidateQueries({ queryKey: KEYS.scopedDetail(id, organizationId) }),
|
|
188
|
+
setScopedDetail: (client, id, organizationId, data) => client.setQueryData(KEYS.scopedDetail(id, organizationId), { data }),
|
|
189
|
+
getScopedDetail: (client, id, organizationId) => {
|
|
190
|
+
return client.getQueryData(KEYS.scopedDetail(id, organizationId))?.data;
|
|
191
|
+
},
|
|
192
|
+
removeScopedDetail: (client, id, organizationId) => client.removeQueries({ queryKey: KEYS.scopedDetail(id, organizationId) })
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
//#endregion
|
|
197
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache };
|
package/dist/client.d.ts
CHANGED
|
@@ -11,6 +11,40 @@ type UseRouterHook = () => {
|
|
|
11
11
|
scroll?: boolean;
|
|
12
12
|
}) => void;
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* Top-level error codes arc's `errorHandlerPlugin` emits on the response root
|
|
16
|
+
* (`json.code`). Mirrors `statusCodeToCode()` + the special `VALIDATION_ERROR`
|
|
17
|
+
* / `DUPLICATE_KEY` / `INTERNAL_ERROR` branches.
|
|
18
|
+
*
|
|
19
|
+
* Open via intersection with `(string & {})` so unknown codes from custom
|
|
20
|
+
* `errorMappers` / `errorMap` still satisfy the type.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Top-level HTTP-status error codes arc's `errorHandlerPlugin` emits on
|
|
24
|
+
* `json.code`. Exported as a `const` array first so callers get both
|
|
25
|
+
* compile-time exhaustiveness AND runtime iteration (e.g. building a
|
|
26
|
+
* client-side i18n lookup, or whitelisting which codes get retried).
|
|
27
|
+
*/
|
|
28
|
+
declare const KNOWN_TOP_LEVEL_CODES: readonly ["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "METHOD_NOT_ALLOWED", "CONFLICT", "UNPROCESSABLE_ENTITY", "RATE_LIMITED", "INTERNAL_ERROR", "BAD_GATEWAY", "SERVICE_UNAVAILABLE", "GATEWAY_TIMEOUT", "VALIDATION_ERROR", "DUPLICATE_KEY", "DOMAIN_ERROR"];
|
|
29
|
+
/**
|
|
30
|
+
* Top-level HTTP-status error code union derived from {@link KNOWN_TOP_LEVEL_CODES}.
|
|
31
|
+
* `(string & {})` keeps the type open so non-arc backends or custom error mappers
|
|
32
|
+
* still satisfy it.
|
|
33
|
+
*/
|
|
34
|
+
type ArcTopLevelErrorCode = (typeof KNOWN_TOP_LEVEL_CODES)[number] | (string & {});
|
|
35
|
+
/**
|
|
36
|
+
* Nested business-logic codes arc's mixins / preset routes / org guards emit
|
|
37
|
+
* on `json.details.code`. These are distinct from the HTTP-status code above —
|
|
38
|
+
* `error.code` (top-level) vs `error.detailsCode` (nested). When a route
|
|
39
|
+
* returns 403 with `details.code: 'ORG_CONTEXT_REQUIRED'`, hosts read
|
|
40
|
+
* `detailsCode` to disambiguate "missing org context" from "permission denied".
|
|
41
|
+
*/
|
|
42
|
+
declare const KNOWN_DETAILS_CODES: readonly ["ORG_CONTEXT_REQUIRED", "ORG_ROLE_REQUIRED", "OWNERSHIP_DENIED", "MIXED_UPDATE_SHAPE", "ALL_FIELDS_STRIPPED", "BEFORE_RESTORE_HOOK_ERROR"];
|
|
43
|
+
/**
|
|
44
|
+
* Nested business-logic error code union derived from {@link KNOWN_DETAILS_CODES}.
|
|
45
|
+
* Same `(string & {})` escape hatch as {@link ArcTopLevelErrorCode}.
|
|
46
|
+
*/
|
|
47
|
+
type ArcDetailsErrorCode = (typeof KNOWN_DETAILS_CODES)[number] | (string & {});
|
|
14
48
|
interface ArcApiErrorOptions {
|
|
15
49
|
status: number;
|
|
16
50
|
statusText: string;
|
|
@@ -39,13 +73,109 @@ declare class ArcApiError extends Error {
|
|
|
39
73
|
readonly endpoint: string;
|
|
40
74
|
readonly method: HttpMethod;
|
|
41
75
|
constructor(message: string, options: ArcApiErrorOptions);
|
|
42
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Top-level error code from arc's response envelope (`json.code`).
|
|
78
|
+
*
|
|
79
|
+
* One of {@link ArcTopLevelErrorCode} — covers HTTP-status codes (`FORBIDDEN`,
|
|
80
|
+
* `CONFLICT`...) plus the dedicated `VALIDATION_ERROR` / `DUPLICATE_KEY`
|
|
81
|
+
* branches arc emits. Returns `null` if the response carries no envelope.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* if (error.code === 'DUPLICATE_KEY') showRetryAsAdmin();
|
|
85
|
+
*/
|
|
86
|
+
get code(): ArcTopLevelErrorCode | null;
|
|
87
|
+
/**
|
|
88
|
+
* Nested business-logic code from arc's `json.details.code` slot.
|
|
89
|
+
*
|
|
90
|
+
* Arc's preset mixins (bulk, softDelete) and org guards emit specific reason
|
|
91
|
+
* codes here — distinct from the HTTP-status `code` above. The most common
|
|
92
|
+
* is `ORG_CONTEXT_REQUIRED` (bulk operations + orgGuard reject when the
|
|
93
|
+
* caller's `request.scope.organizationId` is missing).
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* if (error.detailsCode === 'ORG_CONTEXT_REQUIRED') {
|
|
97
|
+
* alert('Configure auth before bulk operations: configureAuth({ getOrgId })');
|
|
98
|
+
* }
|
|
99
|
+
*/
|
|
100
|
+
get detailsCode(): ArcDetailsErrorCode | null;
|
|
101
|
+
/**
|
|
102
|
+
* Extract field-level validation errors as `{ field: message }` map.
|
|
103
|
+
*
|
|
104
|
+
* Reads three response shapes used by Arc:
|
|
105
|
+
* 1. `{ errors: { email: 'invalid' } }` — record form (legacy / app-level handlers).
|
|
106
|
+
* 2. `{ details: { errors: [{ field, message }] } }` — Fastify AJV + Arc errorHandler emit this.
|
|
107
|
+
* 3. `{ details: { errors: [{ instancePath, message, params }] } }` — raw AJV passthrough.
|
|
108
|
+
*
|
|
109
|
+
* Returns null when none of the shapes match.
|
|
110
|
+
*/
|
|
43
111
|
get fieldErrors(): Record<string, string> | null;
|
|
44
112
|
}
|
|
45
113
|
/**
|
|
46
114
|
* Type guard for ArcApiError.
|
|
47
115
|
*/
|
|
48
116
|
declare function isArcApiError(error: unknown): error is ArcApiError;
|
|
117
|
+
/**
|
|
118
|
+
* Detects request cancellation (AbortSignal triggered) regardless of runtime.
|
|
119
|
+
*
|
|
120
|
+
* Filtering out abort errors from real failures is a common need — without it,
|
|
121
|
+
* unmounting a React component mid-fetch produces noisy logs and bogus error
|
|
122
|
+
* toasts that look like API failures. Use this predicate to skip the catch
|
|
123
|
+
* branch when the request was deliberately cancelled.
|
|
124
|
+
*
|
|
125
|
+
* Handles the three AbortError shapes you'll see in the wild:
|
|
126
|
+
* 1. Browser `DOMException { name: 'AbortError' }`
|
|
127
|
+
* 2. Node 18+ / undici `Error { name: 'AbortError' }` (no DOMException)
|
|
128
|
+
* 3. Some polyfills / older runtimes where the error has `code: 'ERR_ABORTED'`
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* try {
|
|
132
|
+
* await api.getAll({ options: { signal } });
|
|
133
|
+
* } catch (err) {
|
|
134
|
+
* if (isAbortError(err)) return; // user navigated away — silence
|
|
135
|
+
* showToast('Failed to load: ' + err.message);
|
|
136
|
+
* }
|
|
137
|
+
*/
|
|
138
|
+
declare function isAbortError(error: unknown): boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Generic check: is this an `ArcApiError` carrying a specific top-level OR
|
|
141
|
+
* nested `details.code`? Matches whichever slot the code lives in — saves
|
|
142
|
+
* call sites from having to know whether arc emitted it at the root or under
|
|
143
|
+
* `details`.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* if (isArcErrorCode(error, 'DUPLICATE_KEY')) showRetryUI();
|
|
147
|
+
* if (isArcErrorCode(error, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
|
|
148
|
+
*/
|
|
149
|
+
declare function isArcErrorCode(error: unknown, code: ArcTopLevelErrorCode | ArcDetailsErrorCode): error is ArcApiError;
|
|
150
|
+
/**
|
|
151
|
+
* Specific predicate for arc's bulk-preset + orgGuard safety code.
|
|
152
|
+
*
|
|
153
|
+
* Arc's bulk endpoints (`POST/PATCH/DELETE /:resource/bulk`) reject any call
|
|
154
|
+
* where `request.scope.organizationId` is missing — the wire signal is
|
|
155
|
+
* `403 { details: { code: 'ORG_CONTEXT_REQUIRED' } }`. Hosts hitting this
|
|
156
|
+
* need to call `configureAuth({ getOrgId })` before retrying.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* try { await api.bulkCreate({ data: [...] }); }
|
|
160
|
+
* catch (e) {
|
|
161
|
+
* if (isOrgContextRequiredError(e)) {
|
|
162
|
+
* console.warn('Bulk requires org context. Configure: configureAuth({ getOrgId })');
|
|
163
|
+
* } else throw e;
|
|
164
|
+
* }
|
|
165
|
+
*/
|
|
166
|
+
declare function isOrgContextRequiredError(error: unknown): error is ArcApiError;
|
|
167
|
+
/**
|
|
168
|
+
* Specific predicate for arc's `VALIDATION_ERROR` (Fastify AJV + Mongoose
|
|
169
|
+
* ValidationError). When true, `error.fieldErrors` is populated with the
|
|
170
|
+
* `{ field: message }` map.
|
|
171
|
+
*/
|
|
172
|
+
declare function isValidationError(error: unknown): error is ArcApiError;
|
|
173
|
+
/**
|
|
174
|
+
* Specific predicate for arc's `DUPLICATE_KEY` (unique-constraint violation).
|
|
175
|
+
* Arc's errorHandler classifies these uniformly across MongoDB E11000,
|
|
176
|
+
* Postgres 23505, and Prisma P2002.
|
|
177
|
+
*/
|
|
178
|
+
declare function isDuplicateKeyError(error: unknown): error is ArcApiError;
|
|
49
179
|
interface ClientConfig {
|
|
50
180
|
baseUrl: string;
|
|
51
181
|
internalApiKey?: string;
|
|
@@ -79,7 +209,101 @@ interface ClientConfig {
|
|
|
79
209
|
* Default: false — opt-in per-request via `idempotencyKey` option.
|
|
80
210
|
*/
|
|
81
211
|
autoIdempotency?: boolean;
|
|
212
|
+
/**
|
|
213
|
+
* Send `x-arc-scope: platform` on every request. Triggers arc's elevated-scope
|
|
214
|
+
* upgrade (member → elevated) when the caller has the appropriate permission.
|
|
215
|
+
* Use sparingly — typically only for internal admin tooling.
|
|
216
|
+
* Per-request override: set `elevated: true | false` on `RequestOptions`.
|
|
217
|
+
*/
|
|
218
|
+
elevated?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* Network-failure + 5xx retry policy. Off by default — TanStack Query already
|
|
221
|
+
* retries query reads (3× by default), but the SDK's mutation flow and any
|
|
222
|
+
* direct `handleApiRequest` call get nothing without this.
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* configureClient({
|
|
226
|
+
* baseUrl,
|
|
227
|
+
* retry: { attempts: 3, backoff: 'exponential' },
|
|
228
|
+
* });
|
|
229
|
+
*/
|
|
230
|
+
retry?: RetryConfig;
|
|
231
|
+
/**
|
|
232
|
+
* Mutate the outgoing request before fetch. Runs once per attempt (so a retry
|
|
233
|
+
* re-runs the interceptor — useful for refreshed tokens or rotated trace IDs).
|
|
234
|
+
* Return the (possibly modified) context. Async is supported.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* configureClient({
|
|
238
|
+
* baseUrl,
|
|
239
|
+
* beforeRequest: (req) => ({
|
|
240
|
+
* ...req,
|
|
241
|
+
* headers: { ...req.headers, 'x-correlation-id': crypto.randomUUID() },
|
|
242
|
+
* }),
|
|
243
|
+
* });
|
|
244
|
+
*/
|
|
245
|
+
beforeRequest?: BeforeRequestInterceptor;
|
|
246
|
+
/**
|
|
247
|
+
* Inspect or transform the parsed response body. Receives the same shape the
|
|
248
|
+
* SDK is about to return; returning a new `body` replaces it. Errors are not
|
|
249
|
+
* forwarded here — they throw `ArcApiError` before this runs. Async is supported.
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* configureClient({
|
|
253
|
+
* baseUrl,
|
|
254
|
+
* afterResponse: (res) => {
|
|
255
|
+
* console.log(`[arc] ${res.method} ${res.endpoint} ${res.status} ${res.durationMs}ms`);
|
|
256
|
+
* return res; // unchanged
|
|
257
|
+
* },
|
|
258
|
+
* });
|
|
259
|
+
*/
|
|
260
|
+
afterResponse?: AfterResponseInterceptor;
|
|
261
|
+
}
|
|
262
|
+
interface RetryConfig {
|
|
263
|
+
/** Total attempts including the first. `attempts: 3` means 1 try + 2 retries. Default: 0 (off). */
|
|
264
|
+
attempts?: number;
|
|
265
|
+
/**
|
|
266
|
+
* Backoff strategy.
|
|
267
|
+
* - `'exponential'` (default): `min(300 * 2^n, 10_000)` ms
|
|
268
|
+
* - `'linear'`: `300 * (n + 1)` ms
|
|
269
|
+
* - `(attempt) => number`: custom delay in ms; receives the 0-indexed retry attempt
|
|
270
|
+
*/
|
|
271
|
+
backoff?: 'exponential' | 'linear' | ((attempt: number) => number);
|
|
272
|
+
/**
|
|
273
|
+
* Whitelist of statuses to retry on, OR a predicate. Defaults to a function
|
|
274
|
+
* that returns true for: any non-`ArcApiError` (network failure / fetch
|
|
275
|
+
* TypeError) AND `ArcApiError.status >= 500 && < 600`. Never retries on
|
|
276
|
+
* AbortError or 4xx.
|
|
277
|
+
*/
|
|
278
|
+
retryOn?: number[] | ((error: unknown) => boolean);
|
|
82
279
|
}
|
|
280
|
+
/** Context handed to {@link ClientConfig.beforeRequest}. Mutate + return to override. */
|
|
281
|
+
interface BeforeRequestContext {
|
|
282
|
+
method: HttpMethod;
|
|
283
|
+
endpoint: string;
|
|
284
|
+
/** Headers about to be sent. Mutating this is the recommended way to inject auth/trace headers. */
|
|
285
|
+
headers: Record<string, string>;
|
|
286
|
+
/** Body as the SDK is about to send it (already JSON-stringified, FormData, or undefined). */
|
|
287
|
+
body: BodyInit | undefined;
|
|
288
|
+
/** AbortSignal forwarded to fetch, if any. */
|
|
289
|
+
signal?: AbortSignal;
|
|
290
|
+
/** 0 on first attempt, 1+ on retries — useful for trace stamping. */
|
|
291
|
+
attempt: number;
|
|
292
|
+
}
|
|
293
|
+
type BeforeRequestInterceptor = (ctx: BeforeRequestContext) => BeforeRequestContext | Promise<BeforeRequestContext>;
|
|
294
|
+
/** Context handed to {@link ClientConfig.afterResponse}. */
|
|
295
|
+
interface AfterResponseContext<T = unknown> {
|
|
296
|
+
method: HttpMethod;
|
|
297
|
+
endpoint: string;
|
|
298
|
+
status: number;
|
|
299
|
+
/** Parsed response body — JSON object, blob wrapper, or text wrapper depending on Content-Type. */
|
|
300
|
+
body: T;
|
|
301
|
+
/** Total elapsed milliseconds from `beforeRequest` invocation to response parse complete. */
|
|
302
|
+
durationMs: number;
|
|
303
|
+
/** Original Response — clone before reading body if you need raw access. */
|
|
304
|
+
response: Response;
|
|
305
|
+
}
|
|
306
|
+
type AfterResponseInterceptor = <T = unknown>(ctx: AfterResponseContext<T>) => AfterResponseContext<T> | Promise<AfterResponseContext<T>>;
|
|
83
307
|
/**
|
|
84
308
|
* Configure the API client. Call once at app init before any API requests.
|
|
85
309
|
*
|
|
@@ -99,9 +323,21 @@ declare function configureClient(config: ClientConfig): void;
|
|
|
99
323
|
* Get the configured auth mode. Returns 'bearer' if not configured.
|
|
100
324
|
*/
|
|
101
325
|
declare function getAuthMode(): 'bearer' | 'cookie' | 'header';
|
|
326
|
+
/** Get the configured base URL. Returns empty string if not configured. */
|
|
327
|
+
declare function getBaseUrl(): string;
|
|
102
328
|
/** Whether auto-idempotency is enabled on the global client. */
|
|
103
329
|
declare function isAutoIdempotency(): boolean;
|
|
104
330
|
interface AuthConfig {
|
|
331
|
+
/**
|
|
332
|
+
* Returns the current bearer/API token, or `null` if not authenticated.
|
|
333
|
+
*
|
|
334
|
+
* **MUST resolve synchronously.** The signature is `() => string | null`, never
|
|
335
|
+
* `Promise<string | null>`. If your auth library exposes an async session getter
|
|
336
|
+
* (Better Auth, NextAuth, Clerk, OAuth flows), refresh the token out-of-band
|
|
337
|
+
* (timer, event listener, lazy 401 retry) and have `getToken` return the *cached*
|
|
338
|
+
* value. Returning a Promise will be detected and warned about in dev — but the
|
|
339
|
+
* underlying token will be silently treated as `null`, causing 401s.
|
|
340
|
+
*/
|
|
105
341
|
getToken?: () => string | null;
|
|
106
342
|
getOrgId?: () => string | null;
|
|
107
343
|
/** Custom auth header name. Used when authMode is 'header'. Default: 'x-api-key' */
|
|
@@ -113,11 +349,15 @@ interface AuthConfig {
|
|
|
113
349
|
*
|
|
114
350
|
* **SSR safety:** This sets module-level state. Call only in client-side code.
|
|
115
351
|
*
|
|
352
|
+
* **Token resolution is synchronous.** `getToken` must return `string | null`
|
|
353
|
+
* synchronously — never a Promise. See {@link AuthConfig.getToken} for guidance
|
|
354
|
+
* on bridging async auth libraries via cached values.
|
|
355
|
+
*
|
|
116
356
|
* @example
|
|
117
357
|
* // Cookie auth (no token needed)
|
|
118
358
|
* configureAuth({ getOrgId: () => currentOrg.id });
|
|
119
359
|
*
|
|
120
|
-
* // Bearer auth
|
|
360
|
+
* // Bearer auth — token is cached synchronously by the auth library
|
|
121
361
|
* configureAuth({
|
|
122
362
|
* getToken: () => session?.accessToken ?? null,
|
|
123
363
|
* getOrgId: () => currentOrg?.id ?? null,
|
|
@@ -131,7 +371,34 @@ declare function getAuthContext(): {
|
|
|
131
371
|
token: string | null;
|
|
132
372
|
organizationId: string | null;
|
|
133
373
|
};
|
|
374
|
+
/** @internal — exposed for tests; resets the dev-warn dedup flag. */
|
|
375
|
+
declare function _resetAuthWarnings(): void;
|
|
376
|
+
/** Protocol family the URL should target. `http` keeps `getBaseUrl()` as-is; `ws` rewrites `http(s)://` → `ws(s)://`. */
|
|
377
|
+
type StreamUrlProtocol = 'http' | 'ws';
|
|
378
|
+
/**
|
|
379
|
+
* Build an auth-aware URL using the global client + auth singletons.
|
|
380
|
+
*
|
|
381
|
+
* Single source of truth for {@link import('./sse.js').buildSseUrl} (HTTP) and
|
|
382
|
+
* {@link import('./ws.js').buildWsUrl} (WebSocket). Both delegate here so the
|
|
383
|
+
* auth-injection rule (org always, token only when `authMode !== 'cookie'`)
|
|
384
|
+
* stays in one place and can't drift.
|
|
385
|
+
*
|
|
386
|
+
* @param path Path appended to the base URL (leading slash recommended).
|
|
387
|
+
* @param params Caller-supplied params; merged with auth params. Caller wins on key collision.
|
|
388
|
+
* @param protocol `'http'` (default) or `'ws'` — controls the protocol rewrite.
|
|
389
|
+
*/
|
|
390
|
+
declare function buildStreamUrl(path: string, params?: Record<string, string | number | boolean | null | undefined>, protocol?: StreamUrlProtocol): string;
|
|
134
391
|
type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
392
|
+
/** Returned by `handleApiRequest` for PDF, image, and CSV responses. */
|
|
393
|
+
interface BlobResponse {
|
|
394
|
+
data: Blob;
|
|
395
|
+
response: Response;
|
|
396
|
+
}
|
|
397
|
+
/** Returned by `handleApiRequest` for text/plain and text/html responses. */
|
|
398
|
+
interface TextResponse {
|
|
399
|
+
data: string;
|
|
400
|
+
response: Response;
|
|
401
|
+
}
|
|
135
402
|
interface ApiRequestOptions {
|
|
136
403
|
body?: unknown;
|
|
137
404
|
token?: string | null;
|
|
@@ -143,31 +410,96 @@ interface ApiRequestOptions {
|
|
|
143
410
|
signal?: AbortSignal;
|
|
144
411
|
/** Explicit idempotency key for this request. Sent as `Idempotency-Key` header. */
|
|
145
412
|
idempotencyKey?: string;
|
|
413
|
+
/**
|
|
414
|
+
* Send `x-arc-scope: platform` for this request only — overrides `ClientConfig.elevated`.
|
|
415
|
+
* Pass `false` to suppress when client-level elevation is on.
|
|
416
|
+
*/
|
|
417
|
+
elevated?: boolean;
|
|
146
418
|
}
|
|
147
419
|
interface ArcClientConfig extends ClientConfig {
|
|
148
420
|
toast?: ToastHandler;
|
|
149
421
|
navigation?: UseRouterHook;
|
|
422
|
+
/** Per-client token provider. Overrides global configureAuth().getToken. */
|
|
423
|
+
getToken?: () => string | null;
|
|
424
|
+
/** Per-client org ID provider. Overrides global configureAuth().getOrgId. */
|
|
425
|
+
getOrgId?: () => string | null;
|
|
426
|
+
/** Per-client custom auth header name. Used when authMode is 'header'. */
|
|
427
|
+
headerName?: string;
|
|
150
428
|
}
|
|
151
429
|
interface ArcClient {
|
|
152
430
|
request: <T = unknown>(method: HttpMethod, endpoint: string, options?: ApiRequestOptions) => Promise<T>;
|
|
153
431
|
config: ClientConfig;
|
|
154
432
|
toast?: ToastHandler;
|
|
155
433
|
navigation?: UseRouterHook;
|
|
434
|
+
/** Per-client auth context. Falls back to global configureAuth() when not set. */
|
|
435
|
+
auth?: {
|
|
436
|
+
getToken?: () => string | null;
|
|
437
|
+
getOrgId?: () => string | null;
|
|
438
|
+
headerName?: string;
|
|
439
|
+
};
|
|
156
440
|
}
|
|
157
441
|
/**
|
|
158
442
|
* Create an isolated API client for a specific backend.
|
|
159
|
-
* Use this when your app needs to talk to multiple APIs.
|
|
443
|
+
* Use this when your app needs to talk to multiple APIs with different auth.
|
|
160
444
|
*
|
|
161
445
|
* @example
|
|
446
|
+
* // Bearer auth for main API
|
|
447
|
+
* const mainClient = createClient({
|
|
448
|
+
* baseUrl: 'https://api.example.com',
|
|
449
|
+
* getToken: () => session.accessToken,
|
|
450
|
+
* });
|
|
451
|
+
*
|
|
452
|
+
* // API key auth for analytics
|
|
162
453
|
* const analyticsClient = createClient({
|
|
163
454
|
* baseUrl: 'https://analytics.example.com',
|
|
164
|
-
*
|
|
165
|
-
*
|
|
455
|
+
* authMode: 'header',
|
|
456
|
+
* getToken: () => env.ANALYTICS_KEY,
|
|
457
|
+
* headerName: 'x-api-key',
|
|
166
458
|
* });
|
|
167
|
-
*
|
|
168
|
-
* const eventsApi = createCrudApi('events', { client: analyticsClient });
|
|
169
459
|
*/
|
|
170
460
|
declare function createClient(config: ArcClientConfig): ArcClient;
|
|
461
|
+
/**
|
|
462
|
+
* Create an `ArcClient` wired to the global `configureClient` + `configureAuth` setup.
|
|
463
|
+
*
|
|
464
|
+
* Removes the boilerplate every consumer SDK writes by hand (auth-injection adapter
|
|
465
|
+
* + `BaseApi` constructor with `client: { request: handleApiRequest, config: ... }`).
|
|
466
|
+
* The returned client reads `getToken` / `getOrgId` lazily on each request, so token
|
|
467
|
+
* rotation in the global auth layer takes effect immediately.
|
|
468
|
+
*
|
|
469
|
+
* Equivalent to `createClient(...)` with `getToken`/`getOrgId` pulled from
|
|
470
|
+
* `getAuthContext()`. Pass `overrides` to customize specific fields without
|
|
471
|
+
* rebuilding the whole transport.
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* // App init (somewhere in a "use client" provider)
|
|
475
|
+
* configureClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL! });
|
|
476
|
+
* configureAuth({
|
|
477
|
+
* getToken: () => session?.accessToken ?? null,
|
|
478
|
+
* getOrgId: () => currentOrg?.id ?? null,
|
|
479
|
+
* });
|
|
480
|
+
*
|
|
481
|
+
* // SDK module
|
|
482
|
+
* import { createAuthAwareClient } from '@classytic/arc-next/client';
|
|
483
|
+
* import { createCrudApi } from '@classytic/arc-next/api';
|
|
484
|
+
*
|
|
485
|
+
* const client = createAuthAwareClient();
|
|
486
|
+
* export const productsApi = createCrudApi('products', { client });
|
|
487
|
+
*
|
|
488
|
+
* // With per-call overrides
|
|
489
|
+
* const analyticsClient = createAuthAwareClient({
|
|
490
|
+
* baseUrl: 'https://analytics.example.com',
|
|
491
|
+
* authMode: 'header',
|
|
492
|
+
* headerName: 'x-api-key',
|
|
493
|
+
* });
|
|
494
|
+
*/
|
|
495
|
+
declare function createAuthAwareClient(overrides?: Partial<ArcClientConfig>): ArcClient;
|
|
496
|
+
/**
|
|
497
|
+
* Get auth context for a specific client instance, falling back to global.
|
|
498
|
+
*/
|
|
499
|
+
declare function getClientAuthContext(client?: ArcClient): {
|
|
500
|
+
token: string | null;
|
|
501
|
+
organizationId: string | null;
|
|
502
|
+
};
|
|
171
503
|
/**
|
|
172
504
|
* Universal API request handler.
|
|
173
505
|
* Handles JSON, binary (PDF, images), CSV, and text responses.
|
|
@@ -195,4 +527,4 @@ declare function handleApiRequest<T = unknown>(method: HttpMethod, endpoint: str
|
|
|
195
527
|
*/
|
|
196
528
|
declare function createQueryString<T extends Record<string, unknown>>(params?: T): string;
|
|
197
529
|
//#endregion
|
|
198
|
-
export { ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, AuthConfig, ClientConfig, HttpMethod, ToastHandler, UseRouterHook, configureAuth, configureClient, createClient, createQueryString, getAuthContext, getAuthMode, handleApiRequest, isArcApiError, isAutoIdempotency };
|
|
530
|
+
export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcDetailsErrorCode, ArcTopLevelErrorCode, AuthConfig, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, HttpMethod, KNOWN_DETAILS_CODES, KNOWN_TOP_LEVEL_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 };
|