@classytic/arc-next 0.11.1 → 0.12.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/client.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { ERROR_CODES } from "@classytic/repo-core/errors";
2
+
1
3
  //#region src/client.ts
2
4
  /**
3
5
  * Canonical error codes arc and repo-core emit on `json.code`. Single
@@ -21,17 +23,7 @@
21
23
  * `errorMappers` codes still satisfy it.
22
24
  */
23
25
  const KNOWN_ARC_ERROR_CODES = [
24
- "validation_error",
25
- "not_found",
26
- "conflict",
27
- "unauthorized",
28
- "forbidden",
29
- "rate_limited",
30
- "idempotency_conflict",
31
- "precondition_failed",
32
- "internal_error",
33
- "service_unavailable",
34
- "timeout",
26
+ ...Object.values(ERROR_CODES),
35
27
  "arc.bad_request",
36
28
  "arc.unauthorized",
37
29
  "arc.forbidden",
@@ -75,6 +67,13 @@ var ArcApiError = class extends Error {
75
67
  json;
76
68
  endpoint;
77
69
  method;
70
+ /**
71
+ * Server-directed retry pacing from the `Retry-After` response header
72
+ * (seconds or HTTP-date form, normalized to ms). Populated on 429/503;
73
+ * the SDK's retry loop honors it INSTEAD of computed backoff so clients
74
+ * come back exactly when the server says capacity returns.
75
+ */
76
+ retryAfterMs;
78
77
  constructor(message, options) {
79
78
  super(message);
80
79
  this.name = "ArcApiError";
@@ -83,6 +82,7 @@ var ArcApiError = class extends Error {
83
82
  this.json = options.json;
84
83
  this.endpoint = options.endpoint;
85
84
  this.method = options.method;
85
+ this.retryAfterMs = options.retryAfterMs ?? null;
86
86
  }
87
87
  /**
88
88
  * Canonical error code from arc's wire envelope (`json.code`).
@@ -435,7 +435,7 @@ function isAuthRecoverable(error, retryOn403) {
435
435
  function runAuthRecovery(handler, ctx) {
436
436
  if (pendingAuthRecovery) return pendingAuthRecovery;
437
437
  pendingAuthRecovery = (async () => {
438
- let overrideToken = void 0;
438
+ let overrideToken;
439
439
  const setToken = (token) => {
440
440
  overrideToken = token;
441
441
  };
@@ -652,6 +652,44 @@ function createAuthAwareClient(overrides = {}) {
652
652
  };
653
653
  }
654
654
  /**
655
+ * Create a **request-scoped** client for server environments (Next.js App
656
+ * Router route handlers / Server Components, or any SSR runtime).
657
+ *
658
+ * Unlike `configureClient`/`configureAuth` — which set module-level state
659
+ * that leaks across concurrent server requests — this returns an isolated
660
+ * client whose credentials live only in the returned instance. Construct one
661
+ * per request, read cookies/headers in YOUR framework code, and pass the
662
+ * values in. The SDK stays framework-free: no `next` dependency, no
663
+ * `next/headers` import, no hidden cookie access.
664
+ *
665
+ * @example Next.js App Router (Server Component or route handler)
666
+ * ```ts
667
+ * import { cookies, headers } from 'next/headers'; // host code, not SDK code
668
+ * import { createServerClient } from '@classytic/arc-next/client';
669
+ * import { createCrudApi } from '@classytic/arc-next/api';
670
+ *
671
+ * export async function loadOrders() {
672
+ * const jar = await cookies();
673
+ * const client = createServerClient({
674
+ * baseUrl: process.env.API_URL!,
675
+ * token: jar.get('session')?.value ?? null,
676
+ * organizationId: (await headers()).get('x-organization-id'),
677
+ * });
678
+ * const orders = createCrudApi<Order>('orders', { client });
679
+ * // Next fetch-cache passthrough works per call:
680
+ * return orders.getAll({ options: { next: { revalidate: 60, tags: ['orders'] } } });
681
+ * }
682
+ * ```
683
+ */
684
+ function createServerClient(config) {
685
+ const { token = null, organizationId = null, ...rest } = config;
686
+ return createClient({
687
+ ...rest,
688
+ getToken: () => token,
689
+ getOrgId: () => organizationId
690
+ });
691
+ }
692
+ /**
655
693
  * Get auth context for a specific client instance, falling back to global.
656
694
  */
657
695
  function getClientAuthContext(client) {
@@ -668,11 +706,11 @@ function defaultShouldRetry(error) {
668
706
  return error instanceof Error;
669
707
  }
670
708
  /** Compute the delay in ms before the Nth retry. */
671
- function computeBackoff(retry, attempt) {
709
+ function computeBackoff(retry, attempt, error) {
710
+ if (isArcApiError(error) && error.retryAfterMs != null && (error.status === 429 || error.status === 503)) return error.retryAfterMs;
672
711
  const strategy = retry.backoff ?? "exponential";
673
- if (typeof strategy === "function") return Math.max(0, strategy(attempt));
674
- if (strategy === "linear") return 300 * (attempt + 1);
675
- return Math.min(300 * Math.pow(2, attempt), 1e4);
712
+ const base = typeof strategy === "function" ? Math.max(0, strategy(attempt)) : strategy === "linear" ? 300 * (attempt + 1) : Math.min(300 * 2 ** attempt, 1e4);
713
+ return retry.jitter === "full" ? Math.random() * base : base;
676
714
  }
677
715
  /**
678
716
  * Inner request loop — handles 5xx + network-failure backoff per
@@ -695,7 +733,7 @@ async function executeWithBackoff(config, method, endpoint, options = {}) {
695
733
  } catch (error) {
696
734
  lastError = error;
697
735
  if (attempt === totalAttempts - 1 || !shouldRetry(error)) throw error;
698
- const delay = computeBackoff(config.retry ?? {}, attempt);
736
+ const delay = computeBackoff(config.retry ?? {}, attempt, error);
699
737
  if (delay > 0) await sleepAbortable(delay, options.signal);
700
738
  }
701
739
  throw lastError;
@@ -714,6 +752,10 @@ async function executeWithBackoff(config, method, endpoint, options = {}) {
714
752
  * surfaces immediately, no extra round-trip).
715
753
  */
716
754
  async function executeRequest(config, method, endpoint, options = {}) {
755
+ if (config.autoIdempotency && options.idempotencyKey === void 0 && (method === "POST" || method === "PUT" || method === "PATCH")) options = {
756
+ ...options,
757
+ idempotencyKey: globalThis.crypto.randomUUID()
758
+ };
717
759
  const handler = authConfig?.onAuthError;
718
760
  if (!handler) return executeWithBackoff(config, method, endpoint, options);
719
761
  const retryOn403 = authConfig?.retryOn403 ?? false;
@@ -741,6 +783,15 @@ async function executeRequest(config, method, endpoint, options = {}) {
741
783
  }
742
784
  throw new Error("arc-next: auth retry loop terminated without resolution");
743
785
  }
786
+ /** Parse `Retry-After` (delta-seconds or HTTP-date) to ms; null when absent/invalid. */
787
+ function parseRetryAfterMs(header) {
788
+ if (!header) return null;
789
+ const seconds = Number(header);
790
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
791
+ const date = Date.parse(header);
792
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
793
+ return null;
794
+ }
744
795
  /** Sleep that resolves early if the signal aborts. */
745
796
  function sleepAbortable(ms, signal) {
746
797
  return new Promise((resolve, reject) => {
@@ -759,9 +810,42 @@ function sleepAbortable(ms, signal) {
759
810
  signal?.addEventListener("abort", onAbort, { once: true });
760
811
  });
761
812
  }
813
+ /**
814
+ * Compose the caller's AbortSignal with a per-attempt timeout. Returns the
815
+ * effective signal plus a `timedOut()` probe so the catch path can tell a
816
+ * timeout abort (retryable failure) apart from a deliberate caller abort
817
+ * (silent cancellation).
818
+ */
819
+ function withTimeoutSignal(signal, timeoutMs) {
820
+ if (!(timeoutMs > 0)) return {
821
+ signal,
822
+ timedOut: () => false,
823
+ cleanup: () => {}
824
+ };
825
+ const controller = new AbortController();
826
+ let timedOut = false;
827
+ const onCallerAbort = () => controller.abort();
828
+ if (signal) if (signal.aborted) controller.abort();
829
+ else signal.addEventListener("abort", onCallerAbort, { once: true });
830
+ const timer = setTimeout(() => {
831
+ timedOut = true;
832
+ controller.abort();
833
+ }, timeoutMs);
834
+ return {
835
+ signal: controller.signal,
836
+ timedOut: () => timedOut,
837
+ cleanup: () => {
838
+ clearTimeout(timer);
839
+ signal?.removeEventListener("abort", onCallerAbort);
840
+ }
841
+ };
842
+ }
762
843
  /** A single fetch attempt — used by executeRequest's retry loop. */
763
844
  async function executeAttempt(config, method, endpoint, options, attempt) {
764
- const { body, token, organizationId, revalidate, headerOptions, tags, next, cache, signal, idempotencyKey, elevated } = options;
845
+ const { body, token, organizationId, revalidate, headerOptions, tags, next, cache, signal: callerSignal, idempotencyKey, elevated } = options;
846
+ const timeoutMs = options.timeoutMs ?? config.timeoutMs ?? 0;
847
+ const timeout = withTimeoutSignal(callerSignal, timeoutMs);
848
+ const signal = timeout.signal;
765
849
  const startTime = Date.now();
766
850
  try {
767
851
  let headers = {
@@ -773,7 +857,7 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
773
857
  if (config.authMode === "header") {
774
858
  const headerName = authConfig?.headerName ?? "x-api-key";
775
859
  headers[headerName] = token;
776
- } else if (config.authMode !== "cookie") headers["Authorization"] = `Bearer ${token}`;
860
+ } else if (config.authMode !== "cookie") headers.Authorization = `Bearer ${token}`;
777
861
  }
778
862
  if (config.apiVersion) headers["Accept-Version"] = config.apiVersion;
779
863
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
@@ -784,7 +868,7 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
784
868
  ...headerOptions
785
869
  };
786
870
  const credentials = config.credentials ?? (config.authMode === "cookie" ? "include" : "same-origin");
787
- let serializedBody = void 0;
871
+ let serializedBody;
788
872
  if (body !== void 0 && body !== null) serializedBody = isNonJsonBody(body) ? body : JSON.stringify(body);
789
873
  const encryption = config.encryption;
790
874
  if (encryption?.encryptRequests && encryption.encrypt && typeof serializedBody === "string") {
@@ -838,7 +922,8 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
838
922
  statusText: response.statusText,
839
923
  json,
840
924
  endpoint,
841
- method
925
+ method,
926
+ retryAfterMs: parseRetryAfterMs(response.headers.get("Retry-After"))
842
927
  });
843
928
  }
844
929
  const contentType = response.headers.get("Content-Type");
@@ -886,8 +971,11 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
886
971
  })).body;
887
972
  return data;
888
973
  } catch (error) {
974
+ if (timeout.timedOut() && isAbortError(error)) throw Object.assign(/* @__PURE__ */ new Error(`[arc-next] Request timed out after ${timeoutMs}ms: ${method} ${endpoint}`), { name: "TimeoutError" });
889
975
  if (error instanceof Error) throw error;
890
976
  throw new Error("An error occurred while fetching data.");
977
+ } finally {
978
+ timeout.cleanup();
891
979
  }
