From bcdd7ebec4fc1477379e3ab351e158b6ecb83002 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Thu, 3 Sep 2026 10:28:45 +0300 Subject: [PATCH 1/5] fix(orm): provide qualifier for enum resolveFieldFromScopes (#2825) (#2826) --- .../orm/src/client/executor/name-mapper.ts | 5 ++- tests/regression/test/issue-2825.test.ts | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/regression/test/issue-2825.test.ts diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index a5891a7e4..3157537c9 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -817,7 +817,10 @@ export class QueryNameMapper extends OperationNodeTransformer { private processEnumSelection(selection: SelectionNodeChild, fieldName: string) { const { alias, node } = stripAlias(selection); - const fieldScope = this.resolveFieldFromScopes(fieldName); + const fieldScope = this.resolveFieldFromScopes( + fieldName, + ReferenceNode.is(node) ? node.table?.table?.identifier.name : undefined, + ); if (!fieldScope || !fieldScope.model) { return selection; } diff --git a/tests/regression/test/issue-2825.test.ts b/tests/regression/test/issue-2825.test.ts new file mode 100644 index 000000000..b5526e3f8 --- /dev/null +++ b/tests/regression/test/issue-2825.test.ts @@ -0,0 +1,40 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2825 +describe('Regression for issue #2825', () => { + it('handle same column name with enum type', async () => { + const schema = ` +enum UserStatus { + OK1 @map("ok1") + NO1 @map("no1") + + @@map("user_status") +} + +enum PostStatus { + OK2 @map("ok2") + NO2 @map("no2") + + @@map("post_status") +} + +model User { + id Int @id @default(autoincrement()) + status UserStatus? + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + status PostStatus? + author User? @relation(fields: [authorId], references: [id]) + authorId Int +} +`; + + const db = await createTestClient(schema, { usePrismaPush: true, provider: 'postgresql' }); + + await db.$qb.selectFrom('User as u').innerJoin('Post as p', 'p.authorId', 'u.id').select('u.status').execute(); + }); +}); From 914c31fee985968fe4247da67148a2ce2ae93619 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:36:15 +0800 Subject: [PATCH 2/5] [CI] Bump version 3.9.4 (#2829) 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/fetch-client/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/tsdown-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/plugins/soft-delete/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 +- samples/taskforge/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 +- 28 files changed, 28 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 1e6c7ae20..5b22e84ff 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 6f99bea89..79c08f48d 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/better-auth", "displayName": "ZenStack Better Auth Adapter", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index bafccb1ab..ea9e43cfc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/cli", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 2aad5c83e..8c2a56eb3 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/client-helpers", "displayName": "ZenStack Client Helpers", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index 8ac360f1e..8c8d0fef3 100644 --- a/packages/clients/fetch-client/package.json +++ b/packages/clients/fetch-client/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/fetch-client", "displayName": "ZenStack Fetch Client", "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 91ffaa8d2..138757afb 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack TanStack Query Integration", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 9511d797a..ff992909a 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/common-helpers", "displayName": "ZenStack Common Helpers", "description": "ZenStack Common Helpers", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 90af2ab02..e65b1283c 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.9.3", + "version": "3.9.4", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index f4d1a663c..3b5fa089c 100644 --- a/packages/config/tsdown-config/package.json +++ b/packages/config/tsdown-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tsdown-config", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 01fa90127..7c991a981 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.9.3", + "version": "3.9.4", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 29f35e5b3..7304c5014 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.9.3", + "version": "3.9.4", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index b6a036a6f..3948cae8e 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -2,7 +2,7 @@ "name": "create-zenstack", "displayName": "Create ZenStack", "description": "Create a new ZenStack project", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 22e9719e7..07342b5b0 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.9.3", + "version": "3.9.4", "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 db8a41558..86f934bc0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/language", "displayName": "ZenStack Language Tooling", "description": "ZenStack ZModel language specification", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index daefd9a76..e2455a73f 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/orm", "displayName": "ZenStack ORM", "description": "ZenStack ORM", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index bbd07520f..d2318c285 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-policy", "displayName": "ZenStack Access Policy Plugin", "description": "ZenStack plugin that enforces access control policies defined in the schema", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index 7bea6c37b..b5d1cbab8 100644 --- a/packages/plugins/soft-delete/package.json +++ b/packages/plugins/soft-delete/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-soft-delete", "displayName": "ZenStack Soft Delete Plugin", "description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index 6bd9dd7f2..fa086873c 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/schema", "displayName": "ZenStack Schema Object Model", "description": "TypeScript representation of ZModel schema", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c4925af52..5b8b03760 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/sdk", "displayName": "ZenStack SDK", "description": "Utilities for building ZenStack plugins", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index 38cafa65c..570bf26b2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/server", "displayName": "ZenStack Automatic CRUD Server", "description": "ZenStack automatic CRUD API handlers and server adapters for popular frameworks", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index f74273ed2..6dbd9b46f 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/testtools", "displayName": "ZenStack Test Tools", "description": "ZenStack Test Tools", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index 3605da60a..766889196 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/zod", "displayName": "ZenStack Zod Integration", "description": "Automatically deriving Zod schemas from ZModel schemas", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index 7c95a6fdf..ad150236e 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.9.3", + "version": "3.9.4", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index 551a0466f..6e00d7b15 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.9.3", + "version": "3.9.4", "type": "module", "private": true, "description": "A CLI for a team collaboration / project-tracking platform, built on ZenStack v3 (ORM) and better-auth.", diff --git a/tests/e2e/package.json b/tests/e2e/package.json index f27191aff..4d6e24166 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 381c1b1d2..a901ebee5 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 1aa24913e..a89f63088 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.9.3", + "version": "3.9.4", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 63e6dd5fa..3ff29dbb3 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.9.3", + "version": "3.9.4", "private": true, "type": "module", "scripts": { From 3963187b677037e107c2f5faedb37172119a9e7e Mon Sep 17 00:00:00 2001 From: sanny-io <3054653+sanny-io@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:44:57 -0700 Subject: [PATCH 3/5] feat(zod): uuid validation (#2801) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- packages/cli/test/db/pull.test.ts | 1 + packages/language/res/stdlib.zmodel | 11 +++ packages/language/src/utils.ts | 8 ++ .../attribute-application-validator.ts | 9 +++ .../function-invocation-validator.ts | 14 ++++ .../test/attribute-application.test.ts | 75 +++++++++++++++++++ .../language/test/function-invocation.test.ts | 48 ++++++++++++ packages/zod/src/utils.ts | 18 +++++ packages/zod/test/factory.test.ts | 16 ++++ packages/zod/test/schema/schema-lite.ts | 16 ++++ packages/zod/test/schema/schema.ts | 6 ++ packages/zod/test/schema/schema.zmodel | 1 + .../orm/validation/custom-validation.test.ts | 7 ++ tests/e2e/orm/validation/toplevel.test.ts | 14 ++++ 14 files changed, 244 insertions(+) diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index 920332208..101a738db 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -652,6 +652,7 @@ enum Status { score Float @gte(0.0) rating Decimal @lt(10) rank BigInt @lte(999) + extId String @uuid }`, ); runCli('db push', workDir); diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index a8970a1d0..5ec7d7e02 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -542,6 +542,11 @@ attribute @contains(_ text: String, _ message: String?) @@@targetField([StringFi */ attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite +/** + * Validates a string field value is a valid UUID. + */ +attribute @uuid(_ version: Int?, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite + /** * Validates a string field value is a valid email address. */ @@ -649,6 +654,12 @@ function isDate(field: String): Boolean { function isTime(field: String, precision: Int?): Boolean { } @@@expressionContext([ValidationRule]) +/** + * Validates a string field value is a valid UUID. + */ +function isUuid(field: String, version: Int?): Boolean { +} @@@expressionContext([ValidationRule]) + /** * Validates a string field value is a valid url. */ diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index 6c184babd..aa2ecfe3e 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -19,6 +19,7 @@ import { isLiteralExpr, isMemberAccessExpr, isModel, + isNumberLiteral, isObjectExpr, isPlugin, isReferenceExpr, @@ -78,6 +79,13 @@ export function getStringLiteral(node: AstNode | undefined): string | undefined return isStringLiteral(node) ? node.value : undefined; } +/** + * Try getting number value from a potential number literal expression + */ +export function getNumberLiteral(node: AstNode | undefined): number | undefined { + return isNumberLiteral(node) ? Number(node.value) : undefined; +} + const isoDateTimeRegex = /^\d{4}(-\d\d(-\d\d(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?)?)?$/i; /** diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index daaaf7b42..17d1b9904 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -29,6 +29,7 @@ import { getAttributeArg, getContainingDataModel, getDataSourceProvider, + getNumberLiteral, getStringLiteral, hasAttribute, isAuthOrAuthMemberAccess, @@ -460,6 +461,14 @@ export default class AttributeApplicationValidator implements AstValidator(versionArg); + if (version !== undefined && version !== 4 && version !== 7) { + accept('error', 'second argument must be 4 or 7', { + node: expr.args[1]!, + }); + } + } + } + @func('cuid') private _checkCuid(expr: InvocationExpr, accept: ValidationAcceptor) { // first argument must be 1 or 2 if provided diff --git a/packages/language/test/attribute-application.test.ts b/packages/language/test/attribute-application.test.ts index aaec9f987..190e4096f 100644 --- a/packages/language/test/attribute-application.test.ts +++ b/packages/language/test/attribute-application.test.ts @@ -588,6 +588,81 @@ describe('Attribute application validation tests', () => { }); }); + describe('Field-level @uuid attribute', () => { + it('does not require a version arg', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid + } + `, + ); + }); + + it('accepts supported version args', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(4) + } + `, + ); + + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(7) + } + `, + ); + }); + + it('rejects unsupported version args', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(1) + } + `, + /`@uuid` version must be `4` or `7`/, + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(2) + } + `, + /`@uuid` version must be `4` or `7`/, + ); + }); + }); + describe('Native type mapping attributes', () => { describe('sqlite', () => { it('rejects when any native type mapping attribute is used', async () => { diff --git a/packages/language/test/function-invocation.test.ts b/packages/language/test/function-invocation.test.ts index ff6bb45ef..68d20a092 100644 --- a/packages/language/test/function-invocation.test.ts +++ b/packages/language/test/function-invocation.test.ts @@ -414,4 +414,52 @@ describe('Function Invocation Tests', () => { ); }); }); + + describe('isUuid() version validation', () => { + it('should accept valid uuid versions', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid(4)) + + @@validate(isUuid(id, 4)) + } + `); + + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid(7)) + + @@validate(isUuid(id, 7)) + } + `); + }); + + it('should reject invalid uuid versions', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid()) + + @@validate(isUuid(id, 1)) + } + `, + 'second argument must be 4 or 7', + ); + }); + }); }); diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index a25202187..3aeb693b7 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -17,6 +17,7 @@ const stringFuncZodMap = { isEmail: 'email', isUrl: 'url', isPhone: 'e164', + isUuid: 'uuid', isDate: 'date', isTime: 'time', isDateTime: 'datetime', @@ -79,6 +80,15 @@ export function addStringValidation( } break; } + case '@uuid': { + const version = getArgValue(attr.args?.[0]?.value); + if (version === 7) { + result = result.uuidv7(); + } else { + result = result.uuidv4(); + } + break; + } case '@email': result = result.email(); break; @@ -555,6 +565,7 @@ function evalCall(data: any, expr: CallExpression) { case 'isEmail': case 'isUrl': case 'isPhone': + case 'isUuid': case 'isDate': case 'isTime': case 'isDateTime': { @@ -569,6 +580,13 @@ function evalCall(data: any, expr: CallExpression) { `"isTime" optional second argument must be a number`, ); return z.iso.time({ precision }).safeParse(fieldArg).success; + } else if (f === 'isUuid') { + const version = getArgValue(expr.args?.[1]); + invariant( + version === null || version == undefined || version === 4 || version === 7, + `"isUuid" optional second argument must 4 or 7`, + ); + return z.uuid({ version: version ? `v${version}` : undefined }).safeParse(fieldArg).success; } const fn = stringFuncZodMap[f]; return z.string()[fn]().safeParse(fieldArg).success; diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 19c5e9bce..8a21945f5 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -32,6 +32,7 @@ describe.each([ metadata: null, status: 'ACTIVE', address: null, + extId: null, }; // A fully valid Post object (without relations) @@ -290,6 +291,21 @@ describe.each([ expect(result.success).toBe(true); }); + it('rejects invalid uuid for @uuid field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, extId: 'not-a-uuid' }); + expect(result.success).toBe(false); + }); + + it('accepts valid uuid for @uuid field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ + ...validUser, + extId: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3', + }); + expect(result.success).toBe(true); + }); + it('rejects invalid date for @date field', () => { const userSchema = factory.makeModelSchema('User'); const result = userSchema.safeParse({ ...validUser, birthdate: 'not-a-date' }); diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts index c1f44d019..78ef211f2 100644 --- a/packages/zod/test/schema/schema-lite.ts +++ b/packages/zod/test/schema/schema-lite.ts @@ -18,6 +18,7 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("cuid") as FieldDefault }, email: { @@ -82,6 +83,12 @@ export class SchemaType implements SchemaDef { optional: true, attributes: [{ name: "@time" }] as readonly AttributeApplication[] }, + extId: { + name: "extId", + type: "String", + optional: true, + attributes: [{ name: "@uuid" }] as readonly AttributeApplication[] + }, createdAt: { name: "createdAt", type: "DateTime", @@ -129,6 +136,7 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("cuid") as FieldDefault }, title: { @@ -171,6 +179,7 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("cuid") as FieldDefault }, name: { @@ -184,6 +193,7 @@ export class SchemaType implements SchemaDef { discount: { name: "discount", type: "Float", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[], default: 0 as FieldDefault }, finalPrice: { @@ -211,11 +221,13 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("now") as FieldDefault }, assetType: { @@ -239,12 +251,14 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("now") as FieldDefault }, assetType: { @@ -275,12 +289,14 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], default: ExpressionUtils.call("now") as FieldDefault }, assetType: { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index fa7bc045c..ef36c9113 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -83,6 +83,12 @@ export class SchemaType implements SchemaDef { optional: true, attributes: [{ name: "@time" }] as readonly AttributeApplication[] }, + extId: { + name: "extId", + type: "String", + optional: true, + attributes: [{ name: "@uuid" }] as readonly AttributeApplication[] + }, createdAt: { name: "createdAt", type: "DateTime", diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index e7deb27aa..6aa265ad0 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -34,6 +34,7 @@ model User { active Boolean birthdate String? @date localTime String? @time + extId String? @uuid createdAt DateTime? avatar Bytes? metadata Json? diff --git a/tests/e2e/orm/validation/custom-validation.test.ts b/tests/e2e/orm/validation/custom-validation.test.ts index 47f12049d..0523c9fc5 100644 --- a/tests/e2e/orm/validation/custom-validation.test.ts +++ b/tests/e2e/orm/validation/custom-validation.test.ts @@ -15,6 +15,7 @@ describe('Custom validation tests', () => { str6 String? str7 String? str8 String? + str9 String? int1 Int? list1 Int[] list2 Int[] @@ -41,6 +42,8 @@ describe('Custom validation tests', () => { @@validate(str8 == null || isTime(str8), 'invalid str8') + @@validate(str9 == null || isUuid(str9), 'invalid str9') + @@validate(list1 == null || (has(list1, 1) && hasSome(list1, [2, 3]) && hasEvery(list1, [4, 5])), 'invalid list1') @@validate(list2 == null || isEmpty(list2), 'invalid list2', ['x', 'y']) @@ -95,6 +98,9 @@ describe('Custom validation tests', () => { // violates time await expect(_t({ str8: 'not-a-time' })).toBeRejectedByValidation(['invalid str8']); + // violates uuid + await expect(_t({ str9: 'not-a-uuid' })).toBeRejectedByValidation(['invalid str9']); + // violates has await expect(_t({ list1: [2, 3, 4, 5] })).toBeRejectedByValidation(['invalid list1']); @@ -128,6 +134,7 @@ describe('Custom validation tests', () => { str6: '+15555555555', str7: '2000-01-01', str8: '03:15:00', + str9: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3', int1: 2, list1: [1, 2, 4, 5], list2: [], diff --git a/tests/e2e/orm/validation/toplevel.test.ts b/tests/e2e/orm/validation/toplevel.test.ts index fbfdbd92a..f1d2fcde5 100644 --- a/tests/e2e/orm/validation/toplevel.test.ts +++ b/tests/e2e/orm/validation/toplevel.test.ts @@ -18,6 +18,8 @@ describe('Toplevel field validation tests', () => { str8 String? @date str9 String? @time str10 String? @time(-1) + str11 String? @uuid + str12 String? @uuid(7) } `, ); @@ -111,6 +113,18 @@ describe('Toplevel field validation tests', () => { // satisfies @time(-1) await expect(_t({ str10: '03:15' })).toResolveTruthy(); + + // violates @uuid + await expect(_t({ str11: 'not-a-uuid' })).toBeRejectedByValidation(['Invalid UUID']); + + // satisfies @uuid + await expect(_t({ str11: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3' })).toResolveTruthy(); + + // violates @uuid(7) + await expect(_t({ str12: 'not-a-uuid' })).toBeRejectedByValidation(['Invalid UUID']); + + // satisfies @uuid(7) + await expect(_t({ str12: '019ff964-2f1d-7668-9a76-8648f2af9146' })).toResolveTruthy(); } }); From ef879813f1e12eb65914a95a86bd8b367d04e90b Mon Sep 17 00:00:00 2001 From: Jiasheng Date: Fri, 4 Sep 2026 09:31:02 +0800 Subject: [PATCH 4/5] refactor(cli-proxy): update createProxyApp interface to use generic type declaration to avoid type casting when adopted (#2817) --- packages/cli/src/proxy.ts | 52 +++----------- packages/cli/test/proxy.test.ts | 123 ++++++++++++++++++++++++-------- 2 files changed, 103 insertions(+), 72 deletions(-) diff --git a/packages/cli/src/proxy.ts b/packages/cli/src/proxy.ts index 56c389d19..6703e381a 100644 --- a/packages/cli/src/proxy.ts +++ b/packages/cli/src/proxy.ts @@ -37,10 +37,10 @@ export function normalizePublicKey(key: string): string { return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`; } -export interface CreateProxyAppOptions { - client: ClientContract; - schema: SchemaDef; - authDb?: ClientContract; +export interface CreateProxyAppOptions { + client: ClientContract; + schema: Schema; + authDb?: ClientContract; auth?: { studioAuthKey?: string; /** Seconds within which a signed request is considered valid. Defaults to 60. */ @@ -49,37 +49,7 @@ export interface CreateProxyAppOptions { cors?: Parameters[0]; } -export function createProxyApp(options: CreateProxyAppOptions): Hono; -export function createProxyApp( - client: ClientContract, - schema: SchemaDef, - authDb?: ClientContract, - auth?: { - studioAuthKey?: string; - signatureToleranceSecs?: number; - }, -): Hono; -export function createProxyApp( - optionsOrClient: CreateProxyAppOptions | ClientContract, - schema?: SchemaDef, - authDb?: ClientContract, - auth?: { - studioAuthKey?: string; - signatureToleranceSecs?: number; - }, -): Hono { - let options: CreateProxyAppOptions; - if ('client' in optionsOrClient && 'schema' in optionsOrClient) { - options = optionsOrClient as CreateProxyAppOptions; - } else { - options = { - client: optionsOrClient as ClientContract, - schema: schema!, - authDb, - auth, - }; - } - +export function createProxyApp(options: CreateProxyAppOptions): Hono { const app = new Hono(); app.use('*', cors(options.cors)); @@ -93,7 +63,7 @@ export function createProxyApp( app.use( '/api/model/*', - createHonoHandler({ + createHonoHandler({ apiHandler: new RPCApiHandler({ schema: options.schema }), getClient: (c) => resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey), @@ -174,12 +144,12 @@ export function createSignatureMiddleware(publicKey: string, toleranceSeconds: n }; } -export function resolveClient( - client: ClientContract, - authDb: ClientContract, +export function resolveClient( + client: ClientContract, + authDb: ClientContract, c: Context, isAuthKeyEnabled: boolean, -): ClientContract { +): ClientContract { const authHeader = c.req.header('authorization'); if (!isAuthKeyEnabled && !authHeader) { @@ -204,6 +174,6 @@ export function resolveClient( if (claim.type === 'superUser') { return client; } else { - return authDb.$setAuth(claim.data as any) as ClientContract; + return authDb.$setAuth(claim.data as any) as ClientContract; } } diff --git a/packages/cli/test/proxy.test.ts b/packages/cli/test/proxy.test.ts index 2e523b69a..bf4aae283 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -65,9 +65,14 @@ async function createPolicyApp(zmodel: string) { const authDb = client.$use(new PolicyPlugin()); return { client, - app: createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + app: createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }), }; } @@ -106,7 +111,11 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb); + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + }); const baseUrl = await startAt(app); const r = await fetch(`${baseUrl}/api/schema`); @@ -169,7 +178,11 @@ describe('CLI proxy tests', () => { }); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb); + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + }); const baseUrl = await startAt(app); // Create a user via the proxy API. @@ -217,7 +230,11 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb); + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + }); const baseUrl = await startAt(app); const txRes = await fetch(`${baseUrl}/api/model/$transaction/sequential`, { @@ -397,7 +414,11 @@ describe('CLI proxy tests', () => { // No studioAuthKey — backwards-compatible mode const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb); + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + }); const baseUrl = await startAt(app); // No signature header — should still work @@ -426,9 +447,14 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); // Pass the key as raw base64 DER — no PEM markers - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY_DER, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY_DER, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -449,9 +475,14 @@ describe('CLI proxy tests', () => { // No studioAuthKey option — would normally fall back to env var via run(); // here we verify the middleware still works when the resolved key is provided. const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: process.env['ZENSTACK_STUDIO_AUTH_KEY'], - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: process.env['ZENSTACK_STUDIO_AUTH_KEY'], + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -480,9 +511,14 @@ describe('CLI proxy tests', () => { it('should reject a request whose timestamp is older than the tolerance window', async () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -507,9 +543,14 @@ describe('CLI proxy tests', () => { it('should reject a request whose timestamp is too far in the future', async () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -535,9 +576,14 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); // Custom tolerance of 300 seconds const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 300, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 300, + }, }); const baseUrl = await startAt(app); @@ -561,9 +607,14 @@ describe('CLI proxy tests', () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); // Very tight tolerance of 5 seconds - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 5, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 5, + }, }); const baseUrl = await startAt(app); @@ -597,9 +648,14 @@ describe('CLI proxy tests', () => { it('should reject a valid signature if it was produced without the Authorization token', async () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); @@ -627,9 +683,14 @@ describe('CLI proxy tests', () => { it('should accept a request where the signature covers the Authorization token', async () => { const client = await createTestClient(zmodel); const authDb = client.$use(new PolicyPlugin()); - const app = createProxyApp(client, client.$schema, authDb, { - studioAuthKey: TEST_PUBLIC_KEY, - signatureToleranceSecs: 60, + const app = createProxyApp({ + client, + schema: client.$schema, + authDb, + auth: { + studioAuthKey: TEST_PUBLIC_KEY, + signatureToleranceSecs: 60, + }, }); const baseUrl = await startAt(app); From a4a25c4a8e7a7fed01a7c6955e24394fafe5b8ba Mon Sep 17 00:00:00 2001 From: sanny-io <3054653+sanny-io@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:10:11 -0700 Subject: [PATCH 5/5] fix(orm): add mutex for single-connection adapters (#2805) --- packages/orm/src/client/client-impl.ts | 8 +- .../src/client/executor/connection-mutex.ts | 30 +++++ .../src/client/executor/zenstack-driver.ts | 45 ++++++-- .../test/issue-2788/regression.test.ts | 68 ++++++++++++ tests/regression/test/issue-2788/schema.ts | 103 ++++++++++++++++++ .../regression/test/issue-2788/schema.zmodel | 26 +++++ 6 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 packages/orm/src/client/executor/connection-mutex.ts create mode 100644 tests/regression/test/issue-2788/regression.test.ts create mode 100644 tests/regression/test/issue-2788/schema.ts create mode 100644 tests/regression/test/issue-2788/schema.zmodel diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 74b6305a5..0ba70b525 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -122,9 +122,13 @@ export class ClientImpl { this.auth = baseClient.auth; this.slowQueries = baseClient.slowQueries; } else { - const driver = new ZenStackDriver(options.dialect.createDriver(), new Log(this.$options.log ?? [])); - const compiler = options.dialect.createQueryCompiler(); const adapter = options.dialect.createAdapter(); + const driver = new ZenStackDriver( + options.dialect.createDriver(), + new Log(this.$options.log ?? []), + adapter, + ); + const compiler = options.dialect.createQueryCompiler(); const connectionProvider = new DefaultConnectionProvider(driver); this.kyselyProps = { diff --git a/packages/orm/src/client/executor/connection-mutex.ts b/packages/orm/src/client/executor/connection-mutex.ts new file mode 100644 index 000000000..924a6294b --- /dev/null +++ b/packages/orm/src/client/executor/connection-mutex.ts @@ -0,0 +1,30 @@ +/** + * This mutex is used to ensure that only one operation at a time can + * acquire a connection from the driver. This is necessary when the + * driver only has a single connection, like SQLite and PGlite. + * + * @see {@link https://github.com/kysely-org/kysely/blob/478ec67b2de2568f5590a015d3e120644e81bd87/src/driver/connection-mutex.ts|Kysely Source} + */ +export class ConnectionMutex { + #promise?: Promise; + #resolve?: () => void; + + async obtainLock(): Promise { + while (this.#promise) { + await this.#promise; + } + + this.#promise = new Promise((resolve) => { + this.#resolve = resolve; + }); + } + + releaseLock(): void { + const resolve = this.#resolve; + + this.#promise = undefined; + this.#resolve = undefined; + + resolve?.(); + } +} diff --git a/packages/orm/src/client/executor/zenstack-driver.ts b/packages/orm/src/client/executor/zenstack-driver.ts index 747acdeda..90e9fef3c 100644 --- a/packages/orm/src/client/executor/zenstack-driver.ts +++ b/packages/orm/src/client/executor/zenstack-driver.ts @@ -1,4 +1,13 @@ -import type { CompiledQuery, DatabaseConnection, Driver, Log, QueryResult, TransactionSettings } from 'kysely'; +import type { + CompiledQuery, + DatabaseConnection, + DialectAdapter, + Driver, + Log, + QueryResult, + TransactionSettings, +} from 'kysely'; +import { ConnectionMutex } from './connection-mutex'; /** * Copied from kysely's RuntimeDriver @@ -6,6 +15,7 @@ import type { CompiledQuery, DatabaseConnection, Driver, Log, QueryResult, Trans export class ZenStackDriver implements Driver { readonly #driver: Driver; readonly #log: Log; + readonly #connectionMutex?: ConnectionMutex; #initPromise?: Promise; #initDone: boolean; @@ -13,10 +23,14 @@ export class ZenStackDriver implements Driver { #connections = new WeakSet(); #txConnections = new WeakMap Promise>>(); - constructor(driver: Driver, log: Log) { + constructor(driver: Driver, log: Log, adapter: DialectAdapter) { this.#initDone = false; this.#driver = driver; this.#log = log; + + if (!adapter.supportsMultipleConnections) { + this.#connectionMutex = new ConnectionMutex(); + } } async init(): Promise { @@ -48,21 +62,30 @@ export class ZenStackDriver implements Driver { await this.init(); } - const connection = await this.#driver.acquireConnection(); + await this.#connectionMutex?.obtainLock(); - if (!this.#connections.has(connection)) { - if (this.#needsLogging()) { - this.#addLogging(connection); - } + try { + const connection = await this.#driver.acquireConnection(); + if (!this.#connections.has(connection)) { + if (this.#needsLogging()) { + this.#addLogging(connection); + } - this.#connections.add(connection); + this.#connections.add(connection); + } + return connection; + } catch (error) { + this.#connectionMutex?.releaseLock(); + throw error; } - - return connection; } async releaseConnection(connection: DatabaseConnection): Promise { - await this.#driver.releaseConnection(connection); + try { + await this.#driver.releaseConnection(connection); + } finally { + this.#connectionMutex?.releaseLock(); + } } async beginTransaction(connection: DatabaseConnection, settings: TransactionSettings): Promise { diff --git a/tests/regression/test/issue-2788/regression.test.ts b/tests/regression/test/issue-2788/regression.test.ts new file mode 100644 index 000000000..22697fa75 --- /dev/null +++ b/tests/regression/test/issue-2788/regression.test.ts @@ -0,0 +1,68 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; +import { schema } from './schema'; + +// https://github.com/zenstackhq/zenstack/issues/2788 + +describe('Regression for issue #2788', () => { + it('does not error during concurrent upserts', async () => { + const db = await createTestClient(schema); + const user = await db.user.create({ + data: { + email: 'test@zenstack.dev', + posts: { + create: [ + { + title: 'Post 1', + content: 'This is a test post', + }, + ], + }, + }, + include: { posts: true }, + }); + + let posts: any[] = await db.post.findMany(); + + posts[0].title = 'Post 1 Updated'; + + posts.push({ + id: 'cmstai1q2000104js3i7s2d8l', + title: 'Post 2', + content: 'This is a test post', + authorId: user.id, + }); + + await Promise.all( + posts.map(async (p) => { + await db.post.upsert({ + where: { id: p.id }, + update: { ...p }, + create: { title: p.title!, content: p.content!, authorId: p.authorId! }, + }); + }), + ); + + posts = await db.post.findMany(); + + expect(posts.find((p) => p.title === 'Post 1 Updated')).toMatchObject({ + title: 'Post 1 Updated', + content: 'This is a test post', + published: false, + }); + + expect(posts.find((p) => p.title === 'Post 2')).toMatchObject({ + title: 'Post 2', + content: 'This is a test post', + published: false, + }); + + await expect( + db.post.findUnique({ + where: { + id: 'cmstai1q2000104js3i7s2d8l', + }, + }), + ).resolves.toBeNull(); + }); +}); diff --git a/tests/regression/test/issue-2788/schema.ts b/tests/regression/test/issue-2788/schema.ts new file mode 100644 index 000000000..30a40da4c --- /dev/null +++ b/tests/regression/test/issue-2788/schema.ts @@ -0,0 +1,103 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + email: { + name: "email", + type: "String", + unique: true, + attributes: [{ name: "@unique" }, { name: "@email" }, { name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(6) }, { name: "max", value: ExpressionUtils.literal(32) }] }] as readonly AttributeApplication[] + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + title: { + name: "title", + type: "String", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(256) }] }] as readonly AttributeApplication[] + }, + content: { + name: "content", + type: "String" + }, + published: { + name: "published", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + author: { + name: "author", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }] as readonly AttributeApplication[], + relation: { opposite: "posts", fields: ["authorId"], references: ["id"], onDelete: "Cascade" } + }, + authorId: { + name: "authorId", + type: "String", + foreignKeyFor: [ + "author" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/regression/test/issue-2788/schema.zmodel b/tests/regression/test/issue-2788/schema.zmodel new file mode 100644 index 000000000..5877e9b02 --- /dev/null +++ b/tests/regression/test/issue-2788/schema.zmodel @@ -0,0 +1,26 @@ +// This is a sample model to get you started. + +/// A sample data source using local sqlite db. +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + +/// User model +model User { + id String @id @default(cuid()) + email String @unique @email @length(6, 32) + posts Post[] +} + +/// Post model +model Post { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String @length(1, 256) + content String + published Boolean @default(false) + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId String +} \ No newline at end of file