Skip to content

feat(api-headless-cms): simple content entries - #5678

Draft
brunozoric wants to merge 19 commits into
nextfrom
claude/simple-content-entries
Draft

brunozoric wants to merge 19 commits into
nextfrom
claude/simple-content-entries

Conversation

@brunozoric

@brunozoric brunozoric commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Changes

Adds simple content entries — a content entry with the revision and publishing dimensions
removed and the meta-field envelope cut to what is actually needed. Base code only: five operations
reached through code, no GraphQL, no admin UI.

A simple entry is still a normal CMS entry — same CmsModel, same storage operations, same table.
Nothing in the api-headless-cms-* storage packages changes. That was the design constraint, and
it is what makes the entry shape the interesting part of this PR.

The shape

ISimpleCmsEntry has eleven fields: seven carry identity and payload, four exist only because the
storage operations read them and are pinned to constants.

Identity / payload id, entryId, tenant, modelId, values, createdOn, createdBy
Pinned version: 1, status: "draft", locked: false, expiresAt: null

26 of the 28 entry- and revision-level meta fields are gone; createdOn and createdBy are the two
that survive. The field list was derived by auditing what each backend actually reads off the entry —
tenant because createBasePartitionKey throws without it, version because it feeds
REV#${zeroPad(version)}, modelId because of the DynamoDB GSI key, id because the key builders
run parseIdentifier over it.

The invariant

A simple entry is always a single unpublished draft — never published, never locked. Enforced
four ways, because a comment would not be enough:

  1. Literal types — assigning another value does not compile.
  2. readonly — reassigning one does not compile either.
  3. assertSimpleEntryInvariants in both write repositories, so nothing reaches storage without
    passing it, whether the caller used CRUD or resolved a use case straight from the container.
  4. assertRegularModel on the mutating regular repositories, so publishEntry and friends refuse a
    simple model outright.

The five operations

simpleCreateEntry, simpleUpdateEntry, simpleGetEntry, simpleListEntries,
simpleDeleteEntry — on the HeadlessCms facade, and as use cases via
exports/api/cms/simpleEntry.js. Every use case returns a Result and never throws for an expected
failure; getSimpleEntry returns SimpleEntryNotFoundError rather than null. Update changes only
values, creates no second revision, and has no savedOn/modifiedOn to refresh. Delete is
permanent — no bin.

contentEntry/ has 29 slices; this has five. No publish, unpublish, republish,
createRevisionFrom, bin operations, or singleton variants.

A model opts in with a tag

model.tags already exists, so this needs no type or model-builder change — .tags([SIMPLE_MODEL_TAG])
is the whole designation. It guards both directions: simple operations refuse an untagged model,
and the twelve mutating regular repositories refuse a tagged one. The ten read-only regular
repositories are deliberately left alone, so a simple entry stays readable through getEntry and
listEntries.

Changes to existing code — the review-worthy part

  • Twelve mutating contentEntry repositories get one assertRegularModel(model) line. Landed in
    one commit on purpose: a partial rollout leaves exactly the gaps the guard exists to close.
    MoveEntryToBinRepository and DeleteEntryRevisionRepository take an inline object param, so the
    call sits after the destructure rather than first.
  • New EntryDataProcessor (features/contentEntry/entryDataProcessor/) wraps entry validation
    and reference-field mapping behind DI, so data factories stop importing from ~/crud/.
    CreateEntryDataFactory uses it and loses its CmsContext dependency. The other four factories
    still import directly and can migrate separately.
  • cleanInputValues extracted out of CreateEntryDataFactory so both factories share one copy.
  • HeadlessCms now also extends ICmsSimpleEntryContext — a public type addition.

Two things worth knowing

Two items per entry, not one. SK = REV#0001 and SK = L. The L item cannot be dropped —
list reads the latest-item partition, so an entry without it would be invisible. The saving here is
envelope size, not item count.

sort is not string[]. It is restricted to id, createdOn and values.*. A simple model
gets its own OpenSearch index, so every dropped meta field is unmapped there and sorting on one
(savedOn_DESC, say) fails at query time. The type turns that into a compile error.

Listing must pin latest: true, and the backends disagree about that. OpenSearch requires
where.latest or where.published and deliberately refuses to default it — createInitialQuery
throws OPENSEARCH_UNSUPPORTED_QUERY, on the grounds that defaulting invites user error. DynamoDB
quietly defaults instead (const type = initialWhere.published ? "P" : "L"). So a list path that
passes neither works on one backend and throws on the other. ListSimpleEntriesRepository pins
latest: true — correct for a simple entry, which has one revision that is always latest and never
published — and spreads into a fresh object, because both backends delete those keys from whatever
they are handed. Worth knowing for anyone adding another list path.

