From 7447968054b241246d9445d58031a25b1e12adb7 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 2 May 2026 21:18:56 -0700 Subject: [PATCH 1/7] feat(tanstack-query): add useTransaction hook for sequential transactions Adds `$transaction.useSequential()` to the hooks returned by `useClientQueries()` across React, Vue, and Svelte. The hook POSTs an array of operations to the server's `$transaction/sequential` endpoint and automatically invalidates all queries affected by the operations on success. Shared mutation logic (URL building, per-op invalidation) is extracted into `src/common/transaction.ts` to avoid duplication across frameworks. Co-Authored-By: Claude Sonnet 4.6 --- .../tanstack-query/src/common/constants.ts | 4 + .../tanstack-query/src/common/transaction.ts | 48 ++++++ .../tanstack-query/src/common/types.ts | 9 ++ packages/clients/tanstack-query/src/react.ts | 133 ++++++++++++---- .../tanstack-query/src/svelte/index.svelte.ts | 49 +++++- packages/clients/tanstack-query/src/vue.ts | 144 ++++++++++++++---- .../tanstack-query/test/react-query.test.tsx | 92 +++++++++++ 7 files changed, 418 insertions(+), 61 deletions(-) create mode 100644 packages/clients/tanstack-query/src/common/transaction.ts diff --git a/packages/clients/tanstack-query/src/common/constants.ts b/packages/clients/tanstack-query/src/common/constants.ts index 15684479d..f1dba8534 100644 --- a/packages/clients/tanstack-query/src/common/constants.ts +++ b/packages/clients/tanstack-query/src/common/constants.ts @@ -1 +1,5 @@ +/** Route segment for custom procedures. */ export const CUSTOM_PROC_ROUTE_NAME = '$procs'; + +/** Route prefix for transaction endpoints. */ +export const TRANSACTION_ROUTE_PREFIX = '$transaction'; diff --git a/packages/clients/tanstack-query/src/common/transaction.ts b/packages/clients/tanstack-query/src/common/transaction.ts new file mode 100644 index 000000000..77f12b21e --- /dev/null +++ b/packages/clients/tanstack-query/src/common/transaction.ts @@ -0,0 +1,48 @@ +import type { Logger } from '@zenstackhq/client-helpers'; +import { createInvalidator, type InvalidateFunc } from '@zenstackhq/client-helpers'; +import type { FetchFn } from '@zenstackhq/client-helpers/fetch'; +import { fetcher, marshal } from '@zenstackhq/client-helpers/fetch'; +import type { SchemaDef } from '@zenstackhq/schema'; +import { TRANSACTION_ROUTE_PREFIX } from './constants.js'; +import type { TransactionOperation } from './types.js'; + +/** + * Builds the mutation function for a sequential transaction request. + */ +export function makeTransactionMutationFn(endpoint: string, fetch: FetchFn | undefined) { + return (operations: TransactionOperation[]) => { + const reqUrl = `${endpoint}/${TRANSACTION_ROUTE_PREFIX}/sequential`; + const fetchInit = { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: marshal(operations), + }; + return fetcher(reqUrl, fetchInit, fetch); + }; +} + +/** + * Builds the `onSuccess` handler for a sequential transaction mutation that invalidates + * all queries affected by the operations in the transaction. + * + * @param schema The schema definition. + * @param invalidateFunc Function that invalidates queries matching a predicate. + * @param logging Logging option. + * @param origOnSuccess The user-provided `onSuccess` callback to call after invalidation. + */ +export function makeTransactionOnSuccess( + schema: SchemaDef, + invalidateFunc: InvalidateFunc, + logging: Logger | undefined, + origOnSuccess: ((...args: any[]) => any) | undefined, +) { + return async (...args: any[]) => { + const variables = args[1] as TransactionOperation[]; + for (const op of variables) { + const invalidator = createInvalidator(op.model, op.op, schema, invalidateFunc, logging); + // pass op.args as mutation variables so the invalidator can analyze nested writes + await invalidator(args[0], op.args, args[2]); + } + await origOnSuccess?.(...args); + }; +} diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts index a967869ed..262f77615 100644 --- a/packages/clients/tanstack-query/src/common/types.ts +++ b/packages/clients/tanstack-query/src/common/types.ts @@ -100,3 +100,12 @@ export type WithOptimistic = T extends Array ? Array> = Awaited< ReturnType> >; + +/** + * Represents a single operation to execute within a sequential transaction. + */ +export type TransactionOperation = { + model: string; + op: string; + args?: unknown; +}; diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index a62421de1..71ba3b362 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -19,7 +19,14 @@ import { type UseSuspenseQueryOptions, type UseSuspenseQueryResult, } from '@tanstack/react-query'; -import { createInvalidator, createOptimisticUpdater, DEFAULT_QUERY_ENDPOINT, type InferExtResult, type InferOptions, type InferSchema } from '@zenstackhq/client-helpers'; +import { + createInvalidator, + createOptimisticUpdater, + DEFAULT_QUERY_ENDPOINT, + type InferExtResult, + type InferOptions, + type InferSchema, +} from '@zenstackhq/client-helpers'; import { fetcher, makeUrl, marshal } from '@zenstackhq/client-helpers/fetch'; import { lowerCaseFirst } from '@zenstackhq/common-helpers'; import type { @@ -61,16 +68,18 @@ import { createContext, useContext } from 'react'; import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client.js'; import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js'; import { getQueryKey } from './common/query-key.js'; +import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, TrimSlicedOperations, WithOptimistic, } from './common/types.js'; -export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; export type { InferExtResult, InferOptions, InferSchema } from '@zenstackhq/client-helpers'; +export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; export type { SchemaDef } from '@zenstackhq/schema'; type ProcedureHookFn< @@ -147,20 +156,40 @@ export type ModelMutationModelResult< Array extends boolean = false, Options extends QueryOptions = QueryOptions, ExtResult extends ExtResultBase = {}, -> = Omit, TArgs>, 'mutateAsync'> & { +> = Omit< + ModelMutationResult, TArgs>, + 'mutateAsync' +> & { mutateAsync( args: T, options?: ModelMutationOptions, T>, ): Promise>; }; +export type TransactionMutationOptions = Omit< + UseMutationOptions, + 'mutationFn' +> & + Omit; + export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, ExtResult extends ExtResultBase = {}, > = { - [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; -} & ProcedureHooks; + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks< + Schema, + Model, + Options, + ExtResult + >; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): UseMutationResult; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -265,13 +294,26 @@ export type ModelQueryHooks< useInfiniteFindMany, TPageParam = unknown>( args?: SelectSubset>, - options?: ModelInfiniteQueryOptions[], TPageParam>, - ): ModelInfiniteQueryResult[], TPageParam>>; - - useSuspenseInfiniteFindMany, TPageParam = unknown>( + options?: ModelInfiniteQueryOptions< + SimplifiedPlainResult[], + TPageParam + >, + ): ModelInfiniteQueryResult< + InfiniteData[], TPageParam> + >; + + useSuspenseInfiniteFindMany< + T extends FindManyArgs, + TPageParam = unknown, + >( args?: SelectSubset>, - options?: ModelSuspenseInfiniteQueryOptions[], TPageParam>, - ): ModelSuspenseInfiniteQueryResult[], TPageParam>>; + options?: ModelSuspenseInfiniteQueryOptions< + SimplifiedPlainResult[], + TPageParam + >, + ): ModelSuspenseInfiniteQueryResult< + InfiniteData[], TPageParam> + >; useCreate>( options?: ModelMutationOptions, T>, @@ -360,23 +402,20 @@ export type ModelQueryHooks< * @param schema The schema. * @param options Options for all queries originated from this hook. */ -export function useClientQueries< - SchemaOrClient extends SchemaDef | ClientContract, ->( +export function useClientQueries>( schema: InferSchema, options?: QueryContext, -): ClientHooks, InferOptions>, InferExtResult extends ExtResultBase> ? InferExtResult : {}> { - const result = Object.keys(schema.models).reduce( - (acc, model) => { - (acc as any)[lowerCaseFirst(model)] = useModelQueries( - schema as any, - model as any, - options, - ); - return acc; - }, - {} as any, - ); +): ClientHooks< + InferSchema, + InferOptions>, + InferExtResult extends ExtResultBase> + ? InferExtResult + : {} +> { + const result = Object.keys(schema.models).reduce((acc, model) => { + (acc as any)[lowerCaseFirst(model)] = useModelQueries(schema as any, model as any, options); + return acc; + }, {} as any); const procedures = (schema as any).procedures as Record | undefined; if (procedures) { @@ -422,6 +461,10 @@ export function useClientQueries< (result as any).$procs = buildProcedureHooks(); } + (result as any).$transaction = { + useSequential: (hookOptions?: any) => useInternalTransactionMutation(schema, { ...options, ...hookOptions }), + }; + return result; } @@ -599,7 +642,13 @@ export function useInternalInfiniteQuery, QueryKey, TPageParam>, + UseInfiniteQueryOptions< + TQueryFnData, + DefaultError, + InfiniteData, + QueryKey, + TPageParam + >, 'queryKey' | 'initialPageParam' > & QueryContext) @@ -627,7 +676,14 @@ export function useInternalSuspenseInfiniteQuery, QueryKey, TPageParam> & QueryContext, + UseSuspenseInfiniteQueryOptions< + TQueryFnData, + DefaultError, + InfiniteData, + QueryKey, + TPageParam + > & + QueryContext, 'queryKey' | 'initialPageParam' >, ) { @@ -750,6 +806,27 @@ export function useInternalMutation( return useMutation(finalOptions); } +export function useInternalTransactionMutation(schema: SchemaDef, options?: TransactionMutationOptions) { + const { endpoint, fetch, logging } = useFetchOptions(options); + const queryClient = useQueryClient(); + + const mutationFn = makeTransactionMutationFn(endpoint, fetch); + + const finalOptions = { ...options, mutationFn }; + + if (options?.invalidateQueries !== false) { + const origOnSuccess = finalOptions.onSuccess; + finalOptions.onSuccess = makeTransactionOnSuccess( + schema, + (predicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + origOnSuccess as any, + ); + } + + return useMutation(finalOptions); +} + function useFetchOptions(options: QueryContext | undefined) { const { endpoint, fetch, logging } = useHooksContext(); // options take precedence over context diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index 639cf2969..7b1a85486 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -65,16 +65,18 @@ import { getContext, setContext } from 'svelte'; import { getAllQueries, invalidateQueriesMatchingPredicate } from '../common/client.js'; import { CUSTOM_PROC_ROUTE_NAME } from '../common/constants.js'; import { getQueryKey } from '../common/query-key.js'; +import { makeTransactionMutationFn, makeTransactionOnSuccess } from '../common/transaction.js'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, TrimSlicedOperations, WithOptimistic, } from '../common/types.js'; -export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; export type { InferExtResult, InferOptions, InferSchema } from '@zenstackhq/client-helpers'; +export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; export type { SchemaDef } from '@zenstackhq/schema'; type ProcedureHookFn< @@ -157,6 +159,12 @@ export type ModelMutationModelResult< ): Promise>; }; +export type TransactionMutationOptions = Omit< + CreateMutationOptions, + 'mutationFn' +> & + Omit; + export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, @@ -168,7 +176,13 @@ export type ClientHooks< Options, ExtResult >; -} & ProcedureHooks; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): CreateMutationResult; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -374,6 +388,10 @@ export function useClientQueries useInternalTransactionMutation(schema, merge(options, hookOptions)), + }; + return result; } @@ -690,6 +708,33 @@ export function useInternalMutation( return createMutation(finalOptions); } +export function useInternalTransactionMutation(schema: SchemaDef, options?: Accessor) { + const { endpoint, fetch, logging } = useFetchOptions(options); + const queryClient = useQueryClient(); + + const mutationFn = makeTransactionMutationFn(endpoint, fetch); + + const finalOptions = () => { + const optionsValue = options?.(); + const result: any = { ...optionsValue, mutationFn }; + + if (optionsValue?.invalidateQueries !== false) { + result.onSuccess = makeTransactionOnSuccess( + schema, + (predicate: InvalidationPredicate) => + // @ts-ignore + invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + optionsValue?.onSuccess as any, + ); + } + + return result; + }; + + return createMutation(finalOptions); +} + function useFetchOptions(options: Accessor | undefined) { const { endpoint, fetch, logging } = useQuerySettings(); const optionsValue = options?.(); diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index 32ab6e656..aebf38c52 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -63,16 +63,18 @@ import { computed, inject, provide, toValue, unref, type MaybeRefOrGetter, type import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client.js'; import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js'; import { getQueryKey } from './common/query-key.js'; +import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, TrimSlicedOperations, WithOptimistic, } from './common/types.js'; -export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; export type { InferExtResult, InferOptions, InferSchema } from '@zenstackhq/client-helpers'; +export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; export type { SchemaDef } from '@zenstackhq/schema'; export const VueQueryContextKey = 'zenstack-vue-query-context'; @@ -151,13 +153,29 @@ export type ModelMutationModelResult< ): Promise>; }; +export type TransactionMutationOptions = MaybeRefOrGetter< + Omit>, 'mutationFn'> & + Omit +>; + export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, ExtResult extends ExtResultBase = {}, > = { - [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; -} & ProcedureHooks; + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks< + Schema, + Model, + Options, + ExtResult + >; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): UseMutationReturnType; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -226,12 +244,16 @@ export type ModelQueryHooks< { useFindUnique>( args: MaybeRefOrGetter>>, - options?: MaybeRefOrGetter | null>>, + options?: MaybeRefOrGetter< + ModelQueryOptions | null> + >, ): ModelQueryResult | null>; useFindFirst>( args?: MaybeRefOrGetter>>, - options?: MaybeRefOrGetter | null>>, + options?: MaybeRefOrGetter< + ModelQueryOptions | null> + >, ): ModelQueryResult | null>; useExists>( @@ -241,16 +263,24 @@ export type ModelQueryHooks< useFindMany>( args?: MaybeRefOrGetter>>, - options?: MaybeRefOrGetter[]>>, + options?: MaybeRefOrGetter< + ModelQueryOptions[]> + >, ): ModelQueryResult[]>; useInfiniteFindMany, TPageParam = unknown>( args?: MaybeRefOrGetter>>, - options?: MaybeRefOrGetter[], TPageParam>>, - ): ModelInfiniteQueryResult[], TPageParam>>; + options?: MaybeRefOrGetter< + ModelInfiniteQueryOptions[], TPageParam> + >, + ): ModelInfiniteQueryResult< + InfiniteData[], TPageParam> + >; useCreate>( - options?: MaybeRefOrGetter, T>>, + options?: MaybeRefOrGetter< + ModelMutationOptions, T> + >, ): ModelMutationModelResult; useCreateMany>( @@ -258,11 +288,15 @@ export type ModelQueryHooks< ): ModelMutationResult; useCreateManyAndReturn>( - options?: MaybeRefOrGetter[], T>>, + options?: MaybeRefOrGetter< + ModelMutationOptions[], T> + >, ): ModelMutationModelResult; useUpdate>( - options?: MaybeRefOrGetter, T>>, + options?: MaybeRefOrGetter< + ModelMutationOptions, T> + >, ): ModelMutationModelResult; useUpdateMany>( @@ -270,15 +304,21 @@ export type ModelQueryHooks< ): ModelMutationResult; useUpdateManyAndReturn>( - options?: MaybeRefOrGetter[], T>>, + options?: MaybeRefOrGetter< + ModelMutationOptions[], T> + >, ): ModelMutationModelResult; useUpsert>( - options?: MaybeRefOrGetter, T>>, + options?: MaybeRefOrGetter< + ModelMutationOptions, T> + >, ): ModelMutationModelResult; useDelete>( - options?: MaybeRefOrGetter, T>>, + options?: MaybeRefOrGetter< + ModelMutationOptions, T> + >, ): ModelMutationModelResult; useDeleteMany>( @@ -318,12 +358,16 @@ export type ModelQueryHooks< * const client = useClientQueries(schema) * ``` */ -export function useClientQueries< - SchemaOrClient extends SchemaDef | ClientContract, ->( +export function useClientQueries>( schema: InferSchema, options?: MaybeRefOrGetter, -): ClientHooks, InferOptions>, InferExtResult extends ExtResultBase> ? InferExtResult : {}> { +): ClientHooks< + InferSchema, + InferOptions>, + InferExtResult extends ExtResultBase> + ? InferExtResult + : {} +> { const merge = (rootOpt: MaybeRefOrGetter | undefined, opt: MaybeRefOrGetter | undefined): any => { return computed(() => { const rootVal = toValue(rootOpt) ?? {}; @@ -332,17 +376,10 @@ export function useClientQueries< }); }; - const result = Object.keys(schema.models).reduce( - (acc, model) => { - (acc as any)[lowerCaseFirst(model)] = useModelQueries( - schema as any, - model as any, - options, - ); - return acc; - }, - {} as any, - ); + const result = Object.keys(schema.models).reduce((acc, model) => { + (acc as any)[lowerCaseFirst(model)] = useModelQueries(schema as any, model as any, options); + return acc; + }, {} as any); const procedures = (schema as any).procedures as Record | undefined; if (procedures) { @@ -381,6 +418,10 @@ export function useClientQueries< (result as any).$procs = buildProcedureHooks(); } + (result as any).$transaction = { + useSequential: (hookOptions?: any) => useInternalTransactionMutation(schema, merge(options, hookOptions)), + }; + return result; } @@ -392,7 +433,11 @@ export function useModelQueries< Model extends GetModels, Options extends QueryOptions, ExtResult extends ExtResultBase = {}, ->(schema: Schema, model: Model, rootOptions?: MaybeRefOrGetter): ModelQueryHooks { +>( + schema: Schema, + model: Model, + rootOptions?: MaybeRefOrGetter, +): ModelQueryHooks { const modelDef = Object.values(schema.models).find((m) => m.name.toLowerCase() === model.toLowerCase()); if (!modelDef) { throw new Error(`Model "${model}" not found in schema`); @@ -521,7 +566,13 @@ export function useInternalInfiniteQuery, QueryKey, TPageParam> + UseInfiniteQueryOptions< + TQueryFnData, + DefaultError, + InfiniteData, + QueryKey, + TPageParam + > >, 'queryKey' | 'initialPageParam' > & @@ -674,6 +725,37 @@ export function useInternalMutation( return useMutation(finalOptions); } +export function useInternalTransactionMutation( + schema: SchemaDef, + options?: MaybeRefOrGetter< + Omit>, 'mutationFn'> & + Omit + >, +) { + const queryClient = useQueryClient(); + const { endpoint, fetch, logging } = useFetchOptions(options); + + const mutationFn = makeTransactionMutationFn(endpoint, fetch); + + const finalOptions = computed(() => { + const optionsValue = toValue(options); + const result: any = { ...optionsValue, mutationFn }; + + if (optionsValue?.invalidateQueries !== false) { + result.onSuccess = makeTransactionOnSuccess( + schema, + (predicate: InvalidationPredicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + unref(optionsValue?.onSuccess) as any, + ); + } + + return result; + }); + + return useMutation(finalOptions); +} + function useFetchOptions(options: MaybeRefOrGetter) { const { endpoint, fetch, logging } = useQuerySettings(); const optionsValue = toValue(options); diff --git a/packages/clients/tanstack-query/test/react-query.test.tsx b/packages/clients/tanstack-query/test/react-query.test.tsx index 4e2db7698..d8d1d6889 100644 --- a/packages/clients/tanstack-query/test/react-query.test.tsx +++ b/packages/clients/tanstack-query/test/react-query.test.tsx @@ -1784,4 +1784,96 @@ describe('React Query Test', () => { expect(cacheData[0].email).toBe('foo'); }); }); + + it('works with sequential transaction and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const users: any[] = []; + const posts: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: users })) + .persist(); + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: posts })) + .persist(); + + const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); + const { result: postResult } = renderHook(() => useClientQueries(schema).post.useFindMany(), { wrapper }); + + await waitFor(() => { + expect(userResult.current.data).toHaveLength(0); + expect(postResult.current.data).toHaveLength(0); + }); + + nock(`${BASE_URL}/api/model/$transaction/sequential`) + .post(/.*/) + .reply(200, () => { + users.push({ id: '1', email: 'foo@bar.com' }); + posts.push({ id: 'p1', title: 'Hello' }); + return { data: [users[0], posts[0]] }; + }); + + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential(), + { wrapper }, + ); + + act(() => + txResult.current.mutate([ + { model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }, + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + ]), + ); + + await waitFor(() => { + const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + const cachedPosts = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); + expect(cachedUsers).toHaveLength(1); + expect(cachedPosts).toHaveLength(1); + }); + }); + + it('works with sequential transaction and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const users: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: users })) + .persist(); + + const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); + + await waitFor(() => { + expect(userResult.current.data).toHaveLength(0); + }); + + nock(`${BASE_URL}/api/model/$transaction/sequential`) + .post(/.*/) + .reply(200, () => { + users.push({ id: '1', email: 'foo@bar.com' }); + return { data: [users[0]] }; + }); + + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential({ invalidateQueries: false }), + { wrapper }, + ); + + act(() => + txResult.current.mutate([{ model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }]), + ); + + await waitFor(() => { + expect(txResult.current.isSuccess).toBe(true); + // cache not refreshed because invalidation was disabled + const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cachedUsers).toHaveLength(0); + }); + }); }); From 4976409f125ff1eaae5d1f71f7053e0f4075ee5e Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 2 May 2026 21:25:37 -0700 Subject: [PATCH 2/7] merge test --- .../tanstack-query/test/react-query.test.tsx | 150 +++++++++++++++++- 1 file changed, 142 insertions(+), 8 deletions(-) diff --git a/packages/clients/tanstack-query/test/react-query.test.tsx b/packages/clients/tanstack-query/test/react-query.test.tsx index d8d1d6889..37bd462b9 100644 --- a/packages/clients/tanstack-query/test/react-query.test.tsx +++ b/packages/clients/tanstack-query/test/react-query.test.tsx @@ -4,11 +4,12 @@ import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { deserialize, serialize } from '@zenstackhq/client-helpers/fetch'; import nock from 'nock'; import React from 'react'; import { afterEach, describe, expect, it } from 'vitest'; import { getQueryKey } from '../src/common/query-key'; -import { QuerySettingsProvider, useClientQueries } from '../src/react'; +import { AnyNull, DbNull, JsonNull, QuerySettingsProvider, useClientQueries } from '../src/react'; import { schema } from './schemas/basic/schema-lite'; const BASE_URL = 'http://localhost'; @@ -1817,10 +1818,9 @@ describe('React Query Test', () => { return { data: [users[0], posts[0]] }; }); - const { result: txResult } = renderHook( - () => useClientQueries(schema).$transaction.useSequential(), - { wrapper }, - ); + const { result: txResult } = renderHook(() => useClientQueries(schema).$transaction.useSequential(), { + wrapper, + }); act(() => txResult.current.mutate([ @@ -1865,9 +1865,7 @@ describe('React Query Test', () => { { wrapper }, ); - act(() => - txResult.current.mutate([{ model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }]), - ); + act(() => txResult.current.mutate([{ model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }])); await waitFor(() => { expect(txResult.current.isSuccess).toBe(true); @@ -1876,4 +1874,140 @@ describe('React Query Test', () => { expect(cachedUsers).toHaveLength(0); }); }); + + describe('JSON null value serialization', () => { + function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + return { queryClient, wrapper }; + } + + it('encodes DbNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: DbNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('DbNull'); + }); + + it('encodes JsonNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: JsonNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('JsonNull'); + }); + + it('encodes AnyNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: AnyNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('AnyNull'); + }); + + it('encodes DbNull in mutation body with serialization metadata', async () => { + const { wrapper } = createWrapper(); + let capturedBody: any; + + nock(BASE_URL) + .post(/.*/) + .reply(200, function (_uri, body) { + capturedBody = body; + return { data: { id: '1', name: null } }; + }); + + const { result } = renderHook(() => useClientQueries(schema).user.useCreate(), { wrapper }); + + act(() => result.current.mutate({ data: { email: 'test@example.com', name: DbNull } } as any)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(capturedBody.meta?.serialization).toBeDefined(); + const reconstructed = deserialize({ data: capturedBody.data }, capturedBody.meta.serialization) as any; + expect(reconstructed.data.name.__brand).toBe('DbNull'); + }); + + it('deserializes null sentinels in server response back to branded instances', async () => { + const { wrapper } = createWrapper(); + + const responseData = { id: '1', email: 'test@example.com', name: DbNull }; + const { data: serializedData, meta: serializedMeta } = serialize(responseData); + + nock(BASE_URL) + .get(/.*/) + .reply(200, { data: serializedData, meta: { serialization: serializedMeta } }); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect((result.current.data as any).name.__brand).toBe('DbNull'); + }); + }); }); From 78bee2693eb4ff159a8f2e624d4456c4e447d980 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 2 May 2026 21:41:30 -0700 Subject: [PATCH 3/7] fix: address pr comments --- packages/clients/tanstack-query/src/common/client.ts | 5 +++++ packages/clients/tanstack-query/src/react.ts | 4 ++-- packages/clients/tanstack-query/src/svelte/index.svelte.ts | 4 ++-- packages/clients/tanstack-query/src/vue.ts | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/clients/tanstack-query/src/common/client.ts b/packages/clients/tanstack-query/src/common/client.ts index 9914ea736..d28b454df 100644 --- a/packages/clients/tanstack-query/src/common/client.ts +++ b/packages/clients/tanstack-query/src/common/client.ts @@ -2,6 +2,11 @@ import type { QueryClient } from '@tanstack/query-core'; import type { InvalidationPredicate, QueryInfo } from '@zenstackhq/client-helpers'; import { parseQueryKey } from './query-key.js'; +/** Strips a trailing slash from an endpoint URL. */ +export function normalizeEndpoint(endpoint: string) { + return endpoint.replace(/\/$/, ''); +} + export function invalidateQueriesMatchingPredicate(queryClient: QueryClient, predicate: InvalidationPredicate) { return queryClient.invalidateQueries({ predicate: ({ queryKey }) => { diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index 4f8193634..e087cff15 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -65,7 +65,7 @@ import type { } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { createContext, useContext } from 'react'; -import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client.js'; +import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from './common/client.js'; import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js'; import { getQueryKey } from './common/query-key.js'; import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js'; @@ -832,7 +832,7 @@ function useFetchOptions(options: QueryContext | undefined) { const { endpoint, fetch, logging } = useHooksContext(); // options take precedence over context return { - endpoint: options?.endpoint ?? endpoint, + endpoint: normalizeEndpoint(options?.endpoint ?? endpoint), fetch: options?.fetch ?? fetch, logging: options?.logging ?? logging, }; diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index 42e3158ef..85122515d 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -62,7 +62,7 @@ import type { } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { getContext, setContext } from 'svelte'; -import { getAllQueries, invalidateQueriesMatchingPredicate } from '../common/client.js'; +import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from '../common/client.js'; import { CUSTOM_PROC_ROUTE_NAME } from '../common/constants.js'; import { getQueryKey } from '../common/query-key.js'; import { makeTransactionMutationFn, makeTransactionOnSuccess } from '../common/transaction.js'; @@ -741,7 +741,7 @@ function useFetchOptions(options: Accessor | undefined) { const optionsValue = options?.(); // options take precedence over context return { - endpoint: optionsValue?.endpoint ?? endpoint, + endpoint: normalizeEndpoint(optionsValue?.endpoint ?? endpoint), fetch: optionsValue?.fetch ?? fetch, logging: optionsValue?.logging ?? logging, }; diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index 482648f18..c0f0ccad8 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -60,7 +60,7 @@ import type { } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { computed, inject, provide, toValue, unref, type MaybeRefOrGetter, type Ref, type UnwrapRef } from 'vue'; -import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client.js'; +import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from './common/client.js'; import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js'; import { getQueryKey } from './common/query-key.js'; import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js'; @@ -762,7 +762,7 @@ function useFetchOptions(options: MaybeRefOrGetter) { const optionsValue = toValue(options); // options take precedence over context return { - endpoint: optionsValue?.endpoint ?? endpoint, + endpoint: normalizeEndpoint(optionsValue?.endpoint ?? endpoint), fetch: optionsValue?.fetch ?? fetch, logging: optionsValue?.logging ?? logging, }; From 29bf3e6678fb985048d5250c5b61c01766b90232 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 2 May 2026 21:53:04 -0700 Subject: [PATCH 4/7] fix: address PR comments --- packages/clients/tanstack-query/src/common/transaction.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/clients/tanstack-query/src/common/transaction.ts b/packages/clients/tanstack-query/src/common/transaction.ts index 77f12b21e..6f31a8a3d 100644 --- a/packages/clients/tanstack-query/src/common/transaction.ts +++ b/packages/clients/tanstack-query/src/common/transaction.ts @@ -37,8 +37,11 @@ export function makeTransactionOnSuccess( origOnSuccess: ((...args: any[]) => any) | undefined, ) { return async (...args: any[]) => { - const variables = args[1] as TransactionOperation[]; + const variables = Array.isArray(args[1]) ? (args[1] as TransactionOperation[]) : []; for (const op of variables) { + if (typeof op?.model !== 'string' || typeof op?.op !== 'string') { + continue; + } const invalidator = createInvalidator(op.model, op.op, schema, invalidateFunc, logging); // pass op.args as mutation variables so the invalidator can analyze nested writes await invalidator(args[0], op.args, args[2]); From 520cdf4d6ea1dbd2bbc2d1cec02faac007361df7 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 3 May 2026 17:22:05 -0700 Subject: [PATCH 5/7] refactor(tanstack-query): strongly type TransactionOperation and normalize model name lookup - Make `TransactionOperation` a discriminated union over (model, op) pairs with schema-derived args types - Normalize incoming mutation model name via case-insensitive schema lookup so lowerCaseFirst names resolve - Guard transaction onSuccess against malformed variables and per-item shape - Move `normalizeEndpoint` from constants.ts to client.ts - Group react-query tests into CRUD/Optimistic/Sequential describe blocks Co-Authored-By: Claude Sonnet 4.6 --- .../client-helpers/src/invalidation.ts | 9 +- .../src/nested-write-visitor.ts | 4 - .../tanstack-query/src/common/transaction.ts | 9 +- .../tanstack-query/src/common/types.ts | 64 +- packages/clients/tanstack-query/src/react.ts | 15 +- .../tanstack-query/src/svelte/index.svelte.ts | 15 +- packages/clients/tanstack-query/src/vue.ts | 19 +- .../tanstack-query/test/react-query.test.tsx | 3042 +++++++++-------- 8 files changed, 1624 insertions(+), 1553 deletions(-) diff --git a/packages/clients/client-helpers/src/invalidation.ts b/packages/clients/client-helpers/src/invalidation.ts index 1289a881d..ef792fe2a 100644 --- a/packages/clients/client-helpers/src/invalidation.ts +++ b/packages/clients/client-helpers/src/invalidation.ts @@ -30,10 +30,11 @@ export function createInvalidator( invalidator: InvalidateFunc, logging: Logger | undefined, ) { + const normalizedModel = normalizeModelName(model, schema); return async (...args: unknown[]) => { const [_, variables] = args; const predicate = await getInvalidationPredicate( - model, + normalizedModel, operation as ORMWriteActionType, variables, schema, @@ -87,3 +88,9 @@ function findNestedRead(visitingModel: string, targetModels: string[], schema: S const modelsRead = getReadModels(visitingModel, schema, args); return targetModels.some((m) => modelsRead.includes(m)); } + +// resolves a model name to its canonical form as defined in the schema (case-insensitive match) +function normalizeModelName(model: string, schema: SchemaDef) { + const target = model.toLowerCase(); + return Object.keys(schema.models).find((k) => k.toLowerCase() === target) ?? model; +} diff --git a/packages/clients/client-helpers/src/nested-write-visitor.ts b/packages/clients/client-helpers/src/nested-write-visitor.ts index 14ca1e404..f4ec614bc 100644 --- a/packages/clients/client-helpers/src/nested-write-visitor.ts +++ b/packages/clients/client-helpers/src/nested-write-visitor.ts @@ -297,10 +297,6 @@ export class NestedWriteVisitor { } } break; - - default: { - throw new Error(`unhandled action type ${action}`); - } } } diff --git a/packages/clients/tanstack-query/src/common/transaction.ts b/packages/clients/tanstack-query/src/common/transaction.ts index 6f31a8a3d..f4cff0fa2 100644 --- a/packages/clients/tanstack-query/src/common/transaction.ts +++ b/packages/clients/tanstack-query/src/common/transaction.ts @@ -9,8 +9,11 @@ import type { TransactionOperation } from './types.js'; /** * Builds the mutation function for a sequential transaction request. */ -export function makeTransactionMutationFn(endpoint: string, fetch: FetchFn | undefined) { - return (operations: TransactionOperation[]) => { +export function makeTransactionMutationFn( + endpoint: string, + fetch: FetchFn | undefined, +) { + return (operations: TransactionOperation[]) => { const reqUrl = `${endpoint}/${TRANSACTION_ROUTE_PREFIX}/sequential`; const fetchInit = { method: 'POST', @@ -37,7 +40,7 @@ export function makeTransactionOnSuccess( origOnSuccess: ((...args: any[]) => any) | undefined, ) { return async (...args: any[]) => { - const variables = Array.isArray(args[1]) ? (args[1] as TransactionOperation[]) : []; + const variables = Array.isArray(args[1]) ? args[1] : []; for (const op of variables) { if (typeof op?.model !== 'string' || typeof op?.op !== 'string') { continue; diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts index 262f77615..016adcd94 100644 --- a/packages/clients/tanstack-query/src/common/types.ts +++ b/packages/clients/tanstack-query/src/common/types.ts @@ -1,12 +1,28 @@ import type { Logger, OptimisticDataProvider } from '@zenstackhq/client-helpers'; import type { FetchFn } from '@zenstackhq/client-helpers/fetch'; import type { + AggregateArgs, + CountArgs, + CreateArgs, + CreateManyAndReturnArgs, + CreateManyArgs, + DeleteArgs, + DeleteManyArgs, + ExistsArgs, + FindFirstArgs, + FindManyArgs, + FindUniqueArgs, GetProcedureNames, GetSlicedOperations, + GroupByArgs, ModelAllowsCreate, OperationsRequiringCreate, ProcedureFunc, QueryOptions, + UpdateArgs, + UpdateManyAndReturnArgs, + UpdateManyArgs, + UpsertArgs, } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; @@ -102,10 +118,48 @@ export type ProcedureReturn; /** - * Represents a single operation to execute within a sequential transaction. + * Maps each core CRUD operation to its argument type for a given model. */ -export type TransactionOperation = { - model: string; - op: string; - args?: unknown; +type CrudArgsMap> = { + findMany: FindManyArgs; + findUnique: FindUniqueArgs; + findFirst: FindFirstArgs; + create: CreateArgs; + createMany: CreateManyArgs; + createManyAndReturn: CreateManyAndReturnArgs; + update: UpdateArgs; + updateMany: UpdateManyArgs; + updateManyAndReturn: UpdateManyAndReturnArgs; + upsert: UpsertArgs; + delete: DeleteArgs; + deleteMany: DeleteManyArgs; + count: CountArgs; + aggregate: AggregateArgs; + groupBy: GroupByArgs; + exists: ExistsArgs; }; + +/** + * Operations available for a given model, omitting create-style operations + * for models that don't allow them (e.g. delegate models). + */ +type AllowedTransactionOps> = + ModelAllowsCreate extends true + ? keyof CrudArgsMap + : Exclude, OperationsRequiringCreate>; + +/** + * Represents a single operation to execute within a sequential transaction. + * + * The `model`, `op`, and `args` fields are correlated: `op` is constrained to + * the CRUD operations available on `model`, and `args` is typed accordingly. + */ +export type TransactionOperation = { + [Model in GetModels]: { + [Op in AllowedTransactionOps]: { + model: Model; + op: Op; + args?: CrudArgsMap[Op]; + }; + }[AllowedTransactionOps]; +}[GetModels]; diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index e087cff15..731f3fda0 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -167,8 +167,8 @@ export type ModelMutationModelResult< ): Promise>; }; -export type TransactionMutationOptions = Omit< - UseMutationOptions, +export type TransactionMutationOptions = Omit< + UseMutationOptions[]>, 'mutationFn' > & Omit; @@ -187,8 +187,8 @@ export type ClientHooks< } & ProcedureHooks & { $transaction: { useSequential( - options?: TransactionMutationOptions, - ): UseMutationResult; + options?: TransactionMutationOptions, + ): UseMutationResult[]>; }; }; @@ -807,11 +807,14 @@ export function useInternalMutation( return useMutation(finalOptions); } -export function useInternalTransactionMutation(schema: SchemaDef, options?: TransactionMutationOptions) { +export function useInternalTransactionMutation( + schema: Schema, + options?: TransactionMutationOptions, +) { const { endpoint, fetch, logging } = useFetchOptions(options); const queryClient = useQueryClient(); - const mutationFn = makeTransactionMutationFn(endpoint, fetch); + const mutationFn = makeTransactionMutationFn(endpoint, fetch); const finalOptions = { ...options, mutationFn }; diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index 85122515d..b8bf2c9be 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -160,8 +160,8 @@ export type ModelMutationModelResult< ): Promise>; }; -export type TransactionMutationOptions = Omit< - CreateMutationOptions, +export type TransactionMutationOptions = Omit< + CreateMutationOptions[]>, 'mutationFn' > & Omit; @@ -180,8 +180,8 @@ export type ClientHooks< } & ProcedureHooks & { $transaction: { useSequential( - options?: TransactionMutationOptions, - ): CreateMutationResult; + options?: TransactionMutationOptions, + ): CreateMutationResult[]>; }; }; @@ -709,11 +709,14 @@ export function useInternalMutation( return createMutation(finalOptions); } -export function useInternalTransactionMutation(schema: SchemaDef, options?: Accessor) { +export function useInternalTransactionMutation( + schema: Schema, + options?: Accessor>, +) { const { endpoint, fetch, logging } = useFetchOptions(options); const queryClient = useQueryClient(); - const mutationFn = makeTransactionMutationFn(endpoint, fetch); + const mutationFn = makeTransactionMutationFn(endpoint, fetch); const finalOptions = () => { const optionsValue = options?.(); diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index c0f0ccad8..76669b1a8 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -154,8 +154,8 @@ export type ModelMutationModelResult< ): Promise>; }; -export type TransactionMutationOptions = MaybeRefOrGetter< - Omit>, 'mutationFn'> & +export type TransactionMutationOptions = MaybeRefOrGetter< + Omit[]>>, 'mutationFn'> & Omit >; @@ -173,8 +173,8 @@ export type ClientHooks< } & ProcedureHooks & { $transaction: { useSequential( - options?: TransactionMutationOptions, - ): UseMutationReturnType; + options?: TransactionMutationOptions, + ): UseMutationReturnType[], unknown>; }; }; @@ -726,17 +726,14 @@ export function useInternalMutation( return useMutation(finalOptions); } -export function useInternalTransactionMutation( - schema: SchemaDef, - options?: MaybeRefOrGetter< - Omit>, 'mutationFn'> & - Omit - >, +export function useInternalTransactionMutation( + schema: Schema, + options?: TransactionMutationOptions, ) { const queryClient = useQueryClient(); const { endpoint, fetch, logging } = useFetchOptions(options); - const mutationFn = makeTransactionMutationFn(endpoint, fetch); + const mutationFn = makeTransactionMutationFn(endpoint, fetch); const finalOptions = computed(() => { const optionsValue = toValue(options); diff --git a/packages/clients/tanstack-query/test/react-query.test.tsx b/packages/clients/tanstack-query/test/react-query.test.tsx index 37bd462b9..6afae03d9 100644 --- a/packages/clients/tanstack-query/test/react-query.test.tsx +++ b/packages/clients/tanstack-query/test/react-query.test.tsx @@ -45,1833 +45,1841 @@ describe('React Query Test', () => { cleanup(); }); - it('works with simple query', async () => { - const { queryClient, wrapper } = createWrapper(); + describe('CRUD and invalidation', () => { + it('works with simple query', async () => { + const { queryClient, wrapper } = createWrapper(); - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, { - data, - }); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - expect(result.current.data).toMatchObject(data); - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject(data); - }); + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, { + data, + }); - nock(makeUrl('User', 'findFirst', queryArgs)) - .get(/.*/) - .reply(404, () => { - return { error: 'Not Found' }; + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, }); - const { result: errorResult } = renderHook(() => useClientQueries(schema).user.useFindFirst(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(errorResult.current.isError).toBe(true); - }); - }); - - it('works with suspense query', async () => { - const { queryClient, wrapper } = createWrapper(); - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, { - data, + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toMatchObject(data); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject(data); }); - const { result } = renderHook(() => useClientQueries(schema).user.useSuspenseFindUnique(queryArgs), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - expect(result.current.data).toMatchObject(data); - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject(data); + nock(makeUrl('User', 'findFirst', queryArgs)) + .get(/.*/) + .reply(404, () => { + return { error: 'Not Found' }; + }); + const { result: errorResult } = renderHook(() => useClientQueries(schema).user.useFindFirst(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(errorResult.current.isError).toBe(true); + }); }); - }); - it('works with infinite query', async () => { - const { queryClient, wrapper } = createWrapper(); + it('works with suspense query', async () => { + const { queryClient, wrapper } = createWrapper(); - const queryArgs = { where: { id: '1' } }; - const data = [{ id: '1', name: 'foo' }]; + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; - nock(makeUrl('User', 'findMany', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })); + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, { + data, + }); - const { result } = renderHook( - () => - useClientQueries(schema).user.useInfiniteFindMany(queryArgs, { - getNextPageParam: () => null, - }), - { + const { result } = renderHook(() => useClientQueries(schema).user.useSuspenseFindUnique(queryArgs), { wrapper, - }, - ); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - const resultData = result.current.data!; - expect(resultData.pages).toHaveLength(1); - expect(resultData.pages[0]).toMatchObject(data); - expect(resultData?.pageParams).toHaveLength(1); - expect(resultData?.pageParams[0]).toMatchObject(queryArgs); - expect(result.current.hasNextPage).toBe(false); - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), - ); - expect(cacheData.pages[0]).toMatchObject(data); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toMatchObject(data); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject(data); + }); }); - }); - it('works with suspense infinite query', async () => { - const { queryClient, wrapper } = createWrapper(); + it('works with infinite query', async () => { + const { queryClient, wrapper } = createWrapper(); - const queryArgs = { where: { id: '1' } }; - const data = [{ id: '1', name: 'foo' }]; + const queryArgs = { where: { id: '1' } }; + const data = [{ id: '1', name: 'foo' }]; - nock(makeUrl('User', 'findMany', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })); + nock(makeUrl('User', 'findMany', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })); - const { result } = renderHook( - () => - useClientQueries(schema).user.useSuspenseInfiniteFindMany(queryArgs, { - getNextPageParam: () => null, - }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - const resultData = result.current.data!; - expect(resultData.pages).toHaveLength(1); - expect(resultData.pages[0]).toMatchObject(data); - expect(resultData?.pageParams).toHaveLength(1); - expect(resultData?.pageParams[0]).toMatchObject(queryArgs); - expect(result.current.hasNextPage).toBe(false); - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), + const { result } = renderHook( + () => + useClientQueries(schema).user.useInfiniteFindMany(queryArgs, { + getNextPageParam: () => null, + }), + { + wrapper, + }, ); - expect(cacheData.pages[0]).toMatchObject(data); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + const resultData = result.current.data!; + expect(resultData.pages).toHaveLength(1); + expect(resultData.pages[0]).toMatchObject(data); + expect(resultData?.pageParams).toHaveLength(1); + expect(resultData?.pageParams[0]).toMatchObject(queryArgs); + expect(result.current.hasNextPage).toBe(false); + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), + ); + expect(cacheData.pages[0]).toMatchObject(data); + }); }); - }); - it('works with independent mutation and query', async () => { - const { wrapper } = createWrapper(); + it('works with suspense infinite query', async () => { + const { queryClient, wrapper } = createWrapper(); - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; + const queryArgs = { where: { id: '1' } }; + const data = [{ id: '1', name: 'foo' }]; - let queryCount = 0; - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => { - queryCount++; - return { data }; - }) - .persist(); + nock(makeUrl('User', 'findMany', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })); - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); + const { result } = renderHook( + () => + useClientQueries(schema).user.useSuspenseInfiniteFindMany(queryArgs, { + getNextPageParam: () => null, + }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + const resultData = result.current.data!; + expect(resultData.pages).toHaveLength(1); + expect(resultData.pages[0]).toMatchObject(data); + expect(resultData?.pageParams).toHaveLength(1); + expect(resultData?.pageParams[0]).toMatchObject(queryArgs); + expect(result.current.hasNextPage).toBe(false); + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), + ); + expect(cacheData.pages[0]).toMatchObject(data); + }); }); - nock(makeUrl('Post', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: { id: '1', title: 'post1' }, - })); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useCreate(), { - wrapper, - }); + it('works with independent mutation and query', async () => { + const { wrapper } = createWrapper(); - act(() => mutationResult.current.mutate({ data: { title: 'post1' } })); + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; - await waitFor(() => { - // no refetch caused by invalidation - expect(queryCount).toBe(1); - }); - }); + let queryCount = 0; + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => { + queryCount++; + return { data }; + }) + .persist(); - it('works with create and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); - const data: any[] = []; + nock(makeUrl('Post', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: { id: '1', title: 'post1' }, + })); - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useCreate(), { + wrapper, + }); - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); + act(() => mutationResult.current.mutate({ data: { title: 'post1' } })); - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => { - data.push({ id: '1', email: 'foo' }); - return { data: data[0] }; + await waitFor(() => { + // no refetch caused by invalidation + expect(queryCount).toBe(1); }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { - wrapper, }); - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(1); - }); - }); + it('works with create and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - it('works with create and no invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); + const data: any[] = []; - const data: any[] = []; + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => { + data.push({ id: '1', email: 'foo' }); + return { data: data[0] }; + }); - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => { - data.push({ id: '1', email: 'foo' }); - return { data: data[0] }; + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { + wrapper, }); - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(0); + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(1); + }); }); - }); - it('works with optimistic create single', async () => { - const { queryClient, wrapper } = createWrapper(); + it('works with create and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - const data: any[] = []; + const data: any[] = []; - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => { + data.push({ id: '1', email: 'foo' }); + return { data: data[0] }; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { wrapper, - }, - ); + }); - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].email).toBe('foo'); + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(0); + }); }); - }); - it('works with optimistic create updating nested query', async () => { - const { queryClient, wrapper } = createWrapper(); + it('works with update and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - const data: any[] = [{ id: '1', name: 'user1', posts: [] }]; + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); - const { result } = renderHook( - () => - useClientQueries(schema).user.useFindMany( - { - include: { posts: true }, - }, - { optimisticUpdate: true }, - ), - { + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); - nock(makeUrl('Post', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.name = 'bar'; + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { wrapper, - }, - ); + }); - act(() => mutationResult.current.mutate({ data: { title: 'post1', owner: { connect: { id: '1' } } } })); + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'User', - 'findMany', - { include: { posts: true } }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData[0].posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ $optimistic: true, id: expect.any(String), title: 'post1', ownerId: '1' }); + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject({ name: 'bar' }); + }); }); - }); - - it('works with optimistic create updating deeply nested query', async () => { - const { queryClient, wrapper } = createWrapper(); - // populate the cache with a user + it('works with update and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - const userData: any[] = [{ id: '1', email: 'user1', posts: [] }]; + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; - nock(BASE_URL) - .get('/api/model/user/findMany') - .query(true) - .reply(200, () => ({ data: userData })) - .persist(); + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - const { result: userResult } = renderHook( - () => - useClientQueries(schema).user.useFindMany( - { - include: { - posts: { - include: { - category: true, - }, - }, - }, - }, - { optimisticUpdate: true }, - ), - { + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { wrapper, - }, - ); - await waitFor(() => { - expect(userResult.current.data).toHaveLength(1); - }); + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); - // populate the cache with a category - const categoryData: any[] = [{ id: '1', name: 'category1', posts: [] }]; - - nock(BASE_URL) - .get('/api/model/category/findMany') - .query(true) - .reply(200, () => ({ data: categoryData })) - .persist(); - - const { result: categoryResult } = renderHook( - () => - useClientQueries(schema).category.useFindMany( - { - include: { - posts: true, - }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(categoryResult.current.data).toHaveLength(1); - }); + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.name = 'bar'; + return data; + }); - // create a post and connect it to the category - nock(BASE_URL) - .post('/api/model/post/create') - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { wrapper, - }, - ); + }); - act(() => - mutationResult.current.mutate({ - data: { title: 'post1', owner: { connect: { id: '1' } }, category: { connect: { id: '1' } } }, - }), - ); + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); - // assert that the post was created and connected to the category - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'Category', - 'findMany', - { - include: { - posts: true, - }, - }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData[0].posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ - $optimistic: true, - id: expect.any(String), - title: 'post1', - ownerId: '1', + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject({ name: 'foo' }); }); }); - // assert that the post was created and connected to the user, and included the category - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'User', - 'findMany', - { - include: { - posts: { - include: { - category: true, - }, - }, - }, - }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData[0].posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ - $optimistic: true, - id: expect.any(String), - title: 'post1', - ownerId: '1', - categoryId: '1', - // TODO: should this include the category object and not just the foreign key? - // category: { $optimistic: true, id: '1', name: 'category1' }, - }); - }); - }); + it('works with delete and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - it('works with optimistic update with optional one-to-many relationship', async () => { - const { queryClient, wrapper } = createWrapper(); - - // populate the cache with a post, with an optional category relationship - const postData: any = { - id: '1', - title: 'post1', - ownerId: '1', - categoryId: null, - category: null, - }; - - const data: any[] = [postData]; - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .query(true) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result: postResult } = renderHook( - () => - useClientQueries(schema).post.useFindMany( - { - include: { - category: true, - }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(postResult.current.data).toHaveLength(1); - }); + const data: any[] = [{ id: '1', name: 'foo' }]; - // mock a put request to update the post title - nock(makeUrl('Post', 'update')) - .put(/.*/) - .reply(200, () => { - postData.title = 'postA'; - return { data: postData }; - }); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); - - // assert that the post was updated despite the optional (null) category relationship - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'Post', - 'findMany', - { - include: { - category: true, - }, - }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ - $optimistic: true, - id: expect.any(String), - title: 'postA', - ownerId: '1', - categoryId: null, - category: null, }); - }); - }); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); - it('works with optimistic update with nested optional one-to-many relationship', async () => { - const { queryClient, wrapper } = createWrapper(); - - // populate the cache with a user and a post, with an optional category - const postData: any = { - id: '1', - title: 'post1', - ownerId: '1', - categoryId: null, - category: null, - }; - - const userData: any[] = [{ id: '1', name: 'user1', posts: [postData] }]; - - nock(BASE_URL) - .get('/api/model/user/findMany') - .query(true) - .reply(200, () => { - return { data: userData }; - }) - .persist(); - - const { result: userResult } = renderHook( - () => - useClientQueries(schema).user.useFindMany( - { - include: { - posts: { - include: { - category: true, - }, - }, - }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(userResult.current.data).toHaveLength(1); - }); + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => { + data.splice(0, 1); + return { data: [] }; + }); - // mock a put request to update the post title - nock(BASE_URL) - .put('/api/model/post/update') - .reply(200, () => { - postData.title = 'postA'; - return { data: postData }; + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useDelete(), { + wrapper, }); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + act(() => mutationResult.current.mutate({ where: { id: '1' } })); - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); - - // assert that the post was updated - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'User', - 'findMany', - { - include: { - posts: { - include: { - category: true, - }, - }, - }, - }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData[0].posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ - $optimistic: true, - id: expect.any(String), - title: 'postA', - ownerId: '1', - categoryId: null, - category: null, + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(0); }); }); - }); - it('works with optimistic nested create updating query', async () => { - const { queryClient, wrapper } = createWrapper(); + it('top-level mutation and nested-read invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - const data: any[] = []; + const queryArgs = { where: { id: '1' }, include: { posts: true } }; + const data = { posts: [{ id: '1', title: 'post1' }] }; - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - const { result } = renderHook( - () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), - { + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); + }); + await waitFor(() => { + expect(result.current.data).toMatchObject(data); + }); - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => { + data.posts[0]!.title = 'post2'; + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useUpdate(), { wrapper, - }, - ); + }); - act(() => mutationResult.current.mutate({ data: { email: 'user1', posts: { create: { title: 'post1' } } } })); + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'post2' } })); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].title).toBe('post1'); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData.posts[0].title).toBe('post2'); + }); }); - }); - it('works with optimistic create many', async () => { - const { queryClient, wrapper } = createWrapper(); + it('nested mutation and top-level-read invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - const data: any[] = []; + const data = [{ id: '1', title: 'post1', ownerId: '1' }]; - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { + const { result } = renderHook(() => useClientQueries(schema).post.useFindMany(), { wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); + }); + await waitFor(() => { + expect(result.current.data).toMatchObject(data); + }); - nock(makeUrl('User', 'createMany')) - .post(/.*/) - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useCreateMany({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.push({ id: '2', title: 'post2', ownerId: '1' }); + return data; + }); - act(() => mutationResult.current.mutate({ data: [{ email: 'foo' }, { email: 'bar' }] })); + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { + wrapper, + }); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + act(() => + mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { create: { title: 'post2' } } } }), ); - expect(cacheData).toHaveLength(2); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); + expect(cacheData).toHaveLength(2); + }); }); }); - it('works with update and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); + describe('Optimistic mutation', () => { + it('works with optimistic create single', async () => { + const { queryClient, wrapper } = createWrapper(); - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; + const data: any[] = []; - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.name = 'bar'; - return data; + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); }); - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject({ name: 'bar' }); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].email).toBe('foo'); + }); }); - }); - - it('works with update and no invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; + it('works with optimistic create updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + const data: any[] = [{ id: '1', name: 'user1', posts: [] }]; - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.name = 'bar'; - return data; + const { result } = renderHook( + () => + useClientQueries(schema).user.useFindMany( + { + include: { posts: true }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); }); - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + nock(makeUrl('Post', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject({ name: 'foo' }); + act(() => mutationResult.current.mutate({ data: { title: 'post1', owner: { connect: { id: '1' } } } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { include: { posts: true } }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ $optimistic: true, id: expect.any(String), title: 'post1', ownerId: '1' }); + }); }); - }); - - it('works with optimistic update simple', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); + it('works with optimistic create updating deeply nested query', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); + // populate the cache with a user - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => data); + const userData: any[] = [{ id: '1', email: 'user1', posts: [] }]; - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + nock(BASE_URL) + .get('/api/model/user/findMany') + .query(true) + .reply(200, () => ({ data: userData })) + .persist(); + + const { result: userResult } = renderHook( + () => + useClientQueries(schema).user.useFindMany( + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(userResult.current.data).toHaveLength(1); + }); - act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + // populate the cache with a category + const categoryData: any[] = [{ id: '1', name: 'category1', posts: [] }]; - await waitFor(() => { - const cacheData = queryClient.getQueryData( - getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + nock(BASE_URL) + .get('/api/model/category/findMany') + .query(true) + .reply(200, () => ({ data: categoryData })) + .persist(); + + const { result: categoryResult } = renderHook( + () => + useClientQueries(schema).category.useFindMany( + { + include: { + posts: true, + }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, ); - expect(cacheData).toMatchObject({ name: 'bar', $optimistic: true }); - }); - }); + await waitFor(() => { + expect(categoryResult.current.data).toHaveLength(1); + }); - it('works with optimistic update updating nested query', async () => { - const { queryClient, wrapper } = createWrapper(); + // create a post and connect it to the category + nock(BASE_URL) + .post('/api/model/post/create') + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - const queryArgs = { where: { id: '1' }, include: { posts: true } }; - const data = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; + act(() => + mutationResult.current.mutate({ + data: { title: 'post1', owner: { connect: { id: '1' } }, category: { connect: { id: '1' } } }, + }), + ); - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + // assert that the post was created and connected to the category + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'Category', + 'findMany', + { + include: { + posts: true, + }, + }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'post1', + ownerId: '1', + }); + }); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); + // assert that the post was created and connected to the user, and included the category + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'post1', + ownerId: '1', + categoryId: '1', + // TODO: should this include the category object and not just the foreign key? + // category: { $optimistic: true, id: '1', name: 'category1' }, + }); + }); }); - nock(makeUrl('Post', 'update')) - .put(/.*/) - .reply(200, () => data); + it('works with optimistic update with optional one-to-many relationship', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + // populate the cache with a post, with an optional category relationship + const postData: any = { + id: '1', + title: 'post1', + ownerId: '1', + categoryId: null, + category: null, + }; - act(() => - mutationResult.current.mutate({ - where: { id: 'p1' }, - data: { title: 'post2', owner: { connect: { id: '2' } } }, - }), - ); + const data: any[] = [postData]; - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .query(true) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result: postResult } = renderHook( + () => + useClientQueries(schema).post.useFindMany( + { + include: { + category: true, + }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, ); - expect(cacheData.posts[0]).toMatchObject({ title: 'post2', $optimistic: true, ownerId: '2' }); - }); - }); - - it('works with optimistic nested update updating query', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + expect(postResult.current.data).toHaveLength(1); + }); - const queryArgs = { where: { id: 'p1' } }; - const data = { id: 'p1', title: 'post1' }; + // mock a put request to update the post title + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => { + postData.title = 'postA'; + return { data: postData }; + }); - nock(makeUrl('Post', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - const { result } = renderHook( - () => useClientQueries(schema).post.useFindUnique(queryArgs, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ title: 'post1' }); + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); + + // assert that the post was updated despite the optional (null) category relationship + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'Post', + 'findMany', + { + include: { + category: true, + }, + }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'postA', + ownerId: '1', + categoryId: null, + category: null, + }); + }); }); - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => data); + it('works with optimistic update with nested optional one-to-many relationship', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + // populate the cache with a user and a post, with an optional category + const postData: any = { + id: '1', + title: 'post1', + ownerId: '1', + categoryId: null, + category: null, + }; - act(() => - mutationResult.current.mutate({ - where: { id: '1' }, - data: { posts: { update: { where: { id: 'p1' }, data: { title: 'post2' } } } }, - }), - ); + const userData: any[] = [{ id: '1', name: 'user1', posts: [postData] }]; - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + nock(BASE_URL) + .get('/api/model/user/findMany') + .query(true) + .reply(200, () => { + return { data: userData }; + }) + .persist(); + + const { result: userResult } = renderHook( + () => + useClientQueries(schema).user.useFindMany( + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, ); - expect(cacheData).toMatchObject({ title: 'post2', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - create simple', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + expect(userResult.current.data).toHaveLength(1); + }); - const data: any[] = []; + // mock a put request to update the post title + nock(BASE_URL) + .put('/api/model/post/update') + .reply(200, () => { + postData.title = 'postA'; + return { data: postData }; + }); - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); + + // assert that the post was updated + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'postA', + ownerId: '1', + categoryId: null, + category: null, + }); + }); }); - nock(makeUrl('User', 'upsert')) - .post(/.*/) - .reply(200, () => ({ data: null })); + it('works with optimistic nested create updating query', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpsert({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + const data: any[] = []; - act(() => - mutationResult.current.mutate({ - where: { id: '1' }, - create: { id: '1', email: 'foo' }, - update: { email: 'bar' }, - }), - ); + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + const { result } = renderHook( + () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0]).toMatchObject({ id: '1', email: 'foo', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - create updating nested query', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); - const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - nock(makeUrl('User', 'findUnique')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + act(() => mutationResult.current.mutate({ data: { email: 'user1', posts: { create: { title: 'post1' } } } })); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ id: '1' }); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].title).toBe('post1'); + }); }); - nock(makeUrl('Post', 'upsert')) - .post(/.*/) - .reply(200, () => ({ data: null })); + it('works with optimistic create many', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpsert({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + const data: any[] = []; - act(() => - mutationResult.current.mutate({ - where: { id: 'p2' }, - create: { id: 'p2', title: 'post2', owner: { connect: { id: '1' } } }, - update: { title: 'post3' }, - }), - ); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }), + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, ); - const posts = cacheData.posts; - expect(posts).toHaveLength(2); - expect(posts[0]).toMatchObject({ id: 'p2', title: 'post2', ownerId: '1', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - nested create updating query', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); - const data: any = [{ id: 'p1', title: 'post1' }]; + nock(makeUrl('User', 'createMany')) + .post(/.*/) + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useCreateMany({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + act(() => mutationResult.current.mutate({ data: [{ email: 'foo' }, { email: 'bar' }] })); - const { result } = renderHook( - () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(2); + }); }); - nock(makeUrl('User', 'update')) - .post(/.*/) - .reply(200, () => ({ data: null })); + it('works with optimistic update simple', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; - act(() => - mutationResult.current.mutate({ - where: { id: '1' }, - data: { - posts: { - upsert: { - where: { id: 'p2' }, - create: { id: 'p2', title: 'post2' }, - update: { title: 'post3' }, - }, - }, - }, - }), - ); + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), + { + wrapper, + }, ); - expect(cacheData).toHaveLength(2); - expect(cacheData[0]).toMatchObject({ id: 'p2', title: 'post2', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - update simple', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => data); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); + await waitFor(() => { + const cacheData = queryClient.getQueryData( + getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toMatchObject({ name: 'bar', $optimistic: true }); + }); }); - nock(makeUrl('User', 'upsert')) - .post(/.*/) - .reply(200, () => data); + it('works with optimistic update updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpsert({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + const queryArgs = { where: { id: '1' }, include: { posts: true } }; + const data = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; - act(() => mutationResult.current.mutate({ ...queryArgs, update: { email: 'bar' }, create: { email: 'zee' } })); + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - await waitFor(() => { - const cacheData = queryClient.getQueryData( - getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), + { + wrapper, + }, ); - expect(cacheData).toMatchObject({ email: 'bar', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - update updating nested query', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); - const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => data); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - nock(makeUrl('User', 'findUnique')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + act(() => + mutationResult.current.mutate({ + where: { id: 'p1' }, + data: { title: 'post2', owner: { connect: { id: '2' } } }, + }), + ); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ id: '1' }); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData.posts[0]).toMatchObject({ title: 'post2', $optimistic: true, ownerId: '2' }); + }); }); - nock(makeUrl('Post', 'upsert')) - .post(/.*/) - .reply(200, () => ({ data: null })); + it('works with optimistic nested update updating query', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpsert({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + const queryArgs = { where: { id: 'p1' } }; + const data = { id: 'p1', title: 'post1' }; - act(() => - mutationResult.current.mutate({ - where: { id: 'p1' }, - create: { id: 'p1', title: 'post1' }, - update: { title: 'post2' }, - }), - ); + nock(makeUrl('Post', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }), + const { result } = renderHook( + () => useClientQueries(schema).post.useFindUnique(queryArgs, { optimisticUpdate: true }), + { + wrapper, + }, ); - const posts = cacheData.posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - nested update updating query', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + expect(result.current.data).toMatchObject({ title: 'post1' }); + }); - const data: any = [{ id: 'p1', title: 'post1' }]; + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => data); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { posts: { update: { where: { id: 'p1' }, data: { title: 'post2' } } } }, + }), + ); - const { result } = renderHook( - () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toMatchObject({ title: 'post2', $optimistic: true }); + }); }); - nock(makeUrl('User', 'update')) - .post(/.*/) - .reply(200, () => ({ data: null })); + it('works with optimistic upsert - create simple', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + const data: any[] = []; - act(() => - mutationResult.current.mutate({ - where: { id: '1' }, - data: { - posts: { - upsert: { - where: { id: 'p1' }, - create: { id: 'p1', title: 'post1' }, - update: { title: 'post2' }, - }, - }, + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, }, - }), - ); + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + nock(makeUrl('User', 'upsert')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpsert({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); - }); - }); - it('works with delete and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + create: { id: '1', email: 'foo' }, + update: { email: 'bar' }, + }), + ); - const data: any[] = [{ id: '1', name: 'foo' }]; + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0]).toMatchObject({ id: '1', email: 'foo', $optimistic: true }); + }); + }); - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + it('works with optimistic upsert - create updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); + const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - nock(makeUrl('User', 'delete')) - .delete(/.*/) - .reply(200, () => { - data.splice(0, 1); - return { data: [] }; + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); }); - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useDelete(), { - wrapper, - }); + nock(makeUrl('Post', 'upsert')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpsert({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - act(() => mutationResult.current.mutate({ where: { id: '1' } })); + act(() => + mutationResult.current.mutate({ + where: { id: 'p2' }, + create: { id: 'p2', title: 'post2', owner: { connect: { id: '1' } } }, + update: { title: 'post3' }, + }), + ); - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(0); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }), + ); + const posts = cacheData.posts; + expect(posts).toHaveLength(2); + expect(posts[0]).toMatchObject({ id: 'p2', title: 'post2', ownerId: '1', $optimistic: true }); + }); }); - }); - it('works with optimistic delete simple', async () => { - const { queryClient, wrapper } = createWrapper(); + it('works with optimistic upsert - nested create updating query', async () => { + const { queryClient, wrapper } = createWrapper(); - const data: any[] = [{ id: '1', name: 'foo' }]; + const data: any = [{ id: 'p1', title: 'post1' }]; - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); + const { result } = renderHook( + () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); - nock(makeUrl('User', 'delete')) - .delete(/.*/) - .reply(200, () => ({ data })); + nock(makeUrl('User', 'update')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useDelete({ - optimisticUpdate: true, - invalidateQueries: false, + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { + posts: { + upsert: { + where: { id: 'p2' }, + create: { id: 'p2', title: 'post2' }, + update: { title: 'post3' }, + }, + }, + }, }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ where: { id: '1' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), ); - expect(cacheData).toHaveLength(0); - }); - }); - it('works with optimistic delete nested query', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(2); + expect(cacheData[0]).toMatchObject({ id: 'p2', title: 'post2', $optimistic: true }); + }); + }); - const data: any = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; + it('works with optimistic upsert - update simple', async () => { + const { queryClient, wrapper } = createWrapper(); - nock(makeUrl('User', 'findFirst')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; - const { result } = renderHook( - () => - useClientQueries(schema).user.useFindFirst( - { - include: { posts: true }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ id: '1' }); - }); + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - nock(makeUrl('Post', 'delete')) - .delete(/.*/) - .reply(200, () => ({ data })); + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useDelete({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + nock(makeUrl('User', 'upsert')) + .post(/.*/) + .reply(200, () => data); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpsert({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - act(() => mutationResult.current.mutate({ where: { id: 'p1' } })); + act(() => mutationResult.current.mutate({ ...queryArgs, update: { email: 'bar' }, create: { email: 'zee' } })); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'User', - 'findFirst', - { include: { posts: true } }, - { infinite: false, optimisticUpdate: true }, - ), - ); - expect(cacheData.posts).toHaveLength(0); + await waitFor(() => { + const cacheData = queryClient.getQueryData( + getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toMatchObject({ email: 'bar', $optimistic: true }); + }); }); - }); - it('works with optimistic nested delete update query', async () => { - const { queryClient, wrapper } = createWrapper(); + it('works with optimistic upsert - update updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); - const data: any = [ - { id: 'p1', title: 'post1' }, - { id: 'p2', title: 'post2' }, - ]; + const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + nock(makeUrl('User', 'findUnique')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - const { result } = renderHook( - () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(2); - }); + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); + }); - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => ({ data })); + nock(makeUrl('Post', 'upsert')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpsert({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, + act(() => + mutationResult.current.mutate({ + where: { id: 'p1' }, + create: { id: 'p1', title: 'post1' }, + update: { title: 'post2' }, }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { delete: { id: 'p1' } } } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), ); - expect(cacheData).toHaveLength(1); - }); - }); - it('top-level mutation and nested-read invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }), + ); + const posts = cacheData.posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); + }); + }); - const queryArgs = { where: { id: '1' }, include: { posts: true } }; - const data = { posts: [{ id: '1', title: 'post1' }] }; + it('works with optimistic upsert - nested update updating query', async () => { + const { queryClient, wrapper } = createWrapper(); - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + const data: any = [{ id: 'p1', title: 'post1' }]; - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject(data); - }); + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - nock(makeUrl('Post', 'update')) - .put(/.*/) - .reply(200, () => { - data.posts[0]!.title = 'post2'; - return data; + const { result } = renderHook( + () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); }); - const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useUpdate(), { - wrapper, - }); + nock(makeUrl('User', 'update')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'post2' } })); + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { + posts: { + upsert: { + where: { id: 'p1' }, + create: { id: 'p1', title: 'post1' }, + update: { title: 'post2' }, + }, + }, + }, + }), + ); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData.posts[0].title).toBe('post2'); + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); + }); }); - }); - - it('nested mutation and top-level-read invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - const data = [{ id: '1', title: 'post1', ownerId: '1' }]; + it('works with optimistic delete simple', async () => { + const { queryClient, wrapper } = createWrapper(); - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); + const data: any[] = [{ id: '1', name: 'foo' }]; - const { result } = renderHook(() => useClientQueries(schema).post.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject(data); - }); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.push({ id: '2', title: 'post2', ownerId: '1' }); - return data; + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); }); - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => ({ data })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useDelete({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); - act(() => - mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { create: { title: 'post2' } } } }), - ); + act(() => mutationResult.current.mutate({ where: { id: '1' } })); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); - expect(cacheData).toHaveLength(2); + await waitFor(() => { + const cacheData = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(0); + }); }); - }); - it('optimistic create with custom provider', async () => { - const { queryClient, wrapper } = createWrapper(); + it('works with optimistic delete nested query', async () => { + const { queryClient, wrapper } = createWrapper(); - const data: any[] = []; + const data: any = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + nock(makeUrl('User', 'findFirst')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); + const { result } = renderHook( + () => + useClientQueries(schema).user.useFindFirst( + { + include: { posts: true }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); + }); + + nock(makeUrl('Post', 'delete')) + .delete(/.*/) + .reply(200, () => ({ data })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useDelete({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ where: { id: 'p1' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findFirst', + { include: { posts: true } }, + { infinite: false, optimisticUpdate: true }, + ), + ); + expect(cacheData.posts).toHaveLength(0); + }); }); - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => ({ data: null })) - .persist(); - - const { result: mutationResult1 } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - optimisticDataProvider: ({ queryModel, queryOperation }) => { - if (queryModel === 'User' && queryOperation === 'findMany') { - return { kind: 'Skip' }; - } else { - return { kind: 'ProceedDefault' }; - } - }, - }), - { - wrapper, - }, - ); + it('works with optimistic nested delete update query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = [ + { id: 'p1', title: 'post1' }, + { id: 'p2', title: 'post2' }, + ]; - act(() => mutationResult1.current.mutate({ data: { email: 'foo' } })); + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - // cache should not update - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + const { result } = renderHook( + () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, ); - expect(cacheData).toHaveLength(0); + await waitFor(() => { + expect(result.current.data).toHaveLength(2); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => ({ data })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { delete: { id: 'p1' } } } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + }); }); - const { result: mutationResult2 } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - optimisticDataProvider: ({ queryModel, queryOperation, currentData, mutationArgs }) => { - if (queryModel === 'User' && queryOperation === 'findMany') { - return { - kind: 'Update', - data: [ - ...currentData, - { id: 100, email: mutationArgs.data.email + 'hooray', $optimistic: true }, - ], - }; - } else { - return { kind: 'ProceedDefault' }; - } - }, - }), - { - wrapper, - }, - ); + it('optimistic create with custom provider', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; - act(() => mutationResult2.current.mutate({ data: { email: 'foo' } })); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - // cache should update - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].email).toBe('foohooray'); - }); - }); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); - it('optimistic update mixed with non-zenstack queries', async () => { - const { queryClient, wrapper } = createWrapper(); + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => ({ data: null })) + .persist(); + + const { result: mutationResult1 } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + optimisticDataProvider: ({ queryModel, queryOperation }) => { + if (queryModel === 'User' && queryOperation === 'findMany') { + return { kind: 'Skip' }; + } else { + return { kind: 'ProceedDefault' }; + } + }, + }), + { + wrapper, + }, + ); - // non-zenstack query - const { result: myQueryResult } = renderHook( - () => useQuery({ queryKey: ['myQuery'], queryFn: () => ({ data: 'myData' }) }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(myQueryResult.current.data).toEqual({ data: 'myData' }); - }); + act(() => mutationResult1.current.mutate({ data: { email: 'foo' } })); - const data: any[] = []; + // cache should not update + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(0); + }); - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); + const { result: mutationResult2 } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + optimisticDataProvider: ({ queryModel, queryOperation, currentData, mutationArgs }) => { + if (queryModel === 'User' && queryOperation === 'findMany') { + return { + kind: 'Update', + data: [ + ...currentData, + { id: 100, email: mutationArgs.data.email + 'hooray', $optimistic: true }, + ], + }; + } else { + return { kind: 'ProceedDefault' }; + } + }, + }), + { + wrapper, + }, + ); - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); + act(() => mutationResult2.current.mutate({ data: { email: 'foo' } })); + + // cache should update + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].email).toBe('foohooray'); + }); }); - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => ({ data: null })); + it('optimistic update mixed with non-zenstack queries', async () => { + const { queryClient, wrapper } = createWrapper(); - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); + // non-zenstack query + const { result: myQueryResult } = renderHook( + () => useQuery({ queryKey: ['myQuery'], queryFn: () => ({ data: 'myData' }) }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(myQueryResult.current.data).toEqual({ data: 'myData' }); + }); - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].email).toBe('foo'); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].email).toBe('foo'); + }); }); }); - it('works with sequential transaction and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); + describe('Sequential transaction', () => { + it('works with sequential transaction and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - const users: any[] = []; - const posts: any[] = []; + const users: any[] = []; + const posts: any[] = []; - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data: users })) - .persist(); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: users })) + .persist(); - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data: posts })) - .persist(); + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: posts })) + .persist(); - const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); - const { result: postResult } = renderHook(() => useClientQueries(schema).post.useFindMany(), { wrapper }); + const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); + const { result: postResult } = renderHook(() => useClientQueries(schema).post.useFindMany(), { wrapper }); - await waitFor(() => { - expect(userResult.current.data).toHaveLength(0); - expect(postResult.current.data).toHaveLength(0); - }); - - nock(`${BASE_URL}/api/model/$transaction/sequential`) - .post(/.*/) - .reply(200, () => { - users.push({ id: '1', email: 'foo@bar.com' }); - posts.push({ id: 'p1', title: 'Hello' }); - return { data: [users[0], posts[0]] }; + await waitFor(() => { + expect(userResult.current.data).toHaveLength(0); + expect(postResult.current.data).toHaveLength(0); }); - const { result: txResult } = renderHook(() => useClientQueries(schema).$transaction.useSequential(), { - wrapper, - }); + nock(`${BASE_URL}/api/model/$transaction/sequential`) + .post(/.*/) + .reply(200, () => { + users.push({ id: '1', email: 'foo@bar.com' }); + posts.push({ id: 'p1', title: 'Hello' }); + return { data: [users[0], posts[0]] }; + }); - act(() => - txResult.current.mutate([ - { model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }, - { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, - ]), - ); + const { result: txResult } = renderHook(() => useClientQueries(schema).$transaction.useSequential(), { + wrapper, + }); - await waitFor(() => { - const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - const cachedPosts = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); - expect(cachedUsers).toHaveLength(1); - expect(cachedPosts).toHaveLength(1); - }); - }); + act(() => + txResult.current.mutate([ + { model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }, + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + ]), + ); - it('works with sequential transaction and no invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); + await waitFor(() => { + const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + const cachedPosts = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); + expect(cachedUsers).toHaveLength(1); + expect(cachedPosts).toHaveLength(1); + }); + }); - const users: any[] = []; + it('works with sequential transaction and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data: users })) - .persist(); + const users: any[] = []; - const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: users })) + .persist(); - await waitFor(() => { - expect(userResult.current.data).toHaveLength(0); - }); + const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); - nock(`${BASE_URL}/api/model/$transaction/sequential`) - .post(/.*/) - .reply(200, () => { - users.push({ id: '1', email: 'foo@bar.com' }); - return { data: [users[0]] }; + await waitFor(() => { + expect(userResult.current.data).toHaveLength(0); }); - const { result: txResult } = renderHook( - () => useClientQueries(schema).$transaction.useSequential({ invalidateQueries: false }), - { wrapper }, - ); + nock(`${BASE_URL}/api/model/$transaction/sequential`) + .post(/.*/) + .reply(200, () => { + users.push({ id: '1', email: 'foo@bar.com' }); + return { data: [users[0]] }; + }); + + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential({ invalidateQueries: false }), + { wrapper }, + ); - act(() => txResult.current.mutate([{ model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }])); + act(() => + txResult.current.mutate([{ model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }]), + ); - await waitFor(() => { - expect(txResult.current.isSuccess).toBe(true); - // cache not refreshed because invalidation was disabled - const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cachedUsers).toHaveLength(0); + await waitFor(() => { + expect(txResult.current.isSuccess).toBe(true); + // cache not refreshed because invalidation was disabled + const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cachedUsers).toHaveLength(0); + }); }); }); From 6b07448ed6d9f4dd318d906fb668ec5bf6c51765 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 3 May 2026 17:29:47 -0700 Subject: [PATCH 6/7] fix test --- .../test/nested-write-visitor.test.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/clients/client-helpers/test/nested-write-visitor.test.ts b/packages/clients/client-helpers/test/nested-write-visitor.test.ts index 2e09e441a..3d2889fc9 100644 --- a/packages/clients/client-helpers/test/nested-write-visitor.test.ts +++ b/packages/clients/client-helpers/test/nested-write-visitor.test.ts @@ -1097,25 +1097,6 @@ describe('NestedWriteVisitor tests', () => { }), ).resolves.not.toThrow(); }); - - it('throws error for unhandled action type', async () => { - const schema = createSchema({ - User: { - name: 'User', - fields: { - id: createField('id', 'String'), - }, - uniqueFields: {}, - idFields: ['id'], - }, - }); - - const visitor = new NestedWriteVisitor(schema, {}); - - await expect(visitor.visit('User', 'invalidAction' as any, { data: {} })).rejects.toThrow( - 'unhandled action type', - ); - }); }); describe('complex real-world scenarios', () => { From b9657193f104cb1d1a8d52462fd6073b83a542f1 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 3 May 2026 17:53:56 -0700 Subject: [PATCH 7/7] refactor(tanstack-query): tighten TransactionOperation typing and split react tests - Make `args` required on TransactionOperation only for ops whose args type has required fields; keep it optional for read ops with all-optional args - Skip read-only ops in transaction onSuccess so they don't reach createInvalidator - Split the 2k-line react-query test file into per-feature files under test/react/, adding shared helpers - Add typing tests for args optionality and delegate-model rejection in the sequential transaction suite Co-Authored-By: Claude Sonnet 4.6 --- .../tanstack-query/src/common/transaction.ts | 10 +- .../tanstack-query/src/common/types.ts | 8 +- .../tanstack-query/test/react-query.test.tsx | 2021 ----------------- .../test/react/crud-and-invalidation.test.tsx | 448 ++++ .../tanstack-query/test/react/helpers.tsx | 35 + .../react/json-null-serialization.test.tsx | 137 ++ .../test/react/optimistic-mutation.test.tsx | 1324 +++++++++++ .../{ => react}/react-sliced-client.test-d.ts | 6 +- .../test/{ => react}/react-typing.test-d.ts | 6 +- .../react/sequential-transaction.test.tsx | 174 ++ .../svelte-sliced-client.test-d.ts | 6 +- .../test/{ => svelte}/svelte-typing-test.ts | 6 +- .../{ => vue}/vue-sliced-client.test-d.ts | 6 +- .../test/{ => vue}/vue-typing-test.ts | 6 +- 14 files changed, 2145 insertions(+), 2048 deletions(-) delete mode 100644 packages/clients/tanstack-query/test/react-query.test.tsx create mode 100644 packages/clients/tanstack-query/test/react/crud-and-invalidation.test.tsx create mode 100644 packages/clients/tanstack-query/test/react/helpers.tsx create mode 100644 packages/clients/tanstack-query/test/react/json-null-serialization.test.tsx create mode 100644 packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx rename packages/clients/tanstack-query/test/{ => react}/react-sliced-client.test-d.ts (94%) rename packages/clients/tanstack-query/test/{ => react}/react-typing.test-d.ts (97%) create mode 100644 packages/clients/tanstack-query/test/react/sequential-transaction.test.tsx rename packages/clients/tanstack-query/test/{ => svelte}/svelte-sliced-client.test-d.ts (94%) rename packages/clients/tanstack-query/test/{ => svelte}/svelte-typing-test.ts (96%) rename packages/clients/tanstack-query/test/{ => vue}/vue-sliced-client.test-d.ts (94%) rename packages/clients/tanstack-query/test/{ => vue}/vue-typing-test.ts (96%) diff --git a/packages/clients/tanstack-query/src/common/transaction.ts b/packages/clients/tanstack-query/src/common/transaction.ts index f4cff0fa2..afb6dd5c4 100644 --- a/packages/clients/tanstack-query/src/common/transaction.ts +++ b/packages/clients/tanstack-query/src/common/transaction.ts @@ -2,6 +2,7 @@ import type { Logger } from '@zenstackhq/client-helpers'; import { createInvalidator, type InvalidateFunc } from '@zenstackhq/client-helpers'; import type { FetchFn } from '@zenstackhq/client-helpers/fetch'; import { fetcher, marshal } from '@zenstackhq/client-helpers/fetch'; +import { CoreReadOperations } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/schema'; import { TRANSACTION_ROUTE_PREFIX } from './constants.js'; import type { TransactionOperation } from './types.js'; @@ -9,10 +10,7 @@ import type { TransactionOperation } from './types.js'; /** * Builds the mutation function for a sequential transaction request. */ -export function makeTransactionMutationFn( - endpoint: string, - fetch: FetchFn | undefined, -) { +export function makeTransactionMutationFn(endpoint: string, fetch: FetchFn | undefined) { return (operations: TransactionOperation[]) => { const reqUrl = `${endpoint}/${TRANSACTION_ROUTE_PREFIX}/sequential`; const fetchInit = { @@ -45,6 +43,10 @@ export function makeTransactionOnSuccess( if (typeof op?.model !== 'string' || typeof op?.op !== 'string') { continue; } + // read-only ops don't mutate state, so they don't trigger invalidation + if (CoreReadOperations.includes(op.op)) { + continue; + } const invalidator = createInvalidator(op.model, op.op, schema, invalidateFunc, logging); // pass op.args as mutation variables so the invalidator can analyze nested writes await invalidator(args[0], op.args, args[2]); diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts index 016adcd94..564e48934 100644 --- a/packages/clients/tanstack-query/src/common/types.ts +++ b/packages/clients/tanstack-query/src/common/types.ts @@ -156,10 +156,8 @@ type AllowedTransactionOps = { [Model in GetModels]: { - [Op in AllowedTransactionOps]: { - model: Model; - op: Op; - args?: CrudArgsMap[Op]; - }; + [Op in AllowedTransactionOps]: {} extends CrudArgsMap[Op] + ? { model: Model; op: Op; args?: CrudArgsMap[Op] } + : { model: Model; op: Op; args: CrudArgsMap[Op] }; }[AllowedTransactionOps]; }[GetModels]; diff --git a/packages/clients/tanstack-query/test/react-query.test.tsx b/packages/clients/tanstack-query/test/react-query.test.tsx deleted file mode 100644 index 6afae03d9..000000000 --- a/packages/clients/tanstack-query/test/react-query.test.tsx +++ /dev/null @@ -1,2021 +0,0 @@ -/** - * @vitest-environment happy-dom - */ - -import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; -import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; -import { deserialize, serialize } from '@zenstackhq/client-helpers/fetch'; -import nock from 'nock'; -import React from 'react'; -import { afterEach, describe, expect, it } from 'vitest'; -import { getQueryKey } from '../src/common/query-key'; -import { AnyNull, DbNull, JsonNull, QuerySettingsProvider, useClientQueries } from '../src/react'; -import { schema } from './schemas/basic/schema-lite'; - -const BASE_URL = 'http://localhost'; - -describe('React Query Test', () => { - function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - const Provider = QuerySettingsProvider; - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - return { queryClient, wrapper }; - } - - function makeUrl(model: string, operation: string, args?: unknown) { - let r = `${BASE_URL}/api/model/${model}/${operation}`; - if (args) { - r += `?q=${encodeURIComponent(JSON.stringify(args))}`; - } - return r; - } - - afterEach(() => { - nock.cleanAll(); - cleanup(); - }); - - describe('CRUD and invalidation', () => { - it('works with simple query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, { - data, - }); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - expect(result.current.data).toMatchObject(data); - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject(data); - }); - - nock(makeUrl('User', 'findFirst', queryArgs)) - .get(/.*/) - .reply(404, () => { - return { error: 'Not Found' }; - }); - const { result: errorResult } = renderHook(() => useClientQueries(schema).user.useFindFirst(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(errorResult.current.isError).toBe(true); - }); - }); - - it('works with suspense query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, { - data, - }); - - const { result } = renderHook(() => useClientQueries(schema).user.useSuspenseFindUnique(queryArgs), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - expect(result.current.data).toMatchObject(data); - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject(data); - }); - }); - - it('works with infinite query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = [{ id: '1', name: 'foo' }]; - - nock(makeUrl('User', 'findMany', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })); - - const { result } = renderHook( - () => - useClientQueries(schema).user.useInfiniteFindMany(queryArgs, { - getNextPageParam: () => null, - }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - const resultData = result.current.data!; - expect(resultData.pages).toHaveLength(1); - expect(resultData.pages[0]).toMatchObject(data); - expect(resultData?.pageParams).toHaveLength(1); - expect(resultData?.pageParams[0]).toMatchObject(queryArgs); - expect(result.current.hasNextPage).toBe(false); - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), - ); - expect(cacheData.pages[0]).toMatchObject(data); - }); - }); - - it('works with suspense infinite query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = [{ id: '1', name: 'foo' }]; - - nock(makeUrl('User', 'findMany', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })); - - const { result } = renderHook( - () => - useClientQueries(schema).user.useSuspenseInfiniteFindMany(queryArgs, { - getNextPageParam: () => null, - }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - const resultData = result.current.data!; - expect(resultData.pages).toHaveLength(1); - expect(resultData.pages[0]).toMatchObject(data); - expect(resultData?.pageParams).toHaveLength(1); - expect(resultData?.pageParams[0]).toMatchObject(queryArgs); - expect(result.current.hasNextPage).toBe(false); - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), - ); - expect(cacheData.pages[0]).toMatchObject(data); - }); - }); - - it('works with independent mutation and query', async () => { - const { wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - let queryCount = 0; - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => { - queryCount++; - return { data }; - }) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('Post', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: { id: '1', title: 'post1' }, - })); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useCreate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ data: { title: 'post1' } })); - - await waitFor(() => { - // no refetch caused by invalidation - expect(queryCount).toBe(1); - }); - }); - - it('works with create and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => { - data.push({ id: '1', email: 'foo' }); - return { data: data[0] }; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(1); - }); - }); - - it('works with create and no invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => { - data.push({ id: '1', email: 'foo' }); - return { data: data[0] }; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(0); - }); - }); - - it('works with update and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.name = 'bar'; - return data; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject({ name: 'bar' }); - }); - }); - - it('works with update and no invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.name = 'bar'; - return data; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject({ name: 'foo' }); - }); - }); - - it('works with delete and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = [{ id: '1', name: 'foo' }]; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); - - nock(makeUrl('User', 'delete')) - .delete(/.*/) - .reply(200, () => { - data.splice(0, 1); - return { data: [] }; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useDelete(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ where: { id: '1' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(0); - }); - }); - - it('top-level mutation and nested-read invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' }, include: { posts: true } }; - const data = { posts: [{ id: '1', title: 'post1' }] }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject(data); - }); - - nock(makeUrl('Post', 'update')) - .put(/.*/) - .reply(200, () => { - data.posts[0]!.title = 'post2'; - return data; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useUpdate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'post2' } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData.posts[0].title).toBe('post2'); - }); - }); - - it('nested mutation and top-level-read invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data = [{ id: '1', title: 'post1', ownerId: '1' }]; - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).post.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject(data); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.push({ id: '2', title: 'post2', ownerId: '1' }); - return data; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); - - act(() => - mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { create: { title: 'post2' } } } }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); - expect(cacheData).toHaveLength(2); - }); - }); - }); - - describe('Optimistic mutation', () => { - it('works with optimistic create single', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].email).toBe('foo'); - }); - }); - - it('works with optimistic create updating nested query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = [{ id: '1', name: 'user1', posts: [] }]; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => - useClientQueries(schema).user.useFindMany( - { - include: { posts: true }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); - - nock(makeUrl('Post', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ data: { title: 'post1', owner: { connect: { id: '1' } } } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'User', - 'findMany', - { include: { posts: true } }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData[0].posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ $optimistic: true, id: expect.any(String), title: 'post1', ownerId: '1' }); - }); - }); - - it('works with optimistic create updating deeply nested query', async () => { - const { queryClient, wrapper } = createWrapper(); - - // populate the cache with a user - - const userData: any[] = [{ id: '1', email: 'user1', posts: [] }]; - - nock(BASE_URL) - .get('/api/model/user/findMany') - .query(true) - .reply(200, () => ({ data: userData })) - .persist(); - - const { result: userResult } = renderHook( - () => - useClientQueries(schema).user.useFindMany( - { - include: { - posts: { - include: { - category: true, - }, - }, - }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(userResult.current.data).toHaveLength(1); - }); - - // populate the cache with a category - const categoryData: any[] = [{ id: '1', name: 'category1', posts: [] }]; - - nock(BASE_URL) - .get('/api/model/category/findMany') - .query(true) - .reply(200, () => ({ data: categoryData })) - .persist(); - - const { result: categoryResult } = renderHook( - () => - useClientQueries(schema).category.useFindMany( - { - include: { - posts: true, - }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(categoryResult.current.data).toHaveLength(1); - }); - - // create a post and connect it to the category - nock(BASE_URL) - .post('/api/model/post/create') - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => - mutationResult.current.mutate({ - data: { title: 'post1', owner: { connect: { id: '1' } }, category: { connect: { id: '1' } } }, - }), - ); - - // assert that the post was created and connected to the category - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'Category', - 'findMany', - { - include: { - posts: true, - }, - }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData[0].posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ - $optimistic: true, - id: expect.any(String), - title: 'post1', - ownerId: '1', - }); - }); - - // assert that the post was created and connected to the user, and included the category - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'User', - 'findMany', - { - include: { - posts: { - include: { - category: true, - }, - }, - }, - }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData[0].posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ - $optimistic: true, - id: expect.any(String), - title: 'post1', - ownerId: '1', - categoryId: '1', - // TODO: should this include the category object and not just the foreign key? - // category: { $optimistic: true, id: '1', name: 'category1' }, - }); - }); - }); - - it('works with optimistic update with optional one-to-many relationship', async () => { - const { queryClient, wrapper } = createWrapper(); - - // populate the cache with a post, with an optional category relationship - const postData: any = { - id: '1', - title: 'post1', - ownerId: '1', - categoryId: null, - category: null, - }; - - const data: any[] = [postData]; - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .query(true) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result: postResult } = renderHook( - () => - useClientQueries(schema).post.useFindMany( - { - include: { - category: true, - }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(postResult.current.data).toHaveLength(1); - }); - - // mock a put request to update the post title - nock(makeUrl('Post', 'update')) - .put(/.*/) - .reply(200, () => { - postData.title = 'postA'; - return { data: postData }; - }); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); - - // assert that the post was updated despite the optional (null) category relationship - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'Post', - 'findMany', - { - include: { - category: true, - }, - }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ - $optimistic: true, - id: expect.any(String), - title: 'postA', - ownerId: '1', - categoryId: null, - category: null, - }); - }); - }); - - it('works with optimistic update with nested optional one-to-many relationship', async () => { - const { queryClient, wrapper } = createWrapper(); - - // populate the cache with a user and a post, with an optional category - const postData: any = { - id: '1', - title: 'post1', - ownerId: '1', - categoryId: null, - category: null, - }; - - const userData: any[] = [{ id: '1', name: 'user1', posts: [postData] }]; - - nock(BASE_URL) - .get('/api/model/user/findMany') - .query(true) - .reply(200, () => { - return { data: userData }; - }) - .persist(); - - const { result: userResult } = renderHook( - () => - useClientQueries(schema).user.useFindMany( - { - include: { - posts: { - include: { - category: true, - }, - }, - }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(userResult.current.data).toHaveLength(1); - }); - - // mock a put request to update the post title - nock(BASE_URL) - .put('/api/model/post/update') - .reply(200, () => { - postData.title = 'postA'; - return { data: postData }; - }); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); - - // assert that the post was updated - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'User', - 'findMany', - { - include: { - posts: { - include: { - category: true, - }, - }, - }, - }, - { infinite: false, optimisticUpdate: true }, - ), - ); - const posts = cacheData[0].posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ - $optimistic: true, - id: expect.any(String), - title: 'postA', - ownerId: '1', - categoryId: null, - category: null, - }); - }); - }); - - it('works with optimistic nested create updating query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ data: { email: 'user1', posts: { create: { title: 'post1' } } } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].title).toBe('post1'); - }); - }); - - it('works with optimistic create many', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'createMany')) - .post(/.*/) - .reply(200, () => ({ - data: null, - })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useCreateMany({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ data: [{ email: 'foo' }, { email: 'bar' }] })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(2); - }); - }); - - it('works with optimistic update simple', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => data); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData( - getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toMatchObject({ name: 'bar', $optimistic: true }); - }); - }); - - it('works with optimistic update updating nested query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' }, include: { posts: true } }; - const data = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('Post', 'update')) - .put(/.*/) - .reply(200, () => data); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => - mutationResult.current.mutate({ - where: { id: 'p1' }, - data: { title: 'post2', owner: { connect: { id: '2' } } }, - }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData.posts[0]).toMatchObject({ title: 'post2', $optimistic: true, ownerId: '2' }); - }); - }); - - it('works with optimistic nested update updating query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: 'p1' } }; - const data = { id: 'p1', title: 'post1' }; - - nock(makeUrl('Post', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).post.useFindUnique(queryArgs, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ title: 'post1' }); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => data); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => - mutationResult.current.mutate({ - where: { id: '1' }, - data: { posts: { update: { where: { id: 'p1' }, data: { title: 'post2' } } } }, - }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toMatchObject({ title: 'post2', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - create simple', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'upsert')) - .post(/.*/) - .reply(200, () => ({ data: null })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpsert({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => - mutationResult.current.mutate({ - where: { id: '1' }, - create: { id: '1', email: 'foo' }, - update: { email: 'bar' }, - }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0]).toMatchObject({ id: '1', email: 'foo', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - create updating nested query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; - - nock(makeUrl('User', 'findUnique')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ id: '1' }); - }); - - nock(makeUrl('Post', 'upsert')) - .post(/.*/) - .reply(200, () => ({ data: null })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpsert({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => - mutationResult.current.mutate({ - where: { id: 'p2' }, - create: { id: 'p2', title: 'post2', owner: { connect: { id: '1' } } }, - update: { title: 'post3' }, - }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }), - ); - const posts = cacheData.posts; - expect(posts).toHaveLength(2); - expect(posts[0]).toMatchObject({ id: 'p2', title: 'post2', ownerId: '1', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - nested create updating query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any = [{ id: 'p1', title: 'post1' }]; - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); - - nock(makeUrl('User', 'update')) - .post(/.*/) - .reply(200, () => ({ data: null })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => - mutationResult.current.mutate({ - where: { id: '1' }, - data: { - posts: { - upsert: { - where: { id: 'p2' }, - create: { id: 'p2', title: 'post2' }, - update: { title: 'post3' }, - }, - }, - }, - }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(2); - expect(cacheData[0]).toMatchObject({ id: 'p2', title: 'post2', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - update simple', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('User', 'upsert')) - .post(/.*/) - .reply(200, () => data); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpsert({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ ...queryArgs, update: { email: 'bar' }, create: { email: 'zee' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData( - getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toMatchObject({ email: 'bar', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - update updating nested query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; - - nock(makeUrl('User', 'findUnique')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ id: '1' }); - }); - - nock(makeUrl('Post', 'upsert')) - .post(/.*/) - .reply(200, () => ({ data: null })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useUpsert({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => - mutationResult.current.mutate({ - where: { id: 'p1' }, - create: { id: 'p1', title: 'post1' }, - update: { title: 'post2' }, - }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }), - ); - const posts = cacheData.posts; - expect(posts).toHaveLength(1); - expect(posts[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); - }); - }); - - it('works with optimistic upsert - nested update updating query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any = [{ id: 'p1', title: 'post1' }]; - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); - - nock(makeUrl('User', 'update')) - .post(/.*/) - .reply(200, () => ({ data: null })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => - mutationResult.current.mutate({ - where: { id: '1' }, - data: { - posts: { - upsert: { - where: { id: 'p1' }, - create: { id: 'p1', title: 'post1' }, - update: { title: 'post2' }, - }, - }, - }, - }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); - }); - }); - - it('works with optimistic delete simple', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = [{ id: '1', name: 'foo' }]; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); - - nock(makeUrl('User', 'delete')) - .delete(/.*/) - .reply(200, () => ({ data })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useDelete({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ where: { id: '1' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(0); - }); - }); - - it('works with optimistic delete nested query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; - - nock(makeUrl('User', 'findFirst')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => - useClientQueries(schema).user.useFindFirst( - { - include: { posts: true }, - }, - { optimisticUpdate: true }, - ), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toMatchObject({ id: '1' }); - }); - - nock(makeUrl('Post', 'delete')) - .delete(/.*/) - .reply(200, () => ({ data })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).post.useDelete({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ where: { id: 'p1' } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey( - 'User', - 'findFirst', - { include: { posts: true } }, - { infinite: false, optimisticUpdate: true }, - ), - ); - expect(cacheData.posts).toHaveLength(0); - }); - }); - - it('works with optimistic nested delete update query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any = [ - { id: 'p1', title: 'post1' }, - { id: 'p2', title: 'post2' }, - ]; - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(2); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => ({ data })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useUpdate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { delete: { id: 'p1' } } } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - }); - }); - - it('optimistic create with custom provider', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => ({ data: null })) - .persist(); - - const { result: mutationResult1 } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - optimisticDataProvider: ({ queryModel, queryOperation }) => { - if (queryModel === 'User' && queryOperation === 'findMany') { - return { kind: 'Skip' }; - } else { - return { kind: 'ProceedDefault' }; - } - }, - }), - { - wrapper, - }, - ); - - act(() => mutationResult1.current.mutate({ data: { email: 'foo' } })); - - // cache should not update - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(0); - }); - - const { result: mutationResult2 } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - optimisticDataProvider: ({ queryModel, queryOperation, currentData, mutationArgs }) => { - if (queryModel === 'User' && queryOperation === 'findMany') { - return { - kind: 'Update', - data: [ - ...currentData, - { id: 100, email: mutationArgs.data.email + 'hooray', $optimistic: true }, - ], - }; - } else { - return { kind: 'ProceedDefault' }; - } - }, - }), - { - wrapper, - }, - ); - - act(() => mutationResult2.current.mutate({ data: { email: 'foo' } })); - - // cache should update - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].email).toBe('foohooray'); - }); - }); - - it('optimistic update mixed with non-zenstack queries', async () => { - const { queryClient, wrapper } = createWrapper(); - - // non-zenstack query - const { result: myQueryResult } = renderHook( - () => useQuery({ queryKey: ['myQuery'], queryFn: () => ({ data: 'myData' }) }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(myQueryResult.current.data).toEqual({ data: 'myData' }); - }); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => ({ data: null })); - - const { result: mutationResult } = renderHook( - () => - useClientQueries(schema).user.useCreate({ - optimisticUpdate: true, - invalidateQueries: false, - }), - { - wrapper, - }, - ); - - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), - ); - expect(cacheData).toHaveLength(1); - expect(cacheData[0].$optimistic).toBe(true); - expect(cacheData[0].id).toBeTruthy(); - expect(cacheData[0].email).toBe('foo'); - }); - }); - }); - - describe('Sequential transaction', () => { - it('works with sequential transaction and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const users: any[] = []; - const posts: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data: users })) - .persist(); - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data: posts })) - .persist(); - - const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); - const { result: postResult } = renderHook(() => useClientQueries(schema).post.useFindMany(), { wrapper }); - - await waitFor(() => { - expect(userResult.current.data).toHaveLength(0); - expect(postResult.current.data).toHaveLength(0); - }); - - nock(`${BASE_URL}/api/model/$transaction/sequential`) - .post(/.*/) - .reply(200, () => { - users.push({ id: '1', email: 'foo@bar.com' }); - posts.push({ id: 'p1', title: 'Hello' }); - return { data: [users[0], posts[0]] }; - }); - - const { result: txResult } = renderHook(() => useClientQueries(schema).$transaction.useSequential(), { - wrapper, - }); - - act(() => - txResult.current.mutate([ - { model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }, - { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, - ]), - ); - - await waitFor(() => { - const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - const cachedPosts = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); - expect(cachedUsers).toHaveLength(1); - expect(cachedPosts).toHaveLength(1); - }); - }); - - it('works with sequential transaction and no invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const users: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data: users })) - .persist(); - - const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); - - await waitFor(() => { - expect(userResult.current.data).toHaveLength(0); - }); - - nock(`${BASE_URL}/api/model/$transaction/sequential`) - .post(/.*/) - .reply(200, () => { - users.push({ id: '1', email: 'foo@bar.com' }); - return { data: [users[0]] }; - }); - - const { result: txResult } = renderHook( - () => useClientQueries(schema).$transaction.useSequential({ invalidateQueries: false }), - { wrapper }, - ); - - act(() => - txResult.current.mutate([{ model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }]), - ); - - await waitFor(() => { - expect(txResult.current.isSuccess).toBe(true); - // cache not refreshed because invalidation was disabled - const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cachedUsers).toHaveLength(0); - }); - }); - }); - - describe('JSON null value serialization', () => { - function createWrapper() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - return { queryClient, wrapper }; - } - - it('encodes DbNull in query filter and includes serialization metadata in URL', async () => { - const { wrapper } = createWrapper(); - let capturedUri = ''; - - nock(BASE_URL) - .get(/.*/) - .reply(200, function (uri) { - capturedUri = uri; - return { data: [] }; - }); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany({ where: { name: DbNull } } as any), - { wrapper }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - const url = new URL(capturedUri, BASE_URL); - expect(url.searchParams.has('meta')).toBe(true); - - const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); - const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); - const reconstructed = deserialize(q, meta.serialization) as any; - expect(reconstructed.where.name.__brand).toBe('DbNull'); - }); - - it('encodes JsonNull in query filter and includes serialization metadata in URL', async () => { - const { wrapper } = createWrapper(); - let capturedUri = ''; - - nock(BASE_URL) - .get(/.*/) - .reply(200, function (uri) { - capturedUri = uri; - return { data: [] }; - }); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany({ where: { name: JsonNull } } as any), - { wrapper }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - const url = new URL(capturedUri, BASE_URL); - expect(url.searchParams.has('meta')).toBe(true); - - const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); - const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); - const reconstructed = deserialize(q, meta.serialization) as any; - expect(reconstructed.where.name.__brand).toBe('JsonNull'); - }); - - it('encodes AnyNull in query filter and includes serialization metadata in URL', async () => { - const { wrapper } = createWrapper(); - let capturedUri = ''; - - nock(BASE_URL) - .get(/.*/) - .reply(200, function (uri) { - capturedUri = uri; - return { data: [] }; - }); - - const { result } = renderHook( - () => useClientQueries(schema).user.useFindMany({ where: { name: AnyNull } } as any), - { wrapper }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - const url = new URL(capturedUri, BASE_URL); - expect(url.searchParams.has('meta')).toBe(true); - - const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); - const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); - const reconstructed = deserialize(q, meta.serialization) as any; - expect(reconstructed.where.name.__brand).toBe('AnyNull'); - }); - - it('encodes DbNull in mutation body with serialization metadata', async () => { - const { wrapper } = createWrapper(); - let capturedBody: any; - - nock(BASE_URL) - .post(/.*/) - .reply(200, function (_uri, body) { - capturedBody = body; - return { data: { id: '1', name: null } }; - }); - - const { result } = renderHook(() => useClientQueries(schema).user.useCreate(), { wrapper }); - - act(() => result.current.mutate({ data: { email: 'test@example.com', name: DbNull } } as any)); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(capturedBody.meta?.serialization).toBeDefined(); - const reconstructed = deserialize({ data: capturedBody.data }, capturedBody.meta.serialization) as any; - expect(reconstructed.data.name.__brand).toBe('DbNull'); - }); - - it('deserializes null sentinels in server response back to branded instances', async () => { - const { wrapper } = createWrapper(); - - const responseData = { id: '1', email: 'test@example.com', name: DbNull }; - const { data: serializedData, meta: serializedMeta } = serialize(responseData); - - nock(BASE_URL) - .get(/.*/) - .reply(200, { data: serializedData, meta: { serialization: serializedMeta } }); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }), { - wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect((result.current.data as any).name.__brand).toBe('DbNull'); - }); - }); -}); diff --git a/packages/clients/tanstack-query/test/react/crud-and-invalidation.test.tsx b/packages/clients/tanstack-query/test/react/crud-and-invalidation.test.tsx new file mode 100644 index 000000000..5e417a36a --- /dev/null +++ b/packages/clients/tanstack-query/test/react/crud-and-invalidation.test.tsx @@ -0,0 +1,448 @@ +/** + * @vitest-environment happy-dom + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import nock from 'nock'; +import { describe, expect, it } from 'vitest'; +import { getQueryKey } from '../../src/common/query-key'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { createWrapper, makeUrl, registerCleanup } from './helpers'; + +registerCleanup(); + +describe('CRUD and invalidation', () => { + it('works with simple query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, { + data, + }); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toMatchObject(data); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject(data); + }); + + nock(makeUrl('User', 'findFirst', queryArgs)) + .get(/.*/) + .reply(404, () => { + return { error: 'Not Found' }; + }); + const { result: errorResult } = renderHook(() => useClientQueries(schema).user.useFindFirst(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(errorResult.current.isError).toBe(true); + }); + }); + + it('works with suspense query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, { + data, + }); + + const { result } = renderHook(() => useClientQueries(schema).user.useSuspenseFindUnique(queryArgs), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toMatchObject(data); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject(data); + }); + }); + + it('works with infinite query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = [{ id: '1', name: 'foo' }]; + + nock(makeUrl('User', 'findMany', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })); + + const { result } = renderHook( + () => + useClientQueries(schema).user.useInfiniteFindMany(queryArgs, { + getNextPageParam: () => null, + }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + const resultData = result.current.data!; + expect(resultData.pages).toHaveLength(1); + expect(resultData.pages[0]).toMatchObject(data); + expect(resultData?.pageParams).toHaveLength(1); + expect(resultData?.pageParams[0]).toMatchObject(queryArgs); + expect(result.current.hasNextPage).toBe(false); + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), + ); + expect(cacheData.pages[0]).toMatchObject(data); + }); + }); + + it('works with suspense infinite query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = [{ id: '1', name: 'foo' }]; + + nock(makeUrl('User', 'findMany', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })); + + const { result } = renderHook( + () => + useClientQueries(schema).user.useSuspenseInfiniteFindMany(queryArgs, { + getNextPageParam: () => null, + }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + const resultData = result.current.data!; + expect(resultData.pages).toHaveLength(1); + expect(resultData.pages[0]).toMatchObject(data); + expect(resultData?.pageParams).toHaveLength(1); + expect(resultData?.pageParams[0]).toMatchObject(queryArgs); + expect(result.current.hasNextPage).toBe(false); + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), + ); + expect(cacheData.pages[0]).toMatchObject(data); + }); + }); + + it('works with independent mutation and query', async () => { + const { wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + let queryCount = 0; + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => { + queryCount++; + return { data }; + }) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('Post', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: { id: '1', title: 'post1' }, + })); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useCreate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ data: { title: 'post1' } })); + + await waitFor(() => { + // no refetch caused by invalidation + expect(queryCount).toBe(1); + }); + }); + + it('works with create and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => { + data.push({ id: '1', email: 'foo' }); + return { data: data[0] }; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(1); + }); + }); + + it('works with create and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => { + data.push({ id: '1', email: 'foo' }); + return { data: data[0] }; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(0); + }); + }); + + it('works with update and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.name = 'bar'; + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject({ name: 'bar' }); + }); + }); + + it('works with update and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.name = 'bar'; + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject({ name: 'foo' }); + }); + }); + + it('works with delete and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'foo' }]; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => { + data.splice(0, 1); + return { data: [] }; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useDelete(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ where: { id: '1' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(0); + }); + }); + + it('top-level mutation and nested-read invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' }, include: { posts: true } }; + const data = { posts: [{ id: '1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject(data); + }); + + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => { + data.posts[0]!.title = 'post2'; + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useUpdate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'post2' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData.posts[0].title).toBe('post2'); + }); + }); + + it('nested mutation and top-level-read invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data = [{ id: '1', title: 'post1', ownerId: '1' }]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).post.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject(data); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.push({ id: '2', title: 'post2', ownerId: '1' }); + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { + wrapper, + }); + + act(() => + mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { create: { title: 'post2' } } } }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); + expect(cacheData).toHaveLength(2); + }); + }); +}); diff --git a/packages/clients/tanstack-query/test/react/helpers.tsx b/packages/clients/tanstack-query/test/react/helpers.tsx new file mode 100644 index 000000000..045f2d6f4 --- /dev/null +++ b/packages/clients/tanstack-query/test/react/helpers.tsx @@ -0,0 +1,35 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup } from '@testing-library/react'; +import nock from 'nock'; +import React from 'react'; +import { afterEach } from 'vitest'; +import { QuerySettingsProvider } from '../../src/react'; + +export const BASE_URL = 'http://localhost'; + +export function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + return { queryClient, wrapper }; +} + +export function makeUrl(model: string, operation: string, args?: unknown) { + let r = `${BASE_URL}/api/model/${model}/${operation}`; + if (args) { + r += `?q=${encodeURIComponent(JSON.stringify(args))}`; + } + return r; +} + +export function registerCleanup() { + afterEach(() => { + nock.cleanAll(); + cleanup(); + }); +} diff --git a/packages/clients/tanstack-query/test/react/json-null-serialization.test.tsx b/packages/clients/tanstack-query/test/react/json-null-serialization.test.tsx new file mode 100644 index 000000000..32f066658 --- /dev/null +++ b/packages/clients/tanstack-query/test/react/json-null-serialization.test.tsx @@ -0,0 +1,137 @@ +/** + * @vitest-environment happy-dom + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { deserialize, serialize } from '@zenstackhq/client-helpers/fetch'; +import nock from 'nock'; +import { describe, expect, it } from 'vitest'; +import { AnyNull, DbNull, JsonNull, useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { BASE_URL, createWrapper, registerCleanup } from './helpers'; + +registerCleanup(); + +describe('JSON null value serialization', () => { + it('encodes DbNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: DbNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('DbNull'); + }); + + it('encodes JsonNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: JsonNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('JsonNull'); + }); + + it('encodes AnyNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: AnyNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('AnyNull'); + }); + + it('encodes DbNull in mutation body with serialization metadata', async () => { + const { wrapper } = createWrapper(); + let capturedBody: any; + + nock(BASE_URL) + .post(/.*/) + .reply(200, function (_uri, body) { + capturedBody = body; + return { data: { id: '1', name: null } }; + }); + + const { result } = renderHook(() => useClientQueries(schema).user.useCreate(), { wrapper }); + + act(() => result.current.mutate({ data: { email: 'test@example.com', name: DbNull } } as any)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(capturedBody.meta?.serialization).toBeDefined(); + const reconstructed = deserialize({ data: capturedBody.data }, capturedBody.meta.serialization) as any; + expect(reconstructed.data.name.__brand).toBe('DbNull'); + }); + + it('deserializes null sentinels in server response back to branded instances', async () => { + const { wrapper } = createWrapper(); + + const responseData = { id: '1', email: 'test@example.com', name: DbNull }; + const { data: serializedData, meta: serializedMeta } = serialize(responseData); + + nock(BASE_URL) + .get(/.*/) + .reply(200, { data: serializedData, meta: { serialization: serializedMeta } }); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect((result.current.data as any).name.__brand).toBe('DbNull'); + }); +}); diff --git a/packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx b/packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx new file mode 100644 index 000000000..2bdb25698 --- /dev/null +++ b/packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx @@ -0,0 +1,1324 @@ +/** + * @vitest-environment happy-dom + */ + +import { useQuery } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import nock from 'nock'; +import { describe, expect, it } from 'vitest'; +import { getQueryKey } from '../../src/common/query-key'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { BASE_URL, createWrapper, makeUrl, registerCleanup } from './helpers'; + +registerCleanup(); + +describe('Optimistic mutation', () => { + it('works with optimistic create single', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].email).toBe('foo'); + }); + }); + + it('works with optimistic create updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'user1', posts: [] }]; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => + useClientQueries(schema).user.useFindMany( + { + include: { posts: true }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('Post', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ data: { title: 'post1', owner: { connect: { id: '1' } } } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { include: { posts: true } }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ $optimistic: true, id: expect.any(String), title: 'post1', ownerId: '1' }); + }); + }); + + it('works with optimistic create updating deeply nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a user + + const userData: any[] = [{ id: '1', email: 'user1', posts: [] }]; + + nock(BASE_URL) + .get('/api/model/user/findMany') + .query(true) + .reply(200, () => ({ data: userData })) + .persist(); + + const { result: userResult } = renderHook( + () => + useClientQueries(schema).user.useFindMany( + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(userResult.current.data).toHaveLength(1); + }); + + // populate the cache with a category + const categoryData: any[] = [{ id: '1', name: 'category1', posts: [] }]; + + nock(BASE_URL) + .get('/api/model/category/findMany') + .query(true) + .reply(200, () => ({ data: categoryData })) + .persist(); + + const { result: categoryResult } = renderHook( + () => + useClientQueries(schema).category.useFindMany( + { + include: { + posts: true, + }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(categoryResult.current.data).toHaveLength(1); + }); + + // create a post and connect it to the category + nock(BASE_URL) + .post('/api/model/post/create') + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => + mutationResult.current.mutate({ + data: { title: 'post1', owner: { connect: { id: '1' } }, category: { connect: { id: '1' } } }, + }), + ); + + // assert that the post was created and connected to the category + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'Category', + 'findMany', + { + include: { + posts: true, + }, + }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'post1', + ownerId: '1', + }); + }); + + // assert that the post was created and connected to the user, and included the category + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'post1', + ownerId: '1', + categoryId: '1', + // TODO: should this include the category object and not just the foreign key? + // category: { $optimistic: true, id: '1', name: 'category1' }, + }); + }); + }); + + it('works with optimistic update with optional one-to-many relationship', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a post, with an optional category relationship + const postData: any = { + id: '1', + title: 'post1', + ownerId: '1', + categoryId: null, + category: null, + }; + + const data: any[] = [postData]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .query(true) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result: postResult } = renderHook( + () => + useClientQueries(schema).post.useFindMany( + { + include: { + category: true, + }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(postResult.current.data).toHaveLength(1); + }); + + // mock a put request to update the post title + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => { + postData.title = 'postA'; + return { data: postData }; + }); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); + + // assert that the post was updated despite the optional (null) category relationship + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'Post', + 'findMany', + { + include: { + category: true, + }, + }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'postA', + ownerId: '1', + categoryId: null, + category: null, + }); + }); + }); + + it('works with optimistic update with nested optional one-to-many relationship', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a user and a post, with an optional category + const postData: any = { + id: '1', + title: 'post1', + ownerId: '1', + categoryId: null, + category: null, + }; + + const userData: any[] = [{ id: '1', name: 'user1', posts: [postData] }]; + + nock(BASE_URL) + .get('/api/model/user/findMany') + .query(true) + .reply(200, () => { + return { data: userData }; + }) + .persist(); + + const { result: userResult } = renderHook( + () => + useClientQueries(schema).user.useFindMany( + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(userResult.current.data).toHaveLength(1); + }); + + // mock a put request to update the post title + nock(BASE_URL) + .put('/api/model/post/update') + .reply(200, () => { + postData.title = 'postA'; + return { data: postData }; + }); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); + + // assert that the post was updated + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { infinite: false, optimisticUpdate: true }, + ), + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'postA', + ownerId: '1', + categoryId: null, + category: null, + }); + }); + }); + + it('works with optimistic nested create updating query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ data: { email: 'user1', posts: { create: { title: 'post1' } } } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].title).toBe('post1'); + }); + }); + + it('works with optimistic create many', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'createMany')) + .post(/.*/) + .reply(200, () => ({ + data: null, + })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useCreateMany({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ data: [{ email: 'foo' }, { email: 'bar' }] })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(2); + }); + }); + + it('works with optimistic update simple', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => data); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData( + getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toMatchObject({ name: 'bar', $optimistic: true }); + }); + }); + + it('works with optimistic update updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' }, include: { posts: true } }; + const data = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => data); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => + mutationResult.current.mutate({ + where: { id: 'p1' }, + data: { title: 'post2', owner: { connect: { id: '2' } } }, + }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData.posts[0]).toMatchObject({ title: 'post2', $optimistic: true, ownerId: '2' }); + }); + }); + + it('works with optimistic nested update updating query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: 'p1' } }; + const data = { id: 'p1', title: 'post1' }; + + nock(makeUrl('Post', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).post.useFindUnique(queryArgs, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ title: 'post1' }); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => data); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { posts: { update: { where: { id: 'p1' }, data: { title: 'post2' } } } }, + }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toMatchObject({ title: 'post2', $optimistic: true }); + }); + }); + + it('works with optimistic upsert - create simple', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'upsert')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpsert({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + create: { id: '1', email: 'foo' }, + update: { email: 'bar' }, + }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0]).toMatchObject({ id: '1', email: 'foo', $optimistic: true }); + }); + }); + + it('works with optimistic upsert - create updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); + }); + + nock(makeUrl('Post', 'upsert')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpsert({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => + mutationResult.current.mutate({ + where: { id: 'p2' }, + create: { id: 'p2', title: 'post2', owner: { connect: { id: '1' } } }, + update: { title: 'post3' }, + }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }), + ); + const posts = cacheData.posts; + expect(posts).toHaveLength(2); + expect(posts[0]).toMatchObject({ id: 'p2', title: 'post2', ownerId: '1', $optimistic: true }); + }); + }); + + it('works with optimistic upsert - nested create updating query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = [{ id: 'p1', title: 'post1' }]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'update')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { + posts: { + upsert: { + where: { id: 'p2' }, + create: { id: 'p2', title: 'post2' }, + update: { title: 'post3' }, + }, + }, + }, + }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(2); + expect(cacheData[0]).toMatchObject({ id: 'p2', title: 'post2', $optimistic: true }); + }); + }); + + it('works with optimistic upsert - update simple', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique(queryArgs, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('User', 'upsert')) + .post(/.*/) + .reply(200, () => data); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpsert({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ ...queryArgs, update: { email: 'bar' }, create: { email: 'zee' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData( + getQueryKey('User', 'findUnique', queryArgs, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toMatchObject({ email: 'bar', $optimistic: true }); + }); + }); + + it('works with optimistic upsert - update updating nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = { id: '1', name: 'user1', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); + }); + + nock(makeUrl('Post', 'upsert')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useUpsert({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => + mutationResult.current.mutate({ + where: { id: 'p1' }, + create: { id: 'p1', title: 'post1' }, + update: { title: 'post2' }, + }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findUnique', { where: { id: '1' } }, { infinite: false, optimisticUpdate: true }), + ); + const posts = cacheData.posts; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); + }); + }); + + it('works with optimistic upsert - nested update updating query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = [{ id: 'p1', title: 'post1' }]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'update')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => + mutationResult.current.mutate({ + where: { id: '1' }, + data: { + posts: { + upsert: { + where: { id: 'p1' }, + create: { id: 'p1', title: 'post1' }, + update: { title: 'post2' }, + }, + }, + }, + }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0]).toMatchObject({ id: 'p1', title: 'post2', $optimistic: true }); + }); + }); + + it('works with optimistic delete simple', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'foo' }]; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => ({ data })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useDelete({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(0); + }); + }); + + it('works with optimistic delete nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = { id: '1', name: 'foo', posts: [{ id: 'p1', title: 'post1' }] }; + + nock(makeUrl('User', 'findFirst')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => + useClientQueries(schema).user.useFindFirst( + { + include: { posts: true }, + }, + { optimisticUpdate: true }, + ), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toMatchObject({ id: '1' }); + }); + + nock(makeUrl('Post', 'delete')) + .delete(/.*/) + .reply(200, () => ({ data })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).post.useDelete({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ where: { id: 'p1' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findFirst', + { include: { posts: true } }, + { infinite: false, optimisticUpdate: true }, + ), + ); + expect(cacheData.posts).toHaveLength(0); + }); + }); + + it('works with optimistic nested delete update query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any = [ + { id: 'p1', title: 'post1' }, + { id: 'p2', title: 'post2' }, + ]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).post.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(2); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => ({ data })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useUpdate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { delete: { id: 'p1' } } } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('Post', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + }); + }); + + it('optimistic create with custom provider', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => ({ data: null })) + .persist(); + + const { result: mutationResult1 } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + optimisticDataProvider: ({ queryModel, queryOperation }) => { + if (queryModel === 'User' && queryOperation === 'findMany') { + return { kind: 'Skip' }; + } else { + return { kind: 'ProceedDefault' }; + } + }, + }), + { + wrapper, + }, + ); + + act(() => mutationResult1.current.mutate({ data: { email: 'foo' } })); + + // cache should not update + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(0); + }); + + const { result: mutationResult2 } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + optimisticDataProvider: ({ queryModel, queryOperation, currentData, mutationArgs }) => { + if (queryModel === 'User' && queryOperation === 'findMany') { + return { + kind: 'Update', + data: [ + ...currentData, + { id: 100, email: mutationArgs.data.email + 'hooray', $optimistic: true }, + ], + }; + } else { + return { kind: 'ProceedDefault' }; + } + }, + }), + { + wrapper, + }, + ); + + act(() => mutationResult2.current.mutate({ data: { email: 'foo' } })); + + // cache should update + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].email).toBe('foohooray'); + }); + }); + + it('optimistic update mixed with non-zenstack queries', async () => { + const { queryClient, wrapper } = createWrapper(); + + // non-zenstack query + const { result: myQueryResult } = renderHook( + () => useQuery({ queryKey: ['myQuery'], queryFn: () => ({ data: 'myData' }) }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(myQueryResult.current.data).toEqual({ data: 'myData' }); + }); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany(undefined, { optimisticUpdate: true }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => ({ data: null })); + + const { result: mutationResult } = renderHook( + () => + useClientQueries(schema).user.useCreate({ + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + }, + ); + + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', undefined, { infinite: false, optimisticUpdate: true }), + ); + expect(cacheData).toHaveLength(1); + expect(cacheData[0].$optimistic).toBe(true); + expect(cacheData[0].id).toBeTruthy(); + expect(cacheData[0].email).toBe('foo'); + }); + }); +}); diff --git a/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/react/react-sliced-client.test-d.ts similarity index 94% rename from packages/clients/tanstack-query/test/react-sliced-client.test-d.ts rename to packages/clients/tanstack-query/test/react/react-sliced-client.test-d.ts index 78b2eca0a..cc41fb731 100644 --- a/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/react/react-sliced-client.test-d.ts @@ -1,8 +1,8 @@ import { ZenStackClient } from '@zenstackhq/orm'; import { describe, expectTypeOf, it } from 'vitest'; -import { useClientQueries } from '../src/react'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as procSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as procSchema } from '../schemas/procedures/schema-lite'; describe('React client sliced client test', () => { const _db = new ZenStackClient(schema, { diff --git a/packages/clients/tanstack-query/test/react-typing.test-d.ts b/packages/clients/tanstack-query/test/react/react-typing.test-d.ts similarity index 97% rename from packages/clients/tanstack-query/test/react-typing.test-d.ts rename to packages/clients/tanstack-query/test/react/react-typing.test-d.ts index 876336c5e..10a565fbf 100644 --- a/packages/clients/tanstack-query/test/react-typing.test-d.ts +++ b/packages/clients/tanstack-query/test/react/react-typing.test-d.ts @@ -1,7 +1,7 @@ import { describe, it } from 'vitest'; -import { useClientQueries } from '../src/react'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as proceduresSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as proceduresSchema } from '../schemas/procedures/schema-lite'; describe('React client typing test', () => { it('types model queries correctly', () => { diff --git a/packages/clients/tanstack-query/test/react/sequential-transaction.test.tsx b/packages/clients/tanstack-query/test/react/sequential-transaction.test.tsx new file mode 100644 index 000000000..78136865a --- /dev/null +++ b/packages/clients/tanstack-query/test/react/sequential-transaction.test.tsx @@ -0,0 +1,174 @@ +/** + * @vitest-environment happy-dom + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import nock from 'nock'; +import { describe, expect, it } from 'vitest'; +import { getQueryKey } from '../../src/common/query-key'; +import type { TransactionOperation } from '../../src/common/types'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { BASE_URL, createWrapper, makeUrl, registerCleanup } from './helpers'; + +registerCleanup(); + +describe('Sequential transaction', () => { + it('works with sequential transaction and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const users: any[] = []; + const posts: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: users })) + .persist(); + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: posts })) + .persist(); + + const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); + const { result: postResult } = renderHook(() => useClientQueries(schema).post.useFindMany(), { wrapper }); + + await waitFor(() => { + expect(userResult.current.data).toHaveLength(0); + expect(postResult.current.data).toHaveLength(0); + }); + + nock(`${BASE_URL}/api/model/$transaction/sequential`) + .post(/.*/) + .reply(200, () => { + users.push({ id: '1', email: 'foo@bar.com' }); + posts.push({ id: 'p1', title: 'Hello' }); + return { data: [users[0], posts[0]] }; + }); + + const { result: txResult } = renderHook(() => useClientQueries(schema).$transaction.useSequential(), { + wrapper, + }); + + act(() => + txResult.current.mutate([ + { model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }, + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + ]), + ); + + await waitFor(() => { + const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + const cachedPosts = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); + expect(cachedUsers).toHaveLength(1); + expect(cachedPosts).toHaveLength(1); + }); + }); + + describe('args field optionality', () => { + type TxOp = TransactionOperation; + + it('allows omitting args for ops with all-optional args', () => { + const findMany: TxOp = { model: 'User', op: 'findMany' }; + const findFirst: TxOp = { model: 'User', op: 'findFirst' }; + const count: TxOp = { model: 'User', op: 'count' }; + const exists: TxOp = { model: 'User', op: 'exists' }; + const deleteMany: TxOp = { model: 'User', op: 'deleteMany' }; + + // also accepts an explicit args payload + const findManyWithArgs: TxOp = { model: 'User', op: 'findMany', args: { where: { id: '1' } } }; + + expect([findMany, findFirst, count, exists, deleteMany, findManyWithArgs]).toHaveLength(6); + }); + + it('requires args for ops whose args type has required fields', () => { + const create: TxOp = { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }; + const update: TxOp = { + model: 'User', + op: 'update', + args: { where: { id: '1' }, data: { email: 'b@c.com' } }, + }; + const del: TxOp = { model: 'User', op: 'delete', args: { where: { id: '1' } } }; + const findUnique: TxOp = { model: 'User', op: 'findUnique', args: { where: { id: '1' } } }; + const upsert: TxOp = { + model: 'User', + op: 'upsert', + args: { where: { id: '1' }, create: { email: 'c@d.com' }, update: {} }, + }; + const groupBy: TxOp = { model: 'User', op: 'groupBy', args: { by: ['email'] } }; + + // @ts-expect-error 'create' requires args + const badCreate: TxOp = { model: 'User', op: 'create' }; + // @ts-expect-error 'update' requires args + const badUpdate: TxOp = { model: 'User', op: 'update' }; + // @ts-expect-error 'delete' requires args + const badDelete: TxOp = { model: 'User', op: 'delete' }; + // @ts-expect-error 'findUnique' requires args + const badFindUnique: TxOp = { model: 'User', op: 'findUnique' }; + // @ts-expect-error 'upsert' requires args + const badUpsert: TxOp = { model: 'User', op: 'upsert' }; + // @ts-expect-error 'groupBy' requires args + const badGroupBy: TxOp = { model: 'User', op: 'groupBy' }; + + expect([create, update, del, findUnique, upsert, groupBy]).toHaveLength(6); + expect([badCreate, badUpdate, badDelete, badFindUnique, badUpsert, badGroupBy]).toHaveLength(6); + }); + + it('rejects create-style ops on delegate models that disallow create', () => { + // 'Foo' is a delegate model — create-style ops are filtered out of the union + + // @ts-expect-error delegate model cannot 'create' + const badCreate: TxOp = { model: 'Foo', op: 'create' }; + // @ts-expect-error delegate model cannot 'createMany' + const badCreateMany: TxOp = { model: 'Foo', op: 'createMany' }; + // @ts-expect-error delegate model cannot 'createManyAndReturn' + const badCreateManyAndReturn: TxOp = { model: 'Foo', op: 'createManyAndReturn' }; + // @ts-expect-error delegate model cannot 'upsert' + const badUpsert: TxOp = { model: 'Foo', op: 'upsert' }; + + // non-create ops on delegate models are still allowed + const findMany: TxOp = { model: 'Foo', op: 'findMany' }; + const update: TxOp = { model: 'Foo', op: 'update', args: { where: { id: '1' }, data: {} } }; + + expect([badCreate, badCreateMany, badCreateManyAndReturn, badUpsert, findMany, update]).toHaveLength(6); + }); + }); + + it('works with sequential transaction and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const users: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: users })) + .persist(); + + const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); + + await waitFor(() => { + expect(userResult.current.data).toHaveLength(0); + }); + + nock(`${BASE_URL}/api/model/$transaction/sequential`) + .post(/.*/) + .reply(200, () => { + users.push({ id: '1', email: 'foo@bar.com' }); + return { data: [users[0]] }; + }); + + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential({ invalidateQueries: false }), + { wrapper }, + ); + + act(() => txResult.current.mutate([{ model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }])); + + await waitFor(() => { + expect(txResult.current.isSuccess).toBe(true); + // cache not refreshed because invalidation was disabled + const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cachedUsers).toHaveLength(0); + }); + }); +}); diff --git a/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/svelte/svelte-sliced-client.test-d.ts similarity index 94% rename from packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts rename to packages/clients/tanstack-query/test/svelte/svelte-sliced-client.test-d.ts index 2290536a8..5ebf12764 100644 --- a/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/svelte/svelte-sliced-client.test-d.ts @@ -1,8 +1,8 @@ import { ZenStackClient } from '@zenstackhq/orm'; import { describe, expectTypeOf, it } from 'vitest'; -import { useClientQueries } from '../src/svelte/index.svelte'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as procSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/svelte/index.svelte'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as procSchema } from '../schemas/procedures/schema-lite'; describe('Svelte client sliced client test', () => { const _db = new ZenStackClient(schema, { diff --git a/packages/clients/tanstack-query/test/svelte-typing-test.ts b/packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts similarity index 96% rename from packages/clients/tanstack-query/test/svelte-typing-test.ts rename to packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts index 492b086a1..a2c83887b 100644 --- a/packages/clients/tanstack-query/test/svelte-typing-test.ts +++ b/packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts @@ -1,6 +1,6 @@ -import { useClientQueries } from '../src/svelte/index.svelte'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as proceduresSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/svelte/index.svelte'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as proceduresSchema } from '../schemas/procedures/schema-lite'; const client = useClientQueries(schema); const proceduresClient = useClientQueries(proceduresSchema); diff --git a/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/vue/vue-sliced-client.test-d.ts similarity index 94% rename from packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts rename to packages/clients/tanstack-query/test/vue/vue-sliced-client.test-d.ts index 51637c955..28b6ba701 100644 --- a/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/vue/vue-sliced-client.test-d.ts @@ -1,8 +1,8 @@ import { ZenStackClient } from '@zenstackhq/orm'; import { describe, expectTypeOf, it } from 'vitest'; -import { useClientQueries } from '../src/vue'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as procSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/vue'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as procSchema } from '../schemas/procedures/schema-lite'; describe('Vue client sliced client test', () => { const _db = new ZenStackClient(schema, { diff --git a/packages/clients/tanstack-query/test/vue-typing-test.ts b/packages/clients/tanstack-query/test/vue/vue-typing-test.ts similarity index 96% rename from packages/clients/tanstack-query/test/vue-typing-test.ts rename to packages/clients/tanstack-query/test/vue/vue-typing-test.ts index fdd4f8541..e72f90445 100644 --- a/packages/clients/tanstack-query/test/vue-typing-test.ts +++ b/packages/clients/tanstack-query/test/vue/vue-typing-test.ts @@ -1,6 +1,6 @@ -import { useClientQueries } from '../src/vue'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as proceduresSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/vue'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as proceduresSchema } from '../schemas/procedures/schema-lite'; const client = useClientQueries(schema); const proceduresClient = useClientQueries(proceduresSchema);