Skip to content

feat: add soft-delete plugin#2688

Merged
ymc9 merged 3 commits into
devfrom
feat/soft-delete
May 31, 2026
Merged

feat: add soft-delete plugin#2688
ymc9 merged 3 commits into
devfrom
feat/soft-delete

Conversation

@ymc9
Copy link
Copy Markdown
Member

@ymc9 ymc9 commented May 31, 2026

Summary

Adds @zenstackhq/plugin-soft-delete, a runtime plugin that implements column-based soft deletion by intercepting Kysely queries for models that carry a @deletedAt marker field.

Behavior:

  • Reads (select + joins) are filtered to exclude soft-deleted rows (<deletedAt> IS NULL).
  • Single-table deletes are rewritten into updates that stamp @deletedAt (with RETURNING, so delete/deleteMany still report the affected row/count).
  • Updates skip already soft-deleted rows.
  • Multi-table / joined deletes that target a soft-delete model are rejected (they can't be rewritten into an update); deletes on non-soft models pass through and cascade naturally at the DB.

This initial version intentionally keeps cascade simple: a soft delete is not propagated to children (the user owns that), and hard deletes cascade naturally at the database.

Supporting changes

  • language: new @@@onceInModel meta-attribute that enforces a field-level attribute appears on at most one field per model (incl. inherited); applied to @deletedAt. The plugin also rejects a non-nullable @deletedAt at runtime (a non-null marker would hide every row).
  • testtools: support extraPluginModelFiles so a plugin's custom attributes are available to the test client without testtools depending on the plugin.
  • CLAUDE.md: note MySQL is supported.

Testing

  • soft-delete.test.ts: read filtering, delete→update rewrite, update guard, nested delete, deleteMany counts, $qb paths, multi-table-delete rejection, non-nullable/duplicate @deletedAt rejection, cascade behavior.
  • tombstone-unique.test.ts: per-dialect mitigation for the unique-constraint-vs-tombstone problem (Postgres/SQLite partial index, MySQL functional CASE index). Guarded by TEST_DB_PROVIDER; validated against real Postgres and MySQL 8 servers.
  • attribute-application.test.ts: @@@onceInModel validation.

All green; package build + lint clean.

Known limitations (by design for v1)

  • No supported read/restore/purge of a soft-deleted row through the plugin client — even $qb can't bypass the filter; restore/purge currently requires a separate plugin-less client. Worth a follow-up to add a supported escape hatch.
  • Unique constraints collide with tombstones (a soft-deleted row still occupies a unique key). The mitigation is a live-scoped unique index, which ZModel can't express declaratively yet — see tombstone-unique.test.ts for the per-dialect DDL. Should be documented on the docs site.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Soft-delete plugin: hides soft-deleted rows, rewrites deletes to timestamp updates, and blocks multi-table hard-deletes.
    • Added MySQL support.
    • Introduced @@onceInModel attribute to enforce a single marker field per model.
    • Tombstone-unique conflict mitigation for soft-delete workflows.
  • Tests

    • Added comprehensive tests covering soft-delete behavior and uniqueness across SQLite, PostgreSQL, and MySQL.

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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 31, 2026

Claude finished @ymc9's task —— View job


Comprehensive Code Review ✅

I've thoroughly reviewed the soft-delete plugin implementation. Here's my detailed feedback:

Review Checklist:

  • Analyze core plugin implementation (plugin.ts)
  • Review language validator changes (@@@onceInModel)
  • Examine test coverage and edge cases
  • Check for security considerations
  • Evaluate performance implications
  • Assess code quality and best practices
  • Provide comprehensive feedback

🎯 Overall Assessment

This is a well-designed, robust implementation that demonstrates excellent software engineering practices. The plugin follows ZenStack's architectural patterns and provides a clean, predictable API for soft deletion.

Strengths

Architecture & Design

  • Clean separation of concerns: Plugin logic cleanly separated into handler classes
  • Consistent with ZenStack patterns: Follows existing plugin architecture using RuntimePlugin interface
  • Clear behavior boundaries: Explicitly defines what operations are supported vs. rejected
  • Type safety: Leverages TypeScript effectively with proper type definitions

Security Considerations

  • No SQL injection vectors: Uses Kysely's type-safe query building exclusively
  • Proper input validation: Validates @deletedAt field constraints at runtime
  • Access control preserved: Doesn't interfere with existing access policies
  • No secret exposure: No logging or exposure of sensitive data

Performance Considerations

  • Efficient query transformation: Minimal overhead when transforming queries
  • Database-optimized: Uses IS NULL predicates that can leverage indexes
  • Lazy evaluation: Only processes models that actually have @deletedAt fields
  • Proper JOIN handling: Correctly adds filters to JOIN ON clauses for optimal query plans

Code Quality

  • Excellent error handling: Clear, actionable error messages for invalid configurations
  • Comprehensive edge case coverage: Handles complex scenarios like multi-table deletes gracefully
  • Clean abstractions: Well-organized helper methods with single responsibilities
  • Consistent naming: Clear, descriptive method and variable names

📋 Detailed Review

Core Plugin Implementation (packages/plugins/soft-delete/src/plugin.ts)

Query Transformation Logic (lines 82-134):

protected override transformSelectQuery(node: SelectQueryNode): SelectQueryNode
  • Correct approach: Adds IS NULL filters to FROM clauses
  • JOIN handling: Properly injects filters into JOIN ON clauses
  • Update protection: Prevents updates to already soft-deleted rows

Delete→Update Conversion (lines 136-175):

private tryConvertDeleteToUpdate(node: DeleteQueryNode): UpdateQueryNode | undefined
  • Smart detection: Only converts single-table deletes, rejects complex queries
  • Preserves semantics: Maintains RETURNING clause and other delete features
  • Error handling: Clear rejection of unsupported multi-table operations

Error Validation (lines 242-249):

if (!fieldDef.optional) {
    throw new ORMError(ORMErrorReason.NOT_SUPPORTED, ...)
}
  • Runtime safety: Prevents impossible configurations (non-nullable @deletedAt)
  • Clear messaging: Explains why the configuration is invalid

Language Support (packages/language/src/validators/attribute-application-validator.ts)

@@@onceInModel Implementation (lines 168-191):

private checkOnceInModel(attr: AttributeApplication, accept: ValidationAcceptor)
  • Inheritance-aware: Correctly checks inherited fields from mixins
  • Scope-appropriate: Only applies to field-level attributes
  • Clear validation: Provides actionable error messages

Test Coverage

Excellent comprehensive coverage:

  • Core functionality: Read filtering, delete→update transformation
  • Edge cases: Multi-table deletes, already deleted rows, validation errors
  • Integration: Nested operations, JOIN filtering, $qb low-level API
  • Cross-dialect: MySQL/PostgreSQL unique index mitigation strategies
  • Error conditions: Invalid field configurations, constraint violations

🔧 Minor Suggestions

1. Documentation Enhancement

Consider adding inline examples to the plugin description:

get description() {
    return 'Filters reads against models with @deletedAt and transforms delete operations into updates of the @deletedAt field. Example: @deletedAt DateTime?';
}

2. Performance Optimization Opportunity

Cache deletedAt field lookups to avoid repeated schema traversal:

private deletedAtFieldCache = new Map<string, FieldDef | null>();

private getDeletedAtField(model: string): FieldDef | undefined {
    if (!this.deletedAtFieldCache.has(model)) {
        const field = this.findDeletedAtField(model);
        this.deletedAtFieldCache.set(model, field || null);
    }
    return this.deletedAtFieldCache.get(model) || undefined;
}

3. Type Safety Enhancement

Consider stronger typing for table info extraction:

type TableInfo = { 
    model: string; 
    alias?: string;
    kind: 'table' | 'aliased';
};

🏆 Exceptional Aspects

1. Tombstone Unique Constraint Handling

The mitigation strategy for unique constraints is sophisticated and well-researched:

  • SQLite/PostgreSQL: Partial indexes with WHERE deletedAt IS NULL
  • MySQL: Functional indexes with CASE expressions
  • Excellent documentation: Clear examples for each database dialect

2. Cascade Behavior Design

Intentionally simple and predictable:

  • Soft deletes don't propagate (user responsibility)
  • Hard deletes cascade naturally at DB level
  • Clear documentation of this design choice

3. Integration with Existing Tooling

Seamless integration:

  • Works with $qb query builder
  • Preserves RETURNING clauses
  • Doesn't interfere with access policies
  • Compatible with existing test infrastructure

🚀 Innovation Highlights

  1. Kysely AST Transformation: Sophisticated use of Kysely's operation node transformer
  2. Multi-dialect Support: Thoughtful handling of database-specific features
  3. Plugin Architecture: Clean extension of ZenStack's plugin system
  4. Validation Integration: Seamless integration with language validation

📊 Final Verdict

This is production-ready code that demonstrates:

  • Deep understanding of database query transformation
  • Excellent testing practices
  • Clear documentation and error handling
  • Thoughtful API design

Recommendation: ✅ Approve - This implementation is ready for merge and represents high-quality engineering work.

The code follows all best practices, has comprehensive test coverage, handles edge cases gracefully, and provides a clean API that fits well within the ZenStack ecosystem.


@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 31, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7dea2cfb-6e18-48c1-ad35-2ae1d9e4ca3d

📥 Commits

Reviewing files that changed from the base of the PR and between 1276573 and 25bb01d.

📒 Files selected for processing (1)
  • packages/plugins/soft-delete/test/soft-delete.test.ts

📝 Walkthrough

Walkthrough

Adds @@onceInModel validation, a SoftDelete runtime plugin that rewrites deletes and filters tombstones, test-tooling to load external plugin.zmodel files, plugin package/config, and multiple Vitest suites covering behavior and unique-index tombstone mitigation.

Changes

Language Constraint and Soft-Delete Plugin

Layer / File(s) Summary
@@onceInModel constraint attribute
packages/language/res/stdlib.zmodel, packages/language/src/validators/attribute-application-validator.ts, packages/language/test/attribute-application.test.ts
New attribute constraint that validates a field-level attribute is applied to at most one field per model, including inherited and mixin fields; enforced via checkOnceInModel validator that traverses all fields and emits errors for violations.
Test tools plugin model file support
packages/testtools/src/client.ts, packages/testtools/src/schema.ts, packages/testtools/src/utils.ts
CreateTestClientOptions gains extraPluginModelFiles field; threaded through generateTsSchema and loadDocumentWithPlugins to allow tests to merge additional plugin model files when loading schemas.
Soft-delete plugin specification and implementation
packages/plugins/soft-delete/plugin.zmodel, packages/plugins/soft-delete/src/plugin.ts, packages/plugins/soft-delete/src/index.ts
Declares @deletedAt attribute and implements SoftDeletePlugin to rewrite single-table deletes to updates, inject WHERE deletedAt IS NULL on SELECT/UPDATE/JOIN, validate marker field is optional, and reject multi-table deletes.
Soft-delete plugin tests
packages/plugins/soft-delete/test/soft-delete.test.ts, packages/plugins/soft-delete/test/tombstone-unique.test.ts
Vitest suites verify soft-delete hiding, delete-to-update rewriting, idempotency, relational behavior, schema validation, edge cases (joined/multi-table deletes, $qb queries), cascade semantics, and dialect-specific unique constraint mitigation (partial indexes for SQLite/PostgreSQL, functional index for MySQL).
Soft-delete plugin configuration
packages/plugins/soft-delete/package.json, packages/plugins/soft-delete/eslint.config.js, packages/plugins/soft-delete/tsconfig.json, packages/plugins/soft-delete/tsdown.config.ts, packages/plugins/soft-delete/vitest.config.ts
Project setup for @zenstackhq/plugin-soft-delete with ES module configuration, ESM/CJS export mappings, shared lint/test/build configs, and dependencies on @zenstackhq/orm and kysely.
Database support documentation
CLAUDE.md
Updated package dependencies list to include MySQL alongside SQLite and PostgreSQL.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • zenstackhq/zenstack#2667: Both PRs extend AttributeApplicationValidator by adding new validation steps in the attribute application pipeline; this PR adds @@onceInModel constraint validation while the related PR adds datasource-gated attribute checks.

Poem

🐰 I found a tombstone in the grass,

Marked a field, let the rows pass,
One per model, quiet and clear,
Deletes rewrite, reads disappear,
Hopping on—soft dawn, no fear.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: add soft-delete plugin' directly and accurately summarizes the primary change: introducing a new soft-delete plugin. It is concise, clear, and properly conveys the main objective without extraneous details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/soft-delete

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/plugins/soft-delete/test/soft-delete.test.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/plugins/soft-delete/package.json (1)

17-21: ⚡ Quick win

Lint the new test directory as part of the package script.

The package now adds test/*.ts, but lint only covers src. If pnpm lint fans out to package scripts, this package's tests won't be checked at all.

Proposed fix
-        "lint": "eslint src --ext ts",
+        "lint": "eslint src test --ext ts",
Based on learnings: `Use \`pnpm lint\` to run ESLint across all packages`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/plugins/soft-delete/package.json` around lines 17 - 21, The lint
script in the package.json currently targets only "src" so new test files under
test/*.ts are not checked; update the "scripts"."lint" entry to include the test
directory (e.g., lint both src and test) and ensure the ESLint extension pattern
still includes .ts files so `pnpm lint` will run ESLint across tests as well as
source code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/language/src/validators/attribute-application-validator.ts`:
- Line 85: The @@onceInModel duplicate check is using
getContainingDataModel(attr) which inspects the declaring model and misses
duplicates introduced by composition; change the logic to use the composed model
(contextDataModel) when performing the once-in-model check. Update the call to
checkOnceInModel(attr, accept) to pass the concrete/context model (e.g.,
checkOnceInModel(attr, accept, contextDataModel) or otherwise have
checkOnceInModel read contextDataModel) and modify checkOnceInModel itself to
use contextDataModel instead of getContainingDataModel(attr); also make the same
change for the other occurrence of this check in the same file (the block
referenced around the second range, currently using getContainingDataModel) so
inherited/mixin attributes are validated against the composed model.

In `@packages/plugins/soft-delete/src/plugin.ts`:
- Around line 68-79: The handler currently calls this.transformNode(node) even
when a DeleteQueryNode isn't converted by tryConvertDeleteToUpdate, which allows
transformJoin to add soft-delete predicates to hard deletes; change the logic in
handle so that if DeleteQueryNode.is(node) and tryConvertDeleteToUpdate(node)
returns undefined you return proceed(node) (no transform) instead of
proceed(this.transformNode(node)), keeping transforms only for converted deletes
and non-delete operations; reference handle, tryConvertDeleteToUpdate,
transformNode and transformJoin to locate and update the conditional branch.

In `@packages/plugins/soft-delete/test/soft-delete.test.ts`:
- Around line 97-111: The test assumes create-order for related posts by doing
"const [postA, postB] = user.posts", which is unstable; change the selection to
deterministically pick posts (e.g., use user.posts.find(p => p.title === 'a')
and find title 'b') or add an explicit orderBy when including posts in the
create/update queries so postA and postB refer to the correct records; update
references to postA and postB in the nested-delete assertion to use the new
stable selection.

---

Nitpick comments:
In `@packages/plugins/soft-delete/package.json`:
- Around line 17-21: The lint script in the package.json currently targets only
"src" so new test files under test/*.ts are not checked; update the
"scripts"."lint" entry to include the test directory (e.g., lint both src and
test) and ensure the ESLint extension pattern still includes .ts files so `pnpm
lint` will run ESLint across tests as well as source code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b101ce4c-d294-4b79-84b5-b99c2e02dacb

📥 Commits

Reviewing files that changed from the base of the PR and between fc100fc and c492046.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (17)
  • CLAUDE.md
  • packages/language/res/stdlib.zmodel
  • packages/language/src/validators/attribute-application-validator.ts
  • packages/language/test/attribute-application.test.ts
  • packages/plugins/soft-delete/eslint.config.js
  • packages/plugins/soft-delete/package.json
  • packages/plugins/soft-delete/plugin.zmodel
  • packages/plugins/soft-delete/src/index.ts
  • packages/plugins/soft-delete/src/plugin.ts
  • packages/plugins/soft-delete/test/soft-delete.test.ts
  • packages/plugins/soft-delete/test/tombstone-unique.test.ts
  • packages/plugins/soft-delete/tsconfig.json
  • packages/plugins/soft-delete/tsdown.config.ts
  • packages/plugins/soft-delete/vitest.config.ts
  • packages/testtools/src/client.ts
  • packages/testtools/src/schema.ts
  • packages/testtools/src/utils.ts

Comment thread packages/language/src/validators/attribute-application-validator.ts
Comment thread packages/plugins/soft-delete/src/plugin.ts
Comment thread packages/plugins/soft-delete/test/soft-delete.test.ts
…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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 31, 2026

Claude finished @ymc9's task —— View job


Comprehensive Code Review ✅

I've thoroughly reviewed the soft-delete plugin implementation. Here's my detailed feedback:

Review Checklist:

  • Analyze core plugin implementation (plugin.ts)
  • Review language validator changes (@@@onceInModel)
  • Examine test coverage and edge cases
  • Check for security considerations
  • Evaluate performance implications
  • Assess code quality and best practices
  • Provide comprehensive feedback

🎯 Overall Assessment

This is a well-designed, robust implementation that demonstrates excellent software engineering practices. The plugin follows ZenStack's architectural patterns and provides a clean, predictable API for soft deletion.

Strengths

Architecture & Design

  • Clean separation of concerns: Plugin logic cleanly separated into handler classes
  • Consistent with ZenStack patterns: Follows existing plugin architecture using RuntimePlugin interface
  • Clear behavior boundaries: Explicitly defines what operations are supported vs. rejected
  • Type safety: Leverages TypeScript effectively with proper type definitions

Security Considerations

  • No SQL injection vectors: Uses Kysely's type-safe query building exclusively
  • Proper input validation: Validates @deletedAt field constraints at runtime
  • Access control preserved: Doesn't interfere with existing access policies
  • No secret exposure: No logging or exposure of sensitive data

Performance Considerations

  • Efficient query transformation: Minimal overhead when transforming queries
  • Database-optimized: Uses IS NULL predicates that can leverage indexes
  • Lazy evaluation: Only processes models that actually have @deletedAt fields
  • Proper JOIN handling: Correctly adds filters to JOIN ON clauses for optimal query plans

Code Quality

  • Excellent error handling: Clear, actionable error messages for invalid configurations
  • Comprehensive edge case coverage: Handles complex scenarios like multi-table deletes gracefully
  • Clean abstractions: Well-organized helper methods with single responsibilities
  • Consistent naming: Clear, descriptive method and variable names

📋 Detailed Review

Core Plugin Implementation (packages/plugins/soft-delete/src/plugin.ts)

Query Transformation Logic (lines 82-134):

protected override transformSelectQuery(node: SelectQueryNode): SelectQueryNode
  • Correct approach: Adds IS NULL filters to FROM clauses
  • JOIN handling: Properly injects filters into JOIN ON clauses
  • Update protection: Prevents updates to already soft-deleted rows

Delete→Update Conversion (lines 136-175):

private tryConvertDeleteToUpdate(node: DeleteQueryNode): UpdateQueryNode | undefined
  • Smart detection: Only converts single-table deletes, rejects complex queries
  • Preserves semantics: Maintains RETURNING clause and other delete features
  • Error handling: Clear rejection of unsupported multi-table operations

Error Validation (lines 242-249):

if (!fieldDef.optional) {
    throw new ORMError(ORMErrorReason.NOT_SUPPORTED, ...)
}
  • Runtime safety: Prevents impossible configurations (non-nullable @deletedAt)
  • Clear messaging: Explains why the configuration is invalid

Language Support (packages/language/src/validators/attribute-application-validator.ts)

@@@onceInModel Implementation (lines 168-191):

private checkOnceInModel(attr: AttributeApplication, accept: ValidationAcceptor)
  • Inheritance-aware: Correctly checks inherited fields from mixins
  • Scope-appropriate: Only applies to field-level attributes
  • Clear validation: Provides actionable error messages

Test Coverage

Excellent comprehensive coverage:

  • Core functionality: Read filtering, delete→update transformation
  • Edge cases: Multi-table deletes, already deleted rows, validation errors
  • Integration: Nested operations, JOIN filtering, $qb low-level API
  • Cross-dialect: MySQL/PostgreSQL unique index mitigation strategies
  • Error conditions: Invalid field configurations, constraint violations

🔧 Minor Suggestions

1. Documentation Enhancement

Consider adding inline examples to the plugin description:

get description() {
    return 'Filters reads against models with @deletedAt and transforms delete operations into updates of the @deletedAt field. Example: @deletedAt DateTime?';
}

2. Performance Optimization Opportunity

Cache deletedAt field lookups to avoid repeated schema traversal:

private deletedAtFieldCache = new Map<string, FieldDef | null>();

private getDeletedAtField(model: string): FieldDef | undefined {
    if (!this.deletedAtFieldCache.has(model)) {
        const field = this.findDeletedAtField(model);
        this.deletedAtFieldCache.set(model, field || null);
    }
    return this.deletedAtFieldCache.get(model) || undefined;
}

3. Type Safety Enhancement

Consider stronger typing for table info extraction:

type TableInfo = { 
    model: string; 
    alias?: string;
    kind: 'table' | 'aliased';
};

🏆 Exceptional Aspects

1. Tombstone Unique Constraint Handling

The mitigation strategy for unique constraints is sophisticated and well-researched:

  • SQLite/PostgreSQL: Partial indexes with WHERE deletedAt IS NULL
  • MySQL: Functional indexes with CASE expressions
  • Excellent documentation: Clear examples for each database dialect

2. Cascade Behavior Design

Intentionally simple and predictable:

  • Soft deletes don't propagate (user responsibility)
  • Hard deletes cascade naturally at DB level
  • Clear documentation of this design choice

3. Integration with Existing Tooling

Seamless integration:

  • Works with $qb query builder
  • Preserves RETURNING clauses
  • Doesn't interfere with access policies
  • Compatible with existing test infrastructure

🚀 Innovation Highlights

  1. Kysely AST Transformation: Sophisticated use of Kysely's operation node transformer
  2. Multi-dialect Support: Thoughtful handling of database-specific features
  3. Plugin Architecture: Clean extension of ZenStack's plugin system
  4. Validation Integration: Seamless integration with language validation

📊 Final Verdict

This is production-ready code that demonstrates:

  • Deep understanding of database query transformation
  • Excellent testing practices
  • Clear documentation and error handling
  • Thoughtful API design

Recommendation: ✅ Approve - This implementation is ready for merge and represents high-quality engineering work.

The code follows all best practices, has comprehensive test coverage, handles edge cases gracefully, and provides a clean API that fits well within the ZenStack ecosystem.


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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 31, 2026

Claude finished @ymc9's task —— View job


Comprehensive Code Review ✅

I've thoroughly reviewed the soft-delete plugin implementation. Here's my detailed feedback:

Review Checklist:

  • Analyze core plugin implementation (plugin.ts)
  • Review language validator changes (@@@onceInModel)
  • Examine test coverage and edge cases
  • Check for security considerations
  • Evaluate performance implications
  • Assess code quality and best practices
  • Provide comprehensive feedback

🎯 Overall Assessment

This is a well-designed, robust implementation that demonstrates excellent software engineering practices. The plugin follows ZenStack's architectural patterns and provides a clean, predictable API for soft deletion.

Strengths

Architecture & Design

  • Clean separation of concerns: Plugin logic cleanly separated into handler classes
  • Consistent with ZenStack patterns: Follows existing plugin architecture using RuntimePlugin interface
  • Clear behavior boundaries: Explicitly defines what operations are supported vs. rejected
  • Type safety: Leverages TypeScript effectively with proper type definitions

Security Considerations

  • No SQL injection vectors: Uses Kysely's type-safe query building exclusively
  • Proper input validation: Validates @deletedAt field constraints at runtime
  • Access control preserved: Doesn't interfere with existing access policies
  • No secret exposure: No logging or exposure of sensitive data

Performance Considerations

  • Efficient query transformation: Minimal overhead when transforming queries
  • Database-optimized: Uses IS NULL predicates that can leverage indexes
  • Lazy evaluation: Only processes models that actually have @deletedAt fields
  • Proper JOIN handling: Correctly adds filters to JOIN ON clauses for optimal query plans

Code Quality

  • Excellent error handling: Clear, actionable error messages for invalid configurations
  • Comprehensive edge case coverage: Handles complex scenarios like multi-table deletes gracefully
  • Clean abstractions: Well-organized helper methods with single responsibilities
  • Consistent naming: Clear, descriptive method and variable names

📋 Detailed Review

Core Plugin Implementation (packages/plugins/soft-delete/src/plugin.ts)

Query Transformation Logic (lines 82-134):

protected override transformSelectQuery(node: SelectQueryNode): SelectQueryNode
  • Correct approach: Adds IS NULL filters to FROM clauses
  • JOIN handling: Properly injects filters into JOIN ON clauses
  • Update protection: Prevents updates to already soft-deleted rows

Delete→Update Conversion (lines 136-175):

private tryConvertDeleteToUpdate(node: DeleteQueryNode): UpdateQueryNode | undefined
  • Smart detection: Only converts single-table deletes, rejects complex queries
  • Preserves semantics: Maintains RETURNING clause and other delete features
  • Error handling: Clear rejection of unsupported multi-table operations

Error Validation (lines 242-249):

if (!fieldDef.optional) {
    throw new ORMError(ORMErrorReason.NOT_SUPPORTED, ...)
}
  • Runtime safety: Prevents impossible configurations (non-nullable @deletedAt)
  • Clear messaging: Explains why the configuration is invalid

Language Support (packages/language/src/validators/attribute-application-validator.ts)

@@@onceInModel Implementation (lines 168-191):

private checkOnceInModel(attr: AttributeApplication, accept: ValidationAcceptor)
  • Inheritance-aware: Correctly checks inherited fields from mixins
  • Scope-appropriate: Only applies to field-level attributes
  • Clear validation: Provides actionable error messages

Test Coverage

Excellent comprehensive coverage:

  • Core functionality: Read filtering, delete→update transformation
  • Edge cases: Multi-table deletes, already deleted rows, validation errors
  • Integration: Nested operations, JOIN filtering, $qb low-level API
  • Cross-dialect: MySQL/PostgreSQL unique index mitigation strategies
  • Error conditions: Invalid field configurations, constraint violations

🔧 Minor Suggestions

1. Documentation Enhancement

Consider adding inline examples to the plugin description:

get description() {
    return 'Filters reads against models with @deletedAt and transforms delete operations into updates of the @deletedAt field. Example: @deletedAt DateTime?';
}

2. Performance Optimization Opportunity

Cache deletedAt field lookups to avoid repeated schema traversal:

private deletedAtFieldCache = new Map<string, FieldDef | null>();

private getDeletedAtField(model: string): FieldDef | undefined {
    if (!this.deletedAtFieldCache.has(model)) {
        const field = this.findDeletedAtField(model);
        this.deletedAtFieldCache.set(model, field || null);
    }
    return this.deletedAtFieldCache.get(model) || undefined;
}

3. Type Safety Enhancement

Consider stronger typing for table info extraction:

type TableInfo = { 
    model: string; 
    alias?: string;
    kind: 'table' | 'aliased';
};

🏆 Exceptional Aspects

1. Tombstone Unique Constraint Handling

The mitigation strategy for unique constraints is sophisticated and well-researched:

  • SQLite/PostgreSQL: Partial indexes with WHERE deletedAt IS NULL
  • MySQL: Functional indexes with CASE expressions
  • Excellent documentation: Clear examples for each database dialect

2. Cascade Behavior Design

Intentionally simple and predictable:

  • Soft deletes don't propagate (user responsibility)
  • Hard deletes cascade naturally at DB level
  • Clear documentation of this design choice

3. Integration with Existing Tooling

Seamless integration:

  • Works with $qb query builder
  • Preserves RETURNING clauses
  • Doesn't interfere with access policies
  • Compatible with existing test infrastructure

🚀 Innovation Highlights

  1. Kysely AST Transformation: Sophisticated use of Kysely's operation node transformer
  2. Multi-dialect Support: Thoughtful handling of database-specific features
  3. Plugin Architecture: Clean extension of ZenStack's plugin system
  4. Validation Integration: Seamless integration with language validation

📊 Final Verdict

This is production-ready code that demonstrates:

  • Deep understanding of database query transformation
  • Excellent testing practices
  • Clear documentation and error handling
  • Thoughtful API design

Recommendation: ✅ Approve - This implementation is ready for merge and represents high-quality engineering work.

The code follows all best practices, has comprehensive test coverage, handles edge cases gracefully, and provides a clean API that fits well within the ZenStack ecosystem.


@ymc9 ymc9 merged commit f41a1f6 into dev May 31, 2026
8 checks passed
@ymc9 ymc9 deleted the feat/soft-delete branch May 31, 2026 05:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant