@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Classytic
3
+ Copyright (c) 2025 Classytic LLC
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @classytic/arc-next
2
2
 
3
+ [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](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 { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, PaginatedResult } from "@classytic/repo-core/pagination";
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
- interface PopulateOption {
8
- path: string;
9
- select?: string;
10
- match?: Record<string, unknown>;
11
- }
12
- type SortDirection = 1 | -1 | 'asc' | 'desc';
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 | 'size' | 'type' | 'near' | 'nearSphere' | 'geoWithin' | 'withinRadius' | (string & {});
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 | 'true' | 'false';
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?: 'json' | 'blob' | 'text';
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, 'token' | 'organizationId'>;
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
- client?: ArcClient;
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, 'client'>>;
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, 'token' | 'organizationId'>;
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, 'token' | 'organizationId'>;
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, 'token' | 'organizationId'>;
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, 'token' | 'organizationId'>;
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, 'token' | 'organizationId'>;
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, 'token' | 'organizationId'>;
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, 'token' | 'organizationId'>;
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, 'token' | 'organizationId'>;
203
+ options?: Omit<RequestOptions, "token" | "organizationId">;
220
204
  }): Promise<DeleteResult>;
221
- upload({
222
- token,
223
- organizationId,
224
- data,
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: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', endpoint: string, {
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
- token,
278
- organizationId,
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
- token,
301
- organizationId,
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
- this.requestFn = config.client?.request ?? handleApiRequest;
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: 'offset' | 'keyset' | 'aggregate' | null;
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 */readonly realtime: {
23
+ /** Live data: 20s stale, 30s polling */
24
+ readonly realtime: {
25
25
  readonly staleTime: 20000;
26
26
  readonly refetchInterval: 30000;
27
- }; /** Frequently updated: 60s stale */
27
+ };
28
+ /** Frequently updated: 60s stale */
28
29
  readonly frequent: {
29
30
  readonly staleTime: 60000;
30
- }; /** Stable data: 5min stale (same as default) */
31
+ };
32
+ /** Stable data: 5min stale (same as default) */
31
33
  readonly stable: {
32
34
  readonly staleTime: 300000;
33
- }; /** Rarely changes: 10min stale */
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 };