Skip to content

Add trash listing, purge and the retention purge job - #2760

Merged
pournasserian merged 2 commits into
mainfrom
feat/2696-trash-purge
Nov 3, 2026
Merged

pournasserian merged 2 commits into
mainfrom
feat/2696-trash-purge

Conversation

@pournasserian

Copy link
Copy Markdown
Member

Closes #2696

Adds the trash list, purge and the retention job on top of the delete and restore that came with #2747. IContentService and IContentService<T> get QueryTrashAsync and PurgeAsync; ContentService.PurgeAsync deletes the content rows, references and every version of an entry (published ones included) and raises EntryPurgedEvent per locale; the recurring job content.trash.purge calls it for what is past each type's retention and logs a report. No system migration: deleted_at and deleted_by were already on cms_document, and nothing in the system model changed.

Dependencies: none.

Acceptance

Under Q19, everything here is SQLite only until #2711.

  • Trashed entries disappear from queries and reappear on restore with their versions intact. TrashTests.ATrashedEntryLeavesEveryReadAndComesBackWithItsVersions: draft and published get and query, version list and version read, publish; after restore the version rows, their snapshots and status are identical.
  • Purge requires the purge permission and removes all rows. TrashTests.PurgeNeedsItsOwnPermission (Editor, Author, Anonymous and a restore-only role are refused; Super Admin purges) and PurgeDeletesEveryRowOfAnEntryInEveryLocaleAndEveryVersion (document, shared and locale content rows, references and versions, in two locales with published versions; another entry's rows are untouched).
  • The purge job removes only documents past retention and logs a report. TrashPurgeJobTests.TheJobPurgesOnlyWhatIsPastItsRetentionAndLogsAReport (31 days purged, 29 days, today and a live entry stay; the report is logged and a second run is a no-op), EachTypeKeepsItsTrashForItsOwnPeriod, TheJobRunsThroughTheRunnerAndItsScheduleIsStoredOnceHoweverOftenItStarts.

Design

  • List trash reads the trash as drafts through the query engine, in one locale, so the read grant, its row rule, its locale and field limits and the site all hold, and so do filter, sort, select, populate and cursor paging. It needs restore or purge on the type, and lists only what the principal may read. Each entry carries deletedAt, and deletedBy where the principal may read who changed an entry.
  • The query filter on cms_document is off for that whole query (EF cannot turn it off for the root alone), so every row reached through a relation says DeletedAt == null explicitly, in QueryScope.Rows and in the in-site check of FilterCompiler. A trashed target never matches a filter or a row rule and is never populated. TrashTests.ATargetInTheTrashNeverMatchesAFilterOrFillsAPopulate fails without that guard (checked by removing it).
  • Purge is ContentService.PurgeAsync, a write like delete and restore: InTransactionAsync, the permission on the type, ReachAllLocalesAsync with the purge operation, so the row rule, locale limit and site of the purge grant decide. A live entry, another site's or type's, and one the rule excludes are all EntryNotFoundException with the same message; one reached in only some locales is ContentAccessDeniedException, as for restore. The event is staged in the transaction and delivered after the commit; a rolled-back purge, even in a transaction the caller began, leaves every row.
  • References to a purged entry from other entries are those entries' field values and stay. A read leaves out a target that is not there, and publishing warns with the same FCMS2404 as for the trashed one (APurgedTargetIsAsMissingAsATrashedOneWasForPublishing).
  • Retention is per type: [PublishPolicy(TrashRetentionDays = n)], ITypeBuilder.PublishPolicy(trashRetentionDays: n), trashRetentionDays in schema JSON; default 30, least 1. Like maxVersions it is written to JSON only when not the default, so no existing model changes its key.
  • The job runs daily at 03:17 UTC, per type and site, through PurgeAsync as Principal.System with Origin.Job, one transaction per entry, at most 500 per type per run (Limit in the payload). An entry that fails is logged and left for the next run without holding up the others (AnEntryThatCannotBePurgedStaysAndDoesNotStopTheOthers), a run with nothing due does nothing, and the report logs how many were purged and failed.
  • The schedule is stored inline while the host starts and retried in the background only if that fails (AScheduleThatCannotBeStoredAtStartupIsRetriedUntilItIs). A first version that always registered in a background task left the SQLite file locked for host tests that dispose a started host without stopping it (SchemaSyncTests), which is why it is inline.

Security

All from the low-privilege side, in TrashTests:

  • Trashed entries are absent from get, query (draft and published), version list and read, and publish; uniqueness still counts the trash until a purge frees the value (OnlyAPurgeFreesTheValuesTheTrashHolds).
  • Listing the trash needs restore or purge and the read: a principal with only the read gets ContentAccessDeniedException, one with restore but not the read gets the ValidationException an unknown type gets, identical in path and code (OnlyWhoCanRestoreOrPurgeAndReadMayLookAtTheTrash).
  • The list holds the read row rule and field limits (TheTrashListHoldsTheReadRowRuleAndTheFieldLimits), the site and the locale grant, and refuses every locale at once (TheTrashOfAnotherSiteOrLocaleIsNotListed).
  • Purge: the row rule of the grant gives the same EntryNotFoundException as a missing id and leaves the entry in the trash (AnEntryThePurgeRuleExcludesIsNotFoundWhateverTheIdIsAndStaysInTheTrash); the locale and site limits (PurgeNeedsTheGrantOnEveryLocaleOfTheEntryAndTheSiteOfIt); only an entry in the trash, of that type (OnlyAnEntryInTheTrashCanBePurged).

Verification

On Windows: dotnet build -c Release -warnaserror, dotnet test -c Release and dotnet pack -c Release pass; markdownlint-cli2 on the spec found nothing. The two SchemaSyncTests failures described above showed up in a first full run, were traced to the background schedule by disabling the service, and are gone after the change (three repeat runs).

Decisions worth a look

  • List trash requires the read as well as restore or purge, and lists what the read lets the principal see, rather than what restore or purge could reach. A purge-only role that cannot read gets the unknown-type error. The alternative (listing by the restore or purge rule) would show entries and ids the principal cannot read.
  • Purge of an entry that is not in the trash is not found; delete first.
  • Incoming references are left alone (see above).
  • The IContentService surface gains two members on both interfaces, FluentCMS.Testing's scoped services forward them, and ITypeBuilder.PublishPolicy gains an optional parameter, all in PublicAPI.Unshipped.txt.
  • Spec 4.4 and 4.5 are updated with the contract as built.

Known gaps

@pournasserian
pournasserian merged commit 0c249a3 into main Nov 3, 2026
5 checks passed
@pournasserian
pournasserian deleted the feat/2696-trash-purge branch November 3, 2026 14:16
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.

Trash with retention and purge job

1 participant