@classytic/arc-next 0.7.1 → 0.9.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/api.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ArcClient } from "./client.js";
1
+ import { ArcClient, NextFetchOptions } from "./client.js";
2
2
  import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, PaginatedResult } from "@classytic/repo-core/pagination";
3
3
  import { AggResult, AggRow, BulkCreateResult, DeleteManyResult, DeleteResult, UpdateManyResult } from "@classytic/repo-core/repository";
4
4
  import { BracketOperator, BracketOperator as BracketOperator$1 } from "@classytic/repo-core/query-parser";
@@ -50,8 +50,14 @@ interface RequestOptions {
50
50
  token?: string | null;
51
51
  organizationId?: string | null;
52
52
  cache?: RequestCache;
53
- revalidate?: number;
53
+ /** Flattened Next `revalidate`. `false` = cache indefinitely. */
54
+ revalidate?: number | false;
54
55
  tags?: string[];
56
+ /**
57
+ * Idiomatic Next App Router fetch config — passed to `fetch(url, { next })`.
58
+ * Merged with the flattened `revalidate`/`tags`. Prefer this from Next hosts.
59
+ */
60
+ next?: NextFetchOptions;
55
61
  headerOptions?: Record<string, string>;
56
62
  responseType?: 'json' | 'blob' | 'text';
57
63
  signal?: AbortSignal;
@@ -87,6 +93,23 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
87
93
  constructor(entity: string, config?: BaseApiConfig);
88
94
  /** Merge per-instance headers into request options */
89
95
  private withHeaders;
96
+ /**
97
+ * Apply the instance's default `cache` to a per-call options object — but
98
+ * ONLY when the caller expressed no caching intent of their own.
99
+ *
100
+ * A per-call `revalidate`/`next` (time-based ISR) or an explicit `cache`
101
+ * takes precedence. The default is `no-store`; forcing it alongside a
102
+ * `revalidate` makes Next.js throw ("cache: 'no-store' and revalidate are
103
+ * contradictory") and otherwise silently pins the route to dynamic
104
+ * rendering — so a consumer can never opt a single read into ISR. Gating the
105
+ * default here lets `getBySlug({ slug, options: { revalidate: 60 } })` become
106
+ * statically cacheable without every other call losing its no-store default.
107
+ *
108
+ * Framework-agnostic: `cache` / `revalidate` / `next` are inert pass-throughs
109
+ * on runtimes (React Native, plain browser fetch) that don't implement the
110
+ * Next.js fetch extensions, so this never assumes a Next host.
111
+ */
112
+ private withCacheDefault;
90
113
  createQueryString(params?: Record<string, unknown>): string;
91
114
  prepareParams(params?: QueryParams): Record<string, unknown>;
92
115
  getAll({
@@ -259,8 +282,16 @@ declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, T
259
282
  }
260
283
  declare function createCrudApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>>(entity: string, config?: BaseApiConfig): BaseApi<TDoc, TCreate, TUpdate>;
261
284
  type ExtractDoc<T> = T extends PaginatedResult<infer D> ? D : never;
285
+ /** Any preset-augmented BaseApi — the constraint preset factories accept. */
286
+ type AnyBaseApi = BaseApi<any, any, any>;
287
+ /** Document type of a (possibly augmented) BaseApi. */
288
+ type DocOf<A> = A extends BaseApi<infer D, any, any> ? D : never;
289
+ /** Create-payload type of a (possibly augmented) BaseApi. */
290
+ type CreateOf<A> = A extends BaseApi<any, infer C, any> ? C : never;
291
+ /** Update-payload type of a (possibly augmented) BaseApi. */
292
+ type UpdateOf<A> = A extends BaseApi<any, any, infer U> ? U : never;
262
293
  declare function isOffsetPagination<T>(response: PaginatedResult<T>): response is OffsetPaginationResult<T>;
263
294
  declare function isKeysetPagination<T>(response: PaginatedResult<T>): response is KeysetPaginationResult<T>;
264
295
  declare function isAggregatePagination<T>(response: PaginatedResult<T>): response is AggregatePaginationResult<T>;
265
296
  //#endregion
266
- export { type AggResult, type AggRow, BaseApi, BaseApiConfig, type BracketOperator, type BulkCreateResult, type DeleteManyResult, type DeleteResult, ExtractDoc, FilterOperator, PopulateOption, QueryParams, RequestOptions, ScopedArgs, SortDirection, SortSpec, type UpdateManyResult, createCrudApi, isAggregatePagination, isKeysetPagination, isOffsetPagination };
297
+ export { type AggResult, type AggRow, AnyBaseApi, BaseApi, BaseApiConfig, type BracketOperator, type BulkCreateResult, CreateOf, type DeleteManyResult, type DeleteResult, DocOf, ExtractDoc, FilterOperator, PopulateOption, QueryParams, RequestOptions, ScopedArgs, SortDirection, SortSpec, type UpdateManyResult, UpdateOf, createCrudApi, isAggregatePagination, isKeysetPagination, isOffsetPagination };
package/dist/api.js CHANGED
@@ -33,6 +33,29 @@ var BaseApi = class {
33
33
  }
34
34
  };
35
35
  }
