@classytic/arc-next 0.3.1 → 0.4.1

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
@@ -55,7 +55,7 @@ interface ClientConfig {
55
55
  * - 'bearer' (default): Requires a token for authenticated requests. Queries are disabled until a token is provided.
56
56
  * - 'cookie': Auth is handled via HTTP-only cookies (e.g. Better Auth). Queries are always enabled — no token needed.
57
57
  */
58
- authMode?: 'bearer' | 'cookie';
58
+ authMode?: 'bearer' | 'cookie' | 'header';
59
59
  /**
60
60
  * Fetch credentials policy.
61
61
  * - 'include': Always send cookies cross-origin (required for cookie-based auth).
@@ -67,6 +67,18 @@ interface ClientConfig {
67
67
  * - `authMode: 'bearer'` (default) → `'same-origin'`
68
68
  */
69
69
  credentials?: RequestCredentials;
70
+ /**
71
+ * API version sent as `Accept-Version` header.
72
+ * Use when the Arc backend has versioning enabled.
73
+ * @example '2' // sends Accept-Version: 2
74
+ */
75
+ apiVersion?: string;
76
+ /**
77
+ * Auto-generate `Idempotency-Key` header for POST/PUT/PATCH requests.
78
+ * Prevents duplicate mutations on network retries.
79
+ * Default: false — opt-in per-request via `idempotencyKey` option.
80
+ */
81
+ autoIdempotency?: boolean;
70
82
  }
71
83
  /**
72
84
  * Configure the API client. Call once at app init before any API requests.
@@ -86,10 +98,16 @@ declare function configureClient(config: ClientConfig): void;
86
98
  /**
87
99
  * Get the configured auth mode. Returns 'bearer' if not configured.
88
100
  */
89
- declare function getAuthMode(): 'bearer' | 'cookie';
101
+ declare function getAuthMode(): 'bearer' | 'cookie' | 'header';
102
+ /** Get the configured base URL. Returns empty string if not configured. */
103
+ declare function getBaseUrl(): string;
104
+ /** Whether auto-idempotency is enabled on the global client. */
105
+ declare function isAutoIdempotency(): boolean;
90
106
  interface AuthConfig {
91
107
  getToken?: () => string | null;
92
108
  getOrgId?: () => string | null;
109
+ /** Custom auth header name. Used when authMode is 'header'. Default: 'x-api-key' */
110
+ headerName?: string;
93
111
  }
94
112
  /**
95
113
  * Configure auth context for automatic token/orgId injection.
@@ -116,6 +134,16 @@ declare function getAuthContext(): {
116
134
  organizationId: string | null;
117
135
  };
118
136
  type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
137
+ /** Returned by `handleApiRequest` for PDF, image, and CSV responses. */
138
+ interface BlobResponse {
139
+ data: Blob;
140
+ response: Response;
141
+ }
142
+ /** Returned by `handleApiRequest` for text/plain and text/html responses. */
143
+ interface TextResponse {
144
+ data: string;
145
+ response: Response;
146
+ }
119
147
  interface ApiRequestOptions {
120
148
  body?: unknown;
121
149
  token?: string | null;
@@ -125,39 +153,58 @@ interface ApiRequestOptions {
125
153
  tags?: string[];
126
154
  cache?: RequestCache;
127
155
  signal?: AbortSignal;
128
- }
129
- interface BlobResponse {
130
- data: Blob;
131
- response: Response;
132
- }
133
- interface TextResponse {
134
- data: string;
135
- response: Response;
156
+ /** Explicit idempotency key for this request. Sent as `Idempotency-Key` header. */
157
+ idempotencyKey?: string;
136
158
  }
137
159
  interface ArcClientConfig extends ClientConfig {
138
160
  toast?: ToastHandler;
139
161
  navigation?: UseRouterHook;
162
+ /** Per-client token provider. Overrides global configureAuth().getToken. */
163
+ getToken?: () => string | null;
164
+ /** Per-client org ID provider. Overrides global configureAuth().getOrgId. */
165
+ getOrgId?: () => string | null;
166
+ /** Per-client custom auth header name. Used when authMode is 'header'. */
167
+ headerName?: string;
140
168
  }
141
169
  interface ArcClient {
142
170
  request: <T = unknown>(method: HttpMethod, endpoint: string, options?: ApiRequestOptions) => Promise<T>;
143
171
  config: ClientConfig;
144
172
  toast?: ToastHandler;
145
173
  navigation?: UseRouterHook;
174
+ /** Per-client auth context. Falls back to global configureAuth() when not set. */
175
+ auth?: {
176
+ getToken?: () => string | null;
177
+ getOrgId?: () => string | null;
178
+ headerName?: string;
179
+ };
146
180
  }
147
181
  /**
148
182
  * Create an isolated API client for a specific backend.
149
- * Use this when your app needs to talk to multiple APIs.
183
+ * Use this when your app needs to talk to multiple APIs with different auth.
150
184
  *
151
185
  * @example
186
+ * // Bearer auth for main API
187
+ * const mainClient = createClient({
188
+ * baseUrl: 'https://api.example.com',
189
+ * getToken: () => session.accessToken,
190
+ * });
191
+ *
192
+ * // API key auth for analytics
152
193
  * const analyticsClient = createClient({
153
194
  * baseUrl: 'https://analytics.example.com',
154
- * toast: { success: toast.success, error: toast.error },
155
- * navigation: useRouter,
195
+ * authMode: 'header',
196
+ * getToken: () => env.ANALYTICS_KEY,
197
+ * headerName: 'x-api-key',
156
198
  * });
157
- *
158
- * const eventsApi = createCrudApi('events', { client: analyticsClient });
159
199
  */
160
200
  declare function createClient(config: ArcClientConfig): ArcClient;
201
+ /**
202
+ * Get auth context for a specific client instance, falling back to global.
203
+ */
204
+ declare function getClientAuthContext(client?: ArcClient): {
205
+ token: string | null;
206
+ organizationId: string | null;
207
+ };
161
208
  /**
162
209
  * Universal API request handler.
163
210
  * Handles JSON, binary (PDF, images), CSV, and text responses.
@@ -185,4 +232,4 @@ declare function handleApiRequest<T = unknown>(method: HttpMethod, endpoint: str
185
232
  */
186
233
  declare function createQueryString<T extends Record<string, unknown>>(params?: T): string;
187
234
  //#endregion
188
- export { ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, AuthConfig, BlobResponse, ClientConfig, HttpMethod, TextResponse, ToastHandler, UseRouterHook, configureAuth, configureClient, createClient, createQueryString, getAuthContext, getAuthMode, handleApiRequest, isArcApiError };
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 };
package/dist/client.js CHANGED
@@ -55,6 +55,7 @@ let clientConfig = null;
55
55
  * configureClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL!, authMode: 'cookie' });
