@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/dist/hooks.d.ts CHANGED
@@ -2,15 +2,14 @@ import { ArcClient, UseRouterHook } from "./client.js";
2
2
  import { AggResult, AggRow, BaseApi } from "./api.js";
3
3
  import { CacheUtils, QueryKeys } from "./cache.js";
4
4
  import { MutationCallbacks, MutationMessages, TransitionMutationReturn } from "./mutation.js";
5
- import { DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, RequestPassthrough } from "./query.js";
6
- import { SoftDeleteMethods } from "./presets/soft-delete.js";
7
5
  import { BulkMethods } from "./presets/bulk.js";
6
+ import { SearchPresetMethods } from "./presets/search.js";
8
7
  import { SlugLookupMethods } from "./presets/slug.js";
8
+ import { SoftDeleteMethods } from "./presets/soft-delete.js";
9
9
  import { TreeMethods } from "./presets/tree.js";
10
- import { SearchPresetMethods } from "./presets/search.js";
10
+ import { DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, RequestPassthrough } from "./query.js";
11
11
  import { QueryKey, UseQueryResult } from "@tanstack/react-query";
12
12
  import { PaginatedResult } from "@classytic/repo-core/pagination";
13
-
14
13
  //#region src/hooks.d.ts
15
14
  /**
16
15
  * CRUD API interface accepted by createCrudHooks.
@@ -22,11 +21,12 @@ import { PaginatedResult } from "@classytic/repo-core/pagination";
22
21
  * any cast — yet a vanilla `createCrudApi('todos')` instance has none of them
23
22
  * in autocomplete unless you opt in.
24
23
  */
