@classytic/arc-next 0.4.1 → 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 +271 -661
- package/dist/api.d.ts +64 -127
- package/dist/api.js +50 -116
- package/dist/cache.d.ts +108 -0
- package/dist/cache.js +197 -0
- package/dist/client.d.ts +298 -3
- package/dist/client.js +357 -10
- package/dist/hooks.d.ts +96 -20
- package/dist/hooks.js +195 -81
- package/dist/mutation.d.ts +18 -1
- package/dist/mutation.js +20 -1
- package/dist/prefetch.d.ts +13 -2
- package/dist/prefetch.js +27 -3
- 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 -69
- package/dist/query.js +69 -161
- package/dist/sse.d.ts +115 -36
- package/dist/sse.js +179 -103
- 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/client.js
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
//#region src/client.ts
|
|
2
2
|
/**
|
|
3
|
+
* Top-level error codes arc's `errorHandlerPlugin` emits on the response root
|
|
4
|
+
* (`json.code`). Mirrors `statusCodeToCode()` + the special `VALIDATION_ERROR`
|
|
5
|
+
* / `DUPLICATE_KEY` / `INTERNAL_ERROR` branches.
|
|
6
|
+
*
|
|
7
|
+
* Open via intersection with `(string & {})` so unknown codes from custom
|
|
8
|
+
* `errorMappers` / `errorMap` still satisfy the type.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Top-level HTTP-status error codes arc's `errorHandlerPlugin` emits on
|
|
12
|
+
* `json.code`. Exported as a `const` array first so callers get both
|
|
13
|
+
* compile-time exhaustiveness AND runtime iteration (e.g. building a
|
|
14
|
+
* client-side i18n lookup, or whitelisting which codes get retried).
|
|
15
|
+
*/
|
|
16
|
+
const KNOWN_TOP_LEVEL_CODES = [
|
|
17
|
+
"BAD_REQUEST",
|
|
18
|
+
"UNAUTHORIZED",
|
|
19
|
+
"FORBIDDEN",
|
|
20
|
+
"NOT_FOUND",
|
|
21
|
+
"METHOD_NOT_ALLOWED",
|
|
22
|
+
"CONFLICT",
|
|
23
|
+
"UNPROCESSABLE_ENTITY",
|
|
24
|
+
"RATE_LIMITED",
|
|
25
|
+
"INTERNAL_ERROR",
|
|
26
|
+
"BAD_GATEWAY",
|
|
27
|
+
"SERVICE_UNAVAILABLE",
|
|
28
|
+
"GATEWAY_TIMEOUT",
|
|
29
|
+
"VALIDATION_ERROR",
|
|
30
|
+
"DUPLICATE_KEY",
|
|
31
|
+
"DOMAIN_ERROR"
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Nested business-logic codes arc's mixins / preset routes / org guards emit
|
|
35
|
+
* on `json.details.code`. These are distinct from the HTTP-status code above —
|
|
36
|
+
* `error.code` (top-level) vs `error.detailsCode` (nested). When a route
|
|
37
|
+
* returns 403 with `details.code: 'ORG_CONTEXT_REQUIRED'`, hosts read
|
|
38
|
+
* `detailsCode` to disambiguate "missing org context" from "permission denied".
|
|
39
|
+
*/
|
|
40
|
+
const KNOWN_DETAILS_CODES = [
|
|
41
|
+
"ORG_CONTEXT_REQUIRED",
|
|
42
|
+
"ORG_ROLE_REQUIRED",
|
|
43
|
+
"OWNERSHIP_DENIED",
|
|
44
|
+
"MIXED_UPDATE_SHAPE",
|
|
45
|
+
"ALL_FIELDS_STRIPPED",
|
|
46
|
+
"BEFORE_RESTORE_HOOK_ERROR"
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
3
49
|
* Rich API error with status code, response payload, and request metadata.
|
|
4
50
|
* Extends Error so existing `catch (e) { if (e instanceof Error) }` still works.
|
|
5
51
|
*
|
|
@@ -28,9 +74,63 @@ var ArcApiError = class extends Error {
|
|
|
28
74
|
this.endpoint = options.endpoint;
|
|
29
75
|
this.method = options.method;
|
|
30
76
|
}
|
|
31
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Top-level error code from arc's response envelope (`json.code`).
|
|
79
|
+
*
|
|
80
|
+
* One of {@link ArcTopLevelErrorCode} — covers HTTP-status codes (`FORBIDDEN`,
|
|
81
|
+
* `CONFLICT`...) plus the dedicated `VALIDATION_ERROR` / `DUPLICATE_KEY`
|
|
82
|
+
* branches arc emits. Returns `null` if the response carries no envelope.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* if (error.code === 'DUPLICATE_KEY') showRetryAsAdmin();
|
|
86
|
+
*/
|
|
87
|
+
get code() {
|
|
88
|
+
const j = this.json;
|
|
89
|
+
return j && typeof j.code === "string" ? j.code : null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Nested business-logic code from arc's `json.details.code` slot.
|
|
93
|
+
*
|
|
94
|
+
* Arc's preset mixins (bulk, softDelete) and org guards emit specific reason
|
|
95
|
+
* codes here — distinct from the HTTP-status `code` above. The most common
|
|
96
|
+
* is `ORG_CONTEXT_REQUIRED` (bulk operations + orgGuard reject when the
|
|
97
|
+
* caller's `request.scope.organizationId` is missing).
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* if (error.detailsCode === 'ORG_CONTEXT_REQUIRED') {
|
|
101
|
+
* alert('Configure auth before bulk operations: configureAuth({ getOrgId })');
|
|
102
|
+
* }
|
|
103
|
+
*/
|
|
104
|
+
get detailsCode() {
|
|
105
|
+
const c = this.json?.details?.code;
|
|
106
|
+
return typeof c === "string" ? c : null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Extract field-level validation errors as `{ field: message }` map.
|
|
110
|
+
*
|
|
111
|
+
* Reads three response shapes used by Arc:
|
|
112
|
+
* 1. `{ errors: { email: 'invalid' } }` — record form (legacy / app-level handlers).
|
|
113
|
+
* 2. `{ details: { errors: [{ field, message }] } }` — Fastify AJV + Arc errorHandler emit this.
|
|
114
|
+
* 3. `{ details: { errors: [{ instancePath, message, params }] } }` — raw AJV passthrough.
|
|
115
|
+
*
|
|
116
|
+
* Returns null when none of the shapes match.
|
|
117
|
+
*/
|
|
32
118
|
get fieldErrors() {
|
|
33
|
-
|
|
119
|
+
const j = this.json;
|
|
120
|
+
if (!j) return null;
|
|
121
|
+
if (j.errors && !Array.isArray(j.errors) && typeof j.errors === "object") return j.errors;
|
|
122
|
+
const errorList = Array.isArray(j.errors) ? j.errors : Array.isArray(j.details?.errors) ? j.details.errors : null;
|
|
123
|
+
if (!errorList) return null;
|
|
124
|
+
const map = {};
|
|
125
|
+
for (const item of errorList) {
|
|
126
|
+
if (!item || typeof item !== "object") continue;
|
|
127
|
+
const e = item;
|
|
128
|
+
const key = typeof e.field === "string" && e.field || typeof e.instancePath === "string" && e.instancePath.replace(/^\//, "") || typeof e.path === "string" && e.path || typeof e.params?.missingProperty === "string" && e.params.missingProperty || "";
|
|
129
|
+
if (!key) continue;
|
|
130
|
+
const msg = typeof e.message === "string" ? e.message : "Invalid value";
|
|
131
|
+
if (!(key in map)) map[key] = msg;
|
|
132
|
+
}
|
|
133
|
+
return Object.keys(map).length > 0 ? map : null;
|
|
34
134
|
}
|
|
35
135
|
};
|
|
36
136
|
/**
|
|
@@ -39,6 +139,84 @@ var ArcApiError = class extends Error {
|
|
|
39
139
|
function isArcApiError(error) {
|
|
40
140
|
return error instanceof ArcApiError;
|
|
41
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Detects request cancellation (AbortSignal triggered) regardless of runtime.
|
|
144
|
+
*
|
|
145
|
+
* Filtering out abort errors from real failures is a common need — without it,
|
|
146
|
+
* unmounting a React component mid-fetch produces noisy logs and bogus error
|
|
147
|
+
* toasts that look like API failures. Use this predicate to skip the catch
|
|
148
|
+
* branch when the request was deliberately cancelled.
|
|
149
|
+
*
|
|
150
|
+
* Handles the three AbortError shapes you'll see in the wild:
|
|
151
|
+
* 1. Browser `DOMException { name: 'AbortError' }`
|
|
152
|
+
* 2. Node 18+ / undici `Error { name: 'AbortError' }` (no DOMException)
|
|
153
|
+
* 3. Some polyfills / older runtimes where the error has `code: 'ERR_ABORTED'`
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* try {
|
|
157
|
+
* await api.getAll({ options: { signal } });
|
|
158
|
+
* } catch (err) {
|
|
159
|
+
* if (isAbortError(err)) return; // user navigated away — silence
|
|
160
|
+
* showToast('Failed to load: ' + err.message);
|
|
161
|
+
* }
|
|
162
|
+
*/
|
|
163
|
+
function isAbortError(error) {
|
|
164
|
+
if (!error) return false;
|
|
165
|
+
if (typeof error !== "object") return false;
|
|
166
|
+
const e = error;
|
|
167
|
+
if (e.name === "AbortError") return true;
|
|
168
|
+
if (e.code === "ERR_ABORTED") return true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Generic check: is this an `ArcApiError` carrying a specific top-level OR
|
|
173
|
+
* nested `details.code`? Matches whichever slot the code lives in — saves
|
|
174
|
+
* call sites from having to know whether arc emitted it at the root or under
|
|
175
|
+
* `details`.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* if (isArcErrorCode(error, 'DUPLICATE_KEY')) showRetryUI();
|
|
179
|
+
* if (isArcErrorCode(error, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
|
|
180
|
+
*/
|
|
181
|
+
function isArcErrorCode(error, code) {
|
|
182
|
+
if (!isArcApiError(error)) return false;
|
|
183
|
+
return error.code === code || error.detailsCode === code;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Specific predicate for arc's bulk-preset + orgGuard safety code.
|
|
187
|
+
*
|
|
188
|
+
* Arc's bulk endpoints (`POST/PATCH/DELETE /:resource/bulk`) reject any call
|
|
189
|
+
* where `request.scope.organizationId` is missing — the wire signal is
|
|
190
|
+
* `403 { details: { code: 'ORG_CONTEXT_REQUIRED' } }`. Hosts hitting this
|
|
191
|
+
* need to call `configureAuth({ getOrgId })` before retrying.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* try { await api.bulkCreate({ data: [...] }); }
|
|
195
|
+
* catch (e) {
|
|
196
|
+
* if (isOrgContextRequiredError(e)) {
|
|
197
|
+
* console.warn('Bulk requires org context. Configure: configureAuth({ getOrgId })');
|
|
198
|
+
* } else throw e;
|
|
199
|
+
* }
|
|
200
|
+
*/
|
|
201
|
+
function isOrgContextRequiredError(error) {
|
|
202
|
+
return isArcErrorCode(error, "ORG_CONTEXT_REQUIRED");
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Specific predicate for arc's `VALIDATION_ERROR` (Fastify AJV + Mongoose
|
|
206
|
+
* ValidationError). When true, `error.fieldErrors` is populated with the
|
|
207
|
+
* `{ field: message }` map.
|
|
208
|
+
*/
|
|
209
|
+
function isValidationError(error) {
|
|
210
|
+
return isArcErrorCode(error, "VALIDATION_ERROR");
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Specific predicate for arc's `DUPLICATE_KEY` (unique-constraint violation).
|
|
214
|
+
* Arc's errorHandler classifies these uniformly across MongoDB E11000,
|
|
215
|
+
* Postgres 23505, and Prisma P2002.
|
|
216
|
+
*/
|
|
217
|
+
function isDuplicateKeyError(error) {
|
|
218
|
+
return isArcErrorCode(error, "DUPLICATE_KEY");
|
|
219
|
+
}
|
|
42
220
|
let clientConfig = null;
|
|
43
221
|
/**
|
|
44
222
|
* Configure the API client. Call once at app init before any API requests.
|
|
@@ -73,17 +251,22 @@ function isAutoIdempotency() {
|
|
|
73
251
|
return clientConfig?.autoIdempotency ?? false;
|
|
74
252
|
}
|
|
75
253
|
let authConfig = null;
|
|
254
|
+
let hasWarnedAsyncToken = false;
|
|
76
255
|
/**
|
|
77
256
|
* Configure auth context for automatic token/orgId injection.
|
|
78
257
|
* When configured, hooks auto-inject these values so you don't need to pass them manually.
|
|
79
258
|
*
|
|
80
259
|
* **SSR safety:** This sets module-level state. Call only in client-side code.
|
|
81
260
|
*
|
|
261
|
+
* **Token resolution is synchronous.** `getToken` must return `string | null`
|
|
262
|
+
* synchronously — never a Promise. See {@link AuthConfig.getToken} for guidance
|
|
263
|
+
* on bridging async auth libraries via cached values.
|
|
264
|
+
*
|
|
82
265
|
* @example
|
|
83
266
|
* // Cookie auth (no token needed)
|
|
84
267
|
* configureAuth({ getOrgId: () => currentOrg.id });
|
|
85
268
|
*
|
|
86
|
-
* // Bearer auth
|
|
269
|
+
* // Bearer auth — token is cached synchronously by the auth library
|
|
87
270
|
* configureAuth({
|
|
88
271
|
* getToken: () => session?.accessToken ?? null,
|
|
89
272
|
* getOrgId: () => currentOrg?.id ?? null,
|
|
@@ -92,16 +275,58 @@ let authConfig = null;
|
|
|
92
275
|
function configureAuth(config) {
|
|
93
276
|
if (typeof window === "undefined") console.warn("[arc-next] configureAuth() called on the server. This sets module-level state that persists across requests. Call only in client-side code (e.g., a 'use client' provider).");
|
|
94
277
|
authConfig = config;
|
|
278
|
+
hasWarnedAsyncToken = false;
|
|
279
|
+
}
|
|
280
|
+
function readToken(getToken) {
|
|
281
|
+
if (!getToken) return null;
|
|
282
|
+
const result = getToken();
|
|
283
|
+
if (result && typeof result.then === "function") {
|
|
284
|
+
if (!hasWarnedAsyncToken) {
|
|
285
|
+
hasWarnedAsyncToken = true;
|
|
286
|
+
console.warn("[arc-next] configureAuth({ getToken }) returned a Promise. Tokens MUST resolve synchronously — async returns are dropped and requests will be unauthenticated. Cache your token (localStorage, memory, signal) and have getToken() return the cached value.");
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return result ?? null;
|
|
95
291
|
}
|
|
96
292
|
/**
|
|
97
293
|
* Get the current auth context. Returns nulls when not configured.
|
|
98
294
|
*/
|
|
99
295
|
function getAuthContext() {
|
|
100
296
|
return {
|
|
101
|
-
token: authConfig?.getToken
|
|
297
|
+
token: readToken(authConfig?.getToken),
|
|
102
298
|
organizationId: authConfig?.getOrgId?.() ?? null
|
|
103
299
|
};
|
|
104
300
|
}
|
|
301
|
+
/** @internal — exposed for tests; resets the dev-warn dedup flag. */
|
|
302
|
+
function _resetAuthWarnings() {
|
|
303
|
+
hasWarnedAsyncToken = false;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Build an auth-aware URL using the global client + auth singletons.
|
|
307
|
+
*
|
|
308
|
+
* Single source of truth for {@link import('./sse.js').buildSseUrl} (HTTP) and
|
|
309
|
+
* {@link import('./ws.js').buildWsUrl} (WebSocket). Both delegate here so the
|
|
310
|
+
* auth-injection rule (org always, token only when `authMode !== 'cookie'`)
|
|
311
|
+
* stays in one place and can't drift.
|
|
312
|
+
*
|
|
313
|
+
* @param path Path appended to the base URL (leading slash recommended).
|
|
314
|
+
* @param params Caller-supplied params; merged with auth params. Caller wins on key collision.
|
|
315
|
+
* @param protocol `'http'` (default) or `'ws'` — controls the protocol rewrite.
|
|
316
|
+
*/
|
|
317
|
+
function buildStreamUrl(path, params = {}, protocol = "http") {
|
|
318
|
+
const auth = getAuthContext();
|
|
319
|
+
const qs = new URLSearchParams();
|
|
320
|
+
for (const [key, value] of Object.entries(params)) {
|
|
321
|
+
if (value === void 0 || value === null || value === "") continue;
|
|
322
|
+
qs.set(key, String(value));
|
|
323
|
+
}
|
|
324
|
+
if (auth.organizationId && !qs.has("organizationId")) qs.set("organizationId", auth.organizationId);
|
|
325
|
+
if (auth.token && getAuthMode() !== "cookie" && !qs.has("token")) qs.set("token", auth.token);
|
|
326
|
+
const origin = protocol === "ws" ? getBaseUrl().replace(/^http(s?):\/\//, "ws$1://") : getBaseUrl();
|
|
327
|
+
const suffix = qs.toString();
|
|
328
|
+
return `${origin}${path}${suffix ? `?${suffix}` : ""}`;
|
|
329
|
+
}
|
|
105
330
|
/**
|
|
106
331
|
* Create an isolated API client for a specific backend.
|
|
107
332
|
* Use this when your app needs to talk to multiple APIs with different auth.
|
|
@@ -132,7 +357,7 @@ function createClient(config) {
|
|
|
132
357
|
request: (method, endpoint, options) => {
|
|
133
358
|
if (clientAuth) {
|
|
134
359
|
const resolved = { ...options };
|
|
135
|
-
if (resolved.token === void 0 && clientAuth.getToken) resolved.token = clientAuth.getToken
|
|
360
|
+
if (resolved.token === void 0 && clientAuth.getToken) resolved.token = readToken(clientAuth.getToken);
|
|
136
361
|
if (resolved.organizationId === void 0 && clientAuth.getOrgId) resolved.organizationId = clientAuth.getOrgId();
|
|
137
362
|
if (clientCfg.authMode === "header" && resolved.token) {
|
|
138
363
|
resolved.headerOptions = {
|
|
@@ -152,17 +377,115 @@ function createClient(config) {
|
|
|
152
377
|
};
|
|
153
378
|
}
|
|
154
379
|
/**
|
|
380
|
+
* Create an `ArcClient` wired to the global `configureClient` + `configureAuth` setup.
|
|
381
|
+
*
|
|
382
|
+
* Removes the boilerplate every consumer SDK writes by hand (auth-injection adapter
|
|
383
|
+
* + `BaseApi` constructor with `client: { request: handleApiRequest, config: ... }`).
|
|
384
|
+
* The returned client reads `getToken` / `getOrgId` lazily on each request, so token
|
|
385
|
+
* rotation in the global auth layer takes effect immediately.
|
|
386
|
+
*
|
|
387
|
+
* Equivalent to `createClient(...)` with `getToken`/`getOrgId` pulled from
|
|
388
|
+
* `getAuthContext()`. Pass `overrides` to customize specific fields without
|
|
389
|
+
* rebuilding the whole transport.
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* // App init (somewhere in a "use client" provider)
|
|
393
|
+
* configureClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL! });
|
|
394
|
+
* configureAuth({
|
|
395
|
+
* getToken: () => session?.accessToken ?? null,
|
|
396
|
+
* getOrgId: () => currentOrg?.id ?? null,
|
|
397
|
+
* });
|
|
398
|
+
*
|
|
399
|
+
* // SDK module
|
|
400
|
+
* import { createAuthAwareClient } from '@classytic/arc-next/client';
|
|
401
|
+
* import { createCrudApi } from '@classytic/arc-next/api';
|
|
402
|
+
*
|
|
403
|
+
* const client = createAuthAwareClient();
|
|
404
|
+
* export const productsApi = createCrudApi('products', { client });
|
|
405
|
+
*
|
|
406
|
+
* // With per-call overrides
|
|
407
|
+
* const analyticsClient = createAuthAwareClient({
|
|
408
|
+
* baseUrl: 'https://analytics.example.com',
|
|
409
|
+
* authMode: 'header',
|
|
410
|
+
* headerName: 'x-api-key',
|
|
411
|
+
* });
|
|
412
|
+
*/
|
|
413
|
+
function createAuthAwareClient(overrides = {}) {
|
|
414
|
+
return createClient({
|
|
415
|
+
baseUrl: overrides.baseUrl ?? getBaseUrl(),
|
|
416
|
+
authMode: overrides.authMode ?? getAuthMode(),
|
|
417
|
+
autoIdempotency: overrides.autoIdempotency ?? isAutoIdempotency(),
|
|
418
|
+
elevated: overrides.elevated ?? clientConfig?.elevated,
|
|
419
|
+
...overrides,
|
|
420
|
+
getToken: overrides.getToken ?? (() => readToken(authConfig?.getToken)),
|
|
421
|
+
getOrgId: overrides.getOrgId ?? (() => authConfig?.getOrgId?.() ?? null),
|
|
422
|
+
headerName: overrides.headerName ?? authConfig?.headerName
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
155
426
|
* Get auth context for a specific client instance, falling back to global.
|
|
156
427
|
*/
|
|
157
428
|
function getClientAuthContext(client) {
|
|
158
429
|
if (client?.auth) return {
|
|
159
|
-
token: client.auth.getToken
|
|
430
|
+
token: readToken(client.auth.getToken) ?? readToken(authConfig?.getToken),
|
|
160
431
|
organizationId: client.auth.getOrgId?.() ?? authConfig?.getOrgId?.() ?? null
|
|
161
432
|
};
|
|
162
433
|
return getAuthContext();
|
|
163
434
|
}
|
|
435
|
+
/** Default retry predicate: retry on network failures and 5xx, never on Abort or 4xx. */
|
|
436
|
+
function defaultShouldRetry(error) {
|
|
437
|
+
if (isAbortError(error)) return false;
|
|
438
|
+
if (isArcApiError(error)) return error.status >= 500 && error.status < 600;
|
|
439
|
+
return error instanceof Error;
|
|
440
|
+
}
|
|
441
|
+
/** Compute the delay in ms before the Nth retry. */
|
|
442
|
+
function computeBackoff(retry, attempt) {
|
|
443
|
+
const strategy = retry.backoff ?? "exponential";
|
|
444
|
+
if (typeof strategy === "function") return Math.max(0, strategy(attempt));
|
|
445
|
+
if (strategy === "linear") return 300 * (attempt + 1);
|
|
446
|
+
return Math.min(300 * Math.pow(2, attempt), 1e4);
|
|
447
|
+
}
|
|
164
448
|
async function executeRequest(config, method, endpoint, options = {}) {
|
|
165
|
-
const
|
|
449
|
+
const totalAttempts = Math.max(1, config.retry?.attempts ?? 1);
|
|
450
|
+
const shouldRetry = (() => {
|
|
451
|
+
const r = config.retry?.retryOn;
|
|
452
|
+
if (typeof r === "function") return r;
|
|
453
|
+
if (Array.isArray(r)) return (e) => isArcApiError(e) && r.includes(e.status);
|
|
454
|
+
return defaultShouldRetry;
|
|
455
|
+
})();
|
|
456
|
+
let lastError;
|
|
457
|
+
for (let attempt = 0; attempt < totalAttempts; attempt++) try {
|
|
458
|
+
return await executeAttempt(config, method, endpoint, options, attempt);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
lastError = error;
|
|
461
|
+
if (attempt === totalAttempts - 1 || !shouldRetry(error)) throw error;
|
|
462
|
+
const delay = computeBackoff(config.retry ?? {}, attempt);
|
|
463
|
+
if (delay > 0) await sleepAbortable(delay, options.signal);
|
|
464
|
+
}
|
|
465
|
+
throw lastError;
|
|
466
|
+
}
|
|
467
|
+
/** Sleep that resolves early if the signal aborts. */
|
|
468
|
+
function sleepAbortable(ms, signal) {
|
|
469
|
+
return new Promise((resolve, reject) => {
|
|
470
|
+
if (signal?.aborted) {
|
|
471
|
+
reject(Object.assign(/* @__PURE__ */ new Error("Aborted"), { name: "AbortError" }));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const timer = setTimeout(() => {
|
|
475
|
+
signal?.removeEventListener("abort", onAbort);
|
|
476
|
+
resolve();
|
|
477
|
+
}, ms);
|
|
478
|
+
const onAbort = () => {
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
reject(Object.assign(/* @__PURE__ */ new Error("Aborted"), { name: "AbortError" }));
|
|
481
|
+
};
|
|
482
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/** A single fetch attempt — used by executeRequest's retry loop. */
|
|
486
|
+
async function executeAttempt(config, method, endpoint, options, attempt) {
|
|
487
|
+
const { body, token, organizationId, revalidate, headerOptions, tags, cache, signal, idempotencyKey, elevated } = options;
|
|
488
|
+
const startTime = Date.now();
|
|
166
489
|
try {
|
|
167
490
|
let headers = {
|
|
168
491
|
...organizationId ? { "x-organization-id": organizationId } : {},
|
|
@@ -175,19 +498,34 @@ async function executeRequest(config, method, endpoint, options = {}) {
|
|
|
175
498
|
} else headers["Authorization"] = `Bearer ${token}`;
|
|
176
499
|
if (config.apiVersion) headers["Accept-Version"] = config.apiVersion;
|
|
177
500
|
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
501
|
+
if (elevated ?? config.elevated ?? false) headers["x-arc-scope"] = "platform";
|
|
178
502
|
if (body !== void 0 && body !== null && !(body instanceof FormData)) headers["Content-Type"] = "application/json";
|
|
179
503
|
if (headerOptions) headers = {
|
|
180
504
|
...headers,
|
|
181
505
|
...headerOptions
|
|
182
506
|
};
|
|
183
507
|
const credentials = config.credentials ?? (config.authMode === "cookie" ? "include" : "same-origin");
|
|
508
|
+
let serializedBody = void 0;
|
|
509
|
+
if (body !== void 0 && body !== null) serializedBody = body instanceof FormData ? body : JSON.stringify(body);
|
|
510
|
+
if (config.beforeRequest) {
|
|
511
|
+
const ctx = await config.beforeRequest({
|
|
512
|
+
method,
|
|
513
|
+
endpoint,
|
|
514
|
+
headers,
|
|
515
|
+
body: serializedBody,
|
|
516
|
+
signal,
|
|
517
|
+
attempt
|
|
518
|
+
});
|
|
519
|
+
headers = ctx.headers;
|
|
520
|
+
serializedBody = ctx.body;
|
|
521
|
+
}
|
|
184
522
|
const fetchOptions = {
|
|
185
523
|
method,
|
|
186
524
|
headers,
|
|
187
525
|
credentials,
|
|
188
526
|
...signal ? { signal } : {}
|
|
189
527
|
};
|
|
190
|
-
if (
|
|
528
|
+
if (serializedBody !== void 0) fetchOptions.body = serializedBody;
|
|
191
529
|
if (cache) fetchOptions.cache = cache;
|
|
192
530
|
if (revalidate !== void 0) fetchOptions.next = {
|
|
193
531
|
...fetchOptions.next,
|
|
@@ -203,7 +541,8 @@ async function executeRequest(config, method, endpoint, options = {}) {
|
|
|
203
541
|
let errorMessage = response.statusText;
|
|
204
542
|
try {
|
|
205
543
|
json = await response.clone().json();
|
|
206
|
-
|
|
544
|
+
const j = json;
|
|
545
|
+
errorMessage = typeof j?.error === "string" && j.error || typeof j?.message === "string" && j.message || response.statusText;
|
|
207
546
|
} catch {
|
|
208
547
|
try {
|
|
209
548
|
const text = await response.text();
|
|
@@ -251,6 +590,14 @@ async function executeRequest(config, method, endpoint, options = {}) {
|
|
|
251
590
|
throw new Error(`Failed to parse response body from ${method} ${endpoint}: blob error: ${blobError instanceof Error ? blobError.message : String(blobError)}`);
|
|
252
591
|
}
|
|
253
592
|
}
|
|
593
|
+
if (config.afterResponse) data = (await config.afterResponse({
|
|
594
|
+
method,
|
|
595
|
+
endpoint,
|
|
596
|
+
status: response.status,
|
|
597
|
+
body: data,
|
|
598
|
+
durationMs: Date.now() - startTime,
|
|
599
|
+
response
|
|
600
|
+
})).body;
|
|
254
601
|
return data;
|
|
255
602
|
} catch (error) {
|
|
256
603
|
if (error instanceof Error) throw error;
|
|
@@ -314,4 +661,4 @@ function createQueryString(params = {}) {
|
|
|
314
661
|
}
|
|
315
662
|
|
|
316
663
|
//#endregion
|
|
317
|
-
export { ArcApiError, configureAuth, configureClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isArcApiError, isAutoIdempotency };
|
|
664
|
+
export { ArcApiError, KNOWN_DETAILS_CODES, KNOWN_TOP_LEVEL_CODES, _resetAuthWarnings, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
import { ArcClient, UseRouterHook } from "./client.js";
|
|
2
|
-
import {
|
|
2
|
+
import { ApiResponse, BaseApi } from "./api.js";
|
|
3
|
+
import { CacheUtils, QueryKeys } from "./cache.js";
|
|
3
4
|
import { MutationCallbacks, MutationMessages, TransitionMutationReturn } from "./mutation.js";
|
|
4
|
-
import {
|
|
5
|
+
import { DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult } from "./query.js";
|
|
6
|
+
import { SoftDeleteMethods } from "./presets/soft-delete.js";
|
|
7
|
+
import { BulkMethods } from "./presets/bulk.js";
|
|
8
|
+
import { SlugLookupMethods } from "./presets/slug.js";
|
|
9
|
+
import { TreeMethods } from "./presets/tree.js";
|
|
10
|
+
import { SearchPresetMethods } from "./presets/search.js";
|
|
5
11
|
import { QueryKey } from "@tanstack/react-query";
|
|
6
12
|
|
|
7
13
|
//#region src/hooks.d.ts
|
|
8
14
|
/**
|
|
9
15
|
* CRUD API interface accepted by createCrudHooks.
|
|
10
16
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* Always-on surface (CRUD + universal helpers) is derived from BaseApi via Pick
|
|
18
|
+
* so types stay in sync. Preset surfaces are optional intersections of the
|
|
19
|
+
* corresponding `XxxMethods` interfaces, so a `withSearchPreset(api)` result
|
|
20
|
+
* satisfies CrudApi & gets `searchEngine`/`searchSimilar`/`embed` typed without
|
|
21
|
+
* any cast — yet a vanilla `createCrudApi('todos')` instance has none of them
|
|
22
|
+
* in autocomplete unless you opt in.
|
|
14
23
|
*/
|
|
15
24
|
type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete'> & {
|
|
16
25
|
upload?: BaseApi<T, TCreate, TUpdate>['upload'];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
bulkCreate?: BaseApi<T, TCreate, TUpdate>['bulkCreate'];
|
|
21
|
-
bulkUpdate?: BaseApi<T, TCreate, TUpdate>['bulkUpdate'];
|
|
22
|
-
bulkDelete?: BaseApi<T, TCreate, TUpdate>['bulkDelete'];
|
|
23
|
-
getBySlug?: BaseApi<T, TCreate, TUpdate>['getBySlug'];
|
|
24
|
-
getTree?: BaseApi<T, TCreate, TUpdate>['getTree'];
|
|
25
|
-
getChildren?: BaseApi<T, TCreate, TUpdate>['getChildren'];
|
|
26
|
-
findBy?: BaseApi<T, TCreate, TUpdate>['findBy'];
|
|
27
|
-
};
|
|
26
|
+
dispatchAction?: BaseApi<T, TCreate, TUpdate>['dispatchAction'];
|
|
27
|
+
invokeRoute?: BaseApi<T, TCreate, TUpdate>['invokeRoute'];
|
|
28
|
+
} & Partial<SoftDeleteMethods<T>> & Partial<BulkMethods<T, TCreate, TUpdate>> & Partial<SlugLookupMethods<T>> & Partial<TreeMethods<T>> & Partial<SearchPresetMethods<T>>;
|
|
28
29
|
interface CrudHooksConfig<T, TCreate = Partial<T>, TUpdate = Partial<T>> {
|
|
29
30
|
api: CrudApi<T, TCreate, TUpdate>;
|
|
30
31
|
entityKey: string;
|
|
@@ -147,9 +148,63 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
|
|
|
147
148
|
useDetailBySlug: (slug: string | null, options?: DetailQueryOptions<T>) => DetailQueryResult<T>;
|
|
148
149
|
useTree: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
|
|
149
150
|
useChildren: (parentId: string | null, params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Mutation against arc's unified action router (`POST /:id/action`).
|
|
153
|
+
* Server discriminates on `body.action`. Use for state transitions
|
|
154
|
+
* (approve/cancel/dispatch) instead of bespoke routes.
|
|
155
|
+
*/
|
|
156
|
+
useAction: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
|
|
157
|
+
invalidateQueries?: QueryKey[]; /** Default action name. Can be overridden per-call via `mutate({ action })`. */
|
|
158
|
+
action?: string;
|
|
159
|
+
messages?: MutationMessages;
|
|
160
|
+
onSuccess?: (data: ApiResponse<TResult>, variables: {
|
|
161
|
+
id: string;
|
|
162
|
+
action: string;
|
|
163
|
+
data?: TBody;
|
|
164
|
+
}) => void;
|
|
165
|
+
onError?: (error: Error, variables: {
|
|
166
|
+
id: string;
|
|
167
|
+
action: string;
|
|
168
|
+
data?: TBody;
|
|
169
|
+
}) => void;
|
|
170
|
+
onSettled?: (data: ApiResponse<TResult> | undefined, error: Error | null, variables: {
|
|
171
|
+
id: string;
|
|
172
|
+
action: string;
|
|
173
|
+
data?: TBody;
|
|
174
|
+
}) => void;
|
|
175
|
+
}) => TransitionMutationReturn<ApiResponse<TResult>, {
|
|
176
|
+
id: string;
|
|
177
|
+
action?: string;
|
|
178
|
+
data?: TBody;
|
|
179
|
+
}>;
|
|
180
|
+
/** Mutation against the search-preset POST `/search` route. */
|
|
181
|
+
useSearchEngine: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
|
|
182
|
+
path?: string;
|
|
183
|
+
messages?: MutationMessages;
|
|
184
|
+
invalidateQueries?: QueryKey[];
|
|
185
|
+
}) => TransitionMutationReturn<unknown, {
|
|
186
|
+
query?: string;
|
|
187
|
+
body?: TBody;
|
|
188
|
+
}>;
|
|
189
|
+
/** Mutation against the search-preset POST `/search-similar` route. */
|
|
190
|
+
useSearchSimilar: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
|
|
191
|
+
path?: string;
|
|
192
|
+
messages?: MutationMessages;
|
|
193
|
+
invalidateQueries?: QueryKey[];
|
|
194
|
+
}) => TransitionMutationReturn<unknown, {
|
|
195
|
+
query?: string;
|
|
196
|
+
vector?: number[];
|
|
197
|
+
body?: TBody;
|
|
198
|
+
}>;
|
|
199
|
+
/** Mutation against the search-preset POST `/embed` route. */
|
|
200
|
+
useEmbed: (options?: {
|
|
201
|
+
path?: string;
|
|
202
|
+
messages?: MutationMessages;
|
|
203
|
+
invalidateQueries?: QueryKey[];
|
|
204
|
+
}) => TransitionMutationReturn<unknown, {
|
|
205
|
+
input: string | string[];
|
|
206
|
+
body?: Record<string, unknown>;
|
|
207
|
+
}>;
|
|
153
208
|
useUpload: (options?: {
|
|
154
209
|
invalidateQueries?: QueryKey[];
|
|
155
210
|
messages?: MutationMessages;
|
|
@@ -161,7 +216,6 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
|
|
|
161
216
|
id?: string;
|
|
162
217
|
path?: string;
|
|
163
218
|
}>;
|
|
164
|
-
useSearch: (query: string, params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
|
|
165
219
|
useCustomMutation: <TData = unknown, TVariables = unknown>(config: {
|
|
166
220
|
mutationFn: (variables: TVariables) => Promise<TData>;
|
|
167
221
|
invalidateQueries?: QueryKey[];
|
|
@@ -170,6 +224,28 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
|
|
|
170
224
|
onError?: (error: Error, variables: TVariables) => void;
|
|
171
225
|
onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
|
|
172
226
|
}) => TransitionMutationReturn<TData, TVariables>;
|
|
227
|
+
/**
|
|
228
|
+
* Subscribe to live CRUD broadcasts and auto-invalidate this resource's
|
|
229
|
+
* cache. Drop in alongside `createCrudHooks` and every `useList` /
|
|
230
|
+
* `useDetail` rerenders when the backend emits `<resource>.<op>`.
|
|
231
|
+
*
|
|
232
|
+
* `source: 'ws'` uses arc's `websocketPlugin` (`/ws`); `source: 'sse'` uses
|
|
233
|
+
* `ssePlugin` (`/events/stream`). Pass `enabled: false` to opt out.
|
|
234
|
+
*/
|
|
235
|
+
useResourceSync: (options?: {
|
|
236
|
+
source?: 'ws' | 'sse'; /** Override resource name. Defaults to the factory's `entityKey`. */
|
|
237
|
+
resource?: string; /** Override path (default: `/ws` or `/events/stream`). */
|
|
238
|
+
path?: string; /** Whether the connection is active. Default: true. */
|
|
239
|
+
enabled?: boolean; /** Per-event hook fired AFTER cache invalidation. */
|
|
240
|
+
onEvent?: (event: {
|
|
241
|
+
operation: 'created' | 'updated' | 'deleted';
|
|
242
|
+
id?: string;
|
|
243
|
+
data: unknown;
|
|
244
|
+
}) => void; /** Connection-state listener. */
|
|
245
|
+
onConnectionChange?: (connected: boolean) => void;
|
|
246
|
+
}) => {
|
|
247
|
+
isConnected: boolean;
|
|
248
|
+
};
|
|
173
249
|
useNavigation: () => NavigateFn<T>;
|
|
174
250
|
}
|
|
175
251
|
/**
|