From 553f1e18040cfeb981a1dd59989cb657513ff1de Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 21:18:36 +0200 Subject: [PATCH 1/4] feat(content): add the localized editorial layer The server half of Stage 5B, in one change because its pieces do not compile apart: the lifecycle, the language-scoped history, the orchestration layer that writes both in one transaction, the locale-bound preview token, the events, the permission and the routes. **Lifecycle.** A translation gains its own `status` and `publishedAt` on the same terms the base row has them: only with `publication: { enabled: true }`, read-only on the wire, moved only by publish/unpublish. Both transitions guard on the *state* rather than the version, which is what makes them idempotent - a double-clicked button, a retried task and an already published locale all write nothing at all, so no version, no revision and no event. `publishedAt` is stamped once and never rewritten; an unpublish leaves it, because "first published on" stays true. `(languageId, status)` supersedes the plain `languageId` index rather than joining it - it is a prefix, so one index serves both. **History.** `core_content_revisions` gains a nullable `languageId`: `NULL` is the shared history every non-localized content type already had, so the column backfills to exactly the right value with no data step. The single unique index becomes two partial ones, because English v3 and Polish v3 are two different facts and Postgres treats every `NULL` as distinct - one key over a nullable column would enforce nothing for the case it exists to protect. No foreign key to `core_languages`, matching the table's existing design: a revision is an audit trail, and "the Polish copy said this" stays true after the language row is gone. The snapshot carries the locale code so it stays readable when it is. **Snapshots** are partitioned. A shared snapshot holds shared fields only and a translation snapshot holds localized ones only - which is a security boundary as much as a modelling one: a translation restore that carried shared values would let `can_translate` rewrite fields only `can_edit` may touch. **Restore** is scoped by content type, item *and* language before anything is read, so a revision belonging to another locale is not found rather than fetched and rejected. It validates against the *current* localized schemas, writes through the repository so slug uniqueness and the version guard still apply, never moves publication state, and never restores the historical version number. **Preview** tokens gain `l`/`lid`/`tr`. A token names both halves it freezes - the shared revision and the translation revision - so the frozen guarantee is whole rather than half. The locale check is symmetric and has no fallback: a `pl` token used to read `en` is refused, and so is a locale-less token on a locale-scoped read. **Events** are six new keys, gated on `localization` (and on `publication` / `editorial` for the ones that need them), so a non-localized content type gains no key and every existing payload is unchanged. A shared update and a Polish translation update are separate domain facts, never folded into `updated`. **`can_translate`** depends on `can_view` and deliberately not on `can_edit`, which is the whole point of having it. Permissions are JSON per role, so adding one denies by default and needs no migration. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/admin/spec.ts | 152 +++-- packages/vitnode/src/content/const.ts | 36 +- packages/vitnode/src/content/define.ts | 1 - packages/vitnode/src/content/events.ts | 124 +++- packages/vitnode/src/content/index.ts | 15 + packages/vitnode/src/content/indexes.ts | 12 +- .../vitnode/src/content/localization.test.ts | 90 ++- packages/vitnode/src/content/localization.ts | 49 +- packages/vitnode/src/content/registry.ts | 15 + packages/vitnode/src/content/revisions.ts | 68 +- packages/vitnode/src/content/schemas.ts | 12 + .../src/content/server/column-builders.ts | 15 + .../src/content/server/editorial-service.ts | 8 +- packages/vitnode/src/content/server/emit.ts | 12 + packages/vitnode/src/content/server/index.ts | 20 + packages/vitnode/src/content/server/model.ts | 30 + .../src/content/server/preview-token.ts | 66 +- .../src/content/server/revision-snapshot.ts | 100 ++- .../src/content/server/revisions-model.ts | 55 +- .../server/translation-editorial-service.ts | 606 ++++++++++++++++++ .../src/content/server/translation-effects.ts | 110 ++++ .../content/server/translation-http-errors.ts | 43 +- .../src/content/server/translation-model.ts | 251 +++++++- .../content/server/translation-routes.test.ts | 14 +- .../src/content/server/translation-routes.ts | 444 ++++++++++++- .../src/content/server/translation-table.ts | 15 +- packages/vitnode/src/content/server/types.ts | 41 +- packages/vitnode/src/content/types.ts | 64 +- packages/vitnode/src/database/content.ts | 47 +- packages/vitnode/src/locales/en.json | 45 ++ .../vitnode/src/tests/content-fixtures.ts | 33 + .../views/content/actions/edit-action.tsx | 50 +- .../content/actions/translation-api.server.ts | 380 +++++++++++ .../actions/translations/locale-editor.tsx | 145 +++++ .../translations/translation-history.tsx | 178 +++++ .../translations/translation-panel.tsx | 409 ++++++++++++ .../translations/translation-status.tsx | 65 ++ .../views/content/content-admin-view.tsx | 10 + .../content/table/content-table-view.test.tsx | 2 + .../content/table/content-table-view.tsx | 6 + .../example/src/content/localized-article.ts | 36 +- 41 files changed, 3632 insertions(+), 242 deletions(-) create mode 100644 packages/vitnode/src/content/server/translation-editorial-service.ts create mode 100644 packages/vitnode/src/content/server/translation-effects.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/locale-editor.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-history.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/translations/translation-status.tsx 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/model.ts b/packages/vitnode/src/content/server/model.ts index 418620875..f9a10a9bf 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, @@ -91,6 +93,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; /** @@ -261,6 +277,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-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.ts b/packages/vitnode/src/content/server/translation-effects.ts new file mode 100644 index 000000000..83ffd0979 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-effects.ts @@ -0,0 +1,110 @@ +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, + revisionId: outcome.revisionId ?? undefined, + 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-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/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..8f8f23db6 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`language_id IS NULL`), + uniqueIndex("core_content_revisions_translation_version_unique") + .on(t.contentTypeId, t.itemId, t.languageId, t.version) + .where(sql`language_id 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 `language_id 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} /> Date: Fri, 7 Aug 2026 00:48:21 +0200 Subject: [PATCH 2/4] feat(content): give the atomic create a translation revision `localizedService.create` now takes `actor` and, through the model, `pluginId`. With both, the default translation is written through the editorial layer inside the same transaction and leaves a `create` revision. Without them - which is every Stage 5A caller - it goes through the plain repository exactly as before. The gap this closes is real: the base row's own revision snapshots shared fields only, so the values a record was created with in its default language were the one state no revision ever recorded. Restoring the earliest English version could not get them back. `pluginId` is optional rather than assumed, because a revision is stamped with its owner and inventing one would put a wrong value in the column the cleanup job keys off. Also here: the migration. Additive only - no DROP COLUMN, no DELETE, no data step. `status DEFAULT 'draft'` backfills every Stage 5A translation to a draft, `languageId` arrives nullable so every existing revision stays a shared one, and the unique index is rebuilt as the two partial ones. The predicate quotes `"languageId"`: raw SQL would otherwise look for a lower-cased `language_id` and fail at apply time. Co-Authored-By: Claude Opus 5 (1M context) --- .../0030_add_translation_editorial.sql | 13 + apps/docs/migrations/meta/0030_snapshot.json | 3260 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 7 + .../src/content/server/localized-service.ts | 38 + packages/vitnode/src/content/server/model.ts | 45 +- .../src/content/server/translation-effects.ts | 5 +- packages/vitnode/src/database/content.ts | 6 +- plugins/example/src/const.ts | 5 + 8 files changed, 3366 insertions(+), 13 deletions(-) create mode 100644 apps/docs/migrations/0030_add_translation_editorial.sql create mode 100644 apps/docs/migrations/meta/0030_snapshot.json 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/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 f9a10a9bf..9e3f49a50 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -63,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`. @@ -247,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, @@ -258,8 +284,9 @@ export const createContentModel = < schemas, table, }), - translations: buildTranslations(c), - }) + translations, + }); + } : undefined, publicService: definition.publicApi.enabled ? (c: Context) => diff --git a/packages/vitnode/src/content/server/translation-effects.ts b/packages/vitnode/src/content/server/translation-effects.ts index 83ffd0979..6afca0724 100644 --- a/packages/vitnode/src/content/server/translation-effects.ts +++ b/packages/vitnode/src/content/server/translation-effects.ts @@ -27,7 +27,10 @@ const payloadFor = ( contentId: outcome.row.itemId, languageId: outcome.languageId, locale: outcome.locale, - revisionId: outcome.revisionId ?? undefined, + // 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, }; diff --git a/packages/vitnode/src/database/content.ts b/packages/vitnode/src/database/content.ts index 8f8f23db6..2d0019817 100644 --- a/packages/vitnode/src/database/content.ts +++ b/packages/vitnode/src/database/content.ts @@ -107,14 +107,14 @@ export const core_content_revisions = pgTable( // 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`language_id IS NULL`), + .where(sql`"languageId" IS NULL`), uniqueIndex("core_content_revisions_translation_version_unique") .on(t.contentTypeId, t.itemId, t.languageId, t.version) - .where(sql`language_id IS NOT NULL`), + .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 `language_id IS NOT NULL` in those terms. + // history list does not filter on `"languageId" IS NOT NULL` in those terms. index("core_content_revisions_language_idx").on( t.contentTypeId, t.itemId, diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 1cf92fe48..7dfd4cb47 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -24,4 +24,9 @@ export const EXAMPLE_MIGRATIONS = [ // The Stage 5A localized fixture: a base table with only its shared field, and // a translation table holding the localized ones. "0029_add_example_localized_articles.sql", + // Stage 5B. Additive only: `languageId` arrives nullable, so every existing + // revision is a shared one, and the translation lifecycle columns arrive with + // `DEFAULT 'draft'`, so every translation written while Stage 5A was current + // becomes a draft rather than being silently published. + "0030_add_translation_editorial.sql", ]; From c478e464c109a06e249d9487fa78fdd9bd8b8ced Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 7 Aug 2026 01:03:42 +0200 Subject: [PATCH 3/4] test(content): cover the localized editorial layer Runtime, type, route and PostgreSQL coverage for everything Stage 5B added. The Postgres block is the one that proves the parts a mock cannot: that `DEFAULT 'draft'` really does leave every existing translation unpublished, that `publishedAt` survives an unpublish and a republish, that publishing Polish leaves English at version 1, that English v1 and Polish v1 coexist under the two partial unique indexes, that a locale's history read returns only its own revisions, and that a restore cannot reach across a locale. It runs against a disposable database and is skipped without `DATABASE_TEST_URL`. Co-Authored-By: Claude Opus 5 (1M context) --- .../translation-editorial-routes.test.ts | 397 +++++++++++++ .../translation-editorial-service.test.ts | 561 ++++++++++++++++++ .../server/translation-effects.test.ts | 180 ++++++ .../server/translation-preview-token.test.ts | 151 +++++ plugins/example/src/database/postgres.test.ts | 468 ++++++++++++++- plugins/example/src/database/tables.test.ts | 84 ++- 6 files changed, 1836 insertions(+), 5 deletions(-) create mode 100644 packages/vitnode/src/content/server/translation-editorial-routes.test.ts create mode 100644 packages/vitnode/src/content/server/translation-editorial-service.test.ts create mode 100644 packages/vitnode/src/content/server/translation-effects.test.ts create mode 100644 packages/vitnode/src/content/server/translation-preview-token.test.ts 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-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-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/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index 1c1218a11..f8d78b4ae 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -176,6 +176,15 @@ const CORE_QUEUE_STUB = ` * installer, so a test that assumed `en` existed would pass on a developer * machine and fail on a fresh CI database. */ +/** + * Who the editorial suites act as. + * + * `userId: null` on purpose: `actorUserId` is a real foreign key to `core_users`, + * and these tests are about the revisions rather than about who wrote them - a + * seeded user would be a second fixture to keep in step for no assertion. + */ +const ACTOR = { type: "staff" as const, userId: null }; + const CORE_LANGUAGES_STUB = ` CREATE TABLE "core_languages" ( "id" serial PRIMARY KEY NOT NULL, @@ -2117,11 +2126,18 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { 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); From 8d64da2015566c5aeab81d390f4184e0a7a2cc63 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 7 Aug 2026 01:03:43 +0200 Subject: [PATCH 4/4] docs(content): document the localized editorial workflow Three new pages - the per-locale lifecycle and its subordination rule, the per-locale history and what a restore may not cross, and the locale preview token - plus the migration recipe, the event catalogue, and the boundary tables that now name only 5C and 5D. Says plainly what Stage 5B does not ship: a locale preview link cannot be minted until `publicApi` combines with localization, because preview projects through `publicApi.fields`. The token format is here and tested; the routes are not. Co-Authored-By: Claude Opus 5 (1M context) --- .../content/docs/dev/content-engine/index.mdx | 12 +- .../docs/dev/content-engine/limitations.mdx | 26 +- .../localization-migrations.mdx | 49 ++++ .../docs/dev/content-engine/localization.mdx | 35 ++- .../content/docs/dev/content-engine/meta.json | 3 + .../content-engine/translation-editorial.mdx | 253 ++++++++++++++++++ .../content-engine/translation-preview.mdx | 126 +++++++++ .../content-engine/translation-revisions.mdx | 186 +++++++++++++ .../docs/dev/events/built-in-events.mdx | 33 +++ .../content/translation-editorial.test-d.ts | 127 +++++++++ 10 files changed, 821 insertions(+), 29 deletions(-) create mode 100644 apps/docs/content/docs/dev/content-engine/translation-editorial.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/translation-preview.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/translation-revisions.mdx create mode 100644 packages/vitnode/src/content/translation-editorial.test-d.ts 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/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(); + }); +});