feat: universal content engine mvp 8 - #742
Draft
aXenDeveloper wants to merge 24 commits into
Draft
Conversation
Stage 8 opens with the block itself: `delivery` on a content type, and every rule that makes an invalid one fail at definition time rather than at request time. `delivery.enabled` is gated on `publicApi` at the **type** level, not only at boot - a content type with no public API has no public URL, so there is nothing for delivery to be about, and `enabled: true` is a compile error there. Every SEO field reference is checked the same way: it has to be in `publicApi.fields`, of a kind that can fill its slot, and not a repeatable leaf. A `<title>` is rendered into a public page, so it has to be something the public API would already have said out loud. `slugScope` is resolved here and read by the whole delivery layer: it decides whether a historical URL belongs to one language or to the content type. A localized content type with a *shared* slug is refused `redirects`, because every language would answer to the same segment and one retired address would belong to several URLs at once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`contentDeliveryPath` is the one place a content URL is built, and it is relative on purpose: a definition lives in source control and gets deployed to a preview domain, a staging domain and production, so an origin baked into it would be wrong in two of the three places. `contentDeliveryUrl` adds one when a caller has one. The locale segment is normalized, because a path is also a cache key - three spellings of one locale producing three paths would produce three cache entries for one page. `parseContentDeliveryPath` is the strict inverse: it accepts exactly the shape the builder produces and answers `null` to everything else, so a resolver never answers one content type's URL with another's record. The sitemap module is serialization and nothing else. Keeping it apart from the queries is what makes both testable: the XML without a database, the pagination without parsing XML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`core_content_slug_history` records every slug that has ever been a publicly addressable URL, and stores both states - current and retired - which is what makes its uniqueness a **reservation** rather than only a log: a retired address cannot be claimed by unrelated content, so nobody's incoming links quietly change meaning. Two partial unique indexes rather than one over a nullable `languageId`, for the reason `core_content_revisions` needs two: Postgres treats every `NULL` as distinct, so a single key including it would enforce nothing at all for the shared case it exists to protect. They double as the resolver's lookup, which runs on a public request path for a URL that is very often a typo. `path` is stored rather than rebuilt on read, because it is the one thing the engine cannot recompute later: `publicApi.path` is source configuration a developer may change, and the URL that was live is a historical fact. Every write takes the transaction it should run in and none of them opens one. There is deliberately no `delete`: a retired URL is somebody's bookmark. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`model.deliveryService(c, { pluginId })` answers the five delivery questions and
mutates nothing - structurally, not by convention: slug history is written inside the
transaction that moves the slug, so there is no `reserve` here to call without one.
Every answer is derived from the **public projection** rather than from the base row, so
the publication predicate, the field allowlist and the Stage 5 fallback rules are the
ones already tested rather than a second implementation that agrees on the day it is
written. It is also what makes "SEO cannot leak a private field" true at runtime: a
private column is never fetched.
Two properties are worth calling out.
Chains collapse because the resolver never follows the history - it finds the record an
address belongs to and reads that record's *current* slug, so `a -> b -> c` is one hop
from either end. And the redirect destination is read **strictly** by locale: sending
`/pl/articles/stary-slug` to the English canonical would answer a Polish URL with an
English page and permanently tell a crawler that is correct.
Alternates are real published translations and nothing else. A locale served through
`fallback: "default"` has no URL of its own, so listing one would announce an `hreflang`
pointing at a 404.
The sitemap query pages by keyset over the primary key rather than by offset, and reads
`greatest(base, translation)` through the column's own decoder - Drizzle disables the
driver's timestamp parsing for its mappers, so a raw fragment would parse the same value
as local time and put every localized `lastmod` hours out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule this implements is the one Stage 8 rests on: a slug becomes redirectable only if it was previously used by an **addressable public version**. A draft whose slug was corrected three times before anybody saw it creates no redirects, because none of those URLs was ever live - and a `retire` that matched no row is exactly that answer. `applyContentDeliveryWrite` runs inside the caller's transaction, after the guarded write, and the ordering is the whole correctness argument: a writer holding a stale `expectedVersion` fails first and leaves the history exactly as it found it, and the old address is retired before the new one is reserved so a move from `a` to `b` and back to `a` does not hit its own live reservation. The base service owns a **shared** slug and the translation service owns a **localized** one, which is why a localized content type's redirects are per language while a shared slug's are not. `findBasePublication` is added to the translation model because a translation's public reachability is subordinate to the record's: a published Polish translation of a draft article is not a URL anybody can reach. Nothing here emits an event or touches a cache tag. The caller is inside a transaction that may still roll back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two events, each gated on a fact rather than an operation, and both emitted **alongside** `updated`/`restored`/`translation_updated` rather than instead of one: a field moving and a URL moving are different facts with different audiences. A listener that mirrors content wants the first; one that warms a CDN, tells an external search engine or writes an edge redirect table wants the second - and would otherwise have to inspect `changedFields` for a slug field whose name it cannot know. `delivery_redirect_created` fires only when the old address had genuinely been live, which is what keeps a corrected draft from announcing a redirect that does not exist. There is deliberately no sitemap event: every mutation that changes a sitemap line already emits one of these or a publication event. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three new scopes in the existing namespace - `delivery`, `redirect`, `sitemap` - with the locale in the same position it already occupies, so nothing about the tag format has to be learned twice. Each answers a different question a page asked: a `generateMetadata` that renders only metadata is tagged `delivery` alone, and a redirect lookup is tagged by the **old** address, because that is what a request for a moved page arrives with. `contentInvalidationTags` takes an optional `delivery` block and derives everything else from data it already has. Omit it - which is what every content type without the block does - and the output is byte-identical to what it always was: nothing existing has to be re-tagged, and no warm cache is thrown away for a feature the content type does not use. The sitemap tag is expired only when the set of listed URLs actually changed. An edit that changed what an already-listed page *says* leaves the file byte-identical. Scheduled transitions go through the existing revalidation bridge rather than a second cross-origin system, with the same all-origins-must-accept rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three generated public routes and one admin route. They exist because a frontend is very
often not the process that holds the database: VitNode's split deployment runs Next.js
against a separate API, so `generateMetadata`, a catch-all route and a `sitemap.ts` all
need an HTTP answer rather than a service call.
Every public path begins with the static `delivery` segment, which makes them impossible
to shadow whatever order they are registered in: `/{slug}` is one segment and these are
two or three, so a record whose slug is literally `delivery` still resolves.
`resolve` answers `not_found` as a 200 with a body rather than a 404, so a caller can tell
"this URL resolves to nothing" from "the delivery API is unreachable" - and a negative is
not cached as a 404, so publishing the record makes it resolve immediately.
The reserved-slug refusal is a structured 409 declared as a union *alongside* the editorial
one rather than a third arm of it, so a client generated before Stage 8 still parses the
arms it knows. It never carries the owning record's id: a 409 on a public-facing address
must not become a way to enumerate records the caller cannot read. It is also caught
explicitly on the translation path, which otherwise rewrites every 409 into the
unique-clash arm.
The admin route is gated by `can_view` and nothing narrower, and exposes none of
`core_content_slug_history`'s storage columns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A thin adapter, and the thinness is the point: the core engine returns framework-neutral
metadata, and this turns it into the two shapes Next.js asks for. Move to Astro and you
write a different forty lines against the same service.
Every mapped key is **absent** rather than present-and-null when there is no value, because
Next renders a `null` title as an empty `<title>` and an absent one not at all.
`generateMetadata` gets `{}` for a URL that does not resolve rather than a throw - the page
is what calls `notFound()`, and a metadata function that threw would replace a clean 404
with an error boundary.
`contentDeliveryPage` lives in its own module because it is the only helper with a side
effect: `next/navigation`'s control-flow functions throw to unwind the render, so a page
that only wanted metadata should not reach them by accident. It is the one place
`vitnode-frontend/navigation` would be wrong - a delivery location already carries its
locale segment, so routing it through `next-intl` would prefix the locale twice, and that
wrapper is a 307 where a canonical slug change needs a 308.
`contentSitemapEntries` pages through the route with a `maxPages` backstop and reports
truncation rather than throwing, so a partial sitemap is still a valid sitemap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A read-only delivery panel on the row action: canonical URL, publication state, and every address the record has ever answered to. Lazy, like the edit form and the revision history, so a 25-row table costs 25 buttons rather than 25 queries. Gated by `can_view` and nothing narrower. It reports what the slug mutations already did, so the permission that allowed the mutation is the only one it needs - a `can_manage_redirects` for a screen that manages nothing would be a permission every install has to configure for no decision it can make. Read-only is the deliberate scope. A redirect is somebody else's incoming link, so deleting one silently breaks traffic nobody in the dialog can see: that needs its own permission, a confirmation that explains the consequence, and an audit trail. Displaying the history is useful today; managing it is a product rather than a button. The reserved-slug 409 gets its own message on both the shared form and the locale editor, because "another record holds that address now" and "another record used to hold it and it still redirects" are different sentences and possibly different decisions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two shapes, because the interesting cases differ: `example.article` is the nonlocalized reference - no locale segment, one reservation for the one URL it has, and SEO from two fields the public API already exposes. `example.advanced-article` is the localized one: a localized slug, so each language gets its own reservation and changing the English URL creates no Polish redirect; SEO from a localized group with a fallback to the localized `title`; and an `x-default` that appears only when the default locale is genuinely published. `syndication.noIndex` is added as a **shared** boolean so the fixture exercises the one field that drives two consumers - sitemap exclusion and the `robots` directive - which is why it has to be shared: a per-locale value would let the two disagree. The three Stage 6 assertions that named the group's leaves exactly are updated rather than loosened; a new leaf appearing in a partial-update assertion is precisely what those tests are for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Type tests for the rules an author gets wrong while typing - a private field as an SEO title, prose in a title slot, delivery without a public API - because each is a mistake the editor should catch before the file is saved. Plus the assignability check every stage repeats: an eleventh type parameter on `ContentTypeDefinition` must not break the erased form every relation thunk and route builder is written against. The resolver tests run against the **real** service with only its two reads stubbed, rather than against a copy of its logic: the decision it makes is where a mistake becomes a permanent 308 to the wrong page. The cache tests assert exact tag lists rather than "some revalidation happened", because the whole of Stage 8's opt-in claim at that layer is that an existing content type's tags do not move - and only a byte comparison shows it. `findBasePublication` is added to the five translation-model mocks so the suites that predate it keep exercising what they were written for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
49 tests against real Postgres, covering what only a database can show: the two partial unique indexes really do reserve a retired address, a rolled-back write leaves the history exactly as it found it, and two writers racing on one slug produce one winner and one structured version conflict. The full redirect lifecycle is walked end to end - draft (nothing recorded), publish (reserved), A -> B -> C (both old addresses resolve to C in one hop), unpublish (all inactive, history retained), republish (active again), delete (retained, resolves to nothing), restore (the two addresses swap roles). Localized coverage asserts the isolation that matters: an English slug change writes nothing Polish, the same historical address may be retired in two locales, alternates list only real published translations, and a fallback read reports the locale it actually served. The sitemap tests page a fixture in twos and assert every record appears exactly once in ascending key order - and one of them caught a real bug: `greatest(base, translation)` read without the column's decoder parsed a naive timestamp as local time, putting every localized `lastmod` hours out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine pages under `dev/content-engine`, plus the delivery tags in `caching.mdx` and the two new events in `built-in-events.mdx`. Each page leads with the rule rather than the API, because the rules are what a reader has to hold: when a slug becomes redirectable, why an alternate is never fabricated from a fallback, why the canonical URL is the *served* locale, and why the redirect status is not configurable. `content-delivery-migrations.mdx` states plainly what is **not** backfilled and why - scanning revisions would create incorrect permanent redirects from draft-only slugs, and an incorrect permanent redirect is worse than a missing one - then gives the SQL for an explicit backfill and for a route-prefix migration. `content-delivery-limitations.mdx` separates decisions from gaps: no page builder, no manual redirect manager, no `og:image`, no per-locale `noIndex`, no `410`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three small things the example plugin's stricter ruleset caught, none of which the core package's does: `code` is a `unique: true` field and several articles are created inside one millisecond, so a `Date.now()`-derived value was both a duplicate risk and a `Record<string, unknown>` interpolated into a template. A monotonic counter is what it should have been. The sitemap page is annotated explicitly: the optional-call chain through `deliveryService?.()` loses the element type in the typed-lint program even though `tsc` resolves it, and `itemId` is exactly what that test asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**Alternates were silently empty on the public resolve route.** `findBySlug` returns the public *projection*, and a content type that withholds `"id"` has no identifier in it - so `resolveSlug` could not enumerate a record's published translations and answered with an empty `hreflang` set. That is the worst shape a bug can take here: an empty `hreflang` looks exactly like a record with one translation, so it is invisible in the AdminCP and wrong on every page. Since alternates are resolved by identifier and there is no honest way to recover one from a projection that omits it, a **localized** content type with `delivery` now has to expose `"id"` - refused at definition time rather than left as a quiet gap. A nonlocalized content type has no alternates to resolve and needs nothing, so its `itemId` may still be `null`. **The two halves of a resolution could disagree about the locale.** `findBySlug` and `findById` resolve `defaultLocale` internally when the caller names none, while the history lookup treated "no locale" as the *shared* rows - which a localized content type never has. So a service call omitting the locale searched `en` for the live record and found nothing for the redirect. Both halves now resolve the same language. Each fix has a PostgreSQL test that fails without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Formatting and one redundant type assertion in the two tests added for the review fixes. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Two namespaces, not one, and the asymmetry is the whole fix.
A generated API route is `/api/{pluginId}/content/{path}`, so two plugins publishing
`articles` do not collide - Stage 1-7 deliberately allows it, and forbidding it would
fail an app's boot over a name neither author can see. A **canonical delivery URL** is
`/articles/{slug}` with no plugin id in it at all, so the same pair really would give one
public URL two owners: two resolvers claiming it, two sitemaps listing it, and one slug
reservation table with no way to say whose a retired address was.
`byDeliveryPath` is therefore a second map keyed by the path alone, consulted only for a
content type with `delivery.enabled`. A non-delivery route reserves nothing site-wide, so
a delivery-enabled `articles` elsewhere is still free to take it.
The fix is the check rather than a prefix: putting the plugin id in canonical URLs would
solve the ambiguity by making every public content URL uglier for everybody.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slug history is written by `applyContentDeliveryWrite`, and the only two callers are
`editorial-service` and `translation-editorial-service` - because the reservation has to
commit or roll back with the slug mutation, its version check and its revision. A content
type without `editorial` writes through the plain repository, which has no version to
guard and no history to write, so `redirects: { enabled: true }` there was a feature that
silently recorded nothing.
Now a compile error *and* a definition-time throw. Refused rather than downgraded to
`redirects: { enabled: false }`: an author who asked for redirects and quietly got none
would find out from a broken link months later.
The restriction is narrow on purpose. Canonical URLs, SEO, alternates, `hreflang`, the
sitemap and every delivery read are projections over data the content type already has,
and all of them stay available without `editorial` - which is what keeps Stage 5's
"publication and localization without editorial" promise intact. The same rule covers a
localized content type, whose localized history has the same missing write path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sitemap entry carries `<lastmod>`, derived from `updatedAt`. The invalidation logic
treated "the sitemap changed" as "URL membership changed", so a plain title or SEO edit on
a published record left a cached sitemap serving a timestamp that was no longer true - for
as long as the tag lived.
`sitemapChanged: boolean` becomes `sitemap: { contentChanged, indexChanged }`, because the
two cache different documents:
- **`contentChanged`** - this locale's sitemap **file** is no longer byte-identical. True
for any real mutation of a record that is or was publicly reachable, whether what moved
was a URL, a title or an SEO field.
- **`indexChanged`** - the set of files, or how many of them there are, moved. True only
when public reachability flipped. A title edit rewrites a timestamp inside an existing
file and a slug change rewrites one line: neither changes which files exist.
For a nonlocalized content type the locale-less tag *is* its one file, so `contentChanged`
expires it. For a localized one that tag is the index of its per-locale files, which is why
an ordinary edit must not touch it.
The nonlocalized Server Action decides `contentChanged` by comparing `updatedAt` across the
write - not a proxy for "did the sitemap change" but *the value the sitemap serializes*, so
the two move together by construction. It answers "was this a no-op" for free: the engine
issues no `UPDATE` for an update that changed nothing.
The localized path reuses the Stage 5 locale fan-out rather than inventing a second
propagation rule: a shared edit reaches every locale because the base timestamp is in
`max(base, translation)` for all of them, and a translation edit reaches its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while auditing the scheduled mutation paths. `dispatchContentRevalidation` posts the delivery block, and the web-side route parsed the body with a zod object schema that did not declare it - so it was **stripped**. Every background transition crossed the bridge carrying its delivery tags and arrived with none, leaving a stale sitemap and a stale canonical response behind every scheduled publish and unpublish. Declared explicitly, and optional: an API that has not been redeployed posts the Stage 1-7 shape, and that body still has to be accepted rather than 400. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Exact cache-tag assertions for the rule the fix establishes: a real update to a published representation expires that locale's sitemap file, because its `lastModified` moves - even when the canonical URL does not. `public-locale-cache.test.ts` is new and is the localized half: a PL translation edit expires `sitemap:pl` and not `sitemap:en`; a shared edit expires both, because the base timestamp is in `max(base, translation)` for every language; a draft translation is skipped; and none of them touches the index. It also pins the fallback case explicitly - a default-locale edit reaches a fallback-consuming locale through the Stage 5 fan-out even though that locale contributes no sitemap URL, which is a cache miss rather than staleness. The nonlocalized assertions go through the existing `mutation-api` harness, so they exercise the real `revalidateContent` end to end rather than a mock of it: title edit, SEO-only edit, no-op, draft, slug change, publish, delete, and the Stage 1-7 tag list of a content type without delivery. Every one of them fails against the previous implementation - membership-only invalidation misses five localized cases and three nonlocalized ones, and the bridge misses three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The caching page said an edit that only changed what an already-listed page says leaves the sitemap byte-identical. That is false while `<lastmod>` is derived from `updatedAt`, so it is replaced with the rule that is true, plus a per-mutation matrix that separates the sitemap **file** from the sitemap **index** and names the no-op rows explicitly. `slug-history-and-redirects` gains the Editorial requirement with the reason - one transaction for the slug, its version and its history - and a table of what does *not* need Editorial, so nobody reads the restriction as applying to all of `delivery`. `content-delivery-limitations` gains both new refusals: redirects without Editorial, and a delivery path colliding site-wide across two plugins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restore is one of the fifteen Stage 8 mutation paths and shares `applyContentDeliveryWrite` with the rest, so it inherits the same rule - but the AdminCP Server Action reaches it through its own before/after pair, and that pair is what decides the sitemap tag. A restore that moves the slug expires the file and both redirect lookups; a no-op restore returns the row unchanged, so `updatedAt` does not move and nothing is expired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Improving Documentation
pnpm lint:fixto fix formatting issues before opening the PR.Description
What?
Why?