@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/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
- /** Extract field-level validation errors if present (e.g. `{ field: "message" }`) */
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
- return this.json?.errors ?? null;
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?.() ?? null,
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?.() ?? authConfig?.getToken?.() ?? null,
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 { body, token, organizationId, revalidate, headerOptions, tags, cache, signal, idempotencyKey } = options;
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 (body !== void 0 && body !== null) fetchOptions.body = body instanceof FormData ? body : JSON.stringify(body);
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
- errorMessage = json?.message || response.statusText;
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 { BaseApi, FilterOperator } from "./api.js";
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 { CacheUtils, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, QueryKeys } from "./query.js";
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
- * Derived from BaseApi via Pick so the types are always in sync.
12
- * BaseApi instances satisfy this exactly (same source of truth).
13
- * Custom implementations just need to match BaseApi's method signatures.
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
- search?: BaseApi<T, TCreate, TUpdate>['search'];
18
- getDeleted?: BaseApi<T, TCreate, TUpdate>['getDeleted'];
19
- restore?: BaseApi<T, TCreate, TUpdate>['restore'];
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
- useFindBy: (field: string, value: unknown, options?: ListQueryOptions<T> & {
151
- operator?: FilterOperator;
152
- }) => ListQueryResult<T>;
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
  /**