@classytic/arc-next 0.4.1 → 0.6.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/dist/client.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { ErrorDetail } from "@classytic/repo-core/errors";
2
+
1
3
  //#region src/client.d.ts
2
4
  interface ToastHandler {
3
5
  success: (message: string) => void;
@@ -11,6 +13,34 @@ type UseRouterHook = () => {
11
13
  scroll?: boolean;
12
14
  }) => void;
13
15
  };
16
+ /**
17
+ * Canonical error codes arc and repo-core emit on `json.code`. Single
18
+ * top-level slot — arc 2.13's `createError` lifts business codes from
19
+ * `details` to top-level so `repo-core`'s `toErrorContract` round-trips
20
+ * them on the wire. There is no separate `detailsCode` slot; everything
21
+ * lives at `error.code`.
22
+ *
23
+ * Three families compose this list:
24
+ * 1. **repo-core canonical** (`validation_error`, `not_found`, ...) — RFC 7807
25
+ * / Stripe-shaped lowercase + snake_case. Cross-package universals.
26
+ * 2. **arc hierarchical** (`arc.forbidden`, `arc.validation_error`,
27
+ * `arc.org.access_denied`, ...) — what arc's `errorHandlerPlugin`
28
+ * emits for HTTP-status throws + arc-classified errors.
29
+ * 3. **arc business** (`ORG_CONTEXT_REQUIRED`, `ALL_FIELDS_STRIPPED`,
30
+ * `OWNERSHIP_DENIED`, ...) — emitted by mixins / org guards via
31
+ * `createError(status, msg, { code })`. The UPPER_SNAKE form is
32
+ * intentional: these are reason codes, not HTTP-status codes.
33
+ *
34
+ * `(string & {})` keeps the type open so domain packages and custom
35
+ * `errorMappers` codes still satisfy it.
36
+ */
37
+ declare const KNOWN_ARC_ERROR_CODES: readonly ["validation_error", "not_found", "conflict", "unauthorized", "forbidden", "rate_limited", "idempotency_conflict", "precondition_failed", "internal_error", "service_unavailable", "timeout", "arc.bad_request", "arc.unauthorized", "arc.forbidden", "arc.not_found", "arc.conflict", "arc.unprocessable_entity", "arc.rate_limited", "arc.internal_error", "arc.bad_gateway", "arc.service_unavailable", "arc.gateway_timeout", "arc.validation_error", "arc.invalid_id", "arc.org.selection_required", "arc.org.access_denied", "ORG_CONTEXT_REQUIRED", "ORG_ROLE_REQUIRED", "OWNERSHIP_DENIED", "MIXED_UPDATE_SHAPE", "ALL_FIELDS_STRIPPED", "BEFORE_RESTORE_HOOK_ERROR", "duplicate_key"];
38
+ /**
39
+ * Canonical arc error code union. `(string & {})` keeps the type open so
40
+ * domain packages can extend hierarchically (`'order.cart.locked'`,
41
+ * `'payment.gateway.timeout'`) and still satisfy the type.
42
+ */
43
+ type ArcErrorCode = (typeof KNOWN_ARC_ERROR_CODES)[number] | (string & {});
14
44
  interface ArcApiErrorOptions {
15
45
  status: number;
16
46
  statusText: string;
@@ -39,13 +69,108 @@ declare class ArcApiError extends Error {
39
69
  readonly endpoint: string;
40
70
  readonly method: HttpMethod;
41
71
  constructor(message: string, options: ArcApiErrorOptions);
42
- /** Extract field-level validation errors if present (e.g. `{ field: "message" }`) */
72
+ /**
73
+ * Canonical error code from arc's wire envelope (`json.code`).
74
+ *
75
+ * Arc 2.13 + `repo-core` 0.4 emit one canonical {@link ErrorContract}
76
+ * shape — `{ code, message, status, details? }` — with the business
77
+ * code at top-level. Hosts switch on `error.code` directly:
78
+ *
79
+ * @example
80
+ * if (error.code === 'ORG_CONTEXT_REQUIRED') promptOrgSelector();
81
+ * if (error.code === 'arc.not_found') router.replace('/404');
82
+ * if (error.code === 'duplicate_key') showRetryAsAdmin();
83
+ */
84
+ get code(): ArcErrorCode | null;
85
+ /**
86
+ * Canonical structured details — populated for validation failures
87
+ * (one entry per offending field) and duplicate-key conflicts (one entry
88
+ * per offending field). Shape matches `repo-core`'s {@link ErrorDetail}:
89
+ * `{ path?, code, message, meta? }`. Returns `null` for non-arc backends
90
+ * or responses without details.
91
+ */
92
+ get details(): readonly ErrorDetail[] | null;
93
+ /**
94
+ * Extract field-level validation errors as `{ field: message }` map.
95
+ *
96
+ * Reads the canonical `ErrorContract.details: ErrorDetail[]` shape first
97
+ * (what arc 2.13 + repo-core emit), then falls back to legacy shapes for
98
+ * non-arc backends:
99
+ * 1. `details: [{ path, code, message }]` — canonical (arc / repo-core).
100
+ * 2. `errors: { email: 'invalid' }` — record form (legacy app handlers).
101
+ * 3. `details: { errors: [{ field|instancePath, message }] }` — pre-2.13 AJV.
102
+ * 4. `errors: [...]` at the top level — third-party frameworks.
103
+ */
43
104
  get fieldErrors(): Record<string, string> | null;
44
105
  }
45
106
  /**
46
107
  * Type guard for ArcApiError.
47
108
  */
48
109
  declare function isArcApiError(error: unknown): error is ArcApiError;
110
+ /**
111
+ * Detects request cancellation (AbortSignal triggered) regardless of runtime.
112
+ *
113
+ * Filtering out abort errors from real failures is a common need — without it,
114
+ * unmounting a React component mid-fetch produces noisy logs and bogus error
115
+ * toasts that look like API failures. Use this predicate to skip the catch
116
+ * branch when the request was deliberately cancelled.
117
+ *
118
+ * Handles the three AbortError shapes you'll see in the wild:
119
+ * 1. Browser `DOMException { name: 'AbortError' }`
120
+ * 2. Node 18+ / undici `Error { name: 'AbortError' }` (no DOMException)
121
+ * 3. Some polyfills / older runtimes where the error has `code: 'ERR_ABORTED'`
122
+ *
123
+ * @example
124
+ * try {
125
+ * await api.getAll({ options: { signal } });
126
+ * } catch (err) {
127
+ * if (isAbortError(err)) return; // user navigated away — silence
128
+ * showToast('Failed to load: ' + err.message);
129
+ * }
130
+ */
131
+ declare function isAbortError(error: unknown): boolean;
132
+ /**
133
+ * Generic check: is this an `ArcApiError` carrying a specific `code`?
134
+ * Single-slot — arc 2.13 + repo-core 0.4 emit one canonical `code` at
135
+ * top-level. Pass either the canonical lowercase form (`'arc.not_found'`,
136
+ * `'validation_error'`) or arc's UPPER_SNAKE business form
137
+ * (`'ORG_CONTEXT_REQUIRED'`).
138
+ *
139
+ * @example
140
+ * if (isArcErrorCode(error, 'duplicate_key')) showRetryUI();
141
+ * if (isArcErrorCode(error, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
142
+ */
143
+ declare function isArcErrorCode(error: unknown, code: ArcErrorCode): error is ArcApiError;
144
+ /**
145
+ * Specific predicate for arc's bulk-preset + orgGuard safety code.
146
+ *
147
+ * Arc's bulk endpoints (`POST/PATCH/DELETE /:resource/bulk`) reject any call
148
+ * where `request.scope.organizationId` is missing — the wire signal is
149
+ * `403 { code: 'ORG_CONTEXT_REQUIRED', message, status: 403 }`. Hosts hitting
150
+ * this need to call `configureAuth({ getOrgId })` before retrying.
151
+ *
152
+ * @example
153
+ * try { await api.bulkCreate({ data: [...] }); }
154
+ * catch (e) {
155
+ * if (isOrgContextRequiredError(e)) {
156
+ * console.warn('Bulk requires org context. Configure: configureAuth({ getOrgId })');
157
+ * } else throw e;
158
+ * }
159
+ */
160
+ declare function isOrgContextRequiredError(error: unknown): error is ArcApiError;
161
+ /**
162
+ * Specific predicate for validation failures (Fastify AJV + Mongoose
163
+ * ValidationError). When true, `error.fieldErrors` is populated with the
164
+ * `{ field: message }` map. Matches arc's `arc.validation_error` and the
165
+ * canonical `validation_error` from repo-core.
166
+ */
167
+ declare function isValidationError(error: unknown): error is ArcApiError;
168
+ /**
169
+ * Specific predicate for unique-constraint violations. Arc's errorHandler
170
+ * classifies these uniformly across MongoDB E11000, Postgres 23505,
171
+ * Prisma P2002 → `arc.conflict` (with `details[].code === 'duplicate_key'`).
172
+ */
173
+ declare function isDuplicateKeyError(error: unknown): error is ArcApiError;
49
174
  interface ClientConfig {
50
175
  baseUrl: string;
51
176
  internalApiKey?: string;
@@ -79,7 +204,101 @@ interface ClientConfig {
79
204
  * Default: false — opt-in per-request via `idempotencyKey` option.
80
205
  */
81
206
  autoIdempotency?: boolean;
207
+ /**
208
+ * Send `x-arc-scope: platform` on every request. Triggers arc's elevated-scope
209
+ * upgrade (member → elevated) when the caller has the appropriate permission.
210
+ * Use sparingly — typically only for internal admin tooling.
211
+ * Per-request override: set `elevated: true | false` on `RequestOptions`.
212
+ */
213
+ elevated?: boolean;
214
+ /**
215
+ * Network-failure + 5xx retry policy. Off by default — TanStack Query already
216
+ * retries query reads (3× by default), but the SDK's mutation flow and any
217
+ * direct `handleApiRequest` call get nothing without this.
218
+ *
219
+ * @example
220
+ * configureClient({
221
+ * baseUrl,
222
+ * retry: { attempts: 3, backoff: 'exponential' },
223
+ * });
224
+ */
225
+ retry?: RetryConfig;
226
+ /**
227
+ * Mutate the outgoing request before fetch. Runs once per attempt (so a retry
228
+ * re-runs the interceptor — useful for refreshed tokens or rotated trace IDs).
229
+ * Return the (possibly modified) context. Async is supported.
230
+ *
231
+ * @example
232
+ * configureClient({
233
+ * baseUrl,
234
+ * beforeRequest: (req) => ({
235
+ * ...req,
236
+ * headers: { ...req.headers, 'x-correlation-id': crypto.randomUUID() },
237
+ * }),
238
+ * });
239
+ */
240
+ beforeRequest?: BeforeRequestInterceptor;
241
+ /**
242
+ * Inspect or transform the parsed response body. Receives the same shape the
243
+ * SDK is about to return; returning a new `body` replaces it. Errors are not
244
+ * forwarded here — they throw `ArcApiError` before this runs. Async is supported.
245
+ *
246
+ * @example
247
+ * configureClient({
248
+ * baseUrl,
249
+ * afterResponse: (res) => {
250
+ * console.log(`[arc] ${res.method} ${res.endpoint} ${res.status} ${res.durationMs}ms`);
251
+ * return res; // unchanged
252
+ * },
253
+ * });
254
+ */
255
+ afterResponse?: AfterResponseInterceptor;
256
+ }
257
+ interface RetryConfig {
258
+ /** Total attempts including the first. `attempts: 3` means 1 try + 2 retries. Default: 0 (off). */
259
+ attempts?: number;
260
+ /**
261
+ * Backoff strategy.
262
+ * - `'exponential'` (default): `min(300 * 2^n, 10_000)` ms
263
+ * - `'linear'`: `300 * (n + 1)` ms
264
+ * - `(attempt) => number`: custom delay in ms; receives the 0-indexed retry attempt
265
+ */
266
+ backoff?: 'exponential' | 'linear' | ((attempt: number) => number);
267
+ /**
268
+ * Whitelist of statuses to retry on, OR a predicate. Defaults to a function
269
+ * that returns true for: any non-`ArcApiError` (network failure / fetch
270
+ * TypeError) AND `ArcApiError.status >= 500 && < 600`. Never retries on
271
+ * AbortError or 4xx.
272
+ */
273
+ retryOn?: number[] | ((error: unknown) => boolean);
274
+ }
275
+ /** Context handed to {@link ClientConfig.beforeRequest}. Mutate + return to override. */
276
+ interface BeforeRequestContext {
277
+ method: HttpMethod;
278
+ endpoint: string;
279
+ /** Headers about to be sent. Mutating this is the recommended way to inject auth/trace headers. */
280
+ headers: Record<string, string>;
281
+ /** Body as the SDK is about to send it (already JSON-stringified, FormData, or undefined). */
282
+ body: BodyInit | undefined;
283
+ /** AbortSignal forwarded to fetch, if any. */
284
+ signal?: AbortSignal;
285
+ /** 0 on first attempt, 1+ on retries — useful for trace stamping. */
286
+ attempt: number;
82
287
  }
288
+ type BeforeRequestInterceptor = (ctx: BeforeRequestContext) => BeforeRequestContext | Promise<BeforeRequestContext>;
289
+ /** Context handed to {@link ClientConfig.afterResponse}. */
290
+ interface AfterResponseContext<T = unknown> {
291
+ method: HttpMethod;
292
+ endpoint: string;
293
+ status: number;
294
+ /** Parsed response body — JSON object, blob wrapper, or text wrapper depending on Content-Type. */
295
+ body: T;
296
+ /** Total elapsed milliseconds from `beforeRequest` invocation to response parse complete. */
297
+ durationMs: number;
298
+ /** Original Response — clone before reading body if you need raw access. */
299
+ response: Response;
300
+ }
301
+ type AfterResponseInterceptor = <T = unknown>(ctx: AfterResponseContext<T>) => AfterResponseContext<T> | Promise<AfterResponseContext<T>>;
83
302
  /**
84
303
  * Configure the API client. Call once at app init before any API requests.
85
304
  *
@@ -104,6 +323,16 @@ declare function getBaseUrl(): string;
104
323
  /** Whether auto-idempotency is enabled on the global client. */
105
324
  declare function isAutoIdempotency(): boolean;
106
325
  interface AuthConfig {
326
+ /**
327
+ * Returns the current bearer/API token, or `null` if not authenticated.
328
+ *
329
+ * **MUST resolve synchronously.** The signature is `() => string | null`, never
330
+ * `Promise<string | null>`. If your auth library exposes an async session getter
331
+ * (Better Auth, NextAuth, Clerk, OAuth flows), refresh the token out-of-band
332
+ * (timer, event listener, lazy 401 retry) and have `getToken` return the *cached*
333
+ * value. Returning a Promise will be detected and warned about in dev — but the
334
+ * underlying token will be silently treated as `null`, causing 401s.
335
+ */
107
336
  getToken?: () => string | null;
108
337
  getOrgId?: () => string | null;
109
338
  /** Custom auth header name. Used when authMode is 'header'. Default: 'x-api-key' */
@@ -115,11 +344,15 @@ interface AuthConfig {
115
344
  *
116
345
  * **SSR safety:** This sets module-level state. Call only in client-side code.
117
346
  *
347
+ * **Token resolution is synchronous.** `getToken` must return `string | null`
348
+ * synchronously — never a Promise. See {@link AuthConfig.getToken} for guidance
349
+ * on bridging async auth libraries via cached values.
350
+ *
118
351
  * @example
119
352
  * // Cookie auth (no token needed)
120
353
  * configureAuth({ getOrgId: () => currentOrg.id });
121
354
  *
122
- * // Bearer auth
355
+ * // Bearer auth — token is cached synchronously by the auth library
123
356
  * configureAuth({
124
357
  * getToken: () => session?.accessToken ?? null,
125
358
  * getOrgId: () => currentOrg?.id ?? null,
@@ -133,6 +366,23 @@ declare function getAuthContext(): {
133
366
  token: string | null;
134
367
  organizationId: string | null;
135
368
  };
369
+ /** @internal — exposed for tests; resets the dev-warn dedup flag. */
370
+ declare function _resetAuthWarnings(): void;
371
+ /** Protocol family the URL should target. `http` keeps `getBaseUrl()` as-is; `ws` rewrites `http(s)://` → `ws(s)://`. */
372
+ type StreamUrlProtocol = 'http' | 'ws';
373
+ /**
374
+ * Build an auth-aware URL using the global client + auth singletons.
375
+ *
376
+ * Single source of truth for {@link import('./sse.js').buildSseUrl} (HTTP) and
377
+ * {@link import('./ws.js').buildWsUrl} (WebSocket). Both delegate here so the
378
+ * auth-injection rule (org always, token only when `authMode !== 'cookie'`)
379
+ * stays in one place and can't drift.
380
+ *
381
+ * @param path Path appended to the base URL (leading slash recommended).
382
+ * @param params Caller-supplied params; merged with auth params. Caller wins on key collision.
383
+ * @param protocol `'http'` (default) or `'ws'` — controls the protocol rewrite.
384
+ */
385
+ declare function buildStreamUrl(path: string, params?: Record<string, string | number | boolean | null | undefined>, protocol?: StreamUrlProtocol): string;
136
386
  type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
137
387
  /** Returned by `handleApiRequest` for PDF, image, and CSV responses. */
138
388
  interface BlobResponse {
@@ -155,6 +405,11 @@ interface ApiRequestOptions {
155
405
  signal?: AbortSignal;
156
406
  /** Explicit idempotency key for this request. Sent as `Idempotency-Key` header. */
157
407
  idempotencyKey?: string;
408
+ /**
409
+ * Send `x-arc-scope: platform` for this request only — overrides `ClientConfig.elevated`.
410
+ * Pass `false` to suppress when client-level elevation is on.
411
+ */
412
+ elevated?: boolean;
158
413
  }
159
414
  interface ArcClientConfig extends ClientConfig {
160
415
  toast?: ToastHandler;
@@ -198,6 +453,41 @@ interface ArcClient {
198
453
  * });
199
454
  */
200
455
  declare function createClient(config: ArcClientConfig): ArcClient;
456
+ /**
457
+ * Create an `ArcClient` wired to the global `configureClient` + `configureAuth` setup.
458
+ *
459
+ * Removes the boilerplate every consumer SDK writes by hand (auth-injection adapter
460
+ * + `BaseApi` constructor with `client: { request: handleApiRequest, config: ... }`).
461
+ * The returned client reads `getToken` / `getOrgId` lazily on each request, so token
462
+ * rotation in the global auth layer takes effect immediately.
463
+ *
464
+ * Equivalent to `createClient(...)` with `getToken`/`getOrgId` pulled from
465
+ * `getAuthContext()`. Pass `overrides` to customize specific fields without
466
+ * rebuilding the whole transport.
467
+ *
468
+ * @example
469
+ * // App init (somewhere in a "use client" provider)
470
+ * configureClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL! });
471
+ * configureAuth({
472
+ * getToken: () => session?.accessToken ?? null,
473
+ * getOrgId: () => currentOrg?.id ?? null,
474
+ * });
475
+ *
476
+ * // SDK module
477
+ * import { createAuthAwareClient } from '@classytic/arc-next/client';
478
+ * import { createCrudApi } from '@classytic/arc-next/api';
479
+ *
480
+ * const client = createAuthAwareClient();
481
+ * export const productsApi = createCrudApi('products', { client });
482
+ *
483
+ * // With per-call overrides
484
+ * const analyticsClient = createAuthAwareClient({
485
+ * baseUrl: 'https://analytics.example.com',
486
+ * authMode: 'header',
487
+ * headerName: 'x-api-key',
488
+ * });
489
+ */
490
+ declare function createAuthAwareClient(overrides?: Partial<ArcClientConfig>): ArcClient;
201
491
  /**
202
492
  * Get auth context for a specific client instance, falling back to global.
203
493
  */
@@ -210,8 +500,8 @@ declare function getClientAuthContext(client?: ArcClient): {
210
500
  * Handles JSON, binary (PDF, images), CSV, and text responses.
211
501
  *
212
502
  * @example
213
- * const { success, data } = await handleApiRequest<ApiResponse<User>>('GET', '/users/me');
214
- * const response = await handleApiRequest<PaginatedResponse<Product>>('GET', '/products?page=1');
503
+ * const user = await handleApiRequest<User>('GET', '/users/me');
504
+ * const response = await handleApiRequest<PaginatedResult<Product>>('GET', '/products?page=1');
215
505
  */
216
506
  declare function handleApiRequest<T = unknown>(method: HttpMethod, endpoint: string, options?: ApiRequestOptions): Promise<T>;
217
507
  /**
@@ -232,4 +522,4 @@ declare function handleApiRequest<T = unknown>(method: HttpMethod, endpoint: str
232
522
  */
233
523
  declare function createQueryString<T extends Record<string, unknown>>(params?: T): string;
234
524
  //#endregion
235
- export { ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, AuthConfig, BlobResponse, ClientConfig, HttpMethod, TextResponse, ToastHandler, UseRouterHook, configureAuth, configureClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isArcApiError, isAutoIdempotency };
525
+ export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, AuthConfig, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _resetAuthWarnings, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };