Skip to content

[PROPOSAL] feat(tanstack-query): Improvements over PR #2637 useTransaction hook for sequential transactions#2643

Open
docloulou wants to merge 2 commits intozenstackhq:devfrom
docloulou:feat/enhance-tanstack-query-use-transaction
Open

[PROPOSAL] feat(tanstack-query): Improvements over PR #2637 useTransaction hook for sequential transactions#2643
docloulou wants to merge 2 commits intozenstackhq:devfrom
docloulou:feat/enhance-tanstack-query-use-transaction

Conversation

@docloulou
Copy link
Copy Markdown
Contributor

@docloulou docloulou commented May 4, 2026

This PR builds on the sequential transaction system introduced in #2637, adding typed constructors, composable expressions.


New Features

Feature Description
Typed helpers $stepRef<T>, $get, $item, $first, $filter, $map Return serializable plain objects with full IntelliSense on field names and value types
Composable expressions $get($filter($stepRef<Post[]>(1), 'title', 'eq', 'Foo'), 'id') — arbitrary chaining
Filter on step results Filter an array step by field (eq, neq, gt, gte, lt, lte, in, notIn, contains)
Map Extract a field from every element of an array step
RPC tests 31 tests covering step refs, expressions, and edge cases

Examples

1. Simple step ref (create user → linked post)

import { $get, $stepRef } from '@zenstackhq/orm';

const ops = [
    { model: 'User', op: 'create', args: { data: { email: 'alice@test.com' } } },
    {
        model: 'Post',
        op: 'create',
        args: {
            data: {
                title: 'My Post',
                authorId: $get($stepRef<User>(1), 'id'),  // step 1 = created User
            },
        },
    },
];

2. Pick the first item from a list

$get($first($stepRef(1)), 'id')

3. Publish all drafts via filter + map

const ops = [
    { model: 'User', op: 'create', args: { data: { email } } },          // step 1
    { model: 'Post', op: 'create', args: { data: { title, published: false, authorId: $get($stepRef<User>(1), 'id') } } }, // step 2
    { model: 'Post', op: 'create', args: { data: { title, published: true, authorId: $get($stepRef<User>(1), 'id') } } },  // step 3
    { model: 'Post', op: 'findMany', args: { where: { authorId: $get($stepRef<User>(1), 'id') } } }, // step 4
    { model: 'Post', op: 'updateMany', args: {                                                                        // step 5
        where: { id: { in: $map($filter($stepRef<Post[]>(4), 'published', 'eq', false), 'id') } },
        data: { published: true },
    } },
    { model: 'Post', op: 'findMany', args: { where: { authorId: $get($stepRef<User>(1), 'id'), published: true } } }, // step 6
];

4. Using the TanStack Query hook (Vue / React)

const { mutate } = clientQueries.$transaction.useSequential({
    onSuccess(data) {
        const [user, post] = data as [User, Post];
        console.log(`Post "${post.title}" created for ${user.email}`);
    },
    onError(error) {
        console.error(error.message);
    },
});

mutate([
    { model: 'User', op: 'create', args: { data: { email } } },
    { model: 'Post', op: 'create', args: { data: { title, authorId: $get($stepRef<User>(1), 'id') } } },
]);

Summary by CodeRabbit

  • New Features

    • Sequential transactions now support referencing and composing results from earlier steps using new expression helpers ($stepRef, $get, $item, $first, $filter, $map).
    • Enhanced validation with detailed error messages for invalid transaction operations.
  • Documentation

    • Sample applications updated with sequential transaction workflow examples.

… references

- Introduced `$stepRef`, `$get`, `$filter`, and `$map` for referencing results between transaction steps.
- Updated the RPC API to handle sequential transactions, allowing operations to reference previous results.
- Enhanced the Nuxt+Next.js sample application to demonstrate the new transaction capabilities.
- Added tests to verify the functionality of step references in transactions.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

📝 Walkthrough

Walkthrough

This PR introduces a client-side transaction step expression system enabling later transaction operations to reference and manipulate values produced by earlier steps. New composable expression constructors ($stepRef, $get, $item, $first, $filter, $map) allow safe, typed chaining of transaction operations with built-in validation and circular-reference detection.

Changes

Transaction Step Expressions