56
56
  */
57
57
  function configureClient(config) {
58
+ if (typeof window === "undefined") console.warn("[arc-next] configureClient() 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).");
58
59
  clientConfig = config;
59
60
  }
60
61
  /**
@@ -63,6 +64,14 @@ function configureClient(config) {
63
64
  function getAuthMode() {
64
65
  return clientConfig?.authMode ?? "bearer";
65
66
  }
67
+ /** Get the configured base URL. Returns empty string if not configured. */
68
+ function getBaseUrl() {
69
+ return clientConfig?.baseUrl ?? "";
70
+ }
71
+ /** Whether auto-idempotency is enabled on the global client. */
72
+ function isAutoIdempotency() {
73
+ return clientConfig?.autoIdempotency ?? false;
74
+ }
66
75
  let authConfig = null;
67
76
  /**
68
77
  * Configure auth context for automatic token/orgId injection.
@@ -81,6 +90,7 @@ let authConfig = null;
81
90
  * });
82
91
  */
83
92
  function configureAuth(config) {
93
+ 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).");
84
94
  authConfig = config;
85
95
  }
86
96
  /**
@@ -94,35 +104,77 @@ function getAuthContext() {
94
104
  }
95
105
  /**
96
106
  * Create an isolated API client for a specific backend.
97
- * Use this when your app needs to talk to multiple APIs.
107
+ * Use this when your app needs to talk to multiple APIs with different auth.
98
108
  *
99
109
  * @example
110
+ * // Bearer auth for main API
111
+ * const mainClient = createClient({
112
+ * baseUrl: 'https://api.example.com',
113
+ * getToken: () => session.accessToken,
114
+ * });
115
+ *
116
+ * // API key auth for analytics
100
117
  * const analyticsClient = createClient({
101
118
  * baseUrl: 'https://analytics.example.com',
102
- * toast: { success: toast.success, error: toast.error },
103
- * navigation: useRouter,
119
+ * authMode: 'header',
120
+ * getToken: () => env.ANALYTICS_KEY,
121
+ * headerName: 'x-api-key',
104
122
  * });
105
- *
106
- * const eventsApi = createCrudApi('events', { client: analyticsClient });
107
123
  */
