@classytic/arc-next 0.11.0 → 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,13 +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
+ import { PaginatedResult } from "@classytic/repo-core/pagination";
12
13
 
13
14
  //#region src/hooks.d.ts
14
15
  /**
@@ -21,11 +22,11 @@ import { QueryKey, UseQueryResult } from "@tanstack/react-query";
21
22
  * any cast — yet a vanilla `createCrudApi('todos')` instance has none of them
22
23
  * in autocomplete unless you opt in.
23
24
  */
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']; /** Declared aggregations (arc 2.13+). Always available on BaseApi. */
28
- 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"];
29
30
  } & Partial<SoftDeleteMethods<T>> & Partial<BulkMethods<T, TCreate, TUpdate>> & Partial<SlugLookupMethods<T>> & Partial<TreeMethods<T>> & Partial<SearchPresetMethods<T>>;
30
31
  /** Args + options for `useAggregation`. Mirrors `ListQueryOptions` for DX consistency. */
31
32
  interface AggregationQueryOptions<TRow extends AggRow = AggRow, TData = AggResult<TRow>> {
@@ -224,7 +225,11 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
224
225
  useAction: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
225
226
  invalidateQueries?: QueryKey[]; /** Default action name. Can be overridden per-call via `mutate({ action })`. */
226
227
  action?: string;
227
- messages?: MutationMessages;
228
+ messages?: MutationMessages<TResult, {
229
+ id: string;
230
+ action?: string;
231
+ data?: TBody;
232
+ }>;
228
233
  onSuccess?: (data: TResult, variables: {
229
234
  id: string;
230
235
  action: string;
@@ -248,18 +253,25 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
248
253
  /** Mutation against the search-preset POST `/search` route. */
249
254
  useSearchEngine: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
250
255
  path?: string;
251
- messages?: MutationMessages;
256
+ messages?: MutationMessages<TResult[] | PaginatedResult<TResult>, {
257
+ query?: string;
258
+ body?: TBody;
259
+ }>;
252
260
  invalidateQueries?: QueryKey[];
253
- }) => TransitionMutationReturn<unknown, {
261
+ }) => TransitionMutationReturn<TResult[] | PaginatedResult<TResult>, {
254
262
  query?: string;
255
263
  body?: TBody;
256
264
  }>;
257
265
  /** Mutation against the search-preset POST `/search-similar` route. */
258
266
  useSearchSimilar: <TResult = T, TBody extends Record<string, unknown> = Record<string, unknown>>(options?: {
259
267
  path?: string;
260
- messages?: MutationMessages;
268
+ messages?: MutationMessages<TResult[] | PaginatedResult<TResult>, {
269
+ query?: string;
270
+ vector?: number[];
271
+ body?: TBody;
272
+ }>;
261
273
  invalidateQueries?: QueryKey[];
262
- }) => TransitionMutationReturn<unknown, {
274
+ }) => TransitionMutationReturn<TResult[] | PaginatedResult<TResult>, {
263
275
  query?: string;
264
276
  vector?: number[];
265
277
  body?: TBody;
@@ -309,7 +321,7 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
309
321
  useCustomMutation: <TData = unknown, TVariables = unknown>(config: {
310
322
  mutationFn: (variables: TVariables) => Promise<TData>;
311
323
  invalidateQueries?: QueryKey[];
312
- messages?: MutationMessages;
324
+ messages?: MutationMessages<TData, TVariables>;
313
325
  onSuccess?: (data: TData, variables: TVariables) => void;
314
326
  onError?: (error: Error, variables: TVariables) => void;
315
327
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
@@ -323,12 +335,12 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
323
335
  * `ssePlugin` (`/events/stream`). Pass `enabled: false` to opt out.
324
336
  */
325
337
  useResourceSync: (options?: {
326
- 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`. */
327
339
  resource?: string; /** Override path (default: `/ws` or `/events/stream`). */
328
340
  path?: string; /** Whether the connection is active. Default: true. */
329
341
  enabled?: boolean; /** Per-event hook fired AFTER cache invalidation. */
330
342
  onEvent?: (event: {
331
- operation: 'created' | 'updated' | 'deleted';
343
+ operation: "created" | "updated" | "deleted";
332
344
  id?: string;
333
345
  data: unknown;
334
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()}`