@classytic/arc-next 0.7.0 → 0.8.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.d.ts +41 -1
- package/dist/client.js +54 -11
- package/dist/encryption.d.ts +36 -0
- package/dist/encryption.js +75 -0
- package/dist/hooks.d.ts +12 -0
- package/dist/hooks.js +79 -1
- package/dist/query.d.ts +49 -3
- package/dist/query.js +46 -6
- package/package.json +9 -2
package/dist/client.d.ts
CHANGED
|
@@ -171,6 +171,39 @@ declare function isValidationError(error: unknown): error is ArcApiError;
|
|
|
171
171
|
* Prisma P2002 → `arc.conflict` (with `details[].code === 'duplicate_key'`).
|
|
172
172
|
*/
|
|
173
173
|
declare function isDuplicateKeyError(error: unknown): error is ArcApiError;
|
|
174
|
+
/**
|
|
175
|
+
* Application-Layer Encryption (ALE) config — the client half of
|
|
176
|
+
* `@classytic/arc/encryption`. Lets the SDK decrypt JWE response bodies and
|
|
177
|
+
* (optionally) encrypt JSON request bodies, transparently to callers and to
|
|
178
|
+
* the repo-core wire contracts (decryption happens BEFORE error / pagination
|
|
179
|
+
* parsing, so the parsed shape is identical to an unencrypted response).
|
|
180
|
+
*
|
|
181
|
+
* `decrypt` / `encrypt` are host-supplied so the core SDK pulls no crypto
|
|
182
|
+
* dependency. Use `createJoseEncryption()` from `@classytic/arc-next/encryption`
|
|
183
|
+
* for a ready-made JWE implementation, or wire your own (KMS proxy, WebCrypto).
|
|
184
|
+
*/
|
|
185
|
+
interface ClientEncryptionConfig {
|
|
186
|
+
/**
|
|
187
|
+
* Decrypt an encrypted response body (JWE compact string) → plaintext JSON
|
|
188
|
+
* string. Required — this is what makes encrypted responses readable.
|
|
189
|
+
*/
|
|
190
|
+
decrypt: (payload: string) => string | Promise<string>;
|
|
191
|
+
/**
|
|
192
|
+
* Encrypt a serialized JSON request body string → JWE compact string.
|
|
193
|
+
* Required only when `encryptRequests` is true.
|
|
194
|
+
*/
|
|
195
|
+
encrypt?: (json: string) => string | Promise<string>;
|
|
196
|
+
/**
|
|
197
|
+
* Encrypt outbound JSON request bodies. Default `false` — responses are
|
|
198
|
+
* still decrypted regardless (the common case: server encrypts responses,
|
|
199
|
+
* client posts plaintext over TLS).
|
|
200
|
+
*/
|
|
201
|
+
encryptRequests?: boolean;
|
|
202
|
+
/** Response media type signalling an encrypted body. Default `'application/jose'`. */
|
|
203
|
+
responseContentType?: string;
|
|
204
|
+
/** Content-Type set on encrypted outbound requests. Default `'application/jose'`. */
|
|
205
|
+
requestContentType?: string;
|
|
206
|
+
}
|
|
174
207
|
interface ClientConfig {
|
|
175
208
|
baseUrl: string;
|
|
176
209
|
internalApiKey?: string;
|
|
@@ -253,6 +286,13 @@ interface ClientConfig {
|
|
|
253
286
|
* });
|
|
254
287
|
*/
|
|
255
288
|
afterResponse?: AfterResponseInterceptor;
|
|
289
|
+
/**
|
|
290
|
+
* Application-Layer Encryption. When set, the SDK decrypts `application/jose`
|
|
291
|
+
* response bodies (and, with `encryptRequests`, encrypts JSON request bodies)
|
|
292
|
+
* — the client counterpart to `@classytic/arc/encryption` on the backend.
|
|
293
|
+
* Transparent to repo-core wire contracts. See {@link ClientEncryptionConfig}.
|
|
294
|
+
*/
|
|
295
|
+
encryption?: ClientEncryptionConfig;
|
|
256
296
|
}
|
|
257
297
|
interface RetryConfig {
|
|
258
298
|
/** Total attempts including the first. `attempts: 3` means 1 try + 2 retries. Default: 0 (off). */
|
|
@@ -795,4 +835,4 @@ declare const arc: {
|
|
|
795
835
|
delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
|
|
796
836
|
};
|
|
797
837
|
//#endregion
|
|
798
|
-
export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
|
|
838
|
+
export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, RetryConfig, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
|
package/dist/client.js
CHANGED
|
@@ -585,16 +585,48 @@ function createClient(config) {
|
|
|
585
585
|
* });
|
|
586
586
|
*/
|
|
587
587
|
function createAuthAwareClient(overrides = {}) {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
588
|
+
const { toast, navigation, getToken, getOrgId, headerName, ...overrideCfg } = overrides;
|
|
589
|
+
const authGetToken = getToken ?? (() => readToken(authConfig?.getToken));
|
|
590
|
+
const authGetOrgId = getOrgId ?? (() => authConfig?.getOrgId?.() ?? null);
|
|
591
|
+
const authHeaderName = headerName ?? authConfig?.headerName;
|
|
592
|
+
const clientAuth = {
|
|
593
|
+
getToken: authGetToken,
|
|
594
|
+
getOrgId: authGetOrgId,
|
|
595
|
+
headerName: authHeaderName
|
|
596
|
+
};
|
|
597
|
+
const resolveClientCfg = () => ({
|
|
598
|
+
baseUrl: overrideCfg.baseUrl ?? getBaseUrl(),
|
|
599
|
+
authMode: overrideCfg.authMode ?? getAuthMode(),
|
|
600
|
+
autoIdempotency: overrideCfg.autoIdempotency ?? isAutoIdempotency(),
|
|
601
|
+
elevated: overrideCfg.elevated ?? clientConfig?.elevated,
|
|
602
|
+
internalApiKey: overrideCfg.internalApiKey ?? clientConfig?.internalApiKey,
|
|
603
|
+
defaultHeaders: overrideCfg.defaultHeaders ?? clientConfig?.defaultHeaders,
|
|
604
|
+
credentials: overrideCfg.credentials ?? clientConfig?.credentials,
|
|
605
|
+
apiVersion: overrideCfg.apiVersion ?? clientConfig?.apiVersion,
|
|
606
|
+
retry: overrideCfg.retry ?? clientConfig?.retry,
|
|
607
|
+
beforeRequest: overrideCfg.beforeRequest ?? clientConfig?.beforeRequest,
|
|
608
|
+
afterResponse: overrideCfg.afterResponse ?? clientConfig?.afterResponse
|
|
597
609
|
});
|
|
610
|
+
return {
|
|
611
|
+
request: (method, endpoint, options) => {
|
|
612
|
+
const cfg = resolveClientCfg();
|
|
613
|
+
const resolved = { ...options };
|
|
614
|
+
if (resolved.token === void 0) resolved.token = readToken(authGetToken);
|
|
615
|
+
if (resolved.organizationId === void 0) resolved.organizationId = authGetOrgId();
|
|
616
|
+
if (cfg.authMode === "header" && resolved.token) {
|
|
617
|
+
resolved.headerOptions = {
|
|
618
|
+
[authHeaderName ?? "x-api-key"]: resolved.token,
|
|
619
|
+
...resolved.headerOptions ?? {}
|
|
620
|
+
};
|
|
621
|
+
resolved.token = void 0;
|
|
622
|
+
}
|
|
623
|
+
return executeRequest(cfg, method, endpoint, resolved);
|
|
624
|
+
},
|
|
625
|
+
config: resolveClientCfg(),
|
|
626
|
+
toast,
|
|
627
|
+
navigation,
|
|
628
|
+
auth: clientAuth
|
|
629
|
+
};
|
|
598
630
|
}
|
|
599
631
|
/**
|
|
600
632
|
* Get auth context for a specific client instance, falling back to global.
|
|
@@ -731,6 +763,11 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
|
|
|
731
763
|
const credentials = config.credentials ?? (config.authMode === "cookie" ? "include" : "same-origin");
|
|
732
764
|
let serializedBody = void 0;
|
|
733
765
|
if (body !== void 0 && body !== null) serializedBody = isNonJsonBody(body) ? body : JSON.stringify(body);
|
|
766
|
+
const encryption = config.encryption;
|
|
767
|
+
if (encryption?.encryptRequests && encryption.encrypt && typeof serializedBody === "string") {
|
|
768
|
+
serializedBody = await encryption.encrypt(serializedBody);
|
|
769
|
+
headers["Content-Type"] = encryption.requestContentType ?? "application/jose";
|
|
770
|
+
}
|
|
734
771
|
if (config.beforeRequest) {
|
|
735
772
|
const ctx = await config.beforeRequest({
|
|
736
773
|
method,
|
|
@@ -759,6 +796,7 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
|
|
|
759
796
|
...fetchOptions.next,
|
|
760
797
|
tags
|
|
761
798
|
};
|
|
799
|
+
if (!/^https?:\/\//i.test(endpoint) && !config.baseUrl) throw new Error(`[arc-next] handleApiRequest(${method} ${endpoint}): baseUrl is empty. Call configureClient({ baseUrl: '...' }) BEFORE the first request. If you use createAuthAwareClient() at module top-level, make sure the Providers component runs configureClient() first (e.g. in a useState() initializer, before the children render).`);
|
|
762
800
|
const response = await fetch(`${config.baseUrl}${endpoint}`, fetchOptions);
|
|
763
801
|
if (!response.ok) {
|
|
764
802
|
let json = null;
|
|
@@ -766,7 +804,7 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
|
|
|
766
804
|
try {
|
|
767
805
|
json = await response.clone().json();
|
|
768
806
|
const j = json;
|
|
769
|
-
errorMessage = typeof j?.error === "string" && j.error || typeof j?.message === "string" && j.message || response.statusText;
|
|
807
|
+
errorMessage = typeof j?.error === "string" && j.error || typeof j?.meta?.message === "string" && j.meta.message || typeof j?.message === "string" && j.message || response.statusText;
|
|
770
808
|
} catch {
|
|
771
809
|
try {
|
|
772
810
|
const text = await response.text();
|
|
@@ -786,7 +824,12 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
|
|
|
786
824
|
}
|
|
787
825
|
const contentType = response.headers.get("Content-Type");
|
|
788
826
|
let data;
|
|
789
|
-
|
|
827
|
+
const decryptCt = encryption ? encryption.responseContentType ?? "application/jose" : void 0;
|
|
828
|
+
if (encryption && decryptCt && contentType?.includes(decryptCt)) {
|
|
829
|
+
const cipher = await response.text();
|
|
830
|
+
const plaintext = cipher.length > 0 ? await encryption.decrypt(cipher) : "";
|
|
831
|
+
data = plaintext.length > 0 ? JSON.parse(plaintext) : void 0;
|
|
832
|
+
} else if (contentType?.includes("application/json")) data = await response.json();
|
|
790
833
|
else if (contentType?.includes("application/pdf") || contentType?.includes("image/")) data = {
|
|
791
834
|
data: await response.blob(),
|
|
792
835
|
response
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ClientEncryptionConfig } from "./client.js";
|
|
2
|
+
|
|
3
|
+
//#region src/encryption.d.ts
|
|
4
|
+
/** Key material accepted by jose in browser + Node (Web Crypto). */
|
|
5
|
+
type JoseKey = CryptoKey | Uint8Array;
|
|
6
|
+
interface JoseEncryptionOptions {
|
|
7
|
+
/** Single decryption key (private/secret) used for every response. */
|
|
8
|
+
decryptionKey?: JoseKey;
|
|
9
|
+
/**
|
|
10
|
+
* Decryption keys indexed by `kid` (rotation). When set, the inbound JWE's
|
|
11
|
+
* `kid` header selects the key — keep 2–3 entries across a rotation so
|
|
12
|
+
* in-flight responses signed with the previous `kid` still decrypt.
|
|
13
|
+
*/
|
|
14
|
+
decryptionKeys?: Record<string, JoseKey>;
|
|
15
|
+
/** Public/secret key + `kid` used to encrypt outbound requests. */
|
|
16
|
+
encryptionKey?: {
|
|
17
|
+
kid: string;
|
|
18
|
+
key: JoseKey;
|
|
19
|
+
alg?: string;
|
|
20
|
+
};
|
|
21
|
+
/** Key-management algorithm. Default `'RSA-OAEP-256'`. */
|
|
22
|
+
alg?: string;
|
|
23
|
+
/** Content-encryption algorithm. Default `'A256GCM'`. */
|
|
24
|
+
enc?: string;
|
|
25
|
+
/** Inbound-decryption allowlists (anti-downgrade). Default `[alg]` / `[enc]`. */
|
|
26
|
+
allowedAlgs?: string[];
|
|
27
|
+
allowedEncs?: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the `encrypt` / `decrypt` half of a {@link ClientEncryptionConfig}
|
|
31
|
+
* using `jose`. Spread the result into `encryption` and add `encryptRequests`
|
|
32
|
+
* / content-type overrides as needed.
|
|
33
|
+
*/
|
|
34
|
+
declare function createJoseEncryption(options: JoseEncryptionOptions): Pick<ClientEncryptionConfig, 'encrypt' | 'decrypt'>;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { JoseEncryptionOptions, createJoseEncryption };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { CompactEncrypt, compactDecrypt } from "jose";
|
|
2
|
+
|
|
3
|
+
//#region src/encryption.ts
|
|
4
|
+
/**
|
|
5
|
+
* `@classytic/arc-next/encryption` — JWE helper for the client SDK.
|
|
6
|
+
*
|
|
7
|
+
* A ready-made `ClientEncryptionConfig` backed by `jose` (panva) — the same
|
|
8
|
+
* library `@classytic/arc/encryption` uses server-side. Works in the browser
|
|
9
|
+
* and Node (jose runs on Web Crypto). `jose` is an OPTIONAL peer: import this
|
|
10
|
+
* subpath only when you want JWE; the core SDK pulls no crypto dependency.
|
|
11
|
+
*
|
|
12
|
+
* Asymmetric key model (mirrors Visa MLE / the arc backend plugin):
|
|
13
|
+
* - Decrypt RESPONSES with the client's PRIVATE key (the recipient).
|
|
14
|
+
* - Encrypt REQUESTS with the SERVER's PUBLIC key, tagged by `kid`.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { configureClient } from "@classytic/arc-next/client";
|
|
19
|
+
* import { createJoseEncryption } from "@classytic/arc-next/encryption";
|
|
20
|
+
* import { importPKCS8, importSPKI } from "jose";
|
|
21
|
+
*
|
|
22
|
+
* configureClient({
|
|
23
|
+
* baseUrl: "https://api.example.com",
|
|
24
|
+
* encryption: {
|
|
25
|
+
* ...createJoseEncryption({
|
|
26
|
+
* decryptionKeys: { "client-1": await importPKCS8(clientPrivPem, "RSA-OAEP-256") },
|
|
27
|
+
* encryptionKey: { kid: "server-1", key: await importSPKI(serverPubPem, "RSA-OAEP-256") },
|
|
28
|
+
* }),
|
|
29
|
+
* encryptRequests: true,
|
|
30
|
+
* },
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Build the `encrypt` / `decrypt` half of a {@link ClientEncryptionConfig}
|
|
36
|
+
* using `jose`. Spread the result into `encryption` and add `encryptRequests`
|
|
37
|
+
* / content-type overrides as needed.
|
|
38
|
+
*/
|
|
39
|
+
function createJoseEncryption(options) {
|
|
40
|
+
const alg = options.alg ?? "RSA-OAEP-256";
|
|
41
|
+
const enc = options.enc ?? "A256GCM";
|
|
42
|
+
const allowedAlgs = options.allowedAlgs ?? [alg];
|
|
43
|
+
const allowedEncs = options.allowedEncs ?? [enc];
|
|
44
|
+
const decoder = new TextDecoder();
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
const decrypt = async (payload) => {
|
|
47
|
+
const { plaintext } = await compactDecrypt(payload, async (header) => {
|
|
48
|
+
if (options.decryptionKeys) {
|
|
49
|
+
const key = header.kid ? options.decryptionKeys[header.kid] : void 0;
|
|
50
|
+
if (!key) throw new Error(`[arc-next/encryption] no decryption key for kid '${header.kid ?? "<none>"}'`);
|
|
51
|
+
return key;
|
|
52
|
+
}
|
|
53
|
+
if (!options.decryptionKey) throw new Error("[arc-next/encryption] no decryption key configured");
|
|
54
|
+
return options.decryptionKey;
|
|
55
|
+
}, {
|
|
56
|
+
keyManagementAlgorithms: allowedAlgs,
|
|
57
|
+
contentEncryptionAlgorithms: allowedEncs
|
|
58
|
+
});
|
|
59
|
+
return decoder.decode(plaintext);
|
|
60
|
+
};
|
|
61
|
+
const encryptionKey = options.encryptionKey;
|
|
62
|
+
if (!encryptionKey) return { decrypt };
|
|
63
|
+
const encrypt = async (json) => new CompactEncrypt(encoder.encode(json)).setProtectedHeader({
|
|
64
|
+
alg: encryptionKey.alg ?? alg,
|
|
65
|
+
enc,
|
|
66
|
+
kid: encryptionKey.kid
|
|
67
|
+
}).encrypt(encryptionKey.key);
|
|
68
|
+
return {
|
|
69
|
+
encrypt,
|
|
70
|
+
decrypt
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
export { createJoseEncryption };
|
package/dist/hooks.d.ts
CHANGED
|
@@ -180,6 +180,18 @@ interface CrudHooksReturn<T, TCreate, TUpdate> {
|
|
|
180
180
|
/** New signature — auto-injects token from configureAuth() context */(id: string | null, options?: DetailQueryOptions<T>): DetailQueryResult<T>; /** Legacy signature — explicit token */
|
|
181
181
|
(id: string | null, token: string | null, options?: DetailQueryOptions<T>): DetailQueryResult<T>;
|
|
182
182
|
};
|
|
183
|
+
/**
|
|
184
|
+
* Suspense variant of `useList` for Suspense / streaming-SSR routes. Always
|
|
185
|
+
* fetches and SUSPENDS until resolved — wrap the subtree in `<Suspense>` and
|
|
186
|
+
* an error boundary. No `enabled` gate and no `keepPreviousData` preview
|
|
187
|
+
* (TanStack forbids both on suspense queries); `isLoading` is always `false`.
|
|
188
|
+
*/
|
|
189
|
+
useSuspenseList: (params?: Record<string, unknown>, options?: Omit<ListQueryOptions<T>, "enabled">) => ListQueryResult<T>;
|
|
190
|
+
/**
|
|
191
|
+
* Suspense variant of `useDetail`. `id` must be present (it always fetches).
|
|
192
|
+
* Suspends until resolved; no list→detail `placeholderData` preview.
|
|
193
|
+
*/
|
|
194
|
+
useSuspenseDetail: (id: string, options?: Omit<DetailQueryOptions<T>, "enabled">) => DetailQueryResult<T>;
|
|
183
195
|
useInfiniteList: {
|
|
184
196
|
/** New signature — auto-injects token/orgId from configureAuth() context */(params?: Record<string, unknown>, options?: InfiniteListQueryOptions): InfiniteListQueryResult<T>; /** Legacy signature — explicit token */
|
|
185
197
|
(token: string | null, params?: Record<string, unknown>, options?: InfiniteListQueryOptions): InfiniteListQueryResult<T>;
|
package/dist/hooks.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { getAuthMode, getClientAuthContext, hasGlobalStaticAuth } from "./client.js";
|
|
4
4
|
import { isKeysetPagination, isOffsetPagination } from "./api.js";
|
|
5
5
|
import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, syncDetailToLists, updateListCache } from "./cache.js";
|
|
6
|
-
import { findItemInListCache, useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
|
|
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";
|
|
9
9
|
import { connectWs } from "./ws.js";
|
|
@@ -176,6 +176,82 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
176
176
|
]);
|
|
177
177
|
return detailResult;
|
|
178
178
|
}
|
|
179
|
+
function useSuspenseList(params = {}, options = {}) {
|
|
180
|
+
const auth = resolveAuth();
|
|
181
|
+
const token = auth.token;
|
|
182
|
+
let resolvedParams = params;
|
|
183
|
+
if (auth.organizationId && !resolvedParams.organizationId) resolvedParams = {
|
|
184
|
+
...resolvedParams,
|
|
185
|
+
organizationId: auth.organizationId
|
|
186
|
+
};
|
|
187
|
+
const { organizationId, ...restParams } = resolvedParams;
|
|
188
|
+
const scope = options._scope || (organizationId ? "tenant" : "super-admin");
|
|
189
|
+
const { request: requestOpts, ...queryOpts } = options;
|
|
190
|
+
return useSuspenseListQuery({
|
|
191
|
+
queryKey: KEYS.scopedList(scope, {
|
|
192
|
+
organizationId,
|
|
193
|
+
...restParams
|
|
194
|
+
}),
|
|
195
|
+
queryFn: ({ signal }) => api.getAll({
|
|
196
|
+
token,
|
|
197
|
+
organizationId,
|
|
198
|
+
params: restParams,
|
|
199
|
+
options: {
|
|
200
|
+
signal,
|
|
201
|
+
...requestOpts
|
|
202
|
+
}
|
|
203
|
+
}),
|
|
204
|
+
options: {
|
|
205
|
+
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
206
|
+
gcTime: queryOpts.gcTime ?? config.gcTime,
|
|
207
|
+
refetchOnWindowFocus: queryOpts.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
|
|
208
|
+
structuralSharing: queryOpts.structuralSharing ?? config.structuralSharing,
|
|
209
|
+
refetchInterval: queryOpts.refetchInterval,
|
|
210
|
+
refetchIntervalInBackground: queryOpts.refetchIntervalInBackground
|
|
211
|
+
},
|
|
212
|
+
select: queryOpts.select
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function useSuspenseDetail(id, options = {}) {
|
|
216
|
+
const auth = resolveAuth();
|
|
217
|
+
const token = auth.token;
|
|
218
|
+
let opts = options;
|
|
219
|
+
if (auth.organizationId && !opts.organizationId) opts = {
|
|
220
|
+
...opts,
|
|
221
|
+
organizationId: auth.organizationId
|
|
222
|
+
};
|
|
223
|
+
const { organizationId, params: queryParams, request: requestOpts, ...restOptions } = opts;
|
|
224
|
+
const detailKey = KEYS.scopedDetail(id, organizationId ?? null);
|
|
225
|
+
const fullDetailKey = queryParams ? [...detailKey, queryParams] : detailKey;
|
|
226
|
+
const queryClient = useQueryClient();
|
|
227
|
+
const detailResult = useSuspenseDetailQuery({
|
|
228
|
+
queryKey: fullDetailKey,
|
|
229
|
+
queryFn: ({ signal }) => api.getById({
|
|
230
|
+
id,
|
|
231
|
+
token,
|
|
232
|
+
organizationId,
|
|
233
|
+
params: queryParams,
|
|
234
|
+
options: {
|
|
235
|
+
signal,
|
|
236
|
+
...requestOpts
|
|
237
|
+
}
|
|
238
|
+
}),
|
|
239
|
+
options: {
|
|
240
|
+
staleTime: restOptions.staleTime ?? config.staleTime,
|
|
241
|
+
gcTime: restOptions.gcTime ?? config.gcTime,
|
|
242
|
+
refetchOnWindowFocus: restOptions.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
|
|
243
|
+
structuralSharing: restOptions.structuralSharing ?? config.structuralSharing,
|
|
244
|
+
refetchInterval: restOptions.refetchInterval,
|
|
245
|
+
refetchIntervalInBackground: restOptions.refetchIntervalInBackground
|
|
246
|
+
},
|
|
247
|
+
select: restOptions.select
|
|
248
|
+
});
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!detailResult.item) return;
|
|
251
|
+
syncDetailToLists(queryClient, KEYS.lists(), detailResult.item, idField ? { idField } : {});
|
|
252
|
+
}, [detailResult.item, queryClient]);
|
|
253
|
+
return detailResult;
|
|
254
|
+
}
|
|
179
255
|
function useActions() {
|
|
180
256
|
const queryClient = useQueryClient();
|
|
181
257
|
const silentRef = useRef(false);
|
|
@@ -956,6 +1032,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
956
1032
|
cache,
|
|
957
1033
|
useList,
|
|
958
1034
|
useDetail,
|
|
1035
|
+
useSuspenseList,
|
|
1036
|
+
useSuspenseDetail,
|
|
959
1037
|
useInfiniteList,
|
|
960
1038
|
useActions,
|
|
961
1039
|
useBulkActions,
|
package/dist/query.d.ts
CHANGED
|
@@ -57,8 +57,28 @@ interface DetailQueryOptions<TData = unknown> {
|
|
|
57
57
|
/** Pass-through options for the underlying fetch request (cache, revalidate, tags, headers) */
|
|
58
58
|
request?: RequestPassthrough;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Result of `useList` (and `useListQuery`). Matches the same typed-end-to-end
|
|
62
|
+
* shape `repo-core` uses for its server-side result interfaces (`AggResult`,
|
|
63
|
+
* `OffsetPaginationResult`, etc.) — no raw-cache escape hatch on the public
|
|
64
|
+
* surface, just the typed extracted view.
|
|
65
|
+
*
|
|
66
|
+
* Need the raw cache entry? Use `useQueryClient().getQueryData(KEYS.list(...))`
|
|
67
|
+
* — it's typed against whatever `setQueryData` last wrote, and it makes the
|
|
68
|
+
* "I'm reaching past the SDK" intent explicit.
|
|
69
|
+
*/
|
|
60
70
|
interface ListQueryResult<T> {
|
|
71
|
+
/**
|
|
72
|
+
* Extracted items array — `T[]`. Always typed, always flat (infinite-list
|
|
73
|
+
* pages are pre-flattened), always consistent across response-shape
|
|
74
|
+
* variants (`data` / `items` / `results` / any-array fallback).
|
|
75
|
+
*/
|
|
61
76
|
items: T[];
|
|
77
|
+
/**
|
|
78
|
+
* Normalized pagination snapshot — offset / keyset / aggregate all map
|
|
79
|
+
* onto the same `{ method, total, pages, page, limit, hasNext, hasPrev, next? }`
|
|
80
|
+
* shape. `null` when the response carries no pagination signal.
|
|
81
|
+
*/
|
|
62
82
|
pagination: PaginationData | null;
|
|
63
83
|
isLoading: boolean;
|
|
64
84
|
isFetching: boolean;
|
|
@@ -67,9 +87,22 @@ interface ListQueryResult<T> {
|
|
|
67
87
|
isStale: boolean;
|
|
68
88
|
error: Error | null;
|
|
69
89
|
refetch: () => Promise<unknown>;
|
|
70
|
-
data: unknown;
|
|
71
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Result of `useDetail` (and `useDetailQuery`). Matches the typed-end-to-end
|
|
93
|
+
* shape repo-core uses for its result interfaces — no raw-cache escape
|
|
94
|
+
* hatch on the public surface, just the typed extracted view.
|
|
95
|
+
*
|
|
96
|
+
* Need the raw cache entry? `useQueryClient().getQueryData(KEYS.detail(id))`
|
|
97
|
+
* — typed against whatever `setQueryData` last wrote, and the explicit
|
|
98
|
+
* `getQueryData` call signals "I'm reaching past the SDK" at the call site.
|
|
99
|
+
*/
|
|
72
100
|
interface DetailQueryResult<T> {
|
|
101
|
+
/**
|
|
102
|
+
* The extracted entity — `T | null`. Always typed, always the raw doc
|
|
103
|
+
* (not a wrapper), always consistent with what `prefetchDetail` /
|
|
104
|
+
* `cache.getDetail` / `useNavigation` write back.
|
|
105
|
+
*/
|
|
73
106
|
item: T | null;
|
|
74
107
|
isLoading: boolean;
|
|
75
108
|
isFetching: boolean;
|
|
@@ -85,7 +118,6 @@ interface DetailQueryResult<T> {
|
|
|
85
118
|
isPlaceholderData: boolean;
|
|
86
119
|
error: Error | null;
|
|
87
120
|
refetch: () => Promise<unknown>;
|
|
88
|
-
data: unknown;
|
|
89
121
|
}
|
|
90
122
|
interface CreateListQueryConfig {
|
|
91
123
|
queryKey: QueryKey;
|
|
@@ -163,6 +195,20 @@ declare function useDetailQuery<T>({
|
|
|
163
195
|
select,
|
|
164
196
|
placeholderData
|
|
165
197
|
}: CreateDetailQueryConfig<T>): DetailQueryResult<T>;
|
|
198
|
+
/** Suspense variant of {@link useListQuery}. Always enabled; suspends until resolved. */
|
|
199
|
+
declare function useSuspenseListQuery<T>({
|
|
200
|
+
queryKey,
|
|
201
|
+
queryFn,
|
|
202
|
+
options,
|
|
203
|
+
select
|
|
204
|
+
}: Omit<CreateListQueryConfig, "enabled">): ListQueryResult<T>;
|
|
205
|
+
/** Suspense variant of {@link useDetailQuery}. Always enabled; suspends until resolved. */
|
|
206
|
+
declare function useSuspenseDetailQuery<T>({
|
|
207
|
+
queryKey,
|
|
208
|
+
queryFn,
|
|
209
|
+
options,
|
|
210
|
+
select
|
|
211
|
+
}: Omit<CreateDetailQueryConfig<T>, "enabled" | "placeholderData">): DetailQueryResult<T>;
|
|
166
212
|
interface InfiniteListQueryOptions {
|
|
167
213
|
public?: boolean;
|
|
168
214
|
enabled?: boolean;
|
|
@@ -305,4 +351,4 @@ declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>
|
|
|
305
351
|
options
|
|
306
352
|
}: UseApiQueryConfig<TResponse, TData>): UseApiQueryResult<TData>;
|
|
307
353
|
//#endregion
|
|
308
|
-
export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|
|
354
|
+
export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery, useSuspenseDetailQuery, useSuspenseListQuery };
|
package/dist/query.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
|
|
4
|
-
import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { keepPreviousData, useInfiniteQuery, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
|
5
5
|
import { useCallback, useMemo, useRef } from "react";
|
|
6
6
|
|
|
7
7
|
//#region src/query.ts
|
|
@@ -39,8 +39,7 @@ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, select
|
|
|
39
39
|
isSuccess: query.isSuccess,
|
|
40
40
|
isStale: query.isStale,
|
|
41
41
|
error: query.error,
|
|
42
|
-
refetch: query.refetch
|
|
43
|
-
data: query.data
|
|
42
|
+
refetch: query.refetch
|
|
44
43
|
};
|
|
45
44
|
}
|
|
46
45
|
/**
|
|
@@ -97,8 +96,49 @@ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, selec
|
|
|
97
96
|
isStale: query.isStale,
|
|
98
97
|
isPlaceholderData: query.isPlaceholderData,
|
|
99
98
|
error: query.error,
|
|
100
|
-
refetch: query.refetch
|
|
101
|
-
|
|
99
|
+
refetch: query.refetch
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Suspense variant of {@link useListQuery}. Always enabled; suspends until resolved. */
|
|
103
|
+
function useSuspenseListQuery({ queryKey, queryFn, options = {}, select }) {
|
|
104
|
+
const query = useSuspenseQuery({
|
|
105
|
+
queryKey,
|
|
106
|
+
queryFn: ({ signal }) => queryFn({ signal }),
|
|
107
|
+
...DEFAULT_QUERY_CONFIG,
|
|
108
|
+
...options,
|
|
109
|
+
...select ? { select } : {}
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
items: useMemo(() => extractItems(query.data), [query.data]),
|
|
113
|
+
pagination: useMemo(() => normalizePagination(query.data), [query.data]),
|
|
114
|
+
isLoading: false,
|
|
115
|
+
isFetching: query.isFetching,
|
|
116
|
+
isError: query.isError,
|
|
117
|
+
isSuccess: query.isSuccess,
|
|
118
|
+
isStale: query.isStale,
|
|
119
|
+
error: query.error,
|
|
120
|
+
refetch: query.refetch
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Suspense variant of {@link useDetailQuery}. Always enabled; suspends until resolved. */
|
|
124
|
+
function useSuspenseDetailQuery({ queryKey, queryFn, options = {}, select }) {
|
|
125
|
+
const query = useSuspenseQuery({
|
|
126
|
+
queryKey,
|
|
127
|
+
queryFn: ({ signal }) => queryFn({ signal }),
|
|
128
|
+
...DEFAULT_QUERY_CONFIG,
|
|
129
|
+
...options,
|
|
130
|
+
...select ? { select } : {}
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
item: extractItem(query.data),
|
|
134
|
+
isLoading: false,
|
|
135
|
+
isFetching: query.isFetching,
|
|
136
|
+
isError: query.isError,
|
|
137
|
+
isSuccess: query.isSuccess,
|
|
138
|
+
isStale: query.isStale,
|
|
139
|
+
isPlaceholderData: false,
|
|
140
|
+
error: query.error,
|
|
141
|
+
refetch: query.refetch
|
|
102
142
|
};
|
|
103
143
|
}
|
|
104
144
|
function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam, maxPages }) {
|
|
@@ -191,4 +231,4 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
|
|
|
191
231
|
}
|
|
192
232
|
|
|
193
233
|
//#endregion
|
|
194
|
-
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
|
|
234
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery, useSuspenseDetailQuery, useSuspenseListQuery };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "React + TanStack Query SDK for Arc resources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"types": "./dist/client.d.ts",
|
|
39
39
|
"default": "./dist/client.js"
|
|
40
40
|
},
|
|
41
|
+
"./encryption": {
|
|
42
|
+
"types": "./dist/encryption.d.ts",
|
|
43
|
+
"default": "./dist/encryption.js"
|
|
44
|
+
},
|
|
41
45
|
"./api": {
|
|
42
46
|
"types": "./dist/api.d.ts",
|
|
43
47
|
"default": "./dist/api.js"
|
|
@@ -124,12 +128,14 @@
|
|
|
124
128
|
"peerDependencies": {
|
|
125
129
|
"@classytic/repo-core": ">=0.4.0",
|
|
126
130
|
"@tanstack/react-query": ">=5.62.0",
|
|
131
|
+
"jose": ">=5.0.0",
|
|
127
132
|
"react": ">=19.0.0"
|
|
128
133
|
},
|
|
129
134
|
"peerDependenciesMeta": {
|
|
130
135
|
"react": { "optional": false },
|
|
131
136
|
"@tanstack/react-query": { "optional": false },
|
|
132
|
-
"@classytic/repo-core": { "optional": false }
|
|
137
|
+
"@classytic/repo-core": { "optional": false },
|
|
138
|
+
"jose": { "optional": true }
|
|
133
139
|
},
|
|
134
140
|
"devDependencies": {
|
|
135
141
|
"@classytic/dev-tools": "^0.2.0",
|
|
@@ -139,6 +145,7 @@
|
|
|
139
145
|
"@testing-library/react": "^16.3.2",
|
|
140
146
|
"@types/react": "^19.2.14",
|
|
141
147
|
"@types/react-dom": "^19.2.3",
|
|
148
|
+
"jose": "^6.2.3",
|
|
142
149
|
"jsdom": "^29.0.2",
|
|
143
150
|
"react": "^19.2.5",
|
|
144
151
|
"react-dom": "^19.2.5",
|