Layer / File(s) Summary
Type System Foundations
packages/clients/tanstack-query/src/common/types.ts
TransactionArgValue<T> and TransactionArgs<T> now recursively permit StepExpr throughout operation args, replacing the prior optional args?: CrudArgsMap[...] with required args: TransactionArgs[...].
Expression Runtime
packages/orm/src/client/transaction.ts
Defines marker symbols (STEP_REF_SYMBOL, EXPR_SYMBOL), discriminated expression types (StepRefExpr, StepGetExpr, StepItemExpr, StepFirstExpr, StepFilterExpr, StepMapExpr), constructor helpers ($stepRef, $get, $item, $first, $filter, $map), and validation/resolution with security (forbidden-key blocking), type-aware filtering (eq/neq/gt/gte/lt/lte/in/notIn/contains), and circular-reference detection.
API Exports
packages/orm/src/client/index.ts
Re-exports transaction runtime symbols (STEP_REF_SYMBOL, EXPR_SYMBOL, isStepRef, isStepExpr, resolveStepRefs, resolveExpr, $stepRef, $get, $item, $first, $filter, $map, TransactionInputError) and new types (StepRef, StepExpr, ExprWhere, ExprFilterOp, and expression variants).
Server Integration
packages/server/src/api/rpc/index.ts
handleTransaction now executes steps sequentially inside a $transaction callback, resolving each step's args via resolveStepRefs using prior results. Adds TransactionInputError exception handling for validation failures.
Tests & Samples
packages/server/test/api/rpc.test.ts, samples/next.js/{README.md,app/page.tsx}, samples/nuxt/{README.md,app/pages/index.vue}
Comprehensive test suite validating step references, expression chaining, error handling, and atomicity. Sample apps demonstrate full sequential transaction workflows with $stepRef, $get, $filter, and $map composition.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A rabbit hops through transaction steps,
Where $stepRef marks the path it kept,
With $get and $filter at its side,
Each result becomes the next one's guide,
Sequential magic, safely typed! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and specifically describes the main change: improvements to the useTransaction hook for sequential transactions, building on PR #2637.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

… validation

- Added TransactionInputError class to handle user-facing resolution failures in transaction steps.
- Updated path resolution and expression validation functions to throw TransactionInputError for invalid inputs.
- Enhanced RPC API error handling to return bad input responses for TransactionInputError instances.
@docloulou docloulou marked this pull request as ready for review May 5, 2026 10:08
@docloulou docloulou changed the title feat(tanstack-query): Improvements over PR #2637 useTransaction hook for sequential transactions [PROPOSAL] feat(tanstack-query): Improvements over PR #2637 useTransaction hook for sequential transactions May 5, 2026
Copy link
Copy Markdown
Contributor

@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: 2

🧹 Nitpick comments (2)
packages/server/test/api/rpc.test.ts (2)

1096-1125: ⚡ Quick win

The “path omitted” scenario is not actually exercised.

At Line 1124, the test still passes a path ($stepRef(2, 'id')), so it doesn’t validate the path-omitted behavior described in the test name.

