From 8208900e09e998915daa5ce96e79f7453ca4fb3b Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 6 Mar 2026 14:04:58 +0800 Subject: [PATCH 1/8] fix(orm): fallback to compact temp aliases for overlong names (#2425) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> --- .../client/executor/temp-alias-transformer.ts | 46 ++++- .../executor/zenstack-query-executor.ts | 7 +- packages/orm/src/client/options.ts | 5 +- tests/regression/test/issue-2424.test.ts | 161 ++++++++++++++++++ 4 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 tests/regression/test/issue-2424.test.ts diff --git a/packages/orm/src/client/executor/temp-alias-transformer.ts b/packages/orm/src/client/executor/temp-alias-transformer.ts index 1a66a260a..fc742b23a 100644 --- a/packages/orm/src/client/executor/temp-alias-transformer.ts +++ b/packages/orm/src/client/executor/temp-alias-transformer.ts @@ -1,12 +1,37 @@ import { IdentifierNode, OperationNodeTransformer, type OperationNode, type QueryId } from 'kysely'; import { TEMP_ALIAS_PREFIX } from '../query-utils'; +type TempAliasTransformerMode = 'alwaysCompact' | 'compactLongNames'; + +type TempAliasTransformerOptions = { + mode?: TempAliasTransformerMode; + maxIdentifierLength?: number; +}; + /** * Kysely node transformer that replaces temporary aliases created during query construction with * shorter names while ensuring the same temp alias gets replaced with the same name. */ export class TempAliasTransformer extends OperationNodeTransformer { private aliasMap = new Map(); + private readonly textEncoder = new TextEncoder(); + private readonly mode: TempAliasTransformerMode; + private readonly maxIdentifierLength: number; + + constructor(options: TempAliasTransformerOptions = {}) { + super(); + this.mode = options.mode ?? 'alwaysCompact'; + // PostgreSQL limits identifier length to 63 bytes and silently truncates overlong aliases. + const maxIdentifierLength = options.maxIdentifierLength ?? 63; + if ( + !Number.isFinite(maxIdentifierLength) || + !Number.isInteger(maxIdentifierLength) || + maxIdentifierLength <= 0 + ) { + throw new RangeError('maxIdentifierLength must be a positive integer'); + } + this.maxIdentifierLength = maxIdentifierLength; + } run(node: T): T { this.aliasMap.clear(); @@ -14,14 +39,31 @@ export class TempAliasTransformer extends OperationNodeTransformer { } protected override transformIdentifier(node: IdentifierNode, queryId?: QueryId): IdentifierNode { - if (node.name.startsWith(TEMP_ALIAS_PREFIX)) { + if (!node.name.startsWith(TEMP_ALIAS_PREFIX)) { + return super.transformIdentifier(node, queryId); + } + + let shouldCompact = false; + if (this.mode === 'alwaysCompact') { + shouldCompact = true; + } else { + // check if the alias name exceeds the max identifier length, and + // if so, compact it + const aliasByteLength = this.textEncoder.encode(node.name).length; + if (aliasByteLength > this.maxIdentifierLength) { + shouldCompact = true; + } + } + + if (shouldCompact) { let mapped = this.aliasMap.get(node.name); if (!mapped) { mapped = `$$t${this.aliasMap.size + 1}`; this.aliasMap.set(node.name, mapped); } return IdentifierNode.create(mapped); + } else { + return super.transformIdentifier(node, queryId); } - return super.transformIdentifier(node, queryId); } } diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index b0aab7627..52afde140 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -633,10 +633,9 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie } private processTempAlias(query: Node): Node { - if (this.options.useCompactAliasNames === false) { - return query; - } - return new TempAliasTransformer().run(query); + return new TempAliasTransformer({ + mode: this.options.useCompactAliasNames === false ? 'compactLongNames' : 'alwaysCompact', + }).run(query); } private createClientForConnection(connection: DatabaseConnection, inTx: boolean) { diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 111946d3b..e2622d6cf 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -199,8 +199,11 @@ export type ClientOptions = QueryOptions & { validateInput?: boolean; /** - * Whether to use compact alias names (e.g., "$t1", "$t2") when transforming ORM queries to SQL. + * Whether to use compact alias names (e.g., "$$t1", "$$t2") when transforming ORM queries to SQL. * Defaults to `true`. + * + * When set to `false`, original aliases are kept unless temporary aliases become too long for + * safe SQL identifier handling, in which case compact aliases are used as a fallback. */ useCompactAliasNames?: boolean; diff --git a/tests/regression/test/issue-2424.test.ts b/tests/regression/test/issue-2424.test.ts new file mode 100644 index 000000000..0bfd3e4da --- /dev/null +++ b/tests/regression/test/issue-2424.test.ts @@ -0,0 +1,161 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue #2424', () => { + it('deep nested include with PolicyPlugin works with non-compact alias mode', async () => { + const db = await createPolicyTestClient( + ` +model Store { + id String @id + customerOrders CustomerOrder[] + productCatalogItems ProductCatalogItem[] + @@allow('all', true) +} + +model CustomerOrder { + id String @id + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + customerOrderPaymentSummary CustomerOrderPaymentSummary[] + @@allow('all', true) +} + +model CustomerOrderPaymentSummary { + id String @id + customerOrderId String + customerOrder CustomerOrder @relation(fields: [customerOrderId], references: [id], onDelete: Cascade) + customerOrderPaymentSummaryLine CustomerOrderPaymentSummaryLine[] + @@allow('all', true) +} + +model PaymentTransaction { + id String @id + customerOrderPaymentSummaryLine CustomerOrderPaymentSummaryLine[] + paymentTransactionLineItem PaymentTransactionLineItem[] + @@allow('all', true) +} + +model CustomerOrderPaymentSummaryLine { + customerOrderPaymentSummaryId String + lineIndex Int + paymentTransactionId String + customerOrderPaymentSummary CustomerOrderPaymentSummary @relation(fields: [customerOrderPaymentSummaryId], references: [id], onDelete: Cascade) + paymentTransaction PaymentTransaction @relation(fields: [paymentTransactionId], references: [id], onDelete: Cascade) + @@id([customerOrderPaymentSummaryId, lineIndex]) + @@allow('all', true) +} + +model ProductCatalogItem { + storeId String + sku String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + paymentTransactionLineItem PaymentTransactionLineItem[] + @@id([storeId, sku]) + @@allow('all', true) +} + +model InventoryReservation { + id String @id + paymentTransactionLineItem PaymentTransactionLineItem[] + @@allow('all', true) +} + +model PaymentTransactionLineItem { + paymentTransactionId String + lineNumber Int + storeId String + productSku String + inventoryReservationId String? + paymentTransaction PaymentTransaction @relation(fields: [paymentTransactionId], references: [id], onDelete: Cascade) + productCatalogItem ProductCatalogItem @relation(fields: [storeId, productSku], references: [storeId, sku]) + inventoryReservation InventoryReservation? @relation(fields: [inventoryReservationId], references: [id], onDelete: SetNull) + @@id([paymentTransactionId, lineNumber]) + @@allow('all', true) +} + `, + { provider: 'postgresql', useCompactAliasNames: false }, + ); + + const rawDb = db.$unuseAll(); + + await rawDb.store.create({ data: { id: 'store_1' } }); + await rawDb.customerOrder.create({ data: { id: 'order_1', storeId: 'store_1' } }); + await rawDb.customerOrderPaymentSummary.create({ data: { id: 'summary_1', customerOrderId: 'order_1' } }); + await rawDb.paymentTransaction.create({ data: { id: 'payment_1' } }); + await rawDb.customerOrderPaymentSummaryLine.create({ + data: { + customerOrderPaymentSummaryId: 'summary_1', + lineIndex: 0, + paymentTransactionId: 'payment_1', + }, + }); + await rawDb.productCatalogItem.create({ data: { storeId: 'store_1', sku: 'sku_1' } }); + await rawDb.inventoryReservation.create({ data: { id: 'reservation_1' } }); + await rawDb.paymentTransactionLineItem.create({ + data: { + paymentTransactionId: 'payment_1', + lineNumber: 0, + storeId: 'store_1', + productSku: 'sku_1', + inventoryReservationId: 'reservation_1', + }, + }); + + const result = await db.customerOrderPaymentSummary.findUnique({ + where: { id: 'summary_1' }, + include: { + customerOrder: true, + customerOrderPaymentSummaryLine: { + include: { + paymentTransaction: { + include: { + paymentTransactionLineItem: { + include: { + productCatalogItem: true, + inventoryReservation: true, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(result).toMatchObject({ + id: 'summary_1', + customerOrder: { + id: 'order_1', + storeId: 'store_1', + }, + customerOrderPaymentSummaryLine: [ + { + customerOrderPaymentSummaryId: 'summary_1', + lineIndex: 0, + paymentTransactionId: 'payment_1', + paymentTransaction: { + id: 'payment_1', + paymentTransactionLineItem: [ + { + paymentTransactionId: 'payment_1', + lineNumber: 0, + storeId: 'store_1', + productSku: 'sku_1', + inventoryReservationId: 'reservation_1', + productCatalogItem: { + storeId: 'store_1', + sku: 'sku_1', + }, + inventoryReservation: { + id: 'reservation_1', + }, + }, + ], + }, + }, + ], + }); + + await db.$disconnect(); + }); +}); From 158996c11e5e459c3b4a7269a49ef9f8bf1a79a5 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Fri, 6 Mar 2026 20:59:52 -0800 Subject: [PATCH 2/8] perf(orm): use EXISTS instead of COUNT subquery for some/none/every relation filters (#2455) Co-authored-by: Claude Sonnet 4.6 --- .../src/client/crud/dialects/base-dialect.ts | 34 ++--- .../orm/src/client/crud/dialects/mysql.ts | 7 ++ tests/regression/test/issue-2440.test.ts | 119 ++++++++++++++++++ 3 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 tests/regression/test/issue-2440.test.ts diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 3ab2d4979..1f5102121 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -444,35 +444,28 @@ export abstract class BaseCrudDialect { continue; } - const countSelect = (negate: boolean) => { + const existsSelect = (negate: boolean) => { const filter = this.buildFilter(relationModel, relationFilterSelectAlias, subPayload); - return ( - this.eb - // the outer select is needed to avoid mysql's scope issue - .selectFrom( - this.buildSelectModel(relationModel, relationFilterSelectAlias) - .select(() => this.eb.fn.count(this.eb.lit(1)).as('$count')) - .where(buildPkFkWhereRefs(this.eb)) - .where(() => (negate ? this.eb.not(filter) : filter)) - .as('$sub'), - ) - .select('$count') - ); + const innerQuery = this.buildSelectModel(relationModel, relationFilterSelectAlias) + .select(this.eb.lit(1).as('_')) + .where(buildPkFkWhereRefs(this.eb)) + .where(() => (negate ? this.eb.not(filter) : filter)); + return this.buildExistsExpression(innerQuery); }; switch (key) { case 'some': { - result = this.and(result, this.eb(countSelect(false), '>', 0)); + result = this.and(result, existsSelect(false)); break; } case 'every': { - result = this.and(result, this.eb(countSelect(true), '=', 0)); + result = this.and(result, this.eb.not(existsSelect(true))); break; } case 'none': { - result = this.and(result, this.eb(countSelect(false), '=', 0)); + result = this.and(result, this.eb.not(existsSelect(false))); break; } } @@ -1400,6 +1393,15 @@ export abstract class BaseCrudDialect { // #endregion + /** + * Builds an EXISTS expression from an inner SELECT query. + * Can be overridden by dialects that need special handling (e.g., MySQL wraps + * in a derived table to avoid "can't specify target table for update in FROM clause"). + */ + protected buildExistsExpression(innerQuery: SelectQueryBuilder): Expression { + return this.eb.exists(innerQuery); + } + // #region abstract methods abstract get provider(): DataSourceProviderType; diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index e9ca2e4aa..6e444227b 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -177,6 +177,13 @@ export class MySqlCrudDialect extends LateralJoinDiale // #region other overrides + protected override buildExistsExpression(innerQuery: SelectQueryBuilder): Expression { + // MySQL doesn't allow referencing the target table of a DELETE/UPDATE in a subquery + // directly within the same statement. Wrapping in a derived table materializes the + // subquery, making it a separate virtual table that MySQL accepts. + return this.eb.exists(this.eb.selectFrom(innerQuery.as('$exists_sub')).select(this.eb.lit(1).as('_'))); + } + protected buildArrayAgg(arg: Expression): AliasableExpression { return this.eb.fn.coalesce(sql`JSON_ARRAYAGG(${arg})`, sql`JSON_ARRAY()`); } diff --git a/tests/regression/test/issue-2440.test.ts b/tests/regression/test/issue-2440.test.ts new file mode 100644 index 000000000..f61423ab2 --- /dev/null +++ b/tests/regression/test/issue-2440.test.ts @@ -0,0 +1,119 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2440 +describe('Regression for issue 2440', () => { + const schema = ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + value Int + userId Int + user User @relation(fields: [userId], references: [id]) +} + `; + + it('some filter should return users that have at least one matching post', async () => { + const db = await createTestClient(schema); + + // userA has posts with value 1 and 3 + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 1 }, + { title: 'p2', value: 3 }, + ], + }, + }, + }); + // userB has only a post with value 2 + const userB = await db.user.create({ data: { name: 'B', posts: { create: [{ title: 'p3', value: 2 }] } } }); + // userC has no posts + await db.user.create({ data: { name: 'C' } }); + + const result = await db.user.findMany({ + where: { posts: { some: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(userA.id); + + const result2 = await db.user.findMany({ where: { posts: { some: {} } }, orderBy: { id: 'asc' } }); + expect(result2).toHaveLength(2); + expect(result2.map((u: any) => u.id)).toEqual([userA.id, userB.id]); + }); + + it('none filter should return users that have no matching posts', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 1 }, + { title: 'p2', value: 3 }, + ], + }, + }, + }); + await db.user.create({ data: { name: 'B', posts: { create: [{ title: 'p3', value: 2 }] } } }); + const userC = await db.user.create({ data: { name: 'C' } }); + + const result = await db.user.findMany({ + where: { posts: { none: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + // userB (value 2, not > 2) and userC (no posts) have none with value > 2 + expect(result).toHaveLength(2); + const ids = result.map((u: any) => u.id); + expect(ids).not.toContain(userA.id); + expect(ids).toContain(userC.id); + }); + + it('every filter should return users where all posts match the condition', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 3 }, + { title: 'p2', value: 5 }, + ], + }, + }, + }); + await db.user.create({ + data: { + name: 'B', + posts: { + create: [ + { title: 'p3', value: 2 }, + { title: 'p4', value: 4 }, + ], + }, + }, + }); + const userC = await db.user.create({ data: { name: 'C' } }); + + // userA: all posts have value > 2 (3 and 5) ✓ + // userB: has a post with value 2, not > 2 ✗ + // userC: no posts, every filter vacuously true ✓ + const result = await db.user.findMany({ + where: { posts: { every: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(2); + expect(result.map((u: any) => u.id)).toEqual([userA.id, userC.id]); + }); +}); From 4a99088bcef82af53be24714c910319d579dd6ae Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:25:10 -0800 Subject: [PATCH 3/8] fix(sdk): correctly handle mixin fields for delegate model inheritance Fields inherited via a mixin type on a delegate base model were not getting their `originModel` set in the generated schema, causing the ORM to include them in the wrong table's INSERT statement. Introduces `getOwnedFields` and `getDelegateOriginModel` helpers in `model-utils.ts` and uses them in both `ts-schema-generator` and `prisma-schema-generator`, replacing the previous logic that only checked `field.$container` directly. Fixes #2351 Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/src/model-utils.ts | 30 +++++++ .../sdk/src/prisma/prisma-schema-generator.ts | 10 +-- packages/sdk/src/ts-schema-generator.ts | 18 ++--- packages/testtools/src/client.ts | 4 + tests/regression/test/issue-2351.test.ts | 79 +++++++++++++++++++ 5 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 tests/regression/test/issue-2351.test.ts diff --git a/packages/sdk/src/model-utils.ts b/packages/sdk/src/model-utils.ts index 2473f284b..e1cc43f0c 100644 --- a/packages/sdk/src/model-utils.ts +++ b/packages/sdk/src/model-utils.ts @@ -71,6 +71,36 @@ export function isDelegateModel(node: AstNode) { return isDataModel(node) && hasAttribute(node, '@@delegate'); } +/** + * Returns all fields that physically belong to a model's table: its directly declared + * fields plus fields from its mixins (recursively). + */ +export function getOwnedFields(model: DataModel | TypeDef): DataField[] { + const fields: DataField[] = [...model.fields]; + for (const mixin of model.mixins) { + if (mixin.ref) { + fields.push(...getOwnedFields(mixin.ref)); + } + } + return fields; +} + +/** + * Returns the name of the delegate base model that "owns" the given field in the context of + * `contextModel`. This handles both direct fields of delegate models and mixin fields that + * belong to a mixin used by a delegate base model. + */ +export function getDelegateOriginModel(field: DataField, contextModel: DataModel): string | undefined { + let base = contextModel.baseModel?.ref; + while (base) { + if (isDelegateModel(base) && getOwnedFields(base).includes(field)) { + return base.name; + } + base = base.baseModel?.ref; + } + return undefined; +} + export function isUniqueField(field: DataField) { if (hasAttribute(field, '@unique')) { return true; diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index a524755da..815403cac 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -42,7 +42,7 @@ import { import { AstUtils } from 'langium'; import { match } from 'ts-pattern'; import { ModelUtils } from '..'; -import { DELEGATE_AUX_RELATION_PREFIX, getIdFields } from '../model-utils'; +import { DELEGATE_AUX_RELATION_PREFIX, getDelegateOriginModel, getIdFields } from '../model-utils'; import { AttributeArgValue, ModelFieldType, @@ -204,7 +204,7 @@ export class PrismaSchemaGenerator { continue; // skip computed fields } // exclude non-id fields inherited from delegate - if (ModelUtils.isIdField(field, decl) || !this.isInheritedFromDelegate(field, decl)) { + if (ModelUtils.isIdField(field, decl) || !getDelegateOriginModel(field, decl)) { this.generateModelField(model, field, decl); } } @@ -311,7 +311,7 @@ export class PrismaSchemaGenerator { // when building physical schema, exclude `@default` for id fields inherited from delegate base !( ModelUtils.isIdField(field, contextModel) && - this.isInheritedFromDelegate(field, contextModel) && + getDelegateOriginModel(field, contextModel) && attr.decl.$refText === '@default' ), ) @@ -335,10 +335,6 @@ export class PrismaSchemaGenerator { return AstUtils.streamAst(expr).some(isAuthInvocation); } - private isInheritedFromDelegate(field: DataField, contextModel: DataModel) { - return field.$container !== contextModel && ModelUtils.isDelegateModel(field.$container); - } - private makeFieldAttribute(attr: DataFieldAttribute) { const attrName = attr.decl.ref!.name; return new PrismaFieldAttribute( diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 90f6ceafa..b10068a3f 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -45,6 +45,7 @@ import { ModelUtils } from '.'; import { getAttribute, getAuthDecl, + getDelegateOriginModel, getIdFields, hasAttribute, isDelegateModel, @@ -587,17 +588,14 @@ export class TsSchemaGenerator { if ( contextModel && // id fields are duplicated in inherited models - !isIdField(field, contextModel) && - field.$container !== contextModel && - isDelegateModel(field.$container) + !isIdField(field, contextModel) ) { - // field is inherited from delegate - objectFields.push( - ts.factory.createPropertyAssignment( - 'originModel', - ts.factory.createStringLiteral(field.$container.name), - ), - ); + const delegateOrigin = getDelegateOriginModel(field, contextModel); + if (delegateOrigin) { + objectFields.push( + ts.factory.createPropertyAssignment('originModel', ts.factory.createStringLiteral(delegateOrigin)), + ); + } } // discriminator diff --git a/packages/testtools/src/client.ts b/packages/testtools/src/client.ts index 69513eeb4..c9d0ca8b3 100644 --- a/packages/testtools/src/client.ts +++ b/packages/testtools/src/client.ts @@ -238,6 +238,10 @@ export async function createTestClient( execSync('npx prisma db push --schema ./schema.prisma --skip-generate --force-reset', { cwd: workDir, stdio: options.debug ? 'inherit' : 'ignore', + env: { + ...process.env, + PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: 'true', + }, }); } else { await prepareDatabase(provider, dbName); diff --git a/tests/regression/test/issue-2351.test.ts b/tests/regression/test/issue-2351.test.ts new file mode 100644 index 000000000..fcd88d562 --- /dev/null +++ b/tests/regression/test/issue-2351.test.ts @@ -0,0 +1,79 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2351 +describe('Regression for issue 2351', () => { + it('should correctly query delegate model that inherits from a model using a mixin abstract type', async () => { + const db = await createPolicyTestClient( + ` +type BaseEntity { + id String @id @default(cuid()) + createdOn DateTime @default(now()) + updatedOn DateTime @updatedAt + isDeleted Boolean @default(false) + isArchived Boolean @default(false) +} + +enum DataType { + TEXT + NUMBER +} + +model RoutineData with BaseEntity { + dataType DataType + routineId String + Routine Routine @relation(fields: [routineId], references: [id]) + @@delegate(dataType) + @@allow('all', auth().id == Routine.userId) +} + +model Routine { + id String @id @default(cuid()) + userId String + User User @relation(fields: [userId], references: [id]) + data RoutineData[] + @@allow('all', true) +} + +model User { + id String @id @default(cuid()) + name String + routines Routine[] + @@allow('all', true) +} + +model DataText extends RoutineData { + textValue String +} + `, + { usePrismaPush: true }, + ); + + const user = await db.user.create({ + data: { + name: 'Test User', + }, + }); + + const routine = await db.routine.create({ + data: { + userId: user.id, + }, + }); + + const authDb = db.$setAuth({ id: user.id }); + const created = await authDb.dataText.create({ + data: { textValue: 'hello', routineId: routine.id }, + }); + expect(created.textValue).toBe('hello'); + expect(created.isDeleted).toBe(false); + expect(created.isArchived).toBe(false); + + const found = await authDb.dataText.findUnique({ + where: { id: created.id }, + }); + expect(found).not.toBeNull(); + expect(found!.textValue).toBe('hello'); + expect(found!.createdOn).toBeDefined(); + }); +}); From bd2b111b8ed10e48d455d02f0f275369598858db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:26:46 -0800 Subject: [PATCH 4/8] [CI] Bump version 3.4.5 (#2457) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> --- package.json | 2 +- packages/auth-adapters/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/clients/client-helpers/package.json | 2 +- packages/clients/tanstack-query/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/ide/vscode/package.json | 2 +- packages/language/package.json | 2 +- packages/orm/package.json | 2 +- packages/plugins/policy/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- packages/zod/package.json | 2 +- samples/orm/package.json | 2 +- tests/e2e/package.json | 2 +- tests/regression/package.json | 2 +- tests/runtimes/bun/package.json | 2 +- tests/runtimes/edge-runtime/package.json | 2 +- 24 files changed, 24 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index b72b85dd3..9655ac03d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack", "packageManager": "pnpm@10.23.0", "type": "module", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 0b69b2049..b1656e41c 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/better-auth", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", "type": "module", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index f7dc0c589..1391e33a5 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.4.4", + "version": "3.4.5", "type": "module", "author": { "name": "ZenStack Team" diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 0e30523ba..a8da05dbc 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/client-helpers", - "version": "3.4.4", + "version": "3.4.5", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", "type": "module", "scripts": { diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 020014b43..335e71bcb 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.4.4", + "version": "3.4.5", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", "type": "module", "scripts": { diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 50cc27880..4d07aa1c8 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 66b864ba2..2a6274263 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.4.4", + "version": "3.4.5", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 333a5e4c3..64977930a 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.4.4", + "version": "3.4.5", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index c8b22184c..a302ad4a4 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.4.4", + "version": "3.4.5", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 4de6891f5..b45cc8a10 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.4.4", + "version": "3.4.5", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index f4a05378f..41c420fb1 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.4.4", + "version": "3.4.5", "displayName": "ZenStack V3 Language Tools", "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index ad73e4afe..85599be25 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.4.4", + "version": "3.4.5", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/orm/package.json b/packages/orm/package.json index d35875e55..ca38f2849 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/orm", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack ORM", "type": "module", "scripts": { diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 5efc9d1a6..0f9a0c8b5 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/plugin-policy", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Policy Plugin", "type": "module", "scripts": { diff --git a/packages/schema/package.json b/packages/schema/package.json index 1f4ad8cca..16c77054f 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/schema", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Runtime Schema", "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index fcb6049b9..6dcdb9948 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index c2eba36cf..aed1b34e9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack automatic CRUD API handlers and server adapters", "type": "module", "scripts": { diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 9d8efb11c..83ec62bd0 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/zod/package.json b/packages/zod/package.json index a9bf32efe..5da1cb6af 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Zod integration", "type": "module", "scripts": { diff --git a/samples/orm/package.json b/samples/orm/package.json index 0e48042c4..05dfbba1c 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.4.4", + "version": "3.4.5", "description": "", "main": "index.js", "private": true, diff --git a/tests/e2e/package.json b/tests/e2e/package.json index bb8a70c7a..29ea61baf 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.4.4", + "version": "3.4.5", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index e27f0a55c..cdd511a7e 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.4.4", + "version": "3.4.5", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 8513628ee..9c2bce7e6 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.4.4", + "version": "3.4.5", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 48cc8cea8..52b9009f1 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.4.4", + "version": "3.4.5", "private": true, "type": "module", "scripts": { From ffa17203f3bc87fad32f11ac7dc7e60273852cfa Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:14:26 +0000 Subject: [PATCH 5/8] fix(test): add missing dataType field in issue-2351 regression test The test was failing on MySQL because the dataType enum field was required but not provided when creating DataText records. MySQL is stricter about enum validation than SQLite, causing 'Data truncated for column' errors. Co-authored-by: Yiming Cao --- tests/regression/test/issue-2351.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/regression/test/issue-2351.test.ts b/tests/regression/test/issue-2351.test.ts index fcd88d562..ef1d551ef 100644 --- a/tests/regression/test/issue-2351.test.ts +++ b/tests/regression/test/issue-2351.test.ts @@ -63,7 +63,7 @@ model DataText extends RoutineData { const authDb = db.$setAuth({ id: user.id }); const created = await authDb.dataText.create({ - data: { textValue: 'hello', routineId: routine.id }, + data: { textValue: 'hello', routineId: routine.id, dataType: 'TEXT' }, }); expect(created.textValue).toBe('hello'); expect(created.isDeleted).toBe(false); From 956a64ddbea18974dd85916d4fcf52ea3eb28ff7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:30:46 +0000 Subject: [PATCH 6/8] fix(test): correct delegate discriminator enum values to match model names - Change DataType enum from TEXT/NUMBER to DataText/DataNumber - Remove explicit dataType field from test as it's auto-set by delegate discriminator - Fixes MySQL test failure: delegate discriminators must use model names Co-authored-by: Yiming Cao --- tests/regression/test/issue-2351.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/regression/test/issue-2351.test.ts b/tests/regression/test/issue-2351.test.ts index ef1d551ef..9d883200d 100644 --- a/tests/regression/test/issue-2351.test.ts +++ b/tests/regression/test/issue-2351.test.ts @@ -15,8 +15,8 @@ type BaseEntity { } enum DataType { - TEXT - NUMBER + DataText + DataNumber } model RoutineData with BaseEntity { @@ -63,7 +63,7 @@ model DataText extends RoutineData { const authDb = db.$setAuth({ id: user.id }); const created = await authDb.dataText.create({ - data: { textValue: 'hello', routineId: routine.id, dataType: 'TEXT' }, + data: { textValue: 'hello', routineId: routine.id }, }); expect(created.textValue).toBe('hello'); expect(created.isDeleted).toBe(false); From 20832441c7d9bd5cc11bb7243e65f67076bff1ee Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sun, 8 Mar 2026 08:06:28 +0800 Subject: [PATCH 7/8] fix(policy): handle DefaultInsertValueNode in createManyAndReturn (#2461) Co-authored-by: Claude Opus 4.6 --- packages/plugins/policy/src/policy-handler.ts | 4 ++ tests/regression/test/issue-2460.test.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 tests/regression/test/issue-2460.test.ts diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index f42459bc4..968703957 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -920,6 +920,10 @@ export class PolicyHandler extends OperationNodeTransf for (let i = 0; i < data.length; i++) { const item = data[i]!; if (typeof item === 'object' && item && 'kind' in item) { + if (item.kind === 'DefaultInsertValueNode') { + result.push({ node: ValueNode.create(null), raw: null }); + continue; + } const fieldDef = QueryUtils.requireField(this.client.$schema, model, fields[i]!); invariant(item.kind === 'ValueNode', 'expecting a ValueNode'); result.push({ diff --git a/tests/regression/test/issue-2460.test.ts b/tests/regression/test/issue-2460.test.ts new file mode 100644 index 000000000..2ad48a147 --- /dev/null +++ b/tests/regression/test/issue-2460.test.ts @@ -0,0 +1,37 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// createManyAndReturn fails with "Invariant failed: expecting a ValueNode" +// when rows in the batch have asymmetric columns (one row provides a field the other omits) +describe('Regression for issue #2460', () => { + it('createManyAndReturn with asymmetric optional fields across rows', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + role String + @@allow('all', true) +} + +model Item { + id Int @id @default(autoincrement()) + key String + note String? + @@allow('all', auth().role == 'admin') +} + `, + { provider: 'postgresql' }, + ); + + const user = await db.user.create({ data: { role: 'admin' } }); + + const result = await db.$setAuth(user).item.createManyAndReturn({ + data: [ + { key: 'a', note: 'hello' }, + { key: 'b' }, + ], + }); + + expect(result).toHaveLength(2); + }); +}); From 5aaef621b4dd6c80036a088706acc60e1b2a4722 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Sat, 7 Mar 2026 16:26:53 -0800 Subject: [PATCH 8/8] chore: address PR comments (#2463) --- packages/sdk/src/model-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/model-utils.ts b/packages/sdk/src/model-utils.ts index e1cc43f0c..5c1370370 100644 --- a/packages/sdk/src/model-utils.ts +++ b/packages/sdk/src/model-utils.ts @@ -93,7 +93,7 @@ export function getOwnedFields(model: DataModel | TypeDef): DataField[] { export function getDelegateOriginModel(field: DataField, contextModel: DataModel): string | undefined { let base = contextModel.baseModel?.ref; while (base) { - if (isDelegateModel(base) && getOwnedFields(base).includes(field)) { + if (isDelegateModel(base) && getOwnedFields(base).some((f) => f.name === field.name)) { return base.name; } base = base.baseModel?.ref;