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