Skip to content

feat: rpc custom api methods#549

Closed
mwillbanks wants to merge 1 commit intozenstackhq:devfrom
mwillbanks:feat/custom-api-methods
Closed

feat: rpc custom api methods#549
mwillbanks wants to merge 1 commit intozenstackhq:devfrom
mwillbanks:feat/custom-api-methods

Conversation

@mwillbanks
Copy link
Contributor

@mwillbanks mwillbanks commented Dec 30, 2025

  • Provides an ability to create custom operations
  • Refactors argument handling for simplification
  • Forces operation names to follow JS function naming rules
  • Adds the ability to modify the tanstack-query to add in the custom operation on the client side

Some Quick Documentation on Usage:

Server: RPCApiHandler

Defining custom operations

import {
   RPCApiHandler,
   RPCBadInputErrorResponse,
   RPCGenericErrorResponse,
   type RPCApiHandlerOptions,
   type RPCCustomOperation,
} from '@zenstackhq/server';

const customOperations: Record<string, RPCCustomOperation> = {
    overview: async ({ client, model, args }) => {
        if (model !== "report") {
            throw RPCGenericErrorResponse("Invalid endpoint");
        }

        const { since } = (args ?? {}) as { since?: string };
        const sinceDate = since ? new Date(since) : undefined;

        const [activeUsers, posts, comments] = await Promise.all([
            client.user.count({ where: sinceDate ? { updatedAt: { gte: sinceDate } } : {} }),
            client.post.count({ where: sinceDate ? { createdAt: { gte: sinceDate } } : {} }),
            client.comment.count({ where: sinceDate ? { createdAt: { gte: sinceDate } } : {} }),
        ]);

        return { status: 200, body: { data: { activeUsers, posts, comments } } };
    },
    bulkDisable: async ({ client, model, args }) => {
        if (!ALLOWED.has(model)) {
            throw new RPCBadInputErrorResponse(`unsupported model: ${model}`);
        }

        const { ids } = (args ?? {}) as { ids?: string[] };
        if (!ids?.length) {
            throw new RPCBadInputErrorResponse('ids is required');
        }

        const result = await (client as any)[model].updateMany({
            where: { id: { in: ids } },
            data: { disabled: true },
        });

        return { status: 200, body: { data: result.count } };
    },
    search: async ({ client, model, args }) => {
        if (!['post', 'comment'].includes(model)) {
            throw new RPCBadInputErrorResponse(`search not allowed for model ${model}`);
        }

        const { search } = (args ?? {}) as { search?: string };
        if (!search) {
            throw new RPCBadInputErrorResponse('search is required');
        }

        const data = await (client as any)[model].findMany({
            where: { content: { contains: search, mode: 'insensitive' } },
            take: 20,
        });

        return { status: 200, body: { data } };
    },
};


const handler = new RPCApiHandler({
   schema: client.$schema,
   customOperations,
} satisfies RPCApiHandlerOptions);

Error handling

  • Throw RPCBadInputErrorResponse for user input issues → returns HTTP 400.
  • Throw RPCGenericErrorResponse (or any other error) → returns HTTP 500.
  • Throw ORMError → mapped to the corresponding ORM-aware status/shape.

Automatic q/meta unmarshal

If the request includes q and meta query params, the handler automatically deserializes them (SuperJSON-aware) before invoking the custom operation. The custom operation receives the parsed value on args and also on query.q.

Request context provided to custom ops

{
   client,          // Zenstack client
   method,          // HTTP method
   path,            // original path
   query,           // parsed query with q/meta handled
   requestBody,     // raw body (if any)
   model,           // lower-cased model segment from the path
   operation,       // operation segment from the path
   args,            // parsed q/meta if present
}

Client: TanStack Query runtimes

Each runtime can register custom operations so they are callable like built-ins via generated hooks.

Define custom operations config

A custom operation definition specifies the hook kind and HTTP method (for mutations):

import type { CustomOperationDefinition } from '@zenstackhq/tanstack-query/src/common/types';
import { useClientQueries } from '@zenstackhq/tanstack-query/react';
import { schema } from './generated/schema';

const customOps = {
    overview: { kind: 'query' } satisfies CustomOperationDefinition, // used under the virtual model "report"
    bulkDisable: { kind: 'mutation', method: 'POST' } satisfies CustomOperationDefinition,
    search: { kind: 'query' } satisfies CustomOperationDefinition,
} as const;

