@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.
@@ -3,9 +3,16 @@ import * as _$_tanstack_react_query0 from "@tanstack/react-query";
3
3
  import { QueryClient, QueryKey, UseMutateAsyncFunction, UseMutateFunction } from "@tanstack/react-query";
4
4
 
5
5
  //#region src/mutation.d.ts
6
- interface MutationMessages {
7
- success?: string | ((data: unknown, variables: unknown) => string);
8
- error?: string | ((error: Error, variables: unknown) => string);
6
+ /**
7
+ * Toast copy for a mutation. Generic over the mutation's result/variables so
8
+ * `success: (result) => ...` sees the TYPED FLAT action result (arc 2.13+
9
+ * returns results with no `{ data }` envelope) — the stale-envelope
10
+ * `(data as { data: T }).data` cast class is a compile error now. Defaults
11
+ * keep pre-0.11.1 unparameterized usage compiling unchanged.
12
+ */
13
+ interface MutationMessages<TData = unknown, TVariables = unknown> {
14
+ success?: string | ((data: TData, variables: TVariables) => string);
15
+ error?: string | ((error: Error, variables: TVariables) => string);
9
16
  }
10
17
  interface MutationCallbacks<TData, TVariables, TContext = unknown> {
11
18
  onMutate?: (variables: TVariables) => TContext | Promise<TContext>;
@@ -53,10 +60,17 @@ declare function getToastHandler(): ToastHandler;
53
60
  interface TransitionMutationConfig<TData, TVariables> {
54
61
  mutationFn: (variables: TVariables) => Promise<TData>;
55
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;
56
70
  onSuccess?: (data: TData, variables: TVariables) => void;
57
71
  onError?: (error: Error, variables: TVariables) => void;
58
72
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
59
- messages?: MutationMessages;
73
+ messages?: MutationMessages<TData, TVariables>;
60
74
  useTransition?: boolean;
61
75
  showToast?: boolean;
62
76
  /** Per-call toast guard — return false to suppress toast for this invocation */
@@ -80,7 +94,7 @@ interface OptimisticMutationConfig<TData, TVariables> {
80
94
  onSuccess?: (data: TData, variables: TVariables) => void;
81
95
  onError?: (error: Error, variables: TVariables) => void;
82
96
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
83
- messages?: MutationMessages;
97
+ messages?: MutationMessages<TData, TVariables>;
84
98
  showToast?: boolean;
85
99
  toastHandler?: ToastHandler;
86
100
  }
@@ -108,11 +122,32 @@ interface CreateOptimisticMutationConfig<TData, TVariables> {
108
122
  mutationFn: (variables: TVariables) => Promise<TData>;
109
123
  queryClient: QueryClient;
110
124
  queryKeys: QueryKey[];
111
- 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;
112
147
  onSuccess?: (data: TData, variables: TVariables) => void;
113
148
  onError?: (error: Error, variables: TVariables) => void;
114
149
  onSettled?: (data: TData | undefined, error: Error | null, variables: TVariables) => void;
115
- messages?: MutationMessages;
150
+ messages?: MutationMessages<TData, TVariables>;
116
151
  /** Per-call toast guard — return false to suppress toast for this invocation */
117
152
  shouldToast?: () => boolean;
118
153
  toastHandler?: ToastHandler;
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
  });
@@ -6,7 +6,7 @@ interface HistoryEntry {
6
6
  id: string;
7
7
  resource: string;
8
8
  documentId: string;
9
- action: 'create' | 'update' | 'delete' | 'restore' | 'custom';
9
+ action: "create" | "update" | "delete" | "restore" | "custom";
10
10
  userId?: string;
11
11
  organizationId?: string;
12
12
  before?: Record<string, unknown>;
package/dist/query.d.ts CHANGED
@@ -286,7 +286,7 @@ interface UseApiQueryOptions {
286
286
  refetchIntervalInBackground?: boolean;
287
287
  retry?: boolean | number;
288
288
  /** Limit re-renders to changes in these specific fields (perf optimization). */
289
- notifyOnChangeProps?: ('data' | 'error' | 'isLoading' | 'isFetching' | 'isError' | 'isSuccess' | 'isStale')[];
289
+ notifyOnChangeProps?: ("data" | "error" | "isLoading" | "isFetching" | "isError" | "isSuccess" | "isStale")[];
290
290
  }
291
291
  interface UseApiQueryConfig<TResponse, TData> {
292
292
  queryKey: QueryKey;
package/dist/sse.js CHANGED
@@ -166,7 +166,7 @@ function subscribeToEvents(options) {
166
166
  const scheduleReconnect = () => {
167
167
  if (reconnectAttempts < maxReconnectAttempts) {
168
168
  reconnectAttempts += 1;
169
- const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
169
+ const delay = Math.min(reconnectDelay * 1.5 ** (reconnectAttempts - 1), 3e4);
170
170
  reconnectTimer = setTimeout(connect, delay);
171
171
  }
172
172
  };
package/dist/upload.d.ts CHANGED
@@ -71,6 +71,15 @@ interface UploadWithProgressOptions {
71
71
  * an image and returns the resized binary). Default: false (parse JSON).
72
72
  */
73
73
  responseType?: "json" | "text" | "blob";
74
+ /**
75
+ * Upload timeout in ms, assigned to `xhr.timeout` — the whole request
76
+ * (upload + server processing + response) must finish within this window
77
+ * or the promise rejects with a `TimeoutError`. Default: disabled (0) —
78
+ * large files on slow links legitimately take minutes; size it to your
79
+ * payload ceiling when you enable it. (`ClientConfig.timeoutMs` does NOT
80
+ * apply to uploads — the fetch pipeline and XHR transport stay isolated.)
81
+ */
82
+ timeoutMs?: number;
74
83
  }
75
84
  /**
76
85
  * Upload a `FormData` payload via XHR with native progress events.
package/dist/upload.js CHANGED
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { ArcApiError, _getAuthErrorHandler, _isAuthRecoverable, _resolveRefreshedToken, _runAuthRecovery, getAuthMode, getBaseUrl, getClientAuthContext } from "./client.js";
2
4
  import { getToastHandler } from "./mutation.js";
3
5
  import { useQueryClient } from "@tanstack/react-query";
@@ -110,7 +112,7 @@ async function uploadWithProgress(options) {
110
112
  * in the outer loop; this function just performs one upload.
111
113
  */
112
114
  function uploadAttempt(options) {
113
- const { url, formData, method = "POST", onProgress, signal, client, headers: extraHeaders, elevated, idempotencyKey, responseType = "json" } = options;
115
+ const { url, formData, method = "POST", onProgress, signal, client, headers: extraHeaders, elevated, idempotencyKey, responseType = "json", timeoutMs = 0 } = options;
114
116
  return new Promise((resolve, reject) => {
115
117
  if (signal?.aborted) {
116
118
  reject(abortReason(signal));
@@ -119,6 +121,7 @@ function uploadAttempt(options) {
119
121
  const xhr = new XMLHttpRequest();
120
122
  const fullUrl = resolveUrl(url, client);
121
123
  xhr.open(method, fullUrl, true);
124
+ if (timeoutMs > 0) xhr.timeout = timeoutMs;
122
125
  xhr.responseType = responseType === "blob" ? "blob" : "";
123
126
  const authMode = client?.config?.authMode ?? getAuthMode();
124
127
  if (authMode === "cookie") xhr.withCredentials = true;
@@ -130,7 +133,7 @@ function uploadAttempt(options) {
130
133
  if (authMode === "header") {
131
134
  const headerName = client?.auth?.headerName ?? "x-api-key";
132
135
  builtHeaders[headerName] = token;
133
- } else if (authMode !== "cookie") builtHeaders["Authorization"] = `Bearer ${token}`;
136
+ } else if (authMode !== "cookie") builtHeaders.Authorization = `Bearer ${token}`;
134
137
  }
135
138
  if (orgId) builtHeaders["x-organization-id"] = orgId;
136
139
  if (elevated ?? client?.config?.elevated) builtHeaders["x-arc-scope"] = "platform";
@@ -191,7 +194,7 @@ function uploadAttempt(options) {
191
194
  };
192
195
  xhr.ontimeout = () => {
193
196
  if (abortListener && signal) signal.removeEventListener("abort", abortListener);
194
- reject(/* @__PURE__ */ new Error(`Upload timed out: ${fullUrl}`));
197
+ reject(Object.assign(/* @__PURE__ */ new Error(`Upload timed out after ${timeoutMs}ms: ${fullUrl}`), { name: "TimeoutError" }));
195
198
  };
196
199
  xhr.send(formData);
197
200
  });
package/dist/ws.d.ts CHANGED
@@ -21,7 +21,7 @@ interface ArcWsMessage<TData = unknown> {
21
21
  * Backend also accepts `channel` as alias.
22
22
  */
23
23
  interface ArcSubscribeFrame {
24
- type: 'subscribe' | 'unsubscribe';
24
+ type: "subscribe" | "unsubscribe";
25
25
  resource?: string;
26
26
  channel?: string;
27
27
  /** Free-form additional fields the backend may use (token, filters, etc.). */
package/dist/ws.js CHANGED
@@ -150,7 +150,7 @@ function connectWs(options = {}) {
150
150
  }
151
151
  if (reconnectAttempts < maxReconnectAttempts) {
152
152
  reconnectAttempts += 1;
153
- const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
153
+ const delay = Math.min(reconnectDelay * 1.5 ** (reconnectAttempts - 1), 3e4);
154
154
  reconnectTimer = setTimeout(connect, delay);
155
155
  }
156
156
  };