From c4920468f2769314b44f903fe99a936d6cbe0b31 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 30 May 2026 19:33:44 -0700 Subject: [PATCH 1/3] feat: add soft-delete plugin Add `@zenstackhq/plugin-soft-delete`, a runtime plugin that intercepts Kysely queries to implement column-based soft deletion against models carrying a `@deletedAt` marker field: - reads (select/join) are filtered to exclude soft-deleted rows - single-table deletes are rewritten into updates that stamp `@deletedAt` - updates skip already soft-deleted rows - multi-table/joined deletes that target a soft-delete model are rejected (can't be rewritten); non-soft deletes pass through and cascade naturally This initial version intentionally does not handle cascade: soft-delete is not propagated to children (left to the user) and hard deletes cascade naturally at the DB. Supporting changes: - language: add `@@@onceInModel` meta-attribute to enforce a field-level attribute appears on at most one field per model; apply it (and a nullable-field runtime check) to `@deletedAt` - testtools: support `extraPluginModelFiles` so a plugin's custom attributes are available without testtools depending on the plugin - CLAUDE.md: note MySQL is supported Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- packages/language/res/stdlib.zmodel | 6 + .../attribute-application-validator.ts | 27 ++ .../test/attribute-application.test.ts | 61 ++++ packages/plugins/soft-delete/eslint.config.js | 4 + packages/plugins/soft-delete/package.json | 68 ++++ packages/plugins/soft-delete/plugin.zmodel | 10 + packages/plugins/soft-delete/src/index.ts | 1 + packages/plugins/soft-delete/src/plugin.ts | 257 ++++++++++++++ .../soft-delete/test/soft-delete.test.ts | 320 ++++++++++++++++++ .../soft-delete/test/tombstone-unique.test.ts | 79 +++++ packages/plugins/soft-delete/tsconfig.json | 7 + packages/plugins/soft-delete/tsdown.config.ts | 3 + packages/plugins/soft-delete/vitest.config.ts | 4 + packages/testtools/src/client.ts | 13 +- packages/testtools/src/schema.ts | 3 +- packages/testtools/src/utils.ts | 4 +- pnpm-lock.yaml | 46 ++- 18 files changed, 907 insertions(+), 8 deletions(-) create mode 100644 packages/plugins/soft-delete/eslint.config.js create mode 100644 packages/plugins/soft-delete/package.json create mode 100644 packages/plugins/soft-delete/plugin.zmodel create mode 100644 packages/plugins/soft-delete/src/index.ts create mode 100644 packages/plugins/soft-delete/src/plugin.ts create mode 100644 packages/plugins/soft-delete/test/soft-delete.test.ts create mode 100644 packages/plugins/soft-delete/test/tombstone-unique.test.ts create mode 100644 packages/plugins/soft-delete/tsconfig.json create mode 100644 packages/plugins/soft-delete/tsdown.config.ts create mode 100644 packages/plugins/soft-delete/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index c033f44c9..66c279a73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **ORM**: Depends on Kysely, Zod, and various utility libraries - **CLI**: Depends on language package, Commander.js, and Prisma (for migrations) - **Language**: Uses Langium for grammar parsing and AST generation -- **Database Support**: SQLite (better-sqlite3) and PostgreSQL (pg) only +- **Database Support**: SQLite (better-sqlite3), PostgreSQL (pg), and MySQL ### Testing Strategy diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 4561da14a..dc7e38526 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -204,6 +204,12 @@ attribute @@@completionHint(_ values: String[]) */ attribute @@@once() +/** + * Indicates that a field-level attribute can only be applied to a single field within a model + * (including fields inherited from base models and mixins). + */ +attribute @@@onceInModel() + /** * Defines a single-field ID on the model. * diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index b490e20d4..9710d4c16 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -26,6 +26,7 @@ import { } from '../generated/ast'; import { getAllAttributes, + getAllFields, getAttributeArg, getContainingDataModel, getDataSourceProvider, @@ -81,6 +82,7 @@ export default class AttributeApplicationValidator implements AstValidator(); @@ -163,6 +165,31 @@ export default class AttributeApplicationValidator implements AstValidator a.decl.ref?.name === '@@@onceInModel')) { + return; + } + + // only meaningful for field-level attributes within a data model + const field = attr.$container; + if (!isDataField(field)) { + return; + } + const dataModel = getContainingDataModel(attr); + if (!dataModel) { + return; + } + + // count distinct fields (including inherited) carrying this attribute + const fieldsWithAttr = getAllFields(dataModel).filter((f) => + f.attributes.some((a) => a.decl.ref === attrDecl), + ); + if (fieldsWithAttr.length > 1) { + accept('error', `Attribute "${attrDecl.name}" can only be applied to one field per model`, { node: attr }); + } + } + // TODO: design a way to let plugin register validation @check('@@allow') @check('@@deny') diff --git a/packages/language/test/attribute-application.test.ts b/packages/language/test/attribute-application.test.ts index f412d7bec..542b4681c 100644 --- a/packages/language/test/attribute-application.test.ts +++ b/packages/language/test/attribute-application.test.ts @@ -499,6 +499,67 @@ describe('Attribute application validation tests', () => { }); }); + describe('Field-level @@@onceInModel attribute', () => { + it('accepts a single field carrying the attribute', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + attribute @softDelete() @@@targetField([DateTimeField]) @@@onceInModel + + model Foo { + id Int @id @default(autoincrement()) + deletedAt DateTime? @softDelete + } + `); + }); + + it('rejects two fields in the same model carrying the attribute', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + attribute @softDelete() @@@targetField([DateTimeField]) @@@onceInModel + + model Foo { + id Int @id @default(autoincrement()) + deletedAt DateTime? @softDelete + removedAt DateTime? @softDelete + } + `, + /Attribute "@softDelete" can only be applied to one field per model/, + ); + }); + + it('rejects when the attribute is inherited from a mixin and also declared locally', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + attribute @softDelete() @@@targetField([DateTimeField]) @@@onceInModel + + type Base { + deletedAt DateTime? @softDelete + } + + model Foo with Base { + id Int @id @default(autoincrement()) + removedAt DateTime? @softDelete + } + `, + /Attribute "@softDelete" can only be applied to one field per model/, + ); + }); + }); + it('requires relation and fk to have consistent optionality', async () => { await loadSchemaWithError( ` diff --git a/packages/plugins/soft-delete/eslint.config.js b/packages/plugins/soft-delete/eslint.config.js new file mode 100644 index 000000000..5698b9910 --- /dev/null +++ b/packages/plugins/soft-delete/eslint.config.js @@ -0,0 +1,4 @@ +import config from '@zenstackhq/eslint-config/base.js'; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json new file mode 100644 index 000000000..d2ca99112 --- /dev/null +++ b/packages/plugins/soft-delete/package.json @@ -0,0 +1,68 @@ +{ + "name": "@zenstackhq/plugin-soft-delete", + "displayName": "ZenStack Soft Delete Plugin", + "description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries", + "version": "3.7.2", + "type": "module", + "author": { + "name": "ZenStack Team", + "email": "contact@zenstack.dev" + }, + "homepage": "https://zenstack.dev", + "repository": { + "type": "git", + "url": "https://github.com/zenstackhq/zenstack" + }, + "license": "MIT", + "scripts": { + "build": "tsc --noEmit && tsdown", + "watch": "tsdown --watch", + "lint": "eslint src --ext ts", + "test": "vitest run", + "pack": "pnpm pack" + }, + "keywords": [], + "files": [ + "dist", + "plugin.zmodel" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./plugin.zmodel": { + "import": "./plugin.zmodel", + "require": "./plugin.zmodel" + }, + "./package.json": { + "import": "./package.json", + "require": "./package.json" + } + }, + "dependencies": { + "@zenstackhq/common-helpers": "workspace:*", + "@zenstackhq/orm": "workspace:*", + "ts-pattern": "catalog:" + }, + "peerDependencies": { + "kysely": "catalog:" + }, + "devDependencies": { + "@types/better-sqlite3": "catalog:", + "@types/node": "catalog:", + "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/testtools": "workspace:*", + "@zenstackhq/tsdown-config": "workspace:*", + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*", + "better-sqlite3": "catalog:" + }, + "funding": "https://github.com/sponsors/zenstackhq" +} diff --git a/packages/plugins/soft-delete/plugin.zmodel b/packages/plugins/soft-delete/plugin.zmodel new file mode 100644 index 000000000..ba657198b --- /dev/null +++ b/packages/plugins/soft-delete/plugin.zmodel @@ -0,0 +1,10 @@ +/** + * Marks a `DateTime` field as the soft-delete indicator for its owning model. + * + * When the soft-delete plugin is installed, reads against the model are automatically filtered + * to exclude rows whose marked field is non-null, and delete operations are rewritten to set + * the marked field to the current timestamp instead of physically removing the row. + * + * A model can have at most one `@deletedAt` field. + */ +attribute @deletedAt() @@@targetField([DateTimeField]) @@@once @@@onceInModel diff --git a/packages/plugins/soft-delete/src/index.ts b/packages/plugins/soft-delete/src/index.ts new file mode 100644 index 000000000..1110b6451 --- /dev/null +++ b/packages/plugins/soft-delete/src/index.ts @@ -0,0 +1 @@ +export * from './plugin'; diff --git a/packages/plugins/soft-delete/src/plugin.ts b/packages/plugins/soft-delete/src/plugin.ts new file mode 100644 index 000000000..221596a3a --- /dev/null +++ b/packages/plugins/soft-delete/src/plugin.ts @@ -0,0 +1,257 @@ +import { + getCrudDialect, + ORMError, + ORMErrorReason, + QueryUtils, + type BaseCrudDialect, + type ClientContract, + type OnKyselyQueryArgs, + type ProceedKyselyQueryFunction, + type RuntimePlugin, +} from '@zenstackhq/orm'; +import type { FieldDef, SchemaDef } from '@zenstackhq/orm/schema'; +import { + AliasNode, + AndNode, + BinaryOperationNode, + ColumnNode, + ColumnUpdateNode, + DeleteQueryNode, + IdentifierNode, + JoinNode, + OnNode, + OperationNodeTransformer, + OperatorNode, + OrNode, + ParensNode, + ReferenceNode, + SelectQueryNode, + TableNode, + UpdateQueryNode, + ValueNode, + WhereNode, + type OperationNode, + type RootOperationNode, +} from 'kysely'; + +const SOFT_DELETE_ATTRIBUTE = '@deletedAt'; + +export class SoftDeletePlugin implements RuntimePlugin { + get id() { + return 'soft-delete' as const; + } + + get name() { + return 'Soft Delete'; + } + + get description() { + return 'Filters reads against models with @deletedAt and transforms delete operations into updates of the @deletedAt field.'; + } + + onKyselyQuery({ query, client, proceed }: OnKyselyQueryArgs) { + const handler = new SoftDeleteHandler(client); + return handler.handle(query, proceed); + } +} + +type TableInfo = { model: string; alias?: string }; + +class SoftDeleteHandler extends OperationNodeTransformer { + private readonly dialect: BaseCrudDialect; + + constructor(private readonly client: ClientContract) { + super(); + this.dialect = getCrudDialect(client.$schema, client.$options); + } + + async handle(node: RootOperationNode, proceed: ProceedKyselyQueryFunction) { + if (DeleteQueryNode.is(node)) { + const converted = this.tryConvertDeleteToUpdate(node); + if (converted) { + // The rewritten UPDATE still flows through `transformUpdateQuery` so the + // soft-delete filter is added and already-soft-deleted rows aren't re-touched. + return proceed(this.transformNode(converted)); + } + // Not a soft-delete target: let the delete (and any DB-level cascade) proceed naturally. + } + return proceed(this.transformNode(node)); + } + + // Inject ` IS NULL` for soft-delete tables in the FROM clause. + protected override transformSelectQuery(node: SelectQueryNode): SelectQueryNode { + const result = super.transformSelectQuery(node); + if (!result.from) { + return result; + } + const filter = this.buildSoftDeleteFilterForTables(result.from.froms); + if (!filter) { + return result; + } + return { + ...result, + where: this.mergeWhere(result.where, filter), + }; + } + + // Inject ` IS NULL` into ON clauses of joins against soft-delete tables. + protected override transformJoin(node: JoinNode): JoinNode { + const result = super.transformJoin(node); + const info = this.extractTableInfo(result.table); + if (!info) { + return result; + } + const deletedAt = this.getDeletedAtField(info.model); + if (!deletedAt) { + return result; + } + const filter = this.buildIsNullPredicate(info.alias ?? info.model, deletedAt.name); + return { + ...result, + on: result.on ? OnNode.create(this.andNode(result.on.on, filter)) : OnNode.create(filter), + }; + } + + // Prevent updates from touching already soft-deleted rows. + protected override transformUpdateQuery(node: UpdateQueryNode): UpdateQueryNode { + const result = super.transformUpdateQuery(node); + if (!result.table) { + return result; + } + const info = this.extractTableInfo(result.table); + if (!info) { + return result; + } + const deletedAt = this.getDeletedAtField(info.model); + if (!deletedAt) { + return result; + } + const filter = this.buildIsNullPredicate(info.alias ?? info.model, deletedAt.name); + return { + ...result, + where: this.mergeWhere(result.where, filter), + }; + } + + private tryConvertDeleteToUpdate(node: DeleteQueryNode): UpdateQueryNode | undefined { + // Only single-table deletes can be converted. Multi-table/joined deletes can't be rewritten + // into an @deletedAt update — if such a delete targets a soft-delete model, refuse rather + // than silently hard-deleting its rows; otherwise let it fall through and cascade naturally. + if (node.from.froms.length !== 1 || node.using || node.joins?.length) { + for (const fromNode of node.from.froms) { + const info = this.extractTableInfo(fromNode); + if (info && this.getDeletedAtField(info.model)) { + throw new ORMError( + ORMErrorReason.NOT_SUPPORTED, + `Cannot soft-delete from "${info.model}": multi-table or joined DELETE statements cannot be rewritten into an @deletedAt update. Use a single-table delete instead.`, + ); + } + } + return undefined; + } + const fromNode = node.from.froms[0]!; + const info = this.extractTableInfo(fromNode); + if (!info) { + return undefined; + } + const deletedAt = this.getDeletedAtField(info.model); + if (!deletedAt) { + return undefined; + } + + const now = this.dialect.transformInput(new Date(), 'DateTime', false); + const update: UpdateQueryNode = { + kind: 'UpdateQueryNode', + table: fromNode, + updates: [ColumnUpdateNode.create(ColumnNode.create(deletedAt.name), ValueNode.create(now))], + where: node.where, + with: node.with, + returning: node.returning, + limit: node.limit, + orderBy: node.orderBy, + explain: node.explain, + }; + return update; + } + + // #region helpers + + private buildSoftDeleteFilterForTables(tables: readonly OperationNode[]): OperationNode | undefined { + const filters: OperationNode[] = []; + for (const table of tables) { + const info = this.extractTableInfo(table); + if (!info) { + continue; + } + const deletedAt = this.getDeletedAtField(info.model); + if (!deletedAt) { + continue; + } + filters.push(this.buildIsNullPredicate(info.alias ?? info.model, deletedAt.name)); + } + if (filters.length === 0) { + return undefined; + } + return filters.reduce((acc, f) => this.andNode(acc, f)); + } + + private buildIsNullPredicate(table: string, column: string): OperationNode { + return BinaryOperationNode.create( + ReferenceNode.create(ColumnNode.create(column), TableNode.create(table)), + OperatorNode.create('is'), + ValueNode.createImmediate(null), + ); + } + + private andNode(a: OperationNode, b: OperationNode): OperationNode { + return AndNode.create(this.wrap(a), this.wrap(b)); + } + + private wrap(node: OperationNode): OperationNode { + return OrNode.is(node) ? ParensNode.create(node) : node; + } + + private mergeWhere(existing: WhereNode | undefined, filter: OperationNode): WhereNode { + return WhereNode.create(existing ? this.andNode(existing.where, filter) : filter); + } + + private extractTableInfo(node: OperationNode): TableInfo | undefined { + if (TableNode.is(node)) { + return { model: node.table.identifier.name }; + } + if (AliasNode.is(node)) { + const inner = this.extractTableInfo(node.node); + if (!inner) { + return undefined; + } + return { + model: inner.model, + alias: IdentifierNode.is(node.alias) ? node.alias.name : inner.alias, + }; + } + return undefined; + } + + private getDeletedAtField(model: string): FieldDef | undefined { + const modelDef = QueryUtils.getModel(this.client.$schema, model); + if (!modelDef) { + return undefined; + } + for (const fieldDef of Object.values(modelDef.fields)) { + if (fieldDef.attributes?.some((a) => a.name === SOFT_DELETE_ATTRIBUTE)) { + if (!fieldDef.optional) { + // A non-nullable marker can never be null, so the `IS NULL` read filter would + // hide every row. Require the marker to be optional so "not deleted" === null. + throw new ORMError( + ORMErrorReason.NOT_SUPPORTED, + `Field "${model}.${fieldDef.name}" is marked @deletedAt but is not optional. The soft-delete marker must be a nullable field (e.g. "DateTime?").`, + ); + } + return fieldDef; + } + } + return undefined; + } + + // #endregion +} diff --git a/packages/plugins/soft-delete/test/soft-delete.test.ts b/packages/plugins/soft-delete/test/soft-delete.test.ts new file mode 100644 index 000000000..4c46b74b4 --- /dev/null +++ b/packages/plugins/soft-delete/test/soft-delete.test.ts @@ -0,0 +1,320 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { SoftDeletePlugin } from '../src'; + +// The `@deletedAt` attribute is defined in this plugin's `plugin.zmodel`. We feed it to the +// test client explicitly so that `@zenstackhq/testtools` doesn't need to depend on this plugin. +const PLUGIN_MODEL_FILE = fileURLToPath(new URL('../plugin.zmodel', import.meta.url)); + +function createSoftDeleteTestClient(schema: string) { + return createTestClient(schema, { extraPluginModelFiles: [PLUGIN_MODEL_FILE] }); +} + +const schema = ` +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] + deletedAt DateTime? @deletedAt +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int + deletedAt DateTime? @deletedAt +} + +model Tag { + id Int @id @default(autoincrement()) + name String +} +`; + +async function makeClient() { + const raw = await createSoftDeleteTestClient(schema); + const db = raw.$use(new SoftDeletePlugin()); + return { db, raw }; +} + +describe('soft-delete plugin', () => { + it('hides soft-deleted rows from reads', async () => { + const { db } = await makeClient(); + + const a = await db.user.create({ data: { email: 'a@test.com' } }); + const b = await db.user.create({ data: { email: 'b@test.com' } }); + + await db.user.delete({ where: { id: a.id } }); + + await expect(db.user.findMany()).resolves.toEqual([ + expect.objectContaining({ id: b.id, email: 'b@test.com', deletedAt: null }), + ]); + + await expect(db.user.findUnique({ where: { id: a.id } })).resolves.toBeNull(); + await expect(db.user.findUnique({ where: { id: b.id } })).resolves.toMatchObject({ id: b.id }); + }); + + it('rewrites delete into update on the @deletedAt column', async () => { + const { db, raw } = await makeClient(); + const user = await db.user.create({ data: { email: 'soft@test.com' } }); + + // delete returns the affected record, now carrying the soft-delete timestamp + const deleted = await db.user.delete({ where: { id: user.id } }); + expect(deleted).toMatchObject({ id: user.id, email: 'soft@test.com' }); + expect(deleted.deletedAt).not.toBeNull(); + + // the row is still physically present, just marked deleted (peek via plugin-less client) + const row = await raw.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(row.deletedAt).not.toBeNull(); + expect(row.deletedAt!.getTime()).not.toBeNaN(); + }); + + it('skips already soft-deleted rows on subsequent deletes and updates', async () => { + const { db, raw } = await makeClient(); + const user = await db.user.create({ data: { email: 'one@test.com' } }); + + const firstDelete = await db.user.delete({ where: { id: user.id } }); + expect(firstDelete).toMatchObject({ id: user.id }); + + const firstDeletedAt = (await raw.user.findUniqueOrThrow({ where: { id: user.id } })).deletedAt!; + + // a follow-up delete should be a no-op (row already soft-deleted) and report a zero count + const secondDelete = await db.user.deleteMany({ where: { id: user.id } }); + expect(secondDelete).toEqual({ count: 0 }); + const afterSecondDelete = await raw.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(afterSecondDelete.deletedAt!.getTime()).toBe(firstDeletedAt.getTime()); + + // updateMany should also skip soft-deleted rows + await db.user.updateMany({ where: { id: user.id }, data: { email: 'updated@test.com' } }); + const afterUpdate = await raw.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(afterUpdate.email).toBe('one@test.com'); + }); + + it('soft-deletes a related row via a nested delete', async () => { + const { db, raw } = await makeClient(); + const user = await db.user.create({ + data: { email: 'nested@test.com', posts: { create: [{ title: 'a' }, { title: 'b' }] } }, + include: { posts: true }, + }); + const [postA, postB] = user.posts; + + // nested delete of a soft-delete child runs as a soft delete + const updated = await db.user.update({ + where: { id: user.id }, + data: { posts: { delete: { id: postA!.id } } }, + include: { posts: true }, + }); + + // the returned relation only surfaces the surviving post + expect(updated.posts).toEqual([expect.objectContaining({ id: postB!.id, title: 'b', deletedAt: null })]); + + // post A is physically present but marked deleted; post B untouched + // (read via the plugin-less `raw` client so the soft-delete filter doesn't hide it) + const rowA = await raw.post.findUniqueOrThrow({ where: { id: postA!.id } }); + expect(rowA.deletedAt).not.toBeNull(); + + const rowB = await raw.post.findUniqueOrThrow({ where: { id: postB!.id } }); + expect(rowB.deletedAt).toBeNull(); + }); + + it('soft-deletes matching rows on deleteMany and reports the live count', async () => { + const { db, raw } = await makeClient(); + const keep = await db.user.create({ data: { email: 'keep@x.com' } }); + const drop1 = await db.user.create({ data: { email: 'drop1@y.com' } }); + const drop2 = await db.user.create({ data: { email: 'drop2@y.com' } }); + // pre-soft-deleted row that also matches the filter — must not be re-counted + const already = await db.user.create({ data: { email: 'already@y.com' } }); + await db.user.delete({ where: { id: already.id } }); + + // only the two live matching rows are counted + const result = await db.user.deleteMany({ where: { email: { endsWith: '@y.com' } } }); + expect(result).toEqual({ count: 2 }); + + // matched rows are soft-deleted (marked, not physically removed)... + const droppedRows = await raw.user.findMany({ where: { id: { in: [drop1.id, drop2.id] } } }); + expect(droppedRows).toHaveLength(2); + for (const row of droppedRows) { + expect(row.deletedAt).not.toBeNull(); + } + + // ...and reads only surface the untouched row + await expect(db.user.findMany()).resolves.toEqual([ + expect.objectContaining({ id: keep.id, email: 'keep@x.com', deletedAt: null }), + ]); + }); + + it('filters joined relations against the @deletedAt column', async () => { + const { db } = await makeClient(); + const user = await db.user.create({ data: { email: 'rel@test.com' } }); + const live = await db.post.create({ data: { title: 'live', authorId: user.id } }); + const tombstoned = await db.post.create({ data: { title: 'gone', authorId: user.id } }); + + await db.post.delete({ where: { id: tombstoned.id } }); + + const reloaded = await db.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { posts: true }, + }); + + expect(reloaded.posts).toEqual([expect.objectContaining({ id: live.id, title: 'live' })]); + }); + + it('leaves models without @deletedAt untouched', async () => { + const { db, raw } = await makeClient(); + + const tag = await db.tag.create({ data: { name: 'keep' } }); + await db.tag.delete({ where: { id: tag.id } }); + + await expect(db.tag.findUnique({ where: { id: tag.id } })).resolves.toBeNull(); + + // physically gone (confirm via the plugin-less client) + await expect(raw.tag.findUnique({ where: { id: tag.id } })).resolves.toBeNull(); + }); + + it('rejects a non-nullable @deletedAt field', async () => { + // A non-optional marker can never be null, so the IS NULL read filter would hide every row. + const badSchema = ` +model Post { + id Int @id @default(autoincrement()) + title String + deletedAt DateTime @default(now()) @deletedAt +} +`; + const raw = await createSoftDeleteTestClient(badSchema); + const db = raw.$use(new SoftDeletePlugin()); + await expect(db.post.findMany()).rejects.toThrow(/"Post\.deletedAt".*not optional/); + }); + + it('rejects a model with more than one @deletedAt field', async () => { + const twoFieldsSchema = ` +model Post { + id Int @id @default(autoincrement()) + title String + deletedAt DateTime? @deletedAt + removedAt DateTime? @deletedAt +} +`; + await expect(createSoftDeleteTestClient(twoFieldsSchema)).rejects.toThrow( + /@deletedAt.*can only be applied to one field per model/, + ); + }); + + it('rejects a joined/multi-table delete that targets a soft-delete model', async () => { + const { db, raw } = await makeClient(); + const user = await db.user.create({ data: { email: 'spam@test.com' } }); + const post = await db.post.create({ data: { title: 'spammy', authorId: user.id } }); + + // A joined delete on Post (a soft-delete model) can't be rewritten into an @deletedAt update. + await expect( + db.$qb + .deleteFrom('Post') + .using('User') + .whereRef('Post.authorId', '=', 'User.id') + .where('User.email', '=', 'spam@test.com') + .execute(), + ).rejects.toThrow(/Cannot soft-delete from "Post".*single-table delete/s); + + // the row is untouched — neither hard- nor soft-deleted + const row = await raw.post.findUniqueOrThrow({ where: { id: post.id } }); + expect(row.deletedAt).toBeNull(); + }); + + it('rewrites a single-table $qb delete on a soft-delete model into an update', async () => { + const { db, raw } = await makeClient(); + const user = await db.user.create({ data: { email: 'qb@test.com' } }); + + // low-level Kysely delete still flows through the plugin's onKyselyQuery hook; + // the rewritten update still reports the affected-row count as a delete result + const result = await db.$qb.deleteFrom('User').where('id', '=', user.id).execute(); + expect(result).toEqual([{ numDeletedRows: 1n }]); + + // physically present, just marked deleted + const row = await raw.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(row.deletedAt).not.toBeNull(); + + // and hidden from reads through the plugin + await expect(db.user.findUnique({ where: { id: user.id } })).resolves.toBeNull(); + }); + + it('lets a $qb delete on a model without @deletedAt delete physically', async () => { + const { db, raw } = await makeClient(); + const tag = await db.tag.create({ data: { name: 'qb-keep' } }); + + const result = await db.$qb.deleteFrom('Tag').where('id', '=', tag.id).execute(); + expect(result).toEqual([{ numDeletedRows: 1n }]); + + await expect(raw.tag.findUnique({ where: { id: tag.id } })).resolves.toBeNull(); + }); + + it('does not propagate the soft-delete to children (left to the user)', async () => { + // The plugin intentionally does not cascade soft-deletes. Children of a soft-deleted + // parent are left untouched; managing them is the user's responsibility. + const cascadeSchema = ` +model Parent { + id Int @id @default(autoincrement()) + name String + children Child[] + deletedAt DateTime? @deletedAt +} + +model Child { + id Int @id @default(autoincrement()) + parent Parent @relation(fields: [parentId], references: [id]) + parentId Int + deletedAt DateTime? @deletedAt +} +`; + const raw = await createSoftDeleteTestClient(cascadeSchema); + const db = raw.$use(new SoftDeletePlugin()); + + const parent = await db.parent.create({ + data: { name: 'p', children: { create: [{}, {}] } }, + }); + + await db.parent.delete({ where: { id: parent.id } }); + + // parent is soft-deleted... + const parentRow = await raw.parent.findUniqueOrThrow({ where: { id: parent.id } }); + expect(parentRow.deletedAt).not.toBeNull(); + + // ...but its children are left untouched + const childRows = await raw.child.findMany({ where: { parentId: parent.id } }); + expect(childRows).toHaveLength(2); + for (const row of childRows) { + expect(row.deletedAt).toBeNull(); + } + }); + + it('lets a hard-delete cascade naturally at the database', async () => { + // The parent has no @deletedAt, so its delete is a real DELETE. The plugin does not + // interfere, so the DB-level onDelete: Cascade hard-deletes the children too. + const cascadeSchema = ` +model Parent { + id Int @id @default(autoincrement()) + name String + children Child[] +} + +model Child { + id Int @id @default(autoincrement()) + parent Parent @relation(fields: [parentId], references: [id], onDelete: Cascade) + parentId Int + deletedAt DateTime? @deletedAt +} +`; + const raw = await createSoftDeleteTestClient(cascadeSchema); + const db = raw.$use(new SoftDeletePlugin()); + + const parent = await db.parent.create({ + data: { name: 'p', children: { create: [{}, {}] } }, + }); + + await db.parent.delete({ where: { id: parent.id } }); + + // parent and its children are physically gone + await expect(raw.parent.findUnique({ where: { id: parent.id } })).resolves.toBeNull(); + await expect(raw.child.findMany({ where: { parentId: parent.id } })).resolves.toHaveLength(0); + }); +}); diff --git a/packages/plugins/soft-delete/test/tombstone-unique.test.ts b/packages/plugins/soft-delete/test/tombstone-unique.test.ts new file mode 100644 index 000000000..1ad2d3f6f --- /dev/null +++ b/packages/plugins/soft-delete/test/tombstone-unique.test.ts @@ -0,0 +1,79 @@ +import { createTestClient, getTestDbProvider } from '@zenstackhq/testtools'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { SoftDeletePlugin } from '../src'; + +// The `@deletedAt` attribute is defined in this plugin's `plugin.zmodel`. We feed it to the +// test client explicitly so that `@zenstackhq/testtools` doesn't need to depend on this plugin. +const PLUGIN_MODEL_FILE = fileURLToPath(new URL('../plugin.zmodel', import.meta.url)); + +function createSoftDeleteTestClient(schema: string, provider?: 'sqlite' | 'postgresql' | 'mysql') { + return createTestClient(schema, { extraPluginModelFiles: [PLUGIN_MODEL_FILE], provider }); +} + +// A column-based soft delete leaves a tombstone behind, so a plain `@unique` would reject reusing +// the value. The mitigation is a unique index scoped to live (non-deleted) rows. ZModel can't +// express that condition, so each test below emits the dialect-specific DDL directly (no `@unique` +// on the model) and asserts the same behavior: live rows are unique, tombstones may share a value. +describe('tombstone unique conflict mitigation (per-dialect index)', () => { + const uniqueSchema = ` +model User { + id Int @id @default(autoincrement()) + email String + deletedAt DateTime? @deletedAt +} +`; + + async function expectTombstoneUniqueBehavior(db: any) { + const a = await db.user.create({ data: { email: 'a@x.com' } }); + + // a second *live* row with the same email collides + await expect(db.user.create({ data: { email: 'a@x.com' } })).rejects.toThrow(); + + // soft-deleting frees the value — the tombstone leaves the index's scope + await db.user.delete({ where: { id: a.id } }); + const b = await db.user.create({ data: { email: 'a@x.com' } }); + expect(b.id).not.toBe(a.id); + + // uniqueness is still enforced among the remaining live rows + await expect(db.user.create({ data: { email: 'a@x.com' } })).rejects.toThrow(); + } + + it('sqlite: partial unique index scoped to live rows', async ({ skip }) => { + if (getTestDbProvider() !== 'sqlite') { + skip(); + } + const raw = await createSoftDeleteTestClient(uniqueSchema, 'sqlite'); + const db = raw.$use(new SoftDeletePlugin()); + await raw.$executeRawUnsafe( + `CREATE UNIQUE INDEX "User_email_active" ON "User" ("email") WHERE "deletedAt" IS NULL`, + ); + await expectTombstoneUniqueBehavior(db); + }); + + it('postgresql: partial unique index scoped to live rows', async ({ skip }) => { + if (getTestDbProvider() !== 'postgresql') { + skip(); + } + const raw = await createSoftDeleteTestClient(uniqueSchema, 'postgresql'); + const db = raw.$use(new SoftDeletePlugin()); + await raw.$executeRawUnsafe( + `CREATE UNIQUE INDEX "User_email_active" ON "User" ("email") WHERE "deletedAt" IS NULL`, + ); + await expectTombstoneUniqueBehavior(db); + }); + + it('mysql: functional unique index over a CASE expression', async ({ skip }) => { + if (getTestDbProvider() !== 'mysql') { + skip(); + } + const raw = await createSoftDeleteTestClient(uniqueSchema, 'mysql'); + const db = raw.$use(new SoftDeletePlugin()); + // MySQL has no partial indexes; a functional key part over a CASE expr yields NULL for + // tombstones (and MySQL allows multiple NULLs in a unique index). Requires MySQL 8.0.13+. + await raw.$executeRawUnsafe( + 'ALTER TABLE `User` ADD UNIQUE INDEX `User_email_active` ((CASE WHEN `deletedAt` IS NULL THEN `email` END))', + ); + await expectTombstoneUniqueBehavior(db); + }); +}); diff --git a/packages/plugins/soft-delete/tsconfig.json b/packages/plugins/soft-delete/tsconfig.json new file mode 100644 index 000000000..1100998bc --- /dev/null +++ b/packages/plugins/soft-delete/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*", "test/**/*.ts"] +} diff --git a/packages/plugins/soft-delete/tsdown.config.ts b/packages/plugins/soft-delete/tsdown.config.ts new file mode 100644 index 000000000..e0a6d5624 --- /dev/null +++ b/packages/plugins/soft-delete/tsdown.config.ts @@ -0,0 +1,3 @@ +import { createConfig } from '@zenstackhq/tsdown-config'; + +export default createConfig({ entry: { index: 'src/index.ts' } }); diff --git a/packages/plugins/soft-delete/vitest.config.ts b/packages/plugins/soft-delete/vitest.config.ts new file mode 100644 index 000000000..75a9f709c --- /dev/null +++ b/packages/plugins/soft-delete/vitest.config.ts @@ -0,0 +1,4 @@ +import base from '@zenstackhq/vitest-config/base'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(base, defineConfig({})); diff --git a/packages/testtools/src/client.ts b/packages/testtools/src/client.ts index c9d0ca8b3..6b1bc107c 100644 --- a/packages/testtools/src/client.ts +++ b/packages/testtools/src/client.ts @@ -72,6 +72,13 @@ type ExtraTestClientOptions = { */ extraZModelFiles?: Record; + /** + * Extra plugin `plugin.zmodel` files (resolved absolute paths) to merge into the schema + * when loading. Use this to make a plugin's custom attributes available without testtools + * having to depend on the plugin. + */ + extraPluginModelFiles?: string[]; + /** * Extra TypeScript source files to create and compile. */ @@ -143,6 +150,7 @@ export async function createTestClient( options?.extraSourceFiles, undefined, options?.extraZModelFiles, + options?.extraPluginModelFiles, ); workDir = generated.workDir; model = generated.model; @@ -226,7 +234,10 @@ export async function createTestClient( 'a schema file must be provided when using prisma db push', ); if (!model) { - const r = await loadDocumentWithPlugins(path.join(workDir, 'schema.zmodel')); + const r = await loadDocumentWithPlugins( + path.join(workDir, 'schema.zmodel'), + options?.extraPluginModelFiles, + ); if (!r.success) { throw new Error(r.errors.join('\n')); } diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index ffd016b11..98c836435 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -62,6 +62,7 @@ export async function generateTsSchema( extraSourceFiles?: Record, withLiteSchema?: boolean, extraZModelFiles?: Record, + extraPluginModelFiles?: string[], ) { const workDir = createTestProject(); @@ -85,7 +86,7 @@ export async function generateTsSchema( } } - const result = await loadDocumentWithPlugins(zmodelPath); + const result = await loadDocumentWithPlugins(zmodelPath, extraPluginModelFiles); if (!result.success) { throw new Error(`Failed to load schema from ${zmodelPath}: ${result.errors}`); } diff --git a/packages/testtools/src/utils.ts b/packages/testtools/src/utils.ts index 1f8119fe3..52ec98c40 100644 --- a/packages/testtools/src/utils.ts +++ b/packages/testtools/src/utils.ts @@ -1,6 +1,6 @@ import { loadDocument } from '@zenstackhq/language'; -export function loadDocumentWithPlugins(filePath: string) { - const pluginModelFiles = [require.resolve('@zenstackhq/plugin-policy/plugin.zmodel')]; +export function loadDocumentWithPlugins(filePath: string, extraPluginModelFiles: string[] = []) { + const pluginModelFiles = [require.resolve('@zenstackhq/plugin-policy/plugin.zmodel'), ...extraPluginModelFiles]; return loadDocument(filePath, pluginModelFiles); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f22e8e26..dd01fe34b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -739,6 +739,46 @@ importers: specifier: workspace:* version: link:../../config/vitest-config + packages/plugins/soft-delete: + dependencies: + '@zenstackhq/common-helpers': + specifier: workspace:* + version: link:../../common-helpers + '@zenstackhq/orm': + specifier: workspace:* + version: link:../../orm + kysely: + specifier: 'catalog:' + version: 0.29.0 + ts-pattern: + specifier: 'catalog:' + version: 5.7.1 + devDependencies: + '@types/better-sqlite3': + specifier: 'catalog:' + version: 7.6.13 + '@types/node': + specifier: 'catalog:' + version: 20.19.24 + '@zenstackhq/eslint-config': + specifier: workspace:* + version: link:../../config/eslint-config + '@zenstackhq/testtools': + specifier: workspace:* + version: link:../../testtools + '@zenstackhq/tsdown-config': + specifier: workspace:* + version: link:../../config/tsdown-config + '@zenstackhq/typescript-config': + specifier: workspace:* + version: link:../../config/typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../../config/vitest-config + better-sqlite3: + specifier: 'catalog:' + version: 12.5.0 + packages/schema: dependencies: decimal.js: @@ -11892,7 +11932,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 20.19.24 + '@types/node': 25.5.2 '@types/body-parser@1.19.6': dependencies: @@ -11913,7 +11953,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 20.19.24 + '@types/node': 25.5.2 '@types/deep-eql@4.0.2': {} @@ -11968,7 +12008,7 @@ snapshots: '@types/pg@8.16.0': dependencies: - '@types/node': 20.19.24 + '@types/node': 25.5.2 pg-protocol: 1.10.3 pg-types: 2.2.0 From 1276573da57e9f3ed498bf4a2a734f849042e645 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 30 May 2026 20:03:06 -0700 Subject: [PATCH 2/3] fix(soft-delete): don't assert returned deletedAt on delete (dialect-specific) `delete` returns the affected record, but whether that record reflects the post-update `@deletedAt` depends on the dialect's delete-return strategy: SQLite/Postgres use DELETE ... RETURNING (rewritten to UPDATE ... RETURNING, so the returned row is post-update), while MySQL has no RETURNING and does select-then-mutate (returned row is the pre-update snapshot). Assert only the persisted DB state, which is dialect-independent. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugins/soft-delete/test/soft-delete.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugins/soft-delete/test/soft-delete.test.ts b/packages/plugins/soft-delete/test/soft-delete.test.ts index 4c46b74b4..2203b93eb 100644 --- a/packages/plugins/soft-delete/test/soft-delete.test.ts +++ b/packages/plugins/soft-delete/test/soft-delete.test.ts @@ -60,10 +60,11 @@ describe('soft-delete plugin', () => { const { db, raw } = await makeClient(); const user = await db.user.create({ data: { email: 'soft@test.com' } }); - // delete returns the affected record, now carrying the soft-delete timestamp + // delete returns the affected record (whether its `deletedAt` reflects the new value + // depends on the dialect's delete-return strategy — RETURNING vs select-then-mutate — + // so we assert the persisted state below instead) const deleted = await db.user.delete({ where: { id: user.id } }); expect(deleted).toMatchObject({ id: user.id, email: 'soft@test.com' }); - expect(deleted.deletedAt).not.toBeNull(); // the row is still physically present, just marked deleted (peek via plugin-less client) const row = await raw.user.findUniqueOrThrow({ where: { id: user.id } }); From 25bb01d36ca90c3bc141866149bc41020f4218f9 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 30 May 2026 20:27:37 -0700 Subject: [PATCH 3/3] test(soft-delete): select related posts by title, not array position The nested-delete test destructured `[postA, postB] = user.posts` and then asserted `title: 'b'` on postB, coupling array position to title. The include has no orderBy, so row order is DB-defined and not guaranteed across dialects. Select by title instead to make the test order-independent. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugins/soft-delete/test/soft-delete.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/plugins/soft-delete/test/soft-delete.test.ts b/packages/plugins/soft-delete/test/soft-delete.test.ts index 2203b93eb..5d37facf5 100644 --- a/packages/plugins/soft-delete/test/soft-delete.test.ts +++ b/packages/plugins/soft-delete/test/soft-delete.test.ts @@ -99,7 +99,9 @@ describe('soft-delete plugin', () => { data: { email: 'nested@test.com', posts: { create: [{ title: 'a' }, { title: 'b' }] } }, include: { posts: true }, }); - const [postA, postB] = user.posts; + // pick by title — `include` has no orderBy, so array position isn't guaranteed + const postA = user.posts.find((p: any) => p.title === 'a'); + const postB = user.posts.find((p: any) => p.title === 'b'); // nested delete of a soft-delete child runs as a soft delete const updated = await db.user.update({