Skip to content

feat: Add content engine Stage 5B — Editorial Localization - #738

Open
aXenDeveloper wants to merge 4 commits into
feat/Universal-Content-Engine-mvp-5afrom
feat/Universal-Content-Engine-mvp-5b
Open

feat: Add content engine Stage 5B — Editorial Localization#738
aXenDeveloper wants to merge 4 commits into
feat/Universal-Content-Engine-mvp-5afrom
feat/Universal-Content-Engine-mvp-5b

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

Description

What?

Why?

aXenDeveloper and others added 4 commits August 6, 2026 21:19
The server half of Stage 5B, in one change because its pieces do not compile
apart: the lifecycle, the language-scoped history, the orchestration layer that
writes both in one transaction, the locale-bound preview token, the events, the
permission and the routes.

**Lifecycle.** A translation gains its own `status` and `publishedAt` on the same
terms the base row has them: only with `publication: { enabled: true }`,
read-only on the wire, moved only by publish/unpublish. Both transitions guard on
the *state* rather than the version, which is what makes them idempotent - a
double-clicked button, a retried task and an already published locale all write
nothing at all, so no version, no revision and no event. `publishedAt` is stamped
once and never rewritten; an unpublish leaves it, because "first published on"
stays true. `(languageId, status)` supersedes the plain `languageId` index rather
than joining it - it is a prefix, so one index serves both.

**History.** `core_content_revisions` gains a nullable `languageId`: `NULL` is
the shared history every non-localized content type already had, so the column
backfills to exactly the right value with no data step. The single unique index
becomes two partial ones, because English v3 and Polish v3 are two different
facts and Postgres treats every `NULL` as distinct - one key over a nullable
column would enforce nothing for the case it exists to protect. No foreign key to
`core_languages`, matching the table's existing design: a revision is an audit
trail, and "the Polish copy said this" stays true after the language row is gone.
The snapshot carries the locale code so it stays readable when it is.

**Snapshots** are partitioned. A shared snapshot holds shared fields only and a
translation snapshot holds localized ones only - which is a security boundary as
much as a modelling one: a translation restore that carried shared values would
let `can_translate` rewrite fields only `can_edit` may touch.

**Restore** is scoped by content type, item *and* language before anything is
read, so a revision belonging to another locale is not found rather than fetched
and rejected. It validates against the *current* localized schemas, writes through
the repository so slug uniqueness and the version guard still apply, never moves
publication state, and never restores the historical version number.

**Preview** tokens gain `l`/`lid`/`tr`. A token names both halves it freezes -
the shared revision and the translation revision - so the frozen guarantee is
whole rather than half. The locale check is symmetric and has no fallback: a `pl`
token used to read `en` is refused, and so is a locale-less token on a
locale-scoped read.

**Events** are six new keys, gated on `localization` (and on `publication` /
`editorial` for the ones that need them), so a non-localized content type gains
no key and every existing payload is unchanged. A shared update and a Polish
translation update are separate domain facts, never folded into `updated`.

**`can_translate`** depends on `can_view` and deliberately not on `can_edit`,
which is the whole point of having it. Permissions are JSON per role, so adding
one denies by default and needs no migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`localizedService.create` now takes `actor` and, through the model, `pluginId`.
With both, the default translation is written through the editorial layer inside
the same transaction and leaves a `create` revision. Without them - which is
every Stage 5A caller - it goes through the plain repository exactly as before.

The gap this closes is real: the base row's own revision snapshots shared fields
only, so the values a record was created with in its default language were the
one state no revision ever recorded. Restoring the earliest English version could
not get them back.

`pluginId` is optional rather than assumed, because a revision is stamped with
its owner and inventing one would put a wrong value in the column the cleanup job
keys off.

Also here: the migration. Additive only - no DROP COLUMN, no DELETE, no data
step. `status DEFAULT 'draft'` backfills every Stage 5A translation to a draft,
`languageId` arrives nullable so every existing revision stays a shared one, and
the unique index is rebuilt as the two partial ones. The predicate quotes
`"languageId"`: raw SQL would otherwise look for a lower-cased `language_id` and
fail at apply time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Runtime, type, route and PostgreSQL coverage for everything Stage 5B added.

The Postgres block is the one that proves the parts a mock cannot: that
`DEFAULT 'draft'` really does leave every existing translation unpublished, that
`publishedAt` survives an unpublish and a republish, that publishing Polish
leaves English at version 1, that English v1 and Polish v1 coexist under the two
partial unique indexes, that a locale's history read returns only its own
revisions, and that a restore cannot reach across a locale.

It runs against a disposable database and is skipped without `DATABASE_TEST_URL`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three new pages - the per-locale lifecycle and its subordination rule, the
per-locale history and what a restore may not cross, and the locale preview token
- plus the migration recipe, the event catalogue, and the boundary tables that
now name only 5C and 5D.

Says plainly what Stage 5B does not ship: a locale preview link cannot be minted
until `publicApi` combines with localization, because preview projects through
`publicApi.fields`. The token format is here and tested; the routes are not.

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

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vitnode-docs Ready Ready Preview Aug 7, 2026 7:53pm

@github-actions github-actions Bot added the 💡 Feature A new feature label Aug 7, 2026
@aXenDeveloper
aXenDeveloper marked this pull request as ready for review August 7, 2026 19:55
@aXenDeveloper aXenDeveloper changed the title feat: universal content engine mvp 5b feat: Add content engine Stage 5B — Editorial Localization Aug 7, 2026
@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant