Skip to content

Add the content service with optimistic concurrency - #2747

Merged
pournasserian merged 5 commits into
mainfrom
2693-content-service
Nov 2, 2026
Merged

pournasserian merged 5 commits into
mainfrom
2693-content-service

Conversation

@pournasserian

@pournasserian pournasserian commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Closes #2693

Adds IContentService over ContentDocument and IContentService<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 reuses EntryStore. No system migration: nothing in the system model changed.

Dependencies: none.

Acceptance

Under Q19, "all providers" means SQLite until #2711.

  • The sample Blog types round-trip through IContentService<T> in the test host on all providers. SQLite only. FluentCMS.Testing (FluentCMS.Testing: in-process test host and fixtures #2705) and the Blog types (Sample Blog content types and seed API #2706) do not exist, so this uses SampleSchema in the test suite, on a SQLite file through the real package.
  • A stale expectedVersion fails 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 at BEGIN IMMEDIATE and normally sees the new version before it reaches the swap.
  • A principal without content.article.read on 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.
  • No code outside the service and the storage layer touches CmsDbContext for content (architecture test). The rules scan every assembly under src, 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) and dotnet 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 LayeringRulesTests and ContentTableReaches, over every assembly under src. FluentCMS.Data.* and FluentCMS.Cli count as the storage layer. The test project now references every source project so their assemblies are there to scan. The IL scan uses Mono.Cecil, which ArchUnitNET already brings in, so no package is added.

  • CmsDbContext: Core.Content, Core.Storage, the providers and the tool, plus Core.Sites, LocaleRegistry and AuthorizationEvaluator for their own system tables, and FluentCmsServiceCollectionExtensions to register it.
  • Document, Reference, ContentVersion: Core.Content, Core.Storage and the providers, plus AuthorizationEvaluator (is this entry in the site) and SiteManager (does this site have entries). Neither reads content.
  • A content table with no class: Rows, Set<Dictionary<...>>, Set<T>(name), Database.*, ExecuteSql* and FromSql* only in Core.Content, Core.Storage, the providers and the tool. The one exception is JobStore and RecurringJobs from Add the database-backed job runner #2748, which open transactions on SystemDbContext for the job tables. The evaluator's row rule goes through QueryScope in Content/Query.
  • EntryStore: Core.Content and the DI extensions only. Inside Core.Content the rule cannot separate the service from the query engine or the store: Content/Query is a peer, and the evaluator calls it.

Beyond the specification