108
124
  function createClient(config) {
109
- const { toast, navigation, ...clientCfg } = config;
125
+ const { toast, navigation, getToken, getOrgId, headerName, ...clientCfg } = config;
126
+ const clientAuth = getToken || getOrgId || headerName ? {
127
+ getToken,
128
+ getOrgId,
129
+ headerName
130
+ } : void 0;
110
131
  return {
111
- request: (method, endpoint, options) => executeRequest(clientCfg, method, endpoint, options),
132
+ request: (method, endpoint, options) => {
133
+ if (clientAuth) {
134
+ const resolved = { ...options };
135
+ if (resolved.token === void 0 && clientAuth.getToken) resolved.token = clientAuth.getToken();
136
+ if (resolved.organizationId === void 0 && clientAuth.getOrgId) resolved.organizationId = clientAuth.getOrgId();
137
+ if (clientCfg.authMode === "header" && resolved.token) {
138
+ resolved.headerOptions = {
139
+ [clientAuth.headerName ?? "x-api-key"]: resolved.token,
140
+ ...resolved.headerOptions ?? {}
141
+ };
142
+ resolved.token = void 0;
143
+ }
144
+ return executeRequest(clientCfg, method, endpoint, resolved);
145
+ }
146
+ return executeRequest(clientCfg, method, endpoint, options);
147
+ },
112
148
  config: clientCfg,
113
149
  toast,
114
- navigation
150
+ navigation,
151
+ auth: clientAuth
115
152
  };
116
153
  }
