Skip to content

Soft Delete

When you DELETE a document on a Craft Easy resource, the document is not removed from MongoDB. Instead, three fields are set — is_deleted, deleted_at, deleted_by — and every subsequent query automatically filters it out. The row stays in the database until a cleanup job purges it days or months later.

This matches the behaviour most line-of-business systems need: deletes are reversible, audit trails stay intact, foreign references do not dangle, and GDPR erasure is a deliberate secondary step.

What happens on DELETE

When a caller issues DELETE /products/{id}:

  1. The Resource layer verifies the If-Match header (see ETag Concurrency).
  2. BEFORE_DELETE auto-hooks run — cascade deny rules block the operation if any subscribers exist.
  3. The document is updated in place with is_deleted=true, deleted_at=now, and deleted_by=<current user>.
  4. AFTER_DELETE auto-hooks run — cascade null/delete rules fire, and the audit log records the deletion with the pre-delete snapshot.

The response is identical to a hard delete from the caller's perspective — the row is gone as far as normal queries are concerned.

Automatic filtering

Every list and get endpoint transparently filters out soft-deleted rows. You never see them in:

  • GET /products and any filter combination.
  • GET /products/{id} — returns 404 for a soft-deleted row.
  • Cascade traversals and reference lookups.
  • The admin app, which just uses the same endpoints.

The filter is applied in the CRUD layer by adding is_deleted: {$ne: true} to the MongoDB query:

# Equivalent to what the list endpoint does internally
filters["is_deleted"] = {"$ne": True}

Writing {$ne: true} instead of {$ne: false} matters — documents created before soft-delete existed may be missing the field entirely, and $ne: true matches both false and missing.

Viewing deleted documents

Callers with the system scope can include soft-deleted rows by passing include_deleted=true:

curl -H "Authorization: Bearer $SYSTEM_TOKEN" \
  "https://api.example.com/products?include_deleted=true"

The flag is ignored for any other scope — normal users, admins, and tenant-scoped callers cannot see deleted data regardless of the query parameter. This is enforced in the Resource's list handler:

if include_deleted and resource_self._get_scope(request) == "system":
    pass  # No filter — show all including deleted
else:
    filters["is_deleted"] = {"$ne": True}

Undelete

Soft-deleted rows can be restored by clearing the tombstone fields. Most resources expose this via a dedicated POST /{resource}/{id}/undelete endpoint that:

  1. Verifies the caller has permission to see deleted rows (typically system scope).
  2. Rejects the call if the row is not actually deleted.
  3. Sets is_deleted=false and deleted_at=null.
  4. Returns the restored document with a fresh ETag.

Undelete respects cascade rules — it does not restore children that were cascade-deleted along with the parent. If you need to restore a tree, restore each level explicitly.

Permanent deletion (purge)

Soft delete is the default, but the data does not stay forever. Two mechanisms permanently remove tombstoned rows:

1. The purge job

A scheduled job walks each tenant-scoped collection and deletes rows where deleted_at < now - SOFT_DELETE_RETENTION_DAYS. Default retention is 90 days. Tune it per-deployment with the SOFT_DELETE_RETENTION_DAYS setting:

SOFT_DELETE_RETENTION_DAYS=30   # Shorter retention
SOFT_DELETE_RETENTION_DAYS=365  # Longer retention for audit-heavy environments

2. GDPR erasure

A user requesting GDPR erasure triggers an immediate hard delete of every document linked to them, bypassing the retention window. This is a separate workflow and must be enabled with GDPR_ENABLED=true.

Turning soft delete off

Set SOFT_DELETE_ENABLED=false in settings to disable soft delete globally. With the flag off, DELETE requests hard-delete the row from MongoDB. Cascade and audit behaviour are unchanged.

You can also disable soft delete per-resource by passing soft_delete=False to the Resource constructor:

app.register_resource(Resource(TempCache, path="/cache", soft_delete=False))

This is useful for short-lived collections where retention adds nothing (caches, idempotency keys, rate-limit state).

The tombstone fields

The soft-delete contract is three fields on every document:

Field Type Populated when
is_deleted bool Always present. false by default.
deleted_at datetime \| None Set to now() when the row is deleted.
deleted_by ObjectId \| None Set to the current user's id when the row is deleted.

Querying these directly from a script is fine — the fields are stable and documented. But for normal reads, use the API and let the framework apply the filter for you.

Why soft delete by default

The three alternatives (hard delete, event sourcing, archival tables) all have their place, but soft delete is the best default for line-of-business systems because:

  • Mistakes are reversible. A user fat-fingers a delete, support restores the row in seconds.
  • References stay sane. A cascade null that points at a deleted row can still read its deleted_at to show "deleted on 5 Apr" instead of nothing.
  • Audit trails are complete. The audit log references the row, and the row is still there when auditors ask.
  • GDPR is an explicit step. Hard deletes should require intent, not just a stray DELETE button.

If these trade-offs do not fit your domain, turn soft delete off — the rest of Craft Easy works the same way.

Source

  • craft_easy/core/crud/resource.py — the DELETE handler that sets is_deleted/deleted_at, the LIST handler that filters by is_deleted, and the include_deleted logic gated on system scope.
  • craft_easy/settings.pySOFT_DELETE_ENABLED, SOFT_DELETE_RETENTION_DAYS.
  • craft_easy/core/models.pyis_deleted, deleted_at, deleted_by fields on TenantScopedDocument.