const hooks = useClientQueries(schema, { endpoint: '/api/model' }, customOps);

// Virtual model call: GET /api/model/report/overview?q={...}
const report = hooks.report.useOverview({ query: { q: { since: '2024-01-01' } } });

// Model-aware mutation: POST /api/model/user/bulkDisable
const { mutateAsync: disableUsers } = hooks.user.useBulkDisable();
await disableUsers({ ids: ['u1', 'u2'] });

// Custom query with validation: GET /api/model/post/search?q={"search":"zen"}
const posts = hooks.post.useSearch({ query: { q: { search: 'zen' } } });

Notes and constraints

  • Custom operation names must be valid JS identifiers and cannot shadow built-in RPC operations.
  • Infinite custom queries receive a default getNextPageParam if not supplied.
  • Hook names are generated as use<CapitalizedOp> per model namespace (e.g., hooks.post.useEcho).
  • Payload serialization follows the same SuperJSON contract as built-in operations.

End-to-end flow

  1. Add server custom operations to RPCApiHandler and deploy endpoint (e.g., /api/model/<model>/<op>).
  2. Define matching client customOperations config in TanStack Query runtime and call generated hooks.
  3. Use RPC error classes in server custom ops to produce consistent responses.

Summary by CodeRabbit

  • New Features

    • Added support for custom operations across React, Svelte, and Vue client implementations with automatic type inference.
    • Enhanced RPC API handler with custom operation execution and error mapping capabilities.
  • Tests

    • Added comprehensive test coverage for custom operation execution, error handling, and validation.

✏️ Tip: You can customize this high-level summary in your review settings.

* Provides an ability to create custom operations
* Refactors argument handling for simplification
* Forces operation names to follow JS function naming rules
* Adds the ability to modify the tanstack-query to add in the custom operation on the client side
Copilot AI review requested due to automatic review settings December 30, 2025 03:34
@coderabbitai
Copy link

coderabbitai bot commented Dec 30, 2025

📝 Walkthrough

Walkthrough

This PR introduces custom operation support across TanStack Query client libraries (React, Vue, Svelte) and the server-side RPC API handler. It adds type definitions for custom operations with HTTP methods and operation kinds, extends all three client frameworks to generate typed hooks for custom operations, and implements server-side validation and dispatch logic for custom RPC operations with error handling.

Changes

Cohort / File(s) Summary
Custom Operation Type Definitions
packages/clients/tanstack-query/src/common/types.ts
Introduced HttpMethod union type, CustomOperationKind union for query/suspense/infinite variants, and generic CustomOperationDefinition type with phantom __args and __result fields for typing
React Client Custom Operations
packages/clients/tanstack-query/src/react.ts
Added CustomOperationHooks mapped type, extended ClientHooks and ModelQueryHooks with CustomOperations generic parameter, enhanced useClientQueries and useModelQueries to accept custom operations, implemented createCustomOperationHooks to wire custom operation definitions to runtime hooks, and added buildInfiniteOptions helper
Vue Client Custom Operations
packages/clients/tanstack-query/src/vue.ts
Added CustomOperationHooks mapped type, extended ClientHooks and ModelQueryHooks with CustomOperations generic parameter, enhanced useClientQueries and useModelQueries to accept custom operations, and implemented createCustomOperationHooks for hook generation
Svelte Client Custom Operations
packages/clients/tanstack-query/src/svelte/index.svelte.ts
Added CustomOperationHooks mapped type, extended ClientHooks and ModelQueryHooks with CustomOperations generic parameter, enhanced useClientQueries and useModelQueries to accept custom operations, and implemented createCustomOperationHooks for hook generation with kind-based branching
Server-side RPC Custom Operations
packages/server/src/api/rpc/index.ts
Added RPCBadInputErrorResponse and RPCGenericErrorResponse error classes, introduced RPCCustomOperationContext and RPCCustomOperation public types, extended RPCApiHandlerOptions with optional customOperations map, implemented operation dispatch logic with custom operation support, validation, and error mapping via private helper methods
RPC API Exports
packages/server/src/api/index.ts
Re-exported RPC error response classes and custom operation types from rpc module for public API surface
Custom Operations Tests
packages/server/test/api/rpc.test.ts
Added comprehensive test suite for custom operation execution, automatic unmarshalling of query parameters, error mapping to RPC responses, and validation of operation names and types; enhanced makeHandler test helper to accept optional RPCApiHandlerOptions

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Custom operations bloom, a framework's delight,
Hooks that bend to shape, matching kinds just right,
From React to Vue to Svelte's dance so free,
Server and client in symphony! 🎵

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: rpc custom api methods' directly and clearly describes the main feature addition—support for custom RPC API operations—which is the core objective across all modified files.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds support for custom RPC operations, allowing developers to define their own operations beyond the built-in Prisma-style CRUD operations. It refactors argument handling on the server side and provides comprehensive client-side integration through TanStack Query hooks for React, Vue, and Svelte.

