Skip to content

feat: Add content engine Stage 5A — Localization Foundation - #737

Open
aXenDeveloper wants to merge 11 commits into
feat/Universal-Content-Engine-mvp-4from
feat/Universal-Content-Engine-mvp-5a
Open

feat: Add content engine Stage 5A — Localization Foundation#737
aXenDeveloper wants to merge 11 commits into
feat/Universal-Content-Engine-mvp-4from
feat/Universal-Content-Engine-mvp-5a

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

Description

What?

Why?

aXenDeveloper and others added 11 commits August 6, 2026 20:05
Opts a content type into per-language content with one block:

    localization: { enabled: true, defaultLocale: "en", fallback: "none" }

and one flag on the three field kinds that hold prose:

    title: field.text({ localized: true, required: true })

`localized` is a literal type parameter, like `required` and `nullable`, so
`localized: false` and `localized: true` stay distinguishable - which is what
lets every later stage expose translation services and routes conditionally
rather than at runtime. The other builders do not take the argument at all, so a
localized boolean is a compile error, and `defineContentType` refuses it again at
runtime for anything that skipped the builders.

`partitionContentFields` is the one place that decides whether a field is
localized. Every subsystem reads it rather than testing `field.localized === true`
for itself; two copies of that rule is exactly the pair that drifts, and the
consequence of drift is a column generated on one table and read from the other.

The partition drives the base indexes, the admin surfaces and the schemas, so a
localized field cannot be a DataTable column, a sort, a search, a form field, the
title field, or part of an index - it has no column on the base table for any of
those to address.

Stage 5A refuses `localization` alongside `publication`, `editorial`, `publicApi`
and `search`, each with the stage that lifts it in the message. Running Stage 1-4
logic against the base table while ignoring the localized fields would be worse
than a refused definition.

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

`schemas.create`, `update`, `select`, `filters` and `form` narrow to the shared
half of the field map, and a new `schemas.translation` group carries the localized
half: `create`, `update`, `select`, `selectMeta`, `params` and the two envelopes.
`null` for a content type without localization, matching how `publicService` is
`undefined` without a public API.

Content values live under `values`; the locale and `expectedVersion` sit beside
them. That keeps `values` a strict object of the content type's own localized
fields - `itemId`, `languageId` and `version` are identity and transport, and
accepting any of them there would make all three mass-assignable.

Five structured errors, because a client that cannot tell them apart can only show
"something went wrong": a version conflict that names the locale, a
default-translation refusal, a duplicate translation, an unusable language
(missing vs disabled), and a missing base record.

`zodContentTranslationConflict` is its own union rather than three more members of
`zodContentConflict`: that one is the contract every existing generated client is
built from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One table per content type, not one shared table for the whole install. That is
the whole design decision, and everything good about it follows: real Postgres
types, real NOT NULL, a real unique index on (languageId, slug), Drizzle inference
that knows what `title` is, and a migration drizzle-kit generates rather than a
JSONB blob or an EAV table nobody can index.

    PRIMARY KEY (itemId, languageId)
    itemId     -> <base>.id           ON DELETE cascade
    languageId -> core_languages.id   ON DELETE restrict

The two ON DELETE behaviours are opposites on purpose. A record's translations are
part of the record, so removing it takes them along in one statement - no loop over
locales anywhere. Deleting a *language* must not silently delete every article
written in it, so Postgres refuses and a person decides what happens to the content
first. That is deliberately unlike core_languages_words, which cascades: losing a
UI string is an inconvenience, losing every article is not.

`languageId` references `core_languages.id` rather than `.code` - four bytes in a
composite key every translation read uses, and renaming a locale rewrites no
translation rows. The code is still the only thing that appears in a URL.

The base table and the base service narrow to the shared fields, so a
non-localized content type generates exactly the table it always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`core_languages` is the source of truth. The Content Engine keeps no language list
of its own, invents no locale codes, and stores a foreign key to a row an admin
created.

Matching is case-insensitive and the *stored* code comes back, so `/PL/` and `/pl/`
write to the same row and the response says `pl`. The registry loads once per
request into a WeakMap keyed by the context, so resolving twenty locales is one
query rather than twenty - the N+1 a naive `WHERE code = $1` resolver becomes on a
list of translations. A failed load is not cached.

`tx` is threaded through, and it is required for correctness rather than an
optimisation: a pool whose only free connection is held by the caller's open
transaction would otherwise wait forever for one. Reading inside the transaction
also means the language resolved is the one the insert will see.

A missing locale and a disabled one are different answers - 404 versus 409. A
disabled language stays readable: its content is already in the database, and
hiding it would make it unrecoverable. Growing more of it is what gets refused.

