Skip to content

Store content types with their versions and the site record - #2455

Merged
pournasserian merged 4 commits into
v2from
2451-content-type-persistence
Oct 18, 2026
Merged

pournasserian merged 4 commits into
v2from
2451-content-type-persistence

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Closes #2451

What

The second slice of F-SCH (#2415): content types are stored and read back through an application service. This builds on the Core model from #2445.

  • Core:
    • Site holds the one well-known default site (SCH-07).
    • IContentTypeDirectory lists types, gets the current version, gets a given version, creates and revises, each returning a Result. A revision names the version it starts from.
    • ContentType.Restore rebuilds a stored type without validating it again, so a type stays readable after a field type it uses is gone.
    • IFieldType.ReferencedTypes (empty by default) lets a field type declare the content types it points at. reference returns its target, and list asks its item type through the registry.
  • Infrastructure:
    • Sites is seeded with the default site by the migration.
    • ContentTypes holds the site, name, slug and current version number, with (SiteId, Slug) unique.
    • ContentTypeVersions holds one row per version with the definition JSON, jsonb on PostgreSQL. (ContentTypeId, Version) is unique, and rows are inserted once and never changed.
    • ContentTypeDirectory checks slug uniqueness and reference targets against stored data. It writes through IUnitOfWork and appends content_type.created or content_type.revised through IAuditLog, with the content type as subject. The revised event's PreviousVersionId is the replaced version's row id.
  • Migrations ContentTypes for both providers, generated with the documented commands.
  • CONTRIBUTING.md: how a field type declares the types it references.

Why

SCH-01 and SCH-07 need the types stored and scoped to a site. The epic's acceptance needs earlier versions to stay readable. AUD-N1 needs every schema write audited in the same transaction.

Some choices worth a look:

  • Site: one row, seeded by the migration, with a fixed id in Core. Every type carries it and a foreign key enforces it. The directory scopes every read and write to it. Nothing else about sites exists yet, so MST-01 can decide how a caller picks a site.
  • Expected failures before the unit of work: the directory runs its checks before it opens its own scope. Inside a scope, an early return would dispose an uncommitted inner scope and doom the caller's outer unit of work. A test covers a failed create inside an outer unit of work that still commits.
  • Concurrency: two writers can both pass a check before either commits. A create then trips the (SiteId, Slug) index, and the directory maps that unique violation to the same slug_taken result. A revision moves the version pointer with UPDATE … WHERE Version = @base, so the second writer updates no row and gets version_conflict. The (ContentTypeId, Version) index backs this up. The mapping recognises SQLite SQLITE_CONSTRAINT_UNIQUE/PRIMARYKEY and PostgreSQL 23505.
  • No tracking on reads: a long-lived context, such as a Blazor circuit, never sees a stale tracked row.
  • Reference checks go through IFieldType.ReferencedTypes, not a special case for the built-in reference type. A plugin type that points at entries gets the same check (EXT-N1). This adds a defaulted member to the field type interface. Existing implementations are unaffected.
  • Test helpers: the integration tests use TemporaryDatabase and PostgresDataAttribute from Add reading and JSON and CSV export of the audit log #2449 to run every case on both providers.

Tests

Unit:

  • reference declares its target. list declares its item type's targets.
  • A definition lists each referencing field, including a plugin type's.
  • The conformance base gains "declares only slugs as referenced types", which all built-ins and both sample plugin types pass.
  • Restore keeps stored values without validating them.

Integration, each on SQLite and PostgreSQL:

  • A created type reads back in the default site at version 1 with an audit event naming the type and the initiator.
  • A revision stores version 2, version 1 stays readable, and the revised event points at the replaced version's row.
  • A taken slug, an unknown reference target, an invalid definition and a stale or future base version each fail, write nothing and append no event. A self-reference is allowed.
  • A create inside an abandoned unit of work leaves nothing.
  • A failed create inside the caller's unit of work leaves it able to commit.
  • Two concurrent creates of one slug and two concurrent revisions of one version each end with one winner and a failed Result. Disabling the unique-violation mapping makes the create test fail with DbUpdateException on both providers, and disabling the conditional update too makes the revision test fail, so both tests reach the database path.
  • The reference scenario's eight types can be defined.

PostgreSQL only: the definition column is jsonb. The existing migration test reverts the newest migration (ContentTypes) and reapplies it on SQLite.

All of this ran locally against a postgres:17 container as well as SQLite. The schema tests passed five runs in a row.

No new package and no new project reference.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 18, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:schema Content types and entity types (SCH) labels Oct 18, 2026
@pournasserian
pournasserian merged commit 730dd1f into v2 Oct 18, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2451-content-type-persistence branch October 18, 2026 16:48
pournasserian added a commit that referenced this pull request Oct 18, 2026
Closes #2461

## What

The third slice of F-SCH (#2415): entity types (SCH-03) and references
to an entity or one of its fields (SCH-04), on top of the stored content
types from #2451. There is no migration.

- **Entity flag**: the definition document gains `"entity": true|false`,
written first and read back as `ContentTypeDefinition.IsEntity`. A
stored document without it (everything written by #2455) reads as a
plain content type.
- **Fact metadata**: `FactMetadata` sets the names every field of an
entity type carries next to its `value`: `source` (`url`, `file`,
`person` or `system`), `owner`, `validFrom`, `validUntil` and
`lastVerified`. A field cannot opt out, because the definition has no
syntax for it and an unknown field property such as `"fact": false` is
already rejected.
- **An entity type stays one**: `ContentType.Revise` rejects a
definition that drops the flag (`entity_flag_removed`). A content type
may still become an entity type.
- **Field references**: `reference` takes an optional `field`, as in
`{"target": "product", "field": "price"}`. `IFieldType.ReferencedTypes`
becomes `ReferenceTargets` and returns `ReferenceTarget(Type, Field?)`.
`list` passes on its item type's targets.
- **Checks against stored data** (`ContentTypeDirectory`):
- A field target must be a field of the target's current definition
(`reference_field_unknown`).
  - The target must be an entity type (`reference_target_not_entity`).
- A revision cannot remove a field another type references
(`field_referenced`, naming the referencing type and field).
- References to the type's own fields are checked against the definition
being written.
- **CONTRIBUTING.md**: the renamed member.

## Why

SCH-03 and SCH-04, and the F-SCH acceptance: every field on an entity
type carries source, owner and validity, a definition that drops them is
rejected, and `ProductPage` can reference `Product.price`.

Choices worth a look:

- **The flag lives in the versioned definition, not a column.** Flagging
a type is a schema change like any other, and SCH-05 will version it as
one. Nothing queries the flag yet. A column can come with the first
query that needs it.
- **"A definition that drops them" is read as two rules.**
  - A field of an entity type has no way to opt out of the metadata.
- An entity type cannot be revised into a plain content type, because
that would strip provenance from every fact stored under it.

If the maintainers want the flag to be reversible, for example through a
reviewed change set once SCH-05 exists, only the check in
`ContentType.Revise` changes.
- **Field references only target entity types.** SCH-04 names entity
fields, and only facts are worth pointing at field by field. Plain
content types are referenced as whole entries, as before.
- **`FactMetadata` is only names.** Checking a fact's value, whether it
is unsourced or stale, and its owner comes with F-ENT (#2417). Pinning
the names here keeps the schema document (SCH-06) and entity values in
agreement.
- **Removing a referenced field** is checked against every type's
current definition in the site, and only when a revision removes fields.
Tens of types per site keep that cheap (D26). Two concurrent schema
writers could still slip past each other. Schema changes going through
change sets (SCH-05) is where that gets serialised.
- **`ReferencedTypes` is renamed to `ReferenceTargets`** on the field
type extension point. It landed in #2455 and nothing outside this
repository implements it. There is no versioning policy for extension
interfaces yet (EXT-N2).

## Tests

Unit:

- The flag is written first and reads back. A document without it is a
content type. A non-boolean flag and a field-level `"fact"` property are
rejected.
- `FactMetadata` pins its five properties and four source kinds.
- An entity type rejects a revision that drops the flag, and a content
type can take the flag.
- `reference` accepts `field` and rejects a field that is malformed,
blank or not a string, or one given without a target. It declares
`ReferenceTarget("product", "price")`.
- `list` passes a field target on. A definition reports field
references.
- The conformance base now checks that every declared target is a slug
with an optional field name. All built-ins and both sample plugin types
pass.

Integration, each on SQLite and PostgreSQL:

- An entity type stores `"entity": true` and reads back as one. A
definition stored without the flag reads as a content type.
- Dropping the flag fails and writes nothing, while flagging a content
type works.
- `product-page` references `product.price` directly and through a list.
- Each of these fails with an error naming the field: a field the target
lacks, a field of a non-entity type, and a revision removing `price`
while two types reference it. Removing an unreferenced field works.
- A type can reference its own field only as an entity type.
- The reference scenario now defines `product`, `category`, `spec` and
`region` as entity types and the four page types as content types, with
`product-page` and `comparison-page` referencing `product.price`.

All ran locally against a `postgres:17` container as well as SQLite.
There is no migration and no pending model change, no new package and no
new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:schema Content types and entity types (SCH) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Content type persistence: application service, stored versions, the site record and migrations for both providers

1 participant