@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 +15 -1
- package/dist/cache.js +21 -1
- package/dist/field-encryption.d.ts +86 -0
- package/dist/field-encryption.js +159 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +20 -28
- package/dist/prefetch.d.ts +23 -62
- package/dist/prefetch.js +25 -103
- package/dist/presets/tree.js +1 -5
- package/dist/query-options.d.ts +165 -0
- package/dist/query-options.js +188 -0
- package/package.json +173 -165
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
package/dist/prefetch.d.ts
CHANGED
|
@@ -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 };
|