From 5484568c2f8cb757e77d314eb0463af836d9ab0f Mon Sep 17 00:00:00 2001
From: docloulou
Date: Mon, 4 May 2026 17:43:09 +0200
Subject: [PATCH 1/2] feat(transaction): implement sequential transaction
support with step 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.
---
.../tanstack-query/src/common/types.ts | 18 +-
packages/orm/src/client/index.ts | 26 +
packages/orm/src/client/transaction.ts | 419 ++++++++++++
packages/server/src/api/rpc/index.ts | 34 +-
packages/server/test/api/rpc.test.ts | 625 +++++++++++++++++-
samples/next.js/README.md | 2 +
samples/next.js/app/page.tsx | 124 +++-
samples/nuxt/README.md | 1 +
samples/nuxt/app/pages/index.vue | 123 +++-
9 files changed, 1349 insertions(+), 23 deletions(-)
create mode 100644 packages/orm/src/client/transaction.ts
diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts
index 564e48934..c327afebe 100644
--- a/packages/clients/tanstack-query/src/common/types.ts
+++ b/packages/clients/tanstack-query/src/common/types.ts
@@ -19,6 +19,7 @@ import type {
OperationsRequiringCreate,
ProcedureFunc,
QueryOptions,
+ StepExpr,
UpdateArgs,
UpdateManyAndReturnArgs,
UpdateManyArgs,
@@ -139,6 +140,17 @@ type CrudArgsMap> = {
exists: ExistsArgs;
};
+type TransactionArgValue =
+ | T
+ | StepExpr
+ | (T extends readonly (infer U)[]
+ ? TransactionArgValue[]
+ : T extends object
+ ? { [K in keyof T]: TransactionArgValue }
+ : never);
+
+type TransactionArgs = T extends object ? { [K in keyof T]: TransactionArgValue } : TransactionArgValue;
+
/**
* Operations available for a given model, omitting create-style operations
* for models that don't allow them (e.g. delegate models).
@@ -153,11 +165,13 @@ type AllowedTransactionOps = {
[Model in GetModels]: {
[Op in AllowedTransactionOps]: {} extends CrudArgsMap[Op]
- ? { model: Model; op: Op; args?: CrudArgsMap[Op] }
- : { model: Model; op: Op; args: CrudArgsMap[Op] };
+ ? { model: Model; op: Op; args?: TransactionArgs[Op]> }
+ : { model: Model; op: Op; args: TransactionArgs[Op]> };
}[AllowedTransactionOps];
}[GetModels];
diff --git a/packages/orm/src/client/index.ts b/packages/orm/src/client/index.ts
index 7414ae1fe..92b52cffb 100644
--- a/packages/orm/src/client/index.ts
+++ b/packages/orm/src/client/index.ts
@@ -19,6 +19,32 @@ export { ORMError, ORMErrorReason, RejectedByPolicyReason } from './errors';
export * from './options';
export * from './plugin';
export type { ZenStackPromise } from './promise';
+export {
+ STEP_REF_SYMBOL,
+ EXPR_SYMBOL,
+ isStepRef,
+ isStepExpr,
+ resolveStepRefs,
+ resolveExpr,
+ $stepRef,
+ $get,
+ $item,
+ $first,
+ $filter,
+ $map,
+} from './transaction';
+export type {
+ StepRef,
+ StepExpr,
+ ExprWhere,
+ ExprFilterOp,
+ StepRefExpr,
+ StepGetExpr,
+ StepItemExpr,
+ StepFirstExpr,
+ StepFilterExpr,
+ StepMapExpr,
+} from './transaction';
export type { ToKysely } from './query-builder';
export * as QueryUtils from './query-utils';
export type * from './type-utils';
diff --git a/packages/orm/src/client/transaction.ts b/packages/orm/src/client/transaction.ts
new file mode 100644
index 000000000..692afc165
--- /dev/null
+++ b/packages/orm/src/client/transaction.ts
@@ -0,0 +1,419 @@
+export const STEP_REF_SYMBOL = '$zenstackStepRef';
+export const EXPR_SYMBOL = '$zenstackExpr';
+
+// ---- Expression Type System ----
+
+declare const STEP_EXPR_VALUE: unique symbol;
+
+export type ExprFilterOp = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn' | 'contains';
+
+type StringKey = Extract;
+type ExprValueCarrier = { readonly [STEP_EXPR_VALUE]?: T };
+
+/**
+ * Condition for filtering array results.
+ *
+ * `field` and `value` become type-safe when the source expression is typed,
+ * for example: `$filter($stepRef(1), 'title', 'eq', 'Target')`.
+ * The `value` can itself contain nested expressions.
+ */
+export type ExprWhere = {
+ field: string;
+ op: ExprFilterOp;
+ value: unknown;
+};
+
+type StepRefShape = {
+ [EXPR_SYMBOL]: 'ref';
+ /** Number of the step whose result to reference (1-based). */
+ step: number;
+ /**
+ * Dot-separated path to extract from the step's result.
+ * Supports array bracket notation: `items[0].id`
+ */
+ path?: string;
+};
+
+type StepGetShape = {
+ [EXPR_SYMBOL]: 'get';
+ /** The expression whose result to extract a field from. */
+ ref: StepExpr;
+ /** Dot-separated path to extract. */
+ path: string;
+};
+
+type StepItemShape = {
+ [EXPR_SYMBOL]: 'item';
+ /** The expression producing an array. */
+ ref: StepExpr;
+ /** 0-based index into the array. */
+ index: number;
+};
+
+type StepFirstShape = {
+ [EXPR_SYMBOL]: 'first';
+ /** The expression producing an array. Returns the first element. */
+ ref: StepExpr;
+};
+
+type StepFilterShape = {
+ [EXPR_SYMBOL]: 'filter';
+ /** The expression producing an array to filter. */
+ ref: StepExpr;
+ /** Condition to filter by. */
+ where: ExprWhere;
+};
+
+type StepMapShape = {
+ [EXPR_SYMBOL]: 'map';
+ /** The expression producing an array. */
+ ref: StepExpr;
+ /** Field name to extract from each element. */
+ extract: string;
+};
+
+type StepExprShape = StepRefShape | StepGetShape | StepItemShape | StepFirstShape | StepFilterShape | StepMapShape;
+
+export type StepRefExpr = ExprValueCarrier & StepRefShape;
+export type StepGetExpr = ExprValueCarrier & StepGetShape;
+export type StepItemExpr = ExprValueCarrier & StepItemShape;
+export type StepFirstExpr = ExprValueCarrier & StepFirstShape;
+export type StepFilterExpr> = ExprValueCarrier & StepFilterShape;
+export type StepMapExpr = ExprValueCarrier & StepMapShape;
+
+type ExprFilterValue = TOp extends 'in' | 'notIn'
+ ? readonly TValue[] | StepExpr
+ : TOp extends 'contains'
+ ? TValue extends readonly (infer Item)[]
+ ? Item | StepExpr-
+ : TValue extends string
+ ? string | StepExpr
+ : TValue | StepExpr
+ : TValue | StepExpr;
+
+/**
+ * Discriminated union of all supported step expressions.
+ * Each expression resolves to a value at runtime, using accumulated
+ * results from previous transaction steps.
+ *
+ * Expressions compose: where an expression is expected, you can pass
+ * any StepExpr — enabling chains like "filter an array then pick a field".
+ */
+export type StepExpr = ExprValueCarrier & StepExprShape;
+
+/** Backward-compatible simple step reference. */
+export type StepRef = {
+ [STEP_REF_SYMBOL]: true;
+ step: number;
+ path?: string;
+};
+
+// ---- Typed constructor helpers ----
+// These provide full IntelliSense and type safety when building expressions.
+// Since they return plain objects, they survive JSON serialization for RPC usage.
+
+/**
+ * References the result of a previous sequential transaction step.
+ *
+ * Pass a generic type to make later helpers field-aware:
+ * `$stepRef(1)` enables `$filter(..., 'title', 'eq', 'Target')`
+ * with autocomplete for `title` and a string-typed value.
+ */
+export function $stepRef(step: number, path?: string): StepRefExpr {
+ return path !== undefined ? { [EXPR_SYMBOL]: 'ref', step, path } : { [EXPR_SYMBOL]: 'ref', step };
+}
+
+/**
+ * Extracts a field/path from another step expression.
+ *
+ * If the referenced expression is typed, top-level keys are suggested and the
+ * returned expression carries the selected field type.
+ */
+export function $get>(ref: StepExpr, path: TPath): StepGetExpr;
+export function $get(ref: StepExpr, path: string): StepGetExpr;
+export function $get(ref: StepExpr, path: string): StepGetExpr {
+ return { [EXPR_SYMBOL]: 'get', ref, path };
+}
+
+/**
+ * Picks one item from an array-valued expression by zero-based index.
+ */
+export function $item(ref: StepExpr, index: number): StepItemExpr;
+export function $item(ref: StepExpr, index: number): StepItemExpr;
+export function $item(ref: StepExpr, index: number): StepItemExpr {
+ return { [EXPR_SYMBOL]: 'item', ref, index };
+}
+
+/**
+ * Picks the first item from an array-valued expression.
+ */
+export function $first(ref: StepExpr): StepFirstExpr;
+export function $first(ref: StepExpr): StepFirstExpr;
+export function $first(ref: StepExpr): StepFirstExpr {
+ return { [EXPR_SYMBOL]: 'first', ref };
+}
+
+/**
+ * Filters an array-valued expression by a field condition.
+ *
+ * Use a typed step reference for field/value IntelliSense:
+ * `$filter($stepRef(1), 'title', 'eq', 'Target')` suggests `title`
+ * and requires the value to be compatible with `Post['title']`.
+ */
+export function $filter, TOp extends ExprFilterOp>(
+ ref: StepExpr,
+ field: TField,
+ op: TOp,
+ value: ExprFilterValue,
+): StepFilterExpr;
+export function $filter(ref: StepExpr, field: string, op: ExprFilterOp, value: unknown): StepFilterExpr;
+export function $filter(ref: StepExpr, field: string, op: ExprFilterOp, value: unknown): StepFilterExpr {
+ return { [EXPR_SYMBOL]: 'filter', ref, where: { field, op, value } };
+}
+
+/**
+ * Extracts one field from every item of an array-valued expression.
+ *
+ * With a typed array expression, `extract` autocompletes from the item keys and
+ * the returned expression carries the extracted field array type.
+ */
+export function $map>(ref: StepExpr, extract: TField): StepMapExpr;
+export function $map(ref: StepExpr, extract: string): StepMapExpr;
+export function $map(ref: StepExpr, extract: string): StepMapExpr {
+ return { [EXPR_SYMBOL]: 'map', ref, extract };
+}
+
+// ---- Detection helpers ----
+
+export function isStepRef(value: unknown): value is StepRef {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
+ const v = value as Record;
+ return (
+ STEP_REF_SYMBOL in v &&
+ v[STEP_REF_SYMBOL] === true
+ );
+}
+
+export function isStepExpr(value: unknown): value is StepExpr {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
+ const v = value as Record;
+ return typeof v[EXPR_SYMBOL] === 'string' && EXPR_SYMBOL in v;
+}
+
+/** True if value is EITHER a StepRef or a StepExpr. */
+export function isAnyRef(value: unknown): value is StepRef | StepExpr {
+ return isStepRef(value) || isStepExpr(value);
+}
+
+// ---- Path resolution ----
+
+type PathSegment = string | number;
+
+function parsePath(path: string): PathSegment[] {
+ const segments: PathSegment[] = [];
+ const parts = path.split('.');
+ for (const part of parts) {
+ const bracketMatch = part.match(/^(\w+)\[(\d+)\]$/);
+ if (bracketMatch) {
+ segments.push(bracketMatch[1]!);
+ segments.push(parseInt(bracketMatch[2]!, 10));
+ } else {
+ segments.push(part);
+ }
+ }
+ return segments;
+}
+
+function resolvePath(obj: unknown, segments: PathSegment[]): unknown {
+ let current = obj;
+ for (const segment of segments) {
+ if (current == null || typeof current !== 'object') {
+ throw new Error(
+ `Cannot resolve path segment "${segment}": value is ${current === null ? 'null' : typeof current}`,
+ );
+ }
+ if (Array.isArray(current) && typeof segment === 'number') {
+ if (segment < 0 || segment >= current.length) {
+ throw new Error(`Array index ${segment} is out of bounds. Array has ${current.length} elements.`);
+ }
+ current = current[segment];
+ } else if (typeof segment === 'string' && segment in current) {
+ current = (current as Record)[segment];
+ } else {
+ throw new Error(
+ `Cannot resolve path segment "${segment}" on ${Array.isArray(current) ? 'array' : typeof current}`,
+ );
+ }
+ }
+ return current;
+}
+
+// ---- Expression resolution ----
+
+/**
+ * Resolves a StepRef or StepExpr against accumulated step results.
+ * Handles both the old `$zenstackStepRef` format and the new `$zenstackExpr` format.
+ */
+export function resolveExpr(expr: StepExpr | StepRef, results: unknown[]): unknown {
+ // Handle old-style StepRef
+ if (isStepRef(expr)) {
+ const { step, path } = expr;
+ const resultIndex = getResultIndex(step, results);
+ let value = results[resultIndex];
+ if (path) {
+ value = resolvePath(value, parsePath(path));
+ }
+ return value;
+ }
+
+ // Handle new-style StepExpr
+ const kind = expr[EXPR_SYMBOL];
+ switch (kind) {
+ case 'ref': {
+ const { step, path } = expr as Extract;
+ const resultIndex = getResultIndex(step, results);
+ let value = results[resultIndex];
+ if (path) {
+ value = resolvePath(value, parsePath(path));
+ }
+ return value;
+ }
+
+ case 'get': {
+ const { ref, path } = expr as Extract;
+ const resolved = resolveExpr(ref, results);
+ return resolvePath(resolved, parsePath(path));
+ }
+
+ case 'item': {
+ const { ref, index } = expr as Extract;
+ const resolved = resolveExpr(ref, results);
+ ensureArray(resolved, 'item', index);
+ const arr = resolved as unknown[];
+ if (index < 0 || index >= arr.length) {
+ throw new Error(`Array index ${index} is out of bounds. Array has ${arr.length} elements.`);
+ }
+ return arr[index];
+ }
+
+ case 'first': {
+ const { ref } = expr as Extract;
+ const resolved = resolveExpr(ref, results);
+ ensureArray(resolved, 'first');
+ const arr = resolved as unknown[];
+ if (arr.length === 0) {
+ throw new Error('Cannot get first element of an empty array.');
+ }
+ return arr[0];
+ }
+
+ case 'filter': {
+ const { ref, where } = expr as Extract;
+ const resolved = resolveExpr(ref, results);
+ ensureArray(resolved, 'filter');
+ const arr = resolved as Record[];
+ const resolvedValue = isAnyRef(where.value) ? resolveExpr(where.value, results) : where.value;
+ return arr.filter((item) => matchCondition(item, where.field, where.op, resolvedValue));
+ }
+
+ case 'map': {
+ const { ref, extract } = expr as Extract;
+ const resolved = resolveExpr(ref, results);
+ ensureArray(resolved, 'map');
+ const arr = resolved as Record[];
+ return arr.map((item) => {
+ if (!(extract in item)) {
+ throw new Error(`Field "${extract}" not found in array element. Available fields: ${Object.keys(item).join(', ')}`);
+ }
+ return item[extract];
+ });
+ }
+
+ default:
+ throw new Error(`Unknown expression type: "${kind}". Supported types: ref, get, item, first, filter, map`);
+ }
+}
+
+function getResultIndex(step: number, results: unknown[]) {
+ if (step < 1 || step > results.length) {
+ throw new Error(
+ `Step reference to number ${step} is out of bounds. ` +
+ `Step references are 1-based, and there are ${results.length} result(s) available from previous steps ` +
+ `(steps 1..${results.length}).`,
+ );
+ }
+ return step - 1;
+}
+
+function ensureArray(value: unknown, op: string, index?: number): asserts value is unknown[] {
+ if (!Array.isArray(value)) {
+ const hint = index !== undefined ? ` at index ${index}` : '';
+ throw new Error(
+ `Cannot apply "${op}"${hint}: the resolved value is not an array (got ${getValueTypeName(value)}). ` +
+ `Use a "ref" or "get" expression that points to an array result (e.g., from findMany).`,
+ );
+ }
+}
+
+function getValueTypeName(value: unknown) {
+ if (typeof value !== 'object' || value === null) {
+ return typeof value;
+ }
+ return value.constructor?.name || typeof value;
+}
+
+function matchCondition(item: Record, field: string, op: string, value: unknown): boolean {
+ const actual = item[field];
+ switch (op) {
+ case 'eq':
+ return actual === value;
+ case 'neq':
+ return actual !== value;
+ case 'gt':
+ return typeof actual === 'number' && typeof value === 'number' && actual > value;
+ case 'gte':
+ return typeof actual === 'number' && typeof value === 'number' && actual >= value;
+ case 'lt':
+ return typeof actual === 'number' && typeof value === 'number' && actual < value;
+ case 'lte':
+ return typeof actual === 'number' && typeof value === 'number' && actual <= value;
+ case 'in':
+ return Array.isArray(value) && value.includes(actual);
+ case 'notIn':
+ return Array.isArray(value) && !value.includes(actual);
+ case 'contains':
+ return typeof actual === 'string' && typeof value === 'string' && actual.includes(value);
+ default:
+ throw new Error(`Unknown filter operator: "${op}". Supported: eq, neq, gt, gte, lt, lte, in, notIn, contains`);
+ }
+}
+
+// ---- Public entry point (used by RPC handler) ----
+
+/**
+ * Walks through args recursively and resolves any StepRef or StepExpr markers
+ * using the accumulated results from previous steps.
+ *
+ * Handles both formats:
+ * - Old: `{ $zenstackStepRef: true, step: 1, path: 'id' }`
+ * - New: `{ $zenstackExpr: 'ref', step: 1, path: 'id' }` and compositions
+ */
+export function resolveStepRefs(args: unknown, results: unknown[]): unknown {
+ if (isAnyRef(args)) {
+ return resolveExpr(args, results);
+ }
+
+ if (Array.isArray(args)) {
+ return args.map((item) => resolveStepRefs(item, results));
+ }
+
+ if (args && typeof args === 'object' && Object.getPrototypeOf(args) === Object.prototype) {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(args)) {
+ result[key] = resolveStepRefs(value, results);
+ }
+ return result;
+ }
+
+ return args;
+}
diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts
index 024685a98..0979ad03f 100644
--- a/packages/server/src/api/rpc/index.ts
+++ b/packages/server/src/api/rpc/index.ts
@@ -1,5 +1,11 @@
import { lowerCaseFirst, safeJSONStringify } from '@zenstackhq/common-helpers';
-import { CoreCrudOperations, ORMError, ORMErrorReason, type ClientContract } from '@zenstackhq/orm';
+import {
+ CoreCrudOperations,
+ ORMError,
+ ORMErrorReason,
+ resolveStepRefs,
+ type ClientContract,
+} from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import SuperJSON from 'superjson';
import { match } from 'ts-pattern';
@@ -261,13 +267,27 @@ export class RPCApiHandler implements ApiH
}
try {
- const promises = processedOps.map(({ model, op, args }) => {
- return (client as any)[model][op](args);
- });
-
- log(this.options.log, 'debug', () => `handling "$transaction" request with ${promises.length} operations`);
+ log(
+ this.options.log,
+ 'debug',
+ () => `handling "$transaction" request with ${processedOps.length} operations`,
+ );
- const clientResult = await client.$transaction(promises as any);
+ const clientResult = await client.$transaction(async (tx) => {
+ const results: unknown[] = [];
+ for (const opDef of processedOps) {
+ const resolvedArgs = resolveStepRefs(opDef.args, results);
+ log(
+ this.options.log,
+ 'debug',
+ () =>
+ `executing transaction step ${results.length + 1}: ${opDef.model}.${opDef.op} with resolved args: ${safeJSONStringify(resolvedArgs)}`,
+ );
+ const result = await (tx as any)[opDef.model][opDef.op](resolvedArgs);
+ results.push(result);
+ }
+ return results;
+ });
const { json, meta } = SuperJSON.serialize(clientResult);
const responseBody: any = { data: json };
diff --git a/packages/server/test/api/rpc.test.ts b/packages/server/test/api/rpc.test.ts
index d98fecaa0..6a034dc15 100644
--- a/packages/server/test/api/rpc.test.ts
+++ b/packages/server/test/api/rpc.test.ts
@@ -1,4 +1,12 @@
-import { ClientContract } from '@zenstackhq/orm';
+import {
+ ClientContract,
+ $stepRef,
+ $get,
+ $item,
+ $first,
+ $filter,
+ $map,
+} from '@zenstackhq/orm';
import { SchemaDef } from '@zenstackhq/orm/schema';
import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools';
import Decimal from 'decimal.js';
@@ -7,6 +15,14 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { RPCApiHandler } from '../../src/api';
import { schema } from '../utils';
+type TestPost = {
+ id: string;
+ title: string;
+ authorId: string | null;
+ published: boolean;
+ viewCount: number;
+};
+
describe('RPC API Handler Tests', () => {
let client: ClientContract;
let rawClient: ClientContract;
@@ -1029,6 +1045,613 @@ procedure echoOverview(o: Overview): Overview
const count = await rawClient.user.count();
expect(count).toBe(0);
});
+
+ describe('step references', () => {
+ it('passes result from one step to the next by path', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'stepuser1', email: 'stepuser1@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ id: 'steppost1',
+ title: 'Step Post',
+ authorId: $stepRef(1, 'id'),
+ },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'findMany',
+ args: { where: { authorId: $stepRef(1, 'id') } },
+ },
+ ],
+ client: rawClient,
+ });
+ expect(r.status).toBe(200);
+ expect(Array.isArray(r.data)).toBe(true);
+ expect(r.data).toHaveLength(3);
+ expect(r.data[0]).toMatchObject({ id: 'stepuser1', email: 'stepuser1@abc.com' });
+ expect(r.data[1]).toMatchObject({ id: 'steppost1', title: 'Step Post', authorId: 'stepuser1' });
+ expect(r.data[2]).toHaveLength(1);
+ expect(r.data[2][0]).toMatchObject({ id: 'steppost1' });
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('uses entire result of a step when path is omitted', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'stepuser2', email: 'stepuser2@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: { id: 'steppost2', title: 'Step Post 2' },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'findMany',
+ args: {
+ where: {
+ OR: [
+ { id: $stepRef(2, 'id') },
+ ],
+ },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBe(200);
+ expect(r.data[2]).toHaveLength(1);
+ expect(r.data[2][0]).toMatchObject({ id: 'steppost2' });
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('mixes queries and mutations with step refs', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'stepuser3', email: 'stepuser3@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ id: 'steppost3',
+ title: 'Step Post 3',
+ authorId: $stepRef(1, 'id'),
+ },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'findUnique',
+ args: { where: { id: $stepRef(2, 'id') } },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBe(200);
+ expect(r.data[0]).toMatchObject({ id: 'stepuser3' });
+ expect(r.data[1]).toMatchObject({ id: 'steppost3', authorId: 'stepuser3' });
+ expect(r.data[2]).toMatchObject({ id: 'steppost3', authorId: 'stepuser3' });
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('throws an error when referencing a step index that does not exist', async () => {
+ const handleRequest = makeHandler();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'stepuser4', email: 'stepuser4@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ id: 'steppost4',
+ title: 'Broken Post',
+ authorId: $stepRef(5, 'id'),
+ },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBeGreaterThanOrEqual(400);
+ expect(r.error?.message).toMatch(/out of bounds/i);
+
+ // Clean up
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('throws an error when referencing step 0 because steps are 1-based', async () => {
+ const handleRequest = makeHandler();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'stepuser0', email: 'stepuser0@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ id: 'steppost0',
+ title: 'Zero Step Post',
+ authorId: $stepRef(0, 'id'),
+ },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBeGreaterThanOrEqual(400);
+ expect(r.error?.message).toMatch(/1-based/i);
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('maintains atomicity when a step ref is invalid', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.user.deleteMany();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'stepuser5', email: 'stepuser5@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ id: 'steppost5',
+ title: 'Rollback Post',
+ authorId: $stepRef(99, 'id'),
+ },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBeGreaterThanOrEqual(400);
+
+ // User should NOT have been committed because the transaction rolled back
+ const count = await rawClient.user.count();
+ expect(count).toBe(0);
+ });
+
+ it('resolves step refs in deeply nested args', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'stepuser6', email: 'stepuser6@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ id: 'steppost6',
+ title: 'Nested Ref Post',
+ authorId: $stepRef(1, 'id'),
+ },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'update',
+ args: {
+ where: { id: $stepRef(2, 'id') },
+ data: { title: 'Updated Nested Ref Post' },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBe(200);
+ expect(r.data[2]).toMatchObject({ id: 'steppost6', title: 'Updated Nested Ref Post' });
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ describe('expressions', () => {
+ it('resolves $zenstackExpr: ref (new syntax, equivalent to old StepRef)', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'expruser1', email: 'expruser1@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ id: 'exprpost1',
+ title: 'Expr Post 1',
+ authorId: $stepRef(1, 'id'),
+ },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBe(200);
+ expect(r.data[1]).toMatchObject({ id: 'exprpost1', authorId: 'expruser1' });
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('calls findMany then uses item to pick a specific result', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ // Create user with 3 posts
+ await rawClient.user.create({
+ data: {
+ id: 'expruser2',
+ email: 'expruser2@abc.com',
+ posts: {
+ create: [
+ { id: 'p1', title: 'Alpha' },
+ { id: 'p2', title: 'Beta' },
+ { id: 'p3', title: 'Gamma' },
+ ],
+ },
+ },
+ });
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'Post',
+ op: 'findMany',
+ args: { where: { authorId: 'expruser2' }, orderBy: { title: 'asc' } },
+ },
+ {
+ model: 'Post',
+ op: 'update',
+ args: {
+ where: {
+ id: $get($item($stepRef(1), 1), 'id'),
+ },
+ data: { title: 'Beta Updated' },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBe(200);
+ expect(r.data[1]).toMatchObject({ id: 'p2', title: 'Beta Updated' });
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('calls findMany then uses first to get the first result', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ await rawClient.user.create({
+ data: {
+ id: 'expruser3',
+ email: 'expruser3@abc.com',
+ posts: {
+ create: [
+ { id: 'p4', title: 'Delta' },
+ { id: 'p5', title: 'Epsilon' },
+ ],
+ },
+ },
+ });
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'Post',
+ op: 'findMany',
+ args: { where: { authorId: 'expruser3' }, orderBy: { title: 'asc' } },
+ },
+ {
+ model: 'Post',
+ op: 'delete',
+ args: {
+ where: {
+ id: $get($first($stepRef(1)), 'id'),
+ },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBe(200);
+ expect(r.data[1]).toMatchObject({ id: 'p4' });
+
+ // Verify the other post remains
+ const remaining = ((await rawClient.post.findMany()) as TestPost[]).filter((post) => post.authorId === 'expruser3').length;
+ expect(remaining).toBe(1);
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('uses filter expression to find matching elements then extracts a field with map', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ await rawClient.user.create({
+ data: {
+ id: 'expruser4',
+ email: 'expruser4@abc.com',
+ posts: {
+ create: [
+ { id: 'p6', title: 'Published1', published: true, viewCount: 10 },
+ { id: 'p7', title: 'Draft1', published: false, viewCount: 5 },
+ { id: 'p8', title: 'Published2', published: true, viewCount: 20 },
+ ],
+ },
+ },
+ });
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'Post',
+ op: 'findMany',
+ args: { where: { authorId: 'expruser4' } },
+ },
+ {
+ model: 'Post',
+ op: 'updateMany',
+ args: {
+ where: {
+ id: {
+ in: $map($filter($stepRef(1), 'published', 'eq', true), 'id'),
+ },
+ },
+ data: { viewCount: 999 },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBe(200);
+
+ // Verify only published posts were updated
+ const userPosts = ((await rawClient.post.findMany()) as TestPost[]).filter((post) => post.authorId === 'expruser4');
+ expect(userPosts.filter((post) => post.viewCount === 999)).toHaveLength(2);
+
+ expect(userPosts.find((post) => post.id === 'p7')).toMatchObject({ viewCount: 5 });
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('chains filter with get to reference a specific field from a filtered array item', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ await rawClient.user.create({
+ data: {
+ id: 'expruser5',
+ email: 'expruser5@abc.com',
+ posts: {
+ create: [
+ { id: 'p9', title: 'Target', published: true },
+ ],
+ },
+ },
+ });
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'Post',
+ op: 'findMany',
+ args: { where: { authorId: 'expruser5' } },
+ },
+ {
+ model: 'Post',
+ op: 'update',
+ args: {
+ where: {
+ id: $get($first($filter($stepRef(1), 'title', 'eq', 'Target')), 'id'),
+ },
+ data: { title: 'Target Updated' },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBe(200);
+ expect(r.data[1]).toMatchObject({ title: 'Target Updated' });
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ it('errors when using item on a non-array result', async () => {
+ const handleRequest = makeHandler();
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+
+ const r = await handleRequest({
+ method: 'post',
+ path: '/$transaction/sequential',
+ requestBody: [
+ {
+ model: 'User',
+ op: 'create',
+ args: { data: { id: 'expruser6', email: 'expruser6@abc.com' } },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ id: 'exprpost6',
+ title: 'Bad Ref',
+ authorId: $get($item($stepRef(1), 0), 'id'),
+ },
+ },
+ },
+ ],
+ client: rawClient,
+ });
+
+ expect(r.status).toBeGreaterThanOrEqual(400);
+ expect(r.error?.message).toMatch(/not an array/i);
+
+ await rawClient.post.deleteMany();
+ await rawClient.user.deleteMany();
+ });
+
+ 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);
+ });
+ });
+ });
});
function makeHandler() {
diff --git a/samples/next.js/README.md b/samples/next.js/README.md
index a88892ae6..e4a2051e7 100644
--- a/samples/next.js/README.md
+++ b/samples/next.js/README.md
@@ -1,5 +1,7 @@
This is a sample project demonstrating using [Next.js](https://nextjs.org) and [TanStack Query](https://tanstack.com/query) with [ZenStack v3](https://zenstack.dev/v3).
+It includes CRUD, optimistic updates, public-feed queries, and a sequential transaction example using `$stepRef`/`$get`/`$filter`/`$map`.
+
## Getting Started
- pnpm install
diff --git a/samples/next.js/app/page.tsx b/samples/next.js/app/page.tsx
index 9dee7770c..d7729e3b1 100644
--- a/samples/next.js/app/page.tsx
+++ b/samples/next.js/app/page.tsx
@@ -1,7 +1,8 @@
'use client';
-import { Post } from '@/zenstack/models';
+import { Post, User } from '@/zenstack/models';
import { schema } from '@/zenstack/schema-lite';
+import { $filter, $get, $map, $stepRef } from '@zenstackhq/orm';
import { FetchFn, useClientQueries } from '@zenstackhq/tanstack-query/react';
import { LoremIpsum } from 'lorem-ipsum';
import Link from 'next/link';
@@ -9,10 +10,14 @@ import { useState } from 'react';
const lorem = new LoremIpsum({ wordsPerSentence: { max: 6, min: 4 } });
+type TransactionBatchResult = [User, Post, Post, Post[], unknown, Post[]];
+
export default function Home() {
const [showPublishedOnly, setShowPublishedOnly] = useState(false);
const [enableFetch, setEnableFetch] = useState(true);
const [optimistic, setOptimistic] = useState(false);
+ const [transactionMessage, setTransactionMessage] = useState('');
+ const [transactionSucceeded, setTransactionSucceeded] = useState(false);
const fetch: FetchFn = async (url, init) => {
// simulate a delay for showing optimistic update effect
@@ -35,6 +40,22 @@ export default function Home() {
const createPost = clientQueries.post.useCreate({ optimisticUpdate: optimistic });
const deletePost = clientQueries.post.useDelete({ optimisticUpdate: optimistic });
const updatePost = clientQueries.post.useUpdate({ optimisticUpdate: optimistic });
+ const { mutate: runTransaction, isPending: isCreatingTransaction } = clientQueries.$transaction.useSequential({
+ onSuccess(data) {
+ const [user, draftPost, publicPost, postsBeforePublish, , publishedPosts] = data as TransactionBatchResult;
+ const publishedDraftCount = postsBeforePublish.filter((post) => !post.published).length;
+ setTransactionSucceeded(true);
+ setTransactionMessage(
+ `Created ${user.email}, then published ${publishedDraftCount} draft ` +
+ `using ids mapped from step 4. Final public posts: ${publishedPosts.length} ` +
+ `(${draftPost.title}, ${publicPost.title}).`,
+ );
+ },
+ onError(error) {
+ setTransactionSucceeded(false);
+ setTransactionMessage(error.message);
+ },
+ });
const onCreatePost = () => {
if (!users) {
@@ -69,6 +90,79 @@ export default function Home() {
});
};
+ const onCreateTransactionPost = () => {
+ setTransactionMessage('');
+ setTransactionSucceeded(false);
+
+ const suffix = `${Date.now()}-${Math.floor(Math.random() * 1000)}`;
+ const draftTitle = `Draft ${lorem.generateWords()}`;
+ const publicTitle = `Public ${lorem.generateWords()}`;
+ const email = `transaction-${suffix}@example.com`;
+
+ runTransaction([
+ {
+ model: 'User',
+ op: 'create',
+ args: {
+ data: {
+ email,
+ name: 'Transaction User',
+ },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ title: draftTitle,
+ published: false,
+ authorId: $get($stepRef(1), 'id'),
+ },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'create',
+ args: {
+ data: {
+ title: publicTitle,
+ published: true,
+ authorId: $get($stepRef(1), 'id'),
+ },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'findMany',
+ args: {
+ where: { authorId: $get($stepRef(1), 'id') },
+ orderBy: { createdAt: 'asc' },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'updateMany',
+ args: {
+ where: {
+ id: {
+ in: $map($filter($stepRef(4), 'published', 'eq', false), 'id'),
+ },
+ },
+ data: { published: true },
+ },
+ },
+ {
+ model: 'Post',
+ op: 'findMany',
+ args: {
+ where: { authorId: $get($stepRef(1), 'id'), published: true },
+ orderBy: { createdAt: 'asc' },
+ },
+ },
+ ]);
+ };
+
if (isUsersFetched && (!users || users.length === 0)) {
return
No users found. Please run "pnpm db:init" to seed the database.
;
}
@@ -94,12 +188,28 @@ export default function Home() {
-
+
+
+
+
+
+
+ {transactionMessage && (
+
+ {transactionMessage}
+
+ )}
Current users
diff --git a/samples/nuxt/README.md b/samples/nuxt/README.md
index b7700ace6..42274504d 100644
--- a/samples/nuxt/README.md
+++ b/samples/nuxt/README.md
@@ -8,6 +8,7 @@ A simple blog application built with Nuxt, ZenStack ORM, and TanStack Query Vue
- User management
- Published/draft post filtering
- Optimistic updates
+- Sequential transaction example using `$stepRef`/`$get`/`$filter`/`$map`
- TanStack Query Vue integration with ZenStack
## Getting Started
diff --git a/samples/nuxt/app/pages/index.vue b/samples/nuxt/app/pages/index.vue
index 068d2f962..e8f04d516 100644
--- a/samples/nuxt/app/pages/index.vue
+++ b/samples/nuxt/app/pages/index.vue
@@ -1,15 +1,20 @@
@@ -91,12 +184,30 @@ const onTogglePublishPost = (post: Post) => {
-
+ {{ transactionMessage }}
+
Current users
From 17e3f3ac049b0e59db3995b8236df93c06d8246f Mon Sep 17 00:00:00 2001
From: docloulou
Date: Tue, 5 May 2026 12:08:27 +0200
Subject: [PATCH 2/2] refactor(transaction): introduce TransactionInputError
for user input 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.
---
packages/orm/src/client/index.ts | 1 +
packages/orm/src/client/transaction.ts | 255 +++++++++++++++++++++----
packages/server/src/api/rpc/index.ts | 4 +
3 files changed, 225 insertions(+), 35 deletions(-)
diff --git a/packages/orm/src/client/index.ts b/packages/orm/src/client/index.ts
index 92b52cffb..9b98b3dd8 100644
--- a/packages/orm/src/client/index.ts
+++ b/packages/orm/src/client/index.ts
@@ -32,6 +32,7 @@ export {
$first,
$filter,
$map,
+ TransactionInputError,
} from './transaction';
export type {
StepRef,
diff --git a/packages/orm/src/client/transaction.ts b/packages/orm/src/client/transaction.ts
index 692afc165..0048e23dc 100644
--- a/packages/orm/src/client/transaction.ts
+++ b/packages/orm/src/client/transaction.ts
@@ -30,7 +30,7 @@ type StepRefShape = {
/**
* Dot-separated path to extract from the step's result.
* Supports array bracket notation: `items[0].id`
- */
+ */
path?: string;
};
@@ -183,13 +183,26 @@ export function $map(ref: StepExpr, extract: string): StepMapExpr {
return { [EXPR_SYMBOL]: 'map', ref, extract };
}
+// ---- Error type for user-facing resolution failures ----
+
+/**
+ * Error thrown when a step expression cannot be resolved due to user input.
+ * Distinguished from internal errors so callers can return 4xx instead of 5xx.
+ */
+export class TransactionInputError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'TransactionInputError';
+ }
+}
+
// ---- Detection helpers ----
export function isStepRef(value: unknown): value is StepRef {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const v = value as Record;
return (
- STEP_REF_SYMBOL in v &&
+ Object.prototype.hasOwnProperty.call(v, STEP_REF_SYMBOL) &&
v[STEP_REF_SYMBOL] === true
);
}
@@ -197,7 +210,7 @@ export function isStepRef(value: unknown): value is StepRef {
export function isStepExpr(value: unknown): value is StepExpr {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const v = value as Record;
- return typeof v[EXPR_SYMBOL] === 'string' && EXPR_SYMBOL in v;
+ return typeof v[EXPR_SYMBOL] === 'string' && Object.prototype.hasOwnProperty.call(v, EXPR_SYMBOL);
}
/** True if value is EITHER a StepRef or a StepExpr. */
@@ -207,17 +220,32 @@ export function isAnyRef(value: unknown): value is StepRef | StepExpr {
// ---- Path resolution ----
+const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
+
type PathSegment = string | number;
function parsePath(path: string): PathSegment[] {
+ if (typeof path !== 'string' || path.length === 0) {
+ throw new TransactionInputError('Path must be a non-empty string.');
+ }
const segments: PathSegment[] = [];
const parts = path.split('.');
for (const part of parts) {
+ if (part.length === 0) {
+ throw new TransactionInputError(`Path contains an empty segment in "${path}".`);
+ }
const bracketMatch = part.match(/^(\w+)\[(\d+)\]$/);
if (bracketMatch) {
- segments.push(bracketMatch[1]!);
+ const segmentName = bracketMatch[1]!;
+ if (FORBIDDEN_KEYS.has(segmentName)) {
+ throw new TransactionInputError(`Path segment "${segmentName}" is not allowed.`);
+ }
+ segments.push(segmentName);
segments.push(parseInt(bracketMatch[2]!, 10));
} else {
+ if (FORBIDDEN_KEYS.has(part)) {
+ throw new TransactionInputError(`Path segment "${part}" is not allowed.`);
+ }
segments.push(part);
}
}
@@ -228,19 +256,24 @@ function resolvePath(obj: unknown, segments: PathSegment[]): unknown {
let current = obj;
for (const segment of segments) {
if (current == null || typeof current !== 'object') {
- throw new Error(
+ throw new TransactionInputError(
`Cannot resolve path segment "${segment}": value is ${current === null ? 'null' : typeof current}`,
);
}
+ if (typeof segment === 'string' && FORBIDDEN_KEYS.has(segment)) {
+ throw new TransactionInputError(`Path segment "${segment}" is not allowed.`);
+ }
if (Array.isArray(current) && typeof segment === 'number') {
- if (segment < 0 || segment >= current.length) {
- throw new Error(`Array index ${segment} is out of bounds. Array has ${current.length} elements.`);
+ if (segment < 0 || !Number.isSafeInteger(segment) || segment >= current.length) {
+ throw new TransactionInputError(
+ `Array index ${segment} is out of bounds. Array has ${current.length} elements.`,
+ );
}
current = current[segment];
- } else if (typeof segment === 'string' && segment in current) {
+ } else if (typeof segment === 'string' && Object.prototype.hasOwnProperty.call(current, segment)) {
current = (current as Record)[segment];
} else {
- throw new Error(
+ throw new TransactionInputError(
`Cannot resolve path segment "${segment}" on ${Array.isArray(current) ? 'array' : typeof current}`,
);
}
@@ -250,30 +283,85 @@ function resolvePath(obj: unknown, segments: PathSegment[]): unknown {
// ---- Expression resolution ----
+const VALID_EXPR_KINDS = new Set(['ref', 'get', 'item', 'first', 'filter', 'map']);
+
+function validateInteger(value: unknown, label: string): asserts value is number {
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
+ throw new TransactionInputError(
+ `"${label}" must be a safe integer, got ${value === null ? 'null' : typeof value}${typeof value === 'number' ? ` (${value})` : ''}`,
+ );
+ }
+}
+
+function validateExprRef(expr: StepExpr) {
+ const kind = (expr as Record)[EXPR_SYMBOL];
+ if (typeof kind !== 'string' || !VALID_EXPR_KINDS.has(kind)) {
+ throw new TransactionInputError(
+ `Unknown expression type: "${String(kind)}". Supported types: ref, get, item, first, filter, map`,
+ );
+ }
+ // type-specific field validation
+ const e = expr as Record;
+ if (kind === 'ref' || kind === 'get' || kind === 'item' || kind === 'first' || kind === 'filter' || kind === 'map') {
+ if (!Object.prototype.hasOwnProperty.call(e, 'ref') && kind !== 'ref') {
+ throw new TransactionInputError(`Expression of kind "${kind}" must have a "ref" field.`);
+ }
+ }
+}
+
/**
* Resolves a StepRef or StepExpr against accumulated step results.
* Handles both the old `$zenstackStepRef` format and the new `$zenstackExpr` format.
+ * Supports cycle detection via an optional WeakSet.
*/
-export function resolveExpr(expr: StepExpr | StepRef, results: unknown[]): unknown {
+export function resolveExpr(
+ expr: StepExpr | StepRef,
+ results: unknown[],
+ _visited?: WeakSet