@beignet/react-query 0.0.2 → 0.0.4

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/src/index.ts CHANGED
@@ -2,7 +2,10 @@ import type {
2
2
  Client,
3
3
  Endpoint,
4
4
  EndpointCallArgs,
5
+ InferBody,
5
6
  InferEndpointContractError,
7
+ InferPathParams,
8
+ InferQuery,
6
9
  InferSuccessResponse,
7
10
  } from "@beignet/core/client";
8
11
  import {
@@ -14,22 +17,50 @@ import {
14
17
 
15
18
  import type {
16
19
  InfiniteQueryObserverOptions,
17
- MutationOptions,
20
+ InvalidateQueryFilters,
21
+ QueryClient,
22
+ QueryFunction,
18
23
  QueryKey,
19
- QueryOptions,
24
+ UseMutationOptions,
25
+ UseQueryOptions,
20
26
  } from "@tanstack/react-query";
21
27
 
22
- type HasRequiredKeys<T> = {
23
- [K in keyof T]-?: Record<string, never> extends Pick<T, K> ? never : K;
24
- }[keyof T];
25
-
26
28
  type EndpointQueryArgs<
27
29
  TContract extends HttpContractConfig,
28
30
  TProvidedHeaders extends string,
29
- > = Omit<EndpointCallArgs<TContract, TProvidedHeaders>, "rawBody" | "signal">;
31
+ > = Omit<
32
+ EndpointCallArgs<TContract, TProvidedHeaders>,
33
+ "rawBody" | "signal" | "idempotencyKey"
34
+ >;
35
+
36
+ type EndpointArgValue<
37
+ TArgs,
38
+ TKey extends "path" | "query" | "body" | "headers",
39
+ > = TKey extends keyof TArgs ? TArgs[TKey] : undefined;
40
+
41
+ type IsRequiredValue<T> = undefined extends T ? false : true;
42
+
43
+ type RequiresQueryOptionsArgs<
44
+ TContract extends HttpContractConfig,
45
+ TProvidedHeaders extends string,
46
+ > =
47
+ IsRequiredValue<InferPathParams<TContract>> extends true
48
+ ? true
49
+ : IsRequiredValue<InferQuery<TContract>> extends true
50
+ ? true
51
+ : IsRequiredValue<InferBody<TContract>> extends true
52
+ ? true
53
+ : IsRequiredValue<
54
+ EndpointArgValue<
55
+ EndpointCallArgs<TContract, TProvidedHeaders>,
56
+ "headers"
57
+ >
58
+ > extends true
59
+ ? true
60
+ : false;
30
61
 
31
62
  type ContractQueryOptionsBase<TContract extends HttpContractConfig> = Omit<
32
- QueryOptions<
63
+ UseQueryOptions<
33
64
  InferSuccessResponse<TContract>,
34
65
  InferEndpointContractError<TContract>,
35
66
  InferSuccessResponse<TContract>,
@@ -38,6 +69,12 @@ type ContractQueryOptionsBase<TContract extends HttpContractConfig> = Omit<
38
69
  "queryKey" | "queryFn"
39
70
  >;
40
71
 
72
+ type ContractQueryOptionsResult<TContract extends HttpContractConfig> =
73
+ ContractQueryOptionsBase<TContract> & {
74
+ queryKey: QueryKey;
75
+ queryFn: QueryFunction<InferSuccessResponse<TContract>, QueryKey>;
76
+ };
77
+
41
78
  /**
42
79
  * Options accepted by `ReactQueryContractHelper.queryOptions(...)`.
43
80
  *
@@ -56,9 +93,31 @@ type ContractQueryOptionsCallArgs<
56
93
  TContract extends HttpContractConfig,
57
94
  TProvidedHeaders extends string,
58
95
  > =
59
- HasRequiredKeys<EndpointQueryArgs<TContract, TProvidedHeaders>> extends never
60
- ? [args?: ContractUseQueryOptions<TContract, TProvidedHeaders>]
61
- : [args: ContractUseQueryOptions<TContract, TProvidedHeaders>];
96
+ RequiresQueryOptionsArgs<TContract, TProvidedHeaders> extends true
97
+ ? [args: ContractUseQueryOptions<TContract, TProvidedHeaders>]
98
+ : [args?: ContractUseQueryOptions<TContract, TProvidedHeaders>];
99
+
100
+ export type ContractQueryKeyParams<
101
+ TContract extends HttpContractConfig,
102
+ TProvidedHeaders extends string = never,
103
+ > = {
104
+ path?: EndpointArgValue<
105
+ EndpointQueryArgs<TContract, TProvidedHeaders>,
106
+ "path"
107
+ >;
108
+ query?: EndpointArgValue<
109
+ EndpointQueryArgs<TContract, TProvidedHeaders>,
110
+ "query"
111
+ >;
112
+ body?: EndpointArgValue<
113
+ EndpointQueryArgs<TContract, TProvidedHeaders>,
114
+ "body"
115
+ >;
116
+ headers?: EndpointArgValue<
117
+ EndpointQueryArgs<TContract, TProvidedHeaders>,
118
+ "headers"
119
+ >;
120
+ };
62
121
 
63
122
  /**
64
123
  * Options accepted by `ReactQueryContractHelper.mutationOptions(...)`.
@@ -70,7 +129,7 @@ export type ContractUseMutationOptions<
70
129
  TContract extends HttpContractConfig,
71
130
  TProvidedHeaders extends string = never,
72
131
  > = Omit<
73
- MutationOptions<
132
+ UseMutationOptions<
74
133
  InferSuccessResponse<TContract>,
75
134
  InferEndpointContractError<TContract>,
76
135
  EndpointCallArgs<TContract, TProvidedHeaders>,
@@ -79,12 +138,26 @@ export type ContractUseMutationOptions<
79
138
  "mutationFn"
80
139
  >;
81
140
 
141
+ /**
142
+ * Body input for infinite queries.
143
+ *
144
+ * Object bodies are shallow-partial because the full request body is the
145
+ * merge of the static `body` and each `page(...).body`, so neither side has
146
+ * to satisfy required body fields alone. The merged body is still validated
147
+ * by the endpoint call.
148
+ */
149
+ type InfiniteQueryBody<TContract extends HttpContractConfig> =
150
+ InferBody<TContract> extends Record<string, unknown>
151
+ ? Partial<InferBody<TContract>>
152
+ : EndpointCallArgs<TContract>["body"];
153
+
82
154
  type InfiniteQueryResolvedParams<
83
155
  TContract extends HttpContractConfig,
84
156
  TProvidedHeaders extends string = never,
85
157
  > = {
86
158
  path?: EndpointCallArgs<TContract>["path"];
87
159
  query?: EndpointCallArgs<TContract>["query"];
160
+ body?: InfiniteQueryBody<TContract>;
88
161
  headers?: EndpointCallArgs<TContract, TProvidedHeaders>["headers"];
89
162
  };
90
163
 
@@ -95,6 +168,7 @@ type SafeInfiniteQueryArgs<
95
168
  > = {
96
169
  path?: EndpointCallArgs<TContract>["path"];
97
170
  query?: EndpointCallArgs<TContract>["query"];
171
+ body?: InfiniteQueryBody<TContract>;
98
172
  headers?: EndpointCallArgs<TContract, TProvidedHeaders>["headers"];
99
173
  page?: (ctx: {
100
174
  pageParam: TPageParam;
@@ -114,6 +188,7 @@ type DynamicInfiniteQueryArgs<
114
188
  key: readonly unknown[];
115
189
  path?: never;
116
190
  query?: never;
191
+ body?: never;
117
192
  page?: never;
118
193
  };
119
194
 
@@ -140,6 +215,15 @@ function mergeParamObjects<T>(base?: T, patch?: T): T | undefined {
140
215
  if (base == null) return patch;
141
216
  if (patch == null) return base;
142
217
 
218
+ if (
219
+ typeof base !== "object" ||
220
+ typeof patch !== "object" ||
221
+ Array.isArray(base) ||
222
+ Array.isArray(patch)
223
+ ) {
224
+ return patch;
225
+ }
226
+
143
227
  return {
144
228
  ...(base as Record<string, unknown>),
145
229
  ...(patch as Record<string, unknown>),
@@ -166,6 +250,16 @@ function normalizeKeyValue(value: unknown): unknown | undefined {
166
250
  const BEIGNET_QUERY_KEY_SCOPE = "beignet";
167
251
  const UNNAMESPACED_QUERY_KEY_NAMESPACE = null;
168
252
 
253
+ /**
254
+ * Idempotency keys generated per `mutate(...)` invocation, keyed by variables
255
+ * object identity.
256
+ *
257
+ * TanStack Query re-invokes `mutationFn` with the same variables object on
258
+ * every HTTP retry attempt, so one key per variables object means retries
259
+ * reuse the key while separate `mutate(...)` calls get fresh keys.
260
+ */
261
+ const idempotencyKeyForVariables = new WeakMap<object, string>();
262
+
169
263
  /**
170
264
  * Query key for all contracts in one Beignet namespace.
171
265
  */
@@ -176,11 +270,15 @@ export type ContractQueryNamespaceKey = readonly [
176
270
 
177
271
  /**
178
272
  * Query key for one contract without path/query/body params.
273
+ *
274
+ * The fourth element is the contract route (`"GET /todos"`), so contracts
275
+ * with the same derived local name but different routes never share a key.
179
276
  */
180
277
  export type ContractQueryContractKey = readonly [
181
278
  typeof BEIGNET_QUERY_KEY_SCOPE,
182
279
  string | null,
183
280
  string,
281
+ string,
184
282
  ];
185
283
 
186
284
  /**
@@ -190,9 +288,36 @@ export type ContractQueryKey = readonly [
190
288
  typeof BEIGNET_QUERY_KEY_SCOPE,
191
289
  string | null,
192
290
  string,
291
+ string,
193
292
  unknown?,
194
293
  ];
195
294
 
295
+ export type ContractQueryFilterOptions = Omit<
296
+ InvalidateQueryFilters<QueryKey>,
297
+ "queryKey"
298
+ >;
299
+
300
+ export type ContractQueryFilter<TKey extends QueryKey> =
301
+ ContractQueryFilterOptions & {
302
+ queryKey: TKey;
303
+ };
304
+
305
+ /**
306
+ * Options accepted by `createReactQuery(client, options?)`.
307
+ */
308
+ export type CreateReactQueryOptions = {
309
+ /**
310
+ * Header names to include in generated query keys.
311
+ *
312
+ * Headers are excluded from query keys by default because persisted caches
313
+ * would otherwise store credentials such as `Authorization` tokens. Opt in
314
+ * per adapter with the specific identity headers that change response data,
315
+ * such as a tenant header. Names are matched case-insensitively and stored
316
+ * lowercased in the key.
317
+ */
318
+ keyHeaders?: readonly string[];
319
+ };
320
+
196
321
  /**
197
322
  * TanStack Query helper bound to one Beignet contract.
198
323
  *
@@ -207,6 +332,7 @@ export class ReactQueryContractHelper<
207
332
  constructor(
208
333
  private contract: TContract,
209
334
  private _endpoint: Endpoint<TContract, TProvidedHeaders>,
335
+ private options: CreateReactQueryOptions = {},
210
336
  ) {}
211
337
 
212
338
  /**
@@ -216,6 +342,14 @@ export class ReactQueryContractHelper<
216
342
  return this.contract.name;
217
343
  }
218
344
 
345
+ /**
346
+ * Contract route used as the key segment that disambiguates contracts with
347
+ * the same local name, such as `/v1/todos` and `/v2/todos` list contracts.
348
+ */
349
+ get route(): string {
350
+ return `${this.contract.method} ${this.contract.path}`;
351
+ }
352
+
219
353
  /**
220
354
  * Resource namespace used for query-key grouping.
221
355
  */
@@ -241,7 +375,68 @@ export class ReactQueryContractHelper<
241
375
  * Build a contract-level query key without path/query params.
242
376
  */
243
377
  contractKey(): ContractQueryContractKey {
244
- return [BEIGNET_QUERY_KEY_SCOPE, this.namespace, this.localName] as const;
378
+ return [
379
+ BEIGNET_QUERY_KEY_SCOPE,
380
+ this.namespace,
381
+ this.localName,
382
+ this.route,
383
+ ] as const;
384
+ }
385
+
386
+ /**
387
+ * Build TanStack Query filters for all contracts in this namespace.
388
+ */
389
+ namespaceFilter(
390
+ options: ContractQueryFilterOptions = {},
391
+ ): ContractQueryFilter<ContractQueryNamespaceKey> {
392
+ return {
393
+ ...options,
394
+ queryKey: this.namespaceKey(),
395
+ };
396
+ }
397
+
398
+ /**
399
+ * Build TanStack Query filters for every cached call to this contract.
400
+ */
401
+ contractFilter(
402
+ options: ContractQueryFilterOptions = {},
403
+ ): ContractQueryFilter<ContractQueryContractKey> {
404
+ return {
405
+ ...options,
406
+ queryKey: this.contractKey(),
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Pick the adapter-whitelisted `keyHeaders` out of call headers.
412
+ *
413
+ * Returns `undefined` unless the adapter opted in with `keyHeaders` and the
414
+ * call provides at least one whitelisted header. Header names are matched
415
+ * case-insensitively and lowercased in the key component.
416
+ */
417
+ private pickKeyHeaders(
418
+ headers: unknown,
419
+ ): Record<string, unknown> | undefined {
420
+ const keyHeaders = this.options.keyHeaders;
421
+ if (!keyHeaders || keyHeaders.length === 0) return undefined;
422
+ if (
423
+ headers === null ||
424
+ headers === undefined ||
425
+ typeof headers !== "object" ||
426
+ Array.isArray(headers)
427
+ ) {
428
+ return undefined;
429
+ }
430
+
431
+ const allowed = new Set(keyHeaders.map((name) => name.toLowerCase()));
432
+ const picked: Record<string, unknown> = {};
433
+ for (const [name, value] of Object.entries(
434
+ headers as Record<string, unknown>,
435
+ )) {
436
+ const lowered = name.toLowerCase();
437
+ if (allowed.has(lowered)) picked[lowered] = value;
438
+ }
439
+ return picked;
245
440
  }
246
441
 
247
442
  /**
@@ -251,17 +446,28 @@ export class ReactQueryContractHelper<
251
446
  * This ensures consistent serialization between server (dehydrate) and
252
447
  * client (hydrate), since undefined object values may serialize
253
448
  * differently across React's RSC boundary vs JSON.stringify.
449
+ *
450
+ * Headers are never included unless the adapter opted in with
451
+ * `createReactQuery(client, { keyHeaders })`, and then only the whitelisted
452
+ * header names are included.
254
453
  */
255
454
  key(params?: {
256
455
  path?: unknown;
257
456
  query?: unknown;
258
457
  body?: unknown;
458
+ headers?: unknown;
259
459
  }): ContractQueryKey {
260
460
  const path = normalizeKeyValue(params?.path);
261
461
  const query = normalizeKeyValue(params?.query);
262
462
  const body = normalizeKeyValue(params?.body);
263
-
264
- if (path === undefined && query === undefined && body === undefined) {
463
+ const headers = normalizeKeyValue(this.pickKeyHeaders(params?.headers));
464
+
465
+ if (
466
+ path === undefined &&
467
+ query === undefined &&
468
+ body === undefined &&
469
+ headers === undefined
470
+ ) {
265
471
  return this.contractKey();
266
472
  }
267
473
 
@@ -269,9 +475,37 @@ export class ReactQueryContractHelper<
269
475
  if (path !== undefined) cleanParams.path = path;
270
476
  if (query !== undefined) cleanParams.query = query;
271
477
  if (body !== undefined) cleanParams.body = body;
478
+ if (headers !== undefined) cleanParams.headers = headers;
272
479
  return [...this.contractKey(), cleanParams] as const;
273
480
  }
274
481
 
482
+ /**
483
+ * Build TanStack Query filters for one contract call or parameter prefix.
484
+ */
485
+ filter(
486
+ params?: ContractQueryKeyParams<TContract, TProvidedHeaders>,
487
+ options: ContractQueryFilterOptions = {},
488
+ ): ContractQueryFilter<ContractQueryKey> {
489
+ return {
490
+ ...options,
491
+ queryKey: this.key(params),
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Invalidate cached data for this contract.
497
+ *
498
+ * With no params this invalidates every cached call to the contract. Pass
499
+ * path/query/body params to target a single call or parameter prefix.
500
+ */
501
+ invalidate(
502
+ queryClient: QueryClient,
503
+ params?: ContractQueryKeyParams<TContract, TProvidedHeaders>,
504
+ options?: ContractQueryFilterOptions,
505
+ ): Promise<void> {
506
+ return queryClient.invalidateQueries(this.filter(params, options));
507
+ }
508
+
275
509
  /**
276
510
  * Create query options for TanStack Query.
277
511
  *
@@ -280,19 +514,15 @@ export class ReactQueryContractHelper<
280
514
  */
281
515
  queryOptions(
282
516
  ...callArgs: ContractQueryOptionsCallArgs<TContract, TProvidedHeaders>
283
- ): QueryOptions<
284
- InferSuccessResponse<TContract>,
285
- InferEndpointContractError<TContract>,
286
- InferSuccessResponse<TContract>,
287
- QueryKey
288
- > & { queryKey: QueryKey } {
517
+ ): ContractQueryOptionsResult<TContract> {
289
518
  const args = (callArgs[0] ?? {}) as ContractUseQueryOptions<
290
519
  TContract,
291
520
  TProvidedHeaders
292
521
  >;
293
522
  const { path, query, body, headers, key: customKey, ...rest } = args;
294
523
 
295
- const queryKey = (customKey || this.key({ path, query, body })) as QueryKey;
524
+ const queryKey = (customKey ||
525
+ this.key({ path, query, body, headers })) as QueryKey;
296
526
 
297
527
  const queryFn = async (context: { signal?: AbortSignal }) => {
298
528
  return await this._endpoint.call({
@@ -308,12 +538,7 @@ export class ReactQueryContractHelper<
308
538
  ...rest,
309
539
  queryKey,
310
540
  queryFn,
311
- } as QueryOptions<
312
- InferSuccessResponse<TContract>,
313
- InferEndpointContractError<TContract>,
314
- InferSuccessResponse<TContract>,
315
- QueryKey
316
- > & { queryKey: QueryKey };
541
+ } as ContractQueryOptionsResult<TContract>;
317
542
  }
318
543
 
319
544
  /**
@@ -321,33 +546,50 @@ export class ReactQueryContractHelper<
321
546
  *
322
547
  * The generated `mutationFn` accepts the same variables shape as the
323
548
  * underlying contract endpoint call.
549
+ *
550
+ * For contracts with idempotency metadata, the generated `mutationFn`
551
+ * derives one idempotency key per `mutate(...)` invocation and keeps it
552
+ * stable across TanStack retry attempts, which re-invoke `mutationFn` with
553
+ * the same variables object. Pass `idempotencyKey` in the variables to
554
+ * control the key explicitly. Calling `mutate()` with no variables falls
555
+ * back to per-attempt key generation in the client.
324
556
  */
325
557
  mutationOptions(
326
- args: Omit<
327
- MutationOptions<
328
- InferSuccessResponse<TContract>,
329
- InferEndpointContractError<TContract>,
330
- EndpointCallArgs<TContract, TProvidedHeaders>,
331
- unknown
332
- >,
333
- "mutationFn"
334
- > = {},
335
- ): MutationOptions<
558
+ args: ContractUseMutationOptions<TContract, TProvidedHeaders> = {},
559
+ ): UseMutationOptions<
336
560
  InferSuccessResponse<TContract>,
337
561
  InferEndpointContractError<TContract>,
338
562
  EndpointCallArgs<TContract, TProvidedHeaders>,
339
563
  unknown
340
564
  > {
565
+ const hasIdempotencyMetadata = Boolean(this.contract.metadata?.idempotency);
341
566
  const mutationFn = async (
342
567
  vars: EndpointCallArgs<TContract, TProvidedHeaders>,
343
568
  ) => {
344
- return await this._endpoint.call(vars);
569
+ let idempotencyKey = vars?.idempotencyKey;
570
+ if (
571
+ !idempotencyKey &&
572
+ hasIdempotencyMetadata &&
573
+ vars &&
574
+ typeof vars === "object"
575
+ ) {
576
+ idempotencyKey =
577
+ idempotencyKeyForVariables.get(vars) ?? crypto.randomUUID();
578
+ idempotencyKeyForVariables.set(vars, idempotencyKey);
579
+ }
580
+ if (idempotencyKey === undefined) {
581
+ return await this._endpoint.call(vars);
582
+ }
583
+ return await this._endpoint.call({
584
+ ...vars,
585
+ idempotencyKey,
586
+ } as EndpointCallArgs<TContract, TProvidedHeaders>);
345
587
  };
346
588
 
347
589
  return {
348
590
  ...args,
349
591
  mutationFn,
350
- } as MutationOptions<
592
+ } as UseMutationOptions<
351
593
  InferSuccessResponse<TContract>,
352
594
  InferEndpointContractError<TContract>,
353
595
  EndpointCallArgs<TContract, TProvidedHeaders>,
@@ -374,7 +616,7 @@ export class ReactQueryContractHelper<
374
616
  let queryKey: QueryKey;
375
617
  let rest: Omit<
376
618
  ContractInfiniteQueryOptions<TContract, TPageParam, TProvidedHeaders>,
377
- "path" | "query" | "headers" | "page" | "params" | "key"
619
+ "path" | "query" | "body" | "headers" | "page" | "params" | "key"
378
620
  >;
379
621
  let resolveParams: (context: {
380
622
  pageParam: TPageParam;
@@ -391,14 +633,15 @@ export class ReactQueryContractHelper<
391
633
  rest = other;
392
634
  resolveParams = params;
393
635
  } else {
394
- const { path, query, headers, page, key, ...other } = args;
395
- queryKey = (key || this.key({ path, query })) as QueryKey;
636
+ const { path, query, body, headers, page, key, ...other } = args;
637
+ queryKey = (key || this.key({ path, query, body, headers })) as QueryKey;
396
638
  rest = other;
397
639
  resolveParams = (context) => {
398
640
  const pageParams = page?.(context);
399
641
  return {
400
642
  path: mergeParamObjects(path, pageParams?.path),
401
643
  query: mergeParamObjects(query, pageParams?.query),
644
+ body: mergeParamObjects(body, pageParams?.body),
402
645
  headers: mergeParamObjects(headers, pageParams?.headers),
403
646
  };
404
647
  };
@@ -412,6 +655,7 @@ export class ReactQueryContractHelper<
412
655
  return await this._endpoint.call({
413
656
  path: resolvedParams.path,
414
657
  query: resolvedParams.query,
658
+ body: resolvedParams.body,
415
659
  headers: resolvedParams.headers,
416
660
  signal: context.signal,
417
661
  } as unknown as EndpointCallArgs<TContract, TProvidedHeaders>);
@@ -443,9 +687,14 @@ export class ReactQueryContractHelper<
443
687
  *
444
688
  * Create this once near client setup, then bind contracts with the returned
445
689
  * `rq(contract)` function.
690
+ *
691
+ * Pass `keyHeaders` to include specific identity headers, such as a tenant
692
+ * header, in generated query keys. Headers are excluded by default so
693
+ * persisted caches never store credentials.
446
694
  */
447
695
  export function createReactQuery<TProvidedHeaders extends string = never>(
448
696
  client: Client<TProvidedHeaders>,
697
+ options: CreateReactQueryOptions = {},
449
698
  ) {
450
699
  return function rq<TContractLike extends ContractLike>(
451
700
  contract: TContractLike,
@@ -458,6 +707,6 @@ export function createReactQuery<TProvidedHeaders extends string = never>(
458
707
  ResolveContract<TContractLike>,
459
708
  TProvidedHeaders
460
709
  >;
461
- return new ReactQueryContractHelper(resolved, endpoint);
710
+ return new ReactQueryContractHelper(resolved, endpoint, options);
462
711
  };
463
712
  }