feat(api-headless-cms): simple content entries - #5678
brunozoric wants to merge 19 commits into
Conversation
|
🚓 Slop Cop 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 🟡 Low — History contains a revert-of-a-revert / cancel-out commit pair Commit list includes 📏 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 Automated, non-blocking heads-up from an LLM. It can be wrong — use your judgment. Regenerates on every push. |
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>
3472c87 to
d37e1fa
Compare
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>
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, andit is what makes the entry shape the interesting part of this PR.
The shape
ISimpleCmsEntryhas eleven fields: seven carry identity and payload, four exist only because thestorage operations read them and are pinned to constants.
id,entryId,tenant,modelId,values,createdOn,createdByversion: 1,status: "draft",locked: false,expiresAt: null26 of the 28 entry- and revision-level meta fields are gone;
createdOnandcreatedByare the twothat survive. The field list was derived by auditing what each backend actually reads off the entry —
tenantbecausecreateBasePartitionKeythrows without it,versionbecause it feedsREV#${zeroPad(version)},modelIdbecause of the DynamoDB GSI key,idbecause the key buildersrun
parseIdentifierover 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:
readonly— reassigning one does not compile either.assertSimpleEntryInvariantsin both write repositories, so nothing reaches storage withoutpassing it, whether the caller used CRUD or resolved a use case straight from the container.
assertRegularModelon the mutating regular repositories, sopublishEntryand friends refuse asimple model outright.
The five operations
simpleCreateEntry,simpleUpdateEntry,simpleGetEntry,simpleListEntries,simpleDeleteEntry— on theHeadlessCmsfacade, and as use cases viaexports/api/cms/simpleEntry.js. Every use case returns aResultand never throws for an expectedfailure;
getSimpleEntryreturnsSimpleEntryNotFoundErrorrather thannull. Update changes onlyvalues, creates no second revision, and has nosavedOn/modifiedOnto refresh. Delete ispermanent — no bin.
contentEntry/has 29 slices; this has five. Nopublish,unpublish,republish,createRevisionFrom, bin operations, or singleton variants.A model opts in with a tag
model.tagsalready 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
getEntryandlistEntries.Changes to existing code — the review-worthy part
contentEntryrepositories get oneassertRegularModel(model)line. Landed inone commit on purpose: a partial rollout leaves exactly the gaps the guard exists to close.
MoveEntryToBinRepositoryandDeleteEntryRevisionRepositorytake an inline object param, so thecall sits after the destructure rather than first.
EntryDataProcessor(features/contentEntry/entryDataProcessor/) wraps entry validationand reference-field mapping behind DI, so data factories stop importing from
~/crud/.CreateEntryDataFactoryuses it and loses itsCmsContextdependency. The other four factoriesstill import directly and can migrate separately.
cleanInputValuesextracted out ofCreateEntryDataFactoryso both factories share one copy.HeadlessCmsnow also extendsICmsSimpleEntryContext— a public type addition.Two things worth knowing
Two items per entry, not one.
SK = REV#0001andSK = L. TheLitem 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.
sortis notstring[]. It is restricted toid,createdOnandvalues.*. A simple modelgets 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 requireswhere.latestorwhere.publishedand deliberately refuses to default it —createInitialQuerythrows
OPENSEARCH_UNSUPPORTED_QUERY, on the grounds that defaulting invites user error. DynamoDBquietly defaults instead (
const type = initialWhere.published ? "P" : "L"). So a list path thatpasses neither works on one backend and throws on the other.
ListSimpleEntriesRepositorypinslatest: true— correct for a simple entry, which has one revision that is always latest and neverpublished — 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
AccessControlbut publish nothing. Combined with no GraphQLlayer, 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.
regular and simple.
Note on branch contents
The branch also carries
docs: add activity log design and inconsistency review, which is unrelatedto 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:
yarn testyarn test:sqlyarn test:osIdentical totals on all three, taken after the OpenSearch fix.
__tests__/features/simpleContentEntries/: data factory (the produced shape, the pinnedvalues,
idformat, defaults, that no meta field leaks), read path, write path, both model guards,the invariant guard, and the CRUD surface.
__tests__/storageOperations/simpleEntries.test.tsruns the use cases againsta real backend: one revision per entry, readable as the latest revision, no dropped meta field
persisted, update creates no second revision, delete leaves nothing.
contentEntry/for*Repository.ts, so adding a mutating repository without the guard fails the suite. AGENTS.md notestwo transports have shipped unwired with green per-transport tests; this is that check.
consumer package.
Verified per-commit with
yarn check,npx oxlint,npx oxfmt --check,yarn adio,yarn check-ts-configsandyarn build.The storage-backed suite passes on every backend that is reachable:
WEBINY_STORAGE=ddbyarn test:os— alsotest:es, same packageWEBINY_STORAGE=sql/yarn test:sqlpg-osyarn test:pg:osNote that
test:esandtest:osselect the same package (api-headless-cms-ddb-escarries bothkeywords); 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
isLatestandisPublished. Those are not inputs we supply, and theyreflect 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 SQLkeeps 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 backendso 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:
simpleListEntrieswas broken on the search-backed backend and every ddb and sql runwas 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
DdbEsListEntriessilently returns an empty page onindex_not_found_exception,so a naive assertion could pass while proving nothing.
Remaining gap: pg-os
pg-oscannot be exercised through the test preset mechanism at all.WEBINY_STORAGE=pg-osregisters no CMS entry storage operations (
No registration found for Cms/Entry/CreateEntryStorageOperation), becausePgOsCreateEntryis a decorator over an innerimplementation and needs a base backend registered underneath — but the preset loop selects exactly
one storage-operations package and breaks.
pg-os,sqlfails identically, andsql,pg-ospasses onlybecause 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, theinvariant 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 andplan, 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