@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/README.md +50 -0
- package/dist/api.d.ts +40 -23
- package/dist/api.js +14 -1
- package/dist/cache.d.ts +20 -2
- package/dist/cache.js +52 -1
- package/dist/client.d.ts +82 -12
- package/dist/client.js +110 -22
- package/dist/encryption.d.ts +1 -1
- package/dist/hooks.d.ts +10 -10
- package/dist/hooks.js +99 -27
- package/dist/mutation.d.ts +29 -1
- package/dist/mutation.js +28 -11
- package/dist/presets/history.d.ts +1 -1
- package/dist/query.d.ts +1 -1
- package/dist/sse.js +1 -1
- package/dist/upload.d.ts +9 -0
- package/dist/upload.js +6 -3
- package/dist/ws.d.ts +1 -1
- package/dist/ws.js +1 -1
- package/package.json +18 -7
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
|
-
|
|
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
|
|
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
|
-
|
|
674
|
-
|
|
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
|
|
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
|
|
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 };
|
package/dist/encryption.d.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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>,
|
|
26
|
-
upload?: BaseApi<T, TCreate, TUpdate>[
|
|
27
|
-
dispatchAction?: BaseApi<T, TCreate, TUpdate>[
|
|
28
|
-
invokeRoute?: BaseApi<T, TCreate, TUpdate>[
|
|
29
|
-
aggregate?: BaseApi<T, TCreate, TUpdate>[
|
|
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?:
|
|
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:
|
|
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,
|
|
273
|
-
|
|
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")]:
|
|
277
|
-
};
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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()}`
|
package/dist/mutation.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|