feat: Add content engine Stage 5A — Localization Foundation - #737
feat: Add content engine Stage 5A — Localization Foundation#737aXenDeveloper wants to merge 11 commits into
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 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".
| ...(definition.localization.enabled | ||
| ? buildContentTranslationRoutes(model, { pluginId }) | ||
| : []), |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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 👍 / 👎.
Improving Documentation
pnpm lint:fixto fix formatting issues before opening the PR.Description
What?
Why?