diff --git a/docs/planning/backend-technical-spec.md b/docs/planning/backend-technical-spec.md index 1a822c292..fa8035988 100644 --- a/docs/planning/backend-technical-spec.md +++ b/docs/planning/backend-technical-spec.md @@ -465,6 +465,7 @@ Rows 2.3 to 2.8, 2.14, 2.15, 2.19, 1.7, 1.8. | `PatchAsync(id, patch, expectedVersion)` | partial update of the draft with a JSON merge patch; a null removes a field. Its own method, because a class cannot express absence | | `DeleteAsync(id)` | trash, in every locale | | `RestoreAsync(id)` | from trash, in every locale | +| `QueryTrashAsync(ContentQuery)`, `PurgeAsync(id)` | list the trash; delete an entry in it for good (4.5) | | `PublishAsync(id, locale?, expectedVersion?)`, `UnpublishAsync(id, locale?)`, `DiscardDraftAsync(id, locale?, expectedVersion?)` | publishing transitions (4.5); publish returns the entry and `warnings` | | `ScheduleAsync(id, ScheduleRequest)`, `CancelScheduleAsync` | per locale | | `GetVersionsAsync(id, locale?)`, `GetVersionAsync(id, number, locale?)`, `RestoreVersionAsync(id, number, locale?, expectedVersion?)`, `DiffAsync(id, a, b, locale?)` | versions | @@ -556,6 +557,10 @@ Rows 6.1 to 6.11; decisions 38, 39, 40, 41. **Versions.** Every save writes an immutable snapshot (decision 38) with author, origin, client id and an optional message. A per-type cap (default 50, `[PublishPolicy(MaxVersions = n)]`) prunes oldest drafts when a version is written; versions that were published are never pruned, and only a publish marks one so, in `status_after`. Diff is computed on demand as a field-level structural diff (blocks and rich text compared as trees) and returned as a list of `{ path, kind, before, after }`. A snapshot holds hidden fields, so reads and diffs go through the reader's field limits as they are today, and need the Management scope as well as the draft read. Access to versions is decided on the entry's current row: history is part of the entry, so a reader the row rule lets in also sees versions from before the entry came within the rule. A restore is a write of the principal's own: what it may not write or read, and what is hidden or read-only, keeps the value the draft holds, except for the system principal. A principal that can write a field it cannot read may not discard or restore at all, whatever that field holds, because the status and version those answer with would check a saved guess against the value. +**Trash.** `DeleteAsync` sets `deleted_at` and `deleted_by` on every locale of the entry, and the global query filter on `cms_document` hides it from reads, queries, versions, publishing and reference checks; the content rows, references and versions stay as they were, so a restore brings back the entry as it was. `QueryTrashAsync` 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 hold; it needs `restore` or `purge` on the type and lists only what the principal may read. Every entry carries `deletedAt`, and `deletedBy` where the principal may read who changed an entry. The query filter is off for that whole query, so every row reached through a relation says explicitly that it is not in the trash: a trashed target never matches a filter or a row rule and is never populated. A filter on the id of a relation matches only a target the same read would show: of that type, not in the trash, in the site of a site-scoped type, and published on a published read. `PurgeAsync` needs `content.{type}.purge`, deletes the content rows of every state and locale, the references the entry holds and every version of it, published ones included, and raises `EntryPurgedEvent` per locale. It follows the rules of restore: an entry that is not in the trash, in another site or type, or refused by the row rule of the grant is `EntryNotFoundException`, the same for all of them; one the principal reaches in some locales only is `ContentAccessDeniedException`. References from other entries to a purged one are the values of their fields and stay; a read leaves out a target that is not there, and publishing warns with `FCMS2404` as it did for the trashed one. The values the entry held are free again, since uniqueness counted the trash. + +**Retention.** `[PublishPolicy(TrashRetentionDays = n)]` (`PublishPolicy(trashRetentionDays: n)` on the builder, `trashRetentionDays` in schema JSON) sets how long a type keeps its trash; the default is 30 days, the least 1 and the most 36500. The job `content.trash.purge` runs daily at 03:17 UTC from a recurring schedule stored at every startup. Each run purges, per type and site, the entries trashed longer ago than the type keeps them, through `PurgeAsync` as `Principal.System` with `Origin.Job`, one transaction per entry, at most 500 purges per type per run (`Limit` in the payload). Due entries are read oldest first in pages (`PageSize`), so an entry that fails is logged and left for the next run without using up the limit or hiding those behind it, and a type that fails is logged while the others still run. The purge checks inside its transaction that the entry went to the trash before the cutoff, so one trashed again since is left alone. A run logs the number purged and failed. A run that finds nothing due does nothing, so it is safe to run again. + **Reference integrity.** On publish, references to unpublished or trashed entries produce warnings in the response (`warnings[]`) by default; `[PublishPolicy(UnpublishedReferences = Block)]` on a type turns them into a 422. A target counts as published when it has a published row in the entry's locale or its fallback chain. A self-reference is exempt. A cycle is not: under `Block` neither side can go first, and the way out is to publish one side without the reference and set it afterwards. The answer must not be a way to ask about an entry the principal may not read, since a write takes any id: diff --git a/src/FluentCMS.Core/Content/ContentService.Trash.cs b/src/FluentCMS.Core/Content/ContentService.Trash.cs new file mode 100644 index 000000000..a7fe6547a --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentService.Trash.cs @@ -0,0 +1,94 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Core.Content.Query; +using FluentCMS.Localization; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content; + +internal sealed partial class ContentService +{ + public Task> QueryTrashAsync(string type, ContentQuery query, CancellationToken cancellationToken = default) => + QueryTrashAsync(type, query, everySystemField: false, cancellationToken); + + // The trash is read like drafts are: by the engine, so the read grant, its row rule, its locale and field limits and + // the site all hold. Restore or purge on the type is what lets a principal look. What it cannot read is not listed, + // and a type it cannot read answers as an unknown one does. + public async Task> QueryTrashAsync(string typeName, ContentQuery query, bool everySystemField, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(typeName); + ArgumentNullException.ThrowIfNull(query); + + var operation = Current(); + var type = schema.FindContentType(typeName) + ?? throw new ValidationException([new ValidationError("type", QueryErrorCodes.UnknownField, $"'{typeName}' is not a content type that can be read.")]); + + var site = query.Site ?? operation.Site; + var locale = await ResolveLocaleAsync(site, query.Locale ?? operation.Locale, cancellationToken); + if (locale == ILocaleResolver.All) + { + throw new ValidationException([new ValidationError("locale", QueryErrorCodes.InvalidParameter, "The trash is read in one locale.")]); + } + + var target = new AccessTarget(type.Scope == ContentScope.Global ? operation.Site : site, locale); + var permissions = await evaluator.ResolveAsync(operation, cancellationToken); + if (!permissions.Allows(type, ContentOperation.Restore, target) && !permissions.Allows(type, ContentOperation.Purge, target)) + { + if (!permissions.Allows(type, ContentOperation.Read, target)) + { + throw new ValidationException([new ValidationError("type", QueryErrorCodes.UnknownField, $"'{typeName}' is not a content type that can be read.")]); + } + + throw new ContentAccessDeniedException(type.Name, CorePermissions.OperationName(ContentOperation.Restore)); + } + + var page = await ReadAsync(typeName, query with { Fallback = LocaleFallback.None }, null, everySystemField, cancellationToken, trashed: true); + if (page.Items.Count == 0) + { + return page; + } + + var marks = await store.TrashMarksAsync([.. page.Items.Select(entry => (Guid)entry["id"]!)], type.Localized ? locale : "", cancellationToken); + var namesPeople = permissions.CanReadField(type, "updatedBy", target); + foreach (var entry in page.Items) + { + if (marks.TryGetValue((Guid)entry["id"]!, out var mark)) + { + entry["deletedAt"] = mark.At; + if (namesPeople) + { + entry["deletedBy"] = mark.By; + } + } + } + + return page; + } + + // Only an entry in the trash can be purged, so a live one is not found, and one the row rule of the purge grant + // excludes is not found either, whatever the id. Every version goes with it, published ones included. + public Task PurgeAsync(string type, Guid id, CancellationToken cancellationToken = default) => + PurgeAsync(type, id, trashedBefore: null, cancellationToken); + + // The purge job passes the cutoff it listed the entry by, and an entry put in the trash again since is not found. + internal async Task PurgeAsync(string type, Guid id, DateTimeOffset? trashedBefore, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(type); + + var write = await BeginAsync(type, ContentOperation.Purge, cancellationToken); + await InTransactionAsync( + async () => + { + var (documents, row, _) = await ReachAllLocalesAsync(write, ContentOperation.Purge, id, trashed: true, cancellationToken); + + if (await store.PurgeAsync(write.Type, id, trashedBefore, cancellationToken) != documents.Count) + { + throw new EntryNotFoundException(write.Type.Name, id); + } + + _pending.AddRange(documents.Select(document => new EntryChange(EntryChangeKind.Purged, write.Type.Name, id, document.Locale, document.DraftVersion, write.Operation))); + return row; + }, + cancellationToken); + } +} diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs index 652c8c620..460d0e701 100644 --- a/src/FluentCMS.Core/Content/ContentService.cs +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -251,7 +251,7 @@ private async Task ResolveLocaleAsync(SiteId site, string? locale, Cance await locales.ResolveAsync(new LocaleRequest(site) { Parameter = locale }, cancellationToken); // Reads ask the engine, which holds the permission on the type, the row rule, the site and the field limits as one query. - private async Task> ReadAsync(string typeName, ContentQuery query, Guid? id, bool everySystemField, CancellationToken cancellationToken) + private async Task> ReadAsync(string typeName, ContentQuery query, Guid? id, bool everySystemField, CancellationToken cancellationToken, bool trashed = false) { richText.ThrowIfUnknown(query.RichTextFormat); @@ -266,6 +266,11 @@ private async Task> ReadAsync(string typeName, Cont var site = query.Site ?? operation.Site; var locale = await ResolveLocaleAsync(site, query.Locale ?? operation.Locale, cancellationToken); var everyLocale = locale == ILocaleResolver.All; + if (trashed && everyLocale) + { + throw new ValidationException([new ValidationError("locale", QueryErrorCodes.InvalidParameter, "The trash is read in one locale.")]); + } + var oneLocale = everyLocale ? await ResolveLocaleAsync(site, null, cancellationToken) : locale; var bound = query with { Site = site, Locale = oneLocale }; @@ -273,7 +278,9 @@ private async Task> ReadAsync(string typeName, Cont ContentQueryResult? page = null; if (!everyLocale || id is null) { - page = await engine.ExecuteAsync(typeName, bound, everySystemField, cancellationToken); + page = trashed + ? await engine.ExecuteTrashAsync(typeName, bound, everySystemField, cancellationToken) + : await engine.ExecuteAsync(typeName, bound, everySystemField, cancellationToken); found = page.Items; } diff --git a/src/FluentCMS.Core/Content/EntryChange.cs b/src/FluentCMS.Core/Content/EntryChange.cs index b56d95b7a..19fbedc48 100644 --- a/src/FluentCMS.Core/Content/EntryChange.cs +++ b/src/FluentCMS.Core/Content/EntryChange.cs @@ -12,6 +12,7 @@ internal enum EntryChangeKind Unpublished, DraftDiscarded, VersionRestored, + Purged, } internal sealed record EntryChange(EntryChangeKind Kind, string Type, Guid Id, string Locale, int Version, OperationContext Operation) diff --git a/src/FluentCMS.Core/Content/EntryStore.cs b/src/FluentCMS.Core/Content/EntryStore.cs index 1f430081d..c8e1b0ac0 100644 --- a/src/FluentCMS.Core/Content/EntryStore.cs +++ b/src/FluentCMS.Core/Content/EntryStore.cs @@ -172,6 +172,42 @@ public Task SetTrashedAsync(Guid id, DateTimeOffset? at, Guid? by, CancellationT .SetProperty(document => document.DeletedBy, by), cancellationToken); + // When and by whom each of these entries was trashed, in one locale. A trashed row is not read through the query filter. + public async Task> TrashMarksAsync(IReadOnlyCollection ids, string locale, CancellationToken cancellationToken) + { + var marks = await context.Documents.IgnoreQueryFilters().AsNoTracking() + .Where(document => ids.Contains(document.DocumentId) && document.Locale == locale && document.DeletedAt != null) + .Select(document => new { document.DocumentId, document.DeletedAt, document.DeletedBy }) + .ToListAsync(cancellationToken); + + return marks.ToDictionary(mark => mark.DocumentId, mark => (mark.DeletedAt, mark.DeletedBy)); + } + + // Everything an entry holds, in every state and locale. The documents go first and only while they are in the trash + // and, when a cutoff is given, were put there before it, so that an entry trashed again since it was read as due is not + // taken; the caller compares the count with the rows it saw and rolls back on a difference. References to the entry from + // others are theirs: they are the values of those entries' fields, and a read leaves out a target that is not there. + public async Task PurgeAsync(SchemaType type, Guid id, DateTimeOffset? trashedBefore, CancellationToken cancellationToken) + { + var typeName = type.Name; + var documents = context.Documents.IgnoreQueryFilters().Where(document => document.DocumentId == id && document.Type == typeName && document.DeletedAt != null); + if (trashedBefore is { } before) + { + documents = documents.Where(document => document.DeletedAt < before); + } + + var removed = await documents.ExecuteDeleteAsync(cancellationToken); + await context.Versions.Where(version => version.DocumentId == id).ExecuteDeleteAsync(cancellationToken); + await context.References.Where(reference => reference.SourceType == typeName && reference.SourceDocumentId == id).ExecuteDeleteAsync(cancellationToken); + if (type.Localized) + { + await context.Rows(StorageNames.LocaleTable(type)).Where(row => EF.Property(row, StorageNames.DocumentId) == id).ExecuteDeleteAsync(cancellationToken); + } + + await context.Rows(StorageNames.Table(type)).Where(row => EF.Property(row, StorageNames.DocumentId) == id).ExecuteDeleteAsync(cancellationToken); + return removed; + } + // Saves the draft of one locale: the document when it is new, the shared row, the locale row, the references and the // version, in one save, and prunes the versions over the cap of the type. public Task WriteDraftAsync(SchemaType type, Document row, bool isNewDocument, ContentDocument entry, Origin origin, string? clientId, CancellationToken cancellationToken, string? message = null) => diff --git a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs index 86062d898..ba23127c3 100644 --- a/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs +++ b/src/FluentCMS.Core/Content/Query/ContentQueryEngine.cs @@ -33,11 +33,19 @@ public Task ExecuteAsync(string typeName, ContentQuery query // everySystemField is for a content class, which has a property for each of them; a document leaves out the two that // are not in the response shape. Field permissions still decide. - public async Task ExecuteAsync(string typeName, ContentQuery query, bool everySystemField, CancellationToken cancellationToken) + public Task ExecuteAsync(string typeName, ContentQuery query, bool everySystemField, CancellationToken cancellationToken) => + ExecuteAsync(typeName, query, everySystemField, trashed: false, cancellationToken); + + // Reads the entries in the trash as drafts. Whoever calls this has decided that the principal may look at the trash; + // what a draft read is limited by still holds. + public Task ExecuteTrashAsync(string typeName, ContentQuery query, bool everySystemField, CancellationToken cancellationToken) => + ExecuteAsync(typeName, query with { Status = QueryStatus.Draft }, everySystemField, trashed: true, cancellationToken); + + private async Task ExecuteAsync(string typeName, ContentQuery query, bool everySystemField, bool trashed, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - var compiled = Compile(await access.ForQueryAsync(query.Site, query.Locale ?? "", cancellationToken), typeName, query, everySystemField); + var compiled = Compile(await access.ForQueryAsync(query.Site, query.Locale ?? "", cancellationToken), typeName, query, everySystemField, trashed); long? total = compiled.WithTotal ? await compiled.Filtered.LongCountAsync(cancellationToken) : null; var rows = await compiled.Rows.ToListAsync(cancellationToken); @@ -61,12 +69,12 @@ public async Task ExecuteAsync(string typeName, ContentQuery // Separate from running it, so that a test can read the SQL. internal CompiledQuery Compile(string typeName, ContentQuery query) => Compile(access, typeName, query); - private CompiledQuery Compile(IContentQueryAccess access, string typeName, ContentQuery query, bool everySystemField = false) + private CompiledQuery Compile(IContentQueryAccess access, string typeName, ContentQuery query, bool everySystemField = false, bool trashed = false) { ArgumentNullException.ThrowIfNull(query); var state = query.Status == QueryStatus.Draft ? ContentState.Draft : ContentState.Published; - var scope = new QueryScope(context, schema, dialect, access, options, state, query.Locale ?? "", query.Site); + var scope = new QueryScope(context, schema, dialect, access, options, state, query.Locale ?? "", query.Site, trashed); if (schema.FindContentType(typeName) is not { } type || !access.CanReadType(type)) { @@ -84,7 +92,11 @@ private CompiledQuery Compile(IContentQueryAccess access, string typeName, Conte } var row = Expression.Parameter(typeof(QueryRow), "row"); - var rows = scope.Rows(type); + var rows = scope.Rows(type, trashedRoot: trashed); + if (trashed) + { + rows = rows.IgnoreQueryFilters(); + } if (new FilterCompiler(scope).Where(type, query.Filter, row) is { } filter) { diff --git a/src/FluentCMS.Core/Content/Query/FilterCompiler.cs b/src/FluentCMS.Core/Content/Query/FilterCompiler.cs index fc0366d45..e4b5f1385 100644 --- a/src/FluentCMS.Core/Content/Query/FilterCompiler.cs +++ b/src/FluentCMS.Core/Content/Query/FilterCompiler.cs @@ -470,7 +470,7 @@ Expression References(Expression? also) => if (query.Access.RowRule(target) is null) { - return InSite(target, targetId); + return ShownTarget(target, targetId); } var row = Expression.Parameter(typeof(QueryRow), "target"); @@ -479,18 +479,27 @@ Expression References(Expression? also) => return Any(query.Rows(target), row, Expression.AndAlso(Expression.Equal(DocumentId(row), targetId), rule)); } - // A row rule's own condition goes through the rows of the site already, so this is for a target without one. - private MethodCallExpression? InSite(SchemaType target, Expression targetId) + // A filter on the id of a relation matches only a target the same read would show: one of that type that is not in the + // trash, in the site of a site-scoped type, and for a published read published. A row rule's own condition goes through + // the rows of the site already, so this is for a target without one. + private MethodCallExpression ShownTarget(SchemaType target, Expression targetId) { - if (target.Scope != ContentScope.Site || query.Site is not { } site) + var document = Expression.Parameter(typeof(Document), "document"); + Expression shown = Expression.AndAlso( + Expression.Equal(Expression.Property(document, nameof(Document.DocumentId)), targetId), + Expression.Equal(Expression.Property(document, nameof(Document.Type)), Expression.Constant(target.Name))); + shown = Expression.AndAlso(shown, Expression.Equal(Expression.Property(document, nameof(Document.DeletedAt)), Expression.Constant(null, typeof(DateTimeOffset?)))); + if (target.Scope == ContentScope.Site && query.Site is { } site) { - return null; + shown = Expression.AndAlso(shown, Expression.Equal(Expression.Property(document, nameof(Document.SiteId)), Bind(site.Value))); } - var document = Expression.Parameter(typeof(Document), "document"); - return Any(query.Context.Documents, document, Expression.AndAlso( - Expression.Equal(Expression.Property(document, nameof(Document.DocumentId)), targetId), - Expression.Equal(Expression.Property(document, nameof(Document.SiteId)), Bind(site.Value)))); + if (query.State == ContentState.Published) + { + shown = Expression.AndAlso(shown, Expression.NotEqual(Expression.Property(document, nameof(Document.PublishedVersion)), Expression.Constant(null, typeof(int?)))); + } + + return Any(query.Context.Documents, document, shown); } // The target's row in the same state and locale; decision 44 rules out fallback-aware filtering. diff --git a/src/FluentCMS.Core/Content/Query/QueryScope.cs b/src/FluentCMS.Core/Content/Query/QueryScope.cs index 4c2ce5d57..3a2856174 100644 --- a/src/FluentCMS.Core/Content/Query/QueryScope.cs +++ b/src/FluentCMS.Core/Content/Query/QueryScope.cs @@ -7,7 +7,7 @@ namespace FluentCMS.Core.Content.Query; // What one compilation shares: the state, locale and site every joined row must have, and the errors found so far. -internal sealed class QueryScope(CmsDbContext context, SchemaModel schema, IJsonSqlDialect dialect, IContentQueryAccess access, ContentQueryOptions options, short state, string locale, SiteId? site) +internal sealed class QueryScope(CmsDbContext context, SchemaModel schema, IJsonSqlDialect dialect, IContentQueryAccess access, ContentQueryOptions options, short state, string locale, SiteId? site, bool reachesTrash = false) { private readonly List _errors = []; private int _populated; @@ -28,12 +28,25 @@ internal sealed class QueryScope(CmsDbContext context, SchemaModel schema, IJson public SiteId? Site => site; + // The query filter on Document is off for the whole query, because EF cannot turn it off for the root alone. The trash + // is the root's; every row reached through a relation says explicitly that it is not in it. + public bool ReachesTrash => reachesTrash; + public QueryPaths Paths { get; } = new(schema, access); // A site's query reads that site's entries on every level. Global entries belong to no site, and any site may read them. - public IQueryable Rows(SchemaType type) + public IQueryable Rows(SchemaType type, bool trashedRoot = false) { var rows = QueryRows.Of(context, type, state, locale); + if (trashedRoot) + { + rows = rows.Where(row => row.Document.DeletedAt != null); + } + else if (reachesTrash) + { + rows = rows.Where(row => row.Document.DeletedAt == null); + } + if (site is not { } only || type.Scope != ContentScope.Site) { return rows; diff --git a/src/FluentCMS.Core/Content/TrashPurgeJob.cs b/src/FluentCMS.Core/Content/TrashPurgeJob.cs new file mode 100644 index 000000000..4ea49f10d --- /dev/null +++ b/src/FluentCMS.Core/Content/TrashPurgeJob.cs @@ -0,0 +1,189 @@ +using FluentCMS.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Jobs; +using FluentCMS.Schema; +using FluentCMS.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Core.Content; + +// Limit caps what one run purges of each type, so that a run is short; the rest waits for the next. PageSize is how many +// documents are read at a time while looking for what is due. +internal sealed record TrashPurgePayload(int Limit = 500, int PageSize = 100); + +// Purges what has been in the trash for longer than its type keeps it, through the content service so that references, +// versions and events go the way a purge by hand does. An entry that cannot be purged is logged and left for the next +// run, and the others are not held up: the due entries are read oldest first in pages, so those that fail do not use up +// the limit or hide the ones behind them. One type that fails is logged and the others still run. Running again finds +// only what is still due. +internal sealed class TrashPurgeJob(CmsDbContext database, SchemaModel schema, ContentService content, IOperationContextAccessor accessor, TimeProvider time, ILogger logger) : IJob +{ + public const string Type = "content.trash.purge"; + + public const string RecurringName = "content.trash.purge"; + + public const string Cron = "17 3 * * *"; + + public async Task ExecuteAsync(TrashPurgePayload payload, JobContext context) + { + ArgumentNullException.ThrowIfNull(payload); + ArgumentNullException.ThrowIfNull(context); + ArgumentOutOfRangeException.ThrowIfLessThan(payload.Limit, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(payload.PageSize, 1); + + var cancellationToken = context.CancellationToken; + var now = time.GetUtcNow(); + var purged = 0; + var failed = 0; + + foreach (var type in schema.ContentTypes) + { + try + { + var (typePurged, typeFailed) = await PurgeTypeAsync(type, now - TimeSpan.FromDays(type.TrashRetentionDays), payload, context); + purged += typePurged; + failed += typeFailed; + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + TrashLog.TypeFailed(logger, exception, type.Name); + } + } + + TrashLog.Report(logger, purged, failed); + } + + private async Task<(int Purged, int Failed)> PurgeTypeAsync(SchemaType type, DateTimeOffset before, TrashPurgePayload payload, JobContext context) + { + var cancellationToken = context.CancellationToken; + var name = type.Name; + var purged = 0; + var failed = 0; + HashSet tried = []; + (DateTimeOffset At, Guid Id)? after = null; + + while (purged < payload.Limit) + { + var due = database.Documents.IgnoreQueryFilters().AsNoTracking() + .Where(document => document.Type == name && document.DeletedAt != null && document.DeletedAt < before); + if (after is { } last) + { + due = due.Where(document => document.DeletedAt > last.At || (document.DeletedAt == last.At && document.DocumentId.CompareTo(last.Id) > 0)); + } + + var page = await due + .OrderBy(document => document.DeletedAt).ThenBy(document => document.DocumentId) + .Select(document => new { document.DocumentId, document.SiteId, At = document.DeletedAt!.Value }) + .Take(payload.PageSize) + .ToListAsync(cancellationToken); + if (page.Count == 0) + { + break; + } + + after = (page[^1].At, page[^1].DocumentId); + foreach (var entry in page.Where(entry => tried.Add(entry.DocumentId))) + { + var site = entry.SiteId is { } owner ? new SiteId(owner) : context.Operation.Site; + using (SystemOperation.Begin(accessor, Origin.Job, site, correlationId: context.Operation.CorrelationId)) + { + try + { + await content.PurgeAsync(name, entry.DocumentId, before, cancellationToken); + purged++; + } + catch (EntryNotFoundException) + { + // Purged by hand, restored or put in the trash again since it was read. + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + failed++; + TrashLog.PurgeFailed(logger, exception, name, entry.DocumentId); + } + } + + if (purged >= payload.Limit) + { + break; + } + } + } + + if (purged + failed > 0) + { + TrashLog.TypeReport(logger, name, type.TrashRetentionDays, purged, failed); + } + + return (purged, failed); + } +} + +// Stores the schedule while the host starts, so that it is there when startup is over. If the database cannot be reached +// then, it keeps trying in the background, since a host without the schedule would otherwise keep its trash for good. +internal sealed class TrashPurgeScheduleService(IJobScheduler scheduler, TimeProvider time, ILogger logger) : BackgroundService +{ + private static readonly TimeSpan Retry = TimeSpan.FromSeconds(30); + + public override async Task StartAsync(CancellationToken cancellationToken) + { + if (!await TryScheduleAsync(cancellationToken)) + { + await base.StartAsync(cancellationToken); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(Retry, time, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + + if (await TryScheduleAsync(stoppingToken)) + { + return; + } + } + } + + private async Task TryScheduleAsync(CancellationToken cancellationToken) + { + try + { + await scheduler.AddRecurringAsync(TrashPurgeJob.RecurringName, TrashPurgeJob.Cron, new TrashPurgePayload(), cancellationToken: cancellationToken); + return true; + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + TrashLog.ScheduleFailed(logger, exception); + return false; + } + } +} + +internal static partial class TrashLog +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Trash purge: {Purged} entries purged, {Failed} failed.")] + public static partial void Report(ILogger logger, int purged, int failed); + + [LoggerMessage(Level = LogLevel.Information, Message = "Trash purge: {Type} keeps its trash for {Days} days; {Purged} entries purged, {Failed} failed.")] + public static partial void TypeReport(ILogger logger, string type, int days, int purged, int failed); + + [LoggerMessage(Level = LogLevel.Error, Message = "Trash purge could not go through the entries of {Type}; the other types still run.")] + public static partial void TypeFailed(ILogger logger, Exception exception, string type); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Trash purge could not purge {Type} {Id}; it stays in the trash until the next run.")] + public static partial void PurgeFailed(ILogger logger, Exception exception, string type, Guid id); + + [LoggerMessage(Level = LogLevel.Error, Message = "Could not register the recurring purge of the trash; entries stay in the trash until it is.")] + public static partial void ScheduleFailed(ILogger logger, Exception exception); +} diff --git a/src/FluentCMS.Core/Content/TypedContentService.cs b/src/FluentCMS.Core/Content/TypedContentService.cs index ff563eb82..fb072f7f8 100644 --- a/src/FluentCMS.Core/Content/TypedContentService.cs +++ b/src/FluentCMS.Core/Content/TypedContentService.cs @@ -56,6 +56,18 @@ public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) public async Task RestoreAsync(Guid id, CancellationToken cancellationToken = default) => await ReadBackAsync(await documents.RestoreAsync(_type, id, cancellationToken)); + public async Task> QueryTrashAsync(ContentQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ThrowIfUnfit(query.Locale, query.RichTextFormat); + + var page = await documents.QueryTrashAsync(_type, query, everySystemField: true, cancellationToken); + return new PagedResult(page.Items.Select(Map).ToList(), page.Total, page.Page, page.PageSize, page.NextCursor); + } + + public Task PurgeAsync(Guid id, CancellationToken cancellationToken = default) => + documents.PurgeAsync(_type, id, cancellationToken); + public async Task> PublishAsync(Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) { var published = await documents.PublishAsync(_type, id, locale, expectedVersion, cancellationToken); diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs index df22b89d6..68b889e27 100644 --- a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs @@ -112,6 +112,9 @@ public static IServiceCollection AddFluentCms(this IServiceCollection services, services.TryAddSingleton(); services.AddHostedService(); + builder.Jobs.AddJob(TrashPurgeJob.Type); + services.AddHostedService(); + builder.Events.Outbox.Validate(); builder.Jobs.Options.Queues.TryAdd(OutboxDrainJob.Queue, 1); builder.Jobs.AddJob(OutboxDrainJob.Type); diff --git a/src/FluentCMS.Core/Events/EntryChangeSink.cs b/src/FluentCMS.Core/Events/EntryChangeSink.cs index ed4b7fa00..a0ff966db 100644 --- a/src/FluentCMS.Core/Events/EntryChangeSink.cs +++ b/src/FluentCMS.Core/Events/EntryChangeSink.cs @@ -29,6 +29,7 @@ public static EntryEvent From(EntryChange change) EntryChangeKind.Unpublished => new EntryUnpublishedEvent(change.Type, change.Id, change.Locale, change.Version) { EventId = change.EventId, Source = source }, EntryChangeKind.DraftDiscarded => new EntryDraftDiscardedEvent(change.Type, change.Id, change.Locale, change.Version) { EventId = change.EventId, Source = source }, EntryChangeKind.VersionRestored => new EntryVersionRestoredEvent(change.Type, change.Id, change.Locale, change.Version) { EventId = change.EventId, Source = source }, + EntryChangeKind.Purged => new EntryPurgedEvent(change.Type, change.Id, change.Locale, change.Version) { EventId = change.EventId, Source = source }, _ => throw new ArgumentOutOfRangeException(nameof(change), change.Kind, "No event is defined for this kind of change."), }; } diff --git a/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs b/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs index 19d37a8e4..f550689c0 100644 --- a/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs +++ b/src/FluentCMS.Core/Schema/AttributeSchemaReader.cs @@ -67,9 +67,13 @@ public void Read(Type type) { Report(SchemaErrorCodes.InvalidType, $"{type.Name} must keep at least one version"); } + else if (policy.TrashRetentionDays is < 1 or > PublishPolicyAttribute.MaxTrashRetentionDays) + { + Report(SchemaErrorCodes.InvalidType, $"{type.Name} must keep its trash for 1 to {PublishPolicyAttribute.MaxTrashRetentionDays} days"); + } else { - t.PublishPolicy(policy.UnpublishedReferences, policy.MaxVersions); + t.PublishPolicy(policy.UnpublishedReferences, policy.MaxVersions, policy.TrashRetentionDays); } } } diff --git a/src/FluentCMS.Core/Schema/SchemaJsonReader.cs b/src/FluentCMS.Core/Schema/SchemaJsonReader.cs index ea989d4ac..fceaaebea 100644 --- a/src/FluentCMS.Core/Schema/SchemaJsonReader.cs +++ b/src/FluentCMS.Core/Schema/SchemaJsonReader.cs @@ -99,6 +99,7 @@ private void ReadType(JsonElement element, SchemaTypeKind kind, string path) var scope = ContentScope.Site; var unpublishedReferences = UnpublishedReferencePolicy.Warn; var maxVersions = PublishPolicyAttribute.DefaultMaxVersions; + var trashRetentionDays = PublishPolicyAttribute.DefaultTrashRetentionDays; JsonElement? fields = null; var failed = false; @@ -124,8 +125,16 @@ private void ReadType(JsonElement element, SchemaTypeKind kind, string path) case "maxVersions" when kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton && property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out var kept) && kept >= 1: maxVersions = kept; break; + case "trashRetentionDays" when kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton && property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out var days) && days is >= 1 and <= PublishPolicyAttribute.MaxTrashRetentionDays: + trashRetentionDays = days; + break; + case "trashRetentionDays" when kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton: + Fail(propertyPath, $"must be a whole number of days from 1 to {PublishPolicyAttribute.MaxTrashRetentionDays}"); + failed = true; + break; case "unpublishedReferences": case "maxVersions": + case "trashRetentionDays": Fail(propertyPath, "is not a valid publishing setting for this type"); failed = true; break; @@ -155,7 +164,7 @@ private void ReadType(JsonElement element, SchemaTypeKind kind, string path) type.Scope(scope); if (kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton) { - type.PublishPolicy(unpublishedReferences, maxVersions); + type.PublishPolicy(unpublishedReferences, maxVersions, trashRetentionDays); } if (fields is { } list) diff --git a/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs b/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs index d6b8c0b1f..4f3ad9880 100644 --- a/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs +++ b/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs @@ -66,6 +66,11 @@ private static void WriteTypes(Utf8JsonWriter writer, string group, IEnumerable< writer.WriteNumber("maxVersions", type.MaxVersions); } + if (type.TrashRetentionDays != PublishPolicyAttribute.DefaultTrashRetentionDays) + { + writer.WriteNumber("trashRetentionDays", type.TrashRetentionDays); + } + writer.WriteStartArray("fields"); foreach (var field in type.Fields) { diff --git a/src/FluentCMS.Core/Schema/SchemaModelFactory.cs b/src/FluentCMS.Core/Schema/SchemaModelFactory.cs index 7bba10afc..489e9b710 100644 --- a/src/FluentCMS.Core/Schema/SchemaModelFactory.cs +++ b/src/FluentCMS.Core/Schema/SchemaModelFactory.cs @@ -23,6 +23,7 @@ public static SchemaModel Create(IReadOnlyList drafts, IFieldTypeRegi Scope = draft.Scope, UnpublishedReferences = draft.UnpublishedReferences, MaxVersions = draft.MaxVersions, + TrashRetentionDays = draft.TrashRetentionDays, ClrType = draft.ClrType, Fields = draft.Fields.Select(Create).ToList().AsReadOnly(), ReverseRelations = (draft.IsContentType ? reverseRelations[draft.Name] : []).OrderBy(relation => relation.Name, StringComparer.Ordinal).ToList().AsReadOnly(), diff --git a/src/FluentCMS.Core/Schema/TypeBuilder.cs b/src/FluentCMS.Core/Schema/TypeBuilder.cs index b551219c4..b6ef81185 100644 --- a/src/FluentCMS.Core/Schema/TypeBuilder.cs +++ b/src/FluentCMS.Core/Schema/TypeBuilder.cs @@ -22,7 +22,7 @@ public ITypeBuilder Scope(ContentScope scope) return this; } - public ITypeBuilder PublishPolicy(UnpublishedReferencePolicy unpublishedReferences = UnpublishedReferencePolicy.Warn, int maxVersions = PublishPolicyAttribute.DefaultMaxVersions) + public ITypeBuilder PublishPolicy(UnpublishedReferencePolicy unpublishedReferences = UnpublishedReferencePolicy.Warn, int maxVersions = PublishPolicyAttribute.DefaultMaxVersions, int trashRetentionDays = PublishPolicyAttribute.DefaultTrashRetentionDays) { if (!draft.IsContentType) { @@ -30,9 +30,12 @@ public ITypeBuilder PublishPolicy(UnpublishedReferencePolicy unpublishedReferenc } ArgumentOutOfRangeException.ThrowIfLessThan(maxVersions, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(trashRetentionDays, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(trashRetentionDays, PublishPolicyAttribute.MaxTrashRetentionDays); draft.UnpublishedReferences = unpublishedReferences; draft.MaxVersions = maxVersions; + draft.TrashRetentionDays = trashRetentionDays; return this; } diff --git a/src/FluentCMS.Core/Schema/TypeDraft.cs b/src/FluentCMS.Core/Schema/TypeDraft.cs index eaebd2cb4..9d9f9654b 100644 --- a/src/FluentCMS.Core/Schema/TypeDraft.cs +++ b/src/FluentCMS.Core/Schema/TypeDraft.cs @@ -19,6 +19,8 @@ internal sealed class TypeDraft(string name, SchemaTypeKind kind, Type? clrType) public int MaxVersions { get; set; } = PublishPolicyAttribute.DefaultMaxVersions; + public int TrashRetentionDays { get; set; } = PublishPolicyAttribute.DefaultTrashRetentionDays; + public List Fields { get; } = []; public bool IsContentType => Kind is SchemaTypeKind.Collection or SchemaTypeKind.Singleton; diff --git a/src/FluentCMS.Sdk/Content/IContentService.cs b/src/FluentCMS.Sdk/Content/IContentService.cs index 92b26210d..4533552b3 100644 --- a/src/FluentCMS.Sdk/Content/IContentService.cs +++ b/src/FluentCMS.Sdk/Content/IContentService.cs @@ -84,6 +84,31 @@ public interface IContentService /// The entry is not in the trash, or the principal may not reach it. Task RestoreAsync(string type, Guid id, CancellationToken cancellationToken = default); + /// + /// Reads a page of the entries in the trash, as drafts and in one locale. The principal needs restore or purge on the type and + /// sees only the entries it may read as drafts, with the fields it may read; deletedAt and, where it may read who changed an entry, deletedBy say when and by whom. + /// A relation is followed to targets that are not in the trash, and a filter on a relation never matches a target in the trash. + /// + /// The name of the content type. + /// What to read. Its status is ignored, since the trash is read as drafts, and so is its fallback. A missing site or locale is that of the operation, and every locale at once is refused. + /// A token to cancel the work. + /// The page. + /// The principal may read the type but may neither restore nor purge its entries. + /// The query is not valid, or the principal may not read the type or its drafts. + Task> QueryTrashAsync(string type, ContentQuery query, CancellationToken cancellationToken = default); + + /// + /// Deletes an entry in the trash for good, in all its locales: its content, its references, and every version of it, published ones included. + /// The values it held are free to use again. + /// + /// The name of the content type. + /// The id of the entry. + /// A token to cancel the work. + /// A task that completes when the entry is gone. + /// The principal may not purge the entry in every one of its locales. + /// The entry is not in the trash, or the principal may not reach it. + Task PurgeAsync(string type, Guid id, CancellationToken cancellationToken = default); + /// /// Publishes the draft of one locale, under every rule a draft skips and with the references checked. Fields a localized /// type shares go with it, so a grant that does not cover the other published locales may only publish when those fields have not changed. @@ -259,6 +284,12 @@ public interface IContentService /// The entry is not in the trash, or the principal may not reach it. Task RestoreAsync(Guid id, CancellationToken cancellationToken = default); + /// + Task> QueryTrashAsync(ContentQuery query, CancellationToken cancellationToken = default); + + /// + Task PurgeAsync(Guid id, CancellationToken cancellationToken = default); + /// Task> PublishAsync(Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default); diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt index f7d119f88..e65310aa2 100644 --- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt +++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt @@ -119,7 +119,9 @@ FluentCMS.Content.IContentService.GetVersionAsync(string! type, System.Guid id, FluentCMS.Content.IContentService.GetVersionsAsync(string! type, System.Guid id, string? locale = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! FluentCMS.Content.IContentService.PatchAsync(string! type, System.Guid id, FluentCMS.Content.ContentDocument! patch, int expectedVersion, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IContentService.PublishAsync(string! type, System.Guid id, string? locale = null, int? expectedVersion = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Content.IContentService.PurgeAsync(string! type, System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IContentService.QueryAsync(string! type, FluentCMS.Content.ContentQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Content.IContentService.QueryTrashAsync(string! type, FluentCMS.Content.ContentQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! FluentCMS.Content.IContentService.RestoreAsync(string! type, System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IContentService.RestoreVersionAsync(string! type, System.Guid id, int number, string? locale = null, int? expectedVersion = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IContentService.UnpublishAsync(string! type, System.Guid id, string? locale = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -135,7 +137,9 @@ FluentCMS.Content.IContentService.GetVersionAsync(System.Guid id, int number, FluentCMS.Content.IContentService.GetVersionsAsync(System.Guid id, string? locale = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! FluentCMS.Content.IContentService.PatchAsync(System.Guid id, FluentCMS.Content.ContentDocument! patch, int expectedVersion, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IContentService.PublishAsync(System.Guid id, string? locale = null, int? expectedVersion = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Content.IContentService.PurgeAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IContentService.QueryAsync(FluentCMS.Content.ContentQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Content.IContentService.QueryTrashAsync(FluentCMS.Content.ContentQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! FluentCMS.Content.IContentService.RestoreAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IContentService.RestoreVersionAsync(System.Guid id, int number, string? locale = null, int? expectedVersion = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! FluentCMS.Content.IContentService.UnpublishAsync(System.Guid id, string? locale = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -608,6 +612,8 @@ FluentCMS.Publishing.PublishPolicyAttribute FluentCMS.Publishing.PublishPolicyAttribute.MaxVersions.get -> int FluentCMS.Publishing.PublishPolicyAttribute.MaxVersions.set -> void FluentCMS.Publishing.PublishPolicyAttribute.PublishPolicyAttribute() -> void +FluentCMS.Publishing.PublishPolicyAttribute.TrashRetentionDays.get -> int +FluentCMS.Publishing.PublishPolicyAttribute.TrashRetentionDays.set -> void FluentCMS.Publishing.PublishPolicyAttribute.UnpublishedReferences.get -> FluentCMS.Publishing.UnpublishedReferencePolicy FluentCMS.Publishing.PublishPolicyAttribute.UnpublishedReferences.set -> void FluentCMS.Publishing.PublishResult @@ -873,7 +879,7 @@ FluentCMS.Schema.ISchemaBuilder.LoadSchemaFile(string! path) -> FluentCMS.Schema FluentCMS.Schema.ITypeBuilder FluentCMS.Schema.ITypeBuilder.Field(string! name, string! fieldType, System.Action? configure = null) -> FluentCMS.Schema.ITypeBuilder! FluentCMS.Schema.ITypeBuilder.Localized(bool localized = true) -> FluentCMS.Schema.ITypeBuilder! -FluentCMS.Schema.ITypeBuilder.PublishPolicy(FluentCMS.Publishing.UnpublishedReferencePolicy unpublishedReferences = FluentCMS.Publishing.UnpublishedReferencePolicy.Warn, int maxVersions = 50) -> FluentCMS.Schema.ITypeBuilder! +FluentCMS.Schema.ITypeBuilder.PublishPolicy(FluentCMS.Publishing.UnpublishedReferencePolicy unpublishedReferences = FluentCMS.Publishing.UnpublishedReferencePolicy.Warn, int maxVersions = 50, int trashRetentionDays = 30) -> FluentCMS.Schema.ITypeBuilder! FluentCMS.Schema.ITypeBuilder.Scope(FluentCMS.Schema.ContentScope scope) -> FluentCMS.Schema.ITypeBuilder! FluentCMS.Schema.IndexedAttribute FluentCMS.Schema.IndexedAttribute.IndexedAttribute() -> void @@ -987,6 +993,7 @@ FluentCMS.Schema.SchemaType.MaxVersions.get -> int FluentCMS.Schema.SchemaType.Name.get -> string! FluentCMS.Schema.SchemaType.ReverseRelations.get -> System.Collections.Generic.IReadOnlyList! FluentCMS.Schema.SchemaType.Scope.get -> FluentCMS.Schema.ContentScope +FluentCMS.Schema.SchemaType.TrashRetentionDays.get -> int FluentCMS.Schema.SchemaType.UnpublishedReferences.get -> FluentCMS.Publishing.UnpublishedReferencePolicy FluentCMS.Schema.SchemaTypeKind FluentCMS.Schema.SchemaTypeKind.Block = 4 -> FluentCMS.Schema.SchemaTypeKind @@ -1060,6 +1067,8 @@ const FluentCMS.Content.ValidationErrorCodes.Required = "FCMS2101" -> string! const FluentCMS.Content.ValidationErrorCodes.RequiredByCondition = "FCMS2110" -> string! const FluentCMS.Localization.ILocaleResolver.All = "all" -> string! const FluentCMS.Publishing.PublishPolicyAttribute.DefaultMaxVersions = 50 -> int +const FluentCMS.Publishing.PublishPolicyAttribute.DefaultTrashRetentionDays = 30 -> int +const FluentCMS.Publishing.PublishPolicyAttribute.MaxTrashRetentionDays = 36500 -> int const FluentCMS.Publishing.PublishWarningCodes.UnavailableReference = "FCMS2404" -> string! const FluentCMS.Publishing.PublishWarningCodes.UnpublishedReference = "FCMS2403" -> string! const FluentCMS.Schema.FieldSettings.Blocks = "blocks" -> string! diff --git a/src/FluentCMS.Sdk/Publishing/PublishPolicyAttribute.cs b/src/FluentCMS.Sdk/Publishing/PublishPolicyAttribute.cs index 28a8da09a..2db15d1e5 100644 --- a/src/FluentCMS.Sdk/Publishing/PublishPolicyAttribute.cs +++ b/src/FluentCMS.Sdk/Publishing/PublishPolicyAttribute.cs @@ -1,15 +1,24 @@ namespace FluentCMS.Publishing; -/// Sets how entries of a content type are published and how many of their versions are kept. +/// Sets how entries of a content type are published, how many of their versions are kept and how long the trash holds them. [AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class PublishPolicyAttribute : Attribute { /// The number of versions kept per entry and locale when a type does not say. public const int DefaultMaxVersions = 50; + /// The number of days an entry stays in the trash when a type does not say. + public const int DefaultTrashRetentionDays = 30; + + /// The most days a type can keep its trash, a hundred years. + public const int MaxTrashRetentionDays = 36500; + /// What publishing does about a reference to an entry that is not published or is in the trash. public UnpublishedReferencePolicy UnpublishedReferences { get; set; } /// How many versions to keep per entry and locale. The oldest go first, and a version that was published is never pruned. public int MaxVersions { get; set; } = DefaultMaxVersions; + + /// How many days an entry stays in the trash before the purge job deletes it for good, from 1 to . + public int TrashRetentionDays { get; set; } = DefaultTrashRetentionDays; } diff --git a/src/FluentCMS.Sdk/Schema/ITypeBuilder.cs b/src/FluentCMS.Sdk/Schema/ITypeBuilder.cs index e9606f9c5..927bb11f7 100644 --- a/src/FluentCMS.Sdk/Schema/ITypeBuilder.cs +++ b/src/FluentCMS.Sdk/Schema/ITypeBuilder.cs @@ -16,13 +16,14 @@ public interface ITypeBuilder /// The type is a component or a block and is not . ITypeBuilder Scope(ContentScope scope); - /// Sets how entries are published and how many versions are kept. Only collections and singletons are published. + /// Sets how entries are published, how many versions are kept and how long the trash holds them. Only collections and singletons are published. /// What publishing does about a reference to an entry that is not published or is in the trash. /// How many versions to keep per entry and locale. A version that was published is never pruned. + /// How many days an entry stays in the trash before the purge job deletes it for good. /// This builder. /// The type is a component or a block. - /// is less than 1. - ITypeBuilder PublishPolicy(UnpublishedReferencePolicy unpublishedReferences = UnpublishedReferencePolicy.Warn, int maxVersions = PublishPolicyAttribute.DefaultMaxVersions); + /// is less than 1, or is outside 1 to . + ITypeBuilder PublishPolicy(UnpublishedReferencePolicy unpublishedReferences = UnpublishedReferencePolicy.Warn, int maxVersions = PublishPolicyAttribute.DefaultMaxVersions, int trashRetentionDays = PublishPolicyAttribute.DefaultTrashRetentionDays); /// Declares a field. /// The field name, in camelCase. diff --git a/src/FluentCMS.Sdk/Schema/SchemaType.cs b/src/FluentCMS.Sdk/Schema/SchemaType.cs index d0a121850..eac962bb4 100644 --- a/src/FluentCMS.Sdk/Schema/SchemaType.cs +++ b/src/FluentCMS.Sdk/Schema/SchemaType.cs @@ -27,6 +27,9 @@ internal SchemaType() /// How many versions to keep per entry and locale. A version that was published is never pruned. public int MaxVersions { get; internal init; } = PublishPolicyAttribute.DefaultMaxVersions; + /// How many days an entry stays in the trash before the purge job deletes it for good. + public int TrashRetentionDays { get; internal init; } = PublishPolicyAttribute.DefaultTrashRetentionDays; + /// The class the type was read from, or if it was defined without one. It does not take part in comparing models. public Type? ClrType { get; internal init; } diff --git a/src/FluentCMS.Testing/ScopedContentService.cs b/src/FluentCMS.Testing/ScopedContentService.cs index 1841e696d..3da0e3bb1 100644 --- a/src/FluentCMS.Testing/ScopedContentService.cs +++ b/src/FluentCMS.Testing/ScopedContentService.cs @@ -28,6 +28,12 @@ public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) public Task RestoreAsync(Guid id, CancellationToken cancellationToken = default) => runner.RunAsync, T>(service => service.RestoreAsync(id, cancellationToken)); + public Task> QueryTrashAsync(ContentQuery query, CancellationToken cancellationToken = default) => + runner.RunAsync, PagedResult>(service => service.QueryTrashAsync(query, cancellationToken)); + + public Task PurgeAsync(Guid id, CancellationToken cancellationToken = default) => + runner.RunAsync>(service => service.PurgeAsync(id, cancellationToken)); + public Task> PublishAsync(Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) => runner.RunAsync, PublishResult>(service => service.PublishAsync(id, locale, expectedVersion, cancellationToken)); @@ -76,6 +82,12 @@ public Task DeleteAsync(string type, Guid id, CancellationToken cancellationToke public Task RestoreAsync(string type, Guid id, CancellationToken cancellationToken = default) => runner.RunAsync(service => service.RestoreAsync(type, id, cancellationToken)); + public Task> QueryTrashAsync(string type, ContentQuery query, CancellationToken cancellationToken = default) => + runner.RunAsync>(service => service.QueryTrashAsync(type, query, cancellationToken)); + + public Task PurgeAsync(string type, Guid id, CancellationToken cancellationToken = default) => + runner.RunAsync(service => service.PurgeAsync(type, id, cancellationToken)); + public Task> PublishAsync(string type, Guid id, string? locale = null, int? expectedVersion = null, CancellationToken cancellationToken = default) => runner.RunAsync>(service => service.PublishAsync(type, id, locale, expectedVersion, cancellationToken)); diff --git a/tests/FluentCMS.Core.Tests/Schema/PublishPolicyTests.cs b/tests/FluentCMS.Core.Tests/Schema/PublishPolicyTests.cs index 57113d201..52bcd2ed7 100644 --- a/tests/FluentCMS.Core.Tests/Schema/PublishPolicyTests.cs +++ b/tests/FluentCMS.Core.Tests/Schema/PublishPolicyTests.cs @@ -11,15 +11,17 @@ public sealed class PublishPolicyTests [Fact] public void JsonRoundTrips() { - var model = new SchemaBuilder().AddSchemaJson("""{ "collections": [ { "name": "article", "unpublishedReferences": "block", "maxVersions": 7 } ] }""").Build(); + var model = new SchemaBuilder().AddSchemaJson("""{ "collections": [ { "name": "article", "unpublishedReferences": "block", "maxVersions": 7, "trashRetentionDays": 9 } ] }""").Build(); var type = model.FindContentType("article")!; type.UnpublishedReferences.ShouldBe(UnpublishedReferencePolicy.Block); type.MaxVersions.ShouldBe(7); + type.TrashRetentionDays.ShouldBe(9); var again = new SchemaBuilder().AddSchemaJson(SchemaJsonWriter.Write(model)).Build().FindContentType("article")!; again.UnpublishedReferences.ShouldBe(UnpublishedReferencePolicy.Block); again.MaxVersions.ShouldBe(7); + again.TrashRetentionDays.ShouldBe(9); } [Fact] @@ -29,11 +31,16 @@ public void TheDefaultsAreNotWrittenSoNoExistingModelChangesItsKey() json.ShouldNotContain("unpublishedReferences"); json.ShouldNotContain("maxVersions"); + json.ShouldNotContain("trashRetentionDays"); + new SchemaBuilder().AddCollection("article", type => type.PublishPolicy()).Build().FindContentType("article")!.TrashRetentionDays.ShouldBe(PublishPolicyAttribute.DefaultTrashRetentionDays); } [Theory] [InlineData("""{ "collections": [ { "name": "article", "unpublishedReferences": "ignore" } ] }""")] [InlineData("""{ "collections": [ { "name": "article", "maxVersions": 0 } ] }""")] + [InlineData("""{ "collections": [ { "name": "article", "trashRetentionDays": 0 } ] }""")] + [InlineData("""{ "collections": [ { "name": "article", "trashRetentionDays": 36501 } ] }""")] + [InlineData("""{ "components": [ { "name": "seo", "trashRetentionDays": 5 } ] }""")] [InlineData("""{ "components": [ { "name": "seo", "maxVersions": 5 } ] }""")] [InlineData("""{ "blocks": [ { "name": "hero", "unpublishedReferences": "block" } ] }""")] public void JsonRefusesWhatIsNotAPolicy(string json) => @@ -44,6 +51,9 @@ public void TheBuilderRefusesAPolicyOnAComponentAndACapBelowOne() { Should.Throw(() => new SchemaBuilder().AddComponent("seo", type => type.PublishPolicy()).Build()); Should.Throw(() => new SchemaBuilder().AddCollection("article", type => type.PublishPolicy(maxVersions: 0)).Build()); + Should.Throw(() => new SchemaBuilder().AddCollection("article", type => type.PublishPolicy(trashRetentionDays: 0)).Build()); + Should.Throw(() => new SchemaBuilder().AddCollection("article", type => type.PublishPolicy(trashRetentionDays: PublishPolicyAttribute.MaxTrashRetentionDays + 1)).Build()); + new SchemaBuilder().AddCollection("article", type => type.PublishPolicy(trashRetentionDays: PublishPolicyAttribute.MaxTrashRetentionDays)).Build().FindContentType("article")!.TrashRetentionDays.ShouldBe(36500); } [FluentCMS.Schema.Collection("capless")] @@ -52,6 +62,24 @@ private sealed class Capless : ContentItem { } + [FluentCMS.Schema.Collection("hoarder")] + [PublishPolicy(TrashRetentionDays = 0)] + private sealed class Hoarder : ContentItem + { + } + + [FluentCMS.Schema.Collection("forever")] + [PublishPolicy(TrashRetentionDays = 1_000_000)] + private sealed class Forever : ContentItem + { + } + + [FluentCMS.Schema.Collection("keeper")] + [PublishPolicy(TrashRetentionDays = 14)] + private sealed class Keeper : ContentItem + { + } + [Component("policed")] [PublishPolicy] private sealed class Policed @@ -62,6 +90,9 @@ private sealed class Policed public void AttributesReportWhatTheBuilderRefuses() { Should.Throw(() => new SchemaBuilder().AddContentType().Build()); + Should.Throw(() => new SchemaBuilder().AddContentType().Build()); + Should.Throw(() => new SchemaBuilder().AddContentType().Build()); + new SchemaBuilder().AddContentType().Build().FindContentType("keeper")!.TrashRetentionDays.ShouldBe(14); Should.Throw(() => new SchemaBuilder().AddContentType().Build()); } } diff --git a/tests/FluentCMS.Data.Tests/Content/TrashDatabase.cs b/tests/FluentCMS.Data.Tests/Content/TrashDatabase.cs new file mode 100644 index 000000000..1b795aa73 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/TrashDatabase.cs @@ -0,0 +1,53 @@ +using FluentCMS.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +// The trash calls, each a request of its own like those of ServiceDatabase, and what an entry leaves in the tables. +internal static class TrashDatabase +{ + public const string Nothing = "0,0,0,0,0"; + + public static Task> TrashAsync(this ServiceDatabase database, Principal principal, ContentQuery? query = null, string type = "article", string locale = "en", SiteId? site = null) => + database.AsAsync(principal, services => services.GetRequiredService().QueryTrashAsync(type, query ?? new ContentQuery(), Token), locale, site); + + public static Task PurgeAsync(this ServiceDatabase database, Principal principal, Guid id, string type = "article", string locale = "en", SiteId? site = null) => + database.AsAsync( + principal, + async services => + { + await services.GetRequiredService().PurgeAsync(type, id, Token); + return null; + }, + locale, + site); + + public static Task DeleteInAsync(this ServiceDatabase database, Principal principal, Guid id, SiteId site, string type = "article") => + database.AsAsync( + principal, + async services => + { + await services.GetRequiredService().DeleteAsync(type, id, Token); + return null; + }, + site: site); + + // Moves the moment an entry went to the trash, so that a test need not wait for a retention period to pass. + public static void TrashedAt(this ServiceDatabase database, Guid id, DateTimeOffset at) => + database.Read(context => context.Documents.IgnoreQueryFilters() + .Where(document => document.DocumentId == id && document.DeletedAt != null) + .ExecuteUpdate(set => set.SetProperty(document => document.DeletedAt, at))); + + // Every row an article holds, so that a test can say none is left. + public static string Held(this ServiceDatabase database, Guid id) => database.Read(context => string.Join( + ',', + context.Documents.IgnoreQueryFilters().Count(document => document.DocumentId == id), + context.Rows("cms_article").Count(row => EF.Property(row, StorageNames.DocumentId) == id), + context.Rows("cms_article_i18n").Count(row => EF.Property(row, StorageNames.DocumentId) == id), + context.References.Count(reference => reference.SourceDocumentId == id), + context.Versions.Count(version => version.DocumentId == id))); +} diff --git a/tests/FluentCMS.Data.Tests/Content/TrashPurgeJobTests.cs b/tests/FluentCMS.Data.Tests/Content/TrashPurgeJobTests.cs new file mode 100644 index 000000000..bf911a6dd --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/TrashPurgeJobTests.cs @@ -0,0 +1,363 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Data.Tests.Events; +using FluentCMS.Data.Tests.Jobs; +using FluentCMS.Events; +using FluentCMS.Jobs; +using FluentCMS.Publishing; +using FluentCMS.Sites; +using System.Data.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class TrashPurgeJobTests +{ + private static readonly DateTimeOffset Long = DateTimeOffset.UtcNow.AddDays(-90); + + private sealed class Poisoned : IEntryChangeStager + { + public HashSet Ids { get; } = []; + + public Guid Id + { + set + { + Ids.Clear(); + Ids.Add(value); + } + } + + public ValueTask StageAsync(DbContext context, IReadOnlyList changes, CancellationToken cancellationToken) => + changes.Any(change => change.Kind == EntryChangeKind.Purged && Ids.Contains(change.Id)) ? throw new InvalidOperationException("The outbox is down.") : ValueTask.CompletedTask; + } + + private sealed class Heard(Seen seen) : IEventHandler + { + public ValueTask HandleAsync(EntryPurgedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("purged", message, null, cancellationToken); + } + + private static async Task RunAsync(ServiceDatabase database, int limit = 500, int pageSize = 100) + { + using var scope = database.Database.NewScope(); + var context = new OperationContext(Principal.System, Origin.Job, SiteA, "en", null, "test", DateTimeOffset.UtcNow); + await scope.ServiceProvider.GetRequiredService().ExecuteAsync(new TrashPurgePayload(limit, pageSize), new JobContext(Guid.NewGuid(), TrashPurgeJob.Type, "default", 1, 3, context, Token)); + } + + private static async Task TrashedAsync(ServiceDatabase database, string title, DateTimeOffset at, string type = "article", SiteId? site = null) + { + var entry = type == "article" ? new ContentDocument { ["title"] = title } : new ContentDocument { ["name"] = title }; + var id = IdOf(await database.CreateAsync(Editor, entry, type, site: site)); + await database.DeleteInAsync(Editor, id, site ?? SiteA, type); + database.TrashedAt(id, at); + return id; + } + + private static bool Exists(ServiceDatabase database, Guid id) => + database.Read(context => context.Documents.IgnoreQueryFilters().Any(document => document.DocumentId == id)); + + [Fact] + public async Task TheJobPurgesOnlyWhatIsPastItsRetentionAndLogsAReport() + { + var logs = new RecordingLogs(); + var seen = new Seen(); + using var database = new ServiceDatabase(cms => + { + cms.Services.AddSingleton(logs); + cms.Services.AddSingleton(seen); + cms.Events.AddHandler(); + }); + var old = await TrashedAsync(database, "Old", DateTimeOffset.UtcNow.AddDays(-31)); + var recent = await TrashedAsync(database, "Recent", DateTimeOffset.UtcNow.AddDays(-29)); + var fresh = await TrashedAsync(database, "Fresh", DateTimeOffset.UtcNow); + var live = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "Live" })); + + await RunAsync(database); + + Exists(database, old).ShouldBeFalse(); + database.Held(old).ShouldBe(TrashDatabase.Nothing); + new[] { recent, fresh, live }.ShouldAllBe(id => Exists(database, id)); + (await database.TrashAsync(Editor)).Items.Select(entry => (Guid)entry["id"]!).ShouldBe([recent, fresh], ignoreOrder: true); + logs.Count("Report").ShouldBe(1); + logs.Count("TypeReport").ShouldBe(1); + seen.Of().ShouldHaveSingleItem().EntryId.ShouldBe(old); + + await RunAsync(database); + + logs.Count("Report").ShouldBe(2); + logs.Count("TypeReport").ShouldBe(1); + seen.Of().Count().ShouldBe(1); + (await database.RestoreAsync(Editor, recent))["title"].ShouldBe("Recent"); + } + + [Fact] + public async Task EachTypeKeepsItsTrashForItsOwnPeriod() + { + var model = SampleSchema.NewBuilder() + .AddCollection("note", type => type.PublishPolicy(trashRetentionDays: 3).Field("name", "text")) + .AddCollection("memo", type => type.Field("name", "text")) + .Build(); + using var database = new ServiceDatabase(schema: model); + var shortNote = await TrashedAsync(database, "Note", DateTimeOffset.UtcNow.AddDays(-4), "note"); + var freshNote = await TrashedAsync(database, "Note", DateTimeOffset.UtcNow.AddDays(-2), "note"); + var memo = await TrashedAsync(database, "Memo", DateTimeOffset.UtcNow.AddDays(-4), "memo"); + var oldMemo = await TrashedAsync(database, "Memo", DateTimeOffset.UtcNow.AddDays(-31), "memo"); + + await RunAsync(database); + + Exists(database, shortNote).ShouldBeFalse(); + Exists(database, oldMemo).ShouldBeFalse(); + Exists(database, freshNote).ShouldBeTrue(); + Exists(database, memo).ShouldBeTrue(); + model.FindContentType("note")!.TrashRetentionDays.ShouldBe(3); + model.FindContentType("memo")!.TrashRetentionDays.ShouldBe(30); + } + + [Fact] + public async Task TheJobPurgesEverySiteAndEveryVersionOfWhatItTakes() + { + using var database = new ServiceDatabase(); + var inA = await TrashedAsync(database, "In A", Long); + var inB = await TrashedAsync(database, "In B", Long, site: SiteB); + var published = IdOf(await database.CreateAsync(Editor, new() { ["title"] = "Published" })); + await database.PublishAsync(Editor, published); + await database.PatchAsync(Editor, published, new() { ["title"] = "Changed" }, 1); + await database.UpdateAsync(Editor, published, new() { ["title"] = "Bonjour" }, 0, locale: "fr"); + await database.DeleteAsync(Editor, published); + database.TrashedAt(published, Long); + + await RunAsync(database); + + new[] { inA, inB, published }.ShouldAllBe(id => database.Held(id) == TrashDatabase.Nothing); + } + + [Fact] + public async Task AnEntryThatCannotBePurgedStaysAndDoesNotStopTheOthers() + { + var poison = new Poisoned(); + var logs = new RecordingLogs(); + using var database = new ServiceDatabase(cms => + { + cms.Services.AddSingleton(logs); + cms.Services.AddScoped(_ => poison); + }); + var stuck = await TrashedAsync(database, "Stuck", Long); + var other = await TrashedAsync(database, "Other", Long); + poison.Id = stuck; + var before = database.Trace(stuck); + + await RunAsync(database); + + database.Trace(stuck).ShouldBe(before); + Exists(database, other).ShouldBeFalse(); + logs.Count("PurgeFailed").ShouldBe(1); + + poison.Id = Guid.Empty; + await RunAsync(database); + + Exists(database, stuck).ShouldBeFalse(); + } + + [Fact] + public async Task EntriesThatKeepFailingDoNotStarveTheOnesBehindThem() + { + var poison = new Poisoned(); + using var database = new ServiceDatabase(cms => cms.Services.AddScoped(_ => poison)); + var stuck = new List(); + for (var index = 0; index < 3; index++) + { + stuck.Add(await TrashedAsync(database, $"Stuck {index}", DateTimeOffset.UtcNow.AddDays(-100 + index))); + } + + var good = new List + { + await TrashedAsync(database, "Good one", DateTimeOffset.UtcNow.AddDays(-50)), + await TrashedAsync(database, "Good two", DateTimeOffset.UtcNow.AddDays(-40)), + }; + poison.Ids.UnionWith(stuck); + + await RunAsync(database, limit: 2, pageSize: 2); + + good.ShouldAllBe(id => !Exists(database, id)); + stuck.ShouldAllBe(id => Exists(database, id)); + } + + [Fact] + public async Task AnEntryPutInTheTrashAgainAfterItWasListedAsDueIsNotPurged() + { + using var database = new ServiceDatabase(); + var id = await TrashedAsync(database, "Again", DateTimeOffset.UtcNow); + var cutoff = DateTimeOffset.UtcNow.AddDays(-1); + var before = database.Trace(id); + + Task PurgeBeforeAsync(DateTimeOffset at) => database.AsAsync( + Principal.System, + async services => + { + await services.GetRequiredService().PurgeAsync("article", id, at, Token); + return null; + }, + origin: Origin.Job); + + await Should.ThrowAsync(() => PurgeBeforeAsync(cutoff)); + database.Trace(id).ShouldBe(before); + database.Held(id).ShouldNotBe(TrashDatabase.Nothing); + + await PurgeBeforeAsync(DateTimeOffset.UtcNow.AddMinutes(1)); + + database.Held(id).ShouldBe(TrashDatabase.Nothing); + } + + [Fact] + public async Task OneTypeThatFailsDoesNotStopTheOthers() + { + var model = SampleSchema.NewBuilder() + .AddCollection("note", type => type.PublishPolicy(trashRetentionDays: PublishPolicyAttribute.MaxTrashRetentionDays).Field("name", "text")) + .AddCollection("memo", type => type.Field("name", "text")) + .Build(); + var faults = new JobFaults(); + var logs = new RecordingLogs(); + using var database = new ServiceDatabase( + cms => + { + cms.Services.AddSingleton(logs); + cms.Services.ConfigureDbContext(options => options.AddInterceptors(faults)); + }, + model); + var note = await TrashedAsync(database, "Note", Long, "note"); + var memo = await TrashedAsync(database, "Memo", Long, "memo"); + faults.Fail(command => command.CommandText.Contains("cms_document", StringComparison.Ordinal) + && command.CommandText.StartsWith("SELECT", StringComparison.Ordinal) + && command.Parameters.Cast().Any(parameter => Equals(parameter.Value, "note"))); + + await RunAsync(database); + + Exists(database, memo).ShouldBeFalse(); + Exists(database, note).ShouldBeTrue(); + logs.Count("TypeFailed").ShouldBe(1); + logs.Count("Report").ShouldBe(1); + + faults.Recover(); + await RunAsync(database); + + Exists(database, note).ShouldBeTrue(); + } + + [Fact] + public async Task TheLongestRetentionStillWorksOutAMoment() + { + var model = SampleSchema.NewBuilder() + .AddCollection("note", type => type.PublishPolicy(trashRetentionDays: PublishPolicyAttribute.MaxTrashRetentionDays).Field("name", "text")) + .AddCollection("memo", type => type.Field("name", "text")) + .Build(); + using var database = new ServiceDatabase(schema: model); + var note = await TrashedAsync(database, "Note", Long, "note"); + var memo = await TrashedAsync(database, "Memo", Long, "memo"); + + await RunAsync(database); + + Exists(database, note).ShouldBeTrue(); + Exists(database, memo).ShouldBeFalse(); + } + + [Fact] + public async Task TheLimitCapsWhatOneRunPurgesAndTheNextTakesTheRest() + { + using var database = new ServiceDatabase(); + var ids = new List(); + for (var index = 0; index < 3; index++) + { + ids.Add(await TrashedAsync(database, $"Old {index}", Long)); + } + + await RunAsync(database, limit: 2); + ids.Count(id => Exists(database, id)).ShouldBe(1); + + await RunAsync(database, limit: 2); + ids.Count(id => Exists(database, id)).ShouldBe(0); + } + + private sealed class FlakyScheduler : IJobScheduler + { + public int Failures { get; set; } + + public int Added { get; private set; } + + public Task EnqueueAsync(TPayload payload, JobOptions? options = null, CancellationToken cancellationToken = default) + where TJob : class, IJob => throw new NotSupportedException(); + + public Task ScheduleAsync(TPayload payload, DateTimeOffset runAt, JobOptions? options = null, CancellationToken cancellationToken = default) + where TJob : class, IJob => throw new NotSupportedException(); + + public Task AddRecurringAsync(string name, string cron, TPayload payload, string? queue = null, CancellationToken cancellationToken = default) + where TJob : class, IJob + { + if (Failures > 0) + { + Failures--; + throw new InvalidOperationException("The database is down."); + } + + Added++; + return Task.CompletedTask; + } + } + + [Fact] + public async Task AScheduleThatCannotBeStoredAtStartupIsRetriedUntilItIs() + { + var scheduler = new FlakyScheduler { Failures = 2 }; + var clock = new ManualTimeProvider(DateTimeOffset.UtcNow); + using var service = new TrashPurgeScheduleService(scheduler, clock, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + await service.StartAsync(Token); + scheduler.Added.ShouldBe(0); + + for (var tick = 0; tick < 200 && scheduler.Added == 0; tick++) + { + clock.Advance(TimeSpan.FromSeconds(30)); + await Task.Delay(10, Token); + } + + await service.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(10), Token); + await service.StopAsync(Token); + + scheduler.Added.ShouldBe(1); + } + + [Fact] + public async Task TheJobRunsThroughTheRunnerAndItsScheduleIsStoredOnceHoweverOftenItStarts() + { + using var database = new ServiceDatabase(); + var old = await TrashedAsync(database, "Old", Long); + var scheduler = database.Database.Services.GetRequiredService(); + var id = await scheduler.EnqueueAsync(new TrashPurgePayload(), cancellationToken: Token); + + (await database.Database.Services.GetRequiredService().RunNextAsync(JobRunnerOptions.DefaultQueue, "worker", Token)).ShouldBeTrue(); + + Exists(database, old).ShouldBeFalse(); + database.Database.Services.GetRequiredService().Set().Single(job => job.Id == id).Status.ShouldBe(JobStatus.Succeeded); + + var service = database.Database.Services.GetServices().OfType().Single(); + for (var start = 0; start < 2; start++) + { + await service.StartAsync(Token); + await service.StopAsync(Token); + } + + var recurring = database.Database.Services.GetRequiredService().Set().AsNoTracking().Single(job => job.Name == TrashPurgeJob.RecurringName); + recurring.Type.ShouldBe(TrashPurgeJob.Type); + recurring.Cron.ShouldBe(TrashPurgeJob.Cron); + recurring.Enabled.ShouldBeTrue(); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/TrashTests.cs b/tests/FluentCMS.Data.Tests/Content/TrashTests.cs new file mode 100644 index 000000000..ff924babb --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Content/TrashTests.cs @@ -0,0 +1,434 @@ +using FluentCMS.Content; +using FluentCMS.Core.Authorization; +using FluentCMS.Data.Tests.Authorization; +using FluentCMS.Data.Tests.Events; +using FluentCMS.Events; +using FluentCMS.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Content; + +public sealed class TrashTests : IDisposable +{ + private readonly ServiceDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private Guid Purger() => _database.Database.AddRoleForTest( + "Purger", + ("content.*.create", null), + ("content.*.read", null), + ("content.*.update", null), + ("content.*.delete", null), + ("content.*.restore", null), + ("content.*.publish", null), + ("content.*.purge", null)); + + private async Task TrashedAsync(string title, Principal? by = null, string? code = null) + { + var entry = new ContentDocument { ["title"] = title }; + if (code is not null) + { + entry["code"] = code; + } + + var id = IdOf(await _database.CreateAsync(by ?? Editor, entry)); + await _database.DeleteAsync(by ?? Editor, id); + return id; + } + + [Fact] + public async Task ATrashedEntryLeavesEveryReadAndComesBackWithItsVersions() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("Hello"))); + await _database.PatchAsync(Editor, id, Titled("Two"), 1); + await _database.PublishAsync(Editor, id); + await _database.PatchAsync(Editor, id, Titled("Three"), 2); + var versions = _database.Versions(id).Select(version => (version.Locale, version.Number, version.StatusAfter, version.Snapshot)).ToList(); + + await _database.DeleteAsync(Editor, id); + + (await _database.GetAsync(Editor, id)).ShouldBeNull(); + (await _database.GetAsync(Editor, id, new GetOptions())).ShouldBeNull(); + (await _database.QueryAsync(Editor)).Items.ShouldBeEmpty(); + (await _database.QueryAsync(Editor, new ContentQuery { Status = QueryStatus.Published })).Items.ShouldBeEmpty(); + await Should.ThrowAsync(() => _database.VersionsAsync(Editor, id)); + await Should.ThrowAsync(() => _database.VersionAsync(Editor, id, 1)); + await Should.ThrowAsync(() => _database.PublishAsync(Editor, id)); + + var restored = await _database.RestoreAsync(Editor, id); + + restored["title"].ShouldBe("Three"); + restored["version"].ShouldBe(3); + _database.Versions(id).Select(version => (version.Locale, version.Number, version.StatusAfter, version.Snapshot)).ShouldBe(versions); + (await _database.VersionsAsync(Editor, id)).Count.ShouldBe(3); + (await _database.GetAsync(Editor, id, new GetOptions()))!["title"].ShouldBe("Two"); + } + + [Fact] + public async Task TheTrashListsWhatWasDeletedWithWhenAndByWhom() + { + var kept = IdOf(await _database.CreateAsync(Editor, Titled("Kept"))); + var one = await TrashedAsync("One"); + var two = await TrashedAsync("Two"); + + var page = await _database.TrashAsync(Editor); + + page.Items.Select(entry => (Guid)entry["id"]!).ShouldBe([one, two], ignoreOrder: true); + page.Items.Select(entry => (Guid)entry["id"]!).ShouldNotContain(kept); + foreach (var entry in page.Items) + { + entry["deletedAt"].ShouldNotBeNull(); + entry["deletedBy"].ShouldBe(Editor.Id); + } + + page.Items.Single(entry => (Guid)entry["id"]! == one)["title"].ShouldBe("One"); + + var first = await _database.TrashAsync(Editor, new ContentQuery { Page = new PageRequest { Size = 1 } }); + first.Items.Count.ShouldBe(1); + first.NextCursor.ShouldNotBeNull(); + var second = await _database.TrashAsync(Editor, new ContentQuery { Cursor = first.NextCursor, Page = new PageRequest { Size = 1 } }); + second.Items.Count.ShouldBe(1); + second.Items[0]["id"].ShouldNotBe(first.Items[0]["id"]); + + await _database.RestoreAsync(Editor, one); + (await _database.TrashAsync(Editor)).Items.ShouldHaveSingleItem()["id"].ShouldBe(two); + } + + [Fact] + public async Task OnlyWhoCanRestoreOrPurgeAndReadMayLookAtTheTrash() + { + await TrashedAsync("Hidden"); + var restoreOnly = _database.Database.AddRoleForTest("RestoreOnly", ("content.article.read", null), ("content.article.restore", null)); + var purgeOnly = _database.Database.AddRoleForTest("PurgeOnly", ("content.article.read", null), ("content.article.purge", null)); + var readOnly = _database.Database.AddRoleForTest("ReadOnly", ("content.article.read", null)); + var blind = _database.Database.AddRoleForTest("Blind", ("content.article.restore", null), ("content.article.purge", null)); + + (await _database.TrashAsync(WithRole(restoreOnly))).Items.ShouldHaveSingleItem(); + (await _database.TrashAsync(WithRole(purgeOnly))).Items.ShouldHaveSingleItem(); + await Should.ThrowAsync(() => _database.TrashAsync(WithRole(readOnly))); + await Should.ThrowAsync(() => _database.TrashAsync(AuthorRole())); + + // Without the read the type is not there for it, which is what a type that does not exist says. + var unknown = await Should.ThrowAsync(() => _database.TrashAsync(Editor, type: "nothing")); + var unreadable = await Should.ThrowAsync(() => _database.TrashAsync(WithRole(blind))); + unreadable.Errors.Select(error => (error.Path, error.Code)).ShouldBe(unknown.Errors.Select(error => (error.Path, error.Code))); + await Should.ThrowAsync(() => _database.TrashAsync(Principal.Anonymous)); + } + + [Fact] + public async Task TheTrashListHoldsTheReadRowRuleAndTheFieldLimits() + { + var role = _database.Database.AddRoleForTest( + "Tidy", + ("content.*.create", null), + ("content.*.read", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule, Fields = ["title"] }), + ("content.*.delete", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule }), + ("content.*.restore", null)); + var ann = WithRole(role, Guid.CreateVersion7()); + var bob = WithRole(role, Guid.CreateVersion7()); + var mine = IdOf(await _database.CreateAsync(ann, new() { ["title"] = "Mine", ["region"] = "eu" })); + var theirs = IdOf(await _database.CreateAsync(bob, Titled("Theirs"))); + await _database.DeleteAsync(ann, mine); + await _database.DeleteAsync(bob, theirs); + + var listed = (await _database.TrashAsync(ann)).Items.ShouldHaveSingleItem(); + + listed["id"].ShouldBe(mine); + listed["title"].ShouldBe("Mine"); + listed.ContainsKey("region").ShouldBeFalse(); + (await _database.TrashAsync(Editor)).Items.Count.ShouldBe(2); + } + + [Fact] + public async Task TheTrashOfAnotherSiteOrLocaleIsNotListed() + { + var inA = IdOf(await _database.CreateAsync(Editor, Titled("In A"))); + var inB = IdOf(await _database.CreateAsync(Editor, Titled("In B"), site: SiteB)); + await _database.DeleteAsync(Editor, inA); + await _database.DeleteInAsync(Editor, inB, SiteB); + var french = _database.Database.AddRoleForTest("French", ("content.article.read", null), ("content.article.restore", new PermissionConstraints { Locales = ["fr"] })); + + (await _database.TrashAsync(Editor)).Items.ShouldHaveSingleItem()["id"].ShouldBe(inA); + (await _database.TrashAsync(Editor, site: SiteB)).Items.ShouldHaveSingleItem()["id"].ShouldBe(inB); + await Should.ThrowAsync(() => _database.TrashAsync(Editor, new ContentQuery { Locale = "all" })); + await Should.ThrowAsync(() => _database.TrashAsync(WithRole(french), locale: "en")); + (await _database.TrashAsync(WithRole(french), locale: "fr")).Items.ShouldBeEmpty(); + } + + [Fact] + public async Task ATargetInTheTrashNeverMatchesAFilterOrFillsAPopulate() + { + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, type: "author")); + var inTrash = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Written", ["author"] = ann })); + await _database.DeleteAsync(Editor, inTrash); + var byAnn = new ContentQuery { Filter = new FieldFilter("author.name", FilterOperator.Equal, "Ann"), Populate = ["author"] }; + + var withLiveAuthor = (await _database.TrashAsync(Editor, byAnn)).Items.ShouldHaveSingleItem(); + withLiveAuthor["id"].ShouldBe(inTrash); + ((ContentDocument)withLiveAuthor["author"]!)["name"].ShouldBe("Ann"); + + await _database.DeleteAsync(Editor, ann, type: "author"); + + (await _database.TrashAsync(Editor, byAnn)).Items.ShouldBeEmpty(); + var unpopulated = (await _database.TrashAsync(Editor, new ContentQuery { Populate = ["author"] })).Items.ShouldHaveSingleItem(); + ((ContentDocument)unpopulated["author"]!).ContainsKey("name").ShouldBeFalse(); + (await _database.TrashAsync(Editor, type: "author")).Items.ShouldHaveSingleItem()["id"].ShouldBe(ann); + } + + private async Task MatchesAsync(Guid target, string field, QueryStatus status, bool trash = false) + { + var query = new ContentQuery { Filter = new FieldFilter(field, FilterOperator.Equal, target), Status = status }; + return (trash ? await _database.TrashAsync(Editor, query) : await _database.QueryAsync(Editor, query)).Items.Count; + } + + [Fact] + public async Task AFilterOnTheIdOfARelationMatchesOnlyATargetTheSameReadWouldShow() + { + var tag = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Net" }, type: "tag")); + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, type: "author")); + var entry = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Tagged", ["tags"] = new[] { tag }, ["author"] = ann })); + var draft = QueryStatus.Draft; + var published = QueryStatus.Published; + + // A target that exists but is not published is not shown by a published read. + (await MatchesAsync(tag, "tags", draft)).ShouldBe(1); + (await MatchesAsync(ann, "author", draft)).ShouldBe(1); + await _database.PublishAsync(Editor, entry); + (await MatchesAsync(tag, "tags", published)).ShouldBe(0); + (await MatchesAsync(ann, "author", published)).ShouldBe(0); + + await _database.PublishAsync(Editor, tag, type: "tag"); + await _database.PublishAsync(Editor, ann, type: "author"); + (await MatchesAsync(tag, "tags", published)).ShouldBe(1); + (await MatchesAsync(ann, "author", published)).ShouldBe(1); + + // A target in the trash is not shown by any read, whether its type is global or belongs to a site. + await _database.DeleteAsync(Editor, tag, type: "tag"); + await _database.DeleteAsync(Editor, ann, type: "author"); + (await MatchesAsync(tag, "tags", draft)).ShouldBe(0); + (await MatchesAsync(ann, "author", draft)).ShouldBe(0); + (await MatchesAsync(tag, "tags", published)).ShouldBe(0); + (await MatchesAsync(ann, "author", published)).ShouldBe(0); + var anyOf = new ContentQuery { Filter = new FieldFilter("tags", FilterOperator.In, new[] { tag }), Status = draft }; + (await _database.QueryAsync(Editor, anyOf)).Items.ShouldBeEmpty(); + + // The same in the trash list. + await _database.RestoreAsync(Editor, tag, type: "tag"); + await _database.RestoreAsync(Editor, ann, type: "author"); + await _database.DeleteAsync(Editor, entry); + (await MatchesAsync(tag, "tags", draft, trash: true)).ShouldBe(1); + (await MatchesAsync(ann, "author", draft, trash: true)).ShouldBe(1); + await _database.DeleteAsync(Editor, tag, type: "tag"); + await _database.DeleteAsync(Editor, ann, type: "author"); + (await MatchesAsync(tag, "tags", draft, trash: true)).ShouldBe(0); + (await MatchesAsync(ann, "author", draft, trash: true)).ShouldBe(0); + } + + [Fact] + public async Task AFilterOnTheIdOfARelationDoesNotMatchAnEntryOfAnotherType() + { + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, type: "author")); + var other = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Other", ["author"] = ann })); + + (await MatchesAsync(other, "tags", QueryStatus.Draft)).ShouldBe(0); + (await MatchesAsync(ann, "author", QueryStatus.Draft)).ShouldBe(1); + } + + [Fact] + public async Task PurgeDeletesEveryRowOfAnEntryInEveryLocaleAndEveryVersion() + { + var id = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Hello", ["code"] = "one" })); + await _database.UpdateAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + await _database.PublishAsync(Editor, id); + await _database.PublishAsync(Editor, id, "fr"); + await _database.PatchAsync(Editor, id, Titled("Hello again"), 1); + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, type: "author")); + await _database.PatchAsync(Editor, id, new() { ["author"] = ann }, 2); + var other = IdOf(await _database.CreateAsync(Editor, Titled("Other"))); + _database.Held(id).ShouldNotBe(TrashDatabase.Nothing); + _database.Versions(id).ShouldContain(version => version.StatusAfter == EntryStatus.Published); + await _database.DeleteAsync(Editor, id); + var otherHeld = _database.Held(other); + + await _database.PurgeAsync(WithRole(Purger()), id); + + _database.Held(id).ShouldBe(TrashDatabase.Nothing); + _database.Held(other).ShouldBe(otherHeld); + (await _database.TrashAsync(Editor)).Items.ShouldBeEmpty(); + await Should.ThrowAsync(() => _database.RestoreAsync(Editor, id)); + (await _database.GetAsync(Editor, ann, type: "author"))!["name"].ShouldBe("Ann"); + } + + [Fact] + public async Task PurgeNeedsItsOwnPermission() + { + var id = await TrashedAsync("Hello"); + var restorer = _database.Database.AddRoleForTest("Restorer", ("content.*.restore", null), ("content.*.read", null)); + + await Should.ThrowAsync(() => _database.PurgeAsync(Editor, id)); + await Should.ThrowAsync(() => _database.PurgeAsync(AuthorRole(), id)); + await Should.ThrowAsync(() => _database.PurgeAsync(Principal.Anonymous, id)); + await Should.ThrowAsync(() => _database.PurgeAsync(WithRole(restorer), id)); + _database.Held(id).ShouldNotBe(TrashDatabase.Nothing); + + await _database.PurgeAsync(WithRole(BuiltInRoles.SuperAdmin), id); + + _database.Held(id).ShouldBe(TrashDatabase.Nothing); + } + + [Fact] + public async Task OnlyAnEntryInTheTrashCanBePurged() + { + var live = IdOf(await _database.CreateAsync(Editor, Titled("Alive"))); + var author = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, type: "author")); + await _database.DeleteAsync(Editor, author, type: "author"); + var admin = WithRole(BuiltInRoles.SuperAdmin); + + await Should.ThrowAsync(() => _database.PurgeAsync(admin, live)); + await Should.ThrowAsync(() => _database.PurgeAsync(admin, Guid.CreateVersion7())); + await Should.ThrowAsync(() => _database.PurgeAsync(admin, author)); + + _database.Held(live).ShouldNotBe(TrashDatabase.Nothing); + (await _database.TrashAsync(Editor, type: "author")).Items.ShouldHaveSingleItem(); + } + + [Fact] + public async Task AnEntryThePurgeRuleExcludesIsNotFoundWhateverTheIdIsAndStaysInTheTrash() + { + var role = _database.Database.AddRoleForTest( + "OwnPurger", + ("content.*.create", null), + ("content.*.delete", null), + ("content.*.purge", new PermissionConstraints { RowRule = BuiltInRoles.OwnEntriesRule })); + var ann = WithRole(role, Guid.CreateVersion7()); + var bob = WithRole(role, Guid.CreateVersion7()); + var mine = IdOf(await _database.CreateAsync(ann, Titled("Mine"))); + var theirs = IdOf(await _database.CreateAsync(bob, Titled("Theirs"))); + await _database.DeleteAsync(ann, mine); + await _database.DeleteAsync(bob, theirs); + var missing = Guid.CreateVersion7(); + + var refused = await Should.ThrowAsync(() => _database.PurgeAsync(ann, theirs)); + var absent = await Should.ThrowAsync(() => _database.PurgeAsync(ann, missing)); + refused.Message.Replace(theirs.ToString(), "{id}", StringComparison.Ordinal).ShouldBe(absent.Message.Replace(missing.ToString(), "{id}", StringComparison.Ordinal)); + _database.Held(theirs).ShouldNotBe(TrashDatabase.Nothing); + + await _database.PurgeAsync(ann, mine); + + _database.Held(mine).ShouldBe(TrashDatabase.Nothing); + (await _database.TrashAsync(Editor)).Items.ShouldHaveSingleItem()["id"].ShouldBe(theirs); + } + + [Fact] + public async Task PurgeNeedsTheGrantOnEveryLocaleOfTheEntryAndTheSiteOfIt() + { + var both = IdOf(await _database.CreateAsync(Editor, Titled("Hello"))); + await _database.UpdateAsync(Editor, both, Titled("Bonjour"), 0, locale: "fr"); + var englishOnly = IdOf(await _database.CreateAsync(Editor, Titled("Only English"))); + var elsewhere = IdOf(await _database.CreateAsync(Editor, Titled("In B"), site: SiteB)); + await _database.DeleteAsync(Editor, both); + await _database.DeleteAsync(Editor, englishOnly); + await _database.DeleteInAsync(Editor, elsewhere, SiteB); + var french = WithRole(_database.Database.AddRoleForTest("FrenchPurger", ("content.article.purge", new PermissionConstraints { Locales = ["fr"] }))); + var admin = WithRole(BuiltInRoles.SuperAdmin); + + await Should.ThrowAsync(() => _database.PurgeAsync(french, both, locale: "fr")); + await Should.ThrowAsync(() => _database.PurgeAsync(french, englishOnly, locale: "fr")); + await Should.ThrowAsync(() => _database.PurgeAsync(french, both, locale: "en")); + await Should.ThrowAsync(() => _database.PurgeAsync(admin, elsewhere)); + + new[] { both, englishOnly, elsewhere }.ShouldAllBe(id => _database.Held(id) != TrashDatabase.Nothing); + + await _database.PurgeAsync(admin, elsewhere, site: SiteB); + _database.Held(elsewhere).ShouldBe(TrashDatabase.Nothing); + } + + [Fact] + public async Task APurgeInATransactionThatRollsBackLeavesTheEntryInTheTrash() + { + var id = IdOf(await _database.CreateAsync(Editor, Titled("Hello"))); + await _database.PublishAsync(Editor, id); + await _database.DeleteAsync(Editor, id); + var before = _database.Trace(id); + + await _database.AsAsync(WithRole(BuiltInRoles.SuperAdmin), async services => + { + await using var transaction = await services.GetRequiredService().Database.BeginTransactionAsync(Token); + await services.GetRequiredService().PurgeAsync("article", id, Token); + await transaction.RollbackAsync(Token); + return true; + }); + + _database.Trace(id).ShouldBe(before); + (await _database.RestoreAsync(Editor, id))["title"].ShouldBe("Hello"); + } + + [Fact] + public async Task OnlyAPurgeFreesTheValuesTheTrashHolds() + { + var id = await TrashedAsync("First", code: "taken"); + var again = await Should.ThrowAsync(() => _database.CreateAsync(Editor, new() { ["title"] = "Second", ["code"] = "taken" })); + again.Errors.ShouldNotBeEmpty(); + + await _database.PurgeAsync(WithRole(Purger()), id); + + (await _database.CreateAsync(Editor, new() { ["title"] = "Second", ["code"] = "taken" }))["code"].ShouldBe("taken"); + } + + [Fact] + public async Task APurgedTargetIsAsMissingAsATrashedOneWasForPublishing() + { + var ann = IdOf(await _database.CreateAsync(Editor, new() { ["name"] = "Ann" }, type: "author")); + await _database.PublishAsync(Editor, ann, type: "author"); + var source = IdOf(await _database.CreateAsync(Editor, new() { ["title"] = "Written", ["author"] = ann })); + (await _database.PublishAsync(Editor, source)).Warnings.ShouldBeEmpty(); + + await _database.DeleteAsync(Editor, ann, type: "author"); + await _database.PatchAsync(Editor, source, Titled("Trashed target"), 1); + var trashed = (await _database.PublishAsync(Editor, source)).Warnings.ShouldHaveSingleItem(); + (await _database.GetAsync(Editor, source, new GetOptions()))!["author"].ShouldBeNull(); + + await _database.PurgeAsync(WithRole(BuiltInRoles.SuperAdmin), ann, type: "author"); + await _database.PatchAsync(Editor, source, Titled("Purged target"), 2); + var purged = (await _database.PublishAsync(Editor, source)).Warnings.ShouldHaveSingleItem(); + + purged.Code.ShouldBe(PublishWarningCodes.UnavailableReference); + purged.Code.ShouldBe(trashed.Code); + (await _database.GetAsync(Editor, source, new GetOptions()))!["author"].ShouldBeNull(); + } + + private sealed class Heard(Seen seen) : IEventHandler + { + public ValueTask HandleAsync(EntryPurgedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("purged", message, null, cancellationToken); + } + + [Fact] + public async Task APurgeRaisesOneEventPerLocaleOnlyWhenItCommits() + { + var seen = new Seen(); + using var database = new ServiceDatabase(cms => + { + cms.Services.AddSingleton(seen); + cms.Events.AddHandler(); + }); + var id = IdOf(await database.CreateAsync(Editor, Titled("Hello"))); + await database.UpdateAsync(Editor, id, Titled("Bonjour"), 0, locale: "fr"); + await database.PatchAsync(Editor, id, Titled("Hello again"), 1); + await database.DeleteAsync(Editor, id); + var admin = WithRole(BuiltInRoles.SuperAdmin); + + await Should.ThrowAsync(() => database.PurgeAsync(Editor, id)); + seen.Of().ShouldBeEmpty(); + + await database.PurgeAsync(admin, id); + + var heard = seen.Of().OrderBy(@event => @event.Locale, StringComparer.Ordinal).ToList(); + heard.Select(@event => (@event.Type, @event.EntryId, @event.Locale, @event.Version)).ShouldBe([("article", id, "en", 2), ("article", id, "fr", 1)]); + } +} diff --git a/tests/FluentCMS.Data.Tests/Content/TypedContentServiceTests.cs b/tests/FluentCMS.Data.Tests/Content/TypedContentServiceTests.cs index a07384316..3dca4acd2 100644 --- a/tests/FluentCMS.Data.Tests/Content/TypedContentServiceTests.cs +++ b/tests/FluentCMS.Data.Tests/Content/TypedContentServiceTests.cs @@ -112,6 +112,29 @@ await AsAsync(Editor, async service => (await AsAsync(Editor, service => service.RestoreAsync(read.Id, Token))).Title.ShouldBe("Changed"); } + [Fact] + public async Task ATrashedClassIsListedAndPurgedLikeADocument() + { + var created = await AsAsync(Editor, service => service.CreateAsync(new Tag { Name = "Net Core" }, Token)); + await AsAsync(Editor, async service => + { + await service.DeleteAsync(created.Id, Token); + return true; + }); + + var listed = (await AsAsync>(Editor, service => service.QueryTrashAsync(new ContentQuery(), Token))).Items.ShouldHaveSingleItem(); + listed.Id.ShouldBe(created.Id); + listed.Name.ShouldBe("Net Core"); + + var admin = WithRole(BuiltInRoles.SuperAdmin); + await AsAsync(admin, async service => + { + await service.PurgeAsync(created.Id, Token); + return true; + }); + (await AsAsync>(Editor, service => service.QueryTrashAsync(new ContentQuery(), Token))).Items.ShouldBeEmpty(); + } + [Fact] public async Task ASingletonRoundTrips() {