@classytic/arc-next 0.11.1 → 0.13.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/LICENSE +1 -1
- package/README.md +57 -0
- package/dist/api.d.ts +71 -117
- package/dist/api.js +16 -2
- package/dist/cache.d.ts +28 -7
- package/dist/cache.js +52 -1
- package/dist/client.d.ts +126 -13
- package/dist/client.js +159 -24
- package/dist/encryption.d.ts +1 -2
- package/dist/hooks.d.ts +32 -29
- package/dist/hooks.js +99 -27
- package/dist/mutation.d.ts +30 -4
- package/dist/mutation.js +28 -11
- package/dist/prefetch.d.ts +0 -1
- package/dist/presets/bulk.d.ts +0 -1
- package/dist/presets/history.d.ts +1 -2
- package/dist/presets/search.d.ts +17 -8
- package/dist/presets/slug.d.ts +0 -1
- package/dist/presets/soft-delete.d.ts +0 -1
- package/dist/presets/tree.d.ts +0 -1
- package/dist/query-client.d.ts +0 -1
- package/dist/query-options.d.ts +32 -26
- package/dist/query.d.ts +7 -47
- package/dist/sse.d.ts +0 -1
- package/dist/sse.js +4 -3
- package/dist/upload.d.ts +9 -1
- package/dist/upload.js +8 -4
- package/dist/ws.d.ts +1 -2
- package/dist/ws.js +1 -1
- package/package.json +19 -8
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @classytic/arc-next
|
|
2
2
|
|
|
3
|
+
[](https://github.com/sponsors/classytic)
|
|
4
|
+
|
|
3
5
|
React + TanStack Query SDK for the Arc backend framework. Typed CRUD hooks, optimistic updates with rollback, multi-tenant cache scoping, pagination normalization, real-time SSE.
|
|
4
6
|
|
|
5
7
|
**Peers:** React 19+, TanStack React Query 5+
|
|
@@ -37,6 +39,10 @@ configureToast({ success: toast.success, error: toast.error });
|
|
|
37
39
|
configureNavigation(useRouter);
|
|
38
40
|
```
|
|
39
41
|
|
|
42
|
+
Without `configureToast`, mutation feedback is a silent no-op (0.12+) — the SDK never writes to your console; errors still reach `onError` / the rejected promise.
|
|
43
|
+
|
|
44
|
+
> **Targets:** React 19 browser apps + Next.js App Router. React Native is NOT officially supported — the fetch client may work, but SSE needs an `EventSource` polyfill, uploads depend on RN's XHR/FormData behavior, and field encryption needs Web Crypto. File an issue if you need an RN adapter.
|
|
45
|
+
|
|
40
46
|
`getToken` **must be synchronous** — cache async tokens out-of-band. Promise returns are dropped + warned in dev.
|
|
41
47
|
|
|
42
48
|
## Quick Start
|
|
@@ -388,6 +394,44 @@ Pure helpers (`createQueryKeys`, `extractItem`, `updateListCache`, etc.) live in
|
|
|
388
394
|
|
|
389
395
|
TanStack Query manages a client-side cache; data fetched through arc-next hooks should NOT be wrapped in a Server Component's `'use cache'` directive (which would bake the hook output into the static render). Use `'use cache'` for non-arc Server Component fetches (e.g., direct DB queries, third-party APIs). The two layers compose cleanly because they target different cache tiers.
|
|
390
396
|
|
|
397
|
+
## Request-Scoped Server Clients (0.12+)
|
|
398
|
+
|
|
399
|
+
`configureClient` / `configureAuth` set module singletons — correct for the browser, wrong for servers where concurrent requests would share state. On the server, build a **request-scoped** client instead. The SDK never imports `next` or reads cookies itself: your framework code reads the request, the SDK gets plain values.
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
// app/orders/page.tsx (Server Component) — host reads cookies, SDK stays framework-free
|
|
403
|
+
import { cookies } from 'next/headers';
|
|
404
|
+
import { createServerClient } from '@classytic/arc-next/client';
|
|
405
|
+
import { createCrudApi } from '@classytic/arc-next/api';
|
|
406
|
+
|
|
407
|
+
export default async function OrdersPage() {
|
|
408
|
+
const client = createServerClient({
|
|
409
|
+
baseUrl: process.env.API_URL!,
|
|
410
|
+
token: (await cookies()).get('session')?.value ?? null,
|
|
411
|
+
organizationId: null,
|
|
412
|
+
});
|
|
413
|
+
const orders = createCrudApi<Order>('orders', { client });
|
|
414
|
+
const page = await orders.getAll({
|
|
415
|
+
options: { next: { revalidate: 60, tags: ['orders'] } }, // Next fetch-cache passthrough
|
|
416
|
+
});
|
|
417
|
+
// render...
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`next: { tags, revalidate }` and `cache:` are typed pass-throughs to `fetch` — inert on non-Next runtimes, no `next` peer dependency.
|
|
422
|
+
|
|
423
|
+
## Optimistic Updates — the guarantees (0.12+)
|
|
424
|
+
|
|
425
|
+
`useActions()` mutations uphold, in order:
|
|
426
|
+
|
|
427
|
+
1. **Cancel-before-write** — in-flight refetches for affected keys are cancelled before the snapshot, so a late response can't be captured as "previous" state.
|
|
428
|
+
2. **Every affected cache** — detail (bare + org-scoped + parameterized), flat lists, **infinite lists** (per-page; a create inserts into the first page only), while aggregation caches are never optimistically mutated (refetch-only).
|
|
429
|
+
3. **Exact rollback** — on failure every touched entry is restored to its snapshot; untouched entries are never rewritten.
|
|
430
|
+
4. **Temp-ID reconciliation** — `create` inserts a `_optimistic` placeholder with a `temp-…` id, then swaps it in place for the server document on success (and seeds `KEYS.detail(realId)`), so the row never flickers and the real id is immediately navigable.
|
|
431
|
+
5. **Per-record ordering** — sequential `update`/`remove`/`restore` calls to the same record are chained (call order = server order); different records stay parallel.
|
|
432
|
+
6. **Last-standing invalidation** — rapid sequential writes trigger ONE settled refetch (from the last pending write), so an early write's refetch can never overwrite a later write's optimistic state.
|
|
433
|
+
7. **Bulk partial success** — `bulkUpdate`/`bulkRemove` reporting zero changes skip invalidation entirely; `bulkCreate` seeds detail caches from the returned documents.
|
|
434
|
+
|
|
391
435
|
## Errors
|
|
392
436
|
|
|
393
437
|
```ts
|
|
@@ -425,11 +469,17 @@ Network resilience for mutations + direct `handleApiRequest` calls (TanStack Que
|
|
|
425
469
|
```ts
|
|
426
470
|
configureClient({
|
|
427
471
|
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
472
|
+
timeoutMs: 15_000, // per-attempt request timeout; hung fetches fail
|
|
473
|
+
// with a RETRYABLE TimeoutError. Default: disabled.
|
|
474
|
+
// Per-request override: options.timeoutMs (0 disables).
|
|
428
475
|
retry: {
|
|
429
476
|
attempts: 3, // 1 initial + 2 retries; default off
|
|
430
477
|
backoff: 'exponential', // 'exponential' | 'linear' | (attempt) => ms
|
|
478
|
+
jitter: 'full', // randomize delays in [0, computed] — anti-stampede. Default 'none'.
|
|
431
479
|
// retryOn: [502, 503, 504], // optional whitelist; default = network failures + 5xx, never 4xx, never AbortError
|
|
432
480
|
},
|
|
481
|
+
// 429/503 responses with a Retry-After header override computed backoff —
|
|
482
|
+
// the parsed value is also exposed as ArcApiError.retryAfterMs.
|
|
433
483
|
// Mutate outgoing requests (per attempt — retries re-run this)
|
|
434
484
|
beforeRequest: (ctx) => ({
|
|
435
485
|
...ctx,
|
|
@@ -600,3 +650,10 @@ const { mutateAsync: publish, isPending } = useMutationWithTransition({
|
|
|
600
650
|
## License
|
|
601
651
|
|
|
602
652
|
MIT
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
## Trademark
|
|
656
|
+
|
|
657
|
+
The code is MIT-licensed. **"Classytic", "arc", and the logos are trademarks of
|
|
658
|
+
Classytic LLC** and are **not** licensed under MIT — see [TRADEMARK.md](TRADEMARK.md).
|
|
659
|
+
Forks must be renamed; the license covers the code, not the brand.
|
package/dist/api.d.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { ArcClient, NextFetchOptions } from "./client.js";
|
|
2
|
-
import {
|
|
2
|
+
import { BracketOperator, BracketOperator as BracketOperator$1, ParsedPopulate } from "@classytic/repo-core/query-parser";
|
|
3
|
+
import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, PaginatedResult, SortDirection as SortDirection$1 } from "@classytic/repo-core/pagination";
|
|
3
4
|
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";
|
|
5
|
-
|
|
6
5
|
//#region src/api.d.ts
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
/**
|
|
7
|
+
* URL-emittable subset of repo-core's canonical {@link ParsedPopulate} —
|
|
8
|
+
* the exact three fields `populate[path][select]` / `populate[path][match]`
|
|
9
|
+
* bracket emission supports. Derived (not re-declared) so a canonical field
|
|
10
|
+
* rename breaks HERE at compile time instead of silently drifting.
|
|
11
|
+
* `options` / nested `populate` are parse-side-only in repo-core and have
|
|
12
|
+
* no URL grammar, so they're intentionally absent.
|
|
13
|
+
*/
|
|
14
|
+
type PopulateOption = Pick<ParsedPopulate, "path" | "select" | "match">;
|
|
15
|
+
/**
|
|
16
|
+
* Canonical repo-core sort direction (`1 | -1`) plus the URL-ergonomic
|
|
17
|
+
* string forms. The numeric core comes from `@classytic/repo-core/pagination`
|
|
18
|
+
* so the wire vocabulary can't drift; `'asc' | 'desc'` are an SDK-side
|
|
19
|
+
* convenience serialized to the same URL grammar.
|
|
20
|
+
*/
|
|
21
|
+
type SortDirection = SortDirection$1 | "asc" | "desc";
|
|
22
|
+
/** Sort spec: record form (repo-core `SortSpec` widened to string directions) or the raw URL string. */
|
|
13
23
|
type SortSpec = Record<string, SortDirection> | string;
|
|
14
24
|
/**
|
|
15
25
|
* Filter operators supported by arc-next URL emission.
|
|
@@ -26,17 +36,23 @@ type SortSpec = Record<string, SortDirection> | string;
|
|
|
26
36
|
* don't emit them; the union stays open with `(string & {})` so custom
|
|
27
37
|
* domain operators still satisfy the type.
|
|
28
38
|
*/
|
|
29
|
-
type FilterOperator = BracketOperator$1 |
|
|
39
|
+
type FilterOperator = BracketOperator$1 | "size" | "type" | "near" | "nearSphere" | "geoWithin" | "withinRadius" | (string & {});
|
|
30
40
|
interface QueryParams {
|
|
31
41
|
page?: number;
|
|
32
42
|
limit?: number;
|
|
43
|
+
/** Keyset cursor — the CANONICAL wire param (repo-core `STANDARD_RESERVED_PARAMS`). */
|
|
33
44
|
after?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Alias for {@link QueryParams.after}. The server only knows `after`;
|
|
47
|
+
* `prepareParams` rewrites `cursor` → `after` at emission (explicit
|
|
48
|
+
* `after` wins when both are set). Prefer `after` in new code.
|
|
49
|
+
*/
|
|
34
50
|
cursor?: string;
|
|
35
51
|
sort?: string;
|
|
36
52
|
select?: string;
|
|
37
53
|
populate?: string | string[];
|
|
38
54
|
populateOptions?: PopulateOption[];
|
|
39
|
-
lean?: boolean |
|
|
55
|
+
lean?: boolean | "true" | "false";
|
|
40
56
|
/** Database-agnostic joins. Maps alias → collection or full lookup config. */
|
|
41
57
|
lookup?: Record<string, string | {
|
|
42
58
|
from: string;
|
|
@@ -59,7 +75,7 @@ interface RequestOptions {
|
|
|
59
75
|
*/
|
|
60
76
|
next?: NextFetchOptions;
|
|
61
77
|
headerOptions?: Record<string, string>;
|
|
62
|
-
responseType?:
|
|
78
|
+
responseType?: "json" | "blob" | "text";
|
|
63
79
|
signal?: AbortSignal;
|
|
64
80
|
}
|
|
65
81
|
/**
|
|
@@ -72,7 +88,7 @@ interface RequestOptions {
|
|
|
72
88
|
interface ScopedArgs {
|
|
73
89
|
token?: string | null;
|
|
74
90
|
organizationId?: string | null;
|
|
75
|
-
options?: Omit<RequestOptions,
|
|
91
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
76
92
|
}
|
|
77
93
|
interface BaseApiConfig {
|
|
78
94
|
basePath?: string;
|
|
@@ -83,11 +99,22 @@ interface BaseApiConfig {
|
|
|
83
99
|
};
|
|
84
100
|
cache?: RequestCache;
|
|
85
101
|
headers?: Record<string, string>;
|
|
86
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Transport for this API instance.
|
|
104
|
+
*
|
|
105
|
+
* - `ArcClient` — RETAINED: the instance is captured and every request goes
|
|
106
|
+
* through it (isolated multi-client setups, tests, background jobs).
|
|
107
|
+
* - `() => ArcClient` — PROVIDER: re-resolved on EVERY request, so a
|
|
108
|
+
* consumer SDK can swap/reconfigure the underlying client after APIs are
|
|
109
|
+
* constructed without Proxy tricks or stale-capture bugs (the reason the
|
|
110
|
+
* commerce SDK previously wrapped its default client in a `Proxy`).
|
|
111
|
+
* - omitted — the module-global transport (`configureClient`/`configureAuth`).
|
|
112
|
+
*/
|
|
113
|
+
client?: ArcClient | (() => ArcClient);
|
|
87
114
|
}
|
|
88
115
|
declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>> {
|
|
89
116
|
readonly entity: string;
|
|
90
|
-
readonly config: Required<Omit<BaseApiConfig,
|
|
117
|
+
readonly config: Required<Omit<BaseApiConfig, "client">>;
|
|
91
118
|
readonly baseUrl: string;
|
|
92
119
|
private readonly requestFn;
|
|
93
120
|
constructor(entity: string, config?: BaseApiConfig);
|
|
@@ -112,68 +139,41 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
|
|
|
112
139
|
private withCacheDefault;
|
|
113
140
|
createQueryString(params?: Record<string, unknown>): string;
|
|
114
141
|
prepareParams(params?: QueryParams): Record<string, unknown>;
|
|
115
|
-
getAll({
|
|
116
|
-
token,
|
|
117
|
-
organizationId,
|
|
118
|
-
params,
|
|
119
|
-
options
|
|
120
|
-
}?: {
|
|
142
|
+
getAll({ token, organizationId, params, options }?: {
|
|
121
143
|
token?: string | null;
|
|
122
144
|
organizationId?: string | null;
|
|
123
145
|
params?: QueryParams;
|
|
124
|
-
options?: Omit<RequestOptions,
|
|
146
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
125
147
|
}): Promise<PaginatedResult<TDoc>>;
|
|
126
148
|
/**
|
|
127
149
|
* Count records matching the filters — arc's list-route dispatch verb
|
|
128
150
|
* (`?_count=true`): same permissions/row-filters/tenant scoping as
|
|
129
151
|
* `getAll`, ZERO documents fetched. Cheapest way to answer "how many".
|
|
130
152
|
*/
|
|
131
|
-
count({
|
|
132
|
-
token,
|
|
133
|
-
organizationId,
|
|
134
|
-
params,
|
|
135
|
-
options
|
|
136
|
-
}?: {
|
|
153
|
+
count({ token, organizationId, params, options }?: {
|
|
137
154
|
token?: string | null;
|
|
138
155
|
organizationId?: string | null;
|
|
139
156
|
params?: QueryParams;
|
|
140
|
-
options?: Omit<RequestOptions,
|
|
157
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
141
158
|
}): Promise<number>;
|
|
142
159
|
/** Whether ANY record matches the filters (`?_exists=true`). */
|
|
143
|
-
exists({
|
|
144
|
-
token,
|
|
145
|
-
organizationId,
|
|
146
|
-
params,
|
|
147
|
-
options
|
|
148
|
-
}?: {
|
|
160
|
+
exists({ token, organizationId, params, options }?: {
|
|
149
161
|
token?: string | null;
|
|
150
162
|
organizationId?: string | null;
|
|
151
163
|
params?: QueryParams;
|
|
152
|
-
options?: Omit<RequestOptions,
|
|
164
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
153
165
|
}): Promise<boolean>;
|
|
154
166
|
/** 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
|
-
}: {
|
|
167
|
+
distinct<TValue = unknown>({ token, organizationId, field, params, options }: {
|
|
162
168
|
token?: string | null;
|
|
163
169
|
organizationId?: string | null;
|
|
164
170
|
field: string;
|
|
165
171
|
params?: QueryParams;
|
|
166
|
-
options?: Omit<RequestOptions,
|
|
172
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
167
173
|
}): Promise<TValue[]>;
|
|
168
174
|
/** Shared raw GET against the list route (dispatch verbs bypass pagination parsing). */
|
|
169
175
|
private getAllRaw;
|
|
170
|
-
getById({
|
|
171
|
-
token,
|
|
172
|
-
organizationId,
|
|
173
|
-
id,
|
|
174
|
-
params,
|
|
175
|
-
options
|
|
176
|
-
}: {
|
|
176
|
+
getById({ token, organizationId, id, params, options }: {
|
|
177
177
|
token?: string | null;
|
|
178
178
|
organizationId?: string | null;
|
|
179
179
|
id: string;
|
|
@@ -181,62 +181,35 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
|
|
|
181
181
|
select?: string;
|
|
182
182
|
populate?: string | string[];
|
|
183
183
|
};
|
|
184
|
-
options?: Omit<RequestOptions,
|
|
184
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
185
185
|
}): Promise<TDoc>;
|
|
186
|
-
create({
|
|
187
|
-
token,
|
|
188
|
-
organizationId,
|
|
189
|
-
data,
|
|
190
|
-
options
|
|
191
|
-
}: {
|
|
186
|
+
create({ token, organizationId, data, options }: {
|
|
192
187
|
token?: string | null;
|
|
193
188
|
organizationId?: string | null;
|
|
194
189
|
data: TCreate;
|
|
195
|
-
options?: Omit<RequestOptions,
|
|
190
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
196
191
|
}): Promise<TDoc>;
|
|
197
|
-
update({
|
|
198
|
-
token,
|
|
199
|
-
organizationId,
|
|
200
|
-
id,
|
|
201
|
-
data,
|
|
202
|
-
options
|
|
203
|
-
}: {
|
|
192
|
+
update({ token, organizationId, id, data, options }: {
|
|
204
193
|
token?: string | null;
|
|
205
194
|
organizationId?: string | null;
|
|
206
195
|
id: string;
|
|
207
196
|
data: TUpdate;
|
|
208
|
-
options?: Omit<RequestOptions,
|
|
197
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
209
198
|
}): Promise<TDoc>;
|
|
210
|
-
delete({
|
|
211
|
-
token,
|
|
212
|
-
organizationId,
|
|
213
|
-
id,
|
|
214
|
-
options
|
|
215
|
-
}: {
|
|
199
|
+
delete({ token, organizationId, id, options }: {
|
|
216
200
|
token?: string | null;
|
|
217
201
|
organizationId?: string | null;
|
|
218
202
|
id: string;
|
|
219
|
-
options?: Omit<RequestOptions,
|
|
203
|
+
options?: Omit<RequestOptions, "token" | "organizationId">;
|
|
220
204
|
}): Promise<DeleteResult>;
|
|
221
|
-
upload({
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
id
|
|
226
|
-
path,
|
|
227
|
-
options
|
|
228
|
-
}: ScopedArgs & {
|
|
229
|
-
data: FormData; /** Resource ID — shorthand for path, appended as `baseUrl/{id}/upload` */
|
|
230
|
-
id?: string; /** Custom sub-path appended as `baseUrl/{path}`. Takes precedence over `id`. */
|
|
205
|
+
upload({ token, organizationId, data, id, path, options }: ScopedArgs & {
|
|
206
|
+
data: FormData;
|
|
207
|
+
/** Resource ID — shorthand for path, appended as `baseUrl/{id}/upload` */
|
|
208
|
+
id?: string;
|
|
209
|
+
/** Custom sub-path appended as `baseUrl/{path}`. Takes precedence over `id`. */
|
|
231
210
|
path?: string;
|
|
232
211
|
}): Promise<TDoc>;
|
|
233
|
-
request<TResponse = unknown>(method:
|
|
234
|
-
token,
|
|
235
|
-
organizationId,
|
|
236
|
-
data,
|
|
237
|
-
params,
|
|
238
|
-
options
|
|
239
|
-
}?: ScopedArgs & {
|
|
212
|
+
request<TResponse = unknown>(method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE", endpoint: string, { token, organizationId, data, params, options }?: ScopedArgs & {
|
|
240
213
|
data?: unknown;
|
|
241
214
|
params?: QueryParams;
|
|
242
215
|
}): Promise<TResponse>;
|
|
@@ -273,16 +246,9 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
|
|
|
273
246
|
* data: { source: 'csv', items: [...] },
|
|
274
247
|
* });
|
|
275
248
|
*/
|
|
276
|
-
invokeRoute<TResponse = unknown>({
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
method,
|
|
280
|
-
path,
|
|
281
|
-
data,
|
|
282
|
-
params,
|
|
283
|
-
options
|
|
284
|
-
}: ScopedArgs & {
|
|
285
|
-
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; /** Path relative to the resource baseUrl. Leading slash optional. */
|
|
249
|
+
invokeRoute<TResponse = unknown>({ token, organizationId, method, path, data, params, options }: ScopedArgs & {
|
|
250
|
+
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
251
|
+
/** Path relative to the resource baseUrl. Leading slash optional. */
|
|
286
252
|
path: string;
|
|
287
253
|
data?: unknown;
|
|
288
254
|
params?: QueryParams;
|
|
@@ -296,14 +262,9 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
|
|
|
296
262
|
* filter: { from: '2025-01-01', to: '2025-12-31' },
|
|
297
263
|
* });
|
|
298
264
|
*/
|
|
299
|
-
aggregate<TRow extends AggRow = AggRow>({
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
name,
|
|
303
|
-
filter,
|
|
304
|
-
options
|
|
305
|
-
}: ScopedArgs & {
|
|
306
|
-
/** Aggregation name as declared on the resource. */name: string;
|
|
265
|
+
aggregate<TRow extends AggRow = AggRow>({ token, organizationId, name, filter, options }: ScopedArgs & {
|
|
266
|
+
/** Aggregation name as declared on the resource. */
|
|
267
|
+
name: string;
|
|
307
268
|
/**
|
|
308
269
|
* URL-encoded filter narrows + dimension args. Reserved keys (`page`,
|
|
309
270
|
* `limit`, etc.) are stripped server-side; everything else flows into
|
|
@@ -311,14 +272,7 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
|
|
|
311
272
|
*/
|
|
312
273
|
filter?: Record<string, unknown>;
|
|
313
274
|
}): Promise<AggResult<TRow>>;
|
|
314
|
-
dispatchAction<TResult = unknown, TBody extends Record<string, unknown> = Record<string, unknown>>({
|
|
315
|
-
token,
|
|
316
|
-
organizationId,
|
|
317
|
-
id,
|
|
318
|
-
action,
|
|
319
|
-
data,
|
|
320
|
-
options
|
|
321
|
-
}: ScopedArgs & {
|
|
275
|
+
dispatchAction<TResult = unknown, TBody extends Record<string, unknown> = Record<string, unknown>>({ token, organizationId, id, action, data, options }: ScopedArgs & {
|
|
322
276
|
id: string;
|
|
323
277
|
action: string;
|
|
324
278
|
data?: TBody;
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { createQueryString, handleApiRequest } from "./client.js";
|
|
2
|
+
import { STANDARD_RESERVED_PARAMS } from "@classytic/repo-core/query-parser";
|
|
2
3
|
|
|
3
4
|
//#region src/api.ts
|
|
5
|
+
for (const verb of [
|
|
6
|
+
"_count",
|
|
7
|
+
"_exists",
|
|
8
|
+
"_distinct"
|
|
9
|
+
]) if (!STANDARD_RESERVED_PARAMS.has(verb)) throw new Error(`[arc-next] dispatch verb '${verb}' is not in repo-core STANDARD_RESERVED_PARAMS`);
|
|
4
10
|
var BaseApi = class {
|
|
5
11
|
entity;
|
|
6
12
|
config;
|
|
@@ -8,7 +14,8 @@ var BaseApi = class {
|
|
|
8
14
|
requestFn;
|
|
9
15
|
constructor(entity, config = {}) {
|
|
10
16
|
this.entity = entity;
|
|
11
|
-
|
|
17
|
+
const client = config.client;
|
|
18
|
+
this.requestFn = typeof client === "function" ? (method, endpoint, options) => client().request(method, endpoint, options) : client?.request ?? handleApiRequest;
|
|
12
19
|
this.config = {
|
|
13
20
|
basePath: config.basePath ?? "/api/v1",
|
|
14
21
|
defaultParams: {
|
|
@@ -62,6 +69,13 @@ var BaseApi = class {
|
|
|
62
69
|
prepareParams(params = {}) {
|
|
63
70
|
const result = {};
|
|
64
71
|
const CRITICAL_FILTERS = ["organizationId", "ownerId"];
|
|
72
|
+
if (params.cursor !== void 0) {
|
|
73
|
+
const { cursor, ...rest } = params;
|
|
74
|
+
params = params.after !== void 0 ? rest : {
|
|
75
|
+
...rest,
|
|
76
|
+
after: cursor
|
|
77
|
+
};
|
|
78
|
+
}
|
|
65
79
|
Object.entries(params).forEach(([key, value]) => {
|
|
66
80
|
if (CRITICAL_FILTERS.includes(key)) {
|
|
67
81
|
result[key] = value || null;
|
|
@@ -84,7 +98,7 @@ var BaseApi = class {
|
|
|
84
98
|
});
|
|
85
99
|
return;
|
|
86
100
|
}
|
|
87
|
-
if (value !== void 0 && value !== "") if (["page", "limit"].includes(key)) result[key] = parseInt(String(value)) || (key === "page" ? 1 : 10);
|
|
101
|
+
if (value !== void 0 && value !== "") if (["page", "limit"].includes(key)) result[key] = parseInt(String(value), 10) || (key === "page" ? 1 : 10);
|
|
88
102
|
else if (Array.isArray(value)) {
|
|
89
103
|
if (/\[([^\]]+)\]$/.test(key)) result[key] = value.join(",");
|
|
90
104
|
else if (value.length > 1) result[`${key}[in]`] = value.join(",");
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
2
|
-
|
|
3
2
|
//#region src/cache.d.ts
|
|
4
3
|
interface PaginationData {
|
|
5
4
|
/** Pagination method detected from response (offset | keyset | aggregate) */
|
|
6
|
-
method:
|
|
5
|
+
method: "offset" | "keyset" | "aggregate" | null;
|
|
7
6
|
total: number;
|
|
8
7
|
pages: number;
|
|
9
8
|
page: number;
|
|
@@ -21,16 +20,20 @@ declare const DEFAULT_QUERY_CONFIG: {
|
|
|
21
20
|
};
|
|
22
21
|
/** Pre-built query config presets for common data freshness patterns. */
|
|
23
22
|
declare const QUERY_CONFIGS: {
|
|
24
|
-
/** Live data: 20s stale, 30s polling */
|
|
23
|
+
/** Live data: 20s stale, 30s polling */
|
|
24
|
+
readonly realtime: {
|
|
25
25
|
readonly staleTime: 20000;
|
|
26
26
|
readonly refetchInterval: 30000;
|
|
27
|
-
};
|
|
27
|
+
};
|
|
28
|
+
/** Frequently updated: 60s stale */
|
|
28
29
|
readonly frequent: {
|
|
29
30
|
readonly staleTime: 60000;
|
|
30
|
-
};
|
|
31
|
+
};
|
|
32
|
+
/** Stable data: 5min stale (same as default) */
|
|
31
33
|
readonly stable: {
|
|
32
34
|
readonly staleTime: 300000;
|
|
33
|
-
};
|
|
35
|
+
};
|
|
36
|
+
/** Rarely changes: 10min stale */
|
|
34
37
|
readonly static: {
|
|
35
38
|
readonly staleTime: 600000;
|
|
36
39
|
};
|
|
@@ -65,6 +68,24 @@ declare function extractItem<T>(data: unknown): T | null;
|
|
|
65
68
|
* the array length changes.
|
|
66
69
|
*/
|
|
67
70
|
declare function updateListCache<T>(listData: unknown, updater: (items: T[]) => T[]): unknown;
|
|
71
|
+
/**
|
|
72
|
+
* Insert an item at the head of a list cache entry — the optimistic-create
|
|
73
|
+
* primitive. Unlike {@link updateListCache} with a prepending updater (which,
|
|
74
|
+
* on an infinite cache, would insert into EVERY page), this targets exactly
|
|
75
|
+
* one location: the first page of an infinite cache, or the single payload of
|
|
76
|
+
* a flat list. Totals adjust via `updateListCache`'s existing delta logic.
|
|
77
|
+
*/
|
|
78
|
+
declare function prependToListCache(listData: unknown, item: unknown): unknown;
|
|
79
|
+
/**
|
|
80
|
+
* Replace an item (matched by id) across a list cache entry — the
|
|
81
|
+
* temp-ID-reconciliation primitive. After a create succeeds, the optimistic
|
|
82
|
+
* placeholder (`temp-…` id) is swapped for the server document in place, so
|
|
83
|
+
* the UI keeps the row (no flicker) while the id becomes real. Works on flat
|
|
84
|
+
* and infinite shapes via {@link updateListCache}.
|
|
85
|
+
*/
|
|
86
|
+
declare function replaceItemInListCache(listData: unknown, matchId: string, replacement: unknown, opts?: {
|
|
87
|
+
idField?: string;
|
|
88
|
+
}): unknown;
|
|
68
89
|
/**
|
|
69
90
|
* After a detail fetch lands, propagate the fresh values into every cached
|
|
70
91
|
* list entry that contains this item. The item's ID is resolved via
|
|
@@ -163,4 +184,4 @@ interface CacheUtils<T> {
|
|
|
163
184
|
*/
|
|
164
185
|
declare function createCacheUtils<T>(KEYS: QueryKeys): CacheUtils<T>;
|
|
165
186
|
//#endregion
|
|
166
|
-
export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache, withOrgParams };
|
|
187
|
+
export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, prependToListCache, replaceItemInListCache, syncDetailToLists, updateListCache, withOrgParams };
|
package/dist/cache.js
CHANGED
|
@@ -101,6 +101,18 @@ function updateListCache(listData, updater) {
|
|
|
101
101
|
if (Array.isArray(listData)) return updater(listData);
|
|
102
102
|
if (typeof listData !== "object") return listData;
|
|
103
103
|
const d = listData;
|
|
104
|
+
if (Array.isArray(d.pages) && Array.isArray(d.pageParams)) {
|
|
105
|
+
let changed = false;
|
|
106
|
+
const nextPages = d.pages.map((page) => {
|
|
107
|
+
const next = updateListCache(page, updater);
|
|
108
|
+
if (next !== page) changed = true;
|
|
109
|
+
return next;
|
|
110
|
+
});
|
|
111
|
+
return changed ? {
|
|
112
|
+
...d,
|
|
113
|
+
pages: nextPages
|
|
114
|
+
} : listData;
|
|
115
|
+
}
|
|
104
116
|
let arrayField = null;
|
|
105
117
|
for (const key of LIST_KEYS) if (Array.isArray(d[key])) {
|
|
106
118
|
arrayField = key;
|
|
@@ -127,6 +139,45 @@ function updateListCache(listData, updater) {
|
|
|
127
139
|
}
|
|
128
140
|
return result;
|
|
129
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Insert an item at the head of a list cache entry — the optimistic-create
|
|
144
|
+
* primitive. Unlike {@link updateListCache} with a prepending updater (which,
|
|
145
|
+
* on an infinite cache, would insert into EVERY page), this targets exactly
|
|
146
|
+
* one location: the first page of an infinite cache, or the single payload of
|
|
147
|
+
* a flat list. Totals adjust via `updateListCache`'s existing delta logic.
|
|
148
|
+
*/
|
|
149
|
+
function prependToListCache(listData, item) {
|
|
150
|
+
if (!listData) return listData;
|
|
151
|
+
if (typeof listData === "object" && !Array.isArray(listData) && Array.isArray(listData.pages) && Array.isArray(listData.pageParams)) {
|
|
152
|
+
const d = listData;
|
|
153
|
+
if (d.pages.length === 0) return listData;
|
|
154
|
+
const first = updateListCache(d.pages[0], (items) => [item, ...items || []]);
|
|
155
|
+
if (first === d.pages[0]) return listData;
|
|
156
|
+
return {
|
|
157
|
+
...d,
|
|
158
|
+
pages: [first, ...d.pages.slice(1)]
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return updateListCache(listData, (items) => [item, ...items || []]);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Replace an item (matched by id) across a list cache entry — the
|
|
165
|
+
* temp-ID-reconciliation primitive. After a create succeeds, the optimistic
|
|
166
|
+
* placeholder (`temp-…` id) is swapped for the server document in place, so
|
|
167
|
+
* the UI keeps the row (no flicker) while the id becomes real. Works on flat
|
|
168
|
+
* and infinite shapes via {@link updateListCache}.
|
|
169
|
+
*/
|
|
170
|
+
function replaceItemInListCache(listData, matchId, replacement, opts = {}) {
|
|
171
|
+
return updateListCache(listData, (items) => {
|
|
172
|
+
let changed = false;
|
|
173
|
+
const next = items.map((item) => {
|
|
174
|
+
if ((opts.idField ? String(item?.[opts.idField] ?? "") || getItemId(item) : getItemId(item)) !== matchId) return item;
|
|
175
|
+
changed = true;
|
|
176
|
+
return replacement;
|
|
177
|
+
});
|
|
178
|
+
return changed ? next : items;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
130
181
|
/** Item-identity helper that respects a custom idField, falling back to `_id` / `id`. */
|
|
131
182
|
function resolveId(item, idField) {
|
|
132
183
|
if (!item || typeof item !== "object") return null;
|
|
@@ -320,4 +371,4 @@ function createCacheUtils(KEYS) {
|
|
|
320
371
|
}
|
|
321
372
|
|
|
322
373
|
//#endregion
|
|
323
|
-
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache, withOrgParams };
|
|
374
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, prependToListCache, replaceItemInListCache, syncDetailToLists, updateListCache, withOrgParams };
|