diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx index 8d838e3ce..610f559e4 100644 --- a/apps/docs/content/docs/dev/content-engine/caching.mdx +++ b/apps/docs/content/docs/dev/content-engine/caching.mdx @@ -348,6 +348,150 @@ 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, +}); +``` + +```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 ``. 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. + + + 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. + + +### 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. + + + 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. + + +### 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. + +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/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". + + + The sitemap protocol only accepts absolute URLs, so `contentSitemapXml` requires + an origin rather than taking one. See [Sitemaps](/docs/dev/content-engine/sitemaps). + + +## 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 `` 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..9d49d522c --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery-limitations.mdx @@ -0,0 +1,201 @@ +--- +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). + +## 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. + +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 on a nonlocalized content type + +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 +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 + + + + + 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 `` 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..1d982e50b --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/content-delivery.mdx @@ -0,0 +1,234 @@ +--- +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"], + }, + + // `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] + 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. **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) | + +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 | +| `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 | +| 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 localized content type withholding `"id"` | Throw | +| A fallback SEO field with no primary | Throw | + +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. +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 +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..4b58fa8e0 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/localization-and-hreflang.mdx @@ -0,0 +1,216 @@ +--- +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. + +<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: + +```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..21952d9ac --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/sitemaps.mdx @@ -0,0 +1,293 @@ +--- +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. + +### 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 +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..437e9e550 --- /dev/null +++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx @@ -0,0 +1,373 @@ +--- +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 }, +}, + +// Required. See below. +editorial: { 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 +``` + +## 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: + +> 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.", 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/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/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/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/packages/vitnode/src/content/cache.delivery.test.ts b/packages/vitnode/src/content/cache.delivery.test.ts new file mode 100644 index 000000000..d641e3833 --- /dev/null +++ b/packages/vitnode/src/content/cache.delivery.test.ts @@ -0,0 +1,276 @@ +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: { contentChanged: false, indexChanged: 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("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: { contentChanged: true, indexChanged: false } }, + id: 42, + isPublic: true, + slugs: ["same"], + wasPublic: true, + }); + + expect(withSitemap).toContain(contentDeliverySitemapTag(ID)); + + const untouched = contentInvalidationTags({ + contentTypeId: ID, + delivery: { sitemap: { contentChanged: false, indexChanged: false } }, + id: 42, + isPublic: true, + slugs: ["new"], + wasPublic: true, + }); + + 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: { contentChanged: true, indexChanged: 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: { contentChanged: true, indexChanged: 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, 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: { contentChanged: false, indexChanged: 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: { contentChanged: false, indexChanged: 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: { contentChanged: false, indexChanged: 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/cache.ts b/packages/vitnode/src/content/cache.ts index c7a33d73a..cc09755e7 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,56 @@ 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 { + /** 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 { 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 +247,7 @@ const slugTags = ( */ export const contentInvalidationTags = ({ contentTypeId, + delivery, id, isPublic, locales, @@ -161,13 +255,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 +278,75 @@ 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), + ), + // 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 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.indexChanged + ? [...tags, contentDeliverySitemapTag(contentTypeId)] + : tags, + ), ]; }; 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..9be48f575 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,23 @@ 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, + ContentEditorialEnabled<TEditorial>, + ContentDeliveryTitleField<TFields, TPublicField>, + ContentDeliveryDescriptionField<TFields, TPublicField>, + ContentDeliveryNoIndexField<TFields, TPublicField> + > + | { enabled: false } = { enabled: false }, >({ admin, + delivery, editorial, fields, id, @@ -1427,6 +1449,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 +1500,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 +1673,27 @@ 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, + // 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: { + defaultLocale: resolvedLocalization.defaultLocale, + enabled: resolvedLocalization.enabled, + }, + localizedFields, + publicApi: resolvedPublicApi, + publication: publicationEnabled, + }); + const definition: ContentTypeDefinition< TId, TFields, @@ -1653,10 +1704,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 +1743,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.test-d.ts b/packages/vitnode/src/content/delivery.test-d.ts new file mode 100644 index 000000000..29f038812 --- /dev/null +++ b/packages/vitnode/src/content/delivery.test-d.ts @@ -0,0 +1,343 @@ +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", + editorial: { enabled: true }, + 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<true>(); + expectTypeOf(plainType.delivery.enabled).toEqualTypeOf<false>(); + }); + + // 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<typeof deliveredType>().toExtend<AnyContentTypeDefinition>(); + assertType<AnyContentTypeDefinition>(deliveredType); + assertType<AnyContentTypeDefinition>(plainType); + }); + + it("narrows to DeliverableContentTypeDefinition only with delivery", () => { + expectTypeOf< + typeof deliveredType + >().toExtend<DeliverableContentTypeDefinition>(); + expectTypeOf< + typeof plainType + >().not.toExtend<DeliverableContentTypeDefinition>(); + }); + + 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<ContentSitemapChangeFrequency | null>(); + }); + + 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<false>(); + }); +}); + +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({ + ...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 `<title>` 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..6b69aaf1f --- /dev/null +++ b/packages/vitnode/src/content/delivery.test.ts @@ -0,0 +1,824 @@ +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", + // `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 }, + 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", + editorial: { enabled: true }, + 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 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({ + ...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({ + ...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, + // `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", + }), + ).toThrow(/has to be shared/); + }); +}); + +const localizedType = defineContentType({ + ...base, + id: "delivery.localized", + editorial: { enabled: true }, + 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/delivery.ts b/packages/vitnode/src/content/delivery.ts new file mode 100644 index 000000000..629eb56fd --- /dev/null +++ b/packages/vitnode/src/content/delivery.ts @@ -0,0 +1,783 @@ +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, + editorial, + fields, + id, + localization, + localizedFields, + publicApi, + publication, +}: { + delivery: ContentDeliveryConfig | undefined; + /** Whether the content type opted into the editorial workflow. */ + editorial: boolean; + 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"; + + // 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 + // 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); + + // 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 [ + ["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/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/index.ts b/packages/vitnode/src/content/index.ts index 98b55d524..eebb070dc 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,23 +42,29 @@ export { isContentTranslationPubliclyVisible, } from "./cache"; export type { + ContentDeliveryInvalidation, ContentInvalidationInput, ContentInvalidationMode, ContentLocaleInvalidation, ContentLocaleState, ContentPublicLocaleState, + ContentSitemapChange, } from "./cache"; export { parseContentConflict, + parseContentDeliveryConflict, parseContentTranslationConflict, parseContentUnprocessable, zodContentConflict, + zodContentDeliveryConflict, zodContentTranslationConflict, zodContentUnprocessable, } from "./conflicts"; export type { ContentConflict, ContentConflictCode, + ContentDeliveryCode, + ContentDeliveryConflict, ContentTranslationConflict, ContentTranslationConflictCode, ContentUnprocessable, @@ -66,6 +75,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 +119,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 +132,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 +178,8 @@ export { contentEventName } from "./events"; export type { ContentCreatedPayload, ContentDeletedPayload, + ContentDeliveryRedirectCreatedPayload, + ContentDeliverySlugChangedPayload, ContentEventAction, ContentEventsFor, ContentPublishedPayload, @@ -214,6 +259,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 +275,16 @@ export type { ContentBooleanField, ContentCreateInput, ContentDateTimeField, + ContentDeliveryConfig, + ContentDeliveryDescriptionField, + ContentDeliveryEnabled, + ContentDeliveryHreflangConfig, + ContentDeliveryNoIndexField, + ContentDeliveryOpenGraphConfig, + ContentDeliveryRedirectsConfig, + ContentDeliverySeoConfig, + ContentDeliverySitemapConfig, + ContentDeliveryTitleField, ContentEditorialConfig, ContentEditorialEnabled, ContentEditorialField, @@ -271,6 +333,7 @@ export type { ContentSelect, ContentSharedFieldName, ContentSharedValues, + ContentSitemapChangeFrequency, ContentSlugField, ContentSlugRequired, ContentSystemField, @@ -283,6 +346,7 @@ export type { ContentTypeDefinition, ContentUpdateInput, ContentUserField, + DeliverableContentTypeDefinition, EditorialContentTypeDefinition, FilterableContentFieldKind, FilterableContentFieldName, @@ -291,6 +355,8 @@ export type { PublicContentTypeDefinition, PublicFilterableContentFieldName, ResolvedContentAdminConfig, + ResolvedContentDeliveryConfig, + ResolvedContentDeliverySeoConfig, ResolvedContentEditorialConfig, ResolvedContentIndex, ResolvedContentLocalizationConfig, 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; +}; 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(), /** 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 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-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-effects.test.ts b/packages/vitnode/src/content/server/delivery-effects.test.ts new file mode 100644 index 000000000..809f88763 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-effects.test.ts @@ -0,0 +1,228 @@ +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", + editorial: { enabled: true }, + 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, + // 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, +}); + +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("passes the sitemap change through unchanged", () => { + expect(contentDeliveryInvalidation(articleType, outcome())).toStrictEqual({ + sitemap: { contentChanged: true, indexChanged: false }, + }); + expect( + contentDeliveryInvalidation( + articleType, + outcome({ sitemap: { contentChanged: true, indexChanged: true } }), + ), + ).toStrictEqual({ sitemap: { contentChanged: true, indexChanged: true } }); + }); + + 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: { contentChanged: false, indexChanged: false }, + }); + }); +}); 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..acab0d012 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-effects.ts @@ -0,0 +1,132 @@ +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, 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-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-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/delivery-service.test.ts b/packages/vitnode/src/content/server/delivery-service.test.ts new file mode 100644 index 000000000..9dfa0210a --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-service.test.ts @@ -0,0 +1,660 @@ +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", + editorial: { enabled: true }, + 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", + editorial: { enabled: true }, + 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-service.ts b/packages/vitnode/src/content/server/delivery-service.ts new file mode 100644 index 000000000..af3c7e2b4 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-service.ts @@ -0,0 +1,440 @@ +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 }); + }; + + /** + * 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 = 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. + 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: localeFor(locale), + }); + }, + + 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/delivery-writes.test.ts b/packages/vitnode/src/content/server/delivery-writes.test.ts new file mode 100644 index 000000000..d32b96754 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-writes.test.ts @@ -0,0 +1,418 @@ +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", + editorial: { enabled: true }, + 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", + editorial: { enabled: true }, + 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, + // Neither public before nor after, so no sitemap file lists it either way. + sitemap: { contentChanged: false, indexChanged: 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, 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 () => { + 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, + // 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, + }); + }); + + 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({ + sitemap: { contentChanged: true, indexChanged: 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, + sitemap: { contentChanged: true, indexChanged: 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 file changed - the engine simply cannot redirect the + // old address, because nothing recorded it. + redirectCreated: false, + sitemap: { contentChanged: true, indexChanged: false }, + slugChanged: true, + }); + }); +}); 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..551f6ce75 --- /dev/null +++ b/packages/vitnode/src/content/server/delivery-writes.ts @@ -0,0 +1,204 @@ +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"; + +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; + /** + * 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. */ + 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, + 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, + }; +}; + +/** 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-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/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/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/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/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/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; }; 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/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts index 67a4a96d6..05dbf15d3 100644 --- a/packages/vitnode/src/content/server/schedule-effects.ts +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -212,6 +212,13 @@ export const runContentScheduleEffects = async ( const revalidation = await dispatchContentRevalidation(c, { contentTypeId: definition.id, + // 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/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/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-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-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. 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-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; 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/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 = { 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/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, + }; +}; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 9a7538a7d..3e3d89abe 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,254 @@ 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< + // 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, +> { + /** + * 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; + /** + * 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 + ? TEditorialEnabled extends true + ? ContentDeliveryRedirectsConfig | { enabled: false } + : { 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 +1755,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 +1829,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 +1871,8 @@ export interface ContentTypeDefinition< TEditorialEnabled, TPreviewEnabled, TSchedulingEnabled, - TLocalizationEnabled + TLocalizationEnabled, + TDeliveryEnabled > >; /** Search synchronization, or the disabled default when `search` is omitted. */ 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, 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/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"] }, + }, +}); 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/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts index 4cd467ce8..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,8 +4,10 @@ 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, ContentScheduleRejection, ContentUnprocessable, } from "@/content/conflicts"; @@ -25,6 +27,7 @@ import { contentApiFetch } from "@/content/admin/fetch.server"; import { isContentPubliclyVisible } from "@/content/cache"; import { parseContentConflict, + parseContentDeliveryConflict, parseContentScheduleRejection, parseContentUnprocessable, } from "@/content/conflicts"; @@ -51,6 +54,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 +78,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 +203,7 @@ const invalidate = ( revalidateContent( { contentTypeId: definition.id, + ...deliveryInvalidationFor(definition, before, after, previous, current), id, isPublic: current.isPublic, // Both, so a slug change stops the old URL and starts the new one. @@ -200,6 +214,71 @@ 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. + * + * `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 `` 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, + 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, values: Record, @@ -594,12 +673,28 @@ 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); + + const wasEverPublic = result.data?.publishedAt != null; + revalidateContent( { contentTypeId: definition.id, + // 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: { + contentChanged: wasEverPublic, + indexChanged: wasEverPublic, + }, + }, + } + : {}), 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 +755,15 @@ const publicationAction = async ( revalidateContent( { contentTypeId: definition.id, + // 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/mutation-api.test.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.test.ts index a55801b2a..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 @@ -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,203 @@ describe("edit", () => { }); }); +/** + * The sitemap half of delivery invalidation. + * + * A sitemap entry carries ``, 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 `` 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("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; + 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 `` 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/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..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 @@ -87,17 +87,46 @@ 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. + // + // 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 `` 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: { + // 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, + ), + }, + }, + } + : {}), id, // Not consulted when `locales` is present, and supplied truthfully anyway: // a record is publicly reachable when any of its languages is. 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 ? ( { 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..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", @@ -107,13 +110,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"]); }); }); 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..b966301fd --- /dev/null +++ b/plugins/example/src/database/delivery-postgres.test.ts @@ -0,0 +1,1655 @@ +import type { SearchDocument } from "@vitnode/core/api/models/search"; +import type { ContentDeliverySitemapPage } from "@vitnode/core/content/server"; +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; +let db: ReturnType; +let context: Context; +/** A second connection, for the tests that need two writers at once. */ +let rival: ReturnType; +let rivalContext: Context; +let categoryId = 0; + +const emitted: { name: string; payload: Record }[] = []; +const indexed: SearchDocument[] = []; + +const pgErrorCode = async (run: () => Promise) => { + 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 }); + +/** + * 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 = {}, +): Promise<{ id: number; version: number }> => { + nextCode += 1; + + const outcome = await editorial()?.create( + { + category: categoryId, + code: `code-${nextCode}`, + title: "Hello world", + ...values, + }, + { 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 = {}, +): 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 }, + { 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 = {}; + +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) => + ({ + 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) => { + 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) { + // 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; + + for (const entry of result.entries) seen.push(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("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 `` 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 + // `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" }, + { 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" }, + { 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" }, + { actor: ACTOR, expectedVersion: first.enVersion }, + ); + + const second = await publishLocalized({ title: "Second" }); + await translationEditorial()?.create( + second.id, + "pl", + { title: "Shared" }, + { actor: ACTOR }, + ); + const pl = await translationEditorial()?.publish(second.id, "pl", { + actor: ACTOR, + }); + await translationEditorial()?.update( + second.id, + "pl", + { slug: "polski-teraz" }, + { 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("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" }, + { 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" }); + + 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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("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(); + + // 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" }, + }, + { 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" }); + + 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" }); + + 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", + }); + }); + }); +});