25
- type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, 'getAll' | 'getById' | 'create' | 'update' | 'delete' | 'count'> & {
26
- upload?: BaseApi<T, TCreate, TUpdate>['upload'];
27
- dispatchAction?: BaseApi<T, TCreate, TUpdate>['dispatchAction'];
28
- invokeRoute?: BaseApi<T, TCreate, TUpdate>['invokeRoute']; /** Declared aggregations (arc 2.13+). Always available on BaseApi. */
29
- aggregate?: BaseApi<T, TCreate, TUpdate>['aggregate'];
24
+ type CrudApi<T = unknown, TCreate = Partial<T>, TUpdate = Partial<T>> = Pick<BaseApi<T, TCreate, TUpdate>, "getAll" | "getById" | "create" | "update" | "delete" | "count"> & {
25
+ upload?: BaseApi<T, TCreate, TUpdate>["upload"];
26
+ dispatchAction?: BaseApi<T, TCreate, TUpdate>["dispatchAction"];
27
+ invokeRoute?: BaseApi<T, TCreate, TUpdate>["invokeRoute"];
28
+ /** Declared aggregations (arc 2.13+). Always available on BaseApi. */
29
+ aggregate?: BaseApi<T, TCreate, TUpdate>["aggregate"];
30
30
  } & Partial<SoftDeleteMethods<T>> & Partial<BulkMethods<T, TCreate, TUpdate>> & Partial<SlugLookupMethods<T>> & Partial<TreeMethods<T>> & Partial<SearchPresetMethods<T>>;
31
31
  /** Args + options for `useAggregation`. Mirrors `ListQueryOptions` for DX consistency. */
32
32
  interface AggregationQueryOptions<TRow extends AggRow = AggRow, TData = AggResult<TRow>> {
@@ -182,11 +182,15 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
182
182
  KEYS: QueryKeys;
183
183
  cache: CacheUtils<T>;
184
184
  useList: {
185
- /** New signature — auto-injects token/orgId from configureAuth() context */(params?: Record<string, unknown>, options?: ListQueryOptions<T>): ListQueryResult<T>; /** Legacy signature — explicit token */
185
+ /** New signature — auto-injects token/orgId from configureAuth() context */
186
+ (params?: Record<string, unknown>, options?: ListQueryOptions<T>): ListQueryResult<T>;
187
+ /** Legacy signature — explicit token */
186
188
  (token: string | null, params?: Record<string, unknown>, options?: ListQueryOptions<T>): ListQueryResult<T>;
187
189
  };
188
190
  useDetail: {
189
- /** New signature — auto-injects token from configureAuth() context */(id: string | null, options?: DetailQueryOptions<T>): DetailQueryResult<T>; /** Legacy signature — explicit token */
191
+ /** New signature — auto-injects token from configureAuth() context */
192
+ (id: string | null, options?: DetailQueryOptions<T>): DetailQueryResult<T>;
193
+ /** Legacy signature — explicit token */
190
194
  (id: string | null, token: string | null, options?: DetailQueryOptions<T>): DetailQueryResult<T>;
191
195
  };
192
196
  /**
@@ -202,7 +206,9 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
202
206
  */
203
207
  useSuspenseDetail: (id: string, options?: Omit<DetailQueryOptions<T>, "enabled">) => DetailQueryResult<T>;
204
208
  useInfiniteList: {
205
- /** New signature — auto-injects token/orgId from configureAuth() context */(params?: Record<string, unknown>, options?: InfiniteListQueryOptions): InfiniteListQueryResult<T>; /** Legacy signature — explicit token */
209
+ /** New signature — auto-injects token/orgId from configureAuth() context */
210
+ (params?: Record<string, unknown>, options?: InfiniteListQueryOptions): InfiniteListQueryResult<T>;
211
+ /** Legacy signature — explicit token */
206
212
  (token: string | null, params?: Record<string, unknown>, options?: InfiniteListQueryOptions): InfiniteListQueryResult<T>;
207
213
  };
208
214
  useActions: () => CrudActions<T, TCreate, TUpdate>;
@@ -223,7 +229,8 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
223
229
  * (approve/cancel/dispatch) instead of bespoke routes.
224
230
  */
225
231
  useAction: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
226
- invalidateQueries?: QueryKey[]; /** Default action name. Can be overridden per-call via `mutate({ action })`. */
232
+ invalidateQueries?: QueryKey[];
233
+ /** Default action name. Can be overridden per-call via `mutate({ action })`. */
227
234
  action?: string;
228
235
  messages?: MutationMessages<TResult, {
229
236
  id: string;
@@ -335,15 +342,20 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
335
342
  * `ssePlugin` (`/events/stream`). Pass `enabled: false` to opt out.
336
343
  */
337
344
  useResourceSync: (options?: {
338
- source?: 'ws' | 'sse'; /** Override resource name. Defaults to the factory's `entityKey`. */
339
- resource?: string; /** Override path (default: `/ws` or `/events/stream`). */
340
- path?: string; /** Whether the connection is active. Default: true. */
341
- enabled?: boolean; /** Per-event hook fired AFTER cache invalidation. */
345
+ source?: "ws" | "sse";
346
+ /** Override resource name. Defaults to the factory's `entityKey`. */
347
+ resource?: string;
348
+ /** Override path (default: `/ws` or `/events/stream`). */
349
+ path?: string;
350
+ /** Whether the connection is active. Default: true. */
351
+ enabled?: boolean;
352
+ /** Per-event hook fired AFTER cache invalidation. */
342
353
  onEvent?: (event: {
343
- operation: 'created' | 'updated' | 'deleted';
354
+ operation: "created" | "updated" | "deleted";
344
355
  id?: string;
345
356
  data: unknown;
346
- }) => void; /** Connection-state listener. */
357
+ }) => void;
358
+ /** Connection-state listener. */
347
359
  onConnectionChange?: (connected: boolean) => void;
348
360
  }) => {
349
361
  isConnected: boolean;
@@ -360,15 +372,6 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
360
372
  * configureNavigation(useRouter);
361
373
  */
362
374
  declare function configureNavigation(hook: UseRouterHook): void;
363
- declare function createCrudHooks<T, TCreate = Partial<T>, TUpdate = Partial<T>>({
364
- api,
365
- entityKey,
366
- singular,
367
- plural,
368
- idField,
369
- defaults,
370
- callbacks,
371
- client
372
- }: CrudHooksConfig<T, TCreate, TUpdate>): CrudHooksReturn<T, TCreate, TUpdate>;
375
+ declare function createCrudHooks<T, TCreate = Partial<T>, TUpdate = Partial<T>>({ api, entityKey, singular, plural, idField, defaults, callbacks, client }: CrudHooksConfig<T, TCreate, TUpdate>): CrudHooksReturn<T, TCreate, TUpdate>;
373
376
  //#endregion
374
377
  export { AggregationQueryOptions, BulkActions, CallOptions, CrudActions, CrudApi, CrudHooksConfig, CrudHooksReturn, DeleteParams, MutationParams, NavigateFn, NavigationOptions, UpdateParams, configureNavigation, createCrudHooks };
package/dist/hooks.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { getAuthMode, getClientAuthContext, hasGlobalStaticAuth } from "./client.js";
4
4
  import { isKeysetPagination, isOffsetPagination } from "./api.js";
5
- import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, syncDetailToLists, updateListCache, withOrgParams } from "./cache.js";
5
+ import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, prependToListCache, replaceItemInListCache, syncDetailToLists, updateListCache, withOrgParams } from "./cache.js";
6
6
  import { findItemInListCache, useDetailQuery, useInfiniteListQuery, useListQuery, useSuspenseDetailQuery, useSuspenseListQuery } from "./query.js";
