Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/clients/tanstack-query/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,15 @@ type WithOptimisticFlag<T> = T extends object
: T;

export type WithOptimistic<T> = T extends Array<infer U> ? Array<WithOptimisticFlag<U>> : WithOptimisticFlag<T>;

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export type CustomOperationKind = 'query' | 'suspenseQuery' | 'infiniteQuery' | 'suspenseInfiniteQuery' | 'mutation';

export type CustomOperationDefinition<TArgs = unknown, TResult = unknown> = {
kind: CustomOperationKind;
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CustomOperationDefinition type allows specifying an HTTP method for non-mutation kinds, but this is only meaningful for mutations. Query-type operations ('query', 'suspenseQuery', 'infiniteQuery', 'suspenseInfiniteQuery') should always use GET method and don't need a method field. Consider either making the method field conditional (only allowed when kind is 'mutation'), or add validation/documentation clarifying that method is ignored for query-type operations.

Suggested change
kind: CustomOperationKind;
kind: CustomOperationKind;
/**
* HTTP method to use for the operation.
*
* This is only meaningful when `kind` is `'mutation'`. For query-type operations
* (`'query'`, `'suspenseQuery'`, `'infiniteQuery'`, `'suspenseInfiniteQuery'`),
* this field is ignored and `GET` is always used.
*/

Copilot uses AI. Check for mistakes.
method?: HttpMethod;
/** Phantom fields for typing */
__args?: TArgs;
__result?: TResult;
};
133 changes: 122 additions & 11 deletions packages/clients/tanstack-query/src/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { getQueryKey } from './common/query-key';
import type {
ExtraMutationOptions,
ExtraQueryOptions,
CustomOperationDefinition,
QueryContext,
TrimDelegateModelOperations,
WithOptimistic,
Expand Down Expand Up @@ -131,8 +132,32 @@ export type ModelMutationModelResult<
): Promise<SimplifiedResult<Schema, Model, T, Options, false, Array>>;
};

export type ClientHooks<Schema extends SchemaDef, Options extends QueryOptions<Schema> = QueryOptions<Schema>> = {
[Model in GetModels<Schema> as `${Uncapitalize<Model>}`]: ModelQueryHooks<Schema, Model, Options>;
type CustomOperationHooks<CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {}> = {
[K in keyof CustomOperations as `use${Capitalize<string & K>}`]: CustomOperations[K] extends CustomOperationDefinition<
infer TArgs,
infer TResult
>
? CustomOperations[K]['kind'] extends 'mutation'
? (options?: ModelMutationOptions<TResult, TArgs>) => ModelMutationResult<TResult, TArgs>
: CustomOperations[K]['kind'] extends 'query'
? (args?: TArgs, options?: ModelQueryOptions<TResult>) => ModelQueryResult<TResult>
: CustomOperations[K]['kind'] extends 'suspenseQuery'
? (args?: TArgs, options?: ModelSuspenseQueryOptions<TResult>) => ModelSuspenseQueryResult<TResult>
: CustomOperations[K]['kind'] extends 'infiniteQuery'
? (args?: TArgs, options?: ModelInfiniteQueryOptions<TResult>) => ModelInfiniteQueryResult<
InfiniteData<TResult>
>
: (args?: TArgs, options?: ModelSuspenseInfiniteQueryOptions<TResult>) =>
ModelSuspenseInfiniteQueryResult<InfiniteData<TResult>>
: never;
};

export type ClientHooks<
Schema extends SchemaDef,
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
> = {
[Model in GetModels<Schema> as `${Uncapitalize<Model>}`]: ModelQueryHooks<Schema, Model, Options, CustomOperations>;
};

// Note that we can potentially use TypeScript's mapped type to directly map from ORM contract, but that seems
Expand All @@ -141,6 +166,7 @@ export type ModelQueryHooks<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
> = TrimDelegateModelOperations<
Schema,
Model,
Expand Down Expand Up @@ -250,7 +276,7 @@ export type ModelQueryHooks<
args: Subset<T, GroupByArgs<Schema, Model>>,
options?: ModelSuspenseQueryOptions<GroupByResult<Schema, Model, T>>,
): ModelSuspenseQueryResult<GroupByResult<Schema, Model, T>>;
}
} & CustomOperationHooks<CustomOperations>
>;