892
980
  }
893
981
  /**
@@ -1139,4 +1227,4 @@ const arc = {
1139
1227
  };
1140
1228
 
1141
1229
  //#endregion
1142
- export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
1230
+ export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
@@ -31,6 +31,6 @@ interface JoseEncryptionOptions {
31
31
  * using `jose`. Spread the result into `encryption` and add `encryptRequests`
32
32
  * / content-type overrides as needed.
33
33
  */
34
- declare function createJoseEncryption(options: JoseEncryptionOptions): Pick<ClientEncryptionConfig, 'encrypt' | 'decrypt'>;
34
+ declare function createJoseEncryption(options: JoseEncryptionOptions): Pick<ClientEncryptionConfig, "encrypt" | "decrypt">;
35
35
  //#endregion
36
36
  export { JoseEncryptionOptions, createJoseEncryption };
package/dist/hooks.d.ts CHANGED
@@ -2,12 +2,12 @@ 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
13
 
@@ -22,11 +22,11 @@ import { PaginatedResult } from "@classytic/repo-core/pagination";
22
22
  * any cast — yet a vanilla `createCrudApi('todos')` instance has none of them
23
23
  * in autocomplete unless you opt in.
24
24
  */
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'];
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"];
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>> {
@@ -335,12 +335,12 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
335
335
  * `ssePlugin` (`/events/stream`). Pass `enabled: false` to opt out.
336
336
  */
337
337
  useResourceSync: (options?: {
338
- source?: 'ws' | 'sse'; /** Override resource name. Defaults to the factory's `entityKey`. */
338
+ source?: "ws" | "sse"; /** Override resource name. Defaults to the factory's `entityKey`. */
339
339
  resource?: string; /** Override path (default: `/ws` or `/events/stream`). */
340
340
  path?: string; /** Whether the connection is active. Default: true. */
341
341
  enabled?: boolean; /** Per-event hook fired AFTER cache invalidation. */
342
342
  onEvent?: (event: {
343
- operation: 'created' | 'updated' | 'deleted';
343
+ operation: "created" | "updated" | "deleted";
344
344
  id?: string;
345
345
  data: unknown;
346
346
  }) => void; /** Connection-state listener. */
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()}`
@@ -60,6 +60,13 @@ declare function getToastHandler(): ToastHandler;
60
60
  interface TransitionMutationConfig<TData, TVariables> {
61
61
  mutationFn: (variables: TVariables) => Promise<TData>;
62
62
  invalidateQueries?: QueryKey[];
63
+ /**
64
+ * Result-aware invalidation gate. When provided, `invalidateQueries` only
65
+ * fire if this returns true for the mutation result — lets bulk operations
66
+ * skip refetching everything when the server reports nothing changed
67
+ * (`modifiedCount: 0`, `deletedCount: 0`).
68
+ */
69
+ shouldInvalidate?: (data: TData) => boolean;
63
70
  onSuccess?: (data: TData, variables: TVariables) => void;
64
71
  onError?: (error: Error, variables: TVariables) => void;
65
72
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
@@ -115,7 +122,28 @@ interface CreateOptimisticMutationConfig<TData, TVariables> {
115
122
  mutationFn: (variables: TVariables) => Promise<TData>;
116
123
  queryClient: QueryClient;
117
124
  queryKeys: QueryKey[];
118
- optimisticUpdate?: (oldData: unknown, variables: TVariables) => unknown;
125
+ /**
126
+ * Per-cache-entry optimistic updater. Receives the concrete query key of
127
+ * the entry being updated so a single updater can treat list, detail, and
128
+ * aggregation caches differently (merge the doc into `[e,'detail',id]`,
129
+ * map items inside `[e,'list',…]`, leave `[e,'aggregation',…]` untouched).
130
+ * Return the input unchanged to skip the write for that entry.
131
+ */
132
+ optimisticUpdate?: (oldData: unknown, variables: TVariables, queryKey: QueryKey) => unknown;
133
+ /**
134
+ * Shared identity for this resource's write mutations. When set, settled
135
+ * invalidation only fires from the LAST pending mutation carrying the same
136
+ * key (`isMutating === 1`) — rapid sequential writes produce one refetch
137
+ * at the end instead of N racing refetches, and a refetch triggered by
138
+ * write #1 can never clobber write #2's optimistic state.
139
+ */
140
+ mutationKey?: readonly unknown[];
141
+ /**
142
+ * Post-success cache reconciliation, run BEFORE any invalidation while the
143
+ * optimistic state is still in place. Use to swap temp IDs for server
144
+ * documents or seed detail caches from the response.
145
+ */
146
+ reconcile?: (data: TData, variables: TVariables) => void;
119
147
  onSuccess?: (data: TData, variables: TVariables) => void;
120
148
  onError?: (error: Error, variables: TVariables) => void;
121
149
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;