Proposed test adjustment
-                                        { id: $stepRef(2, 'id') },
+                                        { id: $get($stepRef(2), 'id') },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/test/api/rpc.test.ts` around lines 1096 - 1125, The test
named "uses entire result of a step when path is omitted" currently still passes
a path to the step reference; update the requestBody in that test so the OR
condition uses $stepRef(2) (omit the 'id' path) instead of $stepRef(2, 'id') so
the whole result of step 2 is used; keep using makeHandler(), rawClient, and the
same transaction endpoint and then adjust any assertions if necessary to assert
that the entire step result (not just its id) was applied.

1336-1359: ⚡ Quick win

This test title says “new syntax”, but the payload uses the helper syntax.

Line 1358 uses $stepRef(...), so this case doesn’t directly validate raw $zenstackExpr: 'ref' parsing.

Proposed test adjustment
-                                        authorId: $stepRef(1, 'id'),
+                                        authorId: { $zenstackExpr: 'ref', step: 1, path: 'id' },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/test/api/rpc.test.ts` around lines 1336 - 1359, The test
title claims "new syntax" but the payload uses the helper $stepRef helper;
update the test to actually exercise the raw $zenstackExpr ref form or rename
the title. Concretely, in the test that uses handleRequest in the "resolves
$zenstackExpr: ref (new syntax, equivalent to old StepRef)" case, replace the
helper call $stepRef(1, 'id') with the raw expression object { $zenstackExpr: {
op: 'ref', step: 1, key: 'id' } } (or alternatively change the test title to say
"helper syntax" if you prefer to keep $stepRef), so the code path parsing
$zenstackExpr: 'ref' is truly validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/orm/src/client/transaction.ts`:
- Around line 323-329: The current visited WeakSet tracks every seen node and
causes false-positive circular-reference errors when the same subexpression
object is reused across different branches (e.g., the shared `posts` ref), so
update the resolver to track only the active recursion stack: inside the
function that takes `expr` and `visited` (the block using `const visited =
_visited ?? new WeakSet<object>();` and throwing `new
TransactionInputError('Circular reference detected in step expression.')`), add
`visited.add(expr)` before recursing and wrap the recursive resolution body in a
try/finally that calls `visited.delete(expr)` in the finally clause so nodes are
removed on unwind and only the current call path is treated as circular. Ensure
the circular check (visited.has(expr)) remains before adding.

In `@packages/server/test/api/rpc.test.ts`:
- Around line 1618-1652: The test currently only checks r.status >= 400 which is
too broad; update the assertions in the 'errors when filter targets an unknown
operator' test (using makeHandler and handleRequest) to assert the exact failure
and cause: expect r.status toBe(400) (or the precise error code your API
returns) and add an assertion on r.body (or r.data) that the error message
includes the unknown operator indicator (e.g., contains 'unknown operator' or
the token 'regex') so the failure is tied to the invalid operator rather than
any other error.

---

Nitpick comments:
In `@packages/server/test/api/rpc.test.ts`:
- Around line 1096-1125: The test named "uses entire result of a step when path
is omitted" currently still passes a path to the step reference; update the
requestBody in that test so the OR condition uses $stepRef(2) (omit the 'id'
path) instead of $stepRef(2, 'id') so the whole result of step 2 is used; keep
using makeHandler(), rawClient, and the same transaction endpoint and then
adjust any assertions if necessary to assert that the entire step result (not
just its id) was applied.
- Around line 1336-1359: The test title claims "new syntax" but the payload uses
the helper $stepRef helper; update the test to actually exercise the raw
$zenstackExpr ref form or rename the title. Concretely, in the test that uses
handleRequest in the "resolves $zenstackExpr: ref (new syntax, equivalent to old
StepRef)" case, replace the helper call $stepRef(1, 'id') with the raw
expression object { $zenstackExpr: { op: 'ref', step: 1, key: 'id' } } (or
alternatively change the test title to say "helper syntax" if you prefer to keep
$stepRef), so the code path parsing $zenstackExpr: 'ref' is truly validated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 41a9024b-de54-4370-8487-e36f5e8b648f

📥 Commits

Reviewing files that changed from the base of the PR and between 679f91f and 17e3f3a.

📒 Files selected for processing (9)
  • packages/clients/tanstack-query/src/common/types.ts
  • packages/orm/src/client/index.ts
  • packages/orm/src/client/transaction.ts
  • packages/server/src/api/rpc/index.ts
  • packages/server/test/api/rpc.test.ts
  • samples/next.js/README.md
  • samples/next.js/app/page.tsx
  • samples/nuxt/README.md
  • samples/nuxt/app/pages/index.vue

Comment on lines +323 to +329
const visited = _visited ?? new WeakSet<object>();
if (typeof expr === 'object' && expr !== null) {
if (visited.has(expr as object)) {
throw new TransactionInputError('Circular reference detected in step expression.');
}
visited.add(expr as object);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track the active recursion stack, not every visited node.

This WeakSet causes false-positive circular-reference errors when the same subexpression object is reused in two branches of one valid expression tree. For example, const posts = $stepRef<Post[]>(4); $filter(posts, 'id', 'in', $map(posts, 'id')) will visit posts through filter.ref and then fail when map.ref resolves the same object again.

Wrap the resolution body in try/finally and visited.delete(expr) on unwind so only the current recursion path is tracked.

Suggested fix
 export function resolveExpr(
     expr: StepExpr | StepRef,
     results: unknown[],
     _visited?: WeakSet<object>,
 ): unknown {
-    // Cycle detection for client-side local expressions
     const visited = _visited ?? new WeakSet<object>();
-    if (typeof expr === 'object' && expr !== null) {
-        if (visited.has(expr as object)) {
+    const exprObject = typeof expr === 'object' && expr !== null ? (expr as object) : undefined;
+
+    if (exprObject) {
+        if (visited.has(exprObject)) {
             throw new TransactionInputError('Circular reference detected in step expression.');
         }
-        visited.add(expr as object);
+        visited.add(exprObject);
     }
 
-    // Handle old-style StepRef
-    if (isStepRef(expr)) {
-        const { step, path } = expr;
-        validateInteger(step, 'step');
-        const resultIndex = getResultIndex(step, results);
-        let value = results[resultIndex];
-        if (path) {
-            if (typeof path !== 'string') {
-                throw new TransactionInputError('"path" must be a string.');
-            }
-            value = resolvePath(value, parsePath(path));
-        }
-        return value;
-    }
-
-    // Accept plain objects with EXPR_SYMBOL
-    if (!isStepExpr(expr)) {
-        throw new TransactionInputError('Expression must be an object with a valid expression marker.');
-    }
-
-    validateExprRef(expr);
-
-    // Handle new-style StepExpr
-    const kind = (expr as Record<string, unknown>)[EXPR_SYMBOL] as string;
-    switch (kind) {
-        // existing cases...
-    }
+    try {
+        // existing resolution logic
+    } finally {
+        if (exprObject) {
+            visited.delete(exprObject);
+        }
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orm/src/client/transaction.ts` around lines 323 - 329, The current
visited WeakSet tracks every seen node and causes false-positive
circular-reference errors when the same subexpression object is reused across
different branches (e.g., the shared `posts` ref), so update the resolver to
track only the active recursion stack: inside the function that takes `expr` and
`visited` (the block using `const visited = _visited ?? new WeakSet<object>();`
and throwing `new TransactionInputError('Circular reference detected in step
expression.')`), add `visited.add(expr)` before recursing and wrap the recursive
resolution body in a try/finally that calls `visited.delete(expr)` in the
finally clause so nodes are removed on unwind and only the current call path is
treated as circular. Ensure the circular check (visited.has(expr)) remains
before adding.

