Repository navigation
Add the content service with optimistic concurrency - #2747
Merged
Merged
Conversation
4 tasks done
This was referenced Nov 2, 2026
Publishing state machine: states, transitions, versions, diff, restore and reference integrity
#2695
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2693
Adds
IContentServiceoverContentDocumentandIContentService<T>with a reflection mapper, the one path to content where permissions, field limits, hooks, validation and versions meet. Get, query, create, full update, JSON merge patch, delete to trash, restore and count. Publish, unpublish and discard stay with #2695, which reusesEntryStore. No system migration: nothing in the system model changed.Dependencies: none.
Acceptance
Under Q19, "all providers" means SQLite until #2711.
Blogtypes round-trip throughIContentService<T>in the test host on all providers. SQLite only.FluentCMS.Testing(FluentCMS.Testing: in-process test host and fixtures #2705) and theBlogtypes (Sample Blog content types and seed API #2706) do not exist, so this usesSampleSchemain the test suite, on a SQLite file through the real package.expectedVersionfails with the current version attached. SQLite only. Two concurrent updates from two scopes have one winner, over five rounds. A second test drives the compare and swap directly from two contexts, because on SQLite the second writer waits atBEGIN IMMEDIATEand normally sees the new version before it reaches the swap.content.article.readon a field never sees that field in results: get, query, what a write returns, a hook's values,VersionConflictException.Current, a slug derived from it, a validation error, and whether a full update was refused. SQLite only.CmsDbContextfor content (architecture test). The rules scan every assembly undersrc, and a test holds them to that. A fixture proves the IL scan catches each way to a content table.Verification
On Windows:
dotnet build -c Release -warnaserror,dotnet test -c Release(2086 tests, all passed) anddotnet pack -c Release. No Markdown changed. The review's probes were also run against the fixes and are not in the suite; what they check is.What the architecture rule must allow
Rules in
LayeringRulesTestsandContentTableReaches, over every assembly undersrc.FluentCMS.Data.*andFluentCMS.Clicount as the storage layer. The test project now references every source project so their assemblies are there to scan. The IL scan usesMono.Cecil, which ArchUnitNET already brings in, so no package is added.CmsDbContext:Core.Content,Core.Storage, the providers and the tool, plusCore.Sites,LocaleRegistryandAuthorizationEvaluatorfor their own system tables, andFluentCmsServiceCollectionExtensionsto register it.Document,Reference,ContentVersion:Core.Content,Core.Storageand the providers, plusAuthorizationEvaluator(is this entry in the site) andSiteManager(does this site have entries). Neither reads content.Rows,Set<Dictionary<...>>,Set<T>(name),Database.*,ExecuteSql*andFromSql*only inCore.Content,Core.Storage, the providers and the tool. The one exception isJobStoreandRecurringJobsfrom Add the database-backed job runner #2748, which open transactions onSystemDbContextfor the job tables. The evaluator's row rule goes throughQueryScopeinContent/Query.EntryStore:Core.Contentand the DI extensions only. InsideCore.Contentthe rule cannot separate the service from the query engine or the store:Content/Queryis a peer, and the evaluator calls it.Beyond the specification
Decided here, and worth a reviewer's eye:
PatchAsyncis its own method, because a class cannot express absence.expectedVersionis a requiredint, so no caller skips it. AddsEntryNotFoundException,ContentAccessDeniedException,SystemOperationandValidationErrorCodes.NotUnique(FCMS2111), andContentErrorCodes.SingletonExists(FCMS2401) in Core. Spec 4.4 names none of these.ContentAccessDeniedException. An entry that does not exist, in another site or of another type, or refused by its row rule isEntryNotFoundException, so a client cannot probe; a read returns null. An unreadable type on a read stays the engine'sValidationExceptionontype, the same as an unknown type, as Content query model: filter AST, LINQ compiler, relation paths, projection and population #2692 decided.Principal.SystemfromApi,AdminorMcpthrows; onlyJob,CliandSeedmay use it, throughSystemOperation.Begin.query.Sitemust be the operation's site, or a validation error onsite, except for the system principal. The entry point resolves the site (spec 4.6).fallbacksis now a reserved field name, and is ignored on a write. On a full update, a localized field that is empty in this locale and sent with exactly what the chain would serve counts as not sent; a different value is an edit. So a translation that happens to equal the source needs a patch, which is explicit. The chain is read only when such a field exists.seo.imagehas not sentseo.metaTitle. Any other error on a field it cannot read is compared with the stored entry, in a second pass that runs only then: the same path and code there means it is not about this write, and it neither blocks nor shows. A new one, from a rule across fields, or a value a hook set, fails the write with a generic error that has no field or value. Uniqueness still runs after an error that was dropped.ContentService.InTransactionAsyncis internal, for Bulk operations with per-item results and a filter cap #2703: writes run through it are told to the sink after it commits.CancellationToken.None, so a cancelled caller does not fail a committed write.site_id, because a global entry is shared by every site. Reads stay as they are. The seeded instance-wide roles are unaffected, and spec 4.8 still has to say this.HiddenorReadOnlyfield is allowed, because no client can reach those and that is what a hook fills. Add the authorization evaluator and row rules #2743's comment says the system principal is how a hook sets a hidden field, but a hook cannot change its principal, so this differs from that note.[ReadOnly]is now enforced byRejectUnwritable, top level and nested, except for the system principal. Add the authorization evaluator and row rules #2743 did not. A read-only field inside a block must be left out of a write, so a typed round-trip of such a block is refused.BeforeCreateorBeforeUpdatechanged a field. Values go through coercion after every hook, so a hook's raw value is stored in canonical form. A key a hook adds that is not a field is a validation error.UpdateAsyncwithexpectedVersion: 0. It needsupdate, notcreate. The row rule reads an existing locale through a newAccessTarget.RowLocale, the grant's locale limit reads the new one, and the new row copies the owner from the existing one, soown-entrieskeeps working. Shared fields left out are kept.WITH CHECK.PublishedtoModified. Theirdraft_versiondoes not move.sourcewhen empty, on every save, within the limits above, and is not suffixed on a clash. Empty is the placeholder.''and the empty document, not null.cms_referencealso records ids inside blocks, repeated components and rich text, under the top-level field (sections,body), so integrity and usage can see them. The engine does not read those as relations. Inside JSON a reference is stored as{ id, type }, per Appendix A.locale, with every field it holds named infallbacks. It is also limited by what the requested locale lets the principal read. A list still leaves it out (decision 44).locale=allreturns{ id, locales: { en: {...} } }. A list pages over the site's default locale, so an entry with no row there is not listed; by id it is.ReadAllAsyncignoresSelect, as Sites and locales in core: default site, resolvers, fallback chain and global scope #2691 wrote it.createdByandupdatedBywhere permitted, through an internal engine flag; documents leave them out, as the response shape does. A class cannot holdlocale=allor rendered rich text, so those are refused.PatchAsyncis the way.createdBy/updatedByon a write: refused for clients, ignored for the system principal. Other system fields are ignored for everyone, and a hook's changes to them too.created_by,updated_by,deleted_byandauthor_idcome fromOperationContext.AuditPrincipal(Add the database-backed job runner #2748), so a job records who it acts for. Authorization still readsPrincipalalone, so an actor gives a job nothing and takes nothing from it.OperationContext.Timestamp, so a long job writes one time.ChangedPathsno longer lists system fields, because a save always moves the version.Known gaps
fallbacksamong the reserved names yet.expectedVersionis per(document, locale)as the spec says, so two writers in two locales can both change the shared row.What later issues must do
SaveAsync, onEntryStore, which reads and writes by state.cms_version.snapshotholds hidden fields, so version reads must filter them.FieldValue.IsEmpty(RichText.Empty)is false, soRequiredon publish must treat the empty document as missing, as it must''. Pass reference integrity through thesoundEntryCheckparameter ofValidationPipeline.IEntryChangeSink, which hears a change only after the outermost commit and must not throw.IContentServiceinsideContentService.InTransactionAsync, in the same scope, which also gives each failed item its savepoint and tells the sink after the commit. A transaction begun any other way is not told.Blogtypes, on every provider through Validate and complete the PostgreSQL and SQL Server providers #2711.VersionConflictExceptionto 412 withCurrent,EntryNotFoundExceptionto 404,ContentAccessDeniedExceptionto 401 or 403. SetIOperationContextAccessor.Currenton every request.