@classytic/arc-next 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,19 +97,19 @@ All mutations are optimistic with automatic rollback on error. Lists prefill the
97
97
 
98
98
  ### `useApiQuery` — non-CRUD reads
99
99
 
100
- For reports, aggregates, RPC-style endpoints. Auto-unwraps `{ success, data }`:
100
+ For reports, aggregates, RPC-style endpoints. Response IS the data — arc 2.13+ has no envelope:
101
101
 
102
102
  ```ts
103
103
  import { useApiQuery } from "@classytic/arc-next/query";
104
104
 
105
- const { data, isLoading } = useApiQuery<ApiResponse<DashboardStats>>({
105
+ const { data, isLoading } = useApiQuery<DashboardStats>({
106
106
  queryKey: ["dashboard", "stats"],
107
107
  queryFn: ({ signal }) => api.request("GET", "/dashboard/stats", { options: { signal } }),
108
108
  freshness: "realtime", // 'realtime' | 'frequent' | 'stable' | 'static'
109
109
  });
110
110
  ```
111
111
 
112
- Pass a custom `select` to override auto-unwrap.
112
+ Pass a custom `select` to project a sub-field from the response.
113
113
 
114
114
  ## Actions & Custom Routes
115
115
 
@@ -126,14 +126,16 @@ const stats = await api.invokeRoute<{ data: { total: number } }>({
126
126
  method: "GET",
127
127
  path: "/stats",
128
128
  });