36
+ /**
37
+ * Apply the instance's default `cache` to a per-call options object — but
38
+ * ONLY when the caller expressed no caching intent of their own.
39
+ *
40
+ * A per-call `revalidate`/`next` (time-based ISR) or an explicit `cache`
41
+ * takes precedence. The default is `no-store`; forcing it alongside a
42
+ * `revalidate` makes Next.js throw ("cache: 'no-store' and revalidate are
43
+ * contradictory") and otherwise silently pins the route to dynamic
44
+ * rendering — so a consumer can never opt a single read into ISR. Gating the
45
+ * default here lets `getBySlug({ slug, options: { revalidate: 60 } })` become
46
+ * statically cacheable without every other call losing its no-store default.
47
+ *
48
+ * Framework-agnostic: `cache` / `revalidate` / `next` are inert pass-throughs
49
+ * on runtimes (React Native, plain browser fetch) that don't implement the
50
+ * Next.js fetch extensions, so this never assumes a Next host.
51
+ */
52
+ withCacheDefault(options = {}) {
53
+ if (options.cache !== void 0 || options.revalidate !== void 0 || options.next !== void 0) return options;
54
+ return {
55
+ cache: this.config.cache,
56
+ ...options
57
+ };
58
+ }
36
59
  createQueryString(params = {}) {
37
60
  return createQueryString(params);
38
61
  }
@@ -77,10 +100,7 @@ var BaseApi = class {
77
100
  };
78
101
  const processedParams = this.prepareParams(mergedParams);
79
102
  const queryString = this.createQueryString(processedParams);
80
- const requestOptions = {
81
- cache: this.config.cache,
82
- ...options
83
- };
103
+ const requestOptions = { ...this.withCacheDefault(options) };
84
104
  if (token) requestOptions.token = token;
85
105
  if (organizationId) requestOptions.organizationId = organizationId;
86
106
  return this.requestFn("GET", `${this.baseUrl}?${queryString}`, this.withHeaders(requestOptions));
@@ -89,10 +109,7 @@ var BaseApi = class {
89
109
  if (!id) throw new Error("ID is required");
90
110
  const queryString = this.createQueryString(params);
91
111
  const url = queryString ? `${this.baseUrl}/${id}?${queryString}` : `${this.baseUrl}/${id}`;
92
- const requestOptions = {
93
- cache: this.config.cache,
94
- ...options
95
- };
112
+ const requestOptions = { ...this.withCacheDefault(options) };
96
113
  if (token) requestOptions.token = token;
97
114
  if (organizationId) requestOptions.organizationId = organizationId;
98
115
  return this.requestFn("GET", url, this.withHeaders(requestOptions));
@@ -142,8 +159,7 @@ var BaseApi = class {
142
159
  }
143
160
  const requestOptions = {
144
161
  body: data,
145
- cache: this.config.cache,
146
- ...options
162
+ ...this.withCacheDefault(options)
147
163
  };
148
164
  if (token) requestOptions.token = token;
149
165
  if (organizationId) requestOptions.organizationId = organizationId;
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). */
@@ -562,13 +602,40 @@ interface TextResponse {
562
602
  data: string;
563
603
  response: Response;
564
604
  }