Decided here, and worth a reviewer's eye:

  • Contract. PatchAsync is its own method, because a class cannot express absence. expectedVersion is a required int, so no caller skips it. Adds EntryNotFoundException, ContentAccessDeniedException, SystemOperation and ValidationErrorCodes.NotUnique (FCMS2111), and ContentErrorCodes.SingletonExists (FCMS2401) in Core. Spec 4.4 names none of these.
  • Refusals. Lacking the permission on the type is ContentAccessDeniedException. An entry that does not exist, in another site or of another type, or refused by its row rule is EntryNotFoundException, so a client cannot probe; a read returns null. An unreadable type on a read stays the engine's ValidationException on type, the same as an unknown type, as Content query model: filter AST, LINQ compiler, relation paths, projection and population #2692 decided.
  • No ambient operation throws, rather than running as anonymous. Principal.System from Api, Admin or Mcp throws; only Job, Cli and Seed may use it, through SystemOperation.Begin.
  • query.Site must be the operation's site, or a validation error on site, except for the system principal. The entry point resolves the site (spec 4.6).
  • Full update semantics. A field left out is cleared, unless it is hidden, read-only, unwritable or unreadable for this principal, which then keeps its value. A field it cannot write may be sent back unchanged, so a read and a write round-trip. "Unchanged" means equal to what a read showed this principal, on this row and with the targets it may read, so a refusal tells it nothing. A value naming a hidden field inside it is never unchanged.
  • Relation targets the principal cannot read survive a write. Each goes back to its old position in a multiple relation, clamped, on a full update or a patch, so a list sent as it was read is no change at all. A single relation sent empty on a full update keeps its target; an explicit null in a patch still clears, if the field is writable. A target is kept only if it still exists, trashed counts, and this principal cannot read it. The system principal keeps nothing it did not send.
  • Hidden and read-only values inside components and blocks survive what a client sends: a component omitted, null or empty comes back holding only those values, and so does one nested in a block. In a list, an incoming block keeps the values of the stored block of the same type with identical visible content, each used once, and the rest pair up in order.
  • Locale limits and shared fields. A non-localizable field of a localized type needs a grant with no locale limit, or one covering every locale of the entry, including one being added. Otherwise it is unwritable and kept. Two grants that cover the locales only together do not count. Adding a locale the grant does not cover takes the field away from it.
  • Slugs are not derived from a source the principal cannot read, or from a hidden one, even for the system principal. The slug keeps its stored value, or stays empty.
  • fallbacks is 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.
  • Validation errors. A value the client sent is always reported, even on a field it cannot read back, so a refusal does not say whether it equals the stored one. Sent is decided at the full path: a patch of seo.image has not sent seo.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.
  • Typed writes leave out what the principal cannot read, on an update, and hidden and read-only properties unless it is the system principal. A read leaves hidden properties at their defaults, so the system principal sends a hidden property on a create, and on an update only when it holds a value other than its default. Setting a hidden value back to its default needs a patch. A hook still fills a hidden property.
  • Transactions. A write inside an open transaction takes a savepoint, so a failure leaves nothing for that transaction to commit. Where savepoints are not supported, the failure rolls the whole transaction back and rethrows. ContentService.InTransactionAsync is 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.
  • A caller's unsaved work in the same context is left alone: a write saves only its own rows and holds back the rest, whether it succeeds or fails.
  • A create is decided per grant. After the insert, a field the client sent or a hook changed must be allowed by a grant whose row rule holds for the new entry, or the write rolls back.
  • The sink hears nothing of a write in a transaction the service did not begin, because the end of one cannot be seen and an entry that may roll back is never told. That loses the change on a commit, by design. Delete and restore tell one change per locale. A sink that throws is logged per sink. Sinks and the response read run on CancellationToken.None, so a cancelled caller does not fail a committed write.
  • Global types. Writing one needs a grant with no site limit, from a role with no 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.
  • Hooks and field limits. A change by a before hook to a field outside the principal's grants is refused (FCMS1406), and the whole operation fails. A change to a Hidden or ReadOnly field 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 by RejectUnwritable, 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.
  • Validation runs again only if BeforeCreate or BeforeUpdate changed 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.
  • Delete and restore hooks see the entry and may cancel. What they change on it is ignored, since nothing is written.
  • Adding a locale is UpdateAsync with expectedVersion: 0. It needs update, not create. The row rule reads an existing locale through a new AccessTarget.RowLocale, the grant's locale limit reads the new one, and the new row copies the owner from the existing one, so own-entries keeps working. Shared fields left out are kept.
  • Row rule after the write. A create or update that puts the entry outside the rule that allowed it is refused and rolled back, like WITH CHECK.
  • Delete and restore apply to every locale, and need the operation on all of them: none reached is not found, some reached is denied.
  • A change to a shared field moves the other locales from Published to Modified. Their draft_version does not move.
  • Uniqueness counts the trash, because the partial unique index does and a restore would bring the value back. A slug is freed only by purge (Trash with retention and purge job #2696). Empty values do not clash. A refusal also reveals that some entry holds the value, even one the principal cannot read.
  • Slug derives from its source when empty, on every save, within the limits above, and is not suffixed on a clash. Empty is the placeholder.
  • Placeholders go in during coercion, so hooks and validators see '' and the empty document, not null.
  • Singletons allow one entry per site, trash included.
  • cms_reference also 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.
  • A read by id with no row in the locale is served from the first chain locale that has one, under the requested locale, with every field it holds named in fallbacks. It is also limited by what the requested locale lets the principal read. A list still leaves it out (decision 44).
  • locale=all returns { 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. ReadAllAsync ignores Select, as Sites and locales in core: default site, resolvers, fallback chain and global scope #2691 wrote it.
  • Typed reads include createdBy and updatedBy where permitted, through an internal engine flag; documents leave them out, as the response shape does. A class cannot hold locale=all or rendered rich text, so those are refused.
  • Typed writes do not name a property with no value. A property that cannot be read back cannot be written through a class; PatchAsync is the way.
  • createdBy/updatedBy on 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.
  • Audit fields. created_by, updated_by, deleted_by and author_id come from OperationContext.AuditPrincipal (Add the database-backed job runner #2748), so a job records who it acts for. Authorization still reads Principal alone, so an actor gives a job nothing and takes nothing from it.
  • Timestamps on a save are OperationContext.Timestamp, so a long job writes one time.
  • ChangedPaths no longer lists system fields, because a save always moves the version.

Known gaps

  • A read-only field inside a block must be left out of a write, because naming it is refused, so sending back a read of such a block is refused.
  • Spec 3.x does not list fallbacks among the reserved names yet.
  • Shared fields across locales are last write wins: expectedVersion is per (document, locale) as the spec says, so two writers in two locales can both change the shared row.
  • Beyond SQLite: adding a locale in a race is a unique violation, not a conflict, and uniqueness and the singleton check have a race between the check and the commit that BEGIN IMMEDIATE closes on SQLite only. SQL Server with MARS reports no savepoints, so a failed joined write there rolls the whole transaction back. If a hook catches that failure after the rollback, the outer write may continue outside a transaction and then fail at commit. SQLite cannot exercise the real path; a test stands in with a transaction that reports none. Validate and complete the PostgreSQL and SQL Server providers #2711 should cover these.
  • Blocks with no ids: leftovers pair in order within their type, so removing one block and editing the next of that type gives the edited block the removed one's hidden values. A hidden value also stays on a block edited in place. A separate issue will cover stable block ids.
  • Relation targets are not checked on write, not even that they exist, and a read drops an id it cannot find in the site. That is Publishing state machine: states, transitions, versions, diff, restore and reference integrity #2695's integrity check.
  • A hook that opens its own scope gets its own connection and waits on the write lock. Only a call through the same scope joins the transaction.
  • Rich text rendering has no entry or media URL resolver, so links render as placeholders.

What later issues must do

@pournasserian pournasserian mentioned this pull request Nov 2, 2026
4 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IContentService and IContentService<T> with optimistic concurrency

1 participant