@classytic/arc-next 0.9.0 → 0.10.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/cache.d.ts CHANGED
@@ -110,6 +110,20 @@ interface QueryKeys {
110
110
  * is identical between server (prefetch) and client (hooks), so RSC SSR
111
111
  * hydration matches what client-side `useList`/`useDetail` produce.
112
112
  */
113
+ /**
114
+ * Org-normalized key params — the ONE way hooks AND prefetchers merge
115
+ * `organizationId` into a query-key params object.
116
+ *
117
+ * Why this exists: TanStack's key hash (`hashKey`) drops `undefined` object
118
+ * values but KEEPS `null` — so `{ organizationId: null }` and `{}` are
119
+ * DIFFERENT cache entries. Auth resolution returns `organizationId: null`
120
+ * for org-less callers (public storefronts), while server prefetchers
121
+ * conditionally omitted the field — producing keys that never matched and
122
+ * silently defeating SSR hydration (the client refetched everything).
123
+ * Normalizing here (omit when nullish) makes hook and prefetch keys equal
124
+ * by construction. Covered by tests/key-parity.test.ts.
125
+ */
126
+ declare function withOrgParams(organizationId: string | null | undefined, params?: Record<string, unknown>): Record<string, unknown>;
113
127
  declare function createQueryKeys(entityKey: string): QueryKeys;
114
128
  interface CacheUtils<T> {
115
129
  invalidateAll: (client: QueryClient) => Promise<void>;
@@ -149,4 +163,4 @@ interface CacheUtils<T> {
149
163
  */
150
164
  declare function createCacheUtils<T>(KEYS: QueryKeys): CacheUtils<T>;
151
165
  //#endregion
152
- export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache };
166
+ export { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache, withOrgParams };
package/dist/cache.js CHANGED
@@ -221,6 +221,26 @@ function mergeItemIntoListPage(page, targetId, item, idField) {
221
221
  * is identical between server (prefetch) and client (hooks), so RSC SSR
222
222
  * hydration matches what client-side `useList`/`useDetail` produce.
223
223
  */
224
+ /**
225
+ * Org-normalized key params — the ONE way hooks AND prefetchers merge
226
+ * `organizationId` into a query-key params object.
227
+ *
228
+ * Why this exists: TanStack's key hash (`hashKey`) drops `undefined` object
229
+ * values but KEEPS `null` — so `{ organizationId: null }` and `{}` are
230
+ * DIFFERENT cache entries. Auth resolution returns `organizationId: null`
231
+ * for org-less callers (public storefronts), while server prefetchers
232
+ * conditionally omitted the field — producing keys that never matched and
233
+ * silently defeating SSR hydration (the client refetched everything).
234
+ * Normalizing here (omit when nullish) makes hook and prefetch keys equal
235
+ * by construction. Covered by tests/key-parity.test.ts.
236
+ */
237
+ function withOrgParams(organizationId, params = {}) {
238
+ const { organizationId: _drop, ...rest } = params;
239
+ return organizationId ? {
240
+ organizationId,
241
+ ...rest
242
+ } : rest;
243
+ }
224
244
  function createQueryKeys(entityKey) {
225
245
  return {
226
246
  all: [entityKey],
@@ -300,4 +320,4 @@ function createCacheUtils(KEYS) {
300
320
  }
301
321
 
302
322
  //#endregion
303
- export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache };
323
+ export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache, withOrgParams };
@@ -0,0 +1,86 @@
1
+ //#region src/field-encryption.d.ts
2
+ /**
3
+ * `@classytic/arc-next/field-encryption` — client decrypt for arc's
4
+ * FIELD-mode ALE (`@classytic/arc/encryption` with `mode: 'fields'`).
5
+ *
6
+ * Field-mode responses stay `application/json`; only the configured field
7
+ * VALUES arrive as authenticated `arc.v1` envelopes
8
+ * (`arc.v1.<b64url(kid)>.<b64url(iv)>.<b64url(ct)>.<b64url(tag)>`,
9
+ * AES-256-GCM). This helper parses and decrypts those envelopes with the
10
+ * shared symmetric key, via Web Crypto — zero dependencies, works in Node
11
+ * 22+, Bun, Deno, and React Native (with a Web Crypto polyfill).
12
+ *
13
+ * ── SECURITY: trusted runtimes ONLY ─────────────────────────────────────
14
+ * Field mode is SYMMETRIC — whoever holds the key can decrypt every
15
+ * envelope ever produced under it. That key belongs in a Node BFF, a
16
+ * server component, or a native app's secure storage. It must NEVER ship
17
+ * in a browser bundle: anything readable by browser JavaScript is readable
18
+ * by every visitor. For payloads a browser must decrypt, use the
19
+ * asymmetric JWE path (`@classytic/arc-next/encryption`) instead — that is
20
+ * exactly why this lives in its own subpath the web bundle never imports.
21
+ * ────────────────────────────────────────────────────────────────────────
22
+ *
23
+ * Fail-closed: unknown `kid`, tampered ciphertext, or a malformed
24
+ * `arc.v1.*` token throws — an encrypted field is never silently passed
25
+ * through as ciphertext or dropped.
26
+ *
27
+ * @example Node BFF / server component
28
+ * ```ts
29
+ * import { createFieldDecryption } from '@classytic/arc-next/field-encryption';
30
+ *
31
+ * const ale = createFieldDecryption({
32
+ * // 32-byte AES-256 keys by kid — keep the previous kid during rotation.
33
+ * keys: { 'k-2026-07': keyBytes },
34
+ * });
35
+ *
36
+ * configureClient({
37
+ * baseUrl: env.ARC_URL,
38
+ * afterResponse: async (ctx) => {
39
+ * // arc stamps `x-encrypted: true` on field-mode responses — only
40
+ * // those are scanned; every other response passes through untouched.
41
+ * if (ctx.response.headers.get('x-encrypted') === 'true') {
42
+ * ctx.body = await ale.decryptFieldsDeep(ctx.body);
43
+ * }
44
+ * return ctx;
45
+ * },
46
+ * });
47
+ * ```
48
+ */
49
+ /** Version prefix shared with `@classytic/arc/encryption`'s field cipher. */
50
+ declare const FIELD_ENVELOPE_PREFIX = "arc.v1";
51
+ /** Parsed `arc.v1` envelope — `kid` exposed for key resolution. */
52
+ interface ParsedFieldEnvelope {
53
+ readonly kid: string;
54
+ readonly iv: Uint8Array;
55
+ readonly ciphertext: Uint8Array;
56
+ readonly tag: Uint8Array;
57
+ }
58
+ interface FieldDecryptionOptions {
59
+ /**
60
+ * 32-byte AES-256 keys indexed by `kid`. Keep 2–3 entries across a key
61
+ * rotation so envelopes minted under the previous `kid` still decrypt.
62
+ */
63
+ keys: Record<string, Uint8Array>;
64
+ }
65
+ interface FieldDecryption {
66
+ /** Decrypt one `arc.v1` envelope → plaintext string. Throws on tamper/unknown kid. */
67
+ decryptField(token: string): Promise<string>;
68
+ /**
69
+ * Walk a parsed JSON value and decrypt every `arc.v1.*` string in place
70
+ * (arrays and plain objects recursed; other types untouched). Returns the
71
+ * same reference for pipeline ergonomics.
72
+ */
73
+ decryptFieldsDeep<T>(data: T): Promise<T>;
74
+ }
75
+ /** Parse an `arc.v1` envelope, or `null` when the token isn't one. */
76
+ declare function parseFieldEnvelope(token: string): ParsedFieldEnvelope | null;
77
+ /** True when a value is a well-formed `arc.v1` envelope. */
78
+ declare function isFieldEnvelope(value: unknown): value is string;
79
+ /**
80
+ * Build the field-mode decryptor. Keys are validated eagerly (fail-fast at
81
+ * boot, not on the first sensitive response) and imported into Web Crypto
82
+ * once per `kid`, then cached.
83
+ */
84
+ declare function createFieldDecryption(options: FieldDecryptionOptions): FieldDecryption;
85
+ //#endregion
86
+ export { FIELD_ENVELOPE_PREFIX, FieldDecryption, FieldDecryptionOptions, ParsedFieldEnvelope, createFieldDecryption, isFieldEnvelope, parseFieldEnvelope };
@@ -0,0 +1,159 @@
1
+ //#region src/field-encryption.ts
2
+ /**
3
+ * `@classytic/arc-next/field-encryption` — client decrypt for arc's
4
+ * FIELD-mode ALE (`@classytic/arc/encryption` with `mode: 'fields'`).
5
+ *
6
+ * Field-mode responses stay `application/json`; only the configured field
7
+ * VALUES arrive as authenticated `arc.v1` envelopes
8
+ * (`arc.v1.<b64url(kid)>.<b64url(iv)>.<b64url(ct)>.<b64url(tag)>`,
9
+ * AES-256-GCM). This helper parses and decrypts those envelopes with the
10
+ * shared symmetric key, via Web Crypto — zero dependencies, works in Node
11
+ * 22+, Bun, Deno, and React Native (with a Web Crypto polyfill).
12
+ *
13
+ * ── SECURITY: trusted runtimes ONLY ─────────────────────────────────────
14
+ * Field mode is SYMMETRIC — whoever holds the key can decrypt every
15
+ * envelope ever produced under it. That key belongs in a Node BFF, a
16
+ * server component, or a native app's secure storage. It must NEVER ship
17
+ * in a browser bundle: anything readable by browser JavaScript is readable
18
+ * by every visitor. For payloads a browser must decrypt, use the
19
+ * asymmetric JWE path (`@classytic/arc-next/encryption`) instead — that is
20
+ * exactly why this lives in its own subpath the web bundle never imports.
21
+ * ────────────────────────────────────────────────────────────────────────
22
+ *
23
+ * Fail-closed: unknown `kid`, tampered ciphertext, or a malformed
24
+ * `arc.v1.*` token throws — an encrypted field is never silently passed
25
+ * through as ciphertext or dropped.
26
+ *
27
+ * @example Node BFF / server component
28
+ * ```ts
29
+ * import { createFieldDecryption } from '@classytic/arc-next/field-encryption';
30
+ *
31
+ * const ale = createFieldDecryption({
32
+ * // 32-byte AES-256 keys by kid — keep the previous kid during rotation.
33
+ * keys: { 'k-2026-07': keyBytes },
34
+ * });
35
+ *
36
+ * configureClient({
37
+ * baseUrl: env.ARC_URL,
38
+ * afterResponse: async (ctx) => {
39
+ * // arc stamps `x-encrypted: true` on field-mode responses — only
40
+ * // those are scanned; every other response passes through untouched.
41
+ * if (ctx.response.headers.get('x-encrypted') === 'true') {
42
+ * ctx.body = await ale.decryptFieldsDeep(ctx.body);
43
+ * }
44
+ * return ctx;
45
+ * },
46
+ * });
47
+ * ```
48
+ */
49
+ /** Version prefix shared with `@classytic/arc/encryption`'s field cipher. */
50
+ const FIELD_ENVELOPE_PREFIX = "arc.v1";
51
+ const ENVELOPE_PARTS = 6;
52
+ const KEY_BYTES = 32;
53
+ const decoder = new TextDecoder();
54
+ function b64urlToBytes(part) {
55
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
56
+ const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, "=");
57
+ const bin = atob(padded);
58
+ const out = new Uint8Array(bin.length);
59
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
60
+ return out;
61
+ }
62
+ /** Parse an `arc.v1` envelope, or `null` when the token isn't one. */
63
+ function parseFieldEnvelope(token) {
64
+ if (typeof token !== "string") return null;
65
+ const parts = token.split(".");
66
+ if (parts.length !== ENVELOPE_PARTS) return null;
67
+ const [v0, v1, kidPart, ivPart, ctPart, tagPart] = parts;
68
+ if (`${v0}.${v1}` !== "arc.v1") return null;
69
+ if (!kidPart || !ivPart || !ctPart || !tagPart) return null;
70
+ try {
71
+ return {
72
+ kid: decoder.decode(b64urlToBytes(kidPart)),
73
+ iv: b64urlToBytes(ivPart),
74
+ ciphertext: b64urlToBytes(ctPart),
75
+ tag: b64urlToBytes(tagPart)
76
+ };
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+ /** True when a value LOOKS like an envelope (prefix match — cheap scan gate). */
82
+ function hasEnvelopePrefix(value) {
83
+ return typeof value === "string" && value.startsWith(`${"arc.v1"}.`);
84
+ }
85
+ /** True when a value is a well-formed `arc.v1` envelope. */
86
+ function isFieldEnvelope(value) {
87
+ return hasEnvelopePrefix(value) && parseFieldEnvelope(value) !== null;
88
+ }
89
+ /**
90
+ * Build the field-mode decryptor. Keys are validated eagerly (fail-fast at
91
+ * boot, not on the first sensitive response) and imported into Web Crypto
92
+ * once per `kid`, then cached.
93
+ */
94
+ function createFieldDecryption(options) {
95
+ const kids = Object.keys(options.keys);
96
+ if (kids.length === 0) throw new Error("[arc-next/field-encryption] at least one key is required.");
97
+ for (const kid of kids) {
98
+ const key = options.keys[kid];
99
+ if (!(key instanceof Uint8Array) || key.length !== KEY_BYTES) throw new Error(`[arc-next/field-encryption] key '${kid}' must be a ${KEY_BYTES}-byte Uint8Array (AES-256).`);
100
+ }
101
+ const imported = /* @__PURE__ */ new Map();
102
+ function keyFor(kid) {
103
+ let p = imported.get(kid);
104
+ if (!p) {
105
+ const raw = options.keys[kid];
106
+ if (!raw) throw new Error(`[arc-next/field-encryption] no key for kid '${kid}' — keep the previous kid configured during rotation so in-flight envelopes still decrypt.`);
107
+ p = crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["decrypt"]);
108
+ imported.set(kid, p);
109
+ }
110
+ return p;
111
+ }
112
+ async function decryptField(token) {
113
+ const envelope = parseFieldEnvelope(token);
114
+ if (!envelope) throw new Error("[arc-next/field-encryption] malformed arc.v1 envelope.");
115
+ const key = await keyFor(envelope.kid);
116
+ const combined = new Uint8Array(envelope.ciphertext.length + envelope.tag.length);
117
+ combined.set(envelope.ciphertext);
118
+ combined.set(envelope.tag, envelope.ciphertext.length);
119
+ try {
120
+ const plaintext = await crypto.subtle.decrypt({
121
+ name: "AES-GCM",
122
+ iv: envelope.iv
123
+ }, key, combined);
124
+ return decoder.decode(plaintext);
125
+ } catch {
126
+ throw new Error(`[arc-next/field-encryption] decryption failed for kid '${envelope.kid}' — tampered ciphertext or wrong key.`);
127
+ }
128
+ }
129
+ async function walk(node) {
130
+ if (Array.isArray(node)) {
131
+ for (let i = 0; i < node.length; i++) {
132
+ const value = node[i];
133
+ if (hasEnvelopePrefix(value)) node[i] = await decryptField(value);
134
+ else await walk(value);
135
+ }
136
+ return;
137
+ }
138
+ if (node !== null && typeof node === "object") {
139
+ const record = node;
140
+ for (const prop of Object.keys(record)) {
141
+ const value = record[prop];
142
+ if (hasEnvelopePrefix(value)) record[prop] = await decryptField(value);
143
+ else await walk(value);
144
+ }
145
+ }
146
+ }
147
+ async function decryptFieldsDeep(data) {
148
+ if (hasEnvelopePrefix(data)) return await decryptField(data);
149
+ await walk(data);
150
+ return data;
151
+ }
152
+ return {
153
+ decryptField,
154
+ decryptFieldsDeep
155
+ };
156
+ }
157
+
158
+ //#endregion
159
+ export { FIELD_ENVELOPE_PREFIX, createFieldDecryption, isFieldEnvelope, parseFieldEnvelope };
package/dist/hooks.d.ts CHANGED
@@ -89,6 +89,14 @@ interface CrudHooksConfig<T, TCreate = Partial<T>, TUpdate = Partial<T>> {
89
89
  gcTime?: number;
90
90
  refetchOnWindowFocus?: boolean;
91
91
  structuralSharing?: boolean;
92
+ /**
93
+ * Declare this resource's read endpoints PUBLIC (`allowPublic` on the
94
+ * server — e.g. a storefront catalog). Read hooks then enable token-less
95
+ * requests by default, so callers never have to pass `{ public: true }`
96
+ * per hook. Leave unset for auth-gated resources (cart, orders, account) —
97
+ * they keep the bearer token-gate. An explicit per-call `public` wins.
98
+ */
99
+ defaultPublic?: boolean;
92
100
  messages?: {
93
101
  createSuccess?: string;
94
102
  createError?: string;
package/dist/hooks.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { getAuthMode, getClientAuthContext, hasGlobalStaticAuth } from "./client.js";
4
4
  import { isKeysetPagination, isOffsetPagination } from "./api.js";
5
- import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, syncDetailToLists, updateListCache } from "./cache.js";
5
+ import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, syncDetailToLists, updateListCache, withOrgParams } from "./cache.js";
6
6
  import { findItemInListCache, useDetailQuery, useInfiniteListQuery, useListQuery, useSuspenseDetailQuery, useSuspenseListQuery } from "./query.js";
7
7
  import { useMutationWithTransition, useOptimisticMutation } from "./mutation.js";
8
8
  import { subscribeToEvents } from "./sse.js";
@@ -75,6 +75,13 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
75
75
  ...defaults.messages
76
76
  }
77
77
  };
78
+ const computeEnabled = (token, options, extraGate = true) => {
79
+ const merged = options.public === void 0 && config.defaultPublic ? {
80
+ ...options,
81
+ public: true
82
+ } : options;
83
+ return extraGate && createEnabledRule(token, merged, resolveAuthMode(), resolveHasStaticAuth());
84
+ };
78
85
  function useList(tokenOrParams, paramsOrOptions, maybeOptions) {
79
86
  let token;
80
87
  let params;
@@ -97,10 +104,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
97
104
  const scope = options._scope || (organizationId ? "tenant" : "super-admin");
98
105
  const { request: requestOpts, ...queryOpts } = options;
99
106
  return useListQuery({
100
- queryKey: KEYS.scopedList(scope, {
101
- organizationId,
102
- ...restParams
103
- }),
107
+ queryKey: KEYS.scopedList(scope, withOrgParams(organizationId, restParams)),
104
108
  queryFn: ({ signal }) => api.getAll({
105
109
  token,
106
110
  organizationId,
@@ -110,7 +114,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
110
114
  ...requestOpts
111
115
  }
112
116
  }),
113
- enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
117
+ enabled: computeEnabled(token, queryOpts),
114
118
  options: {
115
119
  staleTime: queryOpts.staleTime ?? config.staleTime,
116
120
  gcTime: queryOpts.gcTime ?? config.gcTime,
@@ -154,7 +158,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
154
158
  ...requestOpts
155
159
  }
156
160
  }),
157
- enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
161
+ enabled: computeEnabled(token, restOptions, !!id),
158
162
  options: {
159
163
  staleTime: restOptions.staleTime ?? config.staleTime,
160
164
  gcTime: restOptions.gcTime ?? config.gcTime,
@@ -491,10 +495,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
491
495
  const scope = options._scope || (organizationId ? "tenant" : "super-admin");
492
496
  const { request: requestOpts, ...queryOpts } = options;
493
497
  return useInfiniteListQuery({
494
- queryKey: [...KEYS.scopedList(scope, {
495
- organizationId,
496
- ...restParams
497
- }), "infinite"],
498
+ queryKey: [...KEYS.scopedList(scope, withOrgParams(organizationId, restParams)), "infinite"],
498
499
  queryFn: ({ pageParam, signal }) => {
499
500
  const paginationParams = typeof pageParam === "string" ? { after: pageParam } : { page: pageParam };
500
501
  return api.getAll({
@@ -510,7 +511,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
510
511
  }
511
512
  });
512
513
  },
513
- enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
514
+ enabled: computeEnabled(token, queryOpts),
514
515
  initialPageParam: restParams.after ? restParams.after : 1,
515
516
  getNextPageParam: (lastPage) => {
516
517
  const page = lastPage;
@@ -579,10 +580,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
579
580
  const { organizationId: _, ...restParams } = mergedParams;
580
581
  const { request: requestOpts, ...queryOpts } = options ?? {};
581
582
  return useListQuery({
582
- queryKey: KEYS.custom("deleted", {
583
- organizationId,
584
- ...restParams
585
- }),
583
+ queryKey: KEYS.custom("deleted", withOrgParams(organizationId, restParams)),
586
584
  queryFn: ({ signal }) => {
587
585
  if (!api.getDeleted) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a getDeleted method`));
588
586
  return api.getDeleted({
@@ -595,7 +593,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
595
593
  }
596
594
  });
597
595
  },
598
- enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
596
+ enabled: computeEnabled(token, queryOpts, !!api.getDeleted),
599
597
  options: {
600
598
  staleTime: queryOpts.staleTime ?? config.staleTime,
601
599
  gcTime: queryOpts.gcTime ?? config.gcTime
@@ -626,7 +624,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
626
624
  }
627
625
  });
628
626
  },
629
- enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
627
+ enabled: computeEnabled(token, restOptions, !!api.getBySlug && !!slug),
630
628
  options: {
631
629
  staleTime: restOptions.staleTime ?? config.staleTime,
632
630
  gcTime: restOptions.gcTime ?? config.gcTime,
@@ -654,10 +652,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
654
652
  const { organizationId: _, ...restParams } = mergedParams;
655
653
  const { request: requestOpts, ...queryOpts } = options ?? {};
656
654
  return useListQuery({
657
- queryKey: KEYS.custom("tree", {
658
- organizationId,
659
- ...restParams
660
- }),
655
+ queryKey: KEYS.custom("tree", withOrgParams(organizationId, restParams)),
661
656
  queryFn: ({ signal }) => {
662
657
  if (!api.getTree) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a getTree method`));
663
658
  return api.getTree({
@@ -670,7 +665,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
670
665
  }
671
666
  });
672
667
  },
673
- enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
668
+ enabled: computeEnabled(token, queryOpts, !!api.getTree),
674
669
  options: {
675
670
  staleTime: queryOpts.staleTime ?? config.staleTime,
676
671
  gcTime: queryOpts.gcTime ?? config.gcTime
@@ -686,10 +681,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
686
681
  const { organizationId: _, ...restParams } = mergedParams;
687
682
  const { request: requestOpts, ...queryOpts } = options ?? {};
688
683
  return useListQuery({
689
- queryKey: KEYS.custom("children", parentId, {
690
- organizationId,
691
- ...restParams
692
- }),
684
+ queryKey: KEYS.custom("children", parentId, withOrgParams(organizationId, restParams)),
693
685
  queryFn: ({ signal }) => {
694
686
  if (!api.getChildren) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a getChildren method`));
695
687
  return api.getChildren({
@@ -703,7 +695,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
703
695
  }
704
696
  });
705
697
  },
706
- enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
698
+ enabled: computeEnabled(token, queryOpts, !!api.getChildren && !!parentId),
707
699
  options: {
708
700
  staleTime: queryOpts.staleTime ?? config.staleTime,
709
701
  gcTime: queryOpts.gcTime ?? config.gcTime
@@ -1,3 +1,4 @@
1
+ import { EntityReadApi } from "./query-options.js";
1
2
  import { HydrationBoundary, InfiniteData, QueryClient, dehydrate } from "@tanstack/react-query";
2
3
 
3
4
  //#region src/prefetch.d.ts
@@ -22,13 +23,6 @@ interface PrefetchOptions extends PrefetchAuthContext {
22
23
  revalidate?: number | false;
23
24
  tags?: string[];
24
25
  }
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
- };
32
26
  interface PrefetchDetailOptions extends PrefetchOptions {
33
27
  /** Query params (select, populate) — key must match useDetail's params to share cache */
34
28
  params?: {
@@ -46,11 +40,8 @@ interface CrudPrefetcher {
46
40
  */
47
41
  prefetchList: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
48
42
  /**
49
- * Prefetch a detail query on the server. Uses the same query keys as useDetail.
50
- *
51
- * @example
52
- * const queryClient = getQueryClient();
53
- * await productsPrefetcher.prefetchDetail(queryClient, productId);
43
+ * Prefetch a detail query on the server. Uses the same query keys as useDetail
44
+ * (tenant-scoped when `organizationId` is provided).
54
45
  */
55
46
  prefetchDetail: (queryClient: QueryClient, id: string, options?: PrefetchDetailOptions) => Promise<void>;
56
47
  /**
@@ -68,16 +59,6 @@ interface CrudPrefetcher {
68
59
  * Only available when the API has a `getTree` method (tree preset).
69
60
  */
70
61
  prefetchTree: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
71
- /**
72
- * Prefetch an infinite list query (cursor / page-based pagination). Uses the
73
- * same query keys as `useInfiniteList` and seeds the `{ pages, pageParams }`
74
- * shape TanStack Query expects for `useInfiniteQuery` — a flat
75
- * `prefetchQuery` would NOT match the cache shape and the client hook would
76
- * re-fetch from scratch, defeating the prefetch.
77
- *
78
- * @example
79
- * await productsPrefetcher.prefetchInfiniteList(queryClient, { limit: 20 });
80
- */
81
62
  /**
82
63
  * Prefetch a declared aggregation (arc 2.13+). Uses the same query key as
83
64
  * `useAggregation` so RSC-pre-rendered dashboard rows hydrate without a
@@ -92,12 +73,31 @@ interface CrudPrefetcher {
92
73
  * );
93
74
  */
94
75
  prefetchAggregation: (queryClient: QueryClient, name: string, filter?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
76
+ /**
77
+ * Prefetch an infinite list query (cursor / page-based pagination). Uses the
78
+ * same query keys as `useInfiniteList` and seeds the `{ pages, pageParams }`
79
+ * shape TanStack Query expects for `useInfiniteQuery` — a flat
80
+ * `prefetchQuery` would NOT match the cache shape and the client hook would
81
+ * re-fetch from scratch, defeating the prefetch.
82
+ *
83
+ * @example
84
+ * await productsPrefetcher.prefetchInfiniteList(queryClient, { limit: 20 });
85
+ */
95
86
  prefetchInfiniteList: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
96
87
  }
97
88
  /**
98
89
  * Create server-safe prefetch helpers for CRUD queries.
99
90
  * Use in Next.js server components to pre-populate the query cache before rendering.
100
91
  *
92
+ * Since 0.10 this is a thin layer over `createEntityQueries`
93
+ * (@classytic/arc-next/query-options) — the queryOptions factories are the
94
+ * single source of key + queryFn, shared with the client CRUD hooks, so
95
+ * prefetch keys can never drift from hook keys. Prefer the factories directly
96
+ * for new code that also needs `ensureQueryData` / router-loader integration:
97
+ *
98
+ * const products = createEntityQueries(productApi, 'products');
99
+ * await queryClient.prefetchQuery({ ...products.list({ limit: 20 }, { token }), staleTime: 60_000 });
100
+ *
101
101
  * @example
102
102
  * // products-prefetch.ts
103
103
  * import { productsApi } from '@/api/products-api';
@@ -119,45 +119,6 @@ interface CrudPrefetcher {
119
119
  * );
120
120
  * }
121
121
  */
122
- declare function createCrudPrefetcher(api: {
123
- getAll: (opts: {
124
- params?: Record<string, unknown>;
125
- token?: string | null;
126
- organizationId?: string | null;
127
- options?: ForwardedApiOptions;
128
- }) => Promise<unknown>;
129
- getById: (opts: {
130
- id: string;
131
- token?: string | null;
132
- organizationId?: string | null;
133
- options?: ForwardedApiOptions;
134
- }) => Promise<unknown>;
135
- getBySlug?: (opts: {
136
- slug: string;
137
- token?: string | null;
138
- organizationId?: string | null;
139
- params?: Record<string, unknown>;
140
- options?: ForwardedApiOptions;
141
- }) => Promise<unknown>;
142
- getDeleted?: (opts: {
143
- params?: Record<string, unknown>;
144
- token?: string | null;
145
- organizationId?: string | null;
146
- options?: ForwardedApiOptions;
147
- }) => Promise<unknown>;
148
- aggregate?: (opts: {
149
- name: string;
150
- filter?: Record<string, unknown>;
151
- token?: string | null;
152
- organizationId?: string | null;
153
- options?: ForwardedApiOptions;
154
- }) => Promise<unknown>;
155
- getTree?: (opts: {
156
- params?: Record<string, unknown>;
157
- token?: string | null;
158
- organizationId?: string | null;
159
- options?: ForwardedApiOptions;
160
- }) => Promise<unknown>;
161
- }, entityKey: string): CrudPrefetcher;
122
+ declare function createCrudPrefetcher(api: EntityReadApi, entityKey: string): CrudPrefetcher;
162
123
  //#endregion
163
124
  export { CrudPrefetcher, HydrationBoundary, type InfiniteData, PrefetchAuthContext, PrefetchDetailOptions, PrefetchOptions, createCrudPrefetcher, dehydrate };