7
7
  import { useMutationWithTransition, useOptimisticMutation } from "./mutation.js";
8
8
  import { subscribeToEvents } from "./sse.js";
@@ -58,6 +58,32 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
58
58
  }
59
59
  const KEYS = createQueryKeys(entityKey);
60
60
  const cache = createCacheUtils(KEYS);
61
+ /**
62
+ * Shared identity for this resource's write mutations (create / update /
63
+ * delete). Powers last-standing invalidation in `useOptimisticMutation`:
64
+ * rapid sequential writes trigger ONE settled refetch instead of N racing
65
+ * ones, so an early write's refetch can never clobber a later write's
66
+ * optimistic state.
67
+ */
68
+ const WRITE_MUTATION_KEY = [entityKey, "write"];
69
+ /**
70
+ * Per-record write ordering. `update`/`remove`/`restore` calls against the
71
+ * SAME record are chained (call order = server application order = cache
72
+ * application order); writes to different records stay fully parallel. A
73
+ * failed write does not block the next one — each link runs regardless of
74
+ * the previous outcome. Factory-scoped so every component using this
75
+ * resource's hooks shares one chain per record.
76
+ */
77
+ const writeChains = /* @__PURE__ */ new Map();
78
+ function enqueueWrite(recordId, run) {
79
+ const result = (writeChains.get(recordId) ?? Promise.resolve()).then(run, run);
80
+ const tail = result.then(() => void 0, () => void 0);
81
+ writeChains.set(recordId, tail);
82
+ tail.then(() => {
83
+ if (writeChains.get(recordId) === tail) writeChains.delete(recordId);
84
+ });
85
+ return result;
86
+ }
61
87
  const instanceToast = client?.toast;
62
88
  const instanceNavigation = client?.navigation ?? null;
63
89
  const resolveAuthMode = () => client?.config?.authMode ?? getAuthMode();
