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/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', () => { 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/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..afb6dd5c4 --- /dev/null +++ b/packages/clients/tanstack-query/src/common/transaction.ts @@ -0,0 +1,56 @@ +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'; + +/** + * 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 = Array.isArray(args[1]) ? args[1] : []; + for (const op of variables) { + 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]); + } + 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..564e48934 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'; @@ -100,3 +116,48 @@ export type WithOptimistic = T extends Array ? Array> = Awaited< ReturnType> >; + +/** + * Maps each core CRUD operation to its argument type for a given model. + */ +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]: {} 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/src/react.ts b/packages/clients/tanstack-query/src/react.ts index 07498f066..731f3fda0 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -65,14 +65,16 @@ 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'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, TrimSlicedOperations, WithOptimistic, } from './common/types.js'; @@ -165,6 +167,12 @@ export type ModelMutationModelResult< ): Promise>; }; +export type TransactionMutationOptions = Omit< + UseMutationOptions[]>, + 'mutationFn' +> & + Omit; + export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, @@ -176,7 +184,13 @@ export type ClientHooks< Options, ExtResult >; -} & ProcedureHooks; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): UseMutationResult[]>; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -448,6 +462,10 @@ export function useClientQueries useInternalTransactionMutation(schema, { ...options, ...hookOptions }), + }; + return result; } @@ -789,11 +807,35 @@ export function useInternalMutation( return useMutation(finalOptions); } +export function useInternalTransactionMutation( + schema: Schema, + 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 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 f2cbe4083..b8bf2c9be 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -62,14 +62,16 @@ 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'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, TrimSlicedOperations, WithOptimistic, } from '../common/types.js'; @@ -158,6 +160,12 @@ export type ModelMutationModelResult< ): Promise>; }; +export type TransactionMutationOptions = Omit< + CreateMutationOptions[]>, + 'mutationFn' +> & + Omit; + export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, @@ -169,7 +177,13 @@ export type ClientHooks< Options, ExtResult >; -} & ProcedureHooks; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): CreateMutationResult[]>; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -375,6 +389,10 @@ export function useClientQueries useInternalTransactionMutation(schema, merge(options, hookOptions)), + }; + return result; } @@ -691,12 +709,42 @@ export function useInternalMutation( return createMutation(finalOptions); } +export function useInternalTransactionMutation( + schema: Schema, + 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?.(); // 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 e33393285..76669b1a8 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -60,14 +60,16 @@ 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'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, TrimSlicedOperations, WithOptimistic, } from './common/types.js'; @@ -152,6 +154,11 @@ export type ModelMutationModelResult< ): Promise>; }; +export type TransactionMutationOptions = MaybeRefOrGetter< + Omit[]>>, 'mutationFn'> & + Omit +>; + export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, @@ -163,7 +170,13 @@ export type ClientHooks< Options, ExtResult >; -} & ProcedureHooks; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): UseMutationReturnType[], unknown>; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -406,6 +419,10 @@ export function useClientQueries useInternalTransactionMutation(schema, merge(options, hookOptions)), + }; + return result; } @@ -709,12 +726,40 @@ export function useInternalMutation( return useMutation(finalOptions); } +export function useInternalTransactionMutation( + schema: Schema, + options?: TransactionMutationOptions, +) { + 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); // 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/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-query.test.tsx b/packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx similarity index 66% rename from packages/clients/tanstack-query/test/react-query.test.tsx rename to packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx index 8f23e6252..2bdb25698 100644 --- a/packages/clients/tanstack-query/test/react-query.test.tsx +++ b/packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx @@ -2,293 +2,18 @@ * @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 { useQuery } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; 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(); - }); - - 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); - }); - }); +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'; - 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); - }); - }); +registerCleanup(); +describe('Optimistic mutation', () => { it('works with optimistic create single', async () => { const { queryClient, wrapper } = createWrapper(); @@ -826,82 +551,6 @@ describe('React Query Test', () => { }); }); - 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 optimistic update simple', async () => { const { queryClient, wrapper } = createWrapper(); @@ -1374,42 +1023,6 @@ describe('React Query Test', () => { }); }); - 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('works with optimistic delete simple', async () => { const { queryClient, wrapper } = createWrapper(); @@ -1559,83 +1172,6 @@ describe('React Query Test', () => { }); }); - 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); - }); - }); - it('optimistic create with custom provider', async () => { const { queryClient, wrapper } = createWrapper(); @@ -1785,140 +1321,4 @@ describe('React Query Test', () => { expect(cacheData[0].email).toBe('foo'); }); }); - - 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-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);