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..9b98b3dd8 100644
--- a/packages/orm/src/client/index.ts
+++ b/packages/orm/src/client/index.ts
@@ -19,6 +19,33 @@ 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,
+ TransactionInputError,
+} 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..0048e23dc
--- /dev/null
+++ b/packages/orm/src/client/transaction.ts
@@ -0,0 +1,604 @@
+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 };
+}
+
+// ---- 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 (
+ Object.prototype.hasOwnProperty.call(v, STEP_REF_SYMBOL) &&
+ 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' && Object.prototype.hasOwnProperty.call(v, EXPR_SYMBOL);
+}
+
+/** 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 ----
+
+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) {
+ 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);
+ }
+ }
+ 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 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 || !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' && Object.prototype.hasOwnProperty.call(current, segment)) {
+ current = (current as Record)[segment];
+ } else {
+ throw new TransactionInputError(
+ `Cannot resolve path segment "${segment}" on ${Array.isArray(current) ? 'array' : typeof current}`,
+ );
+ }
+ }
+ return current;
+}
+
+// ---- 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[],
+ _visited?: WeakSet