Whether `defaultLocale` names a real row cannot be a definition-time check - a
definition is plain data built at import time, long before there is a connection.
The boot guard is the explicit runtime phase that replaces it: once per process, on
the first request, naming every offender at once, and skipped entirely when nothing
is localized so an install with no localized content types never touches the
languages table because of it.

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

Every translation row carries its own version, and the version is part of the
statement that changes it rather than checked before it:

    UPDATE ... SET ..., version = version + 1
     WHERE itemId = $1 AND languageId = $2 AND version = $3

Two editors racing produce one statement that matches and one that does not, with
no read-then-write window between them. A delete is the same shape.

The important half is what does not happen: an edit to Polish never conflicts with
an edit to English. Different rows, keyed by (itemId, languageId), independent
counters. Two translators working at the same time is the normal case, not a race.

A no-op is a success that changes nothing - no version bump, no `updatedAt`, no
statement at all - and a stale `expectedVersion` is deliberately not an error on
one, because there is nothing to overwrite. Values are normalised before the diff,
so re-sending the stored slug in a different case counts as no change. Same
semantics the base service already has.

Slugs go through the same normaliser the base service uses, over the localized half
of the field map. Two slug algorithms would drift into `/en/my-post` and
`/pl/my_post`.

Deliberately low level: no event, no cache tag, no search document, no revision. A
repository that emitted events could not be called inside somebody else's
transaction, which is exactly what atomic create needs it to be. Stage 5B
orchestrates the effects on top.

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

One transaction, two inserts. Either both exist or neither does - and that
invariant is what every later stage leans on: a record always resolves in at least
one language, so a locale tab strip always has something to show and a public read
always has something to fall back to.

The default language is resolved inside the transaction and before the base insert,
so a language that has just been removed rolls the base row back with it rather
than leaving an untranslatable record behind. A slug clash does the same.

A record is created in its default locale, and another one is refused: creating
straight into Polish would leave the default translation missing, and every later
stage would need an "unless it was created in another locale" branch.

`createContentModel` grows four nullable members and two service factories, all
`null`/`undefined` without localization - the convention `publicService` and
`editorialService` already set, so a check reads naturally in code that does not
know which content type it was handed, and TypeScript refuses the call until it has
been made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    GET    /{id}/translations              can_view    metadata only
    GET    /{id}/translations/{locale}     can_view
    POST   /{id}/translations/{locale}     can_edit    { values }
    PUT    /{id}/translations/{locale}     can_edit    { expectedVersion, values }
    DELETE /{id}/translations/{locale}     can_delete  { expectedVersion }

Mounted under the same module as the content type's CRUD routes, so a localized
content type gets them without a second registration.

Identity is `contentType + itemId + locale` and never the translation row's own key
- (itemId, languageId) is the primary key, so there is no surrogate id to leak, and
the module the route is mounted in already fixes which table is read.

The list route returns metadata without values. A locale strip needs to know which
languages exist and how stale each one is; dragging every article body in every
language across the wire to find that out is the thing it is designed not to do.

Existing permissions on purpose. A dedicated `can_translate` means a migration for
every role in every install, and shipping it before the AdminCP has a translation
screen to gate would be a checkbox that governs nothing anybody can see. Stage 5B
introduces it with the UI it belongs to.

`withTranslationHttpErrors` separates seven outcomes - record missing, locale
unknown, locale disabled, translation exists, version moved, default translation,
slug taken - and the driver's message reaches none of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
191 new assertions across seven files:

  localization.test.ts          the partition, resolved defaults, every validation
                                rule, the Stage 5A boundaries, and Stage 1-4
                                fixtures proving nothing moved
  localization.test-d.ts        the literal `enabled` flag, the localized/shared
                                name unions, the value types, and six @ts-expect-
                                errors for the field kinds and admin surfaces that
                                must refuse a localized field
  translation-schemas.test.ts   strictness both ways, requiredness, nullability,
                                and every metadata key rejected as a content value
  translation-table.test.ts     localized fields absent from the base table, shared
                                fields absent from the translation table, both
                                foreign keys, the composite key, the locale-scoped
                                slug index, and identifier lengths
  language-resolver.test.ts     canonical casing, missing vs disabled, one query
                                per request, and the boot guard - including that it
                                does not touch the languages table with nothing
                                localized
  translation-model.test.ts     version 1 on create, +1 on a real update, unchanged
                                on a no-op, independent locales, stale update and
                                delete, and the default-translation refusal
  localized-service.test.ts     both inserts in one transaction, and a rollback for
                                each way the second one can fail
  translation-routes.test.ts    all five routes, their permissions, every structured
                                409, and the driver's message not leaking

Two localized fixtures, added rather than flags on existing ones: leaving every
Stage 1-4 fixture exactly as it was is what proves localization changes nothing for
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`example.localized-article`: `featured` shared, `title`, `slug` and `body`
localized. One base table with its shared field, one translation table with the
localized ones, both foreign keys, the composite primary key and a unique
(languageId, slug) index - all in a `drizzle-kit generate` diff a human can read.

