diff --git a/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx index 21ffcc41a..4189a6482 100644 --- a/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx +++ b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx @@ -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 +`_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 diff --git a/apps/docs/content/docs/dev/content-engine/fields.mdx b/apps/docs/content/docs/dev/content-engine/fields.mdx index 0eb512d84..19cfa3206 100644 --- a/apps/docs/content/docs/dev/content-engine/fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/fields.mdx @@ -212,6 +212,20 @@ that reference each other still load fine. row. `defineContentType` rejects the combination up front instead. +## 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 diff --git a/apps/docs/content/docs/dev/content-engine/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx index e035827f5..55a6d2ae5 100644 --- a/apps/docs/content/docs/dev/content-engine/index.mdx +++ b/apps/docs/content/docs/dev/content-engine/index.mdx @@ -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 @@ -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 diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index 37fbddd18..617477605 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -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 | @@ -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 `____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. diff --git a/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx b/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx new file mode 100644 index 000000000..42e74cfbc --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx @@ -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 build:plugins +pnpm --filter 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. + + + 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. + + +## 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. + + + The `DROP COLUMN` is correct about the destination and silent about the data. + Split it: copy first, verify, and only then drop. + + +### 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 +``` + + + 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. + + +A new migration only has to be added to `EXAMPLE_MIGRATIONS` in +`plugins/example/src/const.ts`; both database suites read that list. diff --git a/apps/docs/content/docs/dev/content-engine/localization.mdx b/apps/docs/content/docs/dev/content-engine/localization.mdx new file mode 100644 index 000000000..fa3005b47 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localization.mdx @@ -0,0 +1,311 @@ +--- +title: Localization +description: One opt-in block that moves the text fields you name into a generated per-language table, with real columns and one version per locale. +icon: Languages +--- + +An article exists in English and in Polish. Same author, same category, same +"featured" flag - a different title, a different URL and a different body. + +`localization` is the block that models that. + +```ts title="src/content/localized-article.ts" +export const localizedArticleContentType = defineContentType({ + id: "example.localized-article", + tableName: "example_localized_articles", + + localization: { + enabled: true, + defaultLocale: "en", + }, + + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, required: true }), + + featured: field.boolean({ defaultValue: false }), + }, + + admin: { + label: { + plural: "Example Localized Articles", + singular: "Example Localized Article", + }, + }, +}); +``` + +Two tables come out of that, not one: + +```text +example_localized_articles id, createdAt, updatedAt, featured +example_localized_articles_translations itemId, languageId, version, + createdAt, updatedAt, + title, slug, body +``` + +A content type without the block generates the same single table, the same +routes and the same wire shapes it always did. + + + Localization currently cannot be combined with `publication`, `editorial`, + `publicApi` or `search` - each combination is refused at definition time with a + message naming the stage that lifts it. See [Stage 5A + boundaries](#stage-5a-boundaries). + + +## This is not UI translation + +VitNode already has an i18n system, and it is a different thing. + +| | UI translations | Content Engine translations | +| --- | --- | --- | +| What they translate | Buttons, labels, emails, error messages | The records themselves | +| Where they live | `src/locales/*.json`, `core_languages_words` | A generated `
_translations` table | +| Who writes them | Developers and translators, in source control or AdminCP | Editors, through the content type's API | +| Shape | Key to string | Real typed columns, one row per language | + +Both read the same registry: **`core_languages` is the source of truth for what a +language *is*.** The Content Engine has no language list of its own, does not +invent locale codes, and stores a foreign key to a row an admin created in +AdminCP → Languages. + +## What it turns on + +| | | +| --- | --- | +| A generated translation table | One per localized content type. Real columns, real `NOT NULL`, real indexes | +| A shared/localized split | Every field you did not mark stays on the base table, exactly as before | +| [Per-locale versioning](#optimistic-locking-per-locale) | Each translation has its own `version`. Editing Polish cannot conflict with English | +| Locale-scoped slug uniqueness | `/en/about` and `/pl/about` both work; two English `about` rows do not | +| Atomic creation | A record and its default-locale translation commit together, or not at all | +| A [translation service](/docs/dev/content-engine/translation-service) | Reads and writes, transaction-aware, with structured conflicts | +| Five routes | `GET`/`POST`/`PUT`/`DELETE` under `/{id}/translations` | +| A boot check | An install whose `defaultLocale` does not exist refuses to serve | + +## The block + +```ts +localization: { + enabled: true, + + // Required. Must name a row in `core_languages`. + defaultLocale: "en", + + // Optional. What a public read does for a locale with no translation. + fallback: "none", +} +``` + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | `true` | - | Literal `true`. Omit the block to stay non-localized | +| `defaultLocale` | `string` | - | The locale every record is created in, and the one translation it can never lose | +| `fallback` | `"none" \| "default"` | `"none"` | Reserved for Stage 5C. Resolved now so the configuration is stable before anything reads through it | + +`defaultLocale` is checked twice. Its **shape** is checked at definition time - +it has to look like a locale code and fit `varchar(32)`. Whether it names a real, +enabled row in `core_languages` is a fact about the *installation*, so it is +checked [once at boot](#the-boot-check) instead. + +`fallback` does nothing yet, and this page will not pretend otherwise. Nothing in +Stage 5A reads through it; it exists so that a content type declared today does +not change public behaviour the moment Stage 5C lands. `"none"` is the default +because it is the only answer that cannot silently publish the wrong language. + +## Which fields can be localized + +Three kinds, and they are the ones that hold prose: + +```ts +title: field.text({ localized: true }), +body: field.textarea({ localized: true }), +slug: field.slug({ localized: true, source: "title" }), +``` + +Everything else stays shared, and the builders do not take the argument - so +`field.boolean({ localized: true })` is a compile error, not a runtime surprise. +See [Localized fields](/docs/dev/content-engine/localized-fields) for why each +one is on the list it is on. + +## Shared and localized + +One rule, applied everywhere: **a field is localized when you say so, and shared +otherwise.** The split drives table generation, schema generation, the services, +the routes and the migration, and every one of them reads it from the same +helper - so there is no way for a column to be generated on one table and read +from the other. + +What lands where: + +| Base table | Translation table | +| --- | --- | +| `id`, `createdAt`, `updatedAt` | `itemId`, `languageId` (the primary key) | +| Every shared field | Every localized field | +| | `version`, `createdAt`, `updatedAt` | + +A localized field is **not** a column on the base table, which has consequences +worth knowing up front: + +- it cannot appear in `admin.list.columns`, `orderableFields`, `searchableFields` + or `form.fields`, +- it cannot be `admin.titleField`, +- it cannot appear in `indexes`, +- it is absent from `schemas.create`, `schemas.update` and `schemas.select`. + +All five are compile errors *and* runtime errors. Localized values get their own +AdminCP surface in Stage 5B; until then there is nowhere on the base form for +them to go, and a silently-dropped title is worse than a refused definition. + +## Optimistic locking per locale + +Every translation row carries its own `version`, and every write names the +version it started from: + +```ts +await translations.update( + itemId, + "pl", + { title: "Nowy tytuł" }, + { expectedVersion: 3 }, +); +``` + +The version is part of the `UPDATE`'s `WHERE`, so two editors racing produce one +statement that matches and one that does not - with no read-then-write window +between them. + +The important half is what does **not** happen: an edit to Polish never conflicts +with an edit to English. They are different rows, keyed by +`(itemId, languageId)`, with independent counters. Two translators working in two +languages at the same time is the normal case, not a race. + +A no-op is still a success and changes nothing at all: no version bump, no +`updatedAt`, no write. See [Translation +service](/docs/dev/content-engine/translation-service#no-ops). + +## The default translation + +Creating a localized record creates its default-locale translation in the same +transaction: + +```ts +const { row, translation } = await localizedService.create({ + shared: { featured: true }, + translation: { body: "...", slug: "hello", title: "Hello" }, +}); +``` + +Either both exist or neither does. 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. + +Two rules protect it: + +- **A record is created in its default locale.** Passing another one is refused, + because the record would then have no default translation at all. +- **The default translation cannot be deleted.** The route answers a structured + `CONTENT_DEFAULT_TRANSLATION_REQUIRED` 409. Delete the record instead. + +Deleting the record removes its translations through the foreign key's `ON DELETE +CASCADE` - one statement, no loop over locales, and no window in which a +translation outlives its row. + +## Locale-scoped slugs + +A localized slug is unique **within one language**: + +```text +/en/about ✓ +/pl/about ✓ same string, different language +/en/about ✗ 409, the English one is taken +``` + +That is a `UNIQUE (languageId, slug)` index on the translation table, not on the +base table. Each language derives its own slug from its own title, using the same +`slugify` the rest of the engine uses - there is no second slug algorithm. + +## Language resolution + +Locales travel as strings (`pl`, `pt-BR`) and are resolved to +`core_languages.id` internally. A numeric language id is never the public +identifier of anything. + +Matching is case-insensitive and the **stored** code is what comes back, so +`/PL/` and `/pl/` write to the same row and the response says `pl`. The registry +is loaded once per request, so resolving twenty locales costs one query rather +than twenty. + +Two ways a locale can fail, answered differently because they mean different +things: + +| | | | +| --- | --- | --- | +| No such row in `core_languages` | `404` | There is nothing to address | +| Listed in the app's `i18n.locales` with `enabled: false` | `409 CONTENT_LANGUAGE_DISABLED` | It exists, and this install switched it off | + +A disabled language is **readable and not writable**. Its content is already in +the database, and hiding it would make it unrecoverable; growing more of it in a +language nothing renders is the part that gets refused. + +Deleting a language that content is written in is refused by Postgres itself - +the foreign key is `ON DELETE RESTRICT`. That is deliberately different from +`core_languages_words`, which cascades: losing a UI string is an inconvenience, +losing every article written in a language is not. + +## The boot check + +A content type definition is plain data built at import time, long before there +is a database connection - so "does `core_languages` have a row for `en`" cannot +be a definition-time check. + +It runs once per process instead, on the first request, before any handler: + +```text +[Content Engine] Localized content types have an unusable default locale: + example.localized-article -> localization.defaultLocale "en" does not exist in core_languages +``` + +Loud on purpose. A broken `defaultLocale` means *no record of that type can be +created at all*, which is not a degraded mode worth booting into. It names every +offender at once rather than failing on the first, and it is skipped entirely +when no content type is localized - an install with none never touches the +languages table because of it. + +## Stage 5A boundaries + +Localization lands as infrastructure. The stages that read *through* it are not +here yet, and the honest failure for that is a refused definition rather than a +content type that quietly runs Stage 1-4 logic against the base table while +pretending its localized fields do not exist. + +| Combination | Refused until | Why | +| --- | --- | --- | +| `localization` + `publication` | Stage 5B | A localized record has one status per *language*. Publishing the English draft must not put an empty Polish page on the internet | +| `localization` + `editorial` | Stage 5B | A revision would snapshot the base row only, so restoring it would silently drop every translation | +| `localization` + `publicApi` | Stage 5C | A public read has to resolve a locale and decide what to do when a translation is missing | +| `localization` + `search` | Stage 5D | One document per record would index a single language and rank every other one as a miss | + +Each is a `ContentEngineError` at definition time, with the stage in the message. + +## The roadmap + +| Stage | What it adds | +| --- | --- | +| **5A** (this one) | Tables, types, schemas, language resolution, translation service, per-locale locking, atomic create, routes, migrations | +| **5B** | AdminCP locale tabs, completeness badges, per-locale publication status, per-locale revisions, `can_translate` | +| **5C** | Locale-aware public API, fallback resolution, locale-aware cache tags | +| **5D** | Per-locale search documents, `hreflang`, localized sitemap | + +Explicitly outside all four: locale-specific relations, localized media, AI +translation, translation memory, external TMS integration, and migrating the blog +plugin onto the Content Engine. + +## Where to next + +- [Localized fields](/docs/dev/content-engine/localized-fields) - which kinds, and why the others are refused +- [Translation tables](/docs/dev/content-engine/translation-tables) - the generated schema, keys and indexes +- [Translation service](/docs/dev/content-engine/translation-service) - every method, and every conflict it can raise +- [Localization migrations](/docs/dev/content-engine/localization-migrations) - the generated migration, and how to localize an existing content type safely diff --git a/apps/docs/content/docs/dev/content-engine/localized-fields.mdx b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx new file mode 100644 index 000000000..39ff0d978 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx @@ -0,0 +1,191 @@ +--- +title: Localized fields +description: Which field kinds can hold a translation, why the rest cannot, and the two slug rules that catch the mistakes nobody notices. +icon: Type +--- + +`localized: true` moves a field's value off the base table and into the generated +translation table, one row per language. + +```ts +title: field.text({ localized: true, required: true }), +``` + +Three kinds accept it. + +## The three kinds + +| Kind | Column on the translation table | Notes | +| --- | --- | --- | +| `text` | `varchar(maxLength)`, default 255 | One line of prose. The usual title | +| `textarea` | `text` | Long prose. The usual body | +| `slug` | `varchar(maxLength)`, default 160 | Unique **per language**. See [the slug rules](#the-two-slug-rules) | + +Every other declaration works exactly as it did, and every modifier a field +already had still applies - `required`, `nullable`, `minLength`, `maxLength` all +carry through to the translation table's column: + +```ts +title: field.text({ localized: true, required: true, maxLength: 200 }), +// -> "title" varchar(200) NOT NULL + +body: field.textarea({ localized: true, nullable: true }), +// -> "body" text +``` + +## Which kinds are refused, and why + +The builders for these do not take a `localized` argument, so passing one is a +**compile error**: + +```ts +// @ts-expect-error - each of these is rejected by the type of the argument +field.boolean({ localized: true }); +field.number({ integer: true, localized: true }); +field.dateTime({ localized: true }); +field.enum({ localized: true, values: ["draft", "review"] }); +field.user({ localized: true }); +field.relation({ localized: true, target: () => otherContentType }); +``` + +Not an oversight in each case - a decision: + +- **`boolean`, `number`, `dateTime`.** A per-locale `true`, `42` or timestamp is + not a translation of anything. If English and Polish genuinely need different + numbers, they are different facts and want different fields. +- **`enum`.** Its *values* are identifiers that everything filters, sorts and + branches on, so they have to be the same in every language. Its *labels* are UI + strings, which the [ordinary i18n system](/docs/dev/i18n) already translates - + that is the split, and it is the right one. +- **`relation` and `user`.** Foreign keys. A per-locale relation is a real + feature and it is explicitly out of scope for the whole of Stage 5; a relation + stays shared, and the *related* content type can be localized in its own right. + +`defineContentType` re-checks the same rule at runtime, because a JavaScript +caller or a cast can reach it with anything at all: + +```text +[Content Engine] example.article: Field "views" is `localized: true` but its kind +is "number". Only slug, text, textarea fields can be localized - an enum's +identifiers have to be the same in every language, and a relation's target is +shared. +``` + +## `localized` needs `localization` + +Marking a field without the block is refused: there would be no translation table +for the value to live in. + +```text +[Content Engine] example.article: Field "title" is `localized: true` but the +content type has no `localization: { enabled: true, defaultLocale }` block, so +there is no translation table for it to live in. +``` + +And the reverse: a `localization` block with no localized field is refused too, +because the generated table would hold nothing but its own keys. + +## The two slug rules + +A localized slug and its source have to agree about *where the value lives*. +Both mistakes are silent data bugs rather than crashes, which is why they are +compile-time and definition-time errors. + +### A localized slug needs a localized source + +```ts +// ✗ Every language would derive the same URL from the one shared name. +name: field.text({ required: true }), +slug: field.slug({ localized: true, source: "name" }), + +// ✓ +title: field.text({ localized: true, required: true }), +slug: field.slug({ localized: true, source: "title" }), +``` + +### A shared slug cannot come from a localized source + +```ts +// ✗ `title` has a different value in every language, so there is no single +// value to derive one URL from. +title: field.text({ localized: true, required: true }), +slug: field.slug({ source: "title" }), +``` + +Either localize the slug too, or point it at a shared text field. + +### A slug with no source + +Perfectly fine, localized or not - it just means the caller always supplies it, +so it is `required` in the translation payload: + +```ts +heading: field.text({ localized: true, required: true }), +slug: field.slug({ localized: true }), // required in `values` +``` + +## Reserved names + +A localized field cannot be called `itemId`, `languageId` or `version` - those +are columns the translation table always generates: + +```text +[Content Engine] example.article: Localized field "version" collides with a +generated translation column. Rename it - the translation table always carries +itemId, languageId, version, createdAt, updatedAt. +``` + +`createdAt` and `updatedAt` are already reserved for every content type, localized +or not. + +A **shared** field called `itemId` is fine: it lands on the base table, where +nothing generated claims that name. + +## What the types know + +The partition is visible in the type system, so a localized value cannot be +passed where a shared one is expected: + +```ts +type Article = typeof localizedArticleContentType; + +ContentLocalizedFieldName
; // "title" | "slug" | "body" +ContentSharedFieldName
; // "featured" + +ContentSharedValues
; // { featured?: boolean } +ContentLocalizedValues
; // { title: string; slug?: string; body: string } + +ContentCreateInput
; // === ContentSharedValues
+ContentSelect
; // { id, createdAt, updatedAt, featured } +``` + +For a content type without localization, `ContentLocalizedFieldName` is `never` +and `ContentLocalizedValues` is `{}` - which is what makes a `translation:` key +impossible to fill in by accident on a Stage 1-4 definition, and what keeps every +existing type exactly as it was. + +## Where a localized field may not appear + +Everything on this list addresses a column on the *base* table: + +```ts +admin: { + list: { + columns: ["title"], // ✗ + orderableFields: ["title"], // ✗ + searchableFields: ["title"], // ✗ + }, + form: { fields: ["title"] }, // ✗ + titleField: "title", // ✗ +}, +indexes: [{ on: ["title"] }], // ✗ +``` + +All six are compile errors, and all six are runtime errors as well. The defaults +skip localized fields automatically, so a localized content type that says nothing +about `admin.list` gets a sensible shared-only list without having to opt out of +anything. + +`admin.titleField` falls back to `null` when every text field is localized. A +toast whose wording depended on the reading admin's locale would be worse than no +title at all; Stage 5B gives the AdminCP a locale-aware one. diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index f09f7373a..a47740ede 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -19,6 +19,11 @@ "revisions", "preview", "scheduling", + "localization", + "localized-fields", + "translation-tables", + "translation-service", + "localization-migrations", "admincp", "permissions", "events", diff --git a/apps/docs/content/docs/dev/content-engine/schemas.mdx b/apps/docs/content/docs/dev/content-engine/schemas.mdx index d4a51293e..cd93c9a55 100644 --- a/apps/docs/content/docs/dev/content-engine/schemas.mdx +++ b/apps/docs/content/docs/dev/content-engine/schemas.mdx @@ -167,6 +167,18 @@ the definition, since a definition holds `target` thunks and cannot cross the server/client boundary. `buildFormSchemaFromSpec` also folds an existing row in as Zod defaults, which is how the edit dialog prefills. +## translation + +`null` unless the content type is localized. When it is, `schemas.translation` +carries the per-language half - `create`, `update`, `select`, `selectMeta`, +`params` and the two envelopes - built from the localized fields only, while +`create`, `update` and `select` above narrow to the shared ones. + +Content values live under `values`, and the locale and `expectedVersion` sit +beside them rather than inside, so `values` stays a strict object of the content +type's own fields. See [Translation +service](/docs/dev/content-engine/translation-service#request-and-response-shapes). + ## Why not drizzle-zod A Drizzle column cannot tell you whether a `varchar` should render as a diff --git a/apps/docs/content/docs/dev/content-engine/translation-service.mdx b/apps/docs/content/docs/dev/content-engine/translation-service.mdx new file mode 100644 index 000000000..06bfef86c --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/translation-service.mdx @@ -0,0 +1,354 @@ +--- +title: Translation service +description: The repository behind a localized Content Type - every read, every write, every conflict it can raise, and the five generated routes on top. +icon: Replace +--- + +Two services come with a localized content type. They do different jobs: + +| | | +| --- | --- | +| `model.localizedService(c)` | Creates a base row **and** its default translation, atomically | +| `model.translationService(c)` | Everything else: read, add, update and remove one translation | + +Both are `undefined` for a content type without localization, so TypeScript +refuses the call until you have checked. + +## Atomic creation + +```ts +const { row, translation } = await model.localizedService(c).create({ + shared: { featured: true }, + translation: { + body: "The English body", + title: "Hello World", + // `slug` omitted - derived from this language's title + }, +}); + +row.id; // 7 +translation.locale; // "en" +translation.version; // 1 +translation.values.slug; // "hello-world" +``` + +One transaction, two inserts. Either both exist or neither does: + +| Failure | Result | +| --- | --- | +| The base insert fails | No translation, and no base row | +| The translation insert fails | The base row is rolled back with it | +| A localized slug is already taken | No base row | +| The default language has just been removed | Nothing at all, and no orphan | + +That last one is why the default language is resolved *inside* the transaction and +before the base insert: a record with no translation is exactly the state the +invariant exists to prevent. + +### The locale + +Defaults to the content type's `defaultLocale`, and today may only be that: + +```ts +// ✓ the default, explicitly +await localizedService.create(input, { locale: "en" }); + +// ✗ refused +await localizedService.create(input, { locale: "pl" }); +// A Example Localized Article is created in its default locale "en", not "pl". +// Create it first, then add the "pl" translation. +``` + +A record created straight into Polish would have no default translation, and every +later stage would need an "unless it was created in another locale" branch. + +### Joining a transaction + +```ts +await db.transaction(async tx => { + const { row } = await localizedService.create(input, { tx }); + await somethingElse(row.id, tx); +}); +``` + +Same `tx` convention as every other Content Engine service. + +## Translation reads + +```ts +const translations = model.translationService(c); + +await translations.findByLocale(itemId, "pl"); +await translations.findByLanguageId(itemId, 2); +await translations.findManyForItem(itemId); +await translations.exists(itemId, "pl"); +await translations.resolveDefaultLanguage(); +``` + +| Method | Returns | +| --- | --- | +| `findByLocale(itemId, locale)` | One translation with its `values`, or `null` - including for an unknown locale | +| `findByLanguageId(itemId, languageId)` | The same, keyed by the stored id | +| `findManyForItem(itemId)` | **Metadata only**, one entry per existing translation, ordered by language | +| `exists(itemId, locale)` | `boolean`, without loading a single value | +| `resolveDefaultLanguage()` | The `{ id, locale, isDefault, isEnabled }` this content type creates records in | + +`findManyForItem` deliberately does not return 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. It costs one query for the rows plus one for the language registry - never +one per translation. + +One translation row comes back nested: + +```ts +{ + itemId: 7, + languageId: 2, + locale: "pl", + version: 3, + createdAt: Date, + updatedAt: Date, + values: { title: "Witaj", slug: "witaj", body: "..." }, +} +``` + +The nesting is not decoration: it keeps a localized field called `version` or +`locale` from being confused with the metadata of the row that holds it, and it +makes the update request body and the response the same shape. + +## Translation writes + +```ts +await translations.create(itemId, "pl", { body: "...", title: "Witaj" }); + +await translations.update( + itemId, + "pl", + { title: "Nowy tytuł" }, + { expectedVersion: 3 }, +); + +await translations.delete(itemId, "pl", { expectedVersion: 4 }); +``` + +| Method | Version | On success | On failure | +| --- | --- | --- | --- | +| `create` | starts at 1 | the new row | `ContentTranslationExists`, `ContentTranslationItemMissing`, `ContentLanguageError` | +| `update` | `+ 1`, or unchanged on a no-op | `{ changed, changedFields, row, version }` | `null` if the translation is missing, `ContentTranslationVersionConflict` if it moved | +| `delete` | guarded | the removed row | `null` if already gone, `ContentTranslationVersionConflict`, `ContentDefaultTranslationRequired` | + +All three take `{ tx }`, and all three validate their payload through the +generated strict schema before touching the database - so an invalid object never +costs a query. + +### What the service does not do + +No event, no cache tag, no search document, no revision. That is not a gap, it is +the contract: 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, the way `contentEditorialEffects` already does +for the base row. + +## Optimistic locking + +The version is part of the statement, not checked before it: + +```sql +UPDATE "..._translations" + SET "title" = $1, "version" = "version" + 1 + WHERE "itemId" = $2 AND "languageId" = $3 AND "version" = $4 +RETURNING ... +``` + +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. + +And crucially: **the lock is per locale.** + +```text +English at v3, Polish at v1 + → update English with expectedVersion 3 ✓ English becomes v4 + → update Polish with expectedVersion 1 ✓ Polish becomes v2 +``` + +Neither is told the other language moved. They are different rows. + +```text +Two writers, both holding Polish v2 + → one succeeds, Polish becomes v3 + → the other gets CONTENT_TRANSLATION_VERSION_CONFLICT +``` + +### No-ops + +An update whose values match what is stored is a **success that changes nothing**: + +```ts +{ changed: false, changedFields: [], row: , version: } +``` + +No `UPDATE` is issued at all, so: + +- the version does not move, +- `updatedAt` does not move, +- and a stale `expectedVersion` is *not* an error - there is nothing to overwrite, + so there is nothing to conflict about. + +Values are normalised before the comparison, so re-sending the stored slug in a +different case counts as no change rather than as a pointless write. Same +semantics the base service has always had. + +## Conflicts + +Five distinct outcomes, because a client that cannot tell them apart can only show +"something went wrong": + +| Situation | Status | Code | +| --- | --- | --- | +| No such base record | `404` | - | +| Unknown locale | `404` | - | +| Locale disabled in this install | `409` | `CONTENT_LANGUAGE_DISABLED` | +| That locale already has a translation | `409` | `CONTENT_TRANSLATION_EXISTS` | +| The translation moved since you read it | `409` | `CONTENT_TRANSLATION_VERSION_CONFLICT` | +| Deleting the default translation | `409` | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | +| A localized slug is taken **in this language** | `409` | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | + +The version conflict names the locale, which is the one thing a locale tab strip +has to know to reload the right tab: + +```json +{ + "code": "CONTENT_TRANSLATION_VERSION_CONFLICT", + "contentTypeId": "example.localized-article", + "itemId": 7, + "locale": "pl", + "expectedVersion": 3, + "currentVersion": 5 +} +``` + +Parse one with the exported helper rather than by hand: + +```ts +import { parseContentTranslationConflict } from "@vitnode/core/content"; + +const conflict = parseContentTranslationConflict(await response.text()); +if (conflict?.code === "CONTENT_TRANSLATION_VERSION_CONFLICT") { + // reload the `conflict.locale` tab +} +``` + +This is its own discriminated union rather than three more members of the Stage 4 +`zodContentConflict`: that one is the contract every existing generated client is +built from, and a translation route is new, so it can carry a shape that names the +locale in every arm. + +The driver's message never reaches a client. A `23505` becomes +`CONTENT_TRANSLATION_UNIQUE_CONFLICT`; the constraint name, the column and the +value stay on the server. + +## The generated routes + +Five, mounted under the same module and the same permissions as the content type's +CRUD routes: + +```text +GET /api/{pluginId}/admin/content/{module}/{id}/translations +GET /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} +POST /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} +PUT /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} +DELETE /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} +``` + +| Route | Permission | Body | +| --- | --- | --- | +| `GET /{id}/translations` | `can_view` | - | +| `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 }` | + +Existing permissions on purpose. A dedicated `can_translate` means a migration for +every role in every install, and doing that before the AdminCP has a translation +screen to gate would ship a checkbox that governs nothing anybody can see. It +arrives in Stage 5B, with the UI it belongs to. + +`PUT`, not `PATCH`: the Next.js API route handler exports no `PATCH`. + +### Request and response shapes + +`POST` and `PUT` keep content values in `values` and everything else beside them: + +```json +{ "expectedVersion": 3, "values": { "title": "Nowy tytuł" } } +``` + +`expectedVersion`, `locale`, `itemId` and `languageId` are **rejected inside +`values`** - it is a strict object of the content type's own localized fields, and +accepting identity or transport there would make all four mass-assignable. + +`GET /{id}/translations/{locale}`, `POST` and `PUT` all return the same detail +shape: + +```json +{ + "itemId": 7, + "languageId": 2, + "locale": "pl", + "version": 3, + "createdAt": "2026-08-06T10:00:00.000Z", + "updatedAt": "2026-08-06T10:04:00.000Z", + "values": { "title": "Witaj", "slug": "witaj", "body": "..." } +} +``` + +`PUT` wraps it: `{ changed, row }`. The list route returns metadata only: + +```json +{ + "edges": [ + { "itemId": 7, "languageId": 1, "locale": "en", "version": 4, "createdAt": "...", "updatedAt": "..." }, + { "itemId": 7, "languageId": 2, "locale": "pl", "version": 3, "createdAt": "...", "updatedAt": "..." } + ] +} +``` + +Every shape above is in the OpenAPI document, including the 409 union. + +### Security + +- Every route sits under `/admin/`, so the staff session middleware runs, and + every one carries an explicit `adminStaffPermission`. +- The module the route is mounted in fixes which table is read, so a locale in the + URL can never reach another content type's translation. +- Every query is scoped by both the item and the language. +- `languageId` is never accepted as content data - it is resolved from the locale + in the path. +- System columns (`itemId`, `languageId`, `version`, `createdAt`, `updatedAt`) are + absent from every write schema. +- A translation is never addressed by a row id of its own; identity is + `contentType + itemId + locale`. +- No public route reads translations at all in Stage 5A. + +## Language resolution + +Locales arrive as strings and are resolved against `core_languages`: + +```ts +import { resolveContentLanguage } from "@vitnode/core/content/server"; + +const language = await resolveContentLanguage(c, { + locale: "PL", + requireEnabled: true, +}); +// { id: 2, locale: "pl", isDefault: false, isEnabled: true } +``` + +- Case-insensitive, and the **stored** code comes back. +- `requireEnabled` is the write path. Reading a disabled locale is allowed; + writing one is refused. +- The registry is loaded once per request, so resolving many locales is one query. +- Pass `tx` when you are inside a transaction. This is not an optimisation: a pool + whose only free connection is held by your transaction would otherwise wait + forever for one. diff --git a/apps/docs/content/docs/dev/content-engine/translation-tables.mdx b/apps/docs/content/docs/dev/content-engine/translation-tables.mdx new file mode 100644 index 000000000..cf520242f --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/translation-tables.mdx @@ -0,0 +1,229 @@ +--- +title: Translation tables +description: One generated table per localized Content Type - real columns, a composite key, two foreign keys and a per-language unique index. +icon: Table2 +--- + +A localized content type generates two tables. This page is the second one. + +```ts title="src/database/localized-articles.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { localizedArticleContentType } from "@/content/localized-article"; + +export const localizedArticleContent = createContentModel( + localizedArticleContentType, +); + +// Two exports, not one. Drizzle Kit discovers each table from its export when it +// globs the built `dist/src/database/*.js`, so the translation table needs its +// own or the migration would be generated without it. +export const example_localized_articles = localizedArticleContent.table; +export const example_localized_articles_translations = + localizedArticleContent.translationTable; +``` + +`translationTable` is `null` for a content type without localization, so nothing +about an existing `src/database/*.ts` has to change. + +## The generated schema + +```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 +); + +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") +); +``` + +Note what is *not* on the base table: `title`, `slug` and `body`. A localized +field has exactly one column, and it is on the translation table. + +## Why one table per Content Type + +The three alternatives, and what each one costs: + +| Design | What breaks | +| --- | --- | +| A JSONB blob on the base row | No column types, no `NOT NULL`, no unique slug index, no useful Drizzle inference, and a migration nobody can review | +| `title_en`, `title_pl` columns | Adding a language is a schema migration, and every query has to be written per language | +| One universal EAV table | Every value is `text`, every read is a pivot, and a unique slug per language is not expressible | + +A generated per-type table keeps all of it: real Postgres types, real +constraints, ordinary indexes, ordinary joins, `$inferSelect` that knows what +`title` is, and a `drizzle-kit generate` diff a human can read. + +It generalizes what the blog plugin does by hand - one language row per record +per field, keyed to `core_languages` - without the EAV shape that makes a +per-language unique index impossible. + +## Keys + +```sql +PRIMARY KEY ("itemId", "languageId") +``` + +A composite key rather than a surrogate `id`: the identity of a translation *is* +the record plus the language. A serial primary key would make "one translation per +locale" a constraint somebody could forget to add. + +It also means a translation is never addressed by a row id of its own. External +identity is always: + +```text +contentType + itemId + locale +``` + +The name is generated deterministically and clamped to Postgres' 63-character +identifier limit with a fingerprint of the full name, the same way index names +are - so two long table names that differ only past character 63 cannot collapse +into one constraint. + +## Foreign keys + +```sql +"itemId" → ."id" ON DELETE cascade ON UPDATE cascade +"languageId" → "core_languages"."id" ON DELETE restrict ON UPDATE cascade +``` + +The two `ON DELETE` behaviours are opposites on purpose: + +- **Cascade from the record.** A record's translations are part of the record, so + removing it takes them with it in one statement. There is no loop over locales + anywhere in the engine, and no window in which a translation outlives its row. +- **Restrict from the language.** Deleting a language must not silently delete + every article written in it. Postgres refuses with `23503`, the AdminCP language + screen reports it, and a person decides what happens to the content first. + +That second one is deliberately different from `core_languages_words`, which +cascades. Losing a UI string when a language goes is an inconvenience; losing +every article is not. + +`languageId` references `core_languages.id` rather than `.code`, unlike +`core_languages_words` and `core_users.language`. A numeric key is four bytes in +a composite primary key that every translation read uses, and renaming a locale +code does not have to rewrite every translation row. The **code** is still the +only thing that appears in a URL or a response. + +## Indexes + +| Index | Serves | +| --- | --- | +| `PRIMARY KEY (itemId, languageId)` | One translation by record and language, and every translation of one record (a B-tree serves any prefix of its key) | +| `
_translations_language_id_idx` | "Every row in Polish", and the lookup a language delete has to make before Postgres allows it | +| `
_translations_language_id__key` | The unique URL per language. One per localized slug field | + +Nothing is repeated: the composite key already covers `(itemId, languageId)` and +`itemId`, so neither gets an index of its own. + +### Locale-scoped slug uniqueness + +```sql +CREATE UNIQUE INDEX "example_localized_articles_translations_language_id_slug_key" + ON "example_localized_articles_translations" ("languageId", "slug"); +``` + +That is the whole reason for the design: + +```text +(1, "about") English "about" ✓ +(2, "about") Polish "about" ✓ same string, different language +(1, "about") again in English ✗ 409 +``` + +A localized slug is `NOT NULL` with no default, exactly like a shared one - a +translation nobody can address by URL is not worth allowing - so Postgres' "nulls +are distinct" behaviour never comes into play here. + +## Column types + +Compiled from the same descriptors the base table uses, by the same builder: + +| Field | Column | +| --- | --- | +| `field.text({ localized: true, maxLength: 200 })` | `varchar(200)` | +| `field.textarea({ localized: true })` | `text` | +| `field.slug({ localized: true })` | `varchar(160) NOT NULL` | +| `nullable: true` | the column is nullable | +| `required: true` | `NOT NULL` | + +`version` is `integer NOT NULL DEFAULT 1`, mirroring the editorial column on the +base table. It is never written by a create or an update payload: the column +default makes it 1, and the conditional `UPDATE` that guards on it is the only +thing that moves it. + +## The model API + +```ts +const model = createContentModel(localizedArticleContentType); + +model.table; // the base pgTable +model.translationTable; // the translation pgTable, or null +model.columns; // base column map - shared fields only +model.translationColumns; // translation column map, or null +model.localization; // the resolved config, always present +model.translationSchemas; // the per-language schemas, or null +model.translationService(c); // the translation repository, or undefined +model.localizedService(c); // atomic create, or undefined +``` + +The four nullable members follow the convention `publicService` and +`editorialService` already set: `null`/`undefined` rather than a stub that throws, +so a check reads naturally in code that does not know which content type it was +handed - and so TypeScript refuses the call until the check has been made. + +`model.localization` is always there, so `model.localization.enabled` is the one +flag route builders and background work branch on. + +The lower-level builders are exported too, for the rare case that wants them: + +```ts +import { + contentTranslationTableColumns, + createContentTranslationTable, +} from "@vitnode/core/content/server"; + +const translationTable = createContentTranslationTable(definition, { table }); +``` + +## Table names + +`_translations`, clamped to 63 characters with a fingerprint: + +```text +example_localized_articles → example_localized_articles_translations +``` + +The registry checks the generated name against every base table *and* every other +generated translation table in the install, so a content type whose own +`tableName` happens to be `example_localized_articles_translations` fails at boot +rather than fighting over a table at migration time. + +## What is not on the table + +Stage 5A stores translation *data* and its version, and nothing else: + +```text +status not yet - per-locale publication is Stage 5B +publishedAt not yet - same +revisions not yet - per-locale history is Stage 5B +``` + +Deliberately absent rather than added early and left unused: a column with no +behaviour behind it is a promise the code does not keep, and Stage 5B's migration +can add them with the same `DEFAULT ... NOT NULL` one-liner that made adding +`status` and `version` to an existing base table safe. diff --git a/apps/docs/migrations/0029_add_example_localized_articles.sql b/apps/docs/migrations/0029_add_example_localized_articles.sql new file mode 100644 index 000000000..bd44b28d9 --- /dev/null +++ b/apps/docs/migrations/0029_add_example_localized_articles.sql @@ -0,0 +1,27 @@ +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"); \ No newline at end of file diff --git a/apps/docs/migrations/meta/0029_snapshot.json b/apps/docs/migrations/meta/0029_snapshot.json new file mode 100644 index 000000000..cf75f2b31 --- /dev/null +++ b/apps/docs/migrations/meta/0029_snapshot.json @@ -0,0 +1,3126 @@ +{ + "id": "48b24402-18d7-4bf2-88e4-f0c651f02a1d", + "prevId": "ad292f66-b888-469c-84fc-5b2fb5dd0dcd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles": { + "name": "example_localized_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_idx": { + "name": "example_localized_articles_translations_language_id_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index b9fbeb52a..db7bb2101 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1786024625069, "tag": "0028_add_content_schedule_effects_error", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1786034127995, + "tag": "0029_add_example_localized_articles", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index f1efc4cd4..39ac34e98 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -22,6 +22,7 @@ import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; import { StorageModel } from "@/api/models/storage"; import { validateContentTypes } from "@/content/registry"; +import { ensureContentLocalizationLanguages } from "@/content/server/language-resolver"; import { assertContentPreviewConfig } from "@/content/server/preview-config"; import { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; @@ -272,6 +273,12 @@ export const globalMiddleware = ({ })), ); + // Computed at boot, outside the request: "does anything need the languages + // table" is a property of the installed plugins, not of a request. + const hasLocalizedContentTypes = contentTypesMetadata.some( + entry => entry.definition.localization.enabled, + ); + const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map( plugin => ({ pluginId: plugin.pluginId, @@ -365,6 +372,16 @@ export const globalMiddleware = ({ contentTypes: contentTypesMetadata, }); + // Whether a localized content type's `defaultLocale` names a row in + // `core_languages` is a fact about the *installation*, so it cannot be + // checked when the definition is built - there is no connection yet. This is + // that check, run at most once per process and skipped entirely when nothing + // is localized, so an install with no localized content types never touches + // the languages table because of this. + if (hasLocalizedContentTypes) { + await ensureContentLocalizationLanguages(c, contentTypesMetadata); + } + const user = await new SessionModel(c).getUser(); c.set("user", user); c.set("admin", null); diff --git a/packages/vitnode/src/content/conflicts.ts b/packages/vitnode/src/content/conflicts.ts index ac3e2fbbd..3e69966b5 100644 --- a/packages/vitnode/src/content/conflicts.ts +++ b/packages/vitnode/src/content/conflicts.ts @@ -3,12 +3,16 @@ import { z } from "zod"; import { CONTENT_CONFLICT_CODES, CONTENT_SCHEDULE_CODES, + CONTENT_TRANSLATION_CONFLICT_CODES, CONTENT_UNPROCESSABLE_CODES, } from "./const"; export type ContentConflictCode = (typeof CONTENT_CONFLICT_CODES)[keyof typeof CONTENT_CONFLICT_CODES]; +export type ContentTranslationConflictCode = + (typeof CONTENT_TRANSLATION_CONFLICT_CODES)[keyof typeof CONTENT_TRANSLATION_CONFLICT_CODES]; + export type ContentUnprocessableCode = (typeof CONTENT_UNPROCESSABLE_CODES)[keyof typeof CONTENT_UNPROCESSABLE_CODES]; @@ -37,6 +41,68 @@ export const zodContentConflict = z.discriminatedUnion("code", [ export type ContentConflict = z.infer; +/** + * The 409 body a translation route answers with. + * + * Its own union rather than three more members of {@link zodContentConflict}: + * that one is the contract Stage 4 editorial routes already publish, and every + * generated client is built from it. A translation route is new, so it can carry + * a shape that names the locale in every arm - which is the one thing a locale + * tab strip has to know to point at the right tab. + */ +export const zodContentTranslationConflict = z.discriminatedUnion("code", [ + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.version), + contentTypeId: z.string(), + currentVersion: z.number().int(), + expectedVersion: z.number().int(), + itemId: z.number().int(), + locale: z.string(), + }), + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.defaultRequired), + contentTypeId: z.string(), + itemId: z.number().int(), + locale: z.string(), + }), + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.exists), + contentTypeId: z.string(), + itemId: z.number().int(), + locale: z.string(), + }), + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.languageDisabled), + contentTypeId: z.string(), + locale: z.string(), + }), + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.unique), + contentTypeId: z.string(), + itemId: z.number().int().nullable(), + locale: z.string(), + }), +]); + +export type ContentTranslationConflict = z.infer< + typeof zodContentTranslationConflict +>; + +/** Reads a translation conflict out of a response body, or `null`. */ +export const parseContentTranslationConflict = ( + body: string | undefined, +): ContentTranslationConflict | null => { + if (body === undefined || body === "") return null; + + try { + const parsed = zodContentTranslationConflict.safeParse(JSON.parse(body)); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } +}; + /** The 422 body a restore answers with when the snapshot no longer fits. */ export const zodContentUnprocessable = z.object({ code: z.literal(CONTENT_UNPROCESSABLE_CODES.notRestorable), diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 3e4ea6ffb..231e67e87 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -12,6 +12,22 @@ export const CONTENT_PUBLICATION_FIELDS = ["status", "publishedAt"] as const; */ export const CONTENT_EDITORIAL_FIELDS = ["version"] as const; +/** + * The columns a generated translation table always carries. + * + * Its own list rather than an entry in {@link CONTENT_SYSTEM_FIELDS}: these live + * on the *translation* table, so a content type stays free to declare a shared + * field called `itemId` - it would land on the base table, where nothing + * generated claims that name. + */ +export const CONTENT_TRANSLATION_SYSTEM_FIELDS = [ + "itemId", + "languageId", + "version", + "createdAt", + "updatedAt", +] as const; + export const CONTENT_PUBLICATION_STATUSES = ["draft", "published"] as const; const publicationStatuses: ReadonlySet = new Set( @@ -74,6 +90,52 @@ export const CONTENT_ENUM_DEFAULT_LENGTH = 64; export const CONTENT_SLUG_DEFAULT_LENGTH = 160; +/** + * Field kinds that may carry `localized: true`. + * + * Text only, and deliberately so. `boolean`, `number`, `date`, `dateTime` and + * `enum` hold values, not prose - a per-locale `true` is not a translation, and + * an enum's *labels* are already handled by the ordinary i18n system while its + * *identifiers* have to stay the same in every language or nothing can filter on + * them. `relation` and `user` are foreign keys, and per-locale references are + * explicitly out of scope. + */ +export const CONTENT_LOCALIZED_FIELD_KINDS = [ + "slug", + "text", + "textarea", +] as const; + +const localizedFieldKinds: ReadonlySet = new Set( + CONTENT_LOCALIZED_FIELD_KINDS, +); + +export const isLocalizableFieldKind = (kind: string): boolean => + localizedFieldKinds.has(kind); + +/** Appended to the base table name to get the generated translation table. */ +export const CONTENT_TRANSLATION_TABLE_SUFFIX = "_translations"; + +/** + * What a public read does when a locale has no translation. + * + * Resolved in Stage 5A and *acted on* in Stage 5C: the configuration has to be + * stable before anything reads through it, or every localized content type would + * change public behaviour the moment fallback landed. + */ +export const CONTENT_LOCALIZATION_FALLBACKS = ["none", "default"] as const; + +/** + * A locale as `core_languages.code` stores one: `en`, `pl`, `pt-BR`, `zh-Hans`. + * + * Matched case-insensitively - the resolver returns the canonical stored code, + * so `PL` in a URL resolves to the `pl` row rather than to a 404. + */ +export const CONTENT_LOCALE_PATTERN = /^[a-z]{2,8}(?:[-_][a-z0-9]{2,8})*$/i; + +/** `core_languages.code` is `varchar(32)`. */ +export const CONTENT_LOCALE_MAX_LENGTH = 32; + export const CONTENT_DEFAULT_PAGE_SIZE = 25; export const CONTENT_OPTIONS_LIMIT = 25; @@ -288,6 +350,22 @@ export const CONTENT_CONFLICT_CODES = { version: "CONTENT_VERSION_CONFLICT", } as const; +/** + * Machine-readable reasons a *translation* write was refused. + * + * A separate list from {@link CONTENT_CONFLICT_CODES} rather than three more + * members of it: the base 409 union is the contract Stage 4 editorial routes + * already publish, and widening it would change a response schema every existing + * client is generated from. A translation route answers its own union. + */ +export const CONTENT_TRANSLATION_CONFLICT_CODES = { + defaultRequired: "CONTENT_DEFAULT_TRANSLATION_REQUIRED", + exists: "CONTENT_TRANSLATION_EXISTS", + languageDisabled: "CONTENT_LANGUAGE_DISABLED", + unique: "CONTENT_TRANSLATION_UNIQUE_CONFLICT", + version: "CONTENT_TRANSLATION_VERSION_CONFLICT", +} as const; + export const CONTENT_UNPROCESSABLE_CODES = { notRestorable: "CONTENT_REVISION_NOT_RESTORABLE", } as const; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index b0cd3d98b..ea77666ad 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -6,6 +6,8 @@ import type { ContentFieldMap, ContentFieldsConstraint, ContentIndexInput, + ContentLocalizationConfig, + ContentLocalizationEnabled, ContentPreviewEnabled, ContentPublicApiConfig, ContentPublicationConfig, @@ -19,6 +21,7 @@ import type { ContentTypeDefinition, ResolvedContentAdminConfig, ResolvedContentEditorialConfig, + ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, ResolvedContentSearchConfig, } from "./types"; @@ -57,6 +60,10 @@ import { } from "./const"; import { ContentEngineError } from "./errors"; import { resolveContentIndexes } from "./indexes"; +import { + partitionContentFields, + resolveContentLocalization, +} from "./localization"; import { buildContentSchemas } from "./schemas"; /** Kinds the default `searchableFields` picks up, and `titleField` falls back to. */ @@ -304,14 +311,56 @@ const assertKnownColumns = ( } }; +/** + * Every admin surface addresses a column on the base table, so it is stated in + * terms of the *shared* fields only. A localized field named here would be a + * DataTable column, a sort or a search over something the base table does not + * have - see `ContentAddressableColumn`, which rejects it at compile time too. + */ +const assertNotLocalized = ( + id: string, + label: string, + names: readonly string[], + localizedFields: ContentFieldMap, +): void => { + const localized = names.find(name => localizedFields[name] !== undefined); + if (localized !== undefined) { + throw new ContentEngineError( + `${label} names the localized field "${localized}", which is not a column on the base table. Localized values get their own AdminCP surface in Stage 5B.`, + { contentTypeId: id }, + ); + } +}; + const resolveAdmin = ( id: string, fields: ContentFieldMap, + localizedFields: ContentFieldMap, admin: ContentAdminConfig, publication: boolean, editorial: boolean, ): ResolvedContentAdminConfig => { const fieldNames = Object.keys(fields); + for (const [label, names] of [ + ["admin.form.fields", admin.form?.fields], + ["admin.list.columns", admin.list?.columns], + ["admin.list.orderableFields", admin.list?.orderableFields], + ["admin.list.searchableFields", admin.list?.searchableFields], + [ + "admin.titleField", + admin.titleField === undefined ? undefined : [admin.titleField], + ], + [ + "admin.list.defaultOrderBy", + admin.list?.defaultOrderBy === undefined + ? undefined + : [admin.list.defaultOrderBy], + ], + ] as const) { + if (!names) continue; + assertNotLocalized(id, label, names.map(String), localizedFields); + } + const generatedColumns = [ ...systemFields, ...(publication ? publicationFields : []), @@ -1014,12 +1063,21 @@ export const defineContentType = < TEditorial extends ContentEditorialConfig | { enabled: false } = { enabled: false }, + // The whole `localization` argument, inferred as one type, for the same reason + // `TSearch` and `TEditorial` are: an intersection member is not an inference + // site, so this is the only way the `enabled` literal survives - and every + // conditional that decides whether a translation table, translation schemas + // and a translation service exist reads that literal. + TLocalization extends ContentLocalizationConfig | { enabled: false } = { + enabled: false; + }, >({ admin, editorial, fields, id, indexes = [], + localization, publicApi, publication, search, @@ -1043,6 +1101,12 @@ export const defineContentType = < TPublication, ContentEditorialEnabled >[]; + /** + * Opts into per-language content: every field marked `localized: true` moves + * into a generated `_translations` table, one row per language. + * Omit it and nothing changes. + */ + localization?: TLocalization; /** * Opts into a generated read-only public API. Needs `publication` and exactly * one exposed slug field. Omit it and nothing public is generated. @@ -1067,7 +1131,8 @@ export const defineContentType = < ContentSearchEnabled, ContentEditorialEnabled, ContentPreviewEnabled, - ContentSchedulingEnabled + ContentSchedulingEnabled, + ContentLocalizationEnabled > => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( @@ -1112,8 +1177,14 @@ export const defineContentType = < assertSlugSources(id, fieldMap); + // The one partition every subsystem downstream of here reads. A localized + // field is not a column on the base table, so it takes no part in the base + // indexes, the admin surfaces or the base schemas. + const { localizedFields, sharedFields } = partitionContentFields(fieldMap); + const sharedFieldNames = Object.keys(sharedFields); + const knownColumns = new Set([ - ...fieldNames, + ...sharedFieldNames, ...systemFields, ...(publicationEnabled ? publicationFields : []), ...(editorialEnabled ? editorialFields : []), @@ -1122,18 +1193,23 @@ export const defineContentType = < contentTypeId: id, declared: indexes.map(index => { const on = index.on.map(String); + assertNotLocalized(id, "indexes", on, localizedFields); assertKnownColumns(id, "indexes", on, knownColumns); return { ...index, on }; }), - fields: fieldMap, + // Shared only: a localized slug's unique index is scoped to a language and + // belongs to the translation table, which `resolveContentTranslationIndexes` + // builds. + fields: sharedFields, publication: publicationEnabled, tableName, }); const resolvedAdmin = resolveAdmin( id, - fieldMap, + sharedFields, + localizedFields, admin, publicationEnabled, editorialEnabled, @@ -1177,6 +1253,21 @@ export const defineContentType = < publicationEnabled, ); + // Last, because the Stage 5A boundaries it enforces are stated in terms of + // everything the other resolvers have already settled. + const resolvedLocalization = resolveContentLocalization({ + editorial: editorialEnabled, + fields: fieldMap, + id, + // The `{ enabled: false }` arm exists only so an explicit literal + // typechecks - the same widening `publicApi`, `search` and `editorial` do. + localization: localization as ContentLocalizationConfig | undefined, + publicApi: resolvedPublicApi, + publication: publicationEnabled, + search: resolvedSearch.enabled, + tableName, + }); + return { admin: resolvedAdmin, editorial: resolvedEditorial as ResolvedContentEditorialConfig< @@ -1187,6 +1278,9 @@ export const defineContentType = < fields, id, indexes: resolvedIndexes, + localization: resolvedLocalization as ResolvedContentLocalizationConfig< + ContentLocalizationEnabled + >, permissionModule, publication: { enabled: publicationEnabled as TPublication, @@ -1205,12 +1299,14 @@ export const defineContentType = < ContentSearchEnabled, ContentEditorialEnabled, ContentPreviewEnabled, - ContentSchedulingEnabled + ContentSchedulingEnabled, + ContentLocalizationEnabled > >({ admin: resolvedAdmin, editorial: editorialEnabled, fields: fieldMap, + localization: resolvedLocalization, publicApi: resolvedPublicApi, publication: publicationEnabled, }), diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index 7964f01a8..5ba631d78 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -112,6 +112,167 @@ export class ContentRevisionNotRestorable extends ContentEngineError { readonly revisionId: number; } +/** + * A translation write lost the race against another edit of the *same locale*. + * + * Separate from {@link ContentVersionConflict} because the two guard different + * rows and mean different things to an editor: the base row's version is shared + * by everybody, a translation's belongs to one language. Somebody editing Polish + * must never be told the English copy moved. + */ +export class ContentTranslationVersionConflict extends ContentEngineError { + constructor({ + contentTypeId, + currentVersion, + expectedVersion, + itemId, + locale, + }: { + contentTypeId: string; + currentVersion: number; + expectedVersion: number; + itemId: number; + locale: string; + }) { + super( + `The ${locale} translation is at version ${currentVersion}, not ${expectedVersion}. Someone else saved it first.`, + { contentTypeId }, + ); + + this.name = "ContentTranslationVersionConflict"; + this.currentVersion = currentVersion; + this.expectedVersion = expectedVersion; + this.itemId = itemId; + this.locale = locale; + } + + readonly currentVersion: number; + readonly expectedVersion: number; + readonly itemId: number; + readonly locale: string; +} + +/** + * An attempt to remove the one translation a record cannot be without. + * + * The default-locale translation is created in the same transaction as the base + * row, which is what lets every later stage assume a record always resolves in + * at least one language. Deleting it would break that invariant silently - the + * row would still be there, addressable, and empty in every language. + */ +export class ContentDefaultTranslationRequired extends ContentEngineError { + constructor({ + contentTypeId, + itemId, + locale, + }: { + contentTypeId: string; + itemId: number; + locale: string; + }) { + super( + `"${locale}" is the default locale, so its translation cannot be deleted. Delete the record itself, or change the content type's default locale first.`, + { contentTypeId }, + ); + + this.name = "ContentDefaultTranslationRequired"; + this.itemId = itemId; + this.locale = locale; + } + + readonly itemId: number; + readonly locale: string; +} + +/** + * A create for a locale that already has a translation. + * + * Its own error rather than a bare unique violation: "switch to the tab that + * exists" and "that slug is taken" are different instructions, and the composite + * primary key cannot tell a client which one it hit. + */ +export class ContentTranslationExists extends ContentEngineError { + constructor({ + contentTypeId, + itemId, + locale, + }: { + contentTypeId: string; + itemId: number; + locale: string; + }) { + super( + `This record already has a "${locale}" translation. Update it instead of creating a second one.`, + { contentTypeId }, + ); + + this.name = "ContentTranslationExists"; + this.itemId = itemId; + this.locale = locale; + } + + readonly itemId: number; + readonly locale: string; +} + +/** + * A locale that does not name a usable language. + * + * `reason` is what the routes branch on: an unknown locale is a 404 (there is no + * such thing to address), a disabled one is a 409 (it exists, and this install + * has switched it off). Both are per-request, and neither carries anything + * beyond the locale the caller already sent. + */ +export class ContentLanguageError extends ContentEngineError { + constructor({ + contentTypeId, + locale, + reason, + }: { + contentTypeId?: string; + locale: string; + reason: "disabled" | "missing"; + }) { + super( + reason === "missing" + ? `Unknown locale "${locale}". Add the language in AdminCP -> Languages first.` + : `Locale "${locale}" is disabled on this installation, so its content cannot be written.`, + { contentTypeId }, + ); + + this.name = "ContentLanguageError"; + this.locale = locale; + this.reason = reason; + } + + readonly locale: string; + readonly reason: "disabled" | "missing"; +} + +/** + * A translation write for a base record that is not there. + * + * Checked before the insert rather than left to the foreign key: the driver's + * `23503` cannot say *which* of the two references failed, and "the article is + * gone" and "that language is gone" want different answers. + */ +export class ContentTranslationItemMissing extends ContentEngineError { + constructor({ + contentTypeId, + itemId, + }: { + contentTypeId: string; + itemId: number; + }) { + super(`No record with id ${itemId} to translate.`, { contentTypeId }); + + this.name = "ContentTranslationItemMissing"; + this.itemId = itemId; + } + + readonly itemId: number; +} + /** * A schedule that does not make sense: a time already past, or an unpublish * that would fire before the publish it is meant to follow. diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index a749f170c..f5089bd93 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -34,39 +34,64 @@ const shared = ( required: (args.required ?? false) as TRequired, }); +/** + * `localized` defaults to `false`, and the assertion keeps the literal the + * caller inferred - `?? false` alone would widen it back to `boolean`, and every + * `localized extends true` partition would resolve to the shared branch. + */ +const localizedOf = ( + args: LocalizableArgs, +): TLocalized => (args.localized ?? false) as TLocalized; + +interface LocalizableArgs { + /** + * Store the value per language, in the generated translation table. + * + * Needs `localization: { enabled: true, defaultLocale }` on the content type. + * Only `text`, `textarea` and `slug` accept this. + */ + localized?: TLocalized; +} + const text = < TRequired extends boolean = false, TNullable extends boolean = false, TDefault extends string | undefined = undefined, + TLocalized extends boolean = false, >( - args: SharedArgs & { - defaultValue?: TDefault; - maxLength?: number; - minLength?: number; - unique?: boolean; - } = {}, -): ContentTextField => ({ + args: LocalizableArgs & + SharedArgs & { + defaultValue?: TDefault; + maxLength?: number; + minLength?: number; + unique?: boolean; + } = {}, +): ContentTextField => ({ ...args, ...shared(args), defaultValue: args.defaultValue as TDefault, kind: "text", + localized: localizedOf(args), }); const textarea = < TRequired extends boolean = false, TNullable extends boolean = false, TDefault extends string | undefined = undefined, + TLocalized extends boolean = false, >( - args: SharedArgs & { - defaultValue?: TDefault; - maxLength?: number; - minLength?: number; - } = {}, -): ContentTextareaField => ({ + args: LocalizableArgs & + SharedArgs & { + defaultValue?: TDefault; + maxLength?: number; + minLength?: number; + } = {}, +): ContentTextareaField => ({ ...args, ...shared(args), defaultValue: args.defaultValue as TDefault, kind: "textarea", + localized: localizedOf(args), }); const number = < @@ -136,16 +161,20 @@ const enumField = < * The slug is never re-derived by an update. Changing the title leaves the URL * alone; sending `slug` explicitly is the only way to move it. */ -const slug = ( - args: { +const slug = < + TSource extends string | undefined = undefined, + TLocalized extends boolean = false, +>( + args: LocalizableArgs & { description?: string; /** `varchar` length and the truncation point. Defaults to 160. */ maxLength?: number; source?: TSource; } = {}, -): ContentSlugField => ({ +): ContentSlugField => ({ ...args, kind: "slug", + localized: localizedOf(args), nullable: false, required: (args.source === undefined) as ContentSlugRequired, source: args.source as TSource, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 4ddfab80f..4e996b7e6 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -39,13 +39,17 @@ export type { } from "./cache"; export { parseContentConflict, + parseContentTranslationConflict, parseContentUnprocessable, zodContentConflict, + zodContentTranslationConflict, zodContentUnprocessable, } from "./conflicts"; export type { ContentConflict, ContentConflictCode, + ContentTranslationConflict, + ContentTranslationConflictCode, ContentUnprocessable, ContentUnprocessableCode, } from "./conflicts"; @@ -57,6 +61,10 @@ export { CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FILTERABLE_FIELD_KINDS, + CONTENT_LOCALE_MAX_LENGTH, + CONTENT_LOCALE_PATTERN, + CONTENT_LOCALIZATION_FALLBACKS, + CONTENT_LOCALIZED_FIELD_KINDS, CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS, CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, @@ -89,15 +97,25 @@ export { CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_SYSTEM_FIELDS, CONTENT_TEXT_DEFAULT_LENGTH, + CONTENT_TRANSLATION_CONFLICT_CODES, + CONTENT_TRANSLATION_SYSTEM_FIELDS, + CONTENT_TRANSLATION_TABLE_SUFFIX, CONTENT_UNPROCESSABLE_CODES, isContentPublicationStatus, + isFilterableFieldKind, + isLocalizableFieldKind, RESERVED_FILTER_KEYS, } from "./const"; export { defineContentType } from "./define"; export { + ContentDefaultTranslationRequired, ContentEngineError, ContentInputError, + ContentLanguageError, ContentRevisionNotRestorable, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, ContentVersionConflict, } from "./errors"; export { contentEventName } from "./events"; @@ -112,7 +130,20 @@ export type { } from "./events"; export { field } from "./fields"; export { clampWithFingerprint, fingerprint } from "./fingerprint"; -export { contentIndexName, toSnakeCase } from "./indexes"; +export { + contentIndexName, + contentTranslationPrimaryKeyName, + resolveContentTranslationIndexes, + toSnakeCase, +} from "./indexes"; +export { + contentLocalizationDisabled, + contentTranslationTableName, + isLocalizedContentField, + partitionContentFields, + resolveContentLocalization, +} from "./localization"; +export type { ContentFieldPartition } from "./localization"; export { contentAdminHref, contentPermissionEntries, @@ -144,7 +175,7 @@ export type { ContentScheduleStatus, } from "./schedules"; export { buildContentSchemas } from "./schemas"; -export type { ContentSchemas } from "./schemas"; +export type { ContentSchemas, ContentTranslationSchemas } from "./schemas"; export { contentSearchDocumentId, contentSearchIndexedFieldNames, @@ -175,6 +206,12 @@ export type { ContentFilterInput, ContentIndexConfig, ContentIndexInput, + ContentLocalizationConfig, + ContentLocalizationEnabled, + ContentLocalizationFallback, + ContentLocalizedFieldName, + ContentLocalizedUpdateValues, + ContentLocalizedValues, ContentNumberField, ContentOnDelete, ContentOrderableFieldName, @@ -199,21 +236,28 @@ export type { ContentSearchTextField, ContentSearchTitleField, ContentSelect, + ContentSharedFieldName, + ContentSharedValues, ContentSlugField, ContentSlugRequired, ContentSystemField, ContentTextareaField, ContentTextField, + ContentTranslationMeta, + ContentTranslationRow, + ContentTranslationSystemField, ContentTypeDefinition, ContentUpdateInput, ContentUserField, EditorialContentTypeDefinition, FilterableContentFieldKind, FilterableContentFieldName, + LocalizedContentTypeDefinition, PreviewableContentTypeDefinition, ResolvedContentAdminConfig, ResolvedContentEditorialConfig, ResolvedContentIndex, + ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, ResolvedContentPublicationConfig, ResolvedContentSearchConfig, diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index 1f0677dd2..7a2d02980 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -38,6 +38,68 @@ export const contentIndexName = ({ [tableName, ...columns.map(toSnakeCase), unique ? "key" : "idx"].join("_"), ); +/** + * The deterministic name of the generated translation table's primary key. + * + * Named explicitly rather than left to Drizzle's default so it survives the + * identifier-length clamp: a long base table name plus `_translations` plus + * `_itemId_languageId_pk` passes 63 characters easily, and Postgres truncates + * silently. + */ +export const contentTranslationPrimaryKeyName = ( + translationTableName: string, +): string => + shortenIdentifier(`${translationTableName}_item_id_language_id_pk`); + +/** + * Every index the generated translation table carries. + * + * The composite primary key already serves lookups by `(itemId, languageId)` and + * by `itemId` alone (a B-tree can use any prefix of its key), so neither is + * repeated here. What it cannot serve: + * + * 1. `languageId` on its own - "every row in Polish", and the lookup a language + * delete has to make before it is allowed to proceed, + * 2. one unique index per localized slug, scoped to the language - which is what + * lets `/en/about` and `/pl/about` coexist while a second English `about` is + * a 409. + */ +export const resolveContentTranslationIndexes = ({ + contentTypeId, + localizedFields, + translationTableName, +}: { + contentTypeId: string; + localizedFields: ContentFieldMap; + translationTableName: string; +}): ResolvedContentIndex[] => { + const indexes: ResolvedContentIndex[] = [ + named(translationTableName, { on: ["languageId"] }), + ...Object.entries(localizedFields) + .filter(([, fieldValue]) => fieldValue.kind === "slug") + .map(([name]) => + named(translationTableName, { + on: ["languageId", name], + unique: true, + }), + ), + ]; + + const byName = new Map(); + for (const index of indexes) { + const collision = byName.get(index.name); + if (collision) { + throw new ContentEngineError( + `Translation indexes on [${collision.on.join(", ")}] and [${index.on.join(", ")}] both resolve to the name "${index.name}".`, + { contentTypeId }, + ); + } + byName.set(index.name, index); + } + + return indexes; +}; + /** * Identity of an index for deduplication. Column order matters: an index on * `(status, createdAt)` cannot serve a lookup on `(createdAt, status)`. diff --git a/packages/vitnode/src/content/localization.test-d.ts b/packages/vitnode/src/content/localization.test-d.ts new file mode 100644 index 000000000..875e1c56a --- /dev/null +++ b/packages/vitnode/src/content/localization.test-d.ts @@ -0,0 +1,314 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, + testEditorialNoteContentType, + testEditorialPostContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, + testPostContentType, + testSearchablePostContentType, +} from "@/tests/content-fixtures"; + +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentLocalizedFieldName, + ContentLocalizedUpdateValues, + ContentLocalizedValues, + ContentSelect, + ContentSharedFieldName, + ContentSharedValues, + ContentUpdateInput, + LocalizedContentTypeDefinition, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +type Localized = typeof testLocalizedArticleContentType; +type LocalizedNote = typeof testLocalizedNoteContentType; +type Article = typeof testArticleContentType; +type Post = typeof testPostContentType; + +describe("localization", () => { + // A tenth type parameter on `ContentTypeDefinition`, and this is what says it + // costs nothing: the erased form every relation thunk, registry and route + // builder is written against still accepts every concrete definition. + describe("assignability to AnyContentTypeDefinition", () => { + it("holds for a localized content type", () => { + expectTypeOf().toExtend(); + assertType(testLocalizedArticleContentType); + assertType(testLocalizedNoteContentType); + }); + + it("still holds for every Stage 1-4 fixture", () => { + assertType(testCategoryContentType); + assertType(testArticleContentType); + assertType(testPostContentType); + assertType(testSearchablePostContentType); + assertType(testEditorialPostContentType); + assertType(testEditorialNoteContentType); + }); + }); + + describe("the flag stays literal", () => { + it("is `true` when opted in", () => { + expectTypeOf( + testLocalizedArticleContentType.localization.enabled, + ).toEqualTypeOf(); + }); + + it("is `false` when omitted", () => { + expectTypeOf( + testArticleContentType.localization.enabled, + ).toEqualTypeOf(); + }); + + it("is `false` when written out explicitly", () => { + const explicit = defineContentType({ + id: "test.explicit", + tableName: "test_explicits", + localization: { enabled: false }, + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Explicits", singular: "Explicit" } }, + }); + + expectTypeOf(explicit.localization.enabled).toEqualTypeOf(); + }); + + // `enabled: true` and `enabled: false` staying distinguishable is what lets + // Stage 5B-5D expose translation services, routes and AdminCP tabs + // conditionally instead of at runtime. + it("separates the two through LocalizedContentTypeDefinition", () => { + expectTypeOf().toExtend(); + expectTypeOf
().not.toExtend(); + expectTypeOf().not.toExtend(); + }); + }); + + describe("field-level `localized`", () => { + it("keeps the literal on a text field", () => { + expectTypeOf( + testLocalizedArticleContentType.fields.title.localized, + ).toEqualTypeOf(); + }); + + it("keeps the literal on a textarea field", () => { + expectTypeOf( + testLocalizedArticleContentType.fields.body.localized, + ).toEqualTypeOf(); + }); + + it("keeps the literal on a slug field", () => { + expectTypeOf( + testLocalizedArticleContentType.fields.slug.localized, + ).toEqualTypeOf(); + }); + + it("defaults to `false` rather than widening to boolean", () => { + // A localizable field always carries the literal, so `localized: false` + // and `localized: true` stay distinguishable. `?? false` alone would widen + // it back to `boolean` and every partition would resolve to "shared". + expectTypeOf( + testArticleContentType.fields.title.localized, + ).toEqualTypeOf(); + expectTypeOf( + testArticleContentType.fields.excerpt.localized, + ).toEqualTypeOf(); + }); + + it("leaves the flag off a kind that cannot carry one", () => { + // Inherited from `ContentFieldShared` and never written, so it is + // `boolean | undefined` - which does not extend `true`, which is what puts + // the field in the shared half. + expectTypeOf( + testLocalizedArticleContentType.fields.featured.localized, + ).toEqualTypeOf(); + }); + + it("refuses `localized` on the kinds that cannot hold a translation", () => { + // @ts-expect-error - a per-locale `true` is not a translation. + field.boolean({ localized: true }); + // @ts-expect-error - a number means the same thing in every language. + field.number({ integer: true, localized: true }); + // @ts-expect-error - a date is an instant, not prose. + field.dateTime({ localized: true }); + // @ts-expect-error - enum identifiers have to match across locales. + field.enum({ localized: true, values: ["a", "b"] }); + // @ts-expect-error - a user is a foreign key. + field.user({ localized: true }); + field.relation({ + // @ts-expect-error - per-locale relations are out of scope. + localized: true, + target: () => testCategoryContentType, + }); + }); + }); + + describe("field-name partitions", () => { + it("names the localized fields", () => { + expectTypeOf>().toEqualTypeOf< + "body" | "slug" | "title" + >(); + }); + + it("names the shared fields", () => { + expectTypeOf>().toEqualTypeOf< + "featured" | "views" + >(); + }); + + it("has no localized names for a non-localized content type", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + | "author" + | "category" + | "excerpt" + | "featured" + | "publishedAt" + | "status" + | "title" + | "views" + >(); + }); + }); + + describe("shared values", () => { + it("carry the base-table fields and nothing else", () => { + expectTypeOf>().toEqualTypeOf<{ + featured?: boolean; + views?: number; + }>(); + }); + + it("are what `create` accepts", () => { + expectTypeOf>().toEqualTypeOf< + ContentSharedValues + >(); + }); + + it("are what a base row comes back as", () => { + expectTypeOf>().toEqualTypeOf<{ + createdAt: Date; + featured: boolean; + id: number; + updatedAt: Date; + views: number; + }>(); + }); + + it("leave a non-localized content type's create input alone", () => { + // Every field of a Stage 1 content type is shared, so nothing moved. + expectTypeOf>().toEqualTypeOf<{ + author?: null | number; + category: number; + excerpt?: null | string; + featured?: boolean; + publishedAt?: null | string; + status?: "archived" | "draft" | "published"; + title: string; + views?: number; + }>(); + }); + }); + + describe("localized values", () => { + it("preserve requiredness, nullability and derived slugs", () => { + expectTypeOf>().toEqualTypeOf<{ + body?: null | string; + // Sourced from the title, so it is derivable and therefore optional. + slug?: string; + title: string; + }>(); + }); + + it("require a sourceless slug", () => { + expectTypeOf>().toEqualTypeOf<{ + heading: string; + slug: string; + }>(); + }); + + it("make every key optional on update", () => { + expectTypeOf>().toEqualTypeOf<{ + body?: null | string; + slug?: string; + title?: string; + }>(); + }); + + // The one thing that makes `translation:` impossible to fill in by accident + // on a Stage 1-4 definition. + it("are empty for a non-localized content type", () => { + expectTypeOf< + keyof ContentLocalizedValues
+ >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("keep localized fields out of the base update input", () => { + expectTypeOf>().toEqualTypeOf< + "featured" | "views" + >(); + }); + }); + + describe("admin config addresses shared columns only", () => { + it("rejects a localized field as a list column", () => { + defineContentType({ + id: "test.badcolumn", + tableName: "test_bad_columns", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + featured: field.boolean({ defaultValue: false }), + }, + admin: { + label: { plural: "Bad", singular: "Bad" }, + // @ts-expect-error - `title` is not a column on the base table. + list: { columns: ["title"] }, + }, + }); + }); + + it("rejects a localized field as the title field", () => { + defineContentType({ + id: "test.badtitle", + tableName: "test_bad_titles", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + featured: field.boolean({ defaultValue: false }), + }, + admin: { + label: { plural: "Bad", singular: "Bad" }, + // @ts-expect-error - a localized title has a value per language. + titleField: "title", + }, + }); + }); + + it("rejects a localized field in an index", () => { + defineContentType({ + id: "test.badindex", + tableName: "test_bad_indexes", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + featured: field.boolean({ defaultValue: false }), + }, + // @ts-expect-error - the base table has no `title` column to index. + indexes: [{ on: ["title"] }], + admin: { label: { plural: "Bad", singular: "Bad" } }, + }); + }); + + it("still accepts a shared field everywhere", () => { + expectTypeOf( + testLocalizedArticleContentType.admin.list.columns, + ).toEqualTypeOf(); + }); + }); +}); diff --git a/packages/vitnode/src/content/localization.test.ts b/packages/vitnode/src/content/localization.test.ts new file mode 100644 index 000000000..29c01c362 --- /dev/null +++ b/packages/vitnode/src/content/localization.test.ts @@ -0,0 +1,393 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; +import { + contentTranslationTableName, + isLocalizedContentField, + partitionContentFields, +} from "./localization"; + +/** Builds a localized content type with one thing swapped out. */ +const localized = ( + overrides: Parameters[0] extends never + ? never + : Record, +) => + defineContentType({ + id: "test.subject", + tableName: "test_subjects", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + }, + admin: { label: { plural: "Subjects", singular: "Subject" } }, + ...overrides, + } as never); + +describe("partitionContentFields", () => { + it("splits the field map by the `localized` flag", () => { + const { localizedFields, sharedFields } = partitionContentFields( + testLocalizedArticleContentType.fields, + ); + + expect(Object.keys(localizedFields)).toEqual(["title", "slug", "body"]); + expect(Object.keys(sharedFields)).toEqual(["featured", "views"]); + }); + + it("preserves declaration order in both halves", () => { + const { localizedFields } = partitionContentFields( + testLocalizedArticleContentType.fields, + ); + + // The generated column order, the schema key order and the migration all + // come off this, so it has to be stable rather than merely correct. + expect(Object.keys(localizedFields)).toEqual(["title", "slug", "body"]); + }); + + it("treats every field of a non-localized content type as shared", () => { + const { localizedFields, sharedFields } = partitionContentFields( + testArticleContentType.fields, + ); + + expect(localizedFields).toEqual({}); + expect(Object.keys(sharedFields)).toEqual( + Object.keys(testArticleContentType.fields), + ); + }); + + it("reads the same flag `isLocalizedContentField` does", () => { + const { body, featured } = testLocalizedArticleContentType.fields; + + expect(isLocalizedContentField(body)).toBe(true); + expect(isLocalizedContentField(featured)).toBe(false); + }); +}); + +describe("contentTranslationTableName", () => { + it("suffixes the base table name", () => { + expect(contentTranslationTableName("example_articles")).toBe( + "example_articles_translations", + ); + }); + + it("stays inside the Postgres identifier limit", () => { + // 63 characters is where Postgres truncates silently, and two long names + // that differ only past that point would collapse into one index. + const long = `a_${"very_long_table_name".repeat(4)}`; + const name = contentTranslationTableName(long); + + expect(name.length).toBeLessThanOrEqual(63); + expect(name).not.toBe(contentTranslationTableName(`${long}_other`)); + }); +}); + +describe("resolved localization defaults", () => { + it("is disabled for a content type that omits the block", () => { + expect(testArticleContentType.localization).toEqual({ + defaultLocale: "", + enabled: false, + fallback: "none", + translationIndexes: [], + translationTableName: "", + }); + }); + + it("defaults `fallback` to none, the only safe answer in Stage 5A", () => { + expect(testLocalizedNoteContentType.localization.fallback).toBe("none"); + }); + + it("keeps the configured default locale verbatim, casing included", () => { + // `core_languages.code` is the canonical form and the resolver matches + // case-insensitively, so the definition is not the place to normalise. + expect(testLocalizedNoteContentType.localization.defaultLocale).toBe("EN"); + }); + + it("derives the translation table name and its indexes", () => { + const { translationIndexes, translationTableName } = + testLocalizedArticleContentType.localization; + + expect(translationTableName).toBe("test_localized_articles_translations"); + expect(translationIndexes).toEqual([ + { + name: "test_localized_articles_translations_language_id_idx", + on: ["languageId"], + unique: false, + }, + { + name: "test_localized_articles_translations_language_id_slug_key", + on: ["languageId", "slug"], + unique: true, + }, + ]); + }); + + it("keeps localized fields out of the base indexes", () => { + const columns = testLocalizedArticleContentType.indexes.flatMap( + index => index.on, + ); + + expect(columns).not.toContain("slug"); + expect(columns).not.toContain("title"); + }); + + it("defaults the admin surfaces to the shared fields only", () => { + const { admin } = testLocalizedNoteContentType; + + expect(admin.form.fields).toEqual(["pinned"]); + expect(admin.list.columns).toEqual(["pinned", "updatedAt"]); + expect(admin.list.searchableFields).toEqual([]); + // The only text field is localized, so there is no shared title to fall + // back to - and inventing one would make a toast depend on the reader's + // locale. + expect(admin.titleField).toBeNull(); + }); +}); + +describe("localization validation", () => { + it("rejects `localized: true` without a localization block", () => { + expect(() => + defineContentType({ + id: "test.stray", + tableName: "test_strays", + fields: { title: field.text({ localized: true, required: true }) }, + admin: { label: { plural: "Strays", singular: "Stray" } }, + }), + ).toThrow(/no `localization: \{ enabled: true, defaultLocale \}` block/); + }); + + it("rejects localization with no localized field", () => { + expect(() => + defineContentType({ + id: "test.empty", + tableName: "test_empties", + localization: { enabled: true, defaultLocale: "en" }, + fields: { featured: field.boolean({ defaultValue: false }) }, + admin: { label: { plural: "Empties", singular: "Empty" } }, + }), + ).toThrow(/no field is marked `localized: true`/); + }); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["whitespace only", " "], + ])("rejects a %s default locale", (_label, defaultLocale) => { + expect(() => + localized({ localization: { defaultLocale, enabled: true } }), + ).toThrow(/localization.defaultLocale is required/); + }); + + it("rejects a padded default locale rather than trimming it", () => { + expect(() => + localized({ localization: { defaultLocale: " en ", enabled: true } }), + ).toThrow(/leading or trailing whitespace/); + }); + + it("rejects a default locale that is not shaped like one", () => { + expect(() => + localized({ localization: { defaultLocale: "en_US!", enabled: true } }), + ).toThrow(/does not look like a locale code/); + }); + + it("rejects a default locale wider than core_languages.code", () => { + expect(() => + localized({ + localization: { defaultLocale: "en-".repeat(20), enabled: true }, + }), + ).toThrow(/longer than 32 characters/); + }); + + it.each(["pt-BR", "zh-Hans", "en"])("accepts the locale %s", locale => { + expect( + localized({ localization: { defaultLocale: locale, enabled: true } }) + .localization.defaultLocale, + ).toBe(locale); + }); + + it.each([ + ["boolean", field.boolean({ defaultValue: false })], + ["number", field.number({ integer: true, defaultValue: 0 })], + ["dateTime", field.dateTime({ nullable: true })], + ["enum", field.enum({ values: ["a", "b"], defaultValue: "a" })], + ["user", field.user()], + ])("rejects a localized %s field at runtime", (_kind, fieldValue) => { + // The builders do not accept `localized`, so this is only reachable from + // JavaScript or through a cast - which is exactly why the check exists. + expect(() => + defineContentType({ + id: "test.badkind", + tableName: "test_bad_kinds", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + other: { ...fieldValue, localized: true } as never, + }, + admin: { label: { plural: "Bad", singular: "Bad" } }, + }), + ).toThrow(/Only slug, text, textarea fields can be localized/); + }); + + it("rejects a localized field named after a translation column", () => { + expect(() => + defineContentType({ + id: "test.collide", + tableName: "test_collides", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + itemId: field.text({ localized: true, required: true }), + }, + admin: { label: { plural: "Collides", singular: "Collide" } }, + }), + ).toThrow(/collides with a generated translation column/); + }); + + it("rejects a localized slug sourced from a shared field", () => { + expect(() => + defineContentType({ + id: "test.sharedsource", + tableName: "test_shared_sources", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + name: field.text({ required: true }), + heading: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true, source: "name" }), + }, + admin: { label: { plural: "Shared", singular: "Shared" } }, + }), + ).toThrow(/Every language would derive the same URL/); + }); + + it("rejects a shared slug sourced from a localized field", () => { + expect(() => + defineContentType({ + id: "test.localizedsource", + tableName: "test_localized_sources", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + slug: field.slug({ source: "title" }), + }, + admin: { label: { plural: "Localized", singular: "Localized" } }, + }), + ).toThrow(/there is no single value to derive from/); + }); + + it("rejects a localized slug whose source does not exist", () => { + // The shared slug-source check runs first and is the one that fires. + expect(() => + defineContentType({ + id: "test.nosource", + tableName: "test_no_sources", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true, source: "headline" }), + }, + admin: { label: { plural: "None", singular: "None" } }, + }), + ).toThrow(/which is not a field on this content type/); + }); + + it("rejects a localized field named in the admin list", () => { + expect(() => + localized({ + admin: { + label: { plural: "Subjects", singular: "Subject" }, + list: { columns: ["title"] }, + }, + }), + ).toThrow(/admin.list.columns names the localized field "title"/); + }); + + it("rejects a localized field named in an index", () => { + expect(() => localized({ indexes: [{ on: ["title"] }] })).toThrow( + /indexes names the localized field "title"/, + ); + }); +}); + +describe("Stage 5A capability boundaries", () => { + const withCapability = (extra: Record) => + defineContentType({ + id: "test.boundary", + tableName: "test_boundaries", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true, source: "title" }), + }, + admin: { label: { plural: "Boundaries", singular: "Boundary" } }, + ...extra, + } as never); + + it("refuses localization plus publication until Stage 5B", () => { + expect(() => withCapability({ publication: { enabled: true } })).toThrow( + /per-locale publication lands in Stage 5B/, + ); + }); + + it("refuses localization plus editorial until Stage 5B", () => { + expect(() => withCapability({ editorial: { enabled: true } })).toThrow( + /Per-locale revisions land in Stage 5B/, + ); + }); + + it("refuses localization plus publicApi until Stage 5C", () => { + expect(() => + withCapability({ + publication: { enabled: false }, + publicApi: { enabled: true, fields: ["slug"], path: "boundaries" }, + }), + ).toThrow(); + }); + + it("names the stage in every boundary message", () => { + // "Not yet" is only useful when it says how long. + let message = ""; + try { + withCapability({ publication: { enabled: true } }); + } catch (error) { + message = error instanceof Error ? error.message : ""; + } + + expect(message).toMatch(/Stage 5B/); + }); +}); + +describe("backward compatibility", () => { + it("leaves a Stage 1 content type's generated shape untouched", () => { + expect(testArticleContentType.localization.enabled).toBe(false); + expect(testArticleContentType.schemas.translation).toBeNull(); + expect(Object.keys(testArticleContentType.fields)).toContain("title"); + }); + + it("leaves a Stage 2 content type's admin defaults untouched", () => { + expect(testPostContentType.admin.list.searchableFields).toEqual([ + "title", + "excerpt", + ]); + expect(testPostContentType.admin.titleField).toBe("title"); + }); + + it("keeps every declared field in `definition.fields`", () => { + // The partition is derived, never destructive: the definition still + // describes the whole content type, which is what the AdminCP, the docs and + // a future migration generator all read. + expect(Object.keys(testLocalizedArticleContentType.fields)).toEqual([ + "title", + "slug", + "body", + "featured", + "views", + ]); + }); +}); diff --git a/packages/vitnode/src/content/localization.ts b/packages/vitnode/src/content/localization.ts new file mode 100644 index 000000000..bc632cec8 --- /dev/null +++ b/packages/vitnode/src/content/localization.ts @@ -0,0 +1,308 @@ +import type { + ContentFieldDescriptor, + ContentFieldMap, + ContentLocalizationConfig, + ContentLocalizationFallback, + ResolvedContentLocalizationConfig, + ResolvedContentPublicApiConfig, +} from "./types"; + +import { + CONTENT_IDENTIFIER_MAX_LENGTH, + CONTENT_LOCALE_MAX_LENGTH, + CONTENT_LOCALE_PATTERN, + CONTENT_LOCALIZED_FIELD_KINDS, + CONTENT_TRANSLATION_SYSTEM_FIELDS, + CONTENT_TRANSLATION_TABLE_SUFFIX, + isLocalizableFieldKind, +} from "./const"; +import { ContentEngineError } from "./errors"; +import { clampWithFingerprint } from "./fingerprint"; +import { resolveContentTranslationIndexes } from "./indexes"; + +/** + * The one place that decides whether a field is localized. + * + * Every subsystem - table generation, schemas, services, routes, migrations - + * goes through {@link partitionContentFields} rather than testing + * `field.localized === true` for itself. Two copies of this rule is exactly the + * pair that drifts, and the consequence of drift is a column generated on one + * table and read from the other. + */ +export const isLocalizedContentField = ( + fieldValue: ContentFieldDescriptor, +): boolean => fieldValue.localized === true; + +export interface ContentFieldPartition { + /** Fields stored in the translation table, one row per language. */ + localizedFields: ContentFieldMap; + /** Fields stored on the base table. */ + sharedFields: ContentFieldMap; +} + +/** + * Splits a field map into its base-table and translation-table halves. + * + * Declaration order is preserved in both, so the generated column order, the + * generated schema key order and the migration all stay deterministic. + */ +export const partitionContentFields = ( + fields: ContentFieldMap, +): ContentFieldPartition => { + const localizedFields: ContentFieldMap = {}; + const sharedFields: ContentFieldMap = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (isLocalizedContentField(fieldValue)) { + localizedFields[name] = fieldValue; + continue; + } + sharedFields[name] = fieldValue; + } + + return { localizedFields, sharedFields }; +}; + +/** `example_articles` -> `example_articles_translations`. */ +export const contentTranslationTableName = (tableName: string): string => + clampWithFingerprint( + `${tableName}${CONTENT_TRANSLATION_TABLE_SUFFIX}`, + CONTENT_IDENTIFIER_MAX_LENGTH, + ); + +const disabledLocalization: ResolvedContentLocalizationConfig = { + defaultLocale: "", + enabled: false, + fallback: "none", + translationIndexes: [], + translationTableName: "", +}; + +/** The disabled default, for a content type with no `localization` block. */ +export const contentLocalizationDisabled = + (): ResolvedContentLocalizationConfig => ({ + ...disabledLocalization, + translationIndexes: [], + }); + +const translationSystemFields: readonly string[] = + CONTENT_TRANSLATION_SYSTEM_FIELDS; + +const assertDefaultLocale = (id: string, locale: unknown): string => { + if (typeof locale !== "string" || locale.trim() === "") { + throw new ContentEngineError( + 'localization.defaultLocale is required and must be a locale code, e.g. "en". It names the row in `core_languages` every record is first created in.', + { contentTypeId: id }, + ); + } + + if (locale !== locale.trim()) { + throw new ContentEngineError( + `localization.defaultLocale "${locale}" has leading or trailing whitespace.`, + { contentTypeId: id }, + ); + } + + if (locale.length > CONTENT_LOCALE_MAX_LENGTH) { + throw new ContentEngineError( + `localization.defaultLocale "${locale}" is longer than ${CONTENT_LOCALE_MAX_LENGTH} characters, which is the width of \`core_languages.code\`.`, + { contentTypeId: id }, + ); + } + + if (!CONTENT_LOCALE_PATTERN.test(locale)) { + throw new ContentEngineError( + `localization.defaultLocale "${locale}" does not look like a locale code. Expected something like "en", "pl" or "pt-BR".`, + { contentTypeId: id }, + ); + } + + return locale; +}; + +/** + * Every rule a localized field has to satisfy, in one pass. + * + * Two of them are about the *slug*, and they are the ones worth the words: a + * localized slug derived from a shared title would give every language the same + * URL, and a shared slug derived from a localized title has no single source to + * derive from. Both are silent data bugs rather than crashes, so they are + * rejected at definition time. + */ +const assertLocalizedFields = ( + id: string, + fields: ContentFieldMap, + localizedFields: ContentFieldMap, +): void => { + if (Object.keys(localizedFields).length === 0) { + throw new ContentEngineError( + "localization is enabled but no field is marked `localized: true`, so the generated translation table would hold nothing but its keys. Mark at least one text, textarea or slug field.", + { contentTypeId: id }, + ); + } + + for (const [name, fieldValue] of Object.entries(localizedFields)) { + if (!isLocalizableFieldKind(fieldValue.kind)) { + throw new ContentEngineError( + `Field "${name}" is \`localized: true\` but its kind is "${fieldValue.kind}". Only ${CONTENT_LOCALIZED_FIELD_KINDS.join(", ")} fields can be localized - an enum's identifiers have to be the same in every language, and a relation's target is shared.`, + { contentTypeId: id }, + ); + } + + if (translationSystemFields.includes(name)) { + throw new ContentEngineError( + `Localized field "${name}" collides with a generated translation column. Rename it - the translation table always carries ${translationSystemFields.join(", ")}.`, + { contentTypeId: id }, + ); + } + } + + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "slug" || fieldValue.source === undefined) continue; + + // `assertSlugSources` in `define.ts` has already proven the source exists + // and is a text field; this only checks the two halves agree about *where* + // the value lives. + const source = fields[fieldValue.source]; + if (!source) continue; + + const slugLocalized = isLocalizedContentField(fieldValue); + const sourceLocalized = isLocalizedContentField(source); + + if (slugLocalized && !sourceLocalized) { + throw new ContentEngineError( + `Localized slug "${name}" is sourced from the shared field "${fieldValue.source}". Every language would derive the same URL. Mark "${fieldValue.source}" \`localized: true\` too, or send the slug explicitly.`, + { contentTypeId: id }, + ); + } + + if (!slugLocalized && sourceLocalized) { + throw new ContentEngineError( + `Shared slug "${name}" is sourced from the localized field "${fieldValue.source}", which has a different value in every language - so there is no single value to derive from. Mark "${name}" \`localized: true\`, or point it at a shared text field.`, + { contentTypeId: id }, + ); + } + } +}; + +/** + * Stage 5A boundaries. + * + * Localization lands as infrastructure: the tables, the types, the services and + * the versioning. The stages that read *through* it are not here yet, and the + * honest failure for that is a refused definition rather than a content type + * that quietly runs Stage 1-4 logic against the base table while pretending its + * localized fields do not exist. + * + * Every message names the stage that lifts the restriction, because "not yet" is + * only useful when it says how long. + */ +const assertStageBoundaries = ( + id: string, + { + editorial, + publicApi, + publication, + search, + }: { + editorial: boolean; + publicApi: boolean; + publication: boolean; + search: boolean; + }, +): void => { + if (publication) { + throw new ContentEngineError( + "localization cannot be combined with `publication` yet. A localized record has one status per *language* - publishing the English draft must not put an empty Polish page on the internet - and per-locale publication lands in Stage 5B.", + { contentTypeId: id }, + ); + } + + if (editorial) { + throw new ContentEngineError( + "localization cannot be combined with `editorial` yet. A revision would snapshot the base row only, so restoring it would silently drop every translation. Per-locale revisions land in Stage 5B.", + { contentTypeId: id }, + ); + } + + if (publicApi) { + throw new ContentEngineError( + "localization cannot be combined with `publicApi` yet. A public read has to resolve a locale and decide what to do when a translation is missing, and locale-aware public routes land in Stage 5C.", + { contentTypeId: id }, + ); + } + + if (search) { + throw new ContentEngineError( + "localization cannot be combined with `search` yet. One document per record would index a single language and rank every other one as a miss; per-locale search documents land in Stage 5D.", + { contentTypeId: id }, + ); + } +}; + +/** + * Checks and fills in `localization`. + * + * Runs after every other resolver, because the Stage 5A boundaries are stated in + * terms of capabilities they have already settled. Like every other resolver + * here it repeats what the types say: a JavaScript caller, or a value that + * widened somewhere upstream, can reach this with anything at all. + */ +export const resolveContentLocalization = ({ + editorial, + fields, + id, + localization, + publicApi, + publication, + search, + tableName, +}: { + editorial: boolean; + fields: ContentFieldMap; + id: string; + localization: ContentLocalizationConfig | undefined; + publicApi: ResolvedContentPublicApiConfig; + publication: boolean; + search: boolean; + tableName: string; +}): ResolvedContentLocalizationConfig => { + const { localizedFields } = partitionContentFields(fields); + + if (!localization?.enabled) { + const stray = Object.keys(localizedFields)[0]; + if (stray !== undefined) { + throw new ContentEngineError( + `Field "${stray}" is \`localized: true\` but the content type has no \`localization: { enabled: true, defaultLocale }\` block, so there is no translation table for it to live in.`, + { contentTypeId: id }, + ); + } + + return contentLocalizationDisabled(); + } + + assertStageBoundaries(id, { + editorial, + publicApi: publicApi.enabled, + publication, + search, + }); + + const defaultLocale = assertDefaultLocale(id, localization.defaultLocale); + assertLocalizedFields(id, fields, localizedFields); + + const fallback: ContentLocalizationFallback = localization.fallback ?? "none"; + const translationTableName = contentTranslationTableName(tableName); + + return { + defaultLocale, + enabled: true, + fallback, + translationIndexes: resolveContentTranslationIndexes({ + contentTypeId: id, + localizedFields, + translationTableName, + }), + translationTableName, + }; +}; diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index dcfadce95..f8338c222 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -74,6 +74,23 @@ export const validateContentTypes = ( } byTable.set(definition.tableName, entry); + // The generated translation table shares one namespace with every base + // table, so a content type called `example_articles_translations` and a + // localized `example_articles` would collide - and the shortening clamp + // makes that reachable with two long names that differ only past character + // 63. Both directions are caught by holding one map. + if (definition.localization.enabled) { + const translationTable = definition.localization.translationTableName; + const duplicateTranslationTable = byTable.get(translationTable); + if (duplicateTranslationTable) { + throw new ContentEngineError( + `Translation table "${translationTable}" is claimed by both ${describe(duplicateTranslationTable)} and ${describe(entry)}. Rename one of the base tables.`, + { contentTypeId: definition.id }, + ); + } + byTable.set(translationTable, entry); + } + // Permission modules are scoped per plugin, so only a collision inside one // plugin is ambiguous. const permissionKey = `${pluginId}:${definition.permissionModule}`; @@ -110,7 +127,10 @@ export const validateContentTypes = ( // `resolveContentIndexes` already rejects a collision inside one content // type. Postgres index names are unique per *schema*, though, so two // content types - from one plugin or from two - cannot share one either. - for (const index of definition.indexes) { + for (const index of [ + ...definition.indexes, + ...definition.localization.translationIndexes, + ]) { const owner = byIndexName.get(index.name); if (owner) { throw new ContentEngineError( diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index d4b79fc6f..801cb1677 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -5,15 +5,19 @@ import type { ContentCreateInput, ContentFieldDescriptor, ContentFieldMap, + ContentLocalizedUpdateValues, + ContentLocalizedValues, ContentPublicSelect, ContentSelect, ContentUpdateInput, ResolvedContentAdminConfig, + ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, } from "./types"; import { CONTENT_EDITORIAL_FIELDS, + CONTENT_LOCALE_MAX_LENGTH, CONTENT_PUBLIC_ALWAYS_ORDERABLE, CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, @@ -21,6 +25,10 @@ import { CONTENT_SYSTEM_FIELDS, isFilterableFieldKind, } from "./const"; +import { + contentLocalizationDisabled, + partitionContentFields, +} from "./localization"; /** What a content type without `publicApi` carries: nothing exposed at all. */ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { @@ -35,8 +43,54 @@ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { slugField: "", }; +/** + * The schemas one translation row is written and read through. + * + * Content values live under `values`, and everything else - the locale, the + * expected version - is transport that sits *beside* them. That split is what + * lets `values` stay a strict object of the content type's own localized fields: + * an `expectedVersion` key inside it would be indistinguishable from a field + * somebody is trying to mass-assign. + */ +export interface ContentTranslationSchemas< + TDefinition = AnyContentTypeDefinition, +> { + /** Localized values for a new translation. Strict; requiredness per field. */ + create: z.ZodType>; + /** `{ values }` - the create request body. */ + createEnvelope: z.ZodType<{ values: ContentLocalizedValues }>; + /** The same shape as a plain `ZodObject`, so routes can compose it. */ + createObject: z.ZodObject; + /** + * The form shape for one locale: a plain `ZodObject` with no `z.date()`, so + * `AutoForm` can run `z.toJSONSchema` on it. Stage 5B renders it. + */ + form: z.ZodObject; + /** Path parameters for a translation route: the item, then the locale. */ + params: z.ZodObject<{ id: z.ZodCoercedNumber; locale: z.ZodString }>; + /** One translation as it comes back: metadata plus `values`. */ + select: z.ZodObject; + /** One translation without its values - what the list route returns. */ + selectMeta: z.ZodObject; + /** Localized values for an existing translation. Every key optional, never empty. */ + update: z.ZodType>; + /** `{ expectedVersion, values }` - the update and delete request body. */ + updateEnvelope: z.ZodType<{ + expectedVersion: number; + values: ContentLocalizedUpdateValues; + }>; + /** `{ expectedVersion }` - the delete request body. */ + versionEnvelope: z.ZodType<{ expectedVersion: number }>; +} + export interface ContentSchemas { - /** Request body for create. Rejects unknown keys and system columns. */ + /** + * Request body for create. Rejects unknown keys and system columns. + * + * Shared fields only. A localized content type's localized values arrive + * through `translation.create` instead, in the same transaction - see + * `localizedService.create`. + */ create: z.ZodType>; /** * Query-string filters, restricted to filterable fields. Non-strict: it is @@ -73,6 +127,15 @@ export interface ContentSchemas { * routes can `.extend(...)` it with the joined relation labels. */ selectObject: z.ZodObject; + /** + * The per-language schemas, or `null` when the content type is not localized. + * + * `null` rather than empty schemas, matching how `model.publicService` is + * `undefined` without a public API: a nullable value reads naturally in code + * that does not know which content type it was handed, and it cannot be used + * by accident. + */ + translation: ContentTranslationSchemas | null; /** Request body for update. Every field optional, but never empty. */ update: z.ZodType>; /** @@ -298,20 +361,115 @@ const publicSelectShape = ( * `defineContentType` can call it before the definition object exists and * without re-widening its field map. */ +/** + * The translation schemas for one content type, or `null` when it has none. + * + * Localized fields only, and never a metadata key: `itemId` and `languageId` + * identify the row rather than describing it, and `version` is assigned by the + * conditional `UPDATE` that guards on it. All three are absent from the strict + * `values` object, which is what stops any of them being mass-assigned. + */ +const buildTranslationSchemas = ({ + admin, + localizedFields, + localization, +}: { + admin: ResolvedContentAdminConfig; + localization: ResolvedContentLocalizationConfig; + localizedFields: ContentFieldMap; +}): ContentTranslationSchemas | null => { + if (!localization.enabled) return null; + + const names = Object.keys(localizedFields); + + const create = z.strictObject(inputShape(localizedFields, names)); + const update = z + .strictObject(updateShape(localizedFields, names)) + .refine(value => Object.keys(value).length > 0, { + message: "Provide at least one localized field to update.", + }); + + const expectedVersion = z.number().int().positive(); + const selectMeta = z.object({ + createdAt: z.date(), + itemId: z.number().int().positive(), + languageId: z.number().int().positive(), + locale: z.string(), + updatedAt: z.date(), + version: expectedVersion, + }); + + const values = z.object( + Object.fromEntries( + names.map(name => [ + name, + applyNullable( + baseSelectSchema(localizedFields[name]), + localizedFields[name], + ), + ]), + ), + ); + + return { + create: create as unknown as z.ZodType>, + createEnvelope: z.strictObject({ values: create }) as unknown as z.ZodType<{ + values: ContentLocalizedValues; + }>, + createObject: create, + // The declared form fields, narrowed to the localized ones: a locale tab + // edits one language's values and nothing else. + form: z.object( + inputShape( + localizedFields, + admin.form.fields.filter(name => localizedFields[name] !== undefined), + ), + ), + params: z.object({ + id: z.coerce.number(), + // Loose on purpose, like `publicParams.slug`: an unknown locale and a + // malformed one are both answered the same way, and the value is a bound + // parameter rather than an identifier. + locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH), + }), + select: selectMeta.extend({ values }), + selectMeta, + update: update as unknown as z.ZodType< + ContentLocalizedUpdateValues + >, + updateEnvelope: z.strictObject({ + // Positive, so a client that forgot to send one cannot coerce `0` past the + // guard and race the very check it is meant to lose. + expectedVersion, + values: update, + }) as unknown as z.ZodType<{ + expectedVersion: number; + values: ContentLocalizedUpdateValues; + }>, + versionEnvelope: z.strictObject({ expectedVersion }), + }; +}; + export const buildContentSchemas = ({ admin, editorial = false, fields, + localization = contentLocalizationDisabled(), publicApi = DISABLED_PUBLIC_API, publication = false, }: { admin: ResolvedContentAdminConfig; editorial?: boolean; + /** Every declared field. Partitioned here, so no caller has to. */ fields: ContentFieldMap; + localization?: ResolvedContentLocalizationConfig; publicApi?: ResolvedContentPublicApiConfig; publication?: boolean; }): ContentSchemas => { - const fieldNames = Object.keys(fields); + // Everything below this line is about the base table, so it reads the shared + // half only. The localized half gets its own schemas at the bottom. + const { localizedFields, sharedFields } = partitionContentFields(fields); + const fieldNames = Object.keys(sharedFields); // Read-only on the wire: absent from `create` and `update` (both strict), so // the only way to move them is `service.publish` / `service.unpublish`. @@ -333,7 +491,7 @@ export const buildContentSchemas = ({ ...Object.fromEntries( fieldNames.map(name => [ name, - applyNullable(baseSelectSchema(fields[name]), fields[name]), + applyNullable(baseSelectSchema(sharedFields[name]), sharedFields[name]), ]), ), ...publicationSelectShape, @@ -345,9 +503,9 @@ export const buildContentSchemas = ({ // `strictObject` blocks mass assignment: an unknown key is an error, not // something quietly stripped. System columns are absent from the shape, so // they can never be set from a request. - const create = z.strictObject(inputShape(fields, fieldNames)); + const create = z.strictObject(inputShape(sharedFields, fieldNames)); const update = z - .strictObject(updateShape(fields, fieldNames)) + .strictObject(updateShape(sharedFields, fieldNames)) .refine(value => Object.keys(value).length > 0, { message: "Provide at least one field to update.", }); @@ -360,14 +518,16 @@ export const buildContentSchemas = ({ ]; const selectObject = z.object(selectShape); - const publicSelectObject = z.object(publicSelectShape(fields, publicApi)); + const publicSelectObject = z.object( + publicSelectShape(sharedFields, publicApi), + ); const publicFilterable = new Set(publicApi.filterableFields); // Derived from the same `filterShape`, then narrowed to the configured // allowlist - so a public filter can never reach a field the admin filter // schema would not have accepted either. const publicFilters = z.object( Object.fromEntries( - Object.entries(filterShape(fields)).filter(([name]) => + Object.entries(filterShape(sharedFields)).filter(([name]) => publicFilterable.has(name), ), ), @@ -380,12 +540,12 @@ export const buildContentSchemas = ({ // casts. `buildContentSchemas` is covered by `schemas.test-d.ts`. create: create as unknown as z.ZodType>, filters: z.object({ - ...filterShape(fields), + ...filterShape(sharedFields), ...(publication ? { status: z.enum(CONTENT_PUBLICATION_STATUSES).optional() } : {}), }), - form: z.object(inputShape(fields, admin.form.fields)), + form: z.object(inputShape(sharedFields, admin.form.fields)), order: z.object({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(orderable as [string, ...string[]]).optional(), @@ -412,6 +572,11 @@ export const buildContentSchemas = ({ publicSelectObject, select: selectObject as unknown as z.ZodType>, selectObject, + translation: buildTranslationSchemas({ + admin, + localization, + localizedFields, + }), update: update as unknown as z.ZodType>, updateEnvelope: z.strictObject({ // Positive, so a client that forgot to send one cannot coerce `0` past diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index 8f4465ea2..d6dcddaa1 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -79,6 +79,52 @@ export const buildEditorialColumns = (): Record< version: integer().notNull().default(1), }); +/** + * The columns every generated translation table carries. + * + * `itemId` and `languageId` are the composite primary key, added by + * `createContentTranslationTable` - both are `NOT NULL` here because a key + * column has to be, and both are written by the service rather than by a + * request. + * + * `version` mirrors the editorial column deliberately: a translation has *its + * own* optimistic lock, so an edit in Polish and an edit in English cannot + * conflict with each other. It defaults to 1 and is only ever moved by the + * conditional `UPDATE` that guards on it. + */ +export const buildTranslationSystemColumns = ({ + itemReference, + languageReference, + onItemDelete = "cascade", +}: { + itemReference: ColumnReferenceThunk; + languageReference: ColumnReferenceThunk; + onItemDelete?: "cascade"; +}): Record => ({ + itemId: integer() + .notNull() + // Cascade: a record's translations are part of the record, so removing it + // takes them with it in one statement - there is no loop over locales + // anywhere, and no window in which a translation outlives its row. + .references(itemReference, { onDelete: onItemDelete, onUpdate: "cascade" }), + languageId: integer() + .notNull() + // Restrict, unlike `core_languages_words`, which cascades. Deleting a + // language must not silently delete every article written in it: the + // AdminCP's language screen should refuse, and the person should decide + // what happens to the content first. + .references(languageReference, { + onDelete: "restrict", + onUpdate: "cascade", + }), + version: integer().notNull().default(1), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + /** * Applies `NOT NULL` and the column default. * diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index c0b864870..435d19a75 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -12,6 +12,7 @@ export { buildEditorialColumns, buildPublicationColumns, buildSystemColumns, + buildTranslationSystemColumns, } from "./column-builders"; export type { ColumnReferenceThunk } from "./column-builders"; export { contentEditorialEffects } from "./editorial-effects"; @@ -35,6 +36,27 @@ export { withHttpErrors, } from "./http-errors"; export type { ContentHttpErrorOptions } from "./http-errors"; +export { + assertContentLocalizationLanguages, + ensureContentLocalizationLanguages, + findContentLanguage, + findContentLocalizationProblems, + listContentLanguages, + resetContentLocalizationCheck, + resolveContentLanguage, + resolveDefaultContentLanguage, +} from "./language-resolver"; +export type { + ContentLanguage, + ContentLocalizationProblem, +} from "./language-resolver"; +export { createContentLocalizedService } from "./localized-service"; +export type { + ContentLocalizedCreateInput, + ContentLocalizedCreateOptions, + ContentLocalizedCreateResult, + ContentLocalizedService, +} from "./localized-service"; export { createContentModel, findContentModel } from "./model"; export type { AnyContentModel, @@ -138,7 +160,27 @@ export type { } from "./service"; export { createSlugNormalizer } from "./slugs"; export type { ContentSlugNormalizer } from "./slugs"; -export { contentTableColumns, createContentTable } from "./table"; +export { + assertContentReferences, + contentTableColumns, + createContentTable, +} from "./table"; +export { + contentTranslationConflict, + withTranslationHttpErrors, +} from "./translation-http-errors"; +export { createContentTranslationModel } from "./translation-model"; +export type { + ContentTranslationModel, + ContentTranslationOptions, + ContentTranslationUpdateResult, + ContentTranslationWriteOptions, +} from "./translation-model"; +export { buildContentTranslationRoutes } from "./translation-routes"; +export { + contentTranslationTableColumns, + createContentTranslationTable, +} from "./translation-table"; export type { ContentColumnBuilder, ContentColumnBuilders, @@ -149,4 +191,9 @@ export type { ContentSystemColumnBuilders, ContentTable, ContentTableFor, + ContentTranslationColumnBuilders, + ContentTranslationColumnName, + ContentTranslationSystemColumnBuilders, + ContentTranslationTable, + ContentTranslationTableFor, } from "./types"; diff --git a/packages/vitnode/src/content/server/language-resolver.test.ts b/packages/vitnode/src/content/server/language-resolver.test.ts new file mode 100644 index 000000000..7621290fd --- /dev/null +++ b/packages/vitnode/src/content/server/language-resolver.test.ts @@ -0,0 +1,362 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError, ContentLanguageError } from "../errors"; +import { + assertContentLocalizationLanguages, + ensureContentLocalizationLanguages, + findContentLanguage, + findContentLocalizationProblems, + listContentLanguages, + resetContentLocalizationCheck, + resolveContentLanguage, + resolveDefaultContentLanguage, +} from "./language-resolver"; + +interface LanguageRow { + code: string; + id: number; + isDefault: boolean; +} + +const DEFAULT_ROWS: LanguageRow[] = [ + { code: "en", id: 1, isDefault: true }, + { code: "pl", id: 2, isDefault: false }, + { code: "pt-BR", id: 3, isDefault: false }, +]; + +/** + * A context whose `select().from()` returns the language rows and counts how + * many times it was asked - which is the whole point of the per-request cache. + */ +const createContext = ({ + locales, + rows = DEFAULT_ROWS, +}: { + locales?: { code: string; enabled?: boolean; name: string }[]; + rows?: LanguageRow[]; +} = {}) => { + let queries = 0; + + const db = { + select: () => ({ + // A plain array, not a promise: `await` resolves it just the same, and it + // keeps the mock out of the require-await / promise-function-async + // crossfire. + from: () => { + queries += 1; + + return rows; + }, + }), + }; + + const c = { + get: (key: string) => { + if (key === "db") return db; + if (key === "core") { + return locales ? { i18n: { locales } } : undefined; + } + + return undefined; + }, + } as unknown as Context; + + return { c, queries: () => queries }; +}; + +beforeEach(() => { + resetContentLocalizationCheck(); +}); + +describe("listContentLanguages", () => { + it("returns every language with its id and canonical locale", async () => { + const { c } = createContext(); + + expect(await listContentLanguages(c)).toEqual([ + { id: 1, isDefault: true, isEnabled: true, locale: "en" }, + { id: 2, isDefault: false, isEnabled: true, locale: "pl" }, + { id: 3, isDefault: false, isEnabled: true, locale: "pt-BR" }, + ]); + }); + + it("queries once per request however often it is asked", async () => { + const { c, queries } = createContext(); + + await listContentLanguages(c); + await findContentLanguage(c, "pl"); + await resolveContentLanguage(c, { locale: "en" }); + + // The alternative - `WHERE code = $1` per lookup - is one query per locale, + // which is exactly the N+1 a list of translations would turn into. + expect(queries()).toBe(1); + }); + + it("does not cache a failed load", async () => { + let attempts = 0; + const db = { + select: () => ({ + from: () => { + attempts += 1; + // Thrown synchronously inside the resolver's `await`, which rejects + // its promise exactly as a driver failure would. + if (attempts === 1) throw new Error("connection reset"); + + return DEFAULT_ROWS; + }, + }), + }; + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as unknown as Context; + + await expect(listContentLanguages(c)).rejects.toThrow("connection reset"); + // A blip must not poison the rest of the request. + expect(await listContentLanguages(c)).toHaveLength(3); + }); + + it("marks a locale the app config disables", async () => { + const { c } = createContext({ + locales: [ + { code: "en", name: "English" }, + { code: "pl", enabled: false, name: "Polski" }, + ], + }); + + const languages = await listContentLanguages(c); + + expect(languages.map(language => language.isEnabled)).toEqual([ + true, + false, + // Not mentioned by the config at all, which is not the same as switched + // off: dropping a locale from `i18n.locales` must not make existing + // content unwritable. + true, + ]); + }); +}); + +describe("findContentLanguage", () => { + it("matches case-insensitively and returns the canonical locale", async () => { + const { c } = createContext(); + + // A locale travels in a URL, and `/PL/` naming the same language as `/pl/` + // is what people expect - but what gets stored is the row's own code. + expect((await findContentLanguage(c, "PL"))?.locale).toBe("pl"); + expect((await findContentLanguage(c, "pt-br"))?.locale).toBe("pt-BR"); + expect((await findContentLanguage(c, " en "))?.locale).toBe("en"); + }); + + it("returns null for an unknown or empty locale", async () => { + const { c } = createContext(); + + expect(await findContentLanguage(c, "de")).toBeNull(); + expect(await findContentLanguage(c, "")).toBeNull(); + expect(await findContentLanguage(c, " ")).toBeNull(); + }); +}); + +describe("resolveContentLanguage", () => { + it("resolves an existing language", async () => { + const { c } = createContext(); + + expect(await resolveContentLanguage(c, { locale: "pl" })).toEqual({ + id: 2, + isDefault: false, + isEnabled: true, + locale: "pl", + }); + }); + + it("throws `missing` for an unknown locale", async () => { + const { c } = createContext(); + + await expect( + resolveContentLanguage(c, { locale: "de" }), + ).rejects.toMatchObject({ reason: "missing" }); + }); + + it("reads a disabled language but refuses to write one", async () => { + const { c } = createContext({ + locales: [{ code: "pl", enabled: false, name: "Polski" }], + }); + + // Reading is fine: the content is already there, and hiding it would make it + // unrecoverable. + expect((await resolveContentLanguage(c, { locale: "pl" })).id).toBe(2); + + await expect( + resolveContentLanguage(c, { locale: "pl", requireEnabled: true }), + ).rejects.toMatchObject({ reason: "disabled" }); + }); + + it("distinguishes missing from disabled in its message", async () => { + const { c } = createContext({ + locales: [{ code: "pl", enabled: false, name: "Polski" }], + }); + + await expect(resolveContentLanguage(c, { locale: "de" })).rejects.toThrow( + /Unknown locale "de"/, + ); + await expect( + resolveContentLanguage(c, { locale: "pl", requireEnabled: true }), + ).rejects.toThrow(/is disabled on this installation/); + }); + + it("names the content type in the error when it knows one", async () => { + const { c } = createContext(); + + await expect( + resolveContentLanguage(c, { + contentTypeId: "test.localized", + locale: "de", + }), + ).rejects.toThrow(/test\.localized/); + }); +}); + +describe("resolveDefaultContentLanguage", () => { + it("resolves the configured default locale", async () => { + const { c } = createContext(); + + expect( + await resolveDefaultContentLanguage(c, testLocalizedArticleContentType), + ).toMatchObject({ id: 1, locale: "en" }); + }); + + it("matches the default locale case-insensitively too", async () => { + const { c } = createContext(); + + // The note fixture configures `EN`; the canonical code is `en`. + expect( + await resolveDefaultContentLanguage(c, testLocalizedNoteContentType), + ).toMatchObject({ id: 1, locale: "en" }); + }); + + it("refuses a content type without localization", async () => { + const { c } = createContext(); + + await expect( + resolveDefaultContentLanguage(c, testArticleContentType), + ).rejects.toBeInstanceOf(ContentEngineError); + }); + + it("throws when the default language is gone", async () => { + const { c } = createContext({ + rows: [{ code: "pl", id: 2, isDefault: true }], + }); + + await expect( + resolveDefaultContentLanguage(c, testLocalizedArticleContentType), + ).rejects.toBeInstanceOf(ContentLanguageError); + }); +}); + +describe("the boot guard", () => { + const entries = [ + { + definition: testLocalizedArticleContentType, + pluginId: "@vitnode/example", + }, + { definition: testArticleContentType, pluginId: "@vitnode/example" }, + ]; + + it("passes when every default locale resolves", async () => { + const { c } = createContext(); + + await expect( + assertContentLocalizationLanguages(c, entries), + ).resolves.toBeUndefined(); + }); + + it("never touches the languages table with nothing localized", async () => { + const { c, queries } = createContext(); + + await assertContentLocalizationLanguages(c, [ + { definition: testArticleContentType, pluginId: "@vitnode/example" }, + ]); + + // An install that defines no localized content type must not pay for this. + expect(queries()).toBe(0); + }); + + it("reports a missing default language", async () => { + const { c } = createContext({ + rows: [{ code: "pl", id: 2, isDefault: true }], + }); + + expect(await findContentLocalizationProblems(c, entries)).toEqual([ + { + contentTypeId: "test.localized", + defaultLocale: "en", + reason: "missing", + }, + ]); + }); + + it("reports a disabled default language", async () => { + const { c } = createContext({ + locales: [{ code: "en", enabled: false, name: "English" }], + }); + + expect(await findContentLocalizationProblems(c, entries)).toEqual([ + { + contentTypeId: "test.localized", + defaultLocale: "en", + reason: "disabled", + }, + ]); + }); + + it("names every offender in one error rather than failing on the first", async () => { + const { c } = createContext({ + rows: [{ code: "de", id: 9, isDefault: true }], + }); + + await expect( + assertContentLocalizationLanguages(c, [ + ...entries, + { + definition: testLocalizedNoteContentType, + pluginId: "@vitnode/example", + }, + ]), + ).rejects.toThrow(/test\.localized[\s\S]*test\.localized-note/); + }); + + it("runs at most once per process", async () => { + const { c, queries } = createContext(); + + await ensureContentLocalizationLanguages(c, entries); + await ensureContentLocalizationLanguages(c, entries); + + // The definitions cannot change while the process runs, so re-checking every + // request would be pure cost. One query, for the one check. + expect(queries()).toBe(1); + }); + + it("does not memoise a failure", async () => { + const broken = createContext({ + rows: [{ code: "pl", id: 2, isDefault: true }], + }); + + await expect( + ensureContentLocalizationLanguages(broken.c, entries), + ).rejects.toThrow(); + + const healthy = createContext(); + // A database that was not up yet gets checked again rather than poisoning + // the process. + await expect( + ensureContentLocalizationLanguages(healthy.c, entries), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/content/server/language-resolver.ts b/packages/vitnode/src/content/server/language-resolver.ts new file mode 100644 index 000000000..89ff892c9 --- /dev/null +++ b/packages/vitnode/src/content/server/language-resolver.ts @@ -0,0 +1,345 @@ +import type { Context } from "hono"; + +import type { RegisteredContentType } from "../registry"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; + +import { core_languages } from "../../database/languages"; +import { ContentEngineError, ContentLanguageError } from "../errors"; + +/** + * One usable language, as the Content Engine sees it. + * + * `locale` is the **canonical** `core_languages.code` - what the row stores, not + * what the caller typed. Everything downstream (the translation row, the route + * response, a future cache tag) uses this value, so `PL` in a URL and `pl` in a + * URL end up writing to the same place. + */ +export interface ContentLanguage { + /** `core_languages.id`. The foreign key a translation row actually holds. */ + id: number; + /** Whether `core_languages.default` is set on this row. */ + isDefault: boolean; + /** Whether the app config serves this locale. Disabled ones are read-only. */ + isEnabled: boolean; + locale: string; +} + +/** + * Every language in `core_languages`, cached for the life of one request. + * + * Keyed by the Hono context in a `WeakMap` rather than stored on the context + * itself: `c.set` is typed against `ContextVariableMap`, and adding a Content + * Engine key to the global variable map for a per-request memo would make every + * app carry it. The entry is collected with the request. + * + * The languages table is a handful of rows and every locale in a batch has to be + * resolved anyway, so one query per request beats one query per locale - which is + * what a naive `WHERE code = $1` resolver turns into on a list of translations. + */ +const perRequest = new WeakMap>(); + +/** + * Locales the app config has explicitly switched off. + * + * Optional the whole way down: a direct `app.request()` in a test, and a queue + * handler built by hand, both reach here without the global middleware that + * populates `core` - and "no config" has to mean "nothing disabled" rather than a + * `TypeError`. + */ +const disabledLocales = (c: Context): ReadonlySet => { + const locales = c.get("core")?.i18n?.locales ?? []; + + return new Set( + locales + .filter(locale => locale.enabled === false) + .map(locale => locale.code.toLowerCase()), + ); +}; + +const load = async ( + c: Context, + tx?: ContentDatabase, +): Promise => { + const disabled = disabledLocales(c); + + const rows = await contentDatabase(c, tx) + .select({ + code: core_languages.code, + id: core_languages.id, + isDefault: core_languages.default, + }) + .from(core_languages); + + return rows.map(row => ({ + id: row.id, + isDefault: row.isDefault, + // `core_languages` is the registry of languages that *exist*; the app config + // says which ones it currently serves. A language the config does not + // mention at all stays usable - dropping a locale from `i18n.locales` must + // not make existing content unwritable - but one listed with + // `enabled: false` is a deliberate switch-off. + isEnabled: !disabled.has(row.code.toLowerCase()), + locale: row.code, + })); +}; + +/** + * Every language, resolved once per request. + * + * Never called for a content type without localization, which is what keeps the + * languages table out of the query plan of an install that has none. + * + * `tx` is not an optimisation - it is required for correctness. A pool with one + * connection (which is what `max: 1` and a busy pool both amount to) would have + * that connection held by the caller's open transaction, so a language query + * issued on the *client* would wait for a connection the transaction is never + * going to release until it gets an answer. Reading inside the transaction also + * means the language it resolves is the one the insert will actually see. + */ +export const listContentLanguages = async ( + c: Context, + tx?: ContentDatabase, +): Promise => { + const cached = perRequest.get(c); + if (cached) return await cached; + + const pending = load(c, tx); + perRequest.set(c, pending); + + try { + return await pending; + } catch (error) { + // A failed load must not be cached: the next call in the same request would + // get the same rejection with no chance of recovering from a blip. + perRequest.delete(c); + throw error; + } +}; + +/** + * Finds a language by locale, case-insensitively. `null` when there is none. + * + * Case-insensitive because a locale travels in a URL, and `/pl/` and `/PL/` + * naming the same language is what people expect. The comparison happens in + * JavaScript over the already-loaded rows rather than as `lower(code) = $1`, + * which would not use `core_languages_code_idx` anyway. + */ +export const findContentLanguage = async ( + c: Context, + locale: string, + tx?: ContentDatabase, +): Promise => { + const wanted = locale.trim().toLowerCase(); + if (wanted === "") return null; + + const languages = await listContentLanguages(c, tx); + + return ( + languages.find(language => language.locale.toLowerCase() === wanted) ?? null + ); +}; + +/** + * Resolves a locale to a language, or throws. + * + * `requireEnabled` is the write path: reading a translation in a locale the app + * has switched off is fine - it is already in the database, and hiding it would + * make the content unrecoverable - but writing one is not, because it would grow + * content in a language nothing renders. + */ +export const resolveContentLanguage = async ( + c: Context, + { + contentTypeId, + locale, + requireEnabled = false, + tx, + }: { + contentTypeId?: string; + locale: string; + requireEnabled?: boolean; + /** Read inside this transaction. See {@link listContentLanguages}. */ + tx?: ContentDatabase; + }, +): Promise => { + const language = await findContentLanguage(c, locale, tx); + + if (!language) { + throw new ContentLanguageError({ + contentTypeId, + locale, + reason: "missing", + }); + } + + if (requireEnabled && !language.isEnabled) { + throw new ContentLanguageError({ + contentTypeId, + locale: language.locale, + reason: "disabled", + }); + } + + return language; +}; + +/** + * The language a localized content type creates its records in. + * + * Resolved from the database every time rather than trusted from the definition: + * `defaultLocale` is a string in source control, and whether it names a row in + * `core_languages` is a fact about the installation. + */ +export const resolveDefaultContentLanguage = async ( + c: Context, + definition: AnyContentTypeDefinition, + tx?: ContentDatabase, +): Promise => { + if (!definition.localization.enabled) { + throw new ContentEngineError( + "This content type has no `localization` block, so it has no default locale.", + { contentTypeId: definition.id }, + ); + } + + return await resolveContentLanguage(c, { + contentTypeId: definition.id, + locale: definition.localization.defaultLocale, + requireEnabled: true, + tx, + }); +}; + +/** One localized content type whose configured default locale does not work. */ +export interface ContentLocalizationProblem { + contentTypeId: string; + defaultLocale: string; + reason: "disabled" | "missing"; +} + +/** + * Checks every localized content type's `defaultLocale` against the database. + * + * A content type definition is plain data built at import time, long before there + * is a connection - so "does `core_languages` have a row for `en`" cannot be a + * definition-time check. This is the explicit runtime phase that replaces it, and + * the whole reason it exists is that the alternative is discovering a typo in + * `defaultLocale` on the first create request, in production, as a foreign-key + * violation. + * + * Returns the problems rather than throwing, so the caller decides: the boot + * guard turns them into one error naming every offender at once, which is more + * useful than failing on the first. + */ +export const findContentLocalizationProblems = async ( + c: Context, + contentTypes: readonly RegisteredContentType[], +): Promise => { + const localized = contentTypes.filter( + entry => entry.definition.localization.enabled, + ); + if (localized.length === 0) return []; + + const languages = await listContentLanguages(c); + const byLocale = new Map( + languages.map(language => [language.locale.toLowerCase(), language]), + ); + const problems: ContentLocalizationProblem[] = []; + + for (const { definition } of localized) { + const { defaultLocale } = definition.localization; + const language = byLocale.get(defaultLocale.trim().toLowerCase()); + + if (!language) { + problems.push({ + contentTypeId: definition.id, + defaultLocale, + reason: "missing", + }); + continue; + } + + if (!language.isEnabled) { + problems.push({ + contentTypeId: definition.id, + defaultLocale, + reason: "disabled", + }); + } + } + + return problems; +}; + +const describeProblem = (problem: ContentLocalizationProblem): string => + `${problem.contentTypeId} -> localization.defaultLocale "${problem.defaultLocale}" ${ + problem.reason === "missing" + ? "does not exist in core_languages" + : "is disabled in this app's i18n.locales" + }`; + +/** + * The boot guard: refuses to serve an install whose localized content types name + * a default locale it cannot honour. + * + * Loud on purpose. A localized record is created with its default translation in + * one transaction, so a broken `defaultLocale` means *no record can be created + * at all* - it is not a degraded mode worth booting into. + */ +export const assertContentLocalizationLanguages = async ( + c: Context, + contentTypes: readonly RegisteredContentType[], +): Promise => { + const problems = await findContentLocalizationProblems(c, contentTypes); + if (problems.length === 0) return; + + throw new ContentEngineError( + `Localized content types have an unusable default locale:\n ${problems + .map(describeProblem) + .join("\n ")}`, + ); +}; + +/** + * The same check, run at most once per process. + * + * Memoised rather than repeated per request: the languages table can change while + * the process runs, but a *definition's* `defaultLocale` cannot - and the failure + * this catches is a configuration mistake, which is either there at boot or not + * at all. A language deleted afterwards is caught by the foreign key. + * + * A failure is not memoised, so a database that was not up yet gets checked + * again on the next request instead of poisoning the process. + */ +let bootCheck: Promise | undefined; + +export const ensureContentLocalizationLanguages = async ( + c: Context, + contentTypes: readonly RegisteredContentType[], +): Promise => { + bootCheck ??= assertContentLocalizationLanguages(c, contentTypes).catch( + (error: unknown) => { + bootCheck = undefined; + throw error; + }, + ); + + await bootCheck; +}; + +/** Test seam: forgets the memoised boot check. */ +export const resetContentLocalizationCheck = (): void => { + bootCheck = undefined; +}; + +/** + * The database handle a translation read or write should use. + * + * Shared with the base service's convention: `tx` when the caller owns a + * transaction, the request's client otherwise. + */ +export const contentDatabase = ( + c: Context, + tx?: ContentDatabase, +): ContentDatabase => tx ?? c.get("db"); diff --git a/packages/vitnode/src/content/server/localized-service.test.ts b/packages/vitnode/src/content/server/localized-service.test.ts new file mode 100644 index 000000000..1c65d17a6 --- /dev/null +++ b/packages/vitnode/src/content/server/localized-service.test.ts @@ -0,0 +1,348 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import { + testLocalizedArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError, ContentLanguageError } from "../errors"; +import { createContentModel } from "./model"; + +const localized = createContentModel(testLocalizedArticleContentType); + +/** + * The localized service, narrowed once. + * + * `localizedService` is `undefined` for a content type without localization, so + * TypeScript refuses the call until the check has been made - which is the point, + * and is why every test goes through this instead of asserting past it. + */ +const localizedService = (c: Context) => { + const build = localized.localizedService; + if (!build) throw new Error("Expected a localized service."); + + return build(c); +}; + +const LANGUAGES = [ + { code: "en", id: 1, isDefault: true }, + { code: "pl", id: 2, isDefault: false }, +]; + +interface RecordedCall { + arg: unknown; + op: string; +} + +/** + * A Drizzle stand-in whose `transaction` behaves like the real one in the only + * respect a unit test can observe: the callback's rejection propagates, and + * `rolledBack` records that it did. + * + * Whether Postgres actually discards the base row is a property of Postgres, and + * `plugins/example/src/database/postgres.test.ts` asserts it against a real + * database rather than against a mock that could only ever agree with itself. + */ +const createDbMock = ( + results: unknown[][], + { + failInsert, + languages = LANGUAGES, + }: { + /** 1-based insert whose `returning()` throws, standing in for a driver error. */ + failInsert?: number; + languages?: typeof LANGUAGES; + } = {}, +) => { + const calls: RecordedCall[] = []; + const queue = [...results]; + const state = { committed: false, entered: false, rolledBack: false }; + let inserts = 0; + const chain = (rows: unknown[], failReturning = false) => { + const record = (op: string, arg: unknown) => { + calls.push({ arg, op }); + + return builder; + }; + + const builder = { + // Always the builder, which is itself thenable: `await select().from()` + // (how the language registry is read) resolves through `then` below, and a + // longer chain keeps building. + from: (value: unknown) => record("from", value), + limit: (value: unknown) => record("limit", value), + onConflictDoNothing: (value: unknown) => + record("onConflictDoNothing", value), + orderBy: (value: unknown) => record("orderBy", value), + returning: (value: unknown) => { + if (failReturning) { + throw Object.assign(new Error("duplicate key value"), { + code: "23505", + }); + } + + return record("returning", value); + }, + set: (value: unknown) => record("set", value), + then: async (resolve: (rows: unknown[]) => TResult) => + Promise.resolve(rows).then(resolve), + values: (value: unknown) => record("values", value), + where: (value: unknown) => record("where", value), + }; + + return builder; + }; + + const start = (op: string) => (arg: unknown) => { + calls.push({ arg, op }); + // The language registry is the one `select` whose projection names `code` + // and `isDefault`, so it is answered from `languages` rather than from the + // queue - which keeps every test queueing only the rows it cares about. + const isLanguageSelect = + op === "select" && + typeof arg === "object" && + arg !== null && + "code" in arg && + "isDefault" in arg; + + if (op === "insert") inserts += 1; + + return chain( + isLanguageSelect ? languages : (queue.shift() ?? []), + op === "insert" && inserts === failInsert, + ); + }; + + const db = { + delete: start("delete"), + insert: start("insert"), + select: start("select"), + transaction: async ( + body: (tx: unknown) => Promise, + ): Promise => { + state.entered = true; + try { + const result = await body(db); + state.committed = true; + + return result; + } catch (error) { + state.rolledBack = true; + throw error; + } + }, + update: start("update"), + }; + + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as unknown as Context; + + return { c, calls, state }; +}; + +const opsOf = (calls: RecordedCall[], op: string) => + calls.filter(call => call.op === op).map(call => call.arg); + +const baseRow = { createdAt: new Date(), featured: true, id: 7, views: 0 }; +const translationRow = { + body: null, + createdAt: new Date(), + itemId: 7, + languageId: 1, + slug: "hello", + title: "Hello", + updatedAt: new Date(), + version: 1, +}; + +describe("localized create", () => { + it("writes the base row and its default translation in one transaction", async () => { + const { c, calls, state } = createDbMock([ + [baseRow], + [{ id: 7 }], + [translationRow], + ]); + + const result = await localizedService(c).create({ + shared: { featured: true }, + translation: { title: "Hello" }, + }); + + expect(state).toEqual({ + committed: true, + entered: true, + rolledBack: false, + }); + expect(result.row.id).toBe(7); + expect(result.translation).toMatchObject({ + itemId: 7, + locale: "en", + version: 1, + }); + // Two inserts, one transaction. A record whose default translation lands in + // a second request would be addressable and empty in every language until it + // arrived - or forever, if the second request never came. + expect(opsOf(calls, "insert")).toHaveLength(2); + }); + + it("separates the shared values from the localized ones", async () => { + const { c, calls } = createDbMock([ + [baseRow], + [{ id: 7 }], + [translationRow], + ]); + + await localizedService(c).create({ + shared: { featured: true }, + translation: { title: "Hello" }, + }); + + const [base, translation] = opsOf(calls, "values") as Record< + string, + unknown + >[]; + + // The base insert never sees a localized value, and the translation insert + // never sees a shared one. + expect(base).toEqual({ featured: true, views: 0 }); + expect(translation).toEqual({ + itemId: 7, + languageId: 1, + slug: "hello", + title: "Hello", + }); + }); + + it("defaults the locale to the configured default", async () => { + const { c, calls } = createDbMock([ + [baseRow], + [{ id: 7 }], + [translationRow], + ]); + + await localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }); + + expect(opsOf(calls, "values")[1]).toMatchObject({ languageId: 1 }); + }); + + it("accepts the default locale written out explicitly", async () => { + const { c } = createDbMock([[baseRow], [{ id: 7 }], [translationRow]]); + + await expect( + localizedService(c).create( + { shared: {}, translation: { title: "Hello" } }, + { locale: "EN" }, + ), + ).resolves.toMatchObject({ translation: { locale: "en" } }); + }); + + it("refuses to create a record straight into another locale", async () => { + const { c, state } = createDbMock([[baseRow]]); + + // Creating in Polish would leave the default translation missing - the one + // thing the invariant promises is always there. + await expect( + localizedService(c).create( + { shared: {}, translation: { title: "Witaj" } }, + { locale: "pl" }, + ), + ).rejects.toBeInstanceOf(ContentEngineError); + expect(state.entered).toBe(false); + }); + + it("rolls back when the translation insert fails", async () => { + // The base insert succeeds, the translation's locale already exists. + const { c, state } = createDbMock([[baseRow], [{ id: 7 }], []]); + + await expect( + localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }), + ).rejects.toThrow(); + expect(state.rolledBack).toBe(true); + expect(state.committed).toBe(false); + }); + + it("rolls back when a localized slug is taken", async () => { + // The second insert - the translation - hits the unique + // `(languageId, slug)` index. + const { c, state } = createDbMock([[baseRow], [{ id: 7 }]], { + failInsert: 2, + }); + + await expect( + localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }), + ).rejects.toThrow(/duplicate key value/); + expect(state.rolledBack).toBe(true); + expect(state.committed).toBe(false); + }); + + it("rolls back the translation when the base insert fails", async () => { + const { c, calls, state } = createDbMock([], { failInsert: 1 }); + + await expect( + localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }), + ).rejects.toThrow(/duplicate key value/); + // Never reached the translation at all, so there is nothing orphaned. + expect(opsOf(calls, "insert")).toHaveLength(1); + expect(state.committed).toBe(false); + }); + + it("creates nothing when the default language is gone", async () => { + const { c, calls, state } = createDbMock([], { + languages: [{ code: "pl", id: 2, isDefault: true }], + }); + + await expect( + localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }), + ).rejects.toBeInstanceOf(ContentLanguageError); + // Resolved inside the transaction and *before* the base insert, so there is + // no orphan row to clean up. + expect(opsOf(calls, "insert")).toEqual([]); + expect(state.committed).toBe(false); + }); + + it("joins a transaction the caller already owns", async () => { + const { c, state } = createDbMock([ + [baseRow], + [{ id: 7 }], + [translationRow], + ]); + const tx = c.get("db") as never; + + await localizedService(c).create( + { shared: {}, translation: { title: "Hello" } }, + { tx }, + ); + + // No transaction of its own - the caller's is the one that commits. + expect(state.entered).toBe(false); + }); +}); + +describe("a content type without localization", () => { + it("has no localized service to call", () => { + const posts = createContentModel(testPostContentType, { + references: { category: () => posts.table.id }, + }); + + expect(posts.localizedService).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/content/server/localized-service.ts b/packages/vitnode/src/content/server/localized-service.ts new file mode 100644 index 000000000..b82572b2f --- /dev/null +++ b/packages/vitnode/src/content/server/localized-service.ts @@ -0,0 +1,123 @@ +import type { Context } from "hono"; + +import type { + AnyContentTypeDefinition, + ContentLocalizedValues, + ContentSelect, + ContentSharedValues, + ContentTranslationRow, +} from "../types"; +import type { ContentDatabase, ContentService } from "./service"; +import type { ContentTranslationModel } from "./translation-model"; + +import { ContentEngineError } from "../errors"; + +export interface ContentLocalizedCreateInput { + /** Values for the base table. */ + shared: ContentSharedValues; + /** Values for the default-locale translation created alongside it. */ + translation: ContentLocalizedValues; +} + +export interface ContentLocalizedCreateOptions { + /** + * The locale the first translation is written in. Defaults to - and, today, + * may only be - the content type's configured default locale. + */ + locale?: string; + /** Join an existing transaction instead of opening one. */ + tx?: ContentDatabase; +} + +export interface ContentLocalizedCreateResult { + row: ContentSelect; + translation: ContentTranslationRow; +} + +export interface ContentLocalizedService { + /** + * Creates a base row and its default-locale translation, atomically. + * + * Either both exist or neither does. That is the invariant every later stage + * leans on: a record always resolves in at least one language, so a locale tab + * strip always has something to show, a public read always has something to + * fall back to, and there is no such thing as an "empty" record whose title + * exists in no language at all. + */ + create: ( + input: ContentLocalizedCreateInput, + options?: ContentLocalizedCreateOptions, + ) => Promise>; +} + +/** + * The one write that spans both tables. + * + * Everything else about localization is either base-only (the plain service) or + * translation-only (the translation model). Create is the exception, and it is + * the reason this file exists rather than a third method on one of them. + */ +export const createContentLocalizedService = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + definition, + service, + translations, +}: { + c: Context; + definition: TDefinition; + service: ContentService; + translations: ContentTranslationModel; +}): ContentLocalizedService => { + const contentTypeId = definition.id; + + if (!definition.localization.enabled) { + throw new ContentEngineError( + "The localized service needs `localization: { enabled: true, defaultLocale }` on the content type.", + { contentTypeId }, + ); + } + + const { defaultLocale } = definition.localization; + + return { + create: async ({ shared, translation }, options = {}) => { + const locale = options.locale ?? defaultLocale; + + // A record always starts in its default language. Creating it straight + // into Polish would leave the default translation missing - the one thing + // the invariant above promises is always there - and every later stage + // would need a "unless it was created in another locale" branch. + if (locale.toLowerCase() !== defaultLocale.toLowerCase()) { + throw new ContentEngineError( + `A ${definition.admin.label.singular} is created in its default locale "${defaultLocale}", not "${locale}". Create it first, then add the "${locale}" translation.`, + { contentTypeId }, + ); + } + + const run = async ( + tx: ContentDatabase, + ): Promise> => { + // Resolved inside the transaction, so a default language that has just + // been removed rolls the base insert back with it rather than leaving an + // untranslatable row behind. + const language = await translations.resolveDefaultLanguage({ tx }); + + const row = await service.create(shared, { tx }); + const created = await translations.create( + row.id, + language.locale, + translation, + { tx }, + ); + + return { row, translation: created }; + }; + + if (options.tx) return await run(options.tx); + + return await c.get("db").transaction(async tx => await run(tx)); + }, + }; +}; diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index 04d8efc3a..418620875 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -1,21 +1,35 @@ import type { PgColumn } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import type { ContentSchemas } from "../schemas"; -import type { AnyContentTypeDefinition } from "../types"; +import type { ContentSchemas, ContentTranslationSchemas } from "../schemas"; +import type { + AnyContentTypeDefinition, + ResolvedContentLocalizationConfig, +} from "../types"; import type { ContentEditorialService } from "./editorial-service"; +import type { ContentLocalizedService } from "./localized-service"; import type { ContentPublicService } from "./public-service"; import type { ContentService } from "./service"; +import type { ContentTranslationModel } from "./translation-model"; import type { ContentColumnName, ContentReferences, ContentTableFor, + ContentTranslationColumnName, + ContentTranslationTableFor, } from "./types"; +import { ContentEngineError } from "../errors"; import { createContentEditorialService } from "./editorial-service"; +import { createContentLocalizedService } from "./localized-service"; import { createContentPublicService } from "./public-service"; import { createContentService } from "./service"; import { contentTableColumns, createContentTable } from "./table"; +import { createContentTranslationModel } from "./translation-model"; +import { + contentTranslationTableColumns, + createContentTranslationTable, +} from "./translation-table"; export interface ContentModel { /** Column name -> Drizzle column, for filters, ordering and custom queries. */ @@ -35,6 +49,25 @@ export interface ContentModel { options: { pluginId: string }, ) => ContentEditorialService) | undefined; + /** + * The resolved localization config, mirrored off the definition. + * + * Present on every model, so `model.localization.enabled` is the one flag route + * builders and background work branch on - without reaching through + * `definition` for it. + */ + localization: ResolvedContentLocalizationConfig; + /** + * Creates a base row and its default translation in one transaction, or + * `undefined` when the content type is not localized. + * + * `undefined` rather than a throwing stub, matching `publicService` and + * `editorialService`: the 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. + */ + localizedService: + ((c: Context) => ContentLocalizedService) | undefined; /** * The read-only public repository, or `undefined` when the content type has * no `publicApi`. @@ -50,6 +83,33 @@ export interface ContentModel { service: (c: Context) => ContentService; /** The generated `pgTable`. Export it so Drizzle Kit can find it. */ table: ContentTableFor; + /** + * Column name -> Drizzle column on the translation table, or `null` when the + * content type is not localized. + */ + translationColumns: null | Record< + ContentTranslationColumnName, + PgColumn + >; + /** The per-language schemas, or `null`. Mirrored off `schemas.translation`. */ + translationSchemas: ContentTranslationSchemas | null; + /** + * The translation repository, or `undefined` when the content type is not + * localized. Emits nothing and invalidates nothing - see + * {@link ContentTranslationModel}. + */ + translationService: + ((c: Context) => ContentTranslationModel) | undefined; + /** + * The generated translation `pgTable`, or `null`. Export it alongside `table` + * so Drizzle Kit finds it and the migration is generated: + * + * ```ts + * export const example_articles = articleContent.table; + * export const example_articles_translations = articleContent.translationTable; + * ``` + */ + translationTable: ContentTranslationTableFor | null; } /** @@ -114,6 +174,42 @@ export const createContentModel = < // anything new. const schemas: ContentSchemas = definition.schemas; + const localized = definition.localization.enabled; + const translationTable = localized + ? createContentTranslationTable(definition, { table }) + : null; + const translationColumns = translationTable + ? contentTranslationTableColumns(definition, translationTable) + : null; + const translationSchemas = schemas.translation; + + /** + * One translation model per call, bound to the request's handle. + * + * Built here rather than inside each service so `localizedService` and + * `translationService` share the same instance for one request - and so the + * "localization is enabled" narrowing happens exactly once. + */ + const buildTranslations = ( + c: Context, + ): ContentTranslationModel => { + if (!translationTable || !translationColumns || !translationSchemas) { + throw new ContentEngineError( + "This content type has no `localization` block, so it has no translations.", + { contentTypeId: definition.id }, + ); + } + + return createContentTranslationModel({ + c, + columns: translationColumns, + definition, + schemas: translationSchemas, + table, + translationTable, + }); + }; + return { columns, definition, @@ -133,6 +229,22 @@ export const createContentModel = < table, }) : undefined, + localization: definition.localization, + localizedService: localized + ? (c: Context) => + createContentLocalizedService({ + c, + definition, + service: createContentService({ + c, + columns, + definition, + schemas, + table, + }), + translations: buildTranslations(c), + }) + : undefined, publicService: definition.publicApi.enabled ? (c: Context) => createContentPublicService({ c, columns, definition, table }) @@ -147,5 +259,9 @@ export const createContentModel = < table, }), table, + translationColumns, + translationSchemas, + translationService: localized ? buildTranslations : undefined, + translationTable, }; }; diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 9359227f6..627a52f83 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -6,6 +6,7 @@ import { HTTPException } from "hono/http-exception"; import type { AnyContentTypeDefinition, ContentFilterInput, + ContentOrderableFieldName, ContentReferenceFieldName, } from "../types"; import type { ContentModel } from "./model"; @@ -40,6 +41,7 @@ import { createContentPreviewToken } from "./preview-token"; import { publicationMethods } from "./publication"; import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; import { syncContentSearch } from "./search-sync"; +import { buildContentTranslationRoutes } from "./translation-routes"; const zodLabels = z.record(z.string(), z.string().nullable()); @@ -259,7 +261,14 @@ export const buildContentRoutes = < const data = await model.service(c).findMany({ filters, - orderBy: { column: orderBy, order }, + // `orderBy` came out of a `z.enum(orderableColumns(definition))`, which + // is the same allowlist `ContentOrderableFieldName` approximates - but + // that type is still deferred here, because `TDefinition` is open. The + // service re-checks the name against the runtime allowlist regardless. + orderBy: { + column: orderBy as ContentOrderableFieldName, + order, + }, query: { cursor, first, last, search }, }); @@ -1154,5 +1163,10 @@ export const buildContentRoutes = < ...(definition.editorial.scheduling.enabled ? [scheduleList, scheduleCreate, scheduleCancel] : []), + // Mounted under the same module and the same permissions, so a localized + // content type gets its translation routes without a second registration. + ...(definition.localization.enabled + ? buildContentTranslationRoutes(model, { pluginId }) + : []), ]; }; diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 7e1b15083..d802f0d37 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -12,11 +12,11 @@ import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, ContentCreateInput, - ContentFieldName, ContentFilterInput, ContentOrderableFieldName, ContentReferenceFieldName, ContentSelect, + ContentSharedFieldName, ContentUpdateInput, } from "../types"; @@ -28,6 +28,7 @@ import { CONTENT_PUBLICATION_FIELDS, } from "../const"; import { ContentEngineError } from "../errors"; +import { partitionContentFields } from "../localization"; import { orderableColumns } from "../registry"; import { buildFilterCondition, @@ -83,7 +84,7 @@ export interface ContentServiceOptions { } export interface ContentUpdateResult { - changedFields: ContentFieldName[]; + changedFields: ContentSharedFieldName[]; row: ContentSelect; } @@ -184,7 +185,10 @@ export const createContentService = < schemas: ContentSchemas; table: PgTableWithColumns; }): ContentService => { - const fields = definition.fields; + // Shared only, everywhere in this file: this service reads and writes the base + // table, and a localized field is not a column on it. The translation model + // owns the other half. + const fields = partitionContentFields(definition.fields).sharedFields; const contentTypeId = definition.id; // `buildSystemColumns` always makes `id` a `serial`, which is what // `withPagination` needs to type its cursor. @@ -192,10 +196,13 @@ export const createContentService = < ColumnBaseConfig<"number", string> >; const orderable = orderableColumns(definition); - // `Object.keys` erases the key union that `ContentFieldName` recovers. The - // object is the very field map that type is derived from, so this restates - // what TypeScript already knows rather than asserting anything new. - const fieldNames = Object.keys(fields) as ContentFieldName[]; + // `Object.keys` erases the key union that `ContentSharedFieldName` recovers. + // The object is the shared half of the very field map that type is derived + // from, so this restates what TypeScript already knows rather than asserting + // anything new. + const fieldNames = Object.keys( + fields, + ) as ContentSharedFieldName[]; const publication = definition.publication.enabled; const ownColumnNames = [ "id", diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 5681f093b..e28672a3a 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -27,6 +27,7 @@ import type { import { core_users } from "../../database/users"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const"; import { ContentEngineError } from "../errors"; +import { partitionContentFields } from "../localization"; import { buildContentColumn, buildEditorialColumns, @@ -142,7 +143,9 @@ export const createContentTable = < } const { id: contentTypeId, indexes, tableName } = definition; - const fields = definition.fields; + // Shared only: a localized field's column lives on the generated translation + // table, and `createContentTranslationTable` puts it there. + const fields = partitionContentFields(definition.fields).sharedFields; const referenceThunks = references as Record; const columns: Record = { @@ -222,7 +225,7 @@ export const contentTableColumns = < "updatedAt", ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), - ...Object.keys(definition.fields), + ...Object.keys(partitionContentFields(definition.fields).sharedFields), ]; return Object.fromEntries(names.map(name => [name, source[name]])) as Record< diff --git a/packages/vitnode/src/content/server/translation-http-errors.ts b/packages/vitnode/src/content/server/translation-http-errors.ts new file mode 100644 index 000000000..387d63326 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-http-errors.ts @@ -0,0 +1,133 @@ +import { HTTPException } from "hono/http-exception"; +import { ZodError } from "zod"; + +import type { ContentTranslationConflict } from "../conflicts"; + +import { CONTENT_TRANSLATION_CONFLICT_CODES } from "../const"; +import { + ContentDefaultTranslationRequired, + ContentInputError, + ContentLanguageError, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, +} from "../errors"; +import { rethrowAsHttpError } from "./http-errors"; + +/** A structured 409, in the translation union. */ +export const contentTranslationConflict = ( + body: ContentTranslationConflict, +): HTTPException => + new HTTPException(409, { res: Response.json(body, { status: 409 }) }); + +/** + * Maps a translation write's failures onto HTTP. + * + * The five outcomes it separates are the whole point - a client that cannot tell + * them apart can only show "something went wrong": + * + * | Failure | Status | Code | + * | ------------------------------ | ------ | -------------------------------------- | + * | base record missing | 404 | - | + * | locale unknown | 404 | - | + * | locale disabled | 409 | `CONTENT_LANGUAGE_DISABLED` | + * | translation already exists | 409 | `CONTENT_TRANSLATION_EXISTS` | + * | version moved | 409 | `CONTENT_TRANSLATION_VERSION_CONFLICT` | + * | default translation delete | 409 | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | + * | localized slug taken | 409 | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | + * + * Anything it does not recognise falls through to {@link rethrowAsHttpError}, + * which owns the Postgres constraint codes - so the driver's message, which can + * name columns, constraints and values, never reaches a client from here either. + */ +export const withTranslationHttpErrors = async ( + action: "create" | "delete" | "update", + run: () => Promise, + { + contentTypeId, + itemId, + locale, + }: { contentTypeId: string; itemId: number; locale: string }, +): Promise => { + try { + return await run(); + } catch (error) { + if (error instanceof HTTPException) throw error; + + if (error instanceof ContentTranslationVersionConflict) { + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.version, + contentTypeId, + currentVersion: error.currentVersion, + expectedVersion: error.expectedVersion, + itemId: error.itemId, + locale: error.locale, + }); + } + + if (error instanceof ContentDefaultTranslationRequired) { + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.defaultRequired, + contentTypeId, + itemId: error.itemId, + locale: error.locale, + }); + } + + if (error instanceof ContentTranslationExists) { + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.exists, + contentTypeId, + itemId: error.itemId, + locale: error.locale, + }); + } + + if (error instanceof ContentLanguageError) { + // Missing is a 404 and disabled is a 409: one is "no such thing to + // address", the other is "it exists and this install has switched it off", + // and only the second is something an admin can undo. + if (error.reason === "missing") { + throw new HTTPException(404, { message: error.message }); + } + + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.languageDisabled, + contentTypeId, + locale: error.locale, + }); + } + + if (error instanceof ContentTranslationItemMissing) { + throw new HTTPException(404, { message: error.message }); + } + + // Written for the client on purpose, like the base service's: "send the slug + // explicitly" is useless if it never leaves the server. + if (error instanceof ContentInputError) { + throw new HTTPException(400, { message: error.message }); + } + + if (error instanceof ZodError) { + throw new HTTPException(400, { message: "Invalid input data." }); + } + + try { + return rethrowAsHttpError(error, { action, contentTypeId, itemId }); + } catch (mapped) { + // A localized unique clash is a slug that is taken *in this language*, so + // it answers in the translation union with the locale attached rather than + // with the base 409 the shared mapper produces. + if (mapped instanceof HTTPException && mapped.status === 409) { + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.unique, + contentTypeId, + itemId, + locale, + }); + } + + throw mapped; + } + } +}; diff --git a/packages/vitnode/src/content/server/translation-model.test.ts b/packages/vitnode/src/content/server/translation-model.test.ts new file mode 100644 index 000000000..40636f62d --- /dev/null +++ b/packages/vitnode/src/content/server/translation-model.test.ts @@ -0,0 +1,564 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; + +import { testLocalizedArticleContentType } from "@/tests/content-fixtures"; + +import { + ContentDefaultTranslationRequired, + ContentInputError, + ContentLanguageError, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, +} from "../errors"; +import { createContentModel } from "./model"; + +const localized = createContentModel(testLocalizedArticleContentType); + +const LANGUAGES = [ + { code: "en", id: 1, isDefault: true }, + { code: "pl", id: 2, isDefault: false }, +]; + +interface RecordedCall { + arg: unknown; + op: string; +} + +/** + * A chainable stand-in for the Drizzle client, in the same shape + * `service.test.ts` uses. + * + * The first `select().from()` of a request is the language registry, so it is + * answered from `LANGUAGES` rather than from the queue - which keeps every test + * below queueing only the rows it actually cares about. + */ +const createDbMock = (results: unknown[][], languages = LANGUAGES) => { + const calls: RecordedCall[] = []; + const queue = [...results]; + + const chain = (rows: unknown[]) => { + const record = (op: string, arg: unknown) => { + calls.push({ arg, op }); + + return builder; + }; + + const builder = { + $dynamic: () => builder, + // Always the builder, which is itself thenable: `await select().from()` + // (how the language registry is read) resolves through `then` below, and a + // longer chain keeps building. + from: (value: unknown) => record("from", value), + leftJoin: (value: unknown) => record("leftJoin", value), + limit: (value: unknown) => record("limit", value), + onConflictDoNothing: (value: unknown) => + record("onConflictDoNothing", value), + orderBy: (value: unknown) => record("orderBy", value), + returning: (value: unknown) => record("returning", value), + set: (value: unknown) => record("set", value), + then: async (resolve: (rows: unknown[]) => TResult) => + Promise.resolve(rows).then(resolve), + values: (value: unknown) => record("values", value), + where: (value: unknown) => record("where", value), + }; + + return builder; + }; + + const start = (op: string) => (arg: unknown) => { + calls.push({ arg, op }); + // The language registry is the one `select` whose projection names `code` + // and `isDefault`, so it is answered from `languages` rather than from the + // queue - which keeps every test queueing only the rows it cares about. + const isLanguageSelect = + op === "select" && + typeof arg === "object" && + arg !== null && + "code" in arg && + "isDefault" in arg; + + return chain(isLanguageSelect ? languages : (queue.shift() ?? [])); + }; + + const db = { + delete: start("delete"), + insert: start("insert"), + select: start("select"), + transaction: async ( + body: (tx: unknown) => Promise, + ): Promise => await body(db), + update: start("update"), + }; + + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as unknown as Context; + + return { c, calls }; +}; + +const opsOf = (calls: RecordedCall[], op: string) => + calls.filter(call => call.op === op).map(call => call.arg); + +const translationRow = (overrides: Record = {}) => ({ + body: "Body", + createdAt: new Date("2026-01-01T00:00:00Z"), + itemId: 7, + languageId: 1, + slug: "hello", + title: "Hello", + updatedAt: new Date("2026-01-01T00:00:00Z"), + version: 1, + ...overrides, +}); + +/** + * The translation model, narrowed once. + * + * `translationService` is `undefined` without localization, so TypeScript refuses + * the call until the check has been made. + */ +const translations = (c: Context) => { + const build = localized.translationService; + if (!build) throw new Error("Expected a translation service."); + + return build(c); +}; + +describe("create", () => { + it("writes the resolved language id and starts at version 1", async () => { + const { c, calls } = createDbMock([ + [{ id: 7 }], + [translationRow({ version: 1 })], + ]); + + const row = await translations(c).create(7, "en", { title: "Hello" }); + + expect(row.version).toBe(1); + expect(row.locale).toBe("en"); + expect(row.itemId).toBe(7); + // `version` is never written: the column default is what makes it 1, and the + // conditional UPDATE is the only thing that ever moves it. + expect(opsOf(calls, "values")[0]).toEqual({ + itemId: 7, + languageId: 1, + slug: "hello", + title: "Hello", + }); + }); + + it("nests the localized values under `values`", async () => { + const { c } = createDbMock([[{ id: 7 }], [translationRow()]]); + + const row = await translations(c).create(7, "en", { title: "Hello" }); + + expect(row.values).toEqual({ + body: "Body", + slug: "hello", + title: "Hello", + }); + // Metadata sits beside the values, never inside them. + expect(row.values).not.toHaveProperty("version"); + }); + + it("derives a localized slug from the localized title", async () => { + const { c, calls } = createDbMock([[{ id: 7 }], [translationRow()]]); + + await translations(c).create(7, "pl", { title: "Witaj Świecie" }); + + expect(opsOf(calls, "values")[0]).toMatchObject({ + languageId: 2, + slug: "witaj-swiecie", + }); + }); + + it("normalises a supplied slug rather than trusting it", async () => { + const { c, calls } = createDbMock([[{ id: 7 }], [translationRow()]]); + + await translations(c).create(7, "en", { + slug: "Hello World!", + title: "Hello", + }); + + expect(opsOf(calls, "values")[0]).toMatchObject({ slug: "hello-world" }); + }); + + it("refuses a slug that normalises to nothing", async () => { + const { c } = createDbMock([[{ id: 7 }]]); + + await expect( + translations(c).create(7, "en", { title: "日本語のタイトル" }), + ).rejects.toBeInstanceOf(ContentInputError); + }); + + it("validates the payload before writing", async () => { + const { c } = createDbMock([[{ id: 7 }]]); + + await expect( + translations(c).create(7, "en", { title: "no" }), + ).rejects.toBeInstanceOf(ZodError); + }); + + it("refuses a translation for a record that is not there", async () => { + const { c } = createDbMock([[]]); + + await expect( + translations(c).create(99, "en", { title: "Hello" }), + ).rejects.toBeInstanceOf(ContentTranslationItemMissing); + }); + + it("refuses an unknown locale", async () => { + const { c } = createDbMock([]); + + await expect( + translations(c).create(7, "de", { title: "Hello" }), + ).rejects.toMatchObject({ reason: "missing" }); + }); + + it("reports a locale that already has a translation", async () => { + const { c } = createDbMock([[{ id: 7 }], []]); + + await expect( + translations(c).create(7, "en", { title: "Hello" }), + ).rejects.toBeInstanceOf(ContentTranslationExists); + }); + + it("targets the primary key so a slug clash still surfaces", async () => { + const { c, calls } = createDbMock([[{ id: 7 }], [translationRow()]]); + + await translations(c).create(7, "en", { title: "Hello" }); + + // An untargeted `onConflictDoNothing()` would swallow the unique slug index + // too, and report "this locale exists" for a URL that is taken. + expect(opsOf(calls, "onConflictDoNothing")).toHaveLength(1); + }); +}); + +describe("update", () => { + it("guards on the expected version and increments it", async () => { + const { c, calls } = createDbMock([ + [translationRow({ version: 3 })], + [translationRow({ title: "New", version: 4 })], + ]); + + const result = await translations(c).update( + 7, + "en", + { title: "New" }, + { expectedVersion: 3 }, + ); + + expect(result).toMatchObject({ changed: true, version: 4 }); + expect(result?.changedFields).toEqual(["title"]); + const [values] = opsOf(calls, "set"); + expect(values).toHaveProperty("title", "New"); + expect(values).toHaveProperty("version"); + }); + + it("writes only the fields that actually changed", async () => { + const { c, calls } = createDbMock([ + [translationRow({ version: 1 })], + [translationRow({ title: "New", version: 2 })], + ]); + + await translations(c).update( + 7, + "en", + { body: "Body", title: "New" }, + { expectedVersion: 1 }, + ); + + // `body` was already "Body", so it is not part of the write. + expect(Object.keys(opsOf(calls, "set")[0] as object).sort()).toEqual([ + "title", + "version", + ]); + }); + + it("does nothing at all for a no-op", async () => { + const { c, calls } = createDbMock([[translationRow({ version: 5 })]]); + + const result = await translations(c).update( + 7, + "en", + { title: "Hello" }, + { expectedVersion: 5 }, + ); + + expect(result).toMatchObject({ changed: false, version: 5 }); + expect(result?.changedFields).toEqual([]); + // No UPDATE, so no version bump and no `updatedAt` move: an editor who + // pressed save twice has not created two versions of anything. + expect(opsOf(calls, "update")).toEqual([]); + }); + + it("treats a re-sent slug in a different case as a no-op", async () => { + const { c, calls } = createDbMock([[translationRow({ version: 2 })]]); + + const result = await translations(c).update( + 7, + "en", + { slug: "HELLO" }, + { expectedVersion: 2 }, + ); + + expect(result?.changed).toBe(false); + expect(opsOf(calls, "update")).toEqual([]); + }); + + it("does not check the version on a no-op", async () => { + const { c } = createDbMock([[translationRow({ version: 9 })]]); + + // There is nothing to overwrite, so there is nothing to conflict about. + const result = await translations(c).update( + 7, + "en", + { title: "Hello" }, + { expectedVersion: 2 }, + ); + + expect(result?.changed).toBe(false); + }); + + it("throws a version conflict when the row moved", async () => { + const { c } = createDbMock([ + [translationRow({ version: 2 })], + [], + [translationRow({ version: 4 })], + ]); + + await expect( + translations(c).update(7, "en", { title: "New" }, { expectedVersion: 2 }), + ).rejects.toMatchObject({ + currentVersion: 4, + expectedVersion: 2, + itemId: 7, + locale: "en", + }); + }); + + it("names the locale in the conflict, never the other language", async () => { + const { c } = createDbMock([ + [translationRow({ languageId: 2, version: 2 })], + [], + [translationRow({ languageId: 2, version: 3 })], + ]); + + const error = await translations(c) + .update(7, "pl", { title: "Nowy" }, { expectedVersion: 2 }) + .catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(ContentTranslationVersionConflict); + expect(error).toMatchObject({ locale: "pl" }); + }); + + it("returns null when the translation is missing", async () => { + const { c } = createDbMock([[]]); + + expect( + await translations(c).update( + 7, + "en", + { title: "New" }, + { expectedVersion: 1 }, + ), + ).toBeNull(); + }); + + it("refuses to write into a disabled language", async () => { + const { c } = createDbMock([]); + const context = { + get: (key: string) => + key === "core" + ? { i18n: { locales: [{ code: "pl", enabled: false, name: "PL" }] } } + : c.get(key), + } as unknown as Context; + + await expect( + translations(context).update( + 7, + "pl", + { title: "Nowy" }, + { expectedVersion: 1 }, + ), + ).rejects.toMatchObject({ reason: "disabled" }); + }); +}); + +describe("delete", () => { + it("guards on the expected version", async () => { + const { c } = createDbMock([ + [translationRow({ languageId: 2, version: 3 })], + ]); + + const row = await translations(c).delete(7, "pl", { expectedVersion: 3 }); + + expect(row).toMatchObject({ itemId: 7, locale: "pl", version: 3 }); + }); + + it("throws a version conflict when the row moved", async () => { + const { c } = createDbMock([ + [], + [translationRow({ languageId: 2, version: 5 })], + ]); + + await expect( + translations(c).delete(7, "pl", { expectedVersion: 3 }), + ).rejects.toMatchObject({ currentVersion: 5, expectedVersion: 3 }); + }); + + it("returns null when the translation is already gone", async () => { + const { c } = createDbMock([[], []]); + + // The caller wanted it removed, and it is. + expect( + await translations(c).delete(7, "pl", { expectedVersion: 3 }), + ).toBeNull(); + }); + + it("refuses to delete the default translation", async () => { + const { c, calls } = createDbMock([]); + + await expect( + translations(c).delete(7, "en", { expectedVersion: 1 }), + ).rejects.toBeInstanceOf(ContentDefaultTranslationRequired); + // Refused before anything is issued: the invariant atomic create establishes + // is not something a DELETE gets to test. + expect(opsOf(calls, "delete")).toEqual([]); + }); + + it("refuses the default translation whatever the casing", async () => { + const { c } = createDbMock([]); + + await expect( + translations(c).delete(7, "EN", { expectedVersion: 1 }), + ).rejects.toBeInstanceOf(ContentDefaultTranslationRequired); + }); + + it("allows deleting a translation in a disabled language", async () => { + const { c } = createDbMock([ + [translationRow({ languageId: 2, version: 1 })], + ]); + const context = { + get: (key: string) => + key === "core" + ? { i18n: { locales: [{ code: "pl", enabled: false, name: "PL" }] } } + : c.get(key), + } as unknown as Context; + + // Removing content in a language the install has switched off is exactly + // what somebody would want to do next. + expect( + await translations(context).delete(7, "pl", { expectedVersion: 1 }), + ).toMatchObject({ locale: "pl" }); + }); +}); + +describe("reads", () => { + it("finds one translation by locale", async () => { + const { c } = createDbMock([[translationRow()]]); + + expect(await translations(c).findByLocale(7, "EN")).toMatchObject({ + // The canonical locale, not the caller's casing. + locale: "en", + version: 1, + }); + }); + + it("finds one translation by language id", async () => { + const { c } = createDbMock([[translationRow({ languageId: 2 })]]); + + expect(await translations(c).findByLanguageId(7, 2)).toMatchObject({ + languageId: 2, + locale: "pl", + }); + }); + + it("returns null for an unknown locale rather than throwing", async () => { + const { c } = createDbMock([]); + + expect(await translations(c).findByLocale(7, "de")).toBeNull(); + }); + + it("lists metadata without any localized value", async () => { + const { c } = createDbMock([ + [ + { + createdAt: new Date(), + itemId: 7, + languageId: 1, + updatedAt: new Date(), + version: 2, + }, + { + createdAt: new Date(), + itemId: 7, + languageId: 2, + updatedAt: new Date(), + version: 1, + }, + ], + ]); + + const edges = await translations(c).findManyForItem(7); + + expect(edges.map(edge => edge.locale)).toEqual(["en", "pl"]); + expect(edges[0]).not.toHaveProperty("values"); + expect(edges[0]).not.toHaveProperty("title"); + }); + + it("resolves every locale in a list without a query per row", async () => { + const { c, calls } = createDbMock([ + Array.from({ length: 2 }, (_unused, index) => ({ + createdAt: new Date(), + itemId: 7, + languageId: index + 1, + updatedAt: new Date(), + version: 1, + })), + ]); + + await translations(c).findManyForItem(7); + + // One select for the rows, one for the language registry. Never one per + // translation. + expect(opsOf(calls, "select")).toHaveLength(2); + }); + + it("answers `exists` without loading the values", async () => { + const { c, calls } = createDbMock([[{ itemId: 7 }]]); + + expect(await translations(c).exists(7, "en")).toBe(true); + expect(Object.keys(opsOf(calls, "select")[1] as object)).toEqual([ + "itemId", + ]); + }); + + it("answers `exists` false for an unknown locale", async () => { + const { c } = createDbMock([]); + + expect(await translations(c).exists(7, "de")).toBe(false); + }); + + it("resolves the default language", async () => { + const { c } = createDbMock([]); + + expect(await translations(c).resolveDefaultLanguage()).toMatchObject({ + id: 1, + isDefault: true, + locale: "en", + }); + }); + + it("throws when the default language is gone", async () => { + const { c } = createDbMock([], [{ code: "pl", id: 2, isDefault: true }]); + + await expect( + translations(c).resolveDefaultLanguage(), + ).rejects.toBeInstanceOf(ContentLanguageError); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts new file mode 100644 index 000000000..32ae63c03 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -0,0 +1,486 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, asc, eq, sql } from "drizzle-orm"; + +import type { ContentTranslationSchemas } from "../schemas"; +import type { + AnyContentTypeDefinition, + ContentLocalizedFieldName, + ContentLocalizedUpdateValues, + ContentLocalizedValues, + ContentTranslationMeta, + ContentTranslationRow, +} from "../types"; +import type { ContentLanguage } from "./language-resolver"; +import type { ContentDatabase } from "./service"; + +import { CONTENT_TRANSLATION_SYSTEM_FIELDS } from "../const"; +import { + ContentDefaultTranslationRequired, + ContentEngineError, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, +} from "../errors"; +import { partitionContentFields } from "../localization"; +import { + contentDatabase, + findContentLanguage, + listContentLanguages, + resolveContentLanguage, +} from "./language-resolver"; +import { diffChangedFields } from "./query"; +import { createSlugNormalizer } from "./slugs"; + +export interface ContentTranslationOptions { + /** Run inside an existing transaction. */ + tx?: ContentDatabase; +} + +export interface ContentTranslationWriteOptions extends ContentTranslationOptions { + expectedVersion: number; +} + +export interface ContentTranslationUpdateResult { + /** `false` when nothing moved: no write, no version bump, no `updatedAt`. */ + changed: boolean; + changedFields: ContentLocalizedFieldName[]; + row: ContentTranslationRow; + version: number; +} + +/** + * One localized content type's translation repository. + * + * Deliberately low level. It writes translation rows and enforces the rules that + * belong to the data - per-locale versioning, the default-translation invariant, + * slug normalisation - and does **nothing else**: no event, no cache tag, no + * search document, no revision. Stage 5B orchestrates those on top, the same way + * `contentEditorialEffects` does for the base row today. A repository that + * emitted events could not be called inside somebody else's transaction, which is + * exactly what atomic create needs it to be. + */ +export interface ContentTranslationModel { + /** Inserts one translation at version 1. Throws if the locale already has one. */ + create: ( + itemId: number, + locale: string, + values: ContentLocalizedValues, + options?: ContentTranslationOptions, + ) => Promise>; + /** + * Removes one translation, guarded by its version. + * + * `null` when there is no such translation - the caller wanted it gone, and it + * is. Refuses the default locale outright: that translation is created with the + * record and is what makes "a record always resolves in some language" true. + */ + delete: ( + itemId: number, + locale: string, + options: ContentTranslationWriteOptions, + ) => Promise | null>; + exists: ( + itemId: number, + locale: string, + options?: ContentTranslationOptions, + ) => Promise; + findByLanguageId: ( + itemId: number, + languageId: number, + options?: ContentTranslationOptions, + ) => Promise | null>; + findByLocale: ( + itemId: number, + locale: string, + options?: ContentTranslationOptions, + ) => Promise | null>; + /** Metadata for every translation of one record, without the values. */ + findManyForItem: ( + itemId: number, + options?: ContentTranslationOptions, + ) => Promise; + /** The language this content type creates records in. */ + resolveDefaultLanguage: ( + options?: ContentTranslationOptions, + ) => Promise; + /** Conditional `UPDATE` guarded by `expectedVersion`. A no-op writes nothing. */ + update: ( + itemId: number, + locale: string, + values: ContentLocalizedUpdateValues, + options: ContentTranslationWriteOptions, + ) => Promise | null>; +} + +const translationSystemFields: readonly string[] = + CONTENT_TRANSLATION_SYSTEM_FIELDS; + +export const createContentTranslationModel = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + columns, + definition, + schemas, + table, + translationTable, +}: { + c: Context; + /** Columns of the *translation* table, keyed by name. */ + columns: Record; + definition: TDefinition; + schemas: ContentTranslationSchemas; + /** The base table, for the "is there a record to translate" check. */ + table: PgTable; + translationTable: PgTable; +}): ContentTranslationModel => { + const contentTypeId = definition.id; + + if (!definition.localization.enabled) { + throw new ContentEngineError( + "The translation service needs `localization: { enabled: true, defaultLocale }` on the content type.", + { contentTypeId }, + ); + } + + const { localizedFields } = partitionContentFields(definition.fields); + const localizedNames = Object.keys( + localizedFields, + ) as ContentLocalizedFieldName[]; + const { defaultLocale } = definition.localization; + + const itemColumn = columns.itemId; + const languageColumn = columns.languageId; + const versionColumn = columns.version; + const baseId = (table as unknown as Record).id; + + // The same normaliser the base service uses, over the localized half of the + // field map. Two slug algorithms is exactly the pair that drifts, and the + // consequence would be `/en/my-post` and `/pl/my_post`. + const { withCreateSlugs, withUpdateSlugs } = createSlugNormalizer( + contentTypeId, + localizedFields, + ); + + const metaSelection = (): Record => + Object.fromEntries( + translationSystemFields.map(name => [name, columns[name]]), + ); + + const fullSelection = (): Record => ({ + ...metaSelection(), + ...Object.fromEntries(localizedNames.map(name => [name, columns[name]])), + }); + + const db = (options?: ContentTranslationOptions): ContentDatabase => + contentDatabase(c, options?.tx); + + /** + * Resolves a locale, reading the language registry through whatever handle the + * caller is using. + * + * The `tx` is load-bearing: inside `localizedService.create` the transaction + * holds the connection, so a registry query issued on the client would wait for + * a connection that transaction will not release until it has an answer. + */ + const language = async ( + locale: string, + { requireEnabled, tx }: { requireEnabled: boolean; tx?: ContentDatabase }, + ): Promise => + await resolveContentLanguage(c, { + contentTypeId, + locale, + requireEnabled, + tx, + }); + + /** + * Splits a raw row into metadata and `values`. + * + * The nesting is not decoration: it keeps a localized field called `version` or + * `locale` from being confused with the metadata of the row that holds it, and + * it means the update request body (`{ expectedVersion, values }`) and the + * response have the same shape. + */ + const toRow = ( + row: Record, + locale: string, + ): ContentTranslationRow => { + const values: Record = {}; + for (const name of localizedNames) values[name] = row[name]; + + return { + createdAt: row.createdAt as Date, + itemId: row.itemId as number, + languageId: row.languageId as number, + locale, + updatedAt: row.updatedAt as Date, + values: values as ContentLocalizedValues, + version: row.version as number, + }; + }; + + const versionOf = (row: Record): number => + typeof row.version === "number" ? row.version : 1; + + const readOne = async ( + itemId: number, + languageId: number, + database: ContentDatabase, + ): Promise> => { + const [row] = await database + .select(fullSelection()) + .from(translationTable) + .where(and(eq(itemColumn, itemId), eq(languageColumn, languageId))) + .limit(1); + + return row ?? null; + }; + + const assertItemExists = async ( + itemId: number, + database: ContentDatabase, + ): Promise => { + const [row] = await database + .select({ id: baseId }) + .from(table) + .where(eq(baseId, itemId)) + .limit(1); + + if (!row) { + throw new ContentTranslationItemMissing({ contentTypeId, itemId }); + } + }; + + return { + create: async (itemId, locale, values, options) => { + const target = await language(locale, { + requireEnabled: true, + tx: options?.tx, + }); + const database = db(options); + + // Checked here rather than left to the foreign key: `23503` cannot say + // which of the two references failed, and "no such article" and "no such + // language" deserve different answers. + await assertItemExists(itemId, database); + + const parsed = schemas.create.parse(values) as Record; + + const [row] = await database + .insert(translationTable) + .values({ + ...withCreateSlugs(parsed), + itemId, + languageId: target.id, + }) + // Targeted at the primary key only, so "this locale already has a + // translation" comes back as a row this can look up and name, while a + // *slug* clash still raises `23505` and is reported as one. An untargeted + // `onConflictDoNothing()` would swallow both and report the wrong thing. + .onConflictDoNothing({ + target: [itemColumn, languageColumn], + }) + .returning(fullSelection()); + + if (row) return toRow(row, target.locale); + + throw new ContentTranslationExists({ + contentTypeId, + itemId, + locale: target.locale, + }); + }, + + delete: async (itemId, locale, options) => { + // Resolved without `requireEnabled`: removing content in a language the + // install has switched off is exactly what somebody would want to do next. + const target = await language(locale, { + requireEnabled: false, + tx: options.tx, + }); + + if (target.locale.toLowerCase() === defaultLocale.toLowerCase()) { + throw new ContentDefaultTranslationRequired({ + contentTypeId, + itemId, + locale: target.locale, + }); + } + + const database = db(options); + + // The version is part of the statement that removes the row, not something + // checked before it - otherwise two deletes could both pass the check. + const [row] = await database + .delete(translationTable) + .where( + and( + eq(itemColumn, itemId), + eq(languageColumn, target.id), + eq(versionColumn, options.expectedVersion), + ), + ) + .returning(fullSelection()); + + if (row) return toRow(row, target.locale); + + const current = await readOne(itemId, target.id, database); + // Gone already is not a conflict: the caller wanted it removed, and it is. + if (!current) return null; + + throw new ContentTranslationVersionConflict({ + contentTypeId, + currentVersion: versionOf(current), + expectedVersion: options.expectedVersion, + itemId, + locale: target.locale, + }); + }, + + exists: async (itemId, locale, options) => { + const target = await findContentLanguage(c, locale, options?.tx); + if (!target) return false; + + const [row] = await db(options) + .select({ itemId: itemColumn }) + .from(translationTable) + .where(and(eq(itemColumn, itemId), eq(languageColumn, target.id))) + .limit(1); + + return row !== undefined; + }, + + findByLanguageId: async (itemId, languageId, options) => { + const row = await readOne(itemId, languageId, db(options)); + if (!row) return null; + + // The canonical locale comes off the language registry rather than out of + // the row, which only holds the id. + const languages = await listContentLanguagesById(c, options?.tx); + + return toRow(row, languages.get(languageId)?.locale ?? ""); + }, + + findByLocale: async (itemId, locale, options) => { + const target = await findContentLanguage(c, locale, options?.tx); + if (!target) return null; + + const row = await readOne(itemId, target.id, db(options)); + + return row ? toRow(row, target.locale) : null; + }, + + findManyForItem: async (itemId, options) => { + // Metadata only, and no join: the locale is resolved from the + // already-loaded language registry, so listing translations costs one + // query for the rows plus (at most) one for every language in the install - + // never one per translation. + const rows = await db(options) + .select(metaSelection()) + .from(translationTable) + .where(eq(itemColumn, itemId)) + .orderBy(asc(languageColumn)); + + const languages = await listContentLanguagesById(c, options?.tx); + + return rows.map(row => ({ + createdAt: row.createdAt as Date, + itemId: row.itemId as number, + languageId: row.languageId as number, + locale: languages.get(row.languageId as number)?.locale ?? "", + updatedAt: row.updatedAt as Date, + version: row.version as number, + })); + }, + + resolveDefaultLanguage: async options => + await language(defaultLocale, { + requireEnabled: true, + tx: options?.tx, + }), + + update: async (itemId, locale, values, options) => { + const target = await language(locale, { + requireEnabled: true, + tx: options.tx, + }); + const database = db(options); + + // Parsed before the row is read, so an invalid payload never costs a + // query. Slugs are normalised before the diff, so re-sending the stored + // slug in a different case counts as no change rather than a pointless + // write. + const patch = withUpdateSlugs(schemas.update.parse(values)); + + const current = await readOne(itemId, target.id, database); + if (!current) return null; + + const changedFields = diffChangedFields(localizedNames, current, patch); + + // A no-op is a successful write that changed nothing: it must not bump the + // version, must not move `updatedAt`, and must not fail on a stale + // `expectedVersion` - there is nothing to overwrite, so there is nothing + // to conflict about. The same rule the base service already follows. + if (changedFields.length === 0) { + return { + changed: false, + changedFields, + row: toRow(current, target.locale), + version: versionOf(current), + }; + } + + const [row] = await database + .update(translationTable) + .set({ + ...Object.fromEntries(changedFields.map(key => [key, patch[key]])), + version: sql`${versionColumn} + 1`, + }) + .where( + and( + eq(itemColumn, itemId), + eq(languageColumn, target.id), + eq(versionColumn, options.expectedVersion), + ), + ) + .returning(fullSelection()); + + if (!row) { + // The row was there a moment ago, so this is a lost race rather than a + // missing translation: another writer moved the version in between. + const latest = await readOne(itemId, target.id, database); + if (!latest) return null; + + throw new ContentTranslationVersionConflict({ + contentTypeId, + currentVersion: versionOf(latest), + expectedVersion: options.expectedVersion, + itemId, + locale: target.locale, + }); + } + + return { + changed: true, + changedFields, + row: toRow(row, target.locale), + version: versionOf(row), + }; + }, + }; +}; + +/** The language registry keyed by id, for turning a stored FK back into a locale. */ +const listContentLanguagesById = async ( + c: Context, + tx?: ContentDatabase, +): Promise> => + new Map( + (await listContentLanguages(c, tx)).map(language => [ + language.id, + language, + ]), + ); diff --git a/packages/vitnode/src/content/server/translation-routes.test.ts b/packages/vitnode/src/content/server/translation-routes.test.ts new file mode 100644 index 000000000..64809d3ea --- /dev/null +++ b/packages/vitnode/src/content/server/translation-routes.test.ts @@ -0,0 +1,577 @@ +// @vitest-environment node +import type { MiddlewareHandler } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + testCategoryContentType, + testLocalizedArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { + ContentDefaultTranslationRequired, + ContentLanguageError, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, +} from "../errors"; +import { createContentModel } from "./model"; +import { buildContentRoutes } from "./routes"; +import { buildContentTranslationRoutes } from "./translation-routes"; + +let permissionGranted = true; +const permissionChecks: { module: string; permission: string }[] = []; + +// `assertStaffPermission` reads roles out of the database. The routes' job is to +// *call* it with the right module and permission, so the check itself is replaced +// with a switchable verdict that records what it was asked. +vi.mock("../../api/lib/check-staff-permission", () => ({ + assertStaffPermission: async ( + _c: unknown, + args: { module: string; permission: string }, + ) => { + permissionChecks.push({ + module: args.module, + permission: args.permission, + }); + if (!permissionGranted) { + const { HTTPException } = await import("hono/http-exception"); + throw new HTTPException(403, { message: "Forbidden" }); + } + }, +})); + +const localized = createContentModel(testLocalizedArticleContentType); +const categories = createContentModel(testCategoryContentType); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); +const PLUGIN_ID = "@vitnode/example"; + +const adminUser = { + avatarColor: "000000", + birthday: null, + createdAt: new Date(), + email: "test@test.com", + emailVerified: true, + id: 1, + language: "en", + name: "Test", + nameCode: "test", + newsletter: false, + roleId: 1, +}; + +const translationRow = (overrides: Record = {}) => ({ + createdAt: new Date("2026-01-01T00:00:00Z"), + itemId: 7, + languageId: 1, + locale: "en", + updatedAt: new Date("2026-01-01T00:00:00Z"), + values: { body: null, slug: "hello", title: "Hello" }, + version: 1, + ...overrides, +}); + +interface Harness { + app: OpenAPIHono; + translations: Record>; +} + +const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { + const translations = { + create: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + findByLanguageId: vi.fn(), + findByLocale: vi.fn(), + findManyForItem: vi.fn(), + resolveDefaultLanguage: vi.fn(), + update: vi.fn(), + }; + + permissionGranted = allow; + permissionChecks.length = 0; + vi.spyOn(localized, "translationService", "get").mockReturnValue( + () => translations, + ); + + const app = new OpenAPIHono(); + + const context: MiddlewareHandler = async (c, next) => { + c.set("admin", allow ? { user: adminUser } : null); + await next(); + }; + app.use("*", context); + + for (const { handler, route } of buildContentTranslationRoutes(localized, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, translations }; +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("route registration", () => { + it("appends the five translation routes to a localized content type", () => { + const paths = buildContentRoutes(localized, { pluginId: PLUGIN_ID }).map( + entry => `${entry.route.method.toUpperCase()} ${entry.route.path}`, + ); + + expect(paths).toEqual( + expect.arrayContaining([ + "GET /{id}/translations", + "GET /{id}/translations/{locale}", + "POST /{id}/translations/{locale}", + "PUT /{id}/translations/{locale}", + "DELETE /{id}/translations/{locale}", + ]), + ); + }); + + it("generates none of them for a content type without localization", () => { + const paths = buildContentRoutes(posts, { pluginId: PLUGIN_ID }).map( + entry => entry.route.path, + ); + + expect(paths.some(path => path.includes("translations"))).toBe(false); + }); + + it("refuses to build them for a content type without localization", () => { + expect(() => + buildContentTranslationRoutes(posts, { pluginId: PLUGIN_ID }), + ).toThrow(/needs a localized content type/); + }); +}); + +describe("GET /{id}/translations", () => { + it("returns metadata for every locale", async () => { + const { app, translations } = harness(); + translations.findManyForItem.mockResolvedValue([ + { ...translationRow(), values: undefined }, + { ...translationRow({ languageId: 2, locale: "pl", version: 3 }) }, + ]); + + const response = await app.request("/7/translations"); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + edges: { locale: string; version: number }[]; + }; + expect(body.edges.map(edge => edge.locale)).toEqual(["en", "pl"]); + // The list schema has no `values`, so a body cannot leak into it even when + // the service hands one over. + expect(body.edges[0]).not.toHaveProperty("values"); + }); + + it("needs `can_view`", async () => { + const { app, translations } = harness(); + translations.findManyForItem.mockResolvedValue([]); + + await app.request("/7/translations"); + + expect(permissionChecks).toEqual([ + { module: "test_localized", permission: "can_view" }, + ]); + }); + + it("is 403 without the permission", async () => { + const { app } = harness({ allow: false }); + + expect((await app.request("/7/translations")).status).toBe(403); + }); + + it("rejects a non-numeric identifier", async () => { + const { app } = harness(); + + expect((await app.request("/abc/translations")).status).toBe(400); + }); +}); + +describe("GET /{id}/translations/{locale}", () => { + it("returns one translation with its values", async () => { + const { app, translations } = harness(); + translations.findByLocale.mockResolvedValue(translationRow()); + + const response = await app.request("/7/translations/en"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + itemId: 7, + languageId: 1, + locale: "en", + values: { slug: "hello", title: "Hello" }, + version: 1, + }); + }); + + it("is 404 when there is no translation in that locale", async () => { + const { app, translations } = harness(); + translations.findByLocale.mockResolvedValue(null); + + expect((await app.request("/7/translations/de")).status).toBe(404); + }); + + it("passes the locale through untouched, casing included", async () => { + const { app, translations } = harness(); + translations.findByLocale.mockResolvedValue(translationRow()); + + await app.request("/7/translations/PL"); + + // The resolver owns normalisation; the route does not pre-empt it. + expect(translations.findByLocale).toHaveBeenCalledWith(7, "PL"); + }); + + it("rejects a locale wider than core_languages.code", async () => { + const { app } = harness(); + + expect( + (await app.request(`/7/translations/${"x".repeat(33)}`)).status, + ).toBe(400); + }); +}); + +describe("POST /{id}/translations/{locale}", () => { + const post = async (app: OpenAPIHono, body: unknown, locale = "pl") => + app.request(`/7/translations/${locale}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + it("creates a translation and answers 201", async () => { + const { app, translations } = harness(); + translations.create.mockResolvedValue( + translationRow({ languageId: 2, locale: "pl" }), + ); + + const response = await post(app, { values: { title: "Witaj" } }); + + expect(response.status).toBe(201); + expect(translations.create).toHaveBeenCalledWith(7, "pl", { + title: "Witaj", + }); + }); + + it("needs `can_edit`", async () => { + const { app, translations } = harness(); + translations.create.mockResolvedValue(translationRow()); + + await post(app, { values: { title: "Witaj" } }); + + expect(permissionChecks).toEqual([ + { module: "test_localized", permission: "can_edit" }, + ]); + }); + + it("rejects values outside the envelope", async () => { + const { app } = harness(); + + // `expectedVersion`, `locale` and `itemId` are transport, so they can never + // be part of a strict `values` object. + expect((await post(app, { title: "Witaj" })).status).toBe(400); + expect( + (await post(app, { values: { itemId: 9, title: "Witaj" } })).status, + ).toBe(400); + }); + + it("answers 404 for a record that is not there", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + new ContentTranslationItemMissing({ + contentTypeId: "test.localized", + itemId: 7, + }), + ); + + expect((await post(app, { values: { title: "Witaj" } })).status).toBe(404); + }); + + it("answers 404 for an unknown locale", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + new ContentLanguageError({ locale: "de", reason: "missing" }), + ); + + expect((await post(app, { values: { title: "Hallo" } }, "de")).status).toBe( + 404, + ); + }); + + it("answers a structured 409 for a disabled locale", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + new ContentLanguageError({ locale: "pl", reason: "disabled" }), + ); + + const response = await post(app, { values: { title: "Witaj" } }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_LANGUAGE_DISABLED", + contentTypeId: "test.localized", + locale: "pl", + }); + }); + + it("answers a structured 409 when the locale already has one", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + new ContentTranslationExists({ + contentTypeId: "test.localized", + itemId: 7, + locale: "pl", + }), + ); + + const response = await post(app, { values: { title: "Witaj" } }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_TRANSLATION_EXISTS", + contentTypeId: "test.localized", + itemId: 7, + locale: "pl", + }); + }); + + it("answers a structured 409 when a localized slug is taken", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + Object.assign(new Error("duplicate key"), { code: "23505" }), + ); + + const response = await post(app, { values: { title: "Witaj" } }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_TRANSLATION_UNIQUE_CONFLICT", + contentTypeId: "test.localized", + itemId: 7, + locale: "pl", + }); + }); + + it("never leaks the driver's message", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + Object.assign( + new Error( + 'duplicate key value violates unique constraint "test_localized_articles_translations_language_id_slug_key"', + ), + { code: "23505" }, + ), + ); + + const body = await (await post(app, { values: { title: "Witaj" } })).text(); + + expect(body).not.toContain("unique constraint"); + }); +}); + +describe("PUT /{id}/translations/{locale}", () => { + const put = async (app: OpenAPIHono, body: unknown, locale = "en") => + app.request(`/7/translations/${locale}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + it("updates one locale and reports the new version", async () => { + const { app, translations } = harness(); + translations.update.mockResolvedValue({ + changed: true, + changedFields: ["title"], + row: translationRow({ version: 4 }), + version: 4, + }); + + const response = await put(app, { + expectedVersion: 3, + values: { title: "New" }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + changed: true, + row: { version: 4 }, + }); + expect(translations.update).toHaveBeenCalledWith( + 7, + "en", + { title: "New" }, + { expectedVersion: 3 }, + ); + }); + + it("reports a no-op as changed: false", async () => { + const { app, translations } = harness(); + translations.update.mockResolvedValue({ + changed: false, + changedFields: [], + row: translationRow({ version: 3 }), + version: 3, + }); + + const response = await put(app, { + expectedVersion: 3, + values: { title: "Hello" }, + }); + + expect(await response.json()).toMatchObject({ + changed: false, + row: { version: 3 }, + }); + }); + + it("requires an expected version", async () => { + const { app } = harness(); + + expect((await put(app, { values: { title: "New" } })).status).toBe(400); + }); + + it("rejects an empty patch", async () => { + const { app } = harness(); + + expect((await put(app, { expectedVersion: 1, values: {} })).status).toBe( + 400, + ); + }); + + it("answers a structured 409 naming the locale that moved", async () => { + const { app, translations } = harness(); + translations.update.mockRejectedValue( + new ContentTranslationVersionConflict({ + contentTypeId: "test.localized", + currentVersion: 5, + expectedVersion: 3, + itemId: 7, + locale: "en", + }), + ); + + const response = await put(app, { + expectedVersion: 3, + values: { title: "New" }, + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_TRANSLATION_VERSION_CONFLICT", + contentTypeId: "test.localized", + currentVersion: 5, + expectedVersion: 3, + itemId: 7, + // The tab that has to be reloaded, and only that one. + locale: "en", + }); + }); + + it("is 404 when the translation is missing", async () => { + const { app, translations } = harness(); + translations.update.mockResolvedValue(null); + + expect( + (await put(app, { expectedVersion: 1, values: { title: "New" } })).status, + ).toBe(404); + }); +}); + +describe("DELETE /{id}/translations/{locale}", () => { + const remove = async (app: OpenAPIHono, body: unknown, locale = "pl") => + app.request(`/7/translations/${locale}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "DELETE", + }); + + it("deletes a non-default translation", async () => { + const { app, translations } = harness(); + translations.delete.mockResolvedValue( + translationRow({ languageId: 2, locale: "pl", version: 2 }), + ); + + const response = await remove(app, { expectedVersion: 2 }); + + expect(response.status).toBe(200); + expect(translations.delete).toHaveBeenCalledWith(7, "pl", { + expectedVersion: 2, + }); + }); + + it("needs `can_delete`", async () => { + const { app, translations } = harness(); + translations.delete.mockResolvedValue(translationRow()); + + await remove(app, { expectedVersion: 1 }); + + expect(permissionChecks).toEqual([ + { module: "test_localized", permission: "can_delete" }, + ]); + }); + + it("requires an expected version", async () => { + const { app } = harness(); + + expect((await remove(app, {})).status).toBe(400); + }); + + it("refuses the default translation with a structured 409", async () => { + const { app, translations } = harness(); + translations.delete.mockRejectedValue( + new ContentDefaultTranslationRequired({ + contentTypeId: "test.localized", + itemId: 7, + locale: "en", + }), + ); + + const response = await remove(app, { expectedVersion: 1 }, "en"); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_DEFAULT_TRANSLATION_REQUIRED", + contentTypeId: "test.localized", + itemId: 7, + locale: "en", + }); + }); + + it("is 404 when the translation is already gone", async () => { + const { app, translations } = harness(); + translations.delete.mockResolvedValue(null); + + expect((await remove(app, { expectedVersion: 1 })).status).toBe(404); + }); +}); + +describe("the OpenAPI document", () => { + it("describes every translation route and its 409 union", () => { + const { app } = harness(); + const document = app.getOpenAPI31Document({ + info: { title: "test", version: "1" }, + openapi: "3.1.0", + }); + + const detail = document.paths?.["/{id}/translations/{locale}"]; + + expect(Object.keys(detail ?? {}).sort()).toEqual([ + "delete", + "get", + "post", + "put", + ]); + expect(detail?.put?.responses?.["409"]).toBeDefined(); + // The list route is metadata-only, so it has no 409 at all. + expect( + document.paths?.["/{id}/translations"]?.get?.responses?.["409"], + ).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts new file mode 100644 index 000000000..06f1d152b --- /dev/null +++ b/packages/vitnode/src/content/server/translation-routes.ts @@ -0,0 +1,285 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; +import type { ContentTranslationModel } from "./translation-model"; + +import { buildRoute } from "../../api/lib/route"; +import { zodContentTranslationConflict } from "../conflicts"; +import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_PERMISSIONS } from "../const"; +import { withTranslationHttpErrors } from "./translation-http-errors"; + +/** + * The five generated translation routes for one localized content type. + * + * Identity is `(content type, item, locale)` and never the translation row's own + * key: `(itemId, languageId)` is the primary key, there is no surrogate id to + * leak, and a locale in the URL cannot be used to reach another content type's + * translation because the module the route is mounted in already fixes which + * table is being read. + * + * Permissions reuse the ones the content type already has - `can_view` to read, + * `can_edit` to write, `can_delete` to remove. A dedicated `can_translate` is + * Stage 5B work: adding a permission means a migration for every existing role, + * and doing that before the AdminCP has a translation screen to gate would ship a + * checkbox that governs nothing anybody can see. + */ +export const buildContentTranslationRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition } = model; + const schemas = model.translationSchemas; + const module = definition.permissionModule; + const label = definition.admin.label; + + if (!schemas || !model.translationService) { + throw new Error( + `[Content Engine] ${definition.id}: buildContentTranslationRoutes needs a localized content type.`, + ); + } + + const translationSchemas = schemas; + const buildService = model.translationService; + + const translations = (c: Context): ContentTranslationModel => + buildService(c); + + const jsonBody = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, + }); + const jsonResponse = (schema: z.ZodType, description: string) => ({ + content: { "application/json": { schema } }, + description, + }); + + const readJson = async ( + c: Context, + schema: z.ZodType, + ): Promise => schema.parse(await c.req.json()); + + const identifier = (c: Context): number => { + const value = Number(c.req.param("id")); + if (!Number.isInteger(value) || value <= 0) { + throw new HTTPException(400, { message: "Invalid identifier." }); + } + + return value; + }; + + /** + * The locale from the URL, length-checked and nothing more. + * + * Deliberately not pattern-matched: an unknown locale and a malformed one are + * both answered by the resolver with the same 404, so a stricter regex here + * would only move the same outcome earlier. The value is a bound parameter, + * never an identifier. + */ + const locale = (c: Context): string => { + const value = c.req.param("locale") ?? ""; + if (value === "" || value.length > CONTENT_LOCALE_MAX_LENGTH) { + throw new HTTPException(400, { message: "Invalid locale." }); + } + + return value; + }; + + const conflict = jsonResponse( + zodContentTranslationConflict, + "The translation moved, already exists, is the default one, or a localized value is taken", + ); + const invalidIdentifier = { description: "Invalid identifier or locale" }; + const notFound = { + description: `${label.singular}, locale or translation not found`, + }; + + const list = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/translations", + description: `Which languages one ${label.singular} exists in`, + request: { params: model.schemas.params }, + responses: { + 200: jsonResponse( + z.object({ edges: z.array(translationSchemas.selectMeta) }), + "One entry per existing translation, without its values", + ), + 400: invalidIdentifier, + }, + }, + handler: async c => { + // Metadata only. A locale strip needs to know which languages exist and + // how stale each one is; the detail route loads a body when a tab opens. + const edges = await translations(c).findManyForItem(identifier(c)); + + return c.json({ edges }, 200); + }, + }); + + const detail = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/translations/{locale}", + description: `One ${label.singular} translation`, + request: { params: translationSchemas.params }, + responses: { + 200: jsonResponse(translationSchemas.select, "Translation found"), + 400: invalidIdentifier, + 404: notFound, + }, + }, + handler: async c => { + const row = await translations(c).findByLocale(identifier(c), locale(c)); + if (!row) { + throw new HTTPException(404, { message: "Translation not found." }); + } + + return c.json(row, 200); + }, + }); + + const create = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "post", + path: "/{id}/translations/{locale}", + description: `Add a ${label.singular} translation`, + request: { + params: translationSchemas.params, + body: jsonBody(translationSchemas.createEnvelope), + }, + responses: { + 201: jsonResponse(translationSchemas.select, "Translation created"), + 400: { description: "Invalid input data" }, + 404: notFound, + 409: conflict, + }, + }, + handler: async c => { + const id = identifier(c); + const target = locale(c); + const { values } = await readJson(c, translationSchemas.createEnvelope); + + const row = await withTranslationHttpErrors( + "create", + async () => await translations(c).create(id, target, values), + { contentTypeId: definition.id, itemId: id, locale: target }, + ); + + return c.json(row, 201); + }, + }); + + const update = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + // PUT, not PATCH: the Next.js API route handler exports no PATCH. + method: "put", + path: "/{id}/translations/{locale}", + description: `Update a ${label.singular} translation`, + request: { + params: translationSchemas.params, + body: jsonBody(translationSchemas.updateEnvelope), + }, + responses: { + 200: jsonResponse( + z.object({ + /** `false` when nothing moved - the version is unchanged. */ + changed: z.boolean(), + row: translationSchemas.select, + }), + "Translation updated, or already at those values", + ), + 400: { description: "Invalid or empty payload" }, + 404: notFound, + 409: conflict, + }, + }, + handler: async c => { + const id = identifier(c); + const target = locale(c); + const { expectedVersion, values } = await readJson( + c, + translationSchemas.updateEnvelope, + ); + + const result = await withTranslationHttpErrors( + "update", + async () => + await translations(c).update(id, target, values, { + expectedVersion, + }), + { contentTypeId: definition.id, itemId: id, locale: target }, + ); + if (!result) { + throw new HTTPException(404, { message: "Translation not found." }); + } + + return c.json({ changed: result.changed, row: result.row }, 200); + }, + }); + + /** + * The default-locale translation is not deletable, which is why this route + * declares a 409 rather than treating it as a 400: the request is well formed, + * and the reason it is refused is a state the client can read off the content + * type and act on. + * + * The body carries `expectedVersion` for the same reason the editorial delete + * does: a delete is the widest possible overwrite, and a confirmation dialog + * cannot ask about a change the person has not seen. + */ + const remove = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, + route: { + method: "delete", + path: "/{id}/translations/{locale}", + description: `Delete a ${label.singular} translation`, + request: { + params: translationSchemas.params, + body: jsonBody(translationSchemas.versionEnvelope), + }, + responses: { + 200: jsonResponse(translationSchemas.select, "Translation deleted"), + 400: invalidIdentifier, + 404: notFound, + 409: conflict, + }, + }, + handler: async c => { + const id = identifier(c); + const target = locale(c); + const { expectedVersion } = await readJson( + c, + translationSchemas.versionEnvelope, + ); + + const row = await withTranslationHttpErrors( + "delete", + async () => + await translations(c).delete(id, target, { expectedVersion }), + { contentTypeId: definition.id, itemId: id, locale: target }, + ); + if (!row) { + throw new HTTPException(404, { message: "Translation not found." }); + } + + return c.json(row, 200); + }, + }); + + return [list, detail, create, update, remove]; +}; diff --git a/packages/vitnode/src/content/server/translation-table.test.ts b/packages/vitnode/src/content/server/translation-table.test.ts new file mode 100644 index 000000000..054c3e470 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-table.test.ts @@ -0,0 +1,289 @@ +// @vitest-environment node +import { getTableName } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { + testCategoryContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { createContentTranslationTable } from "./translation-table"; + +const localized = createContentModel(testLocalizedArticleContentType); +const notes = createContentModel(testLocalizedNoteContentType); +const categories = createContentModel(testCategoryContentType); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); + +type AnyTable = Parameters[0]; + +/** `translationTable` is `null` for a content type without localization. */ +const translationTable = (model: { translationTable: AnyTable | null }) => { + if (!model.translationTable) { + throw new Error("Expected a generated translation table."); + } + + return model.translationTable; +}; + +const configOf = (table: AnyTable | null) => { + if (!table) throw new Error("Expected a generated translation table."); + + return getTableConfig(table); +}; + +const base = getTableConfig(localized.table); +const translations = configOf(localized.translationTable); + +describe("the base table of a localized content type", () => { + it("carries the system columns and the shared fields only", () => { + expect(base.columns.map(column => column.name)).toEqual([ + "id", + "createdAt", + "updatedAt", + "featured", + "views", + ]); + }); + + it("has no column for any localized field", () => { + const names = base.columns.map(column => column.name); + + for (const localizedField of ["title", "slug", "body"]) { + expect(names).not.toContain(localizedField); + } + }); + + it("keeps the localized slug's unique index off the base table", () => { + // A slug is unique *per language*, so its index belongs to the translation + // table. A base-table unique index would make the URL global. + expect(base.indexes.map(item => item.config.name)).toEqual([ + "test_localized_articles_created_at_idx", + "test_localized_articles_updated_at_idx", + ]); + }); + + it("exposes only shared columns through the column map", () => { + expect(Object.keys(localized.columns).sort()).toEqual([ + "createdAt", + "featured", + "id", + "updatedAt", + "views", + ]); + }); +}); + +describe("the generated translation table", () => { + it("is named after the base table", () => { + expect(getTableName(translationTable(localized))).toBe( + "test_localized_articles_translations", + ); + }); + + it("enables row level security, like every other generated table", () => { + expect(translations.enableRLS).toBe(true); + }); + + it("carries the keys, the version, the timestamps and the localized fields", () => { + expect(translations.columns.map(column => column.name)).toEqual([ + "itemId", + "languageId", + "version", + "createdAt", + "updatedAt", + "title", + "slug", + "body", + ]); + }); + + it("has no column for any shared field", () => { + const names = translations.columns.map(column => column.name); + + expect(names).not.toContain("featured"); + expect(names).not.toContain("views"); + }); + + it("materialises real Postgres types rather than a JSON blob", () => { + const types = Object.fromEntries( + translations.columns.map(column => [column.name, column.getSQLType()]), + ); + + expect(types).toEqual({ + body: "text", + createdAt: "timestamp", + itemId: "integer", + languageId: "integer", + slug: "varchar(160)", + title: "varchar(200)", + updatedAt: "timestamp", + version: "integer", + }); + }); + + it("keeps a nullable localized field nullable", () => { + const columns = Object.fromEntries( + translations.columns.map(column => [column.name, column]), + ); + + expect(columns.title.notNull).toBe(true); + expect(columns.body.notNull).toBe(false); + // Never nullable and never defaulted: a translation nobody can address by + // URL is not worth allowing. + expect(columns.slug.notNull).toBe(true); + expect(columns.slug.default).toBeUndefined(); + }); + + it("starts every translation at version 1", () => { + const version = translations.columns.find( + column => column.name === "version", + ); + + expect(version?.notNull).toBe(true); + expect(version?.default).toBe(1); + }); + + it("keys a translation by its record and its language", () => { + const [primaryKey] = translations.primaryKeys; + + expect(primaryKey.columns.map(column => column.name)).toEqual([ + "itemId", + "languageId", + ]); + expect(primaryKey.getName()).toBe( + "test_localized_articles_translations_item_id_language_id_pk", + ); + }); + + it("cascades from the record and restricts the language", () => { + const references = translations.foreignKeys.map(foreignKey => { + const reference = foreignKey.reference(); + + return { + column: reference.columns[0].name, + onDelete: foreignKey.onDelete, + target: getTableName(reference.foreignTable), + }; + }); + + expect(references).toEqual( + expect.arrayContaining([ + { + column: "itemId", + onDelete: "cascade", + target: "test_localized_articles", + }, + { + column: "languageId", + onDelete: "restrict", + target: "core_languages", + }, + ]), + ); + }); + + it("scopes the localized slug's uniqueness to one language", () => { + const unique = translations.indexes.filter(item => item.config.unique); + + expect(unique).toHaveLength(1); + expect(unique[0].config.name).toBe( + "test_localized_articles_translations_language_id_slug_key", + ); + expect( + unique[0].config.columns.map(column => + "name" in column ? column.name : "", + ), + ).toEqual(["languageId", "slug"]); + }); + + it("indexes languageId on its own", () => { + // `(itemId, languageId)` and `itemId` are served by the primary key; "every + // row in Polish" is not. + expect(translations.indexes.map(item => item.config.name)).toContain( + "test_localized_articles_translations_language_id_idx", + ); + }); + + it("generates deterministic names inside the Postgres limit", () => { + const names = [ + ...translations.indexes.map(item => item.config.name ?? ""), + ...translations.primaryKeys.map(key => key.getName()), + ]; + + for (const name of names) { + expect(name.length).toBeLessThanOrEqual(63); + expect(name).toMatch(/^[a-z][a-z0-9_]*$/); + } + }); + + it("exposes the translation columns through their own map", () => { + expect(Object.keys(localized.translationColumns ?? {}).sort()).toEqual([ + "body", + "createdAt", + "itemId", + "languageId", + "slug", + "title", + "updatedAt", + "version", + ]); + }); +}); + +describe("a content type without localization", () => { + it("generates exactly one table", () => { + expect(posts.translationTable).toBeNull(); + expect(posts.translationColumns).toBeNull(); + expect(posts.translationSchemas).toBeNull(); + expect(posts.translationService).toBeUndefined(); + expect(posts.localizedService).toBeUndefined(); + }); + + it("keeps every column it had", () => { + const names = getTableConfig(posts.table).columns.map( + column => column.name, + ); + + expect(names).toEqual([ + "id", + "createdAt", + "updatedAt", + "publishedAt", + "status", + "title", + "slug", + "excerpt", + "views", + "author", + "category", + ]); + }); + + it("refuses to build a translation table for it", () => { + expect(() => + createContentTranslationTable(testPostContentType, { + table: posts.table, + }), + ).toThrow(/needs `localization: \{ enabled: true, defaultLocale \}`/); + }); +}); + +describe("a second localized content type", () => { + it("gets its own tables and its own index names", () => { + const other = configOf(notes.translationTable); + + expect(getTableName(translationTable(notes))).toBe( + "test_localized_notes_translations", + ); + expect(other.indexes.map(item => item.config.name)).toEqual([ + "test_localized_notes_translations_language_id_idx", + "test_localized_notes_translations_language_id_slug_key", + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-table.ts b/packages/vitnode/src/content/server/translation-table.ts new file mode 100644 index 000000000..91d5870ef --- /dev/null +++ b/packages/vitnode/src/content/server/translation-table.ts @@ -0,0 +1,117 @@ +import type { + PgColumn, + PgColumnBuilderBase, + PgTable, +} from "drizzle-orm/pg-core"; + +import { index, pgTable, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; + +import type { AnyContentTypeDefinition, ResolvedContentIndex } from "../types"; +import type { + ContentTranslationColumnName, + ContentTranslationTableFor, +} from "./types"; + +import { core_languages } from "../../database/languages"; +import { CONTENT_TRANSLATION_SYSTEM_FIELDS } from "../const"; +import { ContentEngineError } from "../errors"; +import { contentTranslationPrimaryKeyName } from "../indexes"; +import { partitionContentFields } from "../localization"; +import { + buildContentColumn, + buildTranslationSystemColumns, +} from "./column-builders"; + +/** + * Builds the `pgTable` holding one localized content type's translations. + * + * One table per content type, not one shared table for the whole install. That + * is the entire design decision, and everything good about it follows from it: + * real column 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. + * + * Like {@link createContentTable} the result is an ordinary Drizzle table, so + * `drizzle-kit` discovers it by runtime identity when it globs the plugin's built + * `dist/src/database/*.js`. Export it from the plugin's database module: + * + * ```ts + * export const example_articles_translations = articleContent.translationTable; + * ``` + */ +export const createContentTranslationTable = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + { table }: { table: PgTable }, +): ContentTranslationTableFor => { + const { id: contentTypeId, localization } = definition; + + if (!localization.enabled) { + throw new ContentEngineError( + "createContentTranslationTable needs `localization: { enabled: true, defaultLocale }` on the content type.", + { contentTypeId }, + ); + } + + const { localizedFields } = partitionContentFields(definition.fields); + const baseColumns = table as unknown as Record; + + const columns: Record = { + ...buildTranslationSystemColumns({ + itemReference: () => baseColumns.id, + languageReference: () => core_languages.id, + }), + }; + + for (const [name, fieldValue] of Object.entries(localizedFields)) { + columns[name] = buildContentColumn({ contentTypeId, fieldValue, name }); + } + + const { translationIndexes, translationTableName } = localization; + + return pgTable( + translationTableName, + () => columns, + translationTable => { + const columnMap = translationTable as unknown as Record; + + return [ + // `(itemId, languageId)` rather than a surrogate key: the identity of a + // translation *is* the record plus the language, and a serial id would + // make "one translation per locale" a constraint somebody could forget. + primaryKey({ + columns: [columnMap.itemId, columnMap.languageId], + name: contentTranslationPrimaryKeyName(translationTableName), + }), + ...translationIndexes.map((config: ResolvedContentIndex) => { + const [first, ...rest] = config.on.map(name => columnMap[name]); + + return config.unique + ? uniqueIndex(config.name).on(first, ...rest) + : index(config.name).on(first, ...rest); + }), + ]; + }, + ).enableRLS() as unknown as ContentTranslationTableFor; +}; + +/** Column name -> Drizzle column on the translation table. */ +export const contentTranslationTableColumns = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + translationTable: ContentTranslationTableFor, +): Record, PgColumn> => { + const source = translationTable as unknown as Record; + const { localizedFields } = partitionContentFields(definition.fields); + const names = [ + ...CONTENT_TRANSLATION_SYSTEM_FIELDS, + ...Object.keys(localizedFields), + ]; + + return Object.fromEntries(names.map(name => [name, source[name]])) as Record< + ContentTranslationColumnName, + PgColumn + >; +}; diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 6868b3d23..3883939fe 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -18,8 +18,11 @@ import type { import type { ContentEditorialField, ContentFieldsOf, + ContentLocalizedFieldName, ContentPublicationField, + ContentSharedFieldName, ContentSystemField, + ContentTranslationSystemField, HasColumnDefault, } from "../types"; @@ -117,6 +120,69 @@ export type ContentColumnBuilders< [K in keyof TFields]: ContentColumnBuilder; }; +/** `itemId`, `languageId`, `version` and the timestamps. */ +export interface ContentTranslationSystemColumnBuilders { + createdAt: NotNull>>; + itemId: NotNull>; + languageId: NotNull>; + updatedAt: NotNull>>; + version: NotNull>>; +} + +export type ContentTranslationColumnBuilders = + ContentTranslationSystemColumnBuilders & { + [K in keyof TFields]: ContentColumnBuilder; + }; + +/** + * The `pgTable` a localized content type's translations compile to. + * + * Built with Drizzle's own `BuildColumns`, exactly like {@link ContentTable}, so + * `$inferSelect` and `$inferInsert` come out of the same machinery a + * hand-written `pgTable` uses. + */ +export type ContentTranslationTable< + TName extends string, + TFields, +> = PgTableWithColumns<{ + columns: BuildColumns, "pg">; + dialect: "pg"; + name: TName; + schema: undefined; +}>; + +/** + * The localized half of a field map, as a record. + * + * Spelled with a mapped type rather than `Pick` so the erased + * `AnyContentTypeDefinition` - whose localized name union is `never` - resolves + * to an empty record instead of to `never`. + */ +type LocalizedFieldsOf = { + [ + K in ContentLocalizedFieldName & + keyof ContentFieldsOf + ]: ContentFieldsOf[K]; +}; + +/** + * The translation table for one definition. + * + * `string` rather than the literal translation table name: that name is derived + * at *runtime* from `tableName` (suffixed, then clamped to 63 characters with a + * fingerprint), and re-deriving the clamp in the type system would be a second + * implementation of it. Nothing needs the literal - Drizzle only uses the name + * parameter to prefix column names it never exposes by literal type. + */ +export type ContentTranslationTableFor = ContentTranslationTable< + string, + LocalizedFieldsOf +>; + +/** Column name -> Drizzle column on the translation table. */ +export type ContentTranslationColumnName = + ContentLocalizedFieldName | ContentTranslationSystemField; + /** * The `pgTable` a content type compiles to. * @@ -139,18 +205,40 @@ export type ContentTable< schema: undefined; }>; +/** + * The shared half of a field map, as a record. See {@link LocalizedFieldsOf} for + * why it is a mapped type rather than a `Pick`. + */ +type SharedFieldsOf = { + [ + K in ContentSharedFieldName & + keyof ContentFieldsOf + ]: ContentFieldsOf[K]; +}; + +/** + * The base `pgTable` for one definition. + * + * Shared fields only. For a content type without localization every field is + * shared, so this is exactly the table it always generated. + */ export type ContentTableFor = TDefinition extends { editorial: { enabled: infer TEditorial extends boolean }; publication: { enabled: infer TPublication extends boolean }; tableName: infer TName extends string; } - ? ContentTable, TPublication, TEditorial> + ? ContentTable, TPublication, TEditorial> : never; -/** Column name -> Drizzle column, used for allowlisted filters and ordering. */ +/** + * Column name -> Drizzle column, used for allowlisted filters and ordering. + * + * Shared fields only: a localized field is a column on the translation table, + * and {@link ContentTranslationColumnName} is the union that names those. + */ export type ContentColumnName = + | ContentSharedFieldName | ContentSystemField - | (keyof ContentFieldsOf & string) | (TDefinition extends { editorial: { enabled: true } } ? ContentEditorialField : never) diff --git a/packages/vitnode/src/content/translation-schemas.test.ts b/packages/vitnode/src/content/translation-schemas.test.ts new file mode 100644 index 000000000..472a8b13b --- /dev/null +++ b/packages/vitnode/src/content/translation-schemas.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, +} from "@/tests/content-fixtures"; + +const { schemas } = testLocalizedArticleContentType; + +/** Non-null because the fixture is localized - see `translation-table.test.ts`. */ +const translation = (() => { + if (!schemas.translation) { + throw new Error("Expected generated translation schemas."); + } + + return schemas.translation; +})(); + +describe("shared schemas of a localized content type", () => { + it("accept the shared fields", () => { + expect(schemas.create.parse({ featured: true, views: 3 })).toEqual({ + featured: true, + views: 3, + }); + }); + + it("apply the declared defaults, exactly as before", () => { + expect(schemas.create.parse({})).toEqual({ featured: false, views: 0 }); + }); + + it("reject a localized field as a base value", () => { + // Strict, so a localized value cannot be smuggled into the base insert - + // there is no column for it, and quietly stripping it would lose the text. + expect(() => schemas.create.parse({ title: "Hello" })).toThrow(); + expect(() => schemas.update.parse({ title: "Hello" })).toThrow(); + }); + + it("keep localized fields out of the response shape", () => { + expect(Object.keys(schemas.selectObject.shape).sort()).toEqual([ + "createdAt", + "featured", + "id", + "updatedAt", + "views", + ]); + }); + + it("keep localized fields out of the filter and form shapes", () => { + expect(Object.keys(schemas.filters.shape)).not.toContain("title"); + expect(Object.keys(schemas.form.shape)).toEqual(["featured", "views"]); + }); +}); + +describe("translation create", () => { + it("accepts the localized values", () => { + expect(translation.create.parse({ body: "Body", title: "Hello" })).toEqual({ + body: "Body", + title: "Hello", + }); + }); + + it("requires a required localized field", () => { + expect(() => translation.create.parse({ body: "Body" })).toThrow(); + }); + + it("leaves a sourced slug optional and requires a sourceless one", () => { + expect(translation.create.parse({ title: "Hello" })).toEqual({ + title: "Hello", + }); + + const notes = testLocalizedNoteContentType.schemas.translation; + expect(() => notes?.create.parse({ heading: "Hi" })).toThrow(); + }); + + it("accepts null for a nullable localized field", () => { + expect(translation.create.parse({ body: null, title: "Hello" })).toEqual({ + body: null, + title: "Hello", + }); + }); + + it("is strict, so an unknown key is an error", () => { + expect(() => + translation.create.parse({ nope: 1, title: "Hello" }), + ).toThrow(); + }); + + it.each(["itemId", "languageId", "version", "locale", "expectedVersion"])( + "refuses %s as a content value", + key => { + // Identity and transport are not content. Accepting any of these inside + // `values` would make them mass-assignable. + expect(() => + translation.create.parse({ [key]: 1, title: "Hello" }), + ).toThrow(); + }, + ); + + it("wraps the values in an envelope on the wire", () => { + expect( + translation.createEnvelope.parse({ values: { title: "Hello" } }), + ).toEqual({ values: { title: "Hello" } }); + expect(() => + translation.createEnvelope.parse({ title: "Hello" }), + ).toThrow(); + }); +}); + +describe("translation update", () => { + it("makes every localized field optional", () => { + expect(translation.update.parse({ title: "New" })).toEqual({ + title: "New", + }); + }); + + it("refuses an empty patch", () => { + // A `PUT` that names no field is a request that means nothing, and it would + // otherwise burn a version check for no reason. + expect(() => translation.update.parse({})).toThrow(); + }); + + it("never applies create defaults", () => { + expect(translation.update.parse({ body: "Body" })).toEqual({ + body: "Body", + }); + }); + + it("carries `expectedVersion` beside the values, not inside them", () => { + expect( + translation.updateEnvelope.parse({ + expectedVersion: 3, + values: { title: "Nowy tytuł" }, + }), + ).toEqual({ expectedVersion: 3, values: { title: "Nowy tytuł" } }); + }); + + it.each([0, -1, 1.5])("refuses expectedVersion %s", expectedVersion => { + // Positive integers only, so a client that forgot to send one cannot coerce + // `0` past the guard and race the very check it is meant to lose. + expect(() => + translation.updateEnvelope.parse({ + expectedVersion, + values: { title: "New" }, + }), + ).toThrow(); + }); + + it("refuses an update envelope with an empty patch", () => { + expect(() => + translation.updateEnvelope.parse({ expectedVersion: 1, values: {} }), + ).toThrow(); + }); +}); + +describe("translation select", () => { + it("nests the values under `values` beside the metadata", () => { + expect(Object.keys(translation.select.shape).sort()).toEqual([ + "createdAt", + "itemId", + "languageId", + "locale", + "updatedAt", + "values", + "version", + ]); + }); + + it("has a metadata-only shape for the list route", () => { + // A locale strip needs to know which languages exist, not to drag every + // article body in every language across the wire to find out. + expect(Object.keys(translation.selectMeta.shape).sort()).toEqual([ + "createdAt", + "itemId", + "languageId", + "locale", + "updatedAt", + "version", + ]); + }); + + it("coerces the item id and keeps the locale a plain string", () => { + expect(translation.params.parse({ id: "12", locale: "pl" })).toEqual({ + id: 12, + locale: "pl", + }); + }); + + it("refuses a locale wider than core_languages.code", () => { + expect(() => + translation.params.parse({ id: "1", locale: "x".repeat(33) }), + ).toThrow(); + }); +}); + +describe("a content type without localization", () => { + it("has no translation schemas at all", () => { + expect(testArticleContentType.schemas.translation).toBeNull(); + }); + + it("keeps every generated schema it had", () => { + expect(Object.keys(testArticleContentType.schemas.form.shape)).toEqual( + Object.keys(testArticleContentType.fields), + ); + }); +}); diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 4343442ed..548665f6f 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,6 +1,7 @@ import type { CONTENT_EDITORIAL_FIELDS, CONTENT_FILTERABLE_FIELD_KINDS, + CONTENT_LOCALIZATION_FALLBACKS, CONTENT_PUBLIC_EXPOSABLE_COLUMNS, CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, @@ -8,6 +9,7 @@ import type { CONTENT_SEARCH_TEXT_KINDS, CONTENT_SEARCH_TITLE_KINDS, CONTENT_SYSTEM_FIELDS, + CONTENT_TRANSLATION_SYSTEM_FIELDS, } from "./const"; import type { ContentSchemas } from "./schemas"; @@ -18,6 +20,9 @@ export type ContentPublicationField = export type ContentEditorialField = (typeof CONTENT_EDITORIAL_FIELDS)[number]; +export type ContentTranslationSystemField = + (typeof CONTENT_TRANSLATION_SYSTEM_FIELDS)[number]; + export type ContentPublicationStatus = (typeof CONTENT_PUBLICATION_STATUSES)[number]; @@ -42,6 +47,17 @@ export interface ContentFieldShared< > { /** Free text or an i18n key surfaced in AdminCP and OpenAPI. */ description?: string; + /** + * The value is stored per language, in the generated translation table rather + * than on the base table. + * + * Declared here - on every kind - so `fieldValue.localized` reads off the + * descriptor union without a narrowing dance. Only the three kinds in + * {@link CONTENT_LOCALIZED_FIELD_KINDS} accept it: the other builders do not + * take the argument at all, so `field.boolean({ localized: true })` is a + * compile error, and `defineContentType` refuses it again at runtime. + */ + localized?: boolean; /** Column accepts NULL, and `null` is a legal value. */ nullable: TNullable; /** Must be present in the create payload. */ @@ -52,12 +68,17 @@ export interface ContentTextField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, TDefault extends string | undefined = string | undefined, + TLocalized extends boolean = boolean, > extends ContentFieldShared { // Declared non-optional (but possibly `undefined`) so `HasColumnDefault` can // tell "no default" from "defaulted": an optional property would always // include `undefined` in its type and the distinction would be lost. defaultValue: TDefault; kind: "text"; + // Non-optional and literal, for the same reason `required` and `nullable` are: + // every partition in this file keys off `{ localized: true }`, and an optional + // `boolean | undefined` would resolve every field to the shared branch. + localized: TLocalized; maxLength?: number; minLength?: number; /** Adds a unique index on the column. See {@link ContentIndexInput}. */ @@ -86,8 +107,10 @@ export type ContentSlugRequired = TSource extends string */ export interface ContentSlugField< TSource extends string | undefined = string | undefined, + TLocalized extends boolean = boolean, > extends ContentFieldShared, false> { kind: "slug"; + localized: TLocalized; /** `varchar` length and the truncation point. Defaults to 160. */ maxLength?: number; source: TSource; @@ -97,9 +120,11 @@ export interface ContentTextareaField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, TDefault extends string | undefined = string | undefined, + TLocalized extends boolean = boolean, > extends ContentFieldShared { defaultValue: TDefault; kind: "textarea"; + localized: TLocalized; maxLength?: number; minLength?: number; } @@ -278,6 +303,43 @@ type RequiredFieldKeys = { [K in keyof TFields]: TFields[K] extends { required: true } ? K : never; }[keyof TFields]; +// --------------------------------------------------------------------------- +// Shared / localized partition +// +// One rule, stated once as a type and once (in `partitionContentFields`) as +// runtime data: a field is localized when its descriptor carries the literal +// `localized: true`, and shared otherwise. The erased +// `AnyContentTypeDefinition` carries `localized?: boolean`, which does not +// extend `true`, so every field of an erased definition is shared - which is +// exactly how a Stage 1-4 content type behaved before localization existed. +// --------------------------------------------------------------------------- + +type LocalizedFieldKeys = { + [K in keyof TFields]: TFields[K] extends { localized: true } ? K : never; +}[keyof TFields]; + +type SharedFieldKeys = Exclude< + keyof TFields, + LocalizedFieldKeys +>; + +/** + * A create-shaped object over a subset of the field map: required fields stay + * required, everything else is optional, and each value is inferred from its own + * descriptor. + */ +type CreateValuesOf = Prettify< + { + [K in Exclude>]?: ContentFieldInput< + TFields[K] + >; + } & { + [K in Extract>]: ContentFieldInput< + TFields[K] + >; + } +>; + // --------------------------------------------------------------------------- // Admin metadata // --------------------------------------------------------------------------- @@ -300,8 +362,13 @@ type ContentEditorialColumn = /** * Every column name the admin config and `indexes` may address: the declared - * fields, the system columns, and whichever generated columns the content type - * opted into. + * *shared* fields, the system columns, and whichever generated columns the + * content type opted into. + * + * A localized field is absent on purpose. It is not a column on the base table, + * so an index on it, a sort by it or a DataTable column showing it would all + * address something that does not exist. Stage 5B adds the AdminCP locale tabs + * that give localized values a place to appear. */ type ContentAddressableColumn< TFields, @@ -311,7 +378,7 @@ type ContentAddressableColumn< | ContentEditorialColumn | ContentPublicationColumn | ContentSystemField - | keyof TFields; + | SharedFieldKeys; export interface ContentAdminListConfig< TFields = ContentFieldMap, @@ -326,9 +393,9 @@ export interface ContentAdminListConfig< * Allowlist for `orderBy`. System columns - and the publication columns when * enabled - are always allowed and need no entry here. */ - orderableFields?: (keyof TFields)[]; - /** Only `text` and `textarea` fields may be searched. */ - searchableFields?: (keyof TFields)[]; + orderableFields?: SharedFieldKeys[]; + /** Only shared `text` and `textarea` fields may be searched. */ + searchableFields?: SharedFieldKeys[]; } export interface ContentAdminConfig< @@ -336,7 +403,7 @@ export interface ContentAdminConfig< TPublication extends boolean = boolean, TEditorial extends boolean = boolean, > { - form?: { fields?: (keyof TFields)[] }; + form?: { fields?: SharedFieldKeys[] }; label: ContentAdminLabel; list?: ContentAdminListConfig; navigation?: { enabled?: boolean }; @@ -345,8 +412,14 @@ export interface ContentAdminConfig< * "Articles" -> `articles`. */ permissionModule?: string; - /** Field used as the human-readable title in toasts and relation pickers. */ - titleField?: keyof TFields; + /** + * Field used as the human-readable title in toasts and relation pickers. + * + * Shared fields only. A localized title has a different value per language, so + * naming one here would make a toast depend on whose locale the reader is in; + * Stage 5B gives the AdminCP a locale-aware title of its own. + */ + titleField?: SharedFieldKeys; } /** @@ -755,6 +828,139 @@ type ContentEditorialColumns = TDefinition extends { ? { version: number } : Record; +// --------------------------------------------------------------------------- +// Localization +// --------------------------------------------------------------------------- + +export type ContentLocalizationFallback = + (typeof CONTENT_LOCALIZATION_FALLBACKS)[number]; + +/** + * Opts a content type into per-language content: the fields marked + * `localized: true` move off the base table into a generated translation table, + * one row per language. + * + * Nothing about the *UI* language changes - that is `core_languages_words` and + * the ordinary i18n system. This is about the records themselves: an article + * that exists in English and in Polish, with its own title, slug and body in + * each. + * + * `enabled` is literal `true` for the same reason every other opt-in's is: every + * conditional keys off `{ enabled: true }`, and a widened `boolean` would + * silently resolve to "not localized". + */ +export interface ContentLocalizationConfig { + /** + * The locale every record is created in, and the one translation a record can + * never be without. Must name a row in `core_languages`, which is checked + * against the database once, at boot - see `assertContentLocalizationLanguages`. + */ + defaultLocale: string; + enabled: true; + /** + * What a public read should do for a locale with no translation. Resolved now + * and acted on in Stage 5C; `"none"` is the default because it is the only + * answer that cannot silently publish the wrong language. + */ + fallback?: ContentLocalizationFallback; +} + +/** + * `localization` after `defineContentType` has filled in every default. + * + * Generic over `enabled` for the same reason `publication` and `editorial` are: + * a widened `boolean` would make every definition equally (un)localized, so + * `LocalizedContentTypeDefinition` would only ever match after a cast. + */ +export interface ResolvedContentLocalizationConfig< + TEnabled extends boolean = boolean, +> { + defaultLocale: string; + enabled: TEnabled; + fallback: ContentLocalizationFallback; + /** The generated translation table's indexes, named and deduplicated. */ + translationIndexes: ResolvedContentIndex[]; + /** `_translations`, shortened to fit Postgres' identifier limit. */ + translationTableName: string; +} + +/** + * Whether a `localization` argument opted in. + * + * Read back off the argument for the same reason `ContentSearchEnabled` is: the + * whole object is inferred as one type parameter, and an intersection member is + * not an inference site, so this is the only way the literal survives. + */ +export type ContentLocalizationEnabled = TLocalization extends { + enabled: true; +} + ? true + : false; + +/** Field names whose value lives in the translation table. */ +export type ContentLocalizedFieldName = LocalizedFieldKeys< + ContentFieldsOf +> & + string; + +/** Field names whose value lives on the base table. */ +export type ContentSharedFieldName = SharedFieldKeys< + ContentFieldsOf +> & + string; + +/** + * The localized half of a create payload - one locale's worth of values. + * + * Empty (`{}`) for a content type with no localized fields, which is what makes + * `translation:` impossible to fill in by accident on a Stage 1-4 definition. + */ +export type ContentLocalizedValues = CreateValuesOf< + ContentFieldsOf, + keyof ContentFieldsOf & + LocalizedFieldKeys> +>; + +/** The shared half of a create payload - everything on the base table. */ +export type ContentSharedValues = CreateValuesOf< + ContentFieldsOf, + keyof ContentFieldsOf & + SharedFieldKeys> +>; + +/** Every localized field optional, and never empty - see `schemas.translation`. */ +export type ContentLocalizedUpdateValues = Prettify< + Partial> +>; + +/** One translation row, as the service and the generated routes return it. */ +export interface ContentTranslationRow { + createdAt: Date; + itemId: number; + languageId: number; + /** The canonical `core_languages.code`, never the caller's casing. */ + locale: string; + updatedAt: Date; + values: ContentLocalizedValues; + version: number; +} + +/** + * One translation without its values. + * + * What the list route returns, and deliberately so: a locale tab strip needs to + * know which languages exist and how stale each one is, not to drag every + * article body in every language across the wire to find out. + */ +export interface ContentTranslationMeta { + createdAt: Date; + itemId: number; + languageId: number; + locale: string; + updatedAt: Date; + version: number; +} + // --------------------------------------------------------------------------- // Definition // --------------------------------------------------------------------------- @@ -818,6 +1024,18 @@ export type SchedulableContentTypeDefinition = publication: { enabled: true }; }; +/** + * A content type whose records exist in more than one language. + * + * An intersection rather than a tenth type argument, for the same reason + * {@link PublicContentTypeDefinition} is one: `enabled` is the only thing the + * translation layer needs pinned, and narrowing just that keeps every concrete + * definition assignable to `AnyContentTypeDefinition`. + */ +export type LocalizedContentTypeDefinition = AnyContentTypeDefinition & { + localization: { enabled: true }; +}; + export interface ContentTypeDefinition< TId extends string = string, TFields = ContentFieldMap, @@ -828,6 +1046,7 @@ export interface ContentTypeDefinition< TEditorialEnabled extends boolean = boolean, TPreviewEnabled extends boolean = boolean, TSchedulingEnabled extends boolean = boolean, + TLocalizationEnabled extends boolean = boolean, > { admin: ResolvedContentAdminConfig; /** Editorial workflow, or the disabled default when `editorial` is omitted. */ @@ -840,6 +1059,11 @@ export interface ContentTypeDefinition< id: TId; /** Declared indexes plus the automatic ones, deduplicated and named. */ indexes: ResolvedContentIndex[]; + /** + * Per-language content, or the disabled default when `localization` is + * omitted. + */ + localization: ResolvedContentLocalizationConfig; /** Derived from `admin.permissionModule` or `admin.label.plural`. */ permissionModule: string; publicApi: ResolvedContentPublicApiConfig; @@ -855,7 +1079,8 @@ export interface ContentTypeDefinition< TSearchEnabled, TEditorialEnabled, TPreviewEnabled, - TSchedulingEnabled + TSchedulingEnabled, + TLocalizationEnabled > >; /** Search synchronization, or the disabled default when `search` is omitted. */ @@ -872,47 +1097,56 @@ export type ContentFieldsOf = TDefinition extends { ? TFields : never; +/** + * One base row. + * + * Shared fields only: a localized field's value lives on the translation table, + * so it is not a column here and never comes back from a base read. For a + * content type without localization every field is shared, so this is exactly + * the type it always was. + */ export type ContentSelect = Prettify< ContentEditorialColumns & ContentPublicationColumns & { - [K in keyof ContentFieldsOf]: ContentFieldValue< + [K in SharedFieldKeys>]: ContentFieldValue< ContentFieldsOf[K] >; } & { createdAt: Date; id: number; updatedAt: Date } >; -export type ContentCreateInput = Prettify< - { - [ - K in Exclude< - keyof ContentFieldsOf, - RequiredFieldKeys> - > - ]?: ContentFieldInput[K]>; - } & { - [K in RequiredFieldKeys>]: ContentFieldInput< - ContentFieldsOf[K] - >; - } ->; +/** The base-table half of a create payload. See {@link ContentSharedValues}. */ +export type ContentCreateInput = ContentSharedValues; export type ContentUpdateInput = Prettify< Partial> >; +/** + * Every field name the content type declares, localized ones included. + * + * Use {@link ContentSharedFieldName} where a *column on the base table* is + * meant - which is most places. + */ export type ContentFieldName = keyof ContentFieldsOf & string; +/** + * Shared field names of one or more kinds. + * + * Deliberately shared-only: everything derived from this - filters, ordering, + * relation pickers - addresses a column on the *base* table, and a localized + * field does not have one. + */ type FieldNamesOfKind = string & { [ - K in keyof ContentFieldsOf + K in SharedFieldKeys> ]: ContentFieldsOf[K] extends { kind: TKind; } ? K : never; - }[keyof ContentFieldsOf]; + }[SharedFieldKeys>]; /** * Kinds the generated filter schema understands, derived from the one runtime @@ -957,7 +1191,7 @@ export type ContentFilterInput = Partial< * actually opted in. */ export type ContentOrderableFieldName = - | ContentFieldName + | ContentSharedFieldName | ContentSystemField | (TDefinition extends { editorial: { enabled: true } } ? ContentEditorialField diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index e7d02012c..4f93ddcc5 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -197,3 +197,59 @@ export const testSearchablePostContentType = defineContentType({ list: { defaultOrderBy: "publishedAt" }, }, }); + +/** + * The Stage 5A shape: shared and localized fields on one content type. + * + * A separate fixture rather than a flag on an existing one, for the same reason + * the searchable and editorial ones are separate: leaving every Stage 1-4 fixture + * exactly as it was is what proves localization existing changes nothing for + * them. + */ +export const testLocalizedArticleContentType = defineContentType({ + id: "test.localized", + tableName: "test_localized_articles", + localization: { + enabled: true, + defaultLocale: "en", + fallback: "none", + }, + fields: { + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 200, + }), + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, nullable: true }), + // Shared, and deliberately the only one: "a localized field is absent from + // the base table" needs something present there to be contrasted with. + featured: field.boolean({ defaultValue: false }), + views: field.number({ integer: true, min: 0, defaultValue: 0 }), + }, + admin: { + label: { plural: "Test Localized", singular: "Test Localized" }, + list: { columns: ["featured", "views"], orderableFields: ["views"] }, + }, +}); + +/** + * Localized with a slug the caller always supplies. + * + * The other half of the slug rules: a sourceless localized slug is `required` in + * the translation create payload, where a sourced one is derived. + */ +export const testLocalizedNoteContentType = defineContentType({ + id: "test.localized-note", + tableName: "test_localized_notes", + localization: { enabled: true, defaultLocale: "EN" }, + fields: { + heading: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true }), + pinned: field.boolean({ defaultValue: false }), + }, + admin: { + label: { plural: "Test Localized Notes", singular: "Test Localized Note" }, + }, +}); diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts index d59a57541..09f0f5c2a 100644 --- a/plugins/example/src/api/modules/admin/admin.module.ts +++ b/plugins/example/src/api/modules/admin/admin.module.ts @@ -4,6 +4,7 @@ import { buildContentAdminModule } from "@vitnode/core/content/server"; import { CONFIG_PLUGIN } from "@/const"; import { articleContent } from "@/database/articles"; import { categoryContent } from "@/database/categories"; +import { localizedArticleContent } from "@/database/localized-articles"; /** * The generated content module is nested here rather than mounted by the @@ -19,7 +20,13 @@ export const adminModule = buildModule({ modules: [ buildContentAdminModule({ pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [articleContent, categoryContent], + // The localized article is registered on the API side only, so its + // generated CRUD *and* translation routes exist and its staff permissions + // are derived - but it gets no AdminCP screen yet, because a form that + // could not edit `title` in any language would be a worse thing to ship + // than no form at all. Stage 5B adds the locale tabs and registers it in + // `config.tsx` alongside the others. + contentTypes: [articleContent, categoryContent, localizedArticleContent], }), ], }); diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index b7fade25f..1cf92fe48 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -21,4 +21,7 @@ export const EXAMPLE_MIGRATIONS = [ // for an article, and the row has to have somewhere to go. "0027_add_content_schedules.sql", "0028_add_content_schedule_effects_error.sql", + // The Stage 5A localized fixture: a base table with only its shared field, and + // a translation table holding the localized ones. + "0029_add_example_localized_articles.sql", ]; diff --git a/plugins/example/src/content/localized-article.ts b/plugins/example/src/content/localized-article.ts new file mode 100644 index 000000000..b2ace9ac8 --- /dev/null +++ b/plugins/example/src/content/localized-article.ts @@ -0,0 +1,64 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +/** + * The Stage 5A reference: one content type with both halves of the partition. + * + * `featured` is shared, so it lives on `example_localized_articles`. `title`, + * `slug` and `body` are localized, so they live on + * `example_localized_articles_translations` - one row per language, each with its + * own `version`, and a unique `(languageId, slug)` index so `/en/hello` and + * `/pl/hello` can both exist while a second English `hello` is a 409. + * + * Deliberately minimal on every other axis. `publication`, `editorial`, + * `publicApi` and `search` are all refused alongside `localization` in Stage 5A - + * see the boundaries in `resolveContentLocalization` - so this fixture exercises + * the tables, the schemas, the services and the routes and nothing else. It is + * registered on the API side only: the AdminCP locale tabs that would give + * `title` somewhere to be edited are Stage 5B. + */ +export const localizedArticleContentType = defineContentType({ + id: "example.localized-article", + tableName: "example_localized_articles", + + localization: { + enabled: true, + // `en` has to exist in `core_languages`, which the boot guard checks once per + // process. The Postgres suite inserts it (and `pl`) itself - nothing seeds + // languages, they are created by the installer. + defaultLocale: "en", + // The safest default, and the only one Stage 5A can honestly claim: nothing + // reads through the fallback yet, and Stage 5C is where it starts to. + fallback: "none", + }, + + fields: { + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 200, + }), + // Derived from the *localized* title, per language. A slug sourced from a + // shared field would give every language the same URL, which is why the + // engine refuses that combination. + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, required: true }), + + // Shared: whether an article is featured is a property of the article, not + // of the language somebody is reading it in. + featured: field.boolean({ defaultValue: false }), + }, + + admin: { + label: { + plural: "Example Localized Articles", + singular: "Example Localized Article", + }, + list: { + // Shared columns only. A localized field is not a column on the base + // table, so naming one here is a compile error as well as a runtime one. + columns: ["featured", "updatedAt"], + orderableFields: ["featured"], + }, + }, +}); diff --git a/plugins/example/src/database/localized-articles.ts b/plugins/example/src/database/localized-articles.ts new file mode 100644 index 000000000..07f55ab07 --- /dev/null +++ b/plugins/example/src/database/localized-articles.ts @@ -0,0 +1,14 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { localizedArticleContentType } from "@/content/localized-article"; + +export const localizedArticleContent = createContentModel( + localizedArticleContentType, +); + +// Two exports for a localized content type, not one. Drizzle Kit discovers each +// table from the export when it globs the built `dist/src/database/*.js`, so the +// translation table needs its own or the migration would be generated without it. +export const example_localized_articles = localizedArticleContent.table; +export const example_localized_articles_translations = + localizedArticleContent.translationTable; diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index bb543a139..1c1218a11 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -2,7 +2,13 @@ import type { ContentSearchOperation } from "@vitnode/core/content/server"; import type { Context } from "hono"; import { executeContentSchedule } from "@vitnode/core/api/modules/content/helpers/execute-content-schedule"; -import { ContentVersionConflict } from "@vitnode/core/content"; +import { + ContentDefaultTranslationRequired, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, + ContentVersionConflict, +} from "@vitnode/core/content"; import { claimContentSchedule, createContentSearchIndexer, @@ -15,13 +21,14 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import postgres from "postgres"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { CONFIG_PLUGIN, EXAMPLE_MIGRATIONS } from "@/const"; import { articleContentType } from "@/content/article"; import { articleContent } from "./articles"; import { categoryContent } from "./categories"; +import { localizedArticleContent } from "./localized-articles"; /** * A real Postgres smoke test for the Content Engine. @@ -160,6 +167,26 @@ const CORE_QUEUE_STUB = ` ); `; +/** + * Enough of `core_languages` for the localized translation table's foreign key. + * + * Stubbed for the same reason `core_users` is - core's own migrations are not + * replayed here - and it is the *whole* reason the localized suite has to insert + * its own languages: nothing in VitNode seeds them. They are created by the + * installer, so a test that assumed `en` existed would pass on a developer + * machine and fail on a fresh CI database. + */ +const CORE_LANGUAGES_STUB = ` + CREATE TABLE "core_languages" ( + "id" serial PRIMARY KEY NOT NULL, + "code" varchar(32) NOT NULL, + "name" varchar(255) NOT NULL, + "default" boolean DEFAULT false NOT NULL, + "protected" boolean DEFAULT false NOT NULL, + CONSTRAINT "core_languages_code_unique" UNIQUE("code") + ); +`; + let sql: ReturnType; let context: Context; let db: ReturnType; @@ -211,6 +238,15 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { `); await sql.unsafe(CORE_USERS_STUB); await sql.unsafe(CORE_QUEUE_STUB); + await sql.unsafe(CORE_LANGUAGES_STUB); + // Inserted before the migrations run, so the localized translation table's + // `ON DELETE restrict` foreign key has something real to point at. + await sql` + INSERT INTO "core_languages" ("code", "name", "default") VALUES + ('en', 'English', true), + ('pl', 'Polski', false), + ('de', 'Deutsch', false) + `; const run = async (files: readonly string[]) => { for (const statement of migrationSql(files).split( @@ -277,7 +313,22 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { return { contentModels: [ { model: articleContent, pluginId: CONFIG_PLUGIN.pluginId }, + { + model: localizedArticleContent, + pluginId: CONFIG_PLUGIN.pluginId, + }, ], + // Which locales this app *serves*. `core_languages` is the registry + // of the ones that exist; a locale listed here with + // `enabled: false` is a deliberate switch-off, and the resolver + // refuses to write into it. + i18n: { + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + { code: "de", enabled: false, name: "Deutsch" }, + ], + }, }; } if (key === "queue") { @@ -2047,4 +2098,732 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { .create({ category: 1, code: "x", title: "no" }), ).rejects.toThrow(); }); + + /** + * Localization against a real database. + * + * Everything here is a property of Postgres rather than of the engine, which is + * exactly why it cannot be unit-tested: a mock asked whether a rollback happened + * can only agree with itself, and the composite primary key, the two foreign keys + * and the per-language unique index are enforced by the server or not at all. + */ + describe("localization", () => { + // Both are `undefined` for a content type without localization, so + // TypeScript refuses the call until the check has been made. Narrowed once + // here rather than asserted past at every call site. + const translations = (handle = context) => { + const build = localizedArticleContent.translationService; + if (!build) throw new Error("Expected a translation service."); + + return build(handle); + }; + const localizedService = (handle = context) => { + const build = localizedArticleContent.localizedService; + if (!build) throw new Error("Expected a localized service."); + + return build(handle); + }; + + /** Every translation row for one record, straight out of SQL. */ + const rowsFor = async (itemId: number) => + await sql< + { languageId: number; slug: string; title: string; version: number }[] + >` + SELECT "languageId", "slug", "title", "version" + FROM "example_localized_articles_translations" + WHERE "itemId" = ${itemId} + ORDER BY "languageId" + `; + + const countArticles = async () => { + const [{ count }] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "example_localized_articles" + `; + + return count; + }; + + const countTranslations = async () => { + const [{ count }] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count + FROM "example_localized_articles_translations" + `; + + return count; + }; + + beforeEach(async () => { + // The cascade is the point of one of the tests below, so clear the base + // table and let it take the translations with it. + await sql`DELETE FROM "example_localized_articles"`; + }); + + describe("atomic create", () => { + it("commits the base row and its default translation together", async () => { + const { row, translation } = await localizedService().create({ + shared: { featured: true }, + translation: { body: "Hello body", title: "Hello World" }, + }); + + expect(row.featured).toBe(true); + expect(translation).toMatchObject({ + itemId: row.id, + locale: "en", + version: 1, + }); + expect(translation.values).toMatchObject({ + slug: "hello-world", + title: "Hello World", + }); + + // Both really landed, read back through SQL rather than through the + // service that wrote them. + expect(await countArticles()).toBe(1); + expect(await rowsFor(row.id)).toEqual([ + { + languageId: 1, + slug: "hello-world", + title: "Hello World", + version: 1, + }, + ]); + }); + + it("keeps localized values off the base table", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Column Check", title: "Column Check" }, + }); + + const columns = await sql<{ column_name: string }[]>` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'example_localized_articles' + `; + + expect(columns.map(column => column.column_name).sort()).toEqual([ + "createdAt", + "featured", + "id", + "updatedAt", + ]); + expect(row).not.toHaveProperty("title"); + }); + + it("leaves no base row when the translation cannot be written", async () => { + await localizedService().create({ + shared: {}, + translation: { body: "Body of First", slug: "taken", title: "First" }, + }); + const before = await countArticles(); + + // The unique `(languageId, slug)` index rejects the second English row. + await expect( + localizedService().create({ + shared: {}, + translation: { + body: "Body of Second", + slug: "taken", + title: "Second", + }, + }), + ).rejects.toThrow(); + + // No orphan: the base insert went back with the transaction. + expect(await countArticles()).toBe(before); + expect(await countTranslations()).toBe(before); + }); + + it("creates nothing when the default language is missing", async () => { + // A context whose language registry has no `en` row at all - what a typo + // in `defaultLocale`, or a language somebody deleted, looks like. + const blind = { + get: (key: string) => { + if (key === "db") { + return { + ...db, + select: () => ({ + from: () => [], + where: () => ({ limit: () => [] }), + }), + }; + } + + return context.get(key); + }, + } as unknown as Context; + + await expect( + localizedService(blind).create({ + shared: {}, + translation: { + body: "Body of Never Written", + title: "Never Written", + }, + }), + ).rejects.toThrow(); + expect(await countArticles()).toBe(0); + }); + + it("refuses to create a record straight into a non-default locale", async () => { + await expect( + localizedService().create( + { + shared: {}, + translation: { body: "Body of Witaj", title: "Witaj" }, + }, + { locale: "pl" }, + ), + ).rejects.toThrow(/created in its default locale/); + expect(await countArticles()).toBe(0); + }); + }); + + describe("per-locale optimistic locking", () => { + it("versions each locale independently", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of English One", title: "English One" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Polski Jeden", + title: "Polski Jeden", + }); + + // Move English twice, Polish once. + await translations().update( + row.id, + "en", + { body: "Body of English Two", title: "English Two" }, + { expectedVersion: 1 }, + ); + await translations().update( + row.id, + "en", + { body: "Body of English Three", title: "English Three" }, + { expectedVersion: 2 }, + ); + await translations().update( + row.id, + "pl", + { body: "Body of Polski Dwa", title: "Polski Dwa" }, + { expectedVersion: 1 }, + ); + + expect(await rowsFor(row.id)).toMatchObject([ + { languageId: 1, version: 3 }, + { languageId: 2, version: 2 }, + ]); + }); + + it("lets two locales be edited concurrently without conflicting", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Concurrent English", + title: "Concurrent English", + }, + }); + await translations().create(row.id, "pl", { + body: "Body of Concurrent Polski", + title: "Concurrent Polski", + }); + await translations().update( + row.id, + "en", + { body: "Body of English At Two", title: "English At Two" }, + { expectedVersion: 1 }, + ); + await translations().update( + row.id, + "en", + { body: "Body of English At Three", title: "English At Three" }, + { expectedVersion: 2 }, + ); + + // English is at 3, Polish at 1. Two writers, two connections, one each. + const [english, polish] = await Promise.all([ + translations().update( + row.id, + "en", + { body: "Body of English At Four", title: "English At Four" }, + { expectedVersion: 3 }, + ), + translations(rivalContext).update( + row.id, + "pl", + { body: "Body of Polski At Two", title: "Polski At Two" }, + { expectedVersion: 1 }, + ), + ]); + + // Neither is told the other language moved: the lock is per row, and the + // rows are keyed by `(itemId, languageId)`. + expect(english).toMatchObject({ changed: true, version: 4 }); + expect(polish).toMatchObject({ changed: true, version: 2 }); + }); + + it("lets exactly one of two writers on the same locale win", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Race Subject", title: "Race Subject" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Wersja Jeden", + title: "Wersja Jeden", + }); + await translations().update( + row.id, + "pl", + { body: "Body of Wersja Dwa", title: "Wersja Dwa" }, + { expectedVersion: 1 }, + ); + + const outcomes = await Promise.allSettled([ + translations().update( + row.id, + "pl", + { body: "Body of Wersja Trzy A", title: "Wersja Trzy A" }, + { expectedVersion: 2 }, + ), + translations(rivalContext).update( + row.id, + "pl", + { body: "Body of Wersja Trzy B", title: "Wersja Trzy B" }, + { expectedVersion: 2 }, + ), + ]); + + const fulfilled = outcomes.filter( + outcome => outcome.status === "fulfilled", + ); + const rejected = outcomes.filter( + outcome => outcome.status === "rejected", + ); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]).toMatchObject({ + reason: expect.objectContaining({ + currentVersion: 3, + expectedVersion: 2, + locale: "pl", + }), + }); + // The winner's value is the one that is stored, and the version moved once. + const [, polishRow] = await rowsFor(row.id); + expect(polishRow.version).toBe(3); + }); + + it("refuses a stale update", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Stale Update", title: "Stale Update" }, + }); + await translations().update( + row.id, + "en", + { body: "Body of Moved On", title: "Moved On" }, + { expectedVersion: 1 }, + ); + + await expect( + translations().update( + row.id, + "en", + { body: "Body of From The Past", title: "From The Past" }, + { expectedVersion: 1 }, + ), + ).rejects.toThrow(ContentTranslationVersionConflict); + }); + + it("refuses a stale delete", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Stale Delete", title: "Stale Delete" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Do Usuniecia", + title: "Do Usuniecia", + }); + await translations().update( + row.id, + "pl", + { body: "Body of Zmienione", title: "Zmienione" }, + { expectedVersion: 1 }, + ); + + await expect( + translations().delete(row.id, "pl", { expectedVersion: 1 }), + ).rejects.toThrow(ContentTranslationVersionConflict); + expect(await rowsFor(row.id)).toHaveLength(2); + }); + + it("leaves version and updatedAt alone on a no-op", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of No Op Subject", + title: "No Op Subject", + }, + }); + // The raw `postgres` client is used directly here rather than Drizzle, + // so a timestamp arrives as whatever the driver produced - hence the + // explicit `new Date`. + const [before] = await sql<{ updatedAt: string; version: number }[]>` + SELECT "updatedAt", "version" + FROM "example_localized_articles_translations" + WHERE "itemId" = ${row.id} AND "languageId" = 1 + `; + + const result = await translations().update( + row.id, + "en", + { body: "Body of No Op Subject", title: "No Op Subject" }, + { expectedVersion: 1 }, + ); + + const [after] = await sql<{ updatedAt: string; version: number }[]>` + SELECT "updatedAt", "version" + FROM "example_localized_articles_translations" + WHERE "itemId" = ${row.id} AND "languageId" = 1 + `; + + expect(result).toMatchObject({ changed: false, version: 1 }); + expect(after.version).toBe(before.version); + // `$onUpdate` fires on any UPDATE, so an unchanged `updatedAt` is proof no + // statement ran at all. + expect(new Date(after.updatedAt).getTime()).toBe( + new Date(before.updatedAt).getTime(), + ); + }); + + it("moves updatedAt on a real update", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Real Update", title: "Real Update" }, + }); + const [before] = await sql<{ updatedAt: string }[]>` + SELECT "updatedAt" FROM "example_localized_articles_translations" + WHERE "itemId" = ${row.id} AND "languageId" = 1 + `; + + await translations().update( + row.id, + "en", + { body: "Body of Really Updated", title: "Really Updated" }, + { expectedVersion: 1 }, + ); + + const [after] = await sql<{ updatedAt: string }[]>` + SELECT "updatedAt" FROM "example_localized_articles_translations" + WHERE "itemId" = ${row.id} AND "languageId" = 1 + `; + + expect(new Date(after.updatedAt).getTime()).toBeGreaterThanOrEqual( + new Date(before.updatedAt).getTime(), + ); + }); + }); + + describe("the default translation invariant", () => { + it("refuses to delete the default translation", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Keeps Its English", + title: "Keeps Its English", + }, + }); + + await expect( + translations().delete(row.id, "en", { expectedVersion: 1 }), + ).rejects.toThrow(ContentDefaultTranslationRequired); + expect(await rowsFor(row.id)).toHaveLength(1); + }); + + it("deletes an additional translation", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Has Two", title: "Has Two" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Ma Dwa", + title: "Ma Dwa", + }); + + const removed = await translations().delete(row.id, "pl", { + expectedVersion: 1, + }); + + expect(removed).toMatchObject({ locale: "pl" }); + expect(await rowsFor(row.id)).toMatchObject([{ languageId: 1 }]); + }); + }); + + describe("locale-scoped slug uniqueness", () => { + it("allows the same slug in two different languages", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of About", slug: "about", title: "About" }, + }); + + await translations().create(row.id, "pl", { + body: "O nas po polsku", + slug: "about", + title: "O Nas", + }); + + // `/en/about` and `/pl/about` are two different pages, and that is the + // whole reason the unique index is `(languageId, slug)`. + expect((await rowsFor(row.id)).map(item => item.slug)).toEqual([ + "about", + "about", + ]); + }); + + it("refuses two rows with the same slug in one language", async () => { + const first = await localizedService().create({ + shared: {}, + translation: { + body: "Body of First", + slug: "duplicate", + title: "First", + }, + }); + const second = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Second", + slug: "other", + title: "Second", + }, + }); + + const code = await pgErrorCode(async () => + translations().update( + second.row.id, + "en", + { slug: "duplicate" }, + { expectedVersion: 1 }, + ), + ); + + expect(code).toBe("23505"); + expect((await rowsFor(first.row.id))[0].slug).toBe("duplicate"); + }); + + it("derives a different slug per language from that language's title", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of The English Title", + title: "The English Title", + }, + }); + await translations().create(row.id, "pl", { + body: "Body of Polski Tytul", + title: "Polski Tytul", + }); + + expect((await rowsFor(row.id)).map(item => item.slug)).toEqual([ + "the-english-title", + "polski-tytul", + ]); + }); + }); + + describe("foreign keys", () => { + it("cascades translations when the record is deleted", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Going Away", title: "Going Away" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Odchodzi", + title: "Odchodzi", + }); + expect(await rowsFor(row.id)).toHaveLength(2); + + // One statement, no loop over locales: the database owns the cascade. + await localizedArticleContent.service(context).delete(row.id); + + expect(await rowsFor(row.id)).toEqual([]); + }); + + it("restricts deleting a language that content is written in", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Holds English", + title: "Holds English", + }, + }); + await translations().create(row.id, "pl", { + body: "Body of Trzyma Polski", + title: "Trzyma Polski", + }); + + const code = await pgErrorCode( + async () => + await sql`DELETE FROM "core_languages" WHERE "code" = 'pl'`, + ); + + // `23503`, not a silent cascade: deleting a language must not quietly + // delete every article written in it. + expect(code).toBe("23503"); + expect(await rowsFor(row.id)).toHaveLength(2); + }); + + it("refuses a translation for a record that does not exist", async () => { + await expect( + translations().create(999_999, "pl", { + body: "Body of Nigdzie", + title: "Nigdzie", + }), + ).rejects.toThrow(ContentTranslationItemMissing); + }); + }); + + describe("the composite primary key", () => { + it("refuses a second translation in the same locale", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Only One English", + title: "Only One English", + }, + }); + + await expect( + translations().create(row.id, "en", { + body: "Body of Second English", + title: "Second English", + }), + ).rejects.toThrow(ContentTranslationExists); + expect(await rowsFor(row.id)).toHaveLength(1); + }); + + it("is the key Postgres actually created", async () => { + const [key] = await sql<{ columns: string[]; name: string }[]>` + SELECT + c.conname AS name, + array_agg(a.attname ORDER BY k.ord) AS columns + FROM pg_constraint c + JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.conrelid = 'example_localized_articles_translations'::regclass + AND c.contype = 'p' + GROUP BY c.conname + `; + + expect(key.name).toBe( + "example_localized_articles_translations_item_id_language_id_pk", + ); + expect(key.columns).toEqual(["itemId", "languageId"]); + }); + }); + + describe("language resolution", () => { + it("returns the canonical locale whatever casing arrives", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Casing Subject", + title: "Casing Subject", + }, + }); + + expect((await translations().findByLocale(row.id, "EN"))?.locale).toBe( + "en", + ); + }); + + it("refuses to write into a locale the app has disabled", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of No German", title: "No German" }, + }); + + // `de` exists in `core_languages` but the app config switched it off. + await expect( + translations().create(row.id, "de", { + body: "Body of Kein Deutsch", + title: "Kein Deutsch", + }), + ).rejects.toMatchObject({ reason: "disabled" }); + }); + + it("refuses an unknown locale", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of No Klingon", title: "No Klingon" }, + }); + + await expect( + translations().create(row.id, "tlh", { + body: "Body of nuqneH", + title: "nuqneH", + }), + ).rejects.toMatchObject({ reason: "missing" }); + }); + }); + + describe("reads", () => { + it("lists every locale a record exists in, without the bodies", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "A long English body", title: "Listed" }, + }); + await translations().create(row.id, "pl", { + body: "Dlugi polski tekst", + title: "Na Liscie", + }); + + const edges = await translations().findManyForItem(row.id); + + expect(edges.map(edge => edge.locale)).toEqual(["en", "pl"]); + for (const edge of edges) { + expect(edge).not.toHaveProperty("values"); + expect(edge).not.toHaveProperty("body"); + } + }); + + it("finds a translation by language id as well as by locale", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Found Both Ways", + title: "Found Both Ways", + }, + }); + + expect(await translations().findByLanguageId(row.id, 1)).toMatchObject({ + locale: "en", + version: 1, + }); + expect(await translations().exists(row.id, "pl")).toBe(false); + expect(await translations().exists(row.id, "en")).toBe(true); + }); + + it("never joins translations into an ordinary base list", async () => { + await localizedService().create({ + shared: { featured: true }, + translation: { + body: "Body of Base List Row", + title: "Base List Row", + }, + }); + + const page = await localizedArticleContent + .service(context) + .findMany({ query: {} }); + + expect(page.edges).toHaveLength(1); + // Stage 5A loads translations explicitly, one record at a time. A list of + // 25 rows must not drag 25 records' worth of every language with it. + expect(page.edges[0]).not.toHaveProperty("title"); + expect(page.edges[0]).toMatchObject({ featured: true }); + }); + }); + }); }); diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index 821f3764c..83e6ebd8c 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -9,9 +9,28 @@ import { EXAMPLE_MIGRATIONS } from "@/const"; import { example_articles } from "./articles"; import { example_categories } from "./categories"; +import { + example_localized_articles, + example_localized_articles_translations, +} from "./localized-articles"; const articles = getTableConfig(example_articles); const categories = getTableConfig(example_categories); +const localizedArticles = getTableConfig(example_localized_articles); + +// `translationTable` is `null` for every content type without localization, so +// this narrows once - and fails loudly rather than silently skipping the +// assertions below if the fixture ever stops being localized. +const localizedTranslationTable = (() => { + if (!example_localized_articles_translations) { + throw new Error( + "example.localized-article generated no translation table.", + ); + } + + return example_localized_articles_translations; +})(); +const localizedTranslations = getTableConfig(localizedTranslationTable); const indexNames = (config: typeof articles) => config.indexes.map(item => item.config.name); @@ -202,7 +221,12 @@ describe("the generated migration", () => { .filter(name => name.startsWith("example_")); expect([...created].sort(byName)).toEqual( - [...indexNames(articles), ...indexNames(categories)].sort(byName), + [ + ...indexNames(articles), + ...indexNames(categories), + ...indexNames(localizedArticles), + ...indexNames(localizedTranslations), + ].sort(byName), ); }); @@ -275,3 +299,157 @@ describe("the generated migration", () => { ); }); }); + +describe("example_localized_articles", () => { + it("keeps every localized field off the base table", () => { + const columns = localizedArticles.columns.map(column => column.name); + + // `title`, `slug` and `body` are declared on the content type and are + // deliberately absent here: they live one table over, one row per language. + expect(columns).toEqual(["id", "createdAt", "updatedAt", "featured"]); + }); + + it("keeps shared fields off the translation table", () => { + const columns = localizedTranslations.columns.map(column => column.name); + + expect(columns).not.toContain("featured"); + expect(columns).toEqual([ + "itemId", + "languageId", + "version", + "createdAt", + "updatedAt", + "title", + "slug", + "body", + ]); + }); + + it("gives the translation table its own name", () => { + expect(getTableName(localizedTranslationTable)).toBe( + "example_localized_articles_translations", + ); + }); + + it("enables row level security on both tables", () => { + expect(localizedArticles.enableRLS).toBe(true); + expect(localizedTranslations.enableRLS).toBe(true); + }); + + it("materialises the real column types, not JSON", () => { + const types = Object.fromEntries( + localizedTranslations.columns.map(column => [ + column.name, + column.getSQLType(), + ]), + ); + + expect(types).toMatchObject({ + body: "text", // textarea + itemId: "integer", + languageId: "integer", + slug: "varchar(160)", + title: "varchar(200)", + version: "integer", + }); + }); + + it("versions each translation independently, starting at 1", () => { + const version = localizedTranslations.columns.find( + column => column.name === "version", + ); + + expect(version?.notNull).toBe(true); + expect(version?.default).toBe(1); + }); + + it("keys a translation by its record and its language", () => { + const [primaryKey] = localizedTranslations.primaryKeys; + + expect(primaryKey.columns.map(column => column.name)).toEqual([ + "itemId", + "languageId", + ]); + expect(primaryKey.getName()).toBe( + "example_localized_articles_translations_item_id_language_id_pk", + ); + expect(primaryKey.getName().length).toBeLessThanOrEqual(63); + }); + + it("cascades from the record and restricts the language", () => { + const references = localizedTranslations.foreignKeys.map(foreignKey => { + const reference = foreignKey.reference(); + + return { + column: reference.columns[0].name, + onDelete: foreignKey.onDelete, + target: getTableName(reference.foreignTable), + }; + }); + + expect(references).toEqual( + expect.arrayContaining([ + // Translations are part of the record, so they go with it. + { + column: "itemId", + onDelete: "cascade", + target: "example_localized_articles", + }, + // Deleting a language must not silently delete the content written in + // it - the language screen refuses instead. + { + column: "languageId", + onDelete: "restrict", + target: "core_languages", + }, + ]), + ); + }); + + it("scopes the localized slug's uniqueness to one language", () => { + const unique = localizedTranslations.indexes.find( + item => item.config.unique, + ); + + expect(unique?.config.name).toBe( + "example_localized_articles_translations_language_id_slug_key", + ); + expect( + unique?.config.columns.map(column => "name" in column && column.name), + ).toEqual(["languageId", "slug"]); + }); + + it("indexes languageId on its own", () => { + // The composite primary key already serves `(itemId, languageId)` and + // `itemId`; "every row in Polish" needs its own index. + expect(indexNames(localizedTranslations)).toContain( + "example_localized_articles_translations_language_id_idx", + ); + }); + + it("keeps every generated identifier inside the Postgres limit", () => { + for (const name of indexNames(localizedTranslations)) { + expect((name ?? "").length).toBeLessThanOrEqual(63); + } + }); + + it("creates both tables in the committed migration", () => { + expect(migration).toContain('CREATE TABLE "example_localized_articles"'); + expect(migration).toContain( + 'CREATE TABLE "example_localized_articles_translations"', + ); + expect(migration).toContain('PRIMARY KEY("itemId","languageId")'); + expect(migration).toContain( + 'REFERENCES "public"."core_languages"("id") ON DELETE restrict', + ); + expect(migration).toContain( + 'CREATE UNIQUE INDEX "example_localized_articles_translations_language_id_slug_key"', + ); + }); + + it("never adds a localized column to the base table in the migration", () => { + expect(migration).not.toMatch( + /ALTER TABLE "example_localized_articles" ADD COLUMN "title"/, + ); + }); +});