154
+ /**
155
+ * Get auth context for a specific client instance, falling back to global.
156
+ */
157
+ function getClientAuthContext(client) {
158
+ if (client?.auth) return {
159
+ token: client.auth.getToken?.() ?? authConfig?.getToken?.() ?? null,
160
+ organizationId: client.auth.getOrgId?.() ?? authConfig?.getOrgId?.() ?? null
161
+ };
162
+ return getAuthContext();
163
+ }
117
164
  async function executeRequest(config, method, endpoint, options = {}) {
118
- const { body, token, organizationId, revalidate, headerOptions, tags, cache, signal } = options;
165
+ const { body, token, organizationId, revalidate, headerOptions, tags, cache, signal, idempotencyKey } = options;
119
166
  try {
120
167
  let headers = {
121
168
  ...organizationId ? { "x-organization-id": organizationId } : {},
122
169
  ...config.defaultHeaders ?? {}
123
170
  };
124
171
  if (config.internalApiKey) headers["x-internal-api-key"] = config.internalApiKey;
125
- if (token) headers["Authorization"] = `Bearer ${token}`;
172
+ if (token) if (config.authMode === "header") {
173
+ const headerName = authConfig?.headerName ?? "x-api-key";
174
+ headers[headerName] = token;
175
+ } else headers["Authorization"] = `Bearer ${token}`;
176
+ if (config.apiVersion) headers["Accept-Version"] = config.apiVersion;
177
+ if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
126
178
  if (body !== void 0 && body !== null && !(body instanceof FormData)) headers["Content-Type"] = "application/json";
127
179
  if (headerOptions) headers = {
128
180
  ...headers,
@@ -147,8 +199,21 @@ async function executeRequest(config, method, endpoint, options = {}) {
147
199
  };
148
200
  const response = await fetch(`${config.baseUrl}${endpoint}`, fetchOptions);
149
201
  if (!response.ok) {
150
- const json = await response.json().catch(() => null);
151
- throw new ArcApiError(json?.message || response.statusText, {
202
+ let json = null;
203
+ let errorMessage = response.statusText;
204
+ try {
205
+ json = await response.clone().json();
206
+ errorMessage = json?.message || response.statusText;
207
+ } catch {
208
+ try {
209
+ const text = await response.text();
210
+ if (text) {
211
+ json = { rawBody: text };
212
+ errorMessage = text.slice(0, 200) || response.statusText;
213
+ }
214
+ } catch {}
215
+ }
216
+ throw new ArcApiError(errorMessage, {
152
217
  status: response.status,
153
218
  statusText: response.statusText,
154
219
  json,
@@ -176,11 +241,15 @@ async function executeRequest(config, method, endpoint, options = {}) {
176
241
  data: await response.clone().blob(),
177
242
  response
178
243
  };
179
- } catch {
180
- data = {
181
- data: await response.text(),
182
- response
183
- };
244
+ } catch (blobError) {
245
+ try {
246
+ data = {
247
+ data: await response.text(),
248
+ response
249
+ };
250
+ } catch {
251
+ throw new Error(`Failed to parse response body from ${method} ${endpoint}: blob error: ${blobError instanceof Error ? blobError.message : String(blobError)}`);
252
+ }
184
253
  }
185
254
  return data;
186
255
  } catch (error) {
@@ -245,4 +314,4 @@ function createQueryString(params = {}) {
245
314
  }
246
315
 
247
316
  //#endregion
248
- export { ArcApiError, configureAuth, configureClient, createClient, createQueryString, getAuthContext, getAuthMode, handleApiRequest, isArcApiError };
317
+ export { ArcApiError, configureAuth, configureClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isArcApiError, isAutoIdempotency };
package/dist/hooks.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ArcClient, UseRouterHook } from "./client.js";
2
- import { BaseApi } from "./api.js";
2
+ import { BaseApi, FilterOperator } from "./api.js";
3
3
  import { MutationCallbacks, MutationMessages, TransitionMutationReturn } from "./mutation.js";
4
4
  import { CacheUtils, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, QueryKeys } from "./query.js";
5
5
  import { QueryKey } from "@tanstack/react-query";
@@ -15,12 +15,32 @@ import { QueryKey } from "@tanstack/react-query";
15
15
  type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete'> & {
16
16
  upload?: BaseApi<T, TCreate, TUpdate>['upload'];
17
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'];
18
27
  };
19
28
  interface CrudHooksConfig<T, TCreate = Partial<T>, TUpdate = Partial<T>> {
20
29
  api: CrudApi<T, TCreate, TUpdate>;
21
30
  entityKey: string;
22
31
  singular: string;
23
32
  plural?: string;
33
+ /**
34
+ * Primary key field used to extract item IDs from response data.
35
+ * Used for cache key resolution, optimistic updates, and detail cache prefill.
36
+ *
37
+ * Default lookup order: `_id` → `id`.
38
+ * Set this when your resource uses a custom ID field (e.g., `'sku'`, `'slug'`, `'code'`).
39
+ *
40
+ * @example
41
+ * createCrudHooks({ idField: 'sku', ... }) // GET /products/:sku
42
+ */
43
+ idField?: string;
24
44
  defaults?: {
25
45
  staleTime?: number;
26
46
  gcTime?: number;
@@ -72,11 +92,35 @@ interface CrudActions<T, TCreate, TUpdate> {
72
92
  create: (params: MutationParams<TCreate>, options?: CallOptions<T>) => Promise<T>;
73
93
  update: (params: UpdateParams<TUpdate>, options?: CallOptions<T>) => Promise<T>;
74
94
  remove: (params: DeleteParams, options?: CallOptions) => Promise<unknown>;
95
+ /** Restore a soft-deleted item. Only available when backend has softDelete preset. */
96
+ restore: (params: DeleteParams, options?: CallOptions<T>) => Promise<T>;
75
97
  isCreating: boolean;
76
98
  isUpdating: boolean;
77
99
  isDeleting: boolean;
100
+ isRestoring: boolean;
78
101
  isMutating: boolean;
79
102
  }
103
+ interface BulkActions<T, TCreate> {
104
+ bulkCreate: (params: {
105
+ data: TCreate[];
106
+ token?: string | null;
107
+ organizationId?: string | null;
108
+ }, options?: CallOptions<T[]>) => Promise<T[]>;
109
+ bulkUpdate: (params: {
110
+ filter: Record<string, unknown>;
111
+ data: Partial<T>;
112
+ token?: string | null;
113
+ organizationId?: string | null;
114
+ }, options?: CallOptions) => Promise<unknown>;
115
+ bulkRemove: (params: {
116
+ filter: Record<string, unknown>;
117
+ token?: string | null;
118
+ organizationId?: string | null;
119
+ }, options?: CallOptions) => Promise<unknown>;
120
+ isBulkCreating: boolean;
121
+ isBulkUpdating: boolean;
122
+ isBulkDeleting: boolean;
123
+ }
80
124
  interface NavigationOptions {
81
125
  scroll?: boolean;
82
126
  replace?: boolean;
@@ -98,6 +142,14 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
98
142
  (token: string | null, params?: Record<string, unknown>, options?: InfiniteListQueryOptions): InfiniteListQueryResult<T>;
99
143
  };
100
144
  useActions: () => CrudActions<T, TCreate, TUpdate>;
145
+ useBulkActions: () => BulkActions<T, TCreate>;
146
+ useDeleted: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
147
+ useDetailBySlug: (slug: string | null, options?: DetailQueryOptions<T>) => DetailQueryResult<T>;
148
+ useTree: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
149
+ 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>;
101
153
  useUpload: (options?: {
102
154
  invalidateQueries?: QueryKey[];
103
155
  messages?: MutationMessages;
@@ -134,9 +186,11 @@ declare function createCrudHooks<T, TCreate = Partial<T>, TUpdate = Partial<T>>(
134
186
  api,
135
187
  entityKey,
136
188
  singular,
189
+ plural,
190
+ idField,
137
191
  defaults,
138
192
  callbacks,
139
193
  client
140
194
  }: CrudHooksConfig<T, TCreate, TUpdate>): CrudHooksReturn<T, TCreate, TUpdate>;
141
195
  //#endregion
142
- export { CallOptions, CrudActions, CrudApi, CrudHooksConfig, CrudHooksReturn, DeleteParams, MutationParams, NavigateFn, NavigationOptions, UpdateParams, configureNavigation, createCrudHooks };
196
+ export { BulkActions, CallOptions, CrudActions, CrudApi, CrudHooksConfig, CrudHooksReturn, DeleteParams, MutationParams, NavigateFn, NavigationOptions, UpdateParams, configureNavigation, createCrudHooks };