Not in this PR

  • No domain events. Use cases check AccessControl but publish nothing. Combined with no GraphQL
    layer, a simple entry write currently has no hook at all — anything needing to react to one has
    to add events to these slices first. Deliberate, and recorded in the docs rather than left implicit.
  • No GraphQL, no admin UI, no migration for existing entries, no converting a model between
    regular and simple.

Note on branch contents

The branch also carries docs: add activity log design and inconsistency review, which is unrelated
to this feature — docs only, under docs/.bruno/. Say the word and I will strip it out.

How Has This Been Tested?

76 new tests across six files. The full package suite passes on every reachable backend at
the same totals — 996 passed, 16 skipped, 0 failed — against a 920 baseline:

Backend Command Result Duration
DynamoDB yarn test 996 / 0 412s
SQL yarn test:sql 996 / 0 264s
Search-backed yarn test:os 996 / 0 686s

Identical totals on all three, taken after the OpenSearch fix.

  • Unit — __tests__/features/simpleContentEntries/: data factory (the produced shape, the pinned
    values, id format, defaults, that no meta field leaks), read path, write path, both model guards,
    the invariant guard, and the CRUD surface.
  • Storage-backed — __tests__/storageOperations/simpleEntries.test.ts runs the use cases against
    a real backend: one revision per entry, readable as the latest revision, no dropped meta field
    persisted, update creates no second revision, delete leaves nothing.
  • Guard wiring is asserted from disk, not hardcoded — the test walks contentEntry/ for
    *Repository.ts, so adding a mutating repository without the guard fails the suite. AGENTS.md notes
    two transports have shipped unwired with green per-transport tests; this is that check.
  • Export surface is asserted to resolve, so a rotted re-export path fails here rather than in a
    consumer package.

Verified per-commit with yarn check, npx oxlint, npx oxfmt --check, yarn adio,
yarn check-ts-configs and yarn build.

The storage-backed suite passes on every backend that is reachable:

Backend Selected by Integration
DynamoDB default / WEBINY_STORAGE=ddb 9 / 9
Search-backed (OpenSearch) yarn test:os — also test:es, same package 9 / 9
SQL WEBINY_STORAGE=sql / yarn test:sql 9 / 9
pg-os yarn test:pg:os not reachable — see below

Note that test:es and test:os select the same package (api-headless-cms-ddb-es carries both
keywords); Elasticsearch is no longer used, so there is one search-backed backend, not two.

