From 97a0469c2a9f9a213cf6c358de75a8974a60cda2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:48:45 -0800 Subject: [PATCH 1/6] [CI] Bump version 3.10.1 (#2907) 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 aa054663a..6d7b7861e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.10.0", + "version": "3.10.1", "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 8179a10a1..18a293f81 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index d9c31c161..2ac5adc9f 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 812e177a5..4e6fc7e4f 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index cf0618f32..2f2f86b6d 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 3d0aceef6..f5403617a 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index af75d3ba7..44bb72108 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 7d01e5284..ac15fc5fa 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.10.0", + "version": "3.10.1", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index 1f6166638..4a0e4826d 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.10.0", + "version": "3.10.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 4c79af62c..1b08be16c 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.10.0", + "version": "3.10.1", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index f572f3a18..564433054 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.10.0", + "version": "3.10.1", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 5994776bb..fc4169ed4 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 041ff8b81..44050804a 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.10.0", + "version": "3.10.1", "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 9efef6ce4..fda3d8f28 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index b1e37b86a..587dbb8e1 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index ceda16676..4cc848e90 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index ab44006ba..8c27a5bc4 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index 78bf17c81..e7a43cc86 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7016a08fc..4f49f634b 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index 89f8c49f9..ddcc7ffe7 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index c7d87ea64..c845e37aa 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index c1e177818..7b7fcaf44 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.10.0", + "version": "3.10.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index db56ebcbc..957f1e2f4 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.10.0", + "version": "3.10.1", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index 870946d10..97bcfc28c 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.10.0", + "version": "3.10.1", "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 6b909d08a..deaf767dc 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.10.0", + "version": "3.10.1", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index b74c51293..44a6f9cb6 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.10.0", + "version": "3.10.1", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 7d42f831c..2f2d157bd 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.10.0", + "version": "3.10.1", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 41ca231ff..e02e15de6 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.10.0", + "version": "3.10.1", "private": true, "type": "module", "scripts": { From 608c3dc64332b26adc5f9645e256e363a3827a46 Mon Sep 17 00:00:00 2001 From: Evgenii Novalov <83656026+evgenovalov@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:52:15 +0100 Subject: [PATCH 2/6] fix(orm): share the zod schema factory across derived clients that need the same schemas (#2880) Co-authored-by: evgenovalov Co-authored-by: Claude Fable 5.1 --- packages/orm/src/client/client-impl.ts | 40 +++++++------- .../src/client/crud/validator/validator.ts | 12 +++-- packages/orm/src/client/zod/factory.ts | 20 +++++++ .../plugin-infra/zod-factory-sharing.test.ts | 54 +++++++++++++++++++ 4 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 tests/e2e/orm/plugin-infra/zod-factory-sharing.test.ts diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index f6d9f9d76..53f0f28ca 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -33,6 +33,7 @@ import { FindOperationHandler } from './crud/operations/find'; import { GroupByOperationHandler } from './crud/operations/group-by'; import { UpdateOperationHandler } from './crud/operations/update'; import { InputValidator } from './crud/validator'; +import { haveSameSchemaInputs } from './zod/factory'; import type { Diagnostics, QueryInfo } from './diagnostics'; import { createConfigError, createNotFoundError, createNotSupportedError } from './errors'; import { ZenStackDriver } from './executor/zenstack-driver'; @@ -518,11 +519,7 @@ export class ClientImpl { plugins: newPlugins, }; const newClient = new ClientImpl(this.schema, newOptions, this); - // create a new validator to have a fresh schema cache, because plugins may extend the - // query args schemas - newClient.inputValidator = new InputValidator(newClient as any, { - enabled: newOptions.validateInput !== false, - }); + newClient.inputValidator = this.deriveInputValidator(newClient); return newClient; } @@ -539,11 +536,7 @@ export class ClientImpl { plugins: newPlugins, }; const newClient = new ClientImpl(this.schema, newOptions, this); - // create a new validator to have a fresh schema cache, because plugins may - // extend the query args schemas - newClient.inputValidator = new InputValidator(newClient as any, { - enabled: newClient.$options.validateInput !== false, - }); + newClient.inputValidator = this.deriveInputValidator(newClient); return newClient; } @@ -554,14 +547,26 @@ export class ClientImpl { plugins: [] as AnyPlugin[], }; const newClient = new ClientImpl(this.schema, newOptions, this); - // create a new validator to have a fresh schema cache, because plugins may - // extend the query args schemas - newClient.inputValidator = new InputValidator(newClient as any, { - enabled: newOptions.validateInput !== false, - }); + newClient.inputValidator = this.deriveInputValidator(newClient); return newClient; } + /** + * Validator for a client derived from this one. The derived client reuses this client's Zod + * schema factory, and so its schema cache, whenever its options shape the same query-args + * schemas; it only gets a fresh factory when a plugin that extends query args or results was + * added or removed, or `omit`/`slicing` changed. The validator itself is always new because + * `validateInput` may differ. + */ + private deriveInputValidator(newClient: ClientImpl) { + return new InputValidator(newClient as any, { + enabled: newClient.$options.validateInput !== false, + zodFactory: haveSameSchemaInputs(this.$options, newClient.$options) + ? this.inputValidator.zodFactory + : undefined, + }); + } + $setAuth(auth: AuthType | undefined) { if (auth !== undefined && (typeof auth !== 'object' || auth === null || Array.isArray(auth))) { throw new Error('Invalid auth object'); @@ -588,10 +593,7 @@ export class ClientImpl { $setOptions>(options: Options): ClientContract { const newClient = new ClientImpl(this.schema, options as ClientOptions, this); - // create a new validator to have a fresh schema cache, because options may change validation settings - newClient.inputValidator = new InputValidator(newClient as any, { - enabled: newClient.$options.validateInput !== false, - }); + newClient.inputValidator = this.deriveInputValidator(newClient); return newClient as unknown as ClientContract; } diff --git a/packages/orm/src/client/crud/validator/validator.ts b/packages/orm/src/client/crud/validator/validator.ts index defaf9035..dc5f9265e 100644 --- a/packages/orm/src/client/crud/validator/validator.ts +++ b/packages/orm/src/client/crud/validator/validator.ts @@ -25,11 +25,17 @@ import { ZodSchemaFactory } from '../../zod/factory'; type GetSchemaFunc = (model: GetModels) => ZodType; -export type InputValidatorOptions = { +export type InputValidatorOptions = { /** * Whether validation is enabled. Defaults to `true`. */ enabled?: boolean; + + /** + * Schema factory to validate with. Defaults to a new factory (and so a new schema cache) bound + * to the client. + */ + zodFactory?: ZodSchemaFactory; }; export class InputValidator { @@ -38,9 +44,9 @@ export class InputValidator { constructor( private readonly client: ClientContract, - options?: InputValidatorOptions, + options?: InputValidatorOptions, ) { - this.zodFactory = new ZodSchemaFactory(client); + this.zodFactory = options?.zodFactory ?? new ZodSchemaFactory(client); this.enabled = options?.enabled !== false; } diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 53014d4b1..c001b2e94 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -85,6 +85,26 @@ export function createQuerySchemaFactory(clientOrSchema: any, options?: any) { return new ZodSchemaFactory(clientOrSchema, options); } +/** + * Whether two client options produce the same query-args schemas, i.e. a `ZodSchemaFactory` + * built for one can serve the other. Only `omit`, `slicing`, `allowQueryTimeOmitOverride` and + * the plugins that extend query args or results shape the schemas; dialect, auth, hooks and + * validation flags do not. + */ +export function haveSameSchemaInputs(a: ClientOptions, b: ClientOptions): boolean { + if (a === b) { + return true; + } + if (a.omit !== b.omit || a.slicing !== b.slicing || a.allowQueryTimeOmitOverride !== b.allowQueryTimeOmitOverride) { + return false; + } + const shaping = (options: ClientOptions) => + (options.plugins ?? []).filter((plugin) => plugin.queryArgs || plugin.result); + const pa = shaping(a); + const pb = shaping(b); + return pa.length === pb.length && pa.every((plugin, index) => plugin === pb[index]); +} + /** * Builds a `DateTime` value schema that accepts a `Date` object or any string * the JS `Date` constructor parses, and coerces it to a `Date`. ISO datetime, diff --git a/tests/e2e/orm/plugin-infra/zod-factory-sharing.test.ts b/tests/e2e/orm/plugin-infra/zod-factory-sharing.test.ts new file mode 100644 index 000000000..5d4c82768 --- /dev/null +++ b/tests/e2e/orm/plugin-infra/zod-factory-sharing.test.ts @@ -0,0 +1,54 @@ +import { definePlugin, type ClientContract } from '@zenstackhq/orm'; +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import z from 'zod'; +import { schema } from '../schemas/basic'; + +describe('Zod schema factory sharing across derived clients', () => { + let db: ClientContract; + + beforeEach(async () => { + db = await createTestClient(schema); + }); + + afterEach(async () => { + await db?.$disconnect(); + }); + + it('shares the factory with a $use client whose plugin does not extend schemas', async () => { + const derived = db.$use( + definePlugin({ + id: 'noop', + onQuery: ({ args, proceed }) => proceed(args), + }), + ); + expect(derived.$zod).toBe(db.$zod); + + await derived.user.findMany(); + const base = await db.$diagnostics; + const ext = await derived.$diagnostics; + expect(base.zodCache.size).toBeGreaterThan(0); + expect(ext.zodCache).toEqual(base.zodCache); + }); + + it('gives a fresh factory to a $use client whose plugin extends query args', () => { + const derived = db.$use( + definePlugin({ + id: 'cache', + queryArgs: { $read: z.object({ cache: z.boolean().optional() }) }, + onQuery: ({ args, proceed }) => proceed(args), + }), + ); + expect(derived.$zod).not.toBe(db.$zod); + }); + + it('shares the factory when only validation or non-schema options change', () => { + expect(db.$setInputValidation(false).$zod).toBe(db.$zod); + expect(db.$setOptions({ ...db.$options }).$zod).toBe(db.$zod); + }); + + it('gives a fresh factory when a schema-shaping option changes', () => { + const derived = db.$setOptions({ ...db.$options, allowQueryTimeOmitOverride: false }); + expect(derived.$zod).not.toBe(db.$zod); + }); +}); From 4a47f94a77095fb46088144ea1f94102e3c50d41 Mon Sep 17 00:00:00 2001 From: Krist Ponpairin Date: Thu, 5 Nov 2026 23:44:29 +0000 Subject: [PATCH 3/6] fix(orm): don't build an empty IN() when zero rows match an update/updateMany on MySQL (#2887) --- .../executor/zenstack-query-executor.ts | 7 +++++ tests/regression/test/issue-2884.test.ts | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/regression/test/issue-2884.test.ts diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index c7d9cbadc..a2b418029 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -460,6 +460,13 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { ? this.getInsertIds(mutationInfo.model, queryNode, queryResult) : preUpdateIds; + if (mutatedIds && mutatedIds.length === 0) { + // zero rows matched (e.g. a policy-denied or genuinely empty + // updateMany/deleteMany) — nothing to reload, and NOT the "can't + // determine ids at all" case below, so don't warn. + return []; + } + if (mutatedIds) { const idFields = requireIdFields(this.client.$schema, mutationInfo.model); const eb = expressionBuilder(); diff --git a/tests/regression/test/issue-2884.test.ts b/tests/regression/test/issue-2884.test.ts new file mode 100644 index 000000000..91d43785d --- /dev/null +++ b/tests/regression/test/issue-2884.test.ts @@ -0,0 +1,29 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue 2884', () => { + it('zero-match updateMany on MySQL does not throw when an afterEntityMutation hook is registered', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + email String @unique +} + `, + { provider: 'mysql', usePrismaPush: true }, + ); + + const hooked = db.$use({ + id: 'noop', + onEntityMutation: { + afterEntityMutation: async () => {}, + }, + }); + + // no row with id 999 exists -> preUpdateIds resolves to [] -> should be a + // normal zero-match update, not a thrown SQL syntax error + await expect( + hooked.user.updateMany({ where: { id: 999 }, data: { email: 'x@example.com' } }), + ).resolves.toEqual({ count: 0 }); + }); +}); From 269f73f3cf84c8bc295a54cbffe9a92acbde8a4e Mon Sep 17 00:00:00 2001 From: Krist Ponpairin Date: Fri, 6 Nov 2026 00:14:58 +0000 Subject: [PATCH 4/6] fix(policy): inline @computed fields referenced in @@allow/@@deny instead of a bare column ref (#2889) --- .../policy/src/expression-transformer.ts | 11 ++++++ tests/regression/test/issue-2888.test.ts | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/regression/test/issue-2888.test.ts diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 847e7de46..620aa7053 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -1129,6 +1129,17 @@ export class ExpressionTransformer { const tableName = context.alias ?? context.modelOrType; const fieldDef = QueryUtils.requireField(this.schema, context.modelOrType, column); + + if (fieldDef.computed) { + // `@computed` fields don't physically exist as columns — inline the + // computed expression the same way every other query path does via + // `dialect.fieldRef()`, instead of emitting a bare (nonexistent) column ref. + if (fieldDef.params) { + throw new Error(`parameterized computed field "${column}" cannot be used in a policy expression`); + } + return this.dialect.fieldRef(context.modelOrType, column, tableName).toOperationNode(); + } + if (!fieldDef.originModel || fieldDef.originModel === context.modelOrType) { return ReferenceNode.create(ColumnNode.create(column), TableNode.create(tableName)); } diff --git a/tests/regression/test/issue-2888.test.ts b/tests/regression/test/issue-2888.test.ts new file mode 100644 index 000000000..aaa31a088 --- /dev/null +++ b/tests/regression/test/issue-2888.test.ts @@ -0,0 +1,34 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue 2888', () => { + it('a computed field referenced in @@allow does not throw "no such column" and evaluates its expression', async () => { + const db = await createPolicyTestClient( + ` +model Model { + id Int @id @default(autoincrement()) + x Int + canEdit Boolean @computed + + @@allow('create,read', true) + @@allow('update', canEdit) +} + `, + { + provider: 'mysql', + usePrismaPush: true, + computedFields: { + model: { + canEdit: (eb: any) => eb.lit(true), + }, + }, + } as any, + ); + + await db.model.create({ data: { id: 1, x: 1 } }); + + await expect(db.model.update({ where: { id: 1 }, data: { x: 2 } })).resolves.toMatchObject({ + x: 2, + }); + }); +}); From 0153a4e503a039c5cff3c95b6695fb9675a01a48 Mon Sep 17 00:00:00 2001 From: Max Freedom <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:59:15 -0800 Subject: [PATCH 5/6] fix(orm): enforce field validation attributes and `@@validate` inside custom types (#2890) Co-authored-by: Claude Opus 5.5 --- packages/orm/src/client/zod/factory.ts | 19 +++-- tests/regression/test/issue-2844.test.ts | 94 ++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 tests/regression/test/issue-2844.test.ts diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index c001b2e94..a11e0c51c 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -503,16 +503,18 @@ export class ZodSchemaFactory< return this.extraValidationsEnabled ? ZodUtils.addCustomValidation(schema, typeDef.attributes) : schema; } const func = typeDef.strict ? z.strictObject : z.looseObject; - const schema = func( + const objectSchema = func( Object.fromEntries( Object.entries(typeDef.fields).map(([field, def]) => { - // Wrap nested typedef references in z.lazy() so cyclic or self-referencing - // typedefs don't recurse infinitely while building schemas. - let fieldSchema: ZodType = isTypeDef(this.schema, def.type) - ? z.lazy(() => this.makeTypeDefSchema(def.type)) - : this.makeScalarSchema(def.type); + // Wrap nested object typedef references in z.lazy() so cyclic or self-referencing + // typedefs don't recurse infinitely while building schemas. All other fields get + // their validation attributes applied, the same as model fields. + let fieldSchema: ZodType = + isTypeDef(this.schema, def.type) && !isPrimitiveTypeDef(this.schema, def.type) + ? z.lazy(() => this.makeTypeDefSchema(def.type)) + : this.makeScalarSchema(def.type, this.getItemAttributes(def)); if (def.array) { - fieldSchema = fieldSchema.array(); + fieldSchema = ZodUtils.addListValidation(fieldSchema.array(), def.attributes); } if (def.optional) { fieldSchema = fieldSchema.nullish(); @@ -521,6 +523,9 @@ export class ZodSchemaFactory< }), ), ); + const schema = this.extraValidationsEnabled + ? ZodUtils.addCustomValidation(objectSchema, typeDef.attributes) + : objectSchema; // zod doesn't preserve object field order after parsing, here we use a // validation-only custom schema and use the original data if parsing diff --git a/tests/regression/test/issue-2844.test.ts b/tests/regression/test/issue-2844.test.ts new file mode 100644 index 000000000..570531123 --- /dev/null +++ b/tests/regression/test/issue-2844.test.ts @@ -0,0 +1,94 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2844 +describe('Regression for issue 2844', () => { + const schema = ` +type Bounds { + caption String @length(1, 3) + low Int @gte(0) + high Int + + @@validate(low <= high, 'low must not exceed high') +} + +model Example { + id Int @id + bounds Bounds @json +} +`; + + it('enforces custom type validation rules on create', async () => { + const db = await createTestClient(schema); + + await expect( + db.example.create({ data: { id: 1, bounds: { caption: 'too long', low: 0, high: 1 } } }), + ).toBeRejectedByValidation(['data.bounds.caption']); + await expect( + db.example.create({ data: { id: 1, bounds: { caption: 'ok', low: -1, high: 1 } } }), + ).toBeRejectedByValidation(['data.bounds.low']); + await expect( + db.example.create({ data: { id: 1, bounds: { caption: 'ok', low: 5, high: 1 } } }), + ).toBeRejectedByValidation(['low must not exceed high']); + + await expect( + db.example.create({ data: { id: 1, bounds: { caption: 'abc', low: 0, high: 0 } } }), + ).resolves.toMatchObject({ bounds: { caption: 'abc', low: 0, high: 0 } }); + }); + + it('enforces custom type validation rules on update', async () => { + const db = await createTestClient(schema); + await db.example.create({ data: { id: 1, bounds: { caption: 'ok', low: 0, high: 1 } } }); + + await expect( + db.example.update({ where: { id: 1 }, data: { bounds: { caption: 'too long', low: 0, high: 1 } } }), + ).toBeRejectedByValidation(['data.bounds.caption']); + await expect( + db.example.update({ where: { id: 1 }, data: { bounds: { caption: 'ok', low: -1, high: 1 } } }), + ).toBeRejectedByValidation(['data.bounds.low']); + await expect( + db.example.update({ where: { id: 1 }, data: { bounds: { caption: 'ok', low: 5, high: 1 } } }), + ).toBeRejectedByValidation(['low must not exceed high']); + + await expect(db.example.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ + bounds: { caption: 'ok', low: 0, high: 1 }, + }); + }); + + it('enforces validation rules of nested custom types and list fields', async () => { + const db = await createTestClient(` +type Item { + name String @length(1, 5) +} + +type Cart { + owner String @email + items Item[] @length(1, 2) +} + +model Order { + id Int @id + cart Cart @json +} +`); + + await expect( + db.order.create({ data: { id: 1, cart: { owner: 'not-an-email', items: [{ name: 'a' }] } } }), + ).toBeRejectedByValidation(['data.cart.owner']); + await expect( + db.order.create({ data: { id: 1, cart: { owner: 'a@b.com', items: [{ name: 'too long' }] } } }), + ).toBeRejectedByValidation(['data.cart.items[0].name']); + await expect( + db.order.create({ data: { id: 1, cart: { owner: 'a@b.com', items: [] } } }), + ).toBeRejectedByValidation(['data.cart.items']); + await expect( + db.order.create({ + data: { id: 1, cart: { owner: 'a@b.com', items: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] } }, + }), + ).toBeRejectedByValidation(['data.cart.items']); + + await expect( + db.order.create({ data: { id: 1, cart: { owner: 'a@b.com', items: [{ name: 'apple' }] } } }), + ).resolves.toMatchObject({ cart: { owner: 'a@b.com', items: [{ name: 'apple' }] } }); + }); +}); From f02d7e6611f23d8273c41ee2f0fea7654e3acabc Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 5 Nov 2026 19:32:48 -0800 Subject: [PATCH 6/6] fix(policy): handle computed fields in relation, delegate-inherited and before() policy paths (#2910) Co-authored-by: Claude Opus 5.5 --- .../orm/src/client/crud/operations/base.ts | 8 + .../policy/src/expression-transformer.ts | 40 ++- packages/plugins/policy/src/policy-handler.ts | 12 + packages/plugins/policy/src/utils.ts | 15 +- tests/regression/test/issue-2888.test.ts | 259 ++++++++++++++++++ 5 files changed, 312 insertions(+), 22 deletions(-) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index b44ca4265..5bdaf1919 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -171,6 +171,10 @@ export abstract class BaseOperationHandler { const r = await kysely.getExecutor().executeQuery(compiled); result = r.rows as { $exists: number | boolean }[]; } catch (err) { + if (err instanceof ORMError) { + // already a structured error (e.g., raised by a plugin or wrapped by the executor) + throw err; + } throw createDBQueryError(`Failed to execute query: ${err}`, err, compiled.sql, compiled.parameters); } @@ -212,6 +216,10 @@ export abstract class BaseOperationHandler { const r = await kysely.getExecutor().executeQuery(compiled); result = r.rows; } catch (err) { + if (err instanceof ORMError) { + // already a structured error (e.g., raised by a plugin or wrapped by the executor) + throw err; + } throw createDBQueryError(`Failed to execute query: ${err}`, err, compiled.sql, compiled.parameters); } diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 620aa7053..b6cd6dba5 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -58,6 +58,7 @@ import { falseNode, isBeforeInvocation, logicalNot, + rejectParameterizedComputedField, trueNode, } from './utils'; @@ -982,12 +983,7 @@ export class ExpressionTransformer { invariant(i === members.length - 1, 'plain field access must be the last segment'); invariant(!currNode, 'plain field access must be the last segment'); - if (fieldDef.originModel && fieldDef.originModel !== fromModel) { - // field inherited from a delegate base model, look it up from the base table - currNode = this.buildDelegateBaseFieldSelect(fromModel, fromAlias, member, fieldDef.originModel); - } else { - currNode = ReferenceNode.create(ColumnNode.create(member), TableNode.create(fromAlias)); - } + currNode = this.buildFieldRef(fromModel, member, fromAlias); } } @@ -1126,29 +1122,30 @@ export class ExpressionTransformer { // if field comes from a delegate base model, we need to use the join alias // of that base model - const tableName = context.alias ?? context.modelOrType; + return this.buildFieldRef(context.modelOrType, column, context.alias ?? context.modelOrType); + } - const fieldDef = QueryUtils.requireField(this.schema, context.modelOrType, column); + /** + * Builds a reference to a scalar field of `model` whose table is aliased as `modelAlias`. + * Handles regular columns, `@computed` fields (inlined via the dialect), and fields inherited + * from a delegate base model (looked up from the base table with a correlated subquery). + */ + private buildFieldRef(model: string, field: string, modelAlias: string): OperationNode { + const fieldDef = QueryUtils.requireField(this.schema, model, field); - if (fieldDef.computed) { - // `@computed` fields don't physically exist as columns — inline the - // computed expression the same way every other query path does via - // `dialect.fieldRef()`, instead of emitting a bare (nonexistent) column ref. - if (fieldDef.params) { - throw new Error(`parameterized computed field "${column}" cannot be used in a policy expression`); - } - return this.dialect.fieldRef(context.modelOrType, column, tableName).toOperationNode(); - } + rejectParameterizedComputedField(fieldDef, model); - if (!fieldDef.originModel || fieldDef.originModel === context.modelOrType) { - return ReferenceNode.create(ColumnNode.create(column), TableNode.create(tableName)); + if (!fieldDef.originModel || fieldDef.originModel === model) { + return fieldDef.computed + ? this.dialect.fieldRef(model, field, modelAlias).toOperationNode() + : ReferenceNode.create(ColumnNode.create(field), TableNode.create(modelAlias)); } // For inherited fields in delegate sub-types, use a correlated subquery against the // base table. For create operations the VALUES table contains the sub-type's own // fields and its id FK, so the subquery can look up the base record (already inserted // in the same transaction) to resolve inherited field values correctly. - return this.buildDelegateBaseFieldSelect(context.modelOrType, tableName, column, fieldDef.originModel); + return this.buildDelegateBaseFieldSelect(model, modelAlias, field, fieldDef.originModel); } // convert transformer's binding scope to equivalent expression evaluator binding scope @@ -1173,7 +1170,8 @@ export class ExpressionTransformer { kind: 'SelectQueryNode', from: FromNode.create([TableNode.create(baseModel)]), selections: [ - SelectionNode.create(ReferenceNode.create(ColumnNode.create(field), TableNode.create(baseModel))), + // the base model owns the field, so a computed field is evaluated against the base table + SelectionNode.create(this.buildFieldRef(baseModel, field, baseModel) as SelectionNode['selection']), ], where: WhereNode.create( conjunction( diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index ddc58ee7a..6b96c07e6 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -59,6 +59,7 @@ import { isBeforeInvocation, isTrueNode, logicalNot, + rejectParameterizedComputedField, trueNode, } from './utils'; @@ -736,6 +737,17 @@ export class PolicyHandler extends OperationNodeTransf if (fieldDef?.originModel) { baseModelsToJoin.add(fieldDef.originModel); } + if (fieldDef?.computed) { + // parameterized computed fields can't be evaluated without query-time args + rejectParameterizedComputedField(fieldDef, model); + // computed fields aren't physical columns, inline the computed expression + return SelectionNode.create( + AliasNode.create( + this.dialect.fieldRef(owningTable, f, owningTable).toOperationNode(), + IdentifierNode.create(f), + ), + ); + } return SelectionNode.create(ReferenceNode.create(ColumnNode.create(f), TableNode.create(owningTable))); }); diff --git a/packages/plugins/policy/src/utils.ts b/packages/plugins/policy/src/utils.ts index 5deef04bd..632864d5f 100644 --- a/packages/plugins/policy/src/utils.ts +++ b/packages/plugins/policy/src/utils.ts @@ -1,5 +1,5 @@ import { ORMError, ORMErrorReason, RejectedByPolicyReason, type BaseCrudDialect } from '@zenstackhq/orm'; -import { ExpressionUtils, type Expression, type SchemaDef } from '@zenstackhq/orm/schema'; +import { ExpressionUtils, type Expression, type FieldDef, type SchemaDef } from '@zenstackhq/orm/schema'; import type { OperationNode } from 'kysely'; import { AliasNode, @@ -191,3 +191,16 @@ export function createRejectedByPolicyError( export function createUnsupportedError(message: string) { return new ORMError(ORMErrorReason.NOT_SUPPORTED, message); } + +/** + * Throws a config error if the given field is a parameterized computed field. Such fields need + * query-time args, which policy expressions have no way to provide. + */ +export function rejectParameterizedComputedField(fieldDef: FieldDef, model: string) { + if (fieldDef.computed && fieldDef.params) { + throw new ORMError( + ORMErrorReason.CONFIG_ERROR, + `Parameterized computed field "${fieldDef.name}" of model "${model}" cannot be used in a policy expression`, + ); + } +} diff --git a/tests/regression/test/issue-2888.test.ts b/tests/regression/test/issue-2888.test.ts index aaa31a088..b2bdd04bc 100644 --- a/tests/regression/test/issue-2888.test.ts +++ b/tests/regression/test/issue-2888.test.ts @@ -1,3 +1,4 @@ +import { ORMErrorReason } from '@zenstackhq/orm'; import { createPolicyTestClient } from '@zenstackhq/testtools'; import { describe, expect, it } from 'vitest'; @@ -31,4 +32,262 @@ model Model { x: 2, }); }); + + it('binds computed expression to the right row and denies when false', async () => { + const db = await createPolicyTestClient( + ` +model Model { + id Int @id @default(autoincrement()) + x Int + canEdit Boolean @computed + + @@allow('create,read', true) + @@allow('update', canEdit) +} + `, + { + computedFields: { + Model: { + canEdit: (eb: any, ctx: any) => eb(eb.ref(`${ctx.modelAlias}.x`), '>', 0), + }, + }, + } as any, + ); + + await db.model.create({ data: { id: 1, x: 1 } }); + await db.model.create({ data: { id: 2, x: 0 } }); + + await expect(db.model.update({ where: { id: 1 }, data: { x: 2 } })).resolves.toMatchObject({ x: 2 }); + await expect(db.model.update({ where: { id: 2 }, data: { x: 5 } })).toBeRejectedNotFound(); + }); + + it('computed field reached through a relation member access', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + status String + isActive Boolean @computed + posts Post[] + @@allow('all', true) +} + +model Post { + id Int @id @default(autoincrement()) + owner User @relation(fields: [ownerId], references: [id]) + ownerId Int + @@allow('create', true) + @@allow('read', owner.isActive) +} + `, + { + computedFields: { + User: { + isActive: (eb: any, ctx: any) => eb(eb.ref(`${ctx.modelAlias}.status`), '=', 'active'), + }, + }, + } as any, + ); + + await db.$unuseAll().user.create({ data: { id: 1, status: 'active', posts: { create: { id: 1 } } } }); + await db.$unuseAll().user.create({ data: { id: 2, status: 'inactive', posts: { create: { id: 2 } } } }); + + await expect(db.post.findMany()).resolves.toEqual([expect.objectContaining({ id: 1 })]); + }); + + it('computed field inherited from a delegate base model', async () => { + const db = await createPolicyTestClient( + ` +model Asset { + id Int @id @default(autoincrement()) + visibility String + isPublic Boolean @computed + type String + @@delegate(type) + @@allow('create', true) +} + +model Video extends Asset { + duration Int + @@allow('create', true) + @@allow('read', isPublic) +} + `, + { + computedFields: { + Asset: { + isPublic: (eb: any, ctx: any) => eb(eb.ref(`${ctx.modelAlias}.visibility`), '=', 'public'), + }, + }, + } as any, + ); + + await db.$unuseAll().video.create({ data: { id: 1, visibility: 'public', duration: 1 } }); + await db.$unuseAll().video.create({ data: { id: 2, visibility: 'private', duration: 1 } }); + + await expect(db.video.findMany()).resolves.toEqual([expect.objectContaining({ id: 1 })]); + }); + + it('before().computedField in post-update policy', async () => { + const db = await createPolicyTestClient( + ` +model Model { + id Int @id @default(autoincrement()) + x Int + canEdit Boolean @computed + + @@allow('all', true) + @@deny('post-update', !before().canEdit) +} + `, + { + computedFields: { + Model: { + canEdit: (eb: any, ctx: any) => eb(eb.ref(`${ctx.modelAlias}.x`), '>', 0), + }, + }, + } as any, + ); + + await db.model.create({ data: { id: 1, x: 1 } }); + await db.model.create({ data: { id: 2, x: 0 } }); + + await expect(db.model.update({ where: { id: 1 }, data: { x: 2 } })).resolves.toMatchObject({ x: 2 }); + await expect(db.model.update({ where: { id: 2 }, data: { x: 5 } })).toBeRejectedByPolicy(); + }); + + it('computed field in create policy (value-object context)', async () => { + const db = await createPolicyTestClient( + ` +model Model { + id Int @id @default(autoincrement()) + x Int + canCreate Boolean @computed + + @@allow('read', true) + @@allow('create', canCreate) +} + `, + { + computedFields: { + Model: { + canCreate: (eb: any, ctx: any) => eb(eb.ref(`${ctx.modelAlias}.x`), '>', 0), + }, + }, + } as any, + ); + + await expect(db.model.create({ data: { id: 1, x: 1 } })).resolves.toMatchObject({ x: 1 }); + await expect(db.model.create({ data: { id: 2, x: 0 } })).toBeRejectedByPolicy(); + }); + + it('before() on a computed field inherited from a delegate base, and relation access to it', async () => { + const db = await createPolicyTestClient( + ` +model Owner { + id Int @id @default(autoincrement()) + videos Video[] + @@allow('all', true) +} + +model Asset { + id Int @id @default(autoincrement()) + visibility String + isPublic Boolean @computed + type String + @@delegate(type) + @@allow('all', true) +} + +model Video extends Asset { + duration Int + owner Owner @relation(fields: [ownerId], references: [id]) + ownerId Int + comments Comment[] + @@allow('all', true) + @@deny('post-update', !before().isPublic) +} + +model Comment { + id Int @id @default(autoincrement()) + videoId Int + video Video @relation(fields: [videoId], references: [id]) + @@allow('create', true) + @@allow('read', video.isPublic) +} + `, + { + computedFields: { + Asset: { + isPublic: (eb: any, ctx: any) => eb(eb.ref(`${ctx.modelAlias}.visibility`), '=', 'public'), + }, + }, + } as any, + ); + + await db.owner.create({ data: { id: 1 } }); + await db.video.create({ data: { id: 1, visibility: 'public', duration: 1, ownerId: 1 } }); + await db.video.create({ data: { id: 2, visibility: 'private', duration: 1, ownerId: 1 } }); + await db.$unuseAll().comment.create({ data: { id: 1, videoId: 1 } }); + await db.$unuseAll().comment.create({ data: { id: 2, videoId: 2 } }); + + await expect(db.comment.findMany()).resolves.toEqual([expect.objectContaining({ id: 1 })]); + await expect(db.video.update({ where: { id: 1 }, data: { duration: 2 } })).resolves.toMatchObject({ + duration: 2, + }); + await expect(db.video.update({ where: { id: 2 }, data: { duration: 2 } })).toBeRejectedByPolicy(); + }); + + it('parameterized computed field in policy is rejected with a clear error', async () => { + const db = await createPolicyTestClient( + ` +model Model { + id Int @id @default(autoincrement()) + x Int + score(min: Int) Int @computed + @@allow('create', true) + @@allow('read', score > 0) +} + `, + { + computedFields: { + Model: { + score: (eb: any, ctx: any, args: any) => eb(eb.ref(`${ctx.modelAlias}.x`), '-', args?.min ?? 0), + }, + }, + } as any, + ); + await db.$unuseAll().model.create({ data: { id: 1, x: 1 } }); + await expect(db.model.findMany()).rejects.toMatchObject({ + reason: ORMErrorReason.CONFIG_ERROR, + message: expect.stringContaining('Parameterized computed field "score"'), + }); + }); + + it('parameterized computed field referenced via before() is rejected with a clear error', async () => { + const db = await createPolicyTestClient( + ` +model Model { + id Int @id @default(autoincrement()) + x Int + score(min: Int) Int @computed + @@allow('create,read,update', true) + @@allow('post-update', before().score > 0) +} + `, + { + computedFields: { + Model: { + // the default would let the update through if the impl were called without args + score: (eb: any, ctx: any, args: any) => eb(eb.ref(`${ctx.modelAlias}.x`), '-', args?.min ?? 0), + }, + }, + } as any, + ); + await db.model.create({ data: { id: 1, x: 1 } }); + await expect(db.model.update({ where: { id: 1 }, data: { x: 2 } })).rejects.toMatchObject({ + reason: ORMErrorReason.CONFIG_ERROR, + message: expect.stringContaining('Parameterized computed field "score"'), + }); + }); });