129
- const recent = await api.invokeRoute<PaginatedResponse<Todo>>({
129
+ import type { OffsetPaginationResult } from "@classytic/repo-core/pagination";
130
+
131
+ const recent = await api.invokeRoute<OffsetPaginationResult<Todo>>({
130
132
  method: "GET",
131
133
  path: "/recent",
132
134
  params: { limit: 5 },
133
135
  });
134
136
  ```
135
137
 
136
- The `useAction` hook (returned from `createCrudHooks`) wraps `api.dispatchAction()` with toast + invalidation. For custom GETs, compose `api.invokeRoute()` with `useApiQuery` — it auto-unwraps the `{ success, data }` envelope:
138
+ The `useAction` hook (returned from `createCrudHooks`) wraps `api.dispatchAction()` with toast + invalidation. For custom GETs, compose `api.invokeRoute()` with `useApiQuery` — the response IS the data (no envelope since arc 2.13):
137
139
 
138
140
  ```ts
139
141
  const { data } = useApiQuery({
package/dist/api.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { ArcClient } from "./client.js";
2
+ import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, PaginatedResult } from "@classytic/repo-core/pagination";
3
+ import { AggResult, AggRow, BulkCreateResult, DeleteManyResult, DeleteResult, UpdateManyResult } from "@classytic/repo-core/repository";
4
+ import { BracketOperator, BracketOperator as BracketOperator$1 } from "@classytic/repo-core/query-parser";
2
5
 
3
6
  //#region src/api.d.ts
4
7
  interface PopulateOption {
@@ -6,68 +9,24 @@ interface PopulateOption {
6
9
  select?: string;
7
10
  match?: Record<string, unknown>;
8
11
  }
9
- interface ApiResponse<T = unknown> {
10
- success: boolean;
11
- data?: T;
12
- message?: string;
13
- }
14
- interface OffsetPaginationResponse<T = unknown> {
15
- success: boolean;
16
- method: 'offset';
17
- docs: T[];
18
- page: number;
19
- limit: number;
20
- total: number;
21
- pages: number;
22
- hasNext: boolean;
23
- hasPrev: boolean;
24
- warning?: string;
25
- }
26
- interface KeysetPaginationResponse<T = unknown> {
27
- success: boolean;
28
- method: 'keyset';
29
- docs: T[];
30
- limit: number;
31
- hasMore: boolean;
32
- next: string | null;
33
- }
34
- interface AggregatePaginationResponse<T = unknown> {
35
- success: boolean;
36
- method: 'aggregate';
37
- docs: T[];
38
- page: number;
39
- limit: number;
40
- total: number;
41
- pages: number;
42
- hasNext: boolean;
43
- hasPrev: boolean;
44
- warning?: string;
45
- }
46
- type PaginatedResponse<T = unknown> = OffsetPaginationResponse<T> | KeysetPaginationResponse<T> | AggregatePaginationResponse<T>;
47
- interface DeleteResponse {
48
- success: boolean;
49
- data?: {
50
- message?: string;
51
- id?: string;
52
- soft?: boolean;
53
- };
54
- }
55
- interface BulkCreateResponse<T = unknown> {
56
- success: boolean;
57
- data?: T[];
58
- count?: number;
59
- }
60
- interface BulkUpdateResponse {
61
- success: boolean;
62
- modifiedCount?: number;
63
- }
64
- interface BulkDeleteResponse {
65
- success: boolean;
66
- deletedCount?: number;
67
- }
68
12
  type SortDirection = 1 | -1 | 'asc' | 'desc';
69
13
  type SortSpec = Record<string, SortDirection> | string;
70
- type FilterOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'contains' | 'startsWith' | 'endsWith' | 'regex' | 'like' | 'exists' | 'size' | 'type' | 'between' | 'near' | 'nearSphere' | 'geoWithin' | 'withinRadius';
14
+ /**
15
+ * Filter operators supported by arc-next URL emission.
16
+ *
17
+ * Composes:
18
+ * - **Canonical** ({@link BracketOperator}) — every operator repo-core's
19
+ * `parseUrl` reverses. Cross-kit portable: mongokit, sqlitekit, prismakit,
20
+ * and any future kit that consumes the canonical Filter IR all support
21
+ * these out of the box.
22
+ * - **Driver-specific extensions** — operators that require kit-native
23
+ * support. Geo (`near`, `nearSphere`, `geoWithin`, `withinRadius`) is
24
+ * mongokit + sqlitekit-spatialite. `size` / `type` are mongokit
25
+ * array/BSON helpers. Hosts using kits without these features just
26
+ * don't emit them; the union stays open with `(string & {})` so custom
27
+ * domain operators still satisfy the type.
28
+ */
29
+ type FilterOperator = BracketOperator$1 | 'size' | 'type' | 'near' | 'nearSphere' | 'geoWithin' | 'withinRadius' | (string & {});
71
30
  interface QueryParams {
72
31
  page?: number;
73
32
  limit?: number;
@@ -140,7 +99,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
140
99
  organizationId?: string | null;
141
100
  params?: QueryParams;
142
101
  options?: Omit<RequestOptions, 'token' | 'organizationId'>;
143
- }): Promise<PaginatedResponse<TDoc>>;
102
+ }): Promise<PaginatedResult<TDoc>>;
144
103
  getById({
145
104
  token,
146
105
  organizationId,
@@ -156,7 +115,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
156
115
  populate?: string | string[];
157
116
  };
158
117
  options?: Omit<RequestOptions, 'token' | 'organizationId'>;
159
- }): Promise<ApiResponse<TDoc>>;
118
+ }): Promise<TDoc>;
160
119
  create({
161
120
  token,
162
121
  organizationId,
@@ -167,7 +126,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
167
126
  organizationId?: string | null;
168
127
  data: TCreate;
169
128
  options?: Omit<RequestOptions, 'token' | 'organizationId'>;
170
- }): Promise<ApiResponse<TDoc>>;
129
+ }): Promise<TDoc>;
171
130
  update({
172
131
  token,
173
132
  organizationId,
@@ -180,7 +139,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
180
139
  id: string;
181
140
  data: TUpdate;
182
141
  options?: Omit<RequestOptions, 'token' | 'organizationId'>;
183
- }): Promise<ApiResponse<TDoc>>;
142
+ }): Promise<TDoc>;
184
143
  delete({
185
144
  token,
186
145
  organizationId,
@@ -191,7 +150,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
191
150
  organizationId?: string | null;
192
151
  id: string;
193
152
  options?: Omit<RequestOptions, 'token' | 'organizationId'>;
194
- }): Promise<DeleteResponse>;
153
+ }): Promise<DeleteResult>;
195
154
  upload({
196
155
  token,
197
156
  organizationId,
@@ -203,7 +162,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
203
162
  data: FormData; /** Resource ID — shorthand for path, appended as `baseUrl/{id}/upload` */
204
163
  id?: string; /** Custom sub-path appended as `baseUrl/{path}`. Takes precedence over `id`. */
205
164
  path?: string;
206
- }): Promise<ApiResponse<TDoc>>;
165
+ }): Promise<TDoc>;
207
166
  request<TResponse = unknown>(method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', endpoint: string, {
208
167
  token,
209
168
  organizationId,
@@ -223,17 +182,18 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
223
182
  * Arc's escape hatch for endpoints that don't fit CRUD or actions.
224
183
  *
225
184
  * For aggregates / reports, prefer the response-aware {@link useApiQuery} hook
226
- * (which auto-unwraps `{ success, data }`) and pass `invokeRoute` as the queryFn.
185
+ * and pass `invokeRoute` as the queryFn — arc 2.13+ emits raw payloads, so
186
+ * the response IS the data.
227
187
  *
228
188
  * @example
229
- * // GET /todos/stats → { success, data: { total, byStatus } }
189
+ * // GET /todos/stats → { total, byStatus }
230
190
  * const stats = await api.invokeRoute<{ total: number; byStatus: Record<string, number> }>({
231
191
  * method: 'GET',
232
192
  * path: '/stats',
233
193
  * });
234
194
  *
235
195
  * // GET /todos/recent?limit=5 → paginated shape spread to root
236
- * const recent = await api.invokeRoute<PaginatedResponse<Todo>>({
196
+ * const recent = await api.invokeRoute<PaginatedResult<Todo>>({
237
197
  * method: 'GET',
238
198
  * path: '/recent',
239
199
  * params: { limit: 5 },
@@ -260,6 +220,30 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
260
220
  data?: unknown;
261
221
  params?: QueryParams;
262
222
  }): Promise<TResponse>;
223
+ /**
224
+ * Fetch a declared aggregation by name.
225
+ *
226
+ * @example
227
+ * const { rows } = await api.aggregate<{ day: string; total: number }>({
228
+ * name: 'salesByDay',
229
+ * filter: { from: '2025-01-01', to: '2025-12-31' },
230
+ * });
231
+ */
232
+ aggregate<TRow extends AggRow = AggRow>({
233
+ token,
234
+ organizationId,
235
+ name,
236
+ filter,
237
+ options
238
+ }: ScopedArgs & {
239
+ /** Aggregation name as declared on the resource. */name: string;
240
+ /**
241
+ * URL-encoded filter narrows + dimension args. Reserved keys (`page`,
242
+ * `limit`, etc.) are stripped server-side; everything else flows into
243
+ * the AggRequest filter via shallow merge with the host's base filter.
244
+ */
245
+ filter?: Record<string, unknown>;
246
+ }): Promise<AggResult<TRow>>;
263
247
  dispatchAction<TResult = unknown, TBody extends Record<string, unknown> = Record<string, unknown>>({
264
248
  token,
265
249
  organizationId,
@@ -271,12 +255,12 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
271
255
  id: string;
272
256
  action: string;
273
257
  data?: TBody;
274
- }): Promise<ApiResponse<TResult>>;
258
+ }): Promise<TResult>;
275
259
  }
