From d475599a6b2dd6872331f28d7e1e1d028645ccef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:58:40 -0700 Subject: [PATCH 1/3] chore: bump version 3.0.0-beta.12 (#324) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> --- package.json | 2 +- packages/cli/package.json | 2 +- packages/common-helpers/package.json | 2 +- packages/config/eslint-config/package.json | 2 +- packages/config/typescript-config/package.json | 2 +- packages/config/vitest-config/package.json | 2 +- packages/create-zenstack/package.json | 2 +- packages/dialects/sql.js/package.json | 2 +- packages/language/package.json | 2 +- packages/plugins/policy/package.json | 2 +- packages/runtime/package.json | 2 +- packages/sdk/package.json | 2 +- packages/tanstack-query/package.json | 2 +- packages/testtools/package.json | 2 +- packages/zod/package.json | 2 +- samples/blog/package.json | 2 +- tests/e2e/package.json | 2 +- tests/regression/package.json | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 75d7d3de..751b5540 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3987bbe8..386f8d8b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "type": "module", "author": { "name": "ZenStack Team" diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index cf8a1b55..d0142937 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index b72368ee..1f67f526 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 6aaa1e94..16fd3c05 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 3f950df1..2b1fc854 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 9673e489..0308a921 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/dialects/sql.js/package.json b/packages/dialects/sql.js/package.json index 7a7a3a8c..abe28421 100644 --- a/packages/dialects/sql.js/package.json +++ b/packages/dialects/sql.js/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/kysely-sql-js", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "Kysely dialect for sql.js", "type": "module", "scripts": { diff --git a/packages/language/package.json b/packages/language/package.json index e36f2df0..b6653419 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 2035672d..12cc240c 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/plugin-policy", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "ZenStack Policy Plugin", "type": "module", "scripts": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 46249059..c4f65761 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "ZenStack Runtime", "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5cb03d87..7a03329a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index 0ed46656..d00b6e4e 100644 --- a/packages/tanstack-query/package.json +++ b/packages/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "", "main": "index.js", "type": "module", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 48c935a2..e0f22f5a 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/zod/package.json b/packages/zod/package.json index 33125139..b7ae8821 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "", "type": "module", "main": "index.js", diff --git a/samples/blog/package.json b/samples/blog/package.json index 00ec0b00..250183fc 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "description": "", "main": "index.js", "scripts": { diff --git a/tests/e2e/package.json b/tests/e2e/package.json index e737cf2f..9a895dbc 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index e2dd39d8..268d3ceb 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.0.0-beta.11", + "version": "3.0.0-beta.12", "private": true, "type": "module", "scripts": { From 981f643814dfb9336ac573950da459a3adb58fd3 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Wed, 22 Oct 2025 10:26:07 -0700 Subject: [PATCH 2/3] perf: avoid unnecessary post-mutation reads (#325) --- .../src/client/crud/operations/base.ts | 100 +++++++++---- .../src/client/crud/operations/create.ts | 54 +++++--- .../src/client/crud/operations/delete.ts | 30 ++-- .../src/client/crud/operations/update.ts | 131 +++++++++++++----- .../client-api/create-many-and-return.test.ts | 4 +- tests/e2e/orm/client-api/create.test.ts | 4 +- tests/e2e/orm/client-api/update-many.test.ts | 19 ++- tests/e2e/orm/client-api/upsert.test.ts | 31 +++-- 8 files changed, 260 insertions(+), 113 deletions(-) diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 4193fdfa..975fd6e0 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -7,6 +7,7 @@ import { UpdateResult, type Compilable, type IsolationLevel, + type QueryResult, type SelectQueryBuilder, } from 'kysely'; import { nanoid } from 'nanoid'; @@ -248,6 +249,7 @@ export abstract class BaseOperationHandler { data: any, fromRelation?: FromRelationContext, creatingForDelegate = false, + returnFields?: string[], ): Promise { const modelDef = this.requireModel(model); @@ -339,12 +341,15 @@ export abstract class BaseOperationHandler { } const updatedData = this.fillGeneratedAndDefaultValues(modelDef, createFields); - const idFields = requireIdFields(this.schema, model); + + // return id fields if no returnFields specified + returnFields = returnFields ?? requireIdFields(this.schema, model); + const query = kysely .insertInto(model) .$if(Object.keys(updatedData).length === 0, (qb) => qb.defaultValues()) .$if(Object.keys(updatedData).length > 0, (qb) => qb.values(updatedData)) - .returning(idFields as any) + .returning(returnFields as any) .modifyEnd( this.makeContextComment({ model, @@ -661,6 +666,7 @@ export abstract class BaseOperationHandler { input: { data: any; skipDuplicates?: boolean }, returnData: ReturnData, fromRelation?: FromRelationContext, + fieldsToReturn?: string[], ): Promise { if (!input.data || (Array.isArray(input.data) && input.data.length === 0)) { // nothing todo @@ -763,8 +769,8 @@ export abstract class BaseOperationHandler { const result = await this.executeQuery(kysely, query, 'createMany'); return { count: Number(result.numAffectedRows) } as Result; } else { - const idFields = requireIdFields(this.schema, model); - const result = await query.returning(idFields as any).execute(); + fieldsToReturn = fieldsToReturn ?? requireIdFields(this.schema, model); + const result = await query.returning(fieldsToReturn as any).execute(); return result as Result; } } @@ -899,6 +905,7 @@ export abstract class BaseOperationHandler { fromRelation?: FromRelationContext, allowRelationUpdate = true, throwIfNotFound = true, + fieldsToReturn?: string[], ): Promise { if (!data || typeof data !== 'object') { throw new InternalError('data must be an object'); @@ -1044,12 +1051,12 @@ export abstract class BaseOperationHandler { // nothing to update, return the filter so that the caller can identify the entity return combinedWhere; } else { - const idFields = requireIdFields(this.schema, model); + fieldsToReturn = fieldsToReturn ?? requireIdFields(this.schema, model); const query = kysely .updateTable(model) .where(() => this.dialect.buildFilter(model, model, combinedWhere)) .set(updateFields) - .returning(idFields as any) + .returning(fieldsToReturn as any) .modifyEnd( this.makeContextComment({ model, @@ -1058,16 +1065,6 @@ export abstract class BaseOperationHandler { ); const updatedEntity = await this.executeQueryTakeFirst(kysely, query, 'update'); - - // try { - // updatedEntity = await this.executeQueryTakeFirst(kysely, query, 'update'); - // } catch (err) { - // const { sql, parameters } = query.compile(); - // throw new QueryError( - // `Error during update: ${err}, sql: ${sql}, parameters: ${parameters}` - // ); - // } - if (!updatedEntity) { if (throwIfNotFound) { throw new NotFoundError(model); @@ -1214,6 +1211,7 @@ export abstract class BaseOperationHandler { limit: number | undefined, returnData: ReturnData, filterModel?: GetModels, + fieldsToReturn?: string[], ): Promise { if (typeof data !== 'object') { throw new InternalError('data must be an object'); @@ -1302,8 +1300,8 @@ export abstract class BaseOperationHandler { const result = await this.executeQuery(kysely, query, 'update'); return { count: Number(result.numAffectedRows) } as Result; } else { - const idFields = requireIdFields(this.schema, model); - const finalQuery = query.returning(idFields as any); + fieldsToReturn = fieldsToReturn ?? requireIdFields(this.schema, model); + const finalQuery = query.returning(fieldsToReturn as any); const result = await this.executeQuery(kysely, finalQuery, 'update'); return result.rows as Result; } @@ -1861,7 +1859,7 @@ export abstract class BaseOperationHandler { expectedDeleteCount = deleteConditions.length; } - let deleteResult: { count: number }; + let deleteResult: QueryResult; let deleteFromModel: GetModels; const m2m = getManyToManyRelation(this.schema, fromRelation.model, fromRelation.field); @@ -1926,7 +1924,7 @@ export abstract class BaseOperationHandler { } // validate result - if (throwForNotFound && expectedDeleteCount > deleteResult.count) { + if (throwForNotFound && expectedDeleteCount > deleteResult.rows.length) { // some entities were not deleted throw new NotFoundError(deleteFromModel); } @@ -1944,7 +1942,8 @@ export abstract class BaseOperationHandler { where: any, limit?: number, filterModel?: GetModels, - ): Promise<{ count: number }> { + fieldsToReturn?: string[], + ): Promise> { filterModel ??= model; const modelDef = this.requireModel(model); @@ -1957,7 +1956,9 @@ export abstract class BaseOperationHandler { return this.processBaseModelDelete(kysely, modelDef.baseModel, where, limit, filterModel); } - let query = kysely.deleteFrom(model); + fieldsToReturn = fieldsToReturn ?? requireIdFields(this.schema, model); + let query = kysely.deleteFrom(model).returning(fieldsToReturn as any); + let needIdFilter = false; if (limit !== undefined && !this.dialect.supportsDeleteWithLimit) { @@ -1999,8 +2000,7 @@ export abstract class BaseOperationHandler { await this.processDelegateRelationDelete(kysely, modelDef, where, limit); query = query.modifyEnd(this.makeContextComment({ model, operation: 'delete' })); - const result = await this.executeQuery(kysely, query, 'delete'); - return { count: Number(result.numAffectedRows) }; + return this.executeQuery(kysely, query, 'delete'); } private async processDelegateRelationDelete( @@ -2140,4 +2140,56 @@ export abstract class BaseOperationHandler { } return result.rows[0]; } + + protected mutationNeedsReadBack(model: string, args: any) { + if (this.hasPolicyEnabled) { + // TODO: refactor this check + // policy enforcement always requires read back + return { needReadBack: true, selectedFields: undefined }; + } + + if (args.include && typeof args.include === 'object' && Object.keys(args.include).length > 0) { + // includes present, need read back to fetch relations + return { needReadBack: true, selectedFields: undefined }; + } + + const modelDef = this.requireModel(model); + + if (modelDef.baseModel || modelDef.isDelegate) { + // polymorphic model, need read back + return { needReadBack: true, selectedFields: undefined }; + } + + const allFields = Object.keys(modelDef.fields); + const relationFields = Object.values(modelDef.fields) + .filter((f) => f.relation) + .map((f) => f.name); + const computedFields = Object.values(modelDef.fields) + .filter((f) => f.computed) + .map((f) => f.name); + const omit = Object.entries(args.omit ?? {}) + .filter(([, v]) => v) + .map(([k]) => k); + + const allFieldsSelected: string[] = []; + + if (!args.select || typeof args.select !== 'object') { + // all non-relation fields selected + allFieldsSelected.push(...allFields.filter((f) => !relationFields.includes(f) && !omit.includes(f))); + } else { + // explicit select + allFieldsSelected.push( + ...Object.entries(args.select) + .filter(([k, v]) => v && !omit.includes(k)) + .map(([k]) => k), + ); + } + + if (allFieldsSelected.some((f) => relationFields.includes(f) || computedFields.includes(f))) { + // relation or computed field selected, need read back + return { needReadBack: true, selectedFields: undefined }; + } else { + return { needReadBack: false, selectedFields: allFieldsSelected }; + } + } } diff --git a/packages/runtime/src/client/crud/operations/create.ts b/packages/runtime/src/client/crud/operations/create.ts index 36e76211..98124838 100644 --- a/packages/runtime/src/client/crud/operations/create.ts +++ b/packages/runtime/src/client/crud/operations/create.ts @@ -24,19 +24,27 @@ export class CreateOperationHandler extends BaseOperat } private async runCreate(args: CreateArgs>) { + // analyze if we need to read back the created record, or just return the create result + const { needReadBack, selectedFields } = this.mutationNeedsReadBack(this.model, args); + // TODO: avoid using transaction for simple create const result = await this.safeTransaction(async (tx) => { - const createResult = await this.create(tx, this.model, args.data); - return this.readUnique(tx, this.model, { - select: args.select, - include: args.include, - omit: args.omit, - where: getIdValues(this.schema, this.model, createResult) as WhereInput< - Schema, - GetModels, - false - >, - }); + const createResult = await this.create(tx, this.model, args.data, undefined, false, selectedFields); + + if (needReadBack) { + return this.readUnique(tx, this.model, { + select: args.select, + include: args.include, + omit: args.omit, + where: getIdValues(this.schema, this.model, createResult) as WhereInput< + Schema, + GetModels, + false + >, + }); + } else { + return createResult; + } }); if (!result && this.hasPolicyEnabled) { @@ -62,16 +70,24 @@ export class CreateOperationHandler extends BaseOperat return []; } + // analyze if we need to read back the created record, or just return the create result + const { needReadBack, selectedFields } = this.mutationNeedsReadBack(this.model, args); + // TODO: avoid using transaction for simple create return this.safeTransaction(async (tx) => { - const createResult = await this.createMany(tx, this.model, args, true); - return this.read(tx, this.model, { - select: args.select, - omit: args.omit, - where: { - OR: createResult.map((item) => getIdValues(this.schema, this.model, item) as any), - } as any, // TODO: fix type - }); + const createResult = await this.createMany(tx, this.model, args, true, undefined, selectedFields); + + if (needReadBack) { + return this.read(tx, this.model, { + select: args.select, + omit: args.omit, + where: { + OR: createResult.map((item) => getIdValues(this.schema, this.model, item) as any), + } as any, // TODO: fix type + }); + } else { + return createResult; + } }); } } diff --git a/packages/runtime/src/client/crud/operations/delete.ts b/packages/runtime/src/client/crud/operations/delete.ts index 21539aed..e6fb3c2a 100644 --- a/packages/runtime/src/client/crud/operations/delete.ts +++ b/packages/runtime/src/client/crud/operations/delete.ts @@ -18,22 +18,28 @@ export class DeleteOperationHandler extends BaseOperat } async runDelete(args: DeleteArgs>) { - const existing = await this.readUnique(this.kysely, this.model, { - select: args.select, - include: args.include, - omit: args.omit, - where: args.where, - }); + // analyze if we need to read back the deleted record, or just return delete result + const { needReadBack, selectedFields } = this.mutationNeedsReadBack(this.model, args); // TODO: avoid using transaction for simple delete - await this.safeTransaction(async (tx) => { - const result = await this.delete(tx, this.model, args.where); - if (result.count === 0) { + const result = await this.safeTransaction(async (tx) => { + let preDeleteRead: any = undefined; + if (needReadBack) { + preDeleteRead = await this.readUnique(tx, this.model, { + select: args.select, + include: args.include, + omit: args.omit, + where: args.where, + }); + } + const deleteResult = await this.delete(tx, this.model, args.where, undefined, undefined, selectedFields); + if (deleteResult.rows.length === 0) { throw new NotFoundError(this.model); } + return needReadBack ? preDeleteRead : deleteResult.rows[0]; }); - if (!existing && this.hasPolicyEnabled) { + if (!result && this.hasPolicyEnabled) { throw new RejectedByPolicyError( this.model, RejectedByPolicyReason.CANNOT_READ_BACK, @@ -41,13 +47,13 @@ export class DeleteOperationHandler extends BaseOperat ); } - return existing; + return result; } async runDeleteMany(args: DeleteManyArgs> | undefined) { return await this.safeTransaction(async (tx) => { const result = await this.delete(tx, this.model, args?.where, args?.limit); - return result; + return { count: result.rows.length }; }); } } diff --git a/packages/runtime/src/client/crud/operations/update.ts b/packages/runtime/src/client/crud/operations/update.ts index 567721b0..5d8d7b19 100644 --- a/packages/runtime/src/client/crud/operations/update.ts +++ b/packages/runtime/src/client/crud/operations/update.ts @@ -25,26 +25,39 @@ export class UpdateOperationHandler extends BaseOperat } private async runUpdate(args: UpdateArgs>) { - const readBackResult = await this.safeTransaction(async (tx) => { - const updateResult = await this.update(tx, this.model, args.where, args.data); - // updated can be undefined if there's nothing to update, in that case we'll use the original - // filter to read back the entity - const readFilter = updateResult ?? args.where; - let readBackResult: any = undefined; - try { + // analyze if we need to read back the update record, or just return the updated result + const { needReadBack, selectedFields } = this.needReadBack(args); + + const result = await this.safeTransaction(async (tx) => { + const updateResult = await this.update( + tx, + this.model, + args.where, + args.data, + undefined, + undefined, + undefined, + selectedFields, + ); + + if (needReadBack) { + // updated can be undefined if there's nothing to update, in that case we'll use the original + // filter to read back the entity + const readFilter = updateResult ?? args.where; + let readBackResult: any = undefined; readBackResult = await this.readUnique(tx, this.model, { select: args.select, include: args.include, omit: args.omit, where: readFilter as WhereInput, false>, }); - } catch { - // commit the update even if read-back failed + return readBackResult; + } else { + return updateResult; } - return readBackResult; }); - if (!readBackResult) { + if (!result) { // update succeeded but result cannot be read back if (this.hasPolicyEnabled) { // if access policy is enabled, we assume it's due to read violation (not guaranteed though) @@ -59,7 +72,7 @@ export class UpdateOperationHandler extends BaseOperat return null; } } else { - return readBackResult; + return result; } } @@ -75,17 +88,34 @@ export class UpdateOperationHandler extends BaseOperat return []; } + // analyze if we need to read back the updated record, or just return the update result + const { needReadBack, selectedFields } = this.needReadBack(args); + const { readBackResult, updateResult } = await this.safeTransaction(async (tx) => { - const updateResult = await this.updateMany(tx, this.model, args.where, args.data, args.limit, true); - const readBackResult = await this.read(tx, this.model, { - select: args.select, - omit: args.omit, - where: { - OR: updateResult.map((item) => getIdValues(this.schema, this.model, item) as any), - } as any, // TODO: fix type - }); - - return { readBackResult, updateResult }; + const updateResult = await this.updateMany( + tx, + this.model, + args.where, + args.data, + args.limit, + true, + undefined, + selectedFields, + ); + + if (needReadBack) { + const readBackResult = await this.read(tx, this.model, { + select: args.select, + omit: args.omit, + where: { + OR: updateResult.map((item) => getIdValues(this.schema, this.model, item) as any), + } as any, // TODO: fix type + }); + + return { readBackResult, updateResult }; + } else { + return { readBackResult: updateResult, updateResult }; + } }); if (readBackResult.length < updateResult.length && this.hasPolicyEnabled) { @@ -101,6 +131,9 @@ export class UpdateOperationHandler extends BaseOperat } private async runUpsert(args: UpsertArgs>) { + // analyze if we need to read back the updated record, or just return the update result + const { needReadBack, selectedFields } = this.needReadBack(args); + const result = await this.safeTransaction(async (tx) => { let mutationResult: unknown = await this.update( tx, @@ -110,23 +143,28 @@ export class UpdateOperationHandler extends BaseOperat undefined, true, false, + selectedFields, ); if (!mutationResult) { // non-existing, create - mutationResult = await this.create(tx, this.model, args.create); + mutationResult = await this.create(tx, this.model, args.create, undefined, undefined, selectedFields); } - return this.readUnique(tx, this.model, { - select: args.select, - include: args.include, - omit: args.omit, - where: getIdValues(this.schema, this.model, mutationResult) as WhereInput< - Schema, - GetModels, - false - >, - }); + if (needReadBack) { + return this.readUnique(tx, this.model, { + select: args.select, + include: args.include, + omit: args.omit, + where: getIdValues(this.schema, this.model, mutationResult) as WhereInput< + Schema, + GetModels, + false + >, + }); + } else { + return mutationResult; + } }); if (!result && this.hasPolicyEnabled) { @@ -139,4 +177,31 @@ export class UpdateOperationHandler extends BaseOperat return result; } + + private needReadBack(args: any) { + const baseResult = this.mutationNeedsReadBack(this.model, args); + if (baseResult.needReadBack) { + return baseResult; + } + + // further check if we're not updating any non-relation fields, because if so, + // SQL "returning" is not effective, we need to always read back + + const modelDef = this.requireModel(this.model); + const nonRelationFields = Object.entries(modelDef.fields) + .filter(([_, def]) => !def.relation) + .map(([name, _]) => name); + + // update/updateMany payload + if (args.data && !Object.keys(args.data).some((field) => nonRelationFields.includes(field))) { + return { needReadBack: true, selectedFields: undefined }; + } + + // upsert payload + if (args.update && !Object.keys(args.update).some((field: string) => nonRelationFields.includes(field))) { + return { needReadBack: true, selectedFields: undefined }; + } + + return baseResult; + } } diff --git a/tests/e2e/orm/client-api/create-many-and-return.test.ts b/tests/e2e/orm/client-api/create-many-and-return.test.ts index 3b93ce22..c2264444 100644 --- a/tests/e2e/orm/client-api/create-many-and-return.test.ts +++ b/tests/e2e/orm/client-api/create-many-and-return.test.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '@zenstackhq/runtime'; -import { schema } from '../schemas/basic'; import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/basic'; describe('Client createManyAndReturn tests', () => { let client: ClientContract; diff --git a/tests/e2e/orm/client-api/create.test.ts b/tests/e2e/orm/client-api/create.test.ts index 72e2ad7a..91536b0c 100644 --- a/tests/e2e/orm/client-api/create.test.ts +++ b/tests/e2e/orm/client-api/create.test.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '@zenstackhq/runtime'; -import { schema } from '../schemas/basic'; import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/basic'; describe('Client create tests', () => { let client: ClientContract; diff --git a/tests/e2e/orm/client-api/update-many.test.ts b/tests/e2e/orm/client-api/update-many.test.ts index b37abf35..5d3ae08c 100644 --- a/tests/e2e/orm/client-api/update-many.test.ts +++ b/tests/e2e/orm/client-api/update-many.test.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '@zenstackhq/runtime'; -import { schema } from '../schemas/basic'; import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/basic'; describe('Client updateMany tests', () => { let client: ClientContract; @@ -87,13 +87,20 @@ describe('Client updateMany tests', () => { data: { id: '2', email: 'u2@test.com', name: 'User2' }, }); - const r = await client.user.updateManyAndReturn({ + await expect( + client.user.updateManyAndReturn({ + where: { email: 'u1@test.com' }, + data: { name: 'User1-new' }, + }), + ).resolves.toMatchObject([{ id: '1', name: 'User1-new', email: 'u1@test.com' }]); + + const r1 = await client.user.updateManyAndReturn({ where: { email: 'u1@test.com' }, - data: { name: 'User1-new' }, + data: { name: 'User1-new1' }, select: { id: true, name: true }, }); - expect(r).toMatchObject([{ id: '1', name: 'User1-new' }]); + expect(r1).toMatchObject([{ id: '1', name: 'User1-new1' }]); // @ts-expect-error - expect(r[0]!.email).toBeUndefined(); + expect(r1[0]!.email).toBeUndefined(); }); }); diff --git a/tests/e2e/orm/client-api/upsert.test.ts b/tests/e2e/orm/client-api/upsert.test.ts index 14f418b3..49b41343 100644 --- a/tests/e2e/orm/client-api/upsert.test.ts +++ b/tests/e2e/orm/client-api/upsert.test.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '@zenstackhq/runtime'; -import { schema } from '../schemas/basic'; import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/basic'; describe('Client upsert tests', () => { let client: ClientContract; @@ -35,22 +35,22 @@ describe('Client upsert tests', () => { }); // update - await expect( - client.user.upsert({ - where: { id: '1' }, - create: { - id: '2', - email: 'u2@test.com', - name: 'New', - }, - update: { name: 'Updated' }, - include: { profile: true }, - }), - ).resolves.toMatchObject({ + const r = await client.user.upsert({ + where: { id: '1' }, + create: { + id: '2', + email: 'u2@test.com', + name: 'New', + }, + update: { name: 'Updated' }, + select: { id: true, name: true }, + }); + expect(r).toMatchObject({ id: '1', name: 'Updated', - profile: { bio: 'My bio' }, }); + // @ts-expect-error + expect(r.email).toBeUndefined(); // id update await expect( @@ -66,6 +66,7 @@ describe('Client upsert tests', () => { ).resolves.toMatchObject({ id: '3', name: 'Updated', + email: 'u1@test.com', }); }); }); From 68e0072e3b706a4a28f51b3e8fcc7306286957da Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Wed, 22 Oct 2025 11:20:20 -0700 Subject: [PATCH 3/3] fix: use a weaker tx consistency level for better perf (#326) --- packages/runtime/src/client/crud/operations/base.ts | 2 +- packages/runtime/src/client/executor/zenstack-query-executor.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 975fd6e0..f27001fa 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -2083,7 +2083,7 @@ export abstract class BaseOperationHandler { } else { // otherwise, create a new transaction and execute the callback let txBuilder = this.kysely.transaction(); - txBuilder = txBuilder.setIsolationLevel(isolationLevel ?? TransactionIsolationLevel.RepeatableRead); + txBuilder = txBuilder.setIsolationLevel(isolationLevel ?? TransactionIsolationLevel.ReadCommitted); return txBuilder.execute(callback); } } diff --git a/packages/runtime/src/client/executor/zenstack-query-executor.ts b/packages/runtime/src/client/executor/zenstack-query-executor.ts index e1fb6298..a2005bff 100644 --- a/packages/runtime/src/client/executor/zenstack-query-executor.ts +++ b/packages/runtime/src/client/executor/zenstack-query-executor.ts @@ -90,7 +90,7 @@ export class ZenStackQueryExecutor extends DefaultQuer // mutations are wrapped in tx if not already in one if (this.isMutationNode(compiledQuery.query) && !this.driver.isTransactionConnection(connection)) { await this.driver.beginTransaction(connection, { - isolationLevel: TransactionIsolationLevel.RepeatableRead, + isolationLevel: TransactionIsolationLevel.ReadCommitted, }); startedTx = true; }