From 3da0cd5787459afdb337599cb870380643b625d1 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:05:06 +0200 Subject: [PATCH 01/11] feat(content): add localization configuration and localized field types Opts a content type into per-language content with one block: localization: { enabled: true, defaultLocale: "en", fallback: "none" } and one flag on the three field kinds that hold prose: title: field.text({ localized: true, required: true }) `localized` is a literal type parameter, like `required` and `nullable`, so `localized: false` and `localized: true` stay distinguishable - which is what lets every later stage expose translation services and routes conditionally rather than at runtime. The other builders do not take the argument at all, so a localized boolean is a compile error, and `defineContentType` refuses it again at runtime for anything that skipped the builders. `partitionContentFields` is the one place that decides whether a field is localized. Every subsystem reads it rather than testing `field.localized === true` for itself; two copies of that rule is exactly the pair that drifts, and the consequence of drift is a column generated on one table and read from the other. The partition drives the base indexes, the admin surfaces and the schemas, so a localized field cannot be a DataTable column, a sort, a search, a form field, the title field, or part of an index - it has no column on the base table for any of those to address. Stage 5A refuses `localization` alongside `publication`, `editorial`, `publicApi` and `search`, each with the stage that lifts it in the message. Running Stage 1-4 logic against the base table while ignoring the localized fields would be worse than a refused definition. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/const.ts | 78 +++++ packages/vitnode/src/content/define.ts | 106 ++++++- packages/vitnode/src/content/fields.ts | 61 +++- packages/vitnode/src/content/indexes.ts | 62 ++++ packages/vitnode/src/content/localization.ts | 308 +++++++++++++++++++ packages/vitnode/src/content/registry.ts | 22 +- packages/vitnode/src/content/types.ts | 290 +++++++++++++++-- 7 files changed, 877 insertions(+), 50 deletions(-) create mode 100644 packages/vitnode/src/content/localization.ts diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 3e4ea6ffb..231e67e87 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -12,6 +12,22 @@ export const CONTENT_PUBLICATION_FIELDS = ["status", "publishedAt"] as const; */ export const CONTENT_EDITORIAL_FIELDS = ["version"] as const; +/** + * The columns a generated translation table always carries. + * + * Its own list rather than an entry in {@link CONTENT_SYSTEM_FIELDS}: these live + * on the *translation* table, so a content type stays free to declare a shared + * field called `itemId` - it would land on the base table, where nothing + * generated claims that name. + */ +export const CONTENT_TRANSLATION_SYSTEM_FIELDS = [ + "itemId", + "languageId", + "version", + "createdAt", + "updatedAt", +] as const; + export const CONTENT_PUBLICATION_STATUSES = ["draft", "published"] as const; const publicationStatuses: ReadonlySet = new Set( @@ -74,6 +90,52 @@ export const CONTENT_ENUM_DEFAULT_LENGTH = 64; export const CONTENT_SLUG_DEFAULT_LENGTH = 160; +/** + * Field kinds that may carry `localized: true`. + * + * Text only, and deliberately so. `boolean`, `number`, `date`, `dateTime` and + * `enum` hold values, not prose - a per-locale `true` is not a translation, and + * an enum's *labels* are already handled by the ordinary i18n system while its + * *identifiers* have to stay the same in every language or nothing can filter on + * them. `relation` and `user` are foreign keys, and per-locale references are + * explicitly out of scope. + */ +export const CONTENT_LOCALIZED_FIELD_KINDS = [ + "slug", + "text", + "textarea", +] as const; + +const localizedFieldKinds: ReadonlySet = new Set( + CONTENT_LOCALIZED_FIELD_KINDS, +); + +export const isLocalizableFieldKind = (kind: string): boolean => + localizedFieldKinds.has(kind); + +/** Appended to the base table name to get the generated translation table. */ +export const CONTENT_TRANSLATION_TABLE_SUFFIX = "_translations"; + +/** + * What a public read does when a locale has no translation. + * + * Resolved in Stage 5A and *acted on* in Stage 5C: the configuration has to be + * stable before anything reads through it, or every localized content type would + * change public behaviour the moment fallback landed. + */ +export const CONTENT_LOCALIZATION_FALLBACKS = ["none", "default"] as const; + +/** + * A locale as `core_languages.code` stores one: `en`, `pl`, `pt-BR`, `zh-Hans`. + * + * Matched case-insensitively - the resolver returns the canonical stored code, + * so `PL` in a URL resolves to the `pl` row rather than to a 404. + */ +export const CONTENT_LOCALE_PATTERN = /^[a-z]{2,8}(?:[-_][a-z0-9]{2,8})*$/i; + +/** `core_languages.code` is `varchar(32)`. */ +export const CONTENT_LOCALE_MAX_LENGTH = 32; + export const CONTENT_DEFAULT_PAGE_SIZE = 25; export const CONTENT_OPTIONS_LIMIT = 25; @@ -288,6 +350,22 @@ export const CONTENT_CONFLICT_CODES = { version: "CONTENT_VERSION_CONFLICT", } as const; +/** + * Machine-readable reasons a *translation* write was refused. + * + * A separate list from {@link CONTENT_CONFLICT_CODES} rather than three more + * members of it: the base 409 union is the contract Stage 4 editorial routes + * already publish, and widening it would change a response schema every existing + * client is generated from. A translation route answers its own union. + */ +export const CONTENT_TRANSLATION_CONFLICT_CODES = { + defaultRequired: "CONTENT_DEFAULT_TRANSLATION_REQUIRED", + exists: "CONTENT_TRANSLATION_EXISTS", + languageDisabled: "CONTENT_LANGUAGE_DISABLED", + unique: "CONTENT_TRANSLATION_UNIQUE_CONFLICT", + version: "CONTENT_TRANSLATION_VERSION_CONFLICT", +} as const; + export const CONTENT_UNPROCESSABLE_CODES = { notRestorable: "CONTENT_REVISION_NOT_RESTORABLE", } as const; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index b0cd3d98b..ea77666ad 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -6,6 +6,8 @@ import type { ContentFieldMap, ContentFieldsConstraint, ContentIndexInput, + ContentLocalizationConfig, + ContentLocalizationEnabled, ContentPreviewEnabled, ContentPublicApiConfig, ContentPublicationConfig, @@ -19,6 +21,7 @@ import type { ContentTypeDefinition, ResolvedContentAdminConfig, ResolvedContentEditorialConfig, + ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, ResolvedContentSearchConfig, } from "./types"; @@ -57,6 +60,10 @@ import { } from "./const"; import { ContentEngineError } from "./errors"; import { resolveContentIndexes } from "./indexes"; +import { + partitionContentFields, + resolveContentLocalization, +} from "./localization"; import { buildContentSchemas } from "./schemas"; /** Kinds the default `searchableFields` picks up, and `titleField` falls back to. */ @@ -304,14 +311,56 @@ const assertKnownColumns = ( } }; +/** + * Every admin surface addresses a column on the base table, so it is stated in + * terms of the *shared* fields only. A localized field named here would be a + * DataTable column, a sort or a search over something the base table does not + * have - see `ContentAddressableColumn`, which rejects it at compile time too. + */ +const assertNotLocalized = ( + id: string, + label: string, + names: readonly string[], + localizedFields: ContentFieldMap, +): void => { + const localized = names.find(name => localizedFields[name] !== undefined); + if (localized !== undefined) { + throw new ContentEngineError( + `${label} names the localized field "${localized}", which is not a column on the base table. Localized values get their own AdminCP surface in Stage 5B.`, + { contentTypeId: id }, + ); + } +}; + const resolveAdmin = ( id: string, fields: ContentFieldMap, + localizedFields: ContentFieldMap, admin: ContentAdminConfig, publication: boolean, editorial: boolean, ): ResolvedContentAdminConfig => { const fieldNames = Object.keys(fields); + for (const [label, names] of [ + ["admin.form.fields", admin.form?.fields], + ["admin.list.columns", admin.list?.columns], + ["admin.list.orderableFields", admin.list?.orderableFields], + ["admin.list.searchableFields", admin.list?.searchableFields], + [ + "admin.titleField", + admin.titleField === undefined ? undefined : [admin.titleField], + ], + [ + "admin.list.defaultOrderBy", + admin.list?.defaultOrderBy === undefined + ? undefined + : [admin.list.defaultOrderBy], + ], + ] as const) { + if (!names) continue; + assertNotLocalized(id, label, names.map(String), localizedFields); + } + const generatedColumns = [ ...systemFields, ...(publication ? publicationFields : []), @@ -1014,12 +1063,21 @@ export const defineContentType = < TEditorial extends ContentEditorialConfig | { enabled: false } = { enabled: false }, + // The whole `localization` argument, inferred as one type, for the same reason + // `TSearch` and `TEditorial` are: an intersection member is not an inference + // site, so this is the only way the `enabled` literal survives - and every + // conditional that decides whether a translation table, translation schemas + // and a translation service exist reads that literal. + TLocalization extends ContentLocalizationConfig | { enabled: false } = { + enabled: false; + }, >({ admin, editorial, fields, id, indexes = [], + localization, publicApi, publication, search, @@ -1043,6 +1101,12 @@ export const defineContentType = < TPublication, ContentEditorialEnabled >[]; + /** + * Opts into per-language content: every field marked `localized: true` moves + * into a generated `_translations` table, one row per language. + * Omit it and nothing changes. + */ + localization?: TLocalization; /** * Opts into a generated read-only public API. Needs `publication` and exactly * one exposed slug field. Omit it and nothing public is generated. @@ -1067,7 +1131,8 @@ export const defineContentType = < ContentSearchEnabled, ContentEditorialEnabled, ContentPreviewEnabled, - ContentSchedulingEnabled + ContentSchedulingEnabled, + ContentLocalizationEnabled > => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( @@ -1112,8 +1177,14 @@ export const defineContentType = < assertSlugSources(id, fieldMap); + // The one partition every subsystem downstream of here reads. A localized + // field is not a column on the base table, so it takes no part in the base + // indexes, the admin surfaces or the base schemas. + const { localizedFields, sharedFields } = partitionContentFields(fieldMap); + const sharedFieldNames = Object.keys(sharedFields); + const knownColumns = new Set([ - ...fieldNames, + ...sharedFieldNames, ...systemFields, ...(publicationEnabled ? publicationFields : []), ...(editorialEnabled ? editorialFields : []), @@ -1122,18 +1193,23 @@ export const defineContentType = < contentTypeId: id, declared: indexes.map(index => { const on = index.on.map(String); + assertNotLocalized(id, "indexes", on, localizedFields); assertKnownColumns(id, "indexes", on, knownColumns); return { ...index, on }; }), - fields: fieldMap, + // Shared only: a localized slug's unique index is scoped to a language and + // belongs to the translation table, which `resolveContentTranslationIndexes` + // builds. + fields: sharedFields, publication: publicationEnabled, tableName, }); const resolvedAdmin = resolveAdmin( id, - fieldMap, + sharedFields, + localizedFields, admin, publicationEnabled, editorialEnabled, @@ -1177,6 +1253,21 @@ export const defineContentType = < publicationEnabled, ); + // Last, because the Stage 5A boundaries it enforces are stated in terms of + // everything the other resolvers have already settled. + const resolvedLocalization = resolveContentLocalization({ + editorial: editorialEnabled, + fields: fieldMap, + id, + // The `{ enabled: false }` arm exists only so an explicit literal + // typechecks - the same widening `publicApi`, `search` and `editorial` do. + localization: localization as ContentLocalizationConfig | undefined, + publicApi: resolvedPublicApi, + publication: publicationEnabled, + search: resolvedSearch.enabled, + tableName, + }); + return { admin: resolvedAdmin, editorial: resolvedEditorial as ResolvedContentEditorialConfig< @@ -1187,6 +1278,9 @@ export const defineContentType = < fields, id, indexes: resolvedIndexes, + localization: resolvedLocalization as ResolvedContentLocalizationConfig< + ContentLocalizationEnabled + >, permissionModule, publication: { enabled: publicationEnabled as TPublication, @@ -1205,12 +1299,14 @@ export const defineContentType = < ContentSearchEnabled, ContentEditorialEnabled, ContentPreviewEnabled, - ContentSchedulingEnabled + ContentSchedulingEnabled, + ContentLocalizationEnabled > >({ admin: resolvedAdmin, editorial: editorialEnabled, fields: fieldMap, + localization: resolvedLocalization, publicApi: resolvedPublicApi, publication: publicationEnabled, }), diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index a749f170c..f5089bd93 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -34,39 +34,64 @@ const shared = ( required: (args.required ?? false) as TRequired, }); +/** + * `localized` defaults to `false`, and the assertion keeps the literal the + * caller inferred - `?? false` alone would widen it back to `boolean`, and every + * `localized extends true` partition would resolve to the shared branch. + */ +const localizedOf = ( + args: LocalizableArgs, +): TLocalized => (args.localized ?? false) as TLocalized; + +interface LocalizableArgs { + /** + * Store the value per language, in the generated translation table. + * + * Needs `localization: { enabled: true, defaultLocale }` on the content type. + * Only `text`, `textarea` and `slug` accept this. + */ + localized?: TLocalized; +} + const text = < TRequired extends boolean = false, TNullable extends boolean = false, TDefault extends string | undefined = undefined, + TLocalized extends boolean = false, >( - args: SharedArgs & { - defaultValue?: TDefault; - maxLength?: number; - minLength?: number; - unique?: boolean; - } = {}, -): ContentTextField => ({ + args: LocalizableArgs & + SharedArgs & { + defaultValue?: TDefault; + maxLength?: number; + minLength?: number; + unique?: boolean; + } = {}, +): ContentTextField => ({ ...args, ...shared(args), defaultValue: args.defaultValue as TDefault, kind: "text", + localized: localizedOf(args), }); const textarea = < TRequired extends boolean = false, TNullable extends boolean = false, TDefault extends string | undefined = undefined, + TLocalized extends boolean = false, >( - args: SharedArgs & { - defaultValue?: TDefault; - maxLength?: number; - minLength?: number; - } = {}, -): ContentTextareaField => ({ + args: LocalizableArgs & + SharedArgs & { + defaultValue?: TDefault; + maxLength?: number; + minLength?: number; + } = {}, +): ContentTextareaField => ({ ...args, ...shared(args), defaultValue: args.defaultValue as TDefault, kind: "textarea", + localized: localizedOf(args), }); const number = < @@ -136,16 +161,20 @@ const enumField = < * The slug is never re-derived by an update. Changing the title leaves the URL * alone; sending `slug` explicitly is the only way to move it. */ -const slug = ( - args: { +const slug = < + TSource extends string | undefined = undefined, + TLocalized extends boolean = false, +>( + args: LocalizableArgs & { description?: string; /** `varchar` length and the truncation point. Defaults to 160. */ maxLength?: number; source?: TSource; } = {}, -): ContentSlugField => ({ +): ContentSlugField => ({ ...args, kind: "slug", + localized: localizedOf(args), nullable: false, required: (args.source === undefined) as ContentSlugRequired, source: args.source as TSource, diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index 1f0677dd2..7a2d02980 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -38,6 +38,68 @@ export const contentIndexName = ({ [tableName, ...columns.map(toSnakeCase), unique ? "key" : "idx"].join("_"), ); +/** + * The deterministic name of the generated translation table's primary key. + * + * Named explicitly rather than left to Drizzle's default so it survives the + * identifier-length clamp: a long base table name plus `_translations` plus + * `_itemId_languageId_pk` passes 63 characters easily, and Postgres truncates + * silently. + */ +export const contentTranslationPrimaryKeyName = ( + translationTableName: string, +): string => + shortenIdentifier(`${translationTableName}_item_id_language_id_pk`); + +/** + * Every index the generated translation table carries. + * + * The composite primary key already serves lookups by `(itemId, languageId)` and + * by `itemId` alone (a B-tree can use any prefix of its key), so neither is + * repeated here. What it cannot serve: + * + * 1. `languageId` on its own - "every row in Polish", and the lookup a language + * delete has to make before it is allowed to proceed, + * 2. one unique index per localized slug, scoped to the language - which is what + * lets `/en/about` and `/pl/about` coexist while a second English `about` is + * a 409. + */ +export const resolveContentTranslationIndexes = ({ + contentTypeId, + localizedFields, + translationTableName, +}: { + contentTypeId: string; + localizedFields: ContentFieldMap; + translationTableName: string; +}): ResolvedContentIndex[] => { + const indexes: ResolvedContentIndex[] = [ + named(translationTableName, { on: ["languageId"] }), + ...Object.entries(localizedFields) + .filter(([, fieldValue]) => fieldValue.kind === "slug") + .map(([name]) => + named(translationTableName, { + on: ["languageId", name], + unique: true, + }), + ), + ]; + + const byName = new Map(); + for (const index of indexes) { + const collision = byName.get(index.name); + if (collision) { + throw new ContentEngineError( + `Translation indexes on [${collision.on.join(", ")}] and [${index.on.join(", ")}] both resolve to the name "${index.name}".`, + { contentTypeId }, + ); + } + byName.set(index.name, index); + } + + return indexes; +}; + /** * Identity of an index for deduplication. Column order matters: an index on * `(status, createdAt)` cannot serve a lookup on `(createdAt, status)`. diff --git a/packages/vitnode/src/content/localization.ts b/packages/vitnode/src/content/localization.ts new file mode 100644 index 000000000..bc632cec8 --- /dev/null +++ b/packages/vitnode/src/content/localization.ts @@ -0,0 +1,308 @@ +import type { + ContentFieldDescriptor, + ContentFieldMap, + ContentLocalizationConfig, + ContentLocalizationFallback, + ResolvedContentLocalizationConfig, + ResolvedContentPublicApiConfig, +} from "./types"; + +import { + CONTENT_IDENTIFIER_MAX_LENGTH, + CONTENT_LOCALE_MAX_LENGTH, + CONTENT_LOCALE_PATTERN, + CONTENT_LOCALIZED_FIELD_KINDS, + CONTENT_TRANSLATION_SYSTEM_FIELDS, + CONTENT_TRANSLATION_TABLE_SUFFIX, + isLocalizableFieldKind, +} from "./const"; +import { ContentEngineError } from "./errors"; +import { clampWithFingerprint } from "./fingerprint"; +import { resolveContentTranslationIndexes } from "./indexes"; + +/** + * The one place that decides whether a field is localized. + * + * Every subsystem - table generation, schemas, services, routes, migrations - + * goes through {@link partitionContentFields} rather than testing + * `field.localized === true` for itself. Two copies of this rule is exactly the + * pair that drifts, and the consequence of drift is a column generated on one + * table and read from the other. + */ +export const isLocalizedContentField = ( + fieldValue: ContentFieldDescriptor, +): boolean => fieldValue.localized === true; + +export interface ContentFieldPartition { + /** Fields stored in the translation table, one row per language. */ + localizedFields: ContentFieldMap; + /** Fields stored on the base table. */ + sharedFields: ContentFieldMap; +} + +/** + * Splits a field map into its base-table and translation-table halves. + * + * Declaration order is preserved in both, so the generated column order, the + * generated schema key order and the migration all stay deterministic. + */ +export const partitionContentFields = ( + fields: ContentFieldMap, +): ContentFieldPartition => { + const localizedFields: ContentFieldMap = {}; + const sharedFields: ContentFieldMap = {}; + + for (const [name, fieldValue] of Object.entries(fields)) { + if (isLocalizedContentField(fieldValue)) { + localizedFields[name] = fieldValue; + continue; + } + sharedFields[name] = fieldValue; + } + + return { localizedFields, sharedFields }; +}; + +/** `example_articles` -> `example_articles_translations`. */ +export const contentTranslationTableName = (tableName: string): string => + clampWithFingerprint( + `${tableName}${CONTENT_TRANSLATION_TABLE_SUFFIX}`, + CONTENT_IDENTIFIER_MAX_LENGTH, + ); + +const disabledLocalization: ResolvedContentLocalizationConfig = { + defaultLocale: "", + enabled: false, + fallback: "none", + translationIndexes: [], + translationTableName: "", +}; + +/** The disabled default, for a content type with no `localization` block. */ +export const contentLocalizationDisabled = + (): ResolvedContentLocalizationConfig => ({ + ...disabledLocalization, + translationIndexes: [], + }); + +const translationSystemFields: readonly string[] = + CONTENT_TRANSLATION_SYSTEM_FIELDS; + +const assertDefaultLocale = (id: string, locale: unknown): string => { + if (typeof locale !== "string" || locale.trim() === "") { + throw new ContentEngineError( + 'localization.defaultLocale is required and must be a locale code, e.g. "en". It names the row in `core_languages` every record is first created in.', + { contentTypeId: id }, + ); + } + + if (locale !== locale.trim()) { + throw new ContentEngineError( + `localization.defaultLocale "${locale}" has leading or trailing whitespace.`, + { contentTypeId: id }, + ); + } + + if (locale.length > CONTENT_LOCALE_MAX_LENGTH) { + throw new ContentEngineError( + `localization.defaultLocale "${locale}" is longer than ${CONTENT_LOCALE_MAX_LENGTH} characters, which is the width of \`core_languages.code\`.`, + { contentTypeId: id }, + ); + } + + if (!CONTENT_LOCALE_PATTERN.test(locale)) { + throw new ContentEngineError( + `localization.defaultLocale "${locale}" does not look like a locale code. Expected something like "en", "pl" or "pt-BR".`, + { contentTypeId: id }, + ); + } + + return locale; +}; + +/** + * Every rule a localized field has to satisfy, in one pass. + * + * Two of them are about the *slug*, and they are the ones worth the words: a + * localized slug derived from a shared title would give every language the same + * URL, and a shared slug derived from a localized title has no single source to + * derive from. Both are silent data bugs rather than crashes, so they are + * rejected at definition time. + */ +const assertLocalizedFields = ( + id: string, + fields: ContentFieldMap, + localizedFields: ContentFieldMap, +): void => { + if (Object.keys(localizedFields).length === 0) { + throw new ContentEngineError( + "localization is enabled but no field is marked `localized: true`, so the generated translation table would hold nothing but its keys. Mark at least one text, textarea or slug field.", + { contentTypeId: id }, + ); + } + + for (const [name, fieldValue] of Object.entries(localizedFields)) { + if (!isLocalizableFieldKind(fieldValue.kind)) { + throw new ContentEngineError( + `Field "${name}" is \`localized: true\` but its kind is "${fieldValue.kind}". Only ${CONTENT_LOCALIZED_FIELD_KINDS.join(", ")} fields can be localized - an enum's identifiers have to be the same in every language, and a relation's target is shared.`, + { contentTypeId: id }, + ); + } + + if (translationSystemFields.includes(name)) { + throw new ContentEngineError( + `Localized field "${name}" collides with a generated translation column. Rename it - the translation table always carries ${translationSystemFields.join(", ")}.`, + { contentTypeId: id }, + ); + } + } + + for (const [name, fieldValue] of Object.entries(fields)) { + if (fieldValue.kind !== "slug" || fieldValue.source === undefined) continue; + + // `assertSlugSources` in `define.ts` has already proven the source exists + // and is a text field; this only checks the two halves agree about *where* + // the value lives. + const source = fields[fieldValue.source]; + if (!source) continue; + + const slugLocalized = isLocalizedContentField(fieldValue); + const sourceLocalized = isLocalizedContentField(source); + + if (slugLocalized && !sourceLocalized) { + throw new ContentEngineError( + `Localized slug "${name}" is sourced from the shared field "${fieldValue.source}". Every language would derive the same URL. Mark "${fieldValue.source}" \`localized: true\` too, or send the slug explicitly.`, + { contentTypeId: id }, + ); + } + + if (!slugLocalized && sourceLocalized) { + throw new ContentEngineError( + `Shared slug "${name}" is sourced from the localized field "${fieldValue.source}", which has a different value in every language - so there is no single value to derive from. Mark "${name}" \`localized: true\`, or point it at a shared text field.`, + { contentTypeId: id }, + ); + } + } +}; + +/** + * Stage 5A boundaries. + * + * Localization lands as infrastructure: the tables, the types, the services and + * the versioning. The stages that read *through* it are not here yet, and the + * honest failure for that is a refused definition rather than a content type + * that quietly runs Stage 1-4 logic against the base table while pretending its + * localized fields do not exist. + * + * Every message names the stage that lifts the restriction, because "not yet" is + * only useful when it says how long. + */ +const assertStageBoundaries = ( + id: string, + { + editorial, + publicApi, + publication, + search, + }: { + editorial: boolean; + publicApi: boolean; + publication: boolean; + search: boolean; + }, +): void => { + if (publication) { + throw new ContentEngineError( + "localization cannot be combined with `publication` yet. A localized record has one status per *language* - publishing the English draft must not put an empty Polish page on the internet - and per-locale publication lands in Stage 5B.", + { contentTypeId: id }, + ); + } + + if (editorial) { + throw new ContentEngineError( + "localization cannot be combined with `editorial` yet. A revision would snapshot the base row only, so restoring it would silently drop every translation. Per-locale revisions land in Stage 5B.", + { contentTypeId: id }, + ); + } + + if (publicApi) { + throw new ContentEngineError( + "localization cannot be combined with `publicApi` yet. A public read has to resolve a locale and decide what to do when a translation is missing, and locale-aware public routes land in Stage 5C.", + { contentTypeId: id }, + ); + } + + if (search) { + throw new ContentEngineError( + "localization cannot be combined with `search` yet. One document per record would index a single language and rank every other one as a miss; per-locale search documents land in Stage 5D.", + { contentTypeId: id }, + ); + } +}; + +/** + * Checks and fills in `localization`. + * + * Runs after every other resolver, because the Stage 5A boundaries are stated in + * terms of capabilities they have already settled. Like every other resolver + * here it repeats what the types say: a JavaScript caller, or a value that + * widened somewhere upstream, can reach this with anything at all. + */ +export const resolveContentLocalization = ({ + editorial, + fields, + id, + localization, + publicApi, + publication, + search, + tableName, +}: { + editorial: boolean; + fields: ContentFieldMap; + id: string; + localization: ContentLocalizationConfig | undefined; + publicApi: ResolvedContentPublicApiConfig; + publication: boolean; + search: boolean; + tableName: string; +}): ResolvedContentLocalizationConfig => { + const { localizedFields } = partitionContentFields(fields); + + if (!localization?.enabled) { + const stray = Object.keys(localizedFields)[0]; + if (stray !== undefined) { + throw new ContentEngineError( + `Field "${stray}" is \`localized: true\` but the content type has no \`localization: { enabled: true, defaultLocale }\` block, so there is no translation table for it to live in.`, + { contentTypeId: id }, + ); + } + + return contentLocalizationDisabled(); + } + + assertStageBoundaries(id, { + editorial, + publicApi: publicApi.enabled, + publication, + search, + }); + + const defaultLocale = assertDefaultLocale(id, localization.defaultLocale); + assertLocalizedFields(id, fields, localizedFields); + + const fallback: ContentLocalizationFallback = localization.fallback ?? "none"; + const translationTableName = contentTranslationTableName(tableName); + + return { + defaultLocale, + enabled: true, + fallback, + translationIndexes: resolveContentTranslationIndexes({ + contentTypeId: id, + localizedFields, + translationTableName, + }), + translationTableName, + }; +}; diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index dcfadce95..f8338c222 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -74,6 +74,23 @@ export const validateContentTypes = ( } byTable.set(definition.tableName, entry); + // The generated translation table shares one namespace with every base + // table, so a content type called `example_articles_translations` and a + // localized `example_articles` would collide - and the shortening clamp + // makes that reachable with two long names that differ only past character + // 63. Both directions are caught by holding one map. + if (definition.localization.enabled) { + const translationTable = definition.localization.translationTableName; + const duplicateTranslationTable = byTable.get(translationTable); + if (duplicateTranslationTable) { + throw new ContentEngineError( + `Translation table "${translationTable}" is claimed by both ${describe(duplicateTranslationTable)} and ${describe(entry)}. Rename one of the base tables.`, + { contentTypeId: definition.id }, + ); + } + byTable.set(translationTable, entry); + } + // Permission modules are scoped per plugin, so only a collision inside one // plugin is ambiguous. const permissionKey = `${pluginId}:${definition.permissionModule}`; @@ -110,7 +127,10 @@ export const validateContentTypes = ( // `resolveContentIndexes` already rejects a collision inside one content // type. Postgres index names are unique per *schema*, though, so two // content types - from one plugin or from two - cannot share one either. - for (const index of definition.indexes) { + for (const index of [ + ...definition.indexes, + ...definition.localization.translationIndexes, + ]) { const owner = byIndexName.get(index.name); if (owner) { throw new ContentEngineError( diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 4343442ed..548665f6f 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,6 +1,7 @@ import type { CONTENT_EDITORIAL_FIELDS, CONTENT_FILTERABLE_FIELD_KINDS, + CONTENT_LOCALIZATION_FALLBACKS, CONTENT_PUBLIC_EXPOSABLE_COLUMNS, CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, @@ -8,6 +9,7 @@ import type { CONTENT_SEARCH_TEXT_KINDS, CONTENT_SEARCH_TITLE_KINDS, CONTENT_SYSTEM_FIELDS, + CONTENT_TRANSLATION_SYSTEM_FIELDS, } from "./const"; import type { ContentSchemas } from "./schemas"; @@ -18,6 +20,9 @@ export type ContentPublicationField = export type ContentEditorialField = (typeof CONTENT_EDITORIAL_FIELDS)[number]; +export type ContentTranslationSystemField = + (typeof CONTENT_TRANSLATION_SYSTEM_FIELDS)[number]; + export type ContentPublicationStatus = (typeof CONTENT_PUBLICATION_STATUSES)[number]; @@ -42,6 +47,17 @@ export interface ContentFieldShared< > { /** Free text or an i18n key surfaced in AdminCP and OpenAPI. */ description?: string; + /** + * The value is stored per language, in the generated translation table rather + * than on the base table. + * + * Declared here - on every kind - so `fieldValue.localized` reads off the + * descriptor union without a narrowing dance. Only the three kinds in + * {@link CONTENT_LOCALIZED_FIELD_KINDS} accept it: the other builders do not + * take the argument at all, so `field.boolean({ localized: true })` is a + * compile error, and `defineContentType` refuses it again at runtime. + */ + localized?: boolean; /** Column accepts NULL, and `null` is a legal value. */ nullable: TNullable; /** Must be present in the create payload. */ @@ -52,12 +68,17 @@ export interface ContentTextField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, TDefault extends string | undefined = string | undefined, + TLocalized extends boolean = boolean, > extends ContentFieldShared { // Declared non-optional (but possibly `undefined`) so `HasColumnDefault` can // tell "no default" from "defaulted": an optional property would always // include `undefined` in its type and the distinction would be lost. defaultValue: TDefault; kind: "text"; + // Non-optional and literal, for the same reason `required` and `nullable` are: + // every partition in this file keys off `{ localized: true }`, and an optional + // `boolean | undefined` would resolve every field to the shared branch. + localized: TLocalized; maxLength?: number; minLength?: number; /** Adds a unique index on the column. See {@link ContentIndexInput}. */ @@ -86,8 +107,10 @@ export type ContentSlugRequired = TSource extends string */ export interface ContentSlugField< TSource extends string | undefined = string | undefined, + TLocalized extends boolean = boolean, > extends ContentFieldShared, false> { kind: "slug"; + localized: TLocalized; /** `varchar` length and the truncation point. Defaults to 160. */ maxLength?: number; source: TSource; @@ -97,9 +120,11 @@ export interface ContentTextareaField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, TDefault extends string | undefined = string | undefined, + TLocalized extends boolean = boolean, > extends ContentFieldShared { defaultValue: TDefault; kind: "textarea"; + localized: TLocalized; maxLength?: number; minLength?: number; } @@ -278,6 +303,43 @@ type RequiredFieldKeys = { [K in keyof TFields]: TFields[K] extends { required: true } ? K : never; }[keyof TFields]; +// --------------------------------------------------------------------------- +// Shared / localized partition +// +// One rule, stated once as a type and once (in `partitionContentFields`) as +// runtime data: a field is localized when its descriptor carries the literal +// `localized: true`, and shared otherwise. The erased +// `AnyContentTypeDefinition` carries `localized?: boolean`, which does not +// extend `true`, so every field of an erased definition is shared - which is +// exactly how a Stage 1-4 content type behaved before localization existed. +// --------------------------------------------------------------------------- + +type LocalizedFieldKeys = { + [K in keyof TFields]: TFields[K] extends { localized: true } ? K : never; +}[keyof TFields]; + +type SharedFieldKeys = Exclude< + keyof TFields, + LocalizedFieldKeys +>; + +/** + * A create-shaped object over a subset of the field map: required fields stay + * required, everything else is optional, and each value is inferred from its own + * descriptor. + */ +type CreateValuesOf = Prettify< + { + [K in Exclude>]?: ContentFieldInput< + TFields[K] + >; + } & { + [K in Extract>]: ContentFieldInput< + TFields[K] + >; + } +>; + // --------------------------------------------------------------------------- // Admin metadata // --------------------------------------------------------------------------- @@ -300,8 +362,13 @@ type ContentEditorialColumn = /** * Every column name the admin config and `indexes` may address: the declared - * fields, the system columns, and whichever generated columns the content type - * opted into. + * *shared* fields, the system columns, and whichever generated columns the + * content type opted into. + * + * A localized field is absent on purpose. It is not a column on the base table, + * so an index on it, a sort by it or a DataTable column showing it would all + * address something that does not exist. Stage 5B adds the AdminCP locale tabs + * that give localized values a place to appear. */ type ContentAddressableColumn< TFields, @@ -311,7 +378,7 @@ type ContentAddressableColumn< | ContentEditorialColumn | ContentPublicationColumn | ContentSystemField - | keyof TFields; + | SharedFieldKeys; export interface ContentAdminListConfig< TFields = ContentFieldMap, @@ -326,9 +393,9 @@ export interface ContentAdminListConfig< * Allowlist for `orderBy`. System columns - and the publication columns when * enabled - are always allowed and need no entry here. */ - orderableFields?: (keyof TFields)[]; - /** Only `text` and `textarea` fields may be searched. */ - searchableFields?: (keyof TFields)[]; + orderableFields?: SharedFieldKeys[]; + /** Only shared `text` and `textarea` fields may be searched. */ + searchableFields?: SharedFieldKeys[]; } export interface ContentAdminConfig< @@ -336,7 +403,7 @@ export interface ContentAdminConfig< TPublication extends boolean = boolean, TEditorial extends boolean = boolean, > { - form?: { fields?: (keyof TFields)[] }; + form?: { fields?: SharedFieldKeys[] }; label: ContentAdminLabel; list?: ContentAdminListConfig; navigation?: { enabled?: boolean }; @@ -345,8 +412,14 @@ export interface ContentAdminConfig< * "Articles" -> `articles`. */ permissionModule?: string; - /** Field used as the human-readable title in toasts and relation pickers. */ - titleField?: keyof TFields; + /** + * Field used as the human-readable title in toasts and relation pickers. + * + * Shared fields only. A localized title has a different value per language, so + * naming one here would make a toast depend on whose locale the reader is in; + * Stage 5B gives the AdminCP a locale-aware title of its own. + */ + titleField?: SharedFieldKeys; } /** @@ -755,6 +828,139 @@ type ContentEditorialColumns = TDefinition extends { ? { version: number } : Record; +// --------------------------------------------------------------------------- +// Localization +// --------------------------------------------------------------------------- + +export type ContentLocalizationFallback = + (typeof CONTENT_LOCALIZATION_FALLBACKS)[number]; + +/** + * Opts a content type into per-language content: the fields marked + * `localized: true` move off the base table into a generated translation table, + * one row per language. + * + * Nothing about the *UI* language changes - that is `core_languages_words` and + * the ordinary i18n system. This is about the records themselves: an article + * that exists in English and in Polish, with its own title, slug and body in + * each. + * + * `enabled` is literal `true` for the same reason every other opt-in's is: every + * conditional keys off `{ enabled: true }`, and a widened `boolean` would + * silently resolve to "not localized". + */ +export interface ContentLocalizationConfig { + /** + * The locale every record is created in, and the one translation a record can + * never be without. Must name a row in `core_languages`, which is checked + * against the database once, at boot - see `assertContentLocalizationLanguages`. + */ + defaultLocale: string; + enabled: true; + /** + * What a public read should do for a locale with no translation. Resolved now + * and acted on in Stage 5C; `"none"` is the default because it is the only + * answer that cannot silently publish the wrong language. + */ + fallback?: ContentLocalizationFallback; +} + +/** + * `localization` after `defineContentType` has filled in every default. + * + * Generic over `enabled` for the same reason `publication` and `editorial` are: + * a widened `boolean` would make every definition equally (un)localized, so + * `LocalizedContentTypeDefinition` would only ever match after a cast. + */ +export interface ResolvedContentLocalizationConfig< + TEnabled extends boolean = boolean, +> { + defaultLocale: string; + enabled: TEnabled; + fallback: ContentLocalizationFallback; + /** The generated translation table's indexes, named and deduplicated. */ + translationIndexes: ResolvedContentIndex[]; + /** `_translations`, shortened to fit Postgres' identifier limit. */ + translationTableName: string; +} + +/** + * Whether a `localization` argument opted in. + * + * Read back off the argument for the same reason `ContentSearchEnabled` is: the + * whole object is inferred as one type parameter, and an intersection member is + * not an inference site, so this is the only way the literal survives. + */ +export type ContentLocalizationEnabled = TLocalization extends { + enabled: true; +} + ? true + : false; + +/** Field names whose value lives in the translation table. */ +export type ContentLocalizedFieldName = LocalizedFieldKeys< + ContentFieldsOf +> & + string; + +/** Field names whose value lives on the base table. */ +export type ContentSharedFieldName = SharedFieldKeys< + ContentFieldsOf +> & + string; + +/** + * The localized half of a create payload - one locale's worth of values. + * + * Empty (`{}`) for a content type with no localized fields, which is what makes + * `translation:` impossible to fill in by accident on a Stage 1-4 definition. + */ +export type ContentLocalizedValues = CreateValuesOf< + ContentFieldsOf, + keyof ContentFieldsOf & + LocalizedFieldKeys> +>; + +/** The shared half of a create payload - everything on the base table. */ +export type ContentSharedValues = CreateValuesOf< + ContentFieldsOf, + keyof ContentFieldsOf & + SharedFieldKeys> +>; + +/** Every localized field optional, and never empty - see `schemas.translation`. */ +export type ContentLocalizedUpdateValues = Prettify< + Partial> +>; + +/** One translation row, as the service and the generated routes return it. */ +export interface ContentTranslationRow { + createdAt: Date; + itemId: number; + languageId: number; + /** The canonical `core_languages.code`, never the caller's casing. */ + locale: string; + updatedAt: Date; + values: ContentLocalizedValues; + version: number; +} + +/** + * One translation without its values. + * + * What the list route returns, and deliberately so: a locale tab strip needs to + * know which languages exist and how stale each one is, not to drag every + * article body in every language across the wire to find out. + */ +export interface ContentTranslationMeta { + createdAt: Date; + itemId: number; + languageId: number; + locale: string; + updatedAt: Date; + version: number; +} + // --------------------------------------------------------------------------- // Definition // --------------------------------------------------------------------------- @@ -818,6 +1024,18 @@ export type SchedulableContentTypeDefinition = publication: { enabled: true }; }; +/** + * A content type whose records exist in more than one language. + * + * An intersection rather than a tenth type argument, for the same reason + * {@link PublicContentTypeDefinition} is one: `enabled` is the only thing the + * translation layer needs pinned, and narrowing just that keeps every concrete + * definition assignable to `AnyContentTypeDefinition`. + */ +export type LocalizedContentTypeDefinition = AnyContentTypeDefinition & { + localization: { enabled: true }; +}; + export interface ContentTypeDefinition< TId extends string = string, TFields = ContentFieldMap, @@ -828,6 +1046,7 @@ export interface ContentTypeDefinition< TEditorialEnabled extends boolean = boolean, TPreviewEnabled extends boolean = boolean, TSchedulingEnabled extends boolean = boolean, + TLocalizationEnabled extends boolean = boolean, > { admin: ResolvedContentAdminConfig; /** Editorial workflow, or the disabled default when `editorial` is omitted. */ @@ -840,6 +1059,11 @@ export interface ContentTypeDefinition< id: TId; /** Declared indexes plus the automatic ones, deduplicated and named. */ indexes: ResolvedContentIndex[]; + /** + * Per-language content, or the disabled default when `localization` is + * omitted. + */ + localization: ResolvedContentLocalizationConfig; /** Derived from `admin.permissionModule` or `admin.label.plural`. */ permissionModule: string; publicApi: ResolvedContentPublicApiConfig; @@ -855,7 +1079,8 @@ export interface ContentTypeDefinition< TSearchEnabled, TEditorialEnabled, TPreviewEnabled, - TSchedulingEnabled + TSchedulingEnabled, + TLocalizationEnabled > >; /** Search synchronization, or the disabled default when `search` is omitted. */ @@ -872,47 +1097,56 @@ export type ContentFieldsOf = TDefinition extends { ? TFields : never; +/** + * One base row. + * + * Shared fields only: a localized field's value lives on the translation table, + * so it is not a column here and never comes back from a base read. For a + * content type without localization every field is shared, so this is exactly + * the type it always was. + */ export type ContentSelect = Prettify< ContentEditorialColumns & ContentPublicationColumns & { - [K in keyof ContentFieldsOf]: ContentFieldValue< + [K in SharedFieldKeys>]: ContentFieldValue< ContentFieldsOf[K] >; } & { createdAt: Date; id: number; updatedAt: Date } >; -export type ContentCreateInput = Prettify< - { - [ - K in Exclude< - keyof ContentFieldsOf, - RequiredFieldKeys> - > - ]?: ContentFieldInput[K]>; - } & { - [K in RequiredFieldKeys>]: ContentFieldInput< - ContentFieldsOf[K] - >; - } ->; +/** The base-table half of a create payload. See {@link ContentSharedValues}. */ +export type ContentCreateInput = ContentSharedValues; export type ContentUpdateInput = Prettify< Partial> >; +/** + * Every field name the content type declares, localized ones included. + * + * Use {@link ContentSharedFieldName} where a *column on the base table* is + * meant - which is most places. + */ export type ContentFieldName = keyof ContentFieldsOf & string; +/** + * Shared field names of one or more kinds. + * + * Deliberately shared-only: everything derived from this - filters, ordering, + * relation pickers - addresses a column on the *base* table, and a localized + * field does not have one. + */ type FieldNamesOfKind = string & { [ - K in keyof ContentFieldsOf + K in SharedFieldKeys> ]: ContentFieldsOf[K] extends { kind: TKind; } ? K : never; - }[keyof ContentFieldsOf]; + }[SharedFieldKeys>]; /** * Kinds the generated filter schema understands, derived from the one runtime @@ -957,7 +1191,7 @@ export type ContentFilterInput = Partial< * actually opted in. */ export type ContentOrderableFieldName = - | ContentFieldName + | ContentSharedFieldName | ContentSystemField | (TDefinition extends { editorial: { enabled: true } } ? ContentEditorialField From 3dd01ce9a33a61d93c2672996ad96735df119a63 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:05:21 +0200 Subject: [PATCH 02/11] feat(content): partition the generated schemas and add translation conflicts `schemas.create`, `update`, `select`, `filters` and `form` narrow to the shared half of the field map, and a new `schemas.translation` group carries the localized half: `create`, `update`, `select`, `selectMeta`, `params` and the two envelopes. `null` for a content type without localization, matching how `publicService` is `undefined` without a public API. Content values live under `values`; the locale and `expectedVersion` sit beside them. That keeps `values` a strict object of the content type's own localized fields - `itemId`, `languageId` and `version` are identity and transport, and accepting any of them there would make all three mass-assignable. Five structured errors, because a client that cannot tell them apart can only show "something went wrong": a version conflict that names the locale, a default-translation refusal, a duplicate translation, an unusable language (missing vs disabled), and a missing base record. `zodContentTranslationConflict` is its own union rather than three more members of `zodContentConflict`: that one is the contract every existing generated client is built from. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/conflicts.ts | 66 ++++++++ packages/vitnode/src/content/errors.ts | 161 +++++++++++++++++++ packages/vitnode/src/content/index.ts | 48 +++++- packages/vitnode/src/content/schemas.ts | 183 ++++++++++++++++++++-- 4 files changed, 447 insertions(+), 11 deletions(-) diff --git a/packages/vitnode/src/content/conflicts.ts b/packages/vitnode/src/content/conflicts.ts index ac3e2fbbd..3e69966b5 100644 --- a/packages/vitnode/src/content/conflicts.ts +++ b/packages/vitnode/src/content/conflicts.ts @@ -3,12 +3,16 @@ import { z } from "zod"; import { CONTENT_CONFLICT_CODES, CONTENT_SCHEDULE_CODES, + CONTENT_TRANSLATION_CONFLICT_CODES, CONTENT_UNPROCESSABLE_CODES, } from "./const"; export type ContentConflictCode = (typeof CONTENT_CONFLICT_CODES)[keyof typeof CONTENT_CONFLICT_CODES]; +export type ContentTranslationConflictCode = + (typeof CONTENT_TRANSLATION_CONFLICT_CODES)[keyof typeof CONTENT_TRANSLATION_CONFLICT_CODES]; + export type ContentUnprocessableCode = (typeof CONTENT_UNPROCESSABLE_CODES)[keyof typeof CONTENT_UNPROCESSABLE_CODES]; @@ -37,6 +41,68 @@ export const zodContentConflict = z.discriminatedUnion("code", [ export type ContentConflict = z.infer; +/** + * The 409 body a translation route answers with. + * + * Its own union rather than three more members of {@link zodContentConflict}: + * that one is the contract Stage 4 editorial routes already publish, and every + * generated client is built from it. A translation route is new, so it can carry + * a shape that names the locale in every arm - which is the one thing a locale + * tab strip has to know to point at the right tab. + */ +export const zodContentTranslationConflict = z.discriminatedUnion("code", [ + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.version), + contentTypeId: z.string(), + currentVersion: z.number().int(), + expectedVersion: z.number().int(), + itemId: z.number().int(), + locale: z.string(), + }), + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.defaultRequired), + contentTypeId: z.string(), + itemId: z.number().int(), + locale: z.string(), + }), + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.exists), + contentTypeId: z.string(), + itemId: z.number().int(), + locale: z.string(), + }), + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.languageDisabled), + contentTypeId: z.string(), + locale: z.string(), + }), + z.object({ + code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.unique), + contentTypeId: z.string(), + itemId: z.number().int().nullable(), + locale: z.string(), + }), +]); + +export type ContentTranslationConflict = z.infer< + typeof zodContentTranslationConflict +>; + +/** Reads a translation conflict out of a response body, or `null`. */ +export const parseContentTranslationConflict = ( + body: string | undefined, +): ContentTranslationConflict | null => { + if (body === undefined || body === "") return null; + + try { + const parsed = zodContentTranslationConflict.safeParse(JSON.parse(body)); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } +}; + /** The 422 body a restore answers with when the snapshot no longer fits. */ export const zodContentUnprocessable = z.object({ code: z.literal(CONTENT_UNPROCESSABLE_CODES.notRestorable), diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index 7964f01a8..5ba631d78 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -112,6 +112,167 @@ export class ContentRevisionNotRestorable extends ContentEngineError { readonly revisionId: number; } +/** + * A translation write lost the race against another edit of the *same locale*. + * + * Separate from {@link ContentVersionConflict} because the two guard different + * rows and mean different things to an editor: the base row's version is shared + * by everybody, a translation's belongs to one language. Somebody editing Polish + * must never be told the English copy moved. + */ +export class ContentTranslationVersionConflict extends ContentEngineError { + constructor({ + contentTypeId, + currentVersion, + expectedVersion, + itemId, + locale, + }: { + contentTypeId: string; + currentVersion: number; + expectedVersion: number; + itemId: number; + locale: string; + }) { + super( + `The ${locale} translation is at version ${currentVersion}, not ${expectedVersion}. Someone else saved it first.`, + { contentTypeId }, + ); + + this.name = "ContentTranslationVersionConflict"; + this.currentVersion = currentVersion; + this.expectedVersion = expectedVersion; + this.itemId = itemId; + this.locale = locale; + } + + readonly currentVersion: number; + readonly expectedVersion: number; + readonly itemId: number; + readonly locale: string; +} + +/** + * An attempt to remove the one translation a record cannot be without. + * + * The default-locale translation is created in the same transaction as the base + * row, which is what lets every later stage assume a record always resolves in + * at least one language. Deleting it would break that invariant silently - the + * row would still be there, addressable, and empty in every language. + */ +export class ContentDefaultTranslationRequired extends ContentEngineError { + constructor({ + contentTypeId, + itemId, + locale, + }: { + contentTypeId: string; + itemId: number; + locale: string; + }) { + super( + `"${locale}" is the default locale, so its translation cannot be deleted. Delete the record itself, or change the content type's default locale first.`, + { contentTypeId }, + ); + + this.name = "ContentDefaultTranslationRequired"; + this.itemId = itemId; + this.locale = locale; + } + + readonly itemId: number; + readonly locale: string; +} + +/** + * A create for a locale that already has a translation. + * + * Its own error rather than a bare unique violation: "switch to the tab that + * exists" and "that slug is taken" are different instructions, and the composite + * primary key cannot tell a client which one it hit. + */ +export class ContentTranslationExists extends ContentEngineError { + constructor({ + contentTypeId, + itemId, + locale, + }: { + contentTypeId: string; + itemId: number; + locale: string; + }) { + super( + `This record already has a "${locale}" translation. Update it instead of creating a second one.`, + { contentTypeId }, + ); + + this.name = "ContentTranslationExists"; + this.itemId = itemId; + this.locale = locale; + } + + readonly itemId: number; + readonly locale: string; +} + +/** + * A locale that does not name a usable language. + * + * `reason` is what the routes branch on: an unknown locale is a 404 (there is no + * such thing to address), a disabled one is a 409 (it exists, and this install + * has switched it off). Both are per-request, and neither carries anything + * beyond the locale the caller already sent. + */ +export class ContentLanguageError extends ContentEngineError { + constructor({ + contentTypeId, + locale, + reason, + }: { + contentTypeId?: string; + locale: string; + reason: "disabled" | "missing"; + }) { + super( + reason === "missing" + ? `Unknown locale "${locale}". Add the language in AdminCP -> Languages first.` + : `Locale "${locale}" is disabled on this installation, so its content cannot be written.`, + { contentTypeId }, + ); + + this.name = "ContentLanguageError"; + this.locale = locale; + this.reason = reason; + } + + readonly locale: string; + readonly reason: "disabled" | "missing"; +} + +/** + * A translation write for a base record that is not there. + * + * Checked before the insert rather than left to the foreign key: the driver's + * `23503` cannot say *which* of the two references failed, and "the article is + * gone" and "that language is gone" want different answers. + */ +export class ContentTranslationItemMissing extends ContentEngineError { + constructor({ + contentTypeId, + itemId, + }: { + contentTypeId: string; + itemId: number; + }) { + super(`No record with id ${itemId} to translate.`, { contentTypeId }); + + this.name = "ContentTranslationItemMissing"; + this.itemId = itemId; + } + + readonly itemId: number; +} + /** * A schedule that does not make sense: a time already past, or an unpublish * that would fire before the publish it is meant to follow. diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 4ddfab80f..4e996b7e6 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -39,13 +39,17 @@ export type { } from "./cache"; export { parseContentConflict, + parseContentTranslationConflict, parseContentUnprocessable, zodContentConflict, + zodContentTranslationConflict, zodContentUnprocessable, } from "./conflicts"; export type { ContentConflict, ContentConflictCode, + ContentTranslationConflict, + ContentTranslationConflictCode, ContentUnprocessable, ContentUnprocessableCode, } from "./conflicts"; @@ -57,6 +61,10 @@ export { CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FILTERABLE_FIELD_KINDS, + CONTENT_LOCALE_MAX_LENGTH, + CONTENT_LOCALE_PATTERN, + CONTENT_LOCALIZATION_FALLBACKS, + CONTENT_LOCALIZED_FIELD_KINDS, CONTENT_OPTIONS_LIMIT, CONTENT_PERMISSIONS, CONTENT_PREVIEW_DEFAULT_TTL_MINUTES, @@ -89,15 +97,25 @@ export { CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_SYSTEM_FIELDS, CONTENT_TEXT_DEFAULT_LENGTH, + CONTENT_TRANSLATION_CONFLICT_CODES, + CONTENT_TRANSLATION_SYSTEM_FIELDS, + CONTENT_TRANSLATION_TABLE_SUFFIX, CONTENT_UNPROCESSABLE_CODES, isContentPublicationStatus, + isFilterableFieldKind, + isLocalizableFieldKind, RESERVED_FILTER_KEYS, } from "./const"; export { defineContentType } from "./define"; export { + ContentDefaultTranslationRequired, ContentEngineError, ContentInputError, + ContentLanguageError, ContentRevisionNotRestorable, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, ContentVersionConflict, } from "./errors"; export { contentEventName } from "./events"; @@ -112,7 +130,20 @@ export type { } from "./events"; export { field } from "./fields"; export { clampWithFingerprint, fingerprint } from "./fingerprint"; -export { contentIndexName, toSnakeCase } from "./indexes"; +export { + contentIndexName, + contentTranslationPrimaryKeyName, + resolveContentTranslationIndexes, + toSnakeCase, +} from "./indexes"; +export { + contentLocalizationDisabled, + contentTranslationTableName, + isLocalizedContentField, + partitionContentFields, + resolveContentLocalization, +} from "./localization"; +export type { ContentFieldPartition } from "./localization"; export { contentAdminHref, contentPermissionEntries, @@ -144,7 +175,7 @@ export type { ContentScheduleStatus, } from "./schedules"; export { buildContentSchemas } from "./schemas"; -export type { ContentSchemas } from "./schemas"; +export type { ContentSchemas, ContentTranslationSchemas } from "./schemas"; export { contentSearchDocumentId, contentSearchIndexedFieldNames, @@ -175,6 +206,12 @@ export type { ContentFilterInput, ContentIndexConfig, ContentIndexInput, + ContentLocalizationConfig, + ContentLocalizationEnabled, + ContentLocalizationFallback, + ContentLocalizedFieldName, + ContentLocalizedUpdateValues, + ContentLocalizedValues, ContentNumberField, ContentOnDelete, ContentOrderableFieldName, @@ -199,21 +236,28 @@ export type { ContentSearchTextField, ContentSearchTitleField, ContentSelect, + ContentSharedFieldName, + ContentSharedValues, ContentSlugField, ContentSlugRequired, ContentSystemField, ContentTextareaField, ContentTextField, + ContentTranslationMeta, + ContentTranslationRow, + ContentTranslationSystemField, ContentTypeDefinition, ContentUpdateInput, ContentUserField, EditorialContentTypeDefinition, FilterableContentFieldKind, FilterableContentFieldName, + LocalizedContentTypeDefinition, PreviewableContentTypeDefinition, ResolvedContentAdminConfig, ResolvedContentEditorialConfig, ResolvedContentIndex, + ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, ResolvedContentPublicationConfig, ResolvedContentSearchConfig, diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index d4b79fc6f..801cb1677 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -5,15 +5,19 @@ import type { ContentCreateInput, ContentFieldDescriptor, ContentFieldMap, + ContentLocalizedUpdateValues, + ContentLocalizedValues, ContentPublicSelect, ContentSelect, ContentUpdateInput, ResolvedContentAdminConfig, + ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, } from "./types"; import { CONTENT_EDITORIAL_FIELDS, + CONTENT_LOCALE_MAX_LENGTH, CONTENT_PUBLIC_ALWAYS_ORDERABLE, CONTENT_PUBLICATION_FIELDS, CONTENT_PUBLICATION_STATUSES, @@ -21,6 +25,10 @@ import { CONTENT_SYSTEM_FIELDS, isFilterableFieldKind, } from "./const"; +import { + contentLocalizationDisabled, + partitionContentFields, +} from "./localization"; /** What a content type without `publicApi` carries: nothing exposed at all. */ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { @@ -35,8 +43,54 @@ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { slugField: "", }; +/** + * The schemas one translation row is written and read through. + * + * Content values live under `values`, and everything else - the locale, the + * expected version - is transport that sits *beside* them. That split is what + * lets `values` stay a strict object of the content type's own localized fields: + * an `expectedVersion` key inside it would be indistinguishable from a field + * somebody is trying to mass-assign. + */ +export interface ContentTranslationSchemas< + TDefinition = AnyContentTypeDefinition, +> { + /** Localized values for a new translation. Strict; requiredness per field. */ + create: z.ZodType>; + /** `{ values }` - the create request body. */ + createEnvelope: z.ZodType<{ values: ContentLocalizedValues }>; + /** The same shape as a plain `ZodObject`, so routes can compose it. */ + createObject: z.ZodObject; + /** + * The form shape for one locale: a plain `ZodObject` with no `z.date()`, so + * `AutoForm` can run `z.toJSONSchema` on it. Stage 5B renders it. + */ + form: z.ZodObject; + /** Path parameters for a translation route: the item, then the locale. */ + params: z.ZodObject<{ id: z.ZodCoercedNumber; locale: z.ZodString }>; + /** One translation as it comes back: metadata plus `values`. */ + select: z.ZodObject; + /** One translation without its values - what the list route returns. */ + selectMeta: z.ZodObject; + /** Localized values for an existing translation. Every key optional, never empty. */ + update: z.ZodType>; + /** `{ expectedVersion, values }` - the update and delete request body. */ + updateEnvelope: z.ZodType<{ + expectedVersion: number; + values: ContentLocalizedUpdateValues; + }>; + /** `{ expectedVersion }` - the delete request body. */ + versionEnvelope: z.ZodType<{ expectedVersion: number }>; +} + export interface ContentSchemas { - /** Request body for create. Rejects unknown keys and system columns. */ + /** + * Request body for create. Rejects unknown keys and system columns. + * + * Shared fields only. A localized content type's localized values arrive + * through `translation.create` instead, in the same transaction - see + * `localizedService.create`. + */ create: z.ZodType>; /** * Query-string filters, restricted to filterable fields. Non-strict: it is @@ -73,6 +127,15 @@ export interface ContentSchemas { * routes can `.extend(...)` it with the joined relation labels. */ selectObject: z.ZodObject; + /** + * The per-language schemas, or `null` when the content type is not localized. + * + * `null` rather than empty schemas, matching how `model.publicService` is + * `undefined` without a public API: a nullable value reads naturally in code + * that does not know which content type it was handed, and it cannot be used + * by accident. + */ + translation: ContentTranslationSchemas | null; /** Request body for update. Every field optional, but never empty. */ update: z.ZodType>; /** @@ -298,20 +361,115 @@ const publicSelectShape = ( * `defineContentType` can call it before the definition object exists and * without re-widening its field map. */ +/** + * The translation schemas for one content type, or `null` when it has none. + * + * Localized fields only, and never a metadata key: `itemId` and `languageId` + * identify the row rather than describing it, and `version` is assigned by the + * conditional `UPDATE` that guards on it. All three are absent from the strict + * `values` object, which is what stops any of them being mass-assigned. + */ +const buildTranslationSchemas = ({ + admin, + localizedFields, + localization, +}: { + admin: ResolvedContentAdminConfig; + localization: ResolvedContentLocalizationConfig; + localizedFields: ContentFieldMap; +}): ContentTranslationSchemas | null => { + if (!localization.enabled) return null; + + const names = Object.keys(localizedFields); + + const create = z.strictObject(inputShape(localizedFields, names)); + const update = z + .strictObject(updateShape(localizedFields, names)) + .refine(value => Object.keys(value).length > 0, { + message: "Provide at least one localized field to update.", + }); + + const expectedVersion = z.number().int().positive(); + const selectMeta = z.object({ + createdAt: z.date(), + itemId: z.number().int().positive(), + languageId: z.number().int().positive(), + locale: z.string(), + updatedAt: z.date(), + version: expectedVersion, + }); + + const values = z.object( + Object.fromEntries( + names.map(name => [ + name, + applyNullable( + baseSelectSchema(localizedFields[name]), + localizedFields[name], + ), + ]), + ), + ); + + return { + create: create as unknown as z.ZodType>, + createEnvelope: z.strictObject({ values: create }) as unknown as z.ZodType<{ + values: ContentLocalizedValues; + }>, + createObject: create, + // The declared form fields, narrowed to the localized ones: a locale tab + // edits one language's values and nothing else. + form: z.object( + inputShape( + localizedFields, + admin.form.fields.filter(name => localizedFields[name] !== undefined), + ), + ), + params: z.object({ + id: z.coerce.number(), + // Loose on purpose, like `publicParams.slug`: an unknown locale and a + // malformed one are both answered the same way, and the value is a bound + // parameter rather than an identifier. + locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH), + }), + select: selectMeta.extend({ values }), + selectMeta, + update: update as unknown as z.ZodType< + ContentLocalizedUpdateValues + >, + updateEnvelope: z.strictObject({ + // Positive, so a client that forgot to send one cannot coerce `0` past the + // guard and race the very check it is meant to lose. + expectedVersion, + values: update, + }) as unknown as z.ZodType<{ + expectedVersion: number; + values: ContentLocalizedUpdateValues; + }>, + versionEnvelope: z.strictObject({ expectedVersion }), + }; +}; + export const buildContentSchemas = ({ admin, editorial = false, fields, + localization = contentLocalizationDisabled(), publicApi = DISABLED_PUBLIC_API, publication = false, }: { admin: ResolvedContentAdminConfig; editorial?: boolean; + /** Every declared field. Partitioned here, so no caller has to. */ fields: ContentFieldMap; + localization?: ResolvedContentLocalizationConfig; publicApi?: ResolvedContentPublicApiConfig; publication?: boolean; }): ContentSchemas => { - const fieldNames = Object.keys(fields); + // Everything below this line is about the base table, so it reads the shared + // half only. The localized half gets its own schemas at the bottom. + const { localizedFields, sharedFields } = partitionContentFields(fields); + const fieldNames = Object.keys(sharedFields); // Read-only on the wire: absent from `create` and `update` (both strict), so // the only way to move them is `service.publish` / `service.unpublish`. @@ -333,7 +491,7 @@ export const buildContentSchemas = ({ ...Object.fromEntries( fieldNames.map(name => [ name, - applyNullable(baseSelectSchema(fields[name]), fields[name]), + applyNullable(baseSelectSchema(sharedFields[name]), sharedFields[name]), ]), ), ...publicationSelectShape, @@ -345,9 +503,9 @@ export const buildContentSchemas = ({ // `strictObject` blocks mass assignment: an unknown key is an error, not // something quietly stripped. System columns are absent from the shape, so // they can never be set from a request. - const create = z.strictObject(inputShape(fields, fieldNames)); + const create = z.strictObject(inputShape(sharedFields, fieldNames)); const update = z - .strictObject(updateShape(fields, fieldNames)) + .strictObject(updateShape(sharedFields, fieldNames)) .refine(value => Object.keys(value).length > 0, { message: "Provide at least one field to update.", }); @@ -360,14 +518,16 @@ export const buildContentSchemas = ({ ]; const selectObject = z.object(selectShape); - const publicSelectObject = z.object(publicSelectShape(fields, publicApi)); + const publicSelectObject = z.object( + publicSelectShape(sharedFields, publicApi), + ); const publicFilterable = new Set(publicApi.filterableFields); // Derived from the same `filterShape`, then narrowed to the configured // allowlist - so a public filter can never reach a field the admin filter // schema would not have accepted either. const publicFilters = z.object( Object.fromEntries( - Object.entries(filterShape(fields)).filter(([name]) => + Object.entries(filterShape(sharedFields)).filter(([name]) => publicFilterable.has(name), ), ), @@ -380,12 +540,12 @@ export const buildContentSchemas = ({ // casts. `buildContentSchemas` is covered by `schemas.test-d.ts`. create: create as unknown as z.ZodType>, filters: z.object({ - ...filterShape(fields), + ...filterShape(sharedFields), ...(publication ? { status: z.enum(CONTENT_PUBLICATION_STATUSES).optional() } : {}), }), - form: z.object(inputShape(fields, admin.form.fields)), + form: z.object(inputShape(sharedFields, admin.form.fields)), order: z.object({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(orderable as [string, ...string[]]).optional(), @@ -412,6 +572,11 @@ export const buildContentSchemas = ({ publicSelectObject, select: selectObject as unknown as z.ZodType>, selectObject, + translation: buildTranslationSchemas({ + admin, + localization, + localizedFields, + }), update: update as unknown as z.ZodType>, updateEnvelope: z.strictObject({ // Positive, so a client that forgot to send one cannot coerce `0` past From 8177e7b363183833c0e4c0ee5a71c7bf9869809b Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:05:34 +0200 Subject: [PATCH 03/11] feat(content): generate a translation table per localized Content Type One table per content type, not one shared table for the whole install. That is the whole design decision, and everything good about it follows: real Postgres types, real NOT NULL, a real unique index on (languageId, slug), Drizzle inference that knows what `title` is, and a migration drizzle-kit generates rather than a JSONB blob or an EAV table nobody can index. PRIMARY KEY (itemId, languageId) itemId -> .id ON DELETE cascade languageId -> core_languages.id ON DELETE restrict The two ON DELETE behaviours are opposites on purpose. A record's translations are part of the record, so removing it takes them along in one statement - no loop over locales anywhere. Deleting a *language* must not silently delete every article written in it, so Postgres refuses and a person decides what happens to the content first. That is deliberately unlike core_languages_words, which cascades: losing a UI string is an inconvenience, losing every article is not. `languageId` references `core_languages.id` rather than `.code` - four bytes in a composite key every translation read uses, and renaming a locale rewrites no translation rows. The code is still the only thing that appears in a URL. The base table and the base service narrow to the shared fields, so a non-localized content type generates exactly the table it always did. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/content/server/column-builders.ts | 46 +++++++ .../vitnode/src/content/server/service.ts | 21 ++-- packages/vitnode/src/content/server/table.ts | 7 +- .../src/content/server/translation-table.ts | 117 ++++++++++++++++++ packages/vitnode/src/content/server/types.ts | 94 +++++++++++++- 5 files changed, 273 insertions(+), 12 deletions(-) create mode 100644 packages/vitnode/src/content/server/translation-table.ts diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index 8f4465ea2..d6dcddaa1 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -79,6 +79,52 @@ export const buildEditorialColumns = (): Record< version: integer().notNull().default(1), }); +/** + * The columns every generated translation table carries. + * + * `itemId` and `languageId` are the composite primary key, added by + * `createContentTranslationTable` - both are `NOT NULL` here because a key + * column has to be, and both are written by the service rather than by a + * request. + * + * `version` mirrors the editorial column deliberately: a translation has *its + * own* optimistic lock, so an edit in Polish and an edit in English cannot + * conflict with each other. It defaults to 1 and is only ever moved by the + * conditional `UPDATE` that guards on it. + */ +export const buildTranslationSystemColumns = ({ + itemReference, + languageReference, + onItemDelete = "cascade", +}: { + itemReference: ColumnReferenceThunk; + languageReference: ColumnReferenceThunk; + onItemDelete?: "cascade"; +}): Record => ({ + itemId: integer() + .notNull() + // Cascade: a record's translations are part of the record, so removing it + // takes them with it in one statement - there is no loop over locales + // anywhere, and no window in which a translation outlives its row. + .references(itemReference, { onDelete: onItemDelete, onUpdate: "cascade" }), + languageId: integer() + .notNull() + // Restrict, unlike `core_languages_words`, which cascades. Deleting a + // language must not silently delete every article written in it: the + // AdminCP's language screen should refuse, and the person should decide + // what happens to the content first. + .references(languageReference, { + onDelete: "restrict", + onUpdate: "cascade", + }), + version: integer().notNull().default(1), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + /** * Applies `NOT NULL` and the column default. * diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 7e1b15083..d802f0d37 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -12,11 +12,11 @@ import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, ContentCreateInput, - ContentFieldName, ContentFilterInput, ContentOrderableFieldName, ContentReferenceFieldName, ContentSelect, + ContentSharedFieldName, ContentUpdateInput, } from "../types"; @@ -28,6 +28,7 @@ import { CONTENT_PUBLICATION_FIELDS, } from "../const"; import { ContentEngineError } from "../errors"; +import { partitionContentFields } from "../localization"; import { orderableColumns } from "../registry"; import { buildFilterCondition, @@ -83,7 +84,7 @@ export interface ContentServiceOptions { } export interface ContentUpdateResult { - changedFields: ContentFieldName[]; + changedFields: ContentSharedFieldName[]; row: ContentSelect; } @@ -184,7 +185,10 @@ export const createContentService = < schemas: ContentSchemas; table: PgTableWithColumns; }): ContentService => { - const fields = definition.fields; + // Shared only, everywhere in this file: this service reads and writes the base + // table, and a localized field is not a column on it. The translation model + // owns the other half. + const fields = partitionContentFields(definition.fields).sharedFields; const contentTypeId = definition.id; // `buildSystemColumns` always makes `id` a `serial`, which is what // `withPagination` needs to type its cursor. @@ -192,10 +196,13 @@ export const createContentService = < ColumnBaseConfig<"number", string> >; const orderable = orderableColumns(definition); - // `Object.keys` erases the key union that `ContentFieldName` recovers. The - // object is the very field map that type is derived from, so this restates - // what TypeScript already knows rather than asserting anything new. - const fieldNames = Object.keys(fields) as ContentFieldName[]; + // `Object.keys` erases the key union that `ContentSharedFieldName` recovers. + // The object is the shared half of the very field map that type is derived + // from, so this restates what TypeScript already knows rather than asserting + // anything new. + const fieldNames = Object.keys( + fields, + ) as ContentSharedFieldName[]; const publication = definition.publication.enabled; const ownColumnNames = [ "id", diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 5681f093b..e28672a3a 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -27,6 +27,7 @@ import type { import { core_users } from "../../database/users"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const"; import { ContentEngineError } from "../errors"; +import { partitionContentFields } from "../localization"; import { buildContentColumn, buildEditorialColumns, @@ -142,7 +143,9 @@ export const createContentTable = < } const { id: contentTypeId, indexes, tableName } = definition; - const fields = definition.fields; + // Shared only: a localized field's column lives on the generated translation + // table, and `createContentTranslationTable` puts it there. + const fields = partitionContentFields(definition.fields).sharedFields; const referenceThunks = references as Record; const columns: Record = { @@ -222,7 +225,7 @@ export const contentTableColumns = < "updatedAt", ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), - ...Object.keys(definition.fields), + ...Object.keys(partitionContentFields(definition.fields).sharedFields), ]; return Object.fromEntries(names.map(name => [name, source[name]])) as Record< diff --git a/packages/vitnode/src/content/server/translation-table.ts b/packages/vitnode/src/content/server/translation-table.ts new file mode 100644 index 000000000..91d5870ef --- /dev/null +++ b/packages/vitnode/src/content/server/translation-table.ts @@ -0,0 +1,117 @@ +import type { + PgColumn, + PgColumnBuilderBase, + PgTable, +} from "drizzle-orm/pg-core"; + +import { index, pgTable, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; + +import type { AnyContentTypeDefinition, ResolvedContentIndex } from "../types"; +import type { + ContentTranslationColumnName, + ContentTranslationTableFor, +} from "./types"; + +import { core_languages } from "../../database/languages"; +import { CONTENT_TRANSLATION_SYSTEM_FIELDS } from "../const"; +import { ContentEngineError } from "../errors"; +import { contentTranslationPrimaryKeyName } from "../indexes"; +import { partitionContentFields } from "../localization"; +import { + buildContentColumn, + buildTranslationSystemColumns, +} from "./column-builders"; + +/** + * Builds the `pgTable` holding one localized content type's translations. + * + * One table per content type, not one shared table for the whole install. That + * is the entire design decision, and everything good about it follows from it: + * real column types, real `NOT NULL`, a real unique index on `(languageId, slug)`, + * Drizzle inference that knows what `title` is, and a migration `drizzle-kit` + * generates rather than a JSONB blob or an EAV table nobody can index. + * + * Like {@link createContentTable} the result is an ordinary Drizzle table, so + * `drizzle-kit` discovers it by runtime identity when it globs the plugin's built + * `dist/src/database/*.js`. Export it from the plugin's database module: + * + * ```ts + * export const example_articles_translations = articleContent.translationTable; + * ``` + */ +export const createContentTranslationTable = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + { table }: { table: PgTable }, +): ContentTranslationTableFor => { + const { id: contentTypeId, localization } = definition; + + if (!localization.enabled) { + throw new ContentEngineError( + "createContentTranslationTable needs `localization: { enabled: true, defaultLocale }` on the content type.", + { contentTypeId }, + ); + } + + const { localizedFields } = partitionContentFields(definition.fields); + const baseColumns = table as unknown as Record; + + const columns: Record = { + ...buildTranslationSystemColumns({ + itemReference: () => baseColumns.id, + languageReference: () => core_languages.id, + }), + }; + + for (const [name, fieldValue] of Object.entries(localizedFields)) { + columns[name] = buildContentColumn({ contentTypeId, fieldValue, name }); + } + + const { translationIndexes, translationTableName } = localization; + + return pgTable( + translationTableName, + () => columns, + translationTable => { + const columnMap = translationTable as unknown as Record; + + return [ + // `(itemId, languageId)` rather than a surrogate key: the identity of a + // translation *is* the record plus the language, and a serial id would + // make "one translation per locale" a constraint somebody could forget. + primaryKey({ + columns: [columnMap.itemId, columnMap.languageId], + name: contentTranslationPrimaryKeyName(translationTableName), + }), + ...translationIndexes.map((config: ResolvedContentIndex) => { + const [first, ...rest] = config.on.map(name => columnMap[name]); + + return config.unique + ? uniqueIndex(config.name).on(first, ...rest) + : index(config.name).on(first, ...rest); + }), + ]; + }, + ).enableRLS() as unknown as ContentTranslationTableFor; +}; + +/** Column name -> Drizzle column on the translation table. */ +export const contentTranslationTableColumns = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + translationTable: ContentTranslationTableFor, +): Record, PgColumn> => { + const source = translationTable as unknown as Record; + const { localizedFields } = partitionContentFields(definition.fields); + const names = [ + ...CONTENT_TRANSLATION_SYSTEM_FIELDS, + ...Object.keys(localizedFields), + ]; + + return Object.fromEntries(names.map(name => [name, source[name]])) as Record< + ContentTranslationColumnName, + PgColumn + >; +}; diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 6868b3d23..3883939fe 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -18,8 +18,11 @@ import type { import type { ContentEditorialField, ContentFieldsOf, + ContentLocalizedFieldName, ContentPublicationField, + ContentSharedFieldName, ContentSystemField, + ContentTranslationSystemField, HasColumnDefault, } from "../types"; @@ -117,6 +120,69 @@ export type ContentColumnBuilders< [K in keyof TFields]: ContentColumnBuilder; }; +/** `itemId`, `languageId`, `version` and the timestamps. */ +export interface ContentTranslationSystemColumnBuilders { + createdAt: NotNull>>; + itemId: NotNull>; + languageId: NotNull>; + updatedAt: NotNull>>; + version: NotNull>>; +} + +export type ContentTranslationColumnBuilders = + ContentTranslationSystemColumnBuilders & { + [K in keyof TFields]: ContentColumnBuilder; + }; + +/** + * The `pgTable` a localized content type's translations compile to. + * + * Built with Drizzle's own `BuildColumns`, exactly like {@link ContentTable}, so + * `$inferSelect` and `$inferInsert` come out of the same machinery a + * hand-written `pgTable` uses. + */ +export type ContentTranslationTable< + TName extends string, + TFields, +> = PgTableWithColumns<{ + columns: BuildColumns, "pg">; + dialect: "pg"; + name: TName; + schema: undefined; +}>; + +/** + * The localized half of a field map, as a record. + * + * Spelled with a mapped type rather than `Pick` so the erased + * `AnyContentTypeDefinition` - whose localized name union is `never` - resolves + * to an empty record instead of to `never`. + */ +type LocalizedFieldsOf = { + [ + K in ContentLocalizedFieldName & + keyof ContentFieldsOf + ]: ContentFieldsOf[K]; +}; + +/** + * The translation table for one definition. + * + * `string` rather than the literal translation table name: that name is derived + * at *runtime* from `tableName` (suffixed, then clamped to 63 characters with a + * fingerprint), and re-deriving the clamp in the type system would be a second + * implementation of it. Nothing needs the literal - Drizzle only uses the name + * parameter to prefix column names it never exposes by literal type. + */ +export type ContentTranslationTableFor = ContentTranslationTable< + string, + LocalizedFieldsOf +>; + +/** Column name -> Drizzle column on the translation table. */ +export type ContentTranslationColumnName = + ContentLocalizedFieldName | ContentTranslationSystemField; + /** * The `pgTable` a content type compiles to. * @@ -139,18 +205,40 @@ export type ContentTable< schema: undefined; }>; +/** + * The shared half of a field map, as a record. See {@link LocalizedFieldsOf} for + * why it is a mapped type rather than a `Pick`. + */ +type SharedFieldsOf = { + [ + K in ContentSharedFieldName & + keyof ContentFieldsOf + ]: ContentFieldsOf[K]; +}; + +/** + * The base `pgTable` for one definition. + * + * Shared fields only. For a content type without localization every field is + * shared, so this is exactly the table it always generated. + */ export type ContentTableFor = TDefinition extends { editorial: { enabled: infer TEditorial extends boolean }; publication: { enabled: infer TPublication extends boolean }; tableName: infer TName extends string; } - ? ContentTable, TPublication, TEditorial> + ? ContentTable, TPublication, TEditorial> : never; -/** Column name -> Drizzle column, used for allowlisted filters and ordering. */ +/** + * Column name -> Drizzle column, used for allowlisted filters and ordering. + * + * Shared fields only: a localized field is a column on the translation table, + * and {@link ContentTranslationColumnName} is the union that names those. + */ export type ContentColumnName = + | ContentSharedFieldName | ContentSystemField - | (keyof ContentFieldsOf & string) | (TDefinition extends { editorial: { enabled: true } } ? ContentEditorialField : never) From 7e8caddc355d0b3f8a7b98097a5e582de37f917b Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:05:47 +0200 Subject: [PATCH 04/11] feat(content): resolve content locales against core_languages `core_languages` is the source of truth. The Content Engine keeps no language list of its own, invents no locale codes, and stores a foreign key to a row an admin created. Matching is case-insensitive and the *stored* code comes back, so `/PL/` and `/pl/` write to the same row and the response says `pl`. The registry loads once per request into a WeakMap keyed by the context, so resolving twenty locales is one query rather than twenty - the N+1 a naive `WHERE code = $1` resolver becomes on a list of translations. A failed load is not cached. `tx` is threaded through, and it is required for correctness rather than an optimisation: a pool whose only free connection is held by the caller's open transaction would otherwise wait forever for one. Reading inside the transaction also means the language resolved is the one the insert will see. A missing locale and a disabled one are different answers - 404 versus 409. A disabled language stays readable: its content is already in the database, and hiding it would make it unrecoverable. Growing more of it is what gets refused. Whether `defaultLocale` names a real row cannot be a definition-time check - a definition is plain data built at import time, long before there is a connection. The boot guard is the explicit runtime phase that replaces it: once per process, on the first request, naming every offender at once, and skipped entirely when nothing is localized so an install with no localized content types never touches the languages table because of it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/api/middlewares/global.middleware.ts | 17 + .../src/content/server/language-resolver.ts | 345 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 packages/vitnode/src/content/server/language-resolver.ts diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index f1efc4cd4..39ac34e98 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -22,6 +22,7 @@ import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; import { StorageModel } from "@/api/models/storage"; import { validateContentTypes } from "@/content/registry"; +import { ensureContentLocalizationLanguages } from "@/content/server/language-resolver"; import { assertContentPreviewConfig } from "@/content/server/preview-config"; import { CONFIG } from "@/lib/config"; import { collectLocaleCodes } from "@/lib/i18n/load-messages"; @@ -272,6 +273,12 @@ export const globalMiddleware = ({ })), ); + // Computed at boot, outside the request: "does anything need the languages + // table" is a property of the installed plugins, not of a request. + const hasLocalizedContentTypes = contentTypesMetadata.some( + entry => entry.definition.localization.enabled, + ); + const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map( plugin => ({ pluginId: plugin.pluginId, @@ -365,6 +372,16 @@ export const globalMiddleware = ({ contentTypes: contentTypesMetadata, }); + // Whether a localized content type's `defaultLocale` names a row in + // `core_languages` is a fact about the *installation*, so it cannot be + // checked when the definition is built - there is no connection yet. This is + // that check, run at most once per process and skipped entirely when nothing + // is localized, so an install with no localized content types never touches + // the languages table because of this. + if (hasLocalizedContentTypes) { + await ensureContentLocalizationLanguages(c, contentTypesMetadata); + } + const user = await new SessionModel(c).getUser(); c.set("user", user); c.set("admin", null); diff --git a/packages/vitnode/src/content/server/language-resolver.ts b/packages/vitnode/src/content/server/language-resolver.ts new file mode 100644 index 000000000..89ff892c9 --- /dev/null +++ b/packages/vitnode/src/content/server/language-resolver.ts @@ -0,0 +1,345 @@ +import type { Context } from "hono"; + +import type { RegisteredContentType } from "../registry"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; + +import { core_languages } from "../../database/languages"; +import { ContentEngineError, ContentLanguageError } from "../errors"; + +/** + * One usable language, as the Content Engine sees it. + * + * `locale` is the **canonical** `core_languages.code` - what the row stores, not + * what the caller typed. Everything downstream (the translation row, the route + * response, a future cache tag) uses this value, so `PL` in a URL and `pl` in a + * URL end up writing to the same place. + */ +export interface ContentLanguage { + /** `core_languages.id`. The foreign key a translation row actually holds. */ + id: number; + /** Whether `core_languages.default` is set on this row. */ + isDefault: boolean; + /** Whether the app config serves this locale. Disabled ones are read-only. */ + isEnabled: boolean; + locale: string; +} + +/** + * Every language in `core_languages`, cached for the life of one request. + * + * Keyed by the Hono context in a `WeakMap` rather than stored on the context + * itself: `c.set` is typed against `ContextVariableMap`, and adding a Content + * Engine key to the global variable map for a per-request memo would make every + * app carry it. The entry is collected with the request. + * + * The languages table is a handful of rows and every locale in a batch has to be + * resolved anyway, so one query per request beats one query per locale - which is + * what a naive `WHERE code = $1` resolver turns into on a list of translations. + */ +const perRequest = new WeakMap>(); + +/** + * Locales the app config has explicitly switched off. + * + * Optional the whole way down: a direct `app.request()` in a test, and a queue + * handler built by hand, both reach here without the global middleware that + * populates `core` - and "no config" has to mean "nothing disabled" rather than a + * `TypeError`. + */ +const disabledLocales = (c: Context): ReadonlySet => { + const locales = c.get("core")?.i18n?.locales ?? []; + + return new Set( + locales + .filter(locale => locale.enabled === false) + .map(locale => locale.code.toLowerCase()), + ); +}; + +const load = async ( + c: Context, + tx?: ContentDatabase, +): Promise => { + const disabled = disabledLocales(c); + + const rows = await contentDatabase(c, tx) + .select({ + code: core_languages.code, + id: core_languages.id, + isDefault: core_languages.default, + }) + .from(core_languages); + + return rows.map(row => ({ + id: row.id, + isDefault: row.isDefault, + // `core_languages` is the registry of languages that *exist*; the app config + // says which ones it currently serves. A language the config does not + // mention at all stays usable - dropping a locale from `i18n.locales` must + // not make existing content unwritable - but one listed with + // `enabled: false` is a deliberate switch-off. + isEnabled: !disabled.has(row.code.toLowerCase()), + locale: row.code, + })); +}; + +/** + * Every language, resolved once per request. + * + * Never called for a content type without localization, which is what keeps the + * languages table out of the query plan of an install that has none. + * + * `tx` is not an optimisation - it is required for correctness. A pool with one + * connection (which is what `max: 1` and a busy pool both amount to) would have + * that connection held by the caller's open transaction, so a language query + * issued on the *client* would wait for a connection the transaction is never + * going to release until it gets an answer. Reading inside the transaction also + * means the language it resolves is the one the insert will actually see. + */ +export const listContentLanguages = async ( + c: Context, + tx?: ContentDatabase, +): Promise => { + const cached = perRequest.get(c); + if (cached) return await cached; + + const pending = load(c, tx); + perRequest.set(c, pending); + + try { + return await pending; + } catch (error) { + // A failed load must not be cached: the next call in the same request would + // get the same rejection with no chance of recovering from a blip. + perRequest.delete(c); + throw error; + } +}; + +/** + * Finds a language by locale, case-insensitively. `null` when there is none. + * + * Case-insensitive because a locale travels in a URL, and `/pl/` and `/PL/` + * naming the same language is what people expect. The comparison happens in + * JavaScript over the already-loaded rows rather than as `lower(code) = $1`, + * which would not use `core_languages_code_idx` anyway. + */ +export const findContentLanguage = async ( + c: Context, + locale: string, + tx?: ContentDatabase, +): Promise => { + const wanted = locale.trim().toLowerCase(); + if (wanted === "") return null; + + const languages = await listContentLanguages(c, tx); + + return ( + languages.find(language => language.locale.toLowerCase() === wanted) ?? null + ); +}; + +/** + * Resolves a locale to a language, or throws. + * + * `requireEnabled` is the write path: reading a translation in a locale the app + * has switched off is fine - it is already in the database, and hiding it would + * make the content unrecoverable - but writing one is not, because it would grow + * content in a language nothing renders. + */ +export const resolveContentLanguage = async ( + c: Context, + { + contentTypeId, + locale, + requireEnabled = false, + tx, + }: { + contentTypeId?: string; + locale: string; + requireEnabled?: boolean; + /** Read inside this transaction. See {@link listContentLanguages}. */ + tx?: ContentDatabase; + }, +): Promise => { + const language = await findContentLanguage(c, locale, tx); + + if (!language) { + throw new ContentLanguageError({ + contentTypeId, + locale, + reason: "missing", + }); + } + + if (requireEnabled && !language.isEnabled) { + throw new ContentLanguageError({ + contentTypeId, + locale: language.locale, + reason: "disabled", + }); + } + + return language; +}; + +/** + * The language a localized content type creates its records in. + * + * Resolved from the database every time rather than trusted from the definition: + * `defaultLocale` is a string in source control, and whether it names a row in + * `core_languages` is a fact about the installation. + */ +export const resolveDefaultContentLanguage = async ( + c: Context, + definition: AnyContentTypeDefinition, + tx?: ContentDatabase, +): Promise => { + if (!definition.localization.enabled) { + throw new ContentEngineError( + "This content type has no `localization` block, so it has no default locale.", + { contentTypeId: definition.id }, + ); + } + + return await resolveContentLanguage(c, { + contentTypeId: definition.id, + locale: definition.localization.defaultLocale, + requireEnabled: true, + tx, + }); +}; + +/** One localized content type whose configured default locale does not work. */ +export interface ContentLocalizationProblem { + contentTypeId: string; + defaultLocale: string; + reason: "disabled" | "missing"; +} + +/** + * Checks every localized content type's `defaultLocale` against the database. + * + * A content type definition is plain data built at import time, long before there + * is a connection - so "does `core_languages` have a row for `en`" cannot be a + * definition-time check. This is the explicit runtime phase that replaces it, and + * the whole reason it exists is that the alternative is discovering a typo in + * `defaultLocale` on the first create request, in production, as a foreign-key + * violation. + * + * Returns the problems rather than throwing, so the caller decides: the boot + * guard turns them into one error naming every offender at once, which is more + * useful than failing on the first. + */ +export const findContentLocalizationProblems = async ( + c: Context, + contentTypes: readonly RegisteredContentType[], +): Promise => { + const localized = contentTypes.filter( + entry => entry.definition.localization.enabled, + ); + if (localized.length === 0) return []; + + const languages = await listContentLanguages(c); + const byLocale = new Map( + languages.map(language => [language.locale.toLowerCase(), language]), + ); + const problems: ContentLocalizationProblem[] = []; + + for (const { definition } of localized) { + const { defaultLocale } = definition.localization; + const language = byLocale.get(defaultLocale.trim().toLowerCase()); + + if (!language) { + problems.push({ + contentTypeId: definition.id, + defaultLocale, + reason: "missing", + }); + continue; + } + + if (!language.isEnabled) { + problems.push({ + contentTypeId: definition.id, + defaultLocale, + reason: "disabled", + }); + } + } + + return problems; +}; + +const describeProblem = (problem: ContentLocalizationProblem): string => + `${problem.contentTypeId} -> localization.defaultLocale "${problem.defaultLocale}" ${ + problem.reason === "missing" + ? "does not exist in core_languages" + : "is disabled in this app's i18n.locales" + }`; + +/** + * The boot guard: refuses to serve an install whose localized content types name + * a default locale it cannot honour. + * + * Loud on purpose. A localized record is created with its default translation in + * one transaction, so a broken `defaultLocale` means *no record can be created + * at all* - it is not a degraded mode worth booting into. + */ +export const assertContentLocalizationLanguages = async ( + c: Context, + contentTypes: readonly RegisteredContentType[], +): Promise => { + const problems = await findContentLocalizationProblems(c, contentTypes); + if (problems.length === 0) return; + + throw new ContentEngineError( + `Localized content types have an unusable default locale:\n ${problems + .map(describeProblem) + .join("\n ")}`, + ); +}; + +/** + * The same check, run at most once per process. + * + * Memoised rather than repeated per request: the languages table can change while + * the process runs, but a *definition's* `defaultLocale` cannot - and the failure + * this catches is a configuration mistake, which is either there at boot or not + * at all. A language deleted afterwards is caught by the foreign key. + * + * A failure is not memoised, so a database that was not up yet gets checked + * again on the next request instead of poisoning the process. + */ +let bootCheck: Promise | undefined; + +export const ensureContentLocalizationLanguages = async ( + c: Context, + contentTypes: readonly RegisteredContentType[], +): Promise => { + bootCheck ??= assertContentLocalizationLanguages(c, contentTypes).catch( + (error: unknown) => { + bootCheck = undefined; + throw error; + }, + ); + + await bootCheck; +}; + +/** Test seam: forgets the memoised boot check. */ +export const resetContentLocalizationCheck = (): void => { + bootCheck = undefined; +}; + +/** + * The database handle a translation read or write should use. + * + * Shared with the base service's convention: `tx` when the caller owns a + * transaction, the request's client otherwise. + */ +export const contentDatabase = ( + c: Context, + tx?: ContentDatabase, +): ContentDatabase => tx ?? c.get("db"); From e07a52af8ab4f5a12d7204f11d16f3a59041afdd Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:06:07 +0200 Subject: [PATCH 05/11] feat(content): add the translation model and per-locale optimistic locking Every translation row carries its own version, and the version is part of the statement that changes it rather than checked before it: UPDATE ... SET ..., version = version + 1 WHERE itemId = $1 AND languageId = $2 AND version = $3 Two editors racing produce one statement that matches and one that does not, with no read-then-write window between them. A delete is the same shape. The important half is what does not happen: an edit to Polish never conflicts with an edit to English. Different rows, keyed by (itemId, languageId), independent counters. Two translators working at the same time is the normal case, not a race. A no-op is a success that changes nothing - no version bump, no `updatedAt`, no statement at all - and a stale `expectedVersion` is deliberately not an error on one, because there is nothing to overwrite. Values are normalised before the diff, so re-sending the stored slug in a different case counts as no change. Same semantics the base service already has. Slugs go through the same normaliser the base service uses, over the localized half of the field map. Two slug algorithms would drift into `/en/my-post` and `/pl/my_post`. Deliberately low level: no event, no cache tag, no search document, no revision. A repository that emitted events could not be called inside somebody else's transaction, which is exactly what atomic create needs it to be. Stage 5B orchestrates the effects on top. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/content/server/translation-model.ts | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 packages/vitnode/src/content/server/translation-model.ts diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts new file mode 100644 index 000000000..32ae63c03 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -0,0 +1,486 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, asc, eq, sql } from "drizzle-orm"; + +import type { ContentTranslationSchemas } from "../schemas"; +import type { + AnyContentTypeDefinition, + ContentLocalizedFieldName, + ContentLocalizedUpdateValues, + ContentLocalizedValues, + ContentTranslationMeta, + ContentTranslationRow, +} from "../types"; +import type { ContentLanguage } from "./language-resolver"; +import type { ContentDatabase } from "./service"; + +import { CONTENT_TRANSLATION_SYSTEM_FIELDS } from "../const"; +import { + ContentDefaultTranslationRequired, + ContentEngineError, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, +} from "../errors"; +import { partitionContentFields } from "../localization"; +import { + contentDatabase, + findContentLanguage, + listContentLanguages, + resolveContentLanguage, +} from "./language-resolver"; +import { diffChangedFields } from "./query"; +import { createSlugNormalizer } from "./slugs"; + +export interface ContentTranslationOptions { + /** Run inside an existing transaction. */ + tx?: ContentDatabase; +} + +export interface ContentTranslationWriteOptions extends ContentTranslationOptions { + expectedVersion: number; +} + +export interface ContentTranslationUpdateResult { + /** `false` when nothing moved: no write, no version bump, no `updatedAt`. */ + changed: boolean; + changedFields: ContentLocalizedFieldName[]; + row: ContentTranslationRow; + version: number; +} + +/** + * One localized content type's translation repository. + * + * Deliberately low level. It writes translation rows and enforces the rules that + * belong to the data - per-locale versioning, the default-translation invariant, + * slug normalisation - and does **nothing else**: no event, no cache tag, no + * search document, no revision. Stage 5B orchestrates those on top, the same way + * `contentEditorialEffects` does for the base row today. A repository that + * emitted events could not be called inside somebody else's transaction, which is + * exactly what atomic create needs it to be. + */ +export interface ContentTranslationModel { + /** Inserts one translation at version 1. Throws if the locale already has one. */ + create: ( + itemId: number, + locale: string, + values: ContentLocalizedValues, + options?: ContentTranslationOptions, + ) => Promise>; + /** + * Removes one translation, guarded by its version. + * + * `null` when there is no such translation - the caller wanted it gone, and it + * is. Refuses the default locale outright: that translation is created with the + * record and is what makes "a record always resolves in some language" true. + */ + delete: ( + itemId: number, + locale: string, + options: ContentTranslationWriteOptions, + ) => Promise | null>; + exists: ( + itemId: number, + locale: string, + options?: ContentTranslationOptions, + ) => Promise; + findByLanguageId: ( + itemId: number, + languageId: number, + options?: ContentTranslationOptions, + ) => Promise | null>; + findByLocale: ( + itemId: number, + locale: string, + options?: ContentTranslationOptions, + ) => Promise | null>; + /** Metadata for every translation of one record, without the values. */ + findManyForItem: ( + itemId: number, + options?: ContentTranslationOptions, + ) => Promise; + /** The language this content type creates records in. */ + resolveDefaultLanguage: ( + options?: ContentTranslationOptions, + ) => Promise; + /** Conditional `UPDATE` guarded by `expectedVersion`. A no-op writes nothing. */ + update: ( + itemId: number, + locale: string, + values: ContentLocalizedUpdateValues, + options: ContentTranslationWriteOptions, + ) => Promise | null>; +} + +const translationSystemFields: readonly string[] = + CONTENT_TRANSLATION_SYSTEM_FIELDS; + +export const createContentTranslationModel = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + columns, + definition, + schemas, + table, + translationTable, +}: { + c: Context; + /** Columns of the *translation* table, keyed by name. */ + columns: Record; + definition: TDefinition; + schemas: ContentTranslationSchemas; + /** The base table, for the "is there a record to translate" check. */ + table: PgTable; + translationTable: PgTable; +}): ContentTranslationModel => { + const contentTypeId = definition.id; + + if (!definition.localization.enabled) { + throw new ContentEngineError( + "The translation service needs `localization: { enabled: true, defaultLocale }` on the content type.", + { contentTypeId }, + ); + } + + const { localizedFields } = partitionContentFields(definition.fields); + const localizedNames = Object.keys( + localizedFields, + ) as ContentLocalizedFieldName[]; + const { defaultLocale } = definition.localization; + + const itemColumn = columns.itemId; + const languageColumn = columns.languageId; + const versionColumn = columns.version; + const baseId = (table as unknown as Record).id; + + // The same normaliser the base service uses, over the localized half of the + // field map. Two slug algorithms is exactly the pair that drifts, and the + // consequence would be `/en/my-post` and `/pl/my_post`. + const { withCreateSlugs, withUpdateSlugs } = createSlugNormalizer( + contentTypeId, + localizedFields, + ); + + const metaSelection = (): Record => + Object.fromEntries( + translationSystemFields.map(name => [name, columns[name]]), + ); + + const fullSelection = (): Record => ({ + ...metaSelection(), + ...Object.fromEntries(localizedNames.map(name => [name, columns[name]])), + }); + + const db = (options?: ContentTranslationOptions): ContentDatabase => + contentDatabase(c, options?.tx); + + /** + * Resolves a locale, reading the language registry through whatever handle the + * caller is using. + * + * The `tx` is load-bearing: inside `localizedService.create` the transaction + * holds the connection, so a registry query issued on the client would wait for + * a connection that transaction will not release until it has an answer. + */ + const language = async ( + locale: string, + { requireEnabled, tx }: { requireEnabled: boolean; tx?: ContentDatabase }, + ): Promise => + await resolveContentLanguage(c, { + contentTypeId, + locale, + requireEnabled, + tx, + }); + + /** + * Splits a raw row into metadata and `values`. + * + * The nesting is not decoration: it keeps a localized field called `version` or + * `locale` from being confused with the metadata of the row that holds it, and + * it means the update request body (`{ expectedVersion, values }`) and the + * response have the same shape. + */ + const toRow = ( + row: Record, + locale: string, + ): ContentTranslationRow => { + const values: Record = {}; + for (const name of localizedNames) values[name] = row[name]; + + return { + createdAt: row.createdAt as Date, + itemId: row.itemId as number, + languageId: row.languageId as number, + locale, + updatedAt: row.updatedAt as Date, + values: values as ContentLocalizedValues, + version: row.version as number, + }; + }; + + const versionOf = (row: Record): number => + typeof row.version === "number" ? row.version : 1; + + const readOne = async ( + itemId: number, + languageId: number, + database: ContentDatabase, + ): Promise> => { + const [row] = await database + .select(fullSelection()) + .from(translationTable) + .where(and(eq(itemColumn, itemId), eq(languageColumn, languageId))) + .limit(1); + + return row ?? null; + }; + + const assertItemExists = async ( + itemId: number, + database: ContentDatabase, + ): Promise => { + const [row] = await database + .select({ id: baseId }) + .from(table) + .where(eq(baseId, itemId)) + .limit(1); + + if (!row) { + throw new ContentTranslationItemMissing({ contentTypeId, itemId }); + } + }; + + return { + create: async (itemId, locale, values, options) => { + const target = await language(locale, { + requireEnabled: true, + tx: options?.tx, + }); + const database = db(options); + + // Checked here rather than left to the foreign key: `23503` cannot say + // which of the two references failed, and "no such article" and "no such + // language" deserve different answers. + await assertItemExists(itemId, database); + + const parsed = schemas.create.parse(values) as Record; + + const [row] = await database + .insert(translationTable) + .values({ + ...withCreateSlugs(parsed), + itemId, + languageId: target.id, + }) + // Targeted at the primary key only, so "this locale already has a + // translation" comes back as a row this can look up and name, while a + // *slug* clash still raises `23505` and is reported as one. An untargeted + // `onConflictDoNothing()` would swallow both and report the wrong thing. + .onConflictDoNothing({ + target: [itemColumn, languageColumn], + }) + .returning(fullSelection()); + + if (row) return toRow(row, target.locale); + + throw new ContentTranslationExists({ + contentTypeId, + itemId, + locale: target.locale, + }); + }, + + delete: async (itemId, locale, options) => { + // Resolved without `requireEnabled`: removing content in a language the + // install has switched off is exactly what somebody would want to do next. + const target = await language(locale, { + requireEnabled: false, + tx: options.tx, + }); + + if (target.locale.toLowerCase() === defaultLocale.toLowerCase()) { + throw new ContentDefaultTranslationRequired({ + contentTypeId, + itemId, + locale: target.locale, + }); + } + + const database = db(options); + + // The version is part of the statement that removes the row, not something + // checked before it - otherwise two deletes could both pass the check. + const [row] = await database + .delete(translationTable) + .where( + and( + eq(itemColumn, itemId), + eq(languageColumn, target.id), + eq(versionColumn, options.expectedVersion), + ), + ) + .returning(fullSelection()); + + if (row) return toRow(row, target.locale); + + const current = await readOne(itemId, target.id, database); + // Gone already is not a conflict: the caller wanted it removed, and it is. + if (!current) return null; + + throw new ContentTranslationVersionConflict({ + contentTypeId, + currentVersion: versionOf(current), + expectedVersion: options.expectedVersion, + itemId, + locale: target.locale, + }); + }, + + exists: async (itemId, locale, options) => { + const target = await findContentLanguage(c, locale, options?.tx); + if (!target) return false; + + const [row] = await db(options) + .select({ itemId: itemColumn }) + .from(translationTable) + .where(and(eq(itemColumn, itemId), eq(languageColumn, target.id))) + .limit(1); + + return row !== undefined; + }, + + findByLanguageId: async (itemId, languageId, options) => { + const row = await readOne(itemId, languageId, db(options)); + if (!row) return null; + + // The canonical locale comes off the language registry rather than out of + // the row, which only holds the id. + const languages = await listContentLanguagesById(c, options?.tx); + + return toRow(row, languages.get(languageId)?.locale ?? ""); + }, + + findByLocale: async (itemId, locale, options) => { + const target = await findContentLanguage(c, locale, options?.tx); + if (!target) return null; + + const row = await readOne(itemId, target.id, db(options)); + + return row ? toRow(row, target.locale) : null; + }, + + findManyForItem: async (itemId, options) => { + // Metadata only, and no join: the locale is resolved from the + // already-loaded language registry, so listing translations costs one + // query for the rows plus (at most) one for every language in the install - + // never one per translation. + const rows = await db(options) + .select(metaSelection()) + .from(translationTable) + .where(eq(itemColumn, itemId)) + .orderBy(asc(languageColumn)); + + const languages = await listContentLanguagesById(c, options?.tx); + + return rows.map(row => ({ + createdAt: row.createdAt as Date, + itemId: row.itemId as number, + languageId: row.languageId as number, + locale: languages.get(row.languageId as number)?.locale ?? "", + updatedAt: row.updatedAt as Date, + version: row.version as number, + })); + }, + + resolveDefaultLanguage: async options => + await language(defaultLocale, { + requireEnabled: true, + tx: options?.tx, + }), + + update: async (itemId, locale, values, options) => { + const target = await language(locale, { + requireEnabled: true, + tx: options.tx, + }); + const database = db(options); + + // Parsed before the row is read, so an invalid payload never costs a + // query. Slugs are normalised before the diff, so re-sending the stored + // slug in a different case counts as no change rather than a pointless + // write. + const patch = withUpdateSlugs(schemas.update.parse(values)); + + const current = await readOne(itemId, target.id, database); + if (!current) return null; + + const changedFields = diffChangedFields(localizedNames, current, patch); + + // A no-op is a successful write that changed nothing: it must not bump the + // version, must not move `updatedAt`, and must not fail on a stale + // `expectedVersion` - there is nothing to overwrite, so there is nothing + // to conflict about. The same rule the base service already follows. + if (changedFields.length === 0) { + return { + changed: false, + changedFields, + row: toRow(current, target.locale), + version: versionOf(current), + }; + } + + const [row] = await database + .update(translationTable) + .set({ + ...Object.fromEntries(changedFields.map(key => [key, patch[key]])), + version: sql`${versionColumn} + 1`, + }) + .where( + and( + eq(itemColumn, itemId), + eq(languageColumn, target.id), + eq(versionColumn, options.expectedVersion), + ), + ) + .returning(fullSelection()); + + if (!row) { + // The row was there a moment ago, so this is a lost race rather than a + // missing translation: another writer moved the version in between. + const latest = await readOne(itemId, target.id, database); + if (!latest) return null; + + throw new ContentTranslationVersionConflict({ + contentTypeId, + currentVersion: versionOf(latest), + expectedVersion: options.expectedVersion, + itemId, + locale: target.locale, + }); + } + + return { + changed: true, + changedFields, + row: toRow(row, target.locale), + version: versionOf(row), + }; + }, + }; +}; + +/** The language registry keyed by id, for turning a stored FK back into a locale. */ +const listContentLanguagesById = async ( + c: Context, + tx?: ContentDatabase, +): Promise> => + new Map( + (await listContentLanguages(c, tx)).map(language => [ + language.id, + language, + ]), + ); From 11ae724f536a8a38bed1763c045c51d6679fbbbc Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:06:07 +0200 Subject: [PATCH 06/11] feat(content): create a localized record and its default translation atomically One transaction, two inserts. Either both exist or neither does - and that invariant is what every later stage leans on: a record always resolves in at least one language, so a locale tab strip always has something to show and a public read always has something to fall back to. The default language is resolved inside the transaction and before the base insert, so a language that has just been removed rolls the base row back with it rather than leaving an untranslatable record behind. A slug clash does the same. A record is created in its default locale, and another one is refused: creating straight into Polish would leave the default translation missing, and every later stage would need an "unless it was created in another locale" branch. `createContentModel` grows four nullable members and two service factories, all `null`/`undefined` without localization - the convention `publicService` and `editorialService` already set, so a check reads naturally in code that does not know which content type it was handed, and TypeScript refuses the call until it has been made. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/content/server/localized-service.ts | 123 ++++++++++++++++++ packages/vitnode/src/content/server/model.ts | 120 ++++++++++++++++- 2 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 packages/vitnode/src/content/server/localized-service.ts diff --git a/packages/vitnode/src/content/server/localized-service.ts b/packages/vitnode/src/content/server/localized-service.ts new file mode 100644 index 000000000..b82572b2f --- /dev/null +++ b/packages/vitnode/src/content/server/localized-service.ts @@ -0,0 +1,123 @@ +import type { Context } from "hono"; + +import type { + AnyContentTypeDefinition, + ContentLocalizedValues, + ContentSelect, + ContentSharedValues, + ContentTranslationRow, +} from "../types"; +import type { ContentDatabase, ContentService } from "./service"; +import type { ContentTranslationModel } from "./translation-model"; + +import { ContentEngineError } from "../errors"; + +export interface ContentLocalizedCreateInput { + /** Values for the base table. */ + shared: ContentSharedValues; + /** Values for the default-locale translation created alongside it. */ + translation: ContentLocalizedValues; +} + +export interface ContentLocalizedCreateOptions { + /** + * The locale the first translation is written in. Defaults to - and, today, + * may only be - the content type's configured default locale. + */ + locale?: string; + /** Join an existing transaction instead of opening one. */ + tx?: ContentDatabase; +} + +export interface ContentLocalizedCreateResult { + row: ContentSelect; + translation: ContentTranslationRow; +} + +export interface ContentLocalizedService { + /** + * Creates a base row and its default-locale translation, atomically. + * + * Either both exist or neither does. That is the invariant every later stage + * leans on: a record always resolves in at least one language, so a locale tab + * strip always has something to show, a public read always has something to + * fall back to, and there is no such thing as an "empty" record whose title + * exists in no language at all. + */ + create: ( + input: ContentLocalizedCreateInput, + options?: ContentLocalizedCreateOptions, + ) => Promise>; +} + +/** + * The one write that spans both tables. + * + * Everything else about localization is either base-only (the plain service) or + * translation-only (the translation model). Create is the exception, and it is + * the reason this file exists rather than a third method on one of them. + */ +export const createContentLocalizedService = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + definition, + service, + translations, +}: { + c: Context; + definition: TDefinition; + service: ContentService; + translations: ContentTranslationModel; +}): ContentLocalizedService => { + const contentTypeId = definition.id; + + if (!definition.localization.enabled) { + throw new ContentEngineError( + "The localized service needs `localization: { enabled: true, defaultLocale }` on the content type.", + { contentTypeId }, + ); + } + + const { defaultLocale } = definition.localization; + + return { + create: async ({ shared, translation }, options = {}) => { + const locale = options.locale ?? defaultLocale; + + // A record always starts in its default language. Creating it straight + // into Polish would leave the default translation missing - the one thing + // the invariant above promises is always there - and every later stage + // would need a "unless it was created in another locale" branch. + if (locale.toLowerCase() !== defaultLocale.toLowerCase()) { + throw new ContentEngineError( + `A ${definition.admin.label.singular} is created in its default locale "${defaultLocale}", not "${locale}". Create it first, then add the "${locale}" translation.`, + { contentTypeId }, + ); + } + + const run = async ( + tx: ContentDatabase, + ): Promise> => { + // Resolved inside the transaction, so a default language that has just + // been removed rolls the base insert back with it rather than leaving an + // untranslatable row behind. + const language = await translations.resolveDefaultLanguage({ tx }); + + const row = await service.create(shared, { tx }); + const created = await translations.create( + row.id, + language.locale, + translation, + { tx }, + ); + + return { row, translation: created }; + }; + + if (options.tx) return await run(options.tx); + + return await c.get("db").transaction(async tx => await run(tx)); + }, + }; +}; diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index 04d8efc3a..418620875 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -1,21 +1,35 @@ import type { PgColumn } from "drizzle-orm/pg-core"; import type { Context } from "hono"; -import type { ContentSchemas } from "../schemas"; -import type { AnyContentTypeDefinition } from "../types"; +import type { ContentSchemas, ContentTranslationSchemas } from "../schemas"; +import type { + AnyContentTypeDefinition, + ResolvedContentLocalizationConfig, +} from "../types"; import type { ContentEditorialService } from "./editorial-service"; +import type { ContentLocalizedService } from "./localized-service"; import type { ContentPublicService } from "./public-service"; import type { ContentService } from "./service"; +import type { ContentTranslationModel } from "./translation-model"; import type { ContentColumnName, ContentReferences, ContentTableFor, + ContentTranslationColumnName, + ContentTranslationTableFor, } from "./types"; +import { ContentEngineError } from "../errors"; import { createContentEditorialService } from "./editorial-service"; +import { createContentLocalizedService } from "./localized-service"; import { createContentPublicService } from "./public-service"; import { createContentService } from "./service"; import { contentTableColumns, createContentTable } from "./table"; +import { createContentTranslationModel } from "./translation-model"; +import { + contentTranslationTableColumns, + createContentTranslationTable, +} from "./translation-table"; export interface ContentModel { /** Column name -> Drizzle column, for filters, ordering and custom queries. */ @@ -35,6 +49,25 @@ export interface ContentModel { options: { pluginId: string }, ) => ContentEditorialService) | undefined; + /** + * The resolved localization config, mirrored off the definition. + * + * Present on every model, so `model.localization.enabled` is the one flag route + * builders and background work branch on - without reaching through + * `definition` for it. + */ + localization: ResolvedContentLocalizationConfig; + /** + * Creates a base row and its default translation in one transaction, or + * `undefined` when the content type is not localized. + * + * `undefined` rather than a throwing stub, matching `publicService` and + * `editorialService`: the check reads naturally in code that does not know + * which content type it was handed, and TypeScript refuses the call until it + * has been made. + */ + localizedService: + ((c: Context) => ContentLocalizedService) | undefined; /** * The read-only public repository, or `undefined` when the content type has * no `publicApi`. @@ -50,6 +83,33 @@ export interface ContentModel { service: (c: Context) => ContentService; /** The generated `pgTable`. Export it so Drizzle Kit can find it. */ table: ContentTableFor; + /** + * Column name -> Drizzle column on the translation table, or `null` when the + * content type is not localized. + */ + translationColumns: null | Record< + ContentTranslationColumnName, + PgColumn + >; + /** The per-language schemas, or `null`. Mirrored off `schemas.translation`. */ + translationSchemas: ContentTranslationSchemas | null; + /** + * The translation repository, or `undefined` when the content type is not + * localized. Emits nothing and invalidates nothing - see + * {@link ContentTranslationModel}. + */ + translationService: + ((c: Context) => ContentTranslationModel) | undefined; + /** + * The generated translation `pgTable`, or `null`. Export it alongside `table` + * so Drizzle Kit finds it and the migration is generated: + * + * ```ts + * export const example_articles = articleContent.table; + * export const example_articles_translations = articleContent.translationTable; + * ``` + */ + translationTable: ContentTranslationTableFor | null; } /** @@ -114,6 +174,42 @@ export const createContentModel = < // anything new. const schemas: ContentSchemas = definition.schemas; + const localized = definition.localization.enabled; + const translationTable = localized + ? createContentTranslationTable(definition, { table }) + : null; + const translationColumns = translationTable + ? contentTranslationTableColumns(definition, translationTable) + : null; + const translationSchemas = schemas.translation; + + /** + * One translation model per call, bound to the request's handle. + * + * Built here rather than inside each service so `localizedService` and + * `translationService` share the same instance for one request - and so the + * "localization is enabled" narrowing happens exactly once. + */ + const buildTranslations = ( + c: Context, + ): ContentTranslationModel => { + if (!translationTable || !translationColumns || !translationSchemas) { + throw new ContentEngineError( + "This content type has no `localization` block, so it has no translations.", + { contentTypeId: definition.id }, + ); + } + + return createContentTranslationModel({ + c, + columns: translationColumns, + definition, + schemas: translationSchemas, + table, + translationTable, + }); + }; + return { columns, definition, @@ -133,6 +229,22 @@ export const createContentModel = < table, }) : undefined, + localization: definition.localization, + localizedService: localized + ? (c: Context) => + createContentLocalizedService({ + c, + definition, + service: createContentService({ + c, + columns, + definition, + schemas, + table, + }), + translations: buildTranslations(c), + }) + : undefined, publicService: definition.publicApi.enabled ? (c: Context) => createContentPublicService({ c, columns, definition, table }) @@ -147,5 +259,9 @@ export const createContentModel = < table, }), table, + translationColumns, + translationSchemas, + translationService: localized ? buildTranslations : undefined, + translationTable, }; }; From b2f311547dc820c55e28ab5b4a408dee96045dcf Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:06:21 +0200 Subject: [PATCH 07/11] feat(content): add the generated translation routes GET /{id}/translations can_view metadata only GET /{id}/translations/{locale} can_view POST /{id}/translations/{locale} can_edit { values } PUT /{id}/translations/{locale} can_edit { expectedVersion, values } DELETE /{id}/translations/{locale} can_delete { expectedVersion } Mounted under the same module as the content type's CRUD routes, so a localized content type gets them without a second registration. Identity is `contentType + itemId + locale` and never the translation row's own key - (itemId, languageId) is the primary key, so there is no surrogate id to leak, and the module the route is mounted in already fixes which table is read. The list route returns metadata without values. A locale strip needs to know which languages exist and how stale each one is; dragging every article body in every language across the wire to find that out is the thing it is designed not to do. Existing permissions on purpose. A dedicated `can_translate` means a migration for every role in every install, and shipping it before the AdminCP has a translation screen to gate would be a checkbox that governs nothing anybody can see. Stage 5B introduces it with the UI it belongs to. `withTranslationHttpErrors` separates seven outcomes - record missing, locale unknown, locale disabled, translation exists, version moved, default translation, slug taken - and the driver's message reaches none of them. Co-Authored-By: Claude Opus 5 (1M context) --- packages/vitnode/src/content/server/index.ts | 49 ++- packages/vitnode/src/content/server/routes.ts | 16 +- .../content/server/translation-http-errors.ts | 133 ++++++++ .../src/content/server/translation-routes.ts | 285 ++++++++++++++++++ 4 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 packages/vitnode/src/content/server/translation-http-errors.ts create mode 100644 packages/vitnode/src/content/server/translation-routes.ts diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index c0b864870..435d19a75 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -12,6 +12,7 @@ export { buildEditorialColumns, buildPublicationColumns, buildSystemColumns, + buildTranslationSystemColumns, } from "./column-builders"; export type { ColumnReferenceThunk } from "./column-builders"; export { contentEditorialEffects } from "./editorial-effects"; @@ -35,6 +36,27 @@ export { withHttpErrors, } from "./http-errors"; export type { ContentHttpErrorOptions } from "./http-errors"; +export { + assertContentLocalizationLanguages, + ensureContentLocalizationLanguages, + findContentLanguage, + findContentLocalizationProblems, + listContentLanguages, + resetContentLocalizationCheck, + resolveContentLanguage, + resolveDefaultContentLanguage, +} from "./language-resolver"; +export type { + ContentLanguage, + ContentLocalizationProblem, +} from "./language-resolver"; +export { createContentLocalizedService } from "./localized-service"; +export type { + ContentLocalizedCreateInput, + ContentLocalizedCreateOptions, + ContentLocalizedCreateResult, + ContentLocalizedService, +} from "./localized-service"; export { createContentModel, findContentModel } from "./model"; export type { AnyContentModel, @@ -138,7 +160,27 @@ export type { } from "./service"; export { createSlugNormalizer } from "./slugs"; export type { ContentSlugNormalizer } from "./slugs"; -export { contentTableColumns, createContentTable } from "./table"; +export { + assertContentReferences, + contentTableColumns, + createContentTable, +} from "./table"; +export { + contentTranslationConflict, + withTranslationHttpErrors, +} from "./translation-http-errors"; +export { createContentTranslationModel } from "./translation-model"; +export type { + ContentTranslationModel, + ContentTranslationOptions, + ContentTranslationUpdateResult, + ContentTranslationWriteOptions, +} from "./translation-model"; +export { buildContentTranslationRoutes } from "./translation-routes"; +export { + contentTranslationTableColumns, + createContentTranslationTable, +} from "./translation-table"; export type { ContentColumnBuilder, ContentColumnBuilders, @@ -149,4 +191,9 @@ export type { ContentSystemColumnBuilders, ContentTable, ContentTableFor, + ContentTranslationColumnBuilders, + ContentTranslationColumnName, + ContentTranslationSystemColumnBuilders, + ContentTranslationTable, + ContentTranslationTableFor, } from "./types"; diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 9359227f6..627a52f83 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -6,6 +6,7 @@ import { HTTPException } from "hono/http-exception"; import type { AnyContentTypeDefinition, ContentFilterInput, + ContentOrderableFieldName, ContentReferenceFieldName, } from "../types"; import type { ContentModel } from "./model"; @@ -40,6 +41,7 @@ import { createContentPreviewToken } from "./preview-token"; import { publicationMethods } from "./publication"; import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; import { syncContentSearch } from "./search-sync"; +import { buildContentTranslationRoutes } from "./translation-routes"; const zodLabels = z.record(z.string(), z.string().nullable()); @@ -259,7 +261,14 @@ export const buildContentRoutes = < const data = await model.service(c).findMany({ filters, - orderBy: { column: orderBy, order }, + // `orderBy` came out of a `z.enum(orderableColumns(definition))`, which + // is the same allowlist `ContentOrderableFieldName` approximates - but + // that type is still deferred here, because `TDefinition` is open. The + // service re-checks the name against the runtime allowlist regardless. + orderBy: { + column: orderBy as ContentOrderableFieldName, + order, + }, query: { cursor, first, last, search }, }); @@ -1154,5 +1163,10 @@ export const buildContentRoutes = < ...(definition.editorial.scheduling.enabled ? [scheduleList, scheduleCreate, scheduleCancel] : []), + // Mounted under the same module and the same permissions, so a localized + // content type gets its translation routes without a second registration. + ...(definition.localization.enabled + ? buildContentTranslationRoutes(model, { pluginId }) + : []), ]; }; diff --git a/packages/vitnode/src/content/server/translation-http-errors.ts b/packages/vitnode/src/content/server/translation-http-errors.ts new file mode 100644 index 000000000..387d63326 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-http-errors.ts @@ -0,0 +1,133 @@ +import { HTTPException } from "hono/http-exception"; +import { ZodError } from "zod"; + +import type { ContentTranslationConflict } from "../conflicts"; + +import { CONTENT_TRANSLATION_CONFLICT_CODES } from "../const"; +import { + ContentDefaultTranslationRequired, + ContentInputError, + ContentLanguageError, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, +} from "../errors"; +import { rethrowAsHttpError } from "./http-errors"; + +/** A structured 409, in the translation union. */ +export const contentTranslationConflict = ( + body: ContentTranslationConflict, +): HTTPException => + new HTTPException(409, { res: Response.json(body, { status: 409 }) }); + +/** + * Maps a translation write's failures onto HTTP. + * + * The five outcomes it separates are the whole point - a client that cannot tell + * them apart can only show "something went wrong": + * + * | Failure | Status | Code | + * | ------------------------------ | ------ | -------------------------------------- | + * | base record missing | 404 | - | + * | locale unknown | 404 | - | + * | locale disabled | 409 | `CONTENT_LANGUAGE_DISABLED` | + * | translation already exists | 409 | `CONTENT_TRANSLATION_EXISTS` | + * | version moved | 409 | `CONTENT_TRANSLATION_VERSION_CONFLICT` | + * | default translation delete | 409 | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | + * | localized slug taken | 409 | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | + * + * Anything it does not recognise falls through to {@link rethrowAsHttpError}, + * which owns the Postgres constraint codes - so the driver's message, which can + * name columns, constraints and values, never reaches a client from here either. + */ +export const withTranslationHttpErrors = async ( + action: "create" | "delete" | "update", + run: () => Promise, + { + contentTypeId, + itemId, + locale, + }: { contentTypeId: string; itemId: number; locale: string }, +): Promise => { + try { + return await run(); + } catch (error) { + if (error instanceof HTTPException) throw error; + + if (error instanceof ContentTranslationVersionConflict) { + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.version, + contentTypeId, + currentVersion: error.currentVersion, + expectedVersion: error.expectedVersion, + itemId: error.itemId, + locale: error.locale, + }); + } + + if (error instanceof ContentDefaultTranslationRequired) { + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.defaultRequired, + contentTypeId, + itemId: error.itemId, + locale: error.locale, + }); + } + + if (error instanceof ContentTranslationExists) { + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.exists, + contentTypeId, + itemId: error.itemId, + locale: error.locale, + }); + } + + if (error instanceof ContentLanguageError) { + // Missing is a 404 and disabled is a 409: one is "no such thing to + // address", the other is "it exists and this install has switched it off", + // and only the second is something an admin can undo. + if (error.reason === "missing") { + throw new HTTPException(404, { message: error.message }); + } + + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.languageDisabled, + contentTypeId, + locale: error.locale, + }); + } + + if (error instanceof ContentTranslationItemMissing) { + throw new HTTPException(404, { message: error.message }); + } + + // Written for the client on purpose, like the base service's: "send the slug + // explicitly" is useless if it never leaves the server. + if (error instanceof ContentInputError) { + throw new HTTPException(400, { message: error.message }); + } + + if (error instanceof ZodError) { + throw new HTTPException(400, { message: "Invalid input data." }); + } + + try { + return rethrowAsHttpError(error, { action, contentTypeId, itemId }); + } catch (mapped) { + // A localized unique clash is a slug that is taken *in this language*, so + // it answers in the translation union with the locale attached rather than + // with the base 409 the shared mapper produces. + if (mapped instanceof HTTPException && mapped.status === 409) { + throw contentTranslationConflict({ + code: CONTENT_TRANSLATION_CONFLICT_CODES.unique, + contentTypeId, + itemId, + locale, + }); + } + + throw mapped; + } + } +}; diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts new file mode 100644 index 000000000..06f1d152b --- /dev/null +++ b/packages/vitnode/src/content/server/translation-routes.ts @@ -0,0 +1,285 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; +import type { ContentTranslationModel } from "./translation-model"; + +import { buildRoute } from "../../api/lib/route"; +import { zodContentTranslationConflict } from "../conflicts"; +import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_PERMISSIONS } from "../const"; +import { withTranslationHttpErrors } from "./translation-http-errors"; + +/** + * The five generated translation routes for one localized content type. + * + * Identity is `(content type, item, locale)` and never the translation row's own + * key: `(itemId, languageId)` is the primary key, there is no surrogate id to + * leak, and a locale in the URL cannot be used to reach another content type's + * translation because the module the route is mounted in already fixes which + * table is being read. + * + * Permissions reuse the ones the content type already has - `can_view` to read, + * `can_edit` to write, `can_delete` to remove. A dedicated `can_translate` is + * Stage 5B work: adding a permission means a migration for every existing role, + * and doing that before the AdminCP has a translation screen to gate would ship a + * checkbox that governs nothing anybody can see. + */ +export const buildContentTranslationRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel, + { pluginId }: { pluginId: P }, +) => { + const { definition } = model; + const schemas = model.translationSchemas; + const module = definition.permissionModule; + const label = definition.admin.label; + + if (!schemas || !model.translationService) { + throw new Error( + `[Content Engine] ${definition.id}: buildContentTranslationRoutes needs a localized content type.`, + ); + } + + const translationSchemas = schemas; + const buildService = model.translationService; + + const translations = (c: Context): ContentTranslationModel => + buildService(c); + + const jsonBody = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, + }); + const jsonResponse = (schema: z.ZodType, description: string) => ({ + content: { "application/json": { schema } }, + description, + }); + + const readJson = async ( + c: Context, + schema: z.ZodType, + ): Promise => schema.parse(await c.req.json()); + + const identifier = (c: Context): number => { + const value = Number(c.req.param("id")); + if (!Number.isInteger(value) || value <= 0) { + throw new HTTPException(400, { message: "Invalid identifier." }); + } + + return value; + }; + + /** + * The locale from the URL, length-checked and nothing more. + * + * Deliberately not pattern-matched: an unknown locale and a malformed one are + * both answered by the resolver with the same 404, so a stricter regex here + * would only move the same outcome earlier. The value is a bound parameter, + * never an identifier. + */ + const locale = (c: Context): string => { + const value = c.req.param("locale") ?? ""; + if (value === "" || value.length > CONTENT_LOCALE_MAX_LENGTH) { + throw new HTTPException(400, { message: "Invalid locale." }); + } + + return value; + }; + + const conflict = jsonResponse( + zodContentTranslationConflict, + "The translation moved, already exists, is the default one, or a localized value is taken", + ); + const invalidIdentifier = { description: "Invalid identifier or locale" }; + const notFound = { + description: `${label.singular}, locale or translation not found`, + }; + + const list = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/translations", + description: `Which languages one ${label.singular} exists in`, + request: { params: model.schemas.params }, + responses: { + 200: jsonResponse( + z.object({ edges: z.array(translationSchemas.selectMeta) }), + "One entry per existing translation, without its values", + ), + 400: invalidIdentifier, + }, + }, + handler: async c => { + // Metadata only. A locale strip needs to know which languages exist and + // how stale each one is; the detail route loads a body when a tab opens. + const edges = await translations(c).findManyForItem(identifier(c)); + + return c.json({ edges }, 200); + }, + }); + + const detail = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/translations/{locale}", + description: `One ${label.singular} translation`, + request: { params: translationSchemas.params }, + responses: { + 200: jsonResponse(translationSchemas.select, "Translation found"), + 400: invalidIdentifier, + 404: notFound, + }, + }, + handler: async c => { + const row = await translations(c).findByLocale(identifier(c), locale(c)); + if (!row) { + throw new HTTPException(404, { message: "Translation not found." }); + } + + return c.json(row, 200); + }, + }); + + const create = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "post", + path: "/{id}/translations/{locale}", + description: `Add a ${label.singular} translation`, + request: { + params: translationSchemas.params, + body: jsonBody(translationSchemas.createEnvelope), + }, + responses: { + 201: jsonResponse(translationSchemas.select, "Translation created"), + 400: { description: "Invalid input data" }, + 404: notFound, + 409: conflict, + }, + }, + handler: async c => { + const id = identifier(c); + const target = locale(c); + const { values } = await readJson(c, translationSchemas.createEnvelope); + + const row = await withTranslationHttpErrors( + "create", + async () => await translations(c).create(id, target, values), + { contentTypeId: definition.id, itemId: id, locale: target }, + ); + + return c.json(row, 201); + }, + }); + + const update = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + // PUT, not PATCH: the Next.js API route handler exports no PATCH. + method: "put", + path: "/{id}/translations/{locale}", + description: `Update a ${label.singular} translation`, + request: { + params: translationSchemas.params, + body: jsonBody(translationSchemas.updateEnvelope), + }, + responses: { + 200: jsonResponse( + z.object({ + /** `false` when nothing moved - the version is unchanged. */ + changed: z.boolean(), + row: translationSchemas.select, + }), + "Translation updated, or already at those values", + ), + 400: { description: "Invalid or empty payload" }, + 404: notFound, + 409: conflict, + }, + }, + handler: async c => { + const id = identifier(c); + const target = locale(c); + const { expectedVersion, values } = await readJson( + c, + translationSchemas.updateEnvelope, + ); + + const result = await withTranslationHttpErrors( + "update", + async () => + await translations(c).update(id, target, values, { + expectedVersion, + }), + { contentTypeId: definition.id, itemId: id, locale: target }, + ); + if (!result) { + throw new HTTPException(404, { message: "Translation not found." }); + } + + return c.json({ changed: result.changed, row: result.row }, 200); + }, + }); + + /** + * The default-locale translation is not deletable, which is why this route + * declares a 409 rather than treating it as a 400: the request is well formed, + * and the reason it is refused is a state the client can read off the content + * type and act on. + * + * The body carries `expectedVersion` for the same reason the editorial delete + * does: a delete is the widest possible overwrite, and a confirmation dialog + * cannot ask about a change the person has not seen. + */ + const remove = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.delete }, + route: { + method: "delete", + path: "/{id}/translations/{locale}", + description: `Delete a ${label.singular} translation`, + request: { + params: translationSchemas.params, + body: jsonBody(translationSchemas.versionEnvelope), + }, + responses: { + 200: jsonResponse(translationSchemas.select, "Translation deleted"), + 400: invalidIdentifier, + 404: notFound, + 409: conflict, + }, + }, + handler: async c => { + const id = identifier(c); + const target = locale(c); + const { expectedVersion } = await readJson( + c, + translationSchemas.versionEnvelope, + ); + + const row = await withTranslationHttpErrors( + "delete", + async () => + await translations(c).delete(id, target, { expectedVersion }), + { contentTypeId: definition.id, itemId: id, locale: target }, + ); + if (!row) { + throw new HTTPException(404, { message: "Translation not found." }); + } + + return c.json(row, 200); + }, + }); + + return [list, detail, create, update, remove]; +}; From 9938532805dbcd97342c57cac7194904eef3d4c0 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:06:38 +0200 Subject: [PATCH 08/11] test(content): cover localization at the type, unit and route level 191 new assertions across seven files: localization.test.ts the partition, resolved defaults, every validation rule, the Stage 5A boundaries, and Stage 1-4 fixtures proving nothing moved localization.test-d.ts the literal `enabled` flag, the localized/shared name unions, the value types, and six @ts-expect- errors for the field kinds and admin surfaces that must refuse a localized field translation-schemas.test.ts strictness both ways, requiredness, nullability, and every metadata key rejected as a content value translation-table.test.ts localized fields absent from the base table, shared fields absent from the translation table, both foreign keys, the composite key, the locale-scoped slug index, and identifier lengths language-resolver.test.ts canonical casing, missing vs disabled, one query per request, and the boot guard - including that it does not touch the languages table with nothing localized translation-model.test.ts version 1 on create, +1 on a real update, unchanged on a no-op, independent locales, stale update and delete, and the default-translation refusal localized-service.test.ts both inserts in one transaction, and a rollback for each way the second one can fail translation-routes.test.ts all five routes, their permissions, every structured 409, and the driver's message not leaking Two localized fixtures, added rather than flags on existing ones: leaving every Stage 1-4 fixture exactly as it was is what proves localization changes nothing for them. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/content/localization.test-d.ts | 314 ++++++++++ .../vitnode/src/content/localization.test.ts | 393 ++++++++++++ .../content/server/language-resolver.test.ts | 362 +++++++++++ .../content/server/localized-service.test.ts | 348 +++++++++++ .../content/server/translation-model.test.ts | 564 +++++++++++++++++ .../content/server/translation-routes.test.ts | 577 ++++++++++++++++++ .../content/server/translation-table.test.ts | 289 +++++++++ .../src/content/translation-schemas.test.ts | 207 +++++++ .../vitnode/src/tests/content-fixtures.ts | 56 ++ 9 files changed, 3110 insertions(+) create mode 100644 packages/vitnode/src/content/localization.test-d.ts create mode 100644 packages/vitnode/src/content/localization.test.ts create mode 100644 packages/vitnode/src/content/server/language-resolver.test.ts create mode 100644 packages/vitnode/src/content/server/localized-service.test.ts create mode 100644 packages/vitnode/src/content/server/translation-model.test.ts create mode 100644 packages/vitnode/src/content/server/translation-routes.test.ts create mode 100644 packages/vitnode/src/content/server/translation-table.test.ts create mode 100644 packages/vitnode/src/content/translation-schemas.test.ts diff --git a/packages/vitnode/src/content/localization.test-d.ts b/packages/vitnode/src/content/localization.test-d.ts new file mode 100644 index 000000000..875e1c56a --- /dev/null +++ b/packages/vitnode/src/content/localization.test-d.ts @@ -0,0 +1,314 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testCategoryContentType, + testEditorialNoteContentType, + testEditorialPostContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, + testPostContentType, + testSearchablePostContentType, +} from "@/tests/content-fixtures"; + +import type { + AnyContentTypeDefinition, + ContentCreateInput, + ContentLocalizedFieldName, + ContentLocalizedUpdateValues, + ContentLocalizedValues, + ContentSelect, + ContentSharedFieldName, + ContentSharedValues, + ContentUpdateInput, + LocalizedContentTypeDefinition, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +type Localized = typeof testLocalizedArticleContentType; +type LocalizedNote = typeof testLocalizedNoteContentType; +type Article = typeof testArticleContentType; +type Post = typeof testPostContentType; + +describe("localization", () => { + // A tenth type parameter on `ContentTypeDefinition`, and this is what says it + // costs nothing: the erased form every relation thunk, registry and route + // builder is written against still accepts every concrete definition. + describe("assignability to AnyContentTypeDefinition", () => { + it("holds for a localized content type", () => { + expectTypeOf().toExtend(); + assertType(testLocalizedArticleContentType); + assertType(testLocalizedNoteContentType); + }); + + it("still holds for every Stage 1-4 fixture", () => { + assertType(testCategoryContentType); + assertType(testArticleContentType); + assertType(testPostContentType); + assertType(testSearchablePostContentType); + assertType(testEditorialPostContentType); + assertType(testEditorialNoteContentType); + }); + }); + + describe("the flag stays literal", () => { + it("is `true` when opted in", () => { + expectTypeOf( + testLocalizedArticleContentType.localization.enabled, + ).toEqualTypeOf(); + }); + + it("is `false` when omitted", () => { + expectTypeOf( + testArticleContentType.localization.enabled, + ).toEqualTypeOf(); + }); + + it("is `false` when written out explicitly", () => { + const explicit = defineContentType({ + id: "test.explicit", + tableName: "test_explicits", + localization: { enabled: false }, + fields: { title: field.text({ required: true }) }, + admin: { label: { plural: "Explicits", singular: "Explicit" } }, + }); + + expectTypeOf(explicit.localization.enabled).toEqualTypeOf(); + }); + + // `enabled: true` and `enabled: false` staying distinguishable is what lets + // Stage 5B-5D expose translation services, routes and AdminCP tabs + // conditionally instead of at runtime. + it("separates the two through LocalizedContentTypeDefinition", () => { + expectTypeOf().toExtend(); + expectTypeOf
().not.toExtend(); + expectTypeOf().not.toExtend(); + }); + }); + + describe("field-level `localized`", () => { + it("keeps the literal on a text field", () => { + expectTypeOf( + testLocalizedArticleContentType.fields.title.localized, + ).toEqualTypeOf(); + }); + + it("keeps the literal on a textarea field", () => { + expectTypeOf( + testLocalizedArticleContentType.fields.body.localized, + ).toEqualTypeOf(); + }); + + it("keeps the literal on a slug field", () => { + expectTypeOf( + testLocalizedArticleContentType.fields.slug.localized, + ).toEqualTypeOf(); + }); + + it("defaults to `false` rather than widening to boolean", () => { + // A localizable field always carries the literal, so `localized: false` + // and `localized: true` stay distinguishable. `?? false` alone would widen + // it back to `boolean` and every partition would resolve to "shared". + expectTypeOf( + testArticleContentType.fields.title.localized, + ).toEqualTypeOf(); + expectTypeOf( + testArticleContentType.fields.excerpt.localized, + ).toEqualTypeOf(); + }); + + it("leaves the flag off a kind that cannot carry one", () => { + // Inherited from `ContentFieldShared` and never written, so it is + // `boolean | undefined` - which does not extend `true`, which is what puts + // the field in the shared half. + expectTypeOf( + testLocalizedArticleContentType.fields.featured.localized, + ).toEqualTypeOf(); + }); + + it("refuses `localized` on the kinds that cannot hold a translation", () => { + // @ts-expect-error - a per-locale `true` is not a translation. + field.boolean({ localized: true }); + // @ts-expect-error - a number means the same thing in every language. + field.number({ integer: true, localized: true }); + // @ts-expect-error - a date is an instant, not prose. + field.dateTime({ localized: true }); + // @ts-expect-error - enum identifiers have to match across locales. + field.enum({ localized: true, values: ["a", "b"] }); + // @ts-expect-error - a user is a foreign key. + field.user({ localized: true }); + field.relation({ + // @ts-expect-error - per-locale relations are out of scope. + localized: true, + target: () => testCategoryContentType, + }); + }); + }); + + describe("field-name partitions", () => { + it("names the localized fields", () => { + expectTypeOf>().toEqualTypeOf< + "body" | "slug" | "title" + >(); + }); + + it("names the shared fields", () => { + expectTypeOf>().toEqualTypeOf< + "featured" | "views" + >(); + }); + + it("has no localized names for a non-localized content type", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + | "author" + | "category" + | "excerpt" + | "featured" + | "publishedAt" + | "status" + | "title" + | "views" + >(); + }); + }); + + describe("shared values", () => { + it("carry the base-table fields and nothing else", () => { + expectTypeOf>().toEqualTypeOf<{ + featured?: boolean; + views?: number; + }>(); + }); + + it("are what `create` accepts", () => { + expectTypeOf>().toEqualTypeOf< + ContentSharedValues + >(); + }); + + it("are what a base row comes back as", () => { + expectTypeOf>().toEqualTypeOf<{ + createdAt: Date; + featured: boolean; + id: number; + updatedAt: Date; + views: number; + }>(); + }); + + it("leave a non-localized content type's create input alone", () => { + // Every field of a Stage 1 content type is shared, so nothing moved. + expectTypeOf>().toEqualTypeOf<{ + author?: null | number; + category: number; + excerpt?: null | string; + featured?: boolean; + publishedAt?: null | string; + status?: "archived" | "draft" | "published"; + title: string; + views?: number; + }>(); + }); + }); + + describe("localized values", () => { + it("preserve requiredness, nullability and derived slugs", () => { + expectTypeOf>().toEqualTypeOf<{ + body?: null | string; + // Sourced from the title, so it is derivable and therefore optional. + slug?: string; + title: string; + }>(); + }); + + it("require a sourceless slug", () => { + expectTypeOf>().toEqualTypeOf<{ + heading: string; + slug: string; + }>(); + }); + + it("make every key optional on update", () => { + expectTypeOf>().toEqualTypeOf<{ + body?: null | string; + slug?: string; + title?: string; + }>(); + }); + + // The one thing that makes `translation:` impossible to fill in by accident + // on a Stage 1-4 definition. + it("are empty for a non-localized content type", () => { + expectTypeOf< + keyof ContentLocalizedValues
+ >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("keep localized fields out of the base update input", () => { + expectTypeOf>().toEqualTypeOf< + "featured" | "views" + >(); + }); + }); + + describe("admin config addresses shared columns only", () => { + it("rejects a localized field as a list column", () => { + defineContentType({ + id: "test.badcolumn", + tableName: "test_bad_columns", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + featured: field.boolean({ defaultValue: false }), + }, + admin: { + label: { plural: "Bad", singular: "Bad" }, + // @ts-expect-error - `title` is not a column on the base table. + list: { columns: ["title"] }, + }, + }); + }); + + it("rejects a localized field as the title field", () => { + defineContentType({ + id: "test.badtitle", + tableName: "test_bad_titles", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + featured: field.boolean({ defaultValue: false }), + }, + admin: { + label: { plural: "Bad", singular: "Bad" }, + // @ts-expect-error - a localized title has a value per language. + titleField: "title", + }, + }); + }); + + it("rejects a localized field in an index", () => { + defineContentType({ + id: "test.badindex", + tableName: "test_bad_indexes", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + featured: field.boolean({ defaultValue: false }), + }, + // @ts-expect-error - the base table has no `title` column to index. + indexes: [{ on: ["title"] }], + admin: { label: { plural: "Bad", singular: "Bad" } }, + }); + }); + + it("still accepts a shared field everywhere", () => { + expectTypeOf( + testLocalizedArticleContentType.admin.list.columns, + ).toEqualTypeOf(); + }); + }); +}); diff --git a/packages/vitnode/src/content/localization.test.ts b/packages/vitnode/src/content/localization.test.ts new file mode 100644 index 000000000..29c01c362 --- /dev/null +++ b/packages/vitnode/src/content/localization.test.ts @@ -0,0 +1,393 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; +import { + contentTranslationTableName, + isLocalizedContentField, + partitionContentFields, +} from "./localization"; + +/** Builds a localized content type with one thing swapped out. */ +const localized = ( + overrides: Parameters[0] extends never + ? never + : Record, +) => + defineContentType({ + id: "test.subject", + tableName: "test_subjects", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + }, + admin: { label: { plural: "Subjects", singular: "Subject" } }, + ...overrides, + } as never); + +describe("partitionContentFields", () => { + it("splits the field map by the `localized` flag", () => { + const { localizedFields, sharedFields } = partitionContentFields( + testLocalizedArticleContentType.fields, + ); + + expect(Object.keys(localizedFields)).toEqual(["title", "slug", "body"]); + expect(Object.keys(sharedFields)).toEqual(["featured", "views"]); + }); + + it("preserves declaration order in both halves", () => { + const { localizedFields } = partitionContentFields( + testLocalizedArticleContentType.fields, + ); + + // The generated column order, the schema key order and the migration all + // come off this, so it has to be stable rather than merely correct. + expect(Object.keys(localizedFields)).toEqual(["title", "slug", "body"]); + }); + + it("treats every field of a non-localized content type as shared", () => { + const { localizedFields, sharedFields } = partitionContentFields( + testArticleContentType.fields, + ); + + expect(localizedFields).toEqual({}); + expect(Object.keys(sharedFields)).toEqual( + Object.keys(testArticleContentType.fields), + ); + }); + + it("reads the same flag `isLocalizedContentField` does", () => { + const { body, featured } = testLocalizedArticleContentType.fields; + + expect(isLocalizedContentField(body)).toBe(true); + expect(isLocalizedContentField(featured)).toBe(false); + }); +}); + +describe("contentTranslationTableName", () => { + it("suffixes the base table name", () => { + expect(contentTranslationTableName("example_articles")).toBe( + "example_articles_translations", + ); + }); + + it("stays inside the Postgres identifier limit", () => { + // 63 characters is where Postgres truncates silently, and two long names + // that differ only past that point would collapse into one index. + const long = `a_${"very_long_table_name".repeat(4)}`; + const name = contentTranslationTableName(long); + + expect(name.length).toBeLessThanOrEqual(63); + expect(name).not.toBe(contentTranslationTableName(`${long}_other`)); + }); +}); + +describe("resolved localization defaults", () => { + it("is disabled for a content type that omits the block", () => { + expect(testArticleContentType.localization).toEqual({ + defaultLocale: "", + enabled: false, + fallback: "none", + translationIndexes: [], + translationTableName: "", + }); + }); + + it("defaults `fallback` to none, the only safe answer in Stage 5A", () => { + expect(testLocalizedNoteContentType.localization.fallback).toBe("none"); + }); + + it("keeps the configured default locale verbatim, casing included", () => { + // `core_languages.code` is the canonical form and the resolver matches + // case-insensitively, so the definition is not the place to normalise. + expect(testLocalizedNoteContentType.localization.defaultLocale).toBe("EN"); + }); + + it("derives the translation table name and its indexes", () => { + const { translationIndexes, translationTableName } = + testLocalizedArticleContentType.localization; + + expect(translationTableName).toBe("test_localized_articles_translations"); + expect(translationIndexes).toEqual([ + { + name: "test_localized_articles_translations_language_id_idx", + on: ["languageId"], + unique: false, + }, + { + name: "test_localized_articles_translations_language_id_slug_key", + on: ["languageId", "slug"], + unique: true, + }, + ]); + }); + + it("keeps localized fields out of the base indexes", () => { + const columns = testLocalizedArticleContentType.indexes.flatMap( + index => index.on, + ); + + expect(columns).not.toContain("slug"); + expect(columns).not.toContain("title"); + }); + + it("defaults the admin surfaces to the shared fields only", () => { + const { admin } = testLocalizedNoteContentType; + + expect(admin.form.fields).toEqual(["pinned"]); + expect(admin.list.columns).toEqual(["pinned", "updatedAt"]); + expect(admin.list.searchableFields).toEqual([]); + // The only text field is localized, so there is no shared title to fall + // back to - and inventing one would make a toast depend on the reader's + // locale. + expect(admin.titleField).toBeNull(); + }); +}); + +describe("localization validation", () => { + it("rejects `localized: true` without a localization block", () => { + expect(() => + defineContentType({ + id: "test.stray", + tableName: "test_strays", + fields: { title: field.text({ localized: true, required: true }) }, + admin: { label: { plural: "Strays", singular: "Stray" } }, + }), + ).toThrow(/no `localization: \{ enabled: true, defaultLocale \}` block/); + }); + + it("rejects localization with no localized field", () => { + expect(() => + defineContentType({ + id: "test.empty", + tableName: "test_empties", + localization: { enabled: true, defaultLocale: "en" }, + fields: { featured: field.boolean({ defaultValue: false }) }, + admin: { label: { plural: "Empties", singular: "Empty" } }, + }), + ).toThrow(/no field is marked `localized: true`/); + }); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["whitespace only", " "], + ])("rejects a %s default locale", (_label, defaultLocale) => { + expect(() => + localized({ localization: { defaultLocale, enabled: true } }), + ).toThrow(/localization.defaultLocale is required/); + }); + + it("rejects a padded default locale rather than trimming it", () => { + expect(() => + localized({ localization: { defaultLocale: " en ", enabled: true } }), + ).toThrow(/leading or trailing whitespace/); + }); + + it("rejects a default locale that is not shaped like one", () => { + expect(() => + localized({ localization: { defaultLocale: "en_US!", enabled: true } }), + ).toThrow(/does not look like a locale code/); + }); + + it("rejects a default locale wider than core_languages.code", () => { + expect(() => + localized({ + localization: { defaultLocale: "en-".repeat(20), enabled: true }, + }), + ).toThrow(/longer than 32 characters/); + }); + + it.each(["pt-BR", "zh-Hans", "en"])("accepts the locale %s", locale => { + expect( + localized({ localization: { defaultLocale: locale, enabled: true } }) + .localization.defaultLocale, + ).toBe(locale); + }); + + it.each([ + ["boolean", field.boolean({ defaultValue: false })], + ["number", field.number({ integer: true, defaultValue: 0 })], + ["dateTime", field.dateTime({ nullable: true })], + ["enum", field.enum({ values: ["a", "b"], defaultValue: "a" })], + ["user", field.user()], + ])("rejects a localized %s field at runtime", (_kind, fieldValue) => { + // The builders do not accept `localized`, so this is only reachable from + // JavaScript or through a cast - which is exactly why the check exists. + expect(() => + defineContentType({ + id: "test.badkind", + tableName: "test_bad_kinds", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + other: { ...fieldValue, localized: true } as never, + }, + admin: { label: { plural: "Bad", singular: "Bad" } }, + }), + ).toThrow(/Only slug, text, textarea fields can be localized/); + }); + + it("rejects a localized field named after a translation column", () => { + expect(() => + defineContentType({ + id: "test.collide", + tableName: "test_collides", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + itemId: field.text({ localized: true, required: true }), + }, + admin: { label: { plural: "Collides", singular: "Collide" } }, + }), + ).toThrow(/collides with a generated translation column/); + }); + + it("rejects a localized slug sourced from a shared field", () => { + expect(() => + defineContentType({ + id: "test.sharedsource", + tableName: "test_shared_sources", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + name: field.text({ required: true }), + heading: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true, source: "name" }), + }, + admin: { label: { plural: "Shared", singular: "Shared" } }, + }), + ).toThrow(/Every language would derive the same URL/); + }); + + it("rejects a shared slug sourced from a localized field", () => { + expect(() => + defineContentType({ + id: "test.localizedsource", + tableName: "test_localized_sources", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + slug: field.slug({ source: "title" }), + }, + admin: { label: { plural: "Localized", singular: "Localized" } }, + }), + ).toThrow(/there is no single value to derive from/); + }); + + it("rejects a localized slug whose source does not exist", () => { + // The shared slug-source check runs first and is the one that fires. + expect(() => + defineContentType({ + id: "test.nosource", + tableName: "test_no_sources", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true, source: "headline" }), + }, + admin: { label: { plural: "None", singular: "None" } }, + }), + ).toThrow(/which is not a field on this content type/); + }); + + it("rejects a localized field named in the admin list", () => { + expect(() => + localized({ + admin: { + label: { plural: "Subjects", singular: "Subject" }, + list: { columns: ["title"] }, + }, + }), + ).toThrow(/admin.list.columns names the localized field "title"/); + }); + + it("rejects a localized field named in an index", () => { + expect(() => localized({ indexes: [{ on: ["title"] }] })).toThrow( + /indexes names the localized field "title"/, + ); + }); +}); + +describe("Stage 5A capability boundaries", () => { + const withCapability = (extra: Record) => + defineContentType({ + id: "test.boundary", + tableName: "test_boundaries", + localization: { enabled: true, defaultLocale: "en" }, + fields: { + title: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true, source: "title" }), + }, + admin: { label: { plural: "Boundaries", singular: "Boundary" } }, + ...extra, + } as never); + + it("refuses localization plus publication until Stage 5B", () => { + expect(() => withCapability({ publication: { enabled: true } })).toThrow( + /per-locale publication lands in Stage 5B/, + ); + }); + + it("refuses localization plus editorial until Stage 5B", () => { + expect(() => withCapability({ editorial: { enabled: true } })).toThrow( + /Per-locale revisions land in Stage 5B/, + ); + }); + + it("refuses localization plus publicApi until Stage 5C", () => { + expect(() => + withCapability({ + publication: { enabled: false }, + publicApi: { enabled: true, fields: ["slug"], path: "boundaries" }, + }), + ).toThrow(); + }); + + it("names the stage in every boundary message", () => { + // "Not yet" is only useful when it says how long. + let message = ""; + try { + withCapability({ publication: { enabled: true } }); + } catch (error) { + message = error instanceof Error ? error.message : ""; + } + + expect(message).toMatch(/Stage 5B/); + }); +}); + +describe("backward compatibility", () => { + it("leaves a Stage 1 content type's generated shape untouched", () => { + expect(testArticleContentType.localization.enabled).toBe(false); + expect(testArticleContentType.schemas.translation).toBeNull(); + expect(Object.keys(testArticleContentType.fields)).toContain("title"); + }); + + it("leaves a Stage 2 content type's admin defaults untouched", () => { + expect(testPostContentType.admin.list.searchableFields).toEqual([ + "title", + "excerpt", + ]); + expect(testPostContentType.admin.titleField).toBe("title"); + }); + + it("keeps every declared field in `definition.fields`", () => { + // The partition is derived, never destructive: the definition still + // describes the whole content type, which is what the AdminCP, the docs and + // a future migration generator all read. + expect(Object.keys(testLocalizedArticleContentType.fields)).toEqual([ + "title", + "slug", + "body", + "featured", + "views", + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/language-resolver.test.ts b/packages/vitnode/src/content/server/language-resolver.test.ts new file mode 100644 index 000000000..7621290fd --- /dev/null +++ b/packages/vitnode/src/content/server/language-resolver.test.ts @@ -0,0 +1,362 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError, ContentLanguageError } from "../errors"; +import { + assertContentLocalizationLanguages, + ensureContentLocalizationLanguages, + findContentLanguage, + findContentLocalizationProblems, + listContentLanguages, + resetContentLocalizationCheck, + resolveContentLanguage, + resolveDefaultContentLanguage, +} from "./language-resolver"; + +interface LanguageRow { + code: string; + id: number; + isDefault: boolean; +} + +const DEFAULT_ROWS: LanguageRow[] = [ + { code: "en", id: 1, isDefault: true }, + { code: "pl", id: 2, isDefault: false }, + { code: "pt-BR", id: 3, isDefault: false }, +]; + +/** + * A context whose `select().from()` returns the language rows and counts how + * many times it was asked - which is the whole point of the per-request cache. + */ +const createContext = ({ + locales, + rows = DEFAULT_ROWS, +}: { + locales?: { code: string; enabled?: boolean; name: string }[]; + rows?: LanguageRow[]; +} = {}) => { + let queries = 0; + + const db = { + select: () => ({ + // A plain array, not a promise: `await` resolves it just the same, and it + // keeps the mock out of the require-await / promise-function-async + // crossfire. + from: () => { + queries += 1; + + return rows; + }, + }), + }; + + const c = { + get: (key: string) => { + if (key === "db") return db; + if (key === "core") { + return locales ? { i18n: { locales } } : undefined; + } + + return undefined; + }, + } as unknown as Context; + + return { c, queries: () => queries }; +}; + +beforeEach(() => { + resetContentLocalizationCheck(); +}); + +describe("listContentLanguages", () => { + it("returns every language with its id and canonical locale", async () => { + const { c } = createContext(); + + expect(await listContentLanguages(c)).toEqual([ + { id: 1, isDefault: true, isEnabled: true, locale: "en" }, + { id: 2, isDefault: false, isEnabled: true, locale: "pl" }, + { id: 3, isDefault: false, isEnabled: true, locale: "pt-BR" }, + ]); + }); + + it("queries once per request however often it is asked", async () => { + const { c, queries } = createContext(); + + await listContentLanguages(c); + await findContentLanguage(c, "pl"); + await resolveContentLanguage(c, { locale: "en" }); + + // The alternative - `WHERE code = $1` per lookup - is one query per locale, + // which is exactly the N+1 a list of translations would turn into. + expect(queries()).toBe(1); + }); + + it("does not cache a failed load", async () => { + let attempts = 0; + const db = { + select: () => ({ + from: () => { + attempts += 1; + // Thrown synchronously inside the resolver's `await`, which rejects + // its promise exactly as a driver failure would. + if (attempts === 1) throw new Error("connection reset"); + + return DEFAULT_ROWS; + }, + }), + }; + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as unknown as Context; + + await expect(listContentLanguages(c)).rejects.toThrow("connection reset"); + // A blip must not poison the rest of the request. + expect(await listContentLanguages(c)).toHaveLength(3); + }); + + it("marks a locale the app config disables", async () => { + const { c } = createContext({ + locales: [ + { code: "en", name: "English" }, + { code: "pl", enabled: false, name: "Polski" }, + ], + }); + + const languages = await listContentLanguages(c); + + expect(languages.map(language => language.isEnabled)).toEqual([ + true, + false, + // Not mentioned by the config at all, which is not the same as switched + // off: dropping a locale from `i18n.locales` must not make existing + // content unwritable. + true, + ]); + }); +}); + +describe("findContentLanguage", () => { + it("matches case-insensitively and returns the canonical locale", async () => { + const { c } = createContext(); + + // A locale travels in a URL, and `/PL/` naming the same language as `/pl/` + // is what people expect - but what gets stored is the row's own code. + expect((await findContentLanguage(c, "PL"))?.locale).toBe("pl"); + expect((await findContentLanguage(c, "pt-br"))?.locale).toBe("pt-BR"); + expect((await findContentLanguage(c, " en "))?.locale).toBe("en"); + }); + + it("returns null for an unknown or empty locale", async () => { + const { c } = createContext(); + + expect(await findContentLanguage(c, "de")).toBeNull(); + expect(await findContentLanguage(c, "")).toBeNull(); + expect(await findContentLanguage(c, " ")).toBeNull(); + }); +}); + +describe("resolveContentLanguage", () => { + it("resolves an existing language", async () => { + const { c } = createContext(); + + expect(await resolveContentLanguage(c, { locale: "pl" })).toEqual({ + id: 2, + isDefault: false, + isEnabled: true, + locale: "pl", + }); + }); + + it("throws `missing` for an unknown locale", async () => { + const { c } = createContext(); + + await expect( + resolveContentLanguage(c, { locale: "de" }), + ).rejects.toMatchObject({ reason: "missing" }); + }); + + it("reads a disabled language but refuses to write one", async () => { + const { c } = createContext({ + locales: [{ code: "pl", enabled: false, name: "Polski" }], + }); + + // Reading is fine: the content is already there, and hiding it would make it + // unrecoverable. + expect((await resolveContentLanguage(c, { locale: "pl" })).id).toBe(2); + + await expect( + resolveContentLanguage(c, { locale: "pl", requireEnabled: true }), + ).rejects.toMatchObject({ reason: "disabled" }); + }); + + it("distinguishes missing from disabled in its message", async () => { + const { c } = createContext({ + locales: [{ code: "pl", enabled: false, name: "Polski" }], + }); + + await expect(resolveContentLanguage(c, { locale: "de" })).rejects.toThrow( + /Unknown locale "de"/, + ); + await expect( + resolveContentLanguage(c, { locale: "pl", requireEnabled: true }), + ).rejects.toThrow(/is disabled on this installation/); + }); + + it("names the content type in the error when it knows one", async () => { + const { c } = createContext(); + + await expect( + resolveContentLanguage(c, { + contentTypeId: "test.localized", + locale: "de", + }), + ).rejects.toThrow(/test\.localized/); + }); +}); + +describe("resolveDefaultContentLanguage", () => { + it("resolves the configured default locale", async () => { + const { c } = createContext(); + + expect( + await resolveDefaultContentLanguage(c, testLocalizedArticleContentType), + ).toMatchObject({ id: 1, locale: "en" }); + }); + + it("matches the default locale case-insensitively too", async () => { + const { c } = createContext(); + + // The note fixture configures `EN`; the canonical code is `en`. + expect( + await resolveDefaultContentLanguage(c, testLocalizedNoteContentType), + ).toMatchObject({ id: 1, locale: "en" }); + }); + + it("refuses a content type without localization", async () => { + const { c } = createContext(); + + await expect( + resolveDefaultContentLanguage(c, testArticleContentType), + ).rejects.toBeInstanceOf(ContentEngineError); + }); + + it("throws when the default language is gone", async () => { + const { c } = createContext({ + rows: [{ code: "pl", id: 2, isDefault: true }], + }); + + await expect( + resolveDefaultContentLanguage(c, testLocalizedArticleContentType), + ).rejects.toBeInstanceOf(ContentLanguageError); + }); +}); + +describe("the boot guard", () => { + const entries = [ + { + definition: testLocalizedArticleContentType, + pluginId: "@vitnode/example", + }, + { definition: testArticleContentType, pluginId: "@vitnode/example" }, + ]; + + it("passes when every default locale resolves", async () => { + const { c } = createContext(); + + await expect( + assertContentLocalizationLanguages(c, entries), + ).resolves.toBeUndefined(); + }); + + it("never touches the languages table with nothing localized", async () => { + const { c, queries } = createContext(); + + await assertContentLocalizationLanguages(c, [ + { definition: testArticleContentType, pluginId: "@vitnode/example" }, + ]); + + // An install that defines no localized content type must not pay for this. + expect(queries()).toBe(0); + }); + + it("reports a missing default language", async () => { + const { c } = createContext({ + rows: [{ code: "pl", id: 2, isDefault: true }], + }); + + expect(await findContentLocalizationProblems(c, entries)).toEqual([ + { + contentTypeId: "test.localized", + defaultLocale: "en", + reason: "missing", + }, + ]); + }); + + it("reports a disabled default language", async () => { + const { c } = createContext({ + locales: [{ code: "en", enabled: false, name: "English" }], + }); + + expect(await findContentLocalizationProblems(c, entries)).toEqual([ + { + contentTypeId: "test.localized", + defaultLocale: "en", + reason: "disabled", + }, + ]); + }); + + it("names every offender in one error rather than failing on the first", async () => { + const { c } = createContext({ + rows: [{ code: "de", id: 9, isDefault: true }], + }); + + await expect( + assertContentLocalizationLanguages(c, [ + ...entries, + { + definition: testLocalizedNoteContentType, + pluginId: "@vitnode/example", + }, + ]), + ).rejects.toThrow(/test\.localized[\s\S]*test\.localized-note/); + }); + + it("runs at most once per process", async () => { + const { c, queries } = createContext(); + + await ensureContentLocalizationLanguages(c, entries); + await ensureContentLocalizationLanguages(c, entries); + + // The definitions cannot change while the process runs, so re-checking every + // request would be pure cost. One query, for the one check. + expect(queries()).toBe(1); + }); + + it("does not memoise a failure", async () => { + const broken = createContext({ + rows: [{ code: "pl", id: 2, isDefault: true }], + }); + + await expect( + ensureContentLocalizationLanguages(broken.c, entries), + ).rejects.toThrow(); + + const healthy = createContext(); + // A database that was not up yet gets checked again rather than poisoning + // the process. + await expect( + ensureContentLocalizationLanguages(healthy.c, entries), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/content/server/localized-service.test.ts b/packages/vitnode/src/content/server/localized-service.test.ts new file mode 100644 index 000000000..1c65d17a6 --- /dev/null +++ b/packages/vitnode/src/content/server/localized-service.test.ts @@ -0,0 +1,348 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import { + testLocalizedArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError, ContentLanguageError } from "../errors"; +import { createContentModel } from "./model"; + +const localized = createContentModel(testLocalizedArticleContentType); + +/** + * The localized service, narrowed once. + * + * `localizedService` is `undefined` for a content type without localization, so + * TypeScript refuses the call until the check has been made - which is the point, + * and is why every test goes through this instead of asserting past it. + */ +const localizedService = (c: Context) => { + const build = localized.localizedService; + if (!build) throw new Error("Expected a localized service."); + + return build(c); +}; + +const LANGUAGES = [ + { code: "en", id: 1, isDefault: true }, + { code: "pl", id: 2, isDefault: false }, +]; + +interface RecordedCall { + arg: unknown; + op: string; +} + +/** + * A Drizzle stand-in whose `transaction` behaves like the real one in the only + * respect a unit test can observe: the callback's rejection propagates, and + * `rolledBack` records that it did. + * + * Whether Postgres actually discards the base row is a property of Postgres, and + * `plugins/example/src/database/postgres.test.ts` asserts it against a real + * database rather than against a mock that could only ever agree with itself. + */ +const createDbMock = ( + results: unknown[][], + { + failInsert, + languages = LANGUAGES, + }: { + /** 1-based insert whose `returning()` throws, standing in for a driver error. */ + failInsert?: number; + languages?: typeof LANGUAGES; + } = {}, +) => { + const calls: RecordedCall[] = []; + const queue = [...results]; + const state = { committed: false, entered: false, rolledBack: false }; + let inserts = 0; + const chain = (rows: unknown[], failReturning = false) => { + const record = (op: string, arg: unknown) => { + calls.push({ arg, op }); + + return builder; + }; + + const builder = { + // Always the builder, which is itself thenable: `await select().from()` + // (how the language registry is read) resolves through `then` below, and a + // longer chain keeps building. + from: (value: unknown) => record("from", value), + limit: (value: unknown) => record("limit", value), + onConflictDoNothing: (value: unknown) => + record("onConflictDoNothing", value), + orderBy: (value: unknown) => record("orderBy", value), + returning: (value: unknown) => { + if (failReturning) { + throw Object.assign(new Error("duplicate key value"), { + code: "23505", + }); + } + + return record("returning", value); + }, + set: (value: unknown) => record("set", value), + then: async (resolve: (rows: unknown[]) => TResult) => + Promise.resolve(rows).then(resolve), + values: (value: unknown) => record("values", value), + where: (value: unknown) => record("where", value), + }; + + return builder; + }; + + const start = (op: string) => (arg: unknown) => { + calls.push({ arg, op }); + // The language registry is the one `select` whose projection names `code` + // and `isDefault`, so it is answered from `languages` rather than from the + // queue - which keeps every test queueing only the rows it cares about. + const isLanguageSelect = + op === "select" && + typeof arg === "object" && + arg !== null && + "code" in arg && + "isDefault" in arg; + + if (op === "insert") inserts += 1; + + return chain( + isLanguageSelect ? languages : (queue.shift() ?? []), + op === "insert" && inserts === failInsert, + ); + }; + + const db = { + delete: start("delete"), + insert: start("insert"), + select: start("select"), + transaction: async ( + body: (tx: unknown) => Promise, + ): Promise => { + state.entered = true; + try { + const result = await body(db); + state.committed = true; + + return result; + } catch (error) { + state.rolledBack = true; + throw error; + } + }, + update: start("update"), + }; + + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as unknown as Context; + + return { c, calls, state }; +}; + +const opsOf = (calls: RecordedCall[], op: string) => + calls.filter(call => call.op === op).map(call => call.arg); + +const baseRow = { createdAt: new Date(), featured: true, id: 7, views: 0 }; +const translationRow = { + body: null, + createdAt: new Date(), + itemId: 7, + languageId: 1, + slug: "hello", + title: "Hello", + updatedAt: new Date(), + version: 1, +}; + +describe("localized create", () => { + it("writes the base row and its default translation in one transaction", async () => { + const { c, calls, state } = createDbMock([ + [baseRow], + [{ id: 7 }], + [translationRow], + ]); + + const result = await localizedService(c).create({ + shared: { featured: true }, + translation: { title: "Hello" }, + }); + + expect(state).toEqual({ + committed: true, + entered: true, + rolledBack: false, + }); + expect(result.row.id).toBe(7); + expect(result.translation).toMatchObject({ + itemId: 7, + locale: "en", + version: 1, + }); + // Two inserts, one transaction. A record whose default translation lands in + // a second request would be addressable and empty in every language until it + // arrived - or forever, if the second request never came. + expect(opsOf(calls, "insert")).toHaveLength(2); + }); + + it("separates the shared values from the localized ones", async () => { + const { c, calls } = createDbMock([ + [baseRow], + [{ id: 7 }], + [translationRow], + ]); + + await localizedService(c).create({ + shared: { featured: true }, + translation: { title: "Hello" }, + }); + + const [base, translation] = opsOf(calls, "values") as Record< + string, + unknown + >[]; + + // The base insert never sees a localized value, and the translation insert + // never sees a shared one. + expect(base).toEqual({ featured: true, views: 0 }); + expect(translation).toEqual({ + itemId: 7, + languageId: 1, + slug: "hello", + title: "Hello", + }); + }); + + it("defaults the locale to the configured default", async () => { + const { c, calls } = createDbMock([ + [baseRow], + [{ id: 7 }], + [translationRow], + ]); + + await localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }); + + expect(opsOf(calls, "values")[1]).toMatchObject({ languageId: 1 }); + }); + + it("accepts the default locale written out explicitly", async () => { + const { c } = createDbMock([[baseRow], [{ id: 7 }], [translationRow]]); + + await expect( + localizedService(c).create( + { shared: {}, translation: { title: "Hello" } }, + { locale: "EN" }, + ), + ).resolves.toMatchObject({ translation: { locale: "en" } }); + }); + + it("refuses to create a record straight into another locale", async () => { + const { c, state } = createDbMock([[baseRow]]); + + // Creating in Polish would leave the default translation missing - the one + // thing the invariant promises is always there. + await expect( + localizedService(c).create( + { shared: {}, translation: { title: "Witaj" } }, + { locale: "pl" }, + ), + ).rejects.toBeInstanceOf(ContentEngineError); + expect(state.entered).toBe(false); + }); + + it("rolls back when the translation insert fails", async () => { + // The base insert succeeds, the translation's locale already exists. + const { c, state } = createDbMock([[baseRow], [{ id: 7 }], []]); + + await expect( + localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }), + ).rejects.toThrow(); + expect(state.rolledBack).toBe(true); + expect(state.committed).toBe(false); + }); + + it("rolls back when a localized slug is taken", async () => { + // The second insert - the translation - hits the unique + // `(languageId, slug)` index. + const { c, state } = createDbMock([[baseRow], [{ id: 7 }]], { + failInsert: 2, + }); + + await expect( + localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }), + ).rejects.toThrow(/duplicate key value/); + expect(state.rolledBack).toBe(true); + expect(state.committed).toBe(false); + }); + + it("rolls back the translation when the base insert fails", async () => { + const { c, calls, state } = createDbMock([], { failInsert: 1 }); + + await expect( + localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }), + ).rejects.toThrow(/duplicate key value/); + // Never reached the translation at all, so there is nothing orphaned. + expect(opsOf(calls, "insert")).toHaveLength(1); + expect(state.committed).toBe(false); + }); + + it("creates nothing when the default language is gone", async () => { + const { c, calls, state } = createDbMock([], { + languages: [{ code: "pl", id: 2, isDefault: true }], + }); + + await expect( + localizedService(c).create({ + shared: {}, + translation: { title: "Hello" }, + }), + ).rejects.toBeInstanceOf(ContentLanguageError); + // Resolved inside the transaction and *before* the base insert, so there is + // no orphan row to clean up. + expect(opsOf(calls, "insert")).toEqual([]); + expect(state.committed).toBe(false); + }); + + it("joins a transaction the caller already owns", async () => { + const { c, state } = createDbMock([ + [baseRow], + [{ id: 7 }], + [translationRow], + ]); + const tx = c.get("db") as never; + + await localizedService(c).create( + { shared: {}, translation: { title: "Hello" } }, + { tx }, + ); + + // No transaction of its own - the caller's is the one that commits. + expect(state.entered).toBe(false); + }); +}); + +describe("a content type without localization", () => { + it("has no localized service to call", () => { + const posts = createContentModel(testPostContentType, { + references: { category: () => posts.table.id }, + }); + + expect(posts.localizedService).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-model.test.ts b/packages/vitnode/src/content/server/translation-model.test.ts new file mode 100644 index 000000000..40636f62d --- /dev/null +++ b/packages/vitnode/src/content/server/translation-model.test.ts @@ -0,0 +1,564 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; + +import { testLocalizedArticleContentType } from "@/tests/content-fixtures"; + +import { + ContentDefaultTranslationRequired, + ContentInputError, + ContentLanguageError, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, +} from "../errors"; +import { createContentModel } from "./model"; + +const localized = createContentModel(testLocalizedArticleContentType); + +const LANGUAGES = [ + { code: "en", id: 1, isDefault: true }, + { code: "pl", id: 2, isDefault: false }, +]; + +interface RecordedCall { + arg: unknown; + op: string; +} + +/** + * A chainable stand-in for the Drizzle client, in the same shape + * `service.test.ts` uses. + * + * The first `select().from()` of a request is the language registry, so it is + * answered from `LANGUAGES` rather than from the queue - which keeps every test + * below queueing only the rows it actually cares about. + */ +const createDbMock = (results: unknown[][], languages = LANGUAGES) => { + const calls: RecordedCall[] = []; + const queue = [...results]; + + const chain = (rows: unknown[]) => { + const record = (op: string, arg: unknown) => { + calls.push({ arg, op }); + + return builder; + }; + + const builder = { + $dynamic: () => builder, + // Always the builder, which is itself thenable: `await select().from()` + // (how the language registry is read) resolves through `then` below, and a + // longer chain keeps building. + from: (value: unknown) => record("from", value), + leftJoin: (value: unknown) => record("leftJoin", value), + limit: (value: unknown) => record("limit", value), + onConflictDoNothing: (value: unknown) => + record("onConflictDoNothing", value), + orderBy: (value: unknown) => record("orderBy", value), + returning: (value: unknown) => record("returning", value), + set: (value: unknown) => record("set", value), + then: async (resolve: (rows: unknown[]) => TResult) => + Promise.resolve(rows).then(resolve), + values: (value: unknown) => record("values", value), + where: (value: unknown) => record("where", value), + }; + + return builder; + }; + + const start = (op: string) => (arg: unknown) => { + calls.push({ arg, op }); + // The language registry is the one `select` whose projection names `code` + // and `isDefault`, so it is answered from `languages` rather than from the + // queue - which keeps every test queueing only the rows it cares about. + const isLanguageSelect = + op === "select" && + typeof arg === "object" && + arg !== null && + "code" in arg && + "isDefault" in arg; + + return chain(isLanguageSelect ? languages : (queue.shift() ?? [])); + }; + + const db = { + delete: start("delete"), + insert: start("insert"), + select: start("select"), + transaction: async ( + body: (tx: unknown) => Promise, + ): Promise => await body(db), + update: start("update"), + }; + + const c = { + get: (key: string) => (key === "db" ? db : undefined), + } as unknown as Context; + + return { c, calls }; +}; + +const opsOf = (calls: RecordedCall[], op: string) => + calls.filter(call => call.op === op).map(call => call.arg); + +const translationRow = (overrides: Record = {}) => ({ + body: "Body", + createdAt: new Date("2026-01-01T00:00:00Z"), + itemId: 7, + languageId: 1, + slug: "hello", + title: "Hello", + updatedAt: new Date("2026-01-01T00:00:00Z"), + version: 1, + ...overrides, +}); + +/** + * The translation model, narrowed once. + * + * `translationService` is `undefined` without localization, so TypeScript refuses + * the call until the check has been made. + */ +const translations = (c: Context) => { + const build = localized.translationService; + if (!build) throw new Error("Expected a translation service."); + + return build(c); +}; + +describe("create", () => { + it("writes the resolved language id and starts at version 1", async () => { + const { c, calls } = createDbMock([ + [{ id: 7 }], + [translationRow({ version: 1 })], + ]); + + const row = await translations(c).create(7, "en", { title: "Hello" }); + + expect(row.version).toBe(1); + expect(row.locale).toBe("en"); + expect(row.itemId).toBe(7); + // `version` is never written: the column default is what makes it 1, and the + // conditional UPDATE is the only thing that ever moves it. + expect(opsOf(calls, "values")[0]).toEqual({ + itemId: 7, + languageId: 1, + slug: "hello", + title: "Hello", + }); + }); + + it("nests the localized values under `values`", async () => { + const { c } = createDbMock([[{ id: 7 }], [translationRow()]]); + + const row = await translations(c).create(7, "en", { title: "Hello" }); + + expect(row.values).toEqual({ + body: "Body", + slug: "hello", + title: "Hello", + }); + // Metadata sits beside the values, never inside them. + expect(row.values).not.toHaveProperty("version"); + }); + + it("derives a localized slug from the localized title", async () => { + const { c, calls } = createDbMock([[{ id: 7 }], [translationRow()]]); + + await translations(c).create(7, "pl", { title: "Witaj Świecie" }); + + expect(opsOf(calls, "values")[0]).toMatchObject({ + languageId: 2, + slug: "witaj-swiecie", + }); + }); + + it("normalises a supplied slug rather than trusting it", async () => { + const { c, calls } = createDbMock([[{ id: 7 }], [translationRow()]]); + + await translations(c).create(7, "en", { + slug: "Hello World!", + title: "Hello", + }); + + expect(opsOf(calls, "values")[0]).toMatchObject({ slug: "hello-world" }); + }); + + it("refuses a slug that normalises to nothing", async () => { + const { c } = createDbMock([[{ id: 7 }]]); + + await expect( + translations(c).create(7, "en", { title: "日本語のタイトル" }), + ).rejects.toBeInstanceOf(ContentInputError); + }); + + it("validates the payload before writing", async () => { + const { c } = createDbMock([[{ id: 7 }]]); + + await expect( + translations(c).create(7, "en", { title: "no" }), + ).rejects.toBeInstanceOf(ZodError); + }); + + it("refuses a translation for a record that is not there", async () => { + const { c } = createDbMock([[]]); + + await expect( + translations(c).create(99, "en", { title: "Hello" }), + ).rejects.toBeInstanceOf(ContentTranslationItemMissing); + }); + + it("refuses an unknown locale", async () => { + const { c } = createDbMock([]); + + await expect( + translations(c).create(7, "de", { title: "Hello" }), + ).rejects.toMatchObject({ reason: "missing" }); + }); + + it("reports a locale that already has a translation", async () => { + const { c } = createDbMock([[{ id: 7 }], []]); + + await expect( + translations(c).create(7, "en", { title: "Hello" }), + ).rejects.toBeInstanceOf(ContentTranslationExists); + }); + + it("targets the primary key so a slug clash still surfaces", async () => { + const { c, calls } = createDbMock([[{ id: 7 }], [translationRow()]]); + + await translations(c).create(7, "en", { title: "Hello" }); + + // An untargeted `onConflictDoNothing()` would swallow the unique slug index + // too, and report "this locale exists" for a URL that is taken. + expect(opsOf(calls, "onConflictDoNothing")).toHaveLength(1); + }); +}); + +describe("update", () => { + it("guards on the expected version and increments it", async () => { + const { c, calls } = createDbMock([ + [translationRow({ version: 3 })], + [translationRow({ title: "New", version: 4 })], + ]); + + const result = await translations(c).update( + 7, + "en", + { title: "New" }, + { expectedVersion: 3 }, + ); + + expect(result).toMatchObject({ changed: true, version: 4 }); + expect(result?.changedFields).toEqual(["title"]); + const [values] = opsOf(calls, "set"); + expect(values).toHaveProperty("title", "New"); + expect(values).toHaveProperty("version"); + }); + + it("writes only the fields that actually changed", async () => { + const { c, calls } = createDbMock([ + [translationRow({ version: 1 })], + [translationRow({ title: "New", version: 2 })], + ]); + + await translations(c).update( + 7, + "en", + { body: "Body", title: "New" }, + { expectedVersion: 1 }, + ); + + // `body` was already "Body", so it is not part of the write. + expect(Object.keys(opsOf(calls, "set")[0] as object).sort()).toEqual([ + "title", + "version", + ]); + }); + + it("does nothing at all for a no-op", async () => { + const { c, calls } = createDbMock([[translationRow({ version: 5 })]]); + + const result = await translations(c).update( + 7, + "en", + { title: "Hello" }, + { expectedVersion: 5 }, + ); + + expect(result).toMatchObject({ changed: false, version: 5 }); + expect(result?.changedFields).toEqual([]); + // No UPDATE, so no version bump and no `updatedAt` move: an editor who + // pressed save twice has not created two versions of anything. + expect(opsOf(calls, "update")).toEqual([]); + }); + + it("treats a re-sent slug in a different case as a no-op", async () => { + const { c, calls } = createDbMock([[translationRow({ version: 2 })]]); + + const result = await translations(c).update( + 7, + "en", + { slug: "HELLO" }, + { expectedVersion: 2 }, + ); + + expect(result?.changed).toBe(false); + expect(opsOf(calls, "update")).toEqual([]); + }); + + it("does not check the version on a no-op", async () => { + const { c } = createDbMock([[translationRow({ version: 9 })]]); + + // There is nothing to overwrite, so there is nothing to conflict about. + const result = await translations(c).update( + 7, + "en", + { title: "Hello" }, + { expectedVersion: 2 }, + ); + + expect(result?.changed).toBe(false); + }); + + it("throws a version conflict when the row moved", async () => { + const { c } = createDbMock([ + [translationRow({ version: 2 })], + [], + [translationRow({ version: 4 })], + ]); + + await expect( + translations(c).update(7, "en", { title: "New" }, { expectedVersion: 2 }), + ).rejects.toMatchObject({ + currentVersion: 4, + expectedVersion: 2, + itemId: 7, + locale: "en", + }); + }); + + it("names the locale in the conflict, never the other language", async () => { + const { c } = createDbMock([ + [translationRow({ languageId: 2, version: 2 })], + [], + [translationRow({ languageId: 2, version: 3 })], + ]); + + const error = await translations(c) + .update(7, "pl", { title: "Nowy" }, { expectedVersion: 2 }) + .catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(ContentTranslationVersionConflict); + expect(error).toMatchObject({ locale: "pl" }); + }); + + it("returns null when the translation is missing", async () => { + const { c } = createDbMock([[]]); + + expect( + await translations(c).update( + 7, + "en", + { title: "New" }, + { expectedVersion: 1 }, + ), + ).toBeNull(); + }); + + it("refuses to write into a disabled language", async () => { + const { c } = createDbMock([]); + const context = { + get: (key: string) => + key === "core" + ? { i18n: { locales: [{ code: "pl", enabled: false, name: "PL" }] } } + : c.get(key), + } as unknown as Context; + + await expect( + translations(context).update( + 7, + "pl", + { title: "Nowy" }, + { expectedVersion: 1 }, + ), + ).rejects.toMatchObject({ reason: "disabled" }); + }); +}); + +describe("delete", () => { + it("guards on the expected version", async () => { + const { c } = createDbMock([ + [translationRow({ languageId: 2, version: 3 })], + ]); + + const row = await translations(c).delete(7, "pl", { expectedVersion: 3 }); + + expect(row).toMatchObject({ itemId: 7, locale: "pl", version: 3 }); + }); + + it("throws a version conflict when the row moved", async () => { + const { c } = createDbMock([ + [], + [translationRow({ languageId: 2, version: 5 })], + ]); + + await expect( + translations(c).delete(7, "pl", { expectedVersion: 3 }), + ).rejects.toMatchObject({ currentVersion: 5, expectedVersion: 3 }); + }); + + it("returns null when the translation is already gone", async () => { + const { c } = createDbMock([[], []]); + + // The caller wanted it removed, and it is. + expect( + await translations(c).delete(7, "pl", { expectedVersion: 3 }), + ).toBeNull(); + }); + + it("refuses to delete the default translation", async () => { + const { c, calls } = createDbMock([]); + + await expect( + translations(c).delete(7, "en", { expectedVersion: 1 }), + ).rejects.toBeInstanceOf(ContentDefaultTranslationRequired); + // Refused before anything is issued: the invariant atomic create establishes + // is not something a DELETE gets to test. + expect(opsOf(calls, "delete")).toEqual([]); + }); + + it("refuses the default translation whatever the casing", async () => { + const { c } = createDbMock([]); + + await expect( + translations(c).delete(7, "EN", { expectedVersion: 1 }), + ).rejects.toBeInstanceOf(ContentDefaultTranslationRequired); + }); + + it("allows deleting a translation in a disabled language", async () => { + const { c } = createDbMock([ + [translationRow({ languageId: 2, version: 1 })], + ]); + const context = { + get: (key: string) => + key === "core" + ? { i18n: { locales: [{ code: "pl", enabled: false, name: "PL" }] } } + : c.get(key), + } as unknown as Context; + + // Removing content in a language the install has switched off is exactly + // what somebody would want to do next. + expect( + await translations(context).delete(7, "pl", { expectedVersion: 1 }), + ).toMatchObject({ locale: "pl" }); + }); +}); + +describe("reads", () => { + it("finds one translation by locale", async () => { + const { c } = createDbMock([[translationRow()]]); + + expect(await translations(c).findByLocale(7, "EN")).toMatchObject({ + // The canonical locale, not the caller's casing. + locale: "en", + version: 1, + }); + }); + + it("finds one translation by language id", async () => { + const { c } = createDbMock([[translationRow({ languageId: 2 })]]); + + expect(await translations(c).findByLanguageId(7, 2)).toMatchObject({ + languageId: 2, + locale: "pl", + }); + }); + + it("returns null for an unknown locale rather than throwing", async () => { + const { c } = createDbMock([]); + + expect(await translations(c).findByLocale(7, "de")).toBeNull(); + }); + + it("lists metadata without any localized value", async () => { + const { c } = createDbMock([ + [ + { + createdAt: new Date(), + itemId: 7, + languageId: 1, + updatedAt: new Date(), + version: 2, + }, + { + createdAt: new Date(), + itemId: 7, + languageId: 2, + updatedAt: new Date(), + version: 1, + }, + ], + ]); + + const edges = await translations(c).findManyForItem(7); + + expect(edges.map(edge => edge.locale)).toEqual(["en", "pl"]); + expect(edges[0]).not.toHaveProperty("values"); + expect(edges[0]).not.toHaveProperty("title"); + }); + + it("resolves every locale in a list without a query per row", async () => { + const { c, calls } = createDbMock([ + Array.from({ length: 2 }, (_unused, index) => ({ + createdAt: new Date(), + itemId: 7, + languageId: index + 1, + updatedAt: new Date(), + version: 1, + })), + ]); + + await translations(c).findManyForItem(7); + + // One select for the rows, one for the language registry. Never one per + // translation. + expect(opsOf(calls, "select")).toHaveLength(2); + }); + + it("answers `exists` without loading the values", async () => { + const { c, calls } = createDbMock([[{ itemId: 7 }]]); + + expect(await translations(c).exists(7, "en")).toBe(true); + expect(Object.keys(opsOf(calls, "select")[1] as object)).toEqual([ + "itemId", + ]); + }); + + it("answers `exists` false for an unknown locale", async () => { + const { c } = createDbMock([]); + + expect(await translations(c).exists(7, "de")).toBe(false); + }); + + it("resolves the default language", async () => { + const { c } = createDbMock([]); + + expect(await translations(c).resolveDefaultLanguage()).toMatchObject({ + id: 1, + isDefault: true, + locale: "en", + }); + }); + + it("throws when the default language is gone", async () => { + const { c } = createDbMock([], [{ code: "pl", id: 2, isDefault: true }]); + + await expect( + translations(c).resolveDefaultLanguage(), + ).rejects.toBeInstanceOf(ContentLanguageError); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-routes.test.ts b/packages/vitnode/src/content/server/translation-routes.test.ts new file mode 100644 index 000000000..64809d3ea --- /dev/null +++ b/packages/vitnode/src/content/server/translation-routes.test.ts @@ -0,0 +1,577 @@ +// @vitest-environment node +import type { MiddlewareHandler } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + testCategoryContentType, + testLocalizedArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { + ContentDefaultTranslationRequired, + ContentLanguageError, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, +} from "../errors"; +import { createContentModel } from "./model"; +import { buildContentRoutes } from "./routes"; +import { buildContentTranslationRoutes } from "./translation-routes"; + +let permissionGranted = true; +const permissionChecks: { module: string; permission: string }[] = []; + +// `assertStaffPermission` reads roles out of the database. The routes' job is to +// *call* it with the right module and permission, so the check itself is replaced +// with a switchable verdict that records what it was asked. +vi.mock("../../api/lib/check-staff-permission", () => ({ + assertStaffPermission: async ( + _c: unknown, + args: { module: string; permission: string }, + ) => { + permissionChecks.push({ + module: args.module, + permission: args.permission, + }); + if (!permissionGranted) { + const { HTTPException } = await import("hono/http-exception"); + throw new HTTPException(403, { message: "Forbidden" }); + } + }, +})); + +const localized = createContentModel(testLocalizedArticleContentType); +const categories = createContentModel(testCategoryContentType); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); +const PLUGIN_ID = "@vitnode/example"; + +const adminUser = { + avatarColor: "000000", + birthday: null, + createdAt: new Date(), + email: "test@test.com", + emailVerified: true, + id: 1, + language: "en", + name: "Test", + nameCode: "test", + newsletter: false, + roleId: 1, +}; + +const translationRow = (overrides: Record = {}) => ({ + createdAt: new Date("2026-01-01T00:00:00Z"), + itemId: 7, + languageId: 1, + locale: "en", + updatedAt: new Date("2026-01-01T00:00:00Z"), + values: { body: null, slug: "hello", title: "Hello" }, + version: 1, + ...overrides, +}); + +interface Harness { + app: OpenAPIHono; + translations: Record>; +} + +const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { + const translations = { + create: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + findByLanguageId: vi.fn(), + findByLocale: vi.fn(), + findManyForItem: vi.fn(), + resolveDefaultLanguage: vi.fn(), + update: vi.fn(), + }; + + permissionGranted = allow; + permissionChecks.length = 0; + vi.spyOn(localized, "translationService", "get").mockReturnValue( + () => translations, + ); + + const app = new OpenAPIHono(); + + const context: MiddlewareHandler = async (c, next) => { + c.set("admin", allow ? { user: adminUser } : null); + await next(); + }; + app.use("*", context); + + for (const { handler, route } of buildContentTranslationRoutes(localized, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, translations }; +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("route registration", () => { + it("appends the five translation routes to a localized content type", () => { + const paths = buildContentRoutes(localized, { pluginId: PLUGIN_ID }).map( + entry => `${entry.route.method.toUpperCase()} ${entry.route.path}`, + ); + + expect(paths).toEqual( + expect.arrayContaining([ + "GET /{id}/translations", + "GET /{id}/translations/{locale}", + "POST /{id}/translations/{locale}", + "PUT /{id}/translations/{locale}", + "DELETE /{id}/translations/{locale}", + ]), + ); + }); + + it("generates none of them for a content type without localization", () => { + const paths = buildContentRoutes(posts, { pluginId: PLUGIN_ID }).map( + entry => entry.route.path, + ); + + expect(paths.some(path => path.includes("translations"))).toBe(false); + }); + + it("refuses to build them for a content type without localization", () => { + expect(() => + buildContentTranslationRoutes(posts, { pluginId: PLUGIN_ID }), + ).toThrow(/needs a localized content type/); + }); +}); + +describe("GET /{id}/translations", () => { + it("returns metadata for every locale", async () => { + const { app, translations } = harness(); + translations.findManyForItem.mockResolvedValue([ + { ...translationRow(), values: undefined }, + { ...translationRow({ languageId: 2, locale: "pl", version: 3 }) }, + ]); + + const response = await app.request("/7/translations"); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + edges: { locale: string; version: number }[]; + }; + expect(body.edges.map(edge => edge.locale)).toEqual(["en", "pl"]); + // The list schema has no `values`, so a body cannot leak into it even when + // the service hands one over. + expect(body.edges[0]).not.toHaveProperty("values"); + }); + + it("needs `can_view`", async () => { + const { app, translations } = harness(); + translations.findManyForItem.mockResolvedValue([]); + + await app.request("/7/translations"); + + expect(permissionChecks).toEqual([ + { module: "test_localized", permission: "can_view" }, + ]); + }); + + it("is 403 without the permission", async () => { + const { app } = harness({ allow: false }); + + expect((await app.request("/7/translations")).status).toBe(403); + }); + + it("rejects a non-numeric identifier", async () => { + const { app } = harness(); + + expect((await app.request("/abc/translations")).status).toBe(400); + }); +}); + +describe("GET /{id}/translations/{locale}", () => { + it("returns one translation with its values", async () => { + const { app, translations } = harness(); + translations.findByLocale.mockResolvedValue(translationRow()); + + const response = await app.request("/7/translations/en"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + itemId: 7, + languageId: 1, + locale: "en", + values: { slug: "hello", title: "Hello" }, + version: 1, + }); + }); + + it("is 404 when there is no translation in that locale", async () => { + const { app, translations } = harness(); + translations.findByLocale.mockResolvedValue(null); + + expect((await app.request("/7/translations/de")).status).toBe(404); + }); + + it("passes the locale through untouched, casing included", async () => { + const { app, translations } = harness(); + translations.findByLocale.mockResolvedValue(translationRow()); + + await app.request("/7/translations/PL"); + + // The resolver owns normalisation; the route does not pre-empt it. + expect(translations.findByLocale).toHaveBeenCalledWith(7, "PL"); + }); + + it("rejects a locale wider than core_languages.code", async () => { + const { app } = harness(); + + expect( + (await app.request(`/7/translations/${"x".repeat(33)}`)).status, + ).toBe(400); + }); +}); + +describe("POST /{id}/translations/{locale}", () => { + const post = async (app: OpenAPIHono, body: unknown, locale = "pl") => + app.request(`/7/translations/${locale}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + it("creates a translation and answers 201", async () => { + const { app, translations } = harness(); + translations.create.mockResolvedValue( + translationRow({ languageId: 2, locale: "pl" }), + ); + + const response = await post(app, { values: { title: "Witaj" } }); + + expect(response.status).toBe(201); + expect(translations.create).toHaveBeenCalledWith(7, "pl", { + title: "Witaj", + }); + }); + + it("needs `can_edit`", async () => { + const { app, translations } = harness(); + translations.create.mockResolvedValue(translationRow()); + + await post(app, { values: { title: "Witaj" } }); + + expect(permissionChecks).toEqual([ + { module: "test_localized", permission: "can_edit" }, + ]); + }); + + it("rejects values outside the envelope", async () => { + const { app } = harness(); + + // `expectedVersion`, `locale` and `itemId` are transport, so they can never + // be part of a strict `values` object. + expect((await post(app, { title: "Witaj" })).status).toBe(400); + expect( + (await post(app, { values: { itemId: 9, title: "Witaj" } })).status, + ).toBe(400); + }); + + it("answers 404 for a record that is not there", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + new ContentTranslationItemMissing({ + contentTypeId: "test.localized", + itemId: 7, + }), + ); + + expect((await post(app, { values: { title: "Witaj" } })).status).toBe(404); + }); + + it("answers 404 for an unknown locale", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + new ContentLanguageError({ locale: "de", reason: "missing" }), + ); + + expect((await post(app, { values: { title: "Hallo" } }, "de")).status).toBe( + 404, + ); + }); + + it("answers a structured 409 for a disabled locale", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + new ContentLanguageError({ locale: "pl", reason: "disabled" }), + ); + + const response = await post(app, { values: { title: "Witaj" } }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_LANGUAGE_DISABLED", + contentTypeId: "test.localized", + locale: "pl", + }); + }); + + it("answers a structured 409 when the locale already has one", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + new ContentTranslationExists({ + contentTypeId: "test.localized", + itemId: 7, + locale: "pl", + }), + ); + + const response = await post(app, { values: { title: "Witaj" } }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_TRANSLATION_EXISTS", + contentTypeId: "test.localized", + itemId: 7, + locale: "pl", + }); + }); + + it("answers a structured 409 when a localized slug is taken", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + Object.assign(new Error("duplicate key"), { code: "23505" }), + ); + + const response = await post(app, { values: { title: "Witaj" } }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_TRANSLATION_UNIQUE_CONFLICT", + contentTypeId: "test.localized", + itemId: 7, + locale: "pl", + }); + }); + + it("never leaks the driver's message", async () => { + const { app, translations } = harness(); + translations.create.mockRejectedValue( + Object.assign( + new Error( + 'duplicate key value violates unique constraint "test_localized_articles_translations_language_id_slug_key"', + ), + { code: "23505" }, + ), + ); + + const body = await (await post(app, { values: { title: "Witaj" } })).text(); + + expect(body).not.toContain("unique constraint"); + }); +}); + +describe("PUT /{id}/translations/{locale}", () => { + const put = async (app: OpenAPIHono, body: unknown, locale = "en") => + app.request(`/7/translations/${locale}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + it("updates one locale and reports the new version", async () => { + const { app, translations } = harness(); + translations.update.mockResolvedValue({ + changed: true, + changedFields: ["title"], + row: translationRow({ version: 4 }), + version: 4, + }); + + const response = await put(app, { + expectedVersion: 3, + values: { title: "New" }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + changed: true, + row: { version: 4 }, + }); + expect(translations.update).toHaveBeenCalledWith( + 7, + "en", + { title: "New" }, + { expectedVersion: 3 }, + ); + }); + + it("reports a no-op as changed: false", async () => { + const { app, translations } = harness(); + translations.update.mockResolvedValue({ + changed: false, + changedFields: [], + row: translationRow({ version: 3 }), + version: 3, + }); + + const response = await put(app, { + expectedVersion: 3, + values: { title: "Hello" }, + }); + + expect(await response.json()).toMatchObject({ + changed: false, + row: { version: 3 }, + }); + }); + + it("requires an expected version", async () => { + const { app } = harness(); + + expect((await put(app, { values: { title: "New" } })).status).toBe(400); + }); + + it("rejects an empty patch", async () => { + const { app } = harness(); + + expect((await put(app, { expectedVersion: 1, values: {} })).status).toBe( + 400, + ); + }); + + it("answers a structured 409 naming the locale that moved", async () => { + const { app, translations } = harness(); + translations.update.mockRejectedValue( + new ContentTranslationVersionConflict({ + contentTypeId: "test.localized", + currentVersion: 5, + expectedVersion: 3, + itemId: 7, + locale: "en", + }), + ); + + const response = await put(app, { + expectedVersion: 3, + values: { title: "New" }, + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_TRANSLATION_VERSION_CONFLICT", + contentTypeId: "test.localized", + currentVersion: 5, + expectedVersion: 3, + itemId: 7, + // The tab that has to be reloaded, and only that one. + locale: "en", + }); + }); + + it("is 404 when the translation is missing", async () => { + const { app, translations } = harness(); + translations.update.mockResolvedValue(null); + + expect( + (await put(app, { expectedVersion: 1, values: { title: "New" } })).status, + ).toBe(404); + }); +}); + +describe("DELETE /{id}/translations/{locale}", () => { + const remove = async (app: OpenAPIHono, body: unknown, locale = "pl") => + app.request(`/7/translations/${locale}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "DELETE", + }); + + it("deletes a non-default translation", async () => { + const { app, translations } = harness(); + translations.delete.mockResolvedValue( + translationRow({ languageId: 2, locale: "pl", version: 2 }), + ); + + const response = await remove(app, { expectedVersion: 2 }); + + expect(response.status).toBe(200); + expect(translations.delete).toHaveBeenCalledWith(7, "pl", { + expectedVersion: 2, + }); + }); + + it("needs `can_delete`", async () => { + const { app, translations } = harness(); + translations.delete.mockResolvedValue(translationRow()); + + await remove(app, { expectedVersion: 1 }); + + expect(permissionChecks).toEqual([ + { module: "test_localized", permission: "can_delete" }, + ]); + }); + + it("requires an expected version", async () => { + const { app } = harness(); + + expect((await remove(app, {})).status).toBe(400); + }); + + it("refuses the default translation with a structured 409", async () => { + const { app, translations } = harness(); + translations.delete.mockRejectedValue( + new ContentDefaultTranslationRequired({ + contentTypeId: "test.localized", + itemId: 7, + locale: "en", + }), + ); + + const response = await remove(app, { expectedVersion: 1 }, "en"); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + code: "CONTENT_DEFAULT_TRANSLATION_REQUIRED", + contentTypeId: "test.localized", + itemId: 7, + locale: "en", + }); + }); + + it("is 404 when the translation is already gone", async () => { + const { app, translations } = harness(); + translations.delete.mockResolvedValue(null); + + expect((await remove(app, { expectedVersion: 1 })).status).toBe(404); + }); +}); + +describe("the OpenAPI document", () => { + it("describes every translation route and its 409 union", () => { + const { app } = harness(); + const document = app.getOpenAPI31Document({ + info: { title: "test", version: "1" }, + openapi: "3.1.0", + }); + + const detail = document.paths?.["/{id}/translations/{locale}"]; + + expect(Object.keys(detail ?? {}).sort()).toEqual([ + "delete", + "get", + "post", + "put", + ]); + expect(detail?.put?.responses?.["409"]).toBeDefined(); + // The list route is metadata-only, so it has no 409 at all. + expect( + document.paths?.["/{id}/translations"]?.get?.responses?.["409"], + ).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/content/server/translation-table.test.ts b/packages/vitnode/src/content/server/translation-table.test.ts new file mode 100644 index 000000000..054c3e470 --- /dev/null +++ b/packages/vitnode/src/content/server/translation-table.test.ts @@ -0,0 +1,289 @@ +// @vitest-environment node +import { getTableName } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { + testCategoryContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { createContentTranslationTable } from "./translation-table"; + +const localized = createContentModel(testLocalizedArticleContentType); +const notes = createContentModel(testLocalizedNoteContentType); +const categories = createContentModel(testCategoryContentType); +const posts = createContentModel(testPostContentType, { + references: { category: () => categories.table.id }, +}); + +type AnyTable = Parameters[0]; + +/** `translationTable` is `null` for a content type without localization. */ +const translationTable = (model: { translationTable: AnyTable | null }) => { + if (!model.translationTable) { + throw new Error("Expected a generated translation table."); + } + + return model.translationTable; +}; + +const configOf = (table: AnyTable | null) => { + if (!table) throw new Error("Expected a generated translation table."); + + return getTableConfig(table); +}; + +const base = getTableConfig(localized.table); +const translations = configOf(localized.translationTable); + +describe("the base table of a localized content type", () => { + it("carries the system columns and the shared fields only", () => { + expect(base.columns.map(column => column.name)).toEqual([ + "id", + "createdAt", + "updatedAt", + "featured", + "views", + ]); + }); + + it("has no column for any localized field", () => { + const names = base.columns.map(column => column.name); + + for (const localizedField of ["title", "slug", "body"]) { + expect(names).not.toContain(localizedField); + } + }); + + it("keeps the localized slug's unique index off the base table", () => { + // A slug is unique *per language*, so its index belongs to the translation + // table. A base-table unique index would make the URL global. + expect(base.indexes.map(item => item.config.name)).toEqual([ + "test_localized_articles_created_at_idx", + "test_localized_articles_updated_at_idx", + ]); + }); + + it("exposes only shared columns through the column map", () => { + expect(Object.keys(localized.columns).sort()).toEqual([ + "createdAt", + "featured", + "id", + "updatedAt", + "views", + ]); + }); +}); + +describe("the generated translation table", () => { + it("is named after the base table", () => { + expect(getTableName(translationTable(localized))).toBe( + "test_localized_articles_translations", + ); + }); + + it("enables row level security, like every other generated table", () => { + expect(translations.enableRLS).toBe(true); + }); + + it("carries the keys, the version, the timestamps and the localized fields", () => { + expect(translations.columns.map(column => column.name)).toEqual([ + "itemId", + "languageId", + "version", + "createdAt", + "updatedAt", + "title", + "slug", + "body", + ]); + }); + + it("has no column for any shared field", () => { + const names = translations.columns.map(column => column.name); + + expect(names).not.toContain("featured"); + expect(names).not.toContain("views"); + }); + + it("materialises real Postgres types rather than a JSON blob", () => { + const types = Object.fromEntries( + translations.columns.map(column => [column.name, column.getSQLType()]), + ); + + expect(types).toEqual({ + body: "text", + createdAt: "timestamp", + itemId: "integer", + languageId: "integer", + slug: "varchar(160)", + title: "varchar(200)", + updatedAt: "timestamp", + version: "integer", + }); + }); + + it("keeps a nullable localized field nullable", () => { + const columns = Object.fromEntries( + translations.columns.map(column => [column.name, column]), + ); + + expect(columns.title.notNull).toBe(true); + expect(columns.body.notNull).toBe(false); + // Never nullable and never defaulted: a translation nobody can address by + // URL is not worth allowing. + expect(columns.slug.notNull).toBe(true); + expect(columns.slug.default).toBeUndefined(); + }); + + it("starts every translation at version 1", () => { + const version = translations.columns.find( + column => column.name === "version", + ); + + expect(version?.notNull).toBe(true); + expect(version?.default).toBe(1); + }); + + it("keys a translation by its record and its language", () => { + const [primaryKey] = translations.primaryKeys; + + expect(primaryKey.columns.map(column => column.name)).toEqual([ + "itemId", + "languageId", + ]); + expect(primaryKey.getName()).toBe( + "test_localized_articles_translations_item_id_language_id_pk", + ); + }); + + it("cascades from the record and restricts the language", () => { + const references = translations.foreignKeys.map(foreignKey => { + const reference = foreignKey.reference(); + + return { + column: reference.columns[0].name, + onDelete: foreignKey.onDelete, + target: getTableName(reference.foreignTable), + }; + }); + + expect(references).toEqual( + expect.arrayContaining([ + { + column: "itemId", + onDelete: "cascade", + target: "test_localized_articles", + }, + { + column: "languageId", + onDelete: "restrict", + target: "core_languages", + }, + ]), + ); + }); + + it("scopes the localized slug's uniqueness to one language", () => { + const unique = translations.indexes.filter(item => item.config.unique); + + expect(unique).toHaveLength(1); + expect(unique[0].config.name).toBe( + "test_localized_articles_translations_language_id_slug_key", + ); + expect( + unique[0].config.columns.map(column => + "name" in column ? column.name : "", + ), + ).toEqual(["languageId", "slug"]); + }); + + it("indexes languageId on its own", () => { + // `(itemId, languageId)` and `itemId` are served by the primary key; "every + // row in Polish" is not. + expect(translations.indexes.map(item => item.config.name)).toContain( + "test_localized_articles_translations_language_id_idx", + ); + }); + + it("generates deterministic names inside the Postgres limit", () => { + const names = [ + ...translations.indexes.map(item => item.config.name ?? ""), + ...translations.primaryKeys.map(key => key.getName()), + ]; + + for (const name of names) { + expect(name.length).toBeLessThanOrEqual(63); + expect(name).toMatch(/^[a-z][a-z0-9_]*$/); + } + }); + + it("exposes the translation columns through their own map", () => { + expect(Object.keys(localized.translationColumns ?? {}).sort()).toEqual([ + "body", + "createdAt", + "itemId", + "languageId", + "slug", + "title", + "updatedAt", + "version", + ]); + }); +}); + +describe("a content type without localization", () => { + it("generates exactly one table", () => { + expect(posts.translationTable).toBeNull(); + expect(posts.translationColumns).toBeNull(); + expect(posts.translationSchemas).toBeNull(); + expect(posts.translationService).toBeUndefined(); + expect(posts.localizedService).toBeUndefined(); + }); + + it("keeps every column it had", () => { + const names = getTableConfig(posts.table).columns.map( + column => column.name, + ); + + expect(names).toEqual([ + "id", + "createdAt", + "updatedAt", + "publishedAt", + "status", + "title", + "slug", + "excerpt", + "views", + "author", + "category", + ]); + }); + + it("refuses to build a translation table for it", () => { + expect(() => + createContentTranslationTable(testPostContentType, { + table: posts.table, + }), + ).toThrow(/needs `localization: \{ enabled: true, defaultLocale \}`/); + }); +}); + +describe("a second localized content type", () => { + it("gets its own tables and its own index names", () => { + const other = configOf(notes.translationTable); + + expect(getTableName(translationTable(notes))).toBe( + "test_localized_notes_translations", + ); + expect(other.indexes.map(item => item.config.name)).toEqual([ + "test_localized_notes_translations_language_id_idx", + "test_localized_notes_translations_language_id_slug_key", + ]); + }); +}); diff --git a/packages/vitnode/src/content/translation-schemas.test.ts b/packages/vitnode/src/content/translation-schemas.test.ts new file mode 100644 index 000000000..472a8b13b --- /dev/null +++ b/packages/vitnode/src/content/translation-schemas.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testLocalizedArticleContentType, + testLocalizedNoteContentType, +} from "@/tests/content-fixtures"; + +const { schemas } = testLocalizedArticleContentType; + +/** Non-null because the fixture is localized - see `translation-table.test.ts`. */ +const translation = (() => { + if (!schemas.translation) { + throw new Error("Expected generated translation schemas."); + } + + return schemas.translation; +})(); + +describe("shared schemas of a localized content type", () => { + it("accept the shared fields", () => { + expect(schemas.create.parse({ featured: true, views: 3 })).toEqual({ + featured: true, + views: 3, + }); + }); + + it("apply the declared defaults, exactly as before", () => { + expect(schemas.create.parse({})).toEqual({ featured: false, views: 0 }); + }); + + it("reject a localized field as a base value", () => { + // Strict, so a localized value cannot be smuggled into the base insert - + // there is no column for it, and quietly stripping it would lose the text. + expect(() => schemas.create.parse({ title: "Hello" })).toThrow(); + expect(() => schemas.update.parse({ title: "Hello" })).toThrow(); + }); + + it("keep localized fields out of the response shape", () => { + expect(Object.keys(schemas.selectObject.shape).sort()).toEqual([ + "createdAt", + "featured", + "id", + "updatedAt", + "views", + ]); + }); + + it("keep localized fields out of the filter and form shapes", () => { + expect(Object.keys(schemas.filters.shape)).not.toContain("title"); + expect(Object.keys(schemas.form.shape)).toEqual(["featured", "views"]); + }); +}); + +describe("translation create", () => { + it("accepts the localized values", () => { + expect(translation.create.parse({ body: "Body", title: "Hello" })).toEqual({ + body: "Body", + title: "Hello", + }); + }); + + it("requires a required localized field", () => { + expect(() => translation.create.parse({ body: "Body" })).toThrow(); + }); + + it("leaves a sourced slug optional and requires a sourceless one", () => { + expect(translation.create.parse({ title: "Hello" })).toEqual({ + title: "Hello", + }); + + const notes = testLocalizedNoteContentType.schemas.translation; + expect(() => notes?.create.parse({ heading: "Hi" })).toThrow(); + }); + + it("accepts null for a nullable localized field", () => { + expect(translation.create.parse({ body: null, title: "Hello" })).toEqual({ + body: null, + title: "Hello", + }); + }); + + it("is strict, so an unknown key is an error", () => { + expect(() => + translation.create.parse({ nope: 1, title: "Hello" }), + ).toThrow(); + }); + + it.each(["itemId", "languageId", "version", "locale", "expectedVersion"])( + "refuses %s as a content value", + key => { + // Identity and transport are not content. Accepting any of these inside + // `values` would make them mass-assignable. + expect(() => + translation.create.parse({ [key]: 1, title: "Hello" }), + ).toThrow(); + }, + ); + + it("wraps the values in an envelope on the wire", () => { + expect( + translation.createEnvelope.parse({ values: { title: "Hello" } }), + ).toEqual({ values: { title: "Hello" } }); + expect(() => + translation.createEnvelope.parse({ title: "Hello" }), + ).toThrow(); + }); +}); + +describe("translation update", () => { + it("makes every localized field optional", () => { + expect(translation.update.parse({ title: "New" })).toEqual({ + title: "New", + }); + }); + + it("refuses an empty patch", () => { + // A `PUT` that names no field is a request that means nothing, and it would + // otherwise burn a version check for no reason. + expect(() => translation.update.parse({})).toThrow(); + }); + + it("never applies create defaults", () => { + expect(translation.update.parse({ body: "Body" })).toEqual({ + body: "Body", + }); + }); + + it("carries `expectedVersion` beside the values, not inside them", () => { + expect( + translation.updateEnvelope.parse({ + expectedVersion: 3, + values: { title: "Nowy tytuł" }, + }), + ).toEqual({ expectedVersion: 3, values: { title: "Nowy tytuł" } }); + }); + + it.each([0, -1, 1.5])("refuses expectedVersion %s", expectedVersion => { + // Positive integers only, so a client that forgot to send one cannot coerce + // `0` past the guard and race the very check it is meant to lose. + expect(() => + translation.updateEnvelope.parse({ + expectedVersion, + values: { title: "New" }, + }), + ).toThrow(); + }); + + it("refuses an update envelope with an empty patch", () => { + expect(() => + translation.updateEnvelope.parse({ expectedVersion: 1, values: {} }), + ).toThrow(); + }); +}); + +describe("translation select", () => { + it("nests the values under `values` beside the metadata", () => { + expect(Object.keys(translation.select.shape).sort()).toEqual([ + "createdAt", + "itemId", + "languageId", + "locale", + "updatedAt", + "values", + "version", + ]); + }); + + it("has a metadata-only shape for the list route", () => { + // A locale strip needs to know which languages exist, not to drag every + // article body in every language across the wire to find out. + expect(Object.keys(translation.selectMeta.shape).sort()).toEqual([ + "createdAt", + "itemId", + "languageId", + "locale", + "updatedAt", + "version", + ]); + }); + + it("coerces the item id and keeps the locale a plain string", () => { + expect(translation.params.parse({ id: "12", locale: "pl" })).toEqual({ + id: 12, + locale: "pl", + }); + }); + + it("refuses a locale wider than core_languages.code", () => { + expect(() => + translation.params.parse({ id: "1", locale: "x".repeat(33) }), + ).toThrow(); + }); +}); + +describe("a content type without localization", () => { + it("has no translation schemas at all", () => { + expect(testArticleContentType.schemas.translation).toBeNull(); + }); + + it("keeps every generated schema it had", () => { + expect(Object.keys(testArticleContentType.schemas.form.shape)).toEqual( + Object.keys(testArticleContentType.fields), + ); + }); +}); diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index e7d02012c..4f93ddcc5 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -197,3 +197,59 @@ export const testSearchablePostContentType = defineContentType({ list: { defaultOrderBy: "publishedAt" }, }, }); + +/** + * The Stage 5A shape: shared and localized fields on one content type. + * + * A separate fixture rather than a flag on an existing one, for the same reason + * the searchable and editorial ones are separate: leaving every Stage 1-4 fixture + * exactly as it was is what proves localization existing changes nothing for + * them. + */ +export const testLocalizedArticleContentType = defineContentType({ + id: "test.localized", + tableName: "test_localized_articles", + localization: { + enabled: true, + defaultLocale: "en", + fallback: "none", + }, + fields: { + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 200, + }), + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, nullable: true }), + // Shared, and deliberately the only one: "a localized field is absent from + // the base table" needs something present there to be contrasted with. + featured: field.boolean({ defaultValue: false }), + views: field.number({ integer: true, min: 0, defaultValue: 0 }), + }, + admin: { + label: { plural: "Test Localized", singular: "Test Localized" }, + list: { columns: ["featured", "views"], orderableFields: ["views"] }, + }, +}); + +/** + * Localized with a slug the caller always supplies. + * + * The other half of the slug rules: a sourceless localized slug is `required` in + * the translation create payload, where a sourced one is derived. + */ +export const testLocalizedNoteContentType = defineContentType({ + id: "test.localized-note", + tableName: "test_localized_notes", + localization: { enabled: true, defaultLocale: "EN" }, + fields: { + heading: field.text({ localized: true, required: true }), + slug: field.slug({ localized: true }), + pinned: field.boolean({ defaultValue: false }), + }, + admin: { + label: { plural: "Test Localized Notes", singular: "Test Localized Note" }, + }, +}); From 0baeab7d19a777f4796c9cd4e2f32b069c19d176 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:06:57 +0200 Subject: [PATCH 09/11] feat(example): add a localized Content Type fixture and its migration `example.localized-article`: `featured` shared, `title`, `slug` and `body` localized. One base table with its shared field, one translation table with the localized ones, both foreign keys, the composite primary key and a unique (languageId, slug) index - all in a `drizzle-kit generate` diff a human can read. Registered on the API side only. Its CRUD and translation routes exist and its staff permissions are derived, but it gets no AdminCP screen yet: a form that could not edit `title` in any language would be worse to ship than no form at all. Stage 5B adds the locale tabs and registers it in `config.tsx`. Co-Authored-By: Claude Opus 5 (1M context) --- .../0029_add_example_localized_articles.sql | 27 + apps/docs/migrations/meta/0029_snapshot.json | 3126 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 9 +- .../src/api/modules/admin/admin.module.ts | 9 +- plugins/example/src/const.ts | 3 + .../example/src/content/localized-article.ts | 64 + .../src/database/localized-articles.ts | 14 + 7 files changed, 3250 insertions(+), 2 deletions(-) create mode 100644 apps/docs/migrations/0029_add_example_localized_articles.sql create mode 100644 apps/docs/migrations/meta/0029_snapshot.json create mode 100644 plugins/example/src/content/localized-article.ts create mode 100644 plugins/example/src/database/localized-articles.ts diff --git a/apps/docs/migrations/0029_add_example_localized_articles.sql b/apps/docs/migrations/0029_add_example_localized_articles.sql new file mode 100644 index 000000000..bd44b28d9 --- /dev/null +++ b/apps/docs/migrations/0029_add_example_localized_articles.sql @@ -0,0 +1,27 @@ +CREATE TABLE "example_localized_articles" ( + "id" serial PRIMARY KEY NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "featured" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +ALTER TABLE "example_localized_articles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_localized_articles_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" text NOT NULL, + CONSTRAINT "example_localized_articles_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ADD CONSTRAINT "example_localized_articles_translations_itemId_example_localized_articles_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."example_localized_articles"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ADD CONSTRAINT "example_localized_articles_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "example_localized_articles_created_at_idx" ON "example_localized_articles" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "example_localized_articles_updated_at_idx" ON "example_localized_articles" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "example_localized_articles_translations_language_id_idx" ON "example_localized_articles_translations" USING btree ("languageId");--> statement-breakpoint +CREATE UNIQUE INDEX "example_localized_articles_translations_language_id_slug_key" ON "example_localized_articles_translations" USING btree ("languageId","slug"); \ No newline at end of file diff --git a/apps/docs/migrations/meta/0029_snapshot.json b/apps/docs/migrations/meta/0029_snapshot.json new file mode 100644 index 000000000..cf75f2b31 --- /dev/null +++ b/apps/docs/migrations/meta/0029_snapshot.json @@ -0,0 +1,3126 @@ +{ + "id": "48b24402-18d7-4bf2-88e4-f0c651f02a1d", + "prevId": "ad292f66-b888-469c-84fc-5b2fb5dd0dcd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles": { + "name": "example_localized_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_idx": { + "name": "example_localized_articles_translations_language_id_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index b9fbeb52a..db7bb2101 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1786024625069, "tag": "0028_add_content_schedule_effects_error", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1786034127995, + "tag": "0029_add_example_localized_articles", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts index d59a57541..09f0f5c2a 100644 --- a/plugins/example/src/api/modules/admin/admin.module.ts +++ b/plugins/example/src/api/modules/admin/admin.module.ts @@ -4,6 +4,7 @@ import { buildContentAdminModule } from "@vitnode/core/content/server"; import { CONFIG_PLUGIN } from "@/const"; import { articleContent } from "@/database/articles"; import { categoryContent } from "@/database/categories"; +import { localizedArticleContent } from "@/database/localized-articles"; /** * The generated content module is nested here rather than mounted by the @@ -19,7 +20,13 @@ export const adminModule = buildModule({ modules: [ buildContentAdminModule({ pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [articleContent, categoryContent], + // The localized article is registered on the API side only, so its + // generated CRUD *and* translation routes exist and its staff permissions + // are derived - but it gets no AdminCP screen yet, because a form that + // could not edit `title` in any language would be a worse thing to ship + // than no form at all. Stage 5B adds the locale tabs and registers it in + // `config.tsx` alongside the others. + contentTypes: [articleContent, categoryContent, localizedArticleContent], }), ], }); diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index b7fade25f..1cf92fe48 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -21,4 +21,7 @@ export const EXAMPLE_MIGRATIONS = [ // for an article, and the row has to have somewhere to go. "0027_add_content_schedules.sql", "0028_add_content_schedule_effects_error.sql", + // The Stage 5A localized fixture: a base table with only its shared field, and + // a translation table holding the localized ones. + "0029_add_example_localized_articles.sql", ]; diff --git a/plugins/example/src/content/localized-article.ts b/plugins/example/src/content/localized-article.ts new file mode 100644 index 000000000..b2ace9ac8 --- /dev/null +++ b/plugins/example/src/content/localized-article.ts @@ -0,0 +1,64 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +/** + * The Stage 5A reference: one content type with both halves of the partition. + * + * `featured` is shared, so it lives on `example_localized_articles`. `title`, + * `slug` and `body` are localized, so they live on + * `example_localized_articles_translations` - one row per language, each with its + * own `version`, and a unique `(languageId, slug)` index so `/en/hello` and + * `/pl/hello` can both exist while a second English `hello` is a 409. + * + * Deliberately minimal on every other axis. `publication`, `editorial`, + * `publicApi` and `search` are all refused alongside `localization` in Stage 5A - + * see the boundaries in `resolveContentLocalization` - so this fixture exercises + * the tables, the schemas, the services and the routes and nothing else. It is + * registered on the API side only: the AdminCP locale tabs that would give + * `title` somewhere to be edited are Stage 5B. + */ +export const localizedArticleContentType = defineContentType({ + id: "example.localized-article", + tableName: "example_localized_articles", + + localization: { + enabled: true, + // `en` has to exist in `core_languages`, which the boot guard checks once per + // process. The Postgres suite inserts it (and `pl`) itself - nothing seeds + // languages, they are created by the installer. + defaultLocale: "en", + // The safest default, and the only one Stage 5A can honestly claim: nothing + // reads through the fallback yet, and Stage 5C is where it starts to. + fallback: "none", + }, + + fields: { + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 200, + }), + // Derived from the *localized* title, per language. A slug sourced from a + // shared field would give every language the same URL, which is why the + // engine refuses that combination. + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, required: true }), + + // Shared: whether an article is featured is a property of the article, not + // of the language somebody is reading it in. + featured: field.boolean({ defaultValue: false }), + }, + + admin: { + label: { + plural: "Example Localized Articles", + singular: "Example Localized Article", + }, + list: { + // Shared columns only. A localized field is not a column on the base + // table, so naming one here is a compile error as well as a runtime one. + columns: ["featured", "updatedAt"], + orderableFields: ["featured"], + }, + }, +}); diff --git a/plugins/example/src/database/localized-articles.ts b/plugins/example/src/database/localized-articles.ts new file mode 100644 index 000000000..07f55ab07 --- /dev/null +++ b/plugins/example/src/database/localized-articles.ts @@ -0,0 +1,14 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { localizedArticleContentType } from "@/content/localized-article"; + +export const localizedArticleContent = createContentModel( + localizedArticleContentType, +); + +// Two exports for a localized content type, not one. Drizzle Kit discovers each +// table from the export when it globs the built `dist/src/database/*.js`, so the +// translation table needs its own or the migration would be generated without it. +export const example_localized_articles = localizedArticleContent.table; +export const example_localized_articles_translations = + localizedArticleContent.translationTable; From e63b8684774e29362cb3c3d5b2422506cb25b0e3 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:06:57 +0200 Subject: [PATCH 10/11] test(example): assert the generated localization schema and its Postgres behaviour `tables.test.ts` asserts the Drizzle objects and the committed DDL text agree: localized fields absent from the base table, shared fields absent from the translation table, the composite key inside 63 characters, both ON DELETE behaviours, and the language-scoped unique slug index. `postgres.test.ts` gains 28 tests that only a real database can answer, because a mock asked whether a rollback happened can only agree with itself: - the base row and its default translation commit together - each way the translation insert can fail leaves no base row behind - English at v3 and Polish at v1 update concurrently, neither conflicting - two writers on Polish v2: exactly one succeeds, one gets a version conflict - a no-op moves neither `version` nor `updatedAt` - proof no statement ran - the default translation cannot be deleted; another one can - `/en/about` and `/pl/about` coexist; a second English `about` is 23505 - deleting the record cascades its translations - deleting a language that content is written in is 23503 - the composite primary key is the one Postgres actually created - a base list never joins translations Languages are inserted by the suite itself, because nothing in VitNode seeds `core_languages` - assuming `en` existed would pass locally and fail on fresh CI. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/example/src/database/postgres.test.ts | 783 +++++++++++++++++- plugins/example/src/database/tables.test.ts | 180 +++- 2 files changed, 960 insertions(+), 3 deletions(-) diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts index bb543a139..1c1218a11 100644 --- a/plugins/example/src/database/postgres.test.ts +++ b/plugins/example/src/database/postgres.test.ts @@ -2,7 +2,13 @@ import type { ContentSearchOperation } from "@vitnode/core/content/server"; import type { Context } from "hono"; import { executeContentSchedule } from "@vitnode/core/api/modules/content/helpers/execute-content-schedule"; -import { ContentVersionConflict } from "@vitnode/core/content"; +import { + ContentDefaultTranslationRequired, + ContentTranslationExists, + ContentTranslationItemMissing, + ContentTranslationVersionConflict, + ContentVersionConflict, +} from "@vitnode/core/content"; import { claimContentSchedule, createContentSearchIndexer, @@ -15,13 +21,14 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import postgres from "postgres"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { CONFIG_PLUGIN, EXAMPLE_MIGRATIONS } from "@/const"; import { articleContentType } from "@/content/article"; import { articleContent } from "./articles"; import { categoryContent } from "./categories"; +import { localizedArticleContent } from "./localized-articles"; /** * A real Postgres smoke test for the Content Engine. @@ -160,6 +167,26 @@ const CORE_QUEUE_STUB = ` ); `; +/** + * Enough of `core_languages` for the localized translation table's foreign key. + * + * Stubbed for the same reason `core_users` is - core's own migrations are not + * replayed here - and it is the *whole* reason the localized suite has to insert + * its own languages: nothing in VitNode seeds them. They are created by the + * installer, so a test that assumed `en` existed would pass on a developer + * machine and fail on a fresh CI database. + */ +const CORE_LANGUAGES_STUB = ` + CREATE TABLE "core_languages" ( + "id" serial PRIMARY KEY NOT NULL, + "code" varchar(32) NOT NULL, + "name" varchar(255) NOT NULL, + "default" boolean DEFAULT false NOT NULL, + "protected" boolean DEFAULT false NOT NULL, + CONSTRAINT "core_languages_code_unique" UNIQUE("code") + ); +`; + let sql: ReturnType; let context: Context; let db: ReturnType; @@ -211,6 +238,15 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { `); await sql.unsafe(CORE_USERS_STUB); await sql.unsafe(CORE_QUEUE_STUB); + await sql.unsafe(CORE_LANGUAGES_STUB); + // Inserted before the migrations run, so the localized translation table's + // `ON DELETE restrict` foreign key has something real to point at. + await sql` + INSERT INTO "core_languages" ("code", "name", "default") VALUES + ('en', 'English', true), + ('pl', 'Polski', false), + ('de', 'Deutsch', false) + `; const run = async (files: readonly string[]) => { for (const statement of migrationSql(files).split( @@ -277,7 +313,22 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { return { contentModels: [ { model: articleContent, pluginId: CONFIG_PLUGIN.pluginId }, + { + model: localizedArticleContent, + pluginId: CONFIG_PLUGIN.pluginId, + }, ], + // Which locales this app *serves*. `core_languages` is the registry + // of the ones that exist; a locale listed here with + // `enabled: false` is a deliberate switch-off, and the resolver + // refuses to write into it. + i18n: { + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + { code: "de", enabled: false, name: "Deutsch" }, + ], + }, }; } if (key === "queue") { @@ -2047,4 +2098,732 @@ describe.skipIf(!url)("Content Engine against Postgres", () => { .create({ category: 1, code: "x", title: "no" }), ).rejects.toThrow(); }); + + /** + * Localization against a real database. + * + * Everything here is a property of Postgres rather than of the engine, which is + * exactly why it cannot be unit-tested: a mock asked whether a rollback happened + * can only agree with itself, and the composite primary key, the two foreign keys + * and the per-language unique index are enforced by the server or not at all. + */ + describe("localization", () => { + // Both are `undefined` for a content type without localization, so + // TypeScript refuses the call until the check has been made. Narrowed once + // here rather than asserted past at every call site. + const translations = (handle = context) => { + const build = localizedArticleContent.translationService; + if (!build) throw new Error("Expected a translation service."); + + return build(handle); + }; + const localizedService = (handle = context) => { + const build = localizedArticleContent.localizedService; + if (!build) throw new Error("Expected a localized service."); + + return build(handle); + }; + + /** Every translation row for one record, straight out of SQL. */ + const rowsFor = async (itemId: number) => + await sql< + { languageId: number; slug: string; title: string; version: number }[] + >` + SELECT "languageId", "slug", "title", "version" + FROM "example_localized_articles_translations" + WHERE "itemId" = ${itemId} + ORDER BY "languageId" + `; + + const countArticles = async () => { + const [{ count }] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "example_localized_articles" + `; + + return count; + }; + + const countTranslations = async () => { + const [{ count }] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count + FROM "example_localized_articles_translations" + `; + + return count; + }; + + beforeEach(async () => { + // The cascade is the point of one of the tests below, so clear the base + // table and let it take the translations with it. + await sql`DELETE FROM "example_localized_articles"`; + }); + + describe("atomic create", () => { + it("commits the base row and its default translation together", async () => { + const { row, translation } = await localizedService().create({ + shared: { featured: true }, + translation: { body: "Hello body", title: "Hello World" }, + }); + + expect(row.featured).toBe(true); + expect(translation).toMatchObject({ + itemId: row.id, + locale: "en", + version: 1, + }); + expect(translation.values).toMatchObject({ + slug: "hello-world", + title: "Hello World", + }); + + // Both really landed, read back through SQL rather than through the + // service that wrote them. + expect(await countArticles()).toBe(1); + expect(await rowsFor(row.id)).toEqual([ + { + languageId: 1, + slug: "hello-world", + title: "Hello World", + version: 1, + }, + ]); + }); + + it("keeps localized values off the base table", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Column Check", title: "Column Check" }, + }); + + const columns = await sql<{ column_name: string }[]>` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'example_localized_articles' + `; + + expect(columns.map(column => column.column_name).sort()).toEqual([ + "createdAt", + "featured", + "id", + "updatedAt", + ]); + expect(row).not.toHaveProperty("title"); + }); + + it("leaves no base row when the translation cannot be written", async () => { + await localizedService().create({ + shared: {}, + translation: { body: "Body of First", slug: "taken", title: "First" }, + }); + const before = await countArticles(); + + // The unique `(languageId, slug)` index rejects the second English row. + await expect( + localizedService().create({ + shared: {}, + translation: { + body: "Body of Second", + slug: "taken", + title: "Second", + }, + }), + ).rejects.toThrow(); + + // No orphan: the base insert went back with the transaction. + expect(await countArticles()).toBe(before); + expect(await countTranslations()).toBe(before); + }); + + it("creates nothing when the default language is missing", async () => { + // A context whose language registry has no `en` row at all - what a typo + // in `defaultLocale`, or a language somebody deleted, looks like. + const blind = { + get: (key: string) => { + if (key === "db") { + return { + ...db, + select: () => ({ + from: () => [], + where: () => ({ limit: () => [] }), + }), + }; + } + + return context.get(key); + }, + } as unknown as Context; + + await expect( + localizedService(blind).create({ + shared: {}, + translation: { + body: "Body of Never Written", + title: "Never Written", + }, + }), + ).rejects.toThrow(); + expect(await countArticles()).toBe(0); + }); + + it("refuses to create a record straight into a non-default locale", async () => { + await expect( + localizedService().create( + { + shared: {}, + translation: { body: "Body of Witaj", title: "Witaj" }, + }, + { locale: "pl" }, + ), + ).rejects.toThrow(/created in its default locale/); + expect(await countArticles()).toBe(0); + }); + }); + + describe("per-locale optimistic locking", () => { + it("versions each locale independently", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of English One", title: "English One" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Polski Jeden", + title: "Polski Jeden", + }); + + // Move English twice, Polish once. + await translations().update( + row.id, + "en", + { body: "Body of English Two", title: "English Two" }, + { expectedVersion: 1 }, + ); + await translations().update( + row.id, + "en", + { body: "Body of English Three", title: "English Three" }, + { expectedVersion: 2 }, + ); + await translations().update( + row.id, + "pl", + { body: "Body of Polski Dwa", title: "Polski Dwa" }, + { expectedVersion: 1 }, + ); + + expect(await rowsFor(row.id)).toMatchObject([ + { languageId: 1, version: 3 }, + { languageId: 2, version: 2 }, + ]); + }); + + it("lets two locales be edited concurrently without conflicting", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Concurrent English", + title: "Concurrent English", + }, + }); + await translations().create(row.id, "pl", { + body: "Body of Concurrent Polski", + title: "Concurrent Polski", + }); + await translations().update( + row.id, + "en", + { body: "Body of English At Two", title: "English At Two" }, + { expectedVersion: 1 }, + ); + await translations().update( + row.id, + "en", + { body: "Body of English At Three", title: "English At Three" }, + { expectedVersion: 2 }, + ); + + // English is at 3, Polish at 1. Two writers, two connections, one each. + const [english, polish] = await Promise.all([ + translations().update( + row.id, + "en", + { body: "Body of English At Four", title: "English At Four" }, + { expectedVersion: 3 }, + ), + translations(rivalContext).update( + row.id, + "pl", + { body: "Body of Polski At Two", title: "Polski At Two" }, + { expectedVersion: 1 }, + ), + ]); + + // Neither is told the other language moved: the lock is per row, and the + // rows are keyed by `(itemId, languageId)`. + expect(english).toMatchObject({ changed: true, version: 4 }); + expect(polish).toMatchObject({ changed: true, version: 2 }); + }); + + it("lets exactly one of two writers on the same locale win", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Race Subject", title: "Race Subject" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Wersja Jeden", + title: "Wersja Jeden", + }); + await translations().update( + row.id, + "pl", + { body: "Body of Wersja Dwa", title: "Wersja Dwa" }, + { expectedVersion: 1 }, + ); + + const outcomes = await Promise.allSettled([ + translations().update( + row.id, + "pl", + { body: "Body of Wersja Trzy A", title: "Wersja Trzy A" }, + { expectedVersion: 2 }, + ), + translations(rivalContext).update( + row.id, + "pl", + { body: "Body of Wersja Trzy B", title: "Wersja Trzy B" }, + { expectedVersion: 2 }, + ), + ]); + + const fulfilled = outcomes.filter( + outcome => outcome.status === "fulfilled", + ); + const rejected = outcomes.filter( + outcome => outcome.status === "rejected", + ); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]).toMatchObject({ + reason: expect.objectContaining({ + currentVersion: 3, + expectedVersion: 2, + locale: "pl", + }), + }); + // The winner's value is the one that is stored, and the version moved once. + const [, polishRow] = await rowsFor(row.id); + expect(polishRow.version).toBe(3); + }); + + it("refuses a stale update", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Stale Update", title: "Stale Update" }, + }); + await translations().update( + row.id, + "en", + { body: "Body of Moved On", title: "Moved On" }, + { expectedVersion: 1 }, + ); + + await expect( + translations().update( + row.id, + "en", + { body: "Body of From The Past", title: "From The Past" }, + { expectedVersion: 1 }, + ), + ).rejects.toThrow(ContentTranslationVersionConflict); + }); + + it("refuses a stale delete", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Stale Delete", title: "Stale Delete" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Do Usuniecia", + title: "Do Usuniecia", + }); + await translations().update( + row.id, + "pl", + { body: "Body of Zmienione", title: "Zmienione" }, + { expectedVersion: 1 }, + ); + + await expect( + translations().delete(row.id, "pl", { expectedVersion: 1 }), + ).rejects.toThrow(ContentTranslationVersionConflict); + expect(await rowsFor(row.id)).toHaveLength(2); + }); + + it("leaves version and updatedAt alone on a no-op", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of No Op Subject", + title: "No Op Subject", + }, + }); + // The raw `postgres` client is used directly here rather than Drizzle, + // so a timestamp arrives as whatever the driver produced - hence the + // explicit `new Date`. + const [before] = await sql<{ updatedAt: string; version: number }[]>` + SELECT "updatedAt", "version" + FROM "example_localized_articles_translations" + WHERE "itemId" = ${row.id} AND "languageId" = 1 + `; + + const result = await translations().update( + row.id, + "en", + { body: "Body of No Op Subject", title: "No Op Subject" }, + { expectedVersion: 1 }, + ); + + const [after] = await sql<{ updatedAt: string; version: number }[]>` + SELECT "updatedAt", "version" + FROM "example_localized_articles_translations" + WHERE "itemId" = ${row.id} AND "languageId" = 1 + `; + + expect(result).toMatchObject({ changed: false, version: 1 }); + expect(after.version).toBe(before.version); + // `$onUpdate` fires on any UPDATE, so an unchanged `updatedAt` is proof no + // statement ran at all. + expect(new Date(after.updatedAt).getTime()).toBe( + new Date(before.updatedAt).getTime(), + ); + }); + + it("moves updatedAt on a real update", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Real Update", title: "Real Update" }, + }); + const [before] = await sql<{ updatedAt: string }[]>` + SELECT "updatedAt" FROM "example_localized_articles_translations" + WHERE "itemId" = ${row.id} AND "languageId" = 1 + `; + + await translations().update( + row.id, + "en", + { body: "Body of Really Updated", title: "Really Updated" }, + { expectedVersion: 1 }, + ); + + const [after] = await sql<{ updatedAt: string }[]>` + SELECT "updatedAt" FROM "example_localized_articles_translations" + WHERE "itemId" = ${row.id} AND "languageId" = 1 + `; + + expect(new Date(after.updatedAt).getTime()).toBeGreaterThanOrEqual( + new Date(before.updatedAt).getTime(), + ); + }); + }); + + describe("the default translation invariant", () => { + it("refuses to delete the default translation", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Keeps Its English", + title: "Keeps Its English", + }, + }); + + await expect( + translations().delete(row.id, "en", { expectedVersion: 1 }), + ).rejects.toThrow(ContentDefaultTranslationRequired); + expect(await rowsFor(row.id)).toHaveLength(1); + }); + + it("deletes an additional translation", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Has Two", title: "Has Two" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Ma Dwa", + title: "Ma Dwa", + }); + + const removed = await translations().delete(row.id, "pl", { + expectedVersion: 1, + }); + + expect(removed).toMatchObject({ locale: "pl" }); + expect(await rowsFor(row.id)).toMatchObject([{ languageId: 1 }]); + }); + }); + + describe("locale-scoped slug uniqueness", () => { + it("allows the same slug in two different languages", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of About", slug: "about", title: "About" }, + }); + + await translations().create(row.id, "pl", { + body: "O nas po polsku", + slug: "about", + title: "O Nas", + }); + + // `/en/about` and `/pl/about` are two different pages, and that is the + // whole reason the unique index is `(languageId, slug)`. + expect((await rowsFor(row.id)).map(item => item.slug)).toEqual([ + "about", + "about", + ]); + }); + + it("refuses two rows with the same slug in one language", async () => { + const first = await localizedService().create({ + shared: {}, + translation: { + body: "Body of First", + slug: "duplicate", + title: "First", + }, + }); + const second = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Second", + slug: "other", + title: "Second", + }, + }); + + const code = await pgErrorCode(async () => + translations().update( + second.row.id, + "en", + { slug: "duplicate" }, + { expectedVersion: 1 }, + ), + ); + + expect(code).toBe("23505"); + expect((await rowsFor(first.row.id))[0].slug).toBe("duplicate"); + }); + + it("derives a different slug per language from that language's title", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of The English Title", + title: "The English Title", + }, + }); + await translations().create(row.id, "pl", { + body: "Body of Polski Tytul", + title: "Polski Tytul", + }); + + expect((await rowsFor(row.id)).map(item => item.slug)).toEqual([ + "the-english-title", + "polski-tytul", + ]); + }); + }); + + describe("foreign keys", () => { + it("cascades translations when the record is deleted", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of Going Away", title: "Going Away" }, + }); + await translations().create(row.id, "pl", { + body: "Body of Odchodzi", + title: "Odchodzi", + }); + expect(await rowsFor(row.id)).toHaveLength(2); + + // One statement, no loop over locales: the database owns the cascade. + await localizedArticleContent.service(context).delete(row.id); + + expect(await rowsFor(row.id)).toEqual([]); + }); + + it("restricts deleting a language that content is written in", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Holds English", + title: "Holds English", + }, + }); + await translations().create(row.id, "pl", { + body: "Body of Trzyma Polski", + title: "Trzyma Polski", + }); + + const code = await pgErrorCode( + async () => + await sql`DELETE FROM "core_languages" WHERE "code" = 'pl'`, + ); + + // `23503`, not a silent cascade: deleting a language must not quietly + // delete every article written in it. + expect(code).toBe("23503"); + expect(await rowsFor(row.id)).toHaveLength(2); + }); + + it("refuses a translation for a record that does not exist", async () => { + await expect( + translations().create(999_999, "pl", { + body: "Body of Nigdzie", + title: "Nigdzie", + }), + ).rejects.toThrow(ContentTranslationItemMissing); + }); + }); + + describe("the composite primary key", () => { + it("refuses a second translation in the same locale", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Only One English", + title: "Only One English", + }, + }); + + await expect( + translations().create(row.id, "en", { + body: "Body of Second English", + title: "Second English", + }), + ).rejects.toThrow(ContentTranslationExists); + expect(await rowsFor(row.id)).toHaveLength(1); + }); + + it("is the key Postgres actually created", async () => { + const [key] = await sql<{ columns: string[]; name: string }[]>` + SELECT + c.conname AS name, + array_agg(a.attname ORDER BY k.ord) AS columns + FROM pg_constraint c + JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.conrelid = 'example_localized_articles_translations'::regclass + AND c.contype = 'p' + GROUP BY c.conname + `; + + expect(key.name).toBe( + "example_localized_articles_translations_item_id_language_id_pk", + ); + expect(key.columns).toEqual(["itemId", "languageId"]); + }); + }); + + describe("language resolution", () => { + it("returns the canonical locale whatever casing arrives", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Casing Subject", + title: "Casing Subject", + }, + }); + + expect((await translations().findByLocale(row.id, "EN"))?.locale).toBe( + "en", + ); + }); + + it("refuses to write into a locale the app has disabled", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of No German", title: "No German" }, + }); + + // `de` exists in `core_languages` but the app config switched it off. + await expect( + translations().create(row.id, "de", { + body: "Body of Kein Deutsch", + title: "Kein Deutsch", + }), + ).rejects.toMatchObject({ reason: "disabled" }); + }); + + it("refuses an unknown locale", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "Body of No Klingon", title: "No Klingon" }, + }); + + await expect( + translations().create(row.id, "tlh", { + body: "Body of nuqneH", + title: "nuqneH", + }), + ).rejects.toMatchObject({ reason: "missing" }); + }); + }); + + describe("reads", () => { + it("lists every locale a record exists in, without the bodies", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { body: "A long English body", title: "Listed" }, + }); + await translations().create(row.id, "pl", { + body: "Dlugi polski tekst", + title: "Na Liscie", + }); + + const edges = await translations().findManyForItem(row.id); + + expect(edges.map(edge => edge.locale)).toEqual(["en", "pl"]); + for (const edge of edges) { + expect(edge).not.toHaveProperty("values"); + expect(edge).not.toHaveProperty("body"); + } + }); + + it("finds a translation by language id as well as by locale", async () => { + const { row } = await localizedService().create({ + shared: {}, + translation: { + body: "Body of Found Both Ways", + title: "Found Both Ways", + }, + }); + + expect(await translations().findByLanguageId(row.id, 1)).toMatchObject({ + locale: "en", + version: 1, + }); + expect(await translations().exists(row.id, "pl")).toBe(false); + expect(await translations().exists(row.id, "en")).toBe(true); + }); + + it("never joins translations into an ordinary base list", async () => { + await localizedService().create({ + shared: { featured: true }, + translation: { + body: "Body of Base List Row", + title: "Base List Row", + }, + }); + + const page = await localizedArticleContent + .service(context) + .findMany({ query: {} }); + + expect(page.edges).toHaveLength(1); + // Stage 5A loads translations explicitly, one record at a time. A list of + // 25 rows must not drag 25 records' worth of every language with it. + expect(page.edges[0]).not.toHaveProperty("title"); + expect(page.edges[0]).toMatchObject({ featured: true }); + }); + }); + }); }); diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index 821f3764c..83e6ebd8c 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -9,9 +9,28 @@ import { EXAMPLE_MIGRATIONS } from "@/const"; import { example_articles } from "./articles"; import { example_categories } from "./categories"; +import { + example_localized_articles, + example_localized_articles_translations, +} from "./localized-articles"; const articles = getTableConfig(example_articles); const categories = getTableConfig(example_categories); +const localizedArticles = getTableConfig(example_localized_articles); + +// `translationTable` is `null` for every content type without localization, so +// this narrows once - and fails loudly rather than silently skipping the +// assertions below if the fixture ever stops being localized. +const localizedTranslationTable = (() => { + if (!example_localized_articles_translations) { + throw new Error( + "example.localized-article generated no translation table.", + ); + } + + return example_localized_articles_translations; +})(); +const localizedTranslations = getTableConfig(localizedTranslationTable); const indexNames = (config: typeof articles) => config.indexes.map(item => item.config.name); @@ -202,7 +221,12 @@ describe("the generated migration", () => { .filter(name => name.startsWith("example_")); expect([...created].sort(byName)).toEqual( - [...indexNames(articles), ...indexNames(categories)].sort(byName), + [ + ...indexNames(articles), + ...indexNames(categories), + ...indexNames(localizedArticles), + ...indexNames(localizedTranslations), + ].sort(byName), ); }); @@ -275,3 +299,157 @@ describe("the generated migration", () => { ); }); }); + +describe("example_localized_articles", () => { + it("keeps every localized field off the base table", () => { + const columns = localizedArticles.columns.map(column => column.name); + + // `title`, `slug` and `body` are declared on the content type and are + // deliberately absent here: they live one table over, one row per language. + expect(columns).toEqual(["id", "createdAt", "updatedAt", "featured"]); + }); + + it("keeps shared fields off the translation table", () => { + const columns = localizedTranslations.columns.map(column => column.name); + + expect(columns).not.toContain("featured"); + expect(columns).toEqual([ + "itemId", + "languageId", + "version", + "createdAt", + "updatedAt", + "title", + "slug", + "body", + ]); + }); + + it("gives the translation table its own name", () => { + expect(getTableName(localizedTranslationTable)).toBe( + "example_localized_articles_translations", + ); + }); + + it("enables row level security on both tables", () => { + expect(localizedArticles.enableRLS).toBe(true); + expect(localizedTranslations.enableRLS).toBe(true); + }); + + it("materialises the real column types, not JSON", () => { + const types = Object.fromEntries( + localizedTranslations.columns.map(column => [ + column.name, + column.getSQLType(), + ]), + ); + + expect(types).toMatchObject({ + body: "text", // textarea + itemId: "integer", + languageId: "integer", + slug: "varchar(160)", + title: "varchar(200)", + version: "integer", + }); + }); + + it("versions each translation independently, starting at 1", () => { + const version = localizedTranslations.columns.find( + column => column.name === "version", + ); + + expect(version?.notNull).toBe(true); + expect(version?.default).toBe(1); + }); + + it("keys a translation by its record and its language", () => { + const [primaryKey] = localizedTranslations.primaryKeys; + + expect(primaryKey.columns.map(column => column.name)).toEqual([ + "itemId", + "languageId", + ]); + expect(primaryKey.getName()).toBe( + "example_localized_articles_translations_item_id_language_id_pk", + ); + expect(primaryKey.getName().length).toBeLessThanOrEqual(63); + }); + + it("cascades from the record and restricts the language", () => { + const references = localizedTranslations.foreignKeys.map(foreignKey => { + const reference = foreignKey.reference(); + + return { + column: reference.columns[0].name, + onDelete: foreignKey.onDelete, + target: getTableName(reference.foreignTable), + }; + }); + + expect(references).toEqual( + expect.arrayContaining([ + // Translations are part of the record, so they go with it. + { + column: "itemId", + onDelete: "cascade", + target: "example_localized_articles", + }, + // Deleting a language must not silently delete the content written in + // it - the language screen refuses instead. + { + column: "languageId", + onDelete: "restrict", + target: "core_languages", + }, + ]), + ); + }); + + it("scopes the localized slug's uniqueness to one language", () => { + const unique = localizedTranslations.indexes.find( + item => item.config.unique, + ); + + expect(unique?.config.name).toBe( + "example_localized_articles_translations_language_id_slug_key", + ); + expect( + unique?.config.columns.map(column => "name" in column && column.name), + ).toEqual(["languageId", "slug"]); + }); + + it("indexes languageId on its own", () => { + // The composite primary key already serves `(itemId, languageId)` and + // `itemId`; "every row in Polish" needs its own index. + expect(indexNames(localizedTranslations)).toContain( + "example_localized_articles_translations_language_id_idx", + ); + }); + + it("keeps every generated identifier inside the Postgres limit", () => { + for (const name of indexNames(localizedTranslations)) { + expect((name ?? "").length).toBeLessThanOrEqual(63); + } + }); + + it("creates both tables in the committed migration", () => { + expect(migration).toContain('CREATE TABLE "example_localized_articles"'); + expect(migration).toContain( + 'CREATE TABLE "example_localized_articles_translations"', + ); + expect(migration).toContain('PRIMARY KEY("itemId","languageId")'); + expect(migration).toContain( + 'REFERENCES "public"."core_languages"("id") ON DELETE restrict', + ); + expect(migration).toContain( + 'CREATE UNIQUE INDEX "example_localized_articles_translations_language_id_slug_key"', + ); + }); + + it("never adds a localized column to the base table in the migration", () => { + expect(migration).not.toMatch( + /ALTER TABLE "example_localized_articles" ADD COLUMN "title"/, + ); + }); +}); From 776aaee682691ea35e0d06a252952de18a2449fb Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 6 Aug 2026 20:07:11 +0200 Subject: [PATCH 11/11] docs(content): document the Stage 5A localization foundation Five new pages: localization.mdx the block, the shared/localized split, per-locale locking, the default-translation invariant, language resolution, the boot check, the Stage 5A boundaries and the 5B-5D roadmap localized-fields.mdx the three kinds that can be localized, why each other kind is refused, the two slug rules, and where a localized name may not appear translation-tables.mdx the generated schema, why one table per content type beats JSONB / per-language columns / EAV, both foreign keys, and every index translation-service.mdx every read and write, the conflict table, the five routes, their request and response shapes, and the security properties localization-migrations.mdx the generated migration in full, and the six-step copy-verify-drop recipe for localizing a table that already has rows in it The first paragraph of the localization page says what this is *not*: UI translations are `core_languages_words` and the i18n system, content translations are records. Both read the same language registry, and confusing the two is the mistake the page exists to prevent. `fallback` is documented as resolved-but-unread, and the boundaries page names the stage that lifts each refused combination - nothing here implies localized publication, public reads, cache or search work yet. Co-Authored-By: Claude Opus 5 (1M context) --- .../database-and-migrations.mdx | 17 + .../docs/dev/content-engine/fields.mdx | 14 + .../content/docs/dev/content-engine/index.mdx | 9 +- .../docs/dev/content-engine/limitations.mdx | 58 ++- .../localization-migrations.mdx | 249 ++++++++++++ .../docs/dev/content-engine/localization.mdx | 311 +++++++++++++++ .../dev/content-engine/localized-fields.mdx | 191 ++++++++++ .../content/docs/dev/content-engine/meta.json | 5 + .../docs/dev/content-engine/schemas.mdx | 12 + .../content-engine/translation-service.mdx | 354 ++++++++++++++++++ .../dev/content-engine/translation-tables.mdx | 229 +++++++++++ 11 files changed, 1446 insertions(+), 3 deletions(-) create mode 100644 apps/docs/content/docs/dev/content-engine/localization-migrations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/localization.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/localized-fields.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/translation-service.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/translation-tables.mdx diff --git a/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx index 21ffcc41a..4189a6482 100644 --- a/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx +++ b/apps/docs/content/docs/dev/content-engine/database-and-migrations.mdx @@ -331,6 +331,23 @@ Removing a content type for good needs no statement at all: the daily registered, on both tables. A rename looks exactly like a removal to it, which is why the `UPDATE` belongs in the same migration rather than the next release. +## A localized Content Type generates two tables + +Marking a field `localized: true` moves its column onto a generated +`_translations` table, and the plugin's database module exports both: + +```ts +export const example_localized_articles = localizedArticleContent.table; +export const example_localized_articles_translations = + localizedArticleContent.translationTable; +``` + +`translationTable` is `null` without localization, so nothing about an existing +module changes. The generated schema, its keys and its indexes are covered in +[Translation tables](/docs/dev/content-engine/translation-tables); localizing a +table that already has rows in it needs [a hand-written +copy-verify-drop migration](/docs/dev/content-engine/localization-migrations#localizing-a-content-type-that-already-has-rows). + ## Renaming or removing a field diff --git a/apps/docs/content/docs/dev/content-engine/fields.mdx b/apps/docs/content/docs/dev/content-engine/fields.mdx index 0eb512d84..19cfa3206 100644 --- a/apps/docs/content/docs/dev/content-engine/fields.mdx +++ b/apps/docs/content/docs/dev/content-engine/fields.mdx @@ -212,6 +212,20 @@ that reference each other still load fine. row. `defineContentType` rejects the combination up front instead. +## localized + +`text`, `textarea` and `slug` also accept `localized: true`, which moves the value +into a generated per-language table instead of onto the base table: + +```ts +title: field.text({ localized: true, required: true }), +``` + +It needs `localization: { enabled: true, defaultLocale }` on the content type, and +the other builders do not take the argument at all - so a localized `boolean` is a +compile error. See [Localized +fields](/docs/dev/content-engine/localized-fields). + ## Adding a field kind later The descriptor union plus one case in each of the six mappers - column, select diff --git a/apps/docs/content/docs/dev/content-engine/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx index e035827f5..55a6d2ae5 100644 --- a/apps/docs/content/docs/dev/content-engine/index.mdx +++ b/apps/docs/content/docs/dev/content-engine/index.mdx @@ -44,7 +44,7 @@ That gives you: (plus `can_publish` with [publication](/docs/dev/content-engine/publication)) - `content.example.article.created` / `.updated` / `.deleted` events -Three more declarations, each opt-in: +Four more declarations, each opt-in: - [`publication`](/docs/dev/content-engine/publication) adds a draft/published lifecycle, a `can_publish` permission and a badge in the AdminCP @@ -54,9 +54,14 @@ Three more declarations, each opt-in: - [`editorial`](/docs/dev/content-engine/editorial) adds a `version` column, optimistic locking so two editors cannot silently overwrite each other, and a [revision history](/docs/dev/content-engine/revisions) you can restore from +- [`localization`](/docs/dev/content-engine/localization) moves the text fields + you mark into a generated per-language table, with its own version per locale + and a URL per locale Publication alone exposes nothing. Public exposure requires both of the first -two; `editorial` works with or without either. +two; `editorial` works with or without either. `localization` is currently +[exclusive of the other three](/docs/dev/content-engine/localization#stage-5a-boundaries) - +each combination arrives in a later stage. ## What it is not diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx index 37fbddd18..617477605 100644 --- a/apps/docs/content/docs/dev/content-engine/limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx @@ -12,7 +12,8 @@ other 20%, so you find out here rather than halfway through building. | Not supported | Do this instead | | --- | --- | | One-to-one, many-to-many, polymorphic relations | Write the join table and the queries by hand | -| Localised content fields | Use `core_languages_words` directly, as the blog plugin does | +| Localised `boolean`, `number`, `date`, `enum`, `relation` or `user` fields | [Only text kinds can be localized](/docs/dev/content-engine/localized-fields#which-kinds-are-refused-and-why). Split the content type, or keep the value shared | +| Locale-specific relations, localized media | Not in Stage 5 at all. A relation stays shared; the *related* content type can be localized in its own right | | Rich text, media and file fields | Hand-build the field, or store an id and resolve it yourself | | To-the-second [scheduling](/docs/dev/content-engine/scheduling) | The queue drains on a one-minute tick, so a schedule fires within about a minute | | Scheduling a field edit, or a recurring schedule | Only `status` is scheduled. One row, one time, one action | @@ -42,6 +43,61 @@ None of these are blocked - they are simply not generated. The service, the schemas and the table are all public, so a hand-written route sits next to a generated one without friction. +## Localization is infrastructure only, for now + +[`localization`](/docs/dev/content-engine/localization) generates the tables, the +types, the schemas, the services and the translation routes. What it deliberately +refuses is every combination whose *reading* half is not built yet: + +| Combination | Refused until | +| --- | --- | +| `localization` + `publication` | Stage 5B | +| `localization` + `editorial` | Stage 5B | +| `localization` + `publicApi` | Stage 5C | +| `localization` + `search` | Stage 5D | + +Each is a definition-time error naming the stage. A localized content type that +silently ran Stage 1-4 logic against its base table while ignoring its localized +fields would be worse than one that refuses to be declared. + +Also not in Stage 5A: AdminCP locale tabs, completeness badges, `can_translate`, +per-locale publication or revisions, fallback resolution, locale-aware cache tags +and per-locale search documents. The translation routes reuse `can_view`, +`can_edit` and `can_delete` until the UI they gate exists. + +## Localized field names cannot appear on base-table surfaces + +A localized field has no column on the base table, so it cannot be an +`admin.list` column, an `orderableFields` or `searchableFields` entry, a +`form.fields` entry, `admin.titleField`, or part of an `indexes` declaration. All +six are compile errors and runtime errors. + +`admin.titleField` therefore falls back to `null` on a content type whose only +text fields are localized. Stage 5B gives the AdminCP a locale-aware title. + +## Foreign key names on a long translation table are truncated by Postgres + +Drizzle names a foreign key `____fk`, which +for a translation table on an already-long base table passes 63 characters and is +silently truncated by Postgres. That is pre-existing Drizzle behaviour shared with +every hand-written table, not a localization quirk - and the names the *engine* +generates (the composite primary key and both indexes) carry a deterministic +fingerprint and stay inside the limit. + +If two truncated names ever collide, Postgres says so when the migration runs. + +## Turning localization off is a hand-written migration + +Reverting to a single set of columns means choosing one language's values to keep, +and there is no correct default for that choice - so nothing generates it. See +[Localization migrations](/docs/dev/content-engine/localization-migrations#turning-localization-off). + +The same applies in the other direction: localizing a content type that already +has rows generates a `DROP COLUMN` that is correct about the schema and silent +about the data. Copy, verify, then drop - the +[recipe](/docs/dev/content-engine/localization-migrations#localizing-a-content-type-that-already-has-rows) +spells it out. + ## Filters are equality, and only equality `filters` compares a column to one value. That is the whole feature. diff --git a/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx b/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx new file mode 100644 index 000000000..42e74cfbc --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localization-migrations.mdx @@ -0,0 +1,249 @@ +--- +title: Localization migrations +description: What `drizzle-kit generate` produces for a localized Content Type, and the six-step recipe for localizing one that already has rows in it. +icon: DatabaseZap +--- + +Localization is a schema change like any other in the Content Engine: you declare +it, `drizzle-kit` generates the SQL, you read the diff and commit it. Nothing is +created at runtime. + +## A new localized Content Type + +Export both tables from the plugin's database module, then generate: + +```ts title="src/database/localized-articles.ts" +export const example_localized_articles = localizedArticleContent.table; +export const example_localized_articles_translations = + localizedArticleContent.translationTable; +``` + +```bash +pnpm --filter build:plugins +pnpm --filter drizzle-kit generate --name=add_localized_articles +``` + +What comes out - the committed +`0029_add_example_localized_articles.sql`, in full: + +```sql +CREATE TABLE "example_localized_articles" ( + "id" serial PRIMARY KEY NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "featured" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +ALTER TABLE "example_localized_articles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_localized_articles_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" text NOT NULL, + CONSTRAINT "example_localized_articles_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ADD CONSTRAINT "example_localized_articles_translations_itemId_example_localized_articles_id_fk" FOREIGN KEY ("itemId") REFERENCES "public"."example_localized_articles"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_localized_articles_translations" ADD CONSTRAINT "example_localized_articles_translations_languageId_core_languages_id_fk" FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "example_localized_articles_created_at_idx" ON "example_localized_articles" USING btree ("createdAt");--> statement-breakpoint +CREATE INDEX "example_localized_articles_updated_at_idx" ON "example_localized_articles" USING btree ("updatedAt");--> statement-breakpoint +CREATE INDEX "example_localized_articles_translations_language_id_idx" ON "example_localized_articles_translations" USING btree ("languageId");--> statement-breakpoint +CREATE UNIQUE INDEX "example_localized_articles_translations_language_id_slug_key" ON "example_localized_articles_translations" USING btree ("languageId","slug"); +``` + +Everything Stage 5A promises is in that file: the base table with only its shared +field, the translation table with the localized ones, both foreign keys with +opposite `ON DELETE` behaviour, the composite primary key, the `version` default, +the timestamps, and the language-scoped unique slug index. + + + Nothing in VitNode inserts rows into `core_languages` - they are created by the + installer, or in AdminCP → Languages. A fresh database has none, so a localized + content type whose `defaultLocale` is `"en"` will + [refuse to boot](/docs/dev/content-engine/localization#the-boot-check) until an + `en` language exists. Test fixtures have to insert their own. + + +## Localizing a Content Type that already has rows + +This is the interesting case, and the engine deliberately does **not** generate it +for you. + +Marking an existing `title` as `localized: true` makes `drizzle-kit` produce +exactly what it should produce for the schema you described: `CREATE TABLE ... +_translations` **and** `ALTER TABLE ... DROP COLUMN "title"`. The new table starts +empty, so applying that as-is deletes every title you had. + + + The `DROP COLUMN` is correct about the destination and silent about the data. + Split it: copy first, verify, and only then drop. + + +### The six steps + +1. Create the translation table. +2. Resolve the configured default language's id. +3. Copy the localized columns from the base table into it. +4. **Verify the copied row count.** +5. Add the constraints and indexes. +6. Drop the localized columns from the base table - only now. + +Steps 4 and 6 are the ones that matter. Everything before step 4 is repeatable and +harmless; step 6 is the one you cannot undo. + +### The migration, written out + +Generate the migration, then replace its body with this shape. `example_articles` +localizing `title` and `slug` stands in for whatever yours is: + +```sql +-- 1. The new table, without its constraints yet: the copy has to be allowed to +-- land before uniqueness is enforced, so a duplicate is a report rather than a +-- failed migration. +CREATE TABLE "example_articles_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "example_articles_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint + +-- 2 + 3. Copy every existing row into the default language. `INSERT ... SELECT` +-- rather than a loop: one statement, one snapshot, and the language id is +-- looked up rather than hardcoded - a literal `1` is right on the machine +-- it was written on and wrong on every other install. +INSERT INTO "example_articles_translations" + ("itemId", "languageId", "title", "slug", "createdAt", "updatedAt") +SELECT + a."id", + (SELECT "id" FROM "core_languages" WHERE "code" = 'en'), + a."title", + a."slug", + a."createdAt", + a."updatedAt" +FROM "example_articles" a; +--> statement-breakpoint + +-- 4. Verify. Abort the whole migration if a single row did not make it - the +-- alternative is dropping the source columns of the rows that failed. +DO $$ +DECLARE + source_count integer; + copied_count integer; + language_id integer; +BEGIN + SELECT "id" INTO language_id FROM "core_languages" WHERE "code" = 'en'; + IF language_id IS NULL THEN + RAISE EXCEPTION 'No core_languages row for the default locale "en". Create the language before running this migration.'; + END IF; + + SELECT count(*) INTO source_count FROM "example_articles"; + SELECT count(*) INTO copied_count FROM "example_articles_translations"; + + IF source_count <> copied_count THEN + RAISE EXCEPTION 'Copied % of % rows into example_articles_translations; refusing to drop the source columns.', + copied_count, source_count; + END IF; +END $$; +--> statement-breakpoint + +-- 5. Now the constraints and indexes. A pre-existing duplicate slug surfaces here +-- as a named, understandable failure with the data still intact. +ALTER TABLE "example_articles_translations" + ADD CONSTRAINT "example_articles_translations_item_id_language_id_pk" + PRIMARY KEY ("itemId", "languageId");--> statement-breakpoint +ALTER TABLE "example_articles_translations" + ADD CONSTRAINT "example_articles_translations_itemId_example_articles_id_fk" + FOREIGN KEY ("itemId") REFERENCES "public"."example_articles"("id") + ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "example_articles_translations" + ADD CONSTRAINT "example_articles_translations_languageId_core_languages_id_fk" + FOREIGN KEY ("languageId") REFERENCES "public"."core_languages"("id") + ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "example_articles_translations_language_id_idx" + ON "example_articles_translations" USING btree ("languageId");--> statement-breakpoint +CREATE UNIQUE INDEX "example_articles_translations_language_id_slug_key" + ON "example_articles_translations" USING btree ("languageId","slug");--> statement-breakpoint + +-- 6. Only now. Everything above has committed or the migration has aborted. +ALTER TABLE "example_articles" DROP COLUMN "title";--> statement-breakpoint +ALTER TABLE "example_articles" DROP COLUMN "slug"; +``` + +Take the exact constraint and index **names** from the generated file rather than +retyping them - they are derived from your table name and clamped to Postgres' +63-character limit, and a name that does not match the one Drizzle expects makes +every future diff noisy. + +### Before you run it + +- The base table's old unique slug index goes away with the column. That is + correct: uniqueness moves from "globally" to "per language". +- The `en` language row has to exist. Step 4 says so explicitly rather than + letting `languageId` come out `NULL`. +- Run it against a copy of production first. The verification step turns a data + loss into a failed migration, which is the whole point, but a failed migration + is still better discovered on a copy. + +## Turning localization off + +Destructive, and never generated automatically. + +Reverting means choosing **one** language's values to keep, because a single +column cannot hold several. There is no correct default for that choice, so the +engine does not make one. Write it by hand: + +1. Add the columns back to the base table, nullable. +2. `UPDATE ... FROM` the translation table for the one locale you are keeping. +3. Verify the count, exactly as above. +4. Tighten the columns to `NOT NULL` and add the unique slug index back. +5. Drop the translation table. + +Everything in every other language is gone at step 5. Export it first if it might +matter. + +## Renaming a locale code + +Safe. Translations reference `core_languages.id`, not `.code`, so changing a code +is one `UPDATE` to one row and no translation row moves. + +Update the content type's `defaultLocale` to match in the same release - the +[boot check](/docs/dev/content-engine/localization#the-boot-check) will otherwise +refuse to serve, which is the correct outcome for a default locale that no longer +exists. + +## Adding a language + +No migration at all. Create the language in AdminCP → Languages and start writing +translations - the schema does not change when a language does, which is the whole +reason for a row per language rather than a column per language. + +## Testing a migration for real + +The example plugin's Postgres suite replays every committed `example_*` migration +against a throwaway database and then exercises the tables it produced - +concurrent per-locale updates, the cascade, the language restrict, and the unique +slug index: + +```bash +DATABASE_TEST_URL=postgres://postgres:postgres@localhost:5432/vitnode_test \ + pnpm --filter @vitnode/example test +``` + + + It drops and recreates the whole `public` schema, and refuses to run unless the + database name contains "test". Never point it at a development or production + database. + + +A new migration only has to be added to `EXAMPLE_MIGRATIONS` in +`plugins/example/src/const.ts`; both database suites read that list. diff --git a/apps/docs/content/docs/dev/content-engine/localization.mdx b/apps/docs/content/docs/dev/content-engine/localization.mdx new file mode 100644 index 000000000..fa3005b47 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localization.mdx @@ -0,0 +1,311 @@ +--- +title: Localization +description: One opt-in block that moves the text fields you name into a generated per-language table, with real columns and one version per locale. +icon: Languages +--- + +An article exists in English and in Polish. Same author, same category, same +"featured" flag - a different title, a different URL and a different body. + +`localization` is the block that models that. + +```ts title="src/content/localized-article.ts" +export const localizedArticleContentType = defineContentType({ + id: "example.localized-article", + tableName: "example_localized_articles", + + localization: { + enabled: true, + defaultLocale: "en", + }, + + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + body: field.textarea({ localized: true, required: true }), + + featured: field.boolean({ defaultValue: false }), + }, + + admin: { + label: { + plural: "Example Localized Articles", + singular: "Example Localized Article", + }, + }, +}); +``` + +Two tables come out of that, not one: + +```text +example_localized_articles id, createdAt, updatedAt, featured +example_localized_articles_translations itemId, languageId, version, + createdAt, updatedAt, + title, slug, body +``` + +A content type without the block generates the same single table, the same +routes and the same wire shapes it always did. + + + Localization currently cannot be combined with `publication`, `editorial`, + `publicApi` or `search` - each combination is refused at definition time with a + message naming the stage that lifts it. See [Stage 5A + boundaries](#stage-5a-boundaries). + + +## This is not UI translation + +VitNode already has an i18n system, and it is a different thing. + +| | UI translations | Content Engine translations | +| --- | --- | --- | +| What they translate | Buttons, labels, emails, error messages | The records themselves | +| Where they live | `src/locales/*.json`, `core_languages_words` | A generated `
_translations` table | +| Who writes them | Developers and translators, in source control or AdminCP | Editors, through the content type's API | +| Shape | Key to string | Real typed columns, one row per language | + +Both read the same registry: **`core_languages` is the source of truth for what a +language *is*.** The Content Engine has no language list of its own, does not +invent locale codes, and stores a foreign key to a row an admin created in +AdminCP → Languages. + +## What it turns on + +| | | +| --- | --- | +| A generated translation table | One per localized content type. Real columns, real `NOT NULL`, real indexes | +| A shared/localized split | Every field you did not mark stays on the base table, exactly as before | +| [Per-locale versioning](#optimistic-locking-per-locale) | Each translation has its own `version`. Editing Polish cannot conflict with English | +| Locale-scoped slug uniqueness | `/en/about` and `/pl/about` both work; two English `about` rows do not | +| Atomic creation | A record and its default-locale translation commit together, or not at all | +| A [translation service](/docs/dev/content-engine/translation-service) | Reads and writes, transaction-aware, with structured conflicts | +| Five routes | `GET`/`POST`/`PUT`/`DELETE` under `/{id}/translations` | +| A boot check | An install whose `defaultLocale` does not exist refuses to serve | + +## The block + +```ts +localization: { + enabled: true, + + // Required. Must name a row in `core_languages`. + defaultLocale: "en", + + // Optional. What a public read does for a locale with no translation. + fallback: "none", +} +``` + +| Key | Type | Default | What it does | +| --- | --- | --- | --- | +| `enabled` | `true` | - | Literal `true`. Omit the block to stay non-localized | +| `defaultLocale` | `string` | - | The locale every record is created in, and the one translation it can never lose | +| `fallback` | `"none" \| "default"` | `"none"` | Reserved for Stage 5C. Resolved now so the configuration is stable before anything reads through it | + +`defaultLocale` is checked twice. Its **shape** is checked at definition time - +it has to look like a locale code and fit `varchar(32)`. Whether it names a real, +enabled row in `core_languages` is a fact about the *installation*, so it is +checked [once at boot](#the-boot-check) instead. + +`fallback` does nothing yet, and this page will not pretend otherwise. Nothing in +Stage 5A reads through it; it exists so that a content type declared today does +not change public behaviour the moment Stage 5C lands. `"none"` is the default +because it is the only answer that cannot silently publish the wrong language. + +## Which fields can be localized + +Three kinds, and they are the ones that hold prose: + +```ts +title: field.text({ localized: true }), +body: field.textarea({ localized: true }), +slug: field.slug({ localized: true, source: "title" }), +``` + +Everything else stays shared, and the builders do not take the argument - so +`field.boolean({ localized: true })` is a compile error, not a runtime surprise. +See [Localized fields](/docs/dev/content-engine/localized-fields) for why each +one is on the list it is on. + +## Shared and localized + +One rule, applied everywhere: **a field is localized when you say so, and shared +otherwise.** The split drives table generation, schema generation, the services, +the routes and the migration, and every one of them reads it from the same +helper - so there is no way for a column to be generated on one table and read +from the other. + +What lands where: + +| Base table | Translation table | +| --- | --- | +| `id`, `createdAt`, `updatedAt` | `itemId`, `languageId` (the primary key) | +| Every shared field | Every localized field | +| | `version`, `createdAt`, `updatedAt` | + +A localized field is **not** a column on the base table, which has consequences +worth knowing up front: + +- it cannot appear in `admin.list.columns`, `orderableFields`, `searchableFields` + or `form.fields`, +- it cannot be `admin.titleField`, +- it cannot appear in `indexes`, +- it is absent from `schemas.create`, `schemas.update` and `schemas.select`. + +All five are compile errors *and* runtime errors. Localized values get their own +AdminCP surface in Stage 5B; until then there is nowhere on the base form for +them to go, and a silently-dropped title is worse than a refused definition. + +## Optimistic locking per locale + +Every translation row carries its own `version`, and every write names the +version it started from: + +```ts +await translations.update( + itemId, + "pl", + { title: "Nowy tytuł" }, + { expectedVersion: 3 }, +); +``` + +The version is part of the `UPDATE`'s `WHERE`, so two editors racing produce one +statement that matches and one that does not - with no read-then-write window +between them. + +The important half is what does **not** happen: an edit to Polish never conflicts +with an edit to English. They are different rows, keyed by +`(itemId, languageId)`, with independent counters. Two translators working in two +languages at the same time is the normal case, not a race. + +A no-op is still a success and changes nothing at all: no version bump, no +`updatedAt`, no write. See [Translation +service](/docs/dev/content-engine/translation-service#no-ops). + +## The default translation + +Creating a localized record creates its default-locale translation in the same +transaction: + +```ts +const { row, translation } = await localizedService.create({ + shared: { featured: true }, + translation: { body: "...", slug: "hello", title: "Hello" }, +}); +``` + +Either both exist or neither does. That invariant is what every later stage leans +on - a record always resolves in at least one language, so a locale tab strip +always has something to show and a public read always has something to fall back +to. + +Two rules protect it: + +- **A record is created in its default locale.** Passing another one is refused, + because the record would then have no default translation at all. +- **The default translation cannot be deleted.** The route answers a structured + `CONTENT_DEFAULT_TRANSLATION_REQUIRED` 409. Delete the record instead. + +Deleting the record removes its translations through the foreign key's `ON DELETE +CASCADE` - one statement, no loop over locales, and no window in which a +translation outlives its row. + +## Locale-scoped slugs + +A localized slug is unique **within one language**: + +```text +/en/about ✓ +/pl/about ✓ same string, different language +/en/about ✗ 409, the English one is taken +``` + +That is a `UNIQUE (languageId, slug)` index on the translation table, not on the +base table. Each language derives its own slug from its own title, using the same +`slugify` the rest of the engine uses - there is no second slug algorithm. + +## Language resolution + +Locales travel as strings (`pl`, `pt-BR`) and are resolved to +`core_languages.id` internally. A numeric language id is never the public +identifier of anything. + +Matching is case-insensitive and the **stored** code is what comes back, so +`/PL/` and `/pl/` write to the same row and the response says `pl`. The registry +is loaded once per request, so resolving twenty locales costs one query rather +than twenty. + +Two ways a locale can fail, answered differently because they mean different +things: + +| | | | +| --- | --- | --- | +| No such row in `core_languages` | `404` | There is nothing to address | +| Listed in the app's `i18n.locales` with `enabled: false` | `409 CONTENT_LANGUAGE_DISABLED` | It exists, and this install switched it off | + +A disabled language is **readable and not writable**. Its content is already in +the database, and hiding it would make it unrecoverable; growing more of it in a +language nothing renders is the part that gets refused. + +Deleting a language that content is written in is refused by Postgres itself - +the foreign key is `ON DELETE RESTRICT`. That is deliberately different from +`core_languages_words`, which cascades: losing a UI string is an inconvenience, +losing every article written in a language is not. + +## The boot check + +A content type definition is plain data built at import time, long before there +is a database connection - so "does `core_languages` have a row for `en`" cannot +be a definition-time check. + +It runs once per process instead, on the first request, before any handler: + +```text +[Content Engine] Localized content types have an unusable default locale: + example.localized-article -> localization.defaultLocale "en" does not exist in core_languages +``` + +Loud on purpose. A broken `defaultLocale` means *no record of that type can be +created at all*, which is not a degraded mode worth booting into. It names every +offender at once rather than failing on the first, and it is skipped entirely +when no content type is localized - an install with none never touches the +languages table because of it. + +## Stage 5A boundaries + +Localization lands as infrastructure. The stages that read *through* it are not +here yet, and the honest failure for that is a refused definition rather than a +content type that quietly runs Stage 1-4 logic against the base table while +pretending its localized fields do not exist. + +| Combination | Refused until | Why | +| --- | --- | --- | +| `localization` + `publication` | Stage 5B | A localized record has one status per *language*. Publishing the English draft must not put an empty Polish page on the internet | +| `localization` + `editorial` | Stage 5B | A revision would snapshot the base row only, so restoring it would silently drop every translation | +| `localization` + `publicApi` | Stage 5C | A public read has to resolve a locale and decide what to do when a translation is missing | +| `localization` + `search` | Stage 5D | One document per record would index a single language and rank every other one as a miss | + +Each is a `ContentEngineError` at definition time, with the stage in the message. + +## The roadmap + +| Stage | What it adds | +| --- | --- | +| **5A** (this one) | Tables, types, schemas, language resolution, translation service, per-locale locking, atomic create, routes, migrations | +| **5B** | AdminCP locale tabs, completeness badges, per-locale publication status, per-locale revisions, `can_translate` | +| **5C** | Locale-aware public API, fallback resolution, locale-aware cache tags | +| **5D** | Per-locale search documents, `hreflang`, localized sitemap | + +Explicitly outside all four: locale-specific relations, localized media, AI +translation, translation memory, external TMS integration, and migrating the blog +plugin onto the Content Engine. + +## Where to next + +- [Localized fields](/docs/dev/content-engine/localized-fields) - which kinds, and why the others are refused +- [Translation tables](/docs/dev/content-engine/translation-tables) - the generated schema, keys and indexes +- [Translation service](/docs/dev/content-engine/translation-service) - every method, and every conflict it can raise +- [Localization migrations](/docs/dev/content-engine/localization-migrations) - the generated migration, and how to localize an existing content type safely diff --git a/apps/docs/content/docs/dev/content-engine/localized-fields.mdx b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx new file mode 100644 index 000000000..39ff0d978 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localized-fields.mdx @@ -0,0 +1,191 @@ +--- +title: Localized fields +description: Which field kinds can hold a translation, why the rest cannot, and the two slug rules that catch the mistakes nobody notices. +icon: Type +--- + +`localized: true` moves a field's value off the base table and into the generated +translation table, one row per language. + +```ts +title: field.text({ localized: true, required: true }), +``` + +Three kinds accept it. + +## The three kinds + +| Kind | Column on the translation table | Notes | +| --- | --- | --- | +| `text` | `varchar(maxLength)`, default 255 | One line of prose. The usual title | +| `textarea` | `text` | Long prose. The usual body | +| `slug` | `varchar(maxLength)`, default 160 | Unique **per language**. See [the slug rules](#the-two-slug-rules) | + +Every other declaration works exactly as it did, and every modifier a field +already had still applies - `required`, `nullable`, `minLength`, `maxLength` all +carry through to the translation table's column: + +```ts +title: field.text({ localized: true, required: true, maxLength: 200 }), +// -> "title" varchar(200) NOT NULL + +body: field.textarea({ localized: true, nullable: true }), +// -> "body" text +``` + +## Which kinds are refused, and why + +The builders for these do not take a `localized` argument, so passing one is a +**compile error**: + +```ts +// @ts-expect-error - each of these is rejected by the type of the argument +field.boolean({ localized: true }); +field.number({ integer: true, localized: true }); +field.dateTime({ localized: true }); +field.enum({ localized: true, values: ["draft", "review"] }); +field.user({ localized: true }); +field.relation({ localized: true, target: () => otherContentType }); +``` + +Not an oversight in each case - a decision: + +- **`boolean`, `number`, `dateTime`.** A per-locale `true`, `42` or timestamp is + not a translation of anything. If English and Polish genuinely need different + numbers, they are different facts and want different fields. +- **`enum`.** Its *values* are identifiers that everything filters, sorts and + branches on, so they have to be the same in every language. Its *labels* are UI + strings, which the [ordinary i18n system](/docs/dev/i18n) already translates - + that is the split, and it is the right one. +- **`relation` and `user`.** Foreign keys. A per-locale relation is a real + feature and it is explicitly out of scope for the whole of Stage 5; a relation + stays shared, and the *related* content type can be localized in its own right. + +`defineContentType` re-checks the same rule at runtime, because a JavaScript +caller or a cast can reach it with anything at all: + +```text +[Content Engine] example.article: Field "views" is `localized: true` but its kind +is "number". Only slug, text, textarea fields can be localized - an enum's +identifiers have to be the same in every language, and a relation's target is +shared. +``` + +## `localized` needs `localization` + +Marking a field without the block is refused: there would be no translation table +for the value to live in. + +```text +[Content Engine] example.article: Field "title" is `localized: true` but the +content type has no `localization: { enabled: true, defaultLocale }` block, so +there is no translation table for it to live in. +``` + +And the reverse: a `localization` block with no localized field is refused too, +because the generated table would hold nothing but its own keys. + +## The two slug rules + +A localized slug and its source have to agree about *where the value lives*. +Both mistakes are silent data bugs rather than crashes, which is why they are +compile-time and definition-time errors. + +### A localized slug needs a localized source + +```ts +// ✗ Every language would derive the same URL from the one shared name. +name: field.text({ required: true }), +slug: field.slug({ localized: true, source: "name" }), + +// ✓ +title: field.text({ localized: true, required: true }), +slug: field.slug({ localized: true, source: "title" }), +``` + +### A shared slug cannot come from a localized source + +```ts +// ✗ `title` has a different value in every language, so there is no single +// value to derive one URL from. +title: field.text({ localized: true, required: true }), +slug: field.slug({ source: "title" }), +``` + +Either localize the slug too, or point it at a shared text field. + +### A slug with no source + +Perfectly fine, localized or not - it just means the caller always supplies it, +so it is `required` in the translation payload: + +```ts +heading: field.text({ localized: true, required: true }), +slug: field.slug({ localized: true }), // required in `values` +``` + +## Reserved names + +A localized field cannot be called `itemId`, `languageId` or `version` - those +are columns the translation table always generates: + +```text +[Content Engine] example.article: Localized field "version" collides with a +generated translation column. Rename it - the translation table always carries +itemId, languageId, version, createdAt, updatedAt. +``` + +`createdAt` and `updatedAt` are already reserved for every content type, localized +or not. + +A **shared** field called `itemId` is fine: it lands on the base table, where +nothing generated claims that name. + +## What the types know + +The partition is visible in the type system, so a localized value cannot be +passed where a shared one is expected: + +```ts +type Article = typeof localizedArticleContentType; + +ContentLocalizedFieldName
; // "title" | "slug" | "body" +ContentSharedFieldName
; // "featured" + +ContentSharedValues
; // { featured?: boolean } +ContentLocalizedValues
; // { title: string; slug?: string; body: string } + +ContentCreateInput
; // === ContentSharedValues
+ContentSelect
; // { id, createdAt, updatedAt, featured } +``` + +For a content type without localization, `ContentLocalizedFieldName` is `never` +and `ContentLocalizedValues` is `{}` - which is what makes a `translation:` key +impossible to fill in by accident on a Stage 1-4 definition, and what keeps every +existing type exactly as it was. + +## Where a localized field may not appear + +Everything on this list addresses a column on the *base* table: + +```ts +admin: { + list: { + columns: ["title"], // ✗ + orderableFields: ["title"], // ✗ + searchableFields: ["title"], // ✗ + }, + form: { fields: ["title"] }, // ✗ + titleField: "title", // ✗ +}, +indexes: [{ on: ["title"] }], // ✗ +``` + +All six are compile errors, and all six are runtime errors as well. The defaults +skip localized fields automatically, so a localized content type that says nothing +about `admin.list` gets a sensible shared-only list without having to opt out of +anything. + +`admin.titleField` falls back to `null` when every text field is localized. A +toast whose wording depended on the reading admin's locale would be worse than no +title at all; Stage 5B gives the AdminCP a locale-aware one. diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index f09f7373a..a47740ede 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -19,6 +19,11 @@ "revisions", "preview", "scheduling", + "localization", + "localized-fields", + "translation-tables", + "translation-service", + "localization-migrations", "admincp", "permissions", "events", diff --git a/apps/docs/content/docs/dev/content-engine/schemas.mdx b/apps/docs/content/docs/dev/content-engine/schemas.mdx index d4a51293e..cd93c9a55 100644 --- a/apps/docs/content/docs/dev/content-engine/schemas.mdx +++ b/apps/docs/content/docs/dev/content-engine/schemas.mdx @@ -167,6 +167,18 @@ the definition, since a definition holds `target` thunks and cannot cross the server/client boundary. `buildFormSchemaFromSpec` also folds an existing row in as Zod defaults, which is how the edit dialog prefills. +## translation + +`null` unless the content type is localized. When it is, `schemas.translation` +carries the per-language half - `create`, `update`, `select`, `selectMeta`, +`params` and the two envelopes - built from the localized fields only, while +`create`, `update` and `select` above narrow to the shared ones. + +Content values live under `values`, and the locale and `expectedVersion` sit +beside them rather than inside, so `values` stays a strict object of the content +type's own fields. See [Translation +service](/docs/dev/content-engine/translation-service#request-and-response-shapes). + ## Why not drizzle-zod A Drizzle column cannot tell you whether a `varchar` should render as a diff --git a/apps/docs/content/docs/dev/content-engine/translation-service.mdx b/apps/docs/content/docs/dev/content-engine/translation-service.mdx new file mode 100644 index 000000000..06bfef86c --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/translation-service.mdx @@ -0,0 +1,354 @@ +--- +title: Translation service +description: The repository behind a localized Content Type - every read, every write, every conflict it can raise, and the five generated routes on top. +icon: Replace +--- + +Two services come with a localized content type. They do different jobs: + +| | | +| --- | --- | +| `model.localizedService(c)` | Creates a base row **and** its default translation, atomically | +| `model.translationService(c)` | Everything else: read, add, update and remove one translation | + +Both are `undefined` for a content type without localization, so TypeScript +refuses the call until you have checked. + +## Atomic creation + +```ts +const { row, translation } = await model.localizedService(c).create({ + shared: { featured: true }, + translation: { + body: "The English body", + title: "Hello World", + // `slug` omitted - derived from this language's title + }, +}); + +row.id; // 7 +translation.locale; // "en" +translation.version; // 1 +translation.values.slug; // "hello-world" +``` + +One transaction, two inserts. Either both exist or neither does: + +| Failure | Result | +| --- | --- | +| The base insert fails | No translation, and no base row | +| The translation insert fails | The base row is rolled back with it | +| A localized slug is already taken | No base row | +| The default language has just been removed | Nothing at all, and no orphan | + +That last one is why the default language is resolved *inside* the transaction and +before the base insert: a record with no translation is exactly the state the +invariant exists to prevent. + +### The locale + +Defaults to the content type's `defaultLocale`, and today may only be that: + +```ts +// ✓ the default, explicitly +await localizedService.create(input, { locale: "en" }); + +// ✗ refused +await localizedService.create(input, { locale: "pl" }); +// A Example Localized Article is created in its default locale "en", not "pl". +// Create it first, then add the "pl" translation. +``` + +A record created straight into Polish would have no default translation, and every +later stage would need an "unless it was created in another locale" branch. + +### Joining a transaction + +```ts +await db.transaction(async tx => { + const { row } = await localizedService.create(input, { tx }); + await somethingElse(row.id, tx); +}); +``` + +Same `tx` convention as every other Content Engine service. + +## Translation reads + +```ts +const translations = model.translationService(c); + +await translations.findByLocale(itemId, "pl"); +await translations.findByLanguageId(itemId, 2); +await translations.findManyForItem(itemId); +await translations.exists(itemId, "pl"); +await translations.resolveDefaultLanguage(); +``` + +| Method | Returns | +| --- | --- | +| `findByLocale(itemId, locale)` | One translation with its `values`, or `null` - including for an unknown locale | +| `findByLanguageId(itemId, languageId)` | The same, keyed by the stored id | +| `findManyForItem(itemId)` | **Metadata only**, one entry per existing translation, ordered by language | +| `exists(itemId, locale)` | `boolean`, without loading a single value | +| `resolveDefaultLanguage()` | The `{ id, locale, isDefault, isEnabled }` this content type creates records in | + +`findManyForItem` deliberately does not return values. A locale strip needs to +know which languages exist and how stale each one is; dragging every article body +in every language across the wire to find that out is the thing it is designed not +to do. It costs one query for the rows plus one for the language registry - never +one per translation. + +One translation row comes back nested: + +```ts +{ + itemId: 7, + languageId: 2, + locale: "pl", + version: 3, + createdAt: Date, + updatedAt: Date, + values: { title: "Witaj", slug: "witaj", body: "..." }, +} +``` + +The nesting is not decoration: it keeps a localized field called `version` or +`locale` from being confused with the metadata of the row that holds it, and it +makes the update request body and the response the same shape. + +## Translation writes + +```ts +await translations.create(itemId, "pl", { body: "...", title: "Witaj" }); + +await translations.update( + itemId, + "pl", + { title: "Nowy tytuł" }, + { expectedVersion: 3 }, +); + +await translations.delete(itemId, "pl", { expectedVersion: 4 }); +``` + +| Method | Version | On success | On failure | +| --- | --- | --- | --- | +| `create` | starts at 1 | the new row | `ContentTranslationExists`, `ContentTranslationItemMissing`, `ContentLanguageError` | +| `update` | `+ 1`, or unchanged on a no-op | `{ changed, changedFields, row, version }` | `null` if the translation is missing, `ContentTranslationVersionConflict` if it moved | +| `delete` | guarded | the removed row | `null` if already gone, `ContentTranslationVersionConflict`, `ContentDefaultTranslationRequired` | + +All three take `{ tx }`, and all three validate their payload through the +generated strict schema before touching the database - so an invalid object never +costs a query. + +### What the service does not do + +No event, no cache tag, no search document, no revision. That is not a gap, it is +the contract: a repository that emitted events could not be called inside somebody +else's transaction, which is exactly what atomic create needs it to be. Stage 5B +orchestrates the effects on top, the way `contentEditorialEffects` already does +for the base row. + +## Optimistic locking + +The version is part of the statement, not checked before it: + +```sql +UPDATE "..._translations" + SET "title" = $1, "version" = "version" + 1 + WHERE "itemId" = $2 AND "languageId" = $3 AND "version" = $4 +RETURNING ... +``` + +Two editors racing produce one statement that matches and one that does not, with +no read-then-write window between them. A delete is the same shape. + +And crucially: **the lock is per locale.** + +```text +English at v3, Polish at v1 + → update English with expectedVersion 3 ✓ English becomes v4 + → update Polish with expectedVersion 1 ✓ Polish becomes v2 +``` + +Neither is told the other language moved. They are different rows. + +```text +Two writers, both holding Polish v2 + → one succeeds, Polish becomes v3 + → the other gets CONTENT_TRANSLATION_VERSION_CONFLICT +``` + +### No-ops + +An update whose values match what is stored is a **success that changes nothing**: + +```ts +{ changed: false, changedFields: [], row: , version: } +``` + +No `UPDATE` is issued at all, so: + +- the version does not move, +- `updatedAt` does not move, +- and a stale `expectedVersion` is *not* an error - there is nothing to overwrite, + so there is nothing to conflict about. + +Values are normalised before the comparison, so re-sending the stored slug in a +different case counts as no change rather than as a pointless write. Same +semantics the base service has always had. + +## Conflicts + +Five distinct outcomes, because a client that cannot tell them apart can only show +"something went wrong": + +| Situation | Status | Code | +| --- | --- | --- | +| No such base record | `404` | - | +| Unknown locale | `404` | - | +| Locale disabled in this install | `409` | `CONTENT_LANGUAGE_DISABLED` | +| That locale already has a translation | `409` | `CONTENT_TRANSLATION_EXISTS` | +| The translation moved since you read it | `409` | `CONTENT_TRANSLATION_VERSION_CONFLICT` | +| Deleting the default translation | `409` | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | +| A localized slug is taken **in this language** | `409` | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | + +The version conflict names the locale, which is the one thing a locale tab strip +has to know to reload the right tab: + +```json +{ + "code": "CONTENT_TRANSLATION_VERSION_CONFLICT", + "contentTypeId": "example.localized-article", + "itemId": 7, + "locale": "pl", + "expectedVersion": 3, + "currentVersion": 5 +} +``` + +Parse one with the exported helper rather than by hand: + +```ts +import { parseContentTranslationConflict } from "@vitnode/core/content"; + +const conflict = parseContentTranslationConflict(await response.text()); +if (conflict?.code === "CONTENT_TRANSLATION_VERSION_CONFLICT") { + // reload the `conflict.locale` tab +} +``` + +This is its own discriminated union rather than three more members of the Stage 4 +`zodContentConflict`: that one is the contract every existing generated client is +built from, and a translation route is new, so it can carry a shape that names the +locale in every arm. + +The driver's message never reaches a client. A `23505` becomes +`CONTENT_TRANSLATION_UNIQUE_CONFLICT`; the constraint name, the column and the +value stay on the server. + +## The generated routes + +Five, mounted under the same module and the same permissions as the content type's +CRUD routes: + +```text +GET /api/{pluginId}/admin/content/{module}/{id}/translations +GET /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} +POST /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} +PUT /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} +DELETE /api/{pluginId}/admin/content/{module}/{id}/translations/{locale} +``` + +| Route | Permission | Body | +| --- | --- | --- | +| `GET /{id}/translations` | `can_view` | - | +| `GET /{id}/translations/{locale}` | `can_view` | - | +| `POST /{id}/translations/{locale}` | `can_edit` | `{ values }` | +| `PUT /{id}/translations/{locale}` | `can_edit` | `{ expectedVersion, values }` | +| `DELETE /{id}/translations/{locale}` | `can_delete` | `{ expectedVersion }` | + +Existing permissions on purpose. A dedicated `can_translate` means a migration for +every role in every install, and doing that before the AdminCP has a translation +screen to gate would ship a checkbox that governs nothing anybody can see. It +arrives in Stage 5B, with the UI it belongs to. + +`PUT`, not `PATCH`: the Next.js API route handler exports no `PATCH`. + +### Request and response shapes + +`POST` and `PUT` keep content values in `values` and everything else beside them: + +```json +{ "expectedVersion": 3, "values": { "title": "Nowy tytuł" } } +``` + +`expectedVersion`, `locale`, `itemId` and `languageId` are **rejected inside +`values`** - it is a strict object of the content type's own localized fields, and +accepting identity or transport there would make all four mass-assignable. + +`GET /{id}/translations/{locale}`, `POST` and `PUT` all return the same detail +shape: + +```json +{ + "itemId": 7, + "languageId": 2, + "locale": "pl", + "version": 3, + "createdAt": "2026-08-06T10:00:00.000Z", + "updatedAt": "2026-08-06T10:04:00.000Z", + "values": { "title": "Witaj", "slug": "witaj", "body": "..." } +} +``` + +`PUT` wraps it: `{ changed, row }`. The list route returns metadata only: + +```json +{ + "edges": [ + { "itemId": 7, "languageId": 1, "locale": "en", "version": 4, "createdAt": "...", "updatedAt": "..." }, + { "itemId": 7, "languageId": 2, "locale": "pl", "version": 3, "createdAt": "...", "updatedAt": "..." } + ] +} +``` + +Every shape above is in the OpenAPI document, including the 409 union. + +### Security + +- Every route sits under `/admin/`, so the staff session middleware runs, and + every one carries an explicit `adminStaffPermission`. +- The module the route is mounted in fixes which table is read, so a locale in the + URL can never reach another content type's translation. +- Every query is scoped by both the item and the language. +- `languageId` is never accepted as content data - it is resolved from the locale + in the path. +- System columns (`itemId`, `languageId`, `version`, `createdAt`, `updatedAt`) are + absent from every write schema. +- A translation is never addressed by a row id of its own; identity is + `contentType + itemId + locale`. +- No public route reads translations at all in Stage 5A. + +## Language resolution + +Locales arrive as strings and are resolved against `core_languages`: + +```ts +import { resolveContentLanguage } from "@vitnode/core/content/server"; + +const language = await resolveContentLanguage(c, { + locale: "PL", + requireEnabled: true, +}); +// { id: 2, locale: "pl", isDefault: false, isEnabled: true } +``` + +- Case-insensitive, and the **stored** code comes back. +- `requireEnabled` is the write path. Reading a disabled locale is allowed; + writing one is refused. +- The registry is loaded once per request, so resolving many locales is one query. +- Pass `tx` when you are inside a transaction. This is not an optimisation: a pool + whose only free connection is held by your transaction would otherwise wait + forever for one. diff --git a/apps/docs/content/docs/dev/content-engine/translation-tables.mdx b/apps/docs/content/docs/dev/content-engine/translation-tables.mdx new file mode 100644 index 000000000..cf520242f --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/translation-tables.mdx @@ -0,0 +1,229 @@ +--- +title: Translation tables +description: One generated table per localized Content Type - real columns, a composite key, two foreign keys and a per-language unique index. +icon: Table2 +--- + +A localized content type generates two tables. This page is the second one. + +```ts title="src/database/localized-articles.ts" +import { createContentModel } from "@vitnode/core/content/server"; + +import { localizedArticleContentType } from "@/content/localized-article"; + +export const localizedArticleContent = createContentModel( + localizedArticleContentType, +); + +// Two exports, not one. Drizzle Kit discovers each table from its export when it +// globs the built `dist/src/database/*.js`, so the translation table needs its +// own or the migration would be generated without it. +export const example_localized_articles = localizedArticleContent.table; +export const example_localized_articles_translations = + localizedArticleContent.translationTable; +``` + +`translationTable` is `null` for a content type without localization, so nothing +about an existing `src/database/*.ts` has to change. + +## The generated schema + +```sql +CREATE TABLE "example_localized_articles" ( + "id" serial PRIMARY KEY NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "featured" boolean DEFAULT false NOT NULL +); + +CREATE TABLE "example_localized_articles_translations" ( + "itemId" integer NOT NULL, + "languageId" integer NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" text NOT NULL, + CONSTRAINT "example_localized_articles_translations_item_id_language_id_pk" + PRIMARY KEY("itemId","languageId") +); +``` + +Note what is *not* on the base table: `title`, `slug` and `body`. A localized +field has exactly one column, and it is on the translation table. + +## Why one table per Content Type + +The three alternatives, and what each one costs: + +| Design | What breaks | +| --- | --- | +| A JSONB blob on the base row | No column types, no `NOT NULL`, no unique slug index, no useful Drizzle inference, and a migration nobody can review | +| `title_en`, `title_pl` columns | Adding a language is a schema migration, and every query has to be written per language | +| One universal EAV table | Every value is `text`, every read is a pivot, and a unique slug per language is not expressible | + +A generated per-type table keeps all of it: real Postgres types, real +constraints, ordinary indexes, ordinary joins, `$inferSelect` that knows what +`title` is, and a `drizzle-kit generate` diff a human can read. + +It generalizes what the blog plugin does by hand - one language row per record +per field, keyed to `core_languages` - without the EAV shape that makes a +per-language unique index impossible. + +## Keys + +```sql +PRIMARY KEY ("itemId", "languageId") +``` + +A composite key rather than a surrogate `id`: the identity of a translation *is* +the record plus the language. A serial primary key would make "one translation per +locale" a constraint somebody could forget to add. + +It also means a translation is never addressed by a row id of its own. External +identity is always: + +```text +contentType + itemId + locale +``` + +The name is generated deterministically and clamped to Postgres' 63-character +identifier limit with a fingerprint of the full name, the same way index names +are - so two long table names that differ only past character 63 cannot collapse +into one constraint. + +## Foreign keys + +```sql +"itemId" → ."id" ON DELETE cascade ON UPDATE cascade +"languageId" → "core_languages"."id" ON DELETE restrict ON UPDATE cascade +``` + +The two `ON DELETE` behaviours are opposites on purpose: + +- **Cascade from the record.** A record's translations are part of the record, so + removing it takes them with it in one statement. There is no loop over locales + anywhere in the engine, and no window in which a translation outlives its row. +- **Restrict from the language.** Deleting a language must not silently delete + every article written in it. Postgres refuses with `23503`, the AdminCP language + screen reports it, and a person decides what happens to the content first. + +That second one is deliberately different from `core_languages_words`, which +cascades. Losing a UI string when a language goes is an inconvenience; losing +every article is not. + +`languageId` references `core_languages.id` rather than `.code`, unlike +`core_languages_words` and `core_users.language`. A numeric key is four bytes in +a composite primary key that every translation read uses, and renaming a locale +code does not have to rewrite every translation row. The **code** is still the +only thing that appears in a URL or a response. + +## Indexes + +| Index | Serves | +| --- | --- | +| `PRIMARY KEY (itemId, languageId)` | One translation by record and language, and every translation of one record (a B-tree serves any prefix of its key) | +| `
_translations_language_id_idx` | "Every row in Polish", and the lookup a language delete has to make before Postgres allows it | +| `
_translations_language_id__key` | The unique URL per language. One per localized slug field | + +Nothing is repeated: the composite key already covers `(itemId, languageId)` and +`itemId`, so neither gets an index of its own. + +### Locale-scoped slug uniqueness + +```sql +CREATE UNIQUE INDEX "example_localized_articles_translations_language_id_slug_key" + ON "example_localized_articles_translations" ("languageId", "slug"); +``` + +That is the whole reason for the design: + +```text +(1, "about") English "about" ✓ +(2, "about") Polish "about" ✓ same string, different language +(1, "about") again in English ✗ 409 +``` + +A localized slug is `NOT NULL` with no default, exactly like a shared one - a +translation nobody can address by URL is not worth allowing - so Postgres' "nulls +are distinct" behaviour never comes into play here. + +## Column types + +Compiled from the same descriptors the base table uses, by the same builder: + +| Field | Column | +| --- | --- | +| `field.text({ localized: true, maxLength: 200 })` | `varchar(200)` | +| `field.textarea({ localized: true })` | `text` | +| `field.slug({ localized: true })` | `varchar(160) NOT NULL` | +| `nullable: true` | the column is nullable | +| `required: true` | `NOT NULL` | + +`version` is `integer NOT NULL DEFAULT 1`, mirroring the editorial column on the +base table. It is never written by a create or an update payload: the column +default makes it 1, and the conditional `UPDATE` that guards on it is the only +thing that moves it. + +## The model API + +```ts +const model = createContentModel(localizedArticleContentType); + +model.table; // the base pgTable +model.translationTable; // the translation pgTable, or null +model.columns; // base column map - shared fields only +model.translationColumns; // translation column map, or null +model.localization; // the resolved config, always present +model.translationSchemas; // the per-language schemas, or null +model.translationService(c); // the translation repository, or undefined +model.localizedService(c); // atomic create, or undefined +``` + +The four nullable members follow the convention `publicService` and +`editorialService` already set: `null`/`undefined` rather than a stub that throws, +so a check reads naturally in code that does not know which content type it was +handed - and so TypeScript refuses the call until the check has been made. + +`model.localization` is always there, so `model.localization.enabled` is the one +flag route builders and background work branch on. + +The lower-level builders are exported too, for the rare case that wants them: + +```ts +import { + contentTranslationTableColumns, + createContentTranslationTable, +} from "@vitnode/core/content/server"; + +const translationTable = createContentTranslationTable(definition, { table }); +``` + +## Table names + +`_translations`, clamped to 63 characters with a fingerprint: + +```text +example_localized_articles → example_localized_articles_translations +``` + +The registry checks the generated name against every base table *and* every other +generated translation table in the install, so a content type whose own +`tableName` happens to be `example_localized_articles_translations` fails at boot +rather than fighting over a table at migration time. + +## What is not on the table + +Stage 5A stores translation *data* and its version, and nothing else: + +```text +status not yet - per-locale publication is Stage 5B +publishedAt not yet - same +revisions not yet - per-locale history is Stage 5B +``` + +Deliberately absent rather than added early and left unused: a column with no +behaviour behind it is a promise the code does not keep, and Stage 5B's migration +can add them with the same `DEFAULT ... NOT NULL` one-liner that made adding +`status` and `version` to an existing base table safe.