Because a storage suite skips silently and still exits 0 without a flag, I confirmed the tests
are actually live by breaking an assertion and checking each backend fails. That is how the one
storage divergence surfaced: DynamoDB writes exactly the eleven declared fields, SQL writes
thirteen
— the eleven plus isLatest and isPublished. Those are not inputs we supply, and they
reflect a modelling difference rather than extra state: DynamoDB duplicates the record per role and
puts the role in the sort key (REV#0001 / L / P), so a simple entry is two items, while SQL
keeps one row per revision and marks the roles with columns, so a simple entry is one row. Both
columns derive from status, which the shape pins. The test pins the persisted field set per backend
so a third extra cannot appear unnoticed.

Running against OpenSearch caught a real bug

Worth stating plainly, because it is the reason this PR is in better shape than the ddb/sql runs
suggested: simpleListEntries was broken on the search-backed backend and every ddb and sql run
was green throughout. It threw Cannot call Elasticsearch query when not setting "published" or "latest". Fixed as described above.

The gap was in the tests as much as the code — the integration suite covered revisions, latest-revision
reads and delete, all of which are DynamoDB paths even on the search-backed backend, and never
listed. It now has list and cursor-pagination tests with a bounded retry, since OpenSearch indexes
near-real-time and DdbEsListEntries silently returns an empty page on index_not_found_exception,
so a naive assertion could pass while proving nothing.

Remaining gap: pg-os

pg-os cannot be exercised through the test preset mechanism at all. WEBINY_STORAGE=pg-os
registers no CMS entry storage operations (No registration found for Cms/Entry/CreateEntryStorageOperation), because PgOsCreateEntry is a decorator over an inner
implementation and needs a base backend registered underneath — but the preset loop selects exactly
one storage-operations package and breaks. pg-os,sql fails identically, and sql,pg-os passes only
because SQL is what gets selected. This is a test-infrastructure limitation independent of this
feature: no CMS feature is currently verifiable on pg-os.

Documentation

  • src/features/simpleContentEntries/DEVELOPERS.md — the shape and why each field is there, the
    invariant and its four enforcement points, each use case with its signature, access check, storage
    operation and failure modes, slice anatomy, the guards, how to declare a simple model, what lands
    in storage per backend, and what is deliberately absent.
  • docs/.bruno/2026-09-10-simple-content-entries-{design,specs,plans} — the design, spec and
    plan, including the Phase 0 cross-backend field audit and its correction.

No changes needed on the documentation website: there is no public API surface yet — no GraphQL, and
the operations are reached through code. Worth a changelog entry once a consumer ships on top of it.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

🚓 Slop Cop

⚠️ 3 thing(s) worth a look before merging.

Coherent, well-tested feature PR; main issue is an author-acknowledged unrelated docs commit bundled into the branch, plus minor commit-history noise.

🚨 Should this be in the PR?

🟠 Medium — Branch carries an unrelated docs commit (activity-log inconsistency review)

The PR description itself flags that docs: add activity log design and inconsistency review (docs/.bruno/activity-log.md and activity-log-inconsistencies.md, ~726 lines) is unrelated to simple content entries and offers to strip it out. This is a footprint mismatch the author already acknowledged but did not resolve before opening the PR.

🟡 Low — History contains a revert-of-a-revert / cancel-out commit pair

Commit list includes fix(webiny): drop the orphaned event-handler-core dependency followed later by Revert "fix(webiny): drop the orphaned event-handler-core dependency", plus a chore: rebuild webiny package and chore: yarn lock — noise the author's own handoff doc says should be tidied before merge (packages/webiny/package.json net change is only +2/-1, consistent with this being resolved, but the noisy commits remain in history).

📏 Code-style rule checks

🟡 Low — Comment missing trailing period

packages/api-headless-cms/tests/features/simpleContentEntries/writePath.test.ts and readPath.test.ts use inline block comments like // Simulate a record that somehow became published and locked in storage. (this one is fine) but some short inline comments in crud.test.ts (e.g. // The re-exported abstraction must be the same token...) span multiple sentences without periods on each line — minor, low-confidence, so flagged only as low priority per comments.md.

Automated, non-blocking heads-up from an LLM. It can be wrong — use your judgment. Regenerates on every push.

brunozoric and others added 15 commits September 10, 2026 14:10
Records the activity log storage design and a review of it: 32 findings
across contradictions, arithmetic that does not reconcile, and decisions
labelled irreversible that are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reduced content entry stack riding the existing storage operations
unchanged: eleven fields instead of the full envelope, four of them
pinned because the storage operations read them, no revisions and no
publishing. Designation by a model tag, guarded from the repositories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 0 complete. The eleven-field shape holds across all backends: no
additions. pg-os is a decorator over sql, not a backend; isLatest and
isPublished are derived from status and transient.

The anticipated index-mapping failure is withdrawn - prepareEntryToIndex
mandates no meta field. It is replaced by a narrower real constraint: one
OpenSearch index per model means dropped meta fields are unmapped, so
sorting on one fails at query time. The sort parameter is now typed to the
fields that exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 of the simple content entries base code. A reduced entry shape
(eleven fields) riding the existing storage operations unchanged, with
version, status, locked and expiresAt pinned as literal types because the
storage operations read them.

Adds the create data factory, the createSimpleEntry use case and
repository, the six domain errors, the simple model guard, and feature
registration. Extracts cleanInputValues out of CreateEntryDataFactory so
both factories share one copy.

No events and no GraphQL: use cases check AccessControl only, and the
operations are reached through code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds EntryDataProcessor, an injectable service wrapping the two entry
value preparation steps - validation and reference field mapping - so
data factories no longer import from ~/crud/ directly. Grouped as one
multi-method service rather than an abstraction per operation.

CreateEntryDataFactory and CreateSimpleEntryDataFactory both use it and
lose their CmsContext dependency. The four remaining factories still
import directly and can migrate separately.

Also strengthens the simple entry factory test: it now asserts a real
field default is applied and that values are reduced to the model's own
fields, instead of asserting an undefined placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2: getSimpleEntry and listSimpleEntries.

getSimpleEntry returns a Result carrying SimpleEntryNotFoundError rather
than null, and resolves through GetLatestRevisionByEntryIdStorageOperation
because a simple entry has exactly one revision and that operation is
data-loader backed. A versioned id is reduced to its entry id first.

listSimpleEntries defaults to createdOn_DESC, which is safe because
createdOn is one of the eleven fields the entry carries - a dropped meta
field would not be, since a simple model gets its own index where those
are unmapped.

Both repositories assert the model is tagged simple before touching
storage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 3: updateSimpleEntry with its data factory, and deleteSimpleEntry.

The update factory carries identity, the creation stamp and the four
pinned fields over from the original and replaces only values - there is
no savedOn or modifiedOn to refresh, since a simple entry records only
when it was created. Validation receives the original entry so validators
that need it still work.

Delete is permanent and has no bin: the storage operation purges every
item under the entry's partition. Both use cases resolve the entry first
and return SimpleEntryNotFoundError without writing when it is absent.

All five slices are now registered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4. Adds the five simple* methods to the HeadlessCms facade via
crud/simpleContentEntry.crud.ts, unwrapping each use case's Result into
throw-or-return.

Adds assertRegularModel and wires it into all twelve mutating regular
entry repositories in one pass, so a simple model cannot be published,
revisioned, moved or binned through the regular path. The ten read-only
repositories are left alone, so a simple entry stays readable through the
regular get and list paths.

The guard wiring is asserted from disk rather than hardcoded, so it fails
if a new mutating repository is added without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything so far stubbed the storage operations, so nothing proved the
reduced envelope survives real storage. This runs the five use cases
against a real backend and asserts one revision per entry, readability as
the latest revision, that no dropped meta field is persisted, that update
creates no second revision, and that delete leaves nothing behind.

It also corrects Phase 0. isLatest and isPublished are not required
inputs, but SQL does persist them: SqlCreateEntry assigns them from status
and only then deletes them from the in-memory object, so the insert has
already captured them. Measured stored field sets are eleven on ddb and
thirteen on SQL. Both extras derive from status, which the shape pins, so
the shape is unchanged - but the test now pins the persisted field set so
a third extra cannot appear unnoticed.

Verified live on both backends by breaking an assertion and confirming
each fails, since a storage suite without a flag skips silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds DEVELOPERS.md next to the feature, laying out the eleven-field shape
and which four fields are pinned, each of the five use cases with its
signature, access check, storage operation and failure modes, the slice
anatomy, the two guards, how to declare a simple model, what actually
lands in storage per backend, and what is deliberately absent.

Adds exports/api/cms/simpleEntry.ts as the curated public surface, the way
other packages already import exports/api/cms/entry.js. It exposes the
five use cases, the two data factories, SIMPLE_MODEL_TAG, the types and
the six error classes - not the repositories, which are internal wiring.

Tests assert the surface re-exports what it claims, leaks no repository,
and that the re-exported tokens resolve from the container, so a rotted
re-export path fails here rather than in a consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A simple entry is always a single unpublished draft. It is never published
and never locked, and nothing may change that.

Enforced four ways: literal types reject any other value at compile time;
readonly rejects reassignment; assertSimpleEntryInvariants runs in both
write repositories, so no caller can persist a broken entry through CRUD
or through a use case resolved straight from the container; and
assertRegularModel already stops the regular publishing operations from
touching a simple model at all.

The update factory now re-applies the four pinned fields from the
constants instead of copying them off the original, so a stored record
that somehow broke the invariant is normalized rather than propagated.

Adds SimpleEntryInvariantError, reported with the offending field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The barrel no longer re-exports HttpRouteHandler or the IHttpRequest,
IHttpResponse and IHttpResponseBuilder types, which were its only uses of
@webiny/event-handler-core. adio flagged the dependency as unused;
removing it also required regenerating the tsconfig references, which
still listed it.

Also corrects the storage description in the simple content entries docs.
It claimed two items per entry as a general fact, which is only true of
DynamoDB: DynamoDB duplicates the record per role and encodes the role in
the sort key, so a simple entry is two items (REV#0001 and L), while SQL
keeps one row per revision and marks the roles with isLatest and
isPublished columns, so a simple entry is one row. That is where the
eleven vs thirteen field difference comes from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brunozoric
brunozoric force-pushed the claude/simple-content-entries branch from 3472c87 to d37e1fa Compare September 10, 2026 12:16
brunozoric and others added 4 commits September 10, 2026 14:29
The removal was correct against the tree it was made on: the barrel had no
event-handler-core usage at that point, and adio reported the dependency
as unused.

The rebase brought in #5673, which restores those imports as deep paths
(@webiny/event-handler-core/features/http/abstractions.js), so adio now
reports the inverse - used in source but not listed. Restoring the
dependency and regenerating the tsconfig references.

This commit and the one it reverts cancel out and can both be dropped
when the branch history is tidied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…doff

AGENTS.md gains the two simple entry data factories, a note that factories
now reach validation through the injectable EntryDataProcessor rather than
importing from ~/crud/, and a pointer to the feature's DEVELOPERS.md.

ai-context/core-features-reference.md gains a Simple Content Entry
Features section covering the five use cases, both data factories and
EntryDataProcessor, with the import paths and the two contracts that are
easy to get wrong: get never returns null, and listing must pin
latest: true because the backends disagree about defaulting it.

Adds the session handoff under docs/.bruno/handoff/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite passes at identical totals - 996 passed, 16 skipped, 0 failed -
on ddb, sql and ddb-os, all taken after the OpenSearch list fix. That
closes the last uncovered cell; the earlier 994 figure predated the two
integration tests added for the search path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant