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, +): unknown { + // Cycle detection for client-side local expressions + const visited = _visited ?? new WeakSet(); + 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); + } + + // 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)[EXPR_SYMBOL] as string; + switch (kind) { + case 'ref': { + const { step, path } = expr as Extract; + 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; + } + + case 'get': { + const { ref, path } = expr as Extract; + const resolved = resolveExpr(ref, results, visited); + return resolvePath(resolved, parsePath(path)); + } + + case 'item': { + const { ref, index } = expr as Extract; + validateInteger(index, 'index'); + const resolved = resolveExpr(ref, results, visited); + ensureArray(resolved, 'item', index); + const arr = resolved as unknown[]; + if (index < 0 || index >= arr.length) { + throw new TransactionInputError(`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, visited); + ensureArray(resolved, 'first'); + const arr = resolved as unknown[]; + if (arr.length === 0) { + throw new TransactionInputError('Cannot get first element of an empty array.'); + } + return arr[0]; + } + + case 'filter': { + const { ref, where } = expr as Extract; + const resolved = resolveExpr(ref, results, visited); + ensureArray(resolved, 'filter'); + const arr = resolved as Record[]; + const resolvedValue = isAnyRef(where.value) ? resolveExpr(where.value, results, visited) : 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, visited); + ensureArray(resolved, 'map'); + const arr = resolved as Record[]; + return arr.map((item) => { + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + throw new TransactionInputError( + `Cannot extract field "${extract}": array element is ${item === null ? 'null' : Array.isArray(item) ? 'an array' : `a ${typeof item}`}`, + ); + } + if (!Object.prototype.hasOwnProperty.call(item, extract)) { + throw new TransactionInputError( + `Field "${extract}" not found in array element. Available fields: ${Object.keys(item).join(', ')}`, + ); + } + return item[extract]; + }); + } + + default: { + const kindVal = (expr as Record)[EXPR_SYMBOL]; + throw new TransactionInputError( + `Unknown expression type: "${String(kindVal)}". Supported types: ref, get, item, first, filter, map`, + ); + } + } +} + +function getResultIndex(step: number, results: unknown[]) { + if (step < 1 || step > results.length) { + throw new TransactionInputError( + `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 TransactionInputError( + `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 isDate(value: unknown): value is Date { + return value instanceof Date && typeof value.getTime === 'function' && !isNaN(value.getTime()); +} + +function isDecimal(value: unknown): value is { isDecimal: true; equals(other: unknown): boolean } { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return v['isDecimal'] === true && typeof v['equals'] === 'function'; +} + +function isBigInt(value: unknown): value is bigint { + return typeof value === 'bigint'; +} + +function isBuffer(value: unknown): value is Uint8Array { + return value instanceof Uint8Array; +} + +function matchCondition(item: Record, field: string, op: string, value: unknown): boolean { + if (typeof field !== 'string' || field.length === 0) { + throw new TransactionInputError('Filter field must be a non-empty string.'); + } + if (!Object.prototype.hasOwnProperty.call(item, field)) { + // field missing from item — only 'neq' and 'notIn' can match + if (op === 'neq') return true; + if (op === 'notIn' && Array.isArray(value)) return !value.includes(undefined); + // treat as no match + return false; + } + const actual = item[field]; + switch (op) { + case 'eq': + if (isDate(actual) && isDate(value)) return actual.getTime() === value.getTime(); + if (isDecimal(actual) && isDecimal(value)) return actual.equals(value); + if (isBigInt(actual) && isBigInt(value)) return actual === value; + if (isBuffer(actual) && isBuffer(value)) { + if (actual.length !== value.length) return false; + for (let i = 0; i < actual.length; i++) if (actual[i] !== value[i]) return false; + return true; + } + return actual === value; + + case 'neq': + if (isDate(actual) && isDate(value)) return actual.getTime() !== value.getTime(); + if (isDecimal(actual) && isDecimal(value)) return !actual.equals(value); + if (isBigInt(actual) && isBigInt(value)) return actual !== value; + if (isBuffer(actual) && isBuffer(value)) { + if (actual.length !== value.length) return true; + for (let i = 0; i < actual.length; i++) if (actual[i] !== value[i]) return true; + return false; + } + return actual !== value; + + case 'gt': + if (typeof actual === 'number' && typeof value === 'number') return actual > value; + if (isDate(actual) && isDate(value)) return actual.getTime() > value.getTime(); + if (isBigInt(actual) && isBigInt(value)) return actual > value; + return false; + + case 'gte': + if (typeof actual === 'number' && typeof value === 'number') return actual >= value; + if (isDate(actual) && isDate(value)) return actual.getTime() >= value.getTime(); + if (isBigInt(actual) && isBigInt(value)) return actual >= value; + return false; + + case 'lt': + if (typeof actual === 'number' && typeof value === 'number') return actual < value; + if (isDate(actual) && isDate(value)) return actual.getTime() < value.getTime(); + if (isBigInt(actual) && isBigInt(value)) return actual < value; + return false; + + case 'lte': + if (typeof actual === 'number' && typeof value === 'number') return actual <= value; + if (isDate(actual) && isDate(value)) return actual.getTime() <= value.getTime(); + if (isBigInt(actual) && isBigInt(value)) return actual <= value; + return false; + + case 'in': { + if (!Array.isArray(value)) { + throw new TransactionInputError('"in" filter value must be an array.'); + } + if (isDate(actual)) return value.some((v) => isDate(v) && v.getTime() === actual.getTime()); + if (isBigInt(actual)) return value.some((v) => isBigInt(v) && v === actual); + return value.includes(actual); + } + + case 'notIn': { + if (!Array.isArray(value)) { + throw new TransactionInputError('"notIn" filter value must be an array.'); + } + if (isDate(actual)) return !value.some((v) => isDate(v) && v.getTime() === actual.getTime()); + if (isBigInt(actual)) return !value.some((v) => isBigInt(v) && v === actual); + return !value.includes(actual); + } + + case 'contains': + if (typeof actual === 'string' && typeof value === 'string') return actual.includes(value); + if (Array.isArray(actual)) return actual.includes(value); + return false; + + default: + throw new TransactionInputError( + `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)) { + // Block prototype pollution keys + if (FORBIDDEN_KEYS.has(key)) { + continue; + } + 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..9ee20e698 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -1,5 +1,12 @@ import { lowerCaseFirst, safeJSONStringify } from '@zenstackhq/common-helpers'; -import { CoreCrudOperations, ORMError, ORMErrorReason, type ClientContract } from '@zenstackhq/orm'; +import { + CoreCrudOperations, + ORMError, + ORMErrorReason, + TransactionInputError, + resolveStepRefs, + type ClientContract, +} from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/orm/schema'; import SuperJSON from 'superjson'; import { match } from 'ts-pattern'; @@ -261,13 +268,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 }; @@ -284,6 +305,9 @@ export class RPCApiHandler implements ApiH return response; } catch (err) { log(this.options.log, 'error', `error occurred when handling "$transaction" request`, err); + if (err instanceof TransactionInputError) { + return this.makeBadInputErrorResponse(err.message); + } if (err instanceof ORMError) { return this.makeORMErrorResponse(err); } 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 @@