/**
Expand All @@ -259,20 +285,27 @@ export type ModelQueryHooks<
* @param schema The schema.
* @param options Options for all queries originated from this hook.
*/
export function useClientQueries<Schema extends SchemaDef, Options extends QueryOptions<Schema> = QueryOptions<Schema>>(
schema: Schema,
options?: QueryContext,
): ClientHooks<Schema, Options> {
export function useClientQueries<
Schema extends SchemaDef,
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
>(schema: Schema, options?: QueryContext, customOperations?: CustomOperations): ClientHooks<Schema, Options, CustomOperations> {
return Object.keys(schema.models).reduce(
(acc, model) => {
(acc as any)[lowerCaseFirst(model)] = useModelQueries<Schema, GetModels<Schema>, Options>(
(acc as any)[lowerCaseFirst(model)] = useModelQueries<
Schema,
GetModels<Schema>,
Options,
CustomOperations
>(
schema,
model as GetModels<Schema>,
options,
customOperations,
);
return acc;
},
{} as ClientHooks<Schema, Options>,
{} as ClientHooks<Schema, Options, CustomOperations>,
);
}

Expand All @@ -283,15 +316,21 @@ export function useModelQueries<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Options extends QueryOptions<Schema>,
>(schema: Schema, model: Model, rootOptions?: QueryContext): ModelQueryHooks<Schema, Model, Options> {
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
>(
schema: Schema,
model: Model,
rootOptions?: QueryContext,
customOperations?: CustomOperations,
): ModelQueryHooks<Schema, Model, Options, CustomOperations> {
const modelDef = Object.values(schema.models).find((m) => m.name.toLowerCase() === model.toLowerCase());
if (!modelDef) {
throw new Error(`Model "${model}" not found in schema`);
}

const modelName = modelDef.name;

return {
const builtInHooks = {
useFindUnique: (args: any, options?: any) => {
return useInternalQuery(schema, modelName, 'findUnique', args, { ...rootOptions, ...options });
},
Expand Down Expand Up @@ -390,6 +429,78 @@ export function useModelQueries<
return useInternalSuspenseQuery(schema, modelName, 'groupBy', args, { ...rootOptions, ...options });
},
} as ModelQueryHooks<Schema, Model, Options>;

const customHooks = createCustomOperationHooks(schema, modelName, rootOptions, customOperations);

return { ...builtInHooks, ...customHooks } as ModelQueryHooks<Schema, Model, Options, CustomOperations>;
}

function createCustomOperationHooks<
Schema extends SchemaDef,
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
>(
schema: Schema,
modelName: string,
rootOptions: QueryContext | undefined,
customOperations?: CustomOperations,
) {
if (!customOperations) {
return {} as CustomOperationHooks<CustomOperations>;
}

const hooks: Record<string, unknown> = {};
for (const [name, def] of Object.entries(customOperations)) {
const hookName = `use${name.charAt(0).toUpperCase()}${name.slice(1)}`;
Comment on lines +452 to +453
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client-side custom operation names are not validated to ensure they are valid JavaScript identifiers or checked against built-in hook names. While the server validates custom operation names (lines 364-365), the client accepts any operation name. This could lead to runtime errors if an operation name conflicts with built-in hooks (like 'findUnique') or uses invalid characters. Consider adding validation similar to the server side when custom operations are provided, checking against a list of built-in hook names and ensuring valid JavaScript identifiers.

Copilot uses AI. Check for mistakes.
switch (def.kind) {
case 'query':
hooks[hookName] = (args?: unknown, options?: unknown) =>
useInternalQuery(schema, modelName, name, args, {
...(rootOptions ?? {}),
...((options as object) ?? {}),
});
break;
case 'suspenseQuery':
hooks[hookName] = (args?: unknown, options?: unknown) =>
useInternalSuspenseQuery(schema, modelName, name, args, {
...(rootOptions ?? {}),
...((options as object) ?? {}),
});
break;
case 'infiniteQuery':
hooks[hookName] = (args?: unknown, options?: unknown) =>
useInternalInfiniteQuery(schema, modelName, name, args, buildInfiniteOptions(rootOptions, options));
break;
case 'suspenseInfiniteQuery':
hooks[hookName] = (args?: unknown, options?: unknown) =>
useInternalSuspenseInfiniteQuery(
schema,
modelName,
name,
args,
buildInfiniteOptions(rootOptions, options) as any,
);
break;
case 'mutation':
hooks[hookName] = (options?: unknown) =>
useInternalMutation(schema, modelName, (def.method ?? 'POST') as any, name, {
...(rootOptions ?? {}),
...((options as object) ?? {}),
});
break;
default:
break;
}
}

return hooks as CustomOperationHooks<CustomOperations>;
}

function buildInfiniteOptions(rootOptions: QueryContext | undefined, options: unknown) {
const merged = { ...(rootOptions ?? {}), ...((options as object) ?? {}) } as Record<string, unknown>;
if (typeof merged.getNextPageParam !== 'function') {
merged.getNextPageParam = () => undefined;
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default getNextPageParam function returns undefined for all custom infinite queries. This means infinite queries will only fetch one page and cannot support pagination. Users must explicitly provide their own getNextPageParam function. Consider either documenting this requirement more clearly in the type definition or providing a more meaningful default behavior, or validating that getNextPageParam is provided for infinite custom operations.

Suggested change
merged.getNextPageParam = () => undefined;
throw new Error(
'Infinite custom operations require a getNextPageParam function to be provided in the query options.',
);

Copilot uses AI. Check for mistakes.
}
return merged;
}

export function useInternalQuery<TQueryFnData, TData>(
Expand Down
120 changes: 111 additions & 9 deletions packages/clients/tanstack-query/src/svelte/index.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { getContext, setContext } from 'svelte';
import { getAllQueries, invalidateQueriesMatchingPredicate } from '../common/client';
import { getQueryKey } from '../common/query-key';
import type {
CustomOperationDefinition,
ExtraMutationOptions,
ExtraQueryOptions,
QueryContext,
Expand Down Expand Up @@ -120,8 +121,30 @@ export type ModelMutationModelResult<
): Promise<SimplifiedResult<Schema, Model, T, Options, false, Array>>;
};

export type ClientHooks<Schema extends SchemaDef, Options extends QueryOptions<Schema> = QueryOptions<Schema>> = {
[Model in GetModels<Schema> as `${Uncapitalize<Model>}`]: ModelQueryHooks<Schema, Model, Options>;
type CustomOperationHooks<CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {}> = {
[K in keyof CustomOperations as `use${Capitalize<string & K>}`]: CustomOperations[K] extends CustomOperationDefinition<
infer TArgs,
infer TResult
>
? CustomOperations[K]['kind'] extends 'mutation'
? (options?: ModelMutationOptions<TResult, TArgs>) => ModelMutationResult<TResult, TArgs>
: CustomOperations[K]['kind'] extends 'infiniteQuery' | 'suspenseInfiniteQuery'
? (args?: TArgs, options?: ModelInfiniteQueryOptions<TResult>) => ModelInfiniteQueryResult<TResult>
: (args?: TArgs, options?: ModelQueryOptions<TResult>) => ModelQueryResult<TResult>
: never;
};

export type ClientHooks<
Schema extends SchemaDef,
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
> = {
[Model in GetModels<Schema> as `${Uncapitalize<Model>}`]: ModelQueryHooks<
Schema,
Model,
Options,
CustomOperations
>;
};

// Note that we can potentially use TypeScript's mapped type to directly map from ORM contract, but that seems
Expand All @@ -130,6 +153,7 @@ export type ModelQueryHooks<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
> = TrimDelegateModelOperations<
Schema,
Model,
Expand Down Expand Up @@ -202,26 +226,37 @@ export type ModelQueryHooks<
args: Accessor<Subset<T, GroupByArgs<Schema, Model>>>,
options?: Accessor<ModelQueryOptions<GroupByResult<Schema, Model, T>>>,
): ModelQueryResult<GroupByResult<Schema, Model, T>>;
}
} & CustomOperationHooks<CustomOperations>
>;

/**
* Gets data query hooks for all models in the schema.
*/
export function useClientQueries<Schema extends SchemaDef, Options extends QueryOptions<Schema> = QueryOptions<Schema>>(
export function useClientQueries<
Schema extends SchemaDef,
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
>(
schema: Schema,
options?: Accessor<QueryContext>,
): ClientHooks<Schema, Options> {
customOperations?: CustomOperations,
): ClientHooks<Schema, Options, CustomOperations> {
return Object.keys(schema.models).reduce(
(acc, model) => {
(acc as any)[lowerCaseFirst(model)] = useModelQueries<Schema, GetModels<Schema>, Options>(
(acc as any)[lowerCaseFirst(model)] = useModelQueries<
Schema,
GetModels<Schema>,
Options,
CustomOperations
>(
schema,
model as GetModels<Schema>,
options,
customOperations,
);
return acc;
},
{} as ClientHooks<Schema, Options>,
{} as ClientHooks<Schema, Options, CustomOperations>,
);
}

Expand All @@ -232,7 +267,13 @@ export function useModelQueries<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Options extends QueryOptions<Schema>,
>(schema: Schema, model: Model, rootOptions?: Accessor<QueryContext>): ModelQueryHooks<Schema, Model, Options> {
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
>(
schema: Schema,
model: Model,
rootOptions?: Accessor<QueryContext>,
customOperations?: CustomOperations,
): ModelQueryHooks<Schema, Model, Options, CustomOperations> {
const modelDef = Object.values(schema.models).find((m) => m.name.toLowerCase() === model.toLowerCase());
if (!modelDef) {
throw new Error(`Model "${model}" not found in schema`);
Expand All @@ -248,7 +289,7 @@ export function useModelQueries<
};
};

return {
const builtIns = {
useFindUnique: (args: any, options?: any) => {
return useInternalQuery(schema, modelName, 'findUnique', args, merge(rootOptions, options));
},
Expand Down Expand Up @@ -313,6 +354,67 @@ export function useModelQueries<
return useInternalQuery(schema, modelName, 'groupBy', args, options);
},
} as unknown as ModelQueryHooks<Schema, Model, Options>;

const custom = createCustomOperationHooks(schema, modelName, rootOptions, customOperations, merge);

return { ...builtIns, ...custom } as ModelQueryHooks<Schema, Model, Options, CustomOperations>;
}

function createCustomOperationHooks<
Schema extends SchemaDef,
CustomOperations extends Record<string, CustomOperationDefinition<any, any>> = {},
>(
schema: Schema,
modelName: string,
rootOptions: Accessor<QueryContext> | undefined,
customOperations: CustomOperations | undefined,
mergeOptions: (rootOpt: unknown, opt: unknown) => Accessor<any>,
) {
if (!customOperations) {
return {} as CustomOperationHooks<CustomOperations>;
}

const hooks: Record<string, unknown> = {};
for (const [name, def] of Object.entries(customOperations)) {
const hookName = `use${name.charAt(0).toUpperCase()}${name.slice(1)}`;
Comment on lines +378 to +379
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client-side custom operation names are not validated to ensure they are valid JavaScript identifiers or checked against built-in hook names. While the server validates custom operation names (lines 364-365 in packages/server/src/api/rpc/index.ts), the client accepts any operation name. This could lead to runtime errors if an operation name conflicts with built-in hooks or uses invalid characters. Consider adding validation similar to the server side.

Copilot uses AI. Check for mistakes.
const merged = (options?: unknown) => mergeOptions(rootOptions, options);

switch (def.kind) {
case 'query':
case 'suspenseQuery':
hooks[hookName] = (args?: unknown, options?: unknown) =>
useInternalQuery(schema, modelName, name, args, merged(options as Accessor<unknown> | undefined));
break;
case 'infiniteQuery':
case 'suspenseInfiniteQuery':
hooks[hookName] = (args?: unknown, options?: unknown) => {
const mergedOptions = merged(options as Accessor<unknown> | undefined);
const withDefault = () => {
const value = mergedOptions?.() as any;
if (value && typeof value.getNextPageParam !== 'function') {
value.getNextPageParam = () => undefined;
}
return value;
};
return useInternalInfiniteQuery(schema, modelName, name, args, withDefault as any);
};
break;
case 'mutation':
hooks[hookName] = (options?: unknown) =>
useInternalMutation(
schema,
modelName,
(def.method ?? 'POST') as any,
name,
merged(options as Accessor<unknown> | undefined) as any,
);
break;
default:
break;
}
}

return hooks as CustomOperationHooks<CustomOperations>;
}

export function useInternalQuery<TQueryFnData, TData>(
Expand Down
Loading
Loading