refactor(tanstack-query): strongly type TransactionOperation and normalize model name lookup

- Make `TransactionOperation<Schema>` 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 <noreply@anthropic.com>
This commit is contained in:
ymc9 2026-05-03 17:22:05 -07:00
parent 29bf3e6678
commit 520cdf4d6e
8 changed files with 1752 additions and 1681 deletions

View file

@ -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;
}

View file

@ -297,10 +297,6 @@ export class NestedWriteVisitor {
}
}
break;
default: {
throw new Error(`unhandled action type ${action}`);
}
}
}

View file

@ -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<Schema extends SchemaDef>(
endpoint: string,
fetch: FetchFn | undefined,
) {
return (operations: TransactionOperation<Schema>[]) => {
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;

View file

@ -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<Schema extends SchemaDef, Name extends GetProcedureN
>;
/**
* 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<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
findMany: FindManyArgs<Schema, Model>;
findUnique: FindUniqueArgs<Schema, Model>;
findFirst: FindFirstArgs<Schema, Model>;
create: CreateArgs<Schema, Model>;
createMany: CreateManyArgs<Schema, Model>;
createManyAndReturn: CreateManyAndReturnArgs<Schema, Model>;
update: UpdateArgs<Schema, Model>;
updateMany: UpdateManyArgs<Schema, Model>;
updateManyAndReturn: UpdateManyAndReturnArgs<Schema, Model>;
upsert: UpsertArgs<Schema, Model>;
delete: DeleteArgs<Schema, Model>;
deleteMany: DeleteManyArgs<Schema, Model>;
count: CountArgs<Schema, Model>;
aggregate: AggregateArgs<Schema, Model>;
groupBy: GroupByArgs<Schema, Model>;
exists: ExistsArgs<Schema, Model>;
};
/**
* Operations available for a given model, omitting create-style operations
* for models that don't allow them (e.g. delegate models).
*/
type AllowedTransactionOps<Schema extends SchemaDef, Model extends GetModels<Schema>> =
ModelAllowsCreate<Schema, Model> extends true
? keyof CrudArgsMap<Schema, Model>
: Exclude<keyof CrudArgsMap<Schema, Model>, 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<Schema extends SchemaDef> = {
[Model in GetModels<Schema>]: {
[Op in AllowedTransactionOps<Schema, Model>]: {
model: Model;
op: Op;
args?: CrudArgsMap<Schema, Model>[Op];
};
}[AllowedTransactionOps<Schema, Model>];
}[GetModels<Schema>];

View file

@ -167,8 +167,8 @@ export type ModelMutationModelResult<
): Promise<SimplifiedResult<Schema, Model, T, Options, false, Array, ExtResult>>;
};
export type TransactionMutationOptions = Omit<
UseMutationOptions<unknown[], DefaultError, TransactionOperation[]>,
export type TransactionMutationOptions<Schema extends SchemaDef> = Omit<
UseMutationOptions<unknown[], DefaultError, TransactionOperation<Schema>[]>,
'mutationFn'
> &
Omit<ExtraMutationOptions, 'optimisticUpdate' | 'optimisticDataProvider'>;
@ -187,8 +187,8 @@ export type ClientHooks<
} & ProcedureHooks<Schema, Options> & {
$transaction: {
useSequential(
options?: TransactionMutationOptions,
): UseMutationResult<unknown[], DefaultError, TransactionOperation[]>;
options?: TransactionMutationOptions<Schema>,
): UseMutationResult<unknown[], DefaultError, TransactionOperation<Schema>[]>;
};
};
@ -807,11 +807,14 @@ export function useInternalMutation<TArgs, R = any>(
return useMutation(finalOptions);
}
export function useInternalTransactionMutation(schema: SchemaDef, options?: TransactionMutationOptions) {
export function useInternalTransactionMutation<Schema extends SchemaDef>(
schema: Schema,
options?: TransactionMutationOptions<Schema>,
) {
const { endpoint, fetch, logging } = useFetchOptions(options);
const queryClient = useQueryClient();
const mutationFn = makeTransactionMutationFn(endpoint, fetch);
const mutationFn = makeTransactionMutationFn<Schema>(endpoint, fetch);
const finalOptions = { ...options, mutationFn };

View file

@ -160,8 +160,8 @@ export type ModelMutationModelResult<
): Promise<SimplifiedResult<Schema, Model, T, Options, false, Array, ExtResult>>;
};
export type TransactionMutationOptions = Omit<
CreateMutationOptions<unknown[], DefaultError, TransactionOperation[]>,
export type TransactionMutationOptions<Schema extends SchemaDef> = Omit<
CreateMutationOptions<unknown[], DefaultError, TransactionOperation<Schema>[]>,
'mutationFn'
> &
Omit<ExtraMutationOptions, 'optimisticUpdate' | 'optimisticDataProvider'>;
@ -180,8 +180,8 @@ export type ClientHooks<
} & ProcedureHooks<Schema, Options> & {
$transaction: {
useSequential(
options?: TransactionMutationOptions,
): CreateMutationResult<unknown[], DefaultError, TransactionOperation[]>;
options?: TransactionMutationOptions<Schema>,
): CreateMutationResult<unknown[], DefaultError, TransactionOperation<Schema>[]>;
};
};
@ -709,11 +709,14 @@ export function useInternalMutation<TArgs, R = any>(
return createMutation(finalOptions);
}
export function useInternalTransactionMutation(schema: SchemaDef, options?: Accessor<TransactionMutationOptions>) {
export function useInternalTransactionMutation<Schema extends SchemaDef>(
schema: Schema,
options?: Accessor<TransactionMutationOptions<Schema>>,
) {
const { endpoint, fetch, logging } = useFetchOptions(options);
const queryClient = useQueryClient();
const mutationFn = makeTransactionMutationFn(endpoint, fetch);
const mutationFn = makeTransactionMutationFn<Schema>(endpoint, fetch);
const finalOptions = () => {
const optionsValue = options?.();

View file

@ -154,8 +154,8 @@ export type ModelMutationModelResult<
): Promise<SimplifiedResult<Schema, Model, T, Options, false, Array, ExtResult>>;
};
export type TransactionMutationOptions = MaybeRefOrGetter<
Omit<UnwrapRef<UseMutationOptions<unknown[], DefaultError, TransactionOperation[]>>, 'mutationFn'> &
export type TransactionMutationOptions<Schema extends SchemaDef> = MaybeRefOrGetter<
Omit<UnwrapRef<UseMutationOptions<unknown[], DefaultError, TransactionOperation<Schema>[]>>, 'mutationFn'> &
Omit<ExtraMutationOptions, 'optimisticUpdate' | 'optimisticDataProvider'>
>;
@ -173,8 +173,8 @@ export type ClientHooks<
} & ProcedureHooks<Schema, Options> & {
$transaction: {
useSequential(
options?: TransactionMutationOptions,
): UseMutationReturnType<unknown[], DefaultError, TransactionOperation[], unknown>;
options?: TransactionMutationOptions<Schema>,
): UseMutationReturnType<unknown[], DefaultError, TransactionOperation<Schema>[], unknown>;
};
};
@ -726,17 +726,14 @@ export function useInternalMutation<TArgs, R = any>(
return useMutation(finalOptions);
}
export function useInternalTransactionMutation(
schema: SchemaDef,
options?: MaybeRefOrGetter<
Omit<UnwrapRef<UseMutationOptions<unknown[], DefaultError, TransactionOperation[]>>, 'mutationFn'> &
Omit<ExtraMutationOptions, 'optimisticUpdate' | 'optimisticDataProvider'>
>,
export function useInternalTransactionMutation<Schema extends SchemaDef>(
schema: Schema,
options?: TransactionMutationOptions<Schema>,
) {
const queryClient = useQueryClient();
const { endpoint, fetch, logging } = useFetchOptions(options);
const mutationFn = makeTransactionMutationFn(endpoint, fetch);
const mutationFn = makeTransactionMutationFn<Schema>(endpoint, fetch);
const finalOptions = computed(() => {
const optionsValue = toValue(options);

File diff suppressed because it is too large Load diff