@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 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
- return createClient({
589
- baseUrl: overrides.baseUrl ?? getBaseUrl(),
590
- authMode: overrides.authMode ?? getAuthMode(),
591
- autoIdempotency: overrides.autoIdempotency ?? isAutoIdempotency(),
592
- elevated: overrides.elevated ?? clientConfig?.elevated,
593
- ...overrides,
594
- getToken: overrides.getToken ?? (() => readToken(authConfig?.getToken)),
595
- getOrgId: overrides.getOrgId ?? (() => authConfig?.getOrgId?.() ?? null),
596
- headerName: overrides.headerName ?? authConfig?.headerName
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
- if (contentType?.includes("application/json")) data = await response.json();
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
- data: query.data
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.7.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",