From 8a317258b5e47ceabcf4a424b4453d77628f35e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:09:45 -0700 Subject: [PATCH 1/2] [CI] Bump version 3.8.1 (#2732) 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 a4e71f89d..fd7f99f12 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.8.0", + "version": "3.8.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 b227490d7..8e4ea670c 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index 886ece525..860ec1b7c 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index fc4ef18e4..d912a2c9f 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index af544473c..f54ba71f7 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 3124dfe2e..5dbf32802 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 8f8af7346..0e760947c 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index b060de766..f0c924a91 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.8.0", + "version": "3.8.1", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index a03f35558..f6ac021ef 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.8.0", + "version": "3.8.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 4b776e08d..e05c4d88d 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.8.0", + "version": "3.8.1", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index e9a885bff..7a3a073ef 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.8.0", + "version": "3.8.1", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 3bd4b5925..d2de5fab8 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 5355b42de..b0a6a318a 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.8.0", + "version": "3.8.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 f0d3667f4..159beb944 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index 5b7bf333f..d9b6b1d36 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 535c47f99..3bdcfadbd 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index 3cc7a16c6..98f7be796 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index 6ddd024d6..335f07a81 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 4bef954f7..d0982b233 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index b48020437..2eee5275a 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index d445bba40..ef0fb3c1f 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index 417ab942f..d0d9187e4 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.8.0", + "version": "3.8.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index f91960e54..f9f7cf8eb 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.8.0", + "version": "3.8.1", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index 776eca99b..f2c6d4dfd 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.8.0", + "version": "3.8.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 9ba4aa54b..f6887aad9 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.8.0", + "version": "3.8.1", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 3019c41ab..affb61e36 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.8.0", + "version": "3.8.1", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index b35084204..252297158 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.8.0", + "version": "3.8.1", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 038e931c6..16c7eafaa 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.8.0", + "version": "3.8.1", "private": true, "type": "module", "scripts": { From 805a6b81d42cb7200376d224a19ce49d732865a9 Mon Sep 17 00:00:00 2001 From: Abid Quds Date: Fri, 26 Jun 2026 10:29:13 -0400 Subject: [PATCH 2/2] perf(orm): memoize implicit m2m join-table and model lookups (#2715) (#2730) --- packages/orm/src/client/query-utils.ts | 84 ++++++++++++++++++- packages/plugins/policy/src/policy-handler.ts | 65 +++++++------- tests/regression/test/issue-2715.test.ts | 74 ++++++++++++++++ 3 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 tests/regression/test/issue-2715.test.ts diff --git a/packages/orm/src/client/query-utils.ts b/packages/orm/src/client/query-utils.ts index e67e8db3d..5a4b146b4 100644 --- a/packages/orm/src/client/query-utils.ts +++ b/packages/orm/src/client/query-utils.ts @@ -20,8 +20,38 @@ export function hasModel(schema: SchemaDef, model: string) { .includes(model.toLowerCase()); } +/** + * Structural lookups derived purely from the (immutable) schema. The schema does not + * change for a client's lifetime, so these results are memoized per-schema and shared + * across every consumer (orm query building + plugins). Keyed by the schema object so + * the cache dies with the schema and never leaks across schemas. See issue #2715. + */ +interface SchemaLookupCache { + model: Map; + m2mRelation: Map>; + m2mJoinTable?: Map; +} + +const schemaLookupCache = new WeakMap(); + +function getSchemaLookupCache(schema: SchemaDef): SchemaLookupCache { + let cache = schemaLookupCache.get(schema); + if (!cache) { + cache = { model: new Map(), m2mRelation: new Map() }; + schemaLookupCache.set(schema, cache); + } + return cache; +} + export function getModel(schema: SchemaDef, model: string) { - return Object.values(schema.models).find((m) => m.name.toLowerCase() === model.toLowerCase()); + const cache = getSchemaLookupCache(schema); + const key = model.toLowerCase(); + if (cache.model.has(key)) { + return cache.model.get(key); + } + const result = Object.values(schema.models).find((m) => m.name.toLowerCase() === key); + cache.model.set(key, result); + return result; } export function getTypeDef(schema: SchemaDef, type: string) { @@ -260,6 +290,58 @@ export function makeDefaultOrderBy(schema: SchemaDef, model: string) { } export function getManyToManyRelation(schema: SchemaDef, model: string, field: string) { + const cache = getSchemaLookupCache(schema); + const key = `${model} ${field}`; + if (cache.m2mRelation.has(key)) { + return cache.m2mRelation.get(key); + } + const result = computeManyToManyRelation(schema, model, field); + cache.m2mRelation.set(key, result); + return result; +} + +/** + * Endpoints of an implicit many-to-many relation, identified by its join table name. + */ +export interface ManyToManyJoinTableEndpoints { + model: string; + field: string; + otherModel: string; + otherField: string; +} + +/** + * Resolve the relation endpoints for an implicit many-to-many join table by its table + * name, or `undefined` if the table is not an implicit m2m join table. The join-table + * index is built once per schema (single pass) and reused, making this an O(1) lookup. + * See issue #2715. + */ +export function getManyToManyJoinTable( + schema: SchemaDef, + joinTableName: string, +): ManyToManyJoinTableEndpoints | undefined { + const cache = getSchemaLookupCache(schema); + if (!cache.m2mJoinTable) { + const map = new Map(); + for (const model of Object.values(schema.models)) { + for (const field of Object.values(model.fields)) { + const m2m = getManyToManyRelation(schema, model.name, field.name); + if (m2m?.joinTable && !map.has(m2m.joinTable)) { + map.set(m2m.joinTable, { + model: model.name, + field: field.name, + otherModel: m2m.otherModel, + otherField: m2m.otherField, + }); + } + } + } + cache.m2mJoinTable = map; + } + return cache.m2mJoinTable.get(joinTableName); +} + +function computeManyToManyRelation(schema: SchemaDef, model: string, field: string) { const fieldDef = requireField(schema, model, field); if (!fieldDef.array || !fieldDef.relation?.opposite) { return undefined; diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index 93c6c334a..40c382668 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -1290,40 +1290,39 @@ export class PolicyHandler extends OperationNodeTransf } private resolveManyToManyJoinTable(tableName: string) { - for (const model of Object.values(this.client.$schema.models)) { - for (const field of Object.values(model.fields)) { - const m2m = QueryUtils.getManyToManyRelation(this.client.$schema, model.name, field.name); - if (m2m?.joinTable === tableName) { - const sortedRecord = [ - { - model: model.name, - field: field.name, - }, - { - model: m2m.otherModel, - field: m2m.otherField, - }, - ].sort(this.manyToManySorter); - - const firstIdFields = QueryUtils.requireIdFields(this.client.$schema, sortedRecord[0]!.model); - const secondIdFields = QueryUtils.requireIdFields(this.client.$schema, sortedRecord[1]!.model); - invariant( - firstIdFields.length === 1 && secondIdFields.length === 1, - 'only single-field id is supported for implicit many-to-many join table', - ); - - return { - firstModel: sortedRecord[0]!.model, - firstField: sortedRecord[0]!.field, - firstIdField: firstIdFields[0]!, - secondModel: sortedRecord[1]!.model, - secondField: sortedRecord[1]!.field, - secondIdField: secondIdFields[0]!, - }; - } - } + // O(1) lookup backed by a per-schema index (built once); previously this scanned + // the entire schema on every call for every table. See issue #2715. + const endpoints = QueryUtils.getManyToManyJoinTable(this.client.$schema, tableName); + if (!endpoints) { + return undefined; } - return undefined; + + const sortedRecord = [ + { + model: endpoints.model, + field: endpoints.field, + }, + { + model: endpoints.otherModel, + field: endpoints.otherField, + }, + ].sort(this.manyToManySorter); + + const firstIdFields = QueryUtils.requireIdFields(this.client.$schema, sortedRecord[0]!.model); + const secondIdFields = QueryUtils.requireIdFields(this.client.$schema, sortedRecord[1]!.model); + invariant( + firstIdFields.length === 1 && secondIdFields.length === 1, + 'only single-field id is supported for implicit many-to-many join table', + ); + + return { + firstModel: sortedRecord[0]!.model, + firstField: sortedRecord[0]!.field, + firstIdField: firstIdFields[0]!, + secondModel: sortedRecord[1]!.model, + secondField: sortedRecord[1]!.field, + secondIdField: secondIdFields[0]!, + }; } private manyToManySorter(a: { model: string; field: string }, b: { model: string; field: string }): number { diff --git a/tests/regression/test/issue-2715.test.ts b/tests/regression/test/issue-2715.test.ts new file mode 100644 index 000000000..1b6a9c507 --- /dev/null +++ b/tests/regression/test/issue-2715.test.ts @@ -0,0 +1,74 @@ +import { QueryUtils } from '@zenstackhq/orm'; +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2715 +// Resolving an implicit many-to-many join table is a pure function of (schema, tableName), +// but it used to re-scan the entire schema on every call, for every table in every query. +// The resolution is now memoized per (immutable) schema. These tests guard that the +// memoization holds (so the per-query full-schema scan cannot silently regress) and that +// the resolved endpoints are correct across the shapes implicit m2m relations can take: +// plain two-model, self-relation, explicit @relation name, and multiple relations at once. +describe('Regression for issue #2715', () => { + it('resolves implicit m2m join tables correctly and caches each per schema', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + groups Group[] + @@allow('all', true) +} + +model Group { + id Int @id @default(autoincrement()) + users User[] + @@allow('all', true) +} + +// self-relation m2m with an explicit join-table name +model Person { + id Int @id @default(autoincrement()) + following Person[] @relation('follows') + followers Person[] @relation('follows') + @@allow('all', true) +} + `, + ); + + const schema = db.$schema; + + // plain two-model m2m: join table follows Prisma's `_To` (sorted) convention, + // endpoints come from the first declared side (User.groups), other side is Group.users + const groupToUser = QueryUtils.getManyToManyJoinTable(schema, '_GroupToUser'); + expect(groupToUser).toEqual({ + model: 'User', + field: 'groups', + otherModel: 'Group', + otherField: 'users', + }); + + // self-relation m2m with explicit name: join table is `_`, both endpoints are + // the same model, fields are the two relation fields (first declared side first) + const follows = QueryUtils.getManyToManyJoinTable(schema, '_follows'); + expect(follows).toEqual({ + model: 'Person', + field: 'following', + otherModel: 'Person', + otherField: 'followers', + }); + + // memoized: repeated resolution returns the SAME object reference (the index is built + // once and reused). An un-memoized re-scan would construct a fresh descriptor each call, + // so these `toBe`s are what fail if the per-query scan regresses. + expect(QueryUtils.getManyToManyJoinTable(schema, '_GroupToUser')).toBe(groupToUser); + expect(QueryUtils.getManyToManyJoinTable(schema, '_follows')).toBe(follows); + + // distinct relations are cached independently (no cross-contamination) + expect(groupToUser).not.toBe(follows); + + // non-m2m table names and unknown tables resolve to undefined (negative results are + // part of the cached index, not an uncached miss) + expect(QueryUtils.getManyToManyJoinTable(schema, 'User')).toBeUndefined(); + expect(QueryUtils.getManyToManyJoinTable(schema, '_DoesNotExist')).toBeUndefined(); + }); +});