@classytic/arc-next 0.10.0 → 0.11.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/README.md CHANGED
@@ -8,6 +8,20 @@ React + TanStack Query SDK for the Arc backend framework. Typed CRUD hooks, opti
8
8
  npm install @classytic/arc-next
9
9
  ```
10
10
 
11
+ ## Type flow — use WIRE types for `T`
12
+
13
+ `createCrudApi<T>`'s generic should be the kernel/module's exported **wire type**
14
+ (plain JSON shape) — never a mongoose-flavored document type. Kernel → API →
15
+ frontend then stays one type flow with zero casts:
16
+
17
+ ```ts
18
+ import type { OrderWire } from '@classytic/order/wire'; // plain JSON shape
19
+ const orders = createCrudApi<OrderWire>('orders');
20
+ ```
21
+
22
+ Server-side counterpart: arc-* modules export their wire types per the
23
+ module-publishing convention.
24
+
11
25
  ## Setup
12
26
 
13
27
  Call once at app init from a `"use client"` provider:
package/dist/api.d.ts CHANGED
@@ -123,6 +123,50 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
123
123
  params?: QueryParams;
124
124
  options?: Omit<RequestOptions, 'token' | 'organizationId'>;
125
125
  }): Promise<PaginatedResult<TDoc>>;
126
+ /**
127
+ * Count records matching the filters — arc's list-route dispatch verb
128
+ * (`?_count=true`): same permissions/row-filters/tenant scoping as
129
+ * `getAll`, ZERO documents fetched. Cheapest way to answer "how many".
130
+ */
131
+ count({
132
+ token,
133
+ organizationId,
134
+ params,
135
+ options
136
+ }?: {
137
+ token?: string | null;
138
+ organizationId?: string | null;
139
+ params?: QueryParams;
140
+ options?: Omit<RequestOptions, 'token' | 'organizationId'>;
141
+ }): Promise<number>;
142
+ /** Whether ANY record matches the filters (`?_exists=true`). */
143
+ exists({
144
+ token,
145
+ organizationId,
146
+ params,
147
+ options
148
+ }?: {
149
+ token?: string | null;
150
+ organizationId?: string | null;
151
+ params?: QueryParams;
152
+ options?: Omit<RequestOptions, 'token' | 'organizationId'>;
153
+ }): Promise<boolean>;
154
+ /** Distinct values of a field across matching records (`?_distinct=field`). */
155
+ distinct<TValue = unknown>({
156
+ token,
157
+ organizationId,
158
+ field,
159
+ params,
160
+ options
161
+ }: {
162
+ token?: string | null;
163
+ organizationId?: string | null;
164
+ field: string;
165
+ params?: QueryParams;
166
+ options?: Omit<RequestOptions, 'token' | 'organizationId'>;
167
+ }): Promise<TValue[]>;
168
+ /** Shared raw GET against the list route (dispatch verbs bypass pagination parsing). */
169
+ private getAllRaw;
126
170
  getById({
127
171
  token,
128
172
  organizationId,
package/dist/api.js CHANGED
@@ -105,6 +105,60 @@ var BaseApi = class {
105
105
  if (organizationId) requestOptions.organizationId = organizationId;
106
106
  return this.requestFn("GET", `${this.baseUrl}?${queryString}`, this.withHeaders(requestOptions));
107
107
  }
108
+ /**
109
+ * Count records matching the filters — arc's list-route dispatch verb
110
+ * (`?_count=true`): same permissions/row-filters/tenant scoping as
111
+ * `getAll`, ZERO documents fetched. Cheapest way to answer "how many".
112
+ */
113
+ async count({ token = null, organizationId = null, params = {}, options = {} } = {}) {
114
+ return extractVerbField(await this.getAllRaw({
115
+ token,
116
+ organizationId,
117
+ params: {
118
+ ...params,
119
+ _count: true
120
+ },
121
+ options
122
+ }), "count");
123
+ }
124
+ /** Whether ANY record matches the filters (`?_exists=true`). */
125
+ async exists({ token = null, organizationId = null, params = {}, options = {} } = {}) {
126
+ return extractVerbField(await this.getAllRaw({
127
+ token,
128
+ organizationId,
129
+ params: {
130
+ ...params,
131
+ _exists: true
132
+ },
133
+ options
134
+ }), "exists");
135
+ }
136
+ /** Distinct values of a field across matching records (`?_distinct=field`). */
137
+ async distinct({ token = null, organizationId = null, field, params = {}, options = {} }) {
138
+ if (!field) throw new Error("field is required");
139
+ return extractVerbField(await this.getAllRaw({
140
+ token,
141
+ organizationId,
142
+ params: {
143
+ ...params,
144
+ _distinct: field
145
+ },
146
+ options
147
+ }), "values");
148
+ }
149
+ /** Shared raw GET against the list route (dispatch verbs bypass pagination parsing). */
150
+ async getAllRaw({ token = null, organizationId = null, params = {}, options = {} }) {
151
+ const mergedParams = {
152
+ ...this.config.defaultParams,
153
+ ...params
154
+ };
155
+ const processedParams = this.prepareParams(mergedParams);
156
+ const queryString = this.createQueryString(processedParams);
157
+ const requestOptions = { ...this.withCacheDefault(options) };
158
+ if (token) requestOptions.token = token;
159
+ if (organizationId) requestOptions.organizationId = organizationId;
160
+ return this.requestFn("GET", `${this.baseUrl}?${queryString}`, this.withHeaders(requestOptions));
161
+ }
108
162
  async getById({ token = null, organizationId = null, id, params = {}, options = {} }) {
109
163
  if (!id) throw new Error("ID is required");
110
164
  const queryString = this.createQueryString(params);
@@ -255,6 +309,18 @@ function isKeysetPagination(response) {
255
309
  function isAggregatePagination(response) {
256
310
  return "method" in response && response.method === "aggregate";
257
311
  }
312
+ /**
313
+ * Dispatch-verb responses are small objects (`{ count }`, `{ exists }`,
314
+ * `{ values }`). Parse defensively across envelope variants (bare vs
315
+ * `{ data: ... }`) so minor server envelope changes don't break clients.
316
+ */
317
+ function extractVerbField(res, field) {
318
+ const r = res;
319
+ if (r && field in r) return r[field];
320
+ const d = r?.data ?? null;
321
+ if (d && field in d) return d[field];
322
+ throw new Error(`[arc-next] unexpected dispatch-verb response shape (missing '${field}')`);
323
+ }
258
324
 
259
325
  //#endregion
260
326
  export { BaseApi, createCrudApi, isAggregatePagination, isKeysetPagination, isOffsetPagination };
package/dist/client.d.ts CHANGED
@@ -141,6 +141,33 @@ declare function isAbortError(error: unknown): boolean;
141
141
  * if (isArcErrorCode(error, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
142
142
  */
143
143
  declare function isArcErrorCode(error: unknown, code: ArcErrorCode): error is ArcApiError;
144
+ /** `details` payload of arc's `quota.exceeded` 429 (`requireQuota`). */
145
+ interface QuotaDetails {
146
+ /** The metered counter, e.g. `ai.tokens`, `export.runs`. */
147
+ kind: string;
148
+ used: number;
149
+ limit: number;
150
+ /** Billing period key, `YYYY-MM`. */
151
+ period: string;
152
+ /** ISO timestamp of the next period start — render "resets {date}". */
153
+ resetsAt: string;
154
+ }
155
+ /**
156
+ * Type guard for arc's quota denial (429 `quota.exceeded` from
157
+ * `requireQuota`). Render a meter, not a generic failure:
158
+ *
159
+ * @example
160
+ * if (isQuotaExceeded(err)) {
161
+ * const q = getQuotaDetails(err);
162
+ * toast(`${q.used.toLocaleString()} of ${q.limit.toLocaleString()} ${q.kind} used — resets ${new Date(q.resetsAt).toLocaleDateString()}`);
163
+ * }
164
+ *
165
+ * NEVER auto-retry these — a monthly quota doesn't reset between retries
166
+ * (the shared query client already refuses; see query-client.ts).
167
+ */
168
+ declare function isQuotaExceeded(error: unknown): error is ArcApiError;
169
+ /** Structured quota details from a `quota.exceeded` error (null when absent/malformed). */
170
+ declare function getQuotaDetails(error: unknown): QuotaDetails | null;
144
171
  /**
145
172
  * Specific predicate for arc's bulk-preset + orgGuard safety code.
146
173
  *
@@ -623,7 +650,19 @@ interface NextFetchOptions {
623
650
  }
624
651
  interface ApiRequestOptions {
625
652
  body?: unknown;
653
+ /**
654
+ * Bearer token for this request. THREE-STATE contract:
655
+ * - **omitted / `undefined`** → inherit the global `configureAuth()` context
656
+ * (auto-injected by `handleApiRequest`, per-client instances, and hooks)
657
+ * - **explicit `null`** → deliberately unauthenticated (public endpoint)
658
+ * - **string** → use exactly this token (wins over the global context)
659
+ */
626
660
  token?: string | null;
661
+ /**
662
+ * Tenant/org id sent as `x-organization-id`. Same three-state contract as
663
+ * `token`: `undefined` = inherit `configureAuth().getOrgId`, `null` = send
664
+ * no org header (platform-scope calls), string = exactly this org.
665
+ */
627
666
  organizationId?: string | null;
628
667
  /** Flattened Next `revalidate` (see `next`). `false` = cache indefinitely. */
629
668
  revalidate?: number | false;
@@ -864,4 +903,4 @@ declare const arc: {
864
903
  delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
865
904
  };
866
905
  //#endregion
867
- export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
906
+ export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, QuotaDetails, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
package/dist/client.js CHANGED
@@ -201,6 +201,29 @@ function isArcErrorCode(error, code) {
201
201
  return isArcApiError(error) && error.code === code;
202
202
  }
203
203
  /**
204
+ * Type guard for arc's quota denial (429 `quota.exceeded` from
205
+ * `requireQuota`). Render a meter, not a generic failure:
206
+ *
207
+ * @example
208
+ * if (isQuotaExceeded(err)) {
209
+ * const q = getQuotaDetails(err);
210
+ * toast(`${q.used.toLocaleString()} of ${q.limit.toLocaleString()} ${q.kind} used — resets ${new Date(q.resetsAt).toLocaleDateString()}`);
211
+ * }
212
+ *
213
+ * NEVER auto-retry these — a monthly quota doesn't reset between retries
214
+ * (the shared query client already refuses; see query-client.ts).
215
+ */
216
+ function isQuotaExceeded(error) {
217
+ return isArcApiError(error) && error.status === 429 && error.code === "quota.exceeded";
218
+ }
219
+ /** Structured quota details from a `quota.exceeded` error (null when absent/malformed). */
220
+ function getQuotaDetails(error) {
221
+ if (!isQuotaExceeded(error)) return null;
222
+ const d = error.json?.details;
223
+ if (!d || typeof d.kind !== "string" || typeof d.limit !== "number") return null;
224
+ return d;
225
+ }
226
+ /**
204
227
  * Specific predicate for arc's bulk-preset + orgGuard safety code.
205
228
  *
206
229
  * Arc's bulk endpoints (`POST/PATCH/DELETE /:resource/bulk`) reject any call
@@ -877,6 +900,12 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
877
900
  */
878
901
  async function handleApiRequest(method, endpoint, options = {}) {
879
902
  if (!clientConfig) throw new Error("arc-next: Client not configured. Call configureClient({ baseUrl }) before making API requests.");
903
+ if (authConfig) {
904
+ const resolved = { ...options };
905
+ if (resolved.token === void 0) resolved.token = readToken(authConfig.getToken);
906
+ if (resolved.organizationId === void 0) resolved.organizationId = authConfig.getOrgId?.() ?? null;
907
+ return executeRequest(clientConfig, method, endpoint, resolved);
908
+ }
880
909
  return executeRequest(clientConfig, method, endpoint, options);
881
910
  }
882
911
  /**
@@ -1110,4 +1139,4 @@ const arc = {
1110
1139
  };
1111
1140
 
1112
1141
  //#endregion
1113
- export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
1142
+ export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
package/dist/hooks.d.ts CHANGED
@@ -9,6 +9,7 @@ import { SlugLookupMethods } from "./presets/slug.js";
9
9
  import { TreeMethods } from "./presets/tree.js";
10
10
  import { SearchPresetMethods } from "./presets/search.js";
11
11
  import { QueryKey, UseQueryResult } from "@tanstack/react-query";
12
+ import { PaginatedResult } from "@classytic/repo-core/pagination";
12
13
 
13
14
  //#region src/hooks.d.ts
14
15
  /**
@@ -21,7 +22,7 @@ import { QueryKey, UseQueryResult } from "@tanstack/react-query";
21
22
  * any cast — yet a vanilla `createCrudApi('todos')` instance has none of them
22
23
  * in autocomplete unless you opt in.
23
24
  */
24
- type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete'> & {
25
+ type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete' | 'count'> & {
25
26
  upload?: BaseApi<T, TCreate, TUpdate>['upload'];
26
27
  dispatchAction?: BaseApi<T, TCreate, TUpdate>['dispatchAction'];
27
28
  invokeRoute?: BaseApi<T, TCreate, TUpdate>['invokeRoute']; /** Declared aggregations (arc 2.13+). Always available on BaseApi. */
@@ -207,6 +208,12 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
207
208
  useActions: () => CrudActions<T, TCreate, TUpdate>;
208
209
  useBulkActions: () => BulkActions<T, TCreate>;
209
210
  useDeleted: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
211
+ /** Count-only query via arc's `?_count=true` dispatch verb — zero documents fetched. */
212
+ useCount: (params?: Record<string, unknown>, options?: {
213
+ enabled?: boolean;
214
+ staleTime?: number;
215
+ gcTime?: number;
216
+ }) => UseQueryResult<number, Error>;
210
217
  useDetailBySlug: (slug: string | null, options?: DetailQueryOptions<T>) => DetailQueryResult<T>;
211
218
  useTree: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
212
219
  useChildren: (parentId: string | null, params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
@@ -218,7 +225,11 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
218
225
  useAction: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
219
226
  invalidateQueries?: QueryKey[]; /** Default action name. Can be overridden per-call via `mutate({ action })`. */
220
227
  action?: string;
221
- messages?: MutationMessages;
228
+ messages?: MutationMessages<TResult, {
229
+ id: string;
230
+ action?: string;
231
+ data?: TBody;
232
+ }>;
222
233
  onSuccess?: (data: TResult, variables: {
223
234
  id: string;
224
235
  action: string;
@@ -242,18 +253,25 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
242
253
  /** Mutation against the search-preset POST `/search` route. */
243
254
  useSearchEngine: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
244
255
  path?: string;
245
- messages?: MutationMessages;
256
+ messages?: MutationMessages<TResult[] | PaginatedResult<TResult>, {
257
+ query?: string;
258
+ body?: TBody;
259
+ }>;
246
260
  invalidateQueries?: QueryKey[];
247
- }) => TransitionMutationReturn<unknown, {
261
+ }) => TransitionMutationReturn<TResult[] | PaginatedResult<TResult>, {
248
262
  query?: string;
249
263
  body?: TBody;
250
264
  }>;
251
265
  /** Mutation against the search-preset POST `/search-similar` route. */
252
266
  useSearchSimilar: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
253
267
  path?: string;
254
- messages?: MutationMessages;
268
+ messages?: MutationMessages<TResult[] | PaginatedResult<TResult>, {
269
+ query?: string;
270
+ vector?: number[];
271
+ body?: TBody;
272
+ }>;
255
273
  invalidateQueries?: QueryKey[];
256
- }) => TransitionMutationReturn<unknown, {
274
+ }) => TransitionMutationReturn<TResult[] | PaginatedResult<TResult>, {
257
275
  query?: string;
258
276
  vector?: number[];
259
277
  body?: TBody;
@@ -303,7 +321,7 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
303
321
  useCustomMutation: <TData = unknown, TVariables = unknown>(config: {
304
322
  mutationFn: (variables: TVariables) => Promise<TData>;
305
323
  invalidateQueries?: QueryKey[];
306
- messages?: MutationMessages;
324
+ messages?: MutationMessages<TData, TVariables>;
307
325
  onSuccess?: (data: TData, variables: TVariables) => void;
308
326
  onError?: (error: Error, variables: TVariables) => void;
309
327
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
package/dist/hooks.js CHANGED
@@ -601,6 +601,23 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
601
601
  select: queryOpts.select
602
602
  });
603
603
  }
604
+ function useCount(params, options) {
605
+ const auth = resolveAuth();
606
+ const mergedParams = params ?? {};
607
+ const organizationId = mergedParams.organizationId ?? auth.organizationId;
608
+ const { organizationId: _, ...restParams } = mergedParams;
609
+ return useQuery({
610
+ queryKey: KEYS.custom("count", withOrgParams(organizationId, restParams)),
611
+ queryFn: () => api.count({
612
+ token: auth.token,
613
+ organizationId,
614
+ params: restParams
615
+ }),
616
+ enabled: options?.enabled ?? true,
617
+ staleTime: options?.staleTime ?? config.staleTime,
618
+ gcTime: options?.gcTime ?? config.gcTime
619
+ });
620
+ }
604
621
  function useDetailBySlug(slug, options) {
605
622
  const auth = resolveAuth();
606
623
  const token = auth.token;
@@ -1029,6 +1046,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
1029
1046
  useInfiniteList,
1030
1047
  useActions,
1031
1048
  useBulkActions,
1049
+ useCount,
1032
1050
  useDeleted,
1033
1051
  useDetailBySlug,
1034
1052
  useTree,
@@ -3,9 +3,16 @@ import * as _$_tanstack_react_query0 from "@tanstack/react-query";
3
3
  import { QueryClient, QueryKey, UseMutateAsyncFunction, UseMutateFunction } from "@tanstack/react-query";
4
4
 
5
5
  //#region src/mutation.d.ts
6
- interface MutationMessages {
7
- success?: string | ((data: unknown, variables: unknown) => string);
8
- error?: string | ((error: Error, variables: unknown) => string);
6
+ /**
7
+ * Toast copy for a mutation. Generic over the mutation's result/variables so
8
+ * `success: (result) => ...` sees the TYPED FLAT action result (arc 2.13+
9
+ * returns results with no `{ data }` envelope) — the stale-envelope
10
+ * `(data as { data: T }).data` cast class is a compile error now. Defaults
11
+ * keep pre-0.11.1 unparameterized usage compiling unchanged.
12
+ */
13
+ interface MutationMessages<TData = unknown, TVariables = unknown> {
14
+ success?: string | ((data: TData, variables: TVariables) => string);
15
+ error?: string | ((error: Error, variables: TVariables) => string);
9
16
  }
10
17
  interface MutationCallbacks<TData, TVariables, TContext = unknown> {
11
18
  onMutate?: (variables: TVariables) => TContext | Promise<TContext>;
@@ -56,7 +63,7 @@ interface TransitionMutationConfig<TData, TVariables> {
56
63
  onSuccess?: (data: TData, variables: TVariables) => void;
57
64
  onError?: (error: Error, variables: TVariables) => void;
58
65
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
59
- messages?: MutationMessages;
66
+ messages?: MutationMessages<TData, TVariables>;
60
67
  useTransition?: boolean;
61
68
  showToast?: boolean;
62
69
  /** Per-call toast guard — return false to suppress toast for this invocation */
@@ -80,7 +87,7 @@ interface OptimisticMutationConfig<TData, TVariables> {
80
87
  onSuccess?: (data: TData, variables: TVariables) => void;
81
88
  onError?: (error: Error, variables: TVariables) => void;
82
89
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
83
- messages?: MutationMessages;
90
+ messages?: MutationMessages<TData, TVariables>;
84
91
  showToast?: boolean;
85
92
  toastHandler?: ToastHandler;
86
93
  }
@@ -112,7 +119,7 @@ interface CreateOptimisticMutationConfig<TData, TVariables> {
112
119
  onSuccess?: (data: TData, variables: TVariables) => void;
113
120
  onError?: (error: Error, variables: TVariables) => void;
114
121
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
115
- messages?: MutationMessages;
122
+ messages?: MutationMessages<TData, TVariables>;
116
123
  /** Per-call toast guard — return false to suppress toast for this invocation */
117
124
  shouldToast?: () => boolean;
118
125
  toastHandler?: ToastHandler;
package/dist/mutation.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { isArcApiError, isAutoIdempotency } from "./client.js";
3
+ import { getQuotaDetails, isArcApiError, isAutoIdempotency } from "./client.js";
4
4
  import { useMutation, useQueryClient } from "@tanstack/react-query";
5
5
  import { useCallback, useRef, useTransition } from "react";
6
6
 
@@ -50,7 +50,9 @@ function showToast(type, messages, data, variables, error, handler) {
50
50
  } else {
51
51
  const msg = messages?.error;
52
52
  let defaultMsg = error?.message || "An error occurred";
53
- if (isArcApiError(error) && error.fieldErrors) {
53
+ const quota = getQuotaDetails(error);
54
+ if (quota) defaultMsg = `${quota.used.toLocaleString()} of ${quota.limit.toLocaleString()} ${quota.kind} used this period — resets ${new Date(quota.resetsAt).toLocaleDateString()}`;
55
+ else if (isArcApiError(error) && error.fieldErrors) {
54
56
  const fields = Object.entries(error.fieldErrors);
55
57
  if (fields.length > 0) defaultMsg = fields.map(([k, v]) => `${k}: ${v}`).join(", ");
56
58
  }
@@ -0,0 +1,56 @@
1
+ import { AnyBaseApi, ScopedArgs } from "../api.js";
2
+
3
+ //#region src/presets/history.d.ts
4
+ /** One audit-trail entry — arc's `AuditEntry` wire shape for a single record. */
5
+ interface HistoryEntry {
6
+ id: string;
7
+ resource: string;
8
+ documentId: string;
9
+ action: 'create' | 'update' | 'delete' | 'restore' | 'custom';
10
+ userId?: string;
11
+ organizationId?: string;
12
+ before?: Record<string, unknown>;
13
+ after?: Record<string, unknown>;
14
+ /** Field names that changed (updates). */
15
+ changes?: string[];
16
+ requestId?: string;
17
+ timestamp: string;
18
+ metadata?: Record<string, unknown>;
19
+ }
20
+ /** Wire shape of `GET /:resource/:id/history`. */
21
+ interface HistoryPage {
22
+ data: HistoryEntry[];
23
+ limit: number;
24
+ offset: number;
25
+ }
26
+ interface HistoryMethods {
27
+ /**
28
+ * Per-record change timeline. Backend mounts `GET /:resource/:id/history`
29
+ * when the resource declares `history: true` (arc 2.22) — audit-backed,
30
+ * newest first, gated stricter than reads (update → get → auth).
31
+ */
32
+ history(args: ScopedArgs & {
33
+ id: string;
34
+ params?: {
35
+ limit?: number;
36
+ offset?: number;
37
+ };
38
+ }): Promise<HistoryPage>;
39
+ }
40
+ /**
41
+ * Adds the per-record history method to a BaseApi.
42
+ *
43
+ * Mirrors arc's server-side `history: true` flag (2.22). Compose like every
44
+ * other preset wrapper:
45
+ *
46
+ * @example
47
+ * import { createCrudApi } from '@classytic/arc-next/api';
48
+ * import { withHistory } from '@classytic/arc-next/presets/history';
49
+ *
50
+ * const orders = withHistory(createCrudApi<Order>('orders'));
51
+ * const page = await orders.history({ id, params: { limit: 25 } });
52
+ * // page.data[0] → { action: 'update', changes: ['status'], before, after, ... }
53
+ */
54
+ declare function withHistory<TApi extends AnyBaseApi>(api: TApi): TApi & HistoryMethods;
55
+ //#endregion
56
+ export { HistoryEntry, HistoryMethods, HistoryPage, withHistory };
@@ -0,0 +1,29 @@
1
+ //#region src/presets/history.ts
2
+ /**
3
+ * Adds the per-record history method to a BaseApi.
4
+ *
5
+ * Mirrors arc's server-side `history: true` flag (2.22). Compose like every
6
+ * other preset wrapper:
7
+ *
8
+ * @example
9
+ * import { createCrudApi } from '@classytic/arc-next/api';
10
+ * import { withHistory } from '@classytic/arc-next/presets/history';
11
+ *
12
+ * const orders = withHistory(createCrudApi<Order>('orders'));
13
+ * const page = await orders.history({ id, params: { limit: 25 } });
14
+ * // page.data[0] → { action: 'update', changes: ['status'], before, after, ... }
15
+ */
16
+ function withHistory(api) {
17
+ return Object.assign(api, { async history({ token = null, organizationId = null, id, params = {}, options = {} }) {
18
+ if (!id) throw new Error("ID is required");
19
+ return api.request("GET", `${api.baseUrl}/${id}/history`, {
20
+ token,
21
+ organizationId,
22
+ params,
23
+ options
24
+ });
25
+ } });
26
+ }
27
+
28
+ //#endregion
29
+ export { withHistory };
@@ -1,3 +1,4 @@
1
+ import { isQuotaExceeded } from "./client.js";
1
2
  import { QueryClient, defaultShouldDehydrateQuery, isServer } from "@tanstack/react-query";
2
3
 
3
4
  //#region src/query-client.ts
@@ -14,7 +15,7 @@ function makeQueryClient(overrides) {
14
15
  };
15
16
  return new QueryClient({ defaultOptions: {
16
17
  queries: {
17
- retry: opts.retry,
18
+ retry: (failureCount, error) => !isQuotaExceeded(error) && failureCount < (opts.retry || 0),
18
19
  staleTime: opts.staleTime,
19
20
  gcTime: opts.gcTime,
20
21
  refetchOnWindowFocus: opts.refetchOnWindowFocus
package/llms.txt ADDED
@@ -0,0 +1,22 @@
1
+ # @classytic/arc-next
2
+
3
+ > React + TanStack Query SDK for @classytic/arc backends. Typed CRUD hooks,
4
+ > optimistic updates with rollback, tenant-scoped cache keys, pagination
5
+ > normalization (offset + keyset), SSE, uploads.
6
+
7
+ IMPORTANT for coding agents: hand-rolled `fetch()`/axios against an arc API is a
8
+ bug in apps using this package. Before writing any data-fetching code, check:
9
+
10
+ - CRUD + queries → createCrudApi<T>('resource') + createCrudHooks (useList, useDetail, useInfiniteList, useSuspense*)
11
+ - "How many?" → api.count() / useCount() — arc's ?_count=true verb, zero documents fetched (also api.exists / api.distinct)
12
+ - Mutations → useActions/useBulkActions/useCustomMutation (optimistic updates + rollback + toast built in)
13
+ - Custom actions → useAction (arc's POST /:id/action router)
14
+ - Server presets → compose the matching wrapper: withSoftDelete/withBulk/withTree/withSlugLookup/withSearch/withHistory
15
+ - Record timeline → withHistory(api) + api.history(id) — arc 2.22 `history: true` audit timeline
16
+ - Quota 429s → isQuotaExceeded(err) + getQuotaDetails(err) → render "X of Y {kind}, resets {date}"; NEVER auto-retry (the shared query client already refuses)
17
+ - Aggregations → useAggregation(name, filter)
18
+ - Realtime → SSE/WS modules; uploads → useUpload/useFileUpload
19
+ - Auth/org context → configureClient/configureAuth once at app init; hooks read it — never thread tokens by hand
20
+
21
+ Type flow: the generic T in createCrudApi<T> should be the kernel/module's exported
22
+ WIRE type (plain JSON shape) — never a mongoose-flavored document type.
package/package.json CHANGED
@@ -1,173 +1,178 @@
1
- {
2
- "name": "@classytic/arc-next",
3
- "version": "0.10.0",
4
- "description": "React + TanStack Query SDK for Arc resources",
5
- "type": "module",
6
- "sideEffects": false,
7
- "license": "MIT",
8
- "author": "Classytic",
9
- "repository": {
10
- "type": "git",
11
- "url": "https://github.com/classytic/arc-next"
12
- },
13
- "keywords": [
14
- "react",
15
- "tanstack-query",
16
- "react-query",
17
- "crud",
18
- "api-client",
19
- "hooks",
20
- "optimistic-updates",
21
- "pagination",
22
- "multi-tenant",
23
- "arc",
24
- "mongokit",
25
- "sse",
26
- "real-time",
27
- "soft-delete",
28
- "bulk-operations"
29
- ],
30
- "main": "./dist/hooks.js",
31
- "types": "./dist/hooks.d.ts",
32
- "exports": {
33
- ".": {
34
- "types": "./dist/hooks.d.ts",
35
- "default": "./dist/hooks.js"
36
- },
37
- "./client": {
38
- "types": "./dist/client.d.ts",
39
- "default": "./dist/client.js"
40
- },
41
- "./encryption": {
42
- "types": "./dist/encryption.d.ts",
43
- "default": "./dist/encryption.js"
44
- },
45
- "./field-encryption": {
46
- "types": "./dist/field-encryption.d.ts",
47
- "default": "./dist/field-encryption.js"
48
- },
49
- "./api": {
50
- "types": "./dist/api.d.ts",
51
- "default": "./dist/api.js"
52
- },
53
- "./cache": {
54
- "types": "./dist/cache.d.ts",
55
- "default": "./dist/cache.js"
56
- },
57
- "./query": {
58
- "types": "./dist/query.d.ts",
59
- "default": "./dist/query.js"
60
- },
61
- "./mutation": {
62
- "types": "./dist/mutation.d.ts",
63
- "default": "./dist/mutation.js"
64
- },
65
- "./hooks": {
66
- "types": "./dist/hooks.d.ts",
67
- "default": "./dist/hooks.js"
68
- },
69
- "./query-client": {
70
- "types": "./dist/query-client.d.ts",
71
- "default": "./dist/query-client.js"
72
- },
73
- "./prefetch": {
74
- "types": "./dist/prefetch.d.ts",
75
- "default": "./dist/prefetch.js"
76
- },
77
- "./sse": {
78
- "types": "./dist/sse.d.ts",
79
- "default": "./dist/sse.js"
80
- },
81
- "./ws": {
82
- "types": "./dist/ws.d.ts",
83
- "default": "./dist/ws.js"
84
- },
85
- "./upload": {
86
- "types": "./dist/upload.d.ts",
87
- "default": "./dist/upload.js"
88
- },
89
- "./presets/soft-delete": {
90
- "types": "./dist/presets/soft-delete.d.ts",
91
- "default": "./dist/presets/soft-delete.js"
92
- },
93
- "./presets/bulk": {
94
- "types": "./dist/presets/bulk.d.ts",
95
- "default": "./dist/presets/bulk.js"
96
- },
97
- "./presets/slug": {
98
- "types": "./dist/presets/slug.d.ts",
99
- "default": "./dist/presets/slug.js"
100
- },
101
- "./presets/tree": {
102
- "types": "./dist/presets/tree.d.ts",
103
- "default": "./dist/presets/tree.js"
104
- },
105
- "./presets/search": {
106
- "types": "./dist/presets/search.d.ts",
107
- "default": "./dist/presets/search.js"
108
- },
109
- "./package.json": "./package.json",
110
- "./query-options": {
111
- "types": "./dist/query-options.d.ts",
112
- "default": "./dist/query-options.js"
113
- }
114
- },
115
- "files": [
116
- "dist"
117
- ],
118
- "publishConfig": {
119
- "access": "public",
120
- "registry": "https://registry.npmjs.org/"
121
- },
122
- "engines": {
123
- "node": ">=20"
124
- },
125
- "scripts": {
126
- "build": "tsdown",
127
- "dev": "tsdown --watch",
128
- "test": "vitest run",
129
- "test:watch": "vitest",
130
- "typecheck": "tsc --noEmit",
131
- "push": "classytic-push",
132
- "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
133
- "release": "npm run push -- main && npm run release:tag && npm publish",
134
- "prepublishOnly": "npm run typecheck && npm test && npm run build",
135
- "typecheck:tests": "tsc --noEmit -p tsconfig.test.json"
136
- },
137
- "peerDependencies": {
138
- "@classytic/repo-core": ">=0.4.0",
139
- "@tanstack/react-query": ">=5.62.0",
140
- "jose": ">=5.0.0",
141
- "react": ">=19.0.0"
142
- },
143
- "peerDependenciesMeta": {
144
- "react": {
145
- "optional": false
146
- },
147
- "@tanstack/react-query": {
148
- "optional": false
149
- },
150
- "@classytic/repo-core": {
151
- "optional": false
152
- },
153
- "jose": {
154
- "optional": true
155
- }
156
- },
157
- "devDependencies": {
158
- "@classytic/dev-tools": "^0.2.0",
159
- "@classytic/repo-core": "^0.5.0",
160
- "@tanstack/react-query": "^5.97.0",
161
- "@testing-library/jest-dom": "^6.9.1",
162
- "@testing-library/react": "^16.3.2",
163
- "@types/react": "^19.2.14",
164
- "@types/react-dom": "^19.2.3",
165
- "jose": "^6.2.3",
166
- "jsdom": "^29.0.2",
167
- "react": "^19.2.5",
168
- "react-dom": "^19.2.5",
169
- "tsdown": "^0.21.7",
170
- "typescript": "^6.0.2",
171
- "vitest": "^4.1.4"
172
- }
173
- }
1
+ {
2
+ "name": "@classytic/arc-next",
3
+ "version": "0.11.1",
4
+ "description": "React + TanStack Query SDK for Arc resources",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "license": "MIT",
8
+ "author": "Classytic",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/classytic/arc-next"
12
+ },
13
+ "keywords": [
14
+ "react",
15
+ "tanstack-query",
16
+ "react-query",
17
+ "crud",
18
+ "api-client",
19
+ "hooks",
20
+ "optimistic-updates",
21
+ "pagination",
22
+ "multi-tenant",
23
+ "arc",
24
+ "mongokit",
25
+ "sse",
26
+ "real-time",
27
+ "soft-delete",
28
+ "bulk-operations"
29
+ ],
30
+ "main": "./dist/hooks.js",
31
+ "types": "./dist/hooks.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/hooks.d.ts",
35
+ "default": "./dist/hooks.js"
36
+ },
37
+ "./client": {
38
+ "types": "./dist/client.d.ts",
39
+ "default": "./dist/client.js"
40
+ },
41
+ "./encryption": {
42
+ "types": "./dist/encryption.d.ts",
43
+ "default": "./dist/encryption.js"
44
+ },
45
+ "./field-encryption": {
46
+ "types": "./dist/field-encryption.d.ts",
47
+ "default": "./dist/field-encryption.js"
48
+ },
49
+ "./api": {
50
+ "types": "./dist/api.d.ts",
51
+ "default": "./dist/api.js"
52
+ },
53
+ "./cache": {
54
+ "types": "./dist/cache.d.ts",
55
+ "default": "./dist/cache.js"
56
+ },
57
+ "./query": {
58
+ "types": "./dist/query.d.ts",
59
+ "default": "./dist/query.js"
60
+ },
61
+ "./mutation": {
62
+ "types": "./dist/mutation.d.ts",
63
+ "default": "./dist/mutation.js"
64
+ },
65
+ "./hooks": {
66
+ "types": "./dist/hooks.d.ts",
67
+ "default": "./dist/hooks.js"
68
+ },
69
+ "./query-client": {
70
+ "types": "./dist/query-client.d.ts",
71
+ "default": "./dist/query-client.js"
72
+ },
73
+ "./prefetch": {
74
+ "types": "./dist/prefetch.d.ts",
75
+ "default": "./dist/prefetch.js"
76
+ },
77
+ "./sse": {
78
+ "types": "./dist/sse.d.ts",
79
+ "default": "./dist/sse.js"
80
+ },
81
+ "./ws": {
82
+ "types": "./dist/ws.d.ts",
83
+ "default": "./dist/ws.js"
84
+ },
85
+ "./upload": {
86
+ "types": "./dist/upload.d.ts",
87
+ "default": "./dist/upload.js"
88
+ },
89
+ "./presets/soft-delete": {
90
+ "types": "./dist/presets/soft-delete.d.ts",
91
+ "default": "./dist/presets/soft-delete.js"
92
+ },
93
+ "./presets/history": {
94
+ "types": "./dist/presets/history.d.ts",
95
+ "default": "./dist/presets/history.js"
96
+ },
97
+ "./presets/bulk": {
98
+ "types": "./dist/presets/bulk.d.ts",
99
+ "default": "./dist/presets/bulk.js"
100
+ },
101
+ "./presets/slug": {
102
+ "types": "./dist/presets/slug.d.ts",
103
+ "default": "./dist/presets/slug.js"
104
+ },
105
+ "./presets/tree": {
106
+ "types": "./dist/presets/tree.d.ts",
107
+ "default": "./dist/presets/tree.js"
108
+ },
109
+ "./presets/search": {
110
+ "types": "./dist/presets/search.d.ts",
111
+ "default": "./dist/presets/search.js"
112
+ },
113
+ "./package.json": "./package.json",
114
+ "./query-options": {
115
+ "types": "./dist/query-options.d.ts",
116
+ "default": "./dist/query-options.js"
117
+ }
118
+ },
119
+ "files": [
120
+ "dist",
121
+ "llms.txt"
122
+ ],
123
+ "publishConfig": {
124
+ "access": "public",
125
+ "registry": "https://registry.npmjs.org/"
126
+ },
127
+ "engines": {
128
+ "node": ">=20"
129
+ },
130
+ "scripts": {
131
+ "build": "tsdown",
132
+ "dev": "tsdown --watch",
133
+ "test": "vitest run",
134
+ "test:watch": "vitest",
135
+ "typecheck": "tsc --noEmit",
136
+ "push": "classytic-push",
137
+ "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
138
+ "release": "npm run push -- main && npm run release:tag && npm publish",
139
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
140
+ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json"
141
+ },
142
+ "peerDependencies": {
143
+ "@classytic/repo-core": ">=0.4.0",
144
+ "@tanstack/react-query": ">=5.62.0",
145
+ "jose": ">=5.0.0",
146
+ "react": ">=19.0.0"
147
+ },
148
+ "peerDependenciesMeta": {
149
+ "react": {
150
+ "optional": false
151
+ },
152
+ "@tanstack/react-query": {
153
+ "optional": false
154
+ },
155
+ "@classytic/repo-core": {
156
+ "optional": false
157
+ },
158
+ "jose": {
159
+ "optional": true
160
+ }
161
+ },
162
+ "devDependencies": {
163
+ "@classytic/dev-tools": "^0.2.0",
164
+ "@classytic/repo-core": "^0.5.0",
165
+ "@tanstack/react-query": "^5.97.0",
166
+ "@testing-library/jest-dom": "^6.9.1",
167
+ "@testing-library/react": "^16.3.2",
168
+ "@types/react": "^19.2.14",
169
+ "@types/react-dom": "^19.2.3",
170
+ "jose": "^6.2.3",
171
+ "jsdom": "^29.0.2",
172
+ "react": "^19.2.5",
173
+ "react-dom": "^19.2.5",
174
+ "tsdown": "^0.21.7",
175
+ "typescript": "^6.0.2",
176
+ "vitest": "^4.1.4"
177
+ }
178
+ }