@classytic/arc-next 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/api.d.ts +44 -0
- package/dist/api.js +66 -0
- package/dist/client.d.ts +40 -1
- package/dist/client.js +30 -1
- package/dist/hooks.d.ts +7 -1
- package/dist/hooks.js +18 -0
- package/dist/mutation.js +4 -2
- package/dist/presets/history.d.ts +56 -0
- package/dist/presets/history.js +29 -0
- package/dist/query-client.js +2 -1
- package/llms.txt +22 -0
- package/package.json +8 -3
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
|
@@ -21,7 +21,7 @@ import { QueryKey, UseQueryResult } from "@tanstack/react-query";
|
|
|
21
21
|
* any cast — yet a vanilla `createCrudApi('todos')` instance has none of them
|
|
22
22
|
* in autocomplete unless you opt in.
|
|
23
23
|
*/
|
|
24
|
-
type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete'> & {
|
|
24
|
+
type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete' | 'count'> & {
|
|
25
25
|
upload?: BaseApi<T, TCreate, TUpdate>['upload'];
|
|
26
26
|
dispatchAction?: BaseApi<T, TCreate, TUpdate>['dispatchAction'];
|
|
27
27
|
invokeRoute?: BaseApi<T, TCreate, TUpdate>['invokeRoute']; /** Declared aggregations (arc 2.13+). Always available on BaseApi. */
|
|
@@ -207,6 +207,12 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
|
|
|
207
207
|
useActions: () => CrudActions<T, TCreate, TUpdate>;
|
|
208
208
|
useBulkActions: () => BulkActions<T, TCreate>;
|
|
209
209
|
useDeleted: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
|
|
210
|
+
/** Count-only query via arc's `?_count=true` dispatch verb — zero documents fetched. */
|
|
211
|
+
useCount: (params?: Record<string, unknown>, options?: {
|
|
212
|
+
enabled?: boolean;
|
|
213
|
+
staleTime?: number;
|
|
214
|
+
gcTime?: number;
|
|
215
|
+
}) => UseQueryResult<number, Error>;
|
|
210
216
|
useDetailBySlug: (slug: string | null, options?: DetailQueryOptions<T>) => DetailQueryResult<T>;
|
|
211
217
|
useTree: (params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
|
|
212
218
|
useChildren: (parentId: string | null, params?: Record<string, unknown>, options?: ListQueryOptions<T>) => ListQueryResult<T>;
|
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,
|
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
|
-
|
|
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 };
|
package/dist/query-client.js
CHANGED
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "React + TanStack Query SDK for Arc resources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -90,6 +90,10 @@
|
|
|
90
90
|
"types": "./dist/presets/soft-delete.d.ts",
|
|
91
91
|
"default": "./dist/presets/soft-delete.js"
|
|
92
92
|
},
|
|
93
|
+
"./presets/history": {
|
|
94
|
+
"types": "./dist/presets/history.d.ts",
|
|
95
|
+
"default": "./dist/presets/history.js"
|
|
96
|
+
},
|
|
93
97
|
"./presets/bulk": {
|
|
94
98
|
"types": "./dist/presets/bulk.d.ts",
|
|
95
99
|
"default": "./dist/presets/bulk.js"
|
|
@@ -113,7 +117,8 @@
|
|
|
113
117
|
}
|
|
114
118
|
},
|
|
115
119
|
"files": [
|
|
116
|
-
"dist"
|
|
120
|
+
"dist",
|
|
121
|
+
"llms.txt"
|
|
117
122
|
],
|
|
118
123
|
"publishConfig": {
|
|
119
124
|
"access": "public",
|
|
@@ -170,4 +175,4 @@
|
|
|
170
175
|
"typescript": "^6.0.2",
|
|
171
176
|
"vitest": "^4.1.4"
|
|
172
177
|
}
|
|
173
|
-
}
|
|
178
|
+
}
|