Conversation
* 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
📝 WalkthroughWalkthroughThis 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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
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. Comment |
There was a problem hiding this comment.
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.
| for (const [name, def] of Object.entries(customOperations)) { | ||
| const hookName = `use${name.charAt(0).toUpperCase()}${name.slice(1)}`; |
There was a problem hiding this comment.
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.
|
|
||
| const { query: normalizedQuery, qArgs, error: queryError } = this.normalizeQuery(query); | ||
| if (queryError) { | ||
| return this.makeBadInputErrorResponse(queryError); |
There was a problem hiding this comment.
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.
| 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; |
| 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; |
There was a problem hiding this comment.
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.
| merged.getNextPageParam = () => undefined; | |
| throw new Error( | |
| 'Infinite custom operations require a getNextPageParam function to be provided in the query options.', | |
| ); |
| ? (options?: ModelMutationOptions<TResult, TArgs>) => ModelMutationResult<TResult, TArgs> | ||
| : CustomOperations[K]['kind'] extends 'infiniteQuery' | 'suspenseInfiniteQuery' | ||
| ? (args?: TArgs, options?: ModelInfiniteQueryOptions<TResult>) => ModelInfiniteQueryResult< | ||
| InfiniteData<TResult> |
There was a problem hiding this comment.
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.
| InfiniteData<TResult> | |
| InfiniteData<TResult[]> |
| for (const [name, def] of Object.entries(customOperations)) { | ||
| const hookName = `use${name.charAt(0).toUpperCase()}${name.slice(1)}`; |
There was a problem hiding this comment.
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.
| for (const [name, def] of Object.entries(customOperations)) { | ||
| const hookName = `use${name.charAt(0).toUpperCase()}${name.slice(1)}`; |
There was a problem hiding this comment.
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.
| export type CustomOperationKind = 'query' | 'suspenseQuery' | 'infiniteQuery' | 'suspenseInfiniteQuery' | 'mutation'; | ||
|
|
||
| export type CustomOperationDefinition<TArgs = unknown, TResult = unknown> = { | ||
| kind: CustomOperationKind; |
There was a problem hiding this comment.
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.
| 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. | |
| */ |
| 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'); | ||
| }); |
There was a problem hiding this comment.
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.
| 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, | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = () => undefinedmutates the object returned bymergedOptions(). 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.getNextPageParamwithin acomputed()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
normalizeQueryeliminates 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 unknownin normalizeQuery (lines 317, 322, 326, 335) is unusual. Consider using a more explicit type likeundefinedornullto 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
📒 Files selected for processing (7)
packages/clients/tanstack-query/src/common/types.tspackages/clients/tanstack-query/src/react.tspackages/clients/tanstack-query/src/svelte/index.svelte.tspackages/clients/tanstack-query/src/vue.tspackages/server/src/api/index.tspackages/server/src/api/rpc/index.tspackages/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.tspackages/clients/tanstack-query/src/svelte/index.svelte.tspackages/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.tspackages/clients/tanstack-query/src/svelte/index.svelte.tspackages/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. TheCustomOperationKindunion 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 forgetNextPageParam.The default
() => undefinedprevents 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 matchBUILT_IN_OPERATIONS(create, findUnique, findMany, update, delete, etc.), throwing an error if a conflict is detected. WhilecreateCustomOperationHooksitself 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
suspenseQuerywith regularqueryand 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
requestBodyto 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_OPERATIONSSet provides O(1) lookup performance, and theJS_IDENTIFIER_REregex 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
RPCCustomOperationContextappropriately extendsRequestContextwith 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
customOperationsoption 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
normalizeQuerymethod correctly:
- Handles missing query objects
- Validates
qparameter type- Invokes
unmarshalQfor 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:
RPCBadInputErrorResponse→ 400 statusORMError→ ORM-specific status codes (403, 404, 422, etc.)RPCGenericErrorResponse→ 500 status- 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.
| 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); | ||
| } |
There was a problem hiding this comment.
🧩 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.tsRepository: 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.tsRepository: 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 5Repository: 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 2Repository: 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.tsRepository: 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 2Repository: 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 2Repository: 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 -20Repository: 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 tsRepository: 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 3Repository: 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.tsRepository: 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 1Repository: zenstackhq/zenstack-v3
Length of output: 417
🏁 Script executed:
# Get the full RequestContext type definition
rg "type RequestContext" packages/server/src/types.ts -A 15Repository: 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 10Repository: 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 15Repository: zenstackhq/zenstack-v3
Length of output: 685
🏁 Script executed:
# Get full unmarshalQ implementation
sed -n '320,380p' packages/server/src/api/rpc/index.tsRepository: 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/nullRepository: 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 -100Repository: 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 -100Repository: 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 15Repository: 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:
- Provide a helper utility that custom operations can use:
const deserialized = await handler.deserializePayload(requestBody);- Auto-deserialize requestBody before passing to custom operations:
const { result: deserializedBody } = await this.processRequestPayload(requestBody);
return await custom({
...context,
requestBody: deserializedBody,
});- Document explicitly in the JSDoc for
RPCCustomOperationtype, noting that custom operations are responsible for their own SuperJSON deserialization of request body data.
|
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. |
|
Closing as custom operations would certainly solve this in a much better way. |
Some Quick Documentation on Usage:
Server: RPCApiHandler
Defining custom operations
Error handling
RPCBadInputErrorResponsefor user input issues → returns HTTP 400.RPCGenericErrorResponse(or any other error) → returns HTTP 500.ORMError→ mapped to the corresponding ORM-aware status/shape.Automatic
q/metaunmarshalIf the request includes
qandmetaquery params, the handler automatically deserializes them (SuperJSON-aware) before invoking the custom operation. The custom operation receives the parsed value onargsand also onquery.q.Request context provided to custom ops
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):
Notes and constraints
getNextPageParamif not supplied.use<CapitalizedOp>per model namespace (e.g.,hooks.post.useEcho).End-to-end flow
RPCApiHandlerand deploy endpoint (e.g.,/api/model/<model>/<op>).customOperationsconfig in TanStack Query runtime and call generated hooks.Summary by CodeRabbit
New Features
Tests
✏️ Tip: You can customize this high-level summary in your review settings.