Comment on lines +1618 to +1652
it('errors when filter targets an unknown operator', async () => {
const handleRequest = makeHandler();

const r = await handleRequest({
method: 'post',
path: '/$transaction/sequential',
requestBody: [
{
model: 'User',
op: 'findMany',
args: {},
},
{
model: 'User',
op: 'findFirst',
args: {
where: {
id: {
$zenstackExpr: 'get',
ref: {
$zenstackExpr: 'filter',
ref: $stepRef(1),
where: { field: 'email', op: 'regex', value: '.*' },
},
path: 'id',
},
},
},
},
],
client: rawClient,
});

expect(r.status).toBeGreaterThanOrEqual(400);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Unknown-operator test is too broad and can pass for the wrong reason.

Only checking >= 400 (Line 1651) doesn’t prove the failure is caused by the invalid regex operator. Please assert the error details to lock intent.

Proposed assertion tightening
-                expect(r.status).toBeGreaterThanOrEqual(400);
+                expect(r.status).toBe(400);
+                expect(r.error?.message).toMatch(/operator|regex/i);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('errors when filter targets an unknown operator', async () => {
const handleRequest = makeHandler();
const r = await handleRequest({
method: 'post',
path: '/$transaction/sequential',
requestBody: [
{
model: 'User',
op: 'findMany',
args: {},
},
{
model: 'User',
op: 'findFirst',
args: {
where: {
id: {
$zenstackExpr: 'get',
ref: {
$zenstackExpr: 'filter',
ref: $stepRef(1),
where: { field: 'email', op: 'regex', value: '.*' },
},
path: 'id',
},
},
},
},
],
client: rawClient,
});
expect(r.status).toBeGreaterThanOrEqual(400);
});
it('errors when filter targets an unknown operator', async () => {
const handleRequest = makeHandler();
const r = await handleRequest({
method: 'post',
path: '/$transaction/sequential',
requestBody: [
{
model: 'User',
op: 'findMany',
args: {},
},
{
model: 'User',
op: 'findFirst',
args: {
where: {
id: {
$zenstackExpr: 'get',
ref: {
$zenstackExpr: 'filter',
ref: $stepRef(1),
where: { field: 'email', op: 'regex', value: '.*' },
},
path: 'id',
},
},
},
},
],
client: rawClient,
});
expect(r.status).toBe(400);
expect(r.error?.message).toMatch(/operator|regex/i);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/test/api/rpc.test.ts` around lines 1618 - 1652, The test
currently only checks r.status >= 400 which is too broad; update the assertions
in the 'errors when filter targets an unknown operator' test (using makeHandler
and handleRequest) to assert the exact failure and cause: expect r.status
toBe(400) (or the precise error code your API returns) and add an assertion on
r.body (or r.data) that the error message includes the unknown operator
indicator (e.g., contains 'unknown operator' or the token 'regex') so the
failure is tied to the invalid operator rather than any other error.

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.

1 participant