Registered on the API side only. Its CRUD and translation routes exist and its
staff permissions are derived, but it gets no AdminCP screen yet: a form that could
not edit `title` in any language would be worse to ship than no form at all. Stage
5B adds the locale tabs and registers it in `config.tsx`.

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

`tables.test.ts` asserts the Drizzle objects and the committed DDL text agree:
localized fields absent from the base table, shared fields absent from the
translation table, the composite key inside 63 characters, both ON DELETE
behaviours, and the language-scoped unique slug index.

`postgres.test.ts` gains 28 tests that only a real database can answer, because a
mock asked whether a rollback happened can only agree with itself:

  - the base row and its default translation commit together
  - each way the translation insert can fail leaves no base row behind
  - English at v3 and Polish at v1 update concurrently, neither conflicting
  - two writers on Polish v2: exactly one succeeds, one gets a version conflict
  - a no-op moves neither `version` nor `updatedAt` - proof no statement ran
  - the default translation cannot be deleted; another one can
  - `/en/about` and `/pl/about` coexist; a second English `about` is 23505
  - deleting the record cascades its translations
  - deleting a language that content is written in is 23503
  - the composite primary key is the one Postgres actually created
  - a base list never joins translations

Languages are inserted by the suite itself, because nothing in VitNode seeds
`core_languages` - assuming `en` existed would pass locally and fail on fresh CI.

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

  localization.mdx             the block, the shared/localized split, per-locale
                               locking, the default-translation invariant, language
                               resolution, the boot check, the Stage 5A boundaries
                               and the 5B-5D roadmap
  localized-fields.mdx         the three kinds that can be localized, why each other
                               kind is refused, the two slug rules, and where a
                               localized name may not appear
  translation-tables.mdx       the generated schema, why one table per content type
                               beats JSONB / per-language columns / EAV, both
                               foreign keys, and every index
  translation-service.mdx      every read and write, the conflict table, the five
                               routes, their request and response shapes, and the
                               security properties
  localization-migrations.mdx  the generated migration in full, and the six-step
                               copy-verify-drop recipe for localizing a table that
                               already has rows in it

The first paragraph of the localization page says what this is *not*: UI
translations are `core_languages_words` and the i18n system, content translations
are records. Both read the same language registry, and confusing the two is the
mistake the page exists to prevent.

`fallback` is documented as resolved-but-unread, and the boundaries page names the
stage that lifts each refused combination - nothing here implies localized
publication, public reads, cache or search work yet.

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

vercel Bot commented Aug 6, 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 6, 2026 6:11pm

@aXenDeveloper
aXenDeveloper marked this pull request as ready for review August 7, 2026 19:55

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 776aaee682

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1168 to +1170
...(definition.localization.enabled
? buildContentTranslationRoutes(model, { pluginId })
: []),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route localized creates through the atomic service

When localization is enabled here, the generated module still includes the ordinary create handler at lines 333–373, which parses only schemas.create (shared fields) and calls model.service(c).create. A client with can_create can therefore POST / and commit a base row without its required default-locale translation, violating the invariant enforced by localizedService.create and leaving records that resolve in no language. The localized create route must accept both value partitions and use the transactional localized service.

Useful? React with 👍 / 👎.

{ contentTypeId: definition.id, itemId: id, locale: target },
);

return c.json(row, 201);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Emit events after translation mutations

After this translation is created, the handler returns without emitting an event; the update and delete handlers do the same. Consequently, listeners cannot react to localized content changes and the new event contract is also absent from built-in-events.mdx. Emit documented create/update/delete translation events after successful writes, skipping update events only for no-op results.

AGENTS.md reference: AGENTS.md:L36-L36

Useful? React with 👍 / 👎.

const indexes: ResolvedContentIndex[] = [
named(translationTableName, { on: ["languageId"] }),
...Object.entries(localizedFields)
.filter(([, fieldValue]) => fieldValue.kind === "slug")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve unique constraints on localized text fields

For a definition using field.text({ localized: true, unique: true }), the base-index resolver excludes the localized field while this translation resolver selects only slugs. The accepted unique: true option is therefore silently discarded and PostgreSQL permits duplicate values despite the field's uniqueness contract. Generate a language-scoped unique index for localized unique text fields, or reject that option for localized text.

Useful? React with 👍 / 👎.

@aXenDeveloper aXenDeveloper changed the title feat: Add content engine Stage 5a - Localization feat: Add content engine Stage 5A: Localization Foundation Aug 7, 2026
@github-actions github-actions Bot added the 💡 Feature A new feature label Aug 7, 2026
@aXenDeveloper aXenDeveloper changed the title feat: Add content engine Stage 5A: Localization Foundation feat: Add content engine Stage 5A — Localization Foundation 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