276
260
  declare function createCrudApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>>(entity: string, config?: BaseApiConfig): BaseApi<TDoc, TCreate, TUpdate>;
277
- type ExtractDoc<T> = T extends PaginatedResponse<infer D> ? D : never;
278
- declare function isOffsetPagination<T>(response: PaginatedResponse<T>): response is OffsetPaginationResponse<T>;
279
- declare function isKeysetPagination<T>(response: PaginatedResponse<T>): response is KeysetPaginationResponse<T>;
280
- declare function isAggregatePagination<T>(response: PaginatedResponse<T>): response is AggregatePaginationResponse<T>;
261
+ type ExtractDoc<T> = T extends PaginatedResult<infer D> ? D : never;
262
+ declare function isOffsetPagination<T>(response: PaginatedResult<T>): response is OffsetPaginationResult<T>;
263
+ declare function isKeysetPagination<T>(response: PaginatedResult<T>): response is KeysetPaginationResult<T>;
264
+ declare function isAggregatePagination<T>(response: PaginatedResult<T>): response is AggregatePaginationResult<T>;
281
265
  //#endregion
282
- export { AggregatePaginationResponse, ApiResponse, BaseApi, BaseApiConfig, BulkCreateResponse, BulkDeleteResponse, BulkUpdateResponse, DeleteResponse, ExtractDoc, FilterOperator, KeysetPaginationResponse, OffsetPaginationResponse, PaginatedResponse, PopulateOption, QueryParams, RequestOptions, ScopedArgs, SortDirection, SortSpec, createCrudApi, isAggregatePagination, isKeysetPagination, isOffsetPagination };
266
+ export { type AggResult, type AggRow, BaseApi, BaseApiConfig, type BracketOperator, type BulkCreateResult, type DeleteManyResult, type DeleteResult, ExtractDoc, FilterOperator, PopulateOption, QueryParams, RequestOptions, ScopedArgs, SortDirection, SortSpec, type UpdateManyResult, createCrudApi, isAggregatePagination, isKeysetPagination, isOffsetPagination };
package/dist/api.js CHANGED
@@ -158,17 +158,18 @@ var BaseApi = class {
158
158
  * Arc's escape hatch for endpoints that don't fit CRUD or actions.
159
159
  *
160
160
  * For aggregates / reports, prefer the response-aware {@link useApiQuery} hook
161
- * (which auto-unwraps `{ success, data }`) and pass `invokeRoute` as the queryFn.
161
+ * and pass `invokeRoute` as the queryFn — arc 2.13+ emits raw payloads, so
162
+ * the response IS the data.
162
163
  *
163
164
  * @example
164
- * // GET /todos/stats → { success, data: { total, byStatus } }
165
+ * // GET /todos/stats → { total, byStatus }
165
166
  * const stats = await api.invokeRoute<{ total: number; byStatus: Record<string, number> }>({
166
167
  * method: 'GET',
167
168
  * path: '/stats',
168
169
  * });
169
170
  *
170
171
  * // GET /todos/recent?limit=5 → paginated shape spread to root
171
- * const recent = await api.invokeRoute<PaginatedResponse<Todo>>({
172
+ * const recent = await api.invokeRoute<PaginatedResult<Todo>>({
172
173
  * method: 'GET',
173
174
  * path: '/recent',
174
175
  * params: { limit: 5 },
@@ -193,6 +194,24 @@ var BaseApi = class {
193
194
  options
194
195
  });
195
196
  }
197
+ /**
198
+ * Fetch a declared aggregation by name.
199
+ *
200
+ * @example
201
+ * const { rows } = await api.aggregate<{ day: string; total: number }>({
202
+ * name: 'salesByDay',
203
+ * filter: { from: '2025-01-01', to: '2025-12-31' },
204
+ * });
205
+ */
206
+ async aggregate({ token = null, organizationId = null, name, filter, options = {} }) {
207
+ if (!name) throw new Error("Aggregation name is required");
208
+ const queryString = filter ? this.createQueryString(filter) : "";
209
+ const endpoint = `${this.baseUrl}/aggregations/${name}${queryString ? `?${queryString}` : ""}`;
210
+ const requestOptions = { ...options };
211
+ if (token) requestOptions.token = token;
212
+ if (organizationId) requestOptions.organizationId = organizationId;
213
+ return this.requestFn("GET", endpoint, this.withHeaders(requestOptions));
214
+ }
196
215
  async dispatchAction({ token = null, organizationId = null, id, action, data, options = {} }) {
197
216
  if (!id) throw new Error("ID is required");
198
217
  if (!action) throw new Error("Action name is required");
@@ -212,13 +231,13 @@ function createCrudApi(entity, config = {}) {
212
231
  return new BaseApi(entity, config);
213
232
  }
214
233
  function isOffsetPagination(response) {
215
- return response.method === "offset";
234
+ return "method" in response && response.method === "offset";
216
235
  }
217
236
  function isKeysetPagination(response) {
218
- return response.method === "keyset";
237
+ return "method" in response && response.method === "keyset";
219
238
  }
220
239
  function isAggregatePagination(response) {
221
- return response.method === "aggregate";
240
+ return "method" in response && response.method === "aggregate";
222
241
  }
223
242
 
224
243
  //#endregion
package/dist/cache.d.ts CHANGED
@@ -53,9 +53,10 @@ declare function normalizePagination(data: unknown): PaginationData | null;
53
53
  */
54
54
  declare function extractItems<T>(data: unknown): T[];
55
55
  /**
56
- * Strict detail extractor. Checks well-known keys (`data`, `doc`, `item`,
57
- * `result`); falls back to returning the response as-is. Primitive responses
58
- * (string/number/boolean) pass through.
56
+ * Detail extractor. Arc emits the doc directly (no envelope wrapper) — this
57
+ * function is identity-with-null-guard. Kept as a named helper so callers
58
+ * have a stable seam if a future backend ever ships an envelope, and so
59
+ * `null` / `undefined` responses normalize to `null` consistently.
59
60
  */
60
61
  declare function extractItem<T>(data: unknown): T | null;
61
62
  /**
@@ -74,6 +75,14 @@ interface QueryKeys {
74
75
  scopedDetail: (id: string, organizationId: string | null) => QueryKey;
75
76
  custom: (key: string, ...args: unknown[]) => QueryKey;
76
77
  scopedList: (scope: string, params?: unknown) => QueryKey;
78
+ /** Prefix for every aggregation on this resource — invalidate all at once. */
79
+ aggregations: () => QueryKey;
80
+ /**
81
+ * Aggregation key (`arc 2.13+ /aggregations/:name`). The `filter` arg is
82
+ * structurally hashed by TanStack — pass the same object identity (or
83
+ * structurally identical) you pass to `useAggregation` so the cache hits.
84
+ */
85
+ aggregation: (name: string, filter?: unknown) => QueryKey;
77
86
  }
78
87
  /**
79
88
  * Build a hierarchical query-key factory for a resource. The returned shape
@@ -97,6 +106,14 @@ interface CacheUtils<T> {
97
106
  getScopedDetail: (client: QueryClient, id: string, organizationId: string | null) => T | undefined;
98
107
  /** Remove tenant-scoped detail from cache. */
99
108
  removeScopedDetail: (client: QueryClient, id: string, organizationId: string | null) => void;
109
+ /**
110
+ * Invalidate every aggregation for this resource. Call from mutation
111
+ * `onSuccess` so dashboards refresh after CRUD writes.
112
+ *
113
+ * For targeted invalidation of a single aggregation pass the name —
114
+ * prefix-matches every parameterized variant.
115
+ */
116
+ invalidateAggregations: (client: QueryClient, name?: string) => Promise<void>;
100
117
  }
101
118
  /**
102
119
  * Build cache read/write/invalidate helpers bound to the given key factory.
package/dist/cache.js CHANGED
@@ -7,28 +7,32 @@ const DEFAULT_QUERY_CONFIG = {
7
7
  };
8
8
  /** Pre-built query config presets for common data freshness patterns. */
9
9
  const QUERY_CONFIGS = {
10
+ /** Live data: 20s stale, 30s polling */
10
11
  realtime: {
11
12
  staleTime: 2e4,
12
13
  refetchInterval: 3e4
13
14
  },
15
+ /** Frequently updated: 60s stale */
14
16
  frequent: { staleTime: 6e4 },
17
+ /** Stable data: 5min stale (same as default) */
15
18
  stable: { staleTime: 3e5 },
19
+ /** Rarely changes: 10min stale */
16
20
  static: { staleTime: 6e5 }
17
21
  };
18
- /** Well-known keys checked in order for list responses. */
22
+ /**
23
+ * Well-known keys checked in order for list responses.
24
+ *
25
+ * Arc emits `{data: T[]}` for both paginated and bare-list endpoints, so
26
+ * `docs` is the canonical key. `items` / `results` cover non-arc backends
27
+ * the permissive detector still supports — the any-array fallback below
28
+ * keeps `{products: [...]}` / `{users: [...]}` working without per-resource
29
+ * configuration.
30
+ */
19
31
  const LIST_KEYS = [
20
- "docs",
21
32
  "data",
22
33
  "items",
23
34
  "results"
24
35
  ];
25
- /** Well-known keys checked in order for detail responses. */
26
- const DETAIL_KEYS = [
27
- "data",
28
- "doc",
29
- "item",
30
- "result"
31
- ];
32
36
  /**
33
37
  * Extract `_id` or `id` from any item. Returns `null` if neither exists.
34
38
  * Coerces numeric IDs to strings so cache keys stay consistent.
@@ -78,16 +82,14 @@ function extractItems(data) {
78
82
  return [];
79
83
  }
80
84
  /**
81
- * Strict detail extractor. Checks well-known keys (`data`, `doc`, `item`,
82
- * `result`); falls back to returning the response as-is. Primitive responses
83
- * (string/number/boolean) pass through.
85
+ * Detail extractor. Arc emits the doc directly (no envelope wrapper) — this
86
+ * function is identity-with-null-guard. Kept as a named helper so callers
87
+ * have a stable seam if a future backend ever ships an envelope, and so
88
+ * `null` / `undefined` responses normalize to `null` consistently.
84
89
  */
85
90
  function extractItem(data) {
86
91
  if (data == null) return null;
87
- if (typeof data !== "object") return data;
88
- const d = data;
89
- for (const key of DETAIL_KEYS) if (d[key] != null) return d[key];
90
- return d;
92
+ return data;
91
93
  }
92
94
  /**
93
95
  * Optimistic-update helper that mutates the items array of a list cache
@@ -166,6 +168,17 @@ function createQueryKeys(entityKey) {
166
168
  _scope: scope,
167
169
  ...params
168
170
  }
171
+ ],
172
+ aggregations: () => [entityKey, "aggregation"],
173
+ aggregation: (name, filter) => filter !== void 0 ? [
174
+ entityKey,
175
+ "aggregation",
176
+ name,
177
+ filter
178
+ ] : [
179
+ entityKey,
180
+ "aggregation",
181
+ name
169
182
  ]
170
183
  };
171
184
  }
@@ -189,7 +202,8 @@ function createCacheUtils(KEYS) {
189
202
  getScopedDetail: (client, id, organizationId) => {
190
203
  return client.getQueryData(KEYS.scopedDetail(id, organizationId))?.data;
191
204
  },
192
- removeScopedDetail: (client, id, organizationId) => client.removeQueries({ queryKey: KEYS.scopedDetail(id, organizationId) })
205
+ removeScopedDetail: (client, id, organizationId) => client.removeQueries({ queryKey: KEYS.scopedDetail(id, organizationId) }),
206
+ invalidateAggregations: (client, name) => client.invalidateQueries({ queryKey: name ? KEYS.aggregation(name) : KEYS.aggregations() })
193
207
  };
194
208
  }
195
209