diff --git a/apps/docs/content/docs/dev/content-engine/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx index 55a6d2ae5..9d3ef8c07 100644 --- a/apps/docs/content/docs/dev/content-engine/index.mdx +++ b/apps/docs/content/docs/dev/content-engine/index.mdx @@ -55,13 +55,15 @@ Four more declarations, each opt-in: 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 + you mark into a generated per-language table, with its own version, its own + [publish button](/docs/dev/content-engine/translation-editorial), its own + [history](/docs/dev/content-engine/translation-revisions) and a URL per locale Publication alone exposes nothing. Public exposure requires both of the first -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. +two; `editorial` works with or without either. `localization` combines with +`publication` and `editorial`, and is still +[exclusive of `publicApi` and `search`](/docs/dev/content-engine/localization#stage-5b-boundaries) - +both arrive 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 617477605..59f3ef2f2 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -43,16 +43,15 @@ 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 does not read outwards yet [`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: +types, the schemas, the services, the per-locale lifecycle, the per-locale history +and the AdminCP locale tabs. 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 | @@ -60,10 +59,15 @@ 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. +Because preview projects through `publicApi.fields`, a **locale preview link +cannot be minted** until Stage 5C either - the +[token format](/docs/dev/content-engine/translation-preview) is in place and +tested, but the routes that mint and read one are not. + +Also outside Stage 5B: fallback resolution, locale-aware cache tags, per-locale +search documents, an `Outdated` badge (its honest definition needs a comparison +two timestamps cannot make), a locale selector on the AdminCP *list*, and +locale-specific scheduling. ## Localized field names cannot appear on base-table surfaces @@ -73,7 +77,9 @@ A localized field has no column on the base table, so it cannot be an 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. +text fields are localized. The locale tabs show the localized title inside each +tab; the list still has no locale-aware title column, which arrives with the +locale selector in Stage 5C. ## Foreign key names on a long translation table are truncated by Postgres diff --git a/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx b/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx index 42e74cfbc..17bb6ec0b 100644 --- a/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx +++ b/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx @@ -69,6 +69,55 @@ the timestamps, and the language-scoped unique slug index. `en` language exists. Test fixtures have to insert their own. +## Adding the editorial layer to an existing localized type + +Opting a Stage 5A content type into `publication` and `editorial` is purely +additive, and `drizzle-kit` generates all of it. The committed +`0030_add_translation_editorial.sql`, in full: + +```sql +DROP INDEX "example_localized_articles_translations_language_id_idx";--> statement-breakpoint +DROP INDEX "core_content_revisions_item_version_unique";--> statement-breakpoint +ALTER TABLE "core_content_revisions" ADD COLUMN "languageId" integer;--> statement-breakpoint +ALTER TABLE "example_localized_articles" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint +ALTER TABLE "example_localized_articles" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint +ALTER TABLE "example_localized_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_revisions_translation_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","languageId","version") WHERE "languageId" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "core_content_revisions_language_idx" ON "core_content_revisions" USING btree ("contentTypeId","itemId","languageId","version");--> statement-breakpoint +CREATE INDEX "example_localized_articles_status_published_at_idx" ON "example_localized_articles" USING btree ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "example_localized_articles_translations_language_id_status_idx" ON "example_localized_articles_translations" USING btree ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_revisions_item_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","version") WHERE "languageId" IS NULL; +``` + +No `DROP COLUMN`, no `DELETE`, no data step - which is why this one is safe to +apply as generated. Four things are worth reading closely: + +1. **`status DEFAULT 'draft' NOT NULL`** backfills every existing translation to + a draft in one statement. That is the only correct answer: silently publishing + translations somebody wrote while the feature did not exist would put them on + the internet. +2. **`publishedAt` arrives `NULL`** on every existing row, because none of them + has been published in this sense yet. +3. **`languageId` arrives nullable with no default**, so every revision written + before this migration becomes a shared one - which is exactly what it was. +4. **The unique index becomes two partial ones.** The old one is dropped and + recreated with `WHERE "languageId" IS NULL`, so it covers precisely the rows + it covered before; the new one covers the translation rows. One index over a + nullable `languageId` would enforce nothing at all, because Postgres treats + every `NULL` as distinct. + +The dropped `..._language_id_idx` is superseded rather than lost: +`(languageId, status)` leads with the same column, so it serves "every row in +Polish" as well as "every published row in Polish". + + + The predicate is raw SQL, so `WHERE language_id IS NULL` would look for a + column Postgres folded to lower case and fail at apply time with + `column "language_id" does not exist`. The engine writes `"languageId"`. + + ## Localizing a Content Type that already has rows This is the interesting case, and the engine deliberately does **not** generate it diff --git a/apps/docs/content/docs/dev/content-engine/localization.mdx b/apps/docs/content/docs/dev/content-engine/localization.mdx index fa3005b47..b1e7f2615 100644 --- a/apps/docs/content/docs/dev/content-engine/localization.mdx +++ b/apps/docs/content/docs/dev/content-engine/localization.mdx @@ -48,11 +48,11 @@ example_localized_articles_translations itemId, languageId, version, 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). + + Localization works with `publication` and `editorial` from Stage 5B on, but + cannot yet be combined with `publicApi` or `search` - each is refused at + definition time with a message naming the stage that lifts it. See [Stage 5B + boundaries](#stage-5b-boundaries). ## This is not UI translation @@ -274,28 +274,32 @@ 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 +## Stage 5B 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. +Stage 5A landed the infrastructure; Stage 5B landed the editorial layer on top of +it. What is still missing is everything that reads *outwards*, 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. +Because `editorial.preview` requires `publicApi`, a **locale preview link cannot +be minted yet** either - the token format is in place and tested, and the routes +land with Stage 5C. See +[Locale preview](/docs/dev/content-engine/translation-preview). + ## 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` | +| **5A** | Tables, types, schemas, language resolution, translation service, per-locale locking, atomic create, routes, migrations | +| **5B** (this one) | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, `can_translate`, AdminCP locale tabs | | **5C** | Locale-aware public API, fallback resolution, locale-aware cache tags | | **5D** | Per-locale search documents, `hreflang`, localized sitemap | @@ -308,4 +312,7 @@ plugin onto the Content Engine. - [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 +- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - per-locale publish, the subordination rule, permissions and the locale tabs +- [Translation revisions](/docs/dev/content-engine/translation-revisions) - one history per language, and what a restore may not cross +- [Locale preview](/docs/dev/content-engine/translation-preview) - freezing one language, both halves of it - [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/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index a47740ede..b2d952c5d 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -23,6 +23,9 @@ "localized-fields", "translation-tables", "translation-service", + "translation-editorial", + "translation-revisions", + "translation-preview", "localization-migrations", "admincp", "permissions", diff --git a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx new file mode 100644 index 000000000..601bf90a3 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx @@ -0,0 +1,253 @@ +--- +title: Translation lifecycle +description: Each language publishes on its own schedule - and a translation is never public before the record is. +icon: Languages +--- + +[Localization](/docs/dev/content-engine/localization) gave a record one row per +language. This page is about what those rows *do*: a status of their own, a +publish button of their own, and a rule about how the two levels relate. + +```ts title="src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + + localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + publication: { enabled: true }, + editorial: { enabled: true }, + + fields: { + title: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, required: true }), + featured: field.boolean({ defaultValue: false }), + }, + admin: { label: { plural: "Articles", singular: "Article" } }, +}); +``` + +Three opt-ins, and each adds exactly what its name says. `localization` splits +the fields. `publication` gives the record - **and every translation** - a +draft/published lifecycle. `editorial` gives every translation its own version +and its own [revision history](/docs/dev/content-engine/translation-revisions). + +## Two levels, one rule + +```text +base record published AND translation published → this language is public +base record draft → no language is public +``` + +That is the whole model. A translation's status is **subordinate**: publishing +the Polish copy of a draft article puts nothing on the internet, and unpublishing +the article takes every language down at once. + + + A record going live exposes the languages that were *already* marked published, + and no others. This is the difference between "we are ready to launch" and "the + Polish copy is finished", and they are rarely the same day. It also means + nobody can ship a half-finished translation by pressing one button. + + +## The states + +| | What it means | +| --- | --- | +| **Missing** | No translation row for this language. Nothing to publish. | +| **Draft** | A translation exists and is not public. Where every one starts. | +| **Published** | Public - if the base record is published too. | + +There is deliberately no **Outdated**. Its honest definition is "the source +language changed after this translation did", and comparing two `updatedAt` +timestamps does not mean that: a typo fix in English would mark every language +stale, while a rewrite made a second before a translation was saved would not. A +badge that is wrong half the time is worse than no badge. + +## What each transition does + +```text +create a translation +→ status draft, version 1, publishedAt null + +publish +→ status published +→ publishedAt stamped (first time only) +→ version + 1, one revision, one event + +unpublish +→ status draft +→ publishedAt kept +→ version + 1, one revision, one event + +publish an already published translation +→ nothing at all + +unpublish an already draft translation +→ nothing at all +``` + +`publishedAt` means **"first published in this language"** and is never +rewritten. An unpublish leaves it alone because it stays true, and a republish +keeps the original date - which is what a "published on" line should show. + + + No version bump, no revision, no event, no search write, no cache + invalidation. That is what makes a double-clicked publish button, a retried + queue task and a re-run script all harmless. + + +## Doing it in code + +```ts +const service = articleContent.translationEditorialService?.(c, { pluginId }); + +const outcome = await service?.publish(7, "pl", { + actor: resolveContentActor(c), + // Optional. Publishing overwrites no field values, so requiring it would fail + // the button whenever a colleague had fixed a typo - for no protection against + // a lost update. When you do send it, it is AND-ed onto the state guard. + expectedVersion: 3, +}); +``` + +`translationEditorialService` is `undefined` unless the content type is **both** +localized and editorial, so TypeScript refuses the call until you have checked. + +The outcome carries everything the follow-up work needs: + +```ts +{ + changed: true, // false for a no-op + changedFields: [], // localized field names, never a shared one + languageId: 2, + locale: "pl", + operation: "publish", + previousSlug: "stary-adres", // the URL this language answered to before + restoredFromRevisionId: null, + revisionId: 812, + row: { /* the translation */ }, + version: 4, +} +``` + +### The transaction boundary + +```text +translation write +version increment +revision insert +── commit ── +event +search synchronisation +cache invalidation +``` + +Nothing after the line is inside the transaction, and nothing before it is +outside. A rolled-back transaction cannot un-send an event, so the announcement +waits for the commit; a revision written outside the transaction it describes is +a lie waiting to happen, so it goes inside. + +`contentTranslationEffects` does the announcing, and the generated routes call it +for you. A direct service call does not - it may be running inside a transaction +of yours - so opt in explicitly, after the commit: + +```ts +const outcome = await service.publish(7, "pl", { actor }); +if (outcome) { + await contentTranslationEffects(c, articleContentType, outcome, { pluginId }); +} +``` + +## Permissions + +| Action | Permission | +| --- | --- | +| Read a translation, read its history | `can_view` | +| Create or update a translation | `can_translate` | +| Publish or unpublish a translation | `can_publish` | +| Restore a translation revision | `can_restore` | +| Delete a non-default translation | `can_delete` | + +`can_translate` depends on `can_view` and **not** on `can_edit`, which is the +whole point of having it: a translator gets every locale tab without gaining the +ability to touch a shared field, move the record's global publication state or +delete it. + + + Staff permissions are stored as JSON per role, so a new one simply is not on + any existing role. Grant it in AdminCP → Staff. + + +The default-locale translation stays undeletable **even with `can_delete`**. It +is created with the record and is what makes "a record always resolves in some +language" true; delete the record itself instead. + +## Routes + +```http +POST /{id}/translations/{locale}/publish can_publish +POST /{id}/translations/{locale}/unpublish can_publish +``` + +Both take `{ "expectedVersion": 3 }` and answer +`{ "changed": true, "row": { … } }`. A stale version comes back as the same +structured 409 every translation route uses, with `locale` in every arm - which +is what lets a tab strip point at the right tab rather than at the record. + +Locales are canonical strings on the outside and numeric `core_languages.id` +values on the inside. A client never sends an id, so it can never point one at a +language it was not shown. + +## The AdminCP + +The edit dialog of a localized content type opens on a tab strip: + +```text +Shared | English ✓ | Polski ● | Deutsch ○ +``` + +- **Shared** holds the fields that are not per-language, plus the record's global + publication, history and scheduling. +- **Each locale tab** holds that language's fields, its status, its version, its + publish button, its history and - for anything but the default - its delete + button. + +The strip loads metadata only, in one request. A language's values are fetched +when its tab is opened, so opening the dialog on a record with nine languages +costs one query rather than nine. + +Only languages the app actually serves get a tab: they come from the app config, +already filtered to the enabled ones. And **opening a tab never creates a +translation** - a missing language shows `Missing` and an explicit create button, +because looking is not a decision to publish an empty page. + +### When somebody else got there first + +A stale save keeps the form exactly as you left it and shows a banner naming the +language that moved, with a **Reload this language** button. Nothing is retried +and nothing is merged: reloading is a decision, and so is saving over what the +reload reveals. + +English and Polish edits are two different rows with two different version +counters, so they never conflict with each other - only with another edit of the +*same* language. + +## Stage 5B boundaries + +Localization still cannot be combined with: + +- **`publicApi`** — a public read has to resolve a locale and decide what to do + when a translation is missing. Stage 5C. +- **`search`** — one document per record would index a single language and rank + every other one as a miss. Stage 5D. + +Because preview projects through `publicApi.fields`, **locale-bound preview links +cannot be minted yet** either. The token format already carries the locale and +both frozen revisions - see +[Translation preview](/docs/dev/content-engine/translation-preview) - and the +route that mints one lands with Stage 5C. + +Locale-specific *scheduling* stays outside Stage 5 entirely. A scheduled global +publish exposes the languages already marked published and publishes no drafts; a +scheduled global unpublish hides every language at once. diff --git a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx new file mode 100644 index 000000000..30995ffe8 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx @@ -0,0 +1,126 @@ +--- +title: Locale preview +description: How a preview link freezes one language - both halves of it - and why it never falls back. +icon: Eye +--- + +A [preview link](/docs/dev/content-engine/preview) shows an unpublished record to +somebody with no account. For a localized content type it has to answer one more +question: *which language?* + +## What the token carries + +The Stage 4 payload, plus three keys: + +```ts +{ + aud: "content-preview", + exp: 1793558400, // epoch seconds + i: 7, // the record + p: "@vitnode/example", + t: "example.article", + r: 812, // the SHARED revision this freezes + ver: 4, + + l: "pl", // the locale — present only for a translation preview + lid: 2, // core_languages.id, so the reader needs no lookup + tr: 915, // the TRANSLATION revision this freezes +} +``` + +A token with no `l` is a base preview and is byte-identical to what Stage 4 +minted, so links that already exist keep working and keep meaning what they +meant. + +## Both halves, or the guarantee is only half true + +`r` and `tr` are both there on purpose. A preview says "this is what the page +looked like when I shared the link" - and a localized page is built from two +rows. Freezing only the translation would let a shared field change underneath +the reviewer; freezing only the shared row would let the translation change. + + + A locale preview is bound to **one shared revision and one translation + revision**. Neither can drift. `0` in either slot means "there was no revision + to freeze" - a record or a translation that predates its content type opting + into `editorial` - and the live row is read for that half instead. Only that + half loses the guarantee, and only because there is nothing to freeze. + + +## It never falls back + +The locale check is symmetric, and both directions are a refusal: + +```text +token minted for pl, used to read en → refused +token minted for pl, used to read pl → accepted (case-insensitively) +token with no locale, used on a locale read → refused +token minted for pl, used on a base read → refused +``` + +Falling back would hand a reviewer a different language from the one whose link +they were sent, silently. A preview whose language can shift under it is not a +preview of anything. + +Every refusal is the same **404** the ordinary preview route answers for a forged +signature, an expired link or a record that never existed. A 401 or a 403 would +confirm the record exists, which is exactly what a draft URL must not do. + +## What a locale preview may show + +- **Only `publicApi.fields`.** The same projector the public detail route uses, + so a field cannot be public here and private there. +- **The record need not be published**, and neither need the translation. That is + the entire feature. +- **`Cache-Control: private, no-store`** and `X-Robots-Tag: noindex, nofollow`, + so it stays out of shared caches and out of search results if the link is + pasted somewhere public. +- **No side effects.** A preview creates no search document, invalidates no cache + tag and writes no revision. + +## Minting one + +```ts +const { expiresAt, token } = createContentPreviewToken({ + definition: articleContentType, + itemId: 7, + languageId: 2, + locale: "pl", + pluginId, + revisionId: sharedRevisionId, + secret, + translationRevisionId: polishRevisionId, + version, +}); +``` + +Reading one back: + +```ts +const payload = verifyContentPreviewToken({ + definition: articleContentType, + locale: "pl", // must match the token, or it is null + pluginId, + secret, + token, +}); +``` + +## Stage 5B boundary + +`editorial.preview` requires `publicApi`, and `publicApi` is still refused +alongside `localization` until Stage 5C - so **no locale preview link can be +minted yet**. What Stage 5B lands is the token format above and the rules that +make it safe: the locale binding, the two frozen revisions, the symmetric check +and the tamper rejection, all covered by tests. + +The route that mints one (`POST /{id}/translations/{locale}/preview`) and the +public route that reads one arrive with the locale-aware public API in Stage 5C. +Shipping the route now would ship a button that cannot be pressed. + +## Related + +- [Preview](/docs/dev/content-engine/preview) - the shared-row preview this + extends, and the secret both depend on +- [Translation revisions](/docs/dev/content-engine/translation-revisions) - what + `tr` points at diff --git a/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx new file mode 100644 index 000000000..b4d52b0ff --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/translation-revisions.mdx @@ -0,0 +1,186 @@ +--- +title: Translation revisions +description: Each language keeps its own history, and a restore can never cross a locale or reach a shared field. +icon: History +--- + +A localized content type with `editorial: { enabled: true }` keeps **one history +per language**. The Polish copy's versions are its own, the English copy's are +its own, and restoring one cannot touch the other. + +## Where they live + +The same `core_content_revisions` table every editorial content type already +uses, with one column added: + +```text +languageId = NULL → a shared revision (the base row) +languageId = 2 → a translation revision (that language) +``` + +`NULL` is what every revision written before Stage 5B already was, so the column +arrives nullable with no default and backfills to exactly the right value with no +data step. + +### Two partial unique indexes + +```sql +UNIQUE (contentTypeId, itemId, version) WHERE "languageId" IS NULL +UNIQUE (contentTypeId, itemId, languageId, version) WHERE "languageId" IS NOT NULL +``` + +Two, not one. English v3 and Polish v3 are two different facts, so a single key +over `(contentTypeId, itemId, version)` would reject the second - and a single +key that *included* a nullable `languageId` would enforce nothing at all for the +non-localized case it exists to protect, because Postgres treats every `NULL` as +distinct. + + + Matching how the table already declines a foreign key to the record itself. A + revision is an audit trail: "the Polish copy said this" stays true after the + language row is gone. A cascade would erase the fact, and a restrict would + block a language deletion the *translation* table has already had its say + about. The snapshot carries the locale code, so a revision stays readable + without the language it names. + + +## What a translation snapshot holds + +```jsonc +{ + "contentTypeId": "example.article", + "createdAt": "2026-02-01T10:00:00.000Z", + "fields": { "title": "Witaj", "slug": "witaj", "body": "…" }, + "itemId": 7, + "languageId": 2, + "locale": "pl", + "publication": { "publishedAt": null, "status": "draft" }, + "schemaVersion": 1, + "updatedAt": "2026-02-01T10:00:00.000Z", + "version": 3 +} +``` + +**Localized fields only.** What is absent is the point: + +- **No shared fields.** They live on the base row and have their own history. + This is a security boundary as much as a modelling one: a translation snapshot + that carried shared values would let a restore performed with `can_translate` + rewrite fields only `can_edit` may touch. +- **No other locale's values.** Restoring Polish must not touch English. +- **Nothing derived** - no public response object, no search document, no + relation labels. All three are shaped by configuration that may since have + changed. + +The publication pair is *recorded* but not restorable: it is absent from the +update schema, so a restore structurally cannot move it. + +## One revision per real mutation + +```text +create → a `create` revision +update → an `update` revision +publish → a `publish` revision +unpublish → an `unpublish` revision +restore → a `restore` revision, naming the one it came from +delete → a `delete` revision at version + 1 +``` + +A no-op writes none. An unchanged save, an already-published publish and a +restore whose values already match all leave the history exactly as it was. + +The `delete` revision is stamped at `version + 1` because the row is gone and +nothing holds the old version any more - recording it again would collide with +the revision that last wrote it. + + + Pass `pluginId` and an `actor` to `localizedService.create` and the default + translation gets its own `create` revision, in the same transaction as the row. + Without them it is written through the plain repository and leaves no history, + which is the Stage 5A behaviour - and which would make the record's original + English text the one state no revision ever recorded. + + +## Retention is per language + +`editorial.revisions.retention` applies **per locale**. Fifty Polish edits do not +evict the English history, because pruning runs inside the scope the write +belongs to. An install with nine languages keeps nine windows, not one shared +one. + +## Restore + +```ts +const outcome = await service.restore(7, "pl", revisionId, { + actor: resolveContentActor(c), + expectedVersion: 4, +}); +``` + +What it does, and equally what it refuses: + +- **Scoped by content type, item *and* language before anything is read.** A + revision id belonging to another locale is simply not found - never fetched and + then rejected, which would leak that it exists. +- **Validated against the *current* schemas**, not the ones in force when the + snapshot was taken. +- **Localized fields only**, written through the repository so slug uniqueness + and the version guard still apply. +- **Never moves publication state.** Rolling back a typo does not take a page off + the internet, and does not put one on it. +- **Never restores the historical version number.** The translation moves + *forward* to a new version whose revision records where the values came from. + Newer revisions are kept. +- **All or nothing.** Nothing is written unless everything validates. + +### Schema evolution + +| The content type has since… | Restore does | +| --- | --- | +| removed a localized field | ignores it | +| added an optional localized field | leaves it at its current value | +| added a **required** localized field the snapshot lacks | **refuses**, 422 | +| changed a rule the old value now fails | **refuses**, 422 | +| made that localized slug conflict | **refuses**, structured 409 | + +The 422 body names the content type's own field names and nothing else - never a +Zod issue tree, which would leak internal paths: + +```json +{ + "code": "CONTENT_REVISION_NOT_RESTORABLE", + "contentTypeId": "example.article", + "fields": ["summary"], + "revisionId": 812 +} +``` + +## Routes + +```http +GET /{id}/translations/{locale}/revisions can_view +GET /{id}/translations/{locale}/revisions/{revisionId} can_view +POST /{id}/translations/{locale}/revisions/{revisionId}/restore can_restore +``` + +Reading history is `can_view`, not `can_restore`: seeing what changed is part of +seeing the record at all, and "may look, may not roll back" is a reasonable role. + +The list is metadata only and paginates by **version**, newest first - the cursor +is the last version on the previous page and the route is exclusive on it, so +pages append cleanly and never repeat their boundary row. A snapshot loads on +demand, one at a time. + +## In the AdminCP + +Each locale tab has its own **Show this language's history** section, loaded when +it is opened rather than with the tab - a language's history can be long, and +nobody who only wanted to fix a typo should pay for it. Restore is offered on +every version but the current one, and only with `can_restore`. + +## Related + +- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - the + per-locale publish/unpublish these revisions record +- [Revisions](/docs/dev/content-engine/revisions) - the shared history the base + row keeps, and the retention rules both share diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx index 6caa27d6a..48ebb666c 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -260,6 +260,29 @@ content.example.article.scheduled content.example.article.schedule_cancelled ``` +A content type that opts into +[`localization`](/docs/dev/content-engine/localization) emits three more, plus +two with `publication` and one with `editorial`: + +```text +content.example.article.translation_created +content.example.article.translation_updated +content.example.article.translation_deleted + +content.example.article.translation_published (with publication) +content.example.article.translation_unpublished (with publication) + +content.example.article.translation_restored (with editorial) +``` + +Every one of them carries `locale` and `languageId`. They are deliberately +**not** folded into `updated`: a shared update and a Polish translation update +are different domain facts with different consequences - one invalidates every +language, the other invalidates one - and a listener that had to inspect +`changedFields` to tell them apart would get it wrong the first time a field was +renamed. `changedFields` on a translation event names localized fields only, +never a shared one. + They are registered on the global map by the owning plugin with a single `declare module` block, so the names and payloads are as strongly typed as any core event - `changedFields` narrows to that content type's own field names. @@ -293,6 +316,16 @@ core event - `changedFields` narrows to that content type's own field names. description: "Restored only - the revision the values were taken from.", type: "number", }, + locale: { + description: + "Translation events only - the canonical core_languages.code the mutation was made in. Always present, so a listener never has to go and ask which language.", + type: "string", + }, + languageId: { + description: + "Translation events only - core_languages.id, for a listener joining against the languages table directly.", + type: "number", + }, scheduleId: { description: "The schedule row this is about. Always present on scheduled and schedule_cancelled; present on published and unpublished only when a schedule fired them, where it doubles as the idempotency key for at-least-once retries.", diff --git a/apps/docs/migrations/0030_add_translation_editorial.sql b/apps/docs/migrations/0030_add_translation_editorial.sql new file mode 100644 index 000000000..f8adc3851 --- /dev/null +++ b/apps/docs/migrations/0030_add_translation_editorial.sql @@ -0,0 +1,13 @@ +DROP INDEX "example_localized_articles_translations_language_id_idx";--> statement-breakpoint +DROP INDEX "core_content_revisions_item_version_unique";--> statement-breakpoint +ALTER TABLE "core_content_revisions" ADD COLUMN "languageId" integer;--> statement-breakpoint +ALTER TABLE "example_localized_articles" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint +ALTER TABLE "example_localized_articles" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint +ALTER TABLE "example_localized_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_revisions_translation_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","languageId","version") WHERE "languageId" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "core_content_revisions_language_idx" ON "core_content_revisions" USING btree ("contentTypeId","itemId","languageId","version");--> statement-breakpoint +CREATE INDEX "example_localized_articles_status_published_at_idx" ON "example_localized_articles" USING btree ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "example_localized_articles_translations_language_id_status_idx" ON "example_localized_articles_translations" USING btree ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_revisions_item_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","version") WHERE "languageId" IS NULL; \ No newline at end of file diff --git a/apps/docs/migrations/meta/0030_snapshot.json b/apps/docs/migrations/meta/0030_snapshot.json new file mode 100644 index 000000000..4888b3300 --- /dev/null +++ b/apps/docs/migrations/meta/0030_snapshot.json @@ -0,0 +1,3260 @@ +{ + "id": "5bc8c627-98d3-4dc7-992e-3958232a8780", + "prevId": "48b24402-18d7-4bf2-88e4-f0c651f02a1d", + "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 + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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()" + }, + "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 + }, + "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": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_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": {}, + "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()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "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_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "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 db7bb2101..43bca7082 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1786034127995, "tag": "0029_add_example_localized_articles", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1786044755458, + "tag": "0030_add_translation_editorial", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index b250e8eea..63ca6d573 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -6,6 +6,8 @@ import type { ContentFieldKind, } from "../types"; +import { partitionContentFields } from "../localization"; + /** * A single form field, reduced to plain JSON. * @@ -71,6 +73,62 @@ const systemKinds: Record = { version: "number", }; +/** One field descriptor, projected into the serialisable form spec. */ +const projectFormField = ( + name: string, + fieldValue: ContentFieldDescriptor, + labelEnum: ContentEnumLabeller, + labelField: ContentFieldLabeller, +): ContentFormFieldSpec => { + const base: ContentFormFieldSpec = { + kind: fieldValue.kind, + label: labelField(name, fieldValue), + name, + nullable: fieldValue.nullable, + required: fieldValue.required, + ...(fieldValue.description === undefined + ? {} + : { description: fieldValue.description }), + }; + + switch (fieldValue.kind) { + case "boolean": + return { ...base, defaultValue: fieldValue.defaultValue }; + case "enum": + return { + ...base, + defaultValue: fieldValue.defaultValue, + display: fieldValue.display, + options: fieldValue.values.map(value => ({ + label: labelEnum(name, value), + value, + })), + }; + case "number": + return { + ...base, + defaultValue: fieldValue.defaultValue, + integer: fieldValue.integer, + max: fieldValue.max, + min: fieldValue.min, + }; + case "slug": + // No default and no minimum: an empty slug input means "derive it", + // and the server is what decides whether that is possible. + return { ...base, maxLength: fieldValue.maxLength }; + case "text": + case "textarea": + return { + ...base, + defaultValue: fieldValue.defaultValue, + maxLength: fieldValue.maxLength, + minLength: fieldValue.minLength, + }; + default: + return base; + } +}; + /** Projects a definition's form fields into the serialisable spec. */ export const buildContentFormSpec = ({ definition, @@ -89,56 +147,52 @@ export const buildContentFormSpec = ({ contentTypeId: definition.id, pluginId, titleField: definition.admin.titleField, - fields: definition.admin.form.fields.map(name => { - const fieldValue = fields[name]; - const base: ContentFormFieldSpec = { - kind: fieldValue.kind, - label: labelField(name, fieldValue), - name, - nullable: fieldValue.nullable, - required: fieldValue.required, - ...(fieldValue.description === undefined - ? {} - : { description: fieldValue.description }), - }; + // Shared fields only, because that is what `admin.form.fields` resolves to. + // A localized field's input lives on its locale tab - + // {@link buildContentTranslationFormSpec} builds that one. + fields: definition.admin.form.fields.map(name => + projectFormField(name, fields[name], labelEnum, labelField), + ), + }; +}; - switch (fieldValue.kind) { - case "boolean": - return { ...base, defaultValue: fieldValue.defaultValue }; - case "enum": - return { - ...base, - defaultValue: fieldValue.defaultValue, - display: fieldValue.display, - options: fieldValue.values.map(value => ({ - label: labelEnum(name, value), - value, - })), - }; - case "number": - return { - ...base, - defaultValue: fieldValue.defaultValue, - integer: fieldValue.integer, - max: fieldValue.max, - min: fieldValue.min, - }; - case "slug": - // No default and no minimum: an empty slug input means "derive it", - // and the server is what decides whether that is possible. - return { ...base, maxLength: fieldValue.maxLength }; - case "text": - case "textarea": - return { - ...base, - defaultValue: fieldValue.defaultValue, - maxLength: fieldValue.maxLength, - minLength: fieldValue.minLength, - }; - default: - return base; - } - }), +/** + * The form spec for **one locale tab**: localized fields only. + * + * `null` for a content type that is not localized, so a caller structurally cannot + * render a locale tab for something with no translations. + * + * Built from `partitionContentFields` rather than from `admin.form.fields`, which + * resolves to shared names only - and in declaration order, so the tab shows title + * above body for the same reason the shared form shows its fields in the order they + * were written. + */ +export const buildContentTranslationFormSpec = ({ + definition, + labelEnum, + labelField, + pluginId, +}: { + definition: AnyContentTypeDefinition; + labelEnum: ContentEnumLabeller; + labelField: ContentFieldLabeller; + pluginId: string; +}): ContentFormSpec | null => { + if (!definition.localization.enabled) return null; + + const { localizedFields } = partitionContentFields(definition.fields); + const fields = Object.entries(localizedFields).map(([name, fieldValue]) => + projectFormField(name, fieldValue, labelEnum, labelField), + ); + + return { + contentTypeId: definition.id, + fields, + pluginId, + // The shared `titleField` names a shared field by construction, so it would + // describe the wrong thing in a locale toast. The first localized `text` field + // is what a translator is actually looking at. + titleField: fields.find(field => field.kind === "text")?.name ?? null, }; }; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 231e67e87..5414bc924 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -28,6 +28,19 @@ export const CONTENT_TRANSLATION_SYSTEM_FIELDS = [ "updatedAt", ] as const; +/** + * The two columns a translation row gains when the content type has publication. + * + * The same names the base table uses, and deliberately so: a translation's + * lifecycle is the same lifecycle, one row down. Present only with + * `publication: { enabled: true }` - without it there is no draft state for a + * translation's own status to be subordinate to. + */ +export const CONTENT_TRANSLATION_PUBLICATION_FIELDS = [ + "status", + "publishedAt", +] as const; + export const CONTENT_PUBLICATION_STATUSES = ["draft", "published"] as const; const publicationStatuses: ReadonlySet = new Set( @@ -216,6 +229,23 @@ export const CONTENT_REVISION_OPERATIONS = [ "update", ] as const; +/** + * What a *translation* revision records. + * + * The same six operations, and the same one-per-real-mutation rule. Its own list + * rather than a reuse of {@link CONTENT_REVISION_OPERATIONS} so the two can + * diverge without a silent widening - a translation cannot be scheduled, and a + * shared row cannot be translated. + */ +export const CONTENT_TRANSLATION_REVISION_OPERATIONS = [ + "create", + "delete", + "publish", + "restore", + "unpublish", + "update", +] as const; + /** * Who performed a mutation. * @@ -325,8 +355,9 @@ export const CONTENT_SCHEDULE_CODES = { /** * Every content type gets the first four staff permissions. `can_publish` is - * generated only for content types with `publication: { enabled: true }`, and - * `can_restore` only for those with `editorial: { enabled: true }`. + * generated only for content types with `publication: { enabled: true }`, + * `can_restore` only for those with `editorial: { enabled: true }`, and + * `can_translate` only for those with `localization: { enabled: true }`. */ export const CONTENT_PERMISSIONS = { create: "can_create", @@ -334,6 +365,7 @@ export const CONTENT_PERMISSIONS = { edit: "can_edit", publish: "can_publish", restore: "can_restore", + translate: "can_translate", view: "can_view", } as const; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index ea77666ad..bbf2f94e7 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1256,7 +1256,6 @@ export const defineContentType = < // 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 diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts index 295d84a8b..6ff8a271e 100644 --- a/packages/vitnode/src/content/events.ts +++ b/packages/vitnode/src/content/events.ts @@ -1,4 +1,4 @@ -import type { ContentFieldName } from "./types"; +import type { ContentFieldName, ContentLocalizedFieldName } from "./types"; export type ContentEventAction = | "created" @@ -7,6 +7,12 @@ export type ContentEventAction = | "restored" | "schedule_cancelled" | "scheduled" + | "translation_created" + | "translation_deleted" + | "translation_published" + | "translation_restored" + | "translation_unpublished" + | "translation_updated" | "unpublished" | "updated"; @@ -149,6 +155,121 @@ type ContentEditorialEventsFor = > : Record); +/** + * What every translation event carries. + * + * `locale` first because it is the one field a listener always needs: an event + * that said only "article 7 changed" would force every consumer to go and ask + * which language, and half of them would forget. `languageId` rides along for + * anything joining against `core_languages` directly. + * + * Deliberately **not** folded into `updated`. A shared update and a Polish + * translation update are different domain facts with different consequences - one + * invalidates every locale, the other invalidates one - and a listener that had to + * inspect `changedFields` to tell them apart would get it wrong the first time a + * field was renamed. + */ +export interface ContentTranslationEventPayload { + contentId: number; + languageId: number; + /** The canonical `core_languages.code`. */ + locale: string; + /** The version *this translation* holds after the mutation. */ + version: number; +} + +export interface ContentTranslationCreatedPayload extends ContentTranslationEventPayload { + /** + * The revision this mutation wrote. + * + * Absent for a localized content type without `editorial`, which keeps no + * history - optional rather than `0`, so a listener that acts on a revision + * cannot be handed one that does not exist. + */ + revisionId?: number; +} + +export interface ContentTranslationUpdatedPayload< + TDefinition, +> extends ContentTranslationEventPayload { + /** Localized field names this write moved. Never a shared field. */ + changedFields: ContentLocalizedFieldName[]; + revisionId?: number; +} + +export interface ContentTranslationDeletedPayload extends ContentTranslationEventPayload { + revisionId?: number; +} + +export interface ContentTranslationPublishedPayload extends ContentTranslationEventPayload { + /** When this language was first published; never rewritten. */ + publishedAt: Date | null; + revisionId?: number; +} + +export interface ContentTranslationUnpublishedPayload extends ContentTranslationEventPayload { + revisionId?: number; +} + +export interface ContentTranslationRestoredPayload< + TDefinition, +> extends ContentTranslationEventPayload { + changedFields: ContentLocalizedFieldName[]; + /** The revision the values came from - always one of this locale's own. */ + restoredFromRevisionId: number; + /** The revision this restore itself created. */ + revisionId: number; +} + +/** + * The six events a localized content type adds. + * + * Gated on `localization: { enabled: true }` exactly like the publication and + * editorial pairs, so a content type without it gains no key at all and a + * listener for one cannot be registered. That is what keeps every non-localized + * payload byte-identical to what it was before Stage 5B. + * + * The three lifecycle events are gated a second time on publication: without it + * there is no translation status to move. + */ +type ContentLocalizationEventsFor = + (TDefinition extends { + editorial: { enabled: true }; + localization: { enabled: true }; + } + ? Record< + `content.${TDefinition["id"]}.translation_restored`, + ContentTranslationRestoredPayload + > + : Record) & + (TDefinition extends { + localization: { enabled: true }; + publication: { enabled: true }; + } + ? Record< + `content.${TDefinition["id"]}.translation_published`, + ContentTranslationPublishedPayload + > & + Record< + `content.${TDefinition["id"]}.translation_unpublished`, + ContentTranslationUnpublishedPayload + > + : Record) & + (TDefinition extends { localization: { enabled: true } } + ? Record< + `content.${TDefinition["id"]}.translation_created`, + ContentTranslationCreatedPayload + > & + Record< + `content.${TDefinition["id"]}.translation_deleted`, + ContentTranslationDeletedPayload + > & + Record< + `content.${TDefinition["id"]}.translation_updated`, + ContentTranslationUpdatedPayload + > + : Record); + /** * The events a content type emits, as a literal-keyed map. * @@ -169,6 +290,7 @@ type ContentEditorialEventsFor = */ export type ContentEventsFor = ContentEditorialEventsFor & + ContentLocalizationEventsFor & ContentPublicationEventsFor & Record<`content.${TDefinition["id"]}.created`, ContentCreatedPayload> & Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> & diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 4e996b7e6..fdfeb7deb 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -15,6 +15,7 @@ export { export { buildContentColumnSpec, buildContentFormSpec, + buildContentTranslationFormSpec, buildFormSchemaFromSpec, contentFormValuesToPayload, contentTitleFromValues, @@ -98,6 +99,8 @@ export { CONTENT_SYSTEM_FIELDS, CONTENT_TEXT_DEFAULT_LENGTH, CONTENT_TRANSLATION_CONFLICT_CODES, + CONTENT_TRANSLATION_PUBLICATION_FIELDS, + CONTENT_TRANSLATION_REVISION_OPERATIONS, CONTENT_TRANSLATION_SYSTEM_FIELDS, CONTENT_TRANSLATION_TABLE_SUFFIX, CONTENT_UNPROCESSABLE_CODES, @@ -125,6 +128,14 @@ export type { ContentEventAction, ContentEventsFor, ContentPublishedPayload, + ContentRestoredPayload, + ContentTranslationCreatedPayload, + ContentTranslationDeletedPayload, + ContentTranslationEventPayload, + ContentTranslationPublishedPayload, + ContentTranslationRestoredPayload, + ContentTranslationUnpublishedPayload, + ContentTranslationUpdatedPayload, ContentUnpublishedPayload, ContentUpdatedPayload, } from "./events"; @@ -160,12 +171,15 @@ export { contentRevisionDiff } from "./revisions"; export type { ContentActor, ContentActorType, + ContentAnyRevisionSnapshot, ContentRevisionDetail, ContentRevisionDiffEntry, ContentRevisionMeta, ContentRevisionOperation, ContentRevisionSnapshot, ContentSnapshotValue, + ContentTranslationRevisionOperation, + ContentTranslationRevisionSnapshot, } from "./revisions"; export { contentScheduleTimingError } from "./schedules"; export type { @@ -244,6 +258,7 @@ export type { ContentTextareaField, ContentTextField, ContentTranslationMeta, + ContentTranslationPublicationColumns, ContentTranslationRow, ContentTranslationSystemField, ContentTypeDefinition, diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index 7a2d02980..100f0eab3 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -62,19 +62,27 @@ export const contentTranslationPrimaryKeyName = ( * 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. + * a 409, + * 3. `(languageId, status)` when the content type has publication - the public + * read's "every published Polish translation" and the AdminCP's per-locale + * completeness counts both start there. It supersedes (1), which is a prefix + * of it, so the two are deduplicated below rather than both created. */ export const resolveContentTranslationIndexes = ({ contentTypeId, localizedFields, + publication = false, translationTableName, }: { contentTypeId: string; localizedFields: ContentFieldMap; + publication?: boolean; translationTableName: string; }): ResolvedContentIndex[] => { const indexes: ResolvedContentIndex[] = [ - named(translationTableName, { on: ["languageId"] }), + ...(publication + ? [named(translationTableName, { on: ["languageId", "status"] })] + : [named(translationTableName, { on: ["languageId"] })]), ...Object.entries(localizedFields) .filter(([, fieldValue]) => fieldValue.kind === "slug") .map(([name]) => diff --git a/packages/vitnode/src/content/localization.test.ts b/packages/vitnode/src/content/localization.test.ts index 29c01c362..85cc6dd8a 100644 --- a/packages/vitnode/src/content/localization.test.ts +++ b/packages/vitnode/src/content/localization.test.ts @@ -14,8 +14,22 @@ import { contentTranslationTableName, isLocalizedContentField, partitionContentFields, + resolveContentLocalization, } from "./localization"; +/** `publicApi` as `defineContentType` resolves it when there is none. */ +const disabledPublicApi = { + defaultOrder: "desc" as const, + defaultOrderBy: "publishedAt", + enabled: false as const, + fields: [] as never[], + filterableFields: [] as never[], + orderableFields: [] as never[], + path: "", + searchableFields: [] as never[], + slugField: "", +}; + /** Builds a localized content type with one thing swapped out. */ const localized = ( overrides: Parameters[0] extends never @@ -315,7 +329,7 @@ describe("localization validation", () => { }); }); -describe("Stage 5A capability boundaries", () => { +describe("Stage 5B capability boundaries", () => { const withCapability = (extra: Record) => defineContentType({ id: "test.boundary", @@ -329,16 +343,22 @@ describe("Stage 5A capability boundaries", () => { ...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("allows localization plus publication from Stage 5B", () => { + const definition = withCapability({ publication: { enabled: true } }); + + expect(definition.publication.enabled).toBe(true); + // The translation table gains the pair the base table has, so a translation + // has a status of its own to be subordinate with. + expect( + definition.localization.translationIndexes.map(index => index.on), + ).toContainEqual(["languageId", "status"]); }); - it("refuses localization plus editorial until Stage 5B", () => { - expect(() => withCapability({ editorial: { enabled: true } })).toThrow( - /Per-locale revisions land in Stage 5B/, - ); + it("allows localization plus editorial from Stage 5B", () => { + const definition = withCapability({ editorial: { enabled: true } }); + + expect(definition.editorial.enabled).toBe(true); + expect(definition.localization.enabled).toBe(true); }); it("refuses localization plus publicApi until Stage 5C", () => { @@ -350,16 +370,50 @@ describe("Stage 5A capability boundaries", () => { ).toThrow(); }); - it("names the stage in every boundary message", () => { + it("refuses localization plus search until Stage 5D", () => { + expect(() => + withCapability({ + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["slug"], path: "boundaries" }, + search: { enabled: true, titleField: "title" }, + }), + ).toThrow(); + }); + + it("names the stage in every remaining 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/); + const messageOf = (extra: Record): string => { + try { + withCapability(extra); + } catch (error) { + return error instanceof Error ? error.message : ""; + } + + return ""; + }; + + expect( + messageOf({ + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["slug"], path: "boundaries" }, + }), + ).toMatch(/Stage 5C/); + // `search` cannot be reached through `defineContentType` while `publicApi` is + // still refused - a searchable content type has to be a public one - so the + // 5D message is asserted against the resolver directly. + expect(() => + resolveContentLocalization({ + fields: { + title: field.text({ localized: true, required: true }), + }, + id: "test.boundary", + localization: { defaultLocale: "en", enabled: true }, + publicApi: { ...disabledPublicApi }, + publication: true, + search: true, + tableName: "test_boundaries", + }), + ).toThrow(/Stage 5D/); }); }); diff --git a/packages/vitnode/src/content/localization.ts b/packages/vitnode/src/content/localization.ts index bc632cec8..86dcad606 100644 --- a/packages/vitnode/src/content/localization.ts +++ b/packages/vitnode/src/content/localization.ts @@ -186,45 +186,22 @@ const assertLocalizedFields = ( }; /** - * Stage 5A boundaries. + * Stage 5B 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. + * Stage 5A landed the infrastructure and Stage 5B the editorial layer on top of + * it: per-locale publication, per-locale revisions, restore and the locale + * editor. What is still missing is everything that reads *outwards* - the public + * API and the search index - 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; - }, + { publicApi, search }: { publicApi: 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.", @@ -249,7 +226,6 @@ const assertStageBoundaries = ( * widened somewhere upstream, can reach this with anything at all. */ export const resolveContentLocalization = ({ - editorial, fields, id, localization, @@ -258,7 +234,6 @@ export const resolveContentLocalization = ({ search, tableName, }: { - editorial: boolean; fields: ContentFieldMap; id: string; localization: ContentLocalizationConfig | undefined; @@ -281,12 +256,7 @@ export const resolveContentLocalization = ({ return contentLocalizationDisabled(); } - assertStageBoundaries(id, { - editorial, - publicApi: publicApi.enabled, - publication, - search, - }); + assertStageBoundaries(id, { publicApi: publicApi.enabled, search }); const defaultLocale = assertDefaultLocale(id, localization.defaultLocale); assertLocalizedFields(id, fields, localizedFields); @@ -301,6 +271,7 @@ export const resolveContentLocalization = ({ translationIndexes: resolveContentTranslationIndexes({ contentTypeId: id, localizedFields, + publication, translationTableName, }), translationTableName, diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index f8338c222..2c71a5723 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -230,6 +230,21 @@ export const contentPermissionEntries = ( }, ] : []), + // Writing a translation is a different job from editing the record, and often a + // different person's. `can_translate` depends on `can_view` and **not** on + // `can_edit`, which is the whole point: a translator can be given every locale + // tab without being able to touch a shared field, change the global publication + // state or delete the record. Existing roles simply do not have it - permissions + // are stored as JSON per role, so adding one denies by default and needs no + // migration. + ...(definition?.localization.enabled + ? [ + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.translate, + }, + ] + : []), ]; /** diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts index a9b02057c..f9a3d0a78 100644 --- a/packages/vitnode/src/content/revisions.ts +++ b/packages/vitnode/src/content/revisions.ts @@ -1,8 +1,15 @@ -import type { CONTENT_ACTOR_TYPES, CONTENT_REVISION_OPERATIONS } from "./const"; +import type { + CONTENT_ACTOR_TYPES, + CONTENT_REVISION_OPERATIONS, + CONTENT_TRANSLATION_REVISION_OPERATIONS, +} from "./const"; export type ContentRevisionOperation = (typeof CONTENT_REVISION_OPERATIONS)[number]; +export type ContentTranslationRevisionOperation = + (typeof CONTENT_TRANSLATION_REVISION_OPERATIONS)[number]; + export type ContentActorType = (typeof CONTENT_ACTOR_TYPES)[number]; /** @@ -53,6 +60,50 @@ export interface ContentRevisionSnapshot { version: number; } +/** + * The complete post-mutation state of **one translation**. + * + * The same design as {@link ContentRevisionSnapshot} - complete, plain JSON, + * nothing derived - restricted to one language. What is absent is the point: + * + * - **no shared fields.** They live on the base row and have their own history. + * A translation restore that carried them would let somebody with + * `can_translate` rewrite the record's shared values through the back door. + * - **no other locale's values.** Restoring Polish must not touch English. + * - **no public response object and no search document.** Both are derived, and + * both are shaped by configuration that may since have changed. + * + * `locale` is carried alongside `languageId` on purpose: the revision row's + * `languageId` has no foreign key, so this is what keeps a revision readable + * after the language it names has been deleted. + */ +export interface ContentTranslationRevisionSnapshot { + contentTypeId: string; + createdAt: string; + /** Every *localized* field, by name. */ + fields: Record; + itemId: number; + languageId: number; + /** The canonical `core_languages.code` at the time of the mutation. */ + locale: string; + /** Present only for a content type with the publication lifecycle. */ + publication?: { publishedAt: null | string; status: string }; + schemaVersion: number; + updatedAt: string; + /** The version *this translation* holds after the mutation. */ + version: number; +} + +/** + * Either snapshot shape, for the shared `core_content_revisions.snapshot` column. + * + * They are told apart by the row's `languageId`, not by inspecting the JSON: the + * column is what the query filters on, and a discriminator inside the payload + * would be a second source of truth for the same fact. + */ +export type ContentAnyRevisionSnapshot = + ContentRevisionSnapshot | ContentTranslationRevisionSnapshot; + /** One revision as the history list shows it - metadata, never the snapshot. */ export interface ContentRevisionMeta { /** Display name of the actor, or `null` for a system mutation. */ @@ -67,9 +118,18 @@ export interface ContentRevisionMeta { version: number; } -/** One revision with its snapshot, loaded on demand. */ -export interface ContentRevisionDetail extends ContentRevisionMeta { - snapshot: ContentRevisionSnapshot; +/** + * One revision with its snapshot, loaded on demand. + * + * Generic over the snapshot shape so the translation history reads + * `ContentRevisionDetail` and gets the + * localized shape - without a second model, and without widening the existing + * default that every Stage 4 caller relies on. + */ +export interface ContentRevisionDetail< + TSnapshot = ContentRevisionSnapshot, +> extends ContentRevisionMeta { + snapshot: TSnapshot; } export interface ContentRevisionDiffEntry { diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 801cb1677..ce8ac0b73 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -373,10 +373,12 @@ const buildTranslationSchemas = ({ admin, localizedFields, localization, + publication, }: { admin: ResolvedContentAdminConfig; localization: ResolvedContentLocalizationConfig; localizedFields: ContentFieldMap; + publication: boolean; }): ContentTranslationSchemas | null => { if (!localization.enabled) return null; @@ -390,11 +392,20 @@ const buildTranslationSchemas = ({ }); const expectedVersion = z.number().int().positive(); + // Read-only on the wire, exactly like the base row's pair: absent from + // `create` and `update` (both strict), so the only way to move them is + // `publish` / `unpublish`. const selectMeta = z.object({ createdAt: z.date(), itemId: z.number().int().positive(), languageId: z.number().int().positive(), locale: z.string(), + ...(publication + ? { + publishedAt: z.date().nullable(), + status: z.enum(CONTENT_PUBLICATION_STATUSES), + } + : {}), updatedAt: z.date(), version: expectedVersion, }); @@ -576,6 +587,7 @@ export const buildContentSchemas = ({ admin, localization, localizedFields, + publication, }), update: update as unknown as z.ZodType>, updateEnvelope: z.strictObject({ diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index d6dcddaa1..e460fd74b 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -125,6 +125,21 @@ export const buildTranslationSystemColumns = ({ .$onUpdate(() => new Date()), }); +/** + * The two columns a translation row gains with `publication: { enabled: true }`. + * + * Literally {@link buildPublicationColumns}, aliased so the translation table + * reads as what it is rather than borrowing a name that says "base table". The + * `DEFAULT 'draft'` is what makes this migration safe on an install that already + * has Stage 5A translations: every existing row becomes a draft in one statement, + * which is the only correct backfill - silently publishing translations somebody + * wrote while the feature did not exist would put them on the internet. + */ +export const buildTranslationPublicationColumns = (): Record< + string, + PgColumnBuilderBase +> => buildPublicationColumns(); + /** * Applies `NOT NULL` and the column default. * diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index d802826df..e3dab4da7 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -27,6 +27,7 @@ import { ContentRevisionNotRestorable, ContentVersionConflict, } from "../errors"; +import { partitionContentFields } from "../localization"; import { diffChangedFields, toColumnValues } from "./query"; import { contentRevisionSnapshot, @@ -160,7 +161,12 @@ export const createContentEditorialService = < } const contentTypeId = definition.id; - const fields = definition.fields; + // Shared fields only, everywhere below. A localized field is a column on the + // translation table, so selecting it here would address something that does not + // exist, and diffing it would report every language's value as changed at once. + // `partitionContentFields` returns every field for a content type without + // localization, so this is exactly the previous behaviour there. + const fields = partitionContentFields(definition.fields).sharedFields; const fieldNames = Object.keys(fields) as ContentFieldName[]; const primaryCursor = columns.id; const versionColumn = columns.version; diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts index 1d4b10ff6..e2b5b3c4c 100644 --- a/packages/vitnode/src/content/server/emit.ts +++ b/packages/vitnode/src/content/server/emit.ts @@ -10,6 +10,12 @@ import type { ContentDeletedPayload, ContentEventAction, ContentPublishedPayload, + ContentTranslationCreatedPayload, + ContentTranslationDeletedPayload, + ContentTranslationPublishedPayload, + ContentTranslationRestoredPayload, + ContentTranslationUnpublishedPayload, + ContentTranslationUpdatedPayload, ContentUnpublishedPayload, ContentUpdatedPayload, } from "../events"; @@ -21,6 +27,12 @@ type ContentPayload = | ContentCreatedPayload | ContentDeletedPayload | ContentPublishedPayload + | ContentTranslationCreatedPayload + | ContentTranslationDeletedPayload + | ContentTranslationPublishedPayload + | ContentTranslationRestoredPayload + | ContentTranslationUnpublishedPayload + | ContentTranslationUpdatedPayload | ContentUnpublishedPayload | ContentUpdatedPayload; diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 435d19a75..e37f26101 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, + buildTranslationPublicationColumns, buildTranslationSystemColumns, } from "./column-builders"; export type { ColumnReferenceThunk } from "./column-builders"; @@ -103,7 +104,10 @@ export type { ReferenceTarget } from "./references"; export { contentRevisionSnapshot, contentSnapshotRow, + contentTranslationRevisionSnapshot, + contentTranslationSnapshotRow, projectRevisionSnapshot, + projectTranslationRevisionSnapshot, } from "./revision-snapshot"; export { CONTENT_REVISIONS_DEFAULT_PAGE_SIZE, @@ -165,6 +169,20 @@ export { contentTableColumns, createContentTable, } from "./table"; +export { createContentTranslationEditorialService } from "./translation-editorial-service"; +export type { + ContentRevisionDetailForLocale, + ContentTranslationEditorialOptions, + ContentTranslationEditorialOutcome, + ContentTranslationEditorialService, + ContentTranslationEditorialTransitionOptions, + ContentTranslationEditorialWriteOptions, +} from "./translation-editorial-service"; +export { contentTranslationEffects } from "./translation-effects"; +export type { + ContentTranslationEffectsOptions, + ContentTranslationEffectsResult, +} from "./translation-effects"; export { contentTranslationConflict, withTranslationHttpErrors, @@ -173,6 +191,8 @@ export { createContentTranslationModel } from "./translation-model"; export type { ContentTranslationModel, ContentTranslationOptions, + ContentTranslationTransitionOptions, + ContentTranslationTransitionResult, ContentTranslationUpdateResult, ContentTranslationWriteOptions, } from "./translation-model"; diff --git a/packages/vitnode/src/content/server/localized-service.ts b/packages/vitnode/src/content/server/localized-service.ts index b82572b2f..c25d7f06a 100644 --- a/packages/vitnode/src/content/server/localized-service.ts +++ b/packages/vitnode/src/content/server/localized-service.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; +import type { ContentActor } from "../revisions"; import type { AnyContentTypeDefinition, ContentLocalizedValues, @@ -8,6 +9,7 @@ import type { ContentTranslationRow, } from "../types"; import type { ContentDatabase, ContentService } from "./service"; +import type { ContentTranslationEditorialService } from "./translation-editorial-service"; import type { ContentTranslationModel } from "./translation-model"; import { ContentEngineError } from "../errors"; @@ -20,6 +22,16 @@ export interface ContentLocalizedCreateInput { } export interface ContentLocalizedCreateOptions { + /** + * Who is creating the record. + * + * Supply it on an editorial content type and the default translation gets its + * own `create` revision, in the same transaction as the row - which is what + * makes the earliest restorable English state the one the record was created + * with. Without it (or without `editorial`) the translation is written through + * the plain repository and leaves no history, exactly as in Stage 5A. + */ + actor?: ContentActor; /** * The locale the first translation is written in. Defaults to - and, today, * may only be - the content type's configured default locale. @@ -62,11 +74,20 @@ export const createContentLocalizedService = < >({ c, definition, + editorial, service, translations, }: { c: Context; definition: TDefinition; + /** + * The translation editorial layer, when the content type has one. + * + * Optional so a localized content type without `editorial` keeps exactly the + * Stage 5A behaviour: the default translation is written through the repository + * and leaves no history, because there is no history to leave. + */ + editorial?: ContentTranslationEditorialService; service: ContentService; translations: ContentTranslationModel; }): ContentLocalizedService => { @@ -105,6 +126,23 @@ export const createContentLocalizedService = < const language = await translations.resolveDefaultLanguage({ tx }); const row = await service.create(shared, { tx }); + + // Through the editorial layer when there is one and an actor to attribute + // it to, so the default translation's first values are restorable rather + // than being the one state no revision ever recorded. Same transaction + // either way: the base row and its default translation still commit or + // roll back together. + if (editorial && options.actor) { + const outcome = await editorial.create( + row.id, + language.locale, + translation, + { actor: options.actor, tx }, + ); + + return { row, translation: outcome.row }; + } + const created = await translations.create( row.id, language.locale, diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index 418620875..9e3f49a50 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -10,6 +10,7 @@ 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 { ContentTranslationEditorialService } from "./translation-editorial-service"; import type { ContentTranslationModel } from "./translation-model"; import type { ContentColumnName, @@ -25,6 +26,7 @@ import { createContentLocalizedService } from "./localized-service"; import { createContentPublicService } from "./public-service"; import { createContentService } from "./service"; import { contentTableColumns, createContentTable } from "./table"; +import { createContentTranslationEditorialService } from "./translation-editorial-service"; import { createContentTranslationModel } from "./translation-model"; import { contentTranslationTableColumns, @@ -61,13 +63,17 @@ export interface ContentModel { * 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. + * `options.pluginId` is optional and additive: supply it on an editorial content + * type and the default translation gets its own `create` revision, stamped with + * the right owner. Omit it - as every Stage 5A caller does - and the translation + * is written through the plain repository exactly as before. */ localizedService: - ((c: Context) => ContentLocalizedService) | undefined; + | (( + c: Context, + options?: { pluginId?: string }, + ) => ContentLocalizedService) + | undefined; /** * The read-only public repository, or `undefined` when the content type has * no `publicApi`. @@ -91,6 +97,20 @@ export interface ContentModel { ContentTranslationColumnName, PgColumn >; + /** + * The transactional translation editorial layer, or `undefined` unless the + * content type is **both** localized and editorial. + * + * `undefined` rather than a throwing stub, matching every other optional member + * here: 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. + */ + translationEditorialService: + | (( + c: Context, + options: { pluginId: string }, + ) => ContentTranslationEditorialService) + | undefined; /** The per-language schemas, or `null`. Mirrored off `schemas.translation`. */ translationSchemas: ContentTranslationSchemas | null; /** @@ -231,10 +251,32 @@ export const createContentModel = < : undefined, localization: definition.localization, localizedService: localized - ? (c: Context) => - createContentLocalizedService({ + ? (c: Context, options?: { pluginId?: string }) => { + const translations = buildTranslations(c); + const owner = options?.pluginId; + + return createContentLocalizedService({ c, definition, + // Shares the request's translation model with the editorial layer, so + // both halves of an atomic create resolve the same language through + // the same per-request cache. + // + // Built only when the caller named the owning plugin: a revision is + // stamped with its owner, and inventing one would put a wrong value in + // the column the cleanup job keys off. + editorial: + definition.editorial.enabled && + translationSchemas && + owner !== undefined + ? createContentTranslationEditorialService({ + c, + definition, + pluginId: owner, + schemas: translationSchemas, + translations, + }) + : undefined, service: createContentService({ c, columns, @@ -242,8 +284,9 @@ export const createContentModel = < schemas, table, }), - translations: buildTranslations(c), - }) + translations, + }); + } : undefined, publicService: definition.publicApi.enabled ? (c: Context) => @@ -261,6 +304,20 @@ export const createContentModel = < table, translationColumns, translationSchemas, + // Both flags, because the layer needs both halves: localization gives it a + // table to write and `editorial` gives it a history to write to. A localized + // content type without `editorial` keeps the plain repository and nothing else. + translationEditorialService: + localized && definition.editorial.enabled && translationSchemas + ? (c: Context, { pluginId }: { pluginId: string }) => + createContentTranslationEditorialService({ + c, + definition, + pluginId, + schemas: translationSchemas, + translations: buildTranslations(c), + }) + : undefined, translationService: localized ? buildTranslations : undefined, translationTable, }; diff --git a/packages/vitnode/src/content/server/preview-token.ts b/packages/vitnode/src/content/server/preview-token.ts index 45da24be4..8fef84ad3 100644 --- a/packages/vitnode/src/content/server/preview-token.ts +++ b/packages/vitnode/src/content/server/preview-token.ts @@ -3,7 +3,10 @@ import { z } from "zod"; import type { AnyContentTypeDefinition } from "../types"; import { signPayload, verifySignedPayload } from "../../lib/api/signed-token"; -import { CONTENT_PREVIEW_TOKEN_VERSION } from "../const"; +import { + CONTENT_LOCALE_MAX_LENGTH, + CONTENT_PREVIEW_TOKEN_VERSION, +} from "../const"; /** * What a preview link carries, in short keys because it travels in a URL. @@ -24,9 +27,30 @@ export const zodContentPreviewTokenPayload = z.object({ /** Epoch **seconds**, not milliseconds. */ exp: z.number().int().positive(), i: z.number().int().positive(), + /** + * The locale this token previews, for a **translation** preview. + * + * Absent on a base preview, which is what every token minted before Stage 5B + * is - so old links keep working and mean exactly what they meant. Present, it + * binds the token to one language: a `pl` token used on the English tab is + * refused rather than falling back, because a preview whose language could + * shift under it is not a preview of anything. + */ + l: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH).optional(), + /** `core_languages.id`, so the reader needs no second lookup. */ + lid: z.number().int().positive().optional(), p: z.string().min(1), r: z.number().int().nonnegative(), t: z.string().min(1), + /** + * The **translation** revision this token freezes. + * + * Present exactly when `l` is. Together with `r` - the shared revision - it is + * what makes the frozen guarantee whole: Option A of the two models, where the + * token names both halves. `0` means the translation had no revision to freeze, + * which the reader treats the same way `r: 0` is treated for the base row. + */ + tr: z.number().int().nonnegative().optional(), v: z.literal(CONTENT_PREVIEW_TOKEN_VERSION), ver: z.number().int().positive(), }); @@ -50,19 +74,33 @@ export interface ContentPreviewToken { export const createContentPreviewToken = ({ definition, itemId, + languageId, + locale, now = new Date(), pluginId, revisionId, secret, + translationRevisionId, version, }: { definition: AnyContentTypeDefinition; itemId: number; + /** Required with `locale`: `core_languages.id` for that locale. */ + languageId?: number; + /** + * The locale to freeze, for a translation preview. Omit for a base preview. + * + * Supplying it makes the token mean something narrower, not something wider: it + * previews *that* language, and only that language. + */ + locale?: string; now?: Date; pluginId: string; - /** `0` when the record has no revision to freeze. */ + /** The **shared** revision. `0` when the record has none to freeze. */ revisionId: number; secret: string; + /** The **translation** revision. `0` when the locale has none to freeze. */ + translationRevisionId?: number; version: number; }): ContentPreviewToken => { const expiresAt = new Date( @@ -73,6 +111,11 @@ export const createContentPreviewToken = ({ aud: "content-preview", exp: Math.floor(expiresAt.getTime() / 1000), i: itemId, + // Absent unless this is a translation preview, so a base token is byte-for-byte + // the token Stage 4 minted and nothing existing changes shape. + ...(locale === undefined + ? {} + : { l: locale, lid: languageId, tr: translationRevisionId ?? 0 }), p: pluginId, r: revisionId, t: definition.id, @@ -99,12 +142,23 @@ export const createContentPreviewToken = ({ */ export const verifyContentPreviewToken = ({ definition, + locale, now = new Date(), pluginId, secret, token, }: { definition: AnyContentTypeDefinition; + /** + * The locale the reader is serving, when it is serving one. + * + * Checked case-insensitively against the token's own `l`, and **never** relaxed: + * a token minted for `pl` used to read `en` is refused, and a token with no + * locale at all used on a locale-scoped read is refused too. There is no + * fallback here on purpose - falling back would silently hand a reviewer a + * different language from the one whose link they were sent. + */ + locale?: string; now?: Date; pluginId: string; secret: string; @@ -121,5 +175,13 @@ export const verifyContentPreviewToken = ({ if (payload.t !== definition.id) return null; if (payload.exp * 1000 <= now.getTime()) return null; + // Both directions are failures, and both answer the same 404 the caller uses + // for a forged signature: a locale mismatch is a token being used for something + // it was not minted for, which is exactly what the audience check catches one + // level up. + const wanted = locale?.trim().toLowerCase(); + const minted = payload.l?.trim().toLowerCase(); + if (wanted !== minted) return null; + return payload; }; diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index 8f6767044..5b0f5fe56 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -1,10 +1,12 @@ import type { ContentRevisionSnapshot, ContentSnapshotValue, + ContentTranslationRevisionSnapshot, } from "../revisions"; import type { AnyContentTypeDefinition } from "../types"; import { CONTENT_REVISION_SNAPSHOT_VERSION } from "../const"; +import { partitionContentFields } from "../localization"; const toIso = (value: unknown): string => { if (value instanceof Date) return value.toISOString(); @@ -46,6 +48,12 @@ const toSnapshotValue = (value: unknown): ContentSnapshotValue => { * order, so two equal states serialise byte for byte and a diff test is a table * rather than a set comparison. * + * **Shared fields only.** A localized field is not a column on the base row, so + * recording it here would write `null` for every language at once - and restoring + * that snapshot would then try to blank a column the base table does not have. + * Each language's values are snapshotted by + * {@link contentTranslationRevisionSnapshot} instead, against its own history. + * * The publication columns are recorded but are *not* restorable - they are * absent from `schemas.update`, so a restore structurally cannot move them. * They are here so the history can show what the lifecycle was at the time. @@ -56,8 +64,9 @@ export const contentRevisionSnapshot = ( ): ContentRevisionSnapshot => { const values = row as Record; const fields: Record = {}; + const { sharedFields } = partitionContentFields(definition.fields); - for (const name of Object.keys(definition.fields)) { + for (const name of Object.keys(sharedFields)) { fields[name] = toSnapshotValue(values[name]); } @@ -118,8 +127,95 @@ export const projectRevisionSnapshot = ( snapshot: ContentRevisionSnapshot, ): Record => { const projected: Record = {}; + const { sharedFields } = partitionContentFields(definition.fields); + + for (const name of Object.keys(sharedFields)) { + if (!(name in snapshot.fields)) continue; + + projected[name] = snapshot.fields[name]; + } + + return projected; +}; + +/** + * Builds the snapshot stored on a *translation* revision. + * + * The localized half of {@link contentRevisionSnapshot}, and the split is the + * security boundary as much as a modelling one: a translation snapshot that + * carried shared values would let a restore performed with `can_translate` + * rewrite fields only `can_edit` may touch. + * + * `locale` is recorded alongside `languageId` because the revision row's language + * reference has no foreign key - a language can be deleted, and the history has + * to stay readable when it is. + */ +export const contentTranslationRevisionSnapshot = ( + definition: AnyContentTypeDefinition, + row: object, + { languageId, locale }: { languageId: number; locale: string }, +): ContentTranslationRevisionSnapshot => { + const values = row as Record; + const fields: Record = {}; + const { localizedFields } = partitionContentFields(definition.fields); + + for (const name of Object.keys(localizedFields)) { + fields[name] = toSnapshotValue(values[name]); + } + + const snapshot: ContentTranslationRevisionSnapshot = { + contentTypeId: definition.id, + createdAt: toIso(values.createdAt), + fields, + itemId: typeof values.itemId === "number" ? values.itemId : 0, + languageId, + locale, + schemaVersion: CONTENT_REVISION_SNAPSHOT_VERSION, + updatedAt: toIso(values.updatedAt), + version: typeof values.version === "number" ? values.version : 1, + }; + + if (definition.publication.enabled) { + snapshot.publication = { + publishedAt: toIsoOrNull(values.publishedAt), + status: typeof values.status === "string" ? values.status : "draft", + }; + } + + return snapshot; +}; + +/** The localized counterpart of {@link contentSnapshotRow}. */ +export const contentTranslationSnapshotRow = ( + snapshot: ContentTranslationRevisionSnapshot, +): Record => ({ + ...snapshot.fields, + createdAt: snapshot.createdAt, + itemId: snapshot.itemId, + languageId: snapshot.languageId, + publishedAt: snapshot.publication?.publishedAt ?? null, + updatedAt: snapshot.updatedAt, + version: snapshot.version, +}); + +/** + * The part of a translation snapshot a restore may apply: currently declared + * *localized* fields only. + * + * The same schema-evolution rules the shared projection follows - a field the + * content type has since dropped is ignored, one added since is absent - and the + * same exclusion of generated columns. `status` and `publishedAt` are lifecycle + * state that only publish and unpublish may move, so restoring field values never + * takes a translation off the internet or puts it on. + */ +export const projectTranslationRevisionSnapshot = ( + definition: AnyContentTypeDefinition, + snapshot: ContentTranslationRevisionSnapshot, +): Record => { + const projected: Record = {}; + const { localizedFields } = partitionContentFields(definition.fields); - for (const name of Object.keys(definition.fields)) { + for (const name of Object.keys(localizedFields)) { if (!(name in snapshot.fields)) continue; projected[name] = snapshot.fields[name]; diff --git a/packages/vitnode/src/content/server/revisions-model.ts b/packages/vitnode/src/content/server/revisions-model.ts index dc022336a..430a63296 100644 --- a/packages/vitnode/src/content/server/revisions-model.ts +++ b/packages/vitnode/src/content/server/revisions-model.ts @@ -1,9 +1,10 @@ import type { Context } from "hono"; -import { and, desc, eq, lt, lte, notInArray, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, lt, lte, notInArray, sql } from "drizzle-orm"; import type { ContentActor, + ContentAnyRevisionSnapshot, ContentRevisionDetail, ContentRevisionMeta, ContentRevisionOperation, @@ -15,18 +16,20 @@ import type { ContentDatabase } from "./service"; import { core_content_revisions } from "../../database/content"; import { core_users } from "../../database/users"; -export interface ContentRevisionCaptureInput { +export interface ContentRevisionCaptureInput< + TSnapshot = ContentRevisionSnapshot, +> { actor: ContentActor; changedFields: readonly string[]; itemId: number; operation: ContentRevisionOperation; restoredFromRevisionId?: number; - snapshot: ContentRevisionSnapshot; + snapshot: TSnapshot; /** The version the record holds after the mutation. */ version: number; } -export interface ContentRevisionsModel { +export interface ContentRevisionsModel { /** * Writes one revision and prunes past the retention window. * @@ -36,13 +39,13 @@ export interface ContentRevisionsModel { */ capture: ( tx: ContentDatabase, - input: ContentRevisionCaptureInput, + input: ContentRevisionCaptureInput, ) => Promise; findById: ( itemId: number, revisionId: number, tx?: ContentDatabase, - ) => Promise; + ) => Promise | null>; latest: (itemId: number) => Promise; /** Newest first. Metadata only - a snapshot is loaded on demand. */ list: ( @@ -71,23 +74,34 @@ export const CONTENT_REVISIONS_DEFAULT_PAGE_SIZE = 25; export const CONTENT_REVISIONS_MAX_PAGE_SIZE = 100; /** - * Revision reads and writes for one content type. + * Revision reads and writes for one content type, in one language scope. * - * **Every** statement in here filters on `pluginId`, `contentTypeId` *and* - * `itemId`. A revision id on its own is never enough: the table is shared by - * every editorial content type in the install, so trusting an id would let a - * request for article 7 return - or restore - a revision belonging to some - * other plugin's record entirely. + * **Every** statement in here filters on `pluginId`, `contentTypeId`, `itemId` + * *and* `languageId`. A revision id on its own is never enough: the table is + * shared by every editorial content type in the install, so trusting an id would + * let a request for article 7 return - or restore - a revision belonging to some + * other plugin's record entirely. `languageId` joins that list for exactly the + * same reason one step down: without it, the Polish history could restore the + * English snapshot. + * + * `languageId` defaults to `null`, which is the shared scope - so every Stage 1-4 + * call site keeps the behaviour it had, reading and writing rows the two partial + * unique indexes treat as the non-localized history. */ -export const createContentRevisionsModel = ({ +export const createContentRevisionsModel = < + TSnapshot = ContentRevisionSnapshot, +>({ c, definition, + languageId = null, pluginId, }: { c: Context; definition: AnyContentTypeDefinition; + /** `null` for the shared history, a `core_languages.id` for one locale's. */ + languageId?: null | number; pluginId: string; -}): ContentRevisionsModel => { +}): ContentRevisionsModel => { const contentTypeId = definition.id; const retention = definition.editorial.revisions.retention; @@ -97,6 +111,11 @@ export const createContentRevisionsModel = ({ eq(core_content_revisions.pluginId, pluginId), eq(core_content_revisions.contentTypeId, contentTypeId), eq(core_content_revisions.itemId, itemId), + // `IS NULL` rather than `= NULL`: the shared scope is the absence of a + // language, and an equality against `null` matches nothing in SQL. + languageId === null + ? isNull(core_content_revisions.languageId) + : eq(core_content_revisions.languageId, languageId), ); const metaSelection = { @@ -121,10 +140,11 @@ export const createContentRevisionsModel = ({ changedFields: [...input.changedFields], contentTypeId, itemId: input.itemId, + languageId, operation: input.operation, pluginId, restoredFromRevisionId: input.restoredFromRevisionId ?? null, - snapshot: input.snapshot, + snapshot: input.snapshot as ContentAnyRevisionSnapshot, version: input.version, }) .returning({ id: core_content_revisions.id }); @@ -160,7 +180,10 @@ export const createContentRevisionsModel = ({ .where(and(scope(itemId), eq(core_content_revisions.id, revisionId))) .limit(1); - return row ? row : null; + // The column holds either snapshot shape; which one is settled by the + // `languageId` this model was built with, and the scope predicate above has + // just proven the row matches it. + return row ? (row as ContentRevisionDetail) : null; }, latest: async itemId => { diff --git a/packages/vitnode/src/content/server/translation-editorial-routes.test.ts b/packages/vitnode/src/content/server/translation-editorial-routes.test.ts new file mode 100644 index 000000000..57a7c0b25 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-editorial-routes.test.ts @@ -0,0 +1,397 @@ +// @vitest-environment node +import type { MiddlewareHandler } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + testLocalizedArticleContentType, + testLocalizedGuideContentType, +} from "@/tests/content-fixtures"; + +import { + ContentRevisionNotRestorable, + ContentTranslationVersionConflict, +} from "../errors"; +import { contentPermissionEntries } from "../registry"; +import { createContentModel } from "./model"; +import { buildContentTranslationRoutes } from "./translation-routes"; + +let permissionGranted = true; +const permissionChecks: { module: string; permission: string }[] = []; +const emitted = vi.fn(() => ({ failures: [], listeners: 0 })); + +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 guide = createContentModel(testLocalizedGuideContentType); +const plain = createContentModel(testLocalizedArticleContentType); +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 row = (overrides: Record = {}) => ({ + createdAt: new Date("2026-01-01T00:00:00Z"), + itemId: 7, + languageId: 2, + locale: "pl", + publishedAt: null, + status: "draft", + updatedAt: new Date("2026-01-01T00:00:00Z"), + values: { body: null, slug: "witaj", summary: null, title: "Witaj" }, + version: 1, + ...overrides, +}); + +const outcome = (overrides: Record = {}) => ({ + changed: true, + changedFields: [], + languageId: 2, + locale: "pl", + operation: "publish", + previousSlug: null, + restoredFromRevisionId: null, + revisionId: 101, + row: row(), + version: 2, + ...overrides, +}); + +const harness = ({ allow = true }: { allow?: boolean } = {}) => { + const editorial = { + create: vi.fn(), + delete: vi.fn(), + findRevision: vi.fn(), + listRevisions: vi.fn(), + publish: vi.fn(), + restore: vi.fn(), + unpublish: vi.fn(), + update: vi.fn(), + }; + + permissionGranted = allow; + permissionChecks.length = 0; + vi.spyOn(guide, "translationEditorialService", "get").mockReturnValue( + () => editorial, + ); + vi.spyOn(guide, "translationService", "get").mockReturnValue( + () => + ({ + // Only the metadata read is reachable from these routes; everything else + // goes through the editorial layer above. + findManyForItem: vi.fn(() => []), + }) as never, + ); + + const app = new OpenAPIHono(); + const context: MiddlewareHandler = async (c, next) => { + c.set("admin", allow ? { user: adminUser } : null); + c.set("events", { emit: emitted } as never); + await next(); + }; + app.use("*", context); + + for (const { handler, route } of buildContentTranslationRoutes(guide, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, editorial }; +}; + +const post = async ( + app: OpenAPIHono, + path: string, + body: unknown = { expectedVersion: 1 }, +) => + await app.request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "post", + }); + +beforeEach(() => { + vi.restoreAllMocks(); + emitted.mockClear(); +}); + +describe("route registration", () => { + it("adds the lifecycle and history routes for a localized editorial type", () => { + const paths = buildContentTranslationRoutes(guide, { + pluginId: PLUGIN_ID, + }).map(entry => `${entry.route.method.toUpperCase()} ${entry.route.path}`); + + expect(paths).toEqual([ + "GET /{id}/translations", + "GET /{id}/translations/{locale}", + "POST /{id}/translations/{locale}", + "PUT /{id}/translations/{locale}", + "DELETE /{id}/translations/{locale}", + "POST /{id}/translations/{locale}/publish", + "POST /{id}/translations/{locale}/unpublish", + "GET /{id}/translations/{locale}/revisions", + "GET /{id}/translations/{locale}/revisions/{revisionId}", + "POST /{id}/translations/{locale}/revisions/{revisionId}/restore", + ]); + }); + + it("adds none of them without publication or editorial", () => { + const paths = buildContentTranslationRoutes(plain, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + + // The Stage 5A surface exactly, and nothing that would gate a state the + // content type does not have. + expect(paths).toEqual([ + "/{id}/translations", + "/{id}/translations/{locale}", + "/{id}/translations/{locale}", + "/{id}/translations/{locale}", + "/{id}/translations/{locale}", + ]); + }); +}); + +describe("publish and unpublish", () => { + it("publishes one locale and announces it once", async () => { + const { app, editorial } = harness(); + editorial.publish.mockResolvedValue( + outcome({ row: row({ status: "published", version: 2 }) }), + ); + + const response = await post(app, "/7/translations/pl/publish"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ changed: true }); + expect(editorial.publish).toHaveBeenCalledWith(7, "pl", { + actor: { type: "staff", userId: 1 }, + expectedVersion: 1, + }); + expect(emitted).toHaveBeenCalledTimes(1); + }); + + it("announces nothing for an idempotent publish", async () => { + const { app, editorial } = harness(); + editorial.publish.mockResolvedValue( + outcome({ changed: false, revisionId: null }), + ); + + const response = await post(app, "/7/translations/pl/publish"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ changed: false }); + expect(emitted).not.toHaveBeenCalled(); + }); + + it("needs `can_publish`", async () => { + const { app, editorial } = harness(); + editorial.unpublish.mockResolvedValue(outcome({ operation: "unpublish" })); + + await post(app, "/7/translations/pl/unpublish"); + + expect(permissionChecks).toEqual([ + { module: "test_localized_guides", permission: "can_publish" }, + ]); + }); + + it("answers 404 when the locale has no translation", async () => { + const { app, editorial } = harness(); + editorial.publish.mockResolvedValue(null); + + expect((await post(app, "/7/translations/pl/publish")).status).toBe(404); + }); + + it("answers a structured 409 for a stale version", async () => { + const { app, editorial } = harness(); + editorial.publish.mockRejectedValue( + new ContentTranslationVersionConflict({ + contentTypeId: testLocalizedGuideContentType.id, + currentVersion: 4, + expectedVersion: 1, + itemId: 7, + locale: "pl", + }), + ); + + const response = await post(app, "/7/translations/pl/publish"); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_TRANSLATION_VERSION_CONFLICT", + contentTypeId: "test.localized-guide", + currentVersion: 4, + expectedVersion: 1, + itemId: 7, + // The locale is in every arm, which is what lets a tab strip point at the + // right tab rather than at the record. + locale: "pl", + }); + }); +}); + +describe("history", () => { + it("lists one locale's revisions", async () => { + const { app, editorial } = harness(); + editorial.listRevisions.mockResolvedValue({ + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }); + + const response = await app.request("/7/translations/pl/revisions"); + + expect(response.status).toBe(200); + expect(editorial.listRevisions).toHaveBeenCalledWith(7, "pl", { + cursor: undefined, + limit: undefined, + }); + }); + + it("passes the cursor through as a version", async () => { + const { app, editorial } = harness(); + editorial.listRevisions.mockResolvedValue({ + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }); + + await app.request("/7/translations/pl/revisions?cursor=5&first=10"); + + expect(editorial.listRevisions).toHaveBeenCalledWith(7, "pl", { + cursor: 5, + limit: 10, + }); + }); + + it("needs `can_view` to read, not `can_restore`", async () => { + const { app, editorial } = harness(); + editorial.listRevisions.mockResolvedValue({ + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }); + + await app.request("/7/translations/pl/revisions"); + + expect(permissionChecks).toEqual([ + { module: "test_localized_guides", permission: "can_view" }, + ]); + }); + + it("answers 404 for a revision outside this locale", async () => { + const { app, editorial } = harness(); + editorial.findRevision.mockResolvedValue(null); + + expect((await app.request("/7/translations/pl/revisions/42")).status).toBe( + 404, + ); + }); +}); + +describe("restore", () => { + it("needs `can_restore`", async () => { + const { app, editorial } = harness(); + editorial.restore.mockResolvedValue(outcome({ operation: "restore" })); + + await post(app, "/7/translations/pl/revisions/42/restore"); + + expect(permissionChecks).toEqual([ + { module: "test_localized_guides", permission: "can_restore" }, + ]); + }); + + it("requires an expectedVersion", async () => { + const { app } = harness(); + + expect( + (await post(app, "/7/translations/pl/revisions/42/restore", {})).status, + ).toBe(400); + }); + + it("answers a structured 422 when the snapshot no longer fits", async () => { + const { app, editorial } = harness(); + editorial.restore.mockRejectedValue( + new ContentRevisionNotRestorable({ + contentTypeId: testLocalizedGuideContentType.id, + fields: ["title"], + revisionId: 42, + }), + ); + + const response = await post(app, "/7/translations/pl/revisions/42/restore"); + + expect(response.status).toBe(422); + expect(await response.json()).toEqual({ + code: "CONTENT_REVISION_NOT_RESTORABLE", + contentTypeId: "test.localized-guide", + // Field names only - never a Zod issue tree, which names internal paths. + fields: ["title"], + revisionId: 42, + }); + }); + + it("rejects a non-numeric revision identifier", async () => { + const { app } = harness(); + + expect( + (await post(app, "/7/translations/pl/revisions/abc/restore")).status, + ).toBe(400); + }); +}); + +describe("permissions catalogue", () => { + it("adds `can_translate` for a localized content type", () => { + const entries = contentPermissionEntries(testLocalizedGuideContentType); + + expect(entries).toContainEqual({ + // Depends on `can_view` and deliberately not on `can_edit`: a translator + // gets every locale tab without gaining the shared fields. + dependsOn: ["can_view"], + permission: "can_translate", + }); + }); + + it("adds nothing for a content type that is not localized", () => { + const permissions = contentPermissionEntries( + testLocalizedGuideContentType, + ).length; + const withoutLocalization = contentPermissionEntries({ + ...testLocalizedGuideContentType, + localization: { + ...testLocalizedGuideContentType.localization, + enabled: false, + }, + }); + + expect(withoutLocalization).toHaveLength(permissions - 1); + expect( + withoutLocalization.some( + entry => + typeof entry === "object" && entry.permission === "can_translate", + ), + ).toBe(false); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-editorial-service.test.ts b/packages/vitnode/src/content/server/translation-editorial-service.test.ts new file mode 100644 index 000000000..49b36be0c --- /dev/null +++ b/packages/vitnode/src/content/server/translation-editorial-service.test.ts @@ -0,0 +1,561 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testLocalizedGuideContentType } from "@/tests/content-fixtures"; + +import type { ContentTranslationRevisionSnapshot } from "../revisions"; +import type { ContentTranslationModel } from "./translation-model"; + +import { ContentRevisionNotRestorable } from "../errors"; +import { createContentTranslationEditorialService } from "./translation-editorial-service"; + +const PLUGIN_ID = "@vitnode/example"; +const ACTOR = { type: "staff" as const, userId: 1 }; + +/** Every revision written during one test, in order. */ +const captured: { + changedFields: readonly string[]; + itemId: number; + languageId: null | number; + operation: string; + restoredFromRevisionId?: number; + snapshot: ContentTranslationRevisionSnapshot; + version: number; +}[] = []; + +let nextRevisionId = 100; +let storedRevision: ContentTranslationRevisionSnapshot | null = null; +let revisionLanguageId = 1; + +// The revisions model is a real, tested unit of its own; what matters here is +// *what this layer asks it to write* - which language, which operation, which +// snapshot - so it records instead of touching a database. +vi.mock("./revisions-model", () => ({ + CONTENT_REVISIONS_DEFAULT_PAGE_SIZE: 25, + CONTENT_REVISIONS_MAX_PAGE_SIZE: 100, + createContentRevisionsModel: ({ + languageId, + }: { + languageId?: null | number; + }) => ({ + capture: ( + _tx: unknown, + input: { + changedFields: readonly string[]; + itemId: number; + operation: string; + restoredFromRevisionId?: number; + snapshot: ContentTranslationRevisionSnapshot; + version: number; + }, + ) => { + captured.push({ ...input, languageId: languageId ?? null }); + nextRevisionId += 1; + + return nextRevisionId; + }, + findById: (_itemId: number, revisionId: number) => + storedRevision !== null && languageId === revisionLanguageId + ? { + actorName: null, + actorType: "staff" as const, + actorUserId: 1, + changedFields: [], + createdAt: new Date(), + id: revisionId, + operation: "update" as const, + restoredFromRevisionId: null, + snapshot: storedRevision, + version: 1, + } + : null, + latest: () => null, + list: () => ({ + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }), + }), +})); + +const language = (locale: string, id: number) => ({ + id, + isDefault: locale === "en", + isEnabled: true, + locale, +}); + +const row = (overrides: Record = {}) => + ({ + createdAt: new Date("2026-01-01T00:00:00Z"), + itemId: 7, + languageId: 2, + locale: "pl", + publishedAt: null, + status: "draft", + updatedAt: new Date("2026-01-01T00:00:00Z"), + values: { body: null, slug: "witaj", summary: null, title: "Witaj" }, + version: 1, + ...overrides, + }) as never; + +/** The repository, mocked to whatever the case under test needs. */ +const translations = () => { + const model = { + create: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + findByLanguageId: vi.fn(), + findByLocale: vi.fn(), + findManyForItem: vi.fn(), + publish: vi.fn(), + resolveDefaultLanguage: vi.fn(), + resolveLanguage: vi.fn((locale: string) => + language(locale, locale === "en" ? 1 : 2), + ), + unpublish: vi.fn(), + update: vi.fn(), + }; + + return model as unknown as ContentTranslationModel< + typeof testLocalizedGuideContentType + > & + typeof model; +}; + +const service = (model: ReturnType) => { + const schemas = testLocalizedGuideContentType.schemas.translation; + if (!schemas) throw new Error("fixture is not localized"); + + return createContentTranslationEditorialService({ + // Only `transaction` is reached: every method under test either takes a `tx` + // or opens one, and nothing here queries. + c: { + get: () => ({ + transaction: async (body: (tx: unknown) => Promise) => + await body({}), + }), + } as never, + definition: testLocalizedGuideContentType, + pluginId: PLUGIN_ID, + schemas, + translations: model, + }); +}; + +beforeEach(() => { + captured.length = 0; + nextRevisionId = 100; + storedRevision = null; + revisionLanguageId = 1; +}); + +describe("create", () => { + it("writes one `create` revision scoped to the locale", async () => { + const model = translations(); + model.create.mockResolvedValue(row()); + + const outcome = await service(model).create( + 7, + "pl", + { title: "Witaj" }, + { actor: ACTOR }, + ); + + expect(outcome.changed).toBe(true); + expect(outcome.locale).toBe("pl"); + expect(captured).toHaveLength(1); + expect(captured[0]).toMatchObject({ + itemId: 7, + languageId: 2, + operation: "create", + version: 1, + }); + }); + + it("snapshots the localized fields only", async () => { + const model = translations(); + model.create.mockResolvedValue(row()); + + await service(model).create( + 7, + "pl", + { title: "Witaj" }, + { + actor: ACTOR, + }, + ); + + // `featured` is shared. A translation snapshot that carried it would let a + // restore performed with `can_translate` rewrite it. + expect(Object.keys(captured[0].snapshot.fields)).toEqual([ + "title", + "slug", + "body", + "summary", + ]); + expect(captured[0].snapshot.locale).toBe("pl"); + }); + + it("reports every localized field as changed", async () => { + const model = translations(); + model.create.mockResolvedValue(row()); + + const outcome = await service(model).create( + 7, + "pl", + { title: "Witaj" }, + { actor: ACTOR }, + ); + + expect(outcome.changedFields).toEqual(["title", "slug", "body", "summary"]); + }); +}); + +describe("update", () => { + it("writes one `update` revision and carries the previous slug", async () => { + const model = translations(); + model.findByLocale.mockResolvedValue(row({ values: { slug: "stary" } })); + model.update.mockResolvedValue({ + changed: true, + changedFields: ["title", "slug"], + row: row({ values: { slug: "nowy", title: "Nowy" }, version: 2 }), + version: 2, + }); + + const outcome = await service(model).update( + 7, + "pl", + { title: "Nowy" }, + { actor: ACTOR, expectedVersion: 1 }, + ); + + expect(outcome?.previousSlug).toBe("stary"); + expect(outcome?.version).toBe(2); + expect(captured).toHaveLength(1); + expect(captured[0].operation).toBe("update"); + }); + + it("writes nothing at all for a no-op", async () => { + const model = translations(); + model.findByLocale.mockResolvedValue(row()); + model.update.mockResolvedValue({ + changed: false, + changedFields: [], + row: row(), + version: 1, + }); + + const outcome = await service(model).update( + 7, + "pl", + { title: "Witaj" }, + { actor: ACTOR, expectedVersion: 1 }, + ); + + expect(outcome?.changed).toBe(false); + expect(outcome?.revisionId).toBeNull(); + expect(captured).toHaveLength(0); + }); + + it("returns null when the locale has no translation", async () => { + const model = translations(); + model.findByLocale.mockResolvedValue(null); + model.update.mockResolvedValue(null); + + expect( + await service(model).update( + 7, + "pl", + { title: "X" }, + { + actor: ACTOR, + expectedVersion: 1, + }, + ), + ).toBeNull(); + }); +}); + +describe("delete", () => { + it("records the version the row would have had", async () => { + const model = translations(); + model.delete.mockResolvedValue(row({ version: 4 })); + + const outcome = await service(model).delete(7, "pl", { + actor: ACTOR, + expectedVersion: 4, + }); + + // 5, not 4: the row is gone, so nothing holds version 4 any more - and the + // partial unique index would reject a second revision claiming it. + expect(outcome?.version).toBe(5); + expect(captured[0]).toMatchObject({ operation: "delete", version: 5 }); + }); +}); + +describe("publish and unpublish", () => { + it("writes a `publish` revision for a real transition", async () => { + const model = translations(); + model.publish.mockResolvedValue({ + changed: true, + row: row({ publishedAt: new Date(), status: "published", version: 2 }), + version: 2, + }); + + const outcome = await service(model).publish(7, "pl", { actor: ACTOR }); + + expect(outcome?.changed).toBe(true); + expect(captured[0]).toMatchObject({ operation: "publish", version: 2 }); + expect(captured[0].snapshot.publication?.status).toBe("published"); + }); + + it("writes nothing for an already published translation", async () => { + const model = translations(); + model.publish.mockResolvedValue({ + changed: false, + row: row({ status: "published" }), + version: 1, + }); + + const outcome = await service(model).publish(7, "pl", { actor: ACTOR }); + + expect(outcome?.changed).toBe(false); + expect(outcome?.revisionId).toBeNull(); + expect(captured).toHaveLength(0); + }); + + it("passes an optional expectedVersion straight through", async () => { + const model = translations(); + model.unpublish.mockResolvedValue({ + changed: true, + row: row({ version: 3 }), + version: 3, + }); + + await service(model).unpublish(7, "pl", { + actor: ACTOR, + expectedVersion: 2, + }); + + expect(model.unpublish).toHaveBeenCalledWith(7, "pl", { + expectedVersion: 2, + tx: expect.anything(), + }); + }); +}); + +describe("restore", () => { + const snapshot = ( + fields: Record, + ): ContentTranslationRevisionSnapshot => + ({ + contentTypeId: testLocalizedGuideContentType.id, + createdAt: "2026-01-01T00:00:00.000Z", + fields, + itemId: 7, + languageId: 1, + locale: "en", + schemaVersion: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + version: 1, + }) as ContentTranslationRevisionSnapshot; + + it("restores one locale's values and creates a new version", async () => { + const model = translations(); + storedRevision = snapshot({ title: "Old title" }); + model.findByLanguageId.mockResolvedValue( + row({ languageId: 1, locale: "en", values: { title: "New title" } }), + ); + model.update.mockResolvedValue({ + changed: true, + changedFields: ["title"], + row: row({ + languageId: 1, + locale: "en", + values: { title: "Old title" }, + version: 5, + }), + version: 5, + }); + + const outcome = await service(model).restore(7, "en", 42, { + actor: ACTOR, + expectedVersion: 4, + }); + + // Forward to a new version, not back to the historical one. + expect(outcome?.version).toBe(5); + expect(outcome?.restoredFromRevisionId).toBe(42); + expect(captured[0]).toMatchObject({ + operation: "restore", + restoredFromRevisionId: 42, + }); + }); + + it("refuses a revision belonging to another locale", async () => { + const model = translations(); + storedRevision = snapshot({ title: "Old title" }); + // The stored revision belongs to language 1; the request is for `pl`, which + // resolves to 2 - so the scoped read finds nothing. + revisionLanguageId = 1; + + expect( + await service(model).restore(7, "pl", 42, { + actor: ACTOR, + expectedVersion: 1, + }), + ).toBeNull(); + expect(model.update).not.toHaveBeenCalled(); + }); + + it("never restores shared fields", async () => { + const model = translations(); + // A snapshot that somehow carries a shared field - a hand-edited row, or one + // written before the partition existed. + storedRevision = snapshot({ featured: true, title: "Old title" }); + model.findByLanguageId.mockResolvedValue( + row({ languageId: 1, locale: "en", values: { title: "New" } }), + ); + model.update.mockResolvedValue({ + changed: true, + changedFields: ["title"], + row: row({ languageId: 1, locale: "en", version: 2 }), + version: 2, + }); + + await service(model).restore(7, "en", 42, { + actor: ACTOR, + expectedVersion: 1, + }); + + const [, , patch] = model.update.mock.calls[0] as [ + number, + string, + Record, + ]; + expect(patch).not.toHaveProperty("featured"); + expect(patch).toHaveProperty("title", "Old title"); + }); + + it("rejects a snapshot missing a now-required localized field", async () => { + const model = translations(); + // `title` is required, and an empty patch fails the "at least one field" + // refinement - so the restore is refused before anything is written. + storedRevision = snapshot({}); + model.findByLanguageId.mockResolvedValue( + row({ languageId: 1, locale: "en" }), + ); + + await expect( + service(model).restore(7, "en", 42, { + actor: ACTOR, + expectedVersion: 1, + }), + ).rejects.toBeInstanceOf(ContentRevisionNotRestorable); + expect(model.update).not.toHaveBeenCalled(); + }); + + it("writes nothing when the values already match", async () => { + const model = translations(); + storedRevision = snapshot({ title: "Same" }); + model.findByLanguageId.mockResolvedValue( + row({ languageId: 1, locale: "en", values: { title: "Same" } }), + ); + + const outcome = await service(model).restore(7, "en", 42, { + actor: ACTOR, + expectedVersion: 1, + }); + + expect(outcome?.changed).toBe(false); + expect(outcome?.restoredFromRevisionId).toBeNull(); + expect(captured).toHaveLength(0); + }); + + it("never moves publication state", async () => { + const model = translations(); + storedRevision = { + ...snapshot({ title: "Old" }), + publication: { publishedAt: null, status: "draft" }, + }; + model.findByLanguageId.mockResolvedValue( + row({ + languageId: 1, + locale: "en", + status: "published", + values: { title: "New" }, + }), + ); + model.update.mockResolvedValue({ + changed: true, + changedFields: ["title"], + row: row({ + languageId: 1, + locale: "en", + status: "published", + version: 2, + }), + version: 2, + }); + + await service(model).restore(7, "en", 42, { + actor: ACTOR, + expectedVersion: 1, + }); + + const [, , patch] = model.update.mock.calls[0] as [ + number, + string, + Record, + ]; + expect(patch).not.toHaveProperty("status"); + expect(patch).not.toHaveProperty("publishedAt"); + expect(model.publish).not.toHaveBeenCalled(); + expect(model.unpublish).not.toHaveBeenCalled(); + }); +}); + +describe("history reads", () => { + it("resolves the locale without requiring it to be enabled", async () => { + const model = translations(); + + await service(model).listRevisions(7, "de"); + + // Reading the history of a switched-off language is exactly what somebody + // auditing it would want to do. + expect(model.resolveLanguage).toHaveBeenCalledWith("de", { + requireEnabled: false, + tx: undefined, + }); + }); + + it("returns null for a revision id outside this locale", async () => { + const model = translations(); + storedRevision = null; + + expect(await service(model).findRevision(7, "en", 42)).toBeNull(); + }); +}); + +describe("configuration guards", () => { + it("refuses a content type without editorial", () => { + const schemas = testLocalizedGuideContentType.schemas.translation; + if (!schemas) throw new Error("fixture is not localized"); + + expect(() => + createContentTranslationEditorialService({ + c: {} as never, + definition: { + ...testLocalizedGuideContentType, + editorial: { + ...testLocalizedGuideContentType.editorial, + enabled: false, + }, + } as never, + pluginId: PLUGIN_ID, + schemas, + translations: translations(), + }), + ).toThrow(/needs `editorial: \{ enabled: true \}`/); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts new file mode 100644 index 000000000..a8f3e4073 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-editorial-service.ts @@ -0,0 +1,606 @@ +import type { Context } from "hono"; + +import type { + ContentActor, + ContentRevisionMeta, + ContentTranslationRevisionOperation, + ContentTranslationRevisionSnapshot, +} from "../revisions"; +import type { ContentTranslationSchemas } from "../schemas"; +import type { + AnyContentTypeDefinition, + ContentLocalizedFieldName, + ContentLocalizedUpdateValues, + ContentLocalizedValues, + ContentTranslationRow, +} from "../types"; +import type { ContentLanguage } from "./language-resolver"; +import type { + ContentRevisionPage, + ContentRevisionsModel, +} from "./revisions-model"; +import type { ContentDatabase } from "./service"; +import type { ContentTranslationModel } from "./translation-model"; + +import { + ContentEngineError, + ContentRevisionNotRestorable, + ContentTranslationVersionConflict, +} from "../errors"; +import { partitionContentFields } from "../localization"; +import { diffChangedFields } from "./query"; +import { + contentTranslationRevisionSnapshot, + projectTranslationRevisionSnapshot, +} from "./revision-snapshot"; +import { createContentRevisionsModel } from "./revisions-model"; +import { createSlugNormalizer } from "./slugs"; + +/** + * Everything the post-commit effects need about one translation mutation. + * + * The localized mirror of `ContentEditorialOutcome`, and `previousSlug` is + * load-bearing for the same reason: once the write returns, the old localized URL + * is gone, and invalidating the wrong locale's slug tag leaves a moved Polish page + * resolving at its old address. + */ +export interface ContentTranslationEditorialOutcome { + /** `false` when nothing moved: no write, no revision, no event, no tags. */ + changed: boolean; + changedFields: ContentLocalizedFieldName[]; + languageId: number; + /** The canonical `core_languages.code`, never the caller's casing. */ + locale: string; + operation: ContentTranslationRevisionOperation; + /** The localized slug this translation answered to *before* the mutation. */ + previousSlug: null | string; + restoredFromRevisionId: null | number; + /** `null` on a no-op, since no revision was written. */ + revisionId: null | number; + row: ContentTranslationRow; + version: number; +} + +export interface ContentTranslationEditorialOptions { + actor: ContentActor; + /** Join an existing transaction instead of opening one. */ + tx?: ContentDatabase; +} + +export interface ContentTranslationEditorialWriteOptions extends ContentTranslationEditorialOptions { + expectedVersion: number; +} + +/** + * Publish and unpublish guard on the state, so the version is optional - the same + * rule, and the same reasoning, as the base row's transitions. + */ +export interface ContentTranslationEditorialTransitionOptions extends ContentTranslationEditorialOptions { + expectedVersion?: number; +} + +export interface ContentTranslationEditorialService { + /** Adds a translation and records its `create` revision. Starts as a draft. */ + create: ( + itemId: number, + locale: string, + values: ContentLocalizedValues, + options: ContentTranslationEditorialOptions, + ) => Promise>; + /** + * Removes one translation. Refuses the default locale, and refuses a version + * that moved - a delete is the widest possible overwrite, and a confirmation + * dialog cannot ask about a change the person has not seen. + */ + delete: ( + itemId: number, + locale: string, + options: ContentTranslationEditorialWriteOptions, + ) => Promise | null>; + /** One revision of one locale, with its snapshot. Scoped by both. */ + findRevision: ( + itemId: number, + locale: string, + revisionId: number, + ) => Promise; + /** One locale's history, newest first. Metadata only. */ + listRevisions: ( + itemId: number, + locale: string, + args?: { cursor?: number; limit?: number }, + ) => Promise; + publish: ( + itemId: number, + locale: string, + options: ContentTranslationEditorialTransitionOptions, + ) => Promise | null>; + /** + * Rolls one locale's *field values* back to an earlier revision of that same + * locale. + * + * Never crosses a locale, never touches shared fields, and never moves + * publication state. The historical version number is not restored either: the + * translation moves forward to a new version whose revision says where the + * values came from. + */ + restore: ( + itemId: number, + locale: string, + revisionId: number, + options: ContentTranslationEditorialWriteOptions, + ) => Promise | null>; + unpublish: ( + itemId: number, + locale: string, + options: ContentTranslationEditorialTransitionOptions, + ) => Promise | null>; + update: ( + itemId: number, + locale: string, + values: ContentLocalizedUpdateValues, + options: ContentTranslationEditorialWriteOptions, + ) => Promise | null>; +} + +/** One translation revision with its snapshot, plus the locale it belongs to. */ +export interface ContentRevisionDetailForLocale extends ContentRevisionMeta { + locale: string; + snapshot: ContentTranslationRevisionSnapshot; +} + +/** + * The transactional editorial layer for translations. + * + * It holds exactly the rule the base editorial service holds, one row down: + * **the translation write, its version increment and its revision insert are one + * transaction, and nothing else is in it.** No event, no search call, no cache + * API - those run after the commit, in `contentTranslationEffects`, because a + * rolled-back transaction cannot un-send them. + * + * The data operations themselves are not duplicated here. `translation-model.ts` + * owns the conditional writes, the slug uniqueness and the default-translation + * invariant; this adds the transaction, the revision and the outcome the effects + * need. That split is what lets `localizedService.create` call the model inside + * somebody else's transaction without dragging a revision or an event along. + */ +export const createContentTranslationEditorialService = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + definition, + pluginId, + schemas, + translations, +}: { + c: Context; + definition: TDefinition; + pluginId: string; + schemas: ContentTranslationSchemas; + /** The repository this orchestrates. One instance per request, shared. */ + translations: ContentTranslationModel; +}): ContentTranslationEditorialService => { + const contentTypeId = definition.id; + + if (!definition.localization.enabled) { + throw new ContentEngineError( + "The translation editorial service needs `localization: { enabled: true, defaultLocale }` on the content type.", + { contentTypeId }, + ); + } + + if (!definition.editorial.enabled) { + throw new ContentEngineError( + "The translation editorial service needs `editorial: { enabled: true }` on the content type - without it there is no revision history for a translation to write to.", + { contentTypeId }, + ); + } + + const { localizedFields } = partitionContentFields(definition.fields); + const localizedNames = Object.keys( + localizedFields, + ) as ContentLocalizedFieldName[]; + + // The localized slug, if there is one. A content type may declare at most one + // per language in practice; the first is what a URL is built from, and it is + // what the locale-scoped cache tag keys off in Stage 5C. + const slugField = + Object.keys(localizedFields).find( + name => localizedFields[name].kind === "slug", + ) ?? null; + + const { withUpdateSlugs } = createSlugNormalizer( + contentTypeId, + localizedFields, + ); + + /** + * One locale's revision model. + * + * Built per call rather than cached, because the language it is scoped to comes + * out of the request. Everything inside it - the scope predicate, retention + * pruning, the cursor - is the shared implementation with `languageId` bound, + * so a locale's history is pruned to its own retention window rather than + * competing with every other language for the same fifty slots. + */ + const revisionsFor = ( + languageId: number, + ): ContentRevisionsModel => + createContentRevisionsModel({ + c, + definition, + languageId, + pluginId, + }); + + const language = async ( + locale: string, + { requireEnabled, tx }: { requireEnabled: boolean; tx?: ContentDatabase }, + ): Promise => + await translations.resolveLanguage(locale, { requireEnabled, tx }); + + const transact = async ( + options: ContentTranslationEditorialOptions, + body: (tx: ContentDatabase) => Promise, + ): Promise => { + if (options.tx) return await body(options.tx); + + return await c.get("db").transaction(async tx => await body(tx)); + }; + + const slugOf = ( + row: ContentTranslationRow | null, + ): null | string => { + if (!row || slugField === null) return null; + const value = (row.values as Record)[slugField]; + + return typeof value === "string" ? value : null; + }; + + /** The raw column values a snapshot is taken from, flattened out of the row. */ + const snapshotSource = ( + row: ContentTranslationRow, + ): Record => ({ + ...(row.values as Record), + createdAt: row.createdAt, + itemId: row.itemId, + languageId: row.languageId, + publishedAt: (row as { publishedAt?: Date | null }).publishedAt ?? null, + status: (row as { status?: string }).status, + updatedAt: row.updatedAt, + version: row.version, + }); + + const capture = async ( + tx: ContentDatabase, + { + actor, + changedFields, + languageId, + locale, + operation, + restoredFromRevisionId, + row, + version, + }: { + actor: ContentActor; + changedFields: readonly string[]; + languageId: number; + locale: string; + operation: ContentTranslationRevisionOperation; + restoredFromRevisionId?: number; + row: ContentTranslationRow; + version: number; + }, + ): Promise => + await revisionsFor(languageId).capture(tx, { + actor, + changedFields, + itemId: row.itemId, + operation, + restoredFromRevisionId, + snapshot: contentTranslationRevisionSnapshot( + definition, + { ...snapshotSource(row), version }, + { languageId, locale }, + ), + version, + }); + + const unchanged = ( + operation: ContentTranslationRevisionOperation, + row: ContentTranslationRow, + ): ContentTranslationEditorialOutcome => ({ + changed: false, + changedFields: [], + languageId: row.languageId, + locale: row.locale, + operation, + previousSlug: slugOf(row), + restoredFromRevisionId: null, + revisionId: null, + row, + version: row.version, + }); + + /** Publish and unpublish, which differ only in which model method they call. */ + const transition = async ( + itemId: number, + locale: string, + options: ContentTranslationEditorialTransitionOptions, + operation: "publish" | "unpublish", + ): Promise | null> => + await transact(options, async tx => { + const result = await translations[operation](itemId, locale, { + expectedVersion: options.expectedVersion, + tx, + }); + if (!result) return null; + if (!result.changed) return unchanged(operation, result.row); + + const revisionId = await capture(tx, { + actor: options.actor, + changedFields: [], + languageId: result.row.languageId, + locale: result.row.locale, + operation, + row: result.row, + version: result.version, + }); + + return { + changed: true, + changedFields: [], + languageId: result.row.languageId, + locale: result.row.locale, + operation, + previousSlug: slugOf(result.row), + restoredFromRevisionId: null, + revisionId, + row: result.row, + version: result.version, + }; + }); + + return { + create: async (itemId, locale, values, options) => + await transact(options, async tx => { + const row = await translations.create(itemId, locale, values, { tx }); + + const revisionId = await capture(tx, { + actor: options.actor, + // Everything is new, so every localized field "changed" - which is + // what the history should say about a create. + changedFields: localizedNames, + languageId: row.languageId, + locale: row.locale, + operation: "create", + row, + version: row.version, + }); + + return { + changed: true, + changedFields: localizedNames, + languageId: row.languageId, + locale: row.locale, + operation: "create" as const, + previousSlug: null, + restoredFromRevisionId: null, + revisionId, + row, + version: row.version, + }; + }), + + delete: async (itemId, locale, options) => + await transact(options, async tx => { + const row = await translations.delete(itemId, locale, { + expectedVersion: options.expectedVersion, + tx, + }); + if (!row) return null; + + // The row is gone, so no version survives to hold this one. Recording + // `version + 1` keeps the per-locale history strictly increasing and the + // partial unique index meaningful - the alternative collides with the + // revision that last wrote this version. + const version = row.version + 1; + const revisionId = await capture(tx, { + actor: options.actor, + changedFields: [], + languageId: row.languageId, + locale: row.locale, + operation: "delete", + row, + version, + }); + + return { + changed: true, + changedFields: [], + languageId: row.languageId, + locale: row.locale, + operation: "delete" as const, + previousSlug: slugOf(row), + restoredFromRevisionId: null, + revisionId, + row, + version, + }; + }), + + findRevision: async (itemId, locale, revisionId) => { + // The language is resolved without `requireEnabled`: reading the history of + // a locale the install has switched off is exactly what somebody auditing + // it would want to do. + const target = await language(locale, { requireEnabled: false }); + const revision = await revisionsFor(target.id).findById( + itemId, + revisionId, + ); + if (!revision) return null; + + return { ...revision, locale: target.locale }; + }, + + listRevisions: async (itemId, locale, args) => { + const target = await language(locale, { requireEnabled: false }); + + return await revisionsFor(target.id).list(itemId, args); + }, + + publish: async (itemId, locale, options) => + await transition(itemId, locale, options, "publish"), + + restore: async (itemId, locale, revisionId, options) => + await transact(options, async tx => { + const target = await language(locale, { requireEnabled: true, tx }); + + // Scoped by content type, item *and* language before anything is read, so + // a revision id belonging to another locale is simply not found - it is + // never fetched and then rejected, which would leak that it exists. + const revision = await revisionsFor(target.id).findById( + itemId, + revisionId, + tx, + ); + if (!revision) return null; + + const current = await translations.findByLanguageId(itemId, target.id, { + tx, + }); + if (!current) return null; + + const projected = projectTranslationRevisionSnapshot( + definition, + revision.snapshot, + ); + + // Validated against the *current* localized schemas, not the ones in + // force when the snapshot was taken. A field that has since become + // required and is absent from the snapshot fails here, before anything is + // written, so a restore is all or nothing. + const parsed = schemas.update.safeParse(projected); + if (!parsed.success) { + throw new ContentRevisionNotRestorable({ + contentTypeId, + fields: [ + ...new Set( + parsed.error.issues + .map(issue => String(issue.path[0] ?? "")) + .filter(name => name !== ""), + ), + ], + revisionId, + }); + } + + const patch = withUpdateSlugs(parsed.data); + const currentValues = current.values as Record; + const changedFields = diffChangedFields( + localizedNames, + currentValues, + patch, + ); + + if (changedFields.length === 0) { + return { + ...unchanged("restore", current), + // Nothing was restored, so nothing was restored *from*. + restoredFromRevisionId: null, + }; + } + + // Through the model rather than a second `UPDATE` here: the slug + // uniqueness, the version guard and the locale scope are all its rules, + // and a restore that wrote around them could produce a duplicate URL. + const result = await translations.update( + itemId, + target.locale, + Object.fromEntries( + changedFields.map(key => [key, patch[key]]), + ) as ContentLocalizedUpdateValues, + { expectedVersion: options.expectedVersion, tx }, + ); + if (!result) return null; + + // Unreachable in practice - the diff above proved something moved, and the + // update ran in this transaction - but a no-op here must not write a + // revision claiming a restore happened. + if (!result.changed) { + return { + ...unchanged("restore", result.row), + restoredFromRevisionId: null, + }; + } + + const newRevisionId = await capture(tx, { + actor: options.actor, + changedFields: result.changedFields, + languageId: result.row.languageId, + locale: result.row.locale, + operation: "restore", + restoredFromRevisionId: revisionId, + row: result.row, + version: result.version, + }); + + return { + changed: true, + changedFields: result.changedFields, + languageId: result.row.languageId, + locale: result.row.locale, + operation: "restore" as const, + previousSlug: slugOf(current), + restoredFromRevisionId: revisionId, + revisionId: newRevisionId, + row: result.row, + version: result.version, + }; + }), + + unpublish: async (itemId, locale, options) => + await transition(itemId, locale, options, "unpublish"), + + update: async (itemId, locale, values, options) => + await transact(options, async tx => { + // Read first, so the outcome can carry the slug this translation answered + // to before the write - the one thing that cannot be recovered afterwards. + const before = await translations.findByLocale(itemId, locale, { tx }); + + const result = await translations.update(itemId, locale, values, { + expectedVersion: options.expectedVersion, + tx, + }); + if (!result) return null; + + if (!result.changed) return unchanged("update", result.row); + + const revisionId = await capture(tx, { + actor: options.actor, + changedFields: result.changedFields, + languageId: result.row.languageId, + locale: result.row.locale, + operation: "update", + row: result.row, + version: result.version, + }); + + return { + changed: true, + changedFields: result.changedFields, + languageId: result.row.languageId, + locale: result.row.locale, + operation: "update" as const, + previousSlug: slugOf(before), + restoredFromRevisionId: null, + revisionId, + row: result.row, + version: result.version, + }; + }), + }; +}; + +/** Re-exported so a caller need not reach past this module for the conflict. */ +export { ContentTranslationVersionConflict }; diff --git a/packages/vitnode/src/content/server/translation-effects.test.ts b/packages/vitnode/src/content/server/translation-effects.test.ts new file mode 100644 index 000000000..f01ec1ad2 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-effects.test.ts @@ -0,0 +1,180 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { testLocalizedGuideContentType } from "@/tests/content-fixtures"; + +import type { ContentTranslationEditorialOutcome } from "./translation-editorial-service"; + +import { contentTranslationEffects } from "./translation-effects"; + +/** + * The event transport, recording rather than delivering. + * + * Typed loosely on purpose: what these tests assert is the *name* and the + * *payload* the effects choose, and pinning the emitter's signature to the global + * event map would make the assertions depend on whether a plugin's `declare + * module` block happens to be in the program. + */ +const emit = vi.fn( + (_name: string, _payload: Record, _options?: unknown) => ({ + failures: [] as { error: string; listener: string }[], + listeners: 1, + }), +); + +const context = () => + ({ + get: (key: string) => (key === "events" ? { emit } : undefined), + }) as unknown as Context; + +const outcome = ( + overrides: Partial> = {}, +): ContentTranslationEditorialOutcome => + ({ + changed: true, + changedFields: [], + languageId: 2, + locale: "pl", + operation: "update", + previousSlug: null, + restoredFromRevisionId: null, + revisionId: 101, + row: { + createdAt: new Date(), + itemId: 7, + languageId: 2, + locale: "pl", + publishedAt: null, + status: "draft", + updatedAt: new Date(), + values: {}, + version: 2, + }, + version: 2, + ...overrides, + }) as never; + +const run = async ( + overrides: Partial> = {}, +) => + await contentTranslationEffects( + context(), + testLocalizedGuideContentType, + outcome(overrides), + { pluginId: "@vitnode/example" }, + ); + +beforeEach(() => { + emit.mockClear(); +}); + +describe("one event per real mutation", () => { + it.each([ + ["create", "translation_created"], + ["update", "translation_updated"], + ["delete", "translation_deleted"], + ["publish", "translation_published"], + ["unpublish", "translation_unpublished"], + ["restore", "translation_restored"], + ] as const)("maps %s to %s", async (operation, action) => { + await run({ operation }); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith( + `content.test.localized-guide.${action}`, + expect.anything(), + { pluginId: "@vitnode/example" }, + ); + }); + + it("never emits the plain `updated` event", async () => { + await run({ operation: "update" }); + + // A shared update and a Polish translation update are different domain facts + // with different consequences - one invalidates every locale, the other one. + const [name] = emit.mock.calls[0]; + expect(name).not.toBe("content.test.localized-guide.updated"); + }); + + it("emits nothing for a no-op", async () => { + const result = await run({ changed: false }); + + expect(emit).not.toHaveBeenCalled(); + expect(result.event).toBeNull(); + }); +}); + +describe("payloads", () => { + it("always names the locale and the language", async () => { + await run({ operation: "create" }); + + expect(emit.mock.calls[0][1]).toMatchObject({ + contentId: 7, + languageId: 2, + locale: "pl", + revisionId: 101, + version: 2, + }); + }); + + it("carries changedFields on an update", async () => { + await run({ changedFields: ["title"] as never, operation: "update" }); + + expect(emit.mock.calls[0][1]).toMatchObject({ changedFields: ["title"] }); + }); + + it("carries the source revision on a restore", async () => { + await run({ operation: "restore", restoredFromRevisionId: 42 }); + + expect(emit.mock.calls[0][1]).toMatchObject({ + restoredFromRevisionId: 42, + revisionId: 101, + }); + }); + + it("carries publishedAt on a publish", async () => { + const publishedAt = new Date("2026-02-01T00:00:00Z"); + await run({ + operation: "publish", + row: { + createdAt: new Date(), + itemId: 7, + languageId: 2, + locale: "pl", + publishedAt, + status: "published", + updatedAt: new Date(), + values: {}, + version: 2, + } as never, + }); + + expect(emit.mock.calls[0][1]).toMatchObject({ publishedAt }); + }); + + it("omits revisionId when there is no history", async () => { + // The path a localized content type without `editorial` takes: the event still + // fires, but there is no revision to point at, so the key is absent rather + // than zero. + await run({ operation: "create", revisionId: null }); + + expect(emit.mock.calls[0][1]).not.toHaveProperty("revisionId"); + }); +}); + +describe("failure reporting", () => { + it("returns what the transport said rather than swallowing it", async () => { + emit.mockReturnValueOnce({ + failures: [{ error: "boom", listener: "x" }], + listeners: 1, + }); + + const result = await run(); + + // `emit` never throws, so `failures` is the only place a dead listener is + // visible - and the mutation has already committed either way. + expect(result.event?.failures).toHaveLength(1); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-effects.ts b/packages/vitnode/src/content/server/translation-effects.ts new file mode 100644 index 000000000..6afca0724 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-effects.ts @@ -0,0 +1,113 @@ +import type { Context } from "hono"; + +import type { EventEmitResult } from "../../api/models/events"; +import type { ContentEventAction } from "../events"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentTranslationEditorialOutcome } from "./translation-editorial-service"; + +import { emitContentEvent } from "./emit"; + +/** One translation operation, one event. Never `updated` - see `events.ts`. */ +const EVENT_ACTION: Record< + ContentTranslationEditorialOutcome["operation"], + ContentEventAction +> = { + create: "translation_created", + delete: "translation_deleted", + publish: "translation_published", + restore: "translation_restored", + unpublish: "translation_unpublished", + update: "translation_updated", +}; + +const payloadFor = ( + outcome: ContentTranslationEditorialOutcome, +): Record => { + const base = { + contentId: outcome.row.itemId, + languageId: outcome.languageId, + locale: outcome.locale, + // Absent rather than `undefined` when the content type keeps no history: a + // listener that acts on a revision must not be handed one that is not there, + // and `"revisionId" in payload` is how it checks. + ...(outcome.revisionId === null ? {} : { revisionId: outcome.revisionId }), + version: outcome.version, + }; + + switch (outcome.operation) { + case "publish": + return { + ...base, + publishedAt: + (outcome.row as { publishedAt?: Date | null }).publishedAt ?? null, + }; + case "restore": + return { + ...base, + changedFields: outcome.changedFields, + restoredFromRevisionId: outcome.restoredFromRevisionId, + }; + case "update": + return { ...base, changedFields: outcome.changedFields }; + default: + return base; + } +}; + +export interface ContentTranslationEffectsOptions { + /** The plugin that owns the content type, and therefore the event. */ + pluginId: string; +} + +export interface ContentTranslationEffectsResult { + /** + * What the event transport reported, or `null` for a no-op outcome. + * + * Present rather than discarded because `EventsModel.emit` does not throw: + * `failures` is the only place a dead listener or a broker outage is visible. + * A failure here never rolls the committed mutation back - it cannot, the + * transaction is closed - which is exactly why the caller gets to see it. + */ + event: EventEmitResult | null; +} + +/** + * Everything one translation mutation owes the rest of the system, once its + * transaction has committed. + * + * The localized counterpart of `contentEditorialEffects`, and it exists for the + * same reason: "which event does this operation emit" is a rule, and a rule copied + * into six route handlers is a rule that will disagree with itself. + * + * **Call it only after the write has returned - never inside the transaction.** A + * rollback cannot un-emit an event. + * + * A no-op outcome does nothing at all. That is what keeps a double-clicked publish + * button and an empty edit from each producing a second event. + * + * Search synchronisation is deliberately absent: a localized content type cannot + * have `search` enabled yet, so there is no document to write. Stage 5D adds the + * per-locale sync here. Cache invalidation is absent for the reason it is absent + * from the base effects too - it needs the Next runtime, which the API process does + * not have, so the Server Action owns it. + */ +export const contentTranslationEffects = async ( + c: Context, + definition: AnyContentTypeDefinition, + outcome: ContentTranslationEditorialOutcome, + { pluginId }: ContentTranslationEffectsOptions, +): Promise => { + if (!outcome.changed) return { event: null }; + + return { + event: await emitContentEvent( + c, + definition, + EVENT_ACTION[outcome.operation], + payloadFor(outcome) as never, + // The plugin that owns the content type, not whichever module happens to be + // handling the request. + { pluginId }, + ), + }; +}; diff --git a/packages/vitnode/src/content/server/translation-http-errors.ts b/packages/vitnode/src/content/server/translation-http-errors.ts index 387d63326..78d694414 100644 --- a/packages/vitnode/src/content/server/translation-http-errors.ts +++ b/packages/vitnode/src/content/server/translation-http-errors.ts @@ -3,16 +3,20 @@ import { ZodError } from "zod"; import type { ContentTranslationConflict } from "../conflicts"; -import { CONTENT_TRANSLATION_CONFLICT_CODES } from "../const"; +import { + CONTENT_TRANSLATION_CONFLICT_CODES, + CONTENT_UNPROCESSABLE_CODES, +} from "../const"; import { ContentDefaultTranslationRequired, ContentInputError, ContentLanguageError, + ContentRevisionNotRestorable, ContentTranslationExists, ContentTranslationItemMissing, ContentTranslationVersionConflict, } from "../errors"; -import { rethrowAsHttpError } from "./http-errors"; +import { contentUnprocessable, rethrowAsHttpError } from "./http-errors"; /** A structured 409, in the translation union. */ export const contentTranslationConflict = ( @@ -41,19 +45,37 @@ export const contentTranslationConflict = ( * name columns, constraints and values, never reaches a client from here either. */ export const withTranslationHttpErrors = async ( - action: "create" | "delete" | "update", + action: "create" | "delete" | "read" | "update", run: () => Promise, { contentTypeId, itemId, locale, - }: { contentTypeId: string; itemId: number; locale: string }, + }: { + contentTypeId: string; + /** Absent on a read, which has no row to attribute a constraint failure to. */ + itemId?: number; + locale?: string; + }, ): Promise => { try { return await run(); } catch (error) { if (error instanceof HTTPException) throw error; + // A restore whose snapshot no longer fits the content type. Mapped here + // rather than left to the shared mapper so the 422 body is produced whether + // or not the caller asked for structured errors - a translation route always + // answers this way, and its OpenAPI schema says so. + if (error instanceof ContentRevisionNotRestorable) { + throw contentUnprocessable({ + code: CONTENT_UNPROCESSABLE_CODES.notRestorable, + contentTypeId, + fields: error.fields, + revisionId: error.revisionId, + }); + } + if (error instanceof ContentTranslationVersionConflict) { throw contentTranslationConflict({ code: CONTENT_TRANSLATION_CONFLICT_CODES.version, @@ -113,7 +135,14 @@ export const withTranslationHttpErrors = async ( } try { - return rethrowAsHttpError(error, { action, contentTypeId, itemId }); + // `read` has no write semantics for the shared mapper to describe, so it is + // reported as an update - the only paths it can reach there are the generic + // ones, and a read never hits a constraint. + return rethrowAsHttpError(error, { + action: action === "read" ? "update" : 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 @@ -122,8 +151,8 @@ export const withTranslationHttpErrors = async ( throw contentTranslationConflict({ code: CONTENT_TRANSLATION_CONFLICT_CODES.unique, contentTypeId, - itemId, - locale, + itemId: itemId ?? null, + locale: locale ?? "", }); } diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index 32ae63c03..3b13d86ba 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -1,7 +1,8 @@ +import type { SQL } from "drizzle-orm"; import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import { and, asc, eq, sql } from "drizzle-orm"; +import { and, asc, eq, ne, sql } from "drizzle-orm"; import type { ContentTranslationSchemas } from "../schemas"; import type { @@ -15,7 +16,10 @@ import type { import type { ContentLanguage } from "./language-resolver"; import type { ContentDatabase } from "./service"; -import { CONTENT_TRANSLATION_SYSTEM_FIELDS } from "../const"; +import { + CONTENT_TRANSLATION_PUBLICATION_FIELDS, + CONTENT_TRANSLATION_SYSTEM_FIELDS, +} from "../const"; import { ContentDefaultTranslationRequired, ContentEngineError, @@ -50,6 +54,25 @@ export interface ContentTranslationUpdateResult { version: number; } +/** + * Publish and unpublish guard on the *state*, so `expectedVersion` is optional. + * + * Same rule the base row's transitions follow: publishing overwrites no field + * values, so requiring a version would fail the button whenever a colleague had + * fixed a typo, for no protection against a lost update. When one is supplied it + * is `AND`ed on top of the state guard rather than replacing it. + */ +export interface ContentTranslationTransitionOptions extends ContentTranslationOptions { + expectedVersion?: number; +} + +export interface ContentTranslationTransitionResult { + /** `false` when the translation was already in the requested state. */ + changed: boolean; + row: ContentTranslationRow; + version: number; +} + /** * One localized content type's translation repository. * @@ -100,11 +123,43 @@ export interface ContentTranslationModel { findManyForItem: ( itemId: number, options?: ContentTranslationOptions, - ) => Promise; + ) => Promise[]>; + /** + * Marks one translation published, idempotently. + * + * `null` when there is no such translation. `changed: false` when it was + * already published - no version bump, and therefore no revision, no event and + * no cache work either. `publishedAt` is stamped on the first transition and + * never rewritten, so a republish keeps the original date. + * + * Throws without `publication: { enabled: true }`: there is no column to move. + */ + publish: ( + itemId: number, + locale: string, + options?: ContentTranslationTransitionOptions, + ) => Promise | null>; /** The language this content type creates records in. */ resolveDefaultLanguage: ( options?: ContentTranslationOptions, ) => Promise; + /** + * One locale, resolved through the request's language registry, or a throw. + * + * Exposed so the editorial layer resolves a locale exactly the way the + * repository does - same cache, same case-insensitive match, same canonical code + * - rather than reaching into the resolver with its own arguments. + */ + resolveLanguage: ( + locale: string, + options?: { requireEnabled?: boolean; tx?: ContentDatabase }, + ) => Promise; + /** The mirror of {@link publish}. `publishedAt` is deliberately left alone. */ + unpublish: ( + itemId: number, + locale: string, + options?: ContentTranslationTransitionOptions, + ) => Promise | null>; /** Conditional `UPDATE` guarded by `expectedVersion`. A no-op writes nothing. */ update: ( itemId: number, @@ -117,6 +172,22 @@ export interface ContentTranslationModel { const translationSystemFields: readonly string[] = CONTENT_TRANSLATION_SYSTEM_FIELDS; +/** + * A timestamp column as a `Date`, or `null`. + * + * The `postgres` driver hands timestamps back as strings on some paths (a raw + * `RETURNING` among them), and `publishedAt` is compared and formatted rather + * than only echoed - so it is normalised once here instead of at every reader. + */ +const toNullableDate = (value: unknown): Date | null => { + if (value instanceof Date) return value; + if (typeof value !== "string") return null; + + const parsed = new Date(value); + + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + export const createContentTranslationModel = < TDefinition extends AnyContentTypeDefinition, >({ @@ -156,6 +227,12 @@ export const createContentTranslationModel = < const versionColumn = columns.version; const baseId = (table as unknown as Record).id; + const publication = definition.publication.enabled; + const metaNames = [ + ...translationSystemFields, + ...(publication ? CONTENT_TRANSLATION_PUBLICATION_FIELDS : []), + ]; + // 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`. @@ -165,9 +242,7 @@ export const createContentTranslationModel = < ); const metaSelection = (): Record => - Object.fromEntries( - translationSystemFields.map(name => [name, columns[name]]), - ); + Object.fromEntries(metaNames.map(name => [name, columns[name]])); const fullSelection = (): Record => ({ ...metaSelection(), @@ -204,6 +279,35 @@ export const createContentTranslationModel = < * it means the update request body (`{ expectedVersion, values }`) and the * response have the same shape. */ + /** + * The publication half of a row, or nothing. + * + * Read off the row rather than defaulted, so a content type without publication + * has no `status` key at all - a `"draft"` invented here would make + * `isTranslationPublic` answer a question this content type never asked. + */ + const publicationOf = (row: Record): object => + publication + ? { + publishedAt: toNullableDate(row.publishedAt), + status: row.status, + } + : {}; + + const toMeta = ( + row: Record, + locale: string, + ): ContentTranslationMeta => + ({ + ...publicationOf(row), + createdAt: row.createdAt as Date, + itemId: row.itemId as number, + languageId: row.languageId as number, + locale, + updatedAt: row.updatedAt as Date, + version: row.version as number, + }) as ContentTranslationMeta; + const toRow = ( row: Record, locale: string, @@ -212,14 +316,9 @@ export const createContentTranslationModel = < 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, + ...toMeta(row, locale), values: values as ContentLocalizedValues, - version: row.version as number, - }; + } as ContentTranslationRow; }; const versionOf = (row: Record): number => @@ -239,6 +338,96 @@ export const createContentTranslationModel = < return row ?? null; }; + /** + * The `status` column, or a refusal. + * + * A content type without publication has no such column, so a publish call is a + * programming mistake rather than a runtime state - and `eq(undefined, ...)` + * would fail far from the cause with a Drizzle internal error. + */ + const statusColumn = (): PgColumn => { + if (!publication) { + throw new ContentEngineError( + "Translations can only be published on a content type with `publication: { enabled: true }` - without it there is no status column for a translation status to be subordinate to.", + { contentTypeId }, + ); + } + + return columns.status; + }; + + /** + * Publish and unpublish, which guard on the *state* rather than the version. + * + * The state guard is what makes them idempotent, and idempotency is what keeps + * a double-clicked button and a retried task from each producing a second + * version, a second revision and a second event. Deliberately the same shape + * `transition` in the base editorial service uses - two locales' transitions + * touch two rows, so they never contend with each other. + */ + const transition = async ( + itemId: number, + locale: string, + options: ContentTranslationTransitionOptions, + { guard, values }: { guard: SQL; values: Record }, + ): Promise | null> => { + const target = await language(locale, { + // Publishing into a locale the install has switched off would put content + // on a page nothing renders; taking one down must stay possible, which is + // why only the publish direction is checked - by its own guard, below. + requireEnabled: false, + tx: options.tx, + }); + const database = db(options); + + const conditions = [ + eq(itemColumn, itemId), + eq(languageColumn, target.id), + guard, + ]; + if (options.expectedVersion !== undefined) { + conditions.push(eq(versionColumn, options.expectedVersion)); + } + + const [row] = await database + .update(translationTable) + .set({ ...values, version: sql`${versionColumn} + 1` }) + .where(and(...conditions)) + .returning(fullSelection()); + + if (row) { + return { + changed: true, + row: toRow(row, target.locale), + version: versionOf(row), + }; + } + + const current = await readOne(itemId, target.id, database); + if (!current) return null; + + // Nothing matched but the row is there: either it was already in the + // requested state, or the version moved. Only the second is an error. + if ( + options.expectedVersion !== undefined && + versionOf(current) !== options.expectedVersion + ) { + throw new ContentTranslationVersionConflict({ + contentTypeId, + currentVersion: versionOf(current), + expectedVersion: options.expectedVersion, + itemId, + locale: target.locale, + }); + } + + return { + changed: false, + row: toRow(current, target.locale), + version: versionOf(current), + }; + }; + const assertItemExists = async ( itemId: number, database: ContentDatabase, @@ -386,22 +575,42 @@ export const createContentTranslationModel = < 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, - })); + return rows.map(row => + toMeta(row, languages.get(row.languageId as number)?.locale ?? ""), + ); }, + publish: async (itemId, locale, options) => + await transition(itemId, locale, options ?? {}, { + // COALESCE, so a republish keeps the date this language first went out. + // The base row's publish does the same thing to the same effect. + guard: ne(statusColumn(), "published"), + values: { + publishedAt: sql`coalesce(${columns.publishedAt}, now())`, + status: "published", + }, + }), + resolveDefaultLanguage: async options => await language(defaultLocale, { requireEnabled: true, tx: options?.tx, }), + resolveLanguage: async (locale, options) => + await language(locale, { + requireEnabled: options?.requireEnabled ?? false, + tx: options?.tx, + }), + + unpublish: async (itemId, locale, options) => + await transition(itemId, locale, options ?? {}, { + guard: eq(statusColumn(), "published"), + // `publishedAt` survives on purpose: it records when this language was + // first published, which stays true after it is taken down again. + values: { status: "draft" }, + }), + update: async (itemId, locale, values, options) => { const target = await language(locale, { requireEnabled: true, diff --git a/packages/vitnode/src/content/server/translation-preview-token.test.ts b/packages/vitnode/src/content/server/translation-preview-token.test.ts new file mode 100644 index 000000000..5cec66b37 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-preview-token.test.ts @@ -0,0 +1,151 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { testArticleContentType } from "@/tests/content-fixtures"; + +import { + createContentPreviewToken, + verifyContentPreviewToken, +} from "./preview-token"; + +const SECRET = "a".repeat(64); +const PLUGIN_ID = "@vitnode/example"; + +const mint = (overrides: Record = {}) => + createContentPreviewToken({ + definition: testArticleContentType, + itemId: 7, + pluginId: PLUGIN_ID, + revisionId: 11, + secret: SECRET, + version: 3, + ...overrides, + }); + +const verify = (token: string, overrides: Record = {}) => + verifyContentPreviewToken({ + definition: testArticleContentType, + pluginId: PLUGIN_ID, + secret: SECRET, + token, + ...overrides, + }); + +describe("locale binding", () => { + it("round-trips both frozen revisions", () => { + const { token } = mint({ + languageId: 2, + locale: "pl", + translationRevisionId: 55, + }); + + const payload = verify(token, { locale: "pl" }); + + // Both halves, which is what makes the frozen guarantee whole: the shared + // revision *and* the translation revision. Naming only one would let the other + // drift under a reviewer who was told the page was frozen. + expect(payload).toMatchObject({ l: "pl", lid: 2, r: 11, tr: 55 }); + }); + + it("refuses a token minted for another locale", () => { + const { token } = mint({ + languageId: 2, + locale: "pl", + translationRevisionId: 55, + }); + + expect(verify(token, { locale: "en" })).toBeNull(); + }); + + it("refuses a locale-scoped read with a base token", () => { + // A token with no locale previews the shared row. Honouring it on a locale + // read would be a fallback, and a preview must never fall back. + expect(verify(mint().token, { locale: "pl" })).toBeNull(); + }); + + it("refuses a locale token on a base read", () => { + const { token } = mint({ languageId: 2, locale: "pl" }); + + expect(verify(token)).toBeNull(); + }); + + it("matches the locale case-insensitively", () => { + const { token } = mint({ languageId: 2, locale: "pl" }); + + // A locale travels in a URL, and `/PL/` naming the same language is what + // people expect - the same rule the language resolver follows. + expect(verify(token, { locale: "PL" })).not.toBeNull(); + }); + + it("carries no locale keys at all on a base token", () => { + const payload = verify(mint().token); + + // Byte-identical to what Stage 4 minted, so an existing link keeps working and + // keeps meaning what it meant. + expect(payload).not.toHaveProperty("l"); + expect(payload).not.toHaveProperty("tr"); + }); + + it("defaults the translation revision to 0 when there is none to freeze", () => { + const { token } = mint({ languageId: 2, locale: "pl" }); + + expect(verify(token, { locale: "pl" })?.tr).toBe(0); + }); +}); + +describe("tampering", () => { + it("rejects a token whose locale was edited", () => { + const { token } = mint({ languageId: 2, locale: "pl" }); + const [payload, signature] = token.split("."); + const forged = Buffer.from( + JSON.stringify({ + ...(JSON.parse(Buffer.from(payload, "base64url").toString()) as Record< + string, + unknown + >), + l: "en", + }), + ).toString("base64url"); + + expect(verify(`${forged}.${signature}`, { locale: "en" })).toBeNull(); + }); + + it("rejects a token signed with another secret", () => { + const { token } = createContentPreviewToken({ + definition: testArticleContentType, + itemId: 7, + languageId: 2, + locale: "pl", + pluginId: PLUGIN_ID, + revisionId: 11, + secret: "b".repeat(64), + version: 3, + }); + + expect(verify(token, { locale: "pl" })).toBeNull(); + }); + + it("rejects an expired locale token", () => { + const { token } = mint({ + languageId: 2, + locale: "pl", + now: new Date("2020-01-01T00:00:00Z"), + }); + + expect(verify(token, { locale: "pl" })).toBeNull(); + }); + + it("rejects a locale token minted for another content type", () => { + const { token } = mint({ languageId: 2, locale: "pl" }); + + expect( + verifyContentPreviewToken({ + definition: { ...testArticleContentType, id: "test.other" }, + locale: "pl", + pluginId: PLUGIN_ID, + secret: SECRET, + token, + }), + ).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-routes.test.ts b/packages/vitnode/src/content/server/translation-routes.test.ts index 64809d3ea..547781b78 100644 --- a/packages/vitnode/src/content/server/translation-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-routes.test.ts @@ -23,6 +23,7 @@ import { buildContentTranslationRoutes } from "./translation-routes"; let permissionGranted = true; const permissionChecks: { module: string; permission: string }[] = []; +const emitted = vi.fn(() => ({ failures: [], listeners: 0 })); // `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 @@ -88,7 +89,10 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), + publish: vi.fn(), resolveDefaultLanguage: vi.fn(), + resolveLanguage: vi.fn(), + unpublish: vi.fn(), update: vi.fn(), }; @@ -102,6 +106,10 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { const context: MiddlewareHandler = async (c, next) => { c.set("admin", allow ? { user: adminUser } : null); + // Every write route announces itself after the commit. The transport is not + // what these tests are about, so it records instead of delivering - and a + // missing one would surface as a 500 rather than as a missing event. + c.set("events", { emit: emitted } as never); await next(); }; app.use("*", context); @@ -120,7 +128,7 @@ beforeEach(() => { }); describe("route registration", () => { - it("appends the five translation routes to a localized content type", () => { + it("appends the translation routes to a localized content type", () => { const paths = buildContentRoutes(localized, { pluginId: PLUGIN_ID }).map( entry => `${entry.route.method.toUpperCase()} ${entry.route.path}`, ); @@ -260,14 +268,14 @@ describe("POST /{id}/translations/{locale}", () => { }); }); - it("needs `can_edit`", async () => { + it("needs `can_translate`, not `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" }, + { module: "test_localized", permission: "can_translate" }, ]); }); diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts index 06f1d152b..d3233c110 100644 --- a/packages/vitnode/src/content/server/translation-routes.ts +++ b/packages/vitnode/src/content/server/translation-routes.ts @@ -3,29 +3,58 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; -import type { AnyContentTypeDefinition } from "../types"; +import type { + AnyContentTypeDefinition, + ContentLocalizedFieldName, + ContentTranslationRow, +} from "../types"; import type { ContentModel } from "./model"; +import type { + ContentTranslationEditorialOutcome, + ContentTranslationEditorialService, +} from "./translation-editorial-service"; 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 { + zodContentTranslationConflict, + zodContentUnprocessable, +} from "../conflicts"; +import { + CONTENT_ACTOR_TYPES, + CONTENT_LOCALE_MAX_LENGTH, + CONTENT_PERMISSIONS, + CONTENT_TRANSLATION_REVISION_OPERATIONS, +} from "../const"; +import { resolveContentActor } from "./actor"; +import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; +import { contentTranslationEffects } from "./translation-effects"; import { withTranslationHttpErrors } from "./translation-http-errors"; /** - * The five generated translation routes for one localized content type. + * The 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. + * table is being read. Locales are canonical strings on the outside and numeric + * `core_languages.id` values on the inside - a client never sends an id, so it can + * never point one at a language it was not shown. * - * 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. + * Permissions: + * + * | Route | Permission | + * | --- | --- | + * | read, history | `can_view` | + * | create, update | `can_translate` | + * | publish, unpublish | `can_publish` | + * | restore | `can_restore` | + * | delete | `can_delete` | + * + * `can_translate` rather than `can_edit`, which is the point of having it: a + * translator gets every locale tab without gaining the ability to touch a shared + * field, move the global publication state or delete the record. */ export const buildContentTranslationRoutes = < TDefinition extends AnyContentTypeDefinition, @@ -47,10 +76,30 @@ export const buildContentTranslationRoutes = < const translationSchemas = schemas; const buildService = model.translationService; + const buildEditorial = model.translationEditorialService; const translations = (c: Context): ContentTranslationModel => buildService(c); + /** + * The editorial layer, for the routes that only exist when there is one. + * + * A 500 rather than a graceful degradation: every caller below is behind the + * `editorial.enabled` check that decides whether the route is built at all, so + * reaching this is a wiring bug in the engine and not something a request did. + */ + const editorial = ( + c: Context, + ): ContentTranslationEditorialService => { + if (!buildEditorial) { + throw new HTTPException(500, { + message: "This content type has no translation history.", + }); + } + + return buildEditorial(c, { pluginId }); + }; + const jsonBody = (schema: z.ZodType) => ({ content: { "application/json": { schema } }, }); @@ -99,6 +148,52 @@ export const buildContentTranslationRoutes = < description: `${label.singular}, locale or translation not found`, }; + /** + * Announces one translation mutation, once its transaction has committed. + * + * Every write route funnels through this rather than calling the effects + * directly, so "emit exactly one event per real mutation, and none for a no-op" + * is stated once. The outcome carries `changed`, and the effects respect it. + */ + const announce = async ( + c: Context, + outcome: ContentTranslationEditorialOutcome, + ): Promise => { + await contentTranslationEffects(c, definition, outcome, { pluginId }); + }; + + /** + * Turns a bare repository result into the outcome the effects expect. + * + * The path a localized content type **without** `editorial` takes: there is no + * history to write, so there is no revision id - but the event still fires, + * because `translation_created` is gated on localization and not on editorial. + * With `editorial` the service produces a richer outcome itself and this is not + * used. + */ + const plainOutcome = ( + operation: ContentTranslationEditorialOutcome["operation"], + row: ContentTranslationRow, + { + changed = true, + changedFields = [], + }: { + changed?: boolean; + changedFields?: ContentLocalizedFieldName[]; + } = {}, + ): ContentTranslationEditorialOutcome => ({ + changed, + changedFields, + languageId: row.languageId, + locale: row.locale, + operation, + previousSlug: null, + restoredFromRevisionId: null, + revisionId: null, + row, + version: row.version, + }); + const list = buildRoute({ pluginId, adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, @@ -150,7 +245,7 @@ export const buildContentTranslationRoutes = < const create = buildRoute({ pluginId, - adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.translate }, route: { method: "post", path: "/{id}/translations/{locale}", @@ -171,19 +266,32 @@ export const buildContentTranslationRoutes = < const target = locale(c); const { values } = await readJson(c, translationSchemas.createEnvelope); - const row = await withTranslationHttpErrors( + // A new translation is always a draft. Publishing it is a separate, + // separately permissioned step - a translator finishing a Polish copy must + // not put it on the internet by pressing save. + const outcome = await withTranslationHttpErrors( "create", - async () => await translations(c).create(id, target, values), + async () => + buildEditorial + ? await editorial(c).create(id, target, values, { + actor: resolveContentActor(c), + }) + : plainOutcome( + "create", + await translations(c).create(id, target, values), + ), { contentTypeId: definition.id, itemId: id, locale: target }, ); - return c.json(row, 201); + await announce(c, outcome); + + return c.json(outcome.row, 201); }, }); const update = buildRoute({ pluginId, - adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.translate }, route: { // PUT, not PATCH: the Next.js API route handler exports no PATCH. method: "put", @@ -215,19 +323,38 @@ export const buildContentTranslationRoutes = < translationSchemas.updateEnvelope, ); - const result = await withTranslationHttpErrors( + const outcome = await withTranslationHttpErrors( "update", async () => - await translations(c).update(id, target, values, { - expectedVersion, - }), + buildEditorial + ? await editorial(c).update(id, target, values, { + actor: resolveContentActor(c), + expectedVersion, + }) + : await (async () => { + const result = await translations(c).update( + id, + target, + values, + { expectedVersion }, + ); + + return result + ? plainOutcome("update", result.row, { + changed: result.changed, + changedFields: result.changedFields, + }) + : null; + })(), { contentTypeId: definition.id, itemId: id, locale: target }, ); - if (!result) { + if (!outcome) { throw new HTTPException(404, { message: "Translation not found." }); } - return c.json({ changed: result.changed, row: result.row }, 200); + await announce(c, outcome); + + return c.json({ changed: outcome.changed, row: outcome.row }, 200); }, }); @@ -267,19 +394,284 @@ export const buildContentTranslationRoutes = < translationSchemas.versionEnvelope, ); - const row = await withTranslationHttpErrors( + const outcome = await withTranslationHttpErrors( "delete", async () => - await translations(c).delete(id, target, { expectedVersion }), + buildEditorial + ? await editorial(c).delete(id, target, { + actor: resolveContentActor(c), + expectedVersion, + }) + : await (async () => { + const row = await translations(c).delete(id, target, { + expectedVersion, + }); + + return row ? plainOutcome("delete", row) : null; + })(), { contentTypeId: definition.id, itemId: id, locale: target }, ); - if (!row) { + if (!outcome) { throw new HTTPException(404, { message: "Translation not found." }); } - return c.json(row, 200); + await announce(c, outcome); + + return c.json(outcome.row, 200); + }, + }); + + // ------------------------------------------------------------------------- + // Lifecycle: only with `publication`, which is what a translation status is + // subordinate to. Without it the columns do not exist and there is nothing to + // move. + // ------------------------------------------------------------------------- + + const transitionRoute = (action: "publish" | "unpublish") => + buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.publish }, + route: { + method: "post", + path: `/{id}/translations/{locale}/${action}`, + description: `${action === "publish" ? "Publish" : "Unpublish"} one ${label.singular} translation`, + request: { + params: translationSchemas.params, + body: jsonBody(translationSchemas.versionEnvelope), + }, + responses: { + 200: jsonResponse( + z.object({ + /** `false` when it was already in that state - a true no-op. */ + changed: z.boolean(), + row: translationSchemas.select, + }), + `Translation ${action}ed, or already ${action}ed`, + ), + 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 outcome = await withTranslationHttpErrors( + "update", + async () => + await editorial(c)[action](id, target, { + actor: resolveContentActor(c), + expectedVersion, + }), + { contentTypeId: definition.id, itemId: id, locale: target }, + ); + if (!outcome) { + throw new HTTPException(404, { message: "Translation not found." }); + } + + await announce(c, outcome); + + return c.json({ changed: outcome.changed, row: outcome.row }, 200); + }, + }); + + // ------------------------------------------------------------------------- + // History: only with `editorial`. + // ------------------------------------------------------------------------- + + const zodTranslationRevisionMeta = z.object({ + actorName: z.string().nullable(), + actorType: z.enum(CONTENT_ACTOR_TYPES), + actorUserId: z.number().nullable(), + changedFields: z.array(z.string()), + createdAt: z.union([z.date(), z.string()]), + id: z.number(), + operation: z.enum(CONTENT_TRANSLATION_REVISION_OPERATIONS), + restoredFromRevisionId: z.number().nullable(), + version: z.number(), + }); + + // `.loose()` for the same reason the shared revision detail is loose: a snapshot + // is data this content type wrote, and its shape moves with the content type. + const zodTranslationRevisionDetail = zodTranslationRevisionMeta.extend({ + locale: z.string(), + snapshot: z.object({}).loose(), + }); + + const revisionParams = z.object({ + id: z.coerce.number(), + locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH), + revisionId: z.coerce.number(), + }); + + const revisionIdentifier = (c: Context): number => { + const value = Number(c.req.param("revisionId")); + if (!Number.isInteger(value) || value <= 0) { + throw new HTTPException(400, { message: "Invalid revision identifier." }); + } + + return value; + }; + + const revisionQuery = z.object({ + cursor: z.coerce.number().int().positive().optional(), + first: z.coerce + .number() + .int() + .min(1) + .max(CONTENT_REVISIONS_MAX_PAGE_SIZE) + .optional(), + }); + + const revisionList = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/translations/{locale}/revisions", + description: `History of one ${label.singular} translation`, + request: { params: translationSchemas.params, query: revisionQuery }, + responses: { + 200: jsonResponse( + z.object({ + edges: z.array(zodTranslationRevisionMeta), + pageInfo: z.object({ + endCursor: z.number().nullable(), + hasNextPage: z.boolean(), + }), + }), + "Revisions of this locale, newest first", + ), + 400: invalidIdentifier, + 404: notFound, + }, + }, + handler: async c => { + const { cursor, first } = revisionQuery.parse(c.req.query()); + + // Scoped to the locale in the URL, so the English history is unreachable + // from the Polish tab - the model filters on `languageId`, it is not a + // post-filter over a wider read. + const page = await withTranslationHttpErrors( + "read", + async () => + await editorial(c).listRevisions(identifier(c), locale(c), { + cursor, + limit: first, + }), + { contentTypeId: definition.id }, + ); + + return c.json(page, 200); }, }); - return [list, detail, create, update, remove]; + const revisionDetail = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/translations/{locale}/revisions/{revisionId}", + description: `One revision of a ${label.singular} translation`, + request: { params: revisionParams }, + responses: { + 200: jsonResponse(zodTranslationRevisionDetail, "Revision found"), + 400: invalidIdentifier, + 404: { description: "Revision not found" }, + }, + }, + handler: async c => { + const revision = await withTranslationHttpErrors( + "read", + async () => + await editorial(c).findRevision( + identifier(c), + locale(c), + revisionIdentifier(c), + ), + { contentTypeId: definition.id }, + ); + if (!revision) { + throw new HTTPException(404, { message: "Revision not found." }); + } + + return c.json(revision, 200); + }, + }); + + const restore = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.restore }, + route: { + method: "post", + path: "/{id}/translations/{locale}/revisions/{revisionId}/restore", + description: `Restore one ${label.singular} translation to an earlier revision`, + request: { + params: revisionParams, + body: jsonBody(translationSchemas.versionEnvelope), + }, + responses: { + 200: jsonResponse( + z.object({ + changed: z.boolean(), + row: translationSchemas.select, + }), + "Translation restored, or already at those values", + ), + 400: invalidIdentifier, + 404: { description: "Revision not found" }, + 409: conflict, + 422: jsonResponse( + zodContentUnprocessable, + "The revision no longer fits this content type", + ), + }, + }, + handler: async c => { + const id = identifier(c); + const target = locale(c); + const revisionId = revisionIdentifier(c); + const { expectedVersion } = await readJson( + c, + translationSchemas.versionEnvelope, + ); + + const outcome = await withTranslationHttpErrors( + "update", + async () => + await editorial(c).restore(id, target, revisionId, { + actor: resolveContentActor(c), + expectedVersion, + }), + { contentTypeId: definition.id, itemId: id, locale: target }, + ); + if (!outcome) { + throw new HTTPException(404, { message: "Revision not found." }); + } + + await announce(c, outcome); + + return c.json({ changed: outcome.changed, row: outcome.row }, 200); + }, + }); + + const publication = definition.publication.enabled; + const editorialEnabled = definition.editorial.enabled; + + return [ + list, + detail, + create, + update, + remove, + ...(publication && editorialEnabled + ? [transitionRoute("publish"), transitionRoute("unpublish")] + : []), + ...(editorialEnabled ? [revisionList, revisionDetail, restore] : []), + ]; }; diff --git a/packages/vitnode/src/content/server/translation-table.ts b/packages/vitnode/src/content/server/translation-table.ts index 91d5870ef..09e40ea7b 100644 --- a/packages/vitnode/src/content/server/translation-table.ts +++ b/packages/vitnode/src/content/server/translation-table.ts @@ -13,12 +13,16 @@ import type { } from "./types"; import { core_languages } from "../../database/languages"; -import { CONTENT_TRANSLATION_SYSTEM_FIELDS } from "../const"; +import { + CONTENT_TRANSLATION_PUBLICATION_FIELDS, + CONTENT_TRANSLATION_SYSTEM_FIELDS, +} from "../const"; import { ContentEngineError } from "../errors"; import { contentTranslationPrimaryKeyName } from "../indexes"; import { partitionContentFields } from "../localization"; import { buildContentColumn, + buildTranslationPublicationColumns, buildTranslationSystemColumns, } from "./column-builders"; @@ -62,6 +66,12 @@ export const createContentTranslationTable = < itemReference: () => baseColumns.id, languageReference: () => core_languages.id, }), + // Only with publication, matching the base table exactly. Without a global + // draft state there is nothing for a translation's own status to be + // subordinate to, and the column would gate a visibility nothing consults. + ...(definition.publication.enabled + ? buildTranslationPublicationColumns() + : {}), }; for (const [name, fieldValue] of Object.entries(localizedFields)) { @@ -107,6 +117,9 @@ export const contentTranslationTableColumns = < const { localizedFields } = partitionContentFields(definition.fields); const names = [ ...CONTENT_TRANSLATION_SYSTEM_FIELDS, + ...(definition.publication.enabled + ? CONTENT_TRANSLATION_PUBLICATION_FIELDS + : []), ...Object.keys(localizedFields), ]; diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 3883939fe..751f19158 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -129,8 +129,11 @@ export interface ContentTranslationSystemColumnBuilders { version: NotNull>>; } -export type ContentTranslationColumnBuilders = - ContentTranslationSystemColumnBuilders & { +export type ContentTranslationColumnBuilders< + TFields, + TPublication extends boolean = false, +> = ContentTranslationSystemColumnBuilders & + PublicationColumnBuilders & { [K in keyof TFields]: ContentColumnBuilder; }; @@ -144,8 +147,13 @@ export type ContentTranslationColumnBuilders = export type ContentTranslationTable< TName extends string, TFields, + TPublication extends boolean = false, > = PgTableWithColumns<{ - columns: BuildColumns, "pg">; + columns: BuildColumns< + TName, + ContentTranslationColumnBuilders, + "pg" + >; dialect: "pg"; name: TName; schema: undefined; @@ -174,14 +182,29 @@ type LocalizedFieldsOf = { * 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 ->; +export type ContentTranslationTableFor = TDefinition extends { + publication: { enabled: infer TPublication extends boolean }; +} + ? ContentTranslationTable< + string, + LocalizedFieldsOf, + TPublication + > + : never; -/** Column name -> Drizzle column on the translation table. */ +/** + * Column name -> Drizzle column on the translation table. + * + * The publication pair is gated exactly like {@link ContentColumnName} gates it + * on the base table: a translation only carries `status` and `publishedAt` when + * the content type has a lifecycle for them to describe. + */ export type ContentTranslationColumnName = - ContentLocalizedFieldName | ContentTranslationSystemField; + | ContentLocalizedFieldName + | ContentTranslationSystemField + | (TDefinition extends { publication: { enabled: true } } + ? ContentPublicationField + : never); /** * The `pgTable` a content type compiles to. diff --git a/packages/vitnode/src/content/translation-editorial.test-d.ts b/packages/vitnode/src/content/translation-editorial.test-d.ts new file mode 100644 index 000000000..c614bbc07 --- /dev/null +++ b/packages/vitnode/src/content/translation-editorial.test-d.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testLocalizedArticleContentType, + testLocalizedGuideContentType, +} from "@/tests/content-fixtures"; + +import type { + ContentEventsFor, + ContentTranslationPublishedPayload, + ContentTranslationRestoredPayload, + ContentTranslationUpdatedPayload, +} from "./events"; +import type { + AnyContentTypeDefinition, + ContentTranslationMeta, + ContentTranslationRow, +} from "./types"; + +type Guide = typeof testLocalizedGuideContentType; +type Article = typeof testArticleContentType; +type LocalizedOnly = typeof testLocalizedArticleContentType; + +describe("translation lifecycle columns", () => { + it("gives a localized, published content type a status and a publishedAt", () => { + expectTypeOf["status"]>().toEqualTypeOf< + "draft" | "published" + >(); + expectTypeOf< + ContentTranslationRow["publishedAt"] + >().toEqualTypeOf(); + }); + + it("gives the metadata shape the same pair", () => { + expectTypeOf["status"]>().toEqualTypeOf< + "draft" | "published" + >(); + }); + + it("withholds them from a localized content type without publication", () => { + // @ts-expect-error - no publication, so a translation has no status to read. + type _Status = ContentTranslationRow["status"]; + // @ts-expect-error - same, for the timestamp. + type _PublishedAt = ContentTranslationRow["publishedAt"]; + }); + + it("still narrows `values` to the localized fields only", () => { + expectTypeOf["values"]>().toEqualTypeOf< + "body" | "slug" | "summary" | "title" + >(); + }); +}); + +describe("translation events", () => { + type GuideEvents = ContentEventsFor; + type ArticleEvents = ContentEventsFor
; + type LocalizedOnlyEvents = ContentEventsFor; + + it("adds the three core translation events for any localized content type", () => { + expectTypeOf().toHaveProperty( + "content.test.localized.translation_created", + ); + expectTypeOf().toHaveProperty( + "content.test.localized.translation_updated", + ); + expectTypeOf().toHaveProperty( + "content.test.localized.translation_deleted", + ); + }); + + it("adds the lifecycle pair only with publication", () => { + expectTypeOf().toHaveProperty( + "content.test.localized-guide.translation_published", + ); + type _Missing = + // @ts-expect-error - no publication, so no translation lifecycle event. + LocalizedOnlyEvents["content.test.localized.translation_published"]; + }); + + it("adds the restore event only with editorial", () => { + expectTypeOf().toHaveProperty( + "content.test.localized-guide.translation_restored", + ); + type _Missing = + // @ts-expect-error - no editorial, so no history to restore from. + LocalizedOnlyEvents["content.test.localized.translation_restored"]; + }); + + it("adds none of them to a non-localized content type", () => { + type _Missing = + // @ts-expect-error - a Stage 1 content type gains no translation key. + ArticleEvents["content.test.article.translation_created"]; + }); + + it("narrows changedFields to the localized field names", () => { + expectTypeOf< + ContentTranslationUpdatedPayload["changedFields"] + >().toEqualTypeOf<("body" | "slug" | "summary" | "title")[]>(); + }); + + it("always carries the locale on every translation payload", () => { + expectTypeOf< + ContentTranslationPublishedPayload["locale"] + >().toEqualTypeOf(); + expectTypeOf< + ContentTranslationRestoredPayload["restoredFromRevisionId"] + >().toEqualTypeOf(); + }); +}); + +describe("backward compatibility", () => { + it("keeps a localized, editorial, published definition assignable to the erased one", () => { + // Everything that holds a collection of models - the queue handler, the + // cleanup cron, the registry - is written against this. + assertType(testLocalizedGuideContentType); + assertType(testLocalizedArticleContentType); + assertType(testArticleContentType); + }); + + it("leaves a non-localized row shape with no translation members", () => { + expectTypeOf< + keyof ContentTranslationRow
["values"] + >().toEqualTypeOf(); + }); +}); diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 548665f6f..3a11debe3 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -933,33 +933,57 @@ export type ContentLocalizedUpdateValues = Prettify< Partial> >; +/** + * One translation's own publication state, or nothing. + * + * Gated on the *base* content type having publication, for the same reason the + * columns are: a translation status is only meaningful as something subordinate + * to a global one. Optional members rather than a widened `string`, so reading + * `row.status` on a content type without publication is a compile error rather + * than a silent `undefined`. + */ +export type ContentTranslationPublicationColumns = + TDefinition extends { publication: { enabled: true } } + ? { + /** First published, in this language. Never rewritten by a republish. */ + publishedAt: Date | null; + status: ContentPublicationStatus; + } + : Record; + /** 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; -} +export type ContentTranslationRow = Prettify< + ContentTranslationPublicationColumns & { + 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. + * know which languages exist, how stale each one is and whether each is + * published - 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; -} +export type ContentTranslationMeta = + Prettify< + ContentTranslationPublicationColumns & { + createdAt: Date; + itemId: number; + languageId: number; + locale: string; + updatedAt: Date; + version: number; + } + >; // --------------------------------------------------------------------------- // Definition diff --git a/packages/vitnode/src/database/content.ts b/packages/vitnode/src/database/content.ts index 5ed075993..2d0019817 100644 --- a/packages/vitnode/src/database/content.ts +++ b/packages/vitnode/src/database/content.ts @@ -2,8 +2,10 @@ import { sql } from "drizzle-orm"; import { index, pgTable, uniqueIndex } from "drizzle-orm/pg-core"; import type { + ContentAnyRevisionSnapshot, ContentRevisionSnapshot, ContentSnapshotValue, + ContentTranslationRevisionSnapshot, } from "../content/revisions"; import { @@ -36,6 +38,22 @@ export const core_content_revisions = pgTable( pluginId: t.varchar({ length: 255 }).notNull(), contentTypeId: t.varchar({ length: 100 }).notNull(), itemId: t.integer().notNull(), + /** + * Which language this revision belongs to, or `NULL` for a shared one. + * + * `NULL` is the whole history of every non-localized content type and the + * *shared* history of a localized one, which is why it is the column default + * in effect: a nullable column with no default backfills every pre-Stage-5B + * row to exactly the right value in one statement. + * + * Deliberately **not** a foreign key, for the same reason there is none to + * the record: a revision is an audit trail, and "the Polish copy said this" + * stays true after the language row is gone. A cascade would erase the fact + * and a restrict would block a language deletion the *translation* table has + * already had its say about. The snapshot carries the locale code, so a + * revision remains readable without the language it names. + */ + languageId: t.integer(), /** The version the record holds *after* this mutation. */ version: t.integer().notNull(), operation: t @@ -43,7 +61,7 @@ export const core_content_revisions = pgTable( .notNull(), snapshot: t .jsonb() - .$type() + .$type() .notNull() .default({} as ContentRevisionSnapshot), /** Field names this mutation moved, so the history list needs no snapshot. */ @@ -80,9 +98,27 @@ export const core_content_revisions = pgTable( // id already identifies exactly one content type and one table - adding the // owner would widen the index without excluding anything. It is still a // column, because ownership is what the cleanup job keys off. - uniqueIndex("core_content_revisions_item_version_unique").on( + // + // Partial from Stage 5B on. A translation's version counter is its own, so + // English v3 and Polish v3 are two different facts and a single key over + // `(contentTypeId, itemId, version)` would reject the second one. Two partial + // indexes rather than one over a nullable `languageId`, because Postgres + // treats every `NULL` as distinct - a shared key including it would enforce + // nothing at all for the non-localized case it exists to protect. + uniqueIndex("core_content_revisions_item_version_unique") + .on(t.contentTypeId, t.itemId, t.version) + .where(sql`"languageId" IS NULL`), + uniqueIndex("core_content_revisions_translation_version_unique") + .on(t.contentTypeId, t.itemId, t.languageId, t.version) + .where(sql`"languageId" IS NOT NULL`), + // The locale history read: one record's revisions in one language, newest + // first. The partial unique index above cannot serve it - a partial index is + // only usable for queries the planner can prove match its predicate, and the + // history list does not filter on `"languageId" IS NOT NULL` in those terms. + index("core_content_revisions_language_idx").on( t.contentTypeId, t.itemId, + t.languageId, t.version, ), index("core_content_revisions_plugin_id_idx").on(t.pluginId), @@ -173,4 +209,9 @@ export const core_content_schedules = pgTable( export type ContentScheduleRow = typeof core_content_schedules.$inferSelect; /** Re-exported so `src/database` consumers need not reach into `content/`. */ -export type { ContentRevisionSnapshot, ContentSnapshotValue }; +export type { + ContentAnyRevisionSnapshot, + ContentRevisionSnapshot, + ContentSnapshotValue, + ContentTranslationRevisionSnapshot, +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index cf3c9e032..6b0765384 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -567,6 +567,51 @@ "version_conflict": "Someone else saved this while you were editing. Your changes are still here.", "unique_conflict": "A record with these values already exists.", "not_restorable": "This version cannot be restored: {fields} no longer fit this content type." + }, + "translations": { + "shared_tab": "Shared", + "save": "Save translation", + "create": "Create translation", + "publish": "Publish this language", + "unpublish": "Unpublish this language", + "delete": "Delete the {name} translation", + "version": "Version {version}", + "never_published": "Not published yet", + "missing_readonly": "There is no {name} translation yet. You need the translate permission to add one.", + "default_locale_note": "{name} is the default language, so its translation cannot be deleted. Delete the record itself instead.", + "states": { + "missing": "Missing", + "draft": "Draft", + "published": "Published" + }, + "conflict": { + "title": "The {name} translation changed", + "desc": "Someone else saved this language while you were editing. Your changes are still here. Reload to see theirs, then decide what to keep.", + "reload": "Reload this language" + }, + "success": { + "created": "Translation created", + "saved": "Translation saved", + "deleted": "Translation deleted", + "published": "Translation published", + "unpublished": "Translation unpublished" + }, + "errors": { + "version_conflict": "Someone else saved this language while you were editing.", + "unique_conflict": "Another record already uses that address in this language.", + "exists": "This language already has a translation. Reload the tab to edit it.", + "language_disabled": "This language is switched off on this installation, so its content cannot be written.", + "default_required": "This is the default language, so its translation cannot be deleted." + }, + "history": { + "show": "Show this language's history", + "hide": "Hide this language's history", + "empty": "No versions yet. The first edit will show up here.", + "restore": "Restore", + "restored": "Translation restored", + "restore_failed": "This version could not be restored.", + "not_restorable": "This version cannot be restored: {fields} no longer fit this content type." + } } } }, diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 4f93ddcc5..b4a500068 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -253,3 +253,36 @@ export const testLocalizedNoteContentType = defineContentType({ label: { plural: "Test Localized Notes", singular: "Test Localized Note" }, }, }); + +/** + * The Stage 5B fixture: localized **and** editorial **and** published. + * + * All three, because that is the combination the editorial layer needs and the + * one Stage 5A refused. The translation table gains `status` and `publishedAt`, + * each locale gets its own version and its own history, and the base row keeps the + * global lifecycle every translation's visibility is subordinate to. + * + * `publicApi` and `search` are still absent - both remain refused alongside + * localization until Stage 5C and 5D respectively. + */ +export const testLocalizedGuideContentType = defineContentType({ + id: "test.localized-guide", + tableName: "test_localized_guides", + editorial: { enabled: true, revisions: { retention: 5 } }, + localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + publication: { enabled: true }, + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, nullable: true }), + summary: field.text({ localized: true, nullable: true, maxLength: 300 }), + featured: field.boolean({ defaultValue: false }), + }, + admin: { + label: { + plural: "Test Localized Guides", + singular: "Test Localized Guide", + }, + list: { columns: ["featured", "status"] }, + }, +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx index 942590c2d..c3b6bc8d4 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/edit-action.tsx @@ -5,6 +5,8 @@ import { useTranslations } from "next-intl"; import dynamic from "next/dynamic"; import React from "react"; +import type { ContentFormSpec } from "@/content/admin/spec"; + import { useAdminStaffPermission } from "@/components/staff-permission/provider"; import { Button } from "@/components/ui/button"; import { @@ -30,14 +32,39 @@ const ContentForm = dynamic(async () => import("./content-form").then(mod => ({ default: mod.ContentForm })), ); +// The locale editor carries the whole per-language surface - the tab strip, the +// panel, the history - so it is loaded with the dialog rather than with the table. +const LocaleEditor = dynamic(async () => + import("./translations/locale-editor").then(mod => ({ + default: mod.LocaleEditor, + })), +); + +/** + * The edit row action. + * + * For a localized content type it opens the tabbed locale editor instead of the + * plain form: `Shared` first, then one tab per language the app serves. The dialog + * is reachable with `can_edit` **or** `can_translate` - a translator who may not + * touch a shared field still needs somewhere to write the Polish copy, and each tab + * gates its own actions. + */ export const EditContentAction = ({ + defaultLocale, + editorial = false, permissionModule, pluginId, singular, + translationSpec = null, ...props }: ContentFormProps & { + /** The content type's default locale. Required when `translationSpec` is set. */ + defaultLocale?: string; + editorial?: boolean; permissionModule: string; pluginId: string; + /** Localized-field form spec, or `null` when the content type is not localized. */ + translationSpec?: ContentFormSpec | null; }) => { const t = useTranslations("core.content.edit"); const canEdit = useAdminStaffPermission({ @@ -45,8 +72,15 @@ export const EditContentAction = ({ permission: CONTENT_PERMISSIONS.edit, plugin: pluginId, }); + const canTranslate = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.translate, + plugin: pluginId, + }); + + const localized = translationSpec !== null && props.data !== undefined; - if (!canEdit) return null; + if (!canEdit && !(localized && canTranslate)) return null; return ( @@ -77,7 +111,19 @@ export const EditContentAction = ({ }> - + {localized ? ( + + ) : ( + + )} diff --git a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts new file mode 100644 index 000000000..6d2f4a32d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts @@ -0,0 +1,380 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import type { + ContentTranslationConflict, + ContentUnprocessable, +} from "@/content/conflicts"; +import type { + ContentRevisionDetail, + ContentRevisionMeta, + ContentTranslationRevisionSnapshot, +} from "@/content/revisions"; + +import { findFrontendContentType } from "@/content/admin/config"; +import { contentApiFetch } from "@/content/admin/fetch.server"; +import { + parseContentTranslationConflict, + parseContentUnprocessable, +} from "@/content/conflicts"; + +/** + * The generic content screen ships from core, so its cached page path is the + * catch-all route copied into every web app. Same constant the shared mutation + * actions use; duplicated rather than exported, because a `"use server"` module + * may only export async functions. + */ +const CONTENT_PAGE_PATH = + "/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]"; + +/** + * What a translation mutation reports back. + * + * `conflict` is the interesting member and the reason this is not a boolean: the + * locale editor has to tell "somebody else saved this Polish copy" from "that + * Polish slug is taken" from "you cannot delete the default translation", and it + * has to name the locale in each case so the right tab is highlighted. + */ +export interface TranslationMutationResult { + conflict?: ContentTranslationConflict; + error?: string; + status?: number; + unprocessable?: ContentUnprocessable; +} + +const failure = (result: { + error?: string; + status: number; +}): TranslationMutationResult => ({ + conflict: parseContentTranslationConflict(result.error) ?? undefined, + error: result.error ?? "", + status: result.status, + unprocessable: parseContentUnprocessable(result.error) ?? undefined, +}); + +const resolve = (contentTypeId: string) => { + const entry = findFrontendContentType(contentTypeId); + if (!entry) { + throw new Error(`Unknown content type "${contentTypeId}".`); + } + + return entry; +}; + +/** + * The locale is a path segment, so it is encoded rather than interpolated raw. + * + * Belt and braces: the server resolves it against `core_languages` and answers + * 404 for anything it does not recognise, so a crafted value cannot reach a + * different record - but a `/` in a URL segment would change which *route* + * matched, and that is worth closing here. + */ +const segment = (locale: string): string => encodeURIComponent(locale); + +/** One translation as the routes return it: metadata plus nested `values`. */ +const zodTranslation = z + .object({ + itemId: z.number(), + languageId: z.number(), + locale: z.string(), + values: z.record(z.string(), z.unknown()), + version: z.number(), + }) + .loose(); + +const zodTranslationList = z.object({ + edges: z.array(z.object({ locale: z.string() }).loose()), +}); + +const zodTranslationResult = z.object({ + changed: z.boolean(), + row: zodTranslation, +}); + +const zodRevisionList = z.object({ + edges: z.array(z.object({ id: z.number() }).loose()), + pageInfo: z.object({ + endCursor: z.number().nullable(), + hasNextPage: z.boolean(), + }), +}); + +/** One locale's row, as the tab strip and the panel read it. */ +export interface TranslationRow { + itemId: number; + languageId: number; + locale: string; + publishedAt?: null | string; + status?: string; + values: Record; + version: number; +} + +/** One locale's presence and lifecycle, without its values. */ +export interface TranslationMeta { + locale: string; + publishedAt?: null | string; + status?: string; + version: number; +} + +/** + * Which languages one record exists in. + * + * Metadata only, and one request for the whole strip: a tab bar needs to know + * which locales are present and whether each is published, not to drag every + * body in every language across the wire to find out. The panel loads one locale's + * values when its tab is opened. + */ +export const listContentTranslationsAction = async ( + contentTypeId: string, + id: number, +): Promise<{ edges: TranslationMeta[]; error?: string }> => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}/translations`, + pluginId, + schema: zodTranslationList, + }); + + if (result.status !== 200 || !result.data) { + return { edges: [], error: result.error ?? "" }; + } + + return { edges: result.data.edges as unknown as TranslationMeta[] }; +}; + +export const getContentTranslationAction = async ( + contentTypeId: string, + id: number, + locale: string, +): Promise<{ error?: string; row?: TranslationRow }> => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}/translations/${segment(locale)}`, + pluginId, + schema: zodTranslation, + }); + + // A missing translation is a 404 and not an error: "this locale has no + // translation yet" is a state the tab renders as `Missing` with a create + // action, and treating it as a failure would put a toast on an empty tab. + if (result.status === 404) return {}; + if (result.status !== 200 || !result.data) { + return { error: result.error ?? "" }; + } + + return { row: result.data as unknown as TranslationRow }; +}; + +export const createContentTranslationAction = async ( + contentTypeId: string, + id: number, + locale: string, + values: Record, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + body: { values }, + definition, + method: "post", + path: `/${id}/translations/${segment(locale)}`, + pluginId, + schema: zodTranslation, + }); + + if (result.status !== 201) return failure(result); + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; + +export const editContentTranslationAction = async ( + contentTypeId: string, + id: number, + locale: string, + values: Record, + expectedVersion: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + body: { expectedVersion, values }, + definition, + method: "put", + path: `/${id}/translations/${segment(locale)}`, + pluginId, + schema: zodTranslationResult, + }); + + if (result.status !== 200) return failure(result); + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; + +export const deleteContentTranslationAction = async ( + contentTypeId: string, + id: number, + locale: string, + expectedVersion: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + body: { expectedVersion }, + definition, + method: "delete", + path: `/${id}/translations/${segment(locale)}`, + pluginId, + schema: zodTranslation, + }); + + if (result.status !== 200) return failure(result); + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; + +const transition = async ( + action: "publish" | "unpublish", + contentTypeId: string, + id: number, + locale: string, + expectedVersion: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + body: { expectedVersion }, + definition, + method: "post", + path: `/${id}/translations/${segment(locale)}/${action}`, + pluginId, + schema: zodTranslationResult, + }); + + if (result.status !== 200) return failure(result); + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; + +export const publishContentTranslationAction = async ( + contentTypeId: string, + id: number, + locale: string, + expectedVersion: number, +): Promise => + await transition("publish", contentTypeId, id, locale, expectedVersion); + +export const unpublishContentTranslationAction = async ( + contentTypeId: string, + id: number, + locale: string, + expectedVersion: number, +): Promise => + await transition("unpublish", contentTypeId, id, locale, expectedVersion); + +export interface TranslationRevisionPageResult { + edges: ContentRevisionMeta[]; + error?: string; + pageInfo: { endCursor: null | number; hasNextPage: boolean }; +} + +/** One locale's history. The cursor is the last **version** on the page. */ +export const listContentTranslationRevisionsAction = async ( + contentTypeId: string, + id: number, + locale: string, + cursor?: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}/translations/${segment(locale)}/revisions`, + pluginId, + query: cursor === undefined ? undefined : { cursor: String(cursor) }, + schema: zodRevisionList, + }); + + const empty = { endCursor: null, hasNextPage: false }; + + if (result.status !== 200 || !result.data) { + return { edges: [], error: result.error ?? "", pageInfo: empty }; + } + + return { + edges: result.data.edges as unknown as ContentRevisionMeta[], + pageInfo: result.data.pageInfo, + }; +}; + +export const getContentTranslationRevisionAction = async ( + contentTypeId: string, + id: number, + locale: string, + revisionId: number, +): Promise<{ + error?: string; + revision?: ContentRevisionDetail; +}> => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + definition, + method: "get", + path: `/${id}/translations/${segment(locale)}/revisions/${revisionId}`, + pluginId, + schema: z.object({ id: z.number() }).loose(), + }); + + if (result.status !== 200 || !result.data) { + return { error: result.error ?? "" }; + } + + return { + revision: + result.data as unknown as ContentRevisionDetail, + }; +}; + +export const restoreContentTranslationRevisionAction = async ( + contentTypeId: string, + id: number, + locale: string, + revisionId: number, + expectedVersion: number, +): Promise => { + const { definition, pluginId } = resolve(contentTypeId); + + const result = await contentApiFetch({ + body: { expectedVersion }, + definition, + method: "post", + path: `/${id}/translations/${segment(locale)}/revisions/${revisionId}/restore`, + pluginId, + schema: zodTranslationResult, + }); + + if (result.status !== 200) return failure(result); + + revalidatePath(CONTENT_PAGE_PATH, "page"); + + return {}; +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx new file mode 100644 index 000000000..090ed775b --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx @@ -0,0 +1,145 @@ +// No "use client": reached only from `edit-action`, already a client entry. +import { useTranslations } from "next-intl"; +import React from "react"; + +import type { ContentFormSpec } from "@/content/admin/spec"; + +import { useLanguages } from "@/components/languages-provider"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import type { ContentFormProps } from "../content-form"; +import type { TranslationMeta } from "../translation-api.server"; + +import { ContentForm } from "../content-form"; +import { listContentTranslationsAction } from "../translation-api.server"; +import { TranslationPanel } from "./translation-panel"; +import { + translationStateOf, + TranslationStatusBadge, +} from "./translation-status"; + +export interface LocaleEditorProps extends ContentFormProps { + /** The content type's default locale - its translation is never deletable. */ + defaultLocale: string; + editorial: boolean; + permissionModule: string; + pluginId: string; + /** Localized fields only. `null` for a content type that is not localized. */ + translationSpec: ContentFormSpec; +} + +/** + * The edit surface of a localized content type: `Shared | English | Polski | …`. + * + * One tab per language the app serves, plus a first tab for everything that is not + * per-language. The split is the same one the database makes, which is the point - + * a field is on the Shared tab exactly when it is a column on the base table. + * + * The strip loads **metadata only**, in one request: which locales have a + * translation and whether each is published. A locale's values are fetched when its + * tab is opened, so opening the dialog on a record with nine languages costs one + * query rather than nine. + * + * Languages come from the app config through `LanguagesProvider`, which already + * filters to the enabled ones - so a locale the install has switched off gets no + * tab, and no way to grow more content in a language nothing renders. + */ +export const LocaleEditor = ({ + defaultLocale, + editorial, + permissionModule, + pluginId, + translationSpec, + ...form +}: LocaleEditorProps) => { + const t = useTranslations("core.content.translations"); + const languages = useLanguages(); + const itemId = form.data?.id ?? 0; + + const [metas, setMetas] = React.useState([]); + const [reloads, setReloads] = React.useState(0); + + /** + * Loads the strip: one request for every locale's presence and status. + * + * Inlined in the effect so nothing writes state before the first `await` - a + * synchronous write would cost a second render pass every time the dialog opens. + */ + React.useEffect(() => { + if (itemId === 0) return; + + let active = true; + + void listContentTranslationsAction( + translationSpec.contentTypeId, + itemId, + ).then(({ edges }) => { + if (active) setMetas(edges); + }); + + return () => { + active = false; + }; + }, [itemId, reloads, translationSpec.contentTypeId]); + + const metaFor = (locale: string): TranslationMeta | undefined => + metas.find(meta => meta.locale.toLowerCase() === locale.toLowerCase()); + + return ( + + {/* Scrolls rather than wraps: an install with a dozen languages must not + push the form off the bottom of the dialog. */} +
+ + {t("shared_tab")} + + {languages.map(language => { + const meta = metaFor(language.code); + + return ( + + + {language.name ?? language.code} + + + + ); + })} + +
+ + + + + + {languages.map(language => ( + + { + setReloads(count => count + 1); + }} + permissionModule={permissionModule} + pluginId={pluginId} + publication={form.publication ?? false} + spec={translationSpec} + /> + + ))} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-history.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-history.tsx new file mode 100644 index 000000000..b9e127020 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-history.tsx @@ -0,0 +1,178 @@ +// No "use client": reached only from `edit-action`, already a client entry. +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; + +import type { ContentRevisionMeta } from "@/content/revisions"; + +import { DateFormat } from "@/components/date-format"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +import { + listContentTranslationRevisionsAction, + restoreContentTranslationRevisionAction, +} from "../translation-api.server"; + +/** + * One locale's revision history, with restore. + * + * Deliberately its own component rather than a reuse of the shared + * `RevisionHistory`: that one restores through the base editorial route and diffs + * against the shared field list, and pointing it at a translation would offer to + * restore English values into a Polish row. The read is scoped by locale on the + * server, so this list structurally cannot show another language's versions. + * + * Restoring never changes publication state and never restores the historical + * version number - the translation moves *forward* to a new version whose revision + * records where the values came from. + */ +export const TranslationHistory = ({ + contentTypeId, + currentVersion, + itemId, + locale, + onRestored, + permissionModule, + pluginId, +}: { + contentTypeId: string; + currentVersion: number; + itemId: number; + locale: string; + onRestored: () => void; + permissionModule: string; + pluginId: string; +}) => { + const t = useTranslations("core.content.translations.history"); + const tHistory = useTranslations("core.content.history"); + const tErrors = useTranslations("core.global.errors"); + const canRestore = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.restore, + plugin: pluginId, + }); + + const [open, setOpen] = React.useState(false); + const [edges, setEdges] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [busy, setBusy] = React.useState(false); + + const load = React.useCallback(async () => { + setLoading(true); + const page = await listContentTranslationRevisionsAction( + contentTypeId, + itemId, + locale, + ); + setEdges(page.edges); + setLoading(false); + }, [contentTypeId, itemId, locale]); + + const onToggle = () => { + const next = !open; + setOpen(next); + // Loaded when the section is opened, not with the tab: a locale's history can + // be long, and nobody who only wanted to fix a typo should pay for it. + if (next && edges.length === 0) void load(); + }; + + const onRestore = async (revisionId: number) => { + setBusy(true); + try { + const result = await restoreContentTranslationRevisionAction( + contentTypeId, + itemId, + locale, + revisionId, + currentVersion, + ); + + if (result.error !== undefined) { + toast.error(tErrors("title"), { + description: result.unprocessable + ? t("not_restorable", { + fields: result.unprocessable.fields.join(", "), + }) + : t("restore_failed"), + }); + + return; + } + + toast.success(t("restored")); + await load(); + onRestored(); + } finally { + setBusy(false); + } + }; + + return ( +
+ + + {open ? ( +
    + {loading ? ( +
  • {tHistory("loading_more")}
  • + ) : null} + + {!loading && edges.length === 0 ? ( +
  • + {t("empty")} +
  • + ) : null} + + {edges.map(revision => ( +
  • + + + {tHistory( + `operations.${revision.operation}` as Parameters< + typeof tHistory + >[0], + )} + + + v{revision.version} + + + + + + {revision.actorName ?? tHistory("system_actor")} + + + + {canRestore && revision.version !== currentVersion ? ( + + ) : ( + + {revision.version === currentVersion + ? tHistory("current") + : null} + + )} +
  • + ))} +
+ ) : null} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx new file mode 100644 index 000000000..5646c0dab --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx @@ -0,0 +1,409 @@ +// No "use client": reached only from `edit-action`, already a client entry. +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; + +import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentTranslationConflict } from "@/content/conflicts"; + +import { DateFormat } from "@/components/date-format"; +import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Loader } from "@/components/ui/loader"; +import { buildFormSchemaFromSpec } from "@/content/admin/spec"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +import type { + TranslationMutationResult, + TranslationRow, +} from "../translation-api.server"; + +import { contentErrorKey } from "../../lib/mutation-feedback"; +import { + createContentTranslationAction, + deleteContentTranslationAction, + editContentTranslationAction, + getContentTranslationAction, + publishContentTranslationAction, + unpublishContentTranslationAction, +} from "../translation-api.server"; +import { TranslationHistory } from "./translation-history"; +import { + translationStateOf, + TranslationStatusBadge, +} from "./translation-status"; + +export interface TranslationPanelProps { + contentTypeId: string; + /** Enables the history and restore sections. */ + editorial: boolean; + /** `true` when this locale is the content type's default - never deletable. */ + isDefaultLocale: boolean; + itemId: number; + /** Human name of the language, for headings and toasts. */ + languageName: string; + locale: string; + /** Reloads the tab strip after a mutation, so its badges stay honest. */ + onMutated: () => void; + permissionModule: string; + pluginId: string; + /** Enables the publish/unpublish controls. */ + publication: boolean; + spec: ContentFormSpec; +} + +/** + * Turns a refusal into a sentence, without ever quoting the database. + * + * The version conflict is the one that gets special treatment: the form stays + * exactly as the translator left it, and the banner offers to reload. Everything + * else is a toast, because there is nothing to preserve. + */ +const conflictMessage = ( + conflict: ContentTranslationConflict | undefined, +): null | string => { + switch (conflict?.code) { + case "CONTENT_DEFAULT_TRANSLATION_REQUIRED": + return "default_required"; + case "CONTENT_LANGUAGE_DISABLED": + return "language_disabled"; + case "CONTENT_TRANSLATION_EXISTS": + return "exists"; + case "CONTENT_TRANSLATION_UNIQUE_CONFLICT": + return "unique_conflict"; + case "CONTENT_TRANSLATION_VERSION_CONFLICT": + return "version_conflict"; + default: + return null; + } +}; + +/** + * One locale's editing surface. + * + * Everything on it is scoped to this language and nothing else: the form holds + * only localized fields, the version it sends back is this translation's own, and + * the history it opens is this locale's. An English edit and a Polish edit touch + * two different rows with two different counters, so they cannot conflict - which + * is the property the whole tab strip is built on. + * + * A translation is **never** created just because a tab was opened. A locale with + * no translation shows `Missing` and an explicit create button, gated on + * `can_translate` - opening a tab to look is not a decision to publish an empty + * Polish page. + */ +export const TranslationPanel = ({ + contentTypeId, + editorial, + isDefaultLocale, + itemId, + languageName, + locale, + onMutated, + permissionModule, + pluginId, + publication, + spec, +}: TranslationPanelProps) => { + const t = useTranslations("core.content.translations"); + const tErrors = useTranslations("core.global.errors"); + const tContentErrors = useTranslations("core.content.errors"); + + const canTranslate = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.translate, + plugin: pluginId, + }); + const canPublish = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.publish, + plugin: pluginId, + }); + const canDelete = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.delete, + plugin: pluginId, + }); + + const [row, setRow] = React.useState(null); + // Whether a read has come back at all, which is the only way to tell "still + // loading" from "loaded, and this locale has no translation". Derived rather + // than a `loading` flag so nothing sets state synchronously inside the effect. + const [settled, setSettled] = React.useState(false); + const [reloads, setReloads] = React.useState(0); + const [stale, setStale] = React.useState(false); + const [busy, setBusy] = React.useState(false); + + /** + * Reads this locale's values whenever the tab, the record or a reload changes. + * + * Inlined rather than a memoised `load()` so the effect body writes no state + * before its first `await` - a synchronous write here would cost a second render + * pass on every tab switch. `active` drops a response that arrived after the + * person had already moved to another language. + */ + React.useEffect(() => { + let active = true; + + void getContentTranslationAction(contentTypeId, itemId, locale).then( + result => { + if (!active) return; + + setRow(result.row ?? null); + setStale(false); + setSettled(true); + }, + ); + + return () => { + active = false; + }; + }, [contentTypeId, itemId, locale, reloads]); + + /** Re-reads this locale. Used after a mutation and by the conflict banner. */ + const reload = () => { + setSettled(false); + setReloads(count => count + 1); + }; + + const present = row !== null; + const state = translationStateOf({ + present, + status: publication ? (row?.status ?? "draft") : undefined, + }); + + // Rebuilt whenever the loaded values change, so the form prefills with what the + // server has - and, after a reload following a conflict, with what it now has. + const formSchema = React.useMemo( + () => buildFormSchemaFromSpec(spec, row?.values), + [spec, row?.values], + ); + + const report = (result: TranslationMutationResult): boolean => { + if (result.error === undefined) return true; + + const key = conflictMessage(result.conflict); + + if (result.conflict?.code === "CONTENT_TRANSLATION_VERSION_CONFLICT") { + // The form keeps every value the translator typed. Nothing is retried and + // nothing is merged: reloading is a decision, and so is saving over what + // the reload reveals. + setStale(true); + + return false; + } + + toast.error(tErrors("title"), { + description: key + ? t(`errors.${key}` as Parameters[0]) + : (() => { + // Only the status: the structured half of a translation refusal is + // handled above, and the shared mapper's union describes the *base* + // conflict codes rather than these. + const generic = contentErrorKey(result.status, {}); + + return generic + ? tContentErrors(generic) + : tErrors("internal_server_error"); + })(), + }); + + return false; + }; + + /** Runs one mutation, then reloads this locale and the strip above it. */ + const run = async ( + mutate: () => Promise, + successKey: "created" | "deleted" | "published" | "saved" | "unpublished", + ): Promise => { + setBusy(true); + try { + const result = await mutate(); + if (!report(result)) return false; + + toast.success(t(`success.${successKey}` as Parameters[0]), { + description: languageName, + }); + + reload(); + onMutated(); + + return true; + } finally { + setBusy(false); + } + }; + + const onSubmit: AutoFormOnSubmit = async values => { + // No relation or user fields here - only text, textarea and slug can be + // localized - so the values go through as they are. + const payload = values; + + if (row) { + await run( + async () => + await editContentTranslationAction( + contentTypeId, + itemId, + locale, + payload, + row.version, + ), + "saved", + ); + + return; + } + + await run( + async () => + await createContentTranslationAction( + contentTypeId, + itemId, + locale, + payload, + ), + "created", + ); + }; + + if (!settled) return ; + + const publishedAt = + typeof row?.publishedAt === "string" ? new Date(row.publishedAt) : null; + + return ( +
+
+ + {publication && present ? ( + + {publishedAt ? ( + + ) : ( + t("never_published") + )} + + ) : null} + {present ? ( + + {t("version", { version: row.version })} + + ) : null} +
+ + {stale && row ? ( + + {t("conflict.title", { name: languageName })} + +

{t("conflict.desc")}

+ +
+
+ ) : null} + + {!present && !canTranslate ? ( +

+ {t("missing_readonly", { name: languageName })} +

+ ) : null} + + {canTranslate ? ( + ({ id: fieldSpec.name }))} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + children: present ? t("save") : t("create"), + disabled: busy, + }} + /> + ) : null} + + {present && publication && canPublish ? ( +
+ +
+ ) : null} + + {present && editorial ? ( + { + reload(); + onMutated(); + }} + permissionModule={permissionModule} + pluginId={pluginId} + /> + ) : null} + + {present && !isDefaultLocale && canDelete ? ( +
+ +
+ ) : null} + + {present && isDefaultLocale ? ( +

+ {t("default_locale_note", { name: languageName })} +

+ ) : null} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-status.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-status.tsx new file mode 100644 index 000000000..38f5a68da --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-status.tsx @@ -0,0 +1,65 @@ +// No "use client" here on purpose: this module is only reached from +// `edit-action`, which is already a client entry. Declaring it again would make +// this a nested client entry, and `next/dynamic` cannot resolve one from inside a +// published package - the dialog spins forever. +import { CircleCheckIcon, CircleDashedIcon, FileClockIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { Badge } from "@/components/ui/badge"; + +/** + * Where one locale stands. + * + * Three states, and deliberately only three. There is no `Outdated`: the honest + * definition of it would be "the source language changed after this translation + * did", and comparing two `updatedAt` timestamps does not mean that - a typo fix + * in English would mark every translation stale, while a rewrite made a second + * before a translation was saved would not. A badge that is wrong half the time is + * worse than no badge. `Fallback` is a *public read* state and belongs in Stage 5C, + * where there is a public read to describe. + */ +export type TranslationState = "draft" | "missing" | "published"; + +export const translationStateOf = ({ + present, + status, +}: { + present: boolean; + /** Absent for a content type without publication - then present means done. */ + status?: string; +}): TranslationState => { + if (!present) return "missing"; + if (status === undefined) return "published"; + + return status === "published" ? "published" : "draft"; +}; + +const ICONS = { + draft: FileClockIcon, + missing: CircleDashedIcon, + published: CircleCheckIcon, +} as const; + +export const TranslationStatusBadge = ({ + state, +}: { + state: TranslationState; +}) => { + const t = useTranslations("core.content.translations.states"); + const Icon = ICONS[state]; + + return ( + + + {t(state)} + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx index daa426c31..b8099c8c8 100644 --- a/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/content-admin-view.tsx @@ -12,6 +12,7 @@ import { contentI18nKeys, humanizeFieldName } from "@/content/admin/labels"; import { buildContentColumnSpec, buildContentFormSpec, + buildContentTranslationFormSpec, } from "@/content/admin/spec"; import { CONTENT_PERMISSIONS } from "@/content/const"; import { pathToContentTypeId } from "@/content/registry"; @@ -101,6 +102,14 @@ export const ContentAdminView = async ({ labelField: labels.labelField, pluginId, }); + // `null` for a content type that is not localized, which is what makes the + // locale tabs unreachable rather than empty. + const translationSpec = buildContentTranslationFormSpec({ + definition, + labelEnum: labels.labelEnum, + labelField: labels.labelField, + pluginId, + }); const columnSpecs = buildContentColumnSpec({ definition, labelEnum: labels.labelEnum, @@ -132,6 +141,7 @@ export const ContentAdminView = async ({ entry={entry} formSpec={formSpec} searchParams={query} + translationSpec={translationSpec} /> diff --git a/packages/vitnode/src/views/admin/views/content/table/content-table-view.test.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.test.tsx index 896eaa245..c7cbe96c5 100644 --- a/packages/vitnode/src/views/admin/views/content/table/content-table-view.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.test.tsx @@ -59,6 +59,7 @@ const orderProp = async (definition: AnyContentTypeDefinition) => { } as never, formSpec: {} as never, searchParams: {}, + translationSpec: null, })) as ReactElement<{ order: { columns: string[]; defaultOrder: { column: string } }; }>; @@ -86,6 +87,7 @@ const deleteProps = async ( } as never, formSpec: {} as never, searchParams: {}, + translationSpec: null, })) as ReactElement<{ columns: { cell?: (context: { row: Record }) => ReactElement<{ diff --git a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx index 3625e9d2f..49088e952 100644 --- a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx @@ -36,12 +36,15 @@ export const ContentTableView = async ({ columnSpecs, entry, formSpec, + translationSpec, searchParams, }: { columnSpecs: ContentColumnSpec[]; entry: RegisteredFrontendContentType; formSpec: ContentFormSpec; searchParams: Record; + /** Localized-field form spec, or `null` when the content type is not localized. */ + translationSpec: ContentFormSpec | null; }) => { const t = await getTranslations("core.content"); const { definition, pluginId, registration } = entry; @@ -171,6 +174,8 @@ export const ContentTableView = async ({ ) : null} [name, override.component], @@ -182,6 +187,7 @@ export const ContentTableView = async ({ singular={definition.admin.label.singular} spec={formSpec} title={title} + translationSpec={translationSpec} /> { return build(handle); }; + /** + * The atomic-create service. + * + * `pluginId` is passed on purpose: it is what lets the default translation get + * its own `create` revision, stamped with the right owner. Omitting it - which + * every Stage 5A caller does - falls back to the plain repository write. + */ const localizedService = (handle = context) => { const build = localizedArticleContent.localizedService; if (!build) throw new Error("Expected a localized service."); - return build(handle); + return build(handle, { pluginId: CONFIG_PLUGIN.pluginId }); }; /** Every translation row for one record, straight out of SQL. */ @@ -2204,7 +2220,12 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { "createdAt", "featured", "id", + // The base row's own lifecycle from Stage 5B on. Still no `title`, + // `slug` or `body` - those live one table over, one row per language. + "publishedAt", + "status", "updatedAt", + "version", ]); expect(row).not.toHaveProperty("title"); }); @@ -2825,5 +2846,450 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { expect(page.edges[0]).toMatchObject({ featured: true }); }); }); + + describe("translation lifecycle", () => { + const statusOf = async (itemId: number, languageId: number) => { + const [row] = await sql< + { publishedAt: null | string; status: string; version: number }[] + >` + SELECT "status", "publishedAt", "version" + FROM "example_localized_articles_translations" + WHERE "itemId" = ${itemId} AND "languageId" = ${languageId} + `; + + return row; + }; + + /** A record with an English default translation and a Polish one. */ + const twoLocales = async (title: string) => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: `Body of ${title}`, title }, + }); + await translations().create(row.id, "pl", { + body: `Tresc ${title}`, + title: `${title} PL`, + }); + + return row.id; + }; + + it("creates every translation as a draft", async () => { + const itemId = await twoLocales("Lifecycle Draft"); + + // A translator finishing a copy must not put it on the internet by + // pressing save. `DEFAULT 'draft'` is what the migration relies on too. + expect(await statusOf(itemId, 1)).toMatchObject({ + publishedAt: null, + status: "draft", + }); + expect(await statusOf(itemId, 2)).toMatchObject({ status: "draft" }); + }); + + it("stamps publishedAt on the first publish and bumps the version", async () => { + const itemId = await twoLocales("Lifecycle Publish"); + + const result = await translations().publish(itemId, "pl"); + + expect(result).toMatchObject({ changed: true, version: 2 }); + const after = await statusOf(itemId, 2); + expect(after.status).toBe("published"); + expect(after.publishedAt).not.toBeNull(); + }); + + it("is a true no-op when the translation is already published", async () => { + const itemId = await twoLocales("Lifecycle Idempotent"); + await translations().publish(itemId, "pl"); + const before = await statusOf(itemId, 2); + + const result = await translations().publish(itemId, "pl"); + + expect(result).toMatchObject({ changed: false }); + // No version, no timestamp, nothing - which is what keeps a retried task + // from writing a second revision and a second event. + expect(await statusOf(itemId, 2)).toEqual(before); + }); + + it("keeps the original publishedAt across a republish", async () => { + const itemId = await twoLocales("Lifecycle Republish"); + await translations().publish(itemId, "pl"); + const first = await statusOf(itemId, 2); + + await translations().unpublish(itemId, "pl"); + await translations().publish(itemId, "pl"); + + const after = await statusOf(itemId, 2); + expect(after.publishedAt).toBe(first.publishedAt); + expect(after.version).toBe(first.version + 2); + }); + + it("leaves publishedAt alone on unpublish", async () => { + const itemId = await twoLocales("Lifecycle Unpublish"); + await translations().publish(itemId, "pl"); + const published = await statusOf(itemId, 2); + + await translations().unpublish(itemId, "pl"); + + const after = await statusOf(itemId, 2); + expect(after.status).toBe("draft"); + // "First published on" stays true after it is taken down again. + expect(after.publishedAt).toBe(published.publishedAt); + }); + + it("publishes one locale without touching the other", async () => { + const itemId = await twoLocales("Lifecycle Independent"); + + await translations().publish(itemId, "pl"); + + expect(await statusOf(itemId, 1)).toMatchObject({ + status: "draft", + version: 1, + }); + expect(await statusOf(itemId, 2)).toMatchObject({ + status: "published", + }); + }); + + it("refuses a stale expectedVersion", async () => { + const itemId = await twoLocales("Lifecycle Stale"); + + await expect( + translations().publish(itemId, "pl", { expectedVersion: 9 }), + ).rejects.toThrow(/version 1, not 9/); + }); + + it("still publishes into a locale the app has switched off", async () => { + // `de` is `enabled: false` in this app's config. Taking existing content + // in it out of circulation - and putting it back - has to stay possible; + // what is refused is *growing* content there, which is a create. + const itemId = await twoLocales("Lifecycle Disabled"); + await sql` + INSERT INTO "example_localized_articles_translations" + ("itemId", "languageId", "title", "slug", "body") + VALUES (${itemId}, 3, 'Deutsch', 'deutsch', 'Deutscher Text') + `; + + const result = await translations().publish(itemId, "de"); + + expect(result).toMatchObject({ changed: true }); + }); + + it("carries the lifecycle in the metadata list", async () => { + const itemId = await twoLocales("Lifecycle Metadata"); + await translations().publish(itemId, "pl"); + + const metas = await translations().findManyForItem(itemId); + + expect(metas).toHaveLength(2); + expect(metas[0]).toMatchObject({ locale: "en", status: "draft" }); + expect(metas[1]).toMatchObject({ locale: "pl", status: "published" }); + }); + }); + + describe("translation revisions", () => { + const editorial = (handle = context) => { + const build = localizedArticleContent.translationEditorialService; + if (!build) + throw new Error("Expected a translation editorial service."); + + return build(handle, { pluginId: CONFIG_PLUGIN.pluginId }); + }; + + const revisionsFor = async (itemId: number, languageId: null | number) => + await sql< + { languageId: null | number; operation: string; version: number }[] + >` + SELECT "languageId", "operation", "version" + FROM "core_content_revisions" + WHERE "contentTypeId" = 'example.localized-article' + AND "itemId" = ${itemId} + AND ${ + languageId === null + ? sql`"languageId" IS NULL` + : sql`"languageId" = ${languageId}` + } + ORDER BY "version" + `; + + beforeEach(async () => { + await sql` + DELETE FROM "core_content_revisions" + WHERE "contentTypeId" = 'example.localized-article' + `; + }); + + /** + * A record whose default translation has a `create` revision. + * + * `actor` is what asks for one: without it the default translation is written + * through the plain repository and leaves no history, which is exactly the + * Stage 5A behaviour the atomic-create tests above still exercise. + */ + const guide = async (title: string) => { + const { row } = await localizedService().create( + { + shared: {}, + translation: { body: `Body of ${title}`, title }, + }, + { actor: ACTOR }, + ); + + return row.id; + }; + + it("writes one revision per real translation mutation", async () => { + const itemId = await guide("Revision Basics"); + + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski" }, + { actor: ACTOR }, + ); + await editorial().update( + itemId, + "pl", + { title: "Polski Nowy" }, + { actor: ACTOR, expectedVersion: 1 }, + ); + await editorial().publish(itemId, "pl", { actor: ACTOR }); + + const rows = await revisionsFor(itemId, 2); + expect(rows.map(row => row.operation)).toEqual([ + "create", + "update", + "publish", + ]); + expect(rows.map(row => row.version)).toEqual([1, 2, 3]); + }); + + it("writes no revision for a no-op", async () => { + const itemId = await guide("Revision No Op"); + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski" }, + { actor: ACTOR }, + ); + + await editorial().update( + itemId, + "pl", + { title: "Polski" }, + { actor: ACTOR, expectedVersion: 1 }, + ); + await editorial().publish(itemId, "pl", { actor: ACTOR }); + await editorial().publish(itemId, "pl", { actor: ACTOR }); + + // create, then publish. The unchanged update and the second publish both + // wrote nothing. + expect( + (await revisionsFor(itemId, 2)).map(row => row.operation), + ).toEqual(["create", "publish"]); + }); + + it("keeps each locale's versions independent under the partial index", async () => { + const itemId = await guide("Revision Independent"); + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski" }, + { actor: ACTOR }, + ); + + // English v1 and Polish v1 are two different facts, and so are their v2s. + // A single unique index over `(contentTypeId, itemId, version)` would have + // rejected the second of each pair. + await editorial().publish(itemId, "en", { actor: ACTOR }); + await editorial().publish(itemId, "pl", { actor: ACTOR }); + + const english = await revisionsFor(itemId, 1); + const polish = await revisionsFor(itemId, 2); + + expect(english.map(row => row.version)).toEqual([1, 2]); + expect(polish.map(row => row.version)).toEqual([1, 2]); + }); + + it("leaves the shared history unmixed with any locale's", async () => { + const itemId = await guide("Revision Shared"); + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski" }, + { actor: ACTOR }, + ); + + // The base row's own editorial history uses `languageId IS NULL`, which is + // exactly what every pre-Stage-5B revision already was. + expect(await revisionsFor(itemId, null)).toHaveLength(0); + expect(await revisionsFor(itemId, 2)).toHaveLength(1); + }); + + it("snapshots localized fields only", async () => { + const itemId = await guide("Revision Snapshot"); + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski" }, + { actor: ACTOR }, + ); + + const [row] = await sql<{ snapshot: Record }[]>` + SELECT "snapshot" FROM "core_content_revisions" + WHERE "itemId" = ${itemId} AND "languageId" = 2 + `; + const fields = (row.snapshot as { fields: Record }) + .fields; + + // Order is not asserted: Postgres stores `jsonb` with its own key order. + expect(Object.keys(fields).sort()).toEqual(["body", "slug", "title"]); + expect(fields).not.toHaveProperty("featured"); + }); + + it("scopes history reads to one locale", async () => { + const itemId = await guide("Revision Scoped"); + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski" }, + { actor: ACTOR }, + ); + + await editorial().update( + itemId, + "pl", + { title: "Polski Nowy" }, + { actor: ACTOR, expectedVersion: 1 }, + ); + + const english = await editorial().listRevisions(itemId, "en"); + const polish = await editorial().listRevisions(itemId, "pl"); + + // One `create` each, plus the Polish update - and no overlap at all: the + // read filters on `languageId`, it is not a post-filter over a wider one. + expect(english.edges.map(edge => edge.operation)).toEqual(["create"]); + expect(polish.edges.map(edge => edge.operation)).toEqual([ + "update", + "create", + ]); + }); + + it("restores one locale forward to a new version", async () => { + const itemId = await guide("Revision Restore"); + await editorial().update( + itemId, + "en", + { title: "Second Title" }, + { actor: ACTOR, expectedVersion: 1 }, + ); + + const history = await editorial().listRevisions(itemId, "en"); + const first = history.edges.at(-1); + if (!first) throw new Error("Expected a first revision."); + + const outcome = await editorial().restore(itemId, "en", first.id, { + actor: ACTOR, + expectedVersion: 2, + }); + expect(outcome?.changed).toBe(true); + // Forward to 3, not back to 1: the history stays append-only. + expect(outcome?.version).toBe(3); + expect(outcome?.restoredFromRevisionId).toBe(first.id); + expect((await rowsFor(itemId))[0]).toMatchObject({ + title: "Revision Restore", + version: 3, + }); + }); + + it("refuses a revision belonging to another locale", async () => { + const itemId = await guide("Revision Cross Locale"); + await editorial().create( + itemId, + "pl", + { body: "Tresc", title: "Polski" }, + { actor: ACTOR }, + ); + const polish = await editorial().listRevisions(itemId, "pl"); + const revisionId = polish.edges[0]?.id; + if (revisionId === undefined) throw new Error("Expected a revision."); + + // Scoped by language before anything is read, so the Polish revision is + // simply not found from the English tab. + expect( + await editorial().restore(itemId, "en", revisionId, { + actor: ACTOR, + expectedVersion: 1, + }), + ).toBeNull(); + }); + + it("keeps the publication state across a restore", async () => { + const itemId = await guide("Revision Restore Published"); + await editorial().publish(itemId, "en", { actor: ACTOR }); + await editorial().update( + itemId, + "en", + { title: "Changed While Published" }, + { actor: ACTOR, expectedVersion: 2 }, + ); + + const history = await editorial().listRevisions(itemId, "en"); + const original = history.edges.at(-1); + if (!original) throw new Error("Expected the create revision."); + + await editorial().restore(itemId, "en", original.id, { + actor: ACTOR, + expectedVersion: 3, + }); + + // Restoring field values never takes a translation off the internet, and + // never puts one on it. + const [row] = await sql<{ status: string }[]>` + SELECT "status" FROM "example_localized_articles_translations" + WHERE "itemId" = ${itemId} AND "languageId" = 1 + `; + expect(row.status).toBe("published"); + }); + + it("prunes each locale's history against its own retention window", async () => { + const itemId = await guide("Revision Retention"); + + // Retention is 20 on this fixture, so nothing is pruned yet - what this + // proves is that the two locales' counters do not compete for the window. + for (let index = 0; index < 3; index += 1) { + await editorial().update( + itemId, + "en", + { title: `English ${index}` }, + { actor: ACTOR, expectedVersion: index + 1 }, + ); + } + + // The create plus three updates. Polish has none of them, so five Polish + // revisions could never evict an English one. + expect(await revisionsFor(itemId, 1)).toHaveLength(4); + expect(await revisionsFor(itemId, 2)).toHaveLength(0); + }); + + it("refuses a stale expectedVersion on a restore", async () => { + const itemId = await guide("Revision Restore Stale"); + await editorial().update( + itemId, + "en", + { title: "Moved On" }, + { actor: ACTOR, expectedVersion: 1 }, + ); + const history = await editorial().listRevisions(itemId, "en"); + const first = history.edges.at(-1); + if (!first) throw new Error("Expected a first revision."); + + await expect( + editorial().restore(itemId, "en", first.id, { + actor: ACTOR, + expectedVersion: 1, + }), + ).rejects.toThrow(/version 2, not 1/); + }); + }); }); }); diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index 83e6ebd8c..6bc55b062 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -219,8 +219,14 @@ describe("the generated migration", () => { ] .map(match => match[1]) .filter(name => name.startsWith("example_")); + // An index a later migration dropped is not part of the schema a fresh + // database ends up with. `(languageId)` is the case: Stage 5B replaced it with + // `(languageId, status)`, which supersedes it. + const dropped = new Set( + [...migration.matchAll(/DROP INDEX "([^"]+)"/g)].map(match => match[1]), + ); - expect([...created].sort(byName)).toEqual( + expect(created.filter(name => !dropped.has(name)).sort(byName)).toEqual( [ ...indexNames(articles), ...indexNames(categories), @@ -306,7 +312,17 @@ describe("example_localized_articles", () => { // `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"]); + // `status`, `publishedAt` and `version` are the base row's own lifecycle, + // which every translation's is subordinate to. + expect(columns).toEqual([ + "id", + "createdAt", + "updatedAt", + "publishedAt", + "status", + "version", + "featured", + ]); }); it("keeps shared fields off the translation table", () => { @@ -319,6 +335,9 @@ describe("example_localized_articles", () => { "version", "createdAt", "updatedAt", + // The translation's own lifecycle, on the same terms the base row has it. + "publishedAt", + "status", "title", "slug", "body", @@ -419,14 +438,71 @@ describe("example_localized_articles", () => { ).toEqual(["languageId", "slug"]); }); - it("indexes languageId on its own", () => { + it("indexes languageId with the status that qualifies it", () => { // The composite primary key already serves `(itemId, languageId)` and - // `itemId`; "every row in Polish" needs its own index. + // `itemId`; "every published row in Polish" needs its own index. It leads with + // `languageId`, so it serves "every row in Polish" too - which is why the + // plain single-column index is not created alongside it. expect(indexNames(localizedTranslations)).toContain( + "example_localized_articles_translations_language_id_status_idx", + ); + expect(indexNames(localizedTranslations)).not.toContain( "example_localized_articles_translations_language_id_idx", ); }); + it("gives each translation its own lifecycle columns", () => { + const types = Object.fromEntries( + localizedTranslations.columns.map(column => [ + column.name, + column.getSQLType(), + ]), + ); + + expect(types).toMatchObject({ + publishedAt: "timestamp", + status: "varchar(32)", + }); + // `DEFAULT 'draft'` is what makes the Stage 5B migration safe on an install + // that already has Stage 5A translations: every one becomes a draft rather + // than being silently published. + expect(migration).toContain( + `ALTER TABLE "example_localized_articles_translations" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL`, + ); + }); + + it("adds the revision language scope without a data step", () => { + // Nullable and with no default, so every pre-Stage-5B revision is a shared + // one - which is exactly what it was. + expect(migration).toContain( + `ALTER TABLE "core_content_revisions" ADD COLUMN "languageId" integer`, + ); + // Two partial indexes rather than one over a nullable column: Postgres treats + // every NULL as distinct, so a shared key would enforce nothing at all for the + // non-localized case it exists to protect. + expect(migration).toContain( + `CREATE UNIQUE INDEX "core_content_revisions_item_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","version") WHERE "languageId" IS NULL`, + ); + expect(migration).toContain( + `CREATE UNIQUE INDEX "core_content_revisions_translation_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","languageId","version") WHERE "languageId" IS NOT NULL`, + ); + }); + + it("drops no column and no data in the Stage 5B migration", () => { + const stage5b = readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../apps/docs/migrations", + "0030_add_translation_editorial.sql", + ), + "utf8", + ); + + expect(stage5b).not.toMatch(/DROP COLUMN/); + expect(stage5b).not.toMatch(/DROP TABLE/); + expect(stage5b).not.toMatch(/DELETE FROM/); + }); + it("keeps every generated identifier inside the Postgres limit", () => { for (const name of indexNames(localizedTranslations)) { expect((name ?? "").length).toBeLessThanOrEqual(63);