605
+ /**
606
+ * Next.js App Router `fetch` cache directives — forwarded verbatim as
607
+ * `fetch(url, { next })`. This is the idiomatic Next shape; a host using the
608
+ * App Router can pass it 1:1 with what they'd hand to `fetch`. The flattened
609
+ * `revalidate` / `tags` options remain for back-compat and are merged into
610
+ * this object before the request (flattened values win / append).
611
+ *
612
+ * @see https://nextjs.org/docs/app/api-reference/functions/fetch
613
+ */
614
+ interface NextFetchOptions {
615
+ /**
616
+ * Seconds before the cached response is considered stale and revalidated.
617
+ * `false` caches indefinitely (Next's `force-cache` semantics); `0` opts
618
+ * out of caching. Omit to inherit the route's default.
619
+ */
620
+ revalidate?: number | false;
621
+ /** Cache tags for on-demand `revalidateTag(tag)` invalidation. */
622
+ tags?: string[];
623
+ }
565
624
  interface ApiRequestOptions {
566
625
  body?: unknown;
567
626
  token?: string | null;
568
627
  organizationId?: string | null;
569
- revalidate?: number;
628
+ /** Flattened Next `revalidate` (see `next`). `false` = cache indefinitely. */
629
+ revalidate?: number | false;
570
630
  headerOptions?: Record<string, string>;
631
+ /** Flattened Next cache `tags` (see `next`). */
571
632
  tags?: string[];
633
+ /**
634
+ * Idiomatic Next App Router fetch cache config — passed straight to
635
+ * `fetch(url, { next })`. Prefer this over the flattened `revalidate`/`tags`
636
+ * when a Next host wants full control; both are merged.
637
+ */
638
+ next?: NextFetchOptions;
572
639
  cache?: RequestCache;
573
640
  signal?: AbortSignal;
574
641
  /** Explicit idempotency key for this request. Sent as `Idempotency-Key` header. */
@@ -725,10 +792,12 @@ interface ArcFetchOptions extends Omit<RequestInit, 'body' | 'headers'> {
725
792
  elevated?: boolean;
726
793
  /** Per-call `Idempotency-Key` header. */
727
794
  idempotencyKey?: string;
728
- /** Next.js fetch `revalidate` pass-through. */
729
- revalidate?: number;
730
- /** Next.js fetch `tags` pass-through. */
795
+ /** Flattened Next `revalidate` pass-through. `false` = cache indefinitely. */
796
+ revalidate?: number | false;
797
+ /** Flattened Next cache `tags` pass-through. */
731
798
  tags?: string[];
799
+ /** Idiomatic Next App Router fetch config — merged with `revalidate`/`tags`. */
800
+ next?: NextFetchOptions;
732
801
  /** `cache` pass-through (browser fetch + Next.js). */
733
802
  cache?: RequestCache;
734
803
  /**
@@ -795,4 +864,4 @@ declare const arc: {
795
864
  delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
796
865
  };
797
866
  //#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 };
867
+ export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, 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
@@ -738,7 +738,7 @@ function sleepAbortable(ms, signal) {
738
738
  }
739
739
  /** A single fetch attempt — used by executeRequest's retry loop. */
740
740
  async function executeAttempt(config, method, endpoint, options, attempt) {
741
- const { body, token, organizationId, revalidate, headerOptions, tags, cache, signal, idempotencyKey, elevated } = options;
741
+ const { body, token, organizationId, revalidate, headerOptions, tags, next, cache, signal, idempotencyKey, elevated } = options;
742
742
  const startTime = Date.now();
743
743
  try {
744
744
  let headers = {
@@ -763,6 +763,11 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
763
763
  const credentials = config.credentials ?? (config.authMode === "cookie" ? "include" : "same-origin");
764
764
  let serializedBody = void 0;
765
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
+ }
766
771
  if (config.beforeRequest) {
767
772
  const ctx = await config.beforeRequest({
768
773
  method,
@@ -783,14 +788,10 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
783
788
  };
784
789
  if (serializedBody !== void 0) fetchOptions.body = serializedBody;
785
790
  if (cache) fetchOptions.cache = cache;
786
- if (revalidate !== void 0) fetchOptions.next = {
787
- ...fetchOptions.next,
788
- revalidate
789
- };
790
- if (tags) fetchOptions.next = {
791
- ...fetchOptions.next,
792
- tags
793
- };
791
+ const nextCfg = { ...next };
792
+ if (revalidate !== void 0) nextCfg.revalidate = revalidate;
793
+ if (tags) nextCfg.tags = nextCfg.tags ? [...nextCfg.tags, ...tags] : tags;
794
+ if (nextCfg.revalidate !== void 0 || nextCfg.tags) fetchOptions.next = nextCfg;
794
795
  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).`);
795
796
  const response = await fetch(`${config.baseUrl}${endpoint}`, fetchOptions);
796
797
  if (!response.ok) {
@@ -799,7 +800,7 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
799
800
  try {
800
801
  json = await response.clone().json();
801
802
  const j = json;
802
- errorMessage = typeof j?.error === "string" && j.error || typeof j?.message === "string" && j.message || response.statusText;
803
+ errorMessage = typeof j?.error === "string" && j.error || typeof j?.meta?.message === "string" && j.meta.message || typeof j?.message === "string" && j.message || response.statusText;
803
804
  } catch {
804
805
  try {
805
806
  const text = await response.text();
@@ -819,7 +820,12 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
819
820
  }
820
821
  const contentType = response.headers.get("Content-Type");
821
822
  let data;
822
- if (contentType?.includes("application/json")) data = await response.json();
823
+ const decryptCt = encryption ? encryption.responseContentType ?? "application/jose" : void 0;
824
+ if (encryption && decryptCt && contentType?.includes(decryptCt)) {
825
+ const cipher = await response.text();
826
+ const plaintext = cipher.length > 0 ? await encryption.decrypt(cipher) : "";
827
+ data = plaintext.length > 0 ? JSON.parse(plaintext) : void 0;
828
+ } else if (contentType?.includes("application/json")) data = await response.json();
823
829
  else if (contentType?.includes("application/pdf") || contentType?.includes("image/")) data = {
824
830
  data: await response.blob(),
825
831
  response
@@ -1052,7 +1058,7 @@ function sanitizeUserHeaders(headers) {
1052
1058
  * const result = await arc.post<{ ok: true }>('/api/statements', statements);
1053
1059
  */
1054
1060
  function arcFetch(path, options = {}) {
1055
- const { method = "GET", body, headers, signal, elevated, idempotencyKey, revalidate, tags, cache, client } = options;
1061
+ const { method = "GET", body, headers, signal, elevated, idempotencyKey, revalidate, tags, next, cache, client } = options;
1056
1062
  const transport = client ?? getDefaultArcFetchClient();
1057
1063
  const apiOptions = {
1058
1064
  body,
@@ -1062,6 +1068,7 @@ function arcFetch(path, options = {}) {
1062
1068
  ...idempotencyKey ? { idempotencyKey } : {},
1063
1069
  ...revalidate !== void 0 ? { revalidate } : {},
1064
1070
  ...tags ? { tags } : {},
1071
+ ...next ? { next } : {},
1065
1072
  ...cache ? { cache } : {}
1066
1073
  };
1067
1074
  return transport.request(method, path, apiOptions);
@@ -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,
@@ -11,7 +11,24 @@ interface PrefetchAuthContext {
11
11
  }
12
12
  interface PrefetchOptions extends PrefetchAuthContext {
13
13
  staleTime?: number;
14
+ /**
15
+ * Next.js fetch caching forwarded to the underlying API call. A Server
16
+ * Component that prefetches with `revalidate` lets that data participate in
17
+ * ISR — without it the SDK's default `no-store` pins the whole route to
18
+ * dynamic rendering (e.g. a shared layout prefetch silently disables ISR on
19
+ * every page under it). Inert off-Next (React Native / plain browser fetch).
20
+ */
21
+ cache?: RequestCache;
22
+ revalidate?: number | false;
23
+ tags?: string[];
14
24
  }
25
+ /** Per-call API `options` the prefetcher forwards (caching + custom headers). */
26
+ type ForwardedApiOptions = {
27
+ headerOptions?: Record<string, string>;
28
+ cache?: RequestCache;
29
+ revalidate?: number | false;
30
+ tags?: string[];
31
+ };
15
32
  interface PrefetchDetailOptions extends PrefetchOptions {
16
33
  /** Query params (select, populate) — key must match useDetail's params to share cache */
17
34
  params?: {
@@ -107,51 +124,39 @@ declare function createCrudPrefetcher(api: {
107
124
  params?: Record<string, unknown>;
108
125
  token?: string | null;
109
126
  organizationId?: string | null;
110
- options?: {
111
- headerOptions?: Record<string, string>;
112
- };
127
+ options?: ForwardedApiOptions;
113
128
  }) => Promise<unknown>;
114
129
  getById: (opts: {
115
130
  id: string;
116
131
  token?: string | null;
117
132
  organizationId?: string | null;
118
- options?: {
119
- headerOptions?: Record<string, string>;
120
- };
133
+ options?: ForwardedApiOptions;
121
134
  }) => Promise<unknown>;
122
135
  getBySlug?: (opts: {
123
136
  slug: string;
124
137
  token?: string | null;
125
138
  organizationId?: string | null;
126
139
  params?: Record<string, unknown>;
127
- options?: {
128
- headerOptions?: Record<string, string>;
129
- };
140
+ options?: ForwardedApiOptions;
130
141
  }) => Promise<unknown>;
131
142
  getDeleted?: (opts: {
132
143
  params?: Record<string, unknown>;
133
144
  token?: string | null;
134
145
  organizationId?: string | null;
135
- options?: {
136
- headerOptions?: Record<string, string>;
137
- };
146
+ options?: ForwardedApiOptions;
138
147
  }) => Promise<unknown>;
139
148
  aggregate?: (opts: {
140
149
  name: string;
141
150
  filter?: Record<string, unknown>;
142
151
  token?: string | null;
143
152
  organizationId?: string | null;
144
- options?: {
145
- headerOptions?: Record<string, string>;
146
- };
153
+ options?: ForwardedApiOptions;
147
154
  }) => Promise<unknown>;
148
155
  getTree?: (opts: {
149
156
  params?: Record<string, unknown>;
150
157
  token?: string | null;
151
158
  organizationId?: string | null;
152
- options?: {
153
- headerOptions?: Record<string, string>;
154
- };
159
+ options?: ForwardedApiOptions;
155
160
  }) => Promise<unknown>;
156
161
  }, entityKey: string): CrudPrefetcher;
157
162
  //#endregion
package/dist/prefetch.js CHANGED
@@ -29,6 +29,14 @@ import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
29
29
  */
30
30
  function createCrudPrefetcher(api, entityKey) {
31
31
  const KEYS = createQueryKeys(entityKey);
32
+ const apiOptions = (o) => {
33
+ const opt = {};
34
+ if (o.headers) opt.headerOptions = o.headers;
35
+ if (o.cache !== void 0) opt.cache = o.cache;
36
+ if (o.revalidate !== void 0) opt.revalidate = o.revalidate;
37
+ if (o.tags !== void 0) opt.tags = o.tags;
38
+ return Object.keys(opt).length ? { options: opt } : {};
39
+ };
32
40
  return {
33
41
  async prefetchList(queryClient, params = {}, options = {}) {
34
42
  const { organizationId: paramOrgId, ...restParams } = params;
@@ -44,7 +52,7 @@ function createCrudPrefetcher(api, entityKey) {
44
52
  params: restParams,
45
53
  token: options.token ?? null,
46
54
  organizationId: orgId,
47
- ...options.headers ? { options: { headerOptions: options.headers } } : {}
55
+ ...apiOptions(options)
48
56
  }),
49
57
  staleTime: options.staleTime
50
58
  });
@@ -60,7 +68,7 @@ function createCrudPrefetcher(api, entityKey) {
60
68
  token: token ?? null,
61
69
  organizationId: organizationId ?? null,
62
70
  ...params ? { params } : {},
63
- ...options.headers ? { options: { headerOptions: options.headers } } : {}
71
+ ...apiOptions(options)
64
72
  }),
65
73
  staleTime
66
74
  });
@@ -76,7 +84,7 @@ function createCrudPrefetcher(api, entityKey) {
76
84
  token: token ?? null,
77
85
  organizationId: organizationId ?? null,
78
86
  ...params ? { params } : {},
79
- ...options.headers ? { options: { headerOptions: options.headers } } : {}
87
+ ...apiOptions(options)
80
88
  }),
81
89
  staleTime
82
90
  });
@@ -95,7 +103,7 @@ function createCrudPrefetcher(api, entityKey) {
95
103
  params: restParams,
96
104
  token: options.token ?? null,
97
105
  organizationId: orgId,
98
- ...options.headers ? { options: { headerOptions: options.headers } } : {}
106
+ ...apiOptions(options)
99
107
  }),
100
108
  staleTime: options.staleTime
101
109
  });
@@ -115,7 +123,7 @@ function createCrudPrefetcher(api, entityKey) {
115
123
  filter,
116
124
  token: options.token ?? null,
117
125
  organizationId: orgId,
118
- ...options.headers ? { options: { headerOptions: options.headers } } : {}
126
+ ...apiOptions(options)
119
127
  }),
120
128
  staleTime: options.staleTime
121
129
  });
@@ -134,7 +142,7 @@ function createCrudPrefetcher(api, entityKey) {
134
142
  params: restParams,
135
143
  token: options.token ?? null,
136
144
  organizationId: orgId,
137
- ...options.headers ? { options: { headerOptions: options.headers } } : {}
145
+ ...apiOptions(options)
138
146
  }),
139
147
  staleTime: options.staleTime
140
148
  });
@@ -156,7 +164,7 @@ function createCrudPrefetcher(api, entityKey) {
156
164
  },
157
165
  token: options.token ?? null,
158
166
  organizationId: orgId,
159
- ...options.headers ? { options: { headerOptions: options.headers } } : {}
167
+ ...apiOptions(options)
160
168
  }),
161
169
  initialPageParam: 1,
162
170
  getNextPageParam: () => void 0,
@@ -1,4 +1,4 @@
1
- import { BaseApi, ScopedArgs } from "../api.js";
1
+ import { AnyBaseApi, CreateOf, DocOf, ScopedArgs, UpdateOf } from "../api.js";
2
2
  import { BulkCreateResult, DeleteManyResult, UpdateManyResult } from "@classytic/repo-core/repository";
3
3
 
4
4
  //#region src/presets/bulk.d.ts
@@ -38,6 +38,6 @@ interface BulkMethods<TDoc, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>> {
38
38
  * await todos.bulkUpdate({ filter: { status: 'pending' }, data: { status: 'archived' } });
39
39
  * await todos.bulkDelete({ filter: { archivedBefore: '2024-01-01' } });
40
40
  */
41
- declare function withBulk<TDoc, TCreate, TUpdate>(api: BaseApi<TDoc, TCreate, TUpdate>): BaseApi<TDoc, TCreate, TUpdate> & BulkMethods<TDoc, TCreate, TUpdate>;
41
+ declare function withBulk<TApi extends AnyBaseApi>(api: TApi): TApi & BulkMethods<DocOf<TApi>, CreateOf<TApi>, UpdateOf<TApi>>;
42
42
  //#endregion
43
43
  export { BulkMethods, withBulk };
@@ -1,4 +1,4 @@
1
- import { BaseApi, ScopedArgs } from "../api.js";
1
+ import { AnyBaseApi, DocOf, ScopedArgs } from "../api.js";
2
2
  import { PaginatedResult } from "@classytic/repo-core/pagination";
3
3
 
4
4
  //#region src/presets/search.d.ts
@@ -51,6 +51,6 @@ interface SearchPresetMethods<TDoc> {
51
51
  * await places.searchSimilar({ vector: [0.1, 0.2, ...], body: { topK: 5 } });
52
52
  * await places.embed({ input: 'hello world' });
53
53
  */
54
- declare function withSearchPreset<TDoc, TCreate, TUpdate>(api: BaseApi<TDoc, TCreate, TUpdate>): BaseApi<TDoc, TCreate, TUpdate> & SearchPresetMethods<TDoc>;
54
+ declare function withSearchPreset<TApi extends AnyBaseApi>(api: TApi): TApi & SearchPresetMethods<DocOf<TApi>>;
55
55
  //#endregion
56
56
  export { SearchPresetMethods, withSearchPreset };
@@ -1,4 +1,4 @@
1
- import { BaseApi, ScopedArgs } from "../api.js";
1
+ import { AnyBaseApi, DocOf, ScopedArgs } from "../api.js";
2
2
 
3
3
  //#region src/presets/slug.d.ts
4
4
  interface SlugLookupMethods<TDoc> {
@@ -23,6 +23,6 @@ interface SlugLookupMethods<TDoc> {
23
23
  *
24
24
  * const cat = await categories.getBySlug({ slug: 'engineering' });
25
25
  */
26
- declare function withSlugLookup<TDoc, TCreate, TUpdate>(api: BaseApi<TDoc, TCreate, TUpdate>): BaseApi<TDoc, TCreate, TUpdate> & SlugLookupMethods<TDoc>;
26
+ declare function withSlugLookup<TApi extends AnyBaseApi>(api: TApi): TApi & SlugLookupMethods<DocOf<TApi>>;
27
27
  //#endregion
28
28
  export { SlugLookupMethods, withSlugLookup };
@@ -1,4 +1,4 @@
1
- import { BaseApi, QueryParams, ScopedArgs } from "../api.js";
1
+ import { AnyBaseApi, DocOf, QueryParams, ScopedArgs } from "../api.js";
2
2
  import { PaginatedResult } from "@classytic/repo-core/pagination";
3
3
 
4
4
  //#region src/presets/soft-delete.d.ts
@@ -29,6 +29,6 @@ interface SoftDeleteMethods<TDoc> {
29
29
  * await todos.restore({ id }); // undo
30
30
  * const trash = await todos.getDeleted();
31
31
  */
32
- declare function withSoftDelete<TDoc, TCreate, TUpdate>(api: BaseApi<TDoc, TCreate, TUpdate>): BaseApi<TDoc, TCreate, TUpdate> & SoftDeleteMethods<TDoc>;
32
+ declare function withSoftDelete<TApi extends AnyBaseApi>(api: TApi): TApi & SoftDeleteMethods<DocOf<TApi>>;
33
33
  //#endregion
34
34
  export { SoftDeleteMethods, withSoftDelete };
@@ -1,4 +1,4 @@
1
- import { BaseApi, QueryParams, ScopedArgs } from "../api.js";
1
+ import { AnyBaseApi, DocOf, QueryParams, ScopedArgs } from "../api.js";
2
2
  import { PaginatedResult } from "@classytic/repo-core/pagination";
3
3
 
4
4
  //#region src/presets/tree.d.ts
@@ -27,6 +27,6 @@ interface TreeMethods<TDoc> {
27
27
  * const root = await categories.getTree();
28
28
  * const kids = await categories.getChildren({ parentId: 'engineering' });
29
29
  */
30
- declare function withTree<TDoc, TCreate, TUpdate>(api: BaseApi<TDoc, TCreate, TUpdate>): BaseApi<TDoc, TCreate, TUpdate> & TreeMethods<TDoc>;
30
+ declare function withTree<TApi extends AnyBaseApi>(api: TApi): TApi & TreeMethods<DocOf<TApi>>;
31
31
  //#endregion
32
32
  export { TreeMethods, withTree };
package/dist/query.d.ts CHANGED
@@ -195,6 +195,20 @@ declare function useDetailQuery<T>({
195
195
  select,
196
196
  placeholderData
197
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>;
198
212
  interface InfiniteListQueryOptions {
199
213
  public?: boolean;
200
214
  enabled?: boolean;
@@ -337,4 +351,4 @@ declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>
337
351
  options
338
352
  }: UseApiQueryConfig<TResponse, TData>): UseApiQueryResult<TData>;
339
353
  //#endregion
340
- 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
@@ -99,6 +99,48 @@ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, selec
99
99
  refetch: query.refetch
100
100
  };
101
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
142
+ };
143
+ }
102
144
  function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam, maxPages }) {
103
145
  const query = useInfiniteQuery({
104
146
  queryKey,
@@ -189,4 +231,4 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
189
231
  }
190
232
 
191
233
  //#endregion
192
- 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.1",
3
+ "version": "0.9.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"
@@ -119,17 +123,28 @@
119
123
  "push": "classytic-push",
120
124
  "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
121
125
  "release": "npm run push -- main && npm run release:tag && npm publish",
122
- "prepublishOnly": "npm run typecheck && npm test && npm run build"
126
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
127
+ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json"
123
128
  },
124
129
  "peerDependencies": {
125
130
  "@classytic/repo-core": ">=0.4.0",
126
131
  "@tanstack/react-query": ">=5.62.0",
132
+ "jose": ">=5.0.0",
127
133
  "react": ">=19.0.0"
128
134
  },
129
135
  "peerDependenciesMeta": {
130
- "react": { "optional": false },
131
- "@tanstack/react-query": { "optional": false },
132
- "@classytic/repo-core": { "optional": false }
136
+ "react": {
137
+ "optional": false
138
+ },
139
+ "@tanstack/react-query": {
140
+ "optional": false
141
+ },
142
+ "@classytic/repo-core": {
143
+ "optional": false
144
+ },
145
+ "jose": {
146
+ "optional": true
147
+ }
133
148
  },
134
149
  "devDependencies": {
135
150
  "@classytic/dev-tools": "^0.2.0",
@@ -139,6 +154,7 @@
139
154
  "@testing-library/react": "^16.3.2",
140
155
  "@types/react": "^19.2.14",
141
156
  "@types/react-dom": "^19.2.3",
157
+ "jose": "^6.2.3",
142
158
  "jsdom": "^29.0.2",
143
159
  "react": "^19.2.5",
144
160
  "react-dom": "^19.2.5",