From ee0fde0cb42ce8f5b43e2fbf4eed74d92b35d340 Mon Sep 17 00:00:00 2001 From: Erik Dakoda Date: Wed, 14 Oct 2026 14:05:07 -0400 Subject: [PATCH 1/3] fix(clients): keep the ORM runtime out of client bundles (#2871) Co-authored-by: Claude Opus 5.5 --- packages/clients/client-helpers/src/types.ts | 2 +- .../tanstack-query/src/common/transaction.ts | 2 +- packages/orm/package.json | 10 ++ packages/orm/src/client/constants.ts | 125 ++++++++++++++++ .../orm/src/client/crud/operations/base.ts | 137 ++---------------- packages/orm/src/constants.ts | 12 ++ packages/orm/test/constants.test.ts | 22 +++ packages/orm/tsdown.config.ts | 1 + 8 files changed, 184 insertions(+), 127 deletions(-) create mode 100644 packages/orm/src/constants.ts create mode 100644 packages/orm/test/constants.test.ts diff --git a/packages/clients/client-helpers/src/types.ts b/packages/clients/client-helpers/src/types.ts index a4f4f68cc..247b8344a 100644 --- a/packages/clients/client-helpers/src/types.ts +++ b/packages/clients/client-helpers/src/types.ts @@ -1,4 +1,4 @@ -import { ExtQueryArgsMarker, ExtResultMarker, type QueryOptions } from '@zenstackhq/orm'; +import type { ExtQueryArgsMarker, ExtResultMarker, QueryOptions } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/schema'; /** diff --git a/packages/clients/tanstack-query/src/common/transaction.ts b/packages/clients/tanstack-query/src/common/transaction.ts index 396c5b3d4..31d09fb40 100644 --- a/packages/clients/tanstack-query/src/common/transaction.ts +++ b/packages/clients/tanstack-query/src/common/transaction.ts @@ -2,7 +2,7 @@ import type { Logger } from '@zenstackhq/client-helpers'; import { createInvalidator, TRANSACTION_ROUTE_PREFIX, type InvalidateFunc } from '@zenstackhq/client-helpers'; import { fetcher, marshal, type FetchFn } from '@zenstackhq/client-helpers/fetch'; import type { TransactionOperation } from '@zenstackhq/client-helpers'; -import { CoreReadOperations } from '@zenstackhq/orm'; +import { CoreReadOperations } from '@zenstackhq/orm/constants'; import type { SchemaDef } from '@zenstackhq/schema'; /** diff --git a/packages/orm/package.json b/packages/orm/package.json index 6f4021380..71e437f5c 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -106,6 +106,16 @@ "default": "./dist/common-types.cjs" } }, + "./constants": { + "import": { + "types": "./dist/constants.d.mts", + "default": "./dist/constants.mjs" + }, + "require": { + "types": "./dist/constants.d.cts", + "default": "./dist/constants.cjs" + } + }, "./package.json": { "import": "./package.json", "require": "./package.json" diff --git a/packages/orm/src/client/constants.ts b/packages/orm/src/client/constants.ts index eeb7e65b6..582e7df72 100644 --- a/packages/orm/src/client/constants.ts +++ b/packages/orm/src/client/constants.ts @@ -85,3 +85,128 @@ export const FILTER_PROPERTY_TO_KIND = { * Mapping of filter operators to their corresponding filter kind categories. */ export type FilterPropertyToKind = typeof FILTER_PROPERTY_TO_KIND; + +/** + * List of core CRUD operations. It excludes the 'orThrow' variants. + */ +export const CoreCrudOperations = [ + 'findMany', + 'findUnique', + 'findFirst', + 'create', + 'createMany', + 'createManyAndReturn', + 'update', + 'updateMany', + 'updateManyAndReturn', + 'upsert', + 'delete', + 'deleteMany', + 'count', + 'aggregate', + 'groupBy', + 'exists', +] as const; + +/** + * List of core CRUD operations. It excludes the 'orThrow' variants. + */ +export type CoreCrudOperations = (typeof CoreCrudOperations)[number]; + +/** + * List of core read operations. It excludes the 'orThrow' variants. + */ +export const CoreReadOperations = [ + 'findMany', + 'findUnique', + 'findFirst', + 'count', + 'aggregate', + 'groupBy', + 'exists', +] as const; + +/** + * List of core read operations. It excludes the 'orThrow' variants. + */ +export type CoreReadOperations = (typeof CoreReadOperations)[number]; + +/** + * List of core write operations. + */ +export const CoreWriteOperations = [ + 'create', + 'createMany', + 'createManyAndReturn', + 'update', + 'updateMany', + 'updateManyAndReturn', + 'upsert', + 'delete', + 'deleteMany', +] as const; + +/** + * List of core write operations. + */ +export type CoreWriteOperations = (typeof CoreWriteOperations)[number]; + +/** + * List of core create operations. + */ +export const CoreCreateOperations = ['create', 'createMany', 'createManyAndReturn', 'upsert'] as const; + +/** + * List of core create operations. + */ +export type CoreCreateOperations = (typeof CoreCreateOperations)[number]; + +/** + * List of core update operations. + */ +export const CoreUpdateOperations = ['update', 'updateMany', 'updateManyAndReturn', 'upsert'] as const; + +/** + * List of core update operations. + */ +export type CoreUpdateOperations = (typeof CoreUpdateOperations)[number]; + +/** + * List of core delete operations. + */ +export const CoreDeleteOperations = ['delete', 'deleteMany'] as const; + +/** + * List of core delete operations. + */ +export type CoreDeleteOperations = (typeof CoreDeleteOperations)[number]; + +/** + * List of all CRUD operations, including 'orThrow' variants. + */ +export const AllCrudOperations = [...CoreCrudOperations, 'findUniqueOrThrow', 'findFirstOrThrow'] as const; + +/** + * List of all CRUD operations, including 'orThrow' variants. + */ +export type AllCrudOperations = (typeof AllCrudOperations)[number]; + +/** + * List of all read operations, including 'orThrow' variants. + */ +export const AllReadOperations = [...CoreReadOperations, 'findUniqueOrThrow', 'findFirstOrThrow'] as const; + +/** + * List of all read operations, including 'orThrow' variants. + */ +export type AllReadOperations = (typeof AllReadOperations)[number]; + +/** + * List of all write operations - simply an alias of CoreWriteOperations. + */ +export const AllWriteOperations = CoreWriteOperations; + +/** + * List of all write operations - simply an alias of CoreWriteOperations. + */ +export type AllWriteOperations = CoreWriteOperations; diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a9b9e572e..ce8b651e7 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -19,7 +19,7 @@ import { ulid } from 'ulid'; import * as uuid from 'uuid'; import type { AnyKysely } from '../../../utils/kysely-utils'; import { extractFields, fieldsToSelectObject, isEmptyObject } from '../../../utils/object-utils'; -import { NUMERIC_FIELD_TYPES } from '../../constants'; +import { NUMERIC_FIELD_TYPES, type CoreCrudOperations } from '../../constants'; import { TransactionIsolationLevel, type ClientContract, type CRUD } from '../../contract'; import type { FindArgs, SelectIncludeOmit, WhereInput } from '../../crud-types'; import { @@ -54,130 +54,17 @@ import { getCrudDialect } from '../dialects'; import type { BaseCrudDialect } from '../dialects/base-dialect'; import { InputValidator } from '../validator'; -/** - * List of core CRUD operations. It excludes the 'orThrow' variants. - */ -export const CoreCrudOperations = [ - 'findMany', - 'findUnique', - 'findFirst', - 'create', - 'createMany', - 'createManyAndReturn', - 'update', - 'updateMany', - 'updateManyAndReturn', - 'upsert', - 'delete', - 'deleteMany', - 'count', - 'aggregate', - 'groupBy', - 'exists', -] as const; - -/** - * List of core CRUD operations. It excludes the 'orThrow' variants. - */ -export type CoreCrudOperations = (typeof CoreCrudOperations)[number]; - -/** - * List of core read operations. It excludes the 'orThrow' variants. - */ -export const CoreReadOperations = [ - 'findMany', - 'findUnique', - 'findFirst', - 'count', - 'aggregate', - 'groupBy', - 'exists', -] as const; - -/** - * List of core read operations. It excludes the 'orThrow' variants. - */ -export type CoreReadOperations = (typeof CoreReadOperations)[number]; - -/** - * List of core write operations. - */ -export const CoreWriteOperations = [ - 'create', - 'createMany', - 'createManyAndReturn', - 'update', - 'updateMany', - 'updateManyAndReturn', - 'upsert', - 'delete', - 'deleteMany', -] as const; - -/** - * List of core write operations. - */ -export type CoreWriteOperations = (typeof CoreWriteOperations)[number]; - -/** - * List of core create operations. - */ -export const CoreCreateOperations = ['create', 'createMany', 'createManyAndReturn', 'upsert'] as const; - -/** - * List of core create operations. - */ -export type CoreCreateOperations = (typeof CoreCreateOperations)[number]; - -/** - * List of core update operations. - */ -export const CoreUpdateOperations = ['update', 'updateMany', 'updateManyAndReturn', 'upsert'] as const; - -/** - * List of core update operations. - */ -export type CoreUpdateOperations = (typeof CoreUpdateOperations)[number]; - -/** - * List of core delete operations. - */ -export const CoreDeleteOperations = ['delete', 'deleteMany'] as const; - -/** - * List of core delete operations. - */ -export type CoreDeleteOperations = (typeof CoreDeleteOperations)[number]; - -/** - * List of all CRUD operations, including 'orThrow' variants. - */ -export const AllCrudOperations = [...CoreCrudOperations, 'findUniqueOrThrow', 'findFirstOrThrow'] as const; - -/** - * List of all CRUD operations, including 'orThrow' variants. - */ -export type AllCrudOperations = (typeof AllCrudOperations)[number]; - -/** - * List of all read operations, including 'orThrow' variants. - */ -export const AllReadOperations = [...CoreReadOperations, 'findUniqueOrThrow', 'findFirstOrThrow'] as const; - -/** - * List of all read operations, including 'orThrow' variants. - */ -export type AllReadOperations = (typeof AllReadOperations)[number]; - -/** - * List of all write operations - simply an alias of CoreWriteOperations. - */ -export const AllWriteOperations = CoreWriteOperations; - -/** - * List of all write operations - simply an alias of CoreWriteOperations. - */ -export type AllWriteOperations = CoreWriteOperations; +export { + AllCrudOperations, + AllReadOperations, + AllWriteOperations, + CoreCreateOperations, + CoreCrudOperations, + CoreDeleteOperations, + CoreReadOperations, + CoreUpdateOperations, + CoreWriteOperations, +} from '../../constants'; // context for nested relation operations export type FromRelationContext = { diff --git a/packages/orm/src/constants.ts b/packages/orm/src/constants.ts new file mode 100644 index 000000000..8164f7a0a --- /dev/null +++ b/packages/orm/src/constants.ts @@ -0,0 +1,12 @@ +// Dependency-free constants, importable without pulling in the ORM runtime (e.g. from browser clients). +export { + AllCrudOperations, + AllReadOperations, + AllWriteOperations, + CoreCreateOperations, + CoreCrudOperations, + CoreDeleteOperations, + CoreReadOperations, + CoreUpdateOperations, + CoreWriteOperations, +} from './client/constants'; diff --git a/packages/orm/test/constants.test.ts b/packages/orm/test/constants.test.ts new file mode 100644 index 000000000..0270c23d4 --- /dev/null +++ b/packages/orm/test/constants.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import * as constants from '../src/constants'; +import * as orm from '../src/index'; + +describe('constants entry', () => { + it('exports the operation lists of the main entry', () => { + expect(Object.keys(constants).sort()).toEqual([ + 'AllCrudOperations', + 'AllReadOperations', + 'AllWriteOperations', + 'CoreCreateOperations', + 'CoreCrudOperations', + 'CoreDeleteOperations', + 'CoreReadOperations', + 'CoreUpdateOperations', + 'CoreWriteOperations', + ]); + for (const [name, value] of Object.entries(constants)) { + expect(orm[name as keyof typeof orm]).toBe(value); + } + }); +}); diff --git a/packages/orm/tsdown.config.ts b/packages/orm/tsdown.config.ts index 39118a802..af397a1cf 100644 --- a/packages/orm/tsdown.config.ts +++ b/packages/orm/tsdown.config.ts @@ -6,6 +6,7 @@ export default createConfig({ schema: 'src/schema.ts', helpers: 'src/helpers.ts', 'common-types': 'src/common-types.ts', + constants: 'src/constants.ts', 'dialects/sqlite': 'src/dialects/sqlite.ts', 'dialects/postgres': 'src/dialects/postgres.ts', 'dialects/mysql': 'src/dialects/mysql.ts', From 331005811f1bdd3a6cfa5905e2155620cc4e6afe Mon Sep 17 00:00:00 2001 From: Erik Dakoda Date: Sun, 18 Oct 2026 21:59:49 -0400 Subject: [PATCH 2/3] fix(orm): stop delegate cascade delete recursion and simulate sub model cascades (#2872) Co-authored-by: Claude Opus 5.5 --- .../orm/src/client/crud/operations/base.ts | 234 +++++++++++++--- .../orm/src/client/crud/operations/delete.ts | 16 +- .../delegate-cascade-delete.test.ts | 262 ++++++++++++++++++ 3 files changed, 468 insertions(+), 44 deletions(-) create mode 100644 tests/e2e/orm/client-api/delegate-cascade-delete.test.ts diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index ce8b651e7..73ffd4a8d 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -36,6 +36,7 @@ import { ensureArray, extractIdFields, flattenCompoundUniqueFilters, + getDelegateDescendantModels, getDelegateDiscriminatorValue, getDiscriminatorField, getField, @@ -66,6 +67,16 @@ export { CoreWriteOperations, } from '../../constants'; +/** + * Maximum number of rows identified by id in a single statement during cascade delete simulation. + */ +const DELETE_ID_BATCH_SIZE = 1000; + +/** + * Same as `DELETE_ID_BATCH_SIZE`, for compound ids, which are matched with one `OR` branch per row. + */ +const DELETE_COMPOUND_ID_BATCH_SIZE = 100; + // context for nested relation operations export type FromRelationContext = { // the model where the relation field is defined @@ -2263,8 +2274,10 @@ export abstract class BaseOperationHandler { limit?: number, filterModel?: string, fieldsToReturn?: readonly string[], + visited?: Set, ): Promise> { filterModel ??= model; + visited ??= new Set(); const modelDef = this.requireModel(model); @@ -2273,9 +2286,109 @@ export abstract class BaseOperationHandler { throw createNotSupportedError('Deleting with a limit is not supported for polymorphic models'); } // just delete base and it'll cascade back to this model - return this.processBaseModelDelete(kysely, modelDef.baseModel, where, limit, filterModel); + return this.processBaseModelDelete(kysely, modelDef.baseModel, where, limit, filterModel, visited); + } + + // if the model being deleted has a relation to a model that extends a delegate model, and if that + // relation is set to trigger a cascade delete from this model, the deletion will not automatically + // clean up the base hierarchy of the relation side (because polymorphic model's cascade deletion + // works downward not upward). We need to take care of the base deletions manually here. + const cascadeRelations = this.getDelegateCascadeRelations(model, filterModel); + if (cascadeRelations.length === 0) { + return this.executeDelete(kysely, model, where, limit, filterModel, fieldsToReturn); + } + + if (!modelDef.isDelegate) { + if (limit !== undefined) { + throw createNotSupportedError('Deleting with a limit is not supported for polymorphic models'); + } + for (const { fieldDef } of cascadeRelations) { + await this.delete( + kysely, + fieldDef.type, + { [fieldDef.relation!.opposite!]: where }, + undefined, + undefined, + undefined, + visited, + ); + } + return this.executeDelete(kysely, model, where, limit, filterModel, fieldsToReturn); + } + + // for a delegate model, resolve the rows being deleted first: `where` can filter on a sub model or on + // related rows, which the cascades below may delete + const discriminator = getDiscriminatorField(this.schema, model)!; + const deletedRows = await this.read(kysely, filterModel, { + where, + select: { ...this.makeIdSelect(filterModel), [discriminator]: true }, + ...(limit !== undefined ? { take: limit } : {}), + }); + + // rows already being deleted in this chain are skipped, otherwise cyclic data recurses forever + deletedRows.forEach((row) => visited.add(this.makeVisitedKey(model, row))); + + for (const { fieldDef, discriminatorValue } of cascadeRelations) { + const parentRows = + discriminatorValue === undefined + ? deletedRows + : deletedRows.filter((row) => row[discriminator] === discriminatorValue); + for (const batch of this.batchRows(model, parentRows)) { + const childModel = fieldDef.type; + const childRows = await this.read(kysely, childModel, { + where: this.makeChildFilter(model, fieldDef, batch) as any, + select: this.makeIdSelect(childModel), + }); + const pendingRows = childRows.filter((row) => { + const key = this.makeVisitedKey(childModel, row); + if (visited.has(key)) { + return false; + } + visited.add(key); + return true; + }); + + // the deletion will propagate upward to the base model chain + for (const childBatch of this.batchRows(childModel, pendingRows)) { + await this.delete( + kysely, + childModel, + this.makeIdsFilter(childModel, childBatch), + undefined, + undefined, + undefined, + visited, + ); + } + } } + let numAffectedRows = 0n; + const rows: unknown[] = []; + for (const batch of this.batchRows(model, deletedRows)) { + const result = await this.executeDelete( + kysely, + model, + this.makeIdsFilter(model, batch), + undefined, + model, + fieldsToReturn, + ); + numAffectedRows += result.numAffectedRows ?? 0n; + rows.push(...result.rows); + } + return { numAffectedRows, rows }; + } + + private async executeDelete( + kysely: AnyKysely, + model: string, + where: any, + limit: number | undefined, + filterModel: string, + fieldsToReturn: readonly string[] | undefined, + ): Promise> { + const modelDef = this.requireModel(model); fieldsToReturn = fieldsToReturn ?? requireIdFields(this.schema, model); let needIdFilter = false; @@ -2315,13 +2428,6 @@ export abstract class BaseOperationHandler { ) : () => this.dialect.buildFilter(model, model, where); - // if the model being deleted has a relation to a model that extends a delegate model, and if that - // relation is set to trigger a cascade delete from this model, the deletion will not automatically - // clean up the base hierarchy of the relation side (because polymorphic model's cascade deletion - // works downward not upward). We need to take care of the base deletions manually here. - - await this.processDelegateRelationDelete(kysely, modelDef, where, limit); - const query = kysely .deleteFrom(model) .where(deleteFilter) @@ -2332,32 +2438,97 @@ export abstract class BaseOperationHandler { return this.executeQuery(kysely, query, 'delete'); } - private async processDelegateRelationDelete( - kysely: ToKysely, - modelDef: ModelDef, - where: any, - limit: number | undefined, - ) { - for (const fieldDef of Object.values(modelDef.fields)) { - if (fieldDef.relation && fieldDef.relation.opposite) { - const oppositeModelDef = this.requireModel(fieldDef.type); - const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite); - if (oppositeModelDef.baseModel && oppositeRelation.relation?.onDelete === 'Cascade') { - if (limit !== undefined) { - throw createNotSupportedError('Deleting with a limit is not supported for polymorphic models'); + /** + * Gets the relations whose deletion must be simulated when deleting from the given model: relations + * pointing to a model that extends a delegate model, with cascade delete on the opposite side. For a + * delegate model, this includes relations declared on its sub models, because deleting a base row + * cascades to the sub model rows in the database. `discriminatorValue` is set for a sub model relation: + * it is the base model's discriminator value of the rows the relation can belong to. + */ + protected getDelegateCascadeRelations(model: string, filterModel = model) { + const modelDef = this.requireModel(model); + // when deleting through a sub model, only relations its rows can have apply + const filterDiscriminatorValue = + filterModel === model ? undefined : this.getDiscriminatorValueBelow(model, filterModel); + const candidates = Object.values(modelDef.fields).map((fieldDef) => ({ + fieldDef, + discriminatorValue: undefined as unknown, + })); + if (modelDef.isDelegate) { + for (const subModel of getDelegateDescendantModels(this.schema, model)) { + // the sub model's ancestor directly below `model` gives the discriminator value its rows carry + const discriminatorValue = this.getDiscriminatorValueBelow(model, subModel.name); + if (filterDiscriminatorValue !== undefined && discriminatorValue !== filterDiscriminatorValue) { + continue; + } + for (const fieldDef of Object.values(subModel.fields)) { + if (!fieldDef.originModel) { + candidates.push({ fieldDef, discriminatorValue }); } - // the deletion will propagate upward to the base model chain - await this.delete( - kysely, - fieldDef.type as GetModels, - { - [fieldDef.relation.opposite]: where, - }, - undefined, - ); } } } + + return candidates.filter(({ fieldDef }) => { + if (!fieldDef.relation?.opposite) { + return false; + } + const oppositeModelDef = this.requireModel(fieldDef.type); + const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite); + return !!oppositeModelDef.baseModel && oppositeRelation.relation?.onDelete === 'Cascade'; + }); + } + + // gets the discriminator value that `model` stores for rows of `subModel`, which is the value of + // `subModel`'s ancestor directly below `model` + private getDiscriminatorValueBelow(model: string, subModel: string) { + let ancestor = this.requireModel(subModel); + while (ancestor.baseModel !== model) { + ancestor = this.requireModel(ancestor.baseModel!); + } + return getDelegateDiscriminatorValue(this.schema, ancestor.name); + } + + private makeVisitedKey(model: string, row: any) { + // key by the root of the delegate hierarchy, since a row can be reached through any model in it + let root = model; + for (let base = this.requireModel(root).baseModel; base; base = this.requireModel(root).baseModel) { + root = base; + } + return JSON.stringify([root, ...this.requireModel(model).idFields.map((f) => row[f])]); + } + + // filters the children of the given parent rows, by foreign key when it references the parent's id + private makeChildFilter(model: string, fieldDef: FieldDef, parentRows: any[]) { + const opposite = fieldDef.relation!.opposite!; + const { fields, references } = this.requireField(fieldDef.type, opposite).relation ?? {}; + const idFields = this.requireModel(model).idFields; + if (!fields || !references || !references.every((f) => idFields.includes(f))) { + return { [opposite]: this.makeIdsFilter(model, parentRows) }; + } + if (fields.length === 1) { + return { [fields[0]!]: { in: parentRows.map((row) => row[references[0]!]) } }; + } + return { + OR: parentRows.map((row) => Object.fromEntries(fields.map((f, i) => [f, row[references[i]!]]))), + }; + } + + private makeIdsFilter(model: string, rows: any[]) { + const idFields = this.requireModel(model).idFields; + if (idFields.length === 1) { + return { [idFields[0]!]: { in: rows.map((row) => row[idFields[0]!]) } }; + } + return { OR: rows.map((row) => Object.fromEntries(idFields.map((f) => [f, row[f]]))) }; + } + + private *batchRows(model: string, rows: T[]) { + // keeps id lists within the databases' limits on query parameters and expression depth + const size = + this.requireModel(model).idFields.length === 1 ? DELETE_ID_BATCH_SIZE : DELETE_COMPOUND_ID_BATCH_SIZE; + for (let i = 0; i < rows.length; i += size) { + yield rows.slice(i, i + size); + } } private async processBaseModelDelete( @@ -2366,8 +2537,9 @@ export abstract class BaseOperationHandler { where: any, limit: number | undefined, filterModel: string, + visited: Set, ) { - return this.delete(kysely, model, where, limit, filterModel); + return this.delete(kysely, model, where, limit, filterModel, undefined, visited); } protected makeIdSelect(model: string) { diff --git a/packages/orm/src/client/crud/operations/delete.ts b/packages/orm/src/client/crud/operations/delete.ts index 03addadc3..2a1f33bde 100644 --- a/packages/orm/src/client/crud/operations/delete.ts +++ b/packages/orm/src/client/crud/operations/delete.ts @@ -68,18 +68,8 @@ export class DeleteOperationHandler extends BaseOperat return true; } - // Check if any relation points to a delegate sub-model with cascade delete, - // which would trigger processDelegateRelationDelete in BaseOperationHandler.delete() - for (const fieldDef of Object.values(modelDef.fields)) { - if (fieldDef.relation?.opposite) { - const oppositeModelDef = this.requireModel(fieldDef.type); - const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite); - if (oppositeModelDef.baseModel && oppositeRelation.relation?.onDelete === 'Cascade') { - return true; - } - } - } - - return false; + // Check if any relation points to a delegate sub-model with cascade delete, which + // BaseOperationHandler.delete() simulates with nested deletes + return this.getDelegateCascadeRelations(this.model).length > 0; } } diff --git a/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts b/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts new file mode 100644 index 000000000..2c50f3a41 --- /dev/null +++ b/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts @@ -0,0 +1,262 @@ +import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe('Delegate cascade delete', () => { + let db: any; + + afterEach(async () => { + await db?.$disconnect(); + }); + + async function itemIds() { + const rows = await db.$qb.selectFrom('Item').select('id').execute(); + return rows.map((r: { id: string }) => r.id).sort(); + } + + describe('with a base model relation that cascades back into the hierarchy', () => { + const schema = ` +model Item { + id String @id @default(cuid()) + itemKind String + notesAsSource Note[] @relation("NoteSource") + + @@delegate(itemKind) +} + +model Task extends Item {} + +model Note extends Item { + sourceId String? + source Item? @relation("NoteSource", fields: [sourceId], references: [id], onDelete: Cascade) + threadId String? + thread Thread? @relation(fields: [threadId], references: [id], onDelete: Cascade) +} + +model Thread { + id String @id @default(cuid()) + notes Note[] +} +`; + + it('deletes a row with no related rows', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('cascades through the base model relation', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.note.create({ data: { sourceId: task.id } }); + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('cascades through a chain of rows', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + const parent = await db.note.create({ data: { sourceId: task.id } }); + await db.note.create({ data: { sourceId: parent.id } }); + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('cascades from a non-delegate model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const thread = await db.thread.create({ data: {} }); + const note = await db.note.create({ data: { threadId: thread.id } }); + await db.note.create({ data: { sourceId: note.id } }); + await db.thread.delete({ where: { id: thread.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('deletes cyclic data through the sub model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const first = await db.note.create({ data: {} }); + const second = await db.note.create({ data: { sourceId: first.id } }); + await db.note.update({ where: { id: first.id }, data: { sourceId: second.id } }); + await db.note.delete({ where: { id: first.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('deletes cyclic data through the base model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const first = await db.note.create({ data: {} }); + const second = await db.note.create({ data: { sourceId: first.id } }); + await db.note.update({ where: { id: first.id }, data: { sourceId: second.id } }); + await expect(db.item.deleteMany({ where: { id: first.id } })).resolves.toEqual({ count: 1 }); + expect(await itemIds()).toEqual([]); + + const third = await db.note.create({ data: {} }); + const fourth = await db.note.create({ data: { sourceId: third.id } }); + await db.note.update({ where: { id: third.id }, data: { sourceId: fourth.id } }); + await db.item.delete({ where: { id: third.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('deletes many rows filtered by a sub model field', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const thread = await db.thread.create({ data: {} }); + const task = await db.task.create({ data: {} }); + const kept = await db.note.create({ data: { sourceId: task.id } }); + const deleted = await db.note.create({ data: { threadId: thread.id } }); + await db.note.create({ data: { sourceId: deleted.id } }); + await expect(db.note.deleteMany({ where: { threadId: thread.id } })).resolves.toEqual({ count: 1 }); + expect(await itemIds()).toEqual([kept.id, task.id].sort()); + }); + + it('deletes rows filtered by the related rows it cascades to', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.note.create({ data: { sourceId: task.id } }); + const kept = await db.task.create({ data: {} }); + await expect(db.task.deleteMany({ where: { notesAsSource: { some: {} } } })).resolves.toEqual({ + count: 1, + }); + expect(await itemIds()).toEqual([kept.id]); + }); + }); + + describe('with a sub model relation', () => { + const schema = ` +model Item { + id String @id @default(cuid()) + itemKind String + + @@delegate(itemKind) +} + +model Task extends Item { + comments Comment[] +} + +model Comment extends Item { + taskId String? + task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade) +} +`; + + it('cascades when deleting through the sub model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.comment.create({ data: { taskId: task.id } }); + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('cascades when deleting through the base model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.comment.create({ data: { taskId: task.id } }); + await db.item.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('applies a limit when deleting through the base model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + for (let i = 0; i < 2; i++) { + const task = await db.task.create({ data: {} }); + await db.comment.create({ data: { taskId: task.id } }); + } + await expect(db.item.deleteMany({ where: { itemKind: 'Task' }, limit: 1 })).resolves.toEqual({ + count: 1, + }); + const [remainingTask] = await db.task.findMany(); + const [remainingComment] = await db.comment.findMany(); + expect(remainingComment.taskId).toBe(remainingTask.id); + expect(await db.task.count()).toBe(1); + expect(await db.comment.count()).toBe(1); + expect(await itemIds()).toHaveLength(2); + }); + + it('deletes more related rows than fit in one statement', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + const ids = Array.from({ length: 33000 }, (_, i) => `c${String(i).padStart(5, '0')}`); + for (let i = 0; i < ids.length; i += 1000) { + const batch = ids.slice(i, i + 1000); + await db.$qb + .insertInto('Item') + .values(batch.map((id) => ({ id, itemKind: 'Comment' }))) + .execute(); + await db.$qb + .insertInto('Comment') + .values(batch.map((id) => ({ id, taskId: task.id }))) + .execute(); + } + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }, 120_000); + }); + + describe('with a compound id', () => { + const schema = ` +model Item { + id String @id @default(cuid()) + itemKind String + + @@delegate(itemKind) +} + +model Note extends Item { + groupA Int + groupB Int + group Group @relation(fields: [groupA, groupB], references: [a, b], onDelete: Cascade) +} + +model Group { + a Int + b Int + notes Note[] + + @@id([a, b]) +} +`; + + it('deletes many rows with compound ids', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + for (let b = 0; b < 600; b++) { + await db.group.create({ data: { a: 1, b, notes: { create: {} } } }); + } + await expect(db.group.deleteMany({ where: { a: 1 } })).resolves.toEqual({ count: 600 }); + expect(await itemIds()).toEqual([]); + }, 120_000); + }); + + describe('with access policies', () => { + const schema = ` +model Item { + id String @id @default(cuid()) + itemKind String + + @@delegate(itemKind) + @@allow('all', true) +} + +model Note extends Item { + threadId String? + thread Thread? @relation(fields: [threadId], references: [id], onDelete: Cascade) +} + +model Thread { + id String @id @default(cuid()) + hidden Boolean @default(false) + notes Note[] + + @@allow('create,delete', true) + @@allow('read', !hidden) +} +`; + + it('deletes rows that can be deleted but not read', async () => { + db = await createPolicyTestClient(schema, { usePrismaPush: true }); + const raw = db.$unuseAll(); + await raw.thread.create({ data: { id: 'visible', notes: { create: {} } } }); + await raw.thread.create({ data: { id: 'hidden', hidden: true, notes: { create: {} } } }); + await expect(db.thread.deleteMany({})).resolves.toEqual({ count: 2 }); + expect(await raw.thread.count()).toBe(0); + expect(await itemIds()).toEqual([]); + }); + }); +}); From 06c3baa74b38d8b2f571d0096cbb7b44975c17f3 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:01:48 -0700 Subject: [PATCH 3/3] chore: bump version 3.9.8 Co-Authored-By: Claude Fable 5.1 --- 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 c1a28730e..9f1e29e9f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.9.7", + "version": "3.9.8", "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 7d5f27207..dbe2eb5dc 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index 7c48dda00..8c82ab41f 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 61dffda90..98195d403 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index b0a22a57b..113373f6f 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index e90fde2de..87388fe59 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 7160b5cdf..ed8de5b8d 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 302cf90da..0b5c85686 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.7", + "version": "3.9.8", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index 5e582f10b..0ba006702 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.7", + "version": "3.9.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 784687951..fca42f07e 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.7", + "version": "3.9.8", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 44957b247..af5173b11 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.7", + "version": "3.9.8", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index ccdf5d364..ce193d744 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 41ce04d88..288733ce3 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.7", + "version": "3.9.8", "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 deaf5c9f0..16c983bb6 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index 71e437f5c..ad3cf60d8 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 8d858498b..2f5a6ccfa 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index 5c13ff2e2..b118cd578 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index 2e4ba6cfa..4d6c5af5e 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7d85ec3f8..86d274cd2 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index e4062f563..360022c3f 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 2b98013fb..0357c5f94 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index fa7fc5d86..8f3728f3b 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.7", + "version": "3.9.8", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index 949a7195c..a1b15cb3b 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.9.7", + "version": "3.9.8", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index 7287b2122..f7ff391f1 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.9.7", + "version": "3.9.8", "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 a1688bdce..6e600bfa2 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.9.7", + "version": "3.9.8", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 34cc1fae2..9d51055eb 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.9.7", + "version": "3.9.8", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 3fdfd00e0..37e32d7fb 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.9.7", + "version": "3.9.8", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 5e33787c0..285584311 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.7", + "version": "3.9.8", "private": true, "type": "module", "scripts": {