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}:
- The Resource layer verifies the
If-Matchheader (see ETag Concurrency). BEFORE_DELETEauto-hooks run — cascadedenyrules block the operation if any subscribers exist.- The document is updated in place with
is_deleted=true,deleted_at=now, anddeleted_by=<current user>. AFTER_DELETEauto-hooks run — cascadenull/deleterules 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 /productsand any filter combination.GET /products/{id}— returns404for 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:
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:
- Verifies the caller has permission to see deleted rows (typically
systemscope). - Rejects the call if the row is not actually deleted.
- Sets
is_deleted=falseanddeleted_at=null. - 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:
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
nullthat points at a deleted row can still read itsdeleted_atto 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
DELETEbutton.
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 setsis_deleted/deleted_at, the LIST handler that filters byis_deleted, and theinclude_deletedlogic gated onsystemscope.craft_easy/settings.py—SOFT_DELETE_ENABLED,SOFT_DELETE_RETENTION_DAYS.craft_easy/core/models.py—is_deleted,deleted_at,deleted_byfields onTenantScopedDocument.