@@ -149,7 +175,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
149
175
  const detailResult = useDetailQuery({
150
176
  queryKey: fullDetailKey,
151
177
  queryFn: ({ signal }) => api.getById({
152
- id,
178
+ id: id ?? "",
153
179
  token,
154
180
  organizationId,
155
181
  params: queryParams,
@@ -260,6 +286,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
260
286
  const queryClient = useQueryClient();
261
287
  const silentRef = useRef(false);
262
288
  const shouldToast = useCallback(() => !silentRef.current, []);
289
+ const tempIdsRef = useRef(/* @__PURE__ */ new WeakMap());
263
290
  const createMutation = useOptimisticMutation({
264
291
  mutationFn: ({ token, organizationId, data }) => api.create({
265
292
  token,
@@ -268,14 +295,32 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
268
295
  }),
269
296
  queryClient,
270
297
  queryKeys: [KEYS.lists(), KEYS.aggregations()],
298
+ mutationKey: WRITE_MUTATION_KEY,
271
299
  shouldToast,
272
- optimisticUpdate: (oldData, { data }) => {
273
- const optimisticItem = {
300
+ optimisticUpdate: (oldData, variables, qKey) => {
301
+ if (qKey[1] !== "list") return oldData;
302
+ const { data } = variables;
303
+ let tempId = tempIdsRef.current.get(variables);
304
+ if (!tempId) {
305
+ tempId = resolveItemId(data) ?? `temp-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`;
306
+ tempIdsRef.current.set(variables, tempId);
307
+ }
308
+ return prependToListCache(oldData, {
274
309
  ...data,
275
310
  _optimistic: true,
276
- [idField ?? (resolveItemId(data) ? "id" : "_id")]: resolveItemId(data) ?? `temp-${Date.now()}`
277
- };
278
- return updateListCache(oldData, (arr) => [optimisticItem, ...arr || []]);
311
+ [idField ?? (resolveItemId(data) ? "id" : "_id")]: tempId
312
+ });
313
+ },
314
+ reconcile: (raw, variables) => {
315
+ const serverDoc = extractItem(raw);
316
+ if (!serverDoc || typeof serverDoc !== "object") return;
317
+ const tempId = tempIdsRef.current.get(variables);
318
+ if (tempId) for (const [qKey, qData] of queryClient.getQueriesData({ queryKey: KEYS.lists() })) {
319
+ const next = replaceItemInListCache(qData, tempId, serverDoc, idField ? { idField } : {});
320
+ if (next !== qData) queryClient.setQueryData(qKey, next);
321
+ }
322
+ const realId = resolveItemId(serverDoc);
323
+ if (realId) queryClient.setQueryData(KEYS.detail(realId), serverDoc);
279
324
  },
280
325
  onSuccess: (raw, variables) => {
281
326
  callbacks.onCreate?.onSuccess?.(extractItem(raw), { data: variables.data }, void 0);
@@ -305,26 +350,32 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
305
350
  KEYS.details(),
306
351
  KEYS.aggregations()
307
352
  ],
353
+ mutationKey: WRITE_MUTATION_KEY,
308
354
  shouldToast,
309
- optimisticUpdate: (oldData, { id, data }) => {
310
- const updated = updateListCache(oldData, (arr) => (arr || []).map((item) => resolveItemId(item) === id ? {
355
+ optimisticUpdate: (oldData, { id, data }, qKey) => {
356
+ if (qKey[1] === "detail") {
357
+ if (qKey[2] !== id || !oldData || typeof oldData !== "object") return oldData;
358
+ return {
359
+ ...oldData,
360
+ ...data
361
+ };
362
+ }
363
+ if (qKey[1] !== "list") return oldData;
364
+ return updateListCache(oldData, (arr) => (arr || []).map((item) => resolveItemId(item) === id ? {
311
365
  ...item,
312
366
  ...data
313
367
  } : item));
314
- const detailUpdater = (current) => current ? {
315
- ...current,
316
- data: {
317
- ...current.data || {},
318
- ...data
319
- }
320
- } : current;
321
- queryClient.getQueriesData({ queryKey: KEYS.detail(id) }).forEach(([qKey, qData]) => {
322
- if (qData) queryClient.setQueryData(qKey, detailUpdater);
323
- });
324
- return updated;
368
+ },
369
+ reconcile: (raw, { id }) => {
370
+ const serverDoc = extractItem(raw);
371
+ if (!serverDoc || typeof serverDoc !== "object") return;
372
+ for (const [qKey, qData] of queryClient.getQueriesData({ queryKey: KEYS.detail(id) })) if (qData) queryClient.setQueryData(qKey, serverDoc);
373
+ for (const [qKey, qData] of queryClient.getQueriesData({ queryKey: KEYS.lists() })) {
374
+ const next = replaceItemInListCache(qData, id, serverDoc, idField ? { idField } : {});
375
+ if (next !== qData) queryClient.setQueryData(qKey, next);
376
+ }
325
377
  },
326
378
  onSuccess: (raw, { id, data: updateData }) => {
327
- queryClient.invalidateQueries({ queryKey: KEYS.detail(id) });
328
379
  callbacks.onUpdate?.onSuccess?.(extractItem(raw), {
329
380
  id,
330
381
  data: updateData
@@ -356,8 +407,10 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
356
407
  }),
357
408
  queryClient,
358
409
  queryKeys: [KEYS.lists(), KEYS.aggregations()],
410
+ mutationKey: WRITE_MUTATION_KEY,
359
411
  shouldToast,
360
- optimisticUpdate: (oldData, { id }) => {
412
+ optimisticUpdate: (oldData, { id }, qKey) => {
413
+ if (qKey[1] !== "list") return oldData;
361
414
  return updateListCache(oldData, (arr) => (arr || []).filter((item) => resolveItemId(item) !== id));
362
415
  },
363
416
  onSuccess: (data, { id }) => {
@@ -424,7 +477,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
424
477
  update: useCallback(async (params, options) => {
425
478
  silentRef.current = options?.silent ?? false;
426
479
  try {
427
- const entity = extractItem(await updateMutation.mutateAsync(resolveActionAuth(params)));
480
+ const entity = extractItem(await enqueueWrite(params.id, () => updateMutation.mutateAsync(resolveActionAuth(params))));
428
481
  options?.onSuccess?.(entity);
429
482
  options?.onSettled?.(entity, null);
430
483
  return entity;
@@ -439,7 +492,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
439
492
  remove: useCallback(async (params, options) => {
440
493
  silentRef.current = options?.silent ?? false;
441
494
  try {
442
- const result = await deleteMutation.mutateAsync(resolveActionAuth(params));
495
+ const result = await enqueueWrite(params.id, () => deleteMutation.mutateAsync(resolveActionAuth(params)));
443
496
  options?.onSuccess?.(result);
444
497
  options?.onSettled?.(result, null);
445
498
  return result;
@@ -454,7 +507,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
454
507
  restore: useCallback(async (params, options) => {
455
508
  silentRef.current = options?.silent ?? false;
456
509
  try {
457
- const entity = extractItem(await restoreMutation.mutateAsync(resolveActionAuth(params)));
510
+ const entity = extractItem(await enqueueWrite(params.id, () => restoreMutation.mutateAsync(resolveActionAuth(params))));
458
511
  options?.onSuccess?.(entity);
459
512
  options?.onSettled?.(entity, null);
460
513
  return entity;
@@ -631,7 +684,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
631
684
  queryFn: ({ signal }) => {
632
685
  if (!api.getBySlug) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a getBySlug method`));
633
686
  return api.getBySlug({
634
- slug,
687
+ slug: slug ?? "",
635
688
  token,
636
689
  organizationId,
637
690
  params: queryParams,
@@ -704,7 +757,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
704
757
  return api.getChildren({
705
758
  token,
706
759
  organizationId,
707
- parentId,
760
+ parentId: parentId ?? "",
708
761
  params: restParams,
709
762
  options: {
710
763
  signal,
@@ -721,6 +774,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
721
774
  });
722
775
  }
723
776
  function useBulkActions() {
777
+ const queryClient = useQueryClient();
724
778
  const bulkCreateMutation = useMutationWithTransition({
725
779
  mutationFn: (vars) => {
726
780
  if (!api.bulkCreate) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a bulkCreate method`));
@@ -732,6 +786,14 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
732
786
  });
733
787
  },
734
788
  invalidateQueries: [KEYS.lists(), KEYS.aggregations()],
789
+ onSuccess: (raw) => {
790
+ const created = raw?.data;
791
+ if (!Array.isArray(created)) return;
792
+ for (const doc of created) {
793
+ const id = resolveItemId(doc);
794
+ if (id) queryClient.setQueryData(KEYS.detail(id), doc);
795
+ }
796
+ },
735
797
  messages: {
736
798
  success: `${pluralName} created successfully`,
737
799
  error: `Failed to create ${pluralName.toLowerCase()}`
@@ -754,6 +816,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
754
816
  KEYS.details(),
755
817
  KEYS.aggregations()
756
818
  ],
819
+ shouldInvalidate: (raw) => {
820
+ const r = raw;
821
+ if (!r || typeof r.modifiedCount !== "number") return true;
822
+ return r.modifiedCount > 0 || (r.upsertedCount ?? 0) > 0;
823
+ },
757
824
  messages: {
758
825
  success: `${pluralName} updated successfully`,
759
826
  error: `Failed to update ${pluralName.toLowerCase()}`
@@ -771,6 +838,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
771
838
  });
772
839
  },
773
840
  invalidateQueries: [KEYS.lists(), KEYS.aggregations()],
841
+ shouldInvalidate: (raw) => {
842
+ const r = raw;
843
+ if (!r || typeof r.deletedCount !== "number") return true;
844
+ return r.deletedCount > 0;
845
+ },
774
846
  messages: {
775
847
  success: `${pluralName} deleted successfully`,
776
848
  error: `Failed to delete ${pluralName.toLowerCase()}`
@@ -1,7 +1,5 @@
1
1
  import { ToastHandler } from "./client.js";
2
- import * as _$_tanstack_react_query0 from "@tanstack/react-query";
3
2
  import { QueryClient, QueryKey, UseMutateAsyncFunction, UseMutateFunction } from "@tanstack/react-query";
4
-
5
3
  //#region src/mutation.d.ts
6
4
  /**
7
5
  * Toast copy for a mutation. Generic over the mutation's result/variables so
@@ -60,6 +58,13 @@ declare function getToastHandler(): ToastHandler;
60
58
  interface TransitionMutationConfig<TData, TVariables> {
61
59
  mutationFn: (variables: TVariables) => Promise<TData>;
62
60
  invalidateQueries?: QueryKey[];
61
+ /**
62
+ * Result-aware invalidation gate. When provided, `invalidateQueries` only
63
+ * fire if this returns true for the mutation result — lets bulk operations
64
+ * skip refetching everything when the server reports nothing changed
65
+ * (`modifiedCount: 0`, `deletedCount: 0`).
66
+ */
67
+ shouldInvalidate?: (data: TData) => boolean;
63
68
  onSuccess?: (data: TData, variables: TVariables) => void;
64
69
  onError?: (error: Error, variables: TVariables) => void;
65
70
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
@@ -115,7 +120,28 @@ interface CreateOptimisticMutationConfig<TData, TVariables> {
115
120
  mutationFn: (variables: TVariables) => Promise<TData>;
116
121
  queryClient: QueryClient;
117
122
  queryKeys: QueryKey[];
118
- optimisticUpdate?: (oldData: unknown, variables: TVariables) => unknown;
123
+ /**
124
+ * Per-cache-entry optimistic updater. Receives the concrete query key of
125
+ * the entry being updated so a single updater can treat list, detail, and
126
+ * aggregation caches differently (merge the doc into `[e,'detail',id]`,
127
+ * map items inside `[e,'list',…]`, leave `[e,'aggregation',…]` untouched).
128
+ * Return the input unchanged to skip the write for that entry.
129
+ */
130
+ optimisticUpdate?: (oldData: unknown, variables: TVariables, queryKey: QueryKey) => unknown;
131
+ /**
132
+ * Shared identity for this resource's write mutations. When set, settled
133
+ * invalidation only fires from the LAST pending mutation carrying the same
134
+ * key (`isMutating === 1`) — rapid sequential writes produce one refetch
135
+ * at the end instead of N racing refetches, and a refetch triggered by
136
+ * write #1 can never clobber write #2's optimistic state.
137
+ */
138
+ mutationKey?: readonly unknown[];
139
+ /**
140
+ * Post-success cache reconciliation, run BEFORE any invalidation while the
141
+ * optimistic state is still in place. Use to swap temp IDs for server
142
+ * documents or seed detail caches from the response.
143
+ */
144
+ reconcile?: (data: TData, variables: TVariables) => void;
119
145
  onSuccess?: (data: TData, variables: TVariables) => void;
120
146
  onError?: (error: Error, variables: TVariables) => void;
121
147
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
@@ -124,7 +150,7 @@ interface CreateOptimisticMutationConfig<TData, TVariables> {
124
150
  shouldToast?: () => boolean;
125
151
  toastHandler?: ToastHandler;
126
152
  }
127
- declare function useOptimisticMutation<TData, TVariables>(config: CreateOptimisticMutationConfig<TData, TVariables>): _$_tanstack_react_query0.UseMutationResult<TData, Error, TVariables, {
153
+ declare function useOptimisticMutation<TData, TVariables>(config: CreateOptimisticMutationConfig<TData, TVariables>): import("@tanstack/react-query").UseMutationResult<TData, Error, TVariables, {
128
154
  previous: {
129
155
  key: readonly unknown[];
130
156
  data: [readonly unknown[], unknown][];
package/dist/mutation.js CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  import { getQuotaDetails, isArcApiError, isAutoIdempotency } from "./client.js";
4
4
  import { useMutation, useQueryClient } from "@tanstack/react-query";
5
- import { useCallback, useRef, useTransition } from "react";
5
+ import { useRef, useTransition } from "react";
6
6
 
7
7
  //#region src/mutation.ts
8
8
  let toastHandler = {
9
- success: (msg) => console.log("[Success]", msg),
10
- error: (msg) => console.error("[Error]", msg)
9
+ success: () => {},
10
+ error: () => {}
11
11
  };
12
12
  /**
13
13
  * Configure toast handler. Call once at app init.
@@ -78,9 +78,11 @@ function useMutationWithTransition(config) {
78
78
  },
79
79
  onSuccess: (data, variables) => {
80
80
  const invalidate = () => {
81
- invalidateQueries.forEach((key) => queryClient.invalidateQueries({ queryKey: key }));
81
+ invalidateQueries.forEach((key) => {
82
+ queryClient.invalidateQueries({ queryKey: key });
83
+ });
82
84
  };
83
- if (withTransition && invalidateQueries.length > 0) startTransition(invalidate);
85
+ if (invalidateQueries.length > 0 && (config.shouldInvalidate?.(data) ?? true)) if (withTransition) startTransition(invalidate);
84
86
  else invalidate();
85
87
  if (toast && (config.shouldToast?.() ?? true)) showToast("success", messages, data, variables, void 0, instanceToast);
86
88
  onSuccess?.(data, variables);
@@ -121,12 +123,16 @@ function useMutationWithOptimistic(config) {
121
123
  return { previous };
122
124
  },
123
125
  onSuccess: (data, variables) => {
124
- queryKeys.forEach((key) => queryClient.invalidateQueries({ queryKey: key }));
126
+ queryKeys.forEach((key) => {
127
+ queryClient.invalidateQueries({ queryKey: key });
128
+ });
125
129
  if (toast) showToast("success", messages, data, variables, void 0, instanceToast);
126
130
  onSuccess?.(data, variables);
127
131
  },
128
132
  onError: (error, variables, context) => {
129
- context?.previous?.forEach(({ key, data }) => queryClient.setQueryData(key, data));
133
+ context?.previous?.forEach(({ key, data }) => {
134
+ queryClient.setQueryData(key, data);
135
+ });
130
136
  if (toast) showToast("error", messages, null, variables, error, instanceToast);
131
137
  onError?.(error, variables);
132
138
  },
@@ -146,9 +152,15 @@ function useMutationWithOptimistic(config) {
146
152
  };
147
153
  }
148
154
  function useOptimisticMutation(config) {
149
- const { mutationFn, queryClient, queryKeys, optimisticUpdate, onSuccess, onError, onSettled, messages, toastHandler: instanceToast } = config;
155
+ const { mutationFn, queryClient, queryKeys, optimisticUpdate, mutationKey, reconcile, onSuccess, onError, onSettled, messages, toastHandler: instanceToast } = config;
156
+ const invalidateAll = () => {
157
+ queryKeys.forEach((key) => {
158
+ queryClient.invalidateQueries({ queryKey: key });
159
+ });
160
+ };
150
161
  return useMutation({
151
162
  mutationFn,
163
+ ...mutationKey ? { mutationKey } : {},
152
164
  onMutate: async (variables) => {
153
165
  await Promise.all(queryKeys.map((key) => queryClient.cancelQueries({
154
166
  queryKey: key,
@@ -160,24 +172,29 @@ function useOptimisticMutation(config) {
160
172
  }));
161
173
  if (optimisticUpdate) queryKeys.forEach((key) => {
162
174
  queryClient.getQueriesData({ queryKey: key }).forEach(([qKey, qData]) => {
163
- queryClient.setQueryData(qKey, optimisticUpdate(qData, variables));
175
+ const next = optimisticUpdate(qData, variables, qKey);
176
+ if (next !== qData) queryClient.setQueryData(qKey, next);
164
177
  });
165
178
  });
166
179
  return { previous };
167
180
  },
168
181
  onSuccess: (data, variables) => {
169
182
  if ((config.shouldToast?.() ?? true) && messages?.success) showToast("success", messages, data, variables, void 0, instanceToast);
170
- queryKeys.forEach((key) => queryClient.invalidateQueries({ queryKey: key }));
183
+ reconcile?.(data, variables);
184
+ if (!mutationKey) invalidateAll();
171
185
  onSuccess?.(data, variables);
172
186
  },
173
187
  onError: (error, variables, context) => {
174
188
  context?.previous?.forEach(({ data }) => {
175
- data.forEach(([qKey, qData]) => queryClient.setQueryData(qKey, qData));
189
+ data.forEach(([qKey, qData]) => {
190
+ queryClient.setQueryData(qKey, qData);
191
+ });
176
192
  });
177
193
  if (config.shouldToast?.() ?? true) showToast("error", messages, null, variables, error, instanceToast);
178
194
  onError?.(error, variables);
179
195
  },
180
196
  onSettled: (data, error, variables) => {
197
+ if (mutationKey && queryClient.isMutating({ mutationKey }) === 1) invalidateAll();
181
198
  onSettled?.(data, error, variables);
182
199
  }
183
200
  });
@@ -1,6 +1,5 @@
1
1
  import { EntityReadApi } from "./query-options.js";
2
2
  import { HydrationBoundary, InfiniteData, QueryClient, dehydrate } from "@tanstack/react-query";
3
-
4
3
  //#region src/prefetch.d.ts
5
4
  interface PrefetchAuthContext {
6
5
  /** Auth token for protected endpoints. Required for bearer/header auth on server. */
@@ -1,6 +1,5 @@
1
1
  import { AnyBaseApi, CreateOf, DocOf, ScopedArgs, UpdateOf } from "../api.js";
2
2
  import { BulkCreateResult, DeleteManyResult, UpdateManyResult } from "@classytic/repo-core/repository";
3
-
4
3
  //#region src/presets/bulk.d.ts
5
4
  interface BulkMethods<TDoc, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>> {
6
5
  /** Insert many docs in one round-trip. Backend mounts `POST /:resource/bulk`. */
@@ -1,12 +1,11 @@
1
1
  import { AnyBaseApi, ScopedArgs } from "../api.js";
2
-
3
2
  //#region src/presets/history.d.ts
4
3
  /** One audit-trail entry — arc's `AuditEntry` wire shape for a single record. */
5
4
  interface HistoryEntry {
6
5
  id: string;
7
6
  resource: string;
8
7
  documentId: string;
9
- action: 'create' | 'update' | 'delete' | 'restore' | 'custom';
8
+ action: "create" | "update" | "delete" | "restore" | "custom";
10
9
  userId?: string;
11
10
  organizationId?: string;
12
11
  before?: Record<string, unknown>;
@@ -1,6 +1,5 @@
1
1
  import { AnyBaseApi, DocOf, ScopedArgs } from "../api.js";
2
2
  import { PaginatedResult } from "@classytic/repo-core/pagination";
3
-
4
3
  //#region src/presets/search.d.ts
5
4
  interface SearchPresetMethods<TDoc> {
6
5
  /**
@@ -8,8 +7,11 @@ interface SearchPresetMethods<TDoc> {
8
7
  * Backend mounts `POST /:resource/search` via `searchPreset()`.
9
8
  */
10
9
  searchEngine<TResult = TDoc, TBody extends Record<string, unknown> = Record<string, unknown>>(args?: ScopedArgs & {
11
- /** Free-text query forwarded as `body.query`. */query?: string; /** Engine-specific options merged into the request body. */
12
- body?: TBody; /** Override path (default `/search`). */
10
+ /** Free-text query forwarded as `body.query`. */
11
+ query?: string;
12
+ /** Engine-specific options merged into the request body. */
13
+ body?: TBody;
14
+ /** Override path (default `/search`). */
13
15
  path?: string;
14
16
  }): Promise<TResult[] | PaginatedResult<TResult>>;
15
17
  /**
@@ -17,9 +19,13 @@ interface SearchPresetMethods<TDoc> {
17
19
  * Backend mounts `POST /:resource/search-similar`.
18
20
  */
19
21
  searchSimilar<TResult = TDoc, TBody extends Record<string, unknown> = Record<string, unknown>>(args?: ScopedArgs & {
20
- /** Text query — backend embeds and searches for nearest neighbors. */query?: string; /** Pre-computed embedding vector — used directly for similarity search. */
21
- vector?: number[]; /** Vector-engine options (`topK`, `filter`, `index`, ...). */
22
- body?: TBody; /** Override path (default `/search-similar`). */
22
+ /** Text query — backend embeds and searches for nearest neighbors. */
23
+ query?: string;
24
+ /** Pre-computed embedding vector used directly for similarity search. */
25
+ vector?: number[];
26
+ /** Vector-engine options (`topK`, `filter`, `index`, ...). */
27
+ body?: TBody;
28
+ /** Override path (default `/search-similar`). */
23
29
  path?: string;
24
30
  }): Promise<TResult[]>;
25
31
  /**
@@ -27,8 +33,11 @@ interface SearchPresetMethods<TDoc> {
27
33
  * Backend mounts `POST /:resource/embed`.
28
34
  */
29
35
  embed(args: ScopedArgs & {
30
- /** Text or array of texts to embed. */input: string | string[]; /** Embed-engine options (`model`, `dimensions`, ...). */
31
- body?: Record<string, unknown>; /** Override path (default `/embed`). */
36
+ /** Text or array of texts to embed. */
37
+ input: string | string[];
38
+ /** Embed-engine options (`model`, `dimensions`, ...). */
39
+ body?: Record<string, unknown>;
40
+ /** Override path (default `/embed`). */
32
41
  path?: string;
33
42
  }): Promise<number[] | number[][]>;
34
43
  }
@@ -1,5 +1,4 @@
1
1
  import { AnyBaseApi, DocOf, ScopedArgs } from "../api.js";
2
-
3
2
  //#region src/presets/slug.d.ts
4
3
  interface SlugLookupMethods<TDoc> {
5
4
  /** Fetch a single doc by slug. Backend mounts `GET /:resource/slug/:slug`. */
@@ -1,6 +1,5 @@
1
1
  import { AnyBaseApi, DocOf, QueryParams, ScopedArgs } from "../api.js";
2
2
  import { PaginatedResult } from "@classytic/repo-core/pagination";
3
-
4
3
  //#region src/presets/soft-delete.d.ts
5
4
  interface SoftDeleteMethods<TDoc> {
6
5
  /** List soft-deleted docs. Backend mounts `GET /:resource/deleted`. */
@@ -1,6 +1,5 @@
1
1
  import { AnyBaseApi, DocOf, QueryParams, ScopedArgs } from "../api.js";
2
2
  import { PaginatedResult } from "@classytic/repo-core/pagination";
3
-
4
3
  //#region src/presets/tree.d.ts
5
4
  interface TreeMethods<TDoc> {
6
5
  /** Fetch the full hierarchy. Backend mounts `GET /:resource/tree`. */
@@ -1,5 +1,4 @@
1
1
  import { QueryClient } from "@tanstack/react-query";
2
-
3
2
  //#region src/query-client.d.ts
4
3
  interface QueryClientOverrides {
5
4
  staleTime?: number;