Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,23 @@ Removing a content type for good needs no statement at all: the daily
registered, on both tables. A rename looks exactly like a removal to it, which
is why the `UPDATE` belongs in the same migration rather than the next release.

## A localized Content Type generates two tables

Marking a field `localized: true` moves its column onto a generated
`<tableName>_translations` table, and the plugin's database module exports both:

```ts
export const example_localized_articles = localizedArticleContent.table;
export const example_localized_articles_translations =
localizedArticleContent.translationTable;
```

`translationTable` is `null` without localization, so nothing about an existing
module changes. The generated schema, its keys and its indexes are covered in
[Translation tables](/docs/dev/content-engine/translation-tables); localizing a
table that already has rows in it needs [a hand-written
copy-verify-drop migration](/docs/dev/content-engine/localization-migrations#localizing-a-content-type-that-already-has-rows).

## Renaming or removing a field

<Callout type="warn" title="Renaming a field drops the column">
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/content/docs/dev/content-engine/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,20 @@ that reference each other still load fine.
row. `defineContentType` rejects the combination up front instead.
</Callout>

## localized

`text`, `textarea` and `slug` also accept `localized: true`, which moves the value
into a generated per-language table instead of onto the base table:

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

It needs `localization: { enabled: true, defaultLocale }` on the content type, and
the other builders do not take the argument at all - so a localized `boolean` is a
compile error. See [Localized
fields](/docs/dev/content-engine/localized-fields).

## Adding a field kind later

The descriptor union plus one case in each of the six mappers - column, select
Expand Down
9 changes: 7 additions & 2 deletions apps/docs/content/docs/dev/content-engine/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ That gives you:
(plus `can_publish` with [publication](/docs/dev/content-engine/publication))
- `content.example.article.created` / `.updated` / `.deleted` events

Three more declarations, each opt-in:
Four more declarations, each opt-in:

- [`publication`](/docs/dev/content-engine/publication) adds a draft/published
lifecycle, a `can_publish` permission and a badge in the AdminCP
Expand All @@ -54,9 +54,14 @@ Three more declarations, each opt-in:
- [`editorial`](/docs/dev/content-engine/editorial) adds a `version` column,
optimistic locking so two editors cannot silently overwrite each other, and a
[revision history](/docs/dev/content-engine/revisions) you can restore from
- [`localization`](/docs/dev/content-engine/localization) moves the text fields
you mark into a generated per-language table, with its own version per locale
and a URL per locale

Publication alone exposes nothing. Public exposure requires both of the first
two; `editorial` works with or without either.
two; `editorial` works with or without either. `localization` is currently
[exclusive of the other three](/docs/dev/content-engine/localization#stage-5a-boundaries) -
each combination arrives in a later stage.

## What it is not

Expand Down
58 changes: 57 additions & 1 deletion apps/docs/content/docs/dev/content-engine/limitations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ other 20%, so you find out here rather than halfway through building.
| Not supported | Do this instead |
| --- | --- |
| One-to-one, many-to-many, polymorphic relations | Write the join table and the queries by hand |
| Localised content fields | Use `core_languages_words` directly, as the blog plugin does |
| Localised `boolean`, `number`, `date`, `enum`, `relation` or `user` fields | [Only text kinds can be localized](/docs/dev/content-engine/localized-fields#which-kinds-are-refused-and-why). Split the content type, or keep the value shared |
| Locale-specific relations, localized media | Not in Stage 5 at all. A relation stays shared; the *related* content type can be localized in its own right |
| Rich text, media and file fields | Hand-build the field, or store an id and resolve it yourself |
| To-the-second [scheduling](/docs/dev/content-engine/scheduling) | The queue drains on a one-minute tick, so a schedule fires within about a minute |
| Scheduling a field edit, or a recurring schedule | Only `status` is scheduled. One row, one time, one action |
Expand Down Expand Up @@ -42,6 +43,61 @@ None of these are blocked - they are simply not generated. The service, the
schemas and the table are all public, so a hand-written route sits next to a
generated one without friction.

## Localization is infrastructure only, for now

[`localization`](/docs/dev/content-engine/localization) generates the tables, the
types, the schemas, the services and the translation routes. What it deliberately
refuses is every combination whose *reading* half is not built yet:

| Combination | Refused until |
| --- | --- |
| `localization` + `publication` | Stage 5B |
| `localization` + `editorial` | Stage 5B |
| `localization` + `publicApi` | Stage 5C |
| `localization` + `search` | Stage 5D |

Each is a definition-time error naming the stage. A localized content type that
silently ran Stage 1-4 logic against its base table while ignoring its localized
fields would be worse than one that refuses to be declared.

Also not in Stage 5A: AdminCP locale tabs, completeness badges, `can_translate`,
per-locale publication or revisions, fallback resolution, locale-aware cache tags
and per-locale search documents. The translation routes reuse `can_view`,
`can_edit` and `can_delete` until the UI they gate exists.

## Localized field names cannot appear on base-table surfaces

A localized field has no column on the base table, so it cannot be an
`admin.list` column, an `orderableFields` or `searchableFields` entry, a
`form.fields` entry, `admin.titleField`, or part of an `indexes` declaration. All
six are compile errors and runtime errors.

`admin.titleField` therefore falls back to `null` on a content type whose only
text fields are localized. Stage 5B gives the AdminCP a locale-aware title.

## Foreign key names on a long translation table are truncated by Postgres

Drizzle names a foreign key `<table>_<column>_<target>_<targetColumn>_fk`, which
for a translation table on an already-long base table passes 63 characters and is
silently truncated by Postgres. That is pre-existing Drizzle behaviour shared with
every hand-written table, not a localization quirk - and the names the *engine*
generates (the composite primary key and both indexes) carry a deterministic
fingerprint and stay inside the limit.

If two truncated names ever collide, Postgres says so when the migration runs.

## Turning localization off is a hand-written migration

Reverting to a single set of columns means choosing one language's values to keep,
and there is no correct default for that choice - so nothing generates it. See
[Localization migrations](/docs/dev/content-engine/localization-migrations#turning-localization-off).

The same applies in the other direction: localizing a content type that already
has rows generates a `DROP COLUMN` that is correct about the schema and silent
about the data. Copy, verify, then drop - the
[recipe](/docs/dev/content-engine/localization-migrations#localizing-a-content-type-that-already-has-rows)
spells it out.

## Filters are equality, and only equality

`filters` compares a column to one value. That is the whole feature.
Expand Down
249 changes: 249 additions & 0 deletions apps/docs/content/docs/dev/content-engine/localization-migrations.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
---
title: Localization migrations
description: What `drizzle-kit generate` produces for a localized Content Type, and the six-step recipe for localizing one that already has rows in it.
icon: DatabaseZap
---

Localization is a schema change like any other in the Content Engine: you declare
it, `drizzle-kit` generates the SQL, you read the diff and commit it. Nothing is
created at runtime.

## A new localized Content Type

Export both tables from the plugin's database module, then generate:

```ts title="src/database/localized-articles.ts"
export const example_localized_articles = localizedArticleContent.table;
export const example_localized_articles_translations =
localizedArticleContent.translationTable;
```

```bash
pnpm --filter <your-plugin> build:plugins
pnpm --filter <your-app> drizzle-kit generate --name=add_localized_articles
```

What comes out - the committed
`0029_add_example_localized_articles.sql`, in full:

```sql
CREATE TABLE "example_localized_articles" (
"id" serial PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"featured" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
ALTER TABLE "example_localized_articles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE "example_localized_articles_translations" (
"itemId" integer NOT NULL,
"languageId" integer NOT NULL,
"version" integer DEFAULT 1 NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"title" varchar(200) NOT NULL,
"slug" varchar(160) NOT NULL,
"body" text NOT NULL,
CONSTRAINT "example_localized_articles_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId")
);
--> statement-breakpoint
ALTER TABLE "example_localized_articles_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "example_localized_articles_translations" ADD CONSTRAINT "example_localized_articles_translations_itemId_example_localized_articles_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."example_localized_articles"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "example_localized_articles_translations" ADD CONSTRAINT "example_localized_articles_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
CREATE INDEX "example_localized_articles_created_at_idx" ON "example_localized_articles" USING btree ("createdAt");--> statement-breakpoint
CREATE INDEX "example_localized_articles_updated_at_idx" ON "example_localized_articles" USING btree ("updatedAt");--> statement-breakpoint
CREATE INDEX "example_localized_articles_translations_language_id_idx" ON "example_localized_articles_translations" USING btree ("languageId");--> statement-breakpoint
CREATE UNIQUE INDEX "example_localized_articles_translations_language_id_slug_key" ON "example_localized_articles_translations" USING btree ("languageId","slug");
```

Everything Stage 5A promises is in that file: the base table with only its shared
field, the translation table with the localized ones, both foreign keys with
opposite `ON DELETE` behaviour, the composite primary key, the `version` default,
the timestamps, and the language-scoped unique slug index.

<Callout title="Languages are not seeded">
Nothing in VitNode inserts rows into `core_languages` - they are created by the
installer, or in AdminCP → Languages. A fresh database has none, so a localized
content type whose `defaultLocale` is `"en"` will
[refuse to boot](/docs/dev/content-engine/localization#the-boot-check) until an
`en` language exists. Test fixtures have to insert their own.
</Callout>

## Localizing a Content Type that already has rows

This is the interesting case, and the engine deliberately does **not** generate it
for you.

Marking an existing `title` as `localized: true` makes `drizzle-kit` produce
exactly what it should produce for the schema you described: `CREATE TABLE ...
_translations` **and** `ALTER TABLE ... DROP COLUMN "title"`. The new table starts
empty, so applying that as-is deletes every title you had.

<Callout type="warn" title="Never apply the generated diff unedited">
The `DROP COLUMN` is correct about the destination and silent about the data.
Split it: copy first, verify, and only then drop.
</Callout>

### The six steps

1. Create the translation table.
2. Resolve the configured default language's id.
3. Copy the localized columns from the base table into it.
4. **Verify the copied row count.**
5. Add the constraints and indexes.
6. Drop the localized columns from the base table - only now.

Steps 4 and 6 are the ones that matter. Everything before step 4 is repeatable and
harmless; step 6 is the one you cannot undo.

### The migration, written out

Generate the migration, then replace its body with this shape. `example_articles`
localizing `title` and `slug` stands in for whatever yours is:

```sql
-- 1. The new table, without its constraints yet: the copy has to be allowed to
-- land before uniqueness is enforced, so a duplicate is a report rather than a
-- failed migration.
CREATE TABLE "example_articles_translations" (
"itemId" integer NOT NULL,
"languageId" integer NOT NULL,
"version" integer DEFAULT 1 NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"title" varchar(200) NOT NULL,
"slug" varchar(160) NOT NULL
);
--> statement-breakpoint
ALTER TABLE "example_articles_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint

-- 2 + 3. Copy every existing row into the default language. `INSERT ... SELECT`
-- rather than a loop: one statement, one snapshot, and the language id is
-- looked up rather than hardcoded - a literal `1` is right on the machine
-- it was written on and wrong on every other install.
INSERT INTO "example_articles_translations"
("itemId", "languageId", "title", "slug", "createdAt", "updatedAt")
SELECT
a."id",
(SELECT "id" FROM "core_languages" WHERE "code" = 'en'),
a."title",
a."slug",
a."createdAt",
a."updatedAt"
FROM "example_articles" a;
--> statement-breakpoint

-- 4. Verify. Abort the whole migration if a single row did not make it - the
-- alternative is dropping the source columns of the rows that failed.
DO $$
DECLARE
source_count integer;
copied_count integer;
language_id integer;
BEGIN
SELECT "id" INTO language_id FROM "core_languages" WHERE "code" = 'en';
IF language_id IS NULL THEN
RAISE EXCEPTION 'No core_languages row for the default locale "en". Create the language before running this migration.';
END IF;

SELECT count(*) INTO source_count FROM "example_articles";
SELECT count(*) INTO copied_count FROM "example_articles_translations";

IF source_count <> copied_count THEN
RAISE EXCEPTION 'Copied % of % rows into example_articles_translations; refusing to drop the source columns.',
copied_count, source_count;
END IF;
END $$;
--> statement-breakpoint

-- 5. Now the constraints and indexes. A pre-existing duplicate slug surfaces here
-- as a named, understandable failure with the data still intact.
ALTER TABLE "example_articles_translations"
ADD CONSTRAINT "example_articles_translations_item_id_language_id_pk"
PRIMARY KEY ("itemId", "languageId");--> statement-breakpoint
ALTER TABLE "example_articles_translations"
ADD CONSTRAINT "example_articles_translations_itemId_example_articles_id_fk"
FOREIGN KEY ("itemId") REFERENCES "public"."example_articles"("id")
ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "example_articles_translations"
ADD CONSTRAINT "example_articles_translations_languageId_core_languages_id_fk"
FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id")
ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
CREATE INDEX "example_articles_translations_language_id_idx"
ON "example_articles_translations" USING btree ("languageId");--> statement-breakpoint
CREATE UNIQUE INDEX "example_articles_translations_language_id_slug_key"
ON "example_articles_translations" USING btree ("languageId","slug");--> statement-breakpoint

-- 6. Only now. Everything above has committed or the migration has aborted.
ALTER TABLE "example_articles" DROP COLUMN "title";--> statement-breakpoint
ALTER TABLE "example_articles" DROP COLUMN "slug";
```

Take the exact constraint and index **names** from the generated file rather than
retyping them - they are derived from your table name and clamped to Postgres'
63-character limit, and a name that does not match the one Drizzle expects makes
every future diff noisy.

### Before you run it

- The base table's old unique slug index goes away with the column. That is
correct: uniqueness moves from "globally" to "per language".
- The `en` language row has to exist. Step 4 says so explicitly rather than
letting `languageId` come out `NULL`.
- Run it against a copy of production first. The verification step turns a data
loss into a failed migration, which is the whole point, but a failed migration
is still better discovered on a copy.

## Turning localization off

Destructive, and never generated automatically.

Reverting means choosing **one** language's values to keep, because a single
column cannot hold several. There is no correct default for that choice, so the
engine does not make one. Write it by hand:

1. Add the columns back to the base table, nullable.
2. `UPDATE ... FROM` the translation table for the one locale you are keeping.
3. Verify the count, exactly as above.
4. Tighten the columns to `NOT NULL` and add the unique slug index back.
5. Drop the translation table.

Everything in every other language is gone at step 5. Export it first if it might
matter.

## Renaming a locale code

Safe. Translations reference `core_languages.id`, not `.code`, so changing a code
is one `UPDATE` to one row and no translation row moves.

Update the content type's `defaultLocale` to match in the same release - the
[boot check](/docs/dev/content-engine/localization#the-boot-check) will otherwise
refuse to serve, which is the correct outcome for a default locale that no longer
exists.

## Adding a language

No migration at all. Create the language in AdminCP → Languages and start writing
translations - the schema does not change when a language does, which is the whole
reason for a row per language rather than a column per language.

## Testing a migration for real

The example plugin's Postgres suite replays every committed `example_*` migration
against a throwaway database and then exercises the tables it produced -
concurrent per-locale updates, the cascade, the language restrict, and the unique
slug index:

```bash
DATABASE_TEST_URL=postgres://postgres:postgres@localhost:5432/vitnode_test \
pnpm --filter @vitnode/example test
```

<Callout type="warn" title="That suite wipes the database it points at">
It drops and recreates the whole `public` schema, and refuses to run unless the
database name contains "test". Never point it at a development or production
database.
</Callout>

A new migration only has to be added to `EXAMPLE_MIGRATIONS` in
`plugins/example/src/const.ts`; both database suites read that list.
Loading
Loading