Key Changes

  • Server-side custom operation support with validation, error handling, and automatic query parameter unmarshalling
  • Client-side TanStack Query integration generating typed hooks (useX) for custom operations across all three frameworks
  • Unified argument handling that normalizes query parameters before processing operations

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
packages/server/src/api/rpc/index.ts Core implementation of custom operations support including validation, error mapping, and query normalization
packages/server/src/api/index.ts Exports new custom operation types and error classes for public API
packages/server/test/api/rpc.test.ts Test coverage for custom operations including validation, error handling, and query unmarshalling
packages/clients/tanstack-query/src/react.ts React hooks generation for custom operations with support for queries, mutations, and infinite queries
packages/clients/tanstack-query/src/vue.ts Vue hooks generation with reactive support for custom operations
packages/clients/tanstack-query/src/svelte/index.svelte.ts Svelte hooks generation using Svelte 5 runes for custom operations
packages/clients/tanstack-query/src/common/types.ts Type definitions for custom operation configuration including kind and HTTP method

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +378 to +379
for (const [name, def] of Object.entries(customOperations)) {
const hookName = `use${name.charAt(0).toUpperCase()}${name.slice(1)}`;
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.
Comment on lines 95 to +98

const { query: normalizedQuery, qArgs, error: queryError } = this.normalizeQuery(query);
if (queryError) {
return this.makeBadInputErrorResponse(queryError);
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 normalization of query parameters happens before determining if an operation is built-in or custom. This means query parsing errors will be returned even for custom operations that may not require or use the 'q' parameter. Consider moving the query normalization inside the custom operation handling or making it conditional based on whether the operation actually needs it. This would allow custom operations to handle their own query parameter parsing if needed.

Suggested change
const { query: normalizedQuery, qArgs, error: queryError } = this.normalizeQuery(query);
if (queryError) {
return this.makeBadInputErrorResponse(queryError);
let normalizedQuery = query;
let qArgs: unknown;
if (BUILT_IN_OPERATIONS.has(op)) {
const normalized = this.normalizeQuery(query);
if (normalized.error) {
return this.makeBadInputErrorResponse(normalized.error);
}
normalizedQuery = normalized.query;
qArgs = normalized.qArgs;

Copilot uses AI. Check for mistakes.
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.
? (options?: ModelMutationOptions<TResult, TArgs>) => ModelMutationResult<TResult, TArgs>
: CustomOperations[K]['kind'] extends 'infiniteQuery' | 'suspenseInfiniteQuery'
? (args?: TArgs, options?: ModelInfiniteQueryOptions<TResult>) => ModelInfiniteQueryResult<
InfiniteData<TResult>
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 return type for infinite custom query operations in Vue is inconsistent with React. In React (line 147-148), the type is ModelInfiniteQueryResult<InfiniteData<TResult>>, but in Vue it's ModelInfiniteQueryResult<InfiniteData<TResult>> on line 134. However, looking at the built-in infinite query hooks in Vue (line 182), they return ModelInfiniteQueryResult<InfiniteData<SimplifiedPlainResult<Schema, Model, T, Options>[]>> with an array wrapper inside InfiniteData. The custom operation should be consistent with the built-in hooks pattern unless there's a specific reason for the difference.

Suggested change
InfiniteData<TResult>
InfiniteData<TResult[]>

Copilot uses AI. Check for mistakes.
Comment on lines +452 to +453
for (const [name, def] of Object.entries(customOperations)) {
const hookName = `use${name.charAt(0).toUpperCase()}${name.slice(1)}`;
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.
Comment on lines +384 to +385
for (const [name, def] of Object.entries(customOperations)) {
const hookName = `use${name.charAt(0).toUpperCase()}${name.slice(1)}`;
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.
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.
Comment on lines +408 to +427
it('custom operation maps errors', async () => {
const handleRequest = makeHandler({
customOperations: {
bad: async () => {
throw new RPCBadInputErrorResponse('nope');
},
boom: async () => {
throw new RPCGenericErrorResponse('boom');
},
},
});

const bad = await handleRequest({ method: 'get', path: '/post/bad', client: rawClient });
expect(bad.status).toBe(400);
expect(bad.error.message).toBe('nope');

const boom = await handleRequest({ method: 'get', path: '/post/boom', client: rawClient });
expect(boom.status).toBe(500);
expect(boom.error.message).toBe('boom');
});
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.

Test coverage is missing for custom operations that throw ORMError. While the mapCustomOperationError function handles ORMError (lines 346-347), there's no test verifying this behavior. Consider adding a test case where a custom operation throws an ORMError to ensure it's properly mapped to the appropriate HTTP status and response format.

Copilot uses AI. Check for mistakes.
Comment on lines +154 to +167
if (!BUILT_IN_OPERATIONS.has(op)) {
const custom = this.options.customOperations?.[op];
if (custom) {
try {
args = query?.['q']
? this.unmarshalQ(query['q'] as string, query['meta'] as string | undefined)
: {};
return await custom({
client,
method,
path,
query: normalizedQuery,
requestBody,
model,
operation: op,
args: qArgs,
});
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.

Model validation is skipped for custom operations. Built-in operations validate that the model exists in the schema (line 182), but custom operations receive the model name without validation. This could allow requests to non-existent models to reach custom operation handlers. While custom operations can implement their own model validation, it would be more consistent and secure to validate the model before invoking the custom operation, or at least document this responsibility clearly.

Copilot uses AI. Check for mistakes.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/clients/tanstack-query/src/svelte/index.svelte.ts (1)

390-400: Potential mutation of original options object.

In the infinite query case, value.getNextPageParam = () => undefined mutates the object returned by mergedOptions(). If the merged options share a reference with user-provided options, this could unexpectedly modify the original object.

Consider returning a new object instead:

🔎 Suggested fix
                 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;
+                        if (!value) return value;
+                        if (typeof value.getNextPageParam === 'function') {
+                            return value;
                         }
-                        return value;
+                        return { ...value, getNextPageParam: () => undefined };
                     };
                     return useInternalInfiniteQuery(schema, modelName, name, args, withDefault as any);
                 };
packages/clients/tanstack-query/src/vue.ts (1)

394-406: Same mutation concern as Svelte adapter.

The in-place mutation of value.getNextPageParam within a computed() could have unintended side effects. Each computed access would attempt to mutate the same object.

🔎 Suggested fix
                 hooks[hookName] = (args?: unknown, options?: MaybeRefOrGetter<unknown>) => {
                     const mergedOptions = merged(options) as MaybeRefOrGetter<unknown>;
                     const withDefault = computed(() => {
                         const value = toValue(mergedOptions) as any;
-                        if (value && typeof value.getNextPageParam !== 'function') {
-                            value.getNextPageParam = () => undefined;
+                        if (!value) return value;
+                        if (typeof value.getNextPageParam === 'function') {
+                            return value;
                         }
-                        return value;
+                        return { ...value, getNextPageParam: () => undefined };
                     });
                     return useInternalInfiniteQuery(schema, modelName, name, args, withDefault as any);
                 };
packages/server/src/api/rpc/index.ts (1)

96-99: LGTM! Excellent refactoring of query normalization.

Moving query parsing logic into normalizeQuery eliminates code duplication and centralizes error handling. The early error return (lines 97-99) prevents invalid queries from reaching operation handlers.

The type assertion undefined as unknown in normalizeQuery (lines 317, 322, 326, 335) is unusual. Consider using a more explicit type like undefined or null to avoid the double cast, though the current implementation is functionally correct since the consuming code uses nullish coalescing (qArgs ?? {}).

Also applies to: 126-127, 147-148

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between be330ff and ca0c4f2.

📒 Files selected for processing (7)
  • packages/clients/tanstack-query/src/common/types.ts
  • packages/clients/tanstack-query/src/react.ts
  • packages/clients/tanstack-query/src/svelte/index.svelte.ts
  • packages/clients/tanstack-query/src/vue.ts
  • packages/server/src/api/index.ts
  • packages/server/src/api/rpc/index.ts
  • packages/server/test/api/rpc.test.ts
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-11-26T01:55:04.540Z
Learnt from: CR
Repo: zenstackhq/zenstack-v3 PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-26T01:55:04.540Z
Learning: Applies to packages/zenstackhq/orm/**/*.{ts,tsx} : Implement plugin hooks at ORM, Kysely, and entity mutation levels for query interception and customization

Applied to files:

  • packages/clients/tanstack-query/src/react.ts
  • packages/clients/tanstack-query/src/svelte/index.svelte.ts
  • packages/clients/tanstack-query/src/vue.ts
📚 Learning: 2025-11-26T01:55:04.540Z
Learnt from: CR
Repo: zenstackhq/zenstack-v3 PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-26T01:55:04.540Z
Learning: Applies to packages/zenstackhq/orm/**/*.{ts,tsx} : Use Kysely as the query builder interface for low-level database queries, avoiding raw SQL when possible

Applied to files:

  • packages/clients/tanstack-query/src/react.ts
  • packages/clients/tanstack-query/src/svelte/index.svelte.ts
  • packages/clients/tanstack-query/src/vue.ts
📚 Learning: 2025-11-26T01:55:04.540Z
Learnt from: CR
Repo: zenstackhq/zenstack-v3 PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-26T01:55:04.540Z
Learning: Applies to packages/zenstackhq/orm/**/*.test.{ts,tsx} : ORM package tests should include comprehensive client API tests and policy tests

Applied to files:

  • packages/server/test/api/rpc.test.ts
📚 Learning: 2025-11-26T01:55:04.540Z
Learnt from: CR
Repo: zenstackhq/zenstack-v3 PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-11-26T01:55:04.540Z
Learning: Applies to tests/e2e/**/*.{ts,tsx} : E2E tests should validate real-world schema compatibility with established projects

Applied to files:

  • packages/server/test/api/rpc.test.ts
🧬 Code graph analysis (4)
packages/server/src/api/rpc/index.ts (3)
packages/schema/src/schema.ts (1)
  • SchemaDef (11-19)
packages/server/src/types.ts (3)
  • RequestContext (22-47)
  • Response (52-62)
  • ApiHandler (67-82)
packages/orm/src/client/errors.ts (1)
  • ORMError (66-104)
packages/clients/tanstack-query/src/svelte/index.svelte.ts (3)
packages/clients/tanstack-query/src/common/types.ts (2)
  • CustomOperationDefinition (84-90)
  • QueryContext (9-24)
packages/clients/tanstack-query/src/react.ts (4)
  • ModelMutationOptions (117-118)
  • ModelMutationResult (120-120)
  • useInternalQuery (506-527)
  • useInternalInfiniteQuery (552-579)
packages/clients/tanstack-query/src/vue.ts (4)
  • ModelMutationOptions (103-105)
  • ModelMutationResult (107-107)
  • useInternalQuery (420-454)
  • useInternalInfiniteQuery (456-497)
packages/clients/tanstack-query/src/vue.ts (2)
packages/clients/tanstack-query/src/common/types.ts (2)
  • CustomOperationDefinition (84-90)
  • QueryContext (9-24)
packages/common-helpers/src/lower-case-first.ts (1)
  • lowerCaseFirst (1-3)
packages/server/test/api/rpc.test.ts (3)
packages/server/src/api/rpc/index.ts (5)
  • handleRequest (82-219)
  • RPCBadInputErrorResponse (31-31)
  • RPCGenericErrorResponse (33-33)
  • RPCApiHandler (69-377)
  • RPCApiHandlerOptions (48-64)
packages/server/src/api/index.ts (4)
  • RPCBadInputErrorResponse (3-3)
  • RPCGenericErrorResponse (4-4)
  • RPCApiHandler (5-5)
  • RPCApiHandlerOptions (6-6)
packages/schema/src/schema.ts (1)
  • SchemaDef (11-19)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Agent
  • GitHub Check: build-test (22.x, postgresql)
🔇 Additional comments (19)
packages/clients/tanstack-query/src/common/types.ts (1)

79-90: Well-structured type definitions for custom operations.

The phantom fields (__args, __result) enable type inference without runtime overhead. The CustomOperationKind union covers all TanStack Query hook variants appropriately.

packages/clients/tanstack-query/src/react.ts (4)

135-153: Correctly typed hook generation with proper discrimination.

The mapped type appropriately transforms custom operation definitions into typed hooks, with correct return types for each operation kind.


438-496: LGTM with minor observation on default case.

The implementation correctly generates hooks for each operation kind. The default HTTP method for mutations is POST, which is sensible.

The default case silently skips unknown kinds. This is acceptable since TypeScript will catch invalid kinds at compile time through CustomOperationKind.


498-504: Appropriate default for getNextPageParam.

The default () => undefined prevents infinite queries from erroring when no pagination logic is provided, while still requiring users to supply their own logic for actual pagination use cases.


433-436: Shadowing protection is enforced on the server, not in this client-side code.

The server-side ApiHandler.validateCustomOperations() method (packages/server/src/api/rpc/index.ts:357) explicitly validates that custom operation names cannot match BUILT_IN_OPERATIONS (create, findUnique, findMany, update, delete, etc.), throwing an error if a conflict is detected. While createCustomOperationHooks itself contains no shadowing validation, the scenario described in the comment cannot occur because problematic operation names are rejected at the server before they reach the client.

packages/clients/tanstack-query/src/svelte/index.svelte.ts (2)

124-135: Type definitions appropriately simplified for Svelte.

The Svelte adapter correctly maps custom operations to Svelte-specific result types. The consolidation of suspenseQuery with regular query and combining infinite variants is appropriate given Svelte Query's API surface.


357-361: Consistent implementation with React adapter.

The merging pattern and type assertions are consistent across frameworks, which will help maintain the codebase.

packages/clients/tanstack-query/src/vue.ts (2)

125-138: Type definitions properly adapted for Vue.

The custom operation hooks type correctly uses Vue-specific result types and maintains consistency with the other framework adapters.


360-364: Clean integration of custom hooks with built-in hooks.

The implementation maintains consistency with React and Svelte adapters while properly leveraging Vue's reactivity system.

packages/server/src/api/index.ts (1)

2-9: LGTM! Clean API surface expansion.

The additional RPC exports properly expose the custom operation functionality to consumers. The export grouping is logical and consistent with the existing pattern.

packages/server/test/api/rpc.test.ts (3)

7-12: LGTM! Proper imports for custom operation testing.

The imports correctly include the new RPC error types and options interface needed for the expanded test coverage.


361-460: Strong test coverage for custom operations.

The test suite comprehensively covers:

  • Basic custom operation execution (echo)
  • Automatic SuperJSON deserialization of query parameters
  • Error mapping for RPCBadInputErrorResponse and RPCGenericErrorResponse
  • Validation constraints (no built-in shadowing, identifier naming, function type enforcement)

Consider adding a test case for custom operations that receive SuperJSON-serialized requestBody to verify the deserialization contract. Currently, the "custom operation works" test only uses a plain object { message: 'hello' } as requestBody.

Example test case to add:

it('custom operation handles SuperJSON requestBody', async () => {
    const dateValue = new Date();
    const serialized = SuperJSON.serialize({ timestamp: dateValue });
    
    const handleRequest = makeHandler({
        customOperations: {
            processData: async ({ requestBody }) => {
                // Custom ops need to deserialize requestBody themselves
                const { meta, ...rest } = requestBody as any;
                const data = meta?.serialization 
                    ? SuperJSON.deserialize({ json: rest, meta: meta.serialization })
                    : requestBody;
                return { status: 200, body: { data } };
            },
        },
    });

    const r = await handleRequest({
        method: 'post',
        path: '/post/processData',
        client: rawClient,
        requestBody: {
            ...(serialized.json as any),
            meta: { serialization: serialized.meta },
        },
    });

    expect(r.status).toBe(200);
    expect(r.data.timestamp).toBeInstanceOf(Date);
});

617-632: LGTM! Clean test helper refactoring.

The makeHandler function now accepts optional RPCApiHandlerOptions, enabling flexible test scenarios for custom operations. The spread operator correctly merges options with the required schema.

packages/server/src/api/rpc/index.ts (6)

11-29: LGTM! Well-defined constants for operation validation.

The BUILT_IN_OPERATIONS Set provides O(1) lookup performance, and the JS_IDENTIFIER_RE regex correctly validates JavaScript identifiers (starting with letter, underscore, or dollar sign, followed by alphanumeric, underscore, or dollar sign).


31-43: LGTM! Clean error and type definitions.

The error classes extend Error for proper inheritance, and RPCCustomOperationContext appropriately extends RequestContext with operation-specific context (model, operation, args). The function signature allows both sync and async implementations, which is flexible.


59-72: LGTM! Constructor validation ensures fail-fast behavior.

The customOperations option is properly documented with constraints, and validation happens at construction time to catch configuration errors early.


315-339: LGTM! Robust query normalization with proper error handling.

The normalizeQuery method correctly:

  • Handles missing query objects
  • Validates q parameter type
  • Invokes unmarshalQ for SuperJSON deserialization
  • Catches and reports parsing errors
  • Returns a structured result with normalized query, parsed args, and optional error

The implementation is defensive and provides clear error messages.


341-355: LGTM! Comprehensive error mapping for custom operations.

The error mapping correctly prioritizes:

  1. RPCBadInputErrorResponse → 400 status
  2. ORMError → ORM-specific status codes (403, 404, 422, etc.)
  3. RPCGenericErrorResponse → 500 status
  4. Unknown errors → 500 status

This provides custom operations with fine-grained error control while maintaining consistent error responses.


357-376: LGTM! Thorough validation of custom operation constraints.

The validation enforces all documented constraints:

  • Names must be valid JavaScript identifiers
  • Cannot shadow built-in operations
  • Must be functions

The validation runs at construction time, providing immediate feedback on misconfiguration.

Comment on lines +154 to 174
if (!BUILT_IN_OPERATIONS.has(op)) {
const custom = this.options.customOperations?.[op];
if (custom) {
try {
args = query?.['q']
? this.unmarshalQ(query['q'] as string, query['meta'] as string | undefined)
: {};
return await custom({
client,
method,
path,
query: normalizedQuery,
requestBody,
model,
operation: op,
args: qArgs,
});
} catch (err) {
return this.makeBadInputErrorResponse(
err instanceof Error ? err.message : 'invalid "q" query parameter',
);
return this.mapCustomOperationError(err);
}
break;
}

default:
return this.makeBadInputErrorResponse('invalid operation: ' + op);
return this.makeBadInputErrorResponse('invalid operation: ' + op);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the file around the mentioned lines to understand the context
wc -l packages/server/src/api/rpc/index.ts

Repository: zenstackhq/zenstack-v3

Length of output: 106


🏁 Script executed:

# Read the relevant section of the file
sed -n '140,190p' packages/server/src/api/rpc/index.ts

Repository: zenstackhq/zenstack-v3

Length of output: 1704


🏁 Script executed:

# Also search for processRequestPayload to understand what it does
rg "processRequestPayload" packages/server/src/api/rpc/index.ts -B 2 -A 5

Repository: zenstackhq/zenstack-v3

Length of output: 558


🏁 Script executed:

# Look for the type definition of RPCCustomOperation to understand expected interface
rg "RPCCustomOperation" packages/server/src -A 10 -B 2

Repository: zenstackhq/zenstack-v3

Length of output: 2546


🏁 Script executed:

# Read the full method to understand the data flow from HTTP request to custom/built-in operations
sed -n '100,200p' packages/server/src/api/rpc/index.ts

Repository: zenstackhq/zenstack-v3

Length of output: 3553


🏁 Script executed:

# Search for where requestBody comes from in the handler
rg "requestBody" packages/server/src/api/rpc/index.ts -B 5 -A 2

Repository: zenstackhq/zenstack-v3

Length of output: 1580


🏁 Script executed:

# Look for the complete handleRequest or similar method signature
ast-grep --pattern 'async handle($$$) {
  $$$
}'

Repository: zenstackhq/zenstack-v3

Length of output: 48


🏁 Script executed:

# Find where qArgs is set
rg "qArgs\s*=" packages/server/src/api/rpc/index.ts -B 5 -A 2

Repository: zenstackhq/zenstack-v3

Length of output: 48


🏁 Script executed:

# Search for test examples of custom operations
fd "test\|spec" packages/server --type f -name "*.ts" -o -name "*.js" | head -20

Repository: zenstackhq/zenstack-v3

Length of output: 296


🏁 Script executed:

# Look for custom operation usage examples
rg "customOperations" packages/server/src -A 3 -B 1 --type ts

Repository: zenstackhq/zenstack-v3

Length of output: 1061


🏁 Script executed:

# Check JSDoc comments on RPCCustomOperationContext type
sed -n '1,50p' packages/server/src/api/rpc/index.ts | grep -A 20 "RPCCustomOperationContext\|RequestContext"

Repository: zenstackhq/zenstack-v3

Length of output: 1011


🏁 Script executed:

# Find qArgs definition with different pattern
rg "qArgs" packages/server/src/api/rpc/index.ts -B 3 -A 3

Repository: zenstackhq/zenstack-v3

Length of output: 2081


🏁 Script executed:

# Look at the full handleRequest method
sed -n '80,250p' packages/server/src/api/rpc/index.ts

Repository: zenstackhq/zenstack-v3

Length of output: 5985


🏁 Script executed:

# Check RequestContext type to understand what it contains
rg "type RequestContext" packages/server/src -A 5 -B 1

Repository: zenstackhq/zenstack-v3

Length of output: 417


🏁 Script executed:

# Get the full RequestContext type definition
rg "type RequestContext" packages/server/src/types.ts -A 15

Repository: zenstackhq/zenstack-v3

Length of output: 361


🏁 Script executed:

# Look at unmarshalQ to understand how qArgs is created
rg "unmarshalQ" packages/server/src/api/rpc/index.ts -B 2 -A 10

Repository: zenstackhq/zenstack-v3

Length of output: 846


🏁 Script executed:

# Check the full processRequestPayload implementation
rg "private async processRequestPayload" packages/server/src/api/rpc/index.ts -A 15

Repository: zenstackhq/zenstack-v3

Length of output: 685


🏁 Script executed:

# Get full unmarshalQ implementation
sed -n '320,380p' packages/server/src/api/rpc/index.ts

Repository: zenstackhq/zenstack-v3

Length of output: 2027


🏁 Script executed:

# Also check if there are any tests for custom operations
find packages/server -name "*.test.ts" -o -name "*.spec.ts" | xargs grep -l "customOperations" 2>/dev/null

Repository: zenstackhq/zenstack-v3

Length of output: 102


🏁 Script executed:

# Check how requestBody is initially created/parsed
rg "requestBody" packages/server/src -B 5 -A 2 | head -100

Repository: zenstackhq/zenstack-v3

Length of output: 7001


🏁 Script executed:

# Check the test file for custom operations examples
cat packages/server/test/api/rpc.test.ts | head -100

Repository: zenstackhq/zenstack-v3

Length of output: 3270


🏁 Script executed:

# Search for custom operations in tests
grep -n "customOperations" packages/server/test/api/rpc.test.ts -B 5 -A 15

Repository: zenstackhq/zenstack-v3

Length of output: 3946


Custom operations lack automatic SuperJSON deserialization of request body.

Custom operations receive the raw requestBody without automatic SuperJSON deserialization, while built-in operations benefit from automatic deserialization via processRequestPayload (called after the operation type check). Though custom operations do receive auto-unmarshaled query parameters via args and query, they must handle deserialization of POST/PUT request bodies themselves if those bodies contain SuperJSON-serialized data.

Impact: Custom operations expecting SuperJSON-serialized data in requestBody must manually deserialize it, creating an inconsistent developer experience compared to built-in operations and increasing the risk of implementation errors.

Recommendation: Consider one of these approaches:

  1. Provide a helper utility that custom operations can use:
const deserialized = await handler.deserializePayload(requestBody);
  1. Auto-deserialize requestBody before passing to custom operations:
const { result: deserializedBody } = await this.processRequestPayload(requestBody);
return await custom({
    ...context,
    requestBody: deserializedBody,
});
  1. Document explicitly in the JSDoc for RPCCustomOperation type, noting that custom operations are responsible for their own SuperJSON deserialization of request body data.

@sanny-io
Copy link
Contributor

This looks very similar to the Custom Procedures that were outlined here.

https://zenstack.dev/blog/next-chapter-2#custom-procedures

There is actually some code present in V3 already to support that feature, but I don't know how complete it is.

@mwillbanks
Copy link
Contributor Author

Closing as custom operations would certainly solve this in a much better way.

@mwillbanks mwillbanks closed this Dec 30, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants