From 0d27f98065d7be77367374d5586c7905963fa5b2 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 17:08:49 +0200 Subject: [PATCH 01/24] feat(content): add delivery definitions and validation Stage 8 opens with the block itself: `delivery` on a content type, and every rule that makes an invalid one fail at definition time rather than at request time. `delivery.enabled` is gated on `publicApi` at the **type** level, not only at boot - a content type with no public API has no public URL, so there is nothing for delivery to be about, and `enabled: true` is a compile error there. Every SEO field reference is checked the same way: it has to be in `publicApi.fields`, of a kind that can fill its slot, and not a repeatable leaf. A `` is rendered into a public page, so it has to be something the public API would already have said out loud. `slugScope` is resolved here and read by the whole delivery layer: it decides whether a historical URL belongs to one language or to the content type. A localized content type with a *shared* slug is refused `redirects`, because every language would answer to the same segment and one retired address would belong to several URLs at once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/conflicts.ts | 44 ++ packages/vitnode/src/content/const.ts | 106 +++ packages/vitnode/src/content/define.ts | 58 +- packages/vitnode/src/content/delivery.ts | 748 ++++++++++++++++++++++ packages/vitnode/src/content/errors.ts | 60 ++ packages/vitnode/src/content/types.ts | 260 +++++++- 6 files changed, 1272 insertions(+), 4 deletions(-) create mode 100644 packages/vitnode/src/content/delivery.ts diff --git a/packages/vitnode/src/content/conflicts.ts b/packages/vitnode/src/content/conflicts.ts index 3e69966b5..aee8f39c4 100644 --- a/packages/vitnode/src/content/conflicts.ts +++ b/packages/vitnode/src/content/conflicts.ts @@ -2,11 +2,15 @@ import { z } from "zod"; import { CONTENT_CONFLICT_CODES, + CONTENT_DELIVERY_CODES, CONTENT_SCHEDULE_CODES, CONTENT_TRANSLATION_CONFLICT_CODES, CONTENT_UNPROCESSABLE_CODES, } from "./const"; +export type ContentDeliveryCode = + (typeof CONTENT_DELIVERY_CODES)[keyof typeof CONTENT_DELIVERY_CODES]; + export type ContentConflictCode = (typeof CONTENT_CONFLICT_CODES)[keyof typeof CONTENT_CONFLICT_CODES]; @@ -103,6 +107,46 @@ export const parseContentTranslationConflict = ( } }; +/** + * The 409 body a write refused by the slug reservation answers with. + * + * Its own schema rather than a third member of {@link zodContentConflict}: that + * union is the contract Stage 4 editorial routes already publish, and widening it + * would change a response schema every generated client is built from. A route + * that can hit the reservation declares this one **alongside** it, so a client + * that only knows the older union still parses the arms it knows. + * + * `locale` is `null` for a content type whose slug is shared, and the locale code + * when the slug is localized - which is exactly the scope the reservation covers. + * There is deliberately no owning-record id: a 409 on a public-facing address must + * not become a way to enumerate records the caller cannot read. + */ +export const zodContentDeliveryConflict = z.object({ + code: z.literal(CONTENT_DELIVERY_CODES.slugReserved), + contentTypeId: z.string(), + locale: z.string().nullable(), + slug: z.string(), +}); + +export type ContentDeliveryConflict = z.infer< + typeof zodContentDeliveryConflict +>; + +/** Reads a delivery conflict out of a response body, or `null`. */ +export const parseContentDeliveryConflict = ( + body: string | undefined, +): ContentDeliveryConflict | null => { + if (body === undefined || body === "") return null; + + try { + const parsed = zodContentDeliveryConflict.safeParse(JSON.parse(body)); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } +}; + /** The 422 body a restore answers with when the snapshot no longer fits. */ export const zodContentUnprocessable = z.object({ code: z.literal(CONTENT_UNPROCESSABLE_CODES.notRestorable), diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 594de9ed5..1db79fcab 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -450,6 +450,112 @@ export const CONTENT_SCHEDULE_CODES = { unsupported: "CONTENT_SCHEDULE_UNSUPPORTED", } as const; +// --------------------------------------------------------------------------- +// Content delivery (Stage 8) +// --------------------------------------------------------------------------- + +/** + * Field kinds `delivery.seo.titleField` may name. + * + * `text` only, and the same reasoning `CONTENT_SEARCH_TITLE_KINDS` gives: a + * `<title>` is one line, a `textarea` in that slot puts a paragraph in a browser + * tab, and a slug is already in the URL the title accompanies. + */ +export const CONTENT_DELIVERY_TITLE_KINDS = ["text"] as const; + +/** Field kinds `delivery.seo.descriptionField` may name. */ +export const CONTENT_DELIVERY_DESCRIPTION_KINDS = ["text", "textarea"] as const; + +/** + * Field kinds `delivery.seo.noIndexField` may name. + * + * `boolean` only: "should a crawler index this" has two answers, and a truthy + * string would make the sitemap's exclusion rule depend on what somebody typed. + */ +export const CONTENT_DELIVERY_NO_INDEX_KINDS = ["boolean"] as const; + +/** + * The `changefreq` values the sitemap protocol defines. + * + * Validated rather than passed through: a crawler ignores an unknown value + * silently, so a typo would be a hint nobody ever receives. + */ +export const CONTENT_SITEMAP_CHANGE_FREQUENCIES = [ + "always", + "hourly", + "daily", + "weekly", + "monthly", + "yearly", + "never", +] as const; + +const sitemapChangeFrequencies: ReadonlySet<string> = new Set( + CONTENT_SITEMAP_CHANGE_FREQUENCIES, +); + +export const isContentSitemapChangeFrequency = ( + value: unknown, +): value is (typeof CONTENT_SITEMAP_CHANGE_FREQUENCIES)[number] => + typeof value === "string" && sitemapChangeFrequencies.has(value); + +/** + * The sitemap protocol's own ceiling: 50,000 URLs in one file. + * + * A delivery sitemap page never returns more than this, and the index helper + * chunks by it - so a content type with a million records produces a sitemap + * index rather than an invalid document. + */ +export const CONTENT_SITEMAP_MAX_URLS = 50_000; + +/** + * How many URLs one `sitemap.list` page returns by default. + * + * Far below the protocol ceiling on purpose: a page is one keyset query plus one + * batched translation read, and 1,000 rows is a response a serverless function + * can hold without thinking about it. A caller that wants a whole 50,000-URL + * file asks for it explicitly. + */ +export const CONTENT_SITEMAP_DEFAULT_PAGE_SIZE = 1_000; + +/** + * The redirect a moved canonical URL answers with. + * + * `308` rather than `301`, and the difference is not cosmetic: `301` lets a + * client rewrite the method to `GET`, `308` does not. A content URL is read with + * `GET` today, so the two behave identically now - and only one of them still + * behaves correctly the day somebody `POST`s to a form under a moved path. + * + * One status, not a configuration knob: every historical URL of every content + * type answers with this, so there is no per-content-type setting to get wrong + * and no reason for two of them to disagree. + */ +export const CONTENT_DELIVERY_REDIRECT_STATUS = 308; + +/** + * How a delivery resolution came out. + * + * `not_found` rather than a `gone` tombstone: the engine has no abstraction that + * distinguishes "deleted on purpose" from "unpublished for now", and a `410` + * that guessed would tell a crawler to forget a URL that is coming back. + */ +export const CONTENT_DELIVERY_RESOLUTIONS = [ + "content", + "not_found", + "redirect", +] as const; + +/** `core_content_slug_history.path` is `varchar(512)`. */ +export const CONTENT_DELIVERY_PATH_MAX_LENGTH = 512; + +/** Machine-readable reasons a delivery write or read was refused. */ +export const CONTENT_DELIVERY_CODES = { + invalidUrl: "CONTENT_DELIVERY_INVALID_URL", + notEnabled: "CONTENT_DELIVERY_NOT_ENABLED", + redirectConflict: "CONTENT_DELIVERY_REDIRECT_CONFLICT", + slugReserved: "CONTENT_DELIVERY_SLUG_RESERVED", +} as const; + /** * Every content type gets the first four staff permissions. `can_publish` is * generated only for content types with `publication: { enabled: true }`, diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index db0bb63c3..0010892a2 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1,6 +1,11 @@ import type { AnyContentTypeDefinition, ContentAdminConfig, + ContentDeliveryConfig, + ContentDeliveryDescriptionField, + ContentDeliveryEnabled, + ContentDeliveryNoIndexField, + ContentDeliveryTitleField, ContentEditorialConfig, ContentEditorialEnabled, ContentFieldDescriptor, @@ -21,6 +26,7 @@ import type { ContentSearchTitleField, ContentTypeDefinition, ResolvedContentAdminConfig, + ResolvedContentDeliveryConfig, ResolvedContentEditorialConfig, ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, @@ -64,6 +70,7 @@ import { CONTENT_TABLE_NAME_PATTERN, isFilterableFieldKind, } from "./const"; +import { resolveContentDelivery } from "./delivery"; import { ContentEngineError } from "./errors"; import { resolveContentIndexes } from "./indexes"; import { @@ -1410,8 +1417,22 @@ export const defineContentType = < TLocalization extends ContentLocalizationConfig | { enabled: false } = { enabled: false; }, + // The whole `delivery` argument, inferred as one type, for the same two reasons + // `TSearch` and `TEditorial` are. Its *constraint* is what enforces the field + // rules - a constraint is checked once `TPublicField` and `TPublicEnabled` are + // resolved, which is what makes "delivery needs a public API" and "an SEO field + // has to be public" compile errors rather than boot-time ones. + TDelivery extends + | ContentDeliveryConfig< + TPublicEnabled, + ContentDeliveryTitleField<TFields, TPublicField>, + ContentDeliveryDescriptionField<TFields, TPublicField>, + ContentDeliveryNoIndexField<TFields, TPublicField> + > + | { enabled: false } = { enabled: false }, >({ admin, + delivery, editorial, fields, id, @@ -1427,6 +1448,13 @@ export const defineContentType = < TPublication, ContentEditorialEnabled<TEditorial> >; + /** + * Opts into the delivery layer: canonical URLs, slug history, automatic + * redirects, localized alternates, `hreflang`, SEO projection and sitemap + * entries. Needs `publicApi`, and every SEO field it names has to be in + * `publicApi.fields`. Omit it and nothing about the content type changes. + */ + delivery?: TDelivery; /** * Opts into the editorial workflow: a `version` column, optimistic locking * and revision history, plus optional preview and scheduling. Omit it and @@ -1471,7 +1499,8 @@ export const defineContentType = < ContentEditorialEnabled<TEditorial>, ContentPreviewEnabled<TEditorial>, ContentSchedulingEnabled<TEditorial>, - ContentLocalizationEnabled<TLocalization> + ContentLocalizationEnabled<TLocalization>, + ContentDeliveryEnabled<TDelivery> > => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( @@ -1643,6 +1672,24 @@ export const defineContentType = < tableName, }); + // After localization, because "which language owns a historical URL" is read + // off the field partition, and after `publicApi`, because every canonical path + // and every SEO field is stated in terms of the resolved public allowlist. + const resolvedDelivery = resolveContentDelivery({ + // The `{ enabled: false }` arm exists only so an explicit literal typechecks - + // the same widening `publicApi`, `search`, `editorial` and `localization` do. + delivery: delivery as ContentDeliveryConfig | undefined, + fields: fieldMap, + id, + localization: { + defaultLocale: resolvedLocalization.defaultLocale, + enabled: resolvedLocalization.enabled, + }, + localizedFields, + publicApi: resolvedPublicApi, + publication: publicationEnabled, + }); + const definition: ContentTypeDefinition< TId, TFields, @@ -1653,10 +1700,14 @@ export const defineContentType = < ContentEditorialEnabled<TEditorial>, ContentPreviewEnabled<TEditorial>, ContentSchedulingEnabled<TEditorial>, - ContentLocalizationEnabled<TLocalization> + ContentLocalizationEnabled<TLocalization>, + ContentDeliveryEnabled<TDelivery> > = { admin: resolvedAdmin, advanced: resolvedAdvanced, + delivery: resolvedDelivery as ResolvedContentDeliveryConfig< + ContentDeliveryEnabled<TDelivery> + >, editorial: resolvedEditorial as ResolvedContentEditorialConfig< ContentEditorialEnabled<TEditorial>, ContentPreviewEnabled<TEditorial>, @@ -1688,7 +1739,8 @@ export const defineContentType = < ContentEditorialEnabled<TEditorial>, ContentPreviewEnabled<TEditorial>, ContentSchedulingEnabled<TEditorial>, - ContentLocalizationEnabled<TLocalization> + ContentLocalizationEnabled<TLocalization>, + ContentDeliveryEnabled<TDelivery> > >({ admin: resolvedAdmin, diff --git a/packages/vitnode/src/content/delivery.ts b/packages/vitnode/src/content/delivery.ts new file mode 100644 index 000000000..e54cbd1f4 --- /dev/null +++ b/packages/vitnode/src/content/delivery.ts @@ -0,0 +1,748 @@ +import type { + AnyContentTypeDefinition, + ContentDeliveryConfig, + ContentFieldDescriptor, + ContentFieldMap, + ContentSitemapChangeFrequency, + DeliverableContentTypeDefinition, + ResolvedContentDeliveryConfig, + ResolvedContentPublicApiConfig, +} from "./types"; + +import { + CONTENT_DELIVERY_DESCRIPTION_KINDS, + CONTENT_DELIVERY_NO_INDEX_KINDS, + CONTENT_DELIVERY_PATH_MAX_LENGTH, + CONTENT_DELIVERY_TITLE_KINDS, + isContentSitemapChangeFrequency, +} from "./const"; +import { ContentEngineError } from "./errors"; +import { normalizeContentLocale } from "./locale"; +import { readContentPath, splitContentFieldPath } from "./paths"; + +/** + * The Content Delivery layer: what a public URL *is*, rather than what a record + * contains. + * + * Everything in this module is pure and client-safe. It answers four questions + * and nothing else - what is the canonical path of this record in this language, + * which languages does it also exist in, what should the page put in `<head>`, + * and is a given path the current one - so a frontend can render a page, an + * `hreflang` set and a sitemap entry from data the engine already has. + * + * It deliberately does **not** render anything. There is no layout here, no + * React, no Next.js and no `Metadata`: those belong to the application, and the + * `content/next` adapter is the thin translation layer between the two. + */ + +/** Kinds the three SEO slots accept, as runtime sets. */ +const titleKinds: ReadonlySet<string> = new Set(CONTENT_DELIVERY_TITLE_KINDS); +const descriptionKinds: ReadonlySet<string> = new Set( + CONTENT_DELIVERY_DESCRIPTION_KINDS, +); +const noIndexKinds: ReadonlySet<string> = new Set( + CONTENT_DELIVERY_NO_INDEX_KINDS, +); + +/** The disabled default every content type without `delivery` carries. */ +export const contentDeliveryDisabled: ResolvedContentDeliveryConfig<false> = { + enabled: false, + hreflang: { xDefault: null }, + redirects: { enabled: false }, + seo: { + descriptionField: null, + fallbackDescriptionField: null, + fallbackTitleField: null, + noIndexField: null, + openGraph: null, + titleField: null, + }, + sitemap: { changeFrequency: null, enabled: false, priority: null }, + slugScope: "none", +}; + +/** + * Resolves one SEO field name to the descriptor it addresses, or `null`. + * + * A leaf path resolves through its **group**, and a repeatable is deliberately + * not resolvable here: `assertSeoField` needs to tell "this leaf is a column on + * the row" from "this leaf is a column on a child row", and only the first can + * be one page's title. + */ +const resolveSeoTarget = ( + fields: ContentFieldMap, + name: string, +): null | { + container: "group" | "repeatable" | "row"; + descriptor: ContentFieldDescriptor; +} => { + const path = splitContentFieldPath(name); + if (!path) { + const fieldValue = fields[name]; + + return fieldValue ? { container: "row", descriptor: fieldValue } : null; + } + + const [owner, leaf] = path; + const container = fields[owner]; + if (container?.kind !== "group" && container?.kind !== "repeatable") { + return null; + } + + const leafValue = (container as { fields: ContentFieldMap }).fields[leaf]; + + return leafValue + ? { container: container.kind, descriptor: leafValue } + : null; +}; + +/** + * Checks one configured SEO field name. + * + * The public-exposure rule is the important one, and it is what makes "SEO + * metadata cannot leak a private value" a property of the definition rather than + * of every consumer: a `<title>` is rendered into a public page, so it has to be + * something the public API would already have said out loud. + */ +const assertSeoField = ({ + exposed, + fields, + id, + kinds, + label, + localizedFields, + name, + shared = false, +}: { + exposed: ReadonlySet<string>; + fields: ContentFieldMap; + id: string; + kinds: ReadonlySet<string>; + label: string; + localizedFields: ContentFieldMap; + name: string; + /** Whether the slot refuses a localized field. Only `noIndexField` does. */ + shared?: boolean; +}): void => { + const target = resolveSeoTarget(fields, name); + if (!target) { + throw new ContentEngineError( + `${label} references unknown field "${name}".`, + { contentTypeId: id }, + ); + } + + if (target.container === "repeatable") { + throw new ContentEngineError( + `${label} names the repeatable leaf "${name}", which is many values rather than one. A page has one title, one description and one indexing decision.`, + { contentTypeId: id }, + ); + } + + if (!kinds.has(target.descriptor.kind)) { + throw new ContentEngineError( + `${label} names "${name}" of kind "${target.descriptor.kind}". Expected one of: ${[...kinds].sort().join(", ")}.`, + { contentTypeId: id }, + ); + } + + if (!exposed.has(name)) { + throw new ContentEngineError( + `${label} names "${name}", which is not in publicApi.fields. Delivery metadata is rendered into a public page, so every field it is built from has to be publicly readable already.`, + { contentTypeId: id }, + ); + } + + if (shared) { + const path = splitContentFieldPath(name); + const owner = path ? path[0] : name; + if (localizedFields[owner] !== undefined) { + throw new ContentEngineError( + `${label} names the localized field "${name}". This slot has to be shared: sitemap inclusion and the \`robots\` metadata must agree, and a per-locale value would give one record one answer per language while it has a single canonical decision.`, + { contentTypeId: id }, + ); + } + } +}; + +/** + * Checks and fills in `delivery`. + * + * Runs after `resolvePublicApi` and after the field partition, because every rule + * here is stated in terms of both: the public allowlist decides which fields may + * be projected, and the partition decides which language a historical URL belongs + * to. + * + * Nothing is silently ignored. An invalid delivery block fails at definition + * time - a canonical URL that quietly stopped being generated is a page that + * quietly stopped being indexable, and that is not a symptom anybody notices. + */ +export const resolveContentDelivery = ({ + delivery, + fields, + id, + localization, + localizedFields, + publicApi, + publication, +}: { + delivery: ContentDeliveryConfig | undefined; + fields: ContentFieldMap; + id: string; + localization: { defaultLocale: string; enabled: boolean }; + localizedFields: ContentFieldMap; + publicApi: ResolvedContentPublicApiConfig; + publication: boolean; +}): ResolvedContentDeliveryConfig => { + if (!delivery?.enabled) return contentDeliveryDisabled; + + if (!publicApi.enabled) { + throw new ContentEngineError( + "delivery needs `publicApi: { enabled: true, path, fields }`. A content type with no public API has no public URL, so there is no canonical path, no redirect and no sitemap entry for delivery to produce.", + { contentTypeId: id }, + ); + } + + const slugField = publicApi.slugField; + if (slugField === "") { + throw new ContentEngineError( + "delivery needs an exposed slug field. `publicApi` already requires exactly one, so this content type is misconfigured upstream.", + { contentTypeId: id }, + ); + } + + const redirects = delivery.redirects?.enabled === true; + const sitemapConfig = + delivery.sitemap?.enabled === true ? delivery.sitemap : null; + const slugScope = + localizedFields[slugField] === undefined ? "shared" : "localized"; + + // A localized content type whose slug is *shared* has one URL segment and several + // URLs - `/en/articles/hello` and `/pl/articles/hello` are both live, and a slug + // change moves all of them at once. Slug history stores the URL that was live, so + // one retired row would have to be several paths, and the AdminCP would show one + // of them as if it were the address somebody bookmarked. Canonical URLs, SEO, + // alternates and the sitemap all work fine in that shape - only the redirect + // reservation is ambiguous, so only it is refused. + if (redirects && localization.enabled && slugScope === "shared") { + throw new ContentEngineError( + `delivery.redirects needs a localized slug field on a localized content type, but "${slugField}" is shared. Every language answers to the same segment, so one retired address would belong to several URLs at once. Mark the slug \`localized: true\`, or drop \`redirects\`.`, + { contentTypeId: id }, + ); + } + + // Restated even though `publicApi` already requires publication: a sitemap + // lists what anonymous readers can reach, and "what can be reached" is exactly + // the publication lifecycle. Without it every row would be in the sitemap from + // the moment it was created. + if (sitemapConfig && !publication) { + throw new ContentEngineError( + "delivery.sitemap needs `publication: { enabled: true }`. A sitemap lists what is publicly reachable, and without the lifecycle every row would be listed the moment it was created.", + { contentTypeId: id }, + ); + } + + if (sitemapConfig?.priority !== undefined) { + const { priority } = sitemapConfig; + if (!Number.isFinite(priority) || priority < 0 || priority > 1) { + throw new ContentEngineError( + `delivery.sitemap.priority is ${priority}; the sitemap protocol defines it between 0 and 1 inclusive.`, + { contentTypeId: id }, + ); + } + } + + if ( + sitemapConfig?.changeFrequency !== undefined && + !isContentSitemapChangeFrequency(sitemapConfig.changeFrequency) + ) { + throw new ContentEngineError( + `delivery.sitemap.changeFrequency is "${String(sitemapConfig.changeFrequency)}", which is not one of the values the sitemap protocol defines. A crawler ignores an unknown one silently, so a typo would be a hint nobody ever receives.`, + { contentTypeId: id }, + ); + } + + if (delivery.hreflang !== undefined) { + if (delivery.hreflang.xDefault !== "defaultLocale") { + throw new ContentEngineError( + `delivery.hreflang.xDefault is "${String(delivery.hreflang.xDefault)}"; the only supported value is "defaultLocale". An x-default has to point at a URL that actually resolves.`, + { contentTypeId: id }, + ); + } + + if (!localization.enabled) { + throw new ContentEngineError( + "delivery.hreflang needs `localization: { enabled: true, defaultLocale }`. A content type with one language has no alternates, so there is nothing for an x-default to be the default of.", + { contentTypeId: id }, + ); + } + } + + const exposed = new Set(publicApi.fields); + const seo = delivery.seo ?? {}; + + for (const [label, name] of [ + ["delivery.seo.titleField", seo.titleField], + ["delivery.seo.fallbackTitleField", seo.fallbackTitleField], + ["delivery.seo.openGraph.titleField", seo.openGraph?.titleField], + ] as const) { + if (name === undefined) continue; + + assertSeoField({ + exposed, + fields, + id, + kinds: titleKinds, + label, + localizedFields, + name, + }); + } + + for (const [label, name] of [ + ["delivery.seo.descriptionField", seo.descriptionField], + ["delivery.seo.fallbackDescriptionField", seo.fallbackDescriptionField], + [ + "delivery.seo.openGraph.descriptionField", + seo.openGraph?.descriptionField, + ], + ] as const) { + if (name === undefined) continue; + + assertSeoField({ + exposed, + fields, + id, + kinds: descriptionKinds, + label, + localizedFields, + name, + }); + } + + if (seo.noIndexField !== undefined) { + assertSeoField({ + exposed, + fields, + id, + kinds: noIndexKinds, + label: "delivery.seo.noIndexField", + localizedFields, + name: seo.noIndexField, + shared: true, + }); + } + + // A fallback with no primary is a configuration that reads as if it does + // something and does nothing: the primary is what is consulted first, so + // naming only the fallback means the fallback is never reached. + if (seo.fallbackTitleField !== undefined && seo.titleField === undefined) { + throw new ContentEngineError( + "delivery.seo.fallbackTitleField is set without `titleField`. The fallback is only consulted when the primary is empty, so on its own it would never be read - name it as `titleField` instead.", + { contentTypeId: id }, + ); + } + + if ( + seo.fallbackDescriptionField !== undefined && + seo.descriptionField === undefined + ) { + throw new ContentEngineError( + "delivery.seo.fallbackDescriptionField is set without `descriptionField`. The fallback is only consulted when the primary is empty, so on its own it would never be read.", + { contentTypeId: id }, + ); + } + + return { + enabled: true, + hreflang: { xDefault: delivery.hreflang?.xDefault ?? null }, + redirects: { enabled: redirects }, + seo: { + descriptionField: seo.descriptionField ?? null, + fallbackDescriptionField: seo.fallbackDescriptionField ?? null, + fallbackTitleField: seo.fallbackTitleField ?? null, + noIndexField: seo.noIndexField ?? null, + openGraph: + seo.openGraph === undefined + ? null + : { + descriptionField: seo.openGraph.descriptionField ?? null, + titleField: seo.openGraph.titleField ?? null, + }, + titleField: seo.titleField ?? null, + }, + sitemap: { + changeFrequency: sitemapConfig?.changeFrequency ?? null, + enabled: sitemapConfig !== null, + priority: sitemapConfig?.priority ?? null, + }, + slugScope, + }; +}; + +// --------------------------------------------------------------------------- +// Canonical URLs +// --------------------------------------------------------------------------- + +/** + * The canonical **path** of one record, in one language. + * + * ```text + * /articles/my-article nonlocalized + * /pl/articles/moj-artykul localized + * ``` + * + * Relative, always, and that is the point: a content type definition lives in + * source control and gets deployed to a preview domain, a staging domain and + * production, so an origin baked into it would be wrong in two of the three + * places. {@link contentDeliveryUrl} adds one when a caller has one to add. + * + * The locale segment is **normalized** through `normalizeContentLocale`, so + * `PL`, `pl` and `" pl "` produce one path and therefore one cache key. The slug + * is percent-encoded: a generated slug is already URL-safe, but a row written + * straight into the database is not, and a path is what this function promises. + * + * `null` for an empty slug or an empty public path, rather than a link to + * `/articles/` - a canonical URL that points at a list page is worse than no + * canonical URL at all. + */ +export const contentDeliveryPath = ({ + definition, + locale, + slug, +}: { + definition: AnyContentTypeDefinition; + /** Required for a localized content type, ignored otherwise. */ + locale?: null | string; + slug: string; +}): null | string => { + const path = definition.publicApi.path; + const trimmed = slug.trim(); + if (path === "" || trimmed === "") return null; + + const segments: string[] = []; + + if (definition.localization.enabled) { + const normalized = normalizeContentLocale(locale ?? ""); + // A localized record has one URL per language and no locale-less one. Without + // a locale there is no path to build, and guessing would hand a reader the + // wrong language under a URL that claims otherwise. + if (normalized === "") return null; + + segments.push(encodeURIComponent(normalized)); + } + + segments.push(path, encodeURIComponent(trimmed)); + + return `/${segments.join("/")}`; +}; + +/** + * A canonical path turned absolute, when the caller has an origin. + * + * `origin` is whatever the request or the deployment says it is - a configured + * public URL, `NEXT_PUBLIC_WEB_URL`, a forwarded host. It is separate from the + * path for the reason {@link contentDeliveryPath} explains, and it is validated + * here rather than concatenated: `https://example.com` and + * `https://example.com/` have to produce the same URL, and a malformed origin + * has to be a `null` rather than a link with two schemes in it. + */ +export const contentDeliveryUrl = ({ + origin, + path, +}: { + origin: string; + path: null | string; +}): null | string => { + if (path === null) return null; + + try { + return new URL(path, origin).toString(); + } catch { + return null; + } +}; + +/** One published translation's URL, as `alternates` and `hreflang` report it. */ +export interface ContentDeliveryAlternate { + /** The canonical `core_languages.code`. */ + locale: string; + path: string; +} + +/** + * The `hreflang` set of one record, as a framework-neutral map. + * + * `{ languages, xDefault? }` rather than a Next.js `Metadata` object, because the + * core engine has no business knowing which framework renders it - `content/next` + * turns this into `alternates.languages` in one line, and an Astro or Remix + * adapter would do the same. + * + * Built from {@link ContentDeliveryAlternate}s, which are **real published + * translations** and nothing else. A fallback translation is not an alternate: it + * has no URL in the language that fell back to it, so listing one would announce + * a page that answers 404. + */ +export interface ContentDeliveryHreflang { + languages: Record<string, string>; + /** Present only with `delivery.hreflang.xDefault` and a resolvable default. */ + xDefault?: string; +} + +export const contentDeliveryHreflang = ({ + alternates, + definition, +}: { + alternates: readonly ContentDeliveryAlternate[]; + definition: AnyContentTypeDefinition; +}): ContentDeliveryHreflang => { + const languages: Record<string, string> = {}; + for (const alternate of alternates) + languages[alternate.locale] = alternate.path; + + if (definition.delivery.hreflang.xDefault !== "defaultLocale") { + return { languages }; + } + + // Only when the default locale is genuinely one of the alternates. An + // `x-default` pointing at a language this record has not published would be a + // hint to crawl a 404, which is worse than emitting nothing. + const fallback = alternates.find(alternate => + contentDeliveryLocalesMatch( + alternate.locale, + definition.localization.defaultLocale, + ), + ); + + return fallback === undefined + ? { languages } + : { languages, xDefault: fallback.path }; +}; + +const contentDeliveryLocalesMatch = (a: string, b: string): boolean => + normalizeContentLocale(a) === normalizeContentLocale(b); + +// --------------------------------------------------------------------------- +// SEO projection +// --------------------------------------------------------------------------- + +export interface ContentDeliverySeo { + description: null | string; + title: null | string; +} + +export interface ContentDeliveryRobots { + follow: boolean; + index: boolean; +} + +/** + * Reads one configured SEO slot out of a **public** row. + * + * The row is the public projection - the same object the public API returns - so + * a field the allowlist omits is not merely skipped here, it is absent from the + * object entirely. That is what makes "SEO cannot leak a private field" true at + * runtime as well as at definition time. + * + * A whitespace-only value counts as empty, because a `<title>` of three spaces is + * a missing title with extra steps - and that is exactly when the fallback should + * take over. + */ +const readSeoText = ( + row: Record<string, unknown>, + primary: null | string, + fallback: null | string, +): null | string => { + for (const name of [primary, fallback]) { + if (name === null) continue; + + const value = readContentPath(row, name); + if (typeof value !== "string") continue; + + const trimmed = value.trim(); + if (trimmed !== "") return trimmed; + } + + return null; +}; + +/** + * The `<title>` and `<meta name="description">` of one record. + * + * `{ description: null, title: null }` for a content type whose `delivery.seo` + * names nothing - the shape is stable so a frontend never branches on whether + * the block was configured, only on whether a value came back. + */ +export const contentDeliverySeo = ( + definition: AnyContentTypeDefinition, + row: Record<string, unknown>, +): ContentDeliverySeo => { + const { seo } = definition.delivery; + + return { + description: readSeoText( + row, + seo.descriptionField, + seo.fallbackDescriptionField, + ), + title: readSeoText(row, seo.titleField, seo.fallbackTitleField), + }; +}; + +/** + * The Open Graph pair, or `null` when the content type configured none. + * + * `null` rather than an object of nulls, because "this content type does not + * publish Open Graph metadata" and "it does, and this page has no title" are + * different facts and a renderer treats them differently: the first emits no + * tags at all. + * + * Each slot falls back to the ordinary SEO one, which is what makes the common + * case - the same title in both places - a two-line config rather than four. + */ +export const contentDeliveryOpenGraph = ( + definition: AnyContentTypeDefinition, + row: Record<string, unknown>, +): ContentDeliverySeo | null => { + const { seo } = definition.delivery; + if (seo.openGraph === null) return null; + + const base = contentDeliverySeo(definition, row); + + return { + description: + readSeoText(row, seo.openGraph.descriptionField, null) ?? + base.description, + title: readSeoText(row, seo.openGraph.titleField, null) ?? base.title, + }; +}; + +/** + * The `robots` directive of one record, or `null` without a `noIndexField`. + * + * `follow` is always `true`: "do not list this page" and "do not follow the links + * on it" are different instructions, and a content type that asked for the first + * has not asked for the second. A `noindex, nofollow` page is a dead end for a + * crawler walking the site, which is a decision for site-wide robots + * configuration rather than for one record. + * + * The same field drives the sitemap exclusion, which is what keeps the two from + * disagreeing: a record cannot be absent from the sitemap and `index: true` at + * the same time, because there is one boolean behind both. + */ +export const contentDeliveryRobots = ( + definition: AnyContentTypeDefinition, + row: Record<string, unknown>, +): ContentDeliveryRobots | null => { + const { noIndexField } = definition.delivery.seo; + if (noIndexField === null) return null; + + return { follow: true, index: readContentPath(row, noIndexField) !== true }; +}; + +// --------------------------------------------------------------------------- +// Path parsing +// --------------------------------------------------------------------------- + +/** A public path split into the two things a delivery lookup needs. */ +export interface ContentDeliveryPathParts { + /** `null` for a content type that is not localized. */ + locale: null | string; + slug: string; +} + +/** + * Splits a public path back into its locale and its slug. + * + * The inverse of {@link contentDeliveryPath}, and deliberately strict: it accepts + * exactly the shape that function produces and refuses everything else. A path + * with an extra segment, a different public prefix or a traversal in it is `null` + * rather than a best guess - a resolver that guessed would answer one content + * type's URL with another's record. + * + * A query string and a fragment are stripped first, because a browser sends them + * and they are not part of the identity of a page. + */ +export const parseContentDeliveryPath = ( + definition: AnyContentTypeDefinition, + path: string, +): ContentDeliveryPathParts | null => { + if (path.length > CONTENT_DELIVERY_PATH_MAX_LENGTH) return null; + + const withoutQuery = path.split(/[?#]/)[0] ?? ""; + const segments = withoutQuery + .split("/") + .filter(segment => segment !== "") + .map(segment => { + try { + return decodeURIComponent(segment); + } catch { + // A malformed escape is not a path this engine produced. + return null; + } + }); + + if (segments.some(segment => segment === null)) return null; + + const parts = segments as string[]; + const localized = definition.localization.enabled; + const expected = localized ? 3 : 2; + if (parts.length !== expected) return null; + + const [prefix, slug] = localized + ? [parts[1], parts[2]] + : [parts[0], parts[1]]; + if (prefix !== definition.publicApi.path) return null; + if (slug === "" || slug === "." || slug === "..") return null; + + return { + locale: localized ? normalizeContentLocale(parts[0]) : null, + slug, + }; +}; + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** + * Every delivery-enabled content type of an installation, in a stable order. + * + * What a site-level sitemap index is built from: it enumerates the content types + * that have public URLs at all, so an application never hardcodes plugin names - + * installing a plugin adds its content types to the sitemap and removing it takes + * them out again. + * + * Ordered by content type id, so two processes building the same sitemap index + * produce the same document. + */ +export const listDeliveryContentTypes = < + TEntry extends { definition: AnyContentTypeDefinition; pluginId: string }, +>( + entries: readonly TEntry[], +): TEntry[] => + [...entries] + .filter(entry => entry.definition.delivery.enabled) + .sort((a, b) => a.definition.id.localeCompare(b.definition.id)); + +/** Whether one definition has a delivery layer, as a type guard. */ +export const isDeliverableContentType = ( + definition: AnyContentTypeDefinition, +): definition is DeliverableContentTypeDefinition => + definition.delivery.enabled && definition.publicApi.enabled; + +/** The sitemap defaults of one content type, or `null` when it lists nothing. */ +export const contentSitemapDefaults = ( + definition: AnyContentTypeDefinition, +): null | { + changeFrequency: ContentSitemapChangeFrequency | null; + priority: null | number; +} => { + const { sitemap } = definition.delivery; + if (!definition.delivery.enabled || !sitemap.enabled) return null; + + return { + changeFrequency: sitemap.changeFrequency, + priority: sitemap.priority, + }; +}; diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index cb036d1b5..25a888d1d 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -317,6 +317,66 @@ export class ContentAdvancedInputError extends ContentInputError { readonly ids: number[]; } +/** + * A slug that another record's public URL history already owns. + * + * A historical public URL stays reserved for the content type and locale that + * retired it, for as long as redirects are enabled, and this is what enforces + * that. Without the reservation `/articles/hello` could stop redirecting to the + * article it belonged to and start resolving to an unrelated one - so every link, + * bookmark and search result pointing at it would silently change meaning. + * + * Per-request, like {@link ContentInputError}, and structured for the same reason + * {@link ContentVersionConflict} is: the AdminCP points at the slug field and says + * which URL is taken, which it cannot do from prose. Everything it carries is the + * caller's own input echoed back plus the content type id, so there is nothing + * internal in it - in particular never the owning record's identifier, which would + * let a public write probe for records it cannot read. + */ +export class ContentDeliverySlugReserved extends ContentEngineError { + constructor({ + contentTypeId, + locale, + slug, + }: { + contentTypeId: string; + locale: null | string; + slug: string; + }) { + super( + locale === null + ? `The address "${slug}" is reserved: another record used it publicly and it still redirects there. Pick a different one.` + : `The address "${slug}" is reserved in "${locale}": another record used it publicly and it still redirects there. Pick a different one.`, + { contentTypeId }, + ); + + this.name = "ContentDeliverySlugReserved"; + this.locale = locale; + this.slug = slug; + } + + readonly locale: null | string; + readonly slug: string; +} + +/** + * A delivery operation on a content type that has no delivery layer. + * + * A configuration bug rather than a per-request one - the model exposes no + * `deliveryService` at all for such a content type, so reaching this means + * somebody built the service by hand. + */ +export class ContentDeliveryNotEnabled extends ContentEngineError { + constructor({ contentTypeId }: { contentTypeId: string }) { + super( + "This content type has no `delivery` block, so it has no canonical URL, no slug history and no sitemap. Add `delivery: { enabled: true }` to generate them.", + { contentTypeId }, + ); + + this.name = "ContentDeliveryNotEnabled"; + } +} + /** * 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/types.ts b/packages/vitnode/src/content/types.ts index 9a7538a7d..3d406ae86 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1,4 +1,7 @@ import type { + CONTENT_DELIVERY_DESCRIPTION_KINDS, + CONTENT_DELIVERY_NO_INDEX_KINDS, + CONTENT_DELIVERY_TITLE_KINDS, CONTENT_EDITORIAL_FIELDS, CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_LOCALIZATION_FALLBACKS, @@ -8,6 +11,7 @@ import type { CONTENT_SEARCH_DESCRIPTION_KINDS, CONTENT_SEARCH_TEXT_KINDS, CONTENT_SEARCH_TITLE_KINDS, + CONTENT_SITEMAP_CHANGE_FREQUENCIES, CONTENT_SYSTEM_FIELDS, CONTENT_TRANSLATION_SYSTEM_FIELDS, } from "./const"; @@ -1167,6 +1171,238 @@ export interface ResolvedContentSearchConfig< titleField: string; } +// --------------------------------------------------------------------------- +// Delivery (Stage 8) +// --------------------------------------------------------------------------- + +export type ContentSitemapChangeFrequency = + (typeof CONTENT_SITEMAP_CHANGE_FREQUENCIES)[number]; + +/** + * Field names and group leaf paths `delivery.seo` may name, of one or more kinds. + * + * Three rules, one `Extract`, and they are the same three `ContentSearchTitleField` + * enforces for the same reasons: `TPublicField` is the public allowlist, so a + * private field cannot become a `<title>`; the kind union keeps prose out of a + * title slot and a number out of a description; and a **repeatable** leaf is + * absent, because a page has one title and a repeatable has many values. + */ +export type ContentDeliveryTextField< + TFields, + TPublicField extends string, + TKind extends string, +> = Extract< + TPublicField, + | ContentFieldNamesOfKind<TFields, TKind> + | ContentLeafPathsOfKind<TFields, TKind, "group"> +>; + +/** Field names `delivery.seo.titleField` and its fallback accept. */ +export type ContentDeliveryTitleField< + TFields, + TPublicField extends string, +> = ContentDeliveryTextField< + TFields, + TPublicField, + (typeof CONTENT_DELIVERY_TITLE_KINDS)[number] +>; + +/** Field names `delivery.seo.descriptionField` and its fallback accept. */ +export type ContentDeliveryDescriptionField< + TFields, + TPublicField extends string, +> = ContentDeliveryTextField< + TFields, + TPublicField, + (typeof CONTENT_DELIVERY_DESCRIPTION_KINDS)[number] +>; + +/** Field names `delivery.seo.noIndexField` accepts. */ +export type ContentDeliveryNoIndexField< + TFields, + TPublicField extends string, +> = ContentDeliveryTextField< + TFields, + TPublicField, + (typeof CONTENT_DELIVERY_NO_INDEX_KINDS)[number] +>; + +/** + * Optional Open Graph projection, on top of the SEO one. + * + * Separate fields rather than a flag, because the two audiences differ: a + * `<title>` competes in a search result and an `og:title` competes in a chat + * preview, and an author who wants them identical simply names the same field + * twice. There is deliberately no `imageField` - see + * `apps/docs/.../content-delivery-limitations.mdx`. + */ +export interface ContentDeliveryOpenGraphConfig< + TTitle extends string = string, + TDescription extends string = string, +> { + descriptionField?: TDescription; + titleField?: TTitle; +} + +/** + * What a frontend renders in `<head>`, projected from public fields. + * + * Every slot is optional and every fallback is explicit. There is no "derive a + * description from the first 160 characters of the body": a summary somebody did + * not write is a summary nobody reviewed, and it would silently become the + * description of every page that forgot to set one. + */ +export interface ContentDeliverySeoConfig< + TTitle extends string = string, + TDescription extends string = string, + TNoIndex extends string = string, +> { + descriptionField?: TDescription; + /** Used when `descriptionField` resolves to `null` or an empty string. */ + fallbackDescriptionField?: TDescription; + /** Used when `titleField` resolves to `null` or an empty string. */ + fallbackTitleField?: TTitle; + /** + * A **shared** boolean field that keeps one record out of the sitemap and + * reports `robots: { index: false }`. + * + * Shared rather than localized on purpose: the two consumers have to agree, and + * a per-locale value would make "is this record in the sitemap" a question with + * one answer per language while the record has one canonical decision. A + * localized field here is a definition-time error. + */ + noIndexField?: TNoIndex; + openGraph?: ContentDeliveryOpenGraphConfig<TTitle, TDescription>; + titleField?: TTitle; +} + +/** + * Automatic redirects from a record's historical public URLs. + * + * Needs a slug field, which `publicApi` already guarantees. What it adds is + * persistence: every slug that was ever *publicly addressable* is written to + * `core_content_slug_history`, which is what makes an old URL resolvable after + * the row has moved on - and what reserves it, so unrelated content cannot + * quietly inherit somebody else's incoming links. + */ +export interface ContentDeliveryRedirectsConfig { + enabled: true; +} + +export interface ContentDeliverySitemapConfig { + /** One of the seven `changefreq` values the protocol defines. */ + changeFrequency?: ContentSitemapChangeFrequency; + enabled: true; + /** `0` to `1` inclusive. */ + priority?: number; +} + +/** + * `x-default` for a localized content type. + * + * `"defaultLocale"` is the only supported mapping, and that is deliberate: an + * `x-default` has to point at a URL that actually resolves, and the default + * locale's canonical path is the one URL a localized record is guaranteed to have + * whenever it is public at all. Omit the block and no `x-default` is emitted - + * inventing a locale-less route that the engine does not serve would be worse + * than emitting nothing. + */ +export interface ContentDeliveryHreflangConfig { + xDefault: "defaultLocale"; +} + +/** + * Opts a content type into the delivery layer: canonical URLs, slug history, + * redirects, localized alternates, SEO projection and sitemap entries. + * + * Requires `publicApi: { enabled: true }`, checked at compile time through + * `TPublicEnabled` and again at definition time - a content type with no public + * API has no public URL, so there is nothing for delivery to be about. + * + * `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 "no delivery". + */ +export interface ContentDeliveryConfig< + // Defaults to `true` rather than `boolean`, which is what keeps the bare + // `ContentDeliveryConfig` usable as a widened parameter type: `boolean extends + // true` is false, so a `boolean` default would resolve `enabled` to `never` and + // make the erased form describe a config nobody can write. + TPublicEnabled extends boolean = true, + TTitle extends string = string, + TDescription extends string = string, + TNoIndex extends string = string, +> { + /** + * Literal `true`, and only when the content type has a public API. + * + * `never` otherwise, which is what turns "delivery needs `publicApi`" into a + * compile error on the `enabled: true` itself rather than a boot-time throw. The + * runtime check stays as well, for a JavaScript caller and for a value that + * widened somewhere upstream. + */ + enabled: TPublicEnabled extends true ? true : never; + hreflang?: ContentDeliveryHreflangConfig; + redirects?: TPublicEnabled extends true + ? ContentDeliveryRedirectsConfig | { enabled: false } + : { enabled: false }; + seo?: ContentDeliverySeoConfig<TTitle, TDescription, TNoIndex>; + sitemap?: ContentDeliverySitemapConfig | { enabled: false }; +} + +/** + * Whether a `delivery` 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 ContentDeliveryEnabled<TDelivery> = TDelivery extends { + enabled: true; +} + ? true + : false; + +/** `delivery.seo` after `defineContentType` has filled in every default. */ +export interface ResolvedContentDeliverySeoConfig { + descriptionField: null | string; + fallbackDescriptionField: null | string; + fallbackTitleField: null | string; + noIndexField: null | string; + openGraph: null | { + descriptionField: null | string; + titleField: null | string; + }; + titleField: null | string; +} + +/** `delivery` after `defineContentType` has filled in every default. */ +export interface ResolvedContentDeliveryConfig< + TEnabled extends boolean = boolean, +> { + enabled: TEnabled; + hreflang: { xDefault: "defaultLocale" | null }; + redirects: { enabled: boolean }; + seo: ResolvedContentDeliverySeoConfig; + sitemap: { + changeFrequency: ContentSitemapChangeFrequency | null; + enabled: boolean; + priority: null | number; + }; + /** + * Where the slug that addresses this content type lives. + * + * `"localized"` when `publicApi.slugField` is a localized field, `"shared"` + * otherwise - and it is the only thing the whole delivery layer branches on to + * decide which language a historical URL belongs to. A localized slug is + * reserved per language, a shared one once for the content type, and both are + * correct for the URLs they actually produce. + * + * `"none"` for a content type without delivery, which addresses nothing. + */ + slugScope: "localized" | "none" | "shared"; +} + // --------------------------------------------------------------------------- // Editorial // --------------------------------------------------------------------------- @@ -1503,6 +1739,20 @@ export type SchedulableContentTypeDefinition = publication: { enabled: true }; }; +/** + * A content type with a delivery layer: canonical URLs, alternates, SEO and a + * sitemap. + * + * Both halves are pinned, because delivery is defined in terms of the public + * projection: the canonical path is built from `publicApi.path` and the exposed + * slug field, and every SEO field is one of `publicApi.fields`. A content type + * without a public allowlist cannot reach the delivery service at all - which is + * a compile error rather than an empty response. + */ +export type DeliverableContentTypeDefinition = PublicContentTypeDefinition & { + delivery: { enabled: true }; +}; + /** * A content type whose records exist in more than one language. * @@ -1563,10 +1813,17 @@ export interface ContentTypeDefinition< TPreviewEnabled extends boolean = boolean, TSchedulingEnabled extends boolean = boolean, TLocalizationEnabled extends boolean = boolean, + TDeliveryEnabled extends boolean = boolean, > { admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ advanced: ResolvedContentAdvancedConfig; + /** + * Canonical URLs, slug history, SEO and sitemap - or the disabled default when + * `delivery` is omitted, which is what keeps every Stage 1-7 content type + * byte-identical. + */ + delivery: ResolvedContentDeliveryConfig<TDeliveryEnabled>; /** Editorial workflow, or the disabled default when `editorial` is omitted. */ editorial: ResolvedContentEditorialConfig< TEditorialEnabled, @@ -1598,7 +1855,8 @@ export interface ContentTypeDefinition< TEditorialEnabled, TPreviewEnabled, TSchedulingEnabled, - TLocalizationEnabled + TLocalizationEnabled, + TDeliveryEnabled > >; /** Search synchronization, or the disabled default when `search` is omitted. */ From 2b192063dde9f427474ee28af5846a82046faf80 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:09:07 +0200 Subject: [PATCH 02/24] feat(content): add canonical URL and sitemap helpers `contentDeliveryPath` is the one place a content URL is built, and it is relative on purpose: a definition lives in source control and gets deployed to a preview domain, a staging domain and production, so an origin baked into it would be wrong in two of the three places. `contentDeliveryUrl` adds one when a caller has one. The locale segment is normalized, because a path is also a cache key - three spellings of one locale producing three paths would produce three cache entries for one page. `parseContentDeliveryPath` is the strict inverse: it accepts exactly the shape the builder produces and answers `null` to everything else, so a resolver never answers one content type's URL with another's record. The sitemap module is serialization and nothing else. Keeping it apart from the queries is what makes both testable: the XML without a database, the pagination without parsing XML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/index.ts | 65 +++++++ packages/vitnode/src/content/sitemap.ts | 220 ++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 packages/vitnode/src/content/sitemap.ts diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 98b55d524..b1c00d961 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -28,6 +28,9 @@ export type { ContentFormSpec, } from "./admin/spec"; export { + contentDeliveryRedirectTag, + contentDeliverySitemapTag, + contentDeliveryTag, contentInvalidationTags, contentLocaleInvalidationMode, contentLocaleInvalidations, @@ -39,6 +42,7 @@ export { isContentTranslationPubliclyVisible, } from "./cache"; export type { + ContentDeliveryInvalidation, ContentInvalidationInput, ContentInvalidationMode, ContentLocaleInvalidation, @@ -47,15 +51,19 @@ export type { } from "./cache"; export { parseContentConflict, + parseContentDeliveryConflict, parseContentTranslationConflict, parseContentUnprocessable, zodContentConflict, + zodContentDeliveryConflict, zodContentTranslationConflict, zodContentUnprocessable, } from "./conflicts"; export type { ContentConflict, ContentConflictCode, + ContentDeliveryCode, + ContentDeliveryConflict, ContentTranslationConflict, ContentTranslationConflictCode, ContentUnprocessable, @@ -66,6 +74,13 @@ export { CONTENT_CACHE_TAG_MAX_LENGTH, CONTENT_CONFLICT_CODES, CONTENT_DEFAULT_PAGE_SIZE, + CONTENT_DELIVERY_CODES, + CONTENT_DELIVERY_DESCRIPTION_KINDS, + CONTENT_DELIVERY_NO_INDEX_KINDS, + CONTENT_DELIVERY_PATH_MAX_LENGTH, + CONTENT_DELIVERY_REDIRECT_STATUS, + CONTENT_DELIVERY_RESOLUTIONS, + CONTENT_DELIVERY_TITLE_KINDS, CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FILTERABLE_FIELD_KINDS, @@ -103,6 +118,9 @@ export { CONTENT_SEARCH_SLUG_PLACEHOLDER, CONTENT_SEARCH_TEXT_KINDS, CONTENT_SEARCH_TITLE_KINDS, + CONTENT_SITEMAP_CHANGE_FREQUENCIES, + CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, + CONTENT_SITEMAP_MAX_URLS, CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_SYSTEM_FIELDS, CONTENT_TEXT_DEFAULT_LENGTH, @@ -113,15 +131,39 @@ export { CONTENT_TRANSLATION_TABLE_SUFFIX, CONTENT_UNPROCESSABLE_CODES, isContentPublicationStatus, + isContentSitemapChangeFrequency, isFilterableFieldKind, isLocalizableFieldKind, RESERVED_FILTER_KEYS, } from "./const"; export { defineContentType } from "./define"; +export { + contentDeliveryDisabled, + contentDeliveryHreflang, + contentDeliveryOpenGraph, + contentDeliveryPath, + contentDeliveryRobots, + contentDeliverySeo, + contentDeliveryUrl, + contentSitemapDefaults, + isDeliverableContentType, + listDeliveryContentTypes, + parseContentDeliveryPath, + resolveContentDelivery, +} from "./delivery"; +export type { + ContentDeliveryAlternate, + ContentDeliveryHreflang, + ContentDeliveryPathParts, + ContentDeliveryRobots, + ContentDeliverySeo, +} from "./delivery"; export type { ContentAdvancedCode } from "./errors"; export { ContentAdvancedInputError, ContentDefaultTranslationRequired, + ContentDeliveryNotEnabled, + ContentDeliverySlugReserved, ContentEngineError, ContentInputError, ContentLanguageError, @@ -135,6 +177,8 @@ export { contentEventName } from "./events"; export type { ContentCreatedPayload, ContentDeletedPayload, + ContentDeliveryRedirectCreatedPayload, + ContentDeliverySlugChangedPayload, ContentEventAction, ContentEventsFor, ContentPublishedPayload, @@ -214,6 +258,13 @@ export { contentSearchIndexedFieldNames, contentSearchUrl, } from "./search"; +export { + contentSitemapChunks, + contentSitemapIndexXml, + contentSitemapXml, + escapeXml, +} from "./sitemap"; +export type { ContentSitemapEntry, ContentSitemapIndexEntry } from "./sitemap"; export { slugify } from "./slug"; export type { AnyContentTypeDefinition, @@ -223,6 +274,16 @@ export type { ContentBooleanField, ContentCreateInput, ContentDateTimeField, + ContentDeliveryConfig, + ContentDeliveryDescriptionField, + ContentDeliveryEnabled, + ContentDeliveryHreflangConfig, + ContentDeliveryNoIndexField, + ContentDeliveryOpenGraphConfig, + ContentDeliveryRedirectsConfig, + ContentDeliverySeoConfig, + ContentDeliverySitemapConfig, + ContentDeliveryTitleField, ContentEditorialConfig, ContentEditorialEnabled, ContentEditorialField, @@ -271,6 +332,7 @@ export type { ContentSelect, ContentSharedFieldName, ContentSharedValues, + ContentSitemapChangeFrequency, ContentSlugField, ContentSlugRequired, ContentSystemField, @@ -283,6 +345,7 @@ export type { ContentTypeDefinition, ContentUpdateInput, ContentUserField, + DeliverableContentTypeDefinition, EditorialContentTypeDefinition, FilterableContentFieldKind, FilterableContentFieldName, @@ -291,6 +354,8 @@ export type { PublicContentTypeDefinition, PublicFilterableContentFieldName, ResolvedContentAdminConfig, + ResolvedContentDeliveryConfig, + ResolvedContentDeliverySeoConfig, ResolvedContentEditorialConfig, ResolvedContentIndex, ResolvedContentLocalizationConfig, diff --git a/packages/vitnode/src/content/sitemap.ts b/packages/vitnode/src/content/sitemap.ts new file mode 100644 index 000000000..5c4a9cf30 --- /dev/null +++ b/packages/vitnode/src/content/sitemap.ts @@ -0,0 +1,220 @@ +import type { ContentSitemapChangeFrequency } from "./types"; + +import { + CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, + CONTENT_SITEMAP_MAX_URLS, +} from "./const"; + +/** + * Sitemap serialization, and nothing else. + * + * Deliberately separate from the queries that produce the entries: "which URLs + * are public right now" is a keyset scan over two tables, and "what does a + * sitemap file look like" is a string. Folding them into one function would make + * the XML untestable without a database and the pagination untestable without + * parsing XML - so the delivery service owns the first and this module owns the + * second. + * + * Client-safe and pure. No Drizzle, no Hono, no `next/*`. + */ + +/** One line of a sitemap, as the delivery service produces it. */ +export interface ContentSitemapEntry { + /** One of the seven `changefreq` values, or `null` to omit the element. */ + changeFrequency: ContentSitemapChangeFrequency | null; + itemId: number; + /** + * When the representation at this URL last changed. + * + * For a localized entry that is `max(base.updatedAt, translation.updatedAt)`, + * because both halves are rendered into the page: a shared field moving changes + * every language's document even though no translation row was touched. + */ + lastModified: Date; + /** The language this URL is in, or `null` for a nonlocalized content type. */ + locale: null | string; + /** Relative, always. An origin is applied at serialization time. */ + path: string; + priority: null | number; +} + +/** + * XML's five predefined entities, escaped in the one order that is correct. + * + * `&` **first**: escaping it after `<` would turn the `<` this function just + * produced into `&lt;`. A slug is percent-encoded by the path builder so this + * is rarely load-bearing, but a sitemap is a document other people's parsers read, + * and "rarely" is not a guarantee. + */ +export const escapeXml = (value: string): string => + value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +/** + * A path turned into the absolute URL a sitemap has to carry. + * + * The protocol requires absolute URLs, which is the one place delivery cannot + * stay origin-agnostic - so the origin is a required argument here rather than an + * option. A path that will not resolve against it comes back `null` and the entry + * is dropped: a sitemap with one malformed `<loc>` is a sitemap a crawler may + * reject whole. + */ +const absolute = (origin: string, path: string): null | string => { + try { + return new URL(path, origin).toString(); + } catch { + return null; + } +}; + +/** `priority` at the one precision the protocol illustrates, without a float tail. */ +const formatPriority = (priority: number): string => priority.toFixed(1); + +/** + * A `<urlset>` document for one page of entries. + * + * `alternates` are opt-in and, when present, emitted as `xhtml:link` elements - + * the form the sitemap extension for `hreflang` actually defines, with the + * namespace declared on the root element and every alternate of a group repeated + * inside **each** of its `<url>` entries. That last rule is the one implementations + * get wrong, and it is why alternates are supplied per entry rather than derived: + * the caller has already resolved which translations are published, and this + * function does not go looking. + * + * Every entry is emitted in the order it was given, so two processes serializing + * the same page produce byte-identical documents. + */ +export const contentSitemapXml = ({ + alternates, + entries, + origin, +}: { + /** + * The alternates of each entry, keyed by `itemId`. Omit it and no `xhtml:link` + * element is emitted at all, which is a valid sitemap and the right default. + */ + alternates?: ReadonlyMap<number, readonly { locale: string; path: string }[]>; + entries: readonly ContentSitemapEntry[]; + origin: string; +}): string => { + const withAlternates = alternates !== undefined && alternates.size > 0; + const lines: string[] = [ + '<?xml version="1.0" encoding="UTF-8"?>', + withAlternates + ? '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">' + : '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + ]; + + for (const entry of entries) { + const loc = absolute(origin, entry.path); + if (loc === null) continue; + + lines.push(" <url>"); + lines.push(` <loc>${escapeXml(loc)}</loc>`); + lines.push( + ` <lastmod>${escapeXml(entry.lastModified.toISOString())}</lastmod>`, + ); + if (entry.changeFrequency !== null) { + lines.push(` <changefreq>${entry.changeFrequency}</changefreq>`); + } + if (entry.priority !== null) { + lines.push(` <priority>${formatPriority(entry.priority)}</priority>`); + } + + for (const alternate of alternates?.get(entry.itemId) ?? []) { + const href = absolute(origin, alternate.path); + if (href === null) continue; + + lines.push( + ` <xhtml:link rel="alternate" hreflang="${escapeXml(alternate.locale)}" href="${escapeXml(href)}" />`, + ); + } + + lines.push(" </url>"); + } + + lines.push("</urlset>"); + + return `${lines.join("\n")}\n`; +}; + +/** One file in a sitemap index. */ +export interface ContentSitemapIndexEntry { + lastModified?: Date; + /** Relative or absolute; a relative one is resolved against the origin. */ + path: string; +} + +/** + * A `<sitemapindex>` document. + * + * What a site serves at `/sitemap.xml` once one file is not enough. It is a + * separate function from {@link contentSitemapXml} because it is a separate + * document type with a separate root element - and because an index whose entries + * were `<url>` elements is the single most common way to publish a sitemap no + * crawler reads. + */ +export const contentSitemapIndexXml = ({ + entries, + origin, +}: { + entries: readonly ContentSitemapIndexEntry[]; + origin: string; +}): string => { + const lines: string[] = [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + ]; + + for (const entry of entries) { + const loc = absolute(origin, entry.path); + if (loc === null) continue; + + lines.push(" <sitemap>"); + lines.push(` <loc>${escapeXml(loc)}</loc>`); + if (entry.lastModified !== undefined) { + lines.push( + ` <lastmod>${escapeXml(entry.lastModified.toISOString())}</lastmod>`, + ); + } + lines.push(" </sitemap>"); + } + + lines.push("</sitemapindex>"); + + return `${lines.join("\n")}\n`; +}; + +/** + * How many files a given number of URLs needs, and how big each one is. + * + * One `1` for an empty content type rather than `0`: a site that serves + * `/sitemaps/blog.article-1.xml` should get an empty but valid document there + * rather than a 404, because an index that lists a file which does not exist is a + * broken index and a content type with nothing published today will have + * something tomorrow. + * + * `size` is clamped to the protocol's 50,000-URL ceiling, so a caller cannot ask + * for one enormous invalid file by passing a bigger page size. + */ +export const contentSitemapChunks = ({ + size = CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, + total, +}: { + size?: number; + total: number; +}): { pages: number; size: number } => { + const clamped = Math.max( + 1, + Math.min(Math.floor(size), CONTENT_SITEMAP_MAX_URLS), + ); + + return { + pages: Math.max(1, Math.ceil(Math.max(0, total) / clamped)), + size: clamped, + }; +}; From b57d8c861e450f2277192555b7819a2261861305 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:09:07 +0200 Subject: [PATCH 03/24] feat(content): add slug history persistence `core_content_slug_history` records every slug that has ever been a publicly addressable URL, and stores both states - current and retired - which is what makes its uniqueness a **reservation** rather than only a log: a retired address cannot be claimed by unrelated content, so nobody's incoming links quietly change meaning. Two partial unique indexes rather than one over a nullable `languageId`, for the reason `core_content_revisions` needs two: Postgres treats every `NULL` as distinct, so a single key including it would enforce nothing at all for the shared case it exists to protect. They double as the resolver's lookup, which runs on a public request path for a URL that is very often a typo. `path` is stored rather than rebuilt on read, because it is the one thing the engine cannot recompute later: `publicApi.path` is source configuration a developer may change, and the URL that was live is a historical fact. Every write takes the transaction it should run in and none of them opens one. There is deliberately no `delete`: a retired URL is somebody's bookmark. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../0032_add_content_slug_history.sql | 17 + apps/docs/migrations/meta/0032_snapshot.json | 4029 +++++++++++++++++ .../src/content/server/slug-history-model.ts | 371 ++ packages/vitnode/src/database/content.ts | 86 + 4 files changed, 4503 insertions(+) create mode 100644 apps/docs/migrations/0032_add_content_slug_history.sql create mode 100644 apps/docs/migrations/meta/0032_snapshot.json create mode 100644 packages/vitnode/src/content/server/slug-history-model.ts diff --git a/apps/docs/migrations/0032_add_content_slug_history.sql b/apps/docs/migrations/0032_add_content_slug_history.sql new file mode 100644 index 000000000..cb6e056e6 --- /dev/null +++ b/apps/docs/migrations/0032_add_content_slug_history.sql @@ -0,0 +1,17 @@ +CREATE TABLE "core_content_slug_history" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "languageId" integer, + "slug" varchar(160) NOT NULL, + "path" varchar(512) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "retiredAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "core_content_slug_history" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_slug_history_shared_unique" ON "core_content_slug_history" USING btree ("contentTypeId","slug") WHERE "languageId" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "core_content_slug_history_locale_unique" ON "core_content_slug_history" USING btree ("contentTypeId","languageId","slug") WHERE "languageId" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "core_content_slug_history_item_idx" ON "core_content_slug_history" USING btree ("contentTypeId","itemId","languageId");--> statement-breakpoint +CREATE INDEX "core_content_slug_history_plugin_id_idx" ON "core_content_slug_history" USING btree ("pluginId"); \ No newline at end of file diff --git a/apps/docs/migrations/meta/0032_snapshot.json b/apps/docs/migrations/meta/0032_snapshot.json new file mode 100644 index 000000000..386f622ef --- /dev/null +++ b/apps/docs/migrations/meta/0032_snapshot.json @@ -0,0 +1,4029 @@ +{ + "id": "b7094309-91e5-43f2-b9f9-d5666d73f0f4", + "prevId": "c3a84fce-ca99-43a8-8b83-a8be82faeed9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_slug_history": { + "name": "core_content_slug_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retiredAt": { + "name": "retiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_slug_history_shared_unique": { + "name": "core_content_slug_history_shared_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_locale_unique": { + "name": "core_content_slug_history_locale_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_item_idx": { + "name": "core_content_slug_history_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_plugin_id_idx": { + "name": "core_content_slug_history_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_advanced_articles": { + "name": "example_advanced_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 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "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()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_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_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles": { + "name": "example_localized_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/vitnode/src/content/server/slug-history-model.ts b/packages/vitnode/src/content/server/slug-history-model.ts new file mode 100644 index 000000000..be1ce7b41 --- /dev/null +++ b/packages/vitnode/src/content/server/slug-history-model.ts @@ -0,0 +1,371 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; + +import { core_content_slug_history } from "../../database/content"; +import { contentDeliveryPath } from "../delivery"; +import { ContentDeliverySlugReserved } from "../errors"; + +/** + * One retired or current public address of one record. + * + * The AdminCP shows these, the resolver reads them and an audit reads them long + * after the record is gone. `retiredAt === null` means "this is the address the + * record answers to now"; anything else is a URL that redirects to it. + */ +export interface ContentSlugHistoryEntry { + createdAt: Date; + itemId: number; + /** `null` for a shared slug - see `core_content_slug_history`. */ + languageId: null | number; + /** The URL that was live, exactly as it was live. */ + path: string; + retiredAt: Date | null; + slug: string; +} + +/** + * One address of one record, as every write names it. + * + * `languageId` is the storage key and `locale` is what an error message says out + * loud - both, because the two are needed at different layers and deriving one + * from the other here would mean a language lookup inside a transaction that + * already knows the answer. + */ +export interface ContentSlugHistoryTarget { + itemId: number; + /** `null` for a shared slug - see `core_content_slug_history`. */ + languageId: null | number; + /** The canonical `core_languages.code`, or `null` when the slug is shared. */ + locale: null | string; + slug: string; +} + +/** + * The persistence half of slug history: reservations in, lookups out. + * + * Every write takes the transaction it should run in, and none of them opens one. + * That is the whole design constraint: the slug mutation, the reservation and the + * revision have to commit or roll back together, so this module can never be the + * thing that decides when that happens. `editorial-service` and + * `translation-editorial-service` own the transaction and call in. + * + * There is deliberately no `delete`. A retired URL is somebody's bookmark, and + * removing the row would let unrelated content inherit it - so the only way + * history shrinks is a deliberate, permissioned AdminCP action, which Stage 8 does + * not ship. + */ +export interface ContentSlugHistoryModel { + /** + * Refuses a slug that another record's history already owns. + * + * Called **before** the write it guards, so an editor is told at save time + * rather than at publish time - and so the failing transaction has done as + * little as possible. A slug this same record already owns is fine: moving from + * `b` back to `a` re-activates its own retired reservation rather than colliding + * with it. + */ + assertAvailable: ( + tx: ContentDatabase, + args: ContentSlugHistoryTarget, + ) => Promise<void>; + /** + * Every address one record has ever had, newest first. + * + * Scoped by language when one is given, which is what makes the AdminCP's Polish + * tab show Polish URLs and nothing else. + */ + list: ( + args: { itemId: number; languageId?: null | number; limit?: number }, + database?: ContentDatabase, + ) => Promise<ContentSlugHistoryEntry[]>; + /** + * The record a retired (or current) address belongs to, or `null`. + * + * The resolver's one lookup, and the reason the two partial unique indexes lead + * with `(contentTypeId, slug)`: this runs on a public request path for a URL that + * is very often a typo, so it has to be an index hit rather than a scan. + */ + owner: ( + args: { languageId: null | number; slug: string }, + database?: ContentDatabase, + ) => Promise<ContentSlugHistoryEntry | null>; + /** + * Records one slug as the record's **current** public address. + * + * Idempotent: a republish of an unchanged slug re-activates the row it already + * has rather than inserting a second one, which is what keeps a retried queue + * task and a double-clicked publish button harmless. + * + * Throws {@link ContentDeliverySlugReserved} when another record owns the + * address. That check is a `SELECT ... FOR UPDATE` inside the caller's + * transaction rather than a caught unique violation, so the error names the slug + * and the locale instead of a Postgres constraint - and so two concurrent + * reservations of the same URL serialise instead of racing. + */ + reserve: ( + tx: ContentDatabase, + args: ContentSlugHistoryTarget & { + /** The path this slug produced, recorded as the historical fact it is. */ + path: string; + }, + ) => Promise<{ created: boolean }>; + /** + * Stamps one of a record's own addresses as no longer current. + * + * `{ retired: true }` only when a row was actually there and actually active, + * which is precisely the "this URL was publicly addressable" test: a draft whose + * slug was corrected three times before it was ever published has no row to + * retire, so it creates no redirect and emits no event. + */ + retire: ( + tx: ContentDatabase, + args: Omit<ContentSlugHistoryTarget, "locale">, + ) => Promise<{ retired: boolean }>; +} + +const HISTORY_LIST_LIMIT = 50; + +/** + * The language predicate, written the one way that is correct for both cases. + * + * `IS NULL` for a shared slug and `=` for a localized one: `languageId = NULL` is + * `NULL` in SQL, never `true`, so an equality comparison would silently match no + * shared row at all - and a shared reservation that matches nothing is a + * reservation that reserves nothing. + */ +const languageCondition = ( + languageId: null | number, + column: typeof core_content_slug_history.languageId, +): SQL => (languageId === null ? isNull(column) : eq(column, languageId)); + +const toEntry = (row: { + createdAt: Date; + itemId: number; + languageId: null | number; + path: string; + retiredAt: Date | null; + slug: string; +}): ContentSlugHistoryEntry => ({ + createdAt: row.createdAt, + itemId: row.itemId, + languageId: row.languageId, + path: row.path, + retiredAt: row.retiredAt, + slug: row.slug, +}); + +const ENTRY_COLUMNS = { + createdAt: core_content_slug_history.createdAt, + itemId: core_content_slug_history.itemId, + languageId: core_content_slug_history.languageId, + path: core_content_slug_history.path, + retiredAt: core_content_slug_history.retiredAt, + slug: core_content_slug_history.slug, +}; + +export const createContentSlugHistoryModel = ({ + c, + definition, + pluginId, +}: { + c: Context; + definition: AnyContentTypeDefinition; + pluginId: string; +}): ContentSlugHistoryModel => { + const contentTypeId = definition.id; + const scope = eq(core_content_slug_history.contentTypeId, contentTypeId); + + const findOwner = async ( + database: ContentDatabase, + { languageId, slug }: { languageId: null | number; slug: string }, + { lock = false }: { lock?: boolean } = {}, + ): Promise<ContentSlugHistoryEntry | null> => { + const query = database + .select(ENTRY_COLUMNS) + .from(core_content_slug_history) + .where( + and( + scope, + eq(core_content_slug_history.slug, slug), + languageCondition(languageId, core_content_slug_history.languageId), + ), + ) + .limit(1); + + const [row] = lock ? await query.for("update") : await query; + + return row ? toEntry(row) : null; + }; + + return { + assertAvailable: async (tx, { itemId, languageId, locale, slug }) => { + const owner = await findOwner(tx, { languageId, slug }); + if (owner === null || owner.itemId === itemId) return; + + throw new ContentDeliverySlugReserved({ contentTypeId, locale, slug }); + }, + + list: async ({ itemId, languageId, limit }, database) => { + const conditions = [scope, eq(core_content_slug_history.itemId, itemId)]; + if (languageId !== undefined) { + conditions.push( + languageCondition(languageId, core_content_slug_history.languageId), + ); + } + + const rows = await (database ?? c.get("db")) + .select(ENTRY_COLUMNS) + .from(core_content_slug_history) + .where(and(...conditions)) + // Current address first, then the retired ones newest to oldest: that is + // the order somebody reading the panel wants, and `id` breaks the tie so + // two rows created in the same millisecond do not swap places between + // reads. + .orderBy( + asc(core_content_slug_history.retiredAt), + desc(core_content_slug_history.id), + ) + .limit(Math.min(limit ?? HISTORY_LIST_LIMIT, HISTORY_LIST_LIMIT)); + + return rows.map(toEntry); + }, + + owner: async (args, database) => + await findOwner(database ?? c.get("db"), args), + + reserve: async (tx, { itemId, languageId, locale, path, slug }) => { + // Locked, so two writers reserving the same address in two transactions + // serialise here rather than both reaching the unique index and one of them + // surfacing a raw `23505`. + const existing = await findOwner( + tx, + { languageId, slug }, + { lock: true }, + ); + + if (existing !== null) { + if (existing.itemId !== itemId) { + throw new ContentDeliverySlugReserved({ + contentTypeId, + locale, + slug, + }); + } + + // Its own row, coming back into service: a slug that moved away and then + // moved back, or a republish of the address it already had. + await tx + .update(core_content_slug_history) + .set({ path, retiredAt: null }) + .where( + and( + scope, + eq(core_content_slug_history.itemId, itemId), + eq(core_content_slug_history.slug, slug), + languageCondition( + languageId, + core_content_slug_history.languageId, + ), + ), + ); + + return { created: false }; + } + + await tx.insert(core_content_slug_history).values({ + contentTypeId, + itemId, + languageId, + path, + pluginId, + slug, + }); + + return { created: true }; + }, + + retire: async (tx, { itemId, languageId, slug }) => { + const rows = await tx + .update(core_content_slug_history) + .set({ retiredAt: sql`now()` }) + .where( + and( + scope, + eq(core_content_slug_history.itemId, itemId), + eq(core_content_slug_history.slug, slug), + languageCondition(languageId, core_content_slug_history.languageId), + // Only an *active* row is retired. A slug already marked historical + // keeps the moment it stopped being live, which is the only timestamp + // that means anything to an audit. + isNull(core_content_slug_history.retiredAt), + ), + ) + .returning({ id: core_content_slug_history.id }); + + return { retired: rows.length > 0 }; + }, + }; +}; + +/** + * The current address of several records at once, keyed by identifier. + * + * Batched rather than one query per record, because the AdminCP list and a sitemap + * page both want a whole page's worth - and the alternative is the classic query + * per row that only shows up as a problem in production. + */ +export const contentSlugHistoryCurrentPaths = async ( + database: ContentDatabase, + { + contentTypeId, + itemIds, + languageId, + }: { + contentTypeId: string; + itemIds: readonly number[]; + languageId: null | number; + }, +): Promise<Map<number, string>> => { + if (itemIds.length === 0) return new Map(); + + const rows = await database + .select({ + itemId: core_content_slug_history.itemId, + path: core_content_slug_history.path, + }) + .from(core_content_slug_history) + .where( + and( + eq(core_content_slug_history.contentTypeId, contentTypeId), + inArray(core_content_slug_history.itemId, [...itemIds]), + languageCondition(languageId, core_content_slug_history.languageId), + isNull(core_content_slug_history.retiredAt), + ), + ); + + return new Map(rows.map(row => [row.itemId, row.path])); +}; + +/** + * The path one slug produces, or the empty string when it produces none. + * + * A thin wrapper over {@link contentDeliveryPath} for the write paths, which have + * to store *something* in a `NOT NULL` column. An unbuildable path means the slug + * was never addressable, so the caller does not reserve it at all - and this + * returning `""` rather than throwing keeps that decision in the caller where the + * surrounding transaction is. + */ +export const contentSlugHistoryPath = ({ + definition, + locale, + slug, +}: { + definition: AnyContentTypeDefinition; + locale: null | string; + slug: string; +}): string => contentDeliveryPath({ definition, locale, slug }) ?? ""; diff --git a/packages/vitnode/src/database/content.ts b/packages/vitnode/src/database/content.ts index 2d0019817..19f226950 100644 --- a/packages/vitnode/src/database/content.ts +++ b/packages/vitnode/src/database/content.ts @@ -10,9 +10,11 @@ import type { import { CONTENT_ACTOR_TYPES, + CONTENT_DELIVERY_PATH_MAX_LENGTH, CONTENT_REVISION_OPERATIONS, CONTENT_SCHEDULE_ACTIONS, CONTENT_SCHEDULE_STATUSES, + CONTENT_SLUG_DEFAULT_LENGTH, } from "../content/const"; import { core_users } from "./users"; @@ -208,6 +210,90 @@ export const core_content_schedules = pgTable( export type ContentScheduleRow = typeof core_content_schedules.$inferSelect; +/** + * Every slug that has ever been a **publicly addressable** URL, for content types + * with `delivery.redirects`. + * + * Shared and foreign-key-free for the same two reasons as + * {@link core_content_revisions}: the target table is generated at runtime so + * core's static schema cannot name it, and a URL's history stays true after the + * record is gone. It is scoped by `(contentTypeId, itemId)` on every query, and a + * delete leaves the history in place - an incoming link to a deleted article is + * exactly the diagnostic somebody will want, and the resolver answers 404 for it + * by reading the live record rather than by having forgotten the URL. + * + * `languageId` is the locale identity, and `NULL` means the slug is **shared**: + * either the content type is not localized, or it is and its slug lives on the + * base row. That single column is what makes `/en/articles/hello` and + * `/pl/articles/hello` two independent histories - changing the English URL + * creates no Polish redirect - while a shared slug stays one reservation covering + * every language it appears in. + * + * `retiredAt` is `NULL` while the slug is the record's *current* address and is + * stamped when it moves away. Both states are stored, which is what makes the + * uniqueness below a **reservation** rather than only a log: a retired URL cannot + * be claimed by unrelated content, so nobody's incoming links quietly change + * meaning. + */ +export const core_content_slug_history = pgTable( + "core_content_slug_history", + t => ({ + id: t.serial().primaryKey(), + pluginId: t.varchar({ length: 255 }).notNull(), + contentTypeId: t.varchar({ length: 100 }).notNull(), + itemId: t.integer().notNull(), + /** `NULL` for a shared slug. See the table comment. */ + languageId: t.integer(), + slug: t.varchar({ length: CONTENT_SLUG_DEFAULT_LENGTH }).notNull(), + /** + * The canonical path this slug produced, e.g. `/pl/articles/stary-slug`. + * + * Stored rather than rebuilt on read, and the reason is that it is the one + * thing the engine cannot recompute later: a path is built from + * `publicApi.path`, which is source configuration a developer may change. The + * URL that was live is a historical fact, so it is recorded as one - and the + * AdminCP shows exactly the address somebody's bookmark holds. + */ + path: t.varchar({ length: CONTENT_DELIVERY_PATH_MAX_LENGTH }).notNull(), + createdAt: t.timestamp().notNull().defaultNow(), + /** When this slug stopped being the record's address. `NULL` while current. */ + retiredAt: t.timestamp(), + }), + t => [ + // The reservation, and the resolver's lookup, in one index each. + // + // Two partial uniques rather than one over a nullable `languageId`, for + // exactly the reason `core_content_revisions` needs two: Postgres treats every + // `NULL` as distinct, so a single key including it would enforce nothing at + // all for the shared case it exists to protect. + // + // No `pluginId` in either key. `validateContentTypes` rejects a duplicate + // content type id across every installed plugin at boot, so an id already + // identifies one content type - adding the owner would widen the index without + // excluding anything. It is still a column, because ownership is what a + // cleanup or an audit keys off. + uniqueIndex("core_content_slug_history_shared_unique") + .on(t.contentTypeId, t.slug) + .where(sql`"languageId" IS NULL`), + uniqueIndex("core_content_slug_history_locale_unique") + .on(t.contentTypeId, t.languageId, t.slug) + .where(sql`"languageId" IS NOT NULL`), + // One record's history, for the AdminCP panel and for retiring the slug a + // mutation just moved away from. The unique indexes above cannot serve it: + // they lead with the slug rather than with the item, and a partial index is + // only usable for queries the planner can prove match its predicate. + index("core_content_slug_history_item_idx").on( + t.contentTypeId, + t.itemId, + t.languageId, + ), + index("core_content_slug_history_plugin_id_idx").on(t.pluginId), + ], +).enableRLS(); + +export type ContentSlugHistoryRow = + typeof core_content_slug_history.$inferSelect; + /** Re-exported so `src/database` consumers need not reach into `content/`. */ export type { ContentAnyRevisionSnapshot, From 5bdb9a478fc91ac997e8d2c3e7c0d589ec47e7e9 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:09:30 +0200 Subject: [PATCH 04/24] feat(content): add the delivery service and redirect resolver `model.deliveryService(c, { pluginId })` answers the five delivery questions and mutates nothing - structurally, not by convention: slug history is written inside the transaction that moves the slug, so there is no `reserve` here to call without one. Every answer is derived from the **public projection** rather than from the base row, so the publication predicate, the field allowlist and the Stage 5 fallback rules are the ones already tested rather than a second implementation that agrees on the day it is written. It is also what makes "SEO cannot leak a private field" true at runtime: a private column is never fetched. Two properties are worth calling out. Chains collapse because the resolver never follows the history - it finds the record an address belongs to and reads that record's *current* slug, so `a -> b -> c` is one hop from either end. And the redirect destination is read **strictly** by locale: sending `/pl/articles/stary-slug` to the English canonical would answer a Polish URL with an English page and permanently tell a crawler that is correct. Alternates are real published translations and nothing else. A locale served through `fallback: "default"` has no URL of its own, so listing one would announce an `hreflang` pointing at a 404. The sitemap query pages by keyset over the primary key rather than by offset, and reads `greatest(base, translation)` through the column's own decoder - Drizzle disables the driver's timestamp parsing for its mappers, so a raw fragment would parse the same value as local time and put every localized `lastmod` hours out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/content/server/delivery-alternates.ts | 170 +++++++ .../src/content/server/delivery-service.ts | 428 ++++++++++++++++++ .../src/content/server/delivery-sitemap.ts | 292 ++++++++++++ packages/vitnode/src/content/server/index.ts | 38 ++ packages/vitnode/src/content/server/model.ts | 30 +- 5 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 packages/vitnode/src/content/server/delivery-alternates.ts create mode 100644 packages/vitnode/src/content/server/delivery-service.ts create mode 100644 packages/vitnode/src/content/server/delivery-sitemap.ts diff --git a/packages/vitnode/src/content/server/delivery-alternates.ts b/packages/vitnode/src/content/server/delivery-alternates.ts new file mode 100644 index 000000000..4fc2a56b9 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-alternates.ts @@ -0,0 +1,170 @@ +import type { + PgColumn, + PgTable, + PgTableWithColumns, + TableConfig, +} from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, asc, eq, inArray } from "drizzle-orm"; + +import type { ContentDeliveryAlternate } from "../delivery"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; +import type { ContentDatabase } from "./service"; + +import { contentDeliveryPath } from "../delivery"; +import { listContentLanguages } from "./language-resolver"; +import { + contentTranslationPublicationColumns, + publicationColumns, + publishedCondition, +} from "./publication"; + +/** + * The localized alternates of one record: every language it is genuinely + * published in, and its URL there. + * + * "Genuinely" is the whole of it, and it is why this is a query rather than a + * projection of something the public read already returned. An alternate is a + * promise that a URL resolves, so the predicate is the *same* subordinated + * publication rule the public read applies - the base row published, the + * translation published, both dated now or earlier - and a locale that only exists + * through `fallback: "default"` fails it. Fabricating an alternate from a fallback + * would announce `/de/articles/x` for a record with no German translation: an + * `hreflang` pointing at a 404, and an invitation to index the English copy twice. + * + * A language the installation has switched off is filtered out too, in JavaScript + * rather than in SQL - "enabled" is a fact about the app config, not a column on + * `core_languages`, and `listContentLanguages` already holds it for the life of the + * request. + */ +export const readDeliveryAlternates = async < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + itemId, + model, +}: { + c: Context; + itemId: number; + model: ContentModel<TDefinition>; +}): Promise<ContentDeliveryAlternate[]> => { + const batched = await readDeliveryAlternatesMany({ + c, + itemIds: [itemId], + model, + }); + + return batched.get(itemId) ?? []; +}; + +/** + * The same answer for a whole page of records, in one query. + * + * A sitemap with `xhtml:link` alternates needs the alternates of every URL on the + * page, and a per-record query there is the classic N+1 that only becomes visible + * once a site has content. One `IN` and one grouping pass instead. + */ +export const readDeliveryAlternatesMany = async < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + database, + itemIds, + model, +}: { + c: Context; + database?: ContentDatabase; + itemIds: readonly number[]; + model: ContentModel<TDefinition>; +}): Promise<Map<number, ContentDeliveryAlternate[]>> => { + const { columns, definition, translationColumns, translationTable } = model; + const grouped = new Map<number, ContentDeliveryAlternate[]>(); + + if ( + itemIds.length === 0 || + !definition.localization.enabled || + !definition.publicApi.enabled || + !translationTable || + !translationColumns + ) { + return grouped; + } + + const slugField = definition.publicApi.slugField; + const base = publicationColumns(definition, columns); + const translation = contentTranslationPublicationColumns( + definition, + translationColumns, + ); + + // The slug comes off whichever table owns it. A shared slug gives every language + // the same segment, which is a legitimate shape - the locale prefix is what makes + // the two URLs different - so it is read from the base row for all of them. + const slugColumn: PgColumn = + definition.delivery.slugScope === "localized" + ? translationColumns[slugField] + : columns[slugField]; + + const languages = await listContentLanguages(c); + const byId = new Map(languages.map(language => [language.id, language])); + // Widened, not cast: the generated table type carries every column as a literal, + // which Drizzle's `.from()` and `.innerJoin()` overloads cannot resolve through a + // generic. The same widening `buildContentPublicRoutes` documents. + const baseTable: PgTableWithColumns<TableConfig> = model.table; + + const rows = await (database ?? c.get("db")) + .select({ + itemId: translationColumns.itemId, + languageId: translationColumns.languageId, + slug: slugColumn, + }) + .from(translationTable as PgTable) + .innerJoin(baseTable, eq(translationColumns.itemId, columns.id)) + .where( + and( + inArray(translationColumns.itemId, [...itemIds]), + publishedCondition(base), + publishedCondition(translation), + ), + ) + // Deterministic: two processes rendering the same `hreflang` set - or the same + // sitemap - produce the same document, which is what makes a byte comparison a + // usable test rather than a flake. Sorted again by locale below, because the + // canonical code is resolved in JavaScript. + .orderBy( + asc(translationColumns.itemId), + asc(translationColumns.languageId), + ); + + for (const row of rows) { + // The selected keys come back as `unknown` through the generic column map, so + // each one is narrowed here rather than asserted - the same treatment the + // sitemap query gives its own projection. + const itemId = typeof row.itemId === "number" ? row.itemId : null; + const languageId = + typeof row.languageId === "number" ? row.languageId : null; + if (itemId === null || languageId === null) continue; + + const language = byId.get(languageId); + if (!language?.isEnabled) continue; + + const path = contentDeliveryPath({ + definition, + locale: language.locale, + slug: typeof row.slug === "string" ? row.slug : "", + }); + if (path === null) continue; + + const entries = grouped.get(itemId) ?? []; + entries.push({ locale: language.locale, path }); + grouped.set(itemId, entries); + } + + for (const entries of grouped.values()) { + entries.sort((a, b) => a.locale.localeCompare(b.locale)); + } + + return grouped; +}; diff --git a/packages/vitnode/src/content/server/delivery-service.ts b/packages/vitnode/src/content/server/delivery-service.ts new file mode 100644 index 000000000..52d36be37 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-service.ts @@ -0,0 +1,428 @@ +import type { Context } from "hono"; + +import type { + ContentDeliveryAlternate, + ContentDeliveryHreflang, + ContentDeliveryRobots, + ContentDeliverySeo, +} from "../delivery"; +import type { ContentSitemapEntry } from "../sitemap"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDeliverySitemapPage } from "./delivery-sitemap"; +import type { ContentModel } from "./model"; +import type { ContentSlugHistoryEntry } from "./slug-history-model"; + +import { CONTENT_DELIVERY_REDIRECT_STATUS } from "../const"; +import { + contentDeliveryHreflang, + contentDeliveryOpenGraph, + contentDeliveryPath, + contentDeliveryRobots, + contentDeliverySeo, + contentDeliveryUrl, + parseContentDeliveryPath, +} from "../delivery"; +import { ContentDeliveryNotEnabled } from "../errors"; +import { contentLocalesMatch, normalizeContentLocale } from "../locale"; +import { readDeliveryAlternates } from "./delivery-alternates"; +import { readContentDeliverySitemapPage } from "./delivery-sitemap"; +import { findContentLanguage } from "./language-resolver"; +import { createContentSlugHistoryModel } from "./slug-history-model"; + +/** + * Everything a page needs to render one record's `<head>`. + * + * Two locales, not one, and the distinction is the whole reason this type exists: + * `requestedLocale` is what the URL asked for and `locale` is what the reader is + * actually being shown. With `localization.fallback: "default"` those differ, and a + * canonical URL built from the first would announce `/pl/articles/x` for an English + * translation - a URL that answers 404, self-referentially declared canonical. + */ +export interface ContentDeliveryMetadata { + /** Real published translations only. Empty for a nonlocalized content type. */ + alternates: ContentDeliveryAlternate[]; + /** + * The canonical path of the version actually being served. + * + * `null` only when the record has no public URL in that language at all, which + * for a resolved record means its slug is empty - a row written straight into the + * database rather than through the engine. + */ + canonicalPath: null | string; + /** Present only when the caller supplied an origin. */ + canonicalUrl?: null | string; + /** Framework-neutral `hreflang`, ready for an adapter to translate. */ + hreflang: ContentDeliveryHreflang; + /** Whether `locale` differs from `requestedLocale`. */ + isFallback: boolean; + /** + * The record's identifier, when the public projection carries one. + * + * `null` for a content type whose `publicApi.fields` withholds `id`, and that is + * deliberate rather than a gap: delivery metadata is read off the **public** + * projection, so it cannot report a column the public API declined to publish. + * Expose `"id"` in the allowlist and it is always present. + * + * A resolution reached through {@link ContentDeliveryService.findById} always + * carries it, because the caller supplied it. + */ + itemId: null | number; + /** The language this response is actually in. */ + locale: null | string; + /** `null` unless `delivery.seo.openGraph` is configured. */ + openGraph: ContentDeliverySeo | null; + /** What was asked for, normalized. `null` for a nonlocalized content type. */ + requestedLocale: null | string; + /** `null` unless `delivery.seo.noIndexField` is configured. */ + robots: ContentDeliveryRobots | null; + seo: ContentDeliverySeo; +} + +/** + * What one public path resolves to. + * + * A discriminated union rather than a nullable metadata object, because the three + * outcomes need three different HTTP responses and a caller that had to infer + * which one it was holding would get it wrong. `redirect` carries its own status + * so a frontend never hardcodes one. + */ +export type ContentDeliveryResolution = + | (ContentDeliveryMetadata & { type: "content" }) + | { location: string; status: 308; type: "redirect" } + | { type: "not_found" }; + +export interface ContentDeliveryReadOptions { + /** The language to read, for a localized content type. */ + locale?: string; + /** Turns every path in the result into an absolute URL as well. */ + origin?: string; +} + +export interface ContentDeliverySitemapArgs { + /** The last `itemId` of the previous page. Keyset, never an offset. */ + cursor?: number; + /** Defaults to `CONTENT_SITEMAP_DEFAULT_PAGE_SIZE`, capped at the protocol's. */ + limit?: number; + /** Required for a localized content type; each language is its own sitemap. */ + locale?: string; +} + +/** + * The read-only delivery layer of one content type. + * + * There is deliberately **no mutation here at all**. Slug history is written by + * the editorial services, inside the transaction that moves the slug, because that + * is the only place the two can be atomic - and exposing a `reserve` here would be + * an invitation to write one without the other. This object answers questions. + * + * Every answer is derived from the **public** projection, not from the base row: + * `findById` and `resolveSlug` go through `model.publicService`, so the publication + * predicate, the field allowlist and the Stage 5 fallback rules are the ones + * already tested rather than a second implementation that agrees on the day it is + * written. That is also what makes "SEO cannot leak a private field" true here for + * free: a private column is never fetched, so it is not in the row this reads. + */ +export interface ContentDeliveryService { + /** + * Every published translation's URL, in a stable order. + * + * Only real ones. A locale served by the fallback has no URL of its own, so it + * is absent - listing it would announce an `hreflang` alternate that answers + * 404 and invite a crawler to index the same content twice. + */ + alternates: (itemId: number) => Promise<ContentDeliveryAlternate[]>; + /** Delivery metadata by identifier, honouring the content type's fallback. */ + findById: ( + itemId: number, + options?: ContentDeliveryReadOptions, + ) => Promise<ContentDeliveryMetadata | null>; + /** + * Every address this record has ever answered to, current one first. + * + * Read-only, and the AdminCP's delivery panel is its only caller today. It needs + * no permission of its own beyond the one that let the reader see the record. + */ + history: ( + itemId: number, + options?: { locale?: string }, + ) => Promise<ContentSlugHistoryEntry[]>; + /** + * Resolves a whole public path: `/pl/articles/stary-slug`. + * + * The one method a catch-all route calls. It parses the path with the same rules + * {@link contentDeliveryPath} builds it by, so a path this engine did not produce + * is `not_found` rather than a guess. + */ + resolvePath: ( + path: string, + options?: { origin?: string }, + ) => Promise<ContentDeliveryResolution>; + /** The same resolution, when the caller has already split locale from slug. */ + resolveSlug: ( + slug: string, + options?: ContentDeliveryReadOptions, + ) => Promise<ContentDeliveryResolution>; + /** One page of sitemap entries. Cursor-paginated and deterministic. */ + sitemap: ( + args?: ContentDeliverySitemapArgs, + ) => Promise<ContentDeliverySitemapPage>; +} + +/** The exposed slug of a public row, or `null` when it has none. */ +const slugOf = ( + definition: AnyContentTypeDefinition, + row: Record<string, unknown>, +): null | string => { + const value = row[definition.publicApi.slugField]; + + return typeof value === "string" && value !== "" ? value : null; +}; + +/** The language a public row is actually in, off the projection's own key. */ +const localeOf = ( + definition: AnyContentTypeDefinition, + row: Record<string, unknown>, +): null | string => { + if (!definition.localization.enabled) return null; + + return typeof row.locale === "string" ? row.locale : null; +}; + +export const createContentDeliveryService = < + TDefinition extends AnyContentTypeDefinition, +>({ + c, + model, + pluginId, +}: { + c: Context; + model: ContentModel<TDefinition>; + pluginId: string; +}): ContentDeliveryService => { + const { definition } = model; + const contentTypeId = definition.id; + + if (!definition.delivery.enabled || !definition.publicApi.enabled) { + throw new ContentDeliveryNotEnabled({ contentTypeId }); + } + + const localized = definition.localization.enabled; + const buildPublic = model.publicService; + if (!buildPublic) throw new ContentDeliveryNotEnabled({ contentTypeId }); + + const slugHistory = createContentSlugHistoryModel({ + c, + definition, + pluginId, + }); + + /** + * The language a historical URL belongs to. + * + * `null` whenever the slug is shared, which covers both a nonlocalized content + * type and a localized one whose slug lives on the base row - in the second case + * every language answers to the same segment, so one reservation is correct for + * all of them. + */ + const historyLanguageId = async ( + locale: null | string, + ): Promise<null | number> => { + if (definition.delivery.slugScope !== "localized" || locale === null) { + return null; + } + + const language = await findContentLanguage(c, locale); + + return language?.id ?? null; + }; + + const metadataFor = async ( + row: Record<string, unknown>, + { + itemId, + origin, + requestedLocale, + }: { + itemId: null | number; + origin?: string; + requestedLocale: null | string; + }, + ): Promise<ContentDeliveryMetadata> => { + const locale = localeOf(definition, row); + const slug = slugOf(definition, row); + const canonicalPath = + slug === null ? null : contentDeliveryPath({ definition, locale, slug }); + const alternates = + localized && itemId !== null ? await readAlternates(itemId) : []; + + return { + alternates, + canonicalPath, + ...(origin === undefined + ? {} + : { + canonicalUrl: contentDeliveryUrl({ origin, path: canonicalPath }), + }), + hreflang: contentDeliveryHreflang({ alternates, definition }), + // Compared on the normalized forms, so `PL` asking and `pl` answering is not + // reported as a fallback. + isFallback: + requestedLocale !== null && + locale !== null && + !contentLocalesMatch(requestedLocale, locale), + itemId, + locale, + openGraph: contentDeliveryOpenGraph(definition, row), + requestedLocale, + robots: contentDeliveryRobots(definition, row), + seo: contentDeliverySeo(definition, row), + }; + }; + + const readAlternates = async ( + itemId: number, + ): Promise<ContentDeliveryAlternate[]> => + localized ? await readDeliveryAlternates({ c, itemId, model }) : []; + + /** + * The record's canonical path **in one specific language**, or `null`. + * + * Strict about the language on purpose. `publicService.findById` may fall back, + * and a redirect must not: sending `/pl/articles/stary-slug` to the English + * canonical would answer a Polish URL with an English page and permanently tell + * a crawler that is correct. So a row that came back in another language is + * treated as "this locale has no published version", which is what it is. + */ + const strictCanonicalPath = async ( + itemId: number, + locale: null | string, + ): Promise<null | string> => { + const row = await buildPublic(c).findById(itemId, { + locale: locale ?? undefined, + }); + if (!row) return null; + + const values = row as Record<string, unknown>; + const served = localeOf(definition, values); + if ( + locale !== null && + served !== null && + !contentLocalesMatch(locale, served) + ) { + return null; + } + + const slug = slugOf(definition, values); + + return slug === null + ? null + : contentDeliveryPath({ definition, locale: served, slug }); + }; + + const resolve = async ( + slug: string, + { locale, origin }: ContentDeliveryReadOptions = {}, + ): Promise<ContentDeliveryResolution> => { + const requestedLocale = + localized && locale !== undefined ? normalizeContentLocale(locale) : null; + + // The live record first, and strictly by slug: a URL belongs to the language + // it was published under, so `findBySlug` never falls back. + const row = await buildPublic(c).findBySlug(slug, { locale }); + if (row) { + const values = row as Record<string, unknown>; + + return { + ...(await metadataFor(values, { + // Only what the public projection actually carries - see + // `ContentDeliveryMetadata.itemId`. + itemId: typeof values.id === "number" ? values.id : null, + origin, + requestedLocale, + })), + type: "content", + }; + } + + if (!definition.delivery.redirects.enabled) return { type: "not_found" }; + + const languageId = await historyLanguageId(requestedLocale); + const owner = await slugHistory.owner({ languageId, slug }); + if (!owner) return { type: "not_found" }; + + // Straight to the record's **current** address, never to the next entry in the + // chain. `a -> b -> c` collapses here rather than in the data: the database + // keeps the chronology, and the resolver answers with one hop. + const destination = await strictCanonicalPath( + owner.itemId, + requestedLocale, + ); + + // Unpublished, deleted, or published only in another language: a historical URL + // must not become a way to reach content that is not public. 404 rather than a + // redirect to a page that would itself 404. + if (destination === null || destination === owner.path) { + return { type: "not_found" }; + } + + return { + location: destination, + status: CONTENT_DELIVERY_REDIRECT_STATUS, + type: "redirect", + }; + }; + + return { + alternates: async itemId => await readAlternates(itemId), + + findById: async (itemId, { locale, origin } = {}) => { + const row = await buildPublic(c).findById(itemId, { locale }); + if (!row) return null; + + return await metadataFor(row, { + itemId, + origin, + requestedLocale: + localized && locale !== undefined + ? normalizeContentLocale(locale) + : null, + }); + }, + + history: async (itemId, { locale } = {}) => { + const languageId = await historyLanguageId( + locale === undefined ? null : normalizeContentLocale(locale), + ); + + return await slugHistory.list({ + itemId, + // `undefined` - not `null` - when the caller named no locale, so the query + // is unscoped rather than scoped to the shared rows. A shared slug's + // history really is `languageId IS NULL`, and asking for "everything" has + // to stay distinguishable from asking for "the shared ones". + languageId: + definition.delivery.slugScope === "localized" && locale === undefined + ? undefined + : languageId, + }); + }, + + resolvePath: async (path, { origin } = {}) => { + const parts = parseContentDeliveryPath(definition, path); + if (!parts) return { type: "not_found" }; + + return await resolve(parts.slug, { + locale: parts.locale ?? undefined, + origin, + }); + }, + + resolveSlug: async (slug, options) => await resolve(slug, options), + + sitemap: async (args = {}) => + await readContentDeliverySitemapPage({ args, c, model }), + }; +}; + +/** Re-exported so a caller need not reach past this module for the entry type. */ +export type { ContentSitemapEntry }; diff --git a/packages/vitnode/src/content/server/delivery-sitemap.ts b/packages/vitnode/src/content/server/delivery-sitemap.ts new file mode 100644 index 000000000..94e08eb94 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-sitemap.ts @@ -0,0 +1,292 @@ +import type { SQL } from "drizzle-orm"; +import type { + PgColumn, + PgTableWithColumns, + TableConfig, +} from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, asc, eq, gt, ne, sql } from "drizzle-orm"; + +import type { ContentSitemapEntry } from "../sitemap"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDeliverySitemapArgs } from "./delivery-service"; +import type { ContentModel } from "./model"; + +import { + CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, + CONTENT_SITEMAP_MAX_URLS, +} from "../const"; +import { contentDeliveryPath } from "../delivery"; +import { ContentDeliveryNotEnabled } from "../errors"; +import { splitContentFieldPath } from "../paths"; +import { findContentLanguage } from "./language-resolver"; +import { + contentTranslationPublicationColumns, + publicationColumns, + publishedCondition, +} from "./publication"; + +/** + * One page of a content type's sitemap. + * + * `nextCursor` rather than a page number, and `null` rather than `hasNextPage` on + * its own: a sitemap is regenerated from scratch every time a crawler asks, and an + * `OFFSET` deep into a large table both slows down linearly and skips rows when + * something is published between two pages. A keyset over the primary key does + * neither. + */ +export interface ContentDeliverySitemapPage { + entries: ContentSitemapEntry[]; + /** Pass back as `cursor`. `null` when this was the last page. */ + nextCursor: null | number; +} + +/** + * Where a `noIndex` field is stored, resolved to the column it addresses. + * + * A leaf path (`seo.noIndex`) compiles to a generated column, and the delivery + * resolver has already refused a localized one - so this is always a column on the + * base table and the sitemap predicate is one clause rather than a join. + */ +const noIndexColumn = ( + definition: AnyContentTypeDefinition, + columns: Record<string, PgColumn>, +): null | PgColumn => { + const { noIndexField } = definition.delivery.seo; + if (noIndexField === null) return null; + + const path = splitContentFieldPath(noIndexField); + if (!path) return columns[noIndexField] ?? null; + + const leaf = definition.advanced.leaves.find( + entry => entry.path === noIndexField, + ); + + return leaf === undefined ? null : (columns[leaf.columnName] ?? null); +}; + +/** + * One page of sitemap entries for one content type, in one language. + * + * Everything about this function follows from "a sitemap lists what is public + * right now, and nothing else": + * + * - **The publication predicate is not a parameter.** A nonlocalized entry needs + * the base row published; a localized one needs the base row *and* the + * translation published, which is the same subordination the public read + * applies. A draft, an unpublished record and a future `publishedAt` are all + * simply absent. + * - **No fallback, ever.** Each locale is queried against its own translation, so + * a language served English through `fallback: "default"` contributes no URL - + * it has none of its own, and listing one would put the same content in the + * sitemap twice under two addresses. + * - **`lastModified` is `max(base.updatedAt, translation.updatedAt)`** for a + * localized entry. A shared field moving changes what every language's page + * renders even though no translation row was touched, so taking the + * translation's timestamp alone would tell a crawler nothing had changed. + * - **`noIndex` is one clause**, not a post-filter, so a page of 1,000 entries is + * 1,000 listed URLs rather than however many survived. + */ +export const readContentDeliverySitemapPage = async < + TDefinition extends AnyContentTypeDefinition, +>({ + args, + c, + model, +}: { + args: ContentDeliverySitemapArgs; + c: Context; + model: ContentModel<TDefinition>; +}): Promise<ContentDeliverySitemapPage> => { + const { columns, definition, translationColumns } = model; + const { sitemap } = definition.delivery; + + if (!definition.delivery.enabled || !definition.publicApi.enabled) { + throw new ContentDeliveryNotEnabled({ contentTypeId: definition.id }); + } + + // A content type that lists nothing answers with an empty page rather than + // throwing: a site-level sitemap index enumerates every delivery-enabled content + // type, and one of them opting out of the sitemap is a configuration choice, not + // a caller error. + if (!sitemap.enabled) return { entries: [], nextCursor: null }; + + const limit = Math.max( + 1, + Math.min( + args.limit ?? CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, + CONTENT_SITEMAP_MAX_URLS, + ), + ); + const slugField = definition.publicApi.slugField; + const base = publicationColumns(definition, columns); + const exclude = noIndexColumn(definition, columns); + const localized = definition.localization.enabled; + // Widened, not cast - see `readDeliveryAlternatesMany` for why. The translation + // table needs the same treatment for the join below. + const baseTable: PgTableWithColumns<TableConfig> = model.table; + const joinedTranslations: null | PgTableWithColumns<TableConfig> = + model.translationTable; + + const conditions: (SQL | undefined)[] = [ + publishedCondition(base), + args.cursor === undefined ? undefined : gt(columns.id, args.cursor), + // `ne(..., true)` rather than `eq(..., false)`: the column is `NOT NULL` today, + // and a nullable one added later would silently drop every row whose value was + // never set if this asked for an exact `false`. + exclude === null ? undefined : ne(exclude, true), + ]; + + if (!localized) { + const rows = await c + .get("db") + .select({ + itemId: columns.id, + lastModified: columns.updatedAt, + slug: columns[slugField], + }) + .from(baseTable) + .where( + and(...conditions.filter((part): part is SQL => part !== undefined)), + ) + .orderBy(asc(columns.id)) + .limit(limit + 1); + + return page({ + definition, + limit, + locale: null, + rows: rows.map(row => ({ + itemId: row.itemId as number, + lastModified: row.lastModified as Date, + slug: row.slug, + })), + }); + } + + if (!joinedTranslations || !translationColumns) { + return { entries: [], nextCursor: null }; + } + + // Each language is its own sitemap, so the language is resolved before the query + // rather than joined: a locale that names nothing this install serves has no + // sitemap, which is an empty page rather than an error - a crawler asking for + // `/sitemaps/blog.article-de.xml` on a site with no German should get a valid + // empty document. + const language = await findContentLanguage( + c, + args.locale ?? definition.localization.defaultLocale, + ); + if (!language?.isEnabled) return { entries: [], nextCursor: null }; + + const translation = contentTranslationPublicationColumns( + definition, + translationColumns, + ); + const slugColumn: PgColumn = + definition.delivery.slugScope === "localized" + ? translationColumns[slugField] + : columns[slugField]; + + const rows = await c + .get("db") + .select({ + itemId: columns.id, + // The representation's timestamp, not the row's: both halves are rendered + // into the page, so the later of the two is when it last changed. + // + // `.mapWith` is load-bearing rather than tidy. Drizzle turns off the driver's + // own timestamp parsing so its column mappers can treat a naive `timestamp` + // as UTC - but a raw `sql` fragment has no mapper, so the driver's fallback + // parses the same value as *local* time. The two disagree by the server's + // offset, which would put every localized `lastmod` hours out. Borrowing the + // column's decoder makes this expression read exactly as the column does. + lastModified: + sql<Date>`greatest(${columns.updatedAt}, ${translationColumns.updatedAt})`.mapWith( + columns.updatedAt, + ), + slug: slugColumn, + }) + .from(baseTable) + .innerJoin( + joinedTranslations, + and( + eq(translationColumns.itemId, columns.id), + eq(translationColumns.languageId, language.id), + ), + ) + .where( + and( + ...conditions.filter((part): part is SQL => part !== undefined), + publishedCondition(translation), + ), + ) + .orderBy(asc(columns.id)) + .limit(limit + 1); + + return page({ + definition, + limit, + locale: language.locale, + rows: rows.map(row => ({ + itemId: row.itemId as number, + // `greatest()` comes back as a string on some drivers, so it is normalized + // here rather than trusted - a sitemap `lastmod` of "Invalid Date" is a + // document a crawler rejects. + lastModified: + row.lastModified instanceof Date + ? row.lastModified + : new Date(String(row.lastModified)), + slug: row.slug, + })), + }); +}; + +/** + * Turns one over-fetched page of rows into entries and a cursor. + * + * `limit + 1` is fetched and the extra row is dropped, which is how "is there a + * next page" is answered without a second `COUNT` over a table that may be large. + */ +const page = ({ + definition, + limit, + locale, + rows, +}: { + definition: AnyContentTypeDefinition; + limit: number; + locale: null | string; + rows: readonly { itemId: number; lastModified: Date; slug: unknown }[]; +}): ContentDeliverySitemapPage => { + const visible = rows.slice(0, limit); + const { sitemap } = definition.delivery; + const entries: ContentSitemapEntry[] = []; + + for (const row of visible) { + const path = contentDeliveryPath({ + definition, + locale, + slug: typeof row.slug === "string" ? row.slug : "", + }); + // A row with no buildable path has no URL, so it has no sitemap line. It stays + // out of the entries and still advances the cursor, which is why the cursor is + // taken from `visible` rather than from `entries`. + if (path === null) continue; + + entries.push({ + changeFrequency: sitemap.changeFrequency, + itemId: row.itemId, + lastModified: row.lastModified, + locale, + path, + priority: sitemap.priority, + }); + } + + return { + entries, + nextCursor: rows.length > limit ? (visible.at(-1)?.itemId ?? null) : null, + }; +}; diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 3e95553ac..47fd1bb95 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -16,6 +16,34 @@ export { buildTranslationSystemColumns, } from "./column-builders"; export type { ColumnReferenceThunk } from "./column-builders"; +export { + readDeliveryAlternates, + readDeliveryAlternatesMany, +} from "./delivery-alternates"; +export { + contentDeliveryEffects, + contentDeliveryInvalidation, +} from "./delivery-effects"; +export type { ContentDeliveryEffectsResult } from "./delivery-effects"; +export { buildContentDeliveryRoutes } from "./delivery-routes"; +export { createContentDeliveryService } from "./delivery-service"; +export type { + ContentDeliveryMetadata, + ContentDeliveryReadOptions, + ContentDeliveryResolution, + ContentDeliveryService, + ContentDeliverySitemapArgs, +} from "./delivery-service"; +export { readContentDeliverySitemapPage } from "./delivery-sitemap"; +export type { ContentDeliverySitemapPage } from "./delivery-sitemap"; +export { + applyContentDeliveryWrite, + contentSlugHistoryFor, +} from "./delivery-writes"; +export type { + ContentDeliveryOutcome, + ContentDeliveryTransition, +} from "./delivery-writes"; export { contentEditorialEffects } from "./editorial-effects"; export type { ContentEditorialEffectsOptions, @@ -179,6 +207,16 @@ export type { ContentServiceOptions, ContentUpdateResult, } from "./service"; +export { + contentSlugHistoryCurrentPaths, + contentSlugHistoryPath, + createContentSlugHistoryModel, +} from "./slug-history-model"; +export type { + ContentSlugHistoryEntry, + ContentSlugHistoryModel, + ContentSlugHistoryTarget, +} from "./slug-history-model"; export { createSlugNormalizer } from "./slugs"; export type { ContentSlugNormalizer } from "./slugs"; export { diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index 5329b1667..5c64fe47b 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -7,6 +7,7 @@ import type { ResolvedContentLocalizationConfig, } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; +import type { ContentDeliveryService } from "./delivery-service"; import type { ContentEditorialService } from "./editorial-service"; import type { ContentLocalizedService } from "./localized-service"; import type { ContentPublicService } from "./public-service"; @@ -25,6 +26,7 @@ import type { import { ContentEngineError } from "../errors"; import { createContentAdvancedStore } from "./advanced-store"; import { createContentAdvancedTables } from "./advanced-tables"; +import { createContentDeliveryService } from "./delivery-service"; import { createContentEditorialService } from "./editorial-service"; import { createContentLocalizedPublicService } from "./localized-public-service"; import { createContentLocalizedService } from "./localized-service"; @@ -68,6 +70,21 @@ export interface ContentModel<TDefinition extends AnyContentTypeDefinition> { /** Column name -> Drizzle column, for filters, ordering and custom queries. */ columns: Record<ContentColumnName<TDefinition>, PgColumn>; definition: TDefinition; + /** + * The read-only delivery layer, or `undefined` without a `delivery` block. + * + * `undefined` rather than a throwing stub, matching `publicService` and + * `editorialService`: the check reads naturally in code that does not know which + * content type it was handed, and TypeScript refuses the call until it has been + * made. + * + * `options.pluginId` is required because slug history is stamped with its owner - + * the same reason `editorialService` takes one, and `createContentModel` is + * called from `src/database/*.ts`, which has no reason to know it. + */ + deliveryService: + | ((c: Context, options: { pluginId: string }) => ContentDeliveryService) + | undefined; /** * The transactional editorial repository, or `undefined` when the content * type has no `editorial` block. @@ -273,11 +290,20 @@ export const createContentModel = < }); }; - return { + const model: ContentModel<TDefinition> = { advanced, advancedTables, columns, definition, + // Reads `model` lazily, which is what lets the delivery service be built from + // the finished model without a circular construction: it needs + // `publicService`, the table and the translation table, and every one of them + // is assigned by the time a request calls this. + deliveryService: + definition.delivery.enabled && definition.publicApi.enabled + ? (c: Context, { pluginId }: { pluginId: string }) => + createContentDeliveryService({ c, model, pluginId }) + : undefined, // The plugin id arrives at call time rather than being captured here: a // revision is stamped with its owner, and `createContentModel` is called // from `src/database/*.ts`, which does not otherwise need to know it. Every @@ -389,4 +415,6 @@ export const createContentModel = < translationService: localized ? buildTranslations : undefined, translationTable, }; + + return model; }; From 28fb474e8385e03cfe67020eb4c3c28223200042 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:09:31 +0200 Subject: [PATCH 05/24] feat(content): integrate redirects with editorial mutations The rule this implements is the one Stage 8 rests on: a slug becomes redirectable only if it was previously used by an **addressable public version**. A draft whose slug was corrected three times before anybody saw it creates no redirects, because none of those URLs was ever live - and a `retire` that matched no row is exactly that answer. `applyContentDeliveryWrite` runs inside the caller's transaction, after the guarded write, and the ordering is the whole correctness argument: a writer holding a stale `expectedVersion` fails first and leaves the history exactly as it found it, and the old address is retired before the new one is reserved so a move from `a` to `b` and back to `a` does not hit its own live reservation. The base service owns a **shared** slug and the translation service owns a **localized** one, which is why a localized content type's redirects are per language while a shared slug's are not. `findBasePublication` is added to the translation model because a translation's public reachability is subordinate to the record's: a published Polish translation of a draft article is not a URL anybody can reach. Nothing here emits an event or touches a cache tag. The caller is inside a transaction that may still roll back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/content/server/delivery-writes.ts | 190 ++++++++++++++++++ .../src/content/server/editorial-service.ts | 139 +++++++++++++ .../server/translation-editorial-service.ts | 158 +++++++++++++++ .../src/content/server/translation-model.ts | 41 ++++ 4 files changed, 528 insertions(+) create mode 100644 packages/vitnode/src/content/server/delivery-writes.ts diff --git a/packages/vitnode/src/content/server/delivery-writes.ts b/packages/vitnode/src/content/server/delivery-writes.ts new file mode 100644 index 000000000..968ec866f --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-writes.ts @@ -0,0 +1,190 @@ +import type { Context } from "hono"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; +import type { ContentSlugHistoryModel } from "./slug-history-model"; + +import { contentDeliveryPath } from "../delivery"; +import { createContentSlugHistoryModel } from "./slug-history-model"; + +/** + * What one mutation did to a record's public URLs. + * + * Carried on the editorial outcome so the post-commit effects can emit the delivery + * events and pick the cache tags without re-reading anything: once the transaction + * returns, the *old* URL is gone from the row, and it is the one fact that cannot be + * recovered afterwards - the same reason `previousSlug` is already on the outcome. + * + * Optional on both outcome types rather than required, which is what keeps every + * Stage 1-7 construction site compiling untouched and every content type without + * delivery producing exactly the outcome it always produced. + */ +export interface ContentDeliveryOutcome { + /** The path the record answers to after this mutation. */ + canonicalPath: null | string; + itemId: number; + /** `null` when the slug is shared - see `core_content_slug_history`. */ + locale: null | string; + /** The path it answered to before, when the mutation moved it. */ + previousPath: null | string; + previousSlug: null | string; + /** + * Whether a historical URL became a redirect. + * + * `true` only when the old slug had genuinely been publicly addressable, which is + * the difference between "somebody fixed a typo in a draft" and "a live URL + * moved". It is what the `delivery_redirect_created` event is gated on. + */ + redirectCreated: boolean; + /** Whether the set of URLs a sitemap lists changed. */ + sitemapChanged: boolean; + /** The slug the record answers to now, or `null` once it is deleted. */ + slug: null | string; + /** Whether the canonical URL is different from what it was. */ + slugChanged: boolean; +} + +/** + * One record's addressability, before and after a mutation. + * + * Supplied by the caller rather than derived here, because only the caller knows: + * it holds the row on both sides of its own guarded write, and re-reading would + * both cost a query and race with a concurrent writer. + */ +export interface ContentDeliveryTransition { + /** Whether the record is publicly reachable *after* the mutation. */ + isPublic: boolean; + itemId: number; + /** `null` when the slug is shared. */ + languageId: null | number; + /** The canonical locale code, or `null` when the slug is shared. */ + locale: null | string; + previousSlug: null | string; + slug: null | string; + /** Whether it was publicly reachable *before*. */ + wasPublic: boolean; +} + +/** + * The delivery half of one slug-bearing mutation, inside its transaction. + * + * The order below is the whole correctness argument, and it is why this is one + * function rather than three calls sprinkled through the editorial services: + * + * 1. **Retire the old address first.** It has to stop being the record's current + * slug before the new one can be reserved, or a move from `a` to `b` and back to + * `a` would hit its own live reservation. + * 2. **Reserve the new one second**, and only when the record is publicly + * reachable. A draft has no public URL, so reserving its slug would hand out a + * permanent claim on a URL that was never live - and then refuse it to somebody + * who wants it. + * 3. **Report, never act.** Nothing here emits an event, writes a cache tag or + * calls the search index. The caller is inside a transaction that may still roll + * back, and a rollback cannot un-send any of those. + * + * `retire` returning `{ retired: false }` is not a failure: it is the answer to + * "was that slug ever a live URL", and a `false` is what keeps a corrected draft + * from creating a redirect nobody asked for. + */ +export const applyContentDeliveryWrite = async ({ + definition, + slugHistory, + transition, + tx, +}: { + definition: AnyContentTypeDefinition; + /** `null` for a content type with `delivery` but no `redirects`. */ + slugHistory: ContentSlugHistoryModel | null; + transition: ContentDeliveryTransition; + tx: ContentDatabase; +}): Promise<ContentDeliveryOutcome> => { + const { + isPublic, + itemId, + languageId, + locale, + previousSlug, + slug, + wasPublic, + } = transition; + const pathFor = (value: null | string): null | string => + value === null + ? null + : contentDeliveryPath({ definition, locale, slug: value }); + + const canonicalPath = pathFor(slug); + const previousPath = pathFor(previousSlug); + const slugChanged = + previousSlug !== null && slug !== null && previousSlug !== slug; + + let redirectCreated = false; + + if (slugHistory !== null) { + if (slugChanged && previousSlug !== null) { + const { retired } = await slugHistory.retire(tx, { + itemId, + languageId, + slug: previousSlug, + }); + // A retired row is proof the URL was live: it is only ever written by a + // publish or by a slug change on an already-public record. + redirectCreated = retired; + } + + if (isPublic && slug !== null && canonicalPath !== null) { + // Reserved whenever the record is publicly reachable *now*, whether or not + // the slug moved: this is also the publish path, where the address becomes + // live for the first time. Idempotent, so a republish of an unchanged slug + // re-activates the row it already has - and it throws when another record + // owns the address, which is the reservation being enforced. + await slugHistory.reserve(tx, { + itemId, + languageId, + locale, + path: canonicalPath, + slug, + }); + } else if (slug !== null && slug !== previousSlug) { + // A draft taking a *new* slug is checked but not reserved. Not reserved, + // because a draft has no public URL and claiming one would refuse a live + // address to somebody who wants it; checked, because telling an editor "that + // address is taken" at save time is far better than at publish time, when + // they have moved on. + await slugHistory.assertAvailable(tx, { + itemId, + languageId, + locale, + slug, + }); + } + } + + return { + canonicalPath, + itemId, + locale, + previousPath: slugChanged ? previousPath : null, + previousSlug: slugChanged ? previousSlug : null, + redirectCreated, + // A line is added, removed or moved when public reachability changed or when + // the URL did. An edit that only changed what an already-listed page says + // leaves the sitemap byte-identical. + sitemapChanged: wasPublic !== isPublic || slugChanged, + slug, + slugChanged, + }; +}; + +/** Builds the history model a content type with `delivery` writes through. */ +export const contentSlugHistoryFor = ({ + c, + definition, + pluginId, +}: { + c: Context; + definition: AnyContentTypeDefinition; + pluginId: string; +}): ContentSlugHistoryModel | null => + definition.delivery.enabled && definition.delivery.redirects.enabled + ? createContentSlugHistoryModel({ c, definition, pluginId }) + : null; diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index a18f76114..a0d3ccb93 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -24,10 +24,12 @@ import type { ContentValuesOf, } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; +import type { ContentDeliveryOutcome } from "./delivery-writes"; import type { ContentRevisionsModel } from "./revisions-model"; import type { ContentSchedulesModel } from "./schedules-model"; import type { ContentDatabase } from "./service"; +import { isContentPubliclyVisible } from "../cache"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS, @@ -45,6 +47,10 @@ import { buildContentRepeatableOperations, contentCollectionKinds, } from "./collection-api"; +import { + applyContentDeliveryWrite, + contentSlugHistoryFor, +} from "./delivery-writes"; import { changedPathsToColumns, diffChangedPaths, @@ -70,6 +76,13 @@ export interface ContentEditorialOutcome<TDefinition> { changed: boolean; /** Canonical paths - see {@link ContentUpdateResult.changedFields}. */ changedFields: ContentChangedPath<TDefinition>[]; + /** + * What this mutation did to the record's public URLs, or absent. + * + * Absent for every content type without `delivery` - which is every Stage 1-7 + * one - so the outcome those produce is byte-identical to what it always was. + */ + delivery?: ContentDeliveryOutcome; operation: ContentRevisionOperation; /** The slug the record answered to *before* this mutation, if it has one. */ previousSlug: null | string; @@ -338,6 +351,79 @@ export const createContentEditorialService = < const schedules = definition.editorial.scheduling.enabled ? createContentSchedulesModel({ c, definition, pluginId }) : undefined; + + // Only a **shared** slug is this service's business. A localized slug is a column + // on the translation table, so a base-row mutation cannot move it and + // `translation-editorial-service` owns its history - which is also why a localized + // content type's redirects are per language while a shared slug's are not. + const deliveryEnabled = + definition.delivery.enabled && definition.delivery.slugScope === "shared"; + const slugHistory = contentSlugHistoryFor({ c, definition, pluginId }); + + /** Whether a base row is publicly reachable right now. `false` without publication. */ + const publiclyVisible = (row: null | Record<string, unknown>): boolean => { + if (row === null || !publication) return false; + + return isContentPubliclyVisible({ + publishedAt: row.publishedAt as Date | null | undefined, + status: typeof row.status === "string" ? row.status : undefined, + }); + }; + + /** + * The publication state a row held *before* a transition. + * + * Reconstructed rather than re-read, and it is not a guess: a transition is + * guarded on the state it changes, so a `publish` that returned a row can only + * have found it unpublished, and an `unpublish` can only have found it published. + * A second `SELECT` would race with the writer that just won. + */ + const invert = ( + operation: "publish" | "unpublish", + row: Record<string, unknown>, + ): Record<string, unknown> => ({ + publishedAt: row.publishedAt, + status: operation === "publish" ? "draft" : "published", + }); + + /** + * The delivery half of one mutation, inside its transaction. + * + * `undefined` for a content type without delivery, which is what keeps every + * Stage 1-7 outcome byte-identical - and `before`/`after` are the rows the caller + * already holds on each side of its own guarded write, never a re-read. + */ + const applyDelivery = async ( + tx: ContentDatabase, + { + after, + before, + itemId, + }: { + after: null | Record<string, unknown>; + before: null | Record<string, unknown>; + itemId: number; + }, + ): Promise<ContentDeliveryOutcome | undefined> => { + if (!deliveryEnabled) return undefined; + + return await applyContentDeliveryWrite({ + definition, + slugHistory, + transition: { + isPublic: publiclyVisible(after), + itemId, + // A shared slug belongs to no single language: one reservation covers every + // locale the record appears in, because they all answer to the same segment. + languageId: null, + locale: null, + previousSlug: slugOf(before), + slug: slugOf(after), + wasPublic: publiclyVisible(before), + }, + tx, + }); + }; const { withCreateSlugs, withUpdateSlugs } = createSlugNormalizer( contentTypeId, fields, @@ -553,9 +639,20 @@ export const createContentEditorialService = < version, }); + // The moment an address becomes - or stops being - publicly addressable, which + // is exactly when a slug earns its reservation. A publish reserves the current + // slug; an unpublish leaves the history where it is, because the record is + // coming back and its old URLs should redirect again when it does. + const delivery = await applyDelivery(tx, { + after: row, + before: { ...row, ...invert(operation, row) }, + itemId: id, + }); + return { changed: true, changedFields: [], + ...(delivery === undefined ? {} : { delivery }), operation, previousSlug: slugOf(row), restoredFromRevisionId: null, @@ -713,9 +810,19 @@ export const createContentEditorialService = < version, }); + // A created row is a draft, so it reserves nothing - but its slug is still + // checked against the reservations, because "that address belongs to an + // article that moved" is far better heard now than at publish time. + const delivery = await applyDelivery(tx, { + after: row, + before: null, + itemId: typeof row.id === "number" ? row.id : 0, + }); + return { changed: true, changedFields: allPaths, + ...(delivery === undefined ? {} : { delivery }), operation: "create", previousSlug: null, restoredFromRevisionId: null, @@ -776,9 +883,20 @@ export const createContentEditorialService = < version, }); + // History is deliberately **kept**: an incoming link to a deleted article is + // exactly the diagnostic somebody will want, and the resolver answers 404 + // for it by reading the live record rather than by having forgotten the URL. + // So there is nothing to write here - only a sitemap that has lost a line. + const delivery = await applyDelivery(tx, { + after: null, + before: row, + itemId: id, + }); + return { changed: true, changedFields: [], + ...(delivery === undefined ? {} : { delivery }), operation: "delete", previousSlug: slugOf(row), restoredFromRevisionId: null, @@ -898,9 +1016,20 @@ export const createContentEditorialService = < version, }); + // A restore that brings an older slug back moves the canonical URL exactly + // like an edit does, so it retires the current address and redirects it at + // the restored one. A restore that changed no slug writes nothing - which is + // also why this runs after the guarded write rather than before it. + const delivery = await applyDelivery(tx, { + after: row, + before: current, + itemId: id, + }); + return { changed: true, changedFields, + ...(delivery === undefined ? {} : { delivery }), operation: "restore" as const, previousSlug: slugOf(current), restoredFromRevisionId: revisionId, @@ -1000,9 +1129,19 @@ export const createContentEditorialService = < version, }); + // After the guarded write, so the reservation is only taken by the writer + // that actually won the version race - a loser throws above and leaves the + // history exactly as it found it. + const delivery = await applyDelivery(tx, { + after: row, + before: current, + itemId: id, + }); + return { changed: true, changedFields, + ...(delivery === undefined ? {} : { delivery }), operation: "update" as const, previousSlug: slugOf(current), restoredFromRevisionId: null, diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts index f67dd7e7c..72649ab44 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.ts @@ -14,6 +14,7 @@ import type { ContentLocalizedValues, ContentTranslationRow, } from "../types"; +import type { ContentDeliveryOutcome } from "./delivery-writes"; import type { ContentLanguage } from "./language-resolver"; import type { ContentRevisionPage, @@ -22,6 +23,7 @@ import type { import type { ContentDatabase } from "./service"; import type { ContentTranslationModel } from "./translation-model"; +import { isContentTranslationPubliclyVisible } from "../cache"; import { ContentEngineError, ContentRevisionNotRestorable, @@ -33,6 +35,10 @@ import { contentInnerFields, splitContentFieldPath, } from "../paths"; +import { + applyContentDeliveryWrite, + contentSlugHistoryFor, +} from "./delivery-writes"; import { diffChangedPaths } from "./query"; import { contentTranslationRevisionSnapshot, @@ -54,6 +60,14 @@ export interface ContentTranslationEditorialOutcome<TDefinition> { /** `false` when nothing moved: no write, no revision, no event, no tags. */ changed: boolean; changedFields: ContentLocalizedFieldName<TDefinition>[]; + /** + * What this mutation did to **this locale's** public URL, or absent. + * + * Absent for every content type without `delivery`, and for one whose slug is + * shared - a shared slug is a column on the base row, so a translation mutation + * cannot move it and the base editorial service owns its history. + */ + delivery?: ContentDeliveryOutcome; languageId: number; /** The canonical `core_languages.code`, never the caller's casing. */ locale: string; @@ -232,6 +246,90 @@ export const createContentTranslationEditorialService = < localizedFields, ); + // Only a **localized** slug is this service's business. A shared one is a column + // on the base row, so a translation mutation cannot move it - see the base + // editorial service, which owns that history. + const deliveryEnabled = + definition.delivery.enabled && + definition.delivery.slugScope === "localized"; + const slugHistory = contentSlugHistoryFor({ c, definition, pluginId }); + + /** + * The delivery half of one translation mutation, inside its transaction. + * + * The publication test is the **subordinated** one - the base row published *and* + * this translation published - because that is what makes a localized URL public. + * A published Polish translation of a draft article is not an address anybody can + * reach, so reserving its slug would hand out a permanent claim on a URL that was + * never live. + */ + const applyDelivery = async ( + tx: ContentDatabase, + { + after, + before, + itemId, + languageId, + locale, + }: { + after: ContentTranslationRow<TDefinition> | null; + before: ContentTranslationRow<TDefinition> | null; + itemId: number; + languageId: number; + locale: string; + }, + ): Promise<ContentDeliveryOutcome | undefined> => { + if (!deliveryEnabled) return undefined; + + const base = (await translations.findBasePublication(itemId, { tx })) ?? { + publishedAt: null, + status: undefined, + }; + const visible = ( + row: ContentTranslationRow<TDefinition> | null, + ): boolean => { + if (row === null) return false; + + return isContentTranslationPubliclyVisible({ + base, + translation: { + publishedAt: (row as { publishedAt?: Date | null }).publishedAt, + status: (row as { status?: string }).status, + }, + }); + }; + + return await applyContentDeliveryWrite({ + definition, + slugHistory, + transition: { + isPublic: visible(after), + itemId, + languageId, + locale, + previousSlug: slugOf(before), + slug: slugOf(after), + wasPublic: visible(before), + }, + tx, + }); + }; + + /** + * The publication state one translation held before a transition. + * + * The localized twin of the base service's `invert`, and correct for the same + * reason: a transition is guarded on the state it changes, so a `publish` that + * returned a row can only have found it unpublished. + */ + const invertTranslation = ( + operation: "publish" | "unpublish", + row: ContentTranslationRow<TDefinition>, + ): ContentTranslationRow<TDefinition> => ({ + ...row, + status: operation === "publish" ? "draft" : "published", + }); + /** * One locale's revision model. * @@ -366,9 +464,22 @@ export const createContentTranslationEditorialService = < version: result.version, }); + // Publishing a language is the moment its address becomes live, so this is + // where the reservation is taken. Unpublishing writes nothing: the history + // stays, and the resolver stops redirecting to it because it reads the live + // publication state rather than the history. + const delivery = await applyDelivery(tx, { + after: result.row, + before: invertTranslation(operation, result.row), + itemId: result.row.itemId, + languageId: result.row.languageId, + locale: result.row.locale, + }); + return { changed: true, changedFields: [], + ...(delivery === undefined ? {} : { delivery }), languageId: result.row.languageId, locale: result.row.locale, operation, @@ -412,9 +523,21 @@ export const createContentTranslationEditorialService = < version: row.version, }); + // A new translation starts as a draft, so it reserves nothing - but its slug + // is checked, because "that address belongs to an article that moved" is far + // better heard now than at publish time. + const delivery = await applyDelivery(tx, { + after: row, + before: null, + itemId: row.itemId, + languageId: row.languageId, + locale: row.locale, + }); + return { changed: true, changedFields: localizedPaths, + ...(delivery === undefined ? {} : { delivery }), languageId: row.languageId, locale: row.locale, operation: "create" as const, @@ -449,9 +572,21 @@ export const createContentTranslationEditorialService = < version, }); + // The history of a deleted translation is kept, exactly as a deleted + // record's is: the URL existed, and the resolver answers 404 for it by + // finding no live translation rather than by having forgotten it. + const delivery = await applyDelivery(tx, { + after: null, + before: row, + itemId: row.itemId, + languageId: row.languageId, + locale: row.locale, + }); + return { changed: true, changedFields: [], + ...(delivery === undefined ? {} : { delivery }), languageId: row.languageId, locale: row.locale, operation: "delete" as const, @@ -591,9 +726,21 @@ export const createContentTranslationEditorialService = < version: result.version, }); + // A restore that brings an older localized slug back moves this language's + // canonical URL exactly as an edit does - and one that changed no slug + // writes nothing, which is why it runs after the diff proved something moved. + const delivery = await applyDelivery(tx, { + after: result.row, + before: current, + itemId: result.row.itemId, + languageId: result.row.languageId, + locale: result.row.locale, + }); + return { changed: true, changedFields: result.changedFields, + ...(delivery === undefined ? {} : { delivery }), languageId: result.row.languageId, locale: result.row.locale, operation: "restore" as const, @@ -632,9 +779,20 @@ export const createContentTranslationEditorialService = < version: result.version, }); + // After the guarded write, so the reservation is only taken by the writer + // that actually won this locale's version race. + const delivery = await applyDelivery(tx, { + after: result.row, + before, + itemId: result.row.itemId, + languageId: result.row.languageId, + locale: result.row.locale, + }); + return { changed: true, changedFields: result.changedFields, + ...(delivery === undefined ? {} : { delivery }), languageId: result.row.languageId, locale: result.row.locale, operation: "update" as const, diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index e1e39caa4..1c93a4747 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -145,6 +145,23 @@ export interface ContentTranslationModel<TDefinition> { locale: string, options?: ContentTranslationOptions, ) => Promise<boolean>; + /** + * The **base** row's publication state, or `null` when the record is gone. + * + * Exposed because a translation's public reachability is subordinate to the + * record's: a published Polish translation of a draft article is not a public + * URL, so the delivery layer cannot decide whether to reserve an address without + * both halves. It lives here rather than in the editorial layer for the same + * reason `resolveLanguage` does - the base table is this repository's, and a + * second reader would be a second place the two could disagree. + * + * `{ publishedAt: null, status: undefined }` for a content type without + * publication, where a translation is visible as soon as the record is. + */ + findBasePublication: ( + itemId: number, + options?: ContentTranslationOptions, + ) => Promise<null | { publishedAt: Date | null; status: string | undefined }>; findByLanguageId: ( itemId: number, languageId: number, @@ -618,6 +635,30 @@ export const createContentTranslationModel = < return row !== undefined; }, + findBasePublication: async (itemId, options) => { + const baseColumns = table as unknown as Record<string, PgColumn>; + const [row] = await db(options) + .select( + publication + ? { + publishedAt: baseColumns.publishedAt, + status: baseColumns.status, + } + : { publishedAt: baseId, status: baseId }, + ) + .from(table) + .where(eq(baseId, itemId)) + .limit(1); + + if (!row) return null; + if (!publication) return { publishedAt: null, status: undefined }; + + return { + publishedAt: toNullableDate(row.publishedAt), + status: typeof row.status === "string" ? row.status : undefined, + }; + }, + findByLanguageId: async (itemId, languageId, options) => { const row = await readOne(itemId, languageId, db(options)); if (!row) return null; From 940561cbd72ac7926d593077d67f734de17178e3 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:09:48 +0200 Subject: [PATCH 06/24] feat(content): add the delivery events Two events, each gated on a fact rather than an operation, and both emitted **alongside** `updated`/`restored`/`translation_updated` rather than instead of one: a field moving and a URL moving are different facts with different audiences. A listener that mirrors content wants the first; one that warms a CDN, tells an external search engine or writes an edge redirect table wants the second - and would otherwise have to inspect `changedFields` for a slug field whose name it cannot know. `delivery_redirect_created` fires only when the old address had genuinely been live, which is what keeps a corrected draft from announcing a redirect that does not exist. There is deliberately no sitemap event: every mutation that changes a sitemap line already emits one of these or a publication event. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/events.ts | 75 ++++++++++- .../src/content/server/delivery-effects.ts | 126 ++++++++++++++++++ .../src/content/server/editorial-effects.ts | 19 +++ .../src/content/server/translation-effects.ts | 19 ++- 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 packages/vitnode/src/content/server/delivery-effects.ts diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts index 6ff8a271e..531851e66 100644 --- a/packages/vitnode/src/content/events.ts +++ b/packages/vitnode/src/content/events.ts @@ -3,6 +3,8 @@ import type { ContentFieldName, ContentLocalizedFieldName } from "./types"; export type ContentEventAction = | "created" | "deleted" + | "delivery_redirect_created" + | "delivery_slug_changed" | "published" | "restored" | "schedule_cancelled" @@ -270,6 +272,76 @@ type ContentLocalizationEventsFor<TDefinition extends { id: string }> = > : Record<never, never>); +/** + * A record's canonical public URL moved. + * + * Emitted **in addition to** the `updated` (or `restored`) event, not instead of + * it: the field mutation and the URL change are two different facts with two + * different audiences. A listener that mirrors content into another system wants + * the first; one that warms a CDN, tells an external search engine or writes to an + * edge redirect table wants the second, and would otherwise have to inspect + * `changedFields` for a slug field whose name it cannot know. + * + * `locale` is `null` when the slug is shared - a content type that is not + * localized, or a localized one whose slug lives on the base row. + */ +export interface ContentDeliverySlugChangedPayload { + /** The path the record answers to now. */ + canonicalPath: string; + contentId: number; + locale: null | string; + /** The path it answered to before, or `null` when it had no public URL yet. */ + previousPath: null | string; + previousSlug: null | string; + slug: string; +} + +/** + * A historical public URL became a redirect. + * + * Emitted only when the old slug had genuinely been *publicly addressable* - so a + * draft whose slug was corrected three times before it was ever published emits + * nothing, and a published article that moves emits exactly one. That is the + * difference between "a URL exists that needs a redirect" and "somebody edited a + * field", and it is why this is a separate event from the one above rather than a + * boolean on it. + */ +export interface ContentDeliveryRedirectCreatedPayload { + /** Where the historical path now redirects to. */ + canonicalPath: string; + contentId: number; + locale: null | string; + /** The retired path, which now answers with a permanent redirect. */ + previousPath: string; + previousSlug: string; +} + +/** + * The two events the delivery layer adds. + * + * Gated on `delivery: { enabled: true }` exactly like the publication, editorial + * and localization groups, so a content type without it gains no key at all and a + * listener for one cannot be registered - which is what keeps every Stage 1-7 + * event map byte-identical. + * + * Both keys are gated on `delivery` alone rather than the redirect one being gated a + * second time on `redirects`. Whether a content type keeps slug history is a + * *resolved* boolean rather than a literal on the definition's type, so a second gate + * would need another type parameter on `ContentTypeDefinition` to buy one thing: a + * listener nobody can register for an event that would never have fired anyway. + */ +type ContentDeliveryEventsFor<TDefinition extends { id: string }> = + TDefinition extends { delivery: { enabled: true } } + ? Record< + `content.${TDefinition["id"]}.delivery_redirect_created`, + ContentDeliveryRedirectCreatedPayload + > & + Record< + `content.${TDefinition["id"]}.delivery_slug_changed`, + ContentDeliverySlugChangedPayload + > + : Record<never, never>; + /** * The events a content type emits, as a literal-keyed map. * @@ -289,7 +361,8 @@ type ContentLocalizationEventsFor<TDefinition extends { id: string }> = * payloads stay minimal. */ export type ContentEventsFor<TDefinition extends { id: string }> = - ContentEditorialEventsFor<TDefinition> & + ContentDeliveryEventsFor<TDefinition> & + ContentEditorialEventsFor<TDefinition> & ContentLocalizationEventsFor<TDefinition> & ContentPublicationEventsFor<TDefinition> & Record<`content.${TDefinition["id"]}.created`, ContentCreatedPayload> & diff --git a/packages/vitnode/src/content/server/delivery-effects.ts b/packages/vitnode/src/content/server/delivery-effects.ts new file mode 100644 index 000000000..07e3e9ab7 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-effects.ts @@ -0,0 +1,126 @@ +import type { Context } from "hono"; + +import type { EventEmitResult } from "../../api/models/events"; +import type { ContentDeliveryInvalidation } from "../cache"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDeliveryOutcome } from "./delivery-writes"; + +import { emitContentEvent } from "./emit"; + +export interface ContentDeliveryEffectsResult { + /** + * What the event transport reported for each delivery event, in the order they + * were emitted. Empty when the mutation moved no URL. + * + * Present rather than discarded for the same reason the editorial effects keep + * theirs: `EventsModel.emit` does not throw, so `failures` is the only place a + * dead listener or a broker outage is visible. + */ + events: EventEmitResult[]; +} + +/** + * The delivery events one mutation owes the rest of the system, after it commits. + * + * Two events at most, and each one is gated on a fact rather than on an operation: + * + * - **`delivery_slug_changed`** whenever the canonical URL is different from what + * it was. Emitted *alongside* `updated` or `restored`, never instead of one: the + * field mutation and the URL change are different facts with different audiences, + * and a listener that warms a CDN or writes an edge redirect table would + * otherwise have to inspect `changedFields` for a slug field whose name it cannot + * know. + * - **`delivery_redirect_created`** only when the old address had genuinely been + * live. A draft whose slug was corrected three times before it was ever published + * emits nothing at all, which is the difference between "a URL now needs a + * redirect" and "somebody edited a field". + * + * There is deliberately no sitemap event. Every mutation that changes a sitemap + * line already emits `published`, `unpublished`, `deleted` or one of the two above, + * and a fifth event carrying no new information would be one more thing to keep + * consistent for no listener's benefit. + * + * **Call it only after the write has returned - never inside the transaction.** A + * rollback cannot un-emit an event. + */ +export const contentDeliveryEffects = async ( + c: Context, + definition: AnyContentTypeDefinition, + delivery: ContentDeliveryOutcome | undefined, + { pluginId }: { pluginId: string }, +): Promise<ContentDeliveryEffectsResult> => { + const events: EventEmitResult[] = []; + + // A canonical path this engine could not build is a URL nobody can visit, so + // there is no delivery fact to announce. It happens for a slug written straight + // into the database, and for a localized content type whose slug is shared - which + // has one segment and several URLs, so no single canonical path. + if ( + delivery === undefined || + !delivery.slugChanged || + delivery.canonicalPath === null + ) { + return { events }; + } + + events.push( + await emitContentEvent( + c, + definition, + "delivery_slug_changed", + { + canonicalPath: delivery.canonicalPath, + contentId: delivery.itemId, + locale: delivery.locale, + previousPath: delivery.previousPath, + previousSlug: delivery.previousSlug, + slug: delivery.slug, + } as never, + { pluginId }, + ), + ); + + if ( + delivery.redirectCreated && + delivery.previousPath !== null && + delivery.previousSlug !== null + ) { + events.push( + await emitContentEvent( + c, + definition, + "delivery_redirect_created", + { + canonicalPath: delivery.canonicalPath, + contentId: delivery.itemId, + locale: delivery.locale, + previousPath: delivery.previousPath, + previousSlug: delivery.previousSlug, + } as never, + { pluginId }, + ), + ); + } + + return { events }; +}; + +/** + * The delivery half of a mutation's cache invalidation, or `undefined`. + * + * `undefined` for a content type without `delivery`, which is what makes + * `contentInvalidationTags` return exactly the strings it always returned - and + * therefore what makes Stage 8 opt-in at the cache layer as well as everywhere + * else. + */ +export const contentDeliveryInvalidation = ( + definition: AnyContentTypeDefinition, + delivery: ContentDeliveryOutcome | undefined, +): ContentDeliveryInvalidation | undefined => { + if (!definition.delivery.enabled) return undefined; + + // A content type with delivery whose mutation reported nothing still expires its + // delivery metadata - a shared SEO field moving changes what every locale's + // `<head>` renders even though no URL moved. Only the sitemap is conditional. + return { sitemap: delivery?.sitemapChanged ?? false }; +}; diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index 536551c3b..381f1d1c1 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -3,10 +3,12 @@ import type { Context } from "hono"; import type { EventEmitResult } from "../../api/models/events"; import type { ContentEventAction } from "../events"; import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDeliveryEffectsResult } from "./delivery-effects"; import type { ContentEditorialOutcome } from "./editorial-service"; import type { AnyContentModel } from "./model"; import type { ContentSearchSyncOutcome } from "./search-sync"; +import { contentDeliveryEffects } from "./delivery-effects"; import { emitContentEvent } from "./emit"; import { contentSearchAdvancedValues, @@ -101,6 +103,12 @@ export interface ContentEditorialEffectsOptions { } export interface ContentEditorialEffectsResult { + /** + * The delivery events this mutation emitted, or `undefined` for a content type + * without `delivery` - which is what keeps every existing caller's result shape + * unchanged. + */ + delivery?: ContentDeliveryEffectsResult; /** * What the event transport reported. `null` for a no-op outcome, which emits * nothing at all. @@ -160,11 +168,21 @@ export const contentEditorialEffects = async ( { pluginId }, ); + // After the ordinary event, never instead of it: a URL moving and a field moving + // are two facts, and a listener that mirrors content wants the first while one + // that warms a CDN wants the second. + const delivery = definition.delivery.enabled + ? await contentDeliveryEffects(c, definition, outcome.delivery, { + pluginId, + }) + : undefined; + // A localized record is indexed once per published translation, and a mutation // of the *record* moves every one of them: its publication state gates them // all, and a shared field is in all of them. if (definition.localization.enabled && definition.search.enabled) { return { + ...(delivery === undefined ? {} : { delivery }), event, search: null, searchByLocale: model @@ -188,6 +206,7 @@ export const contentEditorialEffects = async ( } return { + ...(delivery === undefined ? {} : { delivery }), event, search: await syncContentSearch(c, definition, { // Read back only when a document is actually made of collection values, diff --git a/packages/vitnode/src/content/server/translation-effects.ts b/packages/vitnode/src/content/server/translation-effects.ts index 748809bca..6bcce3120 100644 --- a/packages/vitnode/src/content/server/translation-effects.ts +++ b/packages/vitnode/src/content/server/translation-effects.ts @@ -3,10 +3,12 @@ import type { Context } from "hono"; import type { EventEmitResult } from "../../api/models/events"; import type { ContentEventAction } from "../events"; import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDeliveryEffectsResult } from "./delivery-effects"; import type { AnyContentModel } from "./model"; import type { ContentSearchSyncOutcome } from "./search-sync"; import type { ContentTranslationEditorialOutcome } from "./translation-editorial-service"; +import { contentDeliveryEffects } from "./delivery-effects"; import { emitContentEvent } from "./emit"; import { contentSearchAdvancedValues, @@ -74,6 +76,11 @@ export interface ContentTranslationEffectsOptions { } export interface ContentTranslationEffectsResult { + /** + * The delivery events this translation mutation emitted, or `undefined` for a + * content type without `delivery`. + */ + delivery?: ContentDeliveryEffectsResult; /** * What the event transport reported, or `null` for a no-op outcome. * @@ -131,14 +138,22 @@ export const contentTranslationEffects = async ( { pluginId }, ); - if (!definition.search.enabled || !model) return { event }; + const delivery = definition.delivery.enabled + ? await contentDeliveryEffects(c, definition, outcome.delivery, { + pluginId, + }) + : undefined; + const withDelivery = delivery === undefined ? {} : { delivery }; + + if (!definition.search.enabled || !model) return { ...withDelivery, event }; // The base row, because a translation's document is built from both halves and // its visibility is subordinate to the record's. const base = await model.service(c).findById(outcome.row.itemId); - if (!base) return { event }; + if (!base) return { ...withDelivery, event }; return { + ...withDelivery, event, // Scoped to the locale that moved. Omitting it would rewrite every other // language's document for a change none of them contains. From dfff439f0459ff19a3e08c208e2e5cf3f992e548 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:09:48 +0200 Subject: [PATCH 07/24] feat(content): integrate delivery cache invalidation Three new scopes in the existing namespace - `delivery`, `redirect`, `sitemap` - with the locale in the same position it already occupies, so nothing about the tag format has to be learned twice. Each answers a different question a page asked: a `generateMetadata` that renders only metadata is tagged `delivery` alone, and a redirect lookup is tagged by the **old** address, because that is what a request for a moved page arrives with. `contentInvalidationTags` takes an optional `delivery` block and derives everything else from data it already has. Omit it - which is what every content type without the block does - and the output is byte-identical to what it always was: nothing existing has to be re-tagged, and no warm cache is thrown away for a feature the content type does not use. The sitemap tag is expired only when the set of listed URLs actually changed. An edit that changed what an already-listed page *says* leaves the file byte-identical. Scheduled transitions go through the existing revalidation bridge rather than a second cross-origin system, with the same all-origins-must-accept rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/cache.ts | 156 +++++++++++++++++- .../src/content/server/schedule-effects.ts | 4 + .../content/actions/mutation-api.server.ts | 50 +++++- .../content/actions/public-locale-cache.ts | 22 ++- 4 files changed, 226 insertions(+), 6 deletions(-) diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index c7a33d73a..3c1994dfe 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -79,6 +79,51 @@ export const contentPublicSlugTag = ( locale?: string, ): string => tag(contentTypeId, "slug", ...localeParts(locale), slug); +/** + * The delivery metadata of one record, in one locale. + * + * Separate from {@link contentPublicItemTag} even though both are keyed by + * identifier, because the two hold different responses: the item tag covers the + * public projection a page renders, and this one covers the canonical path, the + * alternates and the SEO metadata its `<head>` is built from. A page that reads + * both is tagged with both; one that renders only metadata - a `generateMetadata` + * that does not fetch the body - is tagged with this alone and is not thrown away + * when an unrelated field of the record changes. + */ +export const contentDeliveryTag = ( + contentTypeId: string, + id: number, + locale?: string, +): string => tag(contentTypeId, "delivery", ...localeParts(locale), id); + +/** + * One historical URL's redirect lookup, in one locale. + * + * Keyed by the **old** slug, which is what a request for a moved page arrives + * with. The locale is load-bearing for the same reason it is on the slug tag: two + * languages routinely retire the same slug, and a locale-less tag would make one + * language's slug change expire the other language's redirect. + */ +export const contentDeliveryRedirectTag = ( + contentTypeId: string, + slug: string, + locale?: string, +): string => tag(contentTypeId, "redirect", ...localeParts(locale), slug); + +/** + * One content type's sitemap - the whole thing, or one locale's share of it. + * + * Both forms exist and both are expired together by a mutation that changes what + * is listed: a localized content type has one sitemap per language *and* an index + * that enumerates them, and publishing a Polish translation changes the Polish + * file and the number of files. A content type that is not localized only ever + * produces the three-segment form. + */ +export const contentDeliverySitemapTag = ( + contentTypeId: string, + locale?: string, +): string => tag(contentTypeId, "sitemap", ...localeParts(locale)); + /** * How hard a mutation expires the tags it touched. * @@ -107,8 +152,36 @@ export interface ContentLocaleInvalidation { wasPublic: boolean; } +/** + * The delivery half of one mutation's invalidation. + * + * Absent for every content type without `delivery`, which is what makes Stage 8 + * opt-in at the cache layer too: `contentInvalidationTags` returns exactly the + * strings it always returned when this is `undefined`, byte for byte, so nothing + * existing has to be re-tagged and no warm cache is thrown away for a feature the + * content type does not use. + * + * Nothing in here names a locale or a slug of its own: both are already on the + * input - `locales[].slugs` carries the old and the new URL of every locale the + * mutation reached - and deriving the delivery tags from the same data is what + * keeps the public tags and the delivery tags from disagreeing about what moved. + */ +export interface ContentDeliveryInvalidation { + /** + * Whether the set of URLs in the sitemap changed. + * + * `true` for a publish, an unpublish, a delete, a slug change and a translation + * appearing or disappearing - every mutation that adds, removes or moves a line + * in the file. `false` for an edit that only changed what an already-listed page + * says, which leaves the sitemap byte-identical. + */ + sitemap: boolean; +} + export interface ContentInvalidationInput { contentTypeId: string; + /** Delivery tags, for a content type with `delivery: { enabled: true }`. */ + delivery?: ContentDeliveryInvalidation; id: number; /** Whether the row is publicly reachable *after* the mutation. */ isPublic: boolean; @@ -154,6 +227,7 @@ const slugTags = ( */ export const contentInvalidationTags = ({ contentTypeId, + delivery, id, isPublic, locales, @@ -161,13 +235,21 @@ export const contentInvalidationTags = ({ wasPublic, }: ContentInvalidationInput): string[] => { if (locales !== undefined) { - return locales - .filter(entry => entry.wasPublic || entry.isPublic) - .flatMap(entry => [ + const reached = locales.filter(entry => entry.wasPublic || entry.isPublic); + + return [ + ...reached.flatMap(entry => [ contentPublicListTag(contentTypeId, entry.locale), contentPublicItemTag(contentTypeId, id, entry.locale), ...slugTags(contentTypeId, entry.slugs, entry.locale), - ]); + ]), + ...deliveryTags({ + contentTypeId, + delivery, + id, + locales: reached, + }), + ]; } if (!wasPublic && !isPublic) return []; @@ -176,6 +258,72 @@ export const contentInvalidationTags = ({ contentPublicListTag(contentTypeId), contentPublicItemTag(contentTypeId, id), ...slugTags(contentTypeId, slugs), + ...deliveryTags({ + contentTypeId, + delivery, + id, + locales: [{ isPublic, locale: undefined, slugs, wasPublic }], + }), + ]; +}; + +/** + * The delivery tags one mutation touched, per locale it reached. + * + * Three scopes, and each answers a different question a page asked: + * + * - **delivery metadata**, keyed by identifier, because a `generateMetadata` reads + * the canonical path and the alternates of one record; + * - **redirect lookups**, keyed by every slug the record answered to across the + * mutation, because a resolver caches "this old URL points there" and a second + * slug change moves the destination; + * - **the sitemap**, per locale and as a whole, but only when the set of listed + * URLs actually changed. + * + * Empty when the content type has no delivery layer, which is the whole of Stage + * 8's opt-in promise at this layer. + */ +const deliveryTags = ({ + contentTypeId, + delivery, + id, + locales, +}: { + contentTypeId: string; + delivery: ContentDeliveryInvalidation | undefined; + id: number; + locales: readonly { + isPublic: boolean; + locale: string | undefined; + slugs: readonly string[]; + wasPublic: boolean; + }[]; +}): string[] => { + if (delivery === undefined) return []; + + const tags = locales.flatMap(entry => [ + contentDeliveryTag(contentTypeId, id, entry.locale), + ...[...new Set(entry.slugs)] + .filter(slug => slug !== "") + .map(slug => + contentDeliveryRedirectTag(contentTypeId, slug, entry.locale), + ), + ...(delivery.sitemap + ? [contentDeliverySitemapTag(contentTypeId, entry.locale)] + : []), + ]); + + // The locale-less sitemap tag as well: a localized content type's sitemap index + // enumerates its per-locale files, so a language gaining or losing a page + // changes the index too. De-duplicated, because a content type that is not + // localized produces only this form and the per-locale line above already + // emitted it - and a tag list is asserted in tests as well as iterated. + return [ + ...new Set( + delivery.sitemap + ? [...tags, contentDeliverySitemapTag(contentTypeId)] + : tags, + ), ]; }; diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts index 67a4a96d6..a3c850e4b 100644 --- a/packages/vitnode/src/content/server/schedule-effects.ts +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -212,6 +212,10 @@ export const runContentScheduleEffects = async ( const revalidation = await dispatchContentRevalidation(c, { contentTypeId: definition.id, + // A scheduled transition always adds or removes a sitemap line, so the delivery + // tags - including the sitemap's - go out with the rest. Absent for a content + // type without `delivery`, which keeps its tag list byte-identical. + ...(definition.delivery.enabled ? { delivery: { sitemap: true } } : {}), id: payload.itemId, isPublic: isContentRowPublic(row), // A scheduled transition moves the *record*, and the record's publication diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts index 4cd467ce8..16ea7ad4b 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import type { ContentPublicLocaleState } from "@/content/cache"; import type { ContentConflict, + ContentDeliveryConflict, ContentScheduleRejection, ContentUnprocessable, } from "@/content/conflicts"; @@ -25,6 +26,7 @@ import { contentApiFetch } from "@/content/admin/fetch.server"; import { isContentPubliclyVisible } from "@/content/cache"; import { parseContentConflict, + parseContentDeliveryConflict, parseContentScheduleRejection, parseContentUnprocessable, } from "@/content/conflicts"; @@ -51,6 +53,15 @@ interface MutationResult { * sentence. */ conflict?: ContentConflict; + /** + * `CONTENT_DELIVERY_SLUG_RESERVED`, naming the address and its locale. + * + * Its own field rather than a third arm of `conflict`, because the two share a + * status and need different words: a unique clash is "another record holds that + * address now", and this is "another record used to hold it and it still + * redirects there". + */ + delivery?: ContentDeliveryConflict; error?: string; /** Why a schedule was refused, when the API said. */ rejection?: ContentScheduleRejection; @@ -66,6 +77,7 @@ const failure = (result: { status: number; }): MutationResult => ({ conflict: parseContentConflict(result.error) ?? undefined, + delivery: parseContentDeliveryConflict(result.error) ?? undefined, error: result.error ?? "", rejection: parseContentScheduleRejection(result.error) ?? undefined, status: result.status, @@ -190,6 +202,7 @@ const invalidate = ( revalidateContent( { contentTypeId: definition.id, + ...deliveryInvalidationFor(definition, previous, current), id, isPublic: current.isPublic, // Both, so a slug change stops the old URL and starts the new one. @@ -200,6 +213,32 @@ const invalidate = ( ); }; +/** + * The delivery half of a nonlocalized mutation's invalidation. + * + * `{}` for a content type without `delivery`, so spreading it leaves the input - + * and therefore the tag list - exactly as it was. A sitemap line is added, removed + * or moved when public reachability changed or when the URL did, which is the same + * rule `applyContentDeliveryWrite` reports from inside the transaction; stated twice + * because the Server Action cannot see the outcome, only the two rows. + */ +const deliveryInvalidationFor = ( + definition: AnyContentTypeDefinition, + previous: { isPublic: boolean; slug: string }, + current: { isPublic: boolean; slug: string }, +): { delivery?: { sitemap: boolean } } => + definition.delivery.enabled + ? { + delivery: { + sitemap: + previous.isPublic !== current.isPublic || + (previous.slug !== "" && + current.slug !== "" && + previous.slug !== current.slug), + }, + } + : {}; + export const createContentAction = async ( contentTypeId: string, values: Record<string, unknown>, @@ -594,12 +633,19 @@ export const deleteContentAction = async ( // A delete is final, so the question is "was it ever published?" rather // than "was it live a second ago". `publishedAt` survives an unpublish, and // expiring a URL that is now gone forever costs nothing. + const removed = publicStateOf(definition, result.data); + revalidateContent( { contentTypeId: definition.id, + // The sitemap has lost a line whenever the record had one, which is exactly + // "was it ever published" - the same question the `wasPublic` below asks. + ...(definition.delivery.enabled + ? { delivery: { sitemap: result.data?.publishedAt != null } } + : {}), id, isPublic: false, - slugs: [publicStateOf(definition, result.data).slug], + slugs: [removed.slug], wasPublic: result.data?.publishedAt != null, }, // The row is gone. Serving its cached response one more time would be a @@ -660,6 +706,8 @@ const publicationAction = async ( revalidateContent( { contentTypeId: definition.id, + // A real transition always adds or removes a sitemap line. + ...(definition.delivery.enabled ? { delivery: { sitemap: true } } : {}), id, isPublic, slugs: [slug], diff --git a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts index f824b4ce3..393c31a87 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts @@ -87,17 +87,37 @@ export const invalidateContentLocales = ( ): void => { if (!definition.publicApi.enabled || !definition.localization.enabled) return; + const states = diffContentPublicLocaleStates(before, after); const reached = contentLocaleInvalidations({ changed, defaultLocale: definition.localization.defaultLocale, fallback: definition.localization.fallback, locale, - states: diffContentPublicLocaleStates(before, after), + states, }); revalidateContent( { contentTypeId: definition.id, + // The delivery tags, for a content type with `delivery`. Absent otherwise, + // which is what keeps a Stage 1-7 content type's tag list byte-identical. + // + // The sitemap is expired when a locale gained or lost its page, or when one + // moved its URL - which is exactly what the before/after diff already knows, + // so it is read off the states rather than passed down from the action. + ...(definition.delivery.enabled + ? { + delivery: { + sitemap: states.some( + state => + state.isPublic !== state.wasPublic || + (state.previousSlug !== undefined && + state.previousSlug !== "" && + state.previousSlug !== state.slug), + ), + }, + } + : {}), id, // Not consulted when `locales` is present, and supplied truthfully anyway: // a record is publicly reachable when any of its languages is. From 9d22bdfd15e25b5c4daaeb94f742a0e0cc5d2b48 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:10:10 +0200 Subject: [PATCH 08/24] feat(content): add the delivery routes and error contracts Three generated public routes and one admin route. They exist because a frontend is very often not the process that holds the database: VitNode's split deployment runs Next.js against a separate API, so `generateMetadata`, a catch-all route and a `sitemap.ts` all need an HTTP answer rather than a service call. Every public path begins with the static `delivery` segment, which makes them impossible to shadow whatever order they are registered in: `/{slug}` is one segment and these are two or three, so a record whose slug is literally `delivery` still resolves. `resolve` answers `not_found` as a 200 with a body rather than a 404, so a caller can tell "this URL resolves to nothing" from "the delivery API is unreachable" - and a negative is not cached as a 404, so publishing the record makes it resolve immediately. The reserved-slug refusal is a structured 409 declared as a union *alongside* the editorial one rather than a third arm of it, so a client generated before Stage 8 still parses the arms it knows. It never carries the owning record's id: a 409 on a public-facing address must not become a way to enumerate records the caller cannot read. It is also caught explicitly on the translation path, which otherwise rewrites every 409 into the unique-clash arm. The admin route is gated by `can_view` and nothing narrower, and exposes none of `core_content_slug_history`'s storage columns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/content/server/delivery-routes.ts | 286 ++++++++++++++++++ .../vitnode/src/content/server/http-errors.ts | 39 ++- .../src/content/server/public-routes.ts | 8 +- packages/vitnode/src/content/server/routes.ts | 104 ++++++- .../content/server/translation-http-errors.ts | 25 +- .../src/content/server/translation-routes.ts | 14 +- 6 files changed, 468 insertions(+), 8 deletions(-) create mode 100644 packages/vitnode/src/content/server/delivery-routes.ts diff --git a/packages/vitnode/src/content/server/delivery-routes.ts b/packages/vitnode/src/content/server/delivery-routes.ts new file mode 100644 index 000000000..3dfaa8b2b --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-routes.ts @@ -0,0 +1,286 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDeliveryService } from "./delivery-service"; +import type { ContentModel } from "./model"; + +import { buildRoute } from "../../api/lib/route"; +import { + CONTENT_DELIVERY_REDIRECT_STATUS, + CONTENT_LOCALE_MAX_LENGTH, + CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, + CONTENT_SITEMAP_MAX_URLS, +} from "../const"; +import { ContentDeliveryNotEnabled } from "../errors"; +import { resolveContentPublicLocale } from "../locale"; +import { listContentLanguages } from "./language-resolver"; + +/** + * The public delivery routes one content type with `delivery` gets. + * + * ```http + * GET /api/{pluginId}/content/{publicApi.path}/delivery/resolve/{slug} + * GET /api/{pluginId}/content/{publicApi.path}/delivery/item/{id} + * GET /api/{pluginId}/content/{publicApi.path}/delivery/sitemap (delivery.sitemap) + * ``` + * + * They exist because a frontend is very often **not** the process that holds the + * database: VitNode's split deployment runs Next.js against a separate API, so + * `generateMetadata`, a catch-all route and a `sitemap.xml` handler all need an + * HTTP answer rather than a service call. A single-process install can still use + * `model.deliveryService(c)` directly and never touch these. + * + * Every path begins with the static `delivery` segment, which is what makes them + * impossible to shadow: `/{slug}` is one segment and these are two or three, so a + * record whose slug happens to be `delivery` or `sitemap` still resolves the + * ordinary way, whatever order the routes are registered in. + * + * No `adminStaffPermission` and no `/admin/` anywhere in the path - public delivery + * resolution is exactly as public as the content it describes, and requiring a + * session to learn a canonical URL would be requiring one to render a page. + */ +export const buildContentDeliveryRoutes = < + TDefinition extends AnyContentTypeDefinition, + P extends string, +>( + model: ContentModel<TDefinition>, + { pluginId }: { pluginId: P }, +) => { + const { definition } = model; + const label = definition.admin.label; + const localized = definition.localization.enabled; + + const service = (c: Context): ContentDeliveryService => { + const build = model.deliveryService; + if (!build) + throw new ContentDeliveryNotEnabled({ contentTypeId: definition.id }); + + return build(c, { pluginId }); + }; + + const localeQuery = localized + ? { + // Loose on purpose, like `publicParams.slug`: an unknown locale and a + // malformed one are both the same "nothing here", so a stricter pattern + // would only turn one of them into a differently-shaped 400. + locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH).optional(), + } + : {}; + + /** + * Which language this request is for. + * + * The same resolution the public read routes use, for the same reason: an + * explicit `?locale=` that names no language this install serves is a request for + * something that does not exist, and substituting the default would announce an + * English canonical URL under a Polish one. + */ + const localeFor = async (c: Context) => { + if (!localized) return { locale: undefined, source: "default" as const }; + + const languages = await listContentLanguages(c); + + return resolveContentPublicLocale({ + acceptLanguage: c.req.header("accept-language"), + available: languages + .filter(language => language.isEnabled) + .map(language => language.locale), + defaultLocale: definition.localization.defaultLocale, + explicit: c.req.query("locale"), + }); + }; + + const zodAlternate = z.object({ + locale: z.string(), + path: z.string(), + }); + + const zodSeo = z.object({ + description: z.string().nullable(), + title: z.string().nullable(), + }); + + const zodMetadata = z.object({ + alternates: z.array(zodAlternate), + canonicalPath: z.string().nullable(), + hreflang: z.object({ + languages: z.record(z.string(), z.string()), + xDefault: z.string().optional(), + }), + isFallback: z.boolean(), + // Nullable: delivery metadata is read off the public projection, so a content + // type whose allowlist withholds `id` reports none rather than inventing one. + itemId: z.number().int().nullable(), + locale: z.string().nullable(), + openGraph: zodSeo.nullable(), + requestedLocale: z.string().nullable(), + robots: z.object({ follow: z.boolean(), index: z.boolean() }).nullable(), + seo: zodSeo, + }); + + /** + * The resolution, as a discriminated union. + * + * Three arms rather than a nullable object with an optional `location`, because + * the three outcomes need three different HTTP responses and a client that had to + * infer which one it was holding would eventually redirect to `undefined`. + * + * Nothing internal is in it: no `languageId`, no `pluginId`, no `retiredAt`. Those + * are storage details of `core_content_slug_history`, and a public contract that + * carried them would be a public contract that could not change. + */ + const zodResolution = z.discriminatedUnion("type", [ + zodMetadata.extend({ type: z.literal("content") }), + z.object({ + location: z.string(), + status: z.literal(CONTENT_DELIVERY_REDIRECT_STATUS), + type: z.literal("redirect"), + }), + z.object({ type: z.literal("not_found") }), + ]); + + const zodSitemapEntry = z.object({ + changeFrequency: z.string().nullable(), + itemId: z.number().int(), + lastModified: z.string(), + locale: z.string().nullable(), + path: z.string(), + priority: z.number().nullable(), + }); + + const sitemapQuery = z.object({ + ...localeQuery, + cursor: z.coerce.number().int().positive().optional(), + limit: z.coerce + .number() + .int() + .min(1) + .max(CONTENT_SITEMAP_MAX_URLS) + .optional(), + }); + + const resolve = buildRoute({ + pluginId, + route: { + method: "get", + path: "/delivery/resolve/{slug}", + description: `Resolve one ${label.singular} URL to its canonical form, a redirect, or nothing`, + request: { + params: z.object({ slug: z.string() }), + ...(localized ? { query: z.object(localeQuery) } : {}), + }, + responses: { + 200: { + content: { "application/json": { schema: zodResolution } }, + description: + "The resolution. A `not_found` is a 200 with a body, not a 404", + }, + }, + }, + handler: async c => { + const resolved = await localeFor(c); + // An explicit locale naming no language this install serves resolves to + // nothing rather than to the default - the same rule the public detail route + // follows, and the reason a Polish URL is never answered with English. + if (!resolved) return c.json({ type: "not_found" as const }, 200); + + const resolution = await service(c).resolveSlug(c.req.param("slug"), { + locale: resolved.locale, + }); + + return c.json(resolution, 200); + }, + }); + + const item = buildRoute({ + pluginId, + route: { + method: "get", + path: "/delivery/item/{id}", + description: `Delivery metadata for one published ${label.singular}`, + request: { + params: z.object({ id: z.coerce.number().int().positive() }), + ...(localized ? { query: z.object(localeQuery) } : {}), + }, + responses: { + 200: { + content: { "application/json": { schema: zodMetadata } }, + description: `Canonical URL, alternates and SEO metadata`, + }, + 404: { description: `${label.singular} not found` }, + }, + }, + handler: async c => { + const resolved = await localeFor(c); + if (!resolved) throw notFound(label.singular); + + const id = Number(c.req.param("id")); + const metadata = await service(c).findById(id, { + locale: resolved.locale, + }); + if (!metadata) throw notFound(label.singular); + + return c.json(metadata, 200); + }, + }); + + const sitemap = buildRoute({ + pluginId, + route: { + method: "get", + path: "/delivery/sitemap", + description: `One page of the ${label.plural} sitemap`, + request: { query: sitemapQuery }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + entries: z.array(zodSitemapEntry), + nextCursor: z.number().int().nullable(), + }), + }, + }, + description: `Up to ${CONTENT_SITEMAP_MAX_URLS} public URLs, oldest record first`, + }, + 400: { description: "Invalid query parameters" }, + }, + }, + handler: async c => { + const { cursor, limit } = sitemapQuery.parse(c.req.query()); + const resolved = await localeFor(c); + if (!resolved) return c.json({ entries: [], nextCursor: null }, 200); + + const page = await service(c).sitemap({ + cursor, + limit: limit ?? CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, + locale: resolved.locale, + }); + + return c.json( + { + // ISO strings rather than `Date`s, because this crosses a wire: the + // OpenAPI schema says `string` and the runtime has to agree with it. + entries: page.entries.map(entry => ({ + ...entry, + lastModified: entry.lastModified.toISOString(), + })), + nextCursor: page.nextCursor, + }, + 200, + ); + }, + }); + + return [ + resolve, + item, + ...(definition.delivery.sitemap.enabled ? [sitemap] : []), + ]; +}; + +const notFound = (singular: string): HTTPException => + new HTTPException(404, { message: `${singular} not found.` }); diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index 361bd9a22..98db8ebac 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -1,11 +1,20 @@ import { HTTPException } from "hono/http-exception"; import { ZodError } from "zod"; -import type { ContentConflict, ContentUnprocessable } from "../conflicts"; +import type { + ContentConflict, + ContentDeliveryConflict, + ContentUnprocessable, +} from "../conflicts"; import type { ContentScheduleCode } from "../schedules"; -import { CONTENT_CONFLICT_CODES, CONTENT_UNPROCESSABLE_CODES } from "../const"; import { + CONTENT_CONFLICT_CODES, + CONTENT_DELIVERY_CODES, + CONTENT_UNPROCESSABLE_CODES, +} from "../const"; +import { + ContentDeliverySlugReserved, ContentInputError, ContentRevisionNotRestorable, ContentScheduleError, @@ -52,6 +61,19 @@ const jsonError = (status: 400 | 409 | 422, body: unknown): HTTPException => export const contentConflict = (body: ContentConflict): HTTPException => jsonError(409, body); +/** + * A structured 409, for a slug another record's URL history owns. + * + * 409 rather than 400: nothing about the request is malformed, the address is + * simply taken - by a URL that still redirects somewhere, which is a state of the + * system rather than a mistake in the payload. Its own body shape rather than a + * third arm of `zodContentConflict`, so a client generated before Stage 8 still + * parses the arms it knows. + */ +export const contentDeliveryConflict = ( + body: ContentDeliveryConflict, +): HTTPException => jsonError(409, body); + /** A structured 422, for a revision that no longer fits the content type. */ export const contentUnprocessable = ( body: ContentUnprocessable, @@ -104,6 +126,19 @@ export const rethrowAsHttpError = ( }); } + // Before the generic unique-violation mapping below, and before + // `ContentInputError`: a reserved address is a 409 that names the slug and the + // locale, where the driver's own `23505` cannot say which of the two constraints + // - the live slug index or the history reservation - refused the write. + if (error instanceof ContentDeliverySlugReserved) { + throw contentDeliveryConflict({ + code: CONTENT_DELIVERY_CODES.slugReserved, + contentTypeId: error.contentTypeId ?? contentTypeId ?? "", + locale: error.locale, + slug: error.slug, + }); + } + if (error instanceof ContentScheduleError) { throw contentScheduleRejected({ code: error.code, diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 58e792437..652f28200 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -38,6 +38,7 @@ import { splitContentFieldPath, } from "../paths"; import { publicOrderableColumns } from "../registry"; +import { buildContentDeliveryRoutes } from "./delivery-routes"; import { findContentLanguage, listContentLanguages } from "./language-resolver"; import { verifyContentPreviewToken } from "./preview-token"; import { @@ -514,8 +515,13 @@ export const buildContentPublicRoutes = < return [ list, // Before `detail` for readability only - the two can never both match, so - // the order carries no meaning. + // the order carries no meaning. The delivery routes are the same: every one of + // them begins with a static `delivery` segment and `/{slug}` is a single + // segment, so a record whose slug is literally "delivery" still resolves. ...(definition.editorial.preview.enabled ? [preview] : []), + ...(definition.delivery.enabled + ? buildContentDeliveryRoutes(model, { pluginId }) + : []), detail, ]; }; diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 79b273af1..451bf96bf 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -18,6 +18,7 @@ import { } from "../../api/lib/with-pagination"; import { zodContentConflict, + zodContentDeliveryConflict, zodContentScheduleRejection, zodContentUnprocessable, } from "../conflicts"; @@ -181,10 +182,21 @@ export const buildContentRoutes = < // a client can tell "someone saved first" from "that value is taken" and act // on the difference. Everything else keeps the plain-text 409 it has always // returned - a Stage 1-3 route's contract does not change. + // A content type with `delivery.redirects` adds a third arm: an address another + // record's URL history still owns. Declared as a union with the editorial pair + // rather than replacing it, so a client generated before Stage 8 still parses the + // two arms it knows and only fails to recognise the new one. + const conflictSchema = + definition.delivery.enabled && definition.delivery.redirects.enabled + ? z.union([zodContentConflict, zodContentDeliveryConflict]) + : zodContentConflict; + const uniqueConflict = editorial ? jsonResponse( - zodContentConflict, - "A record with these values already exists, or the version moved", + conflictSchema, + definition.delivery.redirects.enabled + ? "A record with these values already exists, the version moved, or the address is reserved by a historical URL" + : "A record with these values already exists, or the version moved", ) : { description: "A record with these values already exists" }; @@ -862,6 +874,93 @@ export const buildContentRoutes = < }, }); + /** + * The delivery state of one record: where it lives, and where it used to. + * + * `can_view` rather than a permission of its own, and deliberately so. This is + * read-only - it reports what the slug mutations already did - so the permission + * that allowed the mutation is the only one it needs, and inventing a + * `can_manage_redirects` for a screen that manages nothing would be a permission + * every install has to configure for no decision it can make. + * + * `locale` scopes it to one language on a content type whose slug is localized, + * which is what lets the AdminCP's Polish tab show Polish URLs and nothing else. + */ + const deliveryDetail = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/delivery", + description: `Canonical URL and historical URLs of one ${label.singular}`, + request: { + params: schemas.params, + query: z.object({ + locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH).optional(), + }), + }, + responses: { + 200: jsonResponse( + z.object({ + /** `null` while the record has no public URL - a draft, say. */ + canonicalPath: z.string().nullable(), + /** + * Every address it has ever answered to, current one first. + * + * `path` is the URL exactly as it was live, which is what somebody's + * bookmark holds. The storage columns behind it - `languageId`, + * `pluginId`, the row id - are deliberately absent: they are details of + * `core_content_slug_history` rather than part of this contract. + */ + history: z.array( + z.object({ + createdAt: z.date(), + path: z.string(), + retiredAt: z.date().nullable(), + slug: z.string(), + }), + ), + /** Whether the record is publicly reachable in this language now. */ + isPublic: z.boolean(), + locale: z.string().nullable(), + }), + `Delivery state of one ${label.singular}`, + ), + 400: invalidIdentifier, + 404: { description: `${label.singular} not found` }, + }, + }, + handler: async c => { + const build = model.deliveryService; + if (!build) throw notFound(definition); + + const id = identifier(c); + const locale = c.req.query("locale"); + const delivery = build(c, { pluginId }); + + // The canonical path comes from the public read, so a draft reports `null` + // rather than a URL that answers 404 - "this is where it *would* live" is a + // different claim from "this is where it lives", and the panel says so. + const metadata = await delivery.findById(id, { locale }); + const history = await delivery.history(id, { locale }); + + return c.json( + { + canonicalPath: metadata?.canonicalPath ?? null, + history: history.map(entry => ({ + createdAt: entry.createdAt, + path: entry.path, + retiredAt: entry.retiredAt, + slug: entry.slug, + })), + isPublic: metadata !== null, + locale: metadata?.locale ?? null, + }, + 200, + ); + }, + }); + /** * Mints a preview link for the record's newest revision. * @@ -1266,6 +1365,7 @@ export const buildContentRoutes = < : []), ...(editorial ? [revisionList, revisionDetail, restore] : []), ...(previewEnabled ? [previewToken] : []), + ...(definition.delivery.enabled ? [deliveryDetail] : []), ...(definition.editorial.scheduling.enabled ? [scheduleList, scheduleCreate, scheduleCancel] : []), diff --git a/packages/vitnode/src/content/server/translation-http-errors.ts b/packages/vitnode/src/content/server/translation-http-errors.ts index 78d694414..5bd4f58f0 100644 --- a/packages/vitnode/src/content/server/translation-http-errors.ts +++ b/packages/vitnode/src/content/server/translation-http-errors.ts @@ -4,11 +4,13 @@ import { ZodError } from "zod"; import type { ContentTranslationConflict } from "../conflicts"; import { + CONTENT_DELIVERY_CODES, CONTENT_TRANSLATION_CONFLICT_CODES, CONTENT_UNPROCESSABLE_CODES, } from "../const"; import { ContentDefaultTranslationRequired, + ContentDeliverySlugReserved, ContentInputError, ContentLanguageError, ContentRevisionNotRestorable, @@ -16,7 +18,11 @@ import { ContentTranslationItemMissing, ContentTranslationVersionConflict, } from "../errors"; -import { contentUnprocessable, rethrowAsHttpError } from "./http-errors"; +import { + contentDeliveryConflict, + contentUnprocessable, + rethrowAsHttpError, +} from "./http-errors"; /** A structured 409, in the translation union. */ export const contentTranslationConflict = ( @@ -39,6 +45,7 @@ export const contentTranslationConflict = ( * | version moved | 409 | `CONTENT_TRANSLATION_VERSION_CONFLICT` | * | default translation delete | 409 | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | * | localized slug taken | 409 | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | + * | localized slug reserved | 409 | `CONTENT_DELIVERY_SLUG_RESERVED` | * * Anything it does not recognise falls through to {@link rethrowAsHttpError}, * which owns the Postgres constraint codes - so the driver's message, which can @@ -124,6 +131,22 @@ export const withTranslationHttpErrors = async <TResult>( throw new HTTPException(404, { message: error.message }); } + // Answered in the **delivery** union rather than translated into + // `CONTENT_TRANSLATION_UNIQUE_CONFLICT`, and the difference matters to a client: + // a unique clash means another record holds that address *now*, so switching to + // it is impossible; a reservation means another record *used* to hold it and it + // still redirects, which is a different thing to explain and possibly to undo. + // It also has to be caught here rather than left to the fallthrough below, which + // rewrites every 409 the shared mapper produces into the unique-clash arm. + if (error instanceof ContentDeliverySlugReserved) { + throw contentDeliveryConflict({ + code: CONTENT_DELIVERY_CODES.slugReserved, + contentTypeId, + locale: error.locale, + slug: error.slug, + }); + } + // 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) { diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts index a924f0951..ad7c0466b 100644 --- a/packages/vitnode/src/content/server/translation-routes.ts +++ b/packages/vitnode/src/content/server/translation-routes.ts @@ -17,6 +17,7 @@ import type { ContentTranslationModel } from "./translation-model"; import { buildRoute } from "../../api/lib/route"; import { + zodContentDeliveryConflict, zodContentTranslationConflict, zodContentUnprocessable, } from "../conflicts"; @@ -146,9 +147,18 @@ export const buildContentTranslationRoutes = < return value; }; + // A localized content type with `delivery.redirects` can also refuse a slug that + // another record's URL history owns, which answers in the delivery union rather + // than this one - see `withTranslationHttpErrors` for why the two are different + // facts. Declared as a union so both shapes are in the generated document, and a + // client written before Stage 8 still parses the arms it knows. const conflict = jsonResponse( - zodContentTranslationConflict, - "The translation moved, already exists, is the default one, or a localized value is taken", + definition.delivery.enabled && definition.delivery.redirects.enabled + ? z.union([zodContentTranslationConflict, zodContentDeliveryConflict]) + : zodContentTranslationConflict, + definition.delivery.redirects.enabled + ? "The translation moved, already exists, is the default one, a localized value is taken, or the address is reserved by a historical URL" + : "The translation moved, already exists, is the default one, or a localized value is taken", ); const invalidIdentifier = { description: "Invalid identifier or locale" }; const notFound = { From b7c8e1fadb851f1d0d74f27d6b6b701d93786829 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:10:10 +0200 Subject: [PATCH 09/24] feat(content): add the Next.js delivery helpers A thin adapter, and the thinness is the point: the core engine returns framework-neutral metadata, and this turns it into the two shapes Next.js asks for. Move to Astro and you write a different forty lines against the same service. Every mapped key is **absent** rather than present-and-null when there is no value, because Next renders a `null` title as an empty `<title>` and an absent one not at all. `generateMetadata` gets `{}` for a URL that does not resolve rather than a throw - the page is what calls `notFound()`, and a metadata function that threw would replace a clean 404 with an error boundary. `contentDeliveryPage` lives in its own module because it is the only helper with a side effect: `next/navigation`'s control-flow functions throw to unwind the render, so a page that only wanted metadata should not reach them by accident. It is the one place `vitnode-frontend/navigation` would be wrong - a delivery location already carries its locale segment, so routing it through `next-intl` would prefix the locale twice, and that wrapper is a 307 where a canonical slug change needs a 308. `contentSitemapEntries` pages through the route with a `maxPages` backstop and reports truncation rather than throwing, so a partial sitemap is still a valid sitemap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/content/next/delivery.server.test.ts | 126 ++++++ .../src/content/next/delivery.server.ts | 407 ++++++++++++++++++ packages/vitnode/src/content/next/index.ts | 14 + .../src/content/next/redirect.server.ts | 78 ++++ 4 files changed, 625 insertions(+) create mode 100644 packages/vitnode/src/content/next/delivery.server.test.ts create mode 100644 packages/vitnode/src/content/next/delivery.server.ts create mode 100644 packages/vitnode/src/content/next/redirect.server.ts diff --git a/packages/vitnode/src/content/next/delivery.server.test.ts b/packages/vitnode/src/content/next/delivery.server.test.ts new file mode 100644 index 000000000..ec97c61c6 --- /dev/null +++ b/packages/vitnode/src/content/next/delivery.server.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; + +// `server-only` throws on import outside a server component, which is exactly its +// job - and exactly what a unit test has to stub, the same way the revalidation +// tests do. +vi.mock("server-only", () => ({})); + +import type { ContentDeliveryResponse } from "./delivery.server"; + +import { contentDeliveryToNextMetadata } from "./delivery.server"; + +/** + * The Next.js metadata mapping, without a network. + * + * `contentDeliveryToNextMetadata` is the whole of the adapter's judgement: which + * delivery fields become which `Metadata` keys, and what an absent value does. Every + * assertion below is about a key being **absent** rather than present-and-null, + * because Next renders a `null` title as an empty `<title>` and an absent one not at + * all - and an empty `<title>` is worse than none. + */ + +const response = ( + overrides: Partial<ContentDeliveryResponse> = {}, +): ContentDeliveryResponse => ({ + alternates: [], + canonicalPath: "/articles/hello", + hreflang: { languages: {} }, + isFallback: false, + itemId: 42, + locale: null, + openGraph: null, + requestedLocale: null, + robots: null, + seo: { description: "A summary.", title: "Hello" }, + ...overrides, +}); + +describe("contentDeliveryToNextMetadata", () => { + it("maps the canonical path relative when no origin is given", () => { + expect(contentDeliveryToNextMetadata(response())).toStrictEqual({ + alternates: { canonical: "/articles/hello" }, + description: "A summary.", + title: "Hello", + }); + }); + + it("makes every URL absolute when an origin is given", () => { + const metadata = contentDeliveryToNextMetadata( + response({ + hreflang: { + languages: { en: "/en/articles/hello", pl: "/pl/articles/witaj" }, + xDefault: "/en/articles/hello", + }, + }), + { origin: "https://example.com" }, + ); + + expect(metadata.alternates).toStrictEqual({ + canonical: "https://example.com/articles/hello", + languages: { + en: "https://example.com/en/articles/hello", + pl: "https://example.com/pl/articles/witaj", + // `x-default` is the standard's own key, so it lives in the same map. + "x-default": "https://example.com/en/articles/hello", + }, + }); + }); + + it("omits a title rather than emitting an empty one", () => { + const metadata = contentDeliveryToNextMetadata( + response({ seo: { description: null, title: null } }), + ); + + expect(metadata).not.toHaveProperty("title"); + expect(metadata).not.toHaveProperty("description"); + }); + + it("omits alternates entirely when there is nothing to say", () => { + const metadata = contentDeliveryToNextMetadata( + response({ canonicalPath: null }), + ); + + expect(metadata).not.toHaveProperty("alternates"); + }); + + it("emits no Open Graph block when the content type configured none", () => { + expect(contentDeliveryToNextMetadata(response())).not.toHaveProperty( + "openGraph", + ); + }); + + it("carries the canonical URL into the Open Graph block", () => { + const metadata = contentDeliveryToNextMetadata( + response({ + openGraph: { description: "Social summary", title: "Social" }, + }), + { origin: "https://example.com" }, + ); + + expect(metadata.openGraph).toStrictEqual({ + description: "Social summary", + title: "Social", + url: "https://example.com/articles/hello", + }); + }); + + it("passes the robots directive through untouched", () => { + expect( + contentDeliveryToNextMetadata( + response({ robots: { follow: true, index: false } }), + ).robots, + ).toStrictEqual({ follow: true, index: false }); + }); + + it("drops an alternate whose path will not resolve against the origin", () => { + const metadata = contentDeliveryToNextMetadata( + response({ hreflang: { languages: { en: "http://", pl: "/pl/x" } } }), + { origin: "https://example.com" }, + ); + + expect(metadata.alternates?.languages).toStrictEqual({ + pl: "https://example.com/pl/x", + }); + }); +}); diff --git a/packages/vitnode/src/content/next/delivery.server.ts b/packages/vitnode/src/content/next/delivery.server.ts new file mode 100644 index 000000000..1f6940876 --- /dev/null +++ b/packages/vitnode/src/content/next/delivery.server.ts @@ -0,0 +1,407 @@ +import "server-only"; + +import type { + ContentDeliveryAlternate, + ContentDeliveryRobots, + ContentDeliverySeo, +} from "../delivery"; +import type { ContentSitemapEntry } from "../sitemap"; +import type { DeliverableContentTypeDefinition } from "../types"; + +import { rawApiFetch } from "../../lib/fetcher/raw"; +import { + contentDeliveryRedirectTag, + contentDeliverySitemapTag, + contentDeliveryTag, +} from "../cache"; +import { CONTENT_SITEMAP_DEFAULT_PAGE_SIZE } from "../const"; +import { contentDeliveryUrl } from "../delivery"; + +/** + * The Next.js side of Content Delivery. + * + * A **thin adapter**, and the thinness is the point: the core engine returns + * framework-neutral delivery metadata, and this module turns it into the two + * shapes Next.js asks for - a `generateMetadata` return value and a `sitemap.ts` + * return value. Nothing here decides anything; move to Astro and you write a + * different forty lines against the same service. + * + * It reads over HTTP rather than through `model.deliveryService`, because in + * VitNode's split deployment the web app is not the process that holds the + * database. A single-process install can still call the service directly and skip + * this entirely. + */ + +/** The delivery metadata of one record, as the API returns it. */ +export interface ContentDeliveryResponse { + alternates: ContentDeliveryAlternate[]; + canonicalPath: null | string; + hreflang: { languages: Record<string, string>; xDefault?: string }; + isFallback: boolean; + /** `null` when the content type's public allowlist withholds `id`. */ + itemId: null | number; + locale: null | string; + openGraph: ContentDeliverySeo | null; + requestedLocale: null | string; + robots: ContentDeliveryRobots | null; + seo: ContentDeliverySeo; +} + +export type ContentDeliveryResolutionResponse = + | (ContentDeliveryResponse & { type: "content" }) + | { location: string; status: number; type: "redirect" } + | { type: "not_found" }; + +/** + * The subset of Next's `Metadata` this adapter produces. + * + * Structural rather than an `import type { Metadata } from "next"`, so the core + * package does not grow a compile-time dependency on the framework's type surface + * for four keys. It is assignable to `Metadata`, which is what a + * `generateMetadata` needs it to be. + */ +export interface ContentDeliveryNextMetadata { + alternates?: { + canonical?: string; + languages?: Record<string, string>; + }; + description?: string; + openGraph?: { + description?: string; + title?: string; + url?: string; + }; + robots?: { follow: boolean; index: boolean }; + title?: string; +} + +const deliveryModule = (definition: DeliverableContentTypeDefinition): string => + `content/${definition.publicApi.path}`; + +/** + * Resolves one public URL through the API, cached and tagged. + * + * Two tags, because one response answers two questions that expire at different + * moments: the record's delivery metadata, and "does this slug still resolve here". + * A slug change invalidates the second for the *old* address and the first for the + * record, and tagging both is what makes a moved page stop being served from its + * former URL. + * + * Only a `200` is stored, and a `not_found` is a `200` with a body - so a URL that + * does not exist yet is not cached as a negative, and publishing the record makes it + * resolve immediately. + */ +export const contentDeliveryResolve = async ({ + definition, + locale, + pluginId, + slug, +}: { + definition: DeliverableContentTypeDefinition; + /** The language to resolve in, for a localized content type. */ + locale?: string; + pluginId: string; + slug: string; +}): Promise<ContentDeliveryResolutionResponse> => { + const effectiveLocale = definition.localization.enabled + ? (locale?.trim() ?? "") === "" + ? definition.localization.defaultLocale + : locale + : undefined; + + const response = await rawApiFetch({ + method: "get", + module: deliveryModule(definition), + options: { + cache: "force-cache", + next: { + tags: [ + contentDeliveryRedirectTag(definition.id, slug, effectiveLocale), + ], + }, + }, + path: `/delivery/resolve/${encodeURIComponent(slug)}`, + pluginId, + query: + effectiveLocale === undefined ? undefined : { locale: effectiveLocale }, + }); + + if (!response.ok) return { type: "not_found" }; + + const payload = (await response.json()) as ContentDeliveryResolutionResponse; + + return payload; +}; + +/** + * Delivery metadata by identifier, cached under the record's delivery tag. + * + * The tag is the *record's*, not the slug's, which is what makes this the right + * call for a page that already knows which record it is rendering: an edit to the + * SEO description expires it, and an unrelated record's publish does not. + */ +export const contentDeliveryItem = async ({ + definition, + id, + locale, + pluginId, +}: { + definition: DeliverableContentTypeDefinition; + id: number; + locale?: string; + pluginId: string; +}): Promise<ContentDeliveryResponse | null> => { + const effectiveLocale = definition.localization.enabled + ? (locale?.trim() ?? "") === "" + ? definition.localization.defaultLocale + : locale + : undefined; + + const response = await rawApiFetch({ + method: "get", + module: deliveryModule(definition), + options: { + cache: "force-cache", + next: { tags: [contentDeliveryTag(definition.id, id, effectiveLocale)] }, + }, + path: `/delivery/item/${id}`, + pluginId, + query: + effectiveLocale === undefined ? undefined : { locale: effectiveLocale }, + }); + + if (!response.ok) return null; + + return (await response.json()) as ContentDeliveryResponse; +}; + +/** + * Delivery metadata as a `generateMetadata` return value. + * + * ```tsx title="src/app/[locale]/articles/[slug]/page.tsx" + * export const generateMetadata = async ({ params }) => { + * const { locale, slug } = await params; + * + * return await contentDeliveryMetadata({ + * definition: articleContentType, + * locale, + * origin: "https://example.com", + * pluginId: "@vitnode/example", + * slug, + * }); + * }; + * ``` + * + * The canonical URL is **absolute when an origin is given and relative otherwise**, + * which is the one place delivery is opinionated: a relative `canonical` is legal + * and resolves against the page, and an absolute one is what every SEO checker asks + * for - so an app that knows its public origin should pass it. + * + * `{}` for a URL that does not resolve, rather than a throw: `generateMetadata` + * runs alongside the page, the page is what calls `notFound()`, and a metadata + * function that threw would replace a clean 404 with an error boundary. + */ +export const contentDeliveryMetadata = async ({ + definition, + locale, + origin, + pluginId, + slug, +}: { + definition: DeliverableContentTypeDefinition; + locale?: string; + /** Turns every URL in the result absolute. Strongly recommended. */ + origin?: string; + pluginId: string; + slug: string; +}): Promise<ContentDeliveryNextMetadata> => { + const resolution = await contentDeliveryResolve({ + definition, + locale, + pluginId, + slug, + }); + + return resolution.type === "content" + ? contentDeliveryToNextMetadata(resolution, { origin }) + : {}; +}; + +/** + * The pure half of {@link contentDeliveryMetadata}: metadata in, `Metadata` out. + * + * Exported separately so a page that already holds the delivery response - because + * it fetched the record and its metadata together - can translate it without a + * second round trip. It is also what makes the mapping unit-testable without a + * network. + */ +export const contentDeliveryToNextMetadata = ( + metadata: ContentDeliveryResponse, + { origin }: { origin?: string } = {}, +): ContentDeliveryNextMetadata => { + const absolute = (path: null | string): string | undefined => { + if (path === null) return undefined; + + return origin === undefined + ? path + : (contentDeliveryUrl({ origin, path }) ?? undefined); + }; + + const canonical = absolute(metadata.canonicalPath); + const languages = Object.fromEntries( + Object.entries(metadata.hreflang.languages).flatMap(([code, path]) => { + const href = absolute(path); + + return href === undefined ? [] : [[code, href]]; + }), + ); + const xDefault = + metadata.hreflang.xDefault === undefined + ? undefined + : absolute(metadata.hreflang.xDefault); + + return { + ...(canonical === undefined && Object.keys(languages).length === 0 + ? {} + : { + alternates: { + ...(canonical === undefined ? {} : { canonical }), + ...(Object.keys(languages).length === 0 + ? {} + : { + languages: { + ...languages, + // `x-default` is the standard's own key, so it goes in the same + // map rather than beside it - which is also how Next emits it. + ...(xDefault === undefined + ? {} + : { "x-default": xDefault }), + }, + }), + }, + }), + ...(metadata.seo.description === null + ? {} + : { description: metadata.seo.description }), + ...(metadata.openGraph === null + ? {} + : { + openGraph: { + ...(metadata.openGraph.description === null + ? {} + : { description: metadata.openGraph.description }), + ...(metadata.openGraph.title === null + ? {} + : { title: metadata.openGraph.title }), + ...(canonical === undefined ? {} : { url: canonical }), + }, + }), + ...(metadata.robots === null ? {} : { robots: metadata.robots }), + ...(metadata.seo.title === null ? {} : { title: metadata.seo.title }), + }; +}; + +/** One entry of a Next.js `sitemap.ts`, as that file's return type wants it. */ +export interface ContentDeliveryNextSitemapEntry { + alternates?: { languages?: Record<string, string> }; + changeFrequency?: ContentSitemapEntry["changeFrequency"]; + lastModified?: Date; + priority?: number; + url: string; +} + +/** + * Every public URL of one content type, in one language, as a Next sitemap. + * + * It pages through the delivery sitemap route until the cursor runs out, so a + * content type with 40,000 published records is 40 requests rather than one + * enormous response - and `maxPages` is a backstop, because an unbounded loop + * against a paginated API is the one bug in this file that could take a site down. + * Reaching it is reported by the return value rather than thrown, so a partial + * sitemap is still a valid sitemap. + * + * Next caps a `sitemap.ts` at 50,000 URLs and splits beyond that with + * `generateSitemaps`; `contentSitemapChunks` is the helper that decides how many + * files that is. + */ +export const contentSitemapEntries = async ({ + definition, + locale, + maxPages = 100, + origin, + pageSize = CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, + pluginId, +}: { + definition: DeliverableContentTypeDefinition; + locale?: string; + /** Backstop on the pagination loop. */ + maxPages?: number; + /** Required: the sitemap protocol only accepts absolute URLs. */ + origin: string; + pageSize?: number; + pluginId: string; +}): Promise<{ + entries: ContentDeliveryNextSitemapEntry[]; + /** `true` when `maxPages` stopped the loop before the cursor ran out. */ + truncated: boolean; +}> => { + const effectiveLocale = definition.localization.enabled + ? (locale?.trim() ?? "") === "" + ? definition.localization.defaultLocale + : locale + : undefined; + + const entries: ContentDeliveryNextSitemapEntry[] = []; + let cursor: null | number = null; + let truncated = false; + + for (let visited = 0; visited < maxPages; visited += 1) { + const response = await rawApiFetch({ + method: "get", + module: deliveryModule(definition), + options: { + cache: "force-cache", + next: { + tags: [contentDeliverySitemapTag(definition.id, effectiveLocale)], + }, + }, + path: "/delivery/sitemap", + pluginId, + query: { + ...(cursor === null ? {} : { cursor: String(cursor) }), + ...(effectiveLocale === undefined ? {} : { locale: effectiveLocale }), + limit: String(pageSize), + }, + }); + + if (!response.ok) break; + + const page = (await response.json()) as { + entries: (Omit<ContentSitemapEntry, "lastModified"> & { + lastModified: string; + })[]; + nextCursor: null | number; + }; + + for (const entry of page.entries) { + const url = contentDeliveryUrl({ origin, path: entry.path }); + if (url === null) continue; + + entries.push({ + ...(entry.changeFrequency === null + ? {} + : { changeFrequency: entry.changeFrequency }), + lastModified: new Date(entry.lastModified), + ...(entry.priority === null ? {} : { priority: entry.priority }), + url, + }); + } + + cursor = page.nextCursor; + if (cursor === null) return { entries, truncated }; + } + + truncated = cursor !== null; + + return { entries, truncated }; +}; diff --git a/packages/vitnode/src/content/next/index.ts b/packages/vitnode/src/content/next/index.ts index c918315b6..1d1e10e3f 100644 --- a/packages/vitnode/src/content/next/index.ts +++ b/packages/vitnode/src/content/next/index.ts @@ -8,12 +8,26 @@ * * The cache *tags* live in `@vitnode/core/content`, because they are strings. */ +export { + contentDeliveryItem, + contentDeliveryMetadata, + contentDeliveryResolve, + contentDeliveryToNextMetadata, + contentSitemapEntries, +} from "./delivery.server"; +export type { + ContentDeliveryNextMetadata, + ContentDeliveryNextSitemapEntry, + ContentDeliveryResolutionResponse, + ContentDeliveryResponse, +} from "./delivery.server"; export { contentPreviewFetch, contentPublicFetch, contentPublicItemTags, } from "./fetch.server"; export type { ContentPublicFetchResult } from "./fetch.server"; +export { contentDeliveryPage } from "./redirect.server"; export { POST as contentRevalidateRoute } from "./revalidate-route.server"; export { revalidateContent } from "./revalidate.server"; export type { diff --git a/packages/vitnode/src/content/next/redirect.server.ts b/packages/vitnode/src/content/next/redirect.server.ts new file mode 100644 index 000000000..3e97f68c0 --- /dev/null +++ b/packages/vitnode/src/content/next/redirect.server.ts @@ -0,0 +1,78 @@ +import "server-only"; +// `vitnode-frontend/navigation` is the locale-aware wrapper every app-level redirect +// should use, and this is the one place it would be wrong: a delivery location is a +// **complete** path that already carries its locale segment - the engine built it - +// so routing it through `next-intl` would prefix the locale a second time and send +// `/pl/articles/x` to `/pl/pl/articles/x`. That wrapper is also a 307; a canonical +// slug change needs the permanent, method-preserving 308. +// eslint-disable-next-line no-restricted-imports +import { notFound, permanentRedirect, RedirectType } from "next/navigation"; + +import type { DeliverableContentTypeDefinition } from "../types"; +import type { ContentDeliveryResponse } from "./delivery.server"; + +import { contentDeliveryResolve } from "./delivery.server"; + +/** + * Resolves a public URL and *acts* on the answer: renders, redirects or 404s. + * + * The one helper in the delivery adapter that has a side effect, and it is kept in + * its own module because of what it imports: `next/navigation`'s control-flow + * functions throw to unwind the render, so a page that only wanted metadata should + * not be able to reach them by accident. + * + * ```tsx title="src/app/[locale]/articles/[slug]/page.tsx" + * const Page = async ({ params }) => { + * const { locale, slug } = await params; + * const delivery = await contentDeliveryPage({ + * definition: articleContentType, + * locale, + * pluginId: "@vitnode/example", + * slug, + * }); + * + * // Only reached when the slug is the current one - a moved URL has already + * // redirected and a missing one has already 404ed. + * return <Article delivery={delivery} />; + * }; + * ``` + * + * `permanentRedirect` issues a **308**, which is what the engine's resolver reports + * and the status a canonical slug change deserves: it preserves the request method, + * where a `301` lets a client rewrite it to `GET`. Both behave identically for the + * `GET` a content page is read with - and only one of them still behaves correctly + * the day a form under a moved path is submitted. + * + * `RedirectType.replace`, so a reader who follows an old link does not have to press + * back twice to leave the page they were never meant to land on. + */ +export const contentDeliveryPage = async ({ + definition, + locale, + pluginId, + slug, +}: { + definition: DeliverableContentTypeDefinition; + locale?: string; + pluginId: string; + slug: string; +}): Promise<ContentDeliveryResponse> => { + const resolution = await contentDeliveryResolve({ + definition, + locale, + pluginId, + slug, + }); + + if (resolution.type === "redirect") { + permanentRedirect(resolution.location, RedirectType.replace); + } + + // A draft, an unpublished record, a deleted one, a slug that never existed and a + // historical URL whose destination is no longer public are all the same 404. A + // redirect to hidden content would be a way to confirm it exists, and that is + // precisely what an unpublished URL must not do. + if (resolution.type === "not_found") notFound(); + + return resolution; +}; From b329b42711a4e7b0e25dd1a56c0a1c009e377025 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:10:31 +0200 Subject: [PATCH 10/24] feat(admin): show content delivery metadata A read-only delivery panel on the row action: canonical URL, publication state, and every address the record has ever answered to. Lazy, like the edit form and the revision history, so a 25-row table costs 25 buttons rather than 25 queries. Gated by `can_view` and nothing narrower. It reports what the slug mutations already did, so the permission that allowed the mutation is the only one it needs - a `can_manage_redirects` for a screen that manages nothing would be a permission every install has to configure for no decision it can make. Read-only is the deliberate scope. A redirect is somebody else's incoming link, so deleting one silently breaks traffic nobody in the dialog can see: that needs its own permission, a confirmation that explains the consequence, and an audit trail. Displaying the history is useful today; managing it is a product rather than a button. The reserved-slug 409 gets its own message on both the shared form and the locale editor, because "another record holds that address now" and "another record used to hold it and it still redirects" are different sentences and possibly different decisions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/locales/en.json | 23 ++- .../views/content/actions/delivery-action.tsx | 107 +++++++++++++ .../content/actions/delivery-api.server.ts | 74 +++++++++ .../actions/delivery/delivery-panel.tsx | 147 ++++++++++++++++++ .../content/actions/translation-api.server.ts | 13 ++ .../translations/translation-panel.tsx | 7 +- .../views/content/lib/mutation-feedback.ts | 9 ++ .../content/table/content-table-view.tsx | 12 ++ 8 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 packages/vitnode/src/views/admin/views/content/actions/delivery-action.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/delivery-api.server.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 80eb8b581..1042340bd 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -527,6 +527,23 @@ "unavailable": "Preview is not configured on this deployment. Set CONTENT_PREVIEW_SECRET to at least 32 random bytes and restart the API.", "live": "This record has no saved version yet, so the link shows it live - it will follow any edits made before the reviewer opens it." }, + "delivery": { + "title": "Delivery for this {name}", + "desc": "Where this record lives on the public site, and where it used to.", + "canonical": "Canonical URL", + "no_canonical": "This record has no public URL yet. It gets one when it is published.", + "historical": "Historical URLs", + "no_history": "No previous URLs. Changing the address of a published record adds one here.", + "redirect_active": "redirects to the current URL", + "redirect_inactive": "not redirecting while this is unpublished", + "retired_at": "Replaced", + "inactive_note": "These URLs start redirecting again as soon as this record is published.", + "load_failed": "This record's delivery state could not be read.", + "states": { + "published": "Published", + "not_published": "Not published" + } + }, "schedule": { "title": "Schedule this {name}", "desc": "Publish or unpublish <title> at a set time. Scheduling changes nothing now.", @@ -577,7 +594,8 @@ "forbidden": "You do not have permission to do this.", "version_conflict": "Someone else saved this while you were editing. Your changes are still here.", "unique_conflict": "A record with these values already exists.", - "not_restorable": "This version cannot be restored: {fields} no longer fit this content type." + "not_restorable": "This version cannot be restored: {fields} no longer fit this content type.", + "slug_reserved": "That address is reserved: another record used it publicly and it still redirects there. Pick a different one." }, "translations": { "shared_tab": "Shared", @@ -614,7 +632,8 @@ "unique_conflict": "Another record already uses that address in this language.", "exists": "This language already has a translation. Reload the tab to edit it.", "language_disabled": "This language is switched off on this installation, so its content cannot be written.", - "default_required": "This is the default language, so its translation cannot be deleted." + "default_required": "This is the default language, so its translation cannot be deleted.", + "slug_reserved": "That address is reserved in this language: another record used it publicly and it still redirects there." }, "history": { "show": "Show this language's history", diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/delivery-action.tsx new file mode 100644 index 000000000..2261c8b37 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery-action.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { LinkIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CONTENT_PERMISSIONS } from "@/content/const"; + +// The panel fetches a record's whole URL history, so it is loaded when the dialog +// is - the same treatment the edit form and the revision history get, and the +// reason a 25-row table costs 25 buttons rather than 25 queries. +const DeliveryPanel = dynamic(async () => + import("./delivery/delivery-panel").then(mod => ({ + default: mod.DeliveryPanel, + })), +); + +/** + * The delivery row action: canonical URL, publication state, historical URLs. + * + * Gated by `can_view`, and by nothing else. It reports what the slug mutations + * already did, so the permission that allowed the mutation is the only one it + * needs - a `can_manage_redirects` for a read-only screen would be a permission + * every install has to configure for no decision it can make. + */ +export const DeliveryContentAction = ({ + contentTypeId, + id, + locale, + permissionModule, + pluginId, + singular, +}: { + contentTypeId: string; + id: number; + /** The language whose URLs to show, when the list is viewed in one. */ + locale?: string; + permissionModule: string; + pluginId: string; + singular: string; +}) => { + const t = useTranslations("core.content.delivery"); + const canView = useAdminStaffPermission({ + module: permissionModule, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + }); + + if (!canView) return null; + + const label = t("title", { name: singular }); + + return ( + + + + + + + } + /> + } + /> + + + + {label} + {t("desc")} + + + }> + + + + + + {label} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/delivery-api.server.ts new file mode 100644 index 000000000..e751c0514 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery-api.server.ts @@ -0,0 +1,74 @@ +"use server"; + +import { z } from "zod"; + +import { findFrontendContentType } from "@/content/admin/config"; +import { contentApiFetch } from "@/content/admin/fetch.server"; + +/** + * One address a record has answered to. + * + * Exactly what the admin route publishes and not one field more: the storage + * columns behind it - `languageId`, `pluginId`, the row id - are details of + * `core_content_slug_history`, and a panel that displayed them would make them + * part of a contract nobody meant to sign. + */ +const zodDeliveryEntry = z.object({ + createdAt: z.coerce.date(), + path: z.string(), + /** `null` while this is the record's current address. */ + retiredAt: z.coerce.date().nullable(), + slug: z.string(), +}); + +const zodDelivery = z.object({ + canonicalPath: z.string().nullable(), + history: z.array(zodDeliveryEntry), + isPublic: z.boolean(), + locale: z.string().nullable(), +}); + +export type ContentDeliveryPanelData = z.infer; + +export interface ContentDeliveryPanelResult { + data?: ContentDeliveryPanelData; + error?: string; +} + +/** + * Reads one record's delivery state for the AdminCP panel. + * + * A Server Action rather than a fetch in the page, because the panel is lazy: it + * loads when somebody opens the dialog, and a record's URL history is not worth a + * query on every row of a 25-row table. + * + * `can_view` is enforced by the route it calls, not here - the AdminCP's session + * cookie travels with the request and the generated route carries the permission, + * which is the same arrangement every other content action uses. There is + * deliberately no `can_manage_redirects`: this screen manages nothing. + */ +export const readContentDeliveryAction = async ( + contentTypeId: string, + id: number, + locale?: string, +): Promise => { + const entry = findFrontendContentType(contentTypeId); + if (!entry) return { error: "Unknown content type." }; + + const result = await contentApiFetch({ + definition: entry.definition, + method: "get", + path: `/${id}/delivery`, + pluginId: entry.pluginId, + query: locale === undefined ? undefined : { locale }, + schema: zodDelivery, + }); + + if (result.status !== 200 || !result.data) { + return { + error: result.error ?? "This record's delivery state could not be read.", + }; + } + + return { data: result.data }; +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx new file mode 100644 index 000000000..ce69d4c5c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { CheckIcon, LinkIcon, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Loader } from "@/components/ui/loader"; + +import type { ContentDeliveryPanelData } from "../delivery-api.server"; + +import { readContentDeliveryAction } from "../delivery-api.server"; + +/** + * The read-only delivery panel: where a record lives, and where it used to. + * + * Read-only on purpose, and it is the deliberate scope of Stage 8. A redirect is + * somebody else's incoming link, so deleting one silently breaks traffic nobody in + * this dialog can see - that is a destructive action, and a destructive action needs + * a permission of its own, a confirmation that explains the consequence, and an + * audit trail. Displaying the history is useful today; managing it is a product, + * not a button. + */ +export const DeliveryPanel = ({ + contentTypeId, + id, + locale, +}: { + contentTypeId: string; + id: number; + /** The language whose URLs to show, on a content type with localized slugs. */ + locale?: string; +}) => { + const t = useTranslations("core.content.delivery"); + const [state, setState] = React.useState< + | { data: ContentDeliveryPanelData; status: "ready" } + | { message: string; status: "error" } + | { status: "loading" } + >({ status: "loading" }); + + React.useEffect(() => { + let active = true; + + void readContentDeliveryAction(contentTypeId, id, locale).then(result => { + if (!active) return; + + setState( + result.data + ? { data: result.data, status: "ready" } + : { message: result.error ?? t("load_failed"), status: "error" }, + ); + }); + + return () => { + active = false; + }; + }, [contentTypeId, id, locale, t]); + + if (state.status === "loading") return ; + + if (state.status === "error") { + return ( +

+ {state.message} +

+ ); + } + + const { canonicalPath, history, isPublic } = state.data; + const historical = history.filter(entry => entry.retiredAt !== null); + + return ( +
+
+

{t("canonical")}

+ + {canonicalPath === null ? ( +

+ {t("no_canonical")} +

+ ) : ( +

+ + {canonicalPath} +

+ )} + + + {isPublic ? ( + + ) : ( + + )} + {isPublic ? t("states.published") : t("states.not_published")} + +
+ +
+

{t("historical")}

+ + {historical.length === 0 ? ( +

+ {t("no_history")} +

+ ) : ( +
    + {historical.map(entry => ( +
  • + + {entry.path} + + → + + + {canonicalPath === null + ? t("redirect_inactive") + : t("redirect_active")} + + + + {entry.retiredAt === null ? null : ( + + {t("retired_at")} + + + )} +
  • + ))} +
+ )} + + {/* Said out loud, because it is the one thing about this screen somebody + will assume is wrong: an unpublished record's old URLs stop redirecting + and start again when it comes back. */} + {historical.length > 0 && canonicalPath === null ? ( +

+ {t("inactive_note")} +

+ ) : null} +
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts index fb2b46f56..bc0e88fc5 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import type { + ContentDeliveryConflict, ContentTranslationConflict, ContentUnprocessable, } from "@/content/conflicts"; @@ -16,6 +17,7 @@ import type { import { findFrontendContentType } from "@/content/admin/config"; import { contentApiFetch } from "@/content/admin/fetch.server"; import { + parseContentDeliveryConflict, parseContentTranslationConflict, parseContentUnprocessable, } from "@/content/conflicts"; @@ -44,6 +46,16 @@ const CONTENT_PAGE_PATH = */ export interface TranslationMutationResult { conflict?: ContentTranslationConflict; + /** + * `CONTENT_DELIVERY_SLUG_RESERVED`, when a localized address is owned by another + * record's URL history. + * + * Its own field rather than a sixth arm of `conflict`, because it is a fact about + * *delivery* rather than about translations - the base routes answer with the same + * shape, and one code for one condition is what lets the AdminCP say the same + * sentence wherever the address was typed. + */ + delivery?: ContentDeliveryConflict; error?: string; status?: number; unprocessable?: ContentUnprocessable; @@ -54,6 +66,7 @@ const failure = (result: { status: number; }): TranslationMutationResult => ({ conflict: parseContentTranslationConflict(result.error) ?? undefined, + delivery: parseContentDeliveryConflict(result.error) ?? undefined, error: result.error ?? "", status: result.status, unprocessable: parseContentUnprocessable(result.error) ?? undefined, diff --git a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx index 5646c0dab..d47fb00c5 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/translations/translation-panel.tsx @@ -184,7 +184,12 @@ export const TranslationPanel = ({ const report = (result: TranslationMutationResult): boolean => { if (result.error === undefined) return true; - const key = conflictMessage(result.conflict); + // The delivery reservation first: it shares a status with the unique clash and + // says a different thing - "that address still redirects to another record" + // rather than "another record holds it now". + const key = result.delivery + ? "slug_reserved" + : conflictMessage(result.conflict); if (result.conflict?.code === "CONTENT_TRANSLATION_VERSION_CONFLICT") { // The form keeps every value the translator typed. Nothing is retried and diff --git a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts index c019f1bca..647dc2f6f 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts @@ -1,5 +1,6 @@ import type { ContentConflict, + ContentDeliveryConflict, ContentUnprocessable, } from "@/content/conflicts"; @@ -9,6 +10,7 @@ export type ContentErrorKey = | "forbidden" | "not_found" | "not_restorable" + | "slug_reserved" | "unique_conflict" | "validation" | "version_conflict"; @@ -31,9 +33,16 @@ export const contentErrorKey = ( status: number | undefined, structured?: { conflict?: ContentConflict; + delivery?: ContentDeliveryConflict; unprocessable?: ContentUnprocessable; }, ): ContentErrorKey | null => { + // Before the plain conflict, because the two share a status and mean different + // things: a unique clash is "another record holds that address now, so you cannot + // have it", and a reservation is "another record *used* to hold it and it still + // redirects" - which is a different sentence and possibly a different decision. + if (structured?.delivery) return "slug_reserved"; + if (structured?.conflict) { return structured.conflict.code === "CONTENT_VERSION_CONFLICT" ? "version_conflict" diff --git a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx index 216d64770..4adc5f130 100644 --- a/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/content-table-view.tsx @@ -13,6 +13,7 @@ import { orderableColumns } from "@/content/registry"; import type { ContentRowData } from "./cells"; import { DeleteContentAction } from "../actions/delete-action"; +import { DeliveryContentAction } from "../actions/delivery-action"; import { EditContentAction } from "../actions/edit-action"; import { HistoryContentAction } from "../actions/history-action"; import { PreviewContentAction } from "../actions/preview-action"; @@ -170,6 +171,7 @@ export const ContentTableView = async ({ definition.editorial.enabled ? "w-36" : "", definition.editorial.preview.enabled ? "w-44" : "", definition.editorial.scheduling.enabled ? "w-52" : "", + definition.delivery.enabled ? "w-60" : "", ] .filter(Boolean) .at(-1), @@ -181,6 +183,16 @@ export const ContentTableView = async ({ return ( <> + {definition.delivery.enabled ? ( + + ) : null} {definition.editorial.preview.enabled ? ( Date: Sat, 8 Aug 2026 17:10:31 +0200 Subject: [PATCH 11/24] feat(example): add the delivery reference fixtures Two shapes, because the interesting cases differ: `example.article` is the nonlocalized reference - no locale segment, one reservation for the one URL it has, and SEO from two fields the public API already exposes. `example.advanced-article` is the localized one: a localized slug, so each language gets its own reservation and changing the English URL creates no Polish redirect; SEO from a localized group with a fallback to the localized `title`; and an `x-default` that appears only when the default locale is genuinely published. `syndication.noIndex` is added as a **shared** boolean so the fixture exercises the one field that drives two consumers - sitemap exclusion and the `robots` directive - which is why it has to be shared: a per-locale value would let the two disagree. The three Stage 6 assertions that named the group's leaves exactly are updated rather than loosened; a new leaf appearing in a partial-update assertion is precisely what those tests are for. Co-Authored-By: Claude Opus 5 (1M context) --- .../0033_add_example_article_no_index.sql | 1 + apps/docs/migrations/meta/0033_snapshot.json | 4036 +++++++++++++++++ apps/docs/migrations/meta/_journal.json | 14 + plugins/example/src/const.ts | 8 + .../example/src/content/advanced-article.ts | 43 + plugins/example/src/content/article.ts | 30 + .../src/database/advanced-postgres.test.ts | 5 + .../src/database/advanced-routes.test.ts | 9 +- 8 files changed, 4143 insertions(+), 3 deletions(-) create mode 100644 apps/docs/migrations/0033_add_example_article_no_index.sql create mode 100644 apps/docs/migrations/meta/0033_snapshot.json diff --git a/apps/docs/migrations/0033_add_example_article_no_index.sql b/apps/docs/migrations/0033_add_example_article_no_index.sql new file mode 100644 index 000000000..cab8ed154 --- /dev/null +++ b/apps/docs/migrations/0033_add_example_article_no_index.sql @@ -0,0 +1 @@ +ALTER TABLE "example_advanced_articles" ADD COLUMN "syndicationNoIndex" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/docs/migrations/meta/0033_snapshot.json b/apps/docs/migrations/meta/0033_snapshot.json new file mode 100644 index 000000000..e1ce93871 --- /dev/null +++ b/apps/docs/migrations/meta/0033_snapshot.json @@ -0,0 +1,4036 @@ +{ + "id": "318c5944-3dbe-4646-a80e-fd047f44db84", + "prevId": "b7094309-91e5-43f2-b9f9-d5666d73f0f4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_revisions": { + "name": "core_content_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "changedFields": { + "name": "changedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "actorType": { + "name": "actorType", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actorUserId": { + "name": "actorUserId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "restoredFromRevisionId": { + "name": "restoredFromRevisionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_content_revisions_item_version_unique": { + "name": "core_content_revisions_item_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_translation_version_unique": { + "name": "core_content_revisions_translation_version_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_language_idx": { + "name": "core_content_revisions_language_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_plugin_id_idx": { + "name": "core_content_revisions_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_revisions_actor_user_id_idx": { + "name": "core_content_revisions_actor_user_id_idx", + "columns": [ + { + "expression": "actorUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_revisions_actorUserId_core_users_id_fk": { + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "tableFrom": "core_content_revisions", + "tableTo": "core_users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_schedules": { + "name": "core_content_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "scheduledFor": { + "name": "scheduledFor", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "createdBy": { + "name": "createdBy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effectsError": { + "name": "effectsError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_schedules_active_unique": { + "name": "core_content_schedules_active_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_due_idx": { + "name": "core_content_schedules_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduledFor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_item_idx": { + "name": "core_content_schedules_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_plugin_id_idx": { + "name": "core_content_schedules_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_schedules_created_by_idx": { + "name": "core_content_schedules_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_content_schedules_createdBy_core_users_id_fk": { + "name": "core_content_schedules_createdBy_core_users_id_fk", + "tableFrom": "core_content_schedules", + "tableTo": "core_users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_content_slug_history": { + "name": "core_content_slug_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "contentTypeId": { + "name": "contentTypeId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retiredAt": { + "name": "retiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_content_slug_history_shared_unique": { + "name": "core_content_slug_history_shared_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_locale_unique": { + "name": "core_content_slug_history_locale_unique", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_item_idx": { + "name": "core_content_slug_history_item_idx", + "columns": [ + { + "expression": "contentTypeId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_content_slug_history_plugin_id_idx": { + "name": "core_content_slug_history_plugin_id_idx", + "columns": [ + { + "expression": "pluginId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_advanced_articles": { + "name": "example_advanced_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 + }, + "syndicationIndexable": { + "name": "syndicationIndexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "syndicationNoIndex": { + "name": "syndicationNoIndex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "syndicationPriority": { + "name": "syndicationPriority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + } + }, + "indexes": { + "example_advanced_articles_syndication_priority_idx": { + "name": "example_advanced_articles_syndication_priority_idx", + "columns": [ + { + "expression": "syndicationPriority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_created_at_idx": { + "name": "example_advanced_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_updated_at_idx": { + "name": "example_advanced_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_status_published_at_idx": { + "name": "example_advanced_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_categories": { + "name": "example_advanced_articles_categories", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_categories_position_key": { + "name": "example_advanced_articles_categories_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_categories_related_item_id_idx": { + "name": "example_advanced_articles_categories_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_categories_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_categories_relatedItemId_example_categories_id_fk": { + "name": "example_advanced_articles_categories_relatedItemId_example_categories_id_fk", + "tableFrom": "example_advanced_articles_categories", + "tableTo": "example_categories", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_categories_pk": { + "name": "example_advanced_articles_categories_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_faq": { + "name": "example_advanced_articles_faq", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "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()" + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_advanced_articles_faq_position_key": { + "name": "example_advanced_articles_faq_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_faq", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_related_articles": { + "name": "example_advanced_articles_related_articles", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "relatedItemId": { + "name": "relatedItemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "example_advanced_articles_related_articles_position_key": { + "name": "example_advanced_articles_related_articles_position_key", + "columns": [ + { + "expression": "itemId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_related_articles_related_item_id_idx": { + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "columns": [ + { + "expression": "relatedItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_related_articles_relatedItemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_related_articles", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "relatedItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_related_articles_pk": { + "name": "example_advanced_articles_related_articles_pk", + "columns": [ + "itemId", + "relatedItemId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_advanced_articles_translations": { + "name": "example_advanced_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "seoTitle": { + "name": "seoTitle", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "seoDescription": { + "name": "seoDescription", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "example_advanced_articles_translations_language_id_status_idx": { + "name": "example_advanced_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_advanced_articles_translations_language_id_slug_key": { + "name": "example_advanced_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_advanced_articles_translations_itemId_example_advanced_articles_id_fk": { + "name": "example_advanced_articles_translations_itemId_example_advanced_articles_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "example_advanced_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_advanced_articles_translations_languageId_core_languages_id_fk": { + "name": "example_advanced_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_advanced_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_advanced_articles_translations_item_id_language_id_pk": { + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_articles": { + "name": "example_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author": { + "name": "author", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_articles_status_created_at_idx": { + "name": "example_articles_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_slug_key": { + "name": "example_articles_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_code_key": { + "name": "example_articles_code_key", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_author_idx": { + "name": "example_articles_author_idx", + "columns": [ + { + "expression": "author", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_category_idx": { + "name": "example_articles_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_created_at_idx": { + "name": "example_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_updated_at_idx": { + "name": "example_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_articles_status_published_at_idx": { + "name": "example_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_articles_author_core_users_id_fk": { + "name": "example_articles_author_core_users_id_fk", + "tableFrom": "example_articles", + "tableTo": "core_users", + "columnsFrom": [ + "author" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "example_articles_category_example_categories_id_fk": { + "name": "example_articles_category_example_categories_id_fk", + "tableFrom": "example_articles", + "tableTo": "example_categories", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_categories": { + "name": "example_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_categories_created_at_idx": { + "name": "example_categories_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_categories_updated_at_idx": { + "name": "example_categories_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles": { + "name": "example_localized_articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "example_localized_articles_created_at_idx": { + "name": "example_localized_articles_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_updated_at_idx": { + "name": "example_localized_articles_updated_at_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_status_published_at_idx": { + "name": "example_localized_articles_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.example_localized_articles_translations": { + "name": "example_localized_articles_translations", + "schema": "", + "columns": { + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageId": { + "name": "languageId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "example_localized_articles_translations_language_id_status_idx": { + "name": "example_localized_articles_translations_language_id_status_idx", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "example_localized_articles_translations_language_id_slug_key": { + "name": "example_localized_articles_translations_language_id_slug_key", + "columns": [ + { + "expression": "languageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "example_localized_articles_translations_itemId_example_localized_articles_id_fk": { + "name": "example_localized_articles_translations_itemId_example_localized_articles_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "example_localized_articles", + "columnsFrom": [ + "itemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "example_localized_articles_translations_languageId_core_languages_id_fk": { + "name": "example_localized_articles_translations_languageId_core_languages_id_fk", + "tableFrom": "example_localized_articles_translations", + "tableTo": "core_languages", + "columnsFrom": [ + "languageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "example_localized_articles_translations_item_id_language_id_pk": { + "name": "example_localized_articles_translations_item_id_language_id_pk", + "columns": [ + "itemId", + "languageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index 230eb9f15..1f44b074f 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -225,6 +225,20 @@ "when": 1786181800826, "tag": "0031_add_example_advanced_articles", "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1786194085698, + "tag": "0032_add_content_slug_history", + "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1786195724174, + "tag": "0033_add_example_article_no_index", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 7e7ff31b6..122c10184 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -35,4 +35,12 @@ export const EXAMPLE_MIGRATIONS = [ // table - each with the constraints that make its ordering and its integrity // facts about the database rather than about the service. "0031_add_example_advanced_articles.sql", + // Stage 8. Core again: `core_content_slug_history` is what makes an old public + // URL keep working, and the delivery suites write reservations for both example + // content types - so the table has to exist before either of them publishes. + "0032_add_content_slug_history.sql", + // The shared boolean `delivery.seo.noIndexField` reads, which drives the sitemap + // exclusion and the `robots` metadata together. Additive and defaulted, so every + // existing row becomes indexable rather than silently disappearing from a sitemap. + "0033_add_example_article_no_index.sql", ]; diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index b3f02af42..86abca97e 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -98,6 +98,15 @@ export const advancedArticleContentType = defineContentType({ syndication: field.group({ fields: { indexable: field.boolean({ defaultValue: true }), + /** + * The Stage 8 `noIndexField`, and shared rather than localized on purpose. + * + * Sitemap exclusion and the `robots` metadata are driven by the same + * boolean, so they cannot disagree - and a per-locale value would give one + * record one answer per language while it has a single canonical decision. + * `delivery` refuses a localized field here for exactly that reason. + */ + noIndex: field.boolean({ defaultValue: false }), priority: field.number({ integer: true, min: 0, @@ -142,6 +151,10 @@ export const advancedArticleContentType = defineContentType({ "seo.title", "seo.description", "syndication.priority", + // Public because delivery projects it: `robots: { index: false }` is rendered + // into the page, so the field it comes from has to be something the public API + // would already have said out loud. + "syndication.noIndex", "faq.question", "faq.answer", "publishedAt", @@ -166,6 +179,36 @@ export const advancedArticleContentType = defineContentType({ pathTemplate: "/{locale}/advanced-articles/{slug}", }, + /** + * The Stage 8 reference for a **localized** content type. + * + * Its canonical path carries the locale - `/pl/advanced-articles/moj-artykul` - + * and so does its slug history: the slug is `localized: true`, so each language + * gets its own reservation and changing the English URL creates no Polish + * redirect. + * + * `seo` reads the localized group, so every language has its own title and + * description, with `fallbackTitleField: "title"` filling in when `seo.title` is + * empty - which it usually is, because nobody writes one twice. + * + * `hreflang.xDefault` points at the default locale's canonical path, and only when + * that language is genuinely published: an `x-default` pointing at a translation + * this record does not have would be a hint to crawl a 404. + */ + delivery: { + enabled: true, + redirects: { enabled: true }, + hreflang: { xDefault: "defaultLocale" }, + seo: { + titleField: "seo.title", + fallbackTitleField: "title", + descriptionField: "seo.description", + noIndexField: "syndication.noIndex", + openGraph: { titleField: "seo.title", descriptionField: "seo.description" }, + }, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + }, + // Leaf paths, materialised against the generated columns: this compiles to an // index on `syndicationPriority`, exactly as `{ on: ["priority"] }` would have // if `priority` were a top-level field. diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index ab6c9574d..ae2fa389c 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -45,6 +45,36 @@ export const articleContentType = defineContentType({ pathTemplate: "/articles/{slug}", }, + /** + * The Stage 8 reference for a **nonlocalized** content type. + * + * Its canonical path has no locale segment - `/articles/my-article` - and its slug + * history has no language either: `languageId` is `NULL`, so one reservation + * covers the one URL the record has. + * + * `redirects` is what makes an old address keep working. Change the slug of a + * *published* article and `/articles/old-slug` answers 308 to the new one, for as + * long as the article stays published; change it while it is still a draft and + * nothing is recorded, because the URL was never live. + * + * `seo` projects two fields the public API already exposes. There is no + * `fallbackTitleField` here because `title` is the primary and it is + * `required: true` - a fallback would never be reached. + */ + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { + titleField: "title", + descriptionField: "excerpt", + // Same fields in both slots, which is the common case: an author who wants a + // different social title names a different field, and one who does not says + // so in two lines rather than four. + openGraph: { titleField: "title", descriptionField: "excerpt" }, + }, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + }, + editorial: { enabled: true, revisions: { retention: 20 }, diff --git a/plugins/example/src/database/advanced-postgres.test.ts b/plugins/example/src/database/advanced-postgres.test.ts index 632cacc54..e81cd796c 100644 --- a/plugins/example/src/database/advanced-postgres.test.ts +++ b/plugins/example/src/database/advanced-postgres.test.ts @@ -663,6 +663,9 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { expect(row?.syndication).toStrictEqual({ indexable: false, + // Stage 8 added a third leaf to the group. It is untouched by a write that + // named only `priority`, which is exactly what "partial group update" means. + noIndex: false, priority: 3, }); }); @@ -1406,6 +1409,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { // Nested, never the flattened column names. expect(snapshot.fields.syndication).toStrictEqual({ indexable: true, + noIndex: false, priority: 5, }); }); @@ -1525,6 +1529,7 @@ describe.skipIf(!url)("Stage 6 advanced modeling against Postgres", () => { expect(row?.syndication).toStrictEqual({ indexable: true, + noIndex: false, priority: 7, }); }); diff --git a/plugins/example/src/database/advanced-routes.test.ts b/plugins/example/src/database/advanced-routes.test.ts index 9c0782d4b..9e1443cff 100644 --- a/plugins/example/src/database/advanced-routes.test.ts +++ b/plugins/example/src/database/advanced-routes.test.ts @@ -107,13 +107,16 @@ describe("advanced article: generated routes", () => { "title", ]); // A private collection is absent from the contract as well as from the - // response - and `syndication` carries only the leaf that was exposed. + // response - and `syndication` carries only the leaves that were exposed. + // `indexable` is still absent, which is the whole point of leaf-level + // allowlisting: `noIndex` joined it in Stage 8 because delivery projects the + // value into a public `robots` directive, and `indexable` did not. expect(shape.relatedArticles).toBeUndefined(); expect( Object.keys( (shape.syndication as unknown as { shape: Record }) .shape, - ), - ).toStrictEqual(["priority"]); + ).sort(), + ).toStrictEqual(["noIndex", "priority"]); }); }); From 224f0881a27d4b10e549d0fbabc575d38f8de274 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 8 Aug 2026 17:10:58 +0200 Subject: [PATCH 12/24] test(content): cover delivery, redirects and the cache boundary Type tests for the rules an author gets wrong while typing - a private field as an SEO title, prose in a title slot, delivery without a public API - because each is a mistake the editor should catch before the file is saved. Plus the assignability check every stage repeats: an eleventh type parameter on `ContentTypeDefinition` must not break the erased form every relation thunk and route builder is written against. The resolver tests run against the **real** service with only its two reads stubbed, rather than against a copy of its logic: the decision it makes is where a mistake becomes a permanent 308 to the wrong page. The cache tests assert exact tag lists rather than "some revalidation happened", because the whole of Stage 8's opt-in claim at that layer is that an existing content type's tags do not move - and only a byte comparison shows it. `findBasePublication` is added to the five translation-model mocks so the suites that predate it keep exercising what they were written for. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/content/cache.delivery.test.ts | 251 +++++++ .../vitnode/src/content/delivery.test-d.ts | 285 ++++++++ packages/vitnode/src/content/delivery.test.ts | 652 +++++++++++++++++ .../server/delivery-admin-route.test.ts | 199 ++++++ .../content/server/delivery-effects.test.ts | 225 ++++++ .../content/server/delivery-routes.test.ts | 261 +++++++ .../content/server/delivery-service.test.ts | 658 ++++++++++++++++++ .../content/server/delivery-writes.test.ts | 406 +++++++++++ .../server/localized-preview-routes.test.ts | 6 + .../translation-advanced-revisions.test.ts | 6 + .../translation-editorial-service.test.ts | 6 + .../translation-publication-routes.test.ts | 6 + .../content/server/translation-routes.test.ts | 6 + packages/vitnode/src/content/sitemap.test.ts | 199 ++++++ .../vitnode/src/tests/content-fixtures.ts | 97 +++ 15 files changed, 3263 insertions(+) create mode 100644 packages/vitnode/src/content/cache.delivery.test.ts create mode 100644 packages/vitnode/src/content/delivery.test-d.ts create mode 100644 packages/vitnode/src/content/delivery.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-admin-route.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-effects.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-routes.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-service.test.ts create mode 100644 packages/vitnode/src/content/server/delivery-writes.test.ts create mode 100644 packages/vitnode/src/content/sitemap.test.ts diff --git a/packages/vitnode/src/content/cache.delivery.test.ts b/packages/vitnode/src/content/cache.delivery.test.ts new file mode 100644 index 000000000..7e77ec0fc --- /dev/null +++ b/packages/vitnode/src/content/cache.delivery.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; + +import { + contentDeliveryRedirectTag, + contentDeliverySitemapTag, + contentDeliveryTag, + contentInvalidationTags, + contentPublicItemTag, + contentPublicListTag, + contentPublicSlugTag, +} from "./cache"; + +/** + * The delivery cache tags, and the promise that a content type without `delivery` + * produces exactly the tags it always produced. + * + * That second half is the important one and is why the assertions are exact strings + * rather than "some revalidation happened": the whole of Stage 8's opt-in claim at + * this layer is that an existing content type's tag list does not move, and only a + * byte comparison can show it. + */ + +const ID = "example.article"; + +describe("delivery tag builders", () => { + it("follows the existing namespace, with the locale after the scope", () => { + expect(contentDeliveryTag(ID, 42)).toBe( + "content:example.article:delivery:42", + ); + expect(contentDeliveryTag(ID, 42, "pl")).toBe( + "content:example.article:delivery:pl:42", + ); + + expect(contentDeliveryRedirectTag(ID, "old-slug")).toBe( + "content:example.article:redirect:old-slug", + ); + expect(contentDeliveryRedirectTag(ID, "stary-slug", "pl")).toBe( + "content:example.article:redirect:pl:stary-slug", + ); + + expect(contentDeliverySitemapTag(ID)).toBe( + "content:example.article:sitemap", + ); + expect(contentDeliverySitemapTag(ID, "pl")).toBe( + "content:example.article:sitemap:pl", + ); + }); + + it("normalizes the locale, so PL and pl expire together", () => { + for (const locale of ["PL", "pl", " pl "]) { + expect(contentDeliveryTag(ID, 1, locale)).toBe( + "content:example.article:delivery:pl:1", + ); + expect(contentDeliverySitemapTag(ID, locale)).toBe( + "content:example.article:sitemap:pl", + ); + } + }); +}); + +describe("contentInvalidationTags without delivery", () => { + it("is byte-identical to the Stage 1-7 output for a flat mutation", () => { + expect( + contentInvalidationTags({ + contentTypeId: ID, + id: 42, + isPublic: true, + slugs: ["old", "new"], + wasPublic: true, + }), + ).toStrictEqual([ + contentPublicListTag(ID), + contentPublicItemTag(ID, 42), + contentPublicSlugTag(ID, "old"), + contentPublicSlugTag(ID, "new"), + ]); + }); + + it("is byte-identical for a localized mutation", () => { + expect( + contentInvalidationTags({ + contentTypeId: ID, + id: 7, + isPublic: true, + locales: [ + { + isPublic: true, + locale: "pl", + slugs: ["stary", "nowy"], + wasPublic: true, + }, + ], + slugs: [], + wasPublic: true, + }), + ).toStrictEqual([ + contentPublicListTag(ID, "pl"), + contentPublicItemTag(ID, 7, "pl"), + contentPublicSlugTag(ID, "stary", "pl"), + contentPublicSlugTag(ID, "nowy", "pl"), + ]); + }); + + it("still returns nothing for a draft edited into another draft", () => { + expect( + contentInvalidationTags({ + contentTypeId: ID, + id: 1, + isPublic: false, + slugs: ["a", "b"], + wasPublic: false, + }), + ).toStrictEqual([]); + }); +}); + +describe("contentInvalidationTags with delivery", () => { + it("adds the delivery metadata tag and one redirect tag per slug", () => { + expect( + contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 42, + isPublic: true, + slugs: ["old", "new"], + wasPublic: true, + }), + ).toStrictEqual([ + contentPublicListTag(ID), + contentPublicItemTag(ID, 42), + contentPublicSlugTag(ID, "old"), + contentPublicSlugTag(ID, "new"), + contentDeliveryTag(ID, 42), + contentDeliveryRedirectTag(ID, "old"), + contentDeliveryRedirectTag(ID, "new"), + ]); + }); + + it("adds the sitemap tag only when the set of listed URLs changed", () => { + const withSitemap = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: true }, + id: 42, + isPublic: true, + slugs: ["new"], + wasPublic: false, + }); + + expect(withSitemap).toContain(contentDeliverySitemapTag(ID)); + + const withoutSitemap = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 42, + isPublic: true, + slugs: ["new"], + wasPublic: true, + }); + + expect(withoutSitemap).not.toContain(contentDeliverySitemapTag(ID)); + }); + + it("emits the sitemap tag once for a nonlocalized content type", () => { + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: true }, + id: 42, + isPublic: true, + slugs: ["new"], + wasPublic: false, + }); + + expect( + tags.filter(tag => tag === contentDeliverySitemapTag(ID)), + ).toHaveLength(1); + }); + + it("expires each locale's sitemap and the index that lists them", () => { + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: true }, + id: 7, + isPublic: true, + locales: [ + { isPublic: true, locale: "en", slugs: ["hello"], wasPublic: true }, + { isPublic: true, locale: "pl", slugs: ["witaj"], wasPublic: false }, + ], + slugs: [], + wasPublic: true, + }); + + expect(tags).toContain(contentDeliverySitemapTag(ID, "en")); + expect(tags).toContain(contentDeliverySitemapTag(ID, "pl")); + // The locale-less one too: a localized content type's index enumerates its + // per-locale files, so a language gaining a page changes the index. + expect(tags).toContain(contentDeliverySitemapTag(ID)); + }); + + it("keeps one locale's delivery tags out of another's", () => { + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 7, + isPublic: true, + locales: [ + { + isPublic: true, + locale: "pl", + slugs: ["stary", "nowy"], + wasPublic: true, + }, + ], + slugs: [], + wasPublic: true, + }); + + expect(tags).toContain(contentDeliveryTag(ID, 7, "pl")); + expect(tags).toContain(contentDeliveryRedirectTag(ID, "stary", "pl")); + expect(tags).not.toContain(contentDeliveryTag(ID, 7, "en")); + expect(tags).not.toContain(contentDeliveryRedirectTag(ID, "stary", "en")); + }); + + it("touches nothing at all for a draft that stayed a draft", () => { + // The delivery tags follow the public ones: a mutation that changed no public + // response should not throw away a warm cache for a feature it did not reach. + expect( + contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 1, + isPublic: false, + slugs: ["a", "b"], + wasPublic: false, + }), + ).toStrictEqual([]); + }); + + it("drops an empty slug rather than tagging a redirect for it", () => { + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: false }, + id: 42, + isPublic: true, + slugs: ["", "new"], + wasPublic: false, + }); + + expect(tags).not.toContain(contentDeliveryRedirectTag(ID, "")); + expect(tags).toContain(contentDeliveryRedirectTag(ID, "new")); + }); +}); diff --git a/packages/vitnode/src/content/delivery.test-d.ts b/packages/vitnode/src/content/delivery.test-d.ts new file mode 100644 index 000000000..2c6416fe6 --- /dev/null +++ b/packages/vitnode/src/content/delivery.test-d.ts @@ -0,0 +1,285 @@ +import { assertType, describe, expectTypeOf, it } from "vitest"; + +import { + testArticleContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import type { ContentEventsFor } from "./events"; +import type { + AnyContentTypeDefinition, + ContentSitemapChangeFrequency, + DeliverableContentTypeDefinition, + ResolvedContentDeliveryConfig, +} from "./types"; + +import { defineContentType } from "./define"; +import { field } from "./fields"; + +/** + * Stage 8 at the type level. + * + * The rules worth a compile error rather than a boot-time one are the ones an author + * gets wrong while typing: naming a private field as an SEO title, putting prose in a + * title slot, or reaching for a delivery service a content type does not have. Every + * `@ts-expect-error` below is a mistake the editor catches before the file is saved. + */ + +const fields = { + excerpt: field.textarea({ maxLength: 500, nullable: true }), + /** Declared but never exposed - the private half of every check below. */ + internalCode: field.text({ nullable: true }), + seo: field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + nullable: true, + }), + slug: field.slug({ source: "title" }), + title: field.text({ maxLength: 200, required: true }), + views: field.number({ integer: true, defaultValue: 0 }), +}; + +const shared = { + admin: { label: { plural: "Articles", singular: "Article" } }, + fields, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "excerpt", "seo.title", "seo.description"], + path: "articles", + }, +} as const; + +const deliveredType = defineContentType({ + ...shared, + id: "typed.delivered", + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { + descriptionField: "seo.description", + fallbackDescriptionField: "excerpt", + fallbackTitleField: "title", + openGraph: { descriptionField: "excerpt", titleField: "title" }, + titleField: "seo.title", + }, + sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + }, + tableName: "typed_delivered", +}); + +const plainType = defineContentType({ + ...shared, + id: "typed.plain", + tableName: "typed_plain", +}); + +describe("delivery configuration", () => { + it("keeps the `enabled` literal, so every conditional resolves", () => { + expectTypeOf(deliveredType.delivery.enabled).toEqualTypeOf(); + expectTypeOf(plainType.delivery.enabled).toEqualTypeOf(); + }); + + // The whole Stage 8 type design rests on this: an eleventh type parameter on + // `ContentTypeDefinition` must not break the erased form every relation thunk, + // registry and route builder is written against. + it("stays assignable to AnyContentTypeDefinition", () => { + expectTypeOf().toExtend(); + assertType(deliveredType); + assertType(plainType); + }); + + it("narrows to DeliverableContentTypeDefinition only with delivery", () => { + expectTypeOf< + typeof deliveredType + >().toExtend(); + expectTypeOf< + typeof plainType + >().not.toExtend(); + }); + + it("accepts a group leaf and a plain field in the SEO slots", () => { + expectTypeOf(deliveredType.delivery.seo.titleField).toEqualTypeOf< + null | string + >(); + expectTypeOf( + deliveredType.delivery.sitemap.changeFrequency, + ).toEqualTypeOf(); + }); + + it("records the slug scope", () => { + expectTypeOf(deliveredType.delivery.slugScope).toEqualTypeOf< + "localized" | "none" | "shared" + >(); + }); +}); + +describe("delivery requires a public API", () => { + it("refuses `enabled: true` without one", () => { + defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "typed.no-public", + // @ts-expect-error - delivery needs `publicApi: { enabled: true }`: without a + // public allowlist there is no canonical URL for delivery to be about. + delivery: { enabled: true }, + fields, + publication: { enabled: true }, + tableName: "typed_no_public", + }); + }); + + it("still accepts an explicit `enabled: false`", () => { + const off = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "typed.off", + delivery: { enabled: false }, + fields, + publication: { enabled: true }, + tableName: "typed_off", + }); + + expectTypeOf(off.delivery.enabled).toEqualTypeOf(); + }); +}); + +describe("SEO field references", () => { + it("refuses a field the public allowlist withholds", () => { + defineContentType({ + ...shared, + id: "typed.private-seo", + delivery: { + enabled: true, + // @ts-expect-error - `internalCode` is a text field, but it is not in + // `publicApi.fields`, and a `` is rendered into a public page. + seo: { titleField: "internalCode" }, + }, + tableName: "typed_private_seo", + }); + }); + + it("refuses prose in a title slot", () => { + defineContentType({ + ...shared, + id: "typed.prose-title", + delivery: { + enabled: true, + // @ts-expect-error - `excerpt` is a textarea. A `<title>` is one line, and a + // paragraph in a browser tab is not a heading. + seo: { titleField: "excerpt" }, + }, + tableName: "typed_prose_title", + }); + }); + + it("refuses a number in a description slot", () => { + defineContentType({ + ...shared, + id: "typed-bad.description", + delivery: { + enabled: true, + // @ts-expect-error - `views` is a number, and it is private besides. + seo: { descriptionField: "views" }, + }, + tableName: "typed_bad_description", + }); + }); + + it("refuses a nested path the content type does not declare", () => { + defineContentType({ + ...shared, + id: "typed.bad-path", + delivery: { + enabled: true, + // @ts-expect-error - `seo.heading` is not a leaf of the `seo` group. + seo: { titleField: "seo.heading" }, + }, + tableName: "typed_bad_path", + }); + }); + + it("accepts a valid nested group path", () => { + const nested = defineContentType({ + ...shared, + id: "typed.nested", + delivery: { + enabled: true, + seo: { descriptionField: "seo.description", titleField: "seo.title" }, + }, + tableName: "typed_nested", + }); + + expectTypeOf(nested.delivery.enabled).toEqualTypeOf<true>(); + }); + + it("refuses an unknown change frequency", () => { + defineContentType({ + ...shared, + id: "typed.bad-freq", + delivery: { + enabled: true, + // @ts-expect-error - not one of the seven values the protocol defines. + sitemap: { changeFrequency: "fortnightly", enabled: true }, + }, + tableName: "typed_bad_freq", + }); + }); +}); + +describe("the resolved config is generic over `enabled`", () => { + it("pins `true` for a delivered content type", () => { + expectTypeOf(deliveredType.delivery).toExtend< + ResolvedContentDeliveryConfig<true> + >(); + }); + + it("pins `false` for one without", () => { + expectTypeOf(plainType.delivery).toExtend< + ResolvedContentDeliveryConfig<false> + >(); + }); +}); + +describe("Stage 1-7 backward compatibility", () => { + it("leaves the existing fixtures assignable and unchanged", () => { + assertType<AnyContentTypeDefinition>(testArticleContentType); + assertType<AnyContentTypeDefinition>(testPostContentType); + expectTypeOf( + testArticleContentType.delivery.enabled, + ).toEqualTypeOf<false>(); + expectTypeOf(testPostContentType.delivery.enabled).toEqualTypeOf<false>(); + }); +}); + +describe("delivery events", () => { + it("adds both keys for a content type with redirects", () => { + expectTypeOf<ContentEventsFor<typeof deliveredType>>().toHaveProperty( + "content.typed.delivered.delivery_slug_changed", + ); + expectTypeOf<ContentEventsFor<typeof deliveredType>>().toHaveProperty( + "content.typed.delivered.delivery_redirect_created", + ); + }); + + it("adds neither for a content type without delivery", () => { + // The keys are gated on `delivery: { enabled: true }`, so a listener for one + // cannot even be registered - which is what keeps every Stage 1-7 event map + // byte-identical. + expectTypeOf<ContentEventsFor<typeof plainType>>().not.toHaveProperty( + "content.typed.plain.delivery_slug_changed", + ); + expectTypeOf<ContentEventsFor<typeof plainType>>().not.toHaveProperty( + "content.typed.plain.delivery_redirect_created", + ); + }); + + it("keeps the ordinary events in place alongside them", () => { + expectTypeOf<ContentEventsFor<typeof deliveredType>>().toHaveProperty( + "content.typed.delivered.updated", + ); + expectTypeOf<ContentEventsFor<typeof deliveredType>>().toHaveProperty( + "content.typed.delivered.published", + ); + }); +}); diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts new file mode 100644 index 000000000..0386ad949 --- /dev/null +++ b/packages/vitnode/src/content/delivery.test.ts @@ -0,0 +1,652 @@ +import { describe, expect, it } from "vitest"; + +import { defineContentType } from "./define"; +import { + contentDeliveryHreflang, + contentDeliveryOpenGraph, + contentDeliveryPath, + contentDeliveryRobots, + contentDeliverySeo, + contentDeliveryUrl, + contentSitemapDefaults, + isDeliverableContentType, + listDeliveryContentTypes, + parseContentDeliveryPath, +} from "./delivery"; +import { field } from "./fields"; + +/** + * Stage 8 definition validation and the pure delivery projections. + * + * Everything here runs without a database, because everything here is a rule about + * a *definition* or a pure function over a public row - and the rules are the half + * of Stage 8 that has to fail loudly at boot rather than quietly at request time. + */ + +const base = { + admin: { label: { plural: "Articles", singular: "Article" } }, + publication: { enabled: true } as const, + tableName: "delivery_articles", +} as const; + +const publicApi = { + enabled: true, + fields: ["id", "title", "slug", "excerpt", "publishedAt"], + path: "articles", +} as const; + +const fields = { + excerpt: field.textarea({ maxLength: 500, nullable: true }), + hidden: field.boolean({ defaultValue: false }), + /** A text field the public allowlist deliberately withholds. */ + internalCode: field.text({ nullable: true }), + slug: field.slug({ source: "title" }), + title: field.text({ maxLength: 200, required: true }), + views: field.number({ integer: true, defaultValue: 0 }), +}; + +const articleType = defineContentType({ + ...base, + id: "delivery.article", + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + }, + fields, + publicApi, +}); + +const plainType = defineContentType({ + ...base, + id: "delivery.plain", + fields, + publicApi, + tableName: "delivery_plain", +}); + +describe("delivery definition validation", () => { + it("defaults to disabled, so a Stage 1-7 content type is unchanged", () => { + expect(plainType.delivery).toStrictEqual({ + enabled: false, + hreflang: { xDefault: null }, + redirects: { enabled: false }, + seo: { + descriptionField: null, + fallbackDescriptionField: null, + fallbackTitleField: null, + noIndexField: null, + openGraph: null, + titleField: null, + }, + sitemap: { changeFrequency: null, enabled: false, priority: null }, + slugScope: "none", + }); + }); + + it("refuses delivery without a public API", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.private", + // Refused by the types too - see `delivery.test-d.ts`. Cast here because + // this asserts the *runtime* guard, which a JavaScript caller still reaches. + delivery: { enabled: true as never }, + fields, + tableName: "delivery_private", + }), + ).toThrow(/delivery needs `publicApi/); + }); + + it("refuses an SEO field that is not publicly exposed", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.private-seo", + // A text field, so the kind check passes - and absent from + // `publicApi.fields`, so a `<title>` built from it would publish something + // the public API deliberately withholds. + delivery: { + enabled: true, + seo: { titleField: "internalCode" as never }, + }, + fields, + publicApi, + tableName: "delivery_private_seo", + }), + ).toThrow(/not in publicApi.fields/); + }); + + it("refuses an unsupported SEO field kind", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-kind", + // `excerpt` is a textarea, which is a description and never a title. + delivery: { enabled: true, seo: { titleField: "excerpt" as never } }, + fields, + publicApi, + tableName: "delivery_bad_kind", + }), + ).toThrow(/of kind "textarea"/); + }); + + it("refuses an unknown SEO field", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.unknown-seo", + delivery: { enabled: true, seo: { titleField: "nope" as never } }, + fields, + publicApi, + tableName: "delivery_unknown_seo", + }), + ).toThrow(/references unknown field "nope"/); + }); + + it("refuses a repeatable leaf as a title", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.repeatable-seo", + delivery: { + enabled: true, + seo: { titleField: "faq.question" as never }, + }, + fields: { + ...fields, + faq: field.repeatable({ + fields: { question: field.text({ required: true }) }, + }), + }, + publicApi: { + ...publicApi, + fields: [...publicApi.fields, "faq.question"], + }, + tableName: "delivery_repeatable_seo", + }), + ).toThrow(/many values rather than one/); + }); + + it("accepts a group leaf as a title and a description", () => { + const withGroup = defineContentType({ + ...base, + id: "delivery.group-seo", + delivery: { + enabled: true, + seo: { + descriptionField: "seo.description", + fallbackTitleField: "title", + titleField: "seo.title", + }, + }, + fields: { + ...fields, + seo: field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + nullable: true, + }), + }, + publicApi: { + ...publicApi, + fields: [...publicApi.fields, "seo.title", "seo.description"], + }, + tableName: "delivery_group_seo", + }); + + expect(withGroup.delivery.seo).toMatchObject({ + descriptionField: "seo.description", + fallbackTitleField: "title", + titleField: "seo.title", + }); + }); + + it("refuses a fallback with no primary, which would never be read", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.orphan-fallback", + delivery: { + enabled: true, + seo: { fallbackTitleField: "title" as never }, + }, + fields, + publicApi, + tableName: "delivery_orphan_fallback", + }), + ).toThrow(/without `titleField`/); + }); + + it("refuses a sitemap priority outside 0-1", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-priority", + delivery: { enabled: true, sitemap: { enabled: true, priority: 7 } }, + fields, + publicApi, + tableName: "delivery_bad_priority", + }), + ).toThrow(/between 0 and 1/); + }); + + it("refuses an unknown change frequency", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-freq", + delivery: { + enabled: true, + sitemap: { + // A crawler ignores an unknown value silently, so a typo has to be + // caught here or it is a hint nobody ever receives. + changeFrequency: "fortnightly" as never, + enabled: true, + }, + }, + fields, + publicApi, + tableName: "delivery_bad_freq", + }), + ).toThrow(/sitemap protocol defines/); + }); + + it("refuses a non-boolean noIndexField", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-noindex", + delivery: { enabled: true, seo: { noIndexField: "title" as never } }, + fields, + publicApi, + tableName: "delivery_bad_noindex", + }), + ).toThrow(/Expected one of: boolean/); + }); + + it("refuses x-default without localization", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.bad-xdefault", + delivery: { enabled: true, hreflang: { xDefault: "defaultLocale" } }, + fields, + publicApi, + tableName: "delivery_bad_xdefault", + }), + ).toThrow(/delivery.hreflang needs `localization/); + }); + + it("records the slug scope so history knows which language owns a URL", () => { + expect(articleType.delivery.slugScope).toBe("shared"); + expect(localizedType.delivery.slugScope).toBe("localized"); + }); + + it("refuses redirects on a localized content type with a shared slug", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.shared-slug", + delivery: { enabled: true, redirects: { enabled: true } }, + fields: { + body: field.textarea({ localized: true, required: true }), + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publicApi: { + enabled: true, + fields: ["title", "slug", "body"], + path: "articles", + }, + tableName: "delivery_shared_slug", + }), + ).toThrow(/needs a localized slug field/); + }); + + it("refuses a localized noIndexField", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.localized-noindex", + delivery: { + enabled: true, + seo: { noIndexField: "flags.noIndex" as never }, + }, + fields: { + // A localized group's leaves live on the translation table, so the value + // would differ per language while the record has one canonical decision. + flags: field.group({ + fields: { noIndex: field.boolean({ defaultValue: false }) }, + localized: true, + }), + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publicApi: { + enabled: true, + fields: ["title", "slug", "flags.noIndex"], + path: "articles", + }, + tableName: "delivery_localized_noindex", + }), + ).toThrow(/has to be shared/); + }); +}); + +const localizedType = defineContentType({ + ...base, + id: "delivery.localized", + delivery: { + enabled: true, + hreflang: { xDefault: "defaultLocale" }, + redirects: { enabled: true }, + seo: { + descriptionField: "seo.description", + fallbackTitleField: "title", + titleField: "seo.title", + }, + sitemap: { changeFrequency: "daily", enabled: true, priority: 0.5 }, + }, + fields: { + seo: field.group({ + fields: { + description: field.textarea({ nullable: true }), + title: field.text({ nullable: true }), + }, + localized: true, + nullable: true, + }), + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true, fallback: "default" }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "seo.title", "seo.description"], + path: "articles", + }, + tableName: "delivery_localized", +}); + +describe("contentDeliveryPath", () => { + it("has no locale segment for a nonlocalized content type", () => { + expect( + contentDeliveryPath({ definition: articleType, slug: "my-article" }), + ).toBe("/articles/my-article"); + }); + + it("prefixes the locale for a localized content type", () => { + expect( + contentDeliveryPath({ + definition: localizedType, + locale: "pl", + slug: "moj-artykul", + }), + ).toBe("/pl/articles/moj-artykul"); + }); + + it("normalizes the locale, so one URL has one cache key", () => { + const paths = ["PL", "pl", " pl "].map(locale => + contentDeliveryPath({ definition: localizedType, locale, slug: "witaj" }), + ); + + expect(new Set(paths).size).toBe(1); + expect(paths[0]).toBe("/pl/articles/witaj"); + }); + + it("refuses to build a localized path with no locale", () => { + expect( + contentDeliveryPath({ definition: localizedType, slug: "witaj" }), + ).toBeNull(); + }); + + it("is null for an empty slug rather than pointing at the list page", () => { + expect( + contentDeliveryPath({ definition: articleType, slug: " " }), + ).toBeNull(); + }); + + it("percent-encodes a slug that was written straight into the database", () => { + expect( + contentDeliveryPath({ definition: articleType, slug: "a b/c" }), + ).toBe("/articles/a%20b%2Fc"); + }); +}); + +describe("contentDeliveryUrl", () => { + it("resolves a path against an origin, with or without a trailing slash", () => { + for (const origin of ["https://example.com", "https://example.com/"]) { + expect(contentDeliveryUrl({ origin, path: "/articles/x" })).toBe( + "https://example.com/articles/x", + ); + } + }); + + it("is null for a malformed origin rather than a URL with two schemes", () => { + expect(contentDeliveryUrl({ origin: "not a url", path: "/x" })).toBeNull(); + }); + + it("passes a null path straight through", () => { + expect( + contentDeliveryUrl({ origin: "https://example.com", path: null }), + ).toBeNull(); + }); +}); + +describe("parseContentDeliveryPath", () => { + it("round-trips the path it builds", () => { + expect( + parseContentDeliveryPath(articleType, "/articles/my-article"), + ).toStrictEqual({ locale: null, slug: "my-article" }); + + expect( + parseContentDeliveryPath(localizedType, "/pl/articles/moj-artykul"), + ).toStrictEqual({ locale: "pl", slug: "moj-artykul" }); + }); + + it("decodes the slug and normalizes the locale", () => { + expect( + parseContentDeliveryPath(localizedType, "/PL/articles/a%20b"), + ).toStrictEqual({ locale: "pl", slug: "a b" }); + }); + + it("strips a query string and a fragment", () => { + expect( + parseContentDeliveryPath(articleType, "/articles/x?utm=1#top"), + ).toStrictEqual({ locale: null, slug: "x" }); + }); + + it("refuses a path that belongs to another content type", () => { + expect(parseContentDeliveryPath(articleType, "/news/x")).toBeNull(); + }); + + it("refuses the wrong number of segments", () => { + for (const path of ["/articles", "/articles/a/b", "/pl/articles/a"]) { + expect(parseContentDeliveryPath(articleType, path)).toBeNull(); + } + }); + + it("refuses a traversal and a malformed escape", () => { + expect(parseContentDeliveryPath(articleType, "/articles/..")).toBeNull(); + expect(parseContentDeliveryPath(articleType, "/articles/%zz")).toBeNull(); + }); + + it("refuses a path longer than the stored column", () => { + expect( + parseContentDeliveryPath(articleType, `/articles/${"a".repeat(600)}`), + ).toBeNull(); + }); +}); + +describe("SEO projection", () => { + it("reads the configured fields off a public row", () => { + expect( + contentDeliverySeo(articleType, { + excerpt: "A summary.", + title: "My article", + }), + ).toStrictEqual({ description: "A summary.", title: "My article" }); + }); + + it("falls back only when the primary is empty", () => { + expect( + contentDeliverySeo(localizedType, { + seo: { description: null, title: " " }, + title: "The heading", + }), + ).toStrictEqual({ description: null, title: "The heading" }); + + expect( + contentDeliverySeo(localizedType, { + seo: { description: "d", title: "SEO heading" }, + title: "The heading", + }), + ).toStrictEqual({ description: "d", title: "SEO heading" }); + }); + + it("never invents a description from other content", () => { + expect( + contentDeliverySeo(articleType, { excerpt: null, title: "T" }), + ).toStrictEqual({ description: null, title: "T" }); + }); + + it("cannot read a field the public row does not carry", () => { + // The row is the public projection, so a private field is absent from the + // object entirely rather than merely skipped. + expect(contentDeliverySeo(articleType, { views: 9 })).toStrictEqual({ + description: null, + title: null, + }); + }); + + it("is a stable shape for a content type that configured nothing", () => { + expect(contentDeliverySeo(plainType, { title: "T" })).toStrictEqual({ + description: null, + title: null, + }); + }); +}); + +describe("Open Graph projection", () => { + it("is null when the content type configured none", () => { + expect(contentDeliveryOpenGraph(articleType, { title: "T" })).toBeNull(); + }); + + it("falls back to the ordinary SEO slots", () => { + const withOg = defineContentType({ + ...base, + id: "delivery.og", + delivery: { + enabled: true, + seo: { openGraph: {}, titleField: "title" }, + }, + fields, + publicApi, + tableName: "delivery_og", + }); + + expect( + contentDeliveryOpenGraph(withOg, { title: "Shared heading" }), + ).toStrictEqual({ description: null, title: "Shared heading" }); + }); +}); + +describe("robots projection", () => { + it("is null without a noIndexField", () => { + expect(contentDeliveryRobots(articleType, {})).toBeNull(); + }); + + it("reads the boolean and always allows following", () => { + const withNoIndex = defineContentType({ + ...base, + id: "delivery.noindex", + delivery: { enabled: true, seo: { noIndexField: "hidden" } }, + fields, + publicApi: { ...publicApi, fields: [...publicApi.fields, "hidden"] }, + tableName: "delivery_noindex", + }); + + expect(contentDeliveryRobots(withNoIndex, { hidden: true })).toStrictEqual({ + follow: true, + index: false, + }); + expect(contentDeliveryRobots(withNoIndex, { hidden: false })).toStrictEqual( + { + follow: true, + index: true, + }, + ); + }); +}); + +describe("contentDeliveryHreflang", () => { + const alternates = [ + { locale: "en", path: "/en/articles/my-article" }, + { locale: "pl", path: "/pl/articles/moj-artykul" }, + ]; + + it("maps alternates to a language map", () => { + expect( + contentDeliveryHreflang({ alternates, definition: localizedType }), + ).toStrictEqual({ + languages: { + en: "/en/articles/my-article", + pl: "/pl/articles/moj-artykul", + }, + xDefault: "/en/articles/my-article", + }); + }); + + it("omits x-default when the default locale is not published", () => { + expect( + contentDeliveryHreflang({ + alternates: [alternates[1]], + definition: localizedType, + }), + ).toStrictEqual({ languages: { pl: "/pl/articles/moj-artykul" } }); + }); + + it("emits no x-default when the content type did not ask for one", () => { + expect( + contentDeliveryHreflang({ alternates, definition: articleType }), + ).toStrictEqual({ + languages: { + en: "/en/articles/my-article", + pl: "/pl/articles/moj-artykul", + }, + }); + }); +}); + +describe("registry helpers", () => { + it("lists only delivery-enabled content types, in a stable order", () => { + const entries = [ + { definition: localizedType, pluginId: "b" }, + { definition: plainType, pluginId: "a" }, + { definition: articleType, pluginId: "a" }, + ]; + + expect( + listDeliveryContentTypes(entries).map(entry => entry.definition.id), + ).toStrictEqual(["delivery.article", "delivery.localized"]); + }); + + it("narrows a definition to a deliverable one", () => { + expect(isDeliverableContentType(articleType)).toBe(true); + expect(isDeliverableContentType(plainType)).toBe(false); + }); + + it("reports the sitemap defaults, or nothing", () => { + expect(contentSitemapDefaults(articleType)).toStrictEqual({ + changeFrequency: "weekly", + priority: 0.7, + }); + expect(contentSitemapDefaults(plainType)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-admin-route.test.ts b/packages/vitnode/src/content/server/delivery-admin-route.test.ts new file mode 100644 index 000000000..b47a659ed --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-admin-route.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment node +import { OpenAPIHono } from "@hono/zod-openapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + testDeliveredPostContentType, + testEditorialPostContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { buildContentRoutes } from "./routes"; + +let permissionGranted = true; +let requestedPermission: null | { module: string; permission: string } = null; + +// `assertStaffPermission` reads roles out of the database. What matters here is that +// the route *asks* for `can_view` and nothing narrower, so the check itself is +// replaced with a recorder plus a switchable verdict. +vi.mock("../../api/lib/check-staff-permission", () => ({ + assertStaffPermission: async ( + _c: unknown, + args: { module: string; permission: string }, + ) => { + requestedPermission = { module: args.module, permission: args.permission }; + if (!permissionGranted) { + const { HTTPException } = await import("hono/http-exception"); + throw new HTTPException(403, { message: "Forbidden" }); + } + }, +})); + +const delivered = createContentModel(testDeliveredPostContentType); +const editorialPosts = createContentModel(testEditorialPostContentType); + +const PLUGIN_ID = "@vitnode/example"; + +const harness = () => { + const service = { + alternates: vi.fn(), + findById: vi.fn(), + history: vi.fn().mockResolvedValue([]), + resolvePath: vi.fn(), + resolveSlug: vi.fn(), + sitemap: vi.fn(), + }; + + vi.spyOn(delivered, "deliveryService", "get").mockReturnValue(() => service); + + const app = new OpenAPIHono(); + for (const { handler, route } of buildContentRoutes(delivered, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, service }; +}; + +beforeEach(() => { + permissionGranted = true; + requestedPermission = null; +}); + +describe("route generation", () => { + it("adds the delivery route only for a delivery-enabled content type", () => { + const withDelivery = buildContentRoutes(delivered, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + const without = buildContentRoutes(editorialPosts, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + + expect(withDelivery).toContain("/{id}/delivery"); + expect(without).not.toContain("/{id}/delivery"); + }); +}); + +describe("admin delivery route", () => { + it("is gated by can_view rather than a permission of its own", async () => { + const { app } = harness(); + + await app.request("/42/delivery"); + + // Read-only, so the permission that allowed the slug mutation is the only one it + // needs. A `can_manage_redirects` would be a permission every install has to + // configure for no decision this screen can make. + expect(requestedPermission).toStrictEqual({ + module: testDeliveredPostContentType.permissionModule, + permission: "can_view", + }); + }); + + it("refuses a request without the permission", async () => { + permissionGranted = false; + const { app } = harness(); + + expect((await app.request("/42/delivery")).status).toBe(403); + }); + + it("reports the canonical URL and the historical ones", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue({ + canonicalPath: "/delivered-posts/current", + locale: null, + }); + service.history.mockResolvedValue([ + { + createdAt: new Date("2026-01-01T00:00:00.000Z"), + itemId: 42, + languageId: null, + path: "/delivered-posts/current", + retiredAt: null, + slug: "current", + }, + { + createdAt: new Date("2025-12-01T00:00:00.000Z"), + itemId: 42, + languageId: null, + path: "/delivered-posts/old", + retiredAt: new Date("2026-01-01T00:00:00.000Z"), + slug: "old", + }, + ]); + + const response = await app.request("/42/delivery"); + const body = (await response.json()) as { + canonicalPath: string; + history: Record<string, unknown>[]; + isPublic: boolean; + }; + + expect(response.status).toBe(200); + expect(body.canonicalPath).toBe("/delivered-posts/current"); + expect(body.isPublic).toBe(true); + expect(body.history).toHaveLength(2); + }); + + it("exposes no storage columns of the history table", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue({ + canonicalPath: "/delivered-posts/current", + locale: null, + }); + service.history.mockResolvedValue([ + { + createdAt: new Date(0), + itemId: 42, + languageId: 2, + path: "/delivered-posts/old", + retiredAt: new Date(0), + slug: "old", + }, + ]); + + const body = (await (await app.request("/42/delivery")).json()) as { + history: Record<string, unknown>[]; + }; + + // `languageId`, `pluginId` and the row id are details of + // `core_content_slug_history`, not part of this contract. + expect(Object.keys(body.history[0]).sort()).toStrictEqual([ + "createdAt", + "path", + "retiredAt", + "slug", + ]); + }); + + it("reports a draft as having no canonical URL rather than inventing one", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(null); + + const body = (await (await app.request("/42/delivery")).json()) as { + canonicalPath: null | string; + isPublic: boolean; + }; + + // "This is where it *would* live" is a different claim from "this is where it + // lives", and the panel must not make the first one look like the second. + expect(body.canonicalPath).toBeNull(); + expect(body.isPublic).toBe(false); + }); + + it("scopes the read to one language when asked", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue({ canonicalPath: null, locale: "pl" }); + + await app.request("/42/delivery?locale=pl"); + + expect(service.findById).toHaveBeenCalledWith(42, { locale: "pl" }); + expect(service.history).toHaveBeenCalledWith(42, { locale: "pl" }); + }); + + it("rejects an invalid identifier", async () => { + const { app } = harness(); + + expect((await app.request("/abc/delivery")).status).toBe(400); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-effects.test.ts b/packages/vitnode/src/content/server/delivery-effects.test.ts new file mode 100644 index 000000000..099477e8c --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-effects.test.ts @@ -0,0 +1,225 @@ +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { ContentDeliveryOutcome } from "./delivery-writes"; + +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { + contentDeliveryEffects, + contentDeliveryInvalidation, +} from "./delivery-effects"; + +/** + * Which delivery events one mutation emits, and which it deliberately does not. + * + * Both events are gated on a *fact* rather than on an operation: the URL moved, and + * the old address had been live. A listener that warms a CDN or writes an edge + * redirect table acts on the second one, so emitting it for a corrected draft would + * make it act on a URL nobody ever visited. + */ + +const articleType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "effects.article", + delivery: { enabled: true, redirects: { enabled: true } }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "effects_articles", +}); + +const plainType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "effects.plain", + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "effects_plain", +}); + +const outcome = ( + overrides: Partial<ContentDeliveryOutcome> = {}, +): ContentDeliveryOutcome => ({ + canonicalPath: "/articles/new", + itemId: 42, + locale: null, + previousPath: "/articles/old", + previousSlug: "old", + redirectCreated: true, + sitemapChanged: true, + slug: "new", + slugChanged: true, + ...overrides, +}); + +const buildContext = () => { + const emitted: { name: string; payload: Record<string, unknown> }[] = []; + + const c = { + get: (key: string) => { + if (key === "events") { + return { + emit: async (name: string, payload: Record<string, unknown>) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ failures: [] }); + }, + }; + } + + return undefined; + }, + } as unknown as Context; + + return { c, emitted }; +}; + +describe("contentDeliveryEffects", () => { + it("emits both events when a live URL moves", async () => { + const { c, emitted } = buildContext(); + + const result = await contentDeliveryEffects(c, articleType, outcome(), { + pluginId: "@vitnode/test", + }); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.effects.article.delivery_slug_changed", + "content.effects.article.delivery_redirect_created", + ]); + expect(emitted[0].payload).toStrictEqual({ + canonicalPath: "/articles/new", + contentId: 42, + locale: null, + previousPath: "/articles/old", + previousSlug: "old", + slug: "new", + }); + expect(emitted[1].payload).toStrictEqual({ + canonicalPath: "/articles/new", + contentId: 42, + locale: null, + previousPath: "/articles/old", + previousSlug: "old", + }); + expect(result.events).toHaveLength(2); + }); + + it("emits only the slug event when the old address was never live", async () => { + const { c, emitted } = buildContext(); + + await contentDeliveryEffects( + c, + articleType, + outcome({ redirectCreated: false }), + { pluginId: "@vitnode/test" }, + ); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.effects.article.delivery_slug_changed", + ]); + }); + + it("emits nothing when no URL moved", async () => { + const { c, emitted } = buildContext(); + + await contentDeliveryEffects( + c, + articleType, + outcome({ + previousPath: null, + previousSlug: null, + redirectCreated: false, + slugChanged: false, + }), + { pluginId: "@vitnode/test" }, + ); + + expect(emitted).toStrictEqual([]); + }); + + it("emits nothing for a mutation that reported no delivery outcome", async () => { + const { c, emitted } = buildContext(); + + await contentDeliveryEffects(c, articleType, undefined, { + pluginId: "@vitnode/test", + }); + + expect(emitted).toStrictEqual([]); + }); + + it("emits nothing when the canonical path cannot be built", async () => { + const { c, emitted } = buildContext(); + + // A slug written straight into the database, or a localized content type with a + // shared slug: no single canonical path, so no delivery fact to announce. + await contentDeliveryEffects( + c, + articleType, + outcome({ canonicalPath: null }), + { pluginId: "@vitnode/test" }, + ); + + expect(emitted).toStrictEqual([]); + }); + + it("carries the locale on a localized move", async () => { + const { c, emitted } = buildContext(); + + await contentDeliveryEffects( + c, + articleType, + outcome({ + canonicalPath: "/pl/articles/nowy", + locale: "pl", + previousPath: "/pl/articles/stary", + previousSlug: "stary", + slug: "nowy", + }), + { pluginId: "@vitnode/test" }, + ); + + expect(emitted[0].payload).toMatchObject({ locale: "pl" }); + }); +}); + +describe("contentDeliveryInvalidation", () => { + it("is undefined for a content type without delivery", () => { + expect(contentDeliveryInvalidation(plainType, outcome())).toBeUndefined(); + }); + + it("reports the sitemap only when the set of listed URLs changed", () => { + expect(contentDeliveryInvalidation(articleType, outcome())).toStrictEqual({ + sitemap: true, + }); + expect( + contentDeliveryInvalidation( + articleType, + outcome({ sitemapChanged: false }), + ), + ).toStrictEqual({ sitemap: false }); + }); + + it("still expires the delivery metadata when no URL moved", () => { + // A shared SEO field moving changes what every locale's `<head>` renders even + // though nothing was added to or removed from the sitemap. + expect(contentDeliveryInvalidation(articleType, undefined)).toStrictEqual({ + sitemap: false, + }); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-routes.test.ts b/packages/vitnode/src/content/server/delivery-routes.test.ts new file mode 100644 index 000000000..a49ba15c3 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-routes.test.ts @@ -0,0 +1,261 @@ +// @vitest-environment node +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import { + testDeliveredPostContentType, + testPostContentType, +} from "@/tests/content-fixtures"; + +import { createContentModel } from "./model"; +import { buildContentPublicRoutes } from "./public-routes"; + +/** + * The generated public delivery routes. + * + * Two things are being asserted, and only one of them is about delivery: + * + * 1. The routes answer without any session at all, and their bodies match the + * schemas the OpenAPI document publishes - including the discriminated union, + * whose whole purpose is that a client can branch on `type` rather than guess. + * 2. A content type **without** `delivery` gains no routes whatsoever. That is the + * Stage 1-7 regression assertion at the routing layer: the path list of an + * existing public content type does not move. + */ + +const delivered = createContentModel(testDeliveredPostContentType); + +const PLUGIN_ID = "@vitnode/example"; + +const metadata = { + alternates: [], + canonicalPath: "/delivered-posts/hello-world", + hreflang: { languages: {} }, + isFallback: false, + itemId: 42, + locale: null, + openGraph: { description: "Prose", title: "Hello world" }, + requestedLocale: null, + robots: { follow: true, index: true }, + seo: { description: "Prose", title: "Hello world" }, +}; + +const harness = () => { + const service = { + alternates: vi.fn(), + findById: vi.fn(), + history: vi.fn(), + resolvePath: vi.fn(), + resolveSlug: vi.fn(), + sitemap: vi.fn(), + }; + + vi.spyOn(delivered, "deliveryService", "get").mockReturnValue(() => service); + // The public service is never reached by a delivery route - the delivery service + // is - but the route builder still asks the model for it. + vi.spyOn(delivered, "publicService", "get").mockReturnValue(() => ({ + findById: vi.fn(), + findBySlug: vi.fn(), + findMany: vi.fn(), + })); + + const app = new OpenAPIHono(); + for (const { handler, route } of buildContentPublicRoutes(delivered, { + pluginId: PLUGIN_ID, + })) { + app.openapi(route, handler); + } + + return { app, service }; +}; + +describe("route generation", () => { + it("adds three delivery routes under a static `delivery` segment", () => { + const paths = buildContentPublicRoutes(delivered, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + + expect(paths).toContain("/delivery/resolve/{slug}"); + expect(paths).toContain("/delivery/item/{id}"); + expect(paths).toContain("/delivery/sitemap"); + }); + + it("adds none at all to a content type without delivery", () => { + const posts = createContentModel(testPostContentType, { + references: { category: () => delivered.table.id }, + }); + + const paths = buildContentPublicRoutes(posts, { pluginId: PLUGIN_ID }).map( + entry => entry.route.path, + ); + + // The Stage 1-7 path list, unchanged. + expect(paths).toStrictEqual(["/", "/{slug}"]); + }); + + it("cannot be shadowed by a record whose slug is `delivery`", () => { + // `/{slug}` is one segment and every delivery path is two or three, so the two + // can never both match whatever order they are registered in. + const paths = buildContentPublicRoutes(delivered, { + pluginId: PLUGIN_ID, + }).map(entry => entry.route.path); + + expect(paths.filter(path => path === "/{slug}")).toHaveLength(1); + expect(paths.every(path => path.split("/").length <= 4)).toBe(true); + }); +}); + +describe("resolve route", () => { + it("answers without any session at all", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ ...metadata, type: "content" }); + + const response = await app.request("/delivery/resolve/hello-world"); + + expect(response.status).toBe(200); + }); + + it("returns the canonical arm for a current slug", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ ...metadata, type: "content" }); + + const response = await app.request("/delivery/resolve/hello-world"); + + expect(await response.json()).toMatchObject({ + canonicalPath: "/delivered-posts/hello-world", + itemId: 42, + type: "content", + }); + }); + + it("returns the redirect arm with its status in the body", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ + location: "/delivered-posts/new", + status: 308, + type: "redirect", + }); + + const response = await app.request("/delivery/resolve/old"); + + // A 200 carrying a redirect, not an HTTP redirect: the *frontend* issues the + // 308, because it owns the URL the reader is on. + expect(response.status).toBe(200); + expect(await response.json()).toStrictEqual({ + location: "/delivered-posts/new", + status: 308, + type: "redirect", + }); + }); + + it("returns the not_found arm as a 200 with a body", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ type: "not_found" }); + + const response = await app.request("/delivery/resolve/nope"); + + // A 200, so a caller distinguishes "this URL resolves to nothing" from "the + // delivery API is unreachable" - and so a negative is not cached as a 404. + expect(response.status).toBe(200); + expect(await response.json()).toStrictEqual({ type: "not_found" }); + }); + + it("exposes no internal storage fields", async () => { + const { app, service } = harness(); + service.resolveSlug.mockResolvedValue({ ...metadata, type: "content" }); + + const body = (await ( + await app.request("/delivery/resolve/hello-world") + ).json()) as Record<string, unknown>; + + for (const internal of ["languageId", "pluginId", "retiredAt"]) { + expect(body).not.toHaveProperty(internal); + } + }); +}); + +describe("item route", () => { + it("returns the delivery metadata of one record", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(metadata); + + const response = await app.request("/delivery/item/42"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + canonicalPath: "/delivered-posts/hello-world", + seo: { description: "Prose", title: "Hello world" }, + }); + expect(service.findById).toHaveBeenCalledWith(42, { locale: undefined }); + }); + + it("is a 404 for a record with no public version", async () => { + const { app, service } = harness(); + service.findById.mockResolvedValue(null); + + expect((await app.request("/delivery/item/42")).status).toBe(404); + }); + + it("rejects a non-numeric identifier at validation time", async () => { + const { app } = harness(); + + expect((await app.request("/delivery/item/abc")).status).toBe(400); + }); +}); + +describe("sitemap route", () => { + it("serializes lastModified as an ISO string, matching its schema", async () => { + const { app, service } = harness(); + service.sitemap.mockResolvedValue({ + entries: [ + { + changeFrequency: "weekly", + itemId: 42, + lastModified: new Date("2026-01-02T03:04:05.000Z"), + locale: null, + path: "/delivered-posts/hello-world", + priority: 0.7, + }, + ], + nextCursor: null, + }); + + const response = await app.request("/delivery/sitemap"); + + expect(response.status).toBe(200); + expect(await response.json()).toStrictEqual({ + entries: [ + { + changeFrequency: "weekly", + itemId: 42, + lastModified: "2026-01-02T03:04:05.000Z", + locale: null, + path: "/delivered-posts/hello-world", + priority: 0.7, + }, + ], + nextCursor: null, + }); + }); + + it("passes the cursor and limit through", async () => { + const { app, service } = harness(); + service.sitemap.mockResolvedValue({ entries: [], nextCursor: null }); + + await app.request("/delivery/sitemap?cursor=99&limit=10"); + + expect(service.sitemap).toHaveBeenCalledWith({ + cursor: 99, + limit: 10, + locale: undefined, + }); + }); + + it("rejects a limit above the protocol ceiling", async () => { + const { app } = harness(); + + expect((await app.request("/delivery/sitemap?limit=50001")).status).toBe( + 400, + ); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-service.test.ts b/packages/vitnode/src/content/server/delivery-service.test.ts new file mode 100644 index 000000000..0d7835ca8 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-service.test.ts @@ -0,0 +1,658 @@ +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; + +import { core_content_slug_history } from "../../database/content"; +import { core_languages } from "../../database/languages"; +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { createContentDeliveryService } from "./delivery-service"; + +/** + * The delivery resolver, against the real service, without a database. + * + * The two reads it performs - the public projection and the slug-history lookup - + * are stubbed, and nothing else is: `createContentDeliveryService` is the code under + * test, so the decision it makes (canonical, redirect, or nothing) is the thing + * being asserted rather than a copy of it. That decision is where a mistake becomes + * a permanent 308 to the wrong page, which is exactly why it is worth testing + * without the ceremony of a database. + * + * The queries themselves are covered by the Postgres suite in `plugins/example`. + */ + +const PLUGIN = "@vitnode/test"; + +const articleType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "delivery.article", + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { enabled: true, priority: 0.7 }, + }, + fields: { + excerpt: field.textarea({ nullable: true }), + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "excerpt"], + path: "articles", + }, + tableName: "delivery_articles", +}); + +const withoutRedirects = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "delivery.no-redirects", + delivery: { enabled: true, sitemap: { enabled: true } }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "delivery_no_redirects", +}); + +const localizedType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "delivery.localized", + delivery: { + enabled: true, + hreflang: { xDefault: "defaultLocale" }, + redirects: { enabled: true }, + seo: { fallbackTitleField: "title", titleField: "seo.title" }, + sitemap: { enabled: true }, + }, + fields: { + seo: field.group({ + fields: { title: field.text({ nullable: true }) }, + localized: true, + nullable: true, + }), + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true, fallback: "default" }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "seo.title"], + path: "articles", + }, + tableName: "delivery_localized_articles", +}); + +/** One retired or current address, as `core_content_slug_history` stores it. */ +interface HistoryRow { + itemId: number; + languageId: null | number; + path: string; + retiredAt: Date | null; + slug: string; +} + +/** One public row, and the language it is in. */ +interface PublicRow { + locale?: string; + values: Record<string, unknown>; +} + +type QueryRows = Record<string, unknown>[]; + +/** + * A Drizzle query builder that resolves to whatever the table asks for. + * + * A thenable rather than a promise-returning `limit()`, because the two reads this + * file needs end differently: the language registry awaits straight off `.from()` + * and the history lookup chains `.where().limit(1)` (and sometimes `.for("update")`). + * One thenable satisfies both without the stub having to know which. + */ +const buildDatabase = (rowsFor: (table: unknown) => QueryRows): unknown => { + const select = () => { + let table: unknown; + + const builder = { + for: () => builder, + from: (value: unknown) => { + table = value; + + return builder; + }, + limit: () => builder, + orderBy: () => builder, + then: async ( + resolve: (rows: QueryRows) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(rowsFor(table)).then(resolve, reject), + where: () => builder, + }; + + return builder; + }; + + return { select }; +}; + +/** + * A model whose public service is a map and whose history table is an array. + * + * `findById` mimics the Stage 5 fallback rule rather than re-deriving it: a locale + * with no row of its own is served the default one, and the row says which language + * it is actually in. That is the contract `createContentLocalizedPublicService` + * holds, and reading through it is the whole reason delivery inherits the + * publication predicate and the field allowlist for free. + */ +const buildService = ({ + byId = {}, + bySlug = {}, + definition, + history = [], + languages = [ + { code: "en", id: 1 }, + { code: "pl", id: 2 }, + ], +}: { + byId?: Record<number, PublicRow[]>; + bySlug?: Record<string, { itemId: number; locale?: string }>; + definition: AnyContentTypeDefinition; + history?: HistoryRow[]; + languages?: { code: string; id: number }[]; +}) => { + const localized = definition.localization.enabled; + const defaultLocale = definition.localization.defaultLocale; + + const rowFor = ( + itemId: number, + locale: string | undefined, + ): null | Record<string, unknown> => { + const rows = byId[itemId] ?? []; + if (!localized) return rows[0]?.values ?? null; + + const wanted = (locale ?? defaultLocale).toLowerCase(); + const exact = rows.find(entry => entry.locale === wanted); + if (exact) return { ...exact.values, locale: exact.locale }; + + if (definition.localization.fallback !== "default") return null; + + const fallback = rows.find(entry => entry.locale === defaultLocale); + + return fallback ? { ...fallback.values, locale: fallback.locale } : null; + }; + + const publicService = { + findById: async (id: number, options?: { locale?: string }) => + await Promise.resolve(rowFor(id, options?.locale)), + findBySlug: async (slug: string, options?: { locale?: string }) => { + const hit = bySlug[slug]; + if (!hit) return await Promise.resolve(null); + // Strict-locale, exactly as the real service is: a URL belongs to the + // language it was published under. + if ( + localized && + hit.locale !== (options?.locale ?? defaultLocale).toLowerCase() + ) { + return await Promise.resolve(null); + } + + return await Promise.resolve(rowFor(hit.itemId, hit.locale)); + }, + findMany: async () => + await Promise.resolve({ edges: [], pageInfo: {} as never }), + }; + + const database = buildDatabase(table => { + if (table === core_languages) { + return languages.map(language => ({ + code: language.code, + id: language.id, + isDefault: language.code === defaultLocale, + })); + } + + if (table === core_content_slug_history) { + // The resolver asks for one address at a time, so the stub returns the whole + // set and relies on the service having narrowed it - which it cannot here. + // Each test therefore supplies at most one row. + return history.map(row => ({ createdAt: new Date(0), ...row })); + } + + return []; + }); + + const c = { + get: (key: string) => { + if (key === "db") return database; + if (key === "core") return { i18n: { locales: [] } }; + + return undefined; + }, + } as unknown as Context; + + const model = { + columns: {}, + definition, + publicService: () => publicService, + table: {}, + translationColumns: null, + translationTable: null, + } as unknown as ContentModel<AnyContentTypeDefinition>; + + return createContentDeliveryService({ c, model, pluginId: PLUGIN }); +}; + +const article = (slug: string, id = 42): PublicRow => ({ + values: { excerpt: null, id, slug, title: "T" }, +}); + +const translation = (locale: string, slug: string, id = 7): PublicRow => ({ + locale, + values: { id, seo: { title: null }, slug, title: "T" }, +}); + +describe("createContentDeliveryService", () => { + it("refuses a content type with no delivery block", () => { + const plain = defineContentType({ + admin: { label: { plural: "P", singular: "P" } }, + id: "delivery.none", + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["title", "slug"], path: "p" }, + tableName: "delivery_none", + }); + + expect(() => buildService({ definition: plain })).toThrow( + /no `delivery` block/, + ); + }); +}); + +describe("resolveSlug", () => { + it("answers the current slug as canonical content", async () => { + const service = buildService({ + byId: { 42: [article("current")] }, + bySlug: { current: { itemId: 42 } }, + definition: articleType, + }); + + expect(await service.resolveSlug("current")).toMatchObject({ + canonicalPath: "/articles/current", + itemId: 42, + type: "content", + }); + }); + + it("redirects a retired slug to the current canonical path", async () => { + const service = buildService({ + byId: { 42: [article("current")] }, + bySlug: { current: { itemId: 42 } }, + definition: articleType, + history: [ + { + itemId: 42, + languageId: null, + path: "/articles/old", + retiredAt: new Date(), + slug: "old", + }, + ], + }); + + expect(await service.resolveSlug("old")).toStrictEqual({ + location: "/articles/current", + status: 308, + type: "redirect", + }); + }); + + it("collapses a chain: both a and b resolve straight to c", async () => { + for (const retired of ["a", "b"]) { + const service = buildService({ + byId: { 42: [article("c")] }, + bySlug: { c: { itemId: 42 } }, + definition: articleType, + history: [ + { + itemId: 42, + languageId: null, + path: `/articles/${retired}`, + retiredAt: new Date(), + slug: retired, + }, + ], + }); + + // One hop, not two: the resolver reads the record's *current* slug rather + // than the next entry in the chain. + expect(await service.resolveSlug(retired)).toStrictEqual({ + location: "/articles/c", + status: 308, + type: "redirect", + }); + } + }); + + it("is not_found when the destination is no longer public", async () => { + const service = buildService({ + // No public row: an unpublished or deleted record looks like this from here. + byId: {}, + bySlug: {}, + definition: articleType, + history: [ + { + itemId: 42, + languageId: null, + path: "/articles/old", + retiredAt: new Date(), + slug: "old", + }, + ], + }); + + expect(await service.resolveSlug("old")).toStrictEqual({ + type: "not_found", + }); + }); + + it("is not_found for a slug nothing has ever used", async () => { + const service = buildService({ definition: articleType }); + + expect(await service.resolveSlug("never-existed")).toStrictEqual({ + type: "not_found", + }); + }); + + it("never redirects a slug to itself", async () => { + const service = buildService({ + byId: { 42: [article("same")] }, + // The live lookup misses - a stale reservation - and the destination equals + // the address asked for. A redirect loop is worse than a 404. + bySlug: {}, + definition: articleType, + history: [ + { + itemId: 42, + languageId: null, + path: "/articles/same", + retiredAt: null, + slug: "same", + }, + ], + }); + + expect(await service.resolveSlug("same")).toStrictEqual({ + type: "not_found", + }); + }); + + it("never reads the history without redirects", async () => { + const service = buildService({ + byId: { 42: [{ values: { id: 42, slug: "current", title: "T" } }] }, + bySlug: {}, + definition: withoutRedirects, + history: [ + { + itemId: 42, + languageId: null, + path: "/articles/old", + retiredAt: new Date(), + slug: "old", + }, + ], + }); + + expect(await service.resolveSlug("old")).toStrictEqual({ + type: "not_found", + }); + }); +}); + +describe("localized resolveSlug", () => { + it("keeps a locale's redirect inside its own language", async () => { + const service = buildService({ + byId: { 7: [translation("en", "hello-world")] }, + bySlug: {}, + definition: localizedType, + history: [ + { + itemId: 7, + languageId: 1, + path: "/en/articles/hello", + retiredAt: new Date(), + slug: "hello", + }, + ], + }); + + expect(await service.resolveSlug("hello", { locale: "en" })).toStrictEqual({ + location: "/en/articles/hello-world", + status: 308, + type: "redirect", + }); + }); + + it("refuses to point one locale's URL at another language's page", async () => { + const service = buildService({ + // Published in English only. A Polish historical URL must not 308 to the + // English page: that is the wrong language under a URL that says otherwise, + // declared permanent. + byId: { 7: [translation("en", "hello")] }, + bySlug: {}, + definition: localizedType, + history: [ + { + itemId: 7, + languageId: 2, + path: "/pl/articles/witaj", + retiredAt: new Date(), + slug: "witaj", + }, + ], + }); + + expect(await service.resolveSlug("witaj", { locale: "pl" })).toStrictEqual({ + type: "not_found", + }); + }); + + it("resolves a slug strictly, never through the fallback", async () => { + const service = buildService({ + byId: { 7: [translation("en", "hello")] }, + bySlug: { hello: { itemId: 7, locale: "en" } }, + definition: localizedType, + }); + + // `/pl/articles/hello` is not the English article, even though the content type + // falls back to English for a *read*. + expect(await service.resolveSlug("hello", { locale: "pl" })).toStrictEqual({ + type: "not_found", + }); + expect(await service.resolveSlug("hello", { locale: "en" })).toMatchObject({ + canonicalPath: "/en/articles/hello", + type: "content", + }); + }); +}); + +describe("findById", () => { + it("reports the served locale, not the requested one, on a fallback", async () => { + const service = buildService({ + byId: { 7: [translation("en", "hello")] }, + definition: localizedType, + }); + + const metadata = await service.findById(7, { locale: "pl" }); + + // `/pl/articles/hello` would be a self-declared canonical that answers 404. + expect(metadata).toMatchObject({ + canonicalPath: "/en/articles/hello", + isFallback: true, + locale: "en", + requestedLocale: "pl", + }); + }); + + it("is not a fallback when the locale differs only in casing", async () => { + const service = buildService({ + byId: { 7: [translation("en", "hello")] }, + definition: localizedType, + }); + + expect(await service.findById(7, { locale: "EN" })).toMatchObject({ + isFallback: false, + locale: "en", + }); + }); + + it("projects the SEO fallback field when the primary is empty", async () => { + const service = buildService({ + byId: { + 7: [ + { + locale: "en", + values: { + id: 7, + seo: { title: null }, + slug: "hello", + title: "The heading", + }, + }, + ], + }, + definition: localizedType, + }); + + expect((await service.findById(7, { locale: "en" }))?.seo).toStrictEqual({ + description: null, + title: "The heading", + }); + }); + + it("is null for a record with no public version", async () => { + const service = buildService({ definition: articleType }); + + expect(await service.findById(99)).toBeNull(); + }); + + it("adds an absolute URL only when an origin is supplied", async () => { + const service = buildService({ + byId: { 42: [article("hello")] }, + definition: articleType, + }); + + expect(await service.findById(42)).not.toHaveProperty("canonicalUrl"); + expect( + await service.findById(42, { origin: "https://example.com" }), + ).toMatchObject({ canonicalUrl: "https://example.com/articles/hello" }); + }); + + it("carries no alternates for a nonlocalized content type", async () => { + const service = buildService({ + byId: { 42: [article("hello")] }, + definition: articleType, + }); + + expect(await service.findById(42)).toMatchObject({ + alternates: [], + hreflang: { languages: {} }, + }); + }); +}); + +describe("resolvePath", () => { + it("refuses a path that belongs to another content type", async () => { + const service = buildService({ definition: articleType }); + + expect(await service.resolvePath("/news/hello")).toStrictEqual({ + type: "not_found", + }); + }); + + it("resolves a canonical path through the public read", async () => { + const service = buildService({ + byId: { 42: [article("hello")] }, + bySlug: { hello: { itemId: 42 } }, + definition: articleType, + }); + + expect(await service.resolvePath("/articles/hello")).toMatchObject({ + canonicalPath: "/articles/hello", + type: "content", + }); + }); + + it("splits the locale out of a localized path", async () => { + const service = buildService({ + byId: { 7: [translation("pl", "witaj")] }, + bySlug: { witaj: { itemId: 7, locale: "pl" } }, + definition: localizedType, + }); + + expect(await service.resolvePath("/pl/articles/witaj")).toMatchObject({ + canonicalPath: "/pl/articles/witaj", + locale: "pl", + type: "content", + }); + }); + + it("redirects a retired localized path", async () => { + const service = buildService({ + byId: { 7: [translation("pl", "nowy-slug")] }, + bySlug: {}, + definition: localizedType, + history: [ + { + itemId: 7, + languageId: 2, + path: "/pl/articles/stary-slug", + retiredAt: new Date(), + slug: "stary-slug", + }, + ], + }); + + expect(await service.resolvePath("/pl/articles/stary-slug")).toStrictEqual({ + location: "/pl/articles/nowy-slug", + status: 308, + type: "redirect", + }); + }); +}); + +describe("sitemap", () => { + it("is an empty page for a content type that lists nothing", async () => { + const noSitemap = defineContentType({ + admin: { label: { plural: "A", singular: "A" } }, + id: "delivery.no-sitemap", + delivery: { enabled: true }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["id", "title", "slug"], path: "a" }, + tableName: "delivery_no_sitemap", + }); + + // An empty page rather than a throw: a site-level index enumerates every + // delivery-enabled content type, and one of them opting out is a choice. + expect( + await buildService({ definition: noSitemap }).sitemap(), + ).toStrictEqual({ entries: [], nextCursor: null }); + }); +}); diff --git a/packages/vitnode/src/content/server/delivery-writes.test.ts b/packages/vitnode/src/content/server/delivery-writes.test.ts new file mode 100644 index 000000000..b0c042f58 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-writes.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it } from "vitest"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; +import type { + ContentSlugHistoryModel, + ContentSlugHistoryTarget, +} from "./slug-history-model"; + +import { defineContentType } from "../define"; +import { ContentDeliverySlugReserved } from "../errors"; +import { field } from "../fields"; +import { applyContentDeliveryWrite } from "./delivery-writes"; + +/** + * When slug history is written, and when it deliberately is not. + * + * The rule this file exists to pin down is the one in §10 of the Stage 8 brief: a + * slug becomes redirectable only if it was **previously used by an addressable + * public version**. That is what separates "a live URL moved and needs a redirect" + * from "somebody fixed a typo in a draft three times before publishing" - and + * getting it wrong means either a pile of redirects nobody asked for, or a moved + * page that 404s. + */ + +const articleType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "writes.article", + delivery: { enabled: true, redirects: { enabled: true } }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "writes_articles", +}); + +const localizedType = defineContentType({ + admin: { label: { plural: "Articles", singular: "Article" } }, + id: "writes.localized", + delivery: { enabled: true, redirects: { enabled: true } }, + fields: { + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publication: { enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "writes_localized", +}); + +interface Call { + args: ContentSlugHistoryTarget | Omit<ContentSlugHistoryTarget, "locale">; + kind: "assertAvailable" | "reserve" | "retire"; +} + +/** + * A history model that records what it was asked to do. + * + * `retired` is the interesting knob: it is the answer to "was that URL ever live", + * and the whole redirect decision hangs off it. + */ +const recorder = ({ + reserved = null, + retired = true, +}: { reserved?: null | string; retired?: boolean } = {}) => { + const calls: Call[] = []; + + const model: ContentSlugHistoryModel = { + assertAvailable: async (_tx, args) => { + calls.push({ args, kind: "assertAvailable" }); + if (reserved !== null && args.slug === reserved) { + throw new ContentDeliverySlugReserved({ + contentTypeId: "writes.article", + locale: args.locale, + slug: args.slug, + }); + } + + return await Promise.resolve(); + }, + list: async () => await Promise.resolve([]), + owner: async () => await Promise.resolve(null), + reserve: async (_tx, args) => { + calls.push({ args, kind: "reserve" }); + if (reserved !== null && args.slug === reserved) { + throw new ContentDeliverySlugReserved({ + contentTypeId: "writes.article", + locale: args.locale, + slug: args.slug, + }); + } + + return await Promise.resolve({ created: true }); + }, + retire: async (_tx, args) => { + calls.push({ args, kind: "retire" }); + + return await Promise.resolve({ retired }); + }, + }; + + return { calls, model }; +}; + +const tx = {} as ContentDatabase; + +const apply = async ( + definition: AnyContentTypeDefinition, + transition: Parameters<typeof applyContentDeliveryWrite>[0]["transition"], + options?: { reserved?: null | string; retired?: boolean }, +) => { + const { calls, model } = recorder(options); + const outcome = await applyContentDeliveryWrite({ + definition, + slugHistory: model, + transition, + tx, + }); + + return { calls, outcome }; +}; + +describe("a draft", () => { + it("checks its slug but reserves nothing", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: null, + slug: "hello", + wasPublic: false, + }); + + // Checked, because "that address belongs to an article that moved" is far + // better heard at save time. Not reserved, because a draft has no public URL + // and claiming one would refuse a live address to somebody who wants it. + expect(calls.map(call => call.kind)).toStrictEqual(["assertAvailable"]); + expect(outcome).toMatchObject({ + canonicalPath: "/articles/hello", + redirectCreated: false, + sitemapChanged: false, + slugChanged: false, + }); + }); + + it("creates no redirect when its slug is corrected before publication", async () => { + const { calls, outcome } = await apply( + articleType, + { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "typo", + slug: "fixed", + wasPublic: false, + }, + // Nothing to retire: the old slug was never publicly addressable, so no row + // exists for it. + { retired: false }, + ); + + expect(calls.map(call => call.kind)).toStrictEqual([ + "retire", + "assertAvailable", + ]); + expect(outcome).toMatchObject({ + previousPath: "/articles/typo", + redirectCreated: false, + slugChanged: true, + }); + }); +}); + +describe("publishing", () => { + it("reserves the current address", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "hello", + wasPublic: false, + }); + + expect(calls).toStrictEqual([ + { + args: { + itemId: 1, + languageId: null, + locale: null, + path: "/articles/hello", + slug: "hello", + }, + kind: "reserve", + }, + ]); + // A publish adds a sitemap line even though no URL moved. + expect(outcome).toMatchObject({ sitemapChanged: true, slugChanged: false }); + }); + + it("refuses an address another record's history owns", async () => { + await expect( + apply( + articleType, + { + isPublic: true, + itemId: 2, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "hello", + wasPublic: false, + }, + { reserved: "hello" }, + ), + ).rejects.toThrow(ContentDeliverySlugReserved); + }); +}); + +describe("moving a published URL", () => { + it("retires the old address and reserves the new one, in that order", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "old", + slug: "new", + wasPublic: true, + }); + + // Retire first: a move from `a` to `b` and back to `a` would otherwise hit its + // own live reservation. + expect(calls.map(call => call.kind)).toStrictEqual(["retire", "reserve"]); + expect(outcome).toMatchObject({ + canonicalPath: "/articles/new", + previousPath: "/articles/old", + previousSlug: "old", + redirectCreated: true, + sitemapChanged: true, + slugChanged: true, + }); + }); + + it("reports no redirect when the old slug had never been live", async () => { + const { outcome } = await apply( + articleType, + { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "old", + slug: "new", + wasPublic: true, + }, + { retired: false }, + ); + + expect(outcome).toMatchObject({ + redirectCreated: false, + slugChanged: true, + }); + }); +}); + +describe("unpublishing and deleting", () => { + it("writes nothing on an unpublish, and keeps the history", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: "hello", + wasPublic: true, + }); + + // No retire (the slug did not move) and no reserve (it is not public). The + // resolver stops redirecting because it reads the live publication state. + expect(calls).toStrictEqual([]); + expect(outcome).toMatchObject({ sitemapChanged: true, slugChanged: false }); + }); + + it("writes nothing on a delete, and reports the lost sitemap line", async () => { + const { calls, outcome } = await apply(articleType, { + isPublic: false, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "hello", + slug: null, + wasPublic: true, + }); + + expect(calls).toStrictEqual([]); + expect(outcome).toMatchObject({ + canonicalPath: null, + sitemapChanged: true, + slug: null, + slugChanged: false, + }); + }); +}); + +describe("a localized slug", () => { + it("carries the language on every write, so histories stay isolated", async () => { + const { calls } = await apply(localizedType, { + isPublic: true, + itemId: 7, + languageId: 2, + locale: "pl", + previousSlug: "stary", + slug: "nowy", + wasPublic: true, + }); + + expect(calls).toStrictEqual([ + { args: { itemId: 7, languageId: 2, slug: "stary" }, kind: "retire" }, + { + args: { + itemId: 7, + languageId: 2, + locale: "pl", + path: "/pl/articles/nowy", + slug: "nowy", + }, + kind: "reserve", + }, + ]); + }); + + it("builds locale-prefixed paths on both sides of the move", async () => { + const { outcome } = await apply(localizedType, { + isPublic: true, + itemId: 7, + languageId: 2, + locale: "pl", + previousSlug: "stary", + slug: "nowy", + wasPublic: true, + }); + + expect(outcome).toMatchObject({ + canonicalPath: "/pl/articles/nowy", + locale: "pl", + previousPath: "/pl/articles/stary", + }); + }); +}); + +describe("delivery without redirects", () => { + it("reports the paths and writes no history at all", async () => { + const withoutRedirects = defineContentType({ + admin: { label: { plural: "A", singular: "A" } }, + id: "writes.no-redirects", + delivery: { enabled: true, sitemap: { enabled: true } }, + fields: { + slug: field.slug({ source: "title" }), + title: field.text({ required: true }), + }, + publication: { enabled: true }, + publicApi: { enabled: true, fields: ["id", "title", "slug"], path: "a" }, + tableName: "writes_no_redirects", + }); + + const outcome = await applyContentDeliveryWrite({ + definition: withoutRedirects, + // `null` is how the caller says "this content type keeps no history". + slugHistory: null, + transition: { + isPublic: true, + itemId: 1, + languageId: null, + locale: null, + previousSlug: "old", + slug: "new", + wasPublic: true, + }, + tx, + }); + + expect(outcome).toMatchObject({ + canonicalPath: "/a/new", + previousPath: "/a/old", + // The URL moved and the sitemap changed - the engine simply cannot redirect + // the old address, because nothing recorded it. + redirectCreated: false, + sitemapChanged: true, + slugChanged: true, + }); + }); +}); diff --git a/packages/vitnode/src/content/server/localized-preview-routes.test.ts b/packages/vitnode/src/content/server/localized-preview-routes.test.ts index f8147a0c7..958d88d3e 100644 --- a/packages/vitnode/src/content/server/localized-preview-routes.test.ts +++ b/packages/vitnode/src/content/server/localized-preview-routes.test.ts @@ -66,6 +66,12 @@ const harness = ({ secret = SECRET }: { secret?: string } = {}) => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn().mockResolvedValue(translationRow()), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts b/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts index b665761f9..f10299ddb 100644 --- a/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts +++ b/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts @@ -102,6 +102,12 @@ const translations = () => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/server/translation-editorial-service.test.ts b/packages/vitnode/src/content/server/translation-editorial-service.test.ts index 1c3da36e4..ffea2d070 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.test.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.test.ts @@ -119,6 +119,12 @@ const translations = () => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/server/translation-publication-routes.test.ts b/packages/vitnode/src/content/server/translation-publication-routes.test.ts index b52c4b2a0..410d9127a 100644 --- a/packages/vitnode/src/content/server/translation-publication-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-publication-routes.test.ts @@ -73,6 +73,12 @@ const harness = () => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/server/translation-routes.test.ts b/packages/vitnode/src/content/server/translation-routes.test.ts index 547781b78..efc0a803d 100644 --- a/packages/vitnode/src/content/server/translation-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-routes.test.ts @@ -86,6 +86,12 @@ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { create: vi.fn(), delete: vi.fn(), exists: vi.fn(), + // Stage 8 reads the base row's publication state to decide whether a + // translation's address is publicly reachable. Resolved as "published" so + // these suites keep exercising what they were written for. + findBasePublication: vi + .fn() + .mockResolvedValue({ publishedAt: new Date(0), status: "published" }), findByLanguageId: vi.fn(), findByLocale: vi.fn(), findManyForItem: vi.fn(), diff --git a/packages/vitnode/src/content/sitemap.test.ts b/packages/vitnode/src/content/sitemap.test.ts new file mode 100644 index 000000000..21d9c9a08 --- /dev/null +++ b/packages/vitnode/src/content/sitemap.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentSitemapEntry } from "./sitemap"; + +import { + contentSitemapChunks, + contentSitemapIndexXml, + contentSitemapXml, + escapeXml, +} from "./sitemap"; + +/** + * Sitemap serialization, without a database. + * + * A sitemap is a document other people's parsers read, so the assertions here are + * mostly about bytes: valid XML, correct escaping, the elements the protocol + * defines and deterministic output. A malformed `<loc>` is not a cosmetic problem - + * a crawler may reject the whole file. + */ + +const entry = ( + overrides: Partial<ContentSitemapEntry> = {}, +): ContentSitemapEntry => ({ + changeFrequency: "weekly", + itemId: 1, + lastModified: new Date("2026-01-02T03:04:05.000Z"), + locale: null, + path: "/articles/my-article", + priority: 0.7, + ...overrides, +}); + +describe("escapeXml", () => { + it("escapes the five predefined entities", () => { + expect(escapeXml(`&<>"'`)).toBe("&<>"'"); + }); + + it("escapes the ampersand first, so nothing is double-escaped", () => { + // `&` after `<` would turn the `<` this produced into `&lt;`. + expect(escapeXml("<a & b>")).toBe("<a & b>"); + }); +}); + +describe("contentSitemapXml", () => { + it("emits a valid urlset with every configured element", () => { + const xml = contentSitemapXml({ + entries: [entry()], + origin: "https://example.com", + }); + + expect(xml).toBe( + [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + " <url>", + " <loc>https://example.com/articles/my-article</loc>", + " <lastmod>2026-01-02T03:04:05.000Z</lastmod>", + " <changefreq>weekly</changefreq>", + " <priority>0.7</priority>", + " </url>", + "</urlset>", + "", + ].join("\n"), + ); + }); + + it("omits changefreq and priority when the content type set none", () => { + const xml = contentSitemapXml({ + entries: [entry({ changeFrequency: null, priority: null })], + origin: "https://example.com", + }); + + expect(xml).not.toContain("changefreq"); + expect(xml).not.toContain("priority"); + expect(xml).toContain("<loc>https://example.com/articles/my-article</loc>"); + }); + + it("drops an entry whose path will not resolve rather than emitting a bad loc", () => { + const xml = contentSitemapXml({ + entries: [entry(), entry({ itemId: 2, path: "http://" })], + origin: "https://example.com", + }); + + expect(xml.match(/<url>/g)).toHaveLength(1); + }); + + it("declares the xhtml namespace only when alternates are supplied", () => { + const without = contentSitemapXml({ + entries: [entry()], + origin: "https://example.com", + }); + expect(without).not.toContain("xmlns:xhtml"); + + const withAlternates = contentSitemapXml({ + alternates: new Map([ + [ + 1, + [ + { locale: "en", path: "/en/articles/my-article" }, + { locale: "pl", path: "/pl/articles/moj-artykul" }, + ], + ], + ]), + entries: [entry({ locale: "en", path: "/en/articles/my-article" })], + origin: "https://example.com", + }); + + expect(withAlternates).toContain( + 'xmlns:xhtml="http://www.w3.org/1999/xhtml"', + ); + // Every alternate of a group is repeated inside each `<url>` - the rule + // implementations get wrong. + expect(withAlternates).toContain( + '<xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/articles/my-article" />', + ); + expect(withAlternates).toContain( + '<xhtml:link rel="alternate" hreflang="pl" href="https://example.com/pl/articles/moj-artykul" />', + ); + }); + + it("is deterministic, so two processes produce identical bytes", () => { + const entries = [entry(), entry({ itemId: 2, path: "/articles/second" })]; + const first = contentSitemapXml({ entries, origin: "https://example.com" }); + const second = contentSitemapXml({ + entries, + origin: "https://example.com", + }); + + expect(first).toBe(second); + }); + + it("emits an empty but valid document for no entries", () => { + expect( + contentSitemapXml({ entries: [], origin: "https://example.com" }), + ).toBe( + [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', + "</urlset>", + "", + ].join("\n"), + ); + }); +}); + +describe("contentSitemapIndexXml", () => { + it("emits a sitemapindex, not a urlset", () => { + const xml = contentSitemapIndexXml({ + entries: [ + { + lastModified: new Date("2026-01-02T03:04:05.000Z"), + path: "/sitemaps/blog.article-1.xml", + }, + { path: "/sitemaps/blog.article-2.xml" }, + ], + origin: "https://example.com", + }); + + expect(xml).toContain("<sitemapindex"); + expect(xml).not.toContain("<urlset"); + expect(xml).toContain( + "<loc>https://example.com/sitemaps/blog.article-1.xml</loc>", + ); + expect(xml).toContain("<lastmod>2026-01-02T03:04:05.000Z</lastmod>"); + // The second entry has no timestamp, so it carries no `lastmod` element. + expect(xml.match(/<lastmod>/g)).toHaveLength(1); + }); +}); + +describe("contentSitemapChunks", () => { + it("is one page for an empty content type, not zero", () => { + // An index that lists a file which does not exist is a broken index, and a + // content type with nothing published today will have something tomorrow. + expect(contentSitemapChunks({ total: 0 })).toStrictEqual({ + pages: 1, + size: 1_000, + }); + }); + + it("divides by the page size and rounds up", () => { + expect(contentSitemapChunks({ size: 100, total: 250 })).toStrictEqual({ + pages: 3, + size: 100, + }); + }); + + it("clamps the page size to the protocol ceiling", () => { + expect( + contentSitemapChunks({ size: 1_000_000, total: 60_000 }), + ).toStrictEqual({ pages: 2, size: 50_000 }); + }); + + it("never accepts a page size below one", () => { + expect(contentSitemapChunks({ size: 0, total: 3 })).toStrictEqual({ + pages: 3, + size: 1, + }); + }); +}); diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 6b6b2c893..cc4082127 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -483,3 +483,100 @@ export const testAdvancedLocalizedContentType = defineContentType({ list: { columns: ["featured", "status"] }, }, }); + +/** + * The Stage 8 shape: `testPostContentType` plus the whole delivery layer. + * + * A separate fixture rather than a flag on the post, for the same reason the + * searchable and editorial ones are separate: leaving the post exactly as it was is + * what proves a content type without `delivery` produces the same tables, the same + * routes, the same cache tags and the same events it always did. + */ +export const testDeliveredPostContentType = defineContentType({ + id: "test.delivered-post", + tableName: "test_delivered_posts", + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + hidden: field.boolean({ defaultValue: false }), + }, + publication: { enabled: true }, + editorial: { enabled: true }, + publicApi: { + enabled: true, + path: "delivered-posts", + fields: ["id", "title", "slug", "excerpt", "hidden", "publishedAt"], + defaultOrderBy: "publishedAt", + }, + delivery: { + enabled: true, + redirects: { enabled: true }, + seo: { + titleField: "title", + descriptionField: "excerpt", + noIndexField: "hidden", + openGraph: { titleField: "title", descriptionField: "excerpt" }, + }, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, + }, + admin: { label: { plural: "Test Delivered", singular: "Test Delivered" } }, +}); + +/** + * A localized delivery content type: locale-prefixed URLs and per-locale history. + * + * Its slug is `localized: true`, which is what `delivery.redirects` requires on a + * localized content type - a shared slug would give every language the same segment, + * so one retired address would belong to several URLs at once. + */ +export const testDeliveredLocalizedContentType = defineContentType({ + id: "test.delivered-localized", + tableName: "test_delivered_localized", + localization: { enabled: true, defaultLocale: "en", fallback: "default" }, + publication: { enabled: true }, + editorial: { enabled: true }, + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + seo: field.group({ + localized: true, + nullable: true, + fields: { + title: field.text({ nullable: true, maxLength: 200 }), + description: field.textarea({ nullable: true, maxLength: 500 }), + }, + }), + }, + publicApi: { + enabled: true, + path: "delivered-localized", + fields: [ + "id", + "title", + "slug", + "seo.title", + "seo.description", + "publishedAt", + ], + defaultOrderBy: "publishedAt", + }, + delivery: { + enabled: true, + redirects: { enabled: true }, + hreflang: { xDefault: "defaultLocale" }, + seo: { + titleField: "seo.title", + fallbackTitleField: "title", + descriptionField: "seo.description", + }, + sitemap: { enabled: true, changeFrequency: "daily", priority: 0.5 }, + }, + admin: { + label: { + plural: "Test Delivered Localized", + singular: "Test Delivered Localized", + }, + list: { columns: ["status", "updatedAt"] }, + }, +}); From 117667da390b967ad40f719c3d446f8bb4099098 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:10:58 +0200 Subject: [PATCH 13/24] test(content): add the Stage 8 PostgreSQL suite 49 tests against real Postgres, covering what only a database can show: the two partial unique indexes really do reserve a retired address, a rolled-back write leaves the history exactly as it found it, and two writers racing on one slug produce one winner and one structured version conflict. The full redirect lifecycle is walked end to end - draft (nothing recorded), publish (reserved), A -> B -> C (both old addresses resolve to C in one hop), unpublish (all inactive, history retained), republish (active again), delete (retained, resolves to nothing), restore (the two addresses swap roles). Localized coverage asserts the isolation that matters: an English slug change writes nothing Polish, the same historical address may be retired in two locales, alternates list only real published translations, and a fallback read reports the locale it actually served. The sitemap tests page a fixture in twos and assert every record appears exactly once in ascending key order - and one of them caught a real bug: `greatest(base, translation)` read without the column's decoder parsed a naive timestamp as local time, putting every localized `lastmod` hours out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/database/delivery-postgres.test.ts | 1479 +++++++++++++++++ 1 file changed, 1479 insertions(+) create mode 100644 plugins/example/src/database/delivery-postgres.test.ts diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts new file mode 100644 index 000000000..4733c48ff --- /dev/null +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -0,0 +1,1479 @@ +import type { SearchDocument } from "@vitnode/core/api/models/search"; +import type { Context } from "hono"; + +import { + ContentDeliverySlugReserved, + ContentVersionConflict, +} from "@vitnode/core/content"; +import { + contentDeliveryEffects, + contentEditorialEffects, + contentTranslationEffects, +} from "@vitnode/core/content/server"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import postgres from "postgres"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { CONFIG_PLUGIN, EXAMPLE_MIGRATIONS } from "@/const"; + +import { advancedArticleContent } from "./advanced-articles"; +import { articleContent } from "./articles"; +import { categoryContent } from "./categories"; + +/** + * Stage 8 against real Postgres. + * + * Everything here is about what the *database* enforces and what the resolver + * actually answers, neither of which a mock can show: + * + * - a historical URL is **reserved** by two partial unique indexes, so an unrelated + * record cannot inherit somebody's incoming links; + * - a redirect chain collapses because the resolver reads the record's current slug + * rather than the next entry in the chain; + * - a slug change and its reservation are **one transaction**, so a writer that + * loses the version race leaves the history exactly as it found it; + * - each locale's history is its own, because `languageId` is part of the key. + * + * Runs only with `DATABASE_TEST_URL` set, and **wipes** the database it points at - + * so the URL has to name one with "test" in it: + * + * ```bash + * DATABASE_TEST_URL=postgres://postgres:postgres@localhost:5432/vitnode_test \ + * pnpm --filter @vitnode/example test + * ``` + */ +const url = process.env.DATABASE_TEST_URL; + +const databaseName = (() => { + if (!url) return ""; + try { + return new URL(url).pathname.replace(/^\//, ""); + } catch { + return ""; + } +})(); + +const here = dirname(fileURLToPath(import.meta.url)); + +const migrationSql = (files: readonly string[]): string => + files + .map(file => + readFileSync( + resolve(here, "../../../../apps/docs/migrations", file), + "utf8", + ), + ) + .join("\n--> statement-breakpoint\n"); + +const CORE_STUBS = ` + CREATE TABLE "core_users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL + ); + CREATE TABLE "core_queue" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "name" varchar(100) NOT NULL, + "queue" varchar(100) DEFAULT 'default' NOT NULL, + "status" varchar(20) DEFAULT 'pending' NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "priority" integer DEFAULT 0 NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "maxAttempts" integer DEFAULT 3 NOT NULL, + "availableAt" timestamp DEFAULT now() NOT NULL, + "reservedAt" timestamp, + "lastError" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "completedAt" timestamp + ); + 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") + ); +`; + +const ACTOR = { type: "staff" as const, userId: null }; +const PLUGIN = CONFIG_PLUGIN.pluginId; + +let sql: ReturnType<typeof postgres>; +let db: ReturnType<typeof drizzle>; +let context: Context; +/** A second connection, for the tests that need two writers at once. */ +let rival: ReturnType<typeof postgres>; +let rivalContext: Context; +let categoryId = 0; + +const emitted: { name: string; payload: Record<string, unknown> }[] = []; +const indexed: SearchDocument[] = []; + +const pgErrorCode = async (run: () => Promise<unknown>) => { + try { + await run(); + } catch (error) { + const cause = (error as { cause?: { code?: string } }).cause; + + return cause?.code ?? (error as { code?: string }).code; + } + + return undefined; +}; + +// --------------------------------------------------------------------------- +// Nonlocalized fixture: `example.article` +// --------------------------------------------------------------------------- + +const editorial = (target: Context = context) => + articleContent.editorialService?.(target, { pluginId: PLUGIN }); + +const delivery = (target: Context = context) => + articleContent.deliveryService?.(target, { pluginId: PLUGIN }); + +const createArticle = async ( + values: Record<string, unknown> = {}, +): Promise<{ id: number; version: number }> => { + const outcome = await editorial()?.create( + { + category: categoryId, + code: `code-${Math.round(Date.now() % 1_000_000)}-${values.title ?? "x"}`, + title: "Hello world", + ...values, + } as never, + { actor: ACTOR }, + ); + if (!outcome) throw new Error("create returned nothing"); + + return { id: outcome.row.id, version: outcome.version }; +}; + +/** Creates, publishes, and hands back the version to write against next. */ +const publishArticle = async ( + values: Record<string, unknown> = {}, +): Promise<{ id: number; version: number }> => { + const created = await createArticle(values); + const published = await editorial()?.publish(created.id, { actor: ACTOR }); + if (!published) throw new Error("publish returned nothing"); + + return { id: created.id, version: published.version }; +}; + +/** + * One record's addresses, as plain objects. + * + * `postgres.js` hands back a `Result` array subclass whose prototype is not + * `Array.prototype`, which `toStrictEqual` compares - so every raw read in this file + * is normalised rather than asserted directly. + */ +const historyRows = async ( + itemId: number, +): Promise<{ path: string; retired: boolean; slug: string }[]> => { + const rows = await sql<{ path: string; retiredAt: null | string; slug: string }[]>` + SELECT "slug", "path", "retiredAt" + FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.article' AND "itemId" = ${itemId} + ORDER BY "id" + `; + + return rows.map(row => ({ + path: row.path, + retired: row.retiredAt !== null, + slug: row.slug, + })); +}; + +// --------------------------------------------------------------------------- +// Localized fixture: `example.advanced-article` +// --------------------------------------------------------------------------- + +const localizedService = () => + advancedArticleContent.localizedService?.(context, { pluginId: PLUGIN }); + +const translationEditorial = (target: Context = context) => + advancedArticleContent.translationEditorialService?.(target, { + pluginId: PLUGIN, + }); + +const advancedEditorial = () => + advancedArticleContent.editorialService?.(context, { pluginId: PLUGIN }); + +const advancedDelivery = () => + advancedArticleContent.deliveryService?.(context, { pluginId: PLUGIN }); + +/** + * A localized article, published in `en` and optionally in `pl`. + * + * Both halves are published on purpose: a translation is only publicly reachable + * when the record is too, which is the subordination the delivery layer reads. + */ +const publishLocalized = async ({ + pl, + title = "Hello world", +}: { pl?: string; title?: string } = {}) => { + const localized = localizedService(); + if (!localized) throw new Error("no localized service"); + + const created = await localized.create({ + shared: {}, + translation: { title }, + }); + + const base = await advancedEditorial()?.publish(created.row.id, { + actor: ACTOR, + }); + if (!base) throw new Error("base publish returned nothing"); + + const en = await translationEditorial()?.publish(created.row.id, "en", { + actor: ACTOR, + }); + if (!en) throw new Error("en publish returned nothing"); + + if (pl !== undefined) { + await translationEditorial()?.create( + created.row.id, + "pl", + { title: pl } as never, + { actor: ACTOR }, + ); + await translationEditorial()?.publish(created.row.id, "pl", { + actor: ACTOR, + }); + } + + return { enVersion: en.version, id: created.row.id }; +}; + +const localizedHistory = async ( + itemId: number, +): Promise< + { languageId: null | number; path: string; retired: boolean; slug: string }[] +> => { + const rows = await sql< + { + languageId: null | number; + path: string; + retiredAt: null | string; + slug: string; + }[] + >` + SELECT "slug", "path", "retiredAt", "languageId" + FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.advanced-article' AND "itemId" = ${itemId} + ORDER BY "id" + `; + + return rows.map(row => ({ + languageId: row.languageId, + path: row.path, + retired: row.retiredAt !== null, + slug: row.slug, + })); +}; + +const localeIds: Record<string, number> = {}; + +describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { + beforeAll(async () => { + if (!/test/i.test(databaseName)) { + throw new Error( + `DATABASE_TEST_URL points at "${databaseName || url}". This suite wipes the database it runs against, so its name must contain "test".`, + ); + } + + sql = postgres(url ?? "", { max: 1, onnotice: () => undefined }); + + await sql.unsafe(` + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + `); + await sql.unsafe(CORE_STUBS); + const languages = await sql<{ code: string; id: number }[]>` + INSERT INTO "core_languages" ("code", "name", "default") VALUES + ('en', 'English', true), + ('pl', 'Polski', false) + RETURNING "id", "code" + `; + for (const language of languages) localeIds[language.code] = language.id; + + for (const statement of migrationSql(EXAMPLE_MIGRATIONS).split( + "--> statement-breakpoint", + )) { + const trimmed = statement.trim(); + if (trimmed) await sql.unsafe(trimmed); + } + + db = drizzle(sql, { casing: "camelCase" }); + rival = postgres(url ?? "", { max: 1, onnotice: () => undefined }); + + const buildContext = (handle: ReturnType<typeof drizzle>) => + ({ + get: (key: string) => { + if (key === "db") return handle; + if (key === "search") { + return { + delete: async () => await Promise.resolve(), + index: async (document: SearchDocument) => { + indexed.push(document); + + return await Promise.resolve(); + }, + }; + } + if (key === "events") { + return { + emit: async ( + name: string, + payload: Record<string, unknown>, + ) => { + emitted.push({ name, payload }); + + return await Promise.resolve({ failures: [] }); + }, + }; + } + if (key === "log") return { error: async () => await Promise.resolve() }; + if (key === "core") { + return { + contentModels: [ + { model: advancedArticleContent, pluginId: PLUGIN }, + { model: articleContent, pluginId: PLUGIN }, + { model: categoryContent, pluginId: PLUGIN }, + ], + i18n: { + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + }, + }; + } + + return undefined; + }, + }) as unknown as Context; + + context = buildContext(db); + rivalContext = buildContext(drizzle(rival, { casing: "camelCase" })); + }, 60_000); + + afterAll(async () => { + await sql?.end(); + await rival?.end(); + }); + + beforeEach(async () => { + await sql`DELETE FROM "example_articles"`; + await sql`DELETE FROM "example_advanced_articles"`; + await sql`DELETE FROM "core_content_slug_history"`; + await sql`DELETE FROM "core_content_revisions"`; + await sql`DELETE FROM "example_categories"`; + + const [category] = await sql<{ id: number }[]>` + INSERT INTO "example_categories" ("name") VALUES ('News') RETURNING "id" + `; + categoryId = category.id; + emitted.length = 0; + indexed.length = 0; + }); + + // ------------------------------------------------------------------------- + // The table itself + // ------------------------------------------------------------------------- + + describe("the reservation constraints", () => { + it("refuses two shared rows for the same address", async () => { + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "slug", "path") + VALUES (${PLUGIN}, 'example.article', 1, 'hello', '/articles/hello') + `; + + const code = await pgErrorCode( + async () => + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "slug", "path") + VALUES (${PLUGIN}, 'example.article', 2, 'hello', '/articles/hello') + `, + ); + + // The partial unique index over `(contentTypeId, slug) WHERE languageId IS + // NULL` is what makes a retired URL a reservation rather than only a log. + expect(code).toBe("23505"); + }); + + it("allows the same address in two different locales", async () => { + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path") + VALUES + (${PLUGIN}, 'example.advanced-article', 1, ${localeIds.en}, 'shared', '/en/advanced-articles/shared'), + (${PLUGIN}, 'example.advanced-article', 2, ${localeIds.pl}, 'shared', '/pl/advanced-articles/shared') + `; + + const [row] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + `; + + // Locale-scoped uniqueness: `/en/x/shared` and `/pl/x/shared` are two URLs. + expect(row.count).toBe(2); + }); + + it("refuses two rows for the same address in one locale", async () => { + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path") + VALUES (${PLUGIN}, 'example.advanced-article', 1, ${localeIds.en}, 'hello', '/en/advanced-articles/hello') + `; + + const code = await pgErrorCode( + async () => + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path") + VALUES (${PLUGIN}, 'example.advanced-article', 2, ${localeIds.en}, 'hello', '/en/advanced-articles/hello') + `, + ); + + expect(code).toBe("23505"); + }); + + it("keeps two content types' histories apart", async () => { + await sql` + INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "slug", "path") + VALUES + (${PLUGIN}, 'example.article', 1, 'hello', '/articles/hello'), + (${PLUGIN}, 'other.thing', 1, 'hello', '/things/hello') + `; + + const [row] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + `; + + expect(row.count).toBe(2); + }); + + it("indexes the resolver's lookup", async () => { + // The redirect lookup is on a public request path for a URL that is very + // often a typo, so it has to be an index hit rather than a scan. + const rows = await sql<{ indexdef: string; indexname: string }[]>` + SELECT indexname, indexdef FROM pg_indexes + WHERE tablename = 'core_content_slug_history' + `; + const names = rows.map(row => row.indexname); + + expect(names).toContain("core_content_slug_history_shared_unique"); + expect(names).toContain("core_content_slug_history_locale_unique"); + expect(names).toContain("core_content_slug_history_item_idx"); + + const shared = rows.find( + row => row.indexname === "core_content_slug_history_shared_unique", + ); + expect(shared?.indexdef).toContain("UNIQUE"); + expect(shared?.indexdef).toMatch(/"?languageId"? IS NULL/); + }); + }); + + // ------------------------------------------------------------------------- + // The redirect lifecycle + // ------------------------------------------------------------------------- + + describe("the redirect lifecycle", () => { + it("records nothing while the record is still a draft", async () => { + const article = await createArticle({ title: "Draft article" }); + + await editorial()?.update( + article.id, + { slug: "corrected" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + // A draft has no public URL, so neither the original nor the corrected slug + // was ever addressable - and neither is reserved. + expect(await historyRows(article.id)).toStrictEqual([]); + }); + + it("reserves the current address on publication", async () => { + const article = await publishArticle({ title: "Hello world" }); + + const rows = await historyRows(article.id); + + expect(rows).toStrictEqual([ + { + path: "/articles/hello-world", + retired: false, + slug: "hello-world", + }, + ]); + }); + + it("resolves the current slug as canonical content", async () => { + const article = await publishArticle({ title: "Hello world" }); + + expect(await delivery()?.resolveSlug("hello-world")).toMatchObject({ + canonicalPath: "/articles/hello-world", + // `example.article` does not expose `id`, so delivery reports none rather + // than publishing a column the public API withheld. + itemId: null, + type: "content", + }); + expect(article.id).toBeGreaterThan(0); + }); + + it("redirects the old address after a slug change", async () => { + const article = await publishArticle({ title: "Hello world" }); + + await editorial()?.update( + article.id, + { slug: "hello-there" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + expect(await delivery()?.resolveSlug("hello-world")).toStrictEqual({ + location: "/articles/hello-there", + status: 308, + type: "redirect", + }); + expect(await delivery()?.resolveSlug("hello-there")).toMatchObject({ + canonicalPath: "/articles/hello-there", + type: "content", + }); + }); + + it("collapses a chain: A and B both resolve straight to C", async () => { + const article = await publishArticle({ title: "Slug a" }); + + const toB = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + await editorial()?.update( + article.id, + { slug: "slug-c" }, + { actor: ACTOR, expectedVersion: toB?.version ?? 0 }, + ); + + // One hop each, never A -> B -> C. + for (const retired of ["slug-a", "slug-b"]) { + expect(await delivery()?.resolveSlug(retired)).toStrictEqual({ + location: "/articles/slug-c", + status: 308, + type: "redirect", + }); + } + expect(await delivery()?.resolveSlug("slug-c")).toMatchObject({ + type: "content", + }); + }); + + it("keeps three rows: two retired and one current", async () => { + const article = await publishArticle({ title: "Slug a" }); + const toB = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + await editorial()?.update( + article.id, + { slug: "slug-c" }, + { actor: ACTOR, expectedVersion: toB?.version ?? 0 }, + ); + + const rows = await historyRows(article.id); + + expect(rows.map(row => row.slug)).toStrictEqual([ + "slug-a", + "slug-b", + "slug-c", + ]); + expect(rows.map(row => row.retired)).toStrictEqual([true, true, false]); + // The database keeps the chronology; the resolver is what collapses it. + expect(rows[0].path).toBe("/articles/slug-a"); + }); + + it("stops redirecting while the record is unpublished, and starts again", async () => { + const article = await publishArticle({ title: "Slug a" }); + const moved = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const unpublished = await editorial()?.unpublish(article.id, { + actor: ACTOR, + }); + + expect(await delivery()?.resolveSlug("slug-a")).toStrictEqual({ + type: "not_found", + }); + expect(await delivery()?.resolveSlug("slug-b")).toStrictEqual({ + type: "not_found", + }); + // The history survives - it is what makes the redirect come back. + expect((await historyRows(article.id)).length).toBe(2); + + await editorial()?.publish(article.id, { + actor: ACTOR, + expectedVersion: unpublished?.version, + }); + + expect(await delivery()?.resolveSlug("slug-a")).toStrictEqual({ + location: "/articles/slug-b", + status: 308, + type: "redirect", + }); + expect(moved?.delivery?.redirectCreated).toBe(true); + }); + + it("keeps the history but resolves nothing after a delete", async () => { + const article = await publishArticle({ title: "Slug a" }); + const moved = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + await editorial()?.delete(article.id, { + actor: ACTOR, + expectedVersion: moved?.version ?? 0, + }); + + // Retained for audit, and never a redirect to content that is gone. + expect((await historyRows(article.id)).length).toBe(2); + expect(await delivery()?.resolveSlug("slug-a")).toStrictEqual({ + type: "not_found", + }); + expect(await delivery()?.resolveSlug("slug-b")).toStrictEqual({ + type: "not_found", + }); + }); + + it("brings a slug back into service when it is restored", async () => { + const article = await publishArticle({ title: "Original name" }); + const [original] = (await editorial()?.revisions.list(article.id))?.edges ?? []; + + const moved = await editorial()?.update( + article.id, + { slug: "new-name" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const restored = await editorial()?.restore(article.id, original.id, { + actor: ACTOR, + expectedVersion: moved?.version ?? 0, + }); + + expect(restored?.delivery).toMatchObject({ + canonicalPath: "/articles/original-name", + previousPath: "/articles/new-name", + redirectCreated: true, + slugChanged: true, + }); + + // The two addresses have swapped roles: `new-name` now redirects to the + // restored `original-name`. + expect(await delivery()?.resolveSlug("new-name")).toStrictEqual({ + location: "/articles/original-name", + status: 308, + type: "redirect", + }); + expect(await delivery()?.resolveSlug("original-name")).toMatchObject({ + type: "content", + }); + }); + + it("writes no history for a restore that moves no slug", async () => { + const article = await publishArticle({ title: "Stable" }); + const [first] = (await editorial()?.revisions.list(article.id))?.edges ?? []; + + const edited = await editorial()?.update( + article.id, + { excerpt: "Changed prose" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const restored = await editorial()?.restore(article.id, first.id, { + actor: ACTOR, + expectedVersion: edited?.version ?? 0, + }); + + expect(restored?.delivery?.slugChanged).toBe(false); + expect((await historyRows(article.id)).map(row => row.slug)).toStrictEqual([ + "stable", + ]); + }); + }); + + // ------------------------------------------------------------------------- + // Reservations + // ------------------------------------------------------------------------- + + describe("slug reservations", () => { + it("refuses an address another record retired", async () => { + const first = await publishArticle({ title: "Hello" }); + await editorial()?.update( + first.id, + { slug: "hello-world" }, + { actor: ACTOR, expectedVersion: first.version }, + ); + + // `hello` is free on the content table now - the first article moved off it - + // so the reservation is the only thing standing between the second article + // and somebody else's incoming links. + await expect(createArticle({ slug: "hello", title: "Second" })).rejects.toThrow( + ContentDeliverySlugReserved, + ); + }); + + it("names the address in the structured error", async () => { + const first = await publishArticle({ title: "Hello" }); + await editorial()?.update( + first.id, + { slug: "hello-world" }, + { actor: ACTOR, expectedVersion: first.version }, + ); + + const error = await createArticle({ + slug: "hello", + title: "Second", + }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(ContentDeliverySlugReserved); + expect(error).toMatchObject({ locale: null, slug: "hello" }); + }); + + it("lets a record take its own retired address back", async () => { + const article = await publishArticle({ title: "Slug a" }); + const toB = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const back = await editorial()?.update( + article.id, + { slug: "slug-a" }, + { actor: ACTOR, expectedVersion: toB?.version ?? 0 }, + ); + + expect(back?.delivery?.canonicalPath).toBe("/articles/slug-a"); + expect(await delivery()?.resolveSlug("slug-b")).toStrictEqual({ + location: "/articles/slug-a", + status: 308, + type: "redirect", + }); + // Two rows, and `slug-a` is live again rather than duplicated. + const rows = await historyRows(article.id); + expect(rows).toHaveLength(2); + expect(rows.find(row => row.slug === "slug-a")?.retired).toBe(false); + }); + + it("never reserves a draft's address", async () => { + await createArticle({ slug: "wanted", title: "A draft" }); + + // A draft has no public URL, so another record may still publish at that + // address - the content table's own unique index is what stops a *live* + // duplicate, and it fires on the create below rather than the reservation. + const code = await pgErrorCode( + async () => await createArticle({ slug: "wanted", title: "Another" }), + ); + + expect(code).toBe("23505"); + const [rows] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + `; + expect(rows.count).toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // Concurrency + // ------------------------------------------------------------------------- + + describe("concurrency", () => { + it("lets one of two racing slug edits win and refuses the other", async () => { + const article = await publishArticle({ title: "Original" }); + + const results = await Promise.allSettled([ + editorial()?.update( + article.id, + { slug: "winner" }, + { actor: ACTOR, expectedVersion: article.version }, + ), + editorial(rivalContext)?.update( + article.id, + { slug: "loser" }, + { actor: ACTOR, expectedVersion: article.version }, + ), + ]); + + const rejected = results.filter(result => result.status === "rejected"); + expect(rejected).toHaveLength(1); + expect(rejected[0]).toMatchObject({ + reason: expect.any(ContentVersionConflict), + }); + + // One winner, so exactly one retirement and one new reservation - the loser's + // transaction rolled back and left the history as it found it. + const rows = await historyRows(article.id); + expect(rows).toHaveLength(2); + expect(rows.filter(row => !row.retired)).toHaveLength(1); + expect(rows.filter(row => row.slug === "loser")).toHaveLength(0); + }); + + it("keeps the history consistent when the write rolls back", async () => { + const article = await publishArticle({ title: "Original" }); + + // A stale expectation: the guarded UPDATE matches nothing, so the reservation + // never runs at all. + await expect( + editorial()?.update( + article.id, + { slug: "never-written" }, + { actor: ACTOR, expectedVersion: article.version + 5 }, + ), + ).rejects.toThrow(ContentVersionConflict); + + expect((await historyRows(article.id)).map(row => row.slug)).toStrictEqual([ + "original", + ]); + }); + + it("serialises two records racing for the same retired address", async () => { + const first = await publishArticle({ title: "Contested" }); + await editorial()?.update( + first.id, + { slug: "moved-on" }, + { actor: ACTOR, expectedVersion: first.version }, + ); + + const second = await createArticle({ slug: "second", title: "Second" }); + const third = await createArticle({ slug: "third", title: "Third" }); + + const results = await Promise.allSettled([ + editorial()?.update( + second.id, + { slug: "contested" }, + { actor: ACTOR, expectedVersion: second.version }, + ), + editorial(rivalContext)?.update( + third.id, + { slug: "contested" }, + { actor: ACTOR, expectedVersion: third.version }, + ), + ]); + + // Both lose: the address belongs to the first article's history, and neither + // of the two may take it. + expect( + results.every(result => result.status === "rejected"), + ).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // Sitemap + // ------------------------------------------------------------------------- + + describe("sitemap", () => { + it("lists only published records", async () => { + const published = await publishArticle({ title: "Published one" }); + await createArticle({ title: "Still a draft" }); + + const page = await delivery()?.sitemap(); + + expect(page?.entries.map(entry => entry.itemId)).toStrictEqual([ + published.id, + ]); + expect(page?.entries[0]).toMatchObject({ + changeFrequency: "weekly", + path: "/articles/published-one", + priority: 0.7, + }); + }); + + it("omits a record whose publication date is in the future", async () => { + const article = await publishArticle({ title: "Scheduled" }); + await sql` + UPDATE "example_articles" + SET "publishedAt" = now() + interval '1 day' + WHERE "id" = ${article.id} + `; + + expect((await delivery()?.sitemap())?.entries).toStrictEqual([]); + }); + + it("paginates by keyset, without duplicates or gaps", async () => { + const ids: number[] = []; + for (const title of ["One", "Two", "Three", "Four", "Five"]) { + ids.push((await publishArticle({ title })).id); + } + + const seen: number[] = []; + let cursor: null | number | undefined = undefined; + + for (let page = 0; page < 10; page += 1) { + const result = await delivery()?.sitemap({ + cursor: cursor ?? undefined, + limit: 2, + }); + if (!result) break; + + seen.push(...result.entries.map(entry => entry.itemId)); + cursor = result.nextCursor; + if (cursor === null) break; + } + + // Every record exactly once, in ascending primary-key order. + expect(seen).toStrictEqual([...ids].sort((a, b) => a - b)); + expect(new Set(seen).size).toBe(seen.length); + expect(cursor).toBeNull(); + }); + + it("uses the base row's updatedAt for a nonlocalized entry", async () => { + const article = await publishArticle({ title: "Timestamped" }); + // Read through the same driver as the sitemap, never as `::text`: a + // `timestamp` column is rendered in the session's timezone as text and parsed + // back as an instant, so comparing the two forms compares two clocks. + const row = await articleContent.service(context).findById(article.id); + + const page = await delivery()?.sitemap(); + + expect(page?.entries[0].lastModified.toISOString()).toBe( + row?.updatedAt.toISOString(), + ); + }); + }); + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + describe("delivery events", () => { + it("emits both events after a live URL moves", async () => { + const article = await publishArticle({ title: "Slug a" }); + emitted.length = 0; + + const outcome = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + await contentDeliveryEffects( + context, + articleContent.definition, + outcome.delivery, + { pluginId: PLUGIN }, + ); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.example.article.delivery_slug_changed", + "content.example.article.delivery_redirect_created", + ]); + expect(emitted[0].payload).toMatchObject({ + canonicalPath: "/articles/slug-b", + contentId: article.id, + previousPath: "/articles/slug-a", + previousSlug: "slug-a", + slug: "slug-b", + }); + }); + + it("emits them alongside the ordinary update event, never instead of it", async () => { + const article = await publishArticle({ title: "Slug a" }); + emitted.length = 0; + + const outcome = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + await contentEditorialEffects(context, articleContent.definition, outcome, { + model: articleContent, + pluginId: PLUGIN, + }); + + expect(emitted.map(entry => entry.name)).toStrictEqual([ + "content.example.article.updated", + "content.example.article.delivery_slug_changed", + "content.example.article.delivery_redirect_created", + ]); + }); + + it("emits nothing for a corrected draft", async () => { + const article = await createArticle({ title: "Draft" }); + emitted.length = 0; + + const outcome = await editorial()?.update( + article.id, + { slug: "corrected" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + await contentDeliveryEffects( + context, + articleContent.definition, + outcome.delivery, + { pluginId: PLUGIN }, + ); + + // The URL moved, but it had never been live - so a listener that warms a CDN + // or writes an edge redirect table hears about a redirect that does not exist. + expect( + emitted.filter(entry => + entry.name.includes("delivery_redirect_created"), + ), + ).toStrictEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + // Search integration + // ------------------------------------------------------------------------- + + describe("search integration", () => { + it("indexes the current canonical URL and never a historical one", async () => { + const article = await publishArticle({ title: "Slug a" }); + + const outcome = await editorial()?.update( + article.id, + { slug: "slug-b" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + if (!outcome) throw new Error("update returned nothing"); + + indexed.length = 0; + await contentEditorialEffects(context, articleContent.definition, outcome, { + model: articleContent, + pluginId: PLUGIN, + }); + + // One document, pointing at the new address. A retired URL never becomes a + // second search result competing with the page it redirects to. + expect(indexed).toHaveLength(1); + expect(indexed[0].url).toBe("/articles/slug-b"); + expect(indexed.filter(document => document.url === "/articles/slug-a")).toStrictEqual( + [], + ); + }); + }); + + // ------------------------------------------------------------------------- + // Localization + // ------------------------------------------------------------------------- + + describe("localized delivery", () => { + it("reserves one address per published language", async () => { + const article = await publishLocalized({ pl: "Witaj swiecie" }); + + const rows = await localizedHistory(article.id); + + expect(rows).toHaveLength(2); + expect(rows.map(row => row.path).sort()).toStrictEqual([ + "/en/advanced-articles/hello-world", + "/pl/advanced-articles/witaj-swiecie", + ]); + // Each carries its own language, which is what keeps the two histories apart. + expect(new Set(rows.map(row => row.languageId)).size).toBe(2); + }); + + it("keeps an English slug change out of the Polish history", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + const before = await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + expect(before?.delivery).toMatchObject({ + canonicalPath: "/en/advanced-articles/hello-there", + locale: "en", + previousPath: "/en/advanced-articles/hello-world", + redirectCreated: true, + }); + + const rows = await localizedHistory(article.id); + const polish = rows.filter(row => row.languageId === localeIds.pl); + + // Polish gained nothing and retired nothing. + expect(polish).toHaveLength(1); + expect(polish[0]).toMatchObject({ + path: "/pl/advanced-articles/witaj", + retired: false, + }); + }); + + it("redirects only inside the locale that moved", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + expect( + await advancedDelivery()?.resolvePath( + "/en/advanced-articles/hello-world", + ), + ).toStrictEqual({ + location: "/en/advanced-articles/hello-there", + status: 308, + type: "redirect", + }); + // The Polish URL is untouched and still canonical. + expect( + await advancedDelivery()?.resolvePath("/pl/advanced-articles/witaj"), + ).toMatchObject({ + canonicalPath: "/pl/advanced-articles/witaj", + type: "content", + }); + }); + + it("allows the same historical address in two locales", async () => { + const first = await publishLocalized({ title: "Shared" }); + await translationEditorial()?.update( + first.id, + "en", + { slug: "english-now" } as never, + { actor: ACTOR, expectedVersion: first.enVersion }, + ); + + const second = await publishLocalized({ title: "Second" }); + await translationEditorial()?.create( + second.id, + "pl", + { title: "Shared" } as never, + { actor: ACTOR }, + ); + const pl = await translationEditorial()?.publish(second.id, "pl", { + actor: ACTOR, + }); + await translationEditorial()?.update( + second.id, + "pl", + { slug: "polski-teraz" } as never, + { actor: ACTOR, expectedVersion: pl?.version ?? 0 }, + ); + + // `/en/.../shared` and `/pl/.../shared` are two URLs, so both may be retired + // by two different records. + const [rows] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + WHERE "slug" = 'shared' + `; + expect(rows.count).toBe(2); + }); + + it("lists only real published translations as alternates", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + expect(await advancedDelivery()?.alternates(article.id)).toStrictEqual([ + { locale: "en", path: "/en/advanced-articles/hello-world" }, + { locale: "pl", path: "/pl/advanced-articles/witaj" }, + ]); + }); + + it("never fabricates an alternate from a draft translation", async () => { + const article = await publishLocalized(); + // Created but deliberately not published. + await translationEditorial()?.create( + article.id, + "pl", + { title: "Wersja robocza" } as never, + { actor: ACTOR }, + ); + + expect(await advancedDelivery()?.alternates(article.id)).toStrictEqual([ + { locale: "en", path: "/en/advanced-articles/hello-world" }, + ]); + }); + + it("reports the served locale on a fallback read", async () => { + const article = await publishLocalized(); + + const metadata = await advancedDelivery()?.findById(article.id, { + locale: "pl", + }); + + // The Polish translation does not exist and the content type falls back to + // English, so the canonical URL is the English one - `/pl/...` would be a + // self-declared canonical that answers 404. + expect(metadata).toMatchObject({ + canonicalPath: "/en/advanced-articles/hello-world", + isFallback: true, + locale: "en", + requestedLocale: "pl", + }); + }); + + it("emits an x-default only when the default locale is published", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + const metadata = await advancedDelivery()?.findById(article.id, { + locale: "pl", + }); + + expect(metadata?.hreflang).toStrictEqual({ + languages: { + en: "/en/advanced-articles/hello-world", + pl: "/pl/advanced-articles/witaj", + }, + xDefault: "/en/advanced-articles/hello-world", + }); + }); + + it("stops a locale's redirects when its translation is unpublished", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + const moved = await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + await translationEditorial()?.unpublish(article.id, "en", { + actor: ACTOR, + expectedVersion: moved?.version, + }); + + expect( + await advancedDelivery()?.resolvePath( + "/en/advanced-articles/hello-world", + ), + ).toStrictEqual({ type: "not_found" }); + // Polish is unaffected: one language going dark is not the record going dark. + expect( + await advancedDelivery()?.resolvePath("/pl/advanced-articles/witaj"), + ).toMatchObject({ type: "content" }); + }); + + it("emits the localized delivery event alongside the translation one", async () => { + const article = await publishLocalized(); + emitted.length = 0; + + const outcome = await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + if (!outcome) throw new Error("update returned nothing"); + + await contentTranslationEffects( + context, + advancedArticleContent.definition, + outcome, + { model: advancedArticleContent, pluginId: PLUGIN }, + ); + + const names = emitted.map(entry => entry.name); + expect(names).toContain( + "content.example.advanced-article.translation_updated", + ); + expect(names).toContain( + "content.example.advanced-article.delivery_slug_changed", + ); + expect( + emitted.find(entry => entry.name.includes("delivery_slug_changed")) + ?.payload, + ).toMatchObject({ locale: "en" }); + }); + }); + + // ------------------------------------------------------------------------- + // Localized sitemap and SEO + // ------------------------------------------------------------------------- + + describe("localized sitemap", () => { + it("lists one URL per published translation, per locale", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + const en = await advancedDelivery()?.sitemap({ locale: "en" }); + const pl = await advancedDelivery()?.sitemap({ locale: "pl" }); + + expect(en?.entries.map(entry => entry.path)).toStrictEqual([ + "/en/advanced-articles/hello-world", + ]); + expect(pl?.entries.map(entry => entry.path)).toStrictEqual([ + "/pl/advanced-articles/witaj", + ]); + // `example.advanced-article` withholds `id` from its public allowlist too, but + // a sitemap entry is built from the row rather than the projection - so the + // identifier is there, and it is what an `xhtml:link` group is keyed by. + expect(en?.entries[0].itemId).toBe(article.id); + }); + + it("omits a draft translation and never falls back for it", async () => { + const article = await publishLocalized(); + await translationEditorial()?.create( + article.id, + "pl", + { title: "Wersja robocza" } as never, + { actor: ACTOR }, + ); + + // No Polish entry at all: it has no URL of its own, and listing the English + // one under a Polish path would put the same content in the sitemap twice. + expect( + (await advancedDelivery()?.sitemap({ locale: "pl" }))?.entries, + ).toStrictEqual([]); + expect(article.id).toBeGreaterThan(0); + }); + + it("takes the later of the base and translation timestamps", async () => { + const article = await publishLocalized(); + + // A shared field moving changes what every language's page renders, even + // though no translation row was touched. + await sql` + UPDATE "example_advanced_articles" + SET "updatedAt" = now() + interval '1 hour' + WHERE "id" = ${article.id} + `; + const base = await advancedArticleContent + .service(context) + .findById(article.id); + const translation = await advancedArticleContent + .translationService?.(context) + .findByLocale(article.id, "en"); + + const page = await advancedDelivery()?.sitemap({ locale: "en" }); + + // The base row is now the later of the two, and that is the timestamp the + // sitemap carries - a shared field moving has to look like a change. + expect(base?.updatedAt.getTime()).toBeGreaterThan( + translation?.updatedAt.getTime() ?? 0, + ); + expect(page?.entries[0].lastModified.toISOString()).toBe( + base?.updatedAt.toISOString(), + ); + }); + + it("excludes a record whose noIndex flag is set", async () => { + const article = await publishLocalized(); + + await sql` + UPDATE "example_advanced_articles" + SET "syndicationNoIndex" = true + WHERE "id" = ${article.id} + `; + + expect( + (await advancedDelivery()?.sitemap({ locale: "en" }))?.entries, + ).toStrictEqual([]); + + // And the two agree: a record absent from the sitemap reports `index: false`. + const metadata = await advancedDelivery()?.findById(article.id, { + locale: "en", + }); + expect(metadata?.robots).toStrictEqual({ follow: true, index: false }); + }); + }); + + describe("localized SEO projection", () => { + it("reads each language's own SEO fields", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + await translationEditorial()?.update( + article.id, + "en", + { seo: { description: "English summary", title: "English SEO" } } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + const en = await advancedDelivery()?.findById(article.id, { + locale: "en", + }); + const pl = await advancedDelivery()?.findById(article.id, { + locale: "pl", + }); + + expect(en?.seo).toStrictEqual({ + description: "English summary", + title: "English SEO", + }); + // Polish set none, so its title falls back to the localized `title` field - + // its own, never English's. + expect(pl?.seo).toStrictEqual({ description: null, title: "Witaj" }); + }); + + it("never leaks a private field into the metadata", async () => { + const article = await publishLocalized(); + + const metadata = await advancedDelivery()?.findById(article.id, { + locale: "en", + }); + + // `syndication.indexable` is a declared field that `publicApi.fields` does not + // expose, so it is not even fetched - the projection cannot reach it. + expect(JSON.stringify(metadata)).not.toContain("indexable"); + }); + }); + + // ------------------------------------------------------------------------- + // Stage 1-7 regression + // ------------------------------------------------------------------------- + + describe("a content type without delivery", () => { + it("has no delivery service and writes no history", async () => { + expect(categoryContent.deliveryService).toBeUndefined(); + expect(categoryContent.definition.delivery.enabled).toBe(false); + + const outcome = await categoryContent + .service(context) + .create({ name: "Guides" } as never); + + expect(outcome).toBeTruthy(); + const [rows] = await sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM "core_content_slug_history" + WHERE "contentTypeId" = 'example.category' + `; + expect(rows.count).toBe(0); + }); + + it("reports no delivery outcome on its mutations", async () => { + const outcome = await categoryContent + .service(context) + .create({ name: "News two" } as never); + + expect(outcome).not.toHaveProperty("delivery"); + }); + }); + + describe("preview", () => { + it("registers no slug history and appears in no sitemap", async () => { + const article = await createArticle({ title: "Unpublished draft" }); + + // A preview reads a revision; it writes nothing. The record is still a draft, + // so it has no reservation and no sitemap line either. + const revisions = await editorial()?.revisions.list(article.id); + expect(revisions?.edges.length).toBeGreaterThan(0); + + expect(await historyRows(article.id)).toStrictEqual([]); + expect( + (await delivery()?.sitemap())?.entries.filter( + entry => entry.itemId === article.id, + ), + ).toStrictEqual([]); + expect(await delivery()?.resolveSlug("unpublished-draft")).toStrictEqual({ + type: "not_found", + }); + }); + }); +}); From 0681413ff73a40851bc0d16010bc111b6458f10c Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:10:59 +0200 Subject: [PATCH 14/24] docs(content): document Content Delivery and SEO Nine pages under `dev/content-engine`, plus the delivery tags in `caching.mdx` and the two new events in `built-in-events.mdx`. Each page leads with the rule rather than the API, because the rules are what a reader has to hold: when a slug becomes redirectable, why an alternate is never fabricated from a fallback, why the canonical URL is the *served* locale, and why the redirect status is not configurable. `content-delivery-migrations.mdx` states plainly what is **not** backfilled and why - scanning revisions would create incorrect permanent redirects from draft-only slugs, and an incorrect permanent redirect is worse than a missing one - then gives the SQL for an explicit backfill and for a route-prefix migration. `content-delivery-limitations.mdx` separates decisions from gaps: no page builder, no manual redirect manager, no `og:image`, no per-locale `noIndex`, no `410`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../docs/dev/content-engine/caching.mdx | 84 +++++ .../dev/content-engine/canonical-urls.mdx | 176 +++++++++ .../content-delivery-limitations.mdx | 166 +++++++++ .../content-delivery-migrations.mdx | 184 ++++++++++ .../content-delivery-nextjs.mdx | 253 +++++++++++++ .../dev/content-engine/content-delivery.mdx | 217 ++++++++++++ .../localization-and-hreflang.mdx | 207 +++++++++++ .../content/docs/dev/content-engine/meta.json | 9 + .../content/docs/dev/content-engine/seo.mdx | 232 ++++++++++++ .../docs/dev/content-engine/sitemaps.mdx | 276 +++++++++++++++ .../slug-history-and-redirects.mdx | 335 ++++++++++++++++++ .../docs/dev/events/built-in-events.mdx | 43 ++- 12 files changed, 2181 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/docs/dev/content-engine/canonical-urls.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/content-delivery-nextjs.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/content-delivery.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/seo.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/sitemaps.mdx create mode 100644 apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx index 8d838e3ce..6952b4c75 100644 --- a/apps/docs/content/docs/dev/content-engine/caching.mdx +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -348,6 +348,90 @@ Nothing falls back to Polish, whatever the fallback setting is - so a Polish edi never throws away the English cache. See [Localized public API](/docs/dev/content-engine/localized-public-api#caching). +## Delivery tags + +A content type with [`delivery`](/docs/dev/content-engine/content-delivery) produces +three more scopes, in the same namespace and with the locale in the same position: + +```ts +import { + contentDeliveryRedirectTag, + contentDeliverySitemapTag, + contentDeliveryTag, +} from "@vitnode/core/content"; + +contentDeliveryTag("example.article", 42); +// "content:example.article:delivery:42" + +contentDeliveryTag("example.article", 42, "pl"); +// "content:example.article:delivery:pl:42" + +contentDeliveryRedirectTag("example.article", "stary-slug", "pl"); +// "content:example.article:redirect:pl:stary-slug" + +contentDeliverySitemapTag("example.article", "pl"); +// "content:example.article:sitemap:pl" +``` + +Each answers a different question a page asked, which is why they are separate from +the three above rather than folded into them: + +| Scope | Keyed by | Holds | +| ---------- | ------------- | -------------------------------------------------- | +| `delivery` | the record | canonical path, alternates, SEO metadata | +| `redirect` | the **slug** | "does this address still resolve here" | +| `sitemap` | the locale | one locale's file, and the index that lists them | + +A `generateMetadata` that renders only metadata is tagged `delivery` alone, so an +unrelated field of the record changing does not throw it away. A redirect lookup is +tagged by the **old** address, because that is what a request for a moved page arrives +with. + +### What expires them + +`contentInvalidationTags` takes an optional `delivery` block and derives everything +from the data it already has - the affected locales and every slug the record answered +to across the mutation: + +```ts +contentInvalidationTags({ + contentTypeId, + delivery: { sitemap: true }, + id, + isPublic, + slugs: [previousSlug, currentSlug], + wasPublic, +}); +``` + +| Mutation | delivery | redirect (old + new) | sitemap | +| --------------------------------- | -------- | -------------------- | ------- | +| Slug change (published) | ✅ | ✅ | ✅ | +| Publish / unpublish | ✅ | ✅ | ✅ | +| Delete | ✅ | ✅ | ✅ | +| Restore that moves a slug | ✅ | ✅ | ✅ | +| Translation create / delete | ✅ | ✅ | ✅ | +| SEO field edit (still published) | ✅ | ✅ | ❌ | + +The last row is the one worth reading twice: an edit that only changed what an +already-listed page *says* leaves the sitemap byte-identical, so its tag is not +expired. Everything that adds, removes or moves a line in the file expires it - and on +a localized content type that means each affected locale's file **and** the +locale-less index that enumerates them. + +<Callout type="warn" title="Delivery is opt-in at this layer too"> + Omit `delivery` from the input - which is what every content type without the block + does - and `contentInvalidationTags` returns exactly the strings it always returned, + byte for byte. Nothing existing has to be re-tagged, and no warm cache is thrown + away for a feature the content type does not use. A test asserts the exact lists. +</Callout> + +### Background mutations + +A [scheduled](/docs/dev/content-engine/scheduling) publish reaches the web app through +the same revalidation bridge, with the delivery tags included - there is no second +cross-origin invalidation system, and the same all-origins-must-accept rule applies. + ## Where the Next imports live Exactly one place: `@vitnode/core/content/next`. diff --git a/apps/docs/content/docs/dev/content-engine/canonical-urls.mdx b/apps/docs/content/docs/dev/content-engine/canonical-urls.mdx new file mode 100644 index 000000000..97da9fae2 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/canonical-urls.mdx @@ -0,0 +1,176 @@ +--- +title: Canonical URLs +description: One helper builds every content URL, it is relative on purpose, and the locale is normalized so one page has one cache key. +icon: Link +--- + +A canonical URL is the one address a page admits to living at. Everything else - +redirects, `hreflang`, sitemaps, cache tags - is defined in terms of it, so the +engine builds it in exactly one place. + +```ts +import { contentDeliveryPath } from "@vitnode/core/content"; + +contentDeliveryPath({ definition: articleContentType, slug: "my-article" }); +// "/articles/my-article" + +contentDeliveryPath({ + definition: advancedArticleContentType, + locale: "pl", + slug: "moj-artykul", +}); +// "/pl/articles/moj-artykul" +``` + +## How a path is built + +```text +nonlocalized /{publicApi.path}/{slug} +localized /{locale}/{publicApi.path}/{slug} +``` + +Nothing is configurable here, and that is the point: a resolver has to be able to +parse back what the builder produced, and a per-content-type URL template would +make that a guess. `publicApi.path` is reused rather than duplicated into +`delivery`, so the public API route and the public page cannot disagree about the +prefix. + +## It is relative + +`contentDeliveryPath` never returns an origin, because a content type definition +lives in source control and gets deployed to a preview domain, a staging domain and +production - so an origin baked into it would be wrong in two of the three places. + +Supply one when you need an absolute URL: + +```ts +import { contentDeliveryUrl } from "@vitnode/core/content"; + +contentDeliveryUrl({ + origin: "https://example.com", + path: "/pl/articles/moj-artykul", +}); +// "https://example.com/pl/articles/moj-artykul" +``` + +`https://example.com` and `https://example.com/` produce the same URL - it resolves +rather than concatenates - and a malformed origin comes back `null` rather than a +link with two schemes in it. + +The delivery service takes the same argument: + +```ts +await delivery.findById(42, { locale: "pl", origin: "https://example.com" }); +// { canonicalPath: "/pl/articles/…", canonicalUrl: "https://example.com/pl/articles/…", … } +``` + +`canonicalUrl` is **absent** rather than `null` when no origin was given, so a +consumer never has to tell "no origin was supplied" from "the URL could not be +built". + +<Callout type="info" title="Sitemaps are the exception"> + The sitemap protocol only accepts absolute URLs, so `contentSitemapXml` requires + an origin rather than taking one. See [Sitemaps](/docs/dev/content-engine/sitemaps). +</Callout> + +## The locale is normalized + +```ts +contentDeliveryPath({ definition, locale: "PL", slug: "witaj" }); +contentDeliveryPath({ definition, locale: "pl", slug: "witaj" }); +contentDeliveryPath({ definition, locale: " pl ", slug: "witaj" }); +// all three: "/pl/articles/witaj" +``` + +Same `normalizeContentLocale` the rest of the engine uses. It matters because a +path is also a cache key: three spellings of one locale producing three paths would +produce three cache entries for one page, and expiring one of them would leave the +other two stale forever. + +Slugs are percent-encoded on the way in. A generated slug is already URL-safe - +[`slugify`](/docs/dev/content-engine/slug-field) guarantees it - but a row written +straight into the database is not, and a *path* is what this function promises. + +## Nulls are deliberate + +`contentDeliveryPath` returns `null` rather than a best effort in three cases: + +- **An empty slug.** A canonical URL that points at the list page is worse than no + canonical URL at all. +- **An empty `publicApi.path`.** The content type has no public API. +- **A localized content type with no locale.** A localized record has one URL per + language and no locale-less one, so guessing would hand a reader the wrong + language under a URL that claims otherwise. + +## Parsing a path back + +```ts +import { parseContentDeliveryPath } from "@vitnode/core/content"; + +parseContentDeliveryPath(articleContentType, "/articles/my-article"); +// { locale: null, slug: "my-article" } + +parseContentDeliveryPath(advancedArticleContentType, "/pl/articles/moj-artykul"); +// { locale: "pl", slug: "moj-artykul" } +``` + +The inverse of the builder, and deliberately strict: it accepts exactly the shape +that function produces and refuses everything else. An extra segment, a different +public prefix, a traversal or a malformed escape is `null` rather than a best guess +- a resolver that guessed would answer one content type's URL with another's +record. + +A query string and a fragment are stripped first, because a browser sends them and +they are not part of the identity of a page. + +`delivery.resolvePath()` is this plus the lookup, and it is what a catch-all route +should call: + +```ts +const resolution = await delivery.resolvePath("/pl/articles/stary-slug"); +``` + +## The canonical URL is the *served* locale + +This is the rule most likely to be got wrong, and it comes straight out of +[Stage 5 fallback](/docs/dev/content-engine/localized-public-api): + +```text +requestedLocale = pl +PL translation missing +fallback EN translation exists +``` + +A public `findById()` may return the English copy. The canonical URL of that +response is the **English** one: + +```ts +{ + requestedLocale: "pl", + locale: "en", + isFallback: true, + canonicalPath: "/en/articles/article", +} +``` + +`/pl/articles/article` would be a self-declared canonical that answers 404 - the +Polish translation does not exist, so nothing serves that URL. Reporting the served +locale is what lets a page render `<link rel="canonical">` correctly *and* show a +"not translated yet" notice. + +`findBySlug` and `resolveSlug` remain strict-locale: a URL belongs to the language +it was published under, so they never fall back at all. + +## Registry helpers + +```ts +import { listDeliveryContentTypes } from "@vitnode/core/content"; + +const delivered = listDeliveryContentTypes(core.contentModels); +``` + +Every delivery-enabled content type of an installation, sorted by id so two +processes building the same sitemap index produce the same document. It is what +lets a site-level `/sitemap.xml` enumerate `blog.article`, `docs.page` and +`shop.category` without hardcoding a single plugin name - installing a plugin adds +its content types and removing it takes them out again. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx new file mode 100644 index 000000000..23a85f327 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx @@ -0,0 +1,166 @@ +--- +title: Delivery limitations +description: What Content Delivery deliberately does not do, and the reasoning behind each line - so you can tell a gap from a decision. +icon: OctagonAlert +--- + +Delivery is metadata and routing infrastructure. Most of the list below is not +"unfinished" - it is the shape of that decision. + +## It does not render anything + +No page builder, no layout builder, no block renderer, no React page generation. The +engine answers "what is the URL of this, and what should the page say about itself"; what +the page *is* belongs to the application. + +The practical consequence: delivery returns a path and a metadata object, and a plugin or +an app builds `/pl/articles/moj-artykul` from them. It does not assume your route +structure and it will not generate one. + +## No manual redirect manager + +There is no UI, no route and no service for creating a redirect by hand - and none for +deleting one. + +The AdminCP panel is **read-only** on purpose. A redirect is somebody else's incoming +link, so deleting one silently breaks traffic nobody in that dialog can see. That is a +destructive action, and a destructive action needs its own permission, a confirmation +that explains the consequence, and an audit trail. Displaying the history is useful +today; managing it is a product rather than a button. + +Consequences worth knowing: + +- A historical address stays reserved for as long as redirects are enabled. There is no + way to release one through the engine, so + [slug reuse](/docs/dev/content-engine/slug-history-and-redirects#historical-addresses-are-reserved) + by an unrelated record is refused permanently. +- If you genuinely need to release one, delete the row. It is an ordinary table, and the + [migrations guide](/docs/dev/content-engine/content-delivery-migrations) documents its + shape. + +There are also no **wildcard** or **regex** redirects, and no redirects between +arbitrary URLs. Slug history maps one record's old addresses to that record's current +one; a rule engine over paths is a different feature living in a different layer (a +middleware, a CDN, a `next.config` `redirects` array). + +## No og:image + +`delivery.seo.openGraph` projects a title and a description, and stops there. + +An `og:image` needs an absolute URL, known dimensions and a stable content type for the +file - which is a media subsystem, and Stage 8 does not build one. Emit it from your own +`generateMetadata` alongside the delivery metadata: + +```ts +const metadata = await contentDeliveryMetadata({ … }); + +return { + ...metadata, + openGraph: { ...metadata.openGraph, images: [await coverImageFor(slug)] }, +}; +``` + +## noIndex is shared, not per locale + +`delivery.seo.noIndexField` must be a **shared** boolean, and a localized one is a +definition-time error. + +The reason is that one field drives two consumers - the sitemap exclusion and the +`robots` directive - and they have to agree. A per-locale value would give one record one +answer per language while it has a single canonical decision, and the two consumers could +then disagree about which URLs exist. + +Per-locale indexing is a real thing to want. It is deferred rather than approximated, +because doing it properly means a per-locale sitemap decision *and* a per-locale +`robots`, both derived from the translation actually being served. + +## A localized content type needs a localized slug for redirects + +```ts +// ✖ localized content type, shared slug field +delivery: { enabled: true, redirects: { enabled: true } } +``` + +Every language answers to the same segment, so `/en/x/hello` and `/pl/x/hello` are both +live and one slug change moves both at once. Slug history stores *the URL that was live*, +so one retired row would have to be several paths - and the panel would show one of them +as if it were the address somebody bookmarked. + +Canonical URLs, SEO, alternates and the sitemap all work in that shape. Only the +reservation is ambiguous, so only `redirects` is refused. Mark the slug +`localized: true` and everything is available. + +## 410 Gone is not distinguished from 404 + +A historical URL whose destination is unpublished or deleted answers `not_found`. + +A `410` would be more informative for a deletion - it tells a crawler to forget the URL - +but the engine has no tombstone abstraction that distinguishes "deleted on purpose" from +"unpublished for now", and a `410` that guessed would tell a crawler to forget a URL that +is coming back next week. One documented status, chosen because it is the one that is +always correct. + +## The redirect status is not configurable + +Always `308`. Every historical URL of every content type answers with it, so there is no +per-content-type setting to get wrong and no reason for two of them to disagree. `301` is +not offered: it lets a client rewrite the method to `GET`, and `308` does not. + +## Sitemap frequency and priority are static + +Per content type, not per record. There are no dynamic callbacks: a function that runs +once per URL in a 50,000-URL file is a performance decision disguised as a configuration +option. + +## No site-wide robots.txt + +`delivery.seo.noIndexField` is per record. Site-wide crawl rules - `Disallow`, crawl +delay, sitemap declarations - are application or core configuration, not something a +content type gets to influence. + +## No delivery mutations in the service + +`model.deliveryService` is read-only, and structurally so: slug history is written by the +editorial services inside the transaction that moves the slug, so there is no `reserve` +to call without one. Admin or manual history mutation, if it ever exists, will be a +separate API with its own permission. + +## Delivery metadata is not a revision + +SEO is derived from content fields, so it already participates in +[revisions](/docs/dev/content-engine/revisions) - restoring a revision that had a +different `seo.title` changes the derived metadata on the next read. There is no separate +SEO history, and there will not be one: two revision systems over the same values is two +things to keep in sync. + +## itemId can be absent + +Delivery metadata reports `itemId: null` for a content type whose `publicApi.fields` +withholds `"id"`. + +That is deliberate rather than a gap: delivery reads the **public projection**, so it +cannot report a column the public API declined to publish. Expose `"id"` in the allowlist +and it is always present. A sitemap entry always carries it, because a sitemap row is +built from the row rather than from the projection. + +## Not in Stage 8 at all + +For the avoidance of doubt: no domain management, no CDN configuration, no content +approval workflow, no collaborative editing, no AI SEO generation, no AI translation, no +translation memory, no external TMS, no GraphQL, no semantic search, no analytics, no A/B +testing and no personalized URLs. + +## See also + +<Cards> + <Card + href="/docs/dev/content-engine/limitations" + title="Content Engine limitations" + description="The stage-wide list." + /> + <Card + href="/docs/dev/content-engine/content-delivery-migrations" + title="Delivery migrations" + description="What is not backfilled, and how to backfill it yourself." + /> +</Cards> diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx new file mode 100644 index 000000000..3a55bc025 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-migrations.mdx @@ -0,0 +1,184 @@ +--- +title: Delivery migrations +description: One new core table, one deterministic migration, and a clear statement about what history does *not* get backfilled. +icon: Database +--- + +Enabling `delivery` adds no columns to your content tables. It needs one shared core +table, and that is the whole schema change. + +## The migration + +```bash +pnpm drizzle-kit generate --name=add_content_slug_history +pnpm db:migrate +``` + +```sql +CREATE TABLE "core_content_slug_history" ( + "id" serial PRIMARY KEY NOT NULL, + "pluginId" varchar(255) NOT NULL, + "contentTypeId" varchar(100) NOT NULL, + "itemId" integer NOT NULL, + "languageId" integer, + "slug" varchar(160) NOT NULL, + "path" varchar(512) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "retiredAt" timestamp +); +ALTER TABLE "core_content_slug_history" ENABLE ROW LEVEL SECURITY; + +CREATE UNIQUE INDEX "core_content_slug_history_shared_unique" + ON "core_content_slug_history" ("contentTypeId","slug") + WHERE "languageId" IS NULL; + +CREATE UNIQUE INDEX "core_content_slug_history_locale_unique" + ON "core_content_slug_history" ("contentTypeId","languageId","slug") + WHERE "languageId" IS NOT NULL; + +CREATE INDEX "core_content_slug_history_item_idx" + ON "core_content_slug_history" ("contentTypeId","itemId","languageId"); + +CREATE INDEX "core_content_slug_history_plugin_id_idx" + ON "core_content_slug_history" ("pluginId"); +``` + +One table for every delivery-enabled content type in the install, for the same reason +`core_content_revisions` is shared: a content table is generated at runtime from a +descriptor, so core's static schema cannot name it - and a per-type history table would +mean a second generated table and a second migration for every plugin. + +### The indexes are the feature + +| Index | What it is for | +| -------------------- | ----------------------------------------------------- | +| `…_shared_unique` | The reservation, for a nonlocalized or shared slug | +| `…_locale_unique` | The reservation, per language | +| `…_item_idx` | One record's history, and retiring the slug it moved off | +| `…_plugin_id_idx` | Ownership, for an audit or a cleanup | + +The two uniques double as the resolver's lookup, which is why they lead with +`(contentTypeId, slug)`: a redirect lookup runs on a public request path for a URL that +is very often a typo, so it has to be an index hit rather than a scan. The PostgreSQL +suite asserts all four exist and that the shared one really is partial. + +Two partial uniques rather than one over a nullable `languageId`, because Postgres treats +every `NULL` as distinct - a single key including it would enforce nothing at all for the +shared case it exists to protect. + +## History is not backfilled + +**Nothing existing gets a history row.** History starts when Stage 8 begins tracking +future public slug changes, and that is a decision rather than an omission: + +```text +existing published article, slug = hello + → no history row + → /articles/hello is canonical, as it always was + → the *next* slug change creates the redirect +``` + +A record's current slug does not need to be history - it is the canonical URL, and the +resolver finds it through the ordinary public read. The first row for a record is written +the next time it is published or the next time its live slug moves. + +### Why revisions are not scanned + +It is tempting to reconstruct history from +[Stage 4 revisions](/docs/dev/content-engine/revisions), and the engine deliberately does +not: + +- **Revision snapshots include draft-only slugs.** A record whose slug was corrected + three times before publication would produce three redirects to URLs nobody ever + visited - and three permanent reservations blocking those addresses. +- **Publication timing is ambiguous.** A snapshot records the values at a version, not + whether that version was ever the *live* one. Reconstructing "was this slug + addressable" from a revision list means guessing. +- **Old schemas differ.** A snapshot taken before a field was renamed does not name the + slug field the content type has today. + +An automatic backfill would therefore create incorrect redirects, and an incorrect +permanent redirect is worse than a missing one: it sends real traffic somewhere wrong and +reserves an address nobody can reclaim. + +### An explicit backfill, if you want one + +If you *know* your data - because you have an access log, an external redirect map, or a +changelog - insert the rows yourself. The shape is documented above and the engine reads +it directly: + +```sql +INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path", "retiredAt") +VALUES + ('@vitnode/example', 'example.article', 42, NULL, + 'old-slug', '/articles/old-slug', now()); +``` + +Three rules to hold: + +1. **`retiredAt` must be set** for a historical address. A `NULL` means "this is the + record's current slug", and two current rows for one record is a state the engine does + not produce. +2. **`path` must be the URL that was live**, not one rebuilt from today's + `publicApi.path`. That is the whole reason the column exists. +3. **`languageId`** is the language for a localized slug and `NULL` for a shared one - + matching `delivery.slugScope`. Getting it wrong puts the row in the other partial + unique index and the resolver will not find it. + +Verify with the AdminCP delivery panel: it lists exactly what the resolver will use. + +## Changing `publicApi.path` + +```text +/articles → /blog +``` + +This is **source configuration**, not a content mutation, and the engine creates no +redirects for it. Every record's URL changes at once, at deploy time, for a reason no +row in the database records. + +Automating it would mean writing one history row per record on boot - a migration +disguised as a config change, running inside a process that may be one of several +starting at the same moment. So it is left to you, deliberately: + +```sql +-- One row per published record, with the old prefix. +INSERT INTO "core_content_slug_history" + ("pluginId", "contentTypeId", "itemId", "languageId", "slug", "path", "retiredAt") +SELECT + '@vitnode/example', 'example.article', a."id", NULL, + a."slug", '/articles/' || a."slug", now() +FROM "example_articles" a +WHERE a."status" = 'published' AND a."publishedAt" IS NOT NULL +ON CONFLICT DO NOTHING; +``` + +That is safe because the slug is unchanged - only the prefix moved - so the retired +`path` is the old URL and the resolver's destination is the record's current canonical +path under the new prefix. Run it in the same deploy as the config change. + +Stage 8's automatic redirects are for **slug changes**. Route-prefix migrations are a +deployment decision, and they get explicit tooling or explicit SQL. + +## Turning delivery off + +Removing the `delivery` block stops the engine reading or writing history. The table and +its rows stay - which is what you want, because turning it back on restores every +redirect rather than starting from nothing. + +Nothing else about the content type changes: no columns are dropped, no routes disappear +beyond the three delivery ones, and no cache tag it produced was ever a delivery tag. + +## Adding delivery to an existing content type + +Safe and additive: + +1. Add the block. No schema change to your table. +2. Run the core migration if you have not already. +3. Existing published records keep their canonical URLs and gain sitemap entries + immediately. +4. The first slug change on a published record creates the first redirect. + +There is no reindex, no rebuild and no backfill step - which is the practical +consequence of delivery being a projection over data the content type already had. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-nextjs.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-nextjs.mdx new file mode 100644 index 000000000..e6b7594ff --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-nextjs.mdx @@ -0,0 +1,253 @@ +--- +title: Delivery in Next.js +description: A thin adapter that turns framework-neutral delivery metadata into a generateMetadata return value, a sitemap.ts and a 308 - and nothing more. +icon: FileCode +--- + +The core delivery layer is framework-neutral on purpose, so `@vitnode/core/content/next` +is a translation layer and nothing else: it maps delivery metadata onto the two shapes +Next.js asks for, and issues the redirect the resolver reports. + +It reads over **HTTP** rather than through `model.deliveryService`, because in VitNode's +split deployment the web app is not the process that holds the database. A +single-process install can call the service directly and skip this entirely. + +## generateMetadata + +```tsx title="src/app/[locale]/articles/[slug]/page.tsx" +import { contentDeliveryMetadata } from "@vitnode/core/content/next"; + +import { articleContentType } from "@vitnode/example/content/article"; + +export const generateMetadata = async ({ + params, +}: { + params: Promise<{ locale: string; slug: string }>; +}) => { + const { locale, slug } = await params; + + return await contentDeliveryMetadata({ + definition: articleContentType, + locale, + origin: "https://example.com", + pluginId: "@vitnode/example", + slug, + }); +}; +``` + +That produces, for a published record: + +```ts +{ + title: "My article", + description: "A summary.", + alternates: { + canonical: "https://example.com/en/articles/my-article", + languages: { + en: "https://example.com/en/articles/my-article", + pl: "https://example.com/pl/articles/moj-artykul", + "x-default": "https://example.com/en/articles/my-article", + }, + }, + openGraph: { + title: "My article", + description: "A summary.", + url: "https://example.com/en/articles/my-article", + }, + robots: { index: true, follow: true }, +} +``` + +Every key is **absent** rather than present-and-null when there is no value. Next +renders a `null` title as an empty `<title>` and an absent one not at all, and an empty +`<title>` is worse than none. + +`{}` for a URL that does not resolve, rather than a throw: `generateMetadata` runs +alongside the page, the page is what calls `notFound()`, and a metadata function that +threw would replace a clean 404 with an error boundary. + +### Pass an origin + +Optional, and strongly recommended. Without it every URL in the result is relative - a +relative `canonical` is legal and resolves against the page, and an absolute one is +what every SEO checker asks for. + +### The pure half + +```ts +import { contentDeliveryToNextMetadata } from "@vitnode/core/content/next"; + +contentDeliveryToNextMetadata(deliveryResponse, { origin }); +``` + +Exported separately so a page that already holds the delivery response - because it +fetched the record and its metadata together - can translate it without a second round +trip. It is also what makes the mapping unit-testable without a network. + +## The page: redirect, render, or 404 + +```tsx title="src/app/[locale]/articles/[slug]/page.tsx" +import { contentDeliveryPage } from "@vitnode/core/content/next"; + +const Page = async ({ + params, +}: { + params: Promise<{ locale: string; slug: string }>; +}) => { + const { locale, slug } = await params; + + // Only returns for the current slug: a moved URL has already 308ed, and a missing + // one has already 404ed. + const delivery = await contentDeliveryPage({ + definition: articleContentType, + locale, + pluginId: "@vitnode/example", + slug, + }); + + return <Article delivery={delivery} />; +}; + +export default Page; +``` + +`contentDeliveryPage` is the only helper in the adapter with a side effect, which is why +it lives in its own module: `next/navigation`'s control-flow functions throw to unwind +the render, so a page that only wanted metadata should not be able to reach them by +accident. + +It issues a **308** via `permanentRedirect`, with `RedirectType.replace` so a reader who +follows an old link does not have to press back twice to leave a page they were never +meant to land on. + +A draft, an unpublished record, a deleted one, a slug that never existed and a +historical URL whose destination is no longer public are all the same `notFound()`. A +redirect to hidden content would be a way to confirm it exists. + +<Callout type="info" title="Why not vitnode-frontend/navigation"> + That wrapper is the locale-aware one every app-level redirect should use, and this is + the one place it would be wrong: a delivery location is a **complete** path that + already carries its locale segment - the engine built it - so routing it through + `next-intl` would prefix the locale a second time. It is also a 307, and a canonical + slug change needs the permanent, method-preserving 308. +</Callout> + +### Resolving without acting + +```ts +import { contentDeliveryResolve } from "@vitnode/core/content/next"; + +const resolution = await contentDeliveryResolve({ + definition: articleContentType, + locale, + pluginId: "@vitnode/example", + slug, +}); + +switch (resolution.type) { + case "content": + return resolution; // canonical metadata + case "redirect": + return resolution; // { location, status: 308 } + case "not_found": + return null; +} +``` + +A discriminated union, so a caller branches on `type` rather than inferring which arm it +is holding. The route answers `not_found` as a **200 with a body** rather than a 404, +which is what lets a caller tell "this URL resolves to nothing" from "the delivery API +is unreachable" - and keeps a negative out of the response cache, so publishing the +record makes it resolve immediately. + +## Sitemap + +```ts title="src/app/sitemap.ts" +import { contentSitemapEntries } from "@vitnode/core/content/next"; + +const sitemap = async () => { + const { entries } = await contentSitemapEntries({ + definition: articleContentType, + origin: "https://example.com", + pluginId: "@vitnode/example", + }); + + return entries; +}; + +export default sitemap; +``` + +It pages through the delivery sitemap route until the cursor runs out, so a content type +with 40,000 published records is 40 requests rather than one enormous response. +`maxPages` (default 100) is a backstop, because an unbounded loop against a paginated +API is the one bug in this file that could take a site down - and reaching it is +reported through `truncated` rather than thrown, so a partial sitemap is still a valid +sitemap. + +```ts +const { entries, truncated } = await contentSitemapEntries({ … }); +if (truncated) { + // Split with `generateSitemaps` - see contentSitemapChunks. +} +``` + +Next caps a `sitemap.ts` at 50,000 URLs and splits beyond that with `generateSitemaps`; +[`contentSitemapChunks`](/docs/dev/content-engine/sitemaps#scaling-past-one-file) is the +helper that decides how many files that is. + +For a localized site, one call per locale: + +```ts +const sitemap = async () => { + const locales = ["en", "pl"]; + const pages = await Promise.all( + locales.map(async locale => + ( + await contentSitemapEntries({ + definition: articleContentType, + locale, + origin: "https://example.com", + pluginId: "@vitnode/example", + }) + ).entries, + ), + ); + + return pages.flat(); +}; +``` + +## Cache tags + +Every read here is `cache: "force-cache"` and carries the delivery tag that a mutation +expires: + +| Helper | Tag | +| -------------------------- | ----------------------------------------- | +| `contentDeliveryResolve` | `content:{id}:redirect:{locale?}:{slug}` | +| `contentDeliveryItem` | `content:{id}:delivery:{locale?}:{itemId}` | +| `contentSitemapEntries` | `content:{id}:sitemap:{locale?}` | + +`resolve` is tagged by the **slug** rather than the record, which is what makes a moved +page stop being served from its former URL: a slug change expires the old address's +lookup and the record's metadata at the same moment. + +`contentDeliveryItem` is tagged by the record, which makes it the right call for a page +that already knows which record it is rendering - an edit to the SEO description expires +it, and an unrelated record's publish does not. + +See [Cache behaviour](/docs/dev/content-engine/caching) for the whole tag list and what +expires each one. + +## Metadata types are not in core + +`ContentDeliveryNextMetadata` is a structural type rather than an +`import type { Metadata } from "next"`, so the core package does not grow a +compile-time dependency on the framework's type surface for four keys. It is assignable +to `Metadata`, which is what a `generateMetadata` needs it to be. + +That is the same reason the core engine returns `{ languages, xDefault? }` rather than +Next's `alternates` shape: move to Astro and you write a different forty lines against +the same service. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx new file mode 100644 index 000000000..04ae39f38 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx @@ -0,0 +1,217 @@ +--- +title: Content Delivery +description: Opt a content type into canonical URLs, slug history, redirects, hreflang, SEO metadata and sitemaps - without the engine rendering a single page. +icon: Route +--- + +Stages 1–7 gave content a table, a lifecycle, a public API, translations and a +history. None of them answered the question a frontend actually asks: + +```text +What is the URL of this thing? +``` + +`delivery` is the block that answers it - and the four questions that follow from +it: what was its URL before, should the old one redirect, which other languages +does it exist in, and what should the page put in `<head>`. + +```ts title="src/content/article.ts" +export const articleContentType = defineContentType({ + id: "example.article", + tableName: "example_articles", + + fields: { + title: field.text({ required: true, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + }, + + publication: { enabled: true }, + + publicApi: { + enabled: true, + path: "articles", + fields: ["id", "title", "slug", "excerpt", "publishedAt"], + }, + + delivery: { // [!code highlight] + enabled: true, // [!code highlight] + redirects: { enabled: true }, // [!code highlight] + seo: { // [!code highlight] + titleField: "title", // [!code highlight] + descriptionField: "excerpt", // [!code highlight] + }, // [!code highlight] + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, // [!code highlight] + }, // [!code highlight] + + admin: { label: { plural: "Articles", singular: "Article" } }, +}); +``` + +That is the whole opt-in. Omit the block and **nothing** about the content type +changes: same tables, same routes, same cache tags, same events, same everything. + +## What delivery is not + +Delivery is metadata and routing infrastructure. It is deliberately **not** a page +builder, and the line is worth stating plainly because it explains most of the API: + +- It returns a **path**, not a page. No React, no layout, no blocks. +- It does not assume your route structure. `/pl/articles/x` is what the engine + builds from `publicApi.path`, and a frontend is free to serve it from anywhere. +- It does not know your domain. Canonical paths are relative; you supply an origin + when you want an absolute URL. + +What it gives you is enough typed metadata that a plugin or an app can render +`/pl/articles/moj-artykul` and `/en/articles/my-article` correctly - including the +`hreflang` set, the redirect from the URL that page used to live at, and the +sitemap entry. + +## The blocks + +| Block | What it adds | +| ----------- | ------------------------------------------------------------------ | +| `redirects` | Durable [slug history](/docs/dev/content-engine/slug-history-and-redirects) and automatic 308s | +| `seo` | [Title, description, Open Graph and robots](/docs/dev/content-engine/seo) projection | +| `sitemap` | A paginated [sitemap service](/docs/dev/content-engine/sitemaps) | +| `hreflang` | An `x-default` for [localized alternates](/docs/dev/content-engine/localization-and-hreflang) | + +Every one of them is optional. `delivery: { enabled: true }` on its own gives you +canonical URLs and alternates, which is already the hard part. + +## Delivery requires a public API + +```ts +delivery: { enabled: true } +// ✖ without `publicApi: { enabled: true }` +``` + +A content type with no public API has no public URL, so there is nothing for +delivery to be about. That is a **compile error** on the `enabled: true` itself, +not just a boot-time throw: + +```ts +// Type 'true' is not assignable to type 'never'. +``` + +The runtime check stays as well, for a JavaScript caller and for a value that +widened somewhere upstream. Every other delivery rule works the same way - see +[Validation rules](#validation-rules). + +## The service + +`model.deliveryService(c, { pluginId })` is the server API. It is read-only, and +that is structural rather than a convention: slug history is written by the +editorial services inside the transaction that moves the slug, so there is no +`reserve` here to call without one. + +```ts +const delivery = articleContent.deliveryService?.(c, { pluginId }); + +await delivery?.findById(42, { locale: "pl" }); +await delivery?.resolvePath("/pl/articles/stary-slug"); +await delivery?.alternates(42); +await delivery?.sitemap({ locale: "pl", limit: 1_000 }); +await delivery?.history(42); +``` + +`undefined` for a content type without `delivery`, exactly like `publicService` +and `editorialService` - so the check reads naturally in code that does not know +which content type it was handed. + +Every answer is derived from the **public projection**, not from the base row: +`findById` and `resolveSlug` go through `model.publicService`, so the publication +predicate, the field allowlist and the +[fallback rules](/docs/dev/content-engine/localized-public-api) are the ones +already tested rather than a second implementation that agrees on the day it is +written. It is also what makes "SEO cannot leak a private field" true at runtime: +a private column is never fetched, so it is not in the row delivery reads. + +## Generated routes + +A delivery-enabled content type gains three public routes: + +```http +GET /api/{pluginId}/content/{path}/delivery/resolve/{slug} +GET /api/{pluginId}/content/{path}/delivery/item/{id} +GET /api/{pluginId}/content/{path}/delivery/sitemap +``` + +They exist because a frontend is very often **not** the process that holds the +database: VitNode's split deployment runs Next.js against a separate API, so +`generateMetadata`, a catch-all route and a `sitemap.ts` handler all need an HTTP +answer rather than a service call. A single-process install can call the service +directly and never touch them. + +Every path begins with the static `delivery` segment, which is what makes them +impossible to shadow: `/{slug}` is one segment and these are two or three, so a +record whose slug is literally `delivery` still resolves the ordinary way. + +<Callout type="info" title="No staff permission"> + Public delivery resolution is exactly as public as the content it describes. + Requiring a session to learn a canonical URL would be requiring one to render a + page. The [AdminCP route](/docs/dev/content-engine/slug-history-and-redirects#admincp) + that shows historical URLs is a different route, and it does require `can_view`. +</Callout> + +## Validation rules + +Delivery fails at **definition time** rather than at request time, because a +canonical URL that quietly stopped being generated is a page that quietly stopped +being indexable - and that is not a symptom anybody notices. + +| Rule | Result | +| ------------------------------------------------------ | -------------------------- | +| `delivery` without `publicApi` | Compile error + throw | +| `sitemap` without `publication` | Throw | +| `redirects` on a localized type with a **shared** slug | Throw | +| An SEO field not in `publicApi.fields` | Compile error + throw | +| A `textarea` as `titleField` | Compile error + throw | +| A repeatable leaf in any SEO slot | Compile error + throw | +| A non-boolean `noIndexField` | Compile error + throw | +| A **localized** `noIndexField` | Throw | +| `sitemap.priority` outside `0`–`1` | Throw | +| An unknown `changeFrequency` | Compile error + throw | +| `hreflang` without `localization` | Throw | +| A fallback SEO field with no primary | Throw | + +The last one is worth a word: `fallbackTitleField` without `titleField` is a +configuration that reads as if it does something and does nothing, because the +fallback is only consulted when the primary is empty. Naming only the fallback +means it is never reached, so the engine tells you to name it as the primary +instead. + +## Where to go next + +<Cards> + <Card + href="/docs/dev/content-engine/canonical-urls" + title="Canonical URLs" + description="How a path is built, and why it is relative." + /> + <Card + href="/docs/dev/content-engine/slug-history-and-redirects" + title="Slug history and redirects" + description="When a URL becomes redirectable, and who owns it afterwards." + /> + <Card + href="/docs/dev/content-engine/seo" + title="SEO" + description="Title, description, Open Graph and robots, from public fields." + /> + <Card + href="/docs/dev/content-engine/sitemaps" + title="Sitemaps" + description="A paginated service, an XML helper and a sitemap index." + /> + <Card + href="/docs/dev/content-engine/localization-and-hreflang" + title="Localization and hreflang" + description="Alternates that are real published translations, and nothing else." + /> + <Card + href="/docs/dev/content-engine/content-delivery-nextjs" + title="Next.js helpers" + description="generateMetadata, sitemap.ts and the redirect." + /> +</Cards> diff --git a/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx new file mode 100644 index 000000000..a91a6267c --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx @@ -0,0 +1,207 @@ +--- +title: Localization and hreflang +description: Alternates are real published translations and nothing else - so an hreflang never points at a 404, and a fallback never fabricates a URL. +icon: Languages +--- + +A localized record has one URL per language, and a page has to announce the others. +`delivery.alternates()` is that list, and its defining property is what it leaves +out. + +```ts +await delivery.alternates(42); +// [ +// { locale: "en", path: "/en/articles/my-article" }, +// { locale: "pl", path: "/pl/articles/moj-artykul" }, +// ] +``` + +## An alternate is a promise that a URL resolves + +So it is included only when **all** of this holds: + +```text +the base row is published +AND the translation is published +AND publishedAt <= now +AND the installation still serves that language +``` + +That is the same subordinated predicate the +[localized public read](/docs/dev/content-engine/localized-public-api) applies - not +a second implementation of it - so an alternate can never describe something the +public API would refuse to serve. + +Ordered by locale, so two processes rendering the same `hreflang` set produce the +same markup. + +## Fallback never fabricates an alternate + +This is the rule to internalise: + +```text +Article #42 + EN published + PL published + DE draft +``` + +```text +alternates: /en/articles/… /pl/articles/… + (no DE) +``` + +A German reader with `fallback: "default"` will be *served* the English copy - that +is what fallback is for. But German has no URL of its own, so listing +`/de/articles/…` would announce an `hreflang` pointing at a 404 and invite a crawler +to index the same content twice under two addresses. + +Fallback decides **which translation answers a request**. It never creates a URL. + +## hreflang + +```ts +const metadata = await delivery.findById(42, { locale: "pl" }); + +metadata.hreflang; +// { +// languages: { en: "/en/articles/my-article", pl: "/pl/articles/moj-artykul" }, +// xDefault: "/en/articles/my-article", +// } +``` + +Framework-neutral by design: `{ languages, xDefault? }` rather than a Next.js +`Metadata` object, because the core engine has no business knowing which framework +renders it. The [Next.js adapter](/docs/dev/content-engine/content-delivery-nextjs) +turns it into `alternates.languages` in one line, and an Astro or Remix adapter would +do the same. + +## x-default + +```ts +delivery: { + enabled: true, + hreflang: { xDefault: "defaultLocale" }, +} +``` + +`"defaultLocale"` is the only supported value, and that is deliberate: an `x-default` +has to point at a URL that actually resolves, and the default locale's canonical path +is the one URL a localized record is guaranteed to have whenever it is public at all. + +It is emitted **only when that language is genuinely published**: + +```text +EN published, PL published → x-default = /en/articles/… +EN unpublished, PL published → no x-default at all +``` + +An `x-default` pointing at a translation the record does not have would be a hint to +crawl a 404 - worse than emitting nothing. + +Omit the block and no `x-default` is emitted. The engine will not invent a +locale-less route it does not serve. + +<Callout type="info" title="It needs localization"> + `delivery.hreflang` on a content type without `localization` is a definition-time + error. One language has no alternates, so there is nothing for an `x-default` to be + the default of. +</Callout> + +## Per-locale slug history + +Each locale's redirects are its own, because `languageId` is part of the history key: + +```text +EN: /en/articles/hello → /en/articles/hello-world +PL: /pl/articles/witaj (unchanged, no redirect created) +``` + +Changing the English URL retires an English address and reserves an English one. The +Polish history is not read and not written. + +The same slug may be retired independently in two locales - `/en/x/shared` and +`/pl/x/shared` are two URLs, so two different records may each own one of them: + +```sql +UNIQUE (contentTypeId, languageId, slug) WHERE languageId IS NOT NULL +``` + +A nonlocalized content type uses `languageId = NULL` and the other partial index. + +### A localized slug is required for redirects + +```ts +// ✖ localized content type, shared slug +fields: { + slug: field.slug({ source: "title" }), // shared + title: field.text({ localized: true, required: true }), +} +delivery: { enabled: true, redirects: { enabled: true } } +``` + +Every language would answer to the same segment, so `/en/x/hello` and `/pl/x/hello` +are both live and one slug change moves both at once. Slug history stores *the URL +that was live*, so one retired row would have to be several paths - and the AdminCP +would show one of them as if it were the address somebody bookmarked. + +Canonical URLs, SEO, alternates and the sitemap all work fine in that shape. Only the +reservation is ambiguous, so only `redirects` is refused. Mark the slug +`localized: true` and everything is available. + +## Unpublishing one language + +```text +EN translation unpublished + /en/articles/hello-world not_found + /en/articles/hello (retired) not_found + /pl/articles/witaj still canonical +``` + +One language going dark is not the record going dark. The resolver reads the live +subordinated publication state per locale, so nothing else is affected - and +republishing the English translation brings its redirects back. + +## Deleting one translation + +The translation's history is **kept**, exactly as a deleted record's is: the URL +existed, and the resolver answers `not_found` for it by finding no live translation +rather than by having forgotten it. + +## The locale is normalized everywhere + +`PL`, `pl` and `" pl "` produce one path, one cache tag and one history lookup. The +canonical spelling always comes back off `core_languages.code`, never the caller's +casing - see [Canonical URLs](/docs/dev/content-engine/canonical-urls#the-locale-is-normalized). + +## Localized sitemaps + +Each language is its own sitemap file, and a draft translation contributes nothing: + +```text +Article #42 EN published PL published DE draft + +/en/articles/article +/pl/articles/artykul +``` + +No fallback URLs, for the same reason there are no fallback alternates. See +[Sitemaps](/docs/dev/content-engine/sitemaps#localized-sitemaps). + +## Events + +A translation slug change emits the delivery events with the locale attached: + +```ts +{ + contentId: 42, + locale: "pl", + previousSlug: "stary-slug", + slug: "nowy-slug", + previousPath: "/pl/articles/stary-slug", + canonicalPath: "/pl/articles/nowy-slug", +} +``` + +They arrive **alongside** `translation_updated`, never instead of it. See +[Slug history and redirects](/docs/dev/content-engine/slug-history-and-redirects#events). diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json index 604d40ee6..b726eba91 100644 --- a/apps/docs/content/docs/dev/content-engine/meta.json +++ b/apps/docs/content/docs/dev/content-engine/meta.json @@ -36,6 +36,15 @@ "advanced-modeling-public-api", "advanced-modeling-migrations", "advanced-modeling-limitations", + "content-delivery", + "canonical-urls", + "slug-history-and-redirects", + "seo", + "localization-and-hreflang", + "sitemaps", + "content-delivery-nextjs", + "content-delivery-migrations", + "content-delivery-limitations", "admincp", "permissions", "events", diff --git a/apps/docs/content/docs/dev/content-engine/seo.mdx b/apps/docs/content/docs/dev/content-engine/seo.mdx new file mode 100644 index 000000000..fdc76d4a6 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/seo.mdx @@ -0,0 +1,232 @@ +--- +title: SEO metadata +description: Project a title, a description, Open Graph and a robots directive out of fields the public API already exposes - with explicit fallbacks and no invented text. +icon: Search +--- + +`delivery.seo` names which **public** fields become which piece of page metadata. +It projects; it never invents. + +```ts +delivery: { + enabled: true, + seo: { + titleField: "seo.title", + fallbackTitleField: "title", + descriptionField: "seo.description", + fallbackDescriptionField: "excerpt", + noIndexField: "syndication.noIndex", + openGraph: { + titleField: "seo.title", + descriptionField: "seo.description", + }, + }, +} +``` + +## Every field has to be public + +```ts +seo: { titleField: "internalNote" } +// ✖ Type error, and a definition-time throw +``` + +A `<title>` is rendered into a public page, so it has to be something the public API +would already have said out loud. That is a **compile error** as well as a runtime +one - `ContentDeliveryTitleField` extracts from `publicApi.fields`, so an +unexposed name is not in the union at all. + +It is also true at runtime for free, and this is the part worth understanding: SEO +is projected from the **public projection** rather than from the base row. A private +column is never fetched, so it is not in the object the projection returns - the +metadata cannot reach it even by mistake. + +## Field kinds + +| Slot | Kinds | Why | +| -------------------------- | -------------------- | ---------------------------------------------- | +| `titleField` | `text` | A `<title>` is one line, not a paragraph | +| `descriptionField` | `text`, `textarea` | Prose is exactly what a description is | +| `noIndexField` | `boolean`, **shared** | Two answers, one canonical decision | + +A **repeatable leaf** is refused in every slot: a page has one title and a +repeatable has many values. A **group leaf** is accepted everywhere - `seo.title` is +one column under a generated name, so it is one value. + +```ts +fields: { + seo: field.group({ + localized: true, + nullable: true, + fields: { + title: field.text({ nullable: true, maxLength: 200 }), + description: field.textarea({ nullable: true, maxLength: 500 }), + }, + }), +} +``` + +Paths use the same canonical dotted form the rest of the engine speaks - see +[Structured fields](/docs/dev/content-engine/structured-fields). There is no second +flatten/unflatten implementation here; delivery reads the projected row through +`readContentPath`, exactly as search does. + +## Fallbacks are explicit + +```ts +seo: { + titleField: "seo.title", + fallbackTitleField: "title", +} +``` + +The fallback is consulted when the primary resolves to `null` or to whitespace - a +`<title>` of three spaces is a missing title with extra steps. That covers the common +case exactly: nobody writes an SEO title twice, so `seo.title` is usually empty and +the article's real `title` is what should appear. + +There is deliberately **no** "derive a description from the first 160 characters of +the body". A summary somebody did not write is a summary nobody reviewed, and it +would silently become the description of every page that forgot to set one. + +<Callout type="warn" title="A fallback with no primary is refused"> + `fallbackTitleField` without `titleField` reads as if it does something and does + nothing: the fallback is only reached when the primary is empty, so on its own it + is never consulted. Name it as `titleField` instead. +</Callout> + +## The result + +```ts +const metadata = await delivery.findById(42, { locale: "pl" }); + +metadata.seo; +// { title: "Mój artykuł", description: "…" } +``` + +The shape is stable whether or not the block was configured - a content type that +names nothing gets `{ title: null, description: null }` - so a frontend never +branches on "was SEO set up", only on "did a value come back". + +## Open Graph + +```ts +seo: { + titleField: "seo.title", + openGraph: { titleField: "social.title" }, +} +``` + +`null` when the content type configured none, and that is a different fact from "it +did, and this page has no title" - a renderer treats them differently, because the +first emits no tags at all. + +Each Open Graph slot falls back to the ordinary SEO one, which makes the common case +- the same title in both places - a two-line config: + +```ts +openGraph: {} // inherits titleField and descriptionField +``` + +<Callout type="info" title="No og:image"> + Stage 8 does not implement a media subsystem, and an `og:image` needs one: an + absolute URL, known dimensions and a stable content type for the file. Emit it from + your own `generateMetadata` alongside the delivery metadata - see + [Limitations](/docs/dev/content-engine/content-delivery-limitations). +</Callout> + +## Robots and noindex + +```ts +seo: { noIndexField: "syndication.noIndex" } +``` + +```ts +metadata.robots; +// { index: false, follow: true } +``` + +One boolean drives **two** consumers, and that is the whole reason it exists as a +single field: a record excluded from the sitemap and a record reporting `index: +false` have to be the same record. Two settings would eventually disagree. + +`follow` is always `true`. "Do not list this page" and "do not follow the links on +it" are different instructions, and a content type that asked for the first has not +asked for the second - a `noindex, nofollow` page is a dead end for a crawler walking +the site, which is a decision for site-wide robots configuration rather than for one +record. + +`null` when no `noIndexField` is configured, so a content type that never thought +about indexing emits no `robots` meta tag rather than an affirmative "yes, index +this". + +### It has to be shared + +```ts +// ✖ a localized group's leaf +seo: { noIndexField: "flags.noIndex" } +``` + +A localized boolean would give one record one answer per language while it has a +single canonical decision - and the sitemap exclusion and the `robots` directive +would then be able to disagree. Delivery refuses it at definition time. + +Per-locale indexing is a real thing to want; it is deferred rather than approximated. +See [Limitations](/docs/dev/content-engine/content-delivery-limitations). + +## Localized SEO + +A localized group gives every language its own copy: + +```ts +fields: { + seo: field.group({ localized: true, nullable: true, fields: { … } }), +} +``` + +```text +en: { title: "English SEO", description: "English summary" } +pl: { title: null, description: null } → falls back to the + Polish `title` +``` + +The fallback is **that language's own** field, never English's. The projection reads +one row - the translation being served - so it has nothing else to reach for. + +On a fallback read the metadata reports the locale it actually served, and the +canonical URL follows it: + +```ts +{ + requestedLocale: "pl", + locale: "en", + isFallback: true, + canonicalPath: "/en/articles/article", + seo: { title: "The English title", … }, +} +``` + +See [Canonical URLs](/docs/dev/content-engine/canonical-urls#the-canonical-url-is-the-served-locale). + +## SEO has no revision history of its own + +SEO is derived from content fields, so it already participates in +[revisions](/docs/dev/content-engine/revisions): + +```text +restore a revision that had seo.title = "Old heading" +→ seo.title is "Old heading" again +→ the delivery metadata says so on the next read +``` + +There is no second history to keep in sync, and restoring SEO is not a separate +operation. That is the reason `delivery.seo` names fields rather than storing values. + +## Search + +Changing an SEO field already triggers +[search synchronization](/docs/dev/content-engine/search) when the field is one +`search` indexes - there is no second indexing path, and delivery adds none. What +delivery guarantees is narrower and worth stating: a search document carries the +**current** canonical URL, and a historical URL never becomes a second document +competing with the page it redirects to. diff --git a/apps/docs/content/docs/dev/content-engine/sitemaps.mdx b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx new file mode 100644 index 000000000..f738b2040 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx @@ -0,0 +1,276 @@ +--- +title: Sitemaps +description: A cursor-paginated service that lists what is public right now, plus pure helpers that turn its entries into valid XML and a sitemap index. +icon: Map +--- + +```ts +delivery: { + enabled: true, + sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, +} +``` + +That is the whole configuration. The engine does not build a file - it answers "which +URLs are public right now", one page at a time, and leaves serialization to a pure +helper. + +## Querying and serializing are separate + +Deliberately, and it is the design decision that makes both testable: "which URLs are +public" is a keyset scan over two tables, and "what does a sitemap file look like" is +a string. Folded into one function, the XML would be untestable without a database +and the pagination untestable without parsing XML. + +```ts +// the query +const page = await delivery.sitemap({ locale: "pl", limit: 1_000 }); + +// the serialization +import { contentSitemapXml } from "@vitnode/core/content"; + +const xml = contentSitemapXml({ + entries: page.entries, + origin: "https://example.com", +}); +``` + +## One page of entries + +```ts +await delivery.sitemap({ cursor, limit, locale }); +``` + +```ts +{ + entries: [ + { + itemId: 42, + locale: "pl", + path: "/pl/articles/moj-artykul", + lastModified: new Date("2026-01-02T03:04:05.000Z"), + changeFrequency: "weekly", + priority: 0.7, + }, + ], + nextCursor: 42, // pass back as `cursor`; null on the last page +} +``` + +### Cursors, never offsets + +`cursor` is the last `itemId` of the previous page, and pagination is a keyset over +the primary key. An `OFFSET` deep into a large table both slows down linearly *and* +skips rows when something is published between two pages - and a sitemap is +regenerated from scratch every time a crawler asks, so both matter. + +Ordering is `ORDER BY id ASC`, which makes the output deterministic: no duplicates, no +gaps, and the same document from two processes. + +`limit` defaults to 1,000 and is capped at the protocol's 50,000. A page is one keyset +query plus one batched read, so 1,000 rows is a response a serverless function can +hold without thinking about it; a caller that wants a whole 50,000-URL file asks for +it explicitly. + +### Only what is public right now + +The publication predicate is not a parameter: + +```text +nonlocalized the base row published +localized the base row AND the translation published +``` + +A draft, an unpublished record and a `publishedAt` in the future are all simply +absent. The localized form is the same subordination the +[public read](/docs/dev/content-engine/localized-public-api) applies. + +## Localized sitemaps + +Each published translation is one URL, and each language is its own file: + +```text +Article #42 EN published PL published DE draft + +/en/articles/article +/pl/articles/artykul +``` + +No DE, and **no fallback URLs**. A locale served English through +`fallback: "default"` has no URL of its own, so listing one would put the same +content in the sitemap twice under two addresses. + +A locale that names no language this install serves gets an empty page rather than an +error - a crawler asking for `/sitemaps/blog.article-de.xml` on a site with no German +should get a valid empty document. + +## lastModified + +| Content type | Value | +| -------------- | -------------------------------------------- | +| nonlocalized | `base.updatedAt` | +| localized | `max(base.updatedAt, translation.updatedAt)` | + +The localized rule is the interesting one: both halves are rendered into the page, so +a **shared** field moving changes what every language's document says even though no +translation row was touched. Taking the translation's timestamp alone would tell a +crawler nothing had changed. + +<Callout type="info" title="Timestamps are read through the column's own decoder"> + Drizzle turns off the driver's timestamp parsing so its column mappers can treat a + naive `timestamp` as UTC. A raw `sql` fragment has no mapper, so the driver's + fallback parses the same value as *local* time - the two disagree by the server's + offset. The `greatest()` expression borrows the column's decoder with `.mapWith`, + which is why a localized `lastmod` is not hours out. The PostgreSQL suite asserts + it. +</Callout> + +## changeFrequency and priority + +Static, per content type, and validated: + +```ts +sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 } +``` + +`changeFrequency` must be one of the seven values the protocol defines - `always`, +`hourly`, `daily`, `weekly`, `monthly`, `yearly`, `never`. A crawler ignores an +unknown value silently, so a typo has to be a compile error or it is a hint nobody +ever receives. + +`priority` must be between `0` and `1` inclusive, and is emitted at one decimal place. + +Both are omitted from the XML when unset, which is valid. There are deliberately no +per-record dynamic callbacks: a function that runs once per URL in a 50,000-URL file +is a performance decision disguised as a configuration option. + +## Excluding one record + +```ts +seo: { noIndexField: "syndication.noIndex" } +``` + +`noIndex = true` removes the record from the sitemap **and** reports +`robots: { index: false }` - one boolean behind both, so they cannot disagree. It is a +single clause in the query rather than a post-filter, so a page of 1,000 entries is +1,000 listed URLs rather than however many survived. + +The field must be a shared boolean. See [SEO](/docs/dev/content-engine/seo#robots-and-noindex). + +## The XML helper + +```ts +import { contentSitemapXml } from "@vitnode/core/content"; + +contentSitemapXml({ entries, origin: "https://example.com" }); +``` + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>https://example.com/articles/my-article</loc> + <lastmod>2026-01-02T03:04:05.000Z</lastmod> + <changefreq>weekly</changefreq> + <priority>0.7</priority> + </url> +</urlset> +``` + +`origin` is required rather than optional: the protocol only accepts absolute URLs, so +this is the one place delivery cannot stay origin-agnostic. An entry whose path will +not resolve against it is **dropped** rather than emitted - one malformed `<loc>` is a +document a crawler may reject whole. + +XML's five predefined entities are escaped, with `&` first: escaping it after `<` would +turn the `<` just produced into `&lt;`. + +### hreflang inside a sitemap + +```ts +contentSitemapXml({ + alternates: await readDeliveryAlternatesMany({ c, itemIds, model }), + entries, + origin: "https://example.com", +}); +``` + +```xml +<url> + <loc>https://example.com/en/articles/my-article</loc> + <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/articles/my-article" /> + <xhtml:link rel="alternate" hreflang="pl" href="https://example.com/pl/articles/moj-artykul" /> +</url> +``` + +Opt-in, and standards-compliant: the namespace is declared on the root element, and +every alternate of a group is repeated inside **each** of its `<url>` entries - +including the entry's own. That last rule is the one implementations get wrong, and it +is why alternates are supplied per entry rather than derived: the caller has already +resolved which translations are published, and the serializer does not go looking. + +`readDeliveryAlternatesMany` batches a whole page into one query rather than one per +URL. + +## Scaling past one file + +```ts +import { contentSitemapChunks, contentSitemapIndexXml } from "@vitnode/core/content"; + +const { pages, size } = contentSitemapChunks({ total, size: 1_000 }); +``` + +```text +/sitemap.xml the index +/sitemaps/blog.article-1.xml +/sitemaps/blog.article-2.xml +``` + +```ts +contentSitemapIndexXml({ + entries: Array.from({ length: pages }, (_, page) => ({ + path: `/sitemaps/blog.article-${page + 1}.xml`, + })), + origin: "https://example.com", +}); +``` + +`pages` is at least `1` even for an empty content type, and that is on purpose: an +index that lists a file which does not exist is a broken index, and a content type +with nothing published today will have something tomorrow. `size` is clamped to the +protocol's 50,000-URL ceiling, so a caller cannot ask for one enormous invalid file. + +`contentSitemapIndexXml` emits `<sitemapindex>` with `<sitemap>` children - a separate +function from `contentSitemapXml` because it is a separate document type, and because +an index whose entries were `<url>` elements is the single most common way to publish a +sitemap no crawler reads. + +## A site-level sitemap + +```ts +import { listDeliveryContentTypes } from "@vitnode/core/content"; + +const delivered = listDeliveryContentTypes(core.contentModels); +``` + +Every delivery-enabled content type of the installation, sorted by id. Build one index +entry per content type per locale per chunk, and no plugin name is ever hardcoded - +installing a plugin adds its URLs and removing it takes them out again. + +## Memory + +Nothing here loads a content type whole: + +- one keyset page at a time, bounded by `limit`; +- one batched translation read per page, never one per row; +- one batched alternates read per page, when alternates are asked for; +- `noIndex` as a `WHERE` clause rather than a post-filter. + +The PostgreSQL suite pages a fixture in twos and asserts every record appears exactly +once, in ascending key order, with the cursor ending at `null`. + +## Next.js + +`contentSitemapEntries` pages through the delivery route and returns entries a +`sitemap.ts` can return directly. See +[Next.js helpers](/docs/dev/content-engine/content-delivery-nextjs#sitemap). diff --git a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx new file mode 100644 index 000000000..50febcb19 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx @@ -0,0 +1,335 @@ +--- +title: Slug history and redirects +description: Every URL a record was ever reachable at is recorded, reserved, and redirected to the current one - with no chains and no stolen addresses. +icon: CornerDownRight +--- + +Change the slug of a published article and its old URL stops existing. Every link +to it, every bookmark, every search result: gone. `delivery.redirects` is the block +that fixes that. + +```ts +delivery: { + enabled: true, + redirects: { enabled: true }, +} +``` + +From then on: + +```text +current: /articles/stary-slug +update: slug = nowy-slug + +after commit: + /articles/nowy-slug canonical + /articles/stary-slug 308 -> /articles/nowy-slug +``` + +## When a slug becomes redirectable + +This is the rule the whole feature rests on, so it is stated exactly: + +> A slug becomes redirectable only if it was **previously used by an addressable +> public version** - that is, the record (or the translation) was published while +> that slug was current. + +The consequence is the useful part: + +| Situation | Redirect? | +| ------------------------------------------------------ | --------- | +| Published as `a`, changed to `b` | ✅ `a → b` | +| Draft created as `a`, corrected to `b`, then published | ❌ none | +| Published as `a`, unpublished, changed to `b` | ❌ none *yet* | +| …then republished | ✅ `a → b` | + +A draft whose slug was corrected three times before anybody saw it produces no +redirects at all, because none of those URLs was ever live. Without that rule a +content type would accumulate a redirect per typo, and each one would be a +permanent claim on an address nobody had visited. + +## What is stored + +One shared table, `core_content_slug_history`: + +```text +id +pluginId +contentTypeId +itemId +languageId NULL for a shared slug +slug +path the URL exactly as it was live +createdAt +retiredAt NULL while this slug is the record's current address +``` + +Both states are stored - current *and* retired - and that is what makes the +uniqueness below a **reservation** rather than only a log. + +`path` is recorded rather than rebuilt on read, because it is the one thing the +engine cannot recompute later: a path is built from `publicApi.path`, which is +source configuration a developer may change. The URL that was live is a historical +fact, so it is kept as one - and the AdminCP shows exactly the address somebody's +bookmark holds. + +<Callout type="info" title="No foreign key to the record"> + Same reasoning as `core_content_revisions`: the target table is generated at + runtime, so core's static schema cannot name it. Every query is scoped by + `(contentTypeId, itemId)`, and a URL's history stays true after the record is + gone. +</Callout> + +## Historical addresses are reserved + +```text +Article 1: old slug = hello current = hello-world +Article 2: slug = hello ✖ CONTENT_DELIVERY_SLUG_RESERVED +``` + +`hello` is free on the content table - Article 1 moved off it - so without the +reservation Article 2 could take it, and `/articles/hello` would silently stop +redirecting to the article it belonged to and start resolving to an unrelated one. +Every link, bookmark and search result pointing at it would change meaning. + +So a historical public slug stays reserved for the **content type and locale** that +retired it, for as long as redirects are enabled. Two partial unique indexes enforce +it: + +```sql +UNIQUE (contentTypeId, slug) WHERE languageId IS NULL +UNIQUE (contentTypeId, languageId, slug) WHERE languageId IS NOT NULL +``` + +Two indexes rather than one over a nullable column, because Postgres treats every +`NULL` as distinct - a single key including `languageId` would enforce nothing at +all for the shared case it exists to protect. + +The refusal is a structured **409**, not a raw constraint error: + +```json +{ + "code": "CONTENT_DELIVERY_SLUG_RESERVED", + "contentTypeId": "example.article", + "locale": null, + "slug": "hello" +} +``` + +It carries no owning-record id on purpose: a 409 on a public-facing address must not +become a way to enumerate records the caller cannot read. + +A record may always take **its own** retired address back - moving from `b` to `a` +re-activates its own row rather than colliding with it. + +<Callout type="warn" title="A draft's slug is checked, not reserved"> + Taking a new slug on a draft checks the reservations - so an editor hears "that + address is taken" at save time rather than at publish time - but claims nothing. + A draft has no public URL, and reserving one would refuse a live address to + somebody who wants it. +</Callout> + +## Resolution collapses chains + +```text +a → b +b → c + +request a → c (one hop) +request b → c (one hop) +``` + +The database keeps the chronology - three rows, two retired - and the *resolver* is +what collapses it. It never follows the history: it looks the address up, finds the +record it belongs to, and reads that record's **current** slug. There is no second +hop to make. + +```ts +await delivery.resolvePath("/articles/a"); +// { type: "redirect", status: 308, location: "/articles/c" } +``` + +## 308, and only 308 + +`308 Permanent Redirect` rather than `301`, and the difference is not cosmetic: a +`301` lets a client rewrite the method to `GET`, a `308` does not. Both behave +identically for the `GET` a content page is read with - and only one of them still +behaves correctly the day somebody `POST`s to a form under a moved path. + +It is **not configurable**. Every historical URL of every content type answers with +this, so there is no per-content-type setting to get wrong and no reason for two of +them to disagree. + +## Unpublished and deleted destinations + +A historical URL must never become a way to reach content that is not public. + +```text +record unpublished → old URLs answer not_found, history retained +record republished → old URLs redirect again +record deleted → old URLs answer not_found, history retained +``` + +The resolver checks the **live** publication state rather than the history, which is +why this needs no extra bookkeeping: an unpublished record simply has no current +canonical path to redirect to, so the answer is `not_found`. + +`not_found` rather than `410 Gone`, and that is a decision rather than an omission: +the engine has no abstraction that distinguishes "deleted on purpose" from +"unpublished for now", and a `410` that guessed would tell a crawler to forget a URL +that is coming back next week. + +History is **kept** on delete. An incoming link to a deleted article is exactly the +diagnostic somebody will want, and the resolver answers 404 for it by reading the +live record rather than by having forgotten the URL. + +## Restore + +A [revision restore](/docs/dev/content-engine/revisions) can move a slug, and it +integrates with history like any other edit: + +```text +current slug: new-name +restored revision: old-name + +after restore: + /articles/old-name canonical + /articles/new-name 308 -> /articles/old-name +``` + +The two addresses swap roles. A restore that changes no slug writes nothing at all - +the diff proves nothing moved before the delivery step runs. + +## Localized history + +Each locale's history is its own, because `languageId` is part of the key: + +```text +EN: /en/articles/hello → /en/articles/hello-world +PL: /pl/articles/witaj (untouched) +``` + +Changing the English URL creates no Polish redirect and retires no Polish address. +The same historical slug may be retired independently in two locales, because +`/en/x/shared` and `/pl/x/shared` are two URLs. + +See [Localization and hreflang](/docs/dev/content-engine/localization-and-hreflang). + +<Callout type="warn" title="A localized content type needs a localized slug"> + `delivery.redirects` refuses a localized content type whose `publicApi.slugField` + is **shared**. Every language would answer to the same segment, so one retired + address would belong to several URLs at once - and slug history stores the URL + that was live. Canonical URLs, SEO, alternates and the sitemap all work fine in + that shape; only the reservation is ambiguous, so only it is refused. +</Callout> + +## Transactions + +The slug write and its reservation are one transaction: + +```text +BEGIN + lock the row (the guarded UPDATE does it) + verify expectedVersion + update the slug + retire the old address + reserve the new one + write the revision +COMMIT + +emit the delivery events +invalidate the cache tags +sync the search index +``` + +The order matters twice. The reservation runs **after** the guarded write, so a +writer holding a stale `expectedVersion` fails first and leaves the history exactly +as it found it. And the old address is retired **before** the new one is reserved, +or a move from `a` to `b` and back to `a` would hit its own live reservation. + +Everything after `COMMIT` is outside the transaction, for the reason every other +stage states: a rollback cannot un-emit an event or un-expire a cache tag. + +## Concurrency + +Two editors racing on one slug produce one winner and one structured +[version conflict](/docs/dev/content-engine/editorial#optimistic-locking) - the +guarded `UPDATE` is the whole mechanism, and the history follows it: + +```text +version 3, slug = A +writer 1: A → B +writer 2: A → C + +one commits; the other gets 409 CONTENT_VERSION_CONFLICT +history: exactly one retirement and one new reservation +``` + +Two *different* records racing for the same retired address both lose: the +reservation lookup takes a row lock, so they serialise rather than race, and the +address belongs to neither of them. + +## Events + +Two events, each gated on a fact rather than an operation: + +```text +content.<id>.delivery_slug_changed +content.<id>.delivery_redirect_created +``` + +```ts +{ + contentId: 42, + locale: "pl", + previousSlug: "stary-slug", + slug: "nowy-slug", + previousPath: "/pl/articles/stary-slug", + canonicalPath: "/pl/articles/nowy-slug", +} +``` + +They are emitted **alongside** `updated` or `restored`, never instead of one: the +field mutation and the URL change are different facts with different audiences. A +listener that mirrors content wants the first; one that warms a CDN, tells an +external search engine or writes to an edge redirect table wants the second, and +would otherwise have to inspect `changedFields` for a slug field whose name it +cannot know. + +`delivery_redirect_created` fires only when the old address had genuinely been live, +so a corrected draft emits nothing. There is deliberately no sitemap event - every +mutation that changes a sitemap line already emits one of these or a publication +event. + +Both are documented in +[Built-in events](/docs/dev/events/built-in-events). + +## AdminCP + +Every delivery-enabled content type gets a read-only delivery panel on its row +action: + +```text +Delivery + +Canonical URL +/pl/articles/moj-artykul + +Status +Published + +Historical URLs +/pl/articles/stary-slug → redirects to the current URL +/pl/articles/jeszcze-starszy → redirects to the current URL +``` + +Gated by `can_view` and nothing narrower. It reports what the slug mutations already +did, so the permission that allowed the mutation is the only one it needs - +inventing a `can_manage_redirects` for a screen that manages nothing would be a +permission every install has to configure for no decision it can make. + +**Read-only is the deliberate scope.** A redirect is somebody else's incoming link, +so deleting one silently breaks traffic nobody in that dialog can see. That is a +destructive action, and a destructive action needs its own permission, a +confirmation that explains the consequence, and an audit trail. Displaying the +history is useful today; managing it is a product rather than a button. diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx index 48ebb666c..522971ecd 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -275,7 +275,33 @@ content.example.article.translation_unpublished (with publication) content.example.article.translation_restored (with editorial) ``` -Every one of them carries `locale` and `languageId`. They are deliberately +And one that opts into +[`delivery`](/docs/dev/content-engine/content-delivery) emits two more: + +```text +content.example.article.delivery_slug_changed +content.example.article.delivery_redirect_created +``` + +These arrive **alongside** `updated` (or `restored`, or `translation_updated`), never +instead of one: a field moving and a URL moving are different facts with different +audiences. A listener that mirrors content wants the first; one that warms a CDN, tells +an external search engine or writes to an edge redirect table wants the second, and +would otherwise have to inspect `changedFields` for a slug field whose name it cannot +know. + +`delivery_redirect_created` fires only when the old address had genuinely been +**publicly addressable** - so an article whose slug was corrected three times while it +was still a draft emits nothing, and a published article that moves emits exactly one. +That is the difference between "a URL now needs a redirect" and "somebody edited a +field". Both payloads carry `previousPath` and `canonicalPath`, and `locale` is `null` +when the slug is shared. + +There is deliberately no sitemap event: every mutation that changes a sitemap line +already emits one of these or a publication event, and a third carrying no new +information would be one more thing to keep consistent for no listener's benefit. + +Every translation event carries `locale` and `languageId`. They are deliberately **not** folded into `updated`: a shared update and a Polish translation update are different domain facts with different consequences - one invalidates every language, the other invalidates one - and a listener that had to inspect @@ -316,6 +342,21 @@ core event - `changedFields` narrows to that content type's own field names. description: "Restored only - the revision the values were taken from.", type: "number", }, + previousSlug: { + description: + "Delivery only - the slug the record answered to before this mutation.", + type: "string", + }, + previousPath: { + description: + "Delivery only - the full path it answered to before, e.g. `/pl/articles/stary-slug`.", + type: "string", + }, + canonicalPath: { + description: + "Delivery only - the path it answers to now, and where the historical one redirects.", + type: "string", + }, locale: { description: "Translation events only - the canonical core_languages.code the mutation was made in. Always present, so a listener never has to go and ask which language.", From 9df590cea8f1ba6cd5d1646c4190584321ad5481 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:18:17 +0200 Subject: [PATCH 15/24] fix(example): satisfy the plugin's typed lint in the delivery suite Three small things the example plugin's stricter ruleset caught, none of which the core package's does: `code` is a `unique: true` field and several articles are created inside one millisecond, so a `Date.now()`-derived value was both a duplicate risk and a `Record<string, unknown>` interpolated into a template. A monotonic counter is what it should have been. The sitemap page is annotated explicitly: the optional-call chain through `deliveryService?.()` loses the element type in the typed-lint program even though `tsc` resolves it, and `itemId` is exactly what that test asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../example/src/content/advanced-article.ts | 5 +- .../src/database/delivery-postgres.test.ts | 127 +++++++++++------- 2 files changed, 80 insertions(+), 52 deletions(-) diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index 86abca97e..665972814 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -204,7 +204,10 @@ export const advancedArticleContentType = defineContentType({ fallbackTitleField: "title", descriptionField: "seo.description", noIndexField: "syndication.noIndex", - openGraph: { titleField: "seo.title", descriptionField: "seo.description" }, + openGraph: { + titleField: "seo.title", + descriptionField: "seo.description", + }, }, sitemap: { enabled: true, changeFrequency: "weekly", priority: 0.7 }, }, diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index 4733c48ff..faebf1259 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -1,4 +1,5 @@ import type { SearchDocument } from "@vitnode/core/api/models/search"; +import type { ContentDeliverySitemapPage } from "@vitnode/core/content/server"; import type { Context } from "hono"; import { @@ -136,16 +137,27 @@ const editorial = (target: Context = context) => const delivery = (target: Context = context) => articleContent.deliveryService?.(target, { pluginId: PLUGIN }); +/** + * A monotonic counter for the `unique: true` `code` field. + * + * `Date.now()` is not enough: several articles are created inside one millisecond by + * the tests below, and a duplicate `code` would surface as a `23505` from an + * unrelated constraint. + */ +let nextCode = 0; + const createArticle = async ( values: Record<string, unknown> = {}, ): Promise<{ id: number; version: number }> => { + nextCode += 1; + const outcome = await editorial()?.create( { category: categoryId, - code: `code-${Math.round(Date.now() % 1_000_000)}-${values.title ?? "x"}`, + code: `code-${nextCode}`, title: "Hello world", ...values, - } as never, + }, { actor: ACTOR }, ); if (!outcome) throw new Error("create returned nothing"); @@ -174,7 +186,9 @@ const publishArticle = async ( const historyRows = async ( itemId: number, ): Promise<{ path: string; retired: boolean; slug: string }[]> => { - const rows = await sql<{ path: string; retiredAt: null | string; slug: string }[]>` + const rows = await sql< + { path: string; retiredAt: null | string; slug: string }[] + >` SELECT "slug", "path", "retiredAt" FROM "core_content_slug_history" WHERE "contentTypeId" = 'example.article' AND "itemId" = ${itemId} @@ -238,7 +252,7 @@ const publishLocalized = async ({ await translationEditorial()?.create( created.row.id, "pl", - { title: pl } as never, + { title: pl }, { actor: ACTOR }, ); await translationEditorial()?.publish(created.row.id, "pl", { @@ -327,17 +341,15 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { } if (key === "events") { return { - emit: async ( - name: string, - payload: Record<string, unknown>, - ) => { + emit: async (name: string, payload: Record<string, unknown>) => { emitted.push({ name, payload }); return await Promise.resolve({ failures: [] }); }, }; } - if (key === "log") return { error: async () => await Promise.resolve() }; + if (key === "log") + return { error: async () => await Promise.resolve() }; if (key === "core") { return { contentModels: [ @@ -658,7 +670,8 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { it("brings a slug back into service when it is restored", async () => { const article = await publishArticle({ title: "Original name" }); - const [original] = (await editorial()?.revisions.list(article.id))?.edges ?? []; + const [original] = + (await editorial()?.revisions.list(article.id))?.edges ?? []; const moved = await editorial()?.update( article.id, @@ -692,7 +705,8 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { it("writes no history for a restore that moves no slug", async () => { const article = await publishArticle({ title: "Stable" }); - const [first] = (await editorial()?.revisions.list(article.id))?.edges ?? []; + const [first] = + (await editorial()?.revisions.list(article.id))?.edges ?? []; const edited = await editorial()?.update( article.id, @@ -706,9 +720,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { }); expect(restored?.delivery?.slugChanged).toBe(false); - expect((await historyRows(article.id)).map(row => row.slug)).toStrictEqual([ - "stable", - ]); + expect( + (await historyRows(article.id)).map(row => row.slug), + ).toStrictEqual(["stable"]); }); }); @@ -728,9 +742,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { // `hello` is free on the content table now - the first article moved off it - // so the reservation is the only thing standing between the second article // and somebody else's incoming links. - await expect(createArticle({ slug: "hello", title: "Second" })).rejects.toThrow( - ContentDeliverySlugReserved, - ); + await expect( + createArticle({ slug: "hello", title: "Second" }), + ).rejects.toThrow(ContentDeliverySlugReserved); }); it("names the address in the structured error", async () => { @@ -842,9 +856,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { ), ).rejects.toThrow(ContentVersionConflict); - expect((await historyRows(article.id)).map(row => row.slug)).toStrictEqual([ - "original", - ]); + expect( + (await historyRows(article.id)).map(row => row.slug), + ).toStrictEqual(["original"]); }); it("serialises two records racing for the same retired address", async () => { @@ -873,9 +887,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { // Both lose: the address belongs to the first article's history, and neither // of the two may take it. - expect( - results.every(result => result.status === "rejected"), - ).toBe(true); + expect(results.every(result => result.status === "rejected")).toBe(true); }); }); @@ -921,13 +933,14 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { let cursor: null | number | undefined = undefined; for (let page = 0; page < 10; page += 1) { - const result = await delivery()?.sitemap({ - cursor: cursor ?? undefined, - limit: 2, - }); + // Annotated, because the optional-call chain through `deliveryService?.()` + // loses the element type in the typed-lint program even though `tsc` + // resolves it - and `itemId` is exactly what this test is about. + const result: ContentDeliverySitemapPage | undefined = + await delivery()?.sitemap({ cursor: cursor ?? undefined, limit: 2 }); if (!result) break; - seen.push(...result.entries.map(entry => entry.itemId)); + for (const entry of result.entries) seen.push(entry.itemId); cursor = result.nextCursor; if (cursor === null) break; } @@ -1000,10 +1013,15 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { ); if (!outcome) throw new Error("update returned nothing"); - await contentEditorialEffects(context, articleContent.definition, outcome, { - model: articleContent, - pluginId: PLUGIN, - }); + await contentEditorialEffects( + context, + articleContent.definition, + outcome, + { + model: articleContent, + pluginId: PLUGIN, + }, + ); expect(emitted.map(entry => entry.name)).toStrictEqual([ "content.example.article.updated", @@ -1056,18 +1074,23 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { if (!outcome) throw new Error("update returned nothing"); indexed.length = 0; - await contentEditorialEffects(context, articleContent.definition, outcome, { - model: articleContent, - pluginId: PLUGIN, - }); + await contentEditorialEffects( + context, + articleContent.definition, + outcome, + { + model: articleContent, + pluginId: PLUGIN, + }, + ); // One document, pointing at the new address. A retired URL never becomes a // second search result competing with the page it redirects to. expect(indexed).toHaveLength(1); expect(indexed[0].url).toBe("/articles/slug-b"); - expect(indexed.filter(document => document.url === "/articles/slug-a")).toStrictEqual( - [], - ); + expect( + indexed.filter(document => document.url === "/articles/slug-a"), + ).toStrictEqual([]); }); }); @@ -1096,7 +1119,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { const before = await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1123,7 +1146,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1150,7 +1173,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( first.id, "en", - { slug: "english-now" } as never, + { slug: "english-now" }, { actor: ACTOR, expectedVersion: first.enVersion }, ); @@ -1158,7 +1181,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.create( second.id, "pl", - { title: "Shared" } as never, + { title: "Shared" }, { actor: ACTOR }, ); const pl = await translationEditorial()?.publish(second.id, "pl", { @@ -1167,7 +1190,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( second.id, "pl", - { slug: "polski-teraz" } as never, + { slug: "polski-teraz" }, { actor: ACTOR, expectedVersion: pl?.version ?? 0 }, ); @@ -1195,7 +1218,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.create( article.id, "pl", - { title: "Wersja robocza" } as never, + { title: "Wersja robocza" }, { actor: ACTOR }, ); @@ -1243,7 +1266,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { const moved = await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1270,7 +1293,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { const outcome = await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); if (!outcome) throw new Error("update returned nothing"); @@ -1324,7 +1347,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.create( article.id, "pl", - { title: "Wersja robocza" } as never, + { title: "Wersja robocza" }, { actor: ACTOR }, ); @@ -1393,7 +1416,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( article.id, "en", - { seo: { description: "English summary", title: "English SEO" } } as never, + { + seo: { description: "English summary", title: "English SEO" }, + }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1437,7 +1462,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { const outcome = await categoryContent .service(context) - .create({ name: "Guides" } as never); + .create({ name: "Guides" }); expect(outcome).toBeTruthy(); const [rows] = await sql<{ count: number }[]>` @@ -1450,7 +1475,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { it("reports no delivery outcome on its mutations", async () => { const outcome = await categoryContent .service(context) - .create({ name: "News two" } as never); + .create({ name: "News two" }); expect(outcome).not.toHaveProperty("delivery"); }); From f3b822d5471ec9bf4fe1cd5fdeb615a06b3cf95e Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:23:46 +0200 Subject: [PATCH 16/24] fix(content): resolve two delivery correctness bugs found in review **Alternates were silently empty on the public resolve route.** `findBySlug` returns the public *projection*, and a content type that withholds `"id"` has no identifier in it - so `resolveSlug` could not enumerate a record's published translations and answered with an empty `hreflang` set. That is the worst shape a bug can take here: an empty `hreflang` looks exactly like a record with one translation, so it is invisible in the AdminCP and wrong on every page. Since alternates are resolved by identifier and there is no honest way to recover one from a projection that omits it, a **localized** content type with `delivery` now has to expose `"id"` - refused at definition time rather than left as a quiet gap. A nonlocalized content type has no alternates to resolve and needs nothing, so its `itemId` may still be `null`. **The two halves of a resolution could disagree about the locale.** `findBySlug` and `findById` resolve `defaultLocale` internally when the caller names none, while the history lookup treated "no locale" as the *shared* rows - which a localized content type never has. So a service call omitting the locale searched `en` for the live record and found nothing for the redirect. Both halves now resolve the same language. Each fix has a PostgreSQL test that fails without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../content-delivery-limitations.mdx | 9 ++-- .../dev/content-engine/content-delivery.mdx | 9 +++- .../localization-and-hreflang.mdx | 9 ++++ packages/vitnode/src/content/delivery.test.ts | 43 ++++++++++++++++++- packages/vitnode/src/content/delivery.ts | 15 +++++++ .../src/content/server/delivery-service.ts | 24 ++++++++--- .../example/src/content/advanced-article.ts | 4 ++ .../src/database/advanced-routes.test.ts | 3 ++ .../src/database/delivery-postgres.test.ts | 43 +++++++++++++++++++ 9 files changed, 148 insertions(+), 11 deletions(-) diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx index 23a85f327..aea4c83b7 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx @@ -133,16 +133,19 @@ different `seo.title` changes the derived metadata on the next read. There is no SEO history, and there will not be one: two revision systems over the same values is two things to keep in sync. -## itemId can be absent +## itemId can be absent on a nonlocalized content type -Delivery metadata reports `itemId: null` for a content type whose `publicApi.fields` -withholds `"id"`. +Delivery metadata reports `itemId: null` for a **nonlocalized** content type whose +`publicApi.fields` withholds `"id"`. That is deliberate rather than a gap: delivery reads the **public projection**, so it cannot report a column the public API declined to publish. Expose `"id"` in the allowlist and it is always present. A sitemap entry always carries it, because a sitemap row is built from the row rather than from the projection. +A **localized** content type has to expose `"id"` - alternates are resolved by +identifier - so its `itemId` is never `null`. + ## Not in Stage 8 at all For the avoidance of doubt: no domain management, no CDN configuration, no content diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx index 04ae39f38..a66f09af2 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx @@ -173,9 +173,16 @@ being indexable - and that is not a symptom anybody notices. | `sitemap.priority` outside `0`–`1` | Throw | | An unknown `changeFrequency` | Compile error + throw | | `hreflang` without `localization` | Throw | +| A localized content type withholding `"id"` | Throw | | A fallback SEO field with no primary | Throw | -The last one is worth a word: `fallbackTitleField` without `titleField` is a +Two are worth a word. A **localized** content type has to expose `"id"` in +`publicApi.fields`, because alternates and `hreflang` are resolved by identifier and +delivery reads the public projection - so without it every localized response would +carry an empty alternate set, which looks exactly like a record with one translation. +A nonlocalized content type has no alternates to resolve and needs nothing. + +And the last one: `fallbackTitleField` without `titleField` is a configuration that reads as if it does something and does nothing, because the fallback is only consulted when the primary is empty. Naming only the fallback means it is never reached, so the engine tells you to name it as the primary diff --git a/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx index a91a6267c..4b58fa8e0 100644 --- a/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx +++ b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx @@ -35,6 +35,15 @@ public API would refuse to serve. Ordered by locale, so two processes rendering the same `hreflang` set produce the same markup. +<Callout type="warn" title="A localized content type must expose id"> + Alternates are resolved **by identifier** - the query enumerates a record's published + translations - and delivery reads the public projection, so a localized content type + that withholds `"id"` from `publicApi.fields` is a definition-time error. Without it + every localized response would carry an empty alternate set, which looks exactly like + a record with one translation. A nonlocalized content type has no alternates and needs + nothing. +</Callout> + ## Fallback never fabricates an alternate This is the rule to internalise: diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts index 0386ad949..41f80fe93 100644 --- a/packages/vitnode/src/content/delivery.test.ts +++ b/packages/vitnode/src/content/delivery.test.ts @@ -308,6 +308,45 @@ describe("delivery definition validation", () => { ).toThrow(/needs a localized slug field/); }); + it("refuses a localized content type that withholds id", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.no-id", + delivery: { enabled: true }, + fields: { + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publicApi: { + enabled: true, + // No `id`, so alternates and `hreflang` could not be resolved - and an + // empty `hreflang` looks exactly like a record with one translation. + fields: ["title", "slug"], + path: "articles", + }, + tableName: "delivery_no_id", + }), + ).toThrow(/needs "id" in publicApi.fields/); + }); + + it("does not require id of a nonlocalized content type", () => { + // It has no alternates to resolve, so there is nothing the identifier is needed + // for - `itemId` simply comes back `null` on its delivery metadata. + const withoutId = defineContentType({ + ...base, + id: "delivery.no-id-flat", + delivery: { enabled: true }, + fields, + publicApi: { enabled: true, fields: ["title", "slug"], path: "articles" }, + tableName: "delivery_no_id_flat", + }); + + expect(withoutId.delivery.enabled).toBe(true); + expect(withoutId.publicApi.fields).not.toContain("id"); + }); + it("refuses a localized noIndexField", () => { expect(() => defineContentType({ @@ -330,7 +369,9 @@ describe("delivery definition validation", () => { localization: { defaultLocale: "en", enabled: true }, publicApi: { enabled: true, - fields: ["title", "slug", "flags.noIndex"], + // `id` because a localized delivery content type has to expose it - see + // "refuses a localized content type that withholds id" below. + fields: ["id", "title", "slug", "flags.noIndex"], path: "articles", }, tableName: "delivery_localized_noindex", diff --git a/packages/vitnode/src/content/delivery.ts b/packages/vitnode/src/content/delivery.ts index e54cbd1f4..fe4004579 100644 --- a/packages/vitnode/src/content/delivery.ts +++ b/packages/vitnode/src/content/delivery.ts @@ -279,6 +279,21 @@ export const resolveContentDelivery = ({ } const exposed = new Set(publicApi.fields); + + // Alternates and `hreflang` are resolved by identifier - the query enumerates a + // record's published translations - and delivery reads the **public projection**, + // so a localized content type that withholds `id` would silently produce an empty + // `hreflang` set from `resolveSlug`. Refused loudly here rather than left as a + // quiet gap: an empty `hreflang` looks exactly like a record with one translation. + // + // Not required of a nonlocalized content type, which has no alternates to resolve. + if (localization.enabled && !exposed.has("id")) { + throw new ContentEngineError( + 'delivery on a localized content type needs "id" in publicApi.fields. Alternates and `hreflang` are resolved by identifier, and delivery reads the public projection - so without it every localized response would carry an empty alternate set.', + { contentTypeId: id }, + ); + } + const seo = delivery.seo ?? {}; for (const [label, name] of [ diff --git a/packages/vitnode/src/content/server/delivery-service.ts b/packages/vitnode/src/content/server/delivery-service.ts index 52d36be37..af3c7e2b4 100644 --- a/packages/vitnode/src/content/server/delivery-service.ts +++ b/packages/vitnode/src/content/server/delivery-service.ts @@ -319,12 +319,27 @@ export const createContentDeliveryService = < : contentDeliveryPath({ definition, locale: served, slug }); }; + /** + * The language a read is *actually* for. + * + * The default locale when the caller named none, because that is what the public + * service resolves internally - and the history lookup has to be about the same + * language, or the live branch would search `en` while the redirect branch searched + * the shared rows and found nothing. + */ + const localeFor = (locale: string | undefined): null | string => { + if (!localized) return null; + + return normalizeContentLocale( + locale ?? definition.localization.defaultLocale, + ); + }; + const resolve = async ( slug: string, { locale, origin }: ContentDeliveryReadOptions = {}, ): Promise<ContentDeliveryResolution> => { - const requestedLocale = - localized && locale !== undefined ? normalizeContentLocale(locale) : null; + const requestedLocale = localeFor(locale); // The live record first, and strictly by slug: a URL belongs to the language // it was published under, so `findBySlug` never falls back. @@ -382,10 +397,7 @@ export const createContentDeliveryService = < return await metadataFor(row, { itemId, origin, - requestedLocale: - localized && locale !== undefined - ? normalizeContentLocale(locale) - : null, + requestedLocale: localeFor(locale), }); }, diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index 665972814..987fadbb2 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -145,6 +145,10 @@ export const advancedArticleContentType = defineContentType({ enabled: true, path: "advanced-articles", fields: [ + // Exposed because Stage 8 needs it: alternates and `hreflang` are resolved by + // identifier, and delivery reads the public projection - so a localized + // delivery content type that withheld `id` would carry an empty alternate set. + "id", "title", "slug", "categories", diff --git a/plugins/example/src/database/advanced-routes.test.ts b/plugins/example/src/database/advanced-routes.test.ts index 9e1443cff..629369630 100644 --- a/plugins/example/src/database/advanced-routes.test.ts +++ b/plugins/example/src/database/advanced-routes.test.ts @@ -99,6 +99,9 @@ describe("advanced article: generated routes", () => { expect(Object.keys(shape).sort()).toStrictEqual([ "categories", "faq", + // Stage 8: a localized delivery content type has to expose `id`, because + // alternates and `hreflang` are resolved by identifier. + "id", "locale", "publishedAt", "seo", diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index faebf1259..51a409381 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -1203,6 +1203,49 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { expect(rows.count).toBe(2); }); + it("carries the alternates through resolveSlug, not only findById", async () => { + const article = await publishLocalized({ pl: "Witaj" }); + + const resolution = await advancedDelivery()?.resolveSlug("hello-world", { + locale: "en", + }); + + // The public resolve route is what a frontend calls, so an empty `hreflang` + // here would be invisible in the AdminCP and wrong on every page. It is only + // possible because the content type exposes `id` - which `delivery` requires + // of a localized content type for exactly this reason. + expect(resolution).toMatchObject({ + itemId: article.id, + type: "content", + }); + expect( + resolution?.type === "content" ? resolution.alternates : [], + ).toStrictEqual([ + { locale: "en", path: "/en/advanced-articles/hello-world" }, + { locale: "pl", path: "/pl/advanced-articles/witaj" }, + ]); + }); + + it("resolves the default locale when the caller names none", async () => { + const article = await publishLocalized(); + await translationEditorial()?.update( + article.id, + "en", + { slug: "hello-there" } as never, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + // The public read resolves `defaultLocale` internally when no locale is given, + // so the history lookup has to be about the same language - otherwise the live + // branch would search `en` while the redirect branch searched the shared rows + // and found nothing. + expect(await advancedDelivery()?.resolveSlug("hello-world")).toStrictEqual({ + location: "/en/advanced-articles/hello-there", + status: 308, + type: "redirect", + }); + }); + it("lists only real published translations as alternates", async () => { const article = await publishLocalized({ pl: "Witaj" }); From 7fc01f9c7376346ac14f37ce7f5beb0b458b65f4 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 17:25:26 +0200 Subject: [PATCH 17/24] style(example): satisfy the plugin lint in the new delivery assertions Formatting and one redundant type assertion in the two tests added for the review fixes. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- plugins/example/src/database/delivery-postgres.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index 51a409381..9cf061ab8 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -1231,7 +1231,7 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { await translationEditorial()?.update( article.id, "en", - { slug: "hello-there" } as never, + { slug: "hello-there" }, { actor: ACTOR, expectedVersion: article.enVersion }, ); @@ -1239,7 +1239,9 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { // so the history lookup has to be about the same language - otherwise the live // branch would search `en` while the redirect branch searched the shared rows // and found nothing. - expect(await advancedDelivery()?.resolveSlug("hello-world")).toStrictEqual({ + expect( + await advancedDelivery()?.resolveSlug("hello-world"), + ).toStrictEqual({ location: "/en/advanced-articles/hello-there", status: 308, type: "redirect", From e122fda317bd66b773665ba4f1d0d8a869346961 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:34:43 +0200 Subject: [PATCH 18/24] fix(content): make delivery paths a globally unique namespace Two namespaces, not one, and the asymmetry is the whole fix. A generated API route is `/api/{pluginId}/content/{path}`, so two plugins publishing `articles` do not collide - Stage 1-7 deliberately allows it, and forbidding it would fail an app's boot over a name neither author can see. A **canonical delivery URL** is `/articles/{slug}` with no plugin id in it at all, so the same pair really would give one public URL two owners: two resolvers claiming it, two sitemaps listing it, and one slug reservation table with no way to say whose a retired address was. `byDeliveryPath` is therefore a second map keyed by the path alone, consulted only for a content type with `delivery.enabled`. A non-delivery route reserves nothing site-wide, so a delivery-enabled `articles` elsewhere is still free to take it. The fix is the check rather than a prefix: putting the plugin id in canonical URLs would solve the ambiguity by making every public content URL uglier for everybody. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/registry.test.ts | 190 ++++++++++++++++++ packages/vitnode/src/content/registry.ts | 37 ++++ 2 files changed, 227 insertions(+) diff --git a/packages/vitnode/src/content/registry.test.ts b/packages/vitnode/src/content/registry.test.ts index 541df4c8f..5a0409b24 100644 --- a/packages/vitnode/src/content/registry.test.ts +++ b/packages/vitnode/src/content/registry.test.ts @@ -393,3 +393,193 @@ describe("public paths", () => { ).not.toThrow(); }); }); + +/** + * Delivery paths are a **site-wide** namespace, unlike the API paths above. + * + * The asymmetry is the whole of this block. A generated API route is + * `/api/{pluginId}/content/{path}`, so two plugins publishing `articles` do not + * collide and Stage 1-7 deliberately allows it. A canonical delivery URL is + * `/articles/{slug}` with no plugin id in it at all, so the same pair really would + * give one public URL two owners: two resolvers claiming it, two sitemaps listing it, + * and one slug reservation table with no way to say whose a retired address was. + */ +describe("delivery paths", () => { + const deliveryWidget = ( + id: string, + tableName: string, + path: string, + { delivery = true }: { delivery?: boolean } = {}, + ) => + defineContentType({ + id, + tableName, + fields: { + title: field.text({ required: true }), + slug: field.slug({ source: "title" }), + }, + publication: { enabled: true }, + publicApi: { + enabled: true, + path, + fields: ["id", "title", "slug"], + }, + ...(delivery ? { delivery: { enabled: true } } : {}), + admin: { + label: { plural: "Widgets", singular: "Widget" }, + // Distinct, so the permission-module check does not fire first and mask the + // one this block is about. + permissionModule: tableName, + }, + }); + + it("still lets two plugins share a path when neither has delivery", () => { + // The Stage 1-7 promise, restated here so a future delivery change cannot + // quietly turn the API namespace into a global one. + expect(() => + validateContentTypes([ + entry( + deliveryWidget("first.one", "first_ones", "articles", { + delivery: false, + }), + "@acme/one", + ), + entry( + deliveryWidget("second.one", "second_ones", "articles", { + delivery: false, + }), + "@acme/two", + ), + ]), + ).not.toThrow(); + }); + + it("rejects two plugins claiming the same delivery path", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("news.article", "news_articles", "articles"), + "@acme/news", + ), + ]), + ).toThrow(ContentEngineError); + }); + + it("names both conflicting owners", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("news.article", "news_articles", "articles"), + "@acme/news", + ), + ]), + ).toThrow( + /Delivery path "articles" is claimed by both @acme\/blog -> blog\.article and @acme\/news -> news\.article/, + ); + }); + + it("says why the namespace is global", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("news.article", "news_articles", "articles"), + "@acme/news", + ), + ]), + ).toThrow(/site-wide public namespaces and must be globally unique/); + }); + + it("accepts different delivery paths across plugins", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("news.article", "news_articles", "news"), + "@acme/news", + ), + ]), + ).not.toThrow(); + }); + + it("rejects two content types in one plugin claiming one delivery path", () => { + // The per-plugin API check fires first here, which is correct - both rules are + // violated, and the one that names the narrower fix wins. + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + entry( + deliveryWidget("blog.news", "blog_news", "articles"), + "@acme/blog", + ), + ]), + ).toThrow(ContentEngineError); + }); + + it("does not let a non-delivery route reserve the site namespace", () => { + // A plugin whose `articles` route has no delivery claims nothing site-wide, so a + // delivery-enabled `articles` elsewhere is still free to take it. + expect(() => + validateContentTypes([ + entry( + deliveryWidget("plain.one", "plain_ones", "articles", { + delivery: false, + }), + "@acme/plain", + ), + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + ]), + ).not.toThrow(); + }); + + it("rejects the mixed case whichever order the two arrive in", () => { + const delivered = () => + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ); + const other = () => + entry( + deliveryWidget("news.article", "news_articles", "articles"), + "@acme/news", + ); + + expect(() => validateContentTypes([delivered(), other()])).toThrow( + ContentEngineError, + ); + expect(() => validateContentTypes([other(), delivered()])).toThrow( + ContentEngineError, + ); + }); + + it("leaves one delivery-enabled content type alone", () => { + expect(() => + validateContentTypes([ + entry( + deliveryWidget("blog.article", "blog_articles", "articles"), + "@acme/blog", + ), + ]), + ).not.toThrow(); + }); +}); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index 2c71a5723..56b28a81c 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -43,6 +43,10 @@ interface IndexOwner { * table name, or two content types resolving to the same Postgres index name. * Permission modules and public paths are checked per plugin, because the * plugin id is part of the key each one is addressed by. + * + * **Delivery paths are the one exception**, and the asymmetry is deliberate: an API + * route carries the plugin id and a canonical delivery URL does not, so the second is + * a site-wide namespace where the first is not. See `byDeliveryPath` below. */ export const validateContentTypes = ( entries: RegisteredContentType[], @@ -51,6 +55,18 @@ export const validateContentTypes = ( const byTable = new Map<string, RegisteredContentType>(); const byPermission = new Map<string, RegisteredContentType>(); const byPublicPath = new Map<string, RegisteredContentType>(); + /** + * Delivery paths, keyed by the path alone. + * + * A **second** map rather than a different key on `byPublicPath`, because the two + * namespaces are genuinely different and both have to be checked. A generated API + * route is `/api/{pluginId}/content/{path}`, so `plugin-a` and `plugin-b` may both + * publish `articles` - and forbidding that would make an app fail to boot over a + * name neither author can see. A **canonical delivery URL** is `/articles/{slug}` + * with no plugin id in it at all, so the same pair really would claim one site-wide + * namespace and `/articles/example` would have two owners. + */ + const byDeliveryPath = new Map<string, RegisteredContentType>(); const byIndexName = new Map<string, IndexOwner>(); for (const entry of entries) { @@ -122,6 +138,27 @@ export const validateContentTypes = ( ); } byPublicPath.set(pathKey, entry); + + // Delivery is the exception, and only delivery. Its canonical URLs are + // framework-neutral **site** paths - `/articles/my-article`, + // `/pl/articles/moj-artykul` - built from `publicApi.path` with no plugin id in + // them, so two delivery-enabled content types sharing a path would give + // `/articles/example` two owners: two resolvers claiming one URL, two sitemaps + // listing it, and one slug reservation table with no way to say which of them a + // retired address belonged to. + // + // The fix is the check, not a prefix: adding the plugin id to the URL would + // solve the ambiguity by making every public content URL uglier for everybody. + if (definition.delivery.enabled) { + const duplicateDeliveryPath = byDeliveryPath.get(path); + if (duplicateDeliveryPath) { + throw new ContentEngineError( + `Delivery path "${path}" is claimed by both ${describe(duplicateDeliveryPath)} and ${describe(entry)}. Delivery paths are site-wide public namespaces and must be globally unique - give one of them a different \`publicApi.path\`, or turn \`delivery\` off on one of them.`, + { contentTypeId: definition.id }, + ); + } + byDeliveryPath.set(path, entry); + } } // `resolveContentIndexes` already rejects a collision inside one content From b71fd58cace3c2d69cab534ebccc92f746961666 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:34:43 +0200 Subject: [PATCH 19/24] fix(content): require editorial for delivery.redirects Slug history is written by `applyContentDeliveryWrite`, and the only two callers are `editorial-service` and `translation-editorial-service` - because the reservation has to commit or roll back with the slug mutation, its version check and its revision. A content type without `editorial` writes through the plain repository, which has no version to guard and no history to write, so `redirects: { enabled: true }` there was a feature that silently recorded nothing. Now a compile error *and* a definition-time throw. Refused rather than downgraded to `redirects: { enabled: false }`: an author who asked for redirects and quietly got none would find out from a broken link months later. The restriction is narrow on purpose. Canonical URLs, SEO, alternates, `hreflang`, the sitemap and every delivery read are projections over data the content type already has, and all of them stay available without `editorial` - which is what keeps Stage 5's "publication and localization without editorial" promise intact. The same rule covers a localized content type, whose localized history has the same missing write path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/define.ts | 4 + .../vitnode/src/content/delivery.test-d.ts | 58 ++++++++ packages/vitnode/src/content/delivery.test.ts | 131 ++++++++++++++++++ packages/vitnode/src/content/delivery.ts | 20 +++ .../content/server/delivery-service.test.ts | 2 + packages/vitnode/src/content/types.ts | 20 ++- 6 files changed, 233 insertions(+), 2 deletions(-) diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 0010892a2..9be48f575 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1425,6 +1425,7 @@ export const defineContentType = < TDelivery extends | ContentDeliveryConfig< TPublicEnabled, + ContentEditorialEnabled<TEditorial>, ContentDeliveryTitleField<TFields, TPublicField>, ContentDeliveryDescriptionField<TFields, TPublicField>, ContentDeliveryNoIndexField<TFields, TPublicField> @@ -1679,6 +1680,9 @@ export const defineContentType = < // The `{ enabled: false }` arm exists only so an explicit literal typechecks - // the same widening `publicApi`, `search`, `editorial` and `localization` do. delivery: delivery as ContentDeliveryConfig | undefined, + // Read off the *resolved* editorial config rather than the argument, so the + // redirect check sees exactly what `resolveEditorial` decided. + editorial: resolvedEditorial.enabled, fields: fieldMap, id, localization: { diff --git a/packages/vitnode/src/content/delivery.test-d.ts b/packages/vitnode/src/content/delivery.test-d.ts index 2c6416fe6..29f038812 100644 --- a/packages/vitnode/src/content/delivery.test-d.ts +++ b/packages/vitnode/src/content/delivery.test-d.ts @@ -55,6 +55,7 @@ const shared = { const deliveredType = defineContentType({ ...shared, id: "typed.delivered", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true }, @@ -144,6 +145,63 @@ describe("delivery requires a public API", () => { }); }); +describe("redirects require editorial", () => { + it("refuses `redirects: { enabled: true }` without editorial", () => { + defineContentType({ + ...shared, + id: "typed.no-editorial", + delivery: { + enabled: true, + // @ts-expect-error - slug history has to be written in the same transaction + // as the slug mutation and its revision, and only the editorial mutation + // paths own one. Without `editorial` this would record nothing. + redirects: { enabled: true }, + }, + tableName: "typed_no_editorial", + }); + }); + + it("still accepts an explicit `redirects: { enabled: false }`", () => { + const off = defineContentType({ + ...shared, + id: "typed.redirects-off", + delivery: { enabled: true, redirects: { enabled: false } }, + tableName: "typed_redirects_off", + }); + + expectTypeOf(off.delivery.enabled).toEqualTypeOf<true>(); + }); + + it("accepts redirects once editorial is enabled", () => { + const on = defineContentType({ + ...shared, + id: "typed.redirects-on", + editorial: { enabled: true }, + delivery: { enabled: true, redirects: { enabled: true } }, + tableName: "typed_redirects_on", + }); + + expectTypeOf(on.delivery.enabled).toEqualTypeOf<true>(); + }); + + it("leaves every other delivery block available without editorial", () => { + // The rule is narrow on purpose: only slug history needs a transaction. + const reads = defineContentType({ + ...shared, + id: "typed.reads-only", + delivery: { + enabled: true, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { changeFrequency: "daily", enabled: true, priority: 0.5 }, + }, + tableName: "typed_reads_only", + }); + + expectTypeOf(reads.delivery.enabled).toEqualTypeOf<true>(); + expectTypeOf(reads.editorial.enabled).toEqualTypeOf<false>(); + }); +}); + describe("SEO field references", () => { it("refuses a field the public allowlist withholds", () => { defineContentType({ diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts index 41f80fe93..6b69aaf1f 100644 --- a/packages/vitnode/src/content/delivery.test.ts +++ b/packages/vitnode/src/content/delivery.test.ts @@ -48,6 +48,9 @@ const fields = { const articleType = defineContentType({ ...base, id: "delivery.article", + // `redirects` needs `editorial`: slug history has to be written in the same + // transaction as the slug mutation and its revision. + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true }, @@ -291,6 +294,7 @@ describe("delivery definition validation", () => { defineContentType({ ...base, id: "delivery.shared-slug", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true } }, fields: { body: field.textarea({ localized: true, required: true }), @@ -308,6 +312,132 @@ describe("delivery definition validation", () => { ).toThrow(/needs a localized slug field/); }); + it("refuses redirects without editorial", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.no-editorial", + // No `editorial`, so the only mutation path is the plain repository - which + // has no version to guard and no history to write. Accepting this would be + // accepting a redirect feature that silently records nothing. + delivery: { + enabled: true, + redirects: { enabled: true as never }, + }, + fields, + publicApi, + tableName: "delivery_no_editorial", + }), + ).toThrow(/delivery.redirects needs `editorial/); + }); + + it("refuses localized redirects without editorial", () => { + expect(() => + defineContentType({ + ...base, + id: "delivery.localized-no-editorial", + delivery: { + enabled: true, + redirects: { enabled: true as never }, + }, + fields: { + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug"], + path: "articles", + }, + tableName: "delivery_localized_no_editorial", + }), + ).toThrow(/delivery.redirects needs `editorial/); + }); + + it("accepts redirects with editorial", () => { + const withEditorial = defineContentType({ + ...base, + id: "delivery.with-editorial", + editorial: { enabled: true }, + delivery: { enabled: true, redirects: { enabled: true } }, + fields, + publicApi, + tableName: "delivery_with_editorial", + }); + + expect(withEditorial.delivery.redirects.enabled).toBe(true); + }); + + it("accepts delivery without redirects and without editorial", () => { + // Everything except slug history is a read over data the content type already + // has, so none of it needs a transactional mutation path. + const withoutEditorial = defineContentType({ + ...base, + id: "delivery.reads-only", + delivery: { + enabled: true, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + }, + fields, + publicApi, + tableName: "delivery_reads_only", + }); + + expect(withoutEditorial.delivery).toMatchObject({ + enabled: true, + redirects: { enabled: false }, + seo: { descriptionField: "excerpt", titleField: "title" }, + sitemap: { changeFrequency: "weekly", enabled: true, priority: 0.7 }, + }); + }); + + it("accepts localized delivery reads without editorial", () => { + // Stage 5 supports publication and localization without editorial, and Stage 8 + // must not take that away - only `redirects` needs the extra dependency. + const localizedReads = defineContentType({ + ...base, + id: "delivery.localized-reads", + delivery: { + enabled: true, + hreflang: { xDefault: "defaultLocale" }, + seo: { fallbackTitleField: "title", titleField: "seo.title" }, + sitemap: { enabled: true }, + }, + fields: { + seo: field.group({ + fields: { title: field.text({ nullable: true }) }, + localized: true, + nullable: true, + }), + slug: field.slug({ localized: true, source: "title" }), + title: field.text({ localized: true, required: true }), + }, + localization: { defaultLocale: "en", enabled: true, fallback: "default" }, + publicApi: { + enabled: true, + fields: ["id", "title", "slug", "seo.title"], + path: "articles", + }, + tableName: "delivery_localized_reads", + }); + + expect(localizedReads.delivery).toMatchObject({ + enabled: true, + hreflang: { xDefault: "defaultLocale" }, + redirects: { enabled: false }, + sitemap: { enabled: true }, + slugScope: "localized", + }); + }); + + it("leaves a content type without delivery untouched by the rule", () => { + // No `editorial`, no `delivery` - the Stage 1-7 shape, still accepted. + expect(plainType.delivery.enabled).toBe(false); + expect(plainType.editorial.enabled).toBe(false); + }); + it("refuses a localized content type that withholds id", () => { expect(() => defineContentType({ @@ -383,6 +513,7 @@ describe("delivery definition validation", () => { const localizedType = defineContentType({ ...base, id: "delivery.localized", + editorial: { enabled: true }, delivery: { enabled: true, hreflang: { xDefault: "defaultLocale" }, diff --git a/packages/vitnode/src/content/delivery.ts b/packages/vitnode/src/content/delivery.ts index fe4004579..629eb56fd 100644 --- a/packages/vitnode/src/content/delivery.ts +++ b/packages/vitnode/src/content/delivery.ts @@ -179,6 +179,7 @@ const assertSeoField = ({ */ export const resolveContentDelivery = ({ delivery, + editorial, fields, id, localization, @@ -187,6 +188,8 @@ export const resolveContentDelivery = ({ publication, }: { delivery: ContentDeliveryConfig | undefined; + /** Whether the content type opted into the editorial workflow. */ + editorial: boolean; fields: ContentFieldMap; id: string; localization: { defaultLocale: string; enabled: boolean }; @@ -217,6 +220,23 @@ export const resolveContentDelivery = ({ const slugScope = localizedFields[slugField] === undefined ? "shared" : "localized"; + // Slug history has to be written in the same transaction as the slug mutation, the + // version check and the revision - and the only mutation paths that own such a + // transaction are `editorial-service` and `translation-editorial-service`. Without + // `editorial` a content type writes through the plain repository, which has neither + // a version to guard nor a history to write, so accepting this would be accepting a + // feature that records nothing. + // + // Refused rather than downgraded to `redirects: { enabled: false }`: an author who + // asked for redirects and silently got none would find out from a broken link + // months later. The type system refuses it too - see `ContentDeliveryConfig`. + if (redirects && !editorial) { + throw new ContentEngineError( + "delivery.redirects needs `editorial: { enabled: true }`. Redirect history has to be written atomically with the slug mutation and its version and revision, and only the editorial mutation paths own that transaction. Delivery without `redirects` - canonical URLs, SEO, alternates and the sitemap - works without editorial.", + { contentTypeId: id }, + ); + } + // A localized content type whose slug is *shared* has one URL segment and several // URLs - `/en/articles/hello` and `/pl/articles/hello` are both live, and a slug // change moves all of them at once. Slug history stores the URL that was live, so diff --git a/packages/vitnode/src/content/server/delivery-service.test.ts b/packages/vitnode/src/content/server/delivery-service.test.ts index 0d7835ca8..9dfa0210a 100644 --- a/packages/vitnode/src/content/server/delivery-service.test.ts +++ b/packages/vitnode/src/content/server/delivery-service.test.ts @@ -29,6 +29,7 @@ const PLUGIN = "@vitnode/test"; const articleType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "delivery.article", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true }, @@ -69,6 +70,7 @@ const withoutRedirects = defineContentType({ const localizedType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "delivery.localized", + editorial: { enabled: true }, delivery: { enabled: true, hreflang: { xDefault: "defaultLocale" }, diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 3d406ae86..3e3d89abe 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1324,11 +1324,12 @@ export interface ContentDeliveryHreflangConfig { * silently resolve to "no delivery". */ export interface ContentDeliveryConfig< - // Defaults to `true` rather than `boolean`, which is what keeps the bare + // Both flags default to `true` rather than `boolean`, which is what keeps the bare // `ContentDeliveryConfig` usable as a widened parameter type: `boolean extends // true` is false, so a `boolean` default would resolve `enabled` to `never` and // make the erased form describe a config nobody can write. TPublicEnabled extends boolean = true, + TEditorialEnabled extends boolean = true, TTitle extends string = string, TDescription extends string = string, TNoIndex extends string = string, @@ -1343,8 +1344,23 @@ export interface ContentDeliveryConfig< */ enabled: TPublicEnabled extends true ? true : never; hreflang?: ContentDeliveryHreflangConfig; + /** + * Gated on **editorial** as well as on the public API, and the second gate is not + * a taste decision: slug history has to be written in the same transaction as the + * slug mutation, the version check and the revision - and the only mutation paths + * that own such a transaction are the editorial ones. Without `editorial` a + * content type writes through the plain repository, which has no version to guard + * and no history to write, so `redirects: { enabled: true }` there would be a + * feature that silently records nothing. + * + * Only `redirects` is gated. Canonical URLs, SEO, alternates, `hreflang` and the + * sitemap are all reads over data the content type already has, and they remain + * available without `editorial`. + */ redirects?: TPublicEnabled extends true - ? ContentDeliveryRedirectsConfig | { enabled: false } + ? TEditorialEnabled extends true + ? ContentDeliveryRedirectsConfig | { enabled: false } + : { enabled: false } : { enabled: false }; seo?: ContentDeliverySeoConfig<TTitle, TDescription, TNoIndex>; sitemap?: ContentDeliverySitemapConfig | { enabled: false }; From 483b830cb4e205bafad8d25e05ef53ddf84821af Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:35:03 +0200 Subject: [PATCH 20/24] fix(content): expire the sitemap whenever its lastModified moves A sitemap entry carries `<lastmod>`, derived from `updatedAt`. The invalidation logic treated "the sitemap changed" as "URL membership changed", so a plain title or SEO edit on a published record left a cached sitemap serving a timestamp that was no longer true - for as long as the tag lived. `sitemapChanged: boolean` becomes `sitemap: { contentChanged, indexChanged }`, because the two cache different documents: - **`contentChanged`** - this locale's sitemap **file** is no longer byte-identical. True for any real mutation of a record that is or was publicly reachable, whether what moved was a URL, a title or an SEO field. - **`indexChanged`** - the set of files, or how many of them there are, moved. True only when public reachability flipped. A title edit rewrites a timestamp inside an existing file and a slug change rewrites one line: neither changes which files exist. For a nonlocalized content type the locale-less tag *is* its one file, so `contentChanged` expires it. For a localized one that tag is the index of its per-locale files, which is why an ordinary edit must not touch it. The nonlocalized Server Action decides `contentChanged` by comparing `updatedAt` across the write - not a proxy for "did the sitemap change" but *the value the sitemap serializes*, so the two move together by construction. It answers "was this a no-op" for free: the engine issues no `UPDATE` for an update that changed nothing. The localized path reuses the Stage 5 locale fan-out rather than inventing a second propagation rule: a shared edit reaches every locale because the base timestamp is in `max(base, translation)` for all of them, and a translation edit reaches its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/vitnode/src/content/cache.ts | 55 ++++++--- packages/vitnode/src/content/index.ts | 1 + .../src/content/server/delivery-effects.ts | 10 +- .../src/content/server/delivery-writes.ts | 26 ++++- .../src/content/server/schedule-effects.ts | 11 +- .../content/actions/mutation-api.server.ts | 106 +++++++++++++----- .../content/actions/public-locale-cache.ts | 29 +++-- 7 files changed, 175 insertions(+), 63 deletions(-) diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index 3c1994dfe..cc09755e7 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -167,15 +167,35 @@ export interface ContentLocaleInvalidation { * keeps the public tags and the delivery tags from disagreeing about what moved. */ export interface ContentDeliveryInvalidation { - /** - * Whether the set of URLs in the sitemap changed. - * - * `true` for a publish, an unpublish, a delete, a slug change and a translation - * appearing or disappearing - every mutation that adds, removes or moves a line - * in the file. `false` for an edit that only changed what an already-listed page - * says, which leaves the sitemap byte-identical. - */ - sitemap: boolean; + /** What this mutation did to the sitemap. See {@link ContentSitemapChange}. */ + sitemap: ContentSitemapChange; +} + +/** + * How one mutation changed a sitemap, split into the two things a tag can cache. + * + * One boolean is not enough, and the reason is `<lastmod>`. A sitemap entry carries + * `lastModified`, derived from `updatedAt` - so a plain title edit on a published + * record changes the **bytes** of that locale's sitemap file even though the set of + * URLs in it is identical. Treating "the sitemap changed" as "membership changed" + * leaves a cached file serving a stale `<lastmod>` for as long as the tag lives. + * + * The two are separate because they cache different documents: + * + * - **`contentChanged`** - the sitemap *file* for this locale is no longer + * byte-identical. True for any real mutation of a record that is or was publicly + * reachable, whether what moved was a URL, a title or an SEO field. + * - **`indexChanged`** - the set of sitemap files, or how many of them there are, + * moved. True only when public reachability flipped, because an index lists files + * and their count follows the number of URLs. A title edit changes neither. + * + * Declared here rather than next to the write path because `cache.ts` is the + * client-safe layer and must not import from `server/` - the same reason the tag + * builders are plain strings a directory up from Drizzle. + */ +export interface ContentSitemapChange { + contentChanged: boolean; + indexChanged: boolean; } export interface ContentInvalidationInput { @@ -308,19 +328,22 @@ const deliveryTags = ({ .map(slug => contentDeliveryRedirectTag(contentTypeId, slug, entry.locale), ), - ...(delivery.sitemap + // The sitemap *file* this locale is listed in. For a content type that is not + // localized `entry.locale` is `undefined`, so this is the locale-less tag - which + // is that content type's only sitemap file rather than an index of files. + ...(delivery.sitemap.contentChanged ? [contentDeliverySitemapTag(contentTypeId, entry.locale)] : []), ]); - // The locale-less sitemap tag as well: a localized content type's sitemap index - // enumerates its per-locale files, so a language gaining or losing a page - // changes the index too. De-duplicated, because a content type that is not - // localized produces only this form and the per-locale line above already - // emitted it - and a tag list is asserted in tests as well as iterated. + // The locale-less tag on its own means the *index* of a localized content type's + // per-locale files, so it is expired only when the set of files or their count + // moved - never for a title edit, which rewrites bytes inside one existing file. + // De-duplicated, because a content type that is not localized produces only this + // form and the line above already emitted it. return [ ...new Set( - delivery.sitemap + delivery.sitemap.indexChanged ? [...tags, contentDeliverySitemapTag(contentTypeId)] : tags, ), diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index b1c00d961..eebb070dc 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -48,6 +48,7 @@ export type { ContentLocaleInvalidation, ContentLocaleState, ContentPublicLocaleState, + ContentSitemapChange, } from "./cache"; export { parseContentConflict, diff --git a/packages/vitnode/src/content/server/delivery-effects.ts b/packages/vitnode/src/content/server/delivery-effects.ts index 07e3e9ab7..acab0d012 100644 --- a/packages/vitnode/src/content/server/delivery-effects.ts +++ b/packages/vitnode/src/content/server/delivery-effects.ts @@ -121,6 +121,12 @@ export const contentDeliveryInvalidation = ( // A content type with delivery whose mutation reported nothing still expires its // delivery metadata - a shared SEO field moving changes what every locale's - // `<head>` renders even though no URL moved. Only the sitemap is conditional. - return { sitemap: delivery?.sitemapChanged ?? false }; + // `<head>` renders even though no URL moved. Only the sitemap is conditional, and + // an absent outcome means the mutation touched no slug-bearing path at all. + return { + sitemap: delivery?.sitemap ?? { + contentChanged: false, + indexChanged: false, + }, + }; }; diff --git a/packages/vitnode/src/content/server/delivery-writes.ts b/packages/vitnode/src/content/server/delivery-writes.ts index 968ec866f..551f6ce75 100644 --- a/packages/vitnode/src/content/server/delivery-writes.ts +++ b/packages/vitnode/src/content/server/delivery-writes.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; +import type { ContentSitemapChange } from "../cache"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDatabase } from "./service"; import type { ContentSlugHistoryModel } from "./slug-history-model"; @@ -36,8 +37,15 @@ export interface ContentDeliveryOutcome { * moved". It is what the `delivery_redirect_created` event is gated on. */ redirectCreated: boolean; - /** Whether the set of URLs a sitemap lists changed. */ - sitemapChanged: boolean; + /** + * What this mutation did to the sitemap. + * + * Two booleans rather than one, because a sitemap entry carries a `<lastmod>` + * derived from `updatedAt`: a plain title edit on a published record changes the + * file's bytes without changing which URLs it lists. See + * {@link ContentSitemapChange}. + */ + sitemap: ContentSitemapChange; /** The slug the record answers to now, or `null` once it is deleted. */ slug: null | string; /** Whether the canonical URL is different from what it was. */ @@ -166,10 +174,16 @@ export const applyContentDeliveryWrite = async ({ previousPath: slugChanged ? previousPath : null, previousSlug: slugChanged ? previousSlug : null, redirectCreated, - // A line is added, removed or moved when public reachability changed or when - // the URL did. An edit that only changed what an already-listed page says - // leaves the sitemap byte-identical. - sitemapChanged: wasPublic !== isPublic || slugChanged, + sitemap: { + // Any real mutation of a record that is or was publicly reachable changes the + // file: it gained a line, lost one, moved one, or moved its own `<lastmod>`. + // This function is only ever reached for a real mutation - a no-op update + // returns before the delivery step - so "was or is public" is the whole test. + contentChanged: wasPublic || isPublic, + // Only appearing or disappearing changes how many files an index lists. A slug + // change rewrites one line inside a file; a title edit rewrites a timestamp. + indexChanged: wasPublic !== isPublic, + }, slug, slugChanged, }; diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts index a3c850e4b..05dbf15d3 100644 --- a/packages/vitnode/src/content/server/schedule-effects.ts +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -212,10 +212,13 @@ export const runContentScheduleEffects = async ( const revalidation = await dispatchContentRevalidation(c, { contentTypeId: definition.id, - // A scheduled transition always adds or removes a sitemap line, so the delivery - // tags - including the sitemap's - go out with the rest. Absent for a content - // type without `delivery`, which keeps its tag list byte-identical. - ...(definition.delivery.enabled ? { delivery: { sitemap: true } } : {}), + // A scheduled transition always flips public reachability, so it changes both the + // file it adds a line to (or removes one from) and the index that counts them. + // Absent for a content type without `delivery`, which keeps its tag list + // byte-identical. + ...(definition.delivery.enabled + ? { delivery: { sitemap: { contentChanged: true, indexChanged: true } } } + : {}), id: payload.itemId, isPublic: isContentRowPublic(row), // A scheduled transition moves the *record*, and the record's publication diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts index 16ea7ad4b..806c2d441 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import type { ContentPublicLocaleState } from "@/content/cache"; +import type { ContentDeliveryInvalidation } from "@/content/cache"; import type { ContentConflict, ContentDeliveryConflict, @@ -202,7 +203,7 @@ const invalidate = ( revalidateContent( { contentTypeId: definition.id, - ...deliveryInvalidationFor(definition, previous, current), + ...deliveryInvalidationFor(definition, before, after, previous, current), id, isPublic: current.isPublic, // Both, so a slug change stops the old URL and starts the new one. @@ -216,28 +217,67 @@ const invalidate = ( /** * The delivery half of a nonlocalized mutation's invalidation. * - * `{}` for a content type without `delivery`, so spreading it leaves the input - - * and therefore the tag list - exactly as it was. A sitemap line is added, removed - * or moved when public reachability changed or when the URL did, which is the same - * rule `applyContentDeliveryWrite` reports from inside the transaction; stated twice - * because the Server Action cannot see the outcome, only the two rows. + * `{}` for a content type without `delivery`, so spreading it leaves the input - and + * therefore the tag list - exactly as it was. + * + * `contentChanged` is decided by comparing `updatedAt` across the write, which is not + * a proxy for "did the sitemap change" but *the value the sitemap serializes*: a + * sitemap entry's `<lastmod>` is `base.updatedAt`, so the two move together by + * construction. It also answers "was this a no-op" for free - the engine issues no + * `UPDATE` for an update that changed nothing, so the timestamp does not move and the + * cached sitemap is still correct. + * + * `indexChanged` is public reachability flipping, and nothing else: an index lists + * files and counts URLs, so a slug change or a title edit leaves it alone. */ const deliveryInvalidationFor = ( definition: AnyContentTypeDefinition, - previous: { isPublic: boolean; slug: string }, - current: { isPublic: boolean; slug: string }, -): { delivery?: { sitemap: boolean } } => - definition.delivery.enabled - ? { - delivery: { - sitemap: - previous.isPublic !== current.isPublic || - (previous.slug !== "" && - current.slug !== "" && - previous.slug !== current.slug), - }, - } - : {}; + before: ContentRow | undefined, + after: ContentRow | undefined, + previous: { isPublic: boolean }, + current: { isPublic: boolean }, +): { delivery?: ContentDeliveryInvalidation } => { + if (!definition.delivery.enabled) return {}; + + const reachable = previous.isPublic || current.isPublic; + + return { + delivery: { + sitemap: { + contentChanged: reachable && timestampMoved(before, after), + indexChanged: previous.isPublic !== current.isPublic, + }, + }, + }; +}; + +/** + * Whether `updatedAt` moved across a write. + * + * `true` when either side is missing - a create or a delete - because the record + * appeared or disappeared and there is no pair to compare. Unparseable values are + * treated the same way: a cached sitemap that might be stale is worse than a cache + * miss. + */ +const timestampMoved = ( + before: ContentRow | undefined, + after: ContentRow | undefined, +): boolean => { + const at = (row: ContentRow | undefined): null | number => { + const value = row?.updatedAt; + if (value instanceof Date) return value.getTime(); + if (typeof value !== "string") return null; + + const parsed = new Date(value).getTime(); + + return Number.isNaN(parsed) ? null : parsed; + }; + + const first = at(before); + const second = at(after); + + return first === null || second === null || first !== second; +}; export const createContentAction = async ( contentTypeId: string, @@ -635,13 +675,22 @@ export const deleteContentAction = async ( // expiring a URL that is now gone forever costs nothing. const removed = publicStateOf(definition, result.data); + const wasEverPublic = result.data?.publishedAt != null; + revalidateContent( { contentTypeId: definition.id, - // The sitemap has lost a line whenever the record had one, which is exactly - // "was it ever published" - the same question the `wasPublic` below asks. + // A delete removes a line from the file and one URL from the index's count, + // whenever the record had one - which is exactly "was it ever published". ...(definition.delivery.enabled - ? { delivery: { sitemap: result.data?.publishedAt != null } } + ? { + delivery: { + sitemap: { + contentChanged: wasEverPublic, + indexChanged: wasEverPublic, + }, + }, + } : {}), id, isPublic: false, @@ -706,8 +755,15 @@ const publicationAction = async ( revalidateContent( { contentTypeId: definition.id, - // A real transition always adds or removes a sitemap line. - ...(definition.delivery.enabled ? { delivery: { sitemap: true } } : {}), + // A real transition flips reachability, so it moves both the file and the + // index that counts its URLs. + ...(definition.delivery.enabled + ? { + delivery: { + sitemap: { contentChanged: true, indexChanged: true }, + }, + } + : {}), id, isPublic, slugs: [slug], diff --git a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts index 393c31a87..b2cb16a39 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts @@ -102,19 +102,28 @@ export const invalidateContentLocales = ( // The delivery tags, for a content type with `delivery`. Absent otherwise, // which is what keeps a Stage 1-7 content type's tag list byte-identical. // - // The sitemap is expired when a locale gained or lost its page, or when one - // moved its URL - which is exactly what the before/after diff already knows, - // so it is read off the states rather than passed down from the action. + // Derived from the locales this mutation actually **reached**, which is the + // Stage 5 fan-out rather than a second locale-propagation rule: a shared field + // reaches every locale, a translation reaches its own, and `sitemap:pl` is + // expired exactly when Polish's public representation moved. That is also what + // makes a plain title edit expire the right file - the sitemap's `<lastmod>` is + // derived from `updatedAt`, so any real edit to a published translation changes + // that file's bytes even though its URL did not move. ...(definition.delivery.enabled ? { delivery: { - sitemap: states.some( - state => - state.isPublic !== state.wasPublic || - (state.previousSlug !== undefined && - state.previousSlug !== "" && - state.previousSlug !== state.slug), - ), + sitemap: { + // Every reached locale that is or was public has a file whose bytes + // moved. This helper is only called for a real mutation. + contentChanged: reached.some( + entry => entry.isPublic || entry.wasPublic, + ), + // Only a locale appearing or disappearing changes how many files the + // index lists. + indexChanged: reached.some( + entry => entry.isPublic !== entry.wasPublic, + ), + }, }, } : {}), From 3810af0fc83b64e03ab7e37862ccb47dce916b16 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:35:04 +0200 Subject: [PATCH 21/24] fix(content): carry the delivery tags across the revalidation bridge Found while auditing the scheduled mutation paths. `dispatchContentRevalidation` posts the delivery block, and the web-side route parsed the body with a zod object schema that did not declare it - so it was **stripped**. Every background transition crossed the bridge carrying its delivery tags and arrived with none, leaving a stale sitemap and a stale canonical response behind every scheduled publish and unpublish. Declared explicitly, and optional: an API that has not been redeployed posts the Stage 1-7 shape, and that body still has to be accepted rather than 400. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../next/revalidate-route.server.test.ts | 58 +++++++++++++++++++ .../content/next/revalidate-route.server.ts | 17 ++++++ 2 files changed, 75 insertions(+) diff --git a/packages/vitnode/src/content/next/revalidate-route.server.test.ts b/packages/vitnode/src/content/next/revalidate-route.server.test.ts index bbfadac94..ec2cf551c 100644 --- a/packages/vitnode/src/content/next/revalidate-route.server.test.ts +++ b/packages/vitnode/src/content/next/revalidate-route.server.test.ts @@ -81,6 +81,64 @@ describe("the revalidation Route Handler", () => { ]); }); + it("carries the delivery tags across the bridge", async () => { + // The bug this pins down: `zodBody` strips whatever it does not declare, so a + // missing `delivery` member meant a scheduled publish crossed the bridge with its + // delivery tags and arrived with none - leaving a stale sitemap and a stale + // canonical response behind every background transition. + await POST( + request({ + body: JSON.stringify({ + ...body, + delivery: { sitemap: { contentChanged: true, indexChanged: true } }, + }), + }), + ); + + const tags = calls.map(call => call.tag); + + expect(tags).toContain("content:example.article:delivery:7"); + expect(tags).toContain("content:example.article:redirect:hello-world"); + expect(tags).toContain("content:example.article:sitemap"); + }); + + it("expires no sitemap tag when the bridge says it did not move", async () => { + await POST( + request({ + body: JSON.stringify({ + ...body, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, + }), + }), + ); + + const tags = calls.map(call => call.tag); + + expect(tags).toContain("content:example.article:delivery:7"); + expect(tags).not.toContain("content:example.article:sitemap"); + }); + + it("accepts a body with no delivery member at all", async () => { + // An API that has not been redeployed posts the Stage 1-7 shape, and that body + // still has to be accepted rather than 400. + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(calls.map(call => call.tag)).not.toContain( + "content:example.article:delivery:7", + ); + }); + + it("refuses a malformed delivery member", async () => { + const response = await POST( + request({ + body: JSON.stringify({ ...body, delivery: { sitemap: true } }), + }), + ); + + expect(response.status).toBe(400); + }); + it("honours stale-while-revalidate", async () => { await POST( request({ diff --git a/packages/vitnode/src/content/next/revalidate-route.server.ts b/packages/vitnode/src/content/next/revalidate-route.server.ts index c896939b6..5f65dc587 100644 --- a/packages/vitnode/src/content/next/revalidate-route.server.ts +++ b/packages/vitnode/src/content/next/revalidate-route.server.ts @@ -12,6 +12,23 @@ import { revalidateContent } from "./revalidate.server"; const zodBody = z.object({ contentTypeId: z.string().min(1), + /** + * The delivery share of a mutation, for a content type with `delivery`. + * + * Optional for the same reason `locales` is: an API that has not been redeployed + * posts a body without it, and that body still has to be accepted. Without this + * member the object schema would **strip** it - so a scheduled publish would cross + * the bridge carrying its delivery tags and arrive with none, leaving a stale + * sitemap and a stale canonical response behind every background transition. + */ + delivery: z + .object({ + sitemap: z.object({ + contentChanged: z.boolean(), + indexChanged: z.boolean(), + }), + }) + .optional(), id: z.number().int().positive(), isPublic: z.boolean(), /** From 943edf57c841fa71f3a1aed8d3ccc43651d8bfd1 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:35:26 +0200 Subject: [PATCH 22/24] test(content): cover the sitemap lastModified invariant Exact cache-tag assertions for the rule the fix establishes: a real update to a published representation expires that locale's sitemap file, because its `lastModified` moves - even when the canonical URL does not. `public-locale-cache.test.ts` is new and is the localized half: a PL translation edit expires `sitemap:pl` and not `sitemap:en`; a shared edit expires both, because the base timestamp is in `max(base, translation)` for every language; a draft translation is skipped; and none of them touches the index. It also pins the fallback case explicitly - a default-locale edit reaches a fallback-consuming locale through the Stage 5 fan-out even though that locale contributes no sitemap URL, which is a cache miss rather than staleness. The nonlocalized assertions go through the existing `mutation-api` harness, so they exercise the real `revalidateContent` end to end rather than a mock of it: title edit, SEO-only edit, no-op, draft, slug change, publish, delete, and the Stage 1-7 tag list of a content type without delivery. Every one of them fails against the previous implementation - membership-only invalidation misses five localized cases and three nonlocalized ones, and the bridge misses three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/content/cache.delivery.test.ts | 55 +++- .../content/server/delivery-effects.test.ts | 21 +- .../content/server/delivery-writes.test.ts | 30 +- .../content/actions/mutation-api.test.ts | 170 +++++++++++ .../actions/public-locale-cache.test.ts | 286 ++++++++++++++++++ .../src/database/delivery-postgres.test.ts | 106 +++++++ 6 files changed, 635 insertions(+), 33 deletions(-) create mode 100644 packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.test.ts diff --git a/packages/vitnode/src/content/cache.delivery.test.ts b/packages/vitnode/src/content/cache.delivery.test.ts index 7e77ec0fc..d641e3833 100644 --- a/packages/vitnode/src/content/cache.delivery.test.ts +++ b/packages/vitnode/src/content/cache.delivery.test.ts @@ -119,7 +119,7 @@ describe("contentInvalidationTags with delivery", () => { expect( contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 42, isPublic: true, slugs: ["old", "new"], @@ -136,34 +136,39 @@ describe("contentInvalidationTags with delivery", () => { ]); }); - it("adds the sitemap tag only when the set of listed URLs changed", () => { + it("expires the sitemap file whenever its bytes moved", () => { + // A nonlocalized content type's locale-less tag *is* its one sitemap file, so + // `contentChanged` is what expires it - including for a plain title edit, whose + // `<lastmod>` moved even though the URL did not. const withSitemap = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: true }, + delivery: { sitemap: { contentChanged: true, indexChanged: false } }, id: 42, isPublic: true, - slugs: ["new"], - wasPublic: false, + slugs: ["same"], + wasPublic: true, }); expect(withSitemap).toContain(contentDeliverySitemapTag(ID)); - const withoutSitemap = contentInvalidationTags({ + const untouched = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 42, isPublic: true, slugs: ["new"], wasPublic: true, }); - expect(withoutSitemap).not.toContain(contentDeliverySitemapTag(ID)); + expect(untouched).not.toContain(contentDeliverySitemapTag(ID)); }); it("emits the sitemap tag once for a nonlocalized content type", () => { + // `contentChanged` and `indexChanged` name the same tag here, because a + // nonlocalized content type has one file and no index. const tags = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: true }, + delivery: { sitemap: { contentChanged: true, indexChanged: true } }, id: 42, isPublic: true, slugs: ["new"], @@ -178,7 +183,7 @@ describe("contentInvalidationTags with delivery", () => { it("expires each locale's sitemap and the index that lists them", () => { const tags = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: true }, + delivery: { sitemap: { contentChanged: true, indexChanged: true } }, id: 7, isPublic: true, locales: [ @@ -191,15 +196,35 @@ describe("contentInvalidationTags with delivery", () => { expect(tags).toContain(contentDeliverySitemapTag(ID, "en")); expect(tags).toContain(contentDeliverySitemapTag(ID, "pl")); - // The locale-less one too: a localized content type's index enumerates its - // per-locale files, so a language gaining a page changes the index. + // The locale-less one too, because a language gaining a page changes how many + // files the index lists. expect(tags).toContain(contentDeliverySitemapTag(ID)); }); + it("expires a locale's file without its index on an ordinary edit", () => { + // The rule §3.6 asks for: a title edit rewrites bytes inside an existing file and + // changes neither which files exist nor how many. + const tags = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: { contentChanged: true, indexChanged: false } }, + id: 7, + isPublic: true, + locales: [ + { isPublic: true, locale: "pl", slugs: ["witaj"], wasPublic: true }, + ], + slugs: [], + wasPublic: true, + }); + + expect(tags).toContain(contentDeliverySitemapTag(ID, "pl")); + expect(tags).not.toContain(contentDeliverySitemapTag(ID)); + expect(tags).not.toContain(contentDeliverySitemapTag(ID, "en")); + }); + it("keeps one locale's delivery tags out of another's", () => { const tags = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 7, isPublic: true, locales: [ @@ -226,7 +251,7 @@ describe("contentInvalidationTags with delivery", () => { expect( contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 1, isPublic: false, slugs: ["a", "b"], @@ -238,7 +263,7 @@ describe("contentInvalidationTags with delivery", () => { it("drops an empty slug rather than tagging a redirect for it", () => { const tags = contentInvalidationTags({ contentTypeId: ID, - delivery: { sitemap: false }, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, id: 42, isPublic: true, slugs: ["", "new"], diff --git a/packages/vitnode/src/content/server/delivery-effects.test.ts b/packages/vitnode/src/content/server/delivery-effects.test.ts index 099477e8c..809f88763 100644 --- a/packages/vitnode/src/content/server/delivery-effects.test.ts +++ b/packages/vitnode/src/content/server/delivery-effects.test.ts @@ -23,6 +23,7 @@ import { const articleType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "effects.article", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true } }, fields: { slug: field.slug({ source: "title" }), @@ -62,7 +63,8 @@ const outcome = ( previousPath: "/articles/old", previousSlug: "old", redirectCreated: true, - sitemapChanged: true, + // A slug change on a published record: the file's bytes moved, the index did not. + sitemap: { contentChanged: true, indexChanged: false }, slug: "new", slugChanged: true, ...overrides, @@ -203,23 +205,24 @@ describe("contentDeliveryInvalidation", () => { expect(contentDeliveryInvalidation(plainType, outcome())).toBeUndefined(); }); - it("reports the sitemap only when the set of listed URLs changed", () => { + it("passes the sitemap change through unchanged", () => { expect(contentDeliveryInvalidation(articleType, outcome())).toStrictEqual({ - sitemap: true, + sitemap: { contentChanged: true, indexChanged: false }, }); expect( contentDeliveryInvalidation( articleType, - outcome({ sitemapChanged: false }), + outcome({ sitemap: { contentChanged: true, indexChanged: true } }), ), - ).toStrictEqual({ sitemap: false }); + ).toStrictEqual({ sitemap: { contentChanged: true, indexChanged: true } }); }); - it("still expires the delivery metadata when no URL moved", () => { - // A shared SEO field moving changes what every locale's `<head>` renders even - // though nothing was added to or removed from the sitemap. + it("expires no sitemap for a mutation that reported no delivery outcome", () => { + // The delivery metadata tag still goes out - a shared SEO field moving changes + // what every locale's `<head>` renders - but a mutation that touched no + // slug-bearing path has nothing to say about the sitemap. expect(contentDeliveryInvalidation(articleType, undefined)).toStrictEqual({ - sitemap: false, + sitemap: { contentChanged: false, indexChanged: false }, }); }); }); diff --git a/packages/vitnode/src/content/server/delivery-writes.test.ts b/packages/vitnode/src/content/server/delivery-writes.test.ts index b0c042f58..d32b96754 100644 --- a/packages/vitnode/src/content/server/delivery-writes.test.ts +++ b/packages/vitnode/src/content/server/delivery-writes.test.ts @@ -26,6 +26,7 @@ import { applyContentDeliveryWrite } from "./delivery-writes"; const articleType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "writes.article", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true } }, fields: { slug: field.slug({ source: "title" }), @@ -43,6 +44,7 @@ const articleType = defineContentType({ const localizedType = defineContentType({ admin: { label: { plural: "Articles", singular: "Article" } }, id: "writes.localized", + editorial: { enabled: true }, delivery: { enabled: true, redirects: { enabled: true } }, fields: { slug: field.slug({ localized: true, source: "title" }), @@ -149,7 +151,8 @@ describe("a draft", () => { expect(outcome).toMatchObject({ canonicalPath: "/articles/hello", redirectCreated: false, - sitemapChanged: false, + // Neither public before nor after, so no sitemap file lists it either way. + sitemap: { contentChanged: false, indexChanged: false }, slugChanged: false, }); }); @@ -207,8 +210,12 @@ describe("publishing", () => { kind: "reserve", }, ]); - // A publish adds a sitemap line even though no URL moved. - expect(outcome).toMatchObject({ sitemapChanged: true, slugChanged: false }); + // A publish adds a sitemap line even though no URL moved, and changes how many + // URLs the index counts. + expect(outcome).toMatchObject({ + sitemap: { contentChanged: true, indexChanged: true }, + slugChanged: false, + }); }); it("refuses an address another record's history owns", async () => { @@ -250,7 +257,9 @@ describe("moving a published URL", () => { previousPath: "/articles/old", previousSlug: "old", redirectCreated: true, - sitemapChanged: true, + // The file's bytes moved - one line now reads a different URL - but the number + // of files an index lists did not. + sitemap: { contentChanged: true, indexChanged: false }, slugChanged: true, }); }); @@ -292,7 +301,10 @@ describe("unpublishing and deleting", () => { // No retire (the slug did not move) and no reserve (it is not public). The // resolver stops redirecting because it reads the live publication state. expect(calls).toStrictEqual([]); - expect(outcome).toMatchObject({ sitemapChanged: true, slugChanged: false }); + expect(outcome).toMatchObject({ + sitemap: { contentChanged: true, indexChanged: true }, + slugChanged: false, + }); }); it("writes nothing on a delete, and reports the lost sitemap line", async () => { @@ -309,7 +321,7 @@ describe("unpublishing and deleting", () => { expect(calls).toStrictEqual([]); expect(outcome).toMatchObject({ canonicalPath: null, - sitemapChanged: true, + sitemap: { contentChanged: true, indexChanged: true }, slug: null, slugChanged: false, }); @@ -396,10 +408,10 @@ describe("delivery without redirects", () => { expect(outcome).toMatchObject({ canonicalPath: "/a/new", previousPath: "/a/old", - // The URL moved and the sitemap changed - the engine simply cannot redirect - // the old address, because nothing recorded it. + // The URL moved and the file changed - the engine simply cannot redirect the + // old address, because nothing recorded it. redirectCreated: false, - sitemapChanged: true, + sitemap: { contentChanged: true, indexChanged: false }, slugChanged: true, }); }); diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts index a55801b2a..629154aa9 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts @@ -5,6 +5,7 @@ import type { AnyContentTypeDefinition } from "@/content/types"; import { testCategoryContentType, + testDeliveredPostContentType, testEditorialPostContentType, testPostContentType, } from "@/tests/content-fixtures"; @@ -79,6 +80,27 @@ const slugTag = (slug: string) => `content:test.post:slug:${slug}`; const editorialSlugTag = (slug: string) => `content:test.editorial:slug:${slug}`; +/** The delivery fixture's own tags. */ +const DELIVERED = "test.delivered-post"; +const DELIVERY_ITEM = `content:${DELIVERED}:delivery:7`; +const DELIVERY_SITEMAP = `content:${DELIVERED}:sitemap`; +const deliveryRedirectTag = (slug: string) => + `content:${DELIVERED}:redirect:${slug}`; + +/** + * One `updatedAt` per call, monotonically increasing. + * + * A real write moves the timestamp; a no-op does not. That distinction is the whole + * signal the sitemap decision reads, so the fixtures have to be explicit about it + * rather than reusing one constant everywhere. + */ +let clock = Date.parse("2026-01-01T00:00:00.000Z"); +const tick = (): string => { + clock += 60_000; + + return new Date(clock).toISOString(); +}; + const tags = () => cacheCalls.map(call => call.tag); /** Which Next cache API was used - `updateTag` is the immediate one. */ const mode = () => [...new Set(cacheCalls.map(call => call.fn))]; @@ -164,6 +186,154 @@ describe("edit", () => { }); }); +/** + * The sitemap half of delivery invalidation. + * + * A sitemap entry carries `<lastmod>`, derived from `updatedAt` - so a plain title + * edit on a published record changes the **bytes** of its sitemap file even though the + * set of URLs is identical. Treating "the sitemap changed" as "membership changed" + * leaves a cached file serving a stale timestamp, which is what these tests pin down. + */ +describe("delivery sitemap invalidation", () => { + const published = (slug: string, updatedAt: string) => ({ + data: { + id: 7, + publishedAt: past, + slug, + status: "published", + updatedAt, + }, + status: 200, + }); + + beforeEach(() => { + definition = testDeliveredPostContentType; + }); + + it("expires the sitemap for a title edit that moved no URL", async () => { + const before = tick(); + responses = [published("same", before), published("same", tick())]; + + await editContentAction(DELIVERED, 7, { title: "Hello" }); + + // The URL did not move, so nothing was added to or removed from the file - but + // `updatedAt` did, so its `<lastmod>` is different and the cached bytes are stale. + expect(tags()).toContain(DELIVERY_SITEMAP); + }); + + it("expires the sitemap for an SEO-only edit", async () => { + const before = tick(); + responses = [published("same", before), published("same", tick())]; + + await editContentAction(DELIVERED, 7, { excerpt: "A new summary." }); + + expect(tags()).toContain(DELIVERY_SITEMAP); + }); + + it("leaves the sitemap alone for a no-op edit", async () => { + // The engine issues no `UPDATE` for an update that changed nothing, so + // `updatedAt` does not move and the cached sitemap is still byte-correct. + const unchanged = tick(); + responses = [published("same", unchanged), published("same", unchanged)]; + + await editContentAction(DELIVERED, 7, { title: "Hello" }); + + expect(tags()).not.toContain(DELIVERY_SITEMAP); + // The rest of the delivery invalidation still happens: the metadata tag and the + // slug's redirect lookup are expired whether or not the sitemap moved. + expect(tags()).toContain(DELIVERY_ITEM); + expect(tags()).toContain(deliveryRedirectTag("same")); + }); + + it("leaves the sitemap alone for a draft edit", async () => { + const draft = (updatedAt: string) => ({ + data: { + id: 7, + publishedAt: null, + slug: "draft", + status: "draft", + updatedAt, + }, + status: 200, + }); + responses = [draft(tick()), draft(tick())]; + + await editContentAction(DELIVERED, 7, { title: "Hello" }); + + // Not public before or after, so it is in no sitemap file either way. + expect(cacheCalls).toEqual([]); + }); + + it("expires the sitemap on a slug change", async () => { + responses = [published("old", tick()), published("new", tick())]; + + await editContentAction(DELIVERED, 7, { title: "Hello" }); + + expect(tags()).toContain(DELIVERY_SITEMAP); + expect(tags()).toContain(deliveryRedirectTag("old")); + expect(tags()).toContain(deliveryRedirectTag("new")); + }); + + it("expires the sitemap on publish and on unpublish", async () => { + responses = [ + { + data: { + changed: true, + row: { + id: 7, + publishedAt: past, + slug: "hello", + status: "published", + updatedAt: tick(), + }, + }, + status: 200, + }, + ]; + + await publishContentAction(DELIVERED, 7); + + expect(tags()).toContain(DELIVERY_SITEMAP); + }); + + it("expires the sitemap on delete when the record had been published", async () => { + responses = [published("hello", tick())]; + + await deleteContentAction(DELIVERED, 7, 1); + + expect(tags()).toContain(DELIVERY_SITEMAP); + }); + + it("leaves the sitemap alone when deleting a record that was never published", async () => { + responses = [ + { + data: { + id: 7, + publishedAt: null, + slug: "draft", + status: "draft", + updatedAt: tick(), + }, + status: 200, + }, + ]; + + await deleteContentAction(DELIVERED, 7, 1); + + expect(tags()).not.toContain(DELIVERY_SITEMAP); + }); + + it("adds no delivery tags at all to a content type without delivery", async () => { + // The Stage 1-7 promise: the tag list of an existing content type does not move. + definition = testPostContentType; + responses = [published("same", tick()), published("same", tick())]; + + await editContentAction("test.post", 7, { title: "Hello" }); + + expect(tags()).toEqual([LIST, ITEM, slugTag("same")]); + }); +}); + describe("publish and unpublish", () => { it("expires the list, the item and the slug on publish", async () => { responses = [ diff --git a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.test.ts b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.test.ts new file mode 100644 index 000000000..a460ea38f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.test.ts @@ -0,0 +1,286 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ContentPublicLocaleState } from "@/content/cache"; + +import { + testDeliveredLocalizedContentType, + testLocalizedPageContentType, +} from "@/tests/content-fixtures"; + +const cacheTags: string[] = []; + +// The real `revalidate.server` runs, so what is asserted is the tag list +// `contentInvalidationTags` actually produces - mocking the layer in between would +// test the mock. +vi.mock("server-only", () => ({})); + +vi.mock("next/cache", () => ({ + revalidatePath: () => undefined, + revalidateTag: (tag: string) => { + cacheTags.push(tag); + }, + updateTag: (tag: string) => { + cacheTags.push(tag); + }, +})); + +vi.mock("@/content/admin/fetch.server", () => ({ + contentApiFetch: async () => await Promise.resolve({ status: 500 }), +})); + +const { invalidateContentLocales } = await import("./public-locale-cache"); + +/** + * The localized half of delivery sitemap invalidation. + * + * A localized sitemap entry's `lastModified` is `max(base.updatedAt, + * translation.updatedAt)`, so a real edit to a **published translation** changes that + * locale's sitemap file even when its URL does not move - and a shared field edit + * changes every published locale's file, because the base timestamp is in all of them. + * + * The distinction these tests pin down is which locale, and whether the *index* moved: + * a title edit rewrites bytes inside one existing file and changes neither which files + * exist nor how many. + */ +const DELIVERED = "test.delivered-localized"; +const sitemapTag = (locale?: string) => + locale === undefined + ? `content:${DELIVERED}:sitemap` + : `content:${DELIVERED}:sitemap:${locale}`; + +/** A locale with its own published translation. */ +const own = (locale: string, slug: string): ContentPublicLocaleState => ({ + hasOwnTranslation: true, + isPublic: true, + locale, + slug, +}); + +/** A locale served the default translation, with none of its own. */ +const fallbackOnly = ( + locale: string, + slug: string, +): ContentPublicLocaleState => ({ + hasOwnTranslation: false, + isPublic: true, + locale, + slug, +}); + +beforeEach(() => { + cacheTags.length = 0; +}); + +describe("a translation update", () => { + it("expires only that locale's sitemap file", () => { + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "translation", locale: "pl" }, + ); + + expect(cacheTags).toContain(sitemapTag("pl")); + // English did not move, so its file is still byte-correct. + expect(cacheTags).not.toContain(sitemapTag("en")); + }); + + it("leaves the sitemap index alone", () => { + // A title edit rewrites a `<lastmod>` inside one file. It does not change which + // files exist, so the index that enumerates them is untouched. + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "translation", locale: "pl" }, + ); + + expect(cacheTags).not.toContain(sitemapTag()); + }); +}); + +describe("a shared update", () => { + it("expires every published locale's sitemap file", () => { + // The base row's `updatedAt` is part of `max(base, translation)` for every + // language, so a shared edit changes what each of their files serializes. + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "shared" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + expect(cacheTags).toContain(sitemapTag("pl")); + }); + + it("still leaves the index alone", () => { + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "shared" }, + ); + + expect(cacheTags).not.toContain(sitemapTag()); + }); + + it("skips a locale that is not public", () => { + const states = [ + own("en", "hello"), + { hasOwnTranslation: true, isPublic: false, locale: "pl", slug: "witaj" }, + ]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "shared" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + // A draft translation is in no sitemap, so nothing about it went stale. + expect(cacheTags).not.toContain(sitemapTag("pl")); + }); +}); + +describe("a default-locale update with a fallback consumer", () => { + it("follows the Stage 5 fan-out", () => { + // `fallback: "default"` makes Polish's *public page* the English translation, so + // Stage 5 reaches Polish - and this reuses that fan-out rather than inventing a + // second locale-propagation rule. + // + // Polish contributes **no sitemap URL**, because a sitemap never lists a fallback + // (the delivery Postgres suite asserts that directly), so expiring its file is + // conservative rather than necessary: a cache miss, never a stale document. + const states = [own("en", "hello"), fallbackOnly("pl", "hello")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "translation", locale: "en" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + expect(cacheTags).toContain(sitemapTag("pl")); + }); + + it("does not reach a locale with its own translation", () => { + // Nothing falls back to a language that has its own copy, so a default-locale + // edit leaves it entirely alone. + const states = [own("en", "hello"), own("pl", "witaj")]; + + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + states, + states, + { changed: "translation", locale: "en" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + expect(cacheTags).not.toContain(sitemapTag("pl")); + }); +}); + +describe("membership changes", () => { + it("expires the file and the index when a translation is published", () => { + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + [ + own("en", "hello"), + { + hasOwnTranslation: true, + isPublic: false, + locale: "pl", + slug: "witaj", + }, + ], + [own("en", "hello"), own("pl", "witaj")], + { changed: "translation", locale: "pl" }, + ); + + expect(cacheTags).toContain(sitemapTag("pl")); + // A language gained a URL, so how many the index counts moved. + expect(cacheTags).toContain(sitemapTag()); + }); + + it("expires the file and the index when a translation is deleted", () => { + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + [own("en", "hello"), own("pl", "witaj")], + // Absent from the "after" side entirely: the translation is gone. + [own("en", "hello")], + { changed: "translation", locale: "pl" }, + ); + + expect(cacheTags).toContain(sitemapTag("pl")); + expect(cacheTags).toContain(sitemapTag()); + }); + + it("expires every locale's file and the index when the record is unpublished", () => { + invalidateContentLocales( + testDeliveredLocalizedContentType, + 7, + [own("en", "hello"), own("pl", "witaj")], + [ + { + hasOwnTranslation: true, + isPublic: false, + locale: "en", + slug: "hello", + }, + { + hasOwnTranslation: true, + isPublic: false, + locale: "pl", + slug: "witaj", + }, + ], + { changed: "shared" }, + ); + + expect(cacheTags).toContain(sitemapTag("en")); + expect(cacheTags).toContain(sitemapTag("pl")); + expect(cacheTags).toContain(sitemapTag()); + }); +}); + +describe("a localized content type without delivery", () => { + it("produces exactly the Stage 1-7 tag list", () => { + const states = [ + { hasOwnTranslation: true, isPublic: true, locale: "en", slug: "hello" }, + { hasOwnTranslation: true, isPublic: true, locale: "pl", slug: "witaj" }, + ]; + + invalidateContentLocales(testLocalizedPageContentType, 7, states, states, { + changed: "translation", + locale: "pl", + }); + + const id = testLocalizedPageContentType.id; + expect(cacheTags).toStrictEqual([ + `content:${id}:list:pl`, + `content:${id}:item:pl:7`, + `content:${id}:slug:pl:witaj`, + ]); + }); +}); diff --git a/plugins/example/src/database/delivery-postgres.test.ts b/plugins/example/src/database/delivery-postgres.test.ts index 9cf061ab8..b966301fd 100644 --- a/plugins/example/src/database/delivery-postgres.test.ts +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -951,6 +951,64 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { expect(cursor).toBeNull(); }); + it("moves lastModified on an ordinary edit that keeps the URL", async () => { + const article = await publishArticle({ title: "Timestamped" }); + const first = await delivery()?.sitemap(); + const before = first?.entries[0].lastModified.getTime() ?? 0; + + // A title edit. The slug is never re-derived on update, so the URL is + // unchanged - and the sitemap's `<lastmod>` still has to move, which is the + // whole reason `contentChanged` cannot be "did membership change". + await editorial()?.update( + article.id, + { excerpt: "A new summary." }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + const second = await delivery()?.sitemap(); + + expect(second?.entries[0].path).toBe(first?.entries[0].path); + expect(second?.entries[0].lastModified.getTime()).toBeGreaterThan(before); + }); + + it("reports the edit as a sitemap content change but not an index change", async () => { + const article = await publishArticle({ title: "Timestamped two" }); + + const outcome = await editorial()?.update( + article.id, + { excerpt: "Changed." }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + // The invariant the cache layer reads: the file's bytes moved, the set of files + // did not. A stale sitemap is exactly what the first half prevents. + expect(outcome?.delivery?.sitemap).toStrictEqual({ + contentChanged: true, + indexChanged: false, + }); + }); + + it("reports no sitemap change for a no-op edit", async () => { + const article = await publishArticle({ title: "Untouched" }); + const before = await delivery()?.sitemap(); + + // Re-sending the stored value writes nothing, so `updatedAt` does not move and + // the cached sitemap is still byte-correct. + const outcome = await editorial()?.update( + article.id, + { title: "Untouched" }, + { actor: ACTOR, expectedVersion: article.version }, + ); + + expect(outcome?.changed).toBe(false); + expect(outcome?.delivery).toBeUndefined(); + + const after = await delivery()?.sitemap(); + expect(after?.entries[0].lastModified.getTime()).toBe( + before?.entries[0].lastModified.getTime(), + ); + }); + it("uses the base row's updatedAt for a nonlocalized entry", async () => { const article = await publishArticle({ title: "Timestamped" }); // Read through the same driver as the sitemap, never as `::text`: a @@ -1404,6 +1462,54 @@ describe.skipIf(!url)("Stage 8 content delivery against Postgres", () => { expect(article.id).toBeGreaterThan(0); }); + it("moves a translation's lastModified on an ordinary edit", async () => { + const article = await publishLocalized(); + const before = await advancedDelivery()?.sitemap({ locale: "en" }); + + const outcome = await translationEditorial()?.update( + article.id, + "en", + { seo: { description: "A new summary." } }, + { actor: ACTOR, expectedVersion: article.enVersion }, + ); + + const after = await advancedDelivery()?.sitemap({ locale: "en" }); + + // Same URL, later timestamp - and the outcome says so, which is what expires + // `sitemap:en` and nothing else. + expect(after?.entries[0].path).toBe(before?.entries[0].path); + expect(after?.entries[0].lastModified.getTime()).toBeGreaterThan( + before?.entries[0].lastModified.getTime() ?? 0, + ); + expect(outcome?.delivery?.sitemap).toStrictEqual({ + contentChanged: true, + indexChanged: false, + }); + }); + + it("reports an index change when a translation is published", async () => { + const localized = localizedService(); + if (!localized) throw new Error("no localized service"); + + const created = await localized.create({ + shared: {}, + translation: { title: "Fresh" }, + }); + await advancedEditorial()?.publish(created.row.id, { actor: ACTOR }); + + const outcome = await translationEditorial()?.publish( + created.row.id, + "en", + { actor: ACTOR }, + ); + + // A language gained a URL, so how many the index counts moved too. + expect(outcome?.delivery?.sitemap).toStrictEqual({ + contentChanged: true, + indexChanged: true, + }); + }); + it("takes the later of the base and translation timestamps", async () => { const article = await publishLocalized(); From d07fb1de3510a931568517210a4b9c826648b36f Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:35:26 +0200 Subject: [PATCH 23/24] docs(content): correct the sitemap cache and Editorial requirements The caching page said an edit that only changed what an already-listed page says leaves the sitemap byte-identical. That is false while `<lastmod>` is derived from `updatedAt`, so it is replaced with the rule that is true, plus a per-mutation matrix that separates the sitemap **file** from the sitemap **index** and names the no-op rows explicitly. `slug-history-and-redirects` gains the Editorial requirement with the reason - one transaction for the slug, its version and its history - and a table of what does *not* need Editorial, so nobody reads the restriction as applying to all of `delivery`. `content-delivery-limitations` gains both new refusals: redirects without Editorial, and a delivery path colliding site-wide across two plugins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../docs/dev/content-engine/caching.mdx | 88 ++++++++++++++++--- .../content-delivery-limitations.mdx | 32 +++++++ .../dev/content-engine/content-delivery.mdx | 14 ++- .../docs/dev/content-engine/sitemaps.mdx | 17 ++++ .../slug-history-and-redirects.mdx | 40 ++++++++- 5 files changed, 174 insertions(+), 17 deletions(-) diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx index 6952b4c75..610f559e4 100644 --- a/apps/docs/content/docs/dev/content-engine/caching.mdx +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -404,20 +404,75 @@ contentInvalidationTags({ }); ``` -| Mutation | delivery | redirect (old + new) | sitemap | -| --------------------------------- | -------- | -------------------- | ------- | -| Slug change (published) | ✅ | ✅ | ✅ | -| Publish / unpublish | ✅ | ✅ | ✅ | -| Delete | ✅ | ✅ | ✅ | -| Restore that moves a slug | ✅ | ✅ | ✅ | -| Translation create / delete | ✅ | ✅ | ✅ | -| SEO field edit (still published) | ✅ | ✅ | ❌ | - -The last row is the one worth reading twice: an edit that only changed what an -already-listed page *says* leaves the sitemap byte-identical, so its tag is not -expired. Everything that adds, removes or moves a line in the file expires it - and on -a localized content type that means each affected locale's file **and** the -locale-less index that enumerates them. +```ts +contentInvalidationTags({ + contentTypeId, + delivery: { sitemap: { contentChanged: true, indexChanged: false } }, + id, + isPublic, + slugs: [previousSlug, currentSlug], + wasPublic, +}); +``` + +The sitemap is **two** decisions rather than one, and the reason is `<lastmod>`. A +sitemap entry carries a `lastModified` derived from `updatedAt`, so a plain title edit +on a published record changes the *bytes* of its sitemap file even though the set of +URLs in it is identical: + +- **`contentChanged`** expires the sitemap **file** of each locale the mutation + reached. True for any real mutation of a record that is or was publicly reachable. +- **`indexChanged`** expires the locale-less tag, which for a localized content type is + the *index* of its per-locale files. True only when public reachability flipped, + because an index lists files and counts URLs. + +| Mutation | delivery | redirect (old + new) | sitemap file | sitemap index | +| ----------------------------------- | -------- | -------------------- | ------------ | ------------- | +| Title / SEO edit (still published) | ✅ | ✅ | ✅ | ❌ | +| Slug change (published) | ✅ | ✅ | ✅ | ❌ | +| Publish / unpublish | ✅ | ✅ | ✅ | ✅ | +| Delete (was published) | ✅ | ✅ | ✅ | ✅ | +| Restore that moves a slug | ✅ | ✅ | ✅ | ❌ | +| Translation publish / unpublish | ✅ | ✅ | ✅ | ✅ | +| Translation create / delete | ✅ | ✅ | ✅ | ✅ | +| No-op edit | ❌ | ❌ | ❌ | ❌ | +| Draft edited into another draft | ❌ | ❌ | ❌ | ❌ | + +The first row is the one worth reading twice. **A real update to a published +representation expires that locale's sitemap file, because its `lastModified` changes - +even when the canonical URL stays the same.** Anything else would leave a cached +sitemap serving a timestamp that is no longer true. + +The last two rows are the other half of the same rule: the engine issues no `UPDATE` +for an update that changed nothing, so `updatedAt` does not move and the cached file is +still byte-correct. A draft is in no sitemap either way. + +<Callout type="info" title="A nonlocalized content type has one file and no index"> + Its locale-less tag *is* its sitemap file, so `contentChanged` is what expires it. + The locale-less tag means "the index" only for a localized content type, whose files + are the per-locale ones. +</Callout> + +### Which locale's sitemap + +Per locale, reusing the Stage 5 fan-out above rather than a second rule: + +```text +PL translation edit → sitemap:pl +EN translation edit → sitemap:en +shared field edit → sitemap:en AND sitemap:pl (the base `updatedAt` is in both) +``` + +A shared edit reaches every locale because a localized entry's `lastModified` is +`max(base.updatedAt, translation.updatedAt)` - so a new base timestamp becomes the +effective value for every published translation. + +One conservative case is worth naming: with `fallback: "default"`, an edit to the +**default** locale's translation also reaches every locale that has no translation of +its own, because that is where their public pages come from. Those locales contribute +no sitemap URL at all - a sitemap never lists a fallback - so expiring their files is a +cache miss rather than a necessity. Following the Stage 5 fan-out is deliberate: one +locale-propagation rule, not two. <Callout type="warn" title="Delivery is opt-in at this layer too"> Omit `delivery` from the input - which is what every content type without the block @@ -432,6 +487,11 @@ A [scheduled](/docs/dev/content-engine/scheduling) publish reaches the web app t the same revalidation bridge, with the delivery tags included - there is no second cross-origin invalidation system, and the same all-origins-must-accept rule applies. +The bridge's request schema declares `delivery` explicitly, because an object schema +strips what it does not name: a body that carried delivery tags and arrived without +them would leave a stale sitemap behind every background transition. It stays optional, +so an API that has not been redeployed keeps working. + ## Where the Next imports live Exactly one place: `@vitnode/core/content/next`. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx index aea4c83b7..9d49d522c 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx @@ -43,6 +43,38 @@ arbitrary URLs. Slug history maps one record's old addresses to that record's cu one; a rule engine over paths is a different feature living in a different layer (a middleware, a CDN, a `next.config` `redirects` array). +## Redirects require Editorial + +```ts +delivery: { enabled: true, redirects: { enabled: true } } +// ✖ without `editorial: { enabled: true }` +``` + +Slug history has to be written in the same transaction as the slug mutation, its version +check and its revision - and only the editorial mutation paths own such a transaction. +Without `editorial` a content type writes through the plain repository, so +`redirects: { enabled: true }` there would record nothing. + +Refused at definition time rather than downgraded, and **only** `redirects` is affected: +canonical URLs, SEO, alternates, `hreflang`, the sitemap and every delivery read remain +available without Editorial, which keeps Stage 5's "publication and localization without +Editorial" promise intact. See +[Redirects require Editorial](/docs/dev/content-engine/slug-history-and-redirects#redirects-require-editorial). + +Lifting it would mean giving the plain mutation paths a version column and a +transactional history write - which is most of what `editorial` already is. + +## A delivery path is a site-wide namespace + +Two plugins may publish the same `publicApi.path` while neither has `delivery`, because +their API routes are `/api/{pluginId}/content/{path}`. Two **delivery-enabled** content +types may not, because a canonical delivery URL is `/articles/{slug}` with no plugin id +in it - one path would give one public URL two owners. + +The fix is a boot-time check rather than a prefix: adding the plugin id to canonical URLs +would make every public content URL uglier for everybody to avoid a collision almost +nobody hits. Rename one `publicApi.path`, or turn `delivery` off on one of them. + ## No og:image `delivery.seo.openGraph` projects a title and a description, and stops there. diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx index a66f09af2..1d982e50b 100644 --- a/apps/docs/content/docs/dev/content-engine/content-delivery.mdx +++ b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx @@ -34,6 +34,10 @@ export const articleContentType = defineContentType({ fields: ["id", "title", "slug", "excerpt", "publishedAt"], }, + // `redirects` below needs this: slug history is written in the same transaction as + // the slug mutation and its revision. + editorial: { enabled: true }, + delivery: { // [!code highlight] enabled: true, // [!code highlight] redirects: { enabled: true }, // [!code highlight] @@ -71,7 +75,7 @@ sitemap entry. | Block | What it adds | | ----------- | ------------------------------------------------------------------ | -| `redirects` | Durable [slug history](/docs/dev/content-engine/slug-history-and-redirects) and automatic 308s | +| `redirects` | Durable [slug history](/docs/dev/content-engine/slug-history-and-redirects) and automatic 308s. **Needs `editorial`** | | `seo` | [Title, description, Open Graph and robots](/docs/dev/content-engine/seo) projection | | `sitemap` | A paginated [sitemap service](/docs/dev/content-engine/sitemaps) | | `hreflang` | An `x-default` for [localized alternates](/docs/dev/content-engine/localization-and-hreflang) | @@ -163,6 +167,7 @@ being indexable - and that is not a symptom anybody notices. | Rule | Result | | ------------------------------------------------------ | -------------------------- | | `delivery` without `publicApi` | Compile error + throw | +| `redirects` without `editorial` | Compile error + throw | | `sitemap` without `publication` | Throw | | `redirects` on a localized type with a **shared** slug | Throw | | An SEO field not in `publicApi.fields` | Compile error + throw | @@ -176,7 +181,12 @@ being indexable - and that is not a symptom anybody notices. | A localized content type withholding `"id"` | Throw | | A fallback SEO field with no primary | Throw | -Two are worth a word. A **localized** content type has to expose `"id"` in +Three are worth a word. `redirects` needs `editorial: { enabled: true }`, because slug +history has to be written in the same transaction as the slug mutation and its revision - +and only the editorial mutation paths own one. Nothing else in `delivery` needs it; see +[Redirects require Editorial](/docs/dev/content-engine/slug-history-and-redirects#redirects-require-editorial). + +A **localized** content type has to expose `"id"` in `publicApi.fields`, because alternates and `hreflang` are resolved by identifier and delivery reads the public projection - so without it every localized response would carry an empty alternate set, which looks exactly like a record with one translation. diff --git a/apps/docs/content/docs/dev/content-engine/sitemaps.mdx b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx index f738b2040..21952d9ac 100644 --- a/apps/docs/content/docs/dev/content-engine/sitemaps.mdx +++ b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx @@ -144,6 +144,23 @@ Both are omitted from the XML when unset, which is valid. There are deliberately per-record dynamic callbacks: a function that runs once per URL in a 50,000-URL file is a performance decision disguised as a configuration option. +### It is also what the cache tag follows + +Because `lastModified` comes from `updatedAt`, a real edit to a published record changes +that locale's sitemap file even when its URL does not move - so the file's cache tag is +expired for a plain title or SEO edit, not only for a publish or a slug change: + +```text +title edit on a published record +→ updatedAt moves +→ <lastmod> moves +→ sitemap file tag expired (the index is not) +``` + +A no-op edit writes no `UPDATE`, so `updatedAt` does not move and the cached file is +still byte-correct. See [Caching](/docs/dev/content-engine/caching#delivery-tags) for +the full matrix and for the file-versus-index distinction. + ## Excluding one record ```ts diff --git a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx index 50febcb19..437e9e550 100644 --- a/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx +++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx @@ -12,7 +12,10 @@ that fixes that. delivery: { enabled: true, redirects: { enabled: true }, -} +}, + +// Required. See below. +editorial: { enabled: true }, ``` From then on: @@ -26,6 +29,41 @@ after commit: /articles/stary-slug 308 -> /articles/nowy-slug ``` +## Redirects require Editorial + +```ts +delivery: { enabled: true, redirects: { enabled: true } } +// ✖ without `editorial: { enabled: true }` +``` + +Slug history has to be written in the **same transaction** as the slug mutation, its +version check and its revision - otherwise a committed slug change could leave the old +URL unreserved, or a reservation could survive a rolled-back write. The only mutation +paths that own such a transaction are the editorial ones; without `editorial` a content +type writes through the plain repository, which has no version to guard and no history +to write. + +So `redirects` without `editorial` would be a feature that silently records nothing. +It is a **compile error** and a definition-time throw rather than a silent downgrade to +`redirects: { enabled: false }` - an author who asked for redirects and quietly got none +would find out from a broken link months later. + +The restriction is narrow. Everything else in `delivery` is a read over data the content +type already has, and stays available without `editorial`: + +| Feature | Needs Editorial? | +| -------------------------------- | ---------------- | +| Canonical URLs | ❌ | +| SEO / Open Graph / robots | ❌ | +| Alternates and `hreflang` | ❌ | +| Sitemap | ❌ | +| Delivery reads and the AdminCP panel | ❌ | +| **Slug history and redirects** | ✅ | + +The same rule applies to a localized content type, and for the same reason: localized +slug history is written by `translation-editorial-service`, which a content type without +`editorial` does not have either. + ## When a slug becomes redirectable This is the rule the whole feature rests on, so it is stated exactly: From 491abdbad71d7b18f0ed5b66cfcdbe90d2fb09d3 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 8 Aug 2026 18:38:29 +0200 Subject: [PATCH 24/24] test(content): cover the restore path in the sitemap audit Restore is one of the fifteen Stage 8 mutation paths and shares `applyContentDeliveryWrite` with the rest, so it inherits the same rule - but the AdminCP Server Action reaches it through its own before/after pair, and that pair is what decides the sitemap tag. A restore that moves the slug expires the file and both redirect lookups; a no-op restore returns the row unchanged, so `updatedAt` does not move and nothing is expired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../content/actions/mutation-api.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts index 629154aa9..44da8218a 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts @@ -323,6 +323,55 @@ describe("delivery sitemap invalidation", () => { expect(tags()).not.toContain(DELIVERY_SITEMAP); }); + it("expires the sitemap for a restore that moved the slug", async () => { + responses = [ + published("current", tick()), + { + data: { + changed: true, + row: { + id: 7, + publishedAt: past, + slug: "restored", + status: "published", + updatedAt: tick(), + }, + }, + status: 200, + }, + ]; + + await restoreContentRevisionAction(DELIVERED, 7, 3, 4); + + expect(tags()).toContain(DELIVERY_SITEMAP); + expect(tags()).toContain(deliveryRedirectTag("current")); + expect(tags()).toContain(deliveryRedirectTag("restored")); + }); + + it("leaves the sitemap alone for a restore that changed nothing", async () => { + const unchanged = tick(); + responses = [ + published("current", unchanged), + { + data: { + changed: false, + row: { + id: 7, + publishedAt: past, + slug: "current", + status: "published", + updatedAt: unchanged, + }, + }, + status: 200, + }, + ]; + + await restoreContentRevisionAction(DELIVERED, 7, 3, 4); + + expect(tags()).not.toContain(DELIVERY_SITEMAP); + }); + it("adds no delivery tags at all to a content type without delivery", async () => { // The Stage 1-7 promise: the tag list of an existing content type does not move. definition = testPostContentType;