diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx
index 8d838e3ce..ebb105d29 100644
--- a/apps/docs/content/docs/dev/content-engine/caching.mdx
+++ b/apps/docs/content/docs/dev/content-engine/caching.mdx
@@ -158,10 +158,23 @@ Two things worth stating out loud:
free.
- **A no-op touches nothing.** Publishing something already published
transitioned nothing, so a double-clicked button costs one 200 and no cache.
+ The same holds for an unpublish, and for a restore that changed nothing -
+ each of those routes answers with `changed`, and the Server Action reads it.
Nothing global is ever expired, and one content type's mutation never touches
another's tags.
+
+An **update** that changed nothing is the exception: the generated `PUT` answers
+with the row, not with a `changed` flag, so the Server Action cannot tell a
+saved-but-identical edit from a real one and treats it as
+`update published, same slug`. That expires three tags
+stale-while-revalidate - the responses stay served while they refresh, so the
+cost is a refresh nobody needed rather than a cache miss. Widening a public
+response contract to save it would be the wrong trade; correctness first, hit
+rate second.
+
+
[Restore](/docs/dev/content-engine/revisions#restore) has no rules of its own -
it lands on the update rows above, because as far as a visitor is concerned a
restore *is* an update. It cannot appear on the publish or unpublish rows at
@@ -348,6 +361,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/concurrency.mdx b/apps/docs/content/docs/dev/content-engine/concurrency.mdx
new file mode 100644
index 000000000..716d647d9
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/concurrency.mdx
@@ -0,0 +1,136 @@
+---
+title: Concurrency
+description: Who wins when two writers race, what the loser is told, and why a no-op is never a conflict.
+icon: GitFork
+---
+
+Two editors open the same article. One fixes a typo, the other rewrites the
+introduction, and both press save within the same second. Exactly one of them
+should win, and the other should be told - clearly enough that the AdminCP can
+reload the newer record and offer to overwrite it.
+
+That is the whole of this page, applied to every mutation the engine has.
+
+## The mechanism
+
+Two primitives, and which one applies depends on whether the content type has an
+editorial workflow.
+
+**Optimistic locking**, on an editorial content type. Every write carries the
+version the caller read, and the write is a single guarded statement:
+
+```sql
+UPDATE "example_articles"
+ SET "title" = $1, "version" = "version" + 1
+ WHERE "id" = $2 AND "version" = $3
+```
+
+There is no read-then-write window, because there is no read. Two racing writers
+produce one statement that matches and one that does not; the one that does not
+gets a [`CONTENT_VERSION_CONFLICT`](/docs/dev/content-engine/editorial) carrying
+both versions.
+
+**A row lock**, on a content type without one. There is no version column to
+guard, so `SELECT ... FOR UPDATE` takes the source row before the collection is
+read - which makes two concurrent `add` calls run one after the other, and the
+second sees the first's result rather than the state they both started from.
+
+The lock is the database's, not the process's. A second API instance is
+serialised by exactly the same primitive.
+
+## The matrix
+
+Every row below is a test on two separate PostgreSQL connections.
+
+| Race | Outcome |
+| ---- | ------- |
+| update vs update | One winner, one `CONTENT_VERSION_CONFLICT`. Version moves once, one revision. |
+| update vs delete | Delete first: the update answers `null` (a 404). Update first: the delete conflicts. Never a resurrection. |
+| update vs publish (same version) | One winner. The loser conflicts. |
+| update vs publish (no version) | Both may land. A publish writes `status` only, so no field value is reverted. |
+| restore vs update | One winner. A restore never silently overwrites a newer edit. |
+| restore vs delete | Delete first: the restore answers `null`. It cannot recreate a removed record. |
+| scheduled vs manual publish | The second finds nothing to do and is skipped. One revision, one announcement. |
+| PL update vs PL update | One winner, one `CONTENT_TRANSLATION_VERSION_CONFLICT`. |
+| PL update vs EN update | **Both win.** Separate version domains, separate rows. |
+| PL update vs shared update | **Both win.** The translation and the base row are two rows with two versions. |
+| PL delete vs stale PL update | No resurrection, either way round. |
+| relation add vs add | One winner. Exactly one junction row, at position 0. |
+| relation remove vs add | One winner. Positions stay contiguous from zero. |
+| reorder vs add / remove | One real mutation. Positions stay contiguous, targets stay unique. |
+| repeatable create vs create | One winner (editorial) or both merged (plain service). |
+| child update vs reorder | One winner. Child identity survives either way. |
+| child delete vs update | One real mutation, and never a resurrected child. |
+| collection vs scalar | One winner. Never one writer's categories under another's version. |
+| two different records | **Both win.** The lock is per row. |
+
+## Two rules that surprise people
+
+### A no-op is not a conflict
+
+An editorial write that changes nothing succeeds, bumps no version and leaves no
+revision - and it does **not** check `expectedVersion`. There is nothing to
+overwrite, so there is nothing to conflict about. An editor who pressed save
+twice has not created two versions of anything.
+
+This reaches further than it looks. `repeatable.update` for a child a concurrent
+writer already deleted computes a list identical to the stored one, so it is a
+successful no-op rather than a conflict. The invariant worth holding is therefore
+**one race, one version increment** - not "one race, one rejected promise".
+
+### A reorder on an unordered relation does nothing
+
+`field.relation({ multiple: true })` without `ordered: true` is a *set*. The
+engine stores it in ascending target order, so `set([9, 2])` and `set([2, 9])`
+are the same state - and `reorder` computes the list that is already stored.
+
+If a sequence matters, say so:
+
+```ts
+relatedArticles: field.relation({
+ multiple: true,
+ ordered: true, // Now `UNIQUE (itemId, position)` makes the order a fact.
+ self: true,
+}),
+```
+
+## Why positions never collide
+
+Rewriting an ordered collection cannot be done in place: moving row A from slot 0
+to slot 1 while row B still sits in slot 1 violates `UNIQUE (itemId, position)`
+*during* the statement, even though the final state is fine.
+
+So every surviving row is first parked at a negative slot - a space no settled
+row ever occupies - and one final `UPDATE` maps the whole set back to `0..n-1` at
+once. No deferrable constraint, no delete-and-recreate, and every child keeps its
+identifier through a reorder.
+
+## Scheduled transitions
+
+A booked publication is claimed with `SELECT ... FOR UPDATE`, and the lock is
+held from the claim through the transition, its revision, the settlement *and*
+the queue row that will announce it. Four conditions are re-read from the
+database under that lock rather than trusted from the queue payload: the row
+exists, it is still `pending`, its generation matches, and its time has come.
+
+That is what makes a cancel honest. It either wins outright - before the claim -
+or waits, then finds the schedule already `completed` and answers a truthful 404.
+There is no window in which an administrator is told the cancel worked and then
+watches the article go live anyway.
+
+A stale booking cannot override newer manual state either, because the transition
+guards on the *state* it is leaving: a scheduled publish of an already-published
+record changes nothing, writes no revision, and announces nothing.
+
+## Writing concurrent code against the engine
+
+Three things worth knowing if you call the services directly:
+
+1. **Pass `expectedVersion`.** Every editorial write requires it, including a
+ collection mutation, and that is deliberate - defaulting it would make that the
+ one write which silently overwrites whatever it finds.
+2. **Join the transaction, do not open a second one.** Every service method takes
+ `{ tx }`. Two transactions on two connections is a deadlock waiting for a
+ quiet afternoon.
+3. **Call the effects after the write returns.** Never inside the transaction
+ callback - see [failure and retries](/docs/dev/content-engine/failure-and-retries).
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
+`` 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 ;
+};
+
+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.
+
+
+ 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.
+
+
+### 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 ``.
+
+```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.
+
+
+ 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`.
+
+
+## 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
+
+
+
+
+
+
+
+
+
diff --git a/apps/docs/content/docs/dev/content-engine/content-engine-observability.mdx b/apps/docs/content/docs/dev/content-engine/content-engine-observability.mdx
new file mode 100644
index 000000000..b3cdfeeb7
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/content-engine-observability.mdx
@@ -0,0 +1,272 @@
+---
+title: Observability
+description: The two questions an operator asks at three in the morning, and where the Content Engine answers them.
+icon: Activity
+---
+
+This is not a monitoring product. There is no time series, no Prometheus and no
+dashboard, because the install has none of those and the Content Engine is not
+the place to introduce them.
+
+What it does have is answers to the two questions that actually get asked when
+something looks wrong.
+
+## Is the search index telling the truth?
+
+Two storages, two questions. `SearchModel.index` writes the canonical
+`core_search_index` row and *then* hands the document to the active provider, so
+an Elasticsearch that refuses the second half leaves a canonical table that is
+perfectly correct and a search box that is missing results.
+
+A diagnostic that only looked at the canonical table would call that healthy. So
+this one asks both.
+
+```http
+GET /api/@vitnode/core/admin/debug/content/status
+```
+
+```json
+{
+ "healthy": false,
+ "searchHealthy": false,
+ "effectsHealthy": true,
+ "contentTypes": [
+ {
+ "contentTypeId": "example.localized-article",
+ "pluginId": "@vitnode/example",
+ "search": {
+ "contentTypeId": "example.localized-article",
+ "canonicalHealthy": true,
+ "healthy": false,
+ "provider": {
+ "name": "elasticsearch",
+ "verified": true,
+ "healthy": false
+ },
+ "locales": [
+ {
+ "locale": "en",
+ "expected": 421,
+ "canonicalIndexed": 421,
+ "canonicalHealthy": true,
+ "providerIndexed": 421,
+ "providerHealthy": true
+ },
+ {
+ "locale": "pl",
+ "expected": 421,
+ "canonicalIndexed": 421,
+ "canonicalHealthy": true,
+ "providerIndexed": 419,
+ "providerHealthy": false
+ }
+ ]
+ },
+ "schedules": null
+ }
+ ]
+}
+```
+
+### The vocabulary
+
+| Field | Means |
+| ----- | ----- |
+| `expected` | Published rows - or published translations - the **database** holds. |
+| `canonicalIndexed` | Documents in `core_search_index`. |
+| `canonicalHealthy` | The canonical table matches the database. |
+| `providerIndexed` | Documents the **active provider** holds for this locale. `null` when it cannot say. |
+| `providerHealthy` | The provider matches the database for this locale. `null` when nobody looked. |
+| `expectedTotal` | Every published row or translation, all locales. |
+| `canonicalIndexedTotal` | Every canonical document, all locales. |
+| `provider.indexedTotal` | Every provider document, **all locales, including ones nothing knows about**. |
+| `verified` | Whether the provider was actually asked. |
+| `healthy` | Canonical **and** provider agree, per locale *and* in total. |
+
+There is deliberately no single `indexed` number. Once there are two storages,
+one number can only be a guess about which of them you meant.
+
+### Why the totals matter as well as the locales
+
+Per-locale counts can only ask about locales somebody already knows to ask for -
+and that list is built from the database and the canonical table. A document that
+exists **only** in the provider is invisible to it.
+
+That is not hypothetical. Deletion runs canonical-first: `SearchModel.delete`
+removes the canonical row and then asks the provider. If the provider's half
+fails, the document survives in a locale that no longer appears in either source,
+so nothing enumerates it:
+
+```text
+database expected pl: 0
+core_search_index pl: 0
+Elasticsearch pl: 1 ← nothing asks about "pl" any more
+```
+
+An unfiltered total finds it, because it is not built from an enumeration:
+`providerTotal > expectedTotal` is enough to say something is wrong even when
+every locale anybody checked agreed. The same guard covers a locale that was
+removed from the installation entirely, and a content type with **no** rows at
+all - where the per-locale list is empty and `[].every(...)` would otherwise say
+everything is fine.
+
+
+A provider that offers no `count` reports `verified: false` and
+`providerHealthy: null`, and the content type is **not** healthy. Absence of
+evidence is reported as absence of evidence - turning it into a clean bill of
+health is exactly how a broken search box hides behind a good canonical table.
+
+
+### What each provider does
+
+| Provider | Behaviour |
+| -------- | --------- |
+| **Postgres** (bundled) | Its store *is* `core_search_index`, so it declares `canonicalStorage` and is verified without a second query. Canonical and provider always agree, because they are one thing. |
+| **Elasticsearch** | Implements `count` with the `_count` API - a number, never fetched documents - filtered by `itemType`, and by `languageCode` when one is given. Omitting the language is the unfiltered total. It creates no index as a side effect: a diagnostic is observational. |
+| **Anything else** | Reported as `verified: false` unless it implements `count`. |
+| **A provider that throws** | `verified: true`, `healthy: false`, and `error` carries the reason. A failure in either the total or a per-locale count is handled the same way. The status route still answers, and the failure is logged behind `[content-diagnostics]`. |
+
+The expected side uses the **same** `publishedCondition` the indexer uses.
+Re-deriving "published" here would let the diagnostic disagree with the thing it
+is diagnosing, which is the one way a health check is worse than none.
+
+### Teaching your own provider to answer
+
+```ts
+export const MySearchAdapter = (): SearchProviderApiPlugin => ({
+ name: "my-engine",
+ // ...
+ count: async (c, { itemType, languageCode }) =>
+ await myEngine.count({ itemType, languageCode }),
+});
+```
+
+Count, do not fetch: a diagnostic over a large collection has to cost the same
+as one over an empty one. Honour `languageCode` if your store keeps one document
+per translation - a single total cannot say "Polish is missing forty documents" -
+and treat an **omitted** `languageCode` as every language, because that call is
+what finds a document in a locale nobody thought to ask about.
+
+Make it observational. Creating an index, warming a cache or writing anything
+would let the health check change the thing it is measuring.
+
+### Repairing drift
+
+```http
+POST /api/@vitnode/core/admin/debug/search/rebuild
+{ "itemType": "example.localized-article" }
+```
+
+Scoped to one collection so a single-collection reindex never wipes the rest of
+the index. A rebuild reproduces exactly what live synchronisation would have
+written, so repairing drift never changes what search returns beyond making it
+correct.
+
+## Did anything scheduled fail to announce itself?
+
+A scheduled transition that committed but whose event, index write or cache
+expiry did not is recorded on the schedule row - and counted per content type on
+the same status route:
+
+```json
+"schedules": { "pending": 3, "withErrors": 0, "failedEffects": 1 }
+```
+
+| Field | Means |
+| ----- | ----- |
+| `pending` | Bookings still waiting to fire. Normal. |
+| `withErrors` | Pending bookings whose last run threw. The transition has **not** happened and the queue is retrying, so this is visible without being a failure. |
+| `failedEffects` | Transitions that **did** happen and were never announced. |
+
+`failedEffects` is the one that matters: the record *is* published, and nobody
+has been told, and no amount of waiting fixes that on its own. The effects task
+retries on the queue's backoff, so a non-zero value that stays non-zero is an
+outage rather than a blip. Per booking, the AdminCP schedule panel shows the
+reason.
+
+## What "healthy" means
+
+Three flags, because one would be misleading:
+
+```ts
+{
+ healthy: false, // searchHealthy && effectsHealthy
+ searchHealthy: true, // every searchable content type agrees, canonical and provider
+ effectsHealthy: false, // nothing committed without being announced
+}
+```
+
+`healthy: true` beside `failedEffects: 15` is worse than no answer - it tells an
+operator to stop looking. Splitting the dimensions means a search outage and an
+announcement backlog are separately visible, and the headline is simply their
+conjunction.
+
+A pending schedule is **not** unhealthy, and neither is a pending one whose last
+attempt threw: in both cases the transition has not happened and the queue is
+still working on it. Only `effectsError` - committed, unannounced - moves the
+needle.
+
+## The logs
+
+Four greppable prefixes in `core_logs`, visible in AdminCP → Advanced → Logs.
+
+| Prefix | Written when |
+| ------ | ------------ |
+| `[content-search]` | An index write or delete threw after a committed mutation. |
+| `[content-effects]` | An event was emitted and a listener did not receive it. |
+| `[content-revalidate]` | A web origin refused or could not be reached. |
+| `[content-diagnostics]` | The search provider could not be counted for a health check. |
+
+Each is a single JSON object behind the prefix, with the content type, the item,
+the operation and the underlying error - enough to find the record without
+correlating three systems by timestamp.
+
+Deliberately **not** logged as errors: a `404`, a no-op mutation, and a draft that
+was not indexed. All three are the engine working, and a log full of them is a log
+nobody reads.
+
+## Using the diagnostics from code
+
+Everything the route returns is available directly, so a plugin can build its own
+panel or a cron job can alert on it:
+
+```ts
+import {
+ contentEngineDiagnostics,
+ contentScheduleHealth,
+ contentSearchDrift,
+} from "@vitnode/core/content/server";
+
+// Everything, sorted by content type id so two calls - and two processes -
+// report the same order.
+const report = await contentEngineDiagnostics(c);
+
+// Or one content type at a time. `healthy` is canonical *and* provider, so
+// check the two separately when you want to tell an index problem from an
+// engine outage.
+const drift = await contentSearchDrift(c, { model: articleContent });
+if (!drift.canonicalHealthy) {
+ await c.get("log").error(`[my-plugin] index drift: ${JSON.stringify(drift.locales)}`);
+}
+if (drift.provider.verified && drift.provider.healthy === false) {
+ await c.get("log").error(`[my-plugin] ${drift.provider.name}: ${drift.provider.error ?? "documents missing"}`);
+}
+
+// Or just the schedules, in one grouped query for many content types.
+const schedules = await contentScheduleHealth(c, ["example.article"]);
+```
+
+The route is behind `system: can_view`, like the rest of the debug surface. There
+is nothing in the response an administrator could not read elsewhere, but there is
+nothing in it a visitor should read either.
+
+## What this deliberately does not do
+
+- **It does not repair anything.** Drift is detected by counting; correcting it is
+ a rebuild, and a rebuild is a decision an operator makes.
+- **It does not compare documents.** A count is not a checksum: two stale
+ documents still count as two. It catches the failure that actually happens - a
+ write that threw, a rebuild that stopped - for the price of two aggregates and
+ one provider count per locale.
+- **It keeps no history.** Every number is computed on demand. Trending them over
+ time is what a monitoring system is for, and this is not one.
diff --git a/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx
new file mode 100644
index 000000000..dc93da8cb
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/content-engine-security.mdx
@@ -0,0 +1,159 @@
+---
+title: Security
+description: The permission matrix over every generated route, what a public response may contain, and why every bad preview link is the same 404.
+icon: Lock
+---
+
+Three questions, and the engine answers each of them in exactly one place so
+there is no second implementation to disagree with the first:
+
+1. **Who may do this?** A staff permission on every generated route.
+2. **What may they see?** An allowlist, applied where the `SELECT` is built.
+3. **Who is this anonymous caller?** A signed token, or nobody.
+
+## The permission matrix
+
+Every generated route carries an explicit `adminStaffPermission`, checked before
+validation - so a request without the permission is a `403` whatever its body
+says. The matrix is enumerated from the route builder in the test suite, which is
+what stops a new endpoint joining the set without one.
+
+| Route | Permission |
+| ----- | ---------- |
+| `GET /` | `can_view` |
+| `GET /{id}` | `can_view` |
+| `GET /options/{field}` | `can_view` |
+| `POST /` | `can_create` |
+| `PUT /{id}` | `can_edit` |
+| `DELETE /{id}` | `can_delete` |
+| `POST /{id}/publish` · `/unpublish` | `can_publish` |
+| `GET /{id}/revisions` · `/{revisionId}` | `can_view` |
+| `POST /{id}/revisions/{revisionId}/restore` | `can_restore` |
+| `POST /{id}/preview` | `can_view` |
+| `GET /{id}/schedules` | `can_view` |
+| `POST /{id}/schedule` · `/{scheduleId}/cancel` | `can_publish` |
+| `GET /{id}/translations` · `/{locale}` · `/public-locales` | `can_view` |
+| `POST` · `PUT /{id}/translations/{locale}` | `can_translate` |
+| `DELETE /{id}/translations/{locale}` | `can_delete` |
+| `POST /{id}/translations/{locale}/publish` · `/unpublish` | `can_publish` |
+| `GET /{id}/translations/{locale}/revisions` · `/{revisionId}` | `can_view` |
+| `POST /{id}/translations/{locale}/revisions/{revisionId}/restore` | `can_restore` |
+
+Two choices in there are worth the sentence they take:
+
+- **`can_publish` for scheduling.** Booking a publication *is* publishing, just
+ later. A role trusted to write drafts is not automatically trusted to put one
+ on the internet at 9am on Monday.
+- **`can_restore` depends on `can_edit`.** Restoring rewrites many fields at once
+ from a source the editor did not type, so somebody who may not edit must not
+ reach the same outcome through the history.
+
+## The translator
+
+`can_translate` depends on `can_view` and deliberately **not** on `can_edit`,
+which is what makes "writes Polish and nothing else" expressible. Give a role
+`can_view + can_translate` and it can:
+
+- read the record, every locale tab and every locale's history;
+- create and edit a translation in any enabled locale.
+
+It cannot:
+
+- edit a shared field (`PUT /{id}` is `can_edit`);
+- publish or unpublish anything, record or translation (`can_publish`);
+- restore a shared revision *or* a locale's own (`can_restore`, which needs
+ `can_edit`);
+- delete the record or a translation (`can_delete`).
+
+Existing roles simply do not have `can_translate` - permissions are stored as
+JSON per role, so a new one denies by default and needs no migration.
+
+## Advanced collections have no second door
+
+There is no per-relation or per-repeatable endpoint. A collection is written
+through the ordinary `PUT /{id}`, gated on `can_edit`, in the same guarded
+transaction as a field edit - so there is no route that hands somebody a write
+primitive they could not have used anyway.
+
+The relation picker (`GET /options/{field}`) is a read of display labels, gated on
+`can_view` like every other read, and it writes nothing.
+
+## Cross-plugin isolation
+
+Two plugins can name a permission module the same thing - `articles` is not an
+unusual choice - and the registry allows it precisely because the plugin id is
+part of the key. That only holds because the **route** carries its own plugin id
+into the check rather than reading whichever plugin is handling the request.
+
+Underneath, everything shared is scoped the same way. The revisions table and the
+schedules table are shared by every content type in the install, so every
+statement filters on `pluginId`, `contentTypeId` *and* `itemId`. A revision id on
+its own proves nothing about ownership, and cancelling somebody else's
+publication would be a strange way to find that out.
+
+## Public responses are an allowlist
+
+`publicApi.fields` is not a filter applied to a full row. It decides the `SELECT`
+itself, so a private column is never fetched - which means it cannot be leaked by
+a mistake further downstream.
+
+What a public response contains, exactly:
+
+- the fields the allowlist names, and nothing else;
+- a group carrying only the **leaves** the allowlist named - exposing `seo.title`
+ does not expose `seo.robots`;
+- a repeatable child carrying its public leaves plus its `id`;
+- a to-many relation as **identifiers**, never expanded rows - a target has its
+ own public API, its own allowlist and its own publication state;
+- `locale`, on a localized content type, because a response has to say which
+ language it is.
+
+What it never contains: `id` unless the allowlist names it, `status`, `version`,
+`createdAt`, `updatedAt`, `languageId`, any revision metadata, any private leaf,
+any private collection, and any flattened storage column name (`seo.title` is
+stored as `seoTitle`, and a response that carried that would publish an internal
+detail *and* give a client two spellings of one value).
+
+## Preview fails closed
+
+A preview link is an unpublished record behind a short-lived credential, so every
+part of it is written to fail rather than to be helpful.
+
+- **The token is the authorization.** HMAC-SHA256, bound to one plugin, one
+ content type, one record, one revision and - for a localized content type - one
+ locale. No session is consulted, which is the point: a reviewer has no account.
+- **Every failure is the same 404.** A forged signature, an expired link, a token
+ for another record, a deleted revision and a record that never existed are
+ indistinguishable. A 401 or a 403 would confirm the record exists, which is
+ precisely what a draft URL must not do.
+- **No fallback.** A `pl` link opened on the English URL is a 404, not the English
+ copy. Falling back would hand a reviewer a different language from the one they
+ were sent.
+- **A weak secret disables it entirely.** An install whose `CONTENT_PREVIEW_SECRET`
+ is missing, too short, or still the published placeholder can have its tokens
+ forged by anyone, so no token is honoured - and the answer is the same 404,
+ because "preview is misconfigured here" is not something an anonymous request
+ needs to learn.
+- **Nothing caches it.** `Cache-Control: private, no-store` and
+ `X-Robots-Tag: noindex, nofollow`, and the response carries no cache tag at all.
+- **The projection is the public one.** The same function the detail route uses,
+ so a field cannot be private on one and public on the other.
+
+## Cache keys cannot mix public and private
+
+An AdminCP read forwards the staff cookie and is never cached - no `force-cache`,
+no tags. A preview is `no-store`. Only the generated public read is cached, and
+only under tags built from `publicApi.path`.
+
+Locale spellings are normalised where the tag is built, so `pl`, `PL` and `" pl "`
+address one cache entry and expire together - a tag is a string comparison, and
+hoping every call site agrees on casing is not a plan.
+
+## Errors say nothing they should not
+
+A driver error carries the constraint name, often the column, and sometimes the
+value that clashed. None of it reaches a response body; every expected failure is
+mapped to a status and, where a client has to branch, a stable code. See
+[the error contracts](/docs/dev/content-engine/editorial). Anything unrecognised
+becomes a bare `500` - the detail goes to `core_logs`, and production answers
+`Internal Server Error` and nothing else.
diff --git a/apps/docs/content/docs/dev/content-engine/failure-and-retries.mdx b/apps/docs/content/docs/dev/content-engine/failure-and-retries.mdx
new file mode 100644
index 000000000..b9394ab75
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/failure-and-retries.mdx
@@ -0,0 +1,186 @@
+---
+title: Failure and retries
+description: What survives an outage, what gets retried, and why event delivery is at-least-once rather than exactly-once.
+icon: TriangleAlert
+---
+
+A content mutation touches four systems, and only one of them can be rolled
+back. The database write either committed or it did not. The event, the search
+document and the cache invalidation are calls to things that can be down for a
+minute - and once the transaction has closed, nothing can un-send them.
+
+So the engine draws a hard line, and everything on this page follows from it.
+
+## Inside the transaction, and after it
+
+```text
+inside the transaction after the commit
+------------------------ ----------------------------
+the row lock the event
+validation that needs locked state the search document
+the content write the cache invalidation
+the version increment the remote revalidation
+the revision insert
+the schedule transition
+the collection rows
+the queue row that announces it
+```
+
+Two things about that split are worth stating outright.
+
+**Nothing external is inside.** No `fetch`, no search call, no `next/cache`. A
+rolled-back transaction cannot un-emit an event, so an event emitted inside one
+is a lie waiting to happen.
+
+**The queue row is inside.** The task that announces a scheduled publication is
+written in the same transaction as the publication, so it exists if and only if
+the transition committed. A crash a millisecond later loses nothing: the row is
+durable, and the queue will drain it.
+
+
+`syncContentSearch`, `contentEditorialEffects` and `revalidateContent` must be
+called **after** the write has returned, never inside a `db.transaction()`
+callback. The generated routes already do this; hand-written code has to.
+
+
+## When the event transport fails
+
+`EventsModel.emit` reports rather than throws, so `failures` is the only place a
+dead listener or a broker outage is visible at all.
+
+The write has already committed by then, and two things follow:
+
+1. **The request still succeeds.** Answering 500 would tell the client its edit
+ was lost when it was not, and invite a retry that creates a second version of
+ everything.
+2. **The failure is never swallowed.** It goes to `core_logs` behind
+ `[content-effects]` with the content type, the item and the listener that
+ failed:
+
+```text
+[content-effects] {"action":"published","contentTypeId":"example.article",
+"delivered":0,"eventId":"...","failures":[{"error":"Service unavailable",
+"listener":"@acme/plugin:notifications:send-notification"}],"itemId":7}
+```
+
+Search it in AdminCP → Advanced → Logs. On the scheduled path the same failure is
+also written onto the schedule row as `effectsError`, and retried.
+
+## When the search engine fails
+
+The index write throws, the mutation stays committed, and the failure is
+reported three ways: on the outcome (`ContentSearchSyncOutcome.error`), in
+`core_logs` behind `[content-search]`, and - for a scheduled transition - on the
+schedule row.
+
+The index is eventually consistent, and "eventually" is bounded by two things:
+
+- **the next write** that touches an indexed field on that record, which
+ rewrites the document;
+- **a rebuild**, which rewrites all of them.
+
+A rebuild reproduces exactly what live synchronisation would have written -
+that equality is itself a test - so repairing drift never changes what search
+returns beyond making it correct.
+
+## When a web origin refuses its cache invalidation
+
+The [revalidation bridge](/docs/dev/content-engine/caching) posts to every
+configured web origin, retries each one twice, and **reports rather than
+throws**. The caller decides what counts as delivered, and the scheduled effects
+task requires *all* of them:
+
+```text
+attempted: 0 nothing needed telling. Not a failure.
+delivered === attempted delivered.
+delivered < attempted a partial. The run fails and is retried.
+```
+
+A partial is the dangerous case, not the acceptable one. With two web apps behind
+one API, one of them accepting an unpublish while the other does not leaves the
+withdrawn page cached and readable - and "at least one worked" would call that a
+success and never try the other again.
+
+## Retrying the announcements without republishing
+
+A scheduled publication is deliberately **two units of work**:
+
+```text
+content-schedule moves the database. Commits, or does not.
+content-schedule-effects announces what committed. Retried on its own.
+```
+
+Retrying them together would re-run the publish - which is idempotent, so the
+second run would find nothing changed and skip the announcements entirely. That
+is exactly how a scheduled unpublish ends up permanently serving a cached page it
+should have expired, and splitting them is what removes it.
+
+So a retry re-announces and never re-transitions. After a failed effects run and
+a successful retry, the record holds the same version, the same `publishedAt` and
+exactly one `publish` revision.
+
+Every reason is reported, not just the first:
+
+```text
+Scheduled publish of example.article#7 committed, but its effects did not
+(event: @acme/plugin:notifications:send (down); search: index refused;
+ cache: 1/2 web origins accepted the invalidation).
+```
+
+An operator who fixes one system and retries should not discover the next one on
+the following run.
+
+## At-least-once, and what to do about it
+
+
+A retried effects run emits its event again. A listener can see the same
+`content.example.article.published` twice, and there is no outbox and no
+deduplication in the transport.
+
+
+Search and cache are idempotent by construction - an upsert and an expiry are the
+same operation however many times they run, and the document a retry writes is
+byte-identical. Events are not, because a listener can do anything.
+
+A listener that must act once keys off the identifiers the payload carries:
+
+```ts
+buildEventListener({
+ name: "announce-on-slack",
+ event: "content.example.article.published",
+ handler: async (c, { payload }) => {
+ // Present only when a schedule caused this, and stable across every retry
+ // of that booking.
+ if (payload.scheduleId && (await alreadyAnnounced(c, payload.scheduleId))) {
+ return;
+ }
+ // ...
+ },
+});
+```
+
+## Idempotency, everywhere else
+
+| Operation | Repeating it |
+| --------- | ------------ |
+| publish / unpublish | A no-op. No write, no version bump, no revision, no event, no index write. |
+| restore to the current values | A no-op, with `revisionId: null`. |
+| relation `add` of a target already present | A no-op. |
+| relation `remove` of one that is not there | A no-op. |
+| repeatable `set` to the same rows and order | A no-op. |
+| search upsert | The same document, rewritten. |
+| search delete | Removes nothing that is not there. |
+| cache expiry | Expires an already-expired tag. |
+| scheduled task delivered twice | The claim refuses anything not still `pending`. |
+| effects task retried | Re-announces; never re-transitions. |
+
+That list falls out of one decision rather than ten special cases: every
+collection mutation computes the whole next state and diffs it against the
+stored one, so "nothing moved" is a single check that every operation shares.
+
+## What is left outstanding, and where to see it
+
+- **Per record:** the AdminCP schedule panel shows `effectsError` on the booking.
+- **Per install:** `GET /admin/debug/content/status` counts outstanding
+ scheduled-effect failures per content type, alongside search index drift. See
+ [observability](/docs/dev/content-engine/content-engine-observability).
diff --git a/apps/docs/content/docs/dev/content-engine/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx
index 0eaa0e221..3a8158ce7 100644
--- a/apps/docs/content/docs/dev/content-engine/index.mdx
+++ b/apps/docs/content/docs/dev/content-engine/index.mdx
@@ -112,6 +112,11 @@ one object instead of two that drift apart.
title="Database & migrations"
description="How Drizzle Kit finds generated tables - and what happens when you rename a field."
/>
+
+ 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.
+
+
+## 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.
+
+
+ `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.
+
+
+## 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..71e167fdc 100644
--- a/apps/docs/content/docs/dev/content-engine/meta.json
+++ b/apps/docs/content/docs/dev/content-engine/meta.json
@@ -36,10 +36,26 @@
"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",
"overriding-admincp",
+ "production-hardening",
+ "concurrency",
+ "failure-and-retries",
+ "content-engine-security",
+ "content-engine-observability",
+ "performance-and-scaling",
+ "migration-hardening",
"limitations"
]
}
diff --git a/apps/docs/content/docs/dev/content-engine/migration-hardening.mdx b/apps/docs/content/docs/dev/content-engine/migration-hardening.mdx
new file mode 100644
index 000000000..c81d978a0
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/migration-hardening.mdx
@@ -0,0 +1,175 @@
+---
+title: Migration hardening
+description: Proving an upgrade is safe before you run it against an install that has rows in it.
+icon: DatabaseZap
+---
+
+A fresh install gets its schema from `drizzle-kit` and that is the end of it. An
+existing install is the interesting case: it has rows, and the upgrade has to
+move them onto the newer shape without a destructive step nobody reviewed.
+
+The patterns are on
+[localization migrations](/docs/dev/content-engine/localization-migrations) and
+[advanced modeling migrations](/docs/dev/content-engine/advanced-modeling-migrations).
+This page is about **proving** them, and about the invariants each one has to
+hold.
+
+## The rule every pattern shares
+
+> The destructive statement lives in a different migration from the copy.
+
+Two files, because between them somebody has to look at the counts. A backfill
+that silently dropped three rows and then deleted its source is not something
+anybody can notice afterwards - the evidence went with the source.
+
+That is why the to-one → to-many pattern is two migrations, why the JSON array →
+repeatable pattern is two, and why the localization pattern has a verification
+step that aborts rather than continuing.
+
+## The invariants
+
+Every upgrade in the test suite checks the same six things. Yours should too.
+
+| Invariant | How to check it |
+| --------- | --------------- |
+| **Row count** | `count(*)` on the source and the destination, before the drop. |
+| **Nullability** | Every new `NOT NULL` column has a default, or the `ALTER` fails on a populated table. |
+| **Foreign keys** | Add them *after* the copy, so a bad reference is a named failure with the data intact. |
+| **Uniqueness** | Same: a pre-existing duplicate surfaces at the index, not halfway through a backfill. |
+| **Order** | `WITH ORDINALITY` for a JSON array; `position` starting at zero, which is where the engine reads from. |
+| **Source intact** | The old column is still there when the first migration ends. |
+
+## What PostgreSQL gives you for free
+
+DDL is transactional. A migration file that fails on its third statement leaves
+**nothing** behind - not the rows it inserted, and not the table it created. So a
+failed migration is always safe to fix and re-run, and the verification step can
+`RAISE EXCEPTION` knowing it will take the whole file with it:
+
+```sql
+DO $$
+DECLARE source_count integer; copied_count integer;
+BEGIN
+ SELECT count(*) INTO source_count FROM "example_articles";
+ SELECT count(*) INTO copied_count FROM "example_articles_translations";
+
+ IF source_count <> copied_count THEN
+ RAISE EXCEPTION 'Copied % of % rows; refusing to drop the source columns.',
+ copied_count, source_count;
+ END IF;
+END $$;
+```
+
+
+`CREATE INDEX CONCURRENTLY` cannot run inside a transaction at all, so a
+migration that uses one is **not** atomic - a failure leaves an invalid index
+behind, and you have to drop it by hand. Nothing the Content Engine generates
+uses `CONCURRENTLY`; if you add one for a large table, put it in a migration of
+its own and know what it costs.
+
+
+## The upgrades that are covered
+
+Each of these runs against a populated table in
+`plugins/example/src/database/migration-postgres.test.ts`.
+
+### Adding a structured group
+
+Additive and safe, because `defineContentType` already requires every leaf of an
+optional group to be nullable or defaulted:
+
+```sql
+ALTER TABLE "example_articles"
+ ADD COLUMN "syndicationIndexable" boolean DEFAULT true NOT NULL,
+ ADD COLUMN "syndicationPriority" integer DEFAULT 5 NOT NULL;
+```
+
+Every existing row gets the default. A `NOT NULL` column with no default is a
+`23502` on a table with rows in it - which is the same rule the definition
+enforces, arrived at from the other side.
+
+### Regrouping columns you already have
+
+Moving `seoTitle` and `seoDescription` into a `seo` group needs **no data
+migration**: `field.text()` named `seoTitle` and `seo.title` compile to the same
+column. Generate the migration and check it is empty before believing it.
+
+### To-one → to-many
+
+Copy at position 0, verify, then drop in a second migration:
+
+```sql
+INSERT INTO "example_articles_categories" ("itemId", "relatedItemId", "position")
+SELECT "id", "category", 0 FROM "example_articles" WHERE "category" IS NOT NULL;
+```
+
+The junction's foreign key goes on afterwards, so a stale reference is a `23503`
+you can look at rather than a half-finished copy.
+
+### JSON array → repeatable
+
+`WITH ORDINALITY` is what carries the order across, and `ordinality - 1` is what
+puts it where the engine reads from:
+
+```sql
+INSERT INTO "example_articles_faq" ("itemId", "position", "question", "answer")
+SELECT a."id", entry.ordinality - 1,
+ entry.value ->> 'question', entry.value ->> 'answer'
+FROM "example_articles" a,
+ jsonb_array_elements(a."faqJson") WITH ORDINALITY AS entry(value, ordinality)
+WHERE a."faqJson" IS NOT NULL;
+```
+
+Get that wrong and nothing fails - somebody's FAQ is silently reordered.
+
+### Non-localized → localized
+
+The longest one, and the only one with a mandatory verification step. Six stages:
+create the table, resolve the language id, copy, **verify**, constrain, drop.
+
+Two details the tests pin:
+
+- **The timestamps travel with the values.** A translation stamped `now()` would
+ tell every editor the whole collection was rewritten on deployment day.
+- **The language is resolved, not hardcoded.** A literal `1` is right on the
+ machine it was written on and wrong on every other install.
+
+And one consequence worth expecting: uniqueness moves from *global* to *per
+language*. The base table's old unique slug index goes away with the column, and
+`/en/about` and `/pl/about` become two legal rows.
+
+## Before you run it anywhere real
+
+1. **Against a copy of production first.** The verification step turns a data
+ loss into a failed migration, which is the whole point - but a failed
+ migration is still better discovered on a copy.
+2. **Check the counts by hand between the two files.** That pause is the entire
+ safety mechanism. Automating it away is a product decision made by a script.
+3. **Take constraint and index names from the generated file.** They are derived
+ from your table name and clamped to 63 characters; a name that does not match
+ makes every future diff noisy.
+4. **Never regenerate an applied migration.** It bumps the journal's `when` and
+ the migrator replays the whole file, which fails on `relation already exists`
+ at best. Add a new one.
+
+## Testing your own migration
+
+Copy the shape of the example suite: build the old table, put rows in it that are
+awkward on purpose, run your migration script, and assert the invariants before
+the destructive step.
+
+```ts
+it("copies every entry and preserves its order", async () => {
+ await migrate(CREATE_CHILD);
+
+ const [{ expected }] = await sql`
+ SELECT coalesce(sum(jsonb_array_length("faqJson")), 0)::int AS expected
+ FROM "legacy_articles"
+ `;
+ expect(await countOf("legacy_articles_faq")).toBe(expected);
+});
+```
+
+The awkward rows are the ones worth having: a `NULL` array, a duplicate that will
+collide with the new unique index, a row whose title normalises to nothing. Every
+one of those has been a real migration bug in something, somewhere.
diff --git a/apps/docs/content/docs/dev/content-engine/performance-and-scaling.mdx b/apps/docs/content/docs/dev/content-engine/performance-and-scaling.mdx
new file mode 100644
index 000000000..5006f6a04
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/performance-and-scaling.mdx
@@ -0,0 +1,347 @@
+---
+title: Performance and scaling
+description: What a page costs in round trips, why that number does not move when the page grows, and where cursor pagination is exact.
+icon: Gauge
+---
+
+None of the numbers here are milliseconds. A wall-clock figure says more about
+the machine than about the code, and a test built on one fails on a busy CI
+runner for reasons nobody can act on.
+
+What is measured instead is **algorithmic**: how many round trips one page costs,
+whether that number moves when the page grows, and whether a lookup seeks on an
+index or reads the whole table. Those are properties of the SQL, and they are the
+ones that decide whether a collection stays usable at a hundred thousand rows.
+
+## A page costs a bounded number of queries
+
+The invariant an N+1 breaks is not "few queries" - it is "the *same* number of
+queries for five rows and for sixty".
+
+| Read | Round trips per page |
+| ---- | -------------------- |
+| Admin list, labels included | bounded - one count, one select, joins inside it |
+| Public list | bounded |
+| Localized public list | bounded, whatever the locale |
+| Public list with advanced collections | bounded, **plus one batch per exposed collection** |
+| Revision history page | bounded |
+| Search rebuild page | bounded |
+
+Two things make that hold.
+
+**Labels are joined, not looked up.** One `LEFT JOIN` per reference field
+resolves every display name in the same round trip - there is no per-row lookup
+anywhere in the list path.
+
+**Collections are batched by parent id.** A to-many relation is deliberately
+absent from `ContentSelect` for exactly this reason: a list that carried one
+would issue a query per row, and a table of 25 rows with two collections would be
+50 round trips. `loadMany` takes the whole page's ids and issues one query per
+collection field.
+
+## Only what the projection asks for
+
+A public read loads the collections the allowlist actually exposes, and no
+others. `relatedArticles` is private on the example content type, so a public
+list never touches its junction table at all - querying it to discard the rows
+afterwards is work with no answer attached.
+
+The same rule runs through search: `contentSearchAdvancedValues` loads only the
+collections the search configuration names. A content type that indexes none -
+every Stage 1-5 one - pays a boolean check and no query.
+
+## Shared collections are loaded once, not once per locale
+
+The localized rebuild emits one document per published translation, so a record
+with three languages appears three times on a page. Its FAQ is *shared*, and
+loading it three times would be an N+1 hiding behind a perfectly correct result.
+
+So the batch loader deduplicates parent ids before it queries. Three translations
+of one record read the FAQ once.
+
+## Cursor pagination
+
+Pages are keyset reads rather than `OFFSET`, so a page deep into a large
+collection seeks on the index instead of counting past every earlier row.
+
+The cursor is the **ordered tuple**: the sort column's value *and* the row's
+identifier. That is not a detail - it is the whole correctness argument. A list
+ordered by `title` and a cursor that is only an identifier describe two
+different sequences, and a page boundary between them skips rows permanently,
+because a short page looks exactly like the end of a collection.
+
+```text
+ORDER BY updatedAt DESC, id DESC
+
+WHERE updatedAt < :cursorValue
+ OR (updatedAt = :cursorValue AND id < :cursorId)
+```
+
+Ascending flips both comparisons; backward pagination (`last`) runs the whole
+thing in reverse and flips the page back afterwards. The `ORDER BY` and the
+predicate are built from the same tuple in the same direction, which is the
+invariant everything else rests on.
+
+
+It is `base64url(JSON)` carrying the column, the value and the identifier - so
+it is meaningless outside the ordering that produced it, and it says so: a
+cursor minted while a list was ordered by `updatedAt` and replayed against the
+same list ordered by `title` is a `400`, not a page of wrong rows. Hand it back
+unchanged; never parse it, and never build one.
+
+Nothing re-reads the row it came from. A cursor is the position **as it stood
+when the page was generated**, so editing or deleting the row that happened to
+sit on the boundary does not move it. Re-reading would mean one edit silently
+skips every row the ordering used to have between the old position and the new
+one - a page of results nobody ever sees, with no error and no short page to
+notice it by.
+
+
+### The value is captured by the query that returned the row
+
+**The cursor value comes from the same `SQL` statement as the row it describes.
+There is no second lookup of the boundary row.** The page query projects it
+alongside the ordinary columns:
+
+```sql
+SELECT title, slug, updatedAt::text AS "__cursorValue"
+FROM articles
+ORDER BY updatedAt ASC, id ASC
+LIMIT 26
+```
+
+The reason is the same one that makes a cursor self-contained, applied half a
+step earlier. A cursor has to name **the exact ordered tuple the row occupied at
+issuance**, and a second `SELECT` after the page has come back is a
+time-of-check / time-of-use gap another writer can walk through:
+
+```text
+page query returns id=42, updatedAt=10:00
+ ← another writer sets id=42 to 14:00
+boundary lookup id=42, updatedAt=14:00
+cursor issued (14:00, 42) ← the row was never there
+```
+
+The next page then starts after 14:00 and 10:01, 11:00, 12:00 and 13:00 are gone
+for good. A `DELETE` in the same window was worse: the lookup found nothing, the
+value became `null`, and `null` is not "no position" - for a nullable ordering it
+is a *real* one inside the null block, so the walk jumped there and abandoned the
+rest of the collection.
+
+One statement closes the window rather than locking it. `__cursorValue` is
+internal: it is stripped before a row reaches a handler, so it appears in no
+response, no OpenAPI schema, no search document and no revision snapshot. A
+projection that names only `title` and `slug` still pages by `updatedAt`
+correctly, because pagination selects what it needs without widening the
+allowlist.
+
+### Timestamps travel as the database wrote them
+
+A Postgres `timestamp` keeps microseconds; a JavaScript `Date` keeps
+milliseconds. A boundary value that had been through a `Date` would be strictly
+*smaller* than the one still in the table, and the next page would exclude the
+whole millisecond it came from - which, since `now()` stamps every row in one
+statement identically, would end a bulk-imported collection's walk after page
+one.
+
+So a temporal cursor carries the column's own `::text` and the predicate binds it
+back with an explicit cast. The value in the cursor is byte-identical to the
+value the next comparison is parsed from.
+
+### A tampered cursor is refused, not coerced
+
+The cursor is opaque but not signed, so every field is checked against the
+column it claims to describe:
+
+| Column | Accepted |
+| ------ | -------- |
+| number | a JSON number, finite |
+| bigint | a decimal integer string |
+| boolean | a JSON boolean |
+| string | a JSON string |
+| `date` | `2026-08-09` - a day, and nothing more |
+| `time` | `10:00:00`, `10:00:00.123456`, plus an offset only if the column has a zone |
+| `timestamp`, `timestamptz` | a day, optionally a time, optionally an offset - `2026-08-09 10:00:00.123456+00` |
+| any | `null`, which is a real position |
+
+Coercion is the failure mode being avoided, not an inelegance: `Boolean("false")`
+is `true`, `Number("")` is `0`, and `BigInt("nonsense")` throws a `SyntaxError`
+that would leave the route answering `500`. Every mismatch is a `400`.
+
+**A malformed or impossible timestamp cursor is rejected as `400` before it
+reaches PostgreSQL.** Shape is not enough for a temporal value, because a shape
+check cannot tell a day from a date that looks like one:
+
+```text
+2026-13-01 2026-00-01 2026-02-30 2025-02-29
+2026-01-32 2026-08-09 24:00:00 2026-08-09 23:60:00
+2026-08-09 23:59:61 2026-08-09 10:00:00+25:00
+```
+
+Every one of those matches `YYYY-MM-DD HH:MM:SS` and none of them is a moment,
+so Postgres answers the cast with `invalid input syntax` - a `500` produced by a
+query string. So the components are range-checked as well: month 1-12, the day
+count for that month *in that year* (2024-02-29 is a day, 2025-02-29 is not),
+hours 0-23, minutes and seconds 0-59, and an offset within ±15:59:59.
+
+The grammar is derived from the column's SQL type rather than from Drizzle's
+JavaScript one, because the two disagree exactly where it matters: `date()` and
+`time()` hand back plain strings, so a value bound straight through would reach
+Postgres as `'nonsense'::date`. Validation is deliberately **stricter** than
+Postgres in one place - `24:00:00` is a valid input to Postgres and something its
+own `::text` never writes, so a cursor carrying one did not come from a row.
+
+The original string is what gets bound; nothing is reformatted, so microseconds
+survive validation digit for digit.
+
+
+Temporal cursor pagination intentionally supports **AD years 1 through 294276**
+and does not support PostgreSQL BC-era representations. Values outside that
+domain are rejected with `400`.
+
+This is a deliberate limit on the accepted grammar, not an inability to produce
+one: cursor values are read straight from `orderColumn::text`, so a row genuinely
+holding a BC-era or out-of-range timestamp would mint a cursor the next request
+then refuses. Narrowing the domain keeps validation something that can be read
+and reasoned about; widening it is a decision, not a fix.
+
+
+**What is guaranteed:**
+
+- **any** orderable column pages exactly - a title, a nullable `publishedAt`, a
+ custom field, whatever its relationship to the identifier;
+- the boundary is **stable**: updating or deleting the row the cursor named does
+ not move it, and every row that was after that position is still reachable -
+ including when the change lands *between* the page query and the cursor being
+ minted, because there is nothing in between;
+- rows sharing a sort value are returned exactly once, because the identifier is
+ the tiebreaker in both the ordering and the predicate;
+- a nullable order column walks through its null block correctly - Postgres sorts
+ `NULLS LAST` ascending and `NULLS FIRST` descending, and the predicate names
+ the block rather than letting `column > NULL` end the walk early;
+- a row already returned never comes back, and the cursor always advances, so a
+ loop always terminates;
+- a page never claims a neighbour it cannot hand out a cursor for;
+- a public page is capped at the server's ceiling however large a `first` an
+ anonymous caller sends.
+
+**What is not:**
+
+
+Rows inserted behind the cursor are not seen until the next pass, and a row that
+is *edited* so that it moves from behind the cursor to ahead of it is seen a
+second time. That is the trade every keyset pagination makes, and it is the right
+one - the alternative is holding a transaction open across requests. What never
+happens is a row being skipped because something *else* moved.
+
+
+### Invalid input is refused, not repaired
+
+`first=0` used to clamp its way into a one-row page that reported
+`hasNextPage: true`; `first=abc` became `NaN` and fell through to the default
+page size. Both are requests nobody made, answered as if they had. Every one of
+these is now a `400`, most of them from the route's own schema:
+
+```text
+first=0 last=0 first=-1 last=-1
+first=abc last=abc first=1.5
+first=5&last=5
+cursor=
+```
+
+A legacy numeric cursor still works in the one place it was ever correct -
+a list ordered by its identifier, where the number really is the whole tuple.
+Anywhere else it is refused rather than guessed at.
+
+## Indexes
+
+Everything a generated query needs is generated with it, and the definitions are
+asserted rather than assumed:
+
+| Query | Index |
+| ----- | ----- |
+| `findById` | the primary key |
+| `findBySlug` | the slug's unique index |
+| localized slug lookup | `UNIQUE (languageId, slug)` on the translation table |
+| public list | `(status, publishedAt)` from the publication block |
+| revision history | `UNIQUE (contentTypeId, itemId, version)` |
+| translation history | the partial unique index on `(…, languageId, version)` |
+| relation membership `EXISTS` | the junction's primary key `(itemId, relatedItemId)` |
+| reverse relation lookup | `(relatedItemId)` - Postgres does not index the child side of a foreign key on its own |
+| repeatable `loadMany` | `UNIQUE (itemId, position)` |
+| schedule claims | `(status, scheduledFor)` and `(contentTypeId, itemId)` |
+
+The plans are checked too, on a table large enough for the planner to have a real
+choice: a slug lookup and an identifier lookup both seek, and a revision history
+page does not scan. Nothing asserts a *cost* - planner costs move between majors
+for reasons that are none of the engine's business.
+
+## Adding an index of your own
+
+```ts
+indexes: [
+ { on: ["status", "createdAt"] },
+ // Leaf paths work, and compile against the generated column.
+ { on: ["syndication.priority"] },
+],
+```
+
+Names are derived from the table and clamped to Postgres' 63 characters with a
+fingerprint, and a collision anywhere in the schema is a **boot failure** rather
+than a migration that fails at deploy time. That includes the names nobody wrote
+down: a junction's primary key, its position constraint, its target index and a
+repeatable's position constraint.
+
+## A rebuild walks by key, not by offset
+
+Both generated indexers page with a cursor: `(itemId, languageId)` for the
+localized one, `id` for the other. `OFFSET` was wrong twice over - it re-reads
+and discards every earlier row, so a deep page pays for every page before it,
+and the offset counts rows in a set that is *moving*. A record unpublished after
+page one shifts everything behind it forward by one, and the next `OFFSET 100`
+steps straight over a row nobody ever indexed.
+
+
+It walks the collection as it stands. A row published **ahead** of the cursor is
+picked up by the same pass; one published behind it is not, and is indexed by its
+own publish instead. A row unpublished before the walk reaches it is simply never
+read; one unpublished after it was read has already had its document written, and
+the live unpublish is what removes it. Nothing is skipped that the walk had not
+already passed.
+
+
+## Reads stay page-bound
+
+A page is a page whatever the table holds behind it: `first: 25` materialises 25
+rows, and `totalCount` is an aggregate rather than a fetch. Nothing in the list
+path reads a collection whole.
+
+The one read that deliberately loads everything is `findDetail`, which is what an
+edit form needs - one record, with its collections attached. A list must never
+call it, and does not.
+
+## Where the limits actually are
+
+- **A repeatable is a handful of rows a person edits in one form.** The ceiling
+ is 200 and the default is 50; model a content type for anything larger.
+- **A to-many relation is capped** for the same reason - it is edited as a set in
+ a picker, not paged.
+- **A rebuild is a queue task**, paged by key rather than by `OFFSET`, so page
+ five hundred costs what page one costs. It is not free; run it when you need it
+ rather than on a schedule.
+- **`totalCount` is a `COUNT(*)`** on every list page. On a very large table with
+ a filter that no index covers, that is the expensive part of the request - and
+ the fix is an index on the filter, not a change to the pagination.
+
+## Running the scale suite
+
+```bash
+DATABASE_TEST_URL=postgres://postgres:postgres@localhost:5432/vitnode_test \
+ pnpm --filter @vitnode/example test
+```
+
+`performance-postgres.test.ts` seeds a few thousand rows rather than the ten
+thousand a plan might suggest, because every property above is visible at any size
+above "a handful" - and a suite nobody waits for is a suite nobody runs. Where
+scale genuinely matters, because a sequential scan is cheaper than an index on a
+tiny table, the fixture is grown until the planner has a real choice to make.
diff --git a/apps/docs/content/docs/dev/content-engine/production-hardening.mdx b/apps/docs/content/docs/dev/content-engine/production-hardening.mdx
new file mode 100644
index 000000000..ea0e42967
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/production-hardening.mdx
@@ -0,0 +1,108 @@
+---
+title: Production hardening
+description: What the Content Engine guarantees under concurrency, partial failure and scale - and, just as importantly, what it does not.
+icon: ShieldCheck
+---
+
+Stages 1-6 built the Content Engine. This page is the other half of the story:
+what happens when two editors press save at the same second, when the search
+engine is down, when a table has a hundred thousand rows in it, and when the
+definition a revision was written under no longer exists.
+
+None of it is new functionality. It is the set of promises the engine makes, each
+one written down next to the test that proves it - because a guarantee nobody
+can point at is a hope.
+
+## The promises, in one place
+
+| Area | Guarantee |
+| ---- | --------- |
+| **Concurrency** | Two writers carrying the same `expectedVersion` produce exactly one winner and one structured conflict. |
+| **Atomicity** | The content write, its version bump, its revision and its collection rows are one transaction. |
+| **Effects** | Events, search and cache invalidation run **after** the commit and can never undo it. |
+| **Delivery** | At-least-once where a retry is involved, best-effort otherwise. Never exactly-once. |
+| **Search** | The database is the source of truth. Live synchronisation and a rebuild produce the same document. |
+| **Cache** | A mutation expires exactly the tags it touched, per locale, and nothing global. |
+| **Security** | Every generated route carries a staff permission; a public response is an allowlist, not a filter. |
+| **Errors** | Every expected constraint failure has a stable status and code. No SQLSTATE reaches a client. |
+| **Pagination** | The cursor is the ordered tuple, so any orderable column pages exactly - ties and nulls included. |
+| **Revisions** | History is append-only and per-record monotonic, across deletes and recreations. |
+| **Boot** | A generated table, index or constraint name that would collide is a boot failure, not a Tuesday. |
+
+Each one has a page of its own:
+
+- [Concurrency](/docs/dev/content-engine/concurrency) - the race matrix and who wins.
+- [Failure and retries](/docs/dev/content-engine/failure-and-retries) - what survives an outage, and what gets retried.
+- [Security](/docs/dev/content-engine/content-engine-security) - permissions, privacy, preview.
+- [Observability](/docs/dev/content-engine/content-engine-observability) - what an operator can diagnose.
+- [Migration hardening](/docs/dev/content-engine/migration-hardening) - upgrading an install that has rows in it.
+- [Performance and scaling](/docs/dev/content-engine/performance-and-scaling) - pagination, query counts, N+1.
+
+## What is *not* promised
+
+Worth reading before the rest, because most production surprises come from a
+guarantee somebody assumed rather than one that broke.
+
+
+There is no outbox. A retried scheduled effect re-emits its event, so a listener
+can see the same `published` twice. Payloads carry a `scheduleId` precisely so a
+listener that must act once can key off it. See
+[failure and retries](/docs/dev/content-engine/failure-and-retries).
+
+
+- **No frozen dataset across pages.** A cursor is a position in an ordering, not
+ a snapshot. Rows inserted behind the cursor are not seen; rows already returned
+ never come back. The *ordering itself* is exact for any orderable column - the
+ cursor encodes the whole ordered tuple.
+- **No cross-request transaction.** A mutation is atomic. A *workflow* made of
+ several mutations is not, and nothing here pretends otherwise.
+- **No automatic schema migration.** Migrations are generated by `drizzle-kit`
+ and committed. The engine creates no schema at runtime.
+- **No field-level permissions.** Permissions are per content type and per
+ operation. A translator is limited by *locale*, not by field.
+- **No repair of a search index that is merely stale.** Drift is *detected* by
+ counting; correcting it is a rebuild, which is a decision an operator makes.
+
+## Running the hardening suites
+
+Everything on these pages is tested against a real PostgreSQL, because none of
+it can be shown with a mock - a lock wait, a guarded `UPDATE` that matches
+nothing and a `DELETE` that commits mid-transaction are all database behaviour.
+
+```bash
+DATABASE_TEST_URL=postgres://postgres:postgres@localhost:5432/vitnode_test \
+ pnpm --filter @vitnode/example test
+```
+
+
+The suites drop and recreate the schema, and refuse to start unless the database
+name contains "test".
+
+
+The files, and what each one is for:
+
+| File | Covers |
+| ---- | ------ |
+| `concurrency-postgres.test.ts` | Every race in the matrix, on two connections. |
+| `resilience-postgres.test.ts` | Event, search and cache failures; idempotency; drift. |
+| `integrity-postgres.test.ts` | Delete cascades, schema-evolution restore, disabled locales. |
+| `performance-postgres.test.ts` | Pagination, query counts, index use, batch loading. |
+| `migration-postgres.test.ts` | The documented upgrade patterns, against real rows. |
+
+Without `DATABASE_TEST_URL` they skip, loudly, rather than passing quietly.
+
+## Supported PostgreSQL versions
+
+PostgreSQL 17 and 18 are both supported, and the suites are **version-aware**
+rather than lenient. The one difference that reaches an API contract:
+
+```text
+ON DELETE RESTRICT violated
+ PostgreSQL 18 and later -> SQLSTATE 23001 (restrict_violation)
+ earlier majors -> SQLSTATE 23503 (foreign_key_violation)
+```
+
+Both map to the same `409`, so upgrading the database does not change what a
+client sees. The tests assert the *correct* code for the server they are running
+against - "one of these two" would still pass if a future major stopped refusing
+the delete entirely.
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 `` 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 `` 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
+`` 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.
+
+
+ `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.
+
+
+## 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
+```
+
+
+ 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).
+
+
+## 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.
+
+
+ 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.
+
+
+## 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
+→ 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
+
+
+
+ https://example.com/articles/my-article
+ 2026-01-02T03:04:05.000Z
+ weekly
+ 0.7
+
+
+```
+
+`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 `` 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 `<`.
+
+### hreflang inside a sitemap
+
+```ts
+contentSitemapXml({
+ alternates: await readDeliveryAlternatesMany({ c, itemIds, model }),
+ entries,
+ origin: "https://example.com",
+});
+```
+
+```xml
+
+ https://example.com/en/articles/my-article
+
+
+
+```
+
+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 `` 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 `` with `` children - a separate
+function from `contentSitemapXml` because it is a separate document type, and because
+an index whose entries were `` 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..4c7390353
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/slug-history-and-redirects.mdx
@@ -0,0 +1,387 @@
+---
+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.
+
+
+ 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.
+
+
+## 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.
+
+
+ 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.
+
+
+## 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).
+
+
+ `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.
+
+
+## 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..delivery_slug_changed
+content..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).
+
+### When nobody hears them
+
+Both are emitted **after** the transaction commits, and `emit()` reports a dead
+listener rather than throwing one - so a broker outage cannot roll a slug change
+back, and the request still answers 200. It is not swallowed either: the failure
+goes to `core_logs` behind `[content-effects]` with the content type, the item, the
+locale and the listener that failed, exactly like the publication events. See
+[Observability](/docs/dev/content-engine/content-engine-observability#the-logs).
+
+That log line is the one worth alerting on. A listener that purges a CDN or writes
+an edge redirect table missing a `delivery_slug_changed` leaves the old address
+404ing at the edge while the origin is entirely correct - which is the failure
+nobody notices from the inside.
+
+## 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/apps/docs/src/examples/data-table.tsx b/apps/docs/src/examples/data-table.tsx
index d0d86a2c0..0de593d86 100644
--- a/apps/docs/src/examples/data-table.tsx
+++ b/apps/docs/src/examples/data-table.tsx
@@ -73,8 +73,9 @@ export default function DataTableExample() {
pageInfo={{
hasNextPage: false,
hasPreviousPage: false,
- startCursor: 1,
- endCursor: 1,
+ // Opaque cursors, as the API mints them - never row identifiers.
+ startCursor: null,
+ endCursor: null,
count: 1,
totalCount: 1,
}}
diff --git a/packages/elasticsearch/src/index.test.ts b/packages/elasticsearch/src/index.test.ts
index 864f74194..d207eb526 100644
--- a/packages/elasticsearch/src/index.test.ts
+++ b/packages/elasticsearch/src/index.test.ts
@@ -6,6 +6,7 @@ const {
bulk,
index,
deleteByQuery,
+ countDocs,
ping,
search,
exists,
@@ -15,6 +16,7 @@ const {
const bulk = vi.fn();
const del = vi.fn();
const deleteByQuery = vi.fn();
+ const countDocs = vi.fn();
const ping = vi.fn();
const search = vi.fn();
const exists = vi.fn();
@@ -25,6 +27,7 @@ const {
bulk,
delete: del,
deleteByQuery,
+ count: countDocs,
ping,
search,
indices: { exists, create },
@@ -44,6 +47,7 @@ const {
bulk,
index,
deleteByQuery,
+ countDocs,
ping,
search,
exists,
@@ -448,3 +452,105 @@ describe("ElasticsearchSearchAdapter.ping", () => {
expect(await ElasticsearchSearchAdapter(config).ping?.(c)).toBe(false);
});
});
+
+/**
+ * Provider-level diagnostics.
+ *
+ * The canonical `core_search_index` and this index are two storages, and only
+ * one of them is what a visitor actually searches. A drift diagnostic that could
+ * not ask this one would report a perfectly healthy canonical table while the
+ * search box was missing results - which is the failure this API exists to make
+ * visible.
+ */
+describe("ElasticsearchSearchAdapter.count", () => {
+ it("declares itself countable, so diagnostics can verify it", () => {
+ const adapter = ElasticsearchSearchAdapter(config);
+
+ expect(typeof adapter.count).toBe("function");
+ // And it is *not* the canonical storage: it mirrors, so it can drift.
+ expect(adapter.capabilities?.canonicalStorage).toBeUndefined();
+ });
+
+ it("counts one collection without fetching a single document", async () => {
+ countDocs.mockResolvedValue({ count: 42 });
+
+ const total = await ElasticsearchSearchAdapter(config).count?.(c, {
+ itemType: "blog_post",
+ });
+
+ expect(total).toBe(42);
+ // `_count`, never `_search`: a diagnostic over a large index has to cost
+ // the same as one over an empty one.
+ expect(search).not.toHaveBeenCalled();
+ expect(countDocs.mock.calls[0][0]).toMatchObject({
+ index: "test",
+ query: { bool: { filter: [{ term: { itemType: "blog_post" } }] } },
+ });
+ });
+
+ it("narrows to one language when asked", async () => {
+ // Per-locale is the whole point: "Polish is missing forty documents" is not
+ // something a single total can say.
+ countDocs.mockResolvedValue({ count: 7 });
+
+ await ElasticsearchSearchAdapter(config).count?.(c, {
+ itemType: "blog_post",
+ languageCode: "pl",
+ });
+
+ expect(countDocs.mock.calls[0][0].query.bool.filter).toEqual([
+ { term: { itemType: "blog_post" } },
+ { term: { languageCode: "pl" } },
+ ]);
+ });
+
+ it("reads an index that does not exist yet as empty", async () => {
+ // An install that has never rebuilt has no index. That is drift to report,
+ // not a crash in the status route.
+ countDocs.mockResolvedValue({});
+
+ await expect(
+ ElasticsearchSearchAdapter(config).count?.(c, { itemType: "blog_post" }),
+ ).resolves.toBe(0);
+ expect(countDocs.mock.calls[0][1]).toMatchObject({ ignore: [404] });
+ });
+
+ it("counts every language when none is named", async () => {
+ // The unfiltered total, which is what catches a document left behind in a
+ // locale the content type no longer has - per-locale counts can only ask
+ // about locales somebody already knows to ask for.
+ countDocs.mockResolvedValue({ count: 9 });
+
+ await ElasticsearchSearchAdapter(config).count?.(c, {
+ itemType: "blog_post",
+ });
+
+ expect(countDocs.mock.calls[0][0].query.bool.filter).toEqual([
+ { term: { itemType: "blog_post" } },
+ ]);
+ });
+
+ it("creates no index as a side effect", async () => {
+ // A diagnostic is observational. Creating the index here would make the
+ // health check change the thing it is measuring - and would report a
+ // never-rebuilt install as a healthy empty one.
+ countDocs.mockResolvedValue({ count: 0 });
+
+ await ElasticsearchSearchAdapter(config).count?.(c, {
+ itemType: "blog_post",
+ });
+
+ expect(create).not.toHaveBeenCalled();
+ expect(exists).not.toHaveBeenCalled();
+ });
+
+ it("lets a transport failure surface, so the caller can report it", async () => {
+ // Swallowing it here would turn "Elasticsearch is down" into "zero
+ // documents", which reads as drift rather than as an outage.
+ countDocs.mockRejectedValue(new Error("connect ECONNREFUSED"));
+
+ await expect(
+ ElasticsearchSearchAdapter(config).count?.(c, { itemType: "blog_post" }),
+ ).rejects.toThrow("connect ECONNREFUSED");
+ });
+});
diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts
index 31947ba5d..08fee597f 100644
--- a/packages/elasticsearch/src/index.ts
+++ b/packages/elasticsearch/src/index.ts
@@ -294,6 +294,39 @@ export const ElasticsearchSearchAdapter = (
languageScopedDelete: true,
},
+ /**
+ * How many documents this index holds for one collection.
+ *
+ * `_count` rather than a search: it returns a number without fetching a
+ * single document, so a diagnostic over a large index costs the same as one
+ * over an empty one. `languageCode` narrows it to a single translation,
+ * which is what makes per-locale drift visible - "Polish is missing forty
+ * documents" is not something a total can say.
+ *
+ * A missing index means zero rather than an error: an install that has never
+ * rebuilt has no index yet, and that is drift to report, not a crash.
+ */
+ count: async (_c, { itemType, languageCode }) => {
+ const response = await getClient().count(
+ {
+ index,
+ query: {
+ bool: {
+ filter: [
+ { term: { itemType } },
+ ...(languageCode === undefined
+ ? []
+ : [{ term: { languageCode } }]),
+ ],
+ },
+ },
+ },
+ { ignore: [404] },
+ );
+
+ return response.count ?? 0;
+ },
+
index: async (_c, doc) => {
await ensureIndex();
await getClient().index({
diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts
index 88193f36f..3a4ee863f 100644
--- a/packages/vitnode/src/api/adapters/search/postgres.ts
+++ b/packages/vitnode/src/api/adapters/search/postgres.ts
@@ -75,11 +75,15 @@ const buildFilters = (params: SearchQueryParams): SQL | undefined => {
export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
name: "postgres",
- // `languageScopedDelete` is true by construction: this provider's store *is*
- // `core_search_index`, and `SearchModel.delete` already narrows that table by
- // `languageCode` before it gets here. There is no second copy to keep in step.
+ // Two capabilities that are both true for the same reason: this provider's
+ // store *is* `core_search_index`. `languageScopedDelete` because
+ // `SearchModel.delete` already narrows that table by `languageCode` before it
+ // gets here, and `canonicalStorage` because there is no second copy to drift
+ // from - so diagnostics can report the canonical count as the provider count
+ // rather than asking the same table twice.
capabilities: {
authorBoost: false,
+ canonicalStorage: true,
facets: false,
languageScopedDelete: true,
timeDecay: false,
diff --git a/packages/vitnode/src/api/lib/pagination-cursor.test.ts b/packages/vitnode/src/api/lib/pagination-cursor.test.ts
new file mode 100644
index 000000000..b475575d5
--- /dev/null
+++ b/packages/vitnode/src/api/lib/pagination-cursor.test.ts
@@ -0,0 +1,507 @@
+import { bigint, date, pgTable, time, timestamp } from "drizzle-orm/pg-core";
+// @vitest-environment node
+import { HTTPException } from "hono/http-exception";
+import { describe, expect, it } from "vitest";
+
+import { core_users } from "@/database/users";
+
+import {
+ cursorValueForColumn,
+ cursorValueIsCanonicalText,
+ cursorValueOf,
+ decodePaginationCursor,
+ encodePaginationCursor,
+ isCursorSortableColumn,
+} from "./pagination-cursor";
+
+/** The column shapes core itself has no example of, but a plugin may. */
+const probes = pgTable("cursor_probes", {
+ big: bigint({ mode: "bigint" }),
+ clock: time(),
+ clockTz: time({ withTimezone: true }),
+ day: date(),
+ moment: timestamp(),
+});
+
+/**
+ * The cursor is the ordered tuple, or it is nothing.
+ *
+ * An identifier on its own only describes a position when the list is ordered
+ * by the identifier. For any other column it names a row whose place in the
+ * sequence nobody knows - which is how a list ordered by `updatedAt` used to
+ * skip every row whose id happened to fall on the wrong side of it.
+ */
+
+const statusOf = (error: unknown): number =>
+ error instanceof HTTPException ? error.status : 0;
+
+describe("encoding", () => {
+ it("round-trips a cursor", () => {
+ const cursor = {
+ column: "updatedAt",
+ id: 42,
+ value: "2026-08-08T12:00:00.000Z",
+ };
+
+ expect(
+ decodePaginationCursor(encodePaginationCursor(cursor), {
+ column: "updatedAt",
+ primaryKey: "id",
+ }),
+ ).toEqual(cursor);
+ });
+
+ it("is opaque, so nothing downstream starts parsing it", () => {
+ const encoded = encodePaginationCursor({
+ column: "updatedAt",
+ id: 42,
+ value: "2026-08-08T12:00:00.000Z",
+ });
+
+ // base64url: URL-safe, and not a number somebody will be tempted to read.
+ expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
+ expect(Number.isNaN(Number(encoded))).toBe(true);
+ });
+
+ it("round-trips a null order value, which is a real position", () => {
+ const cursor = { column: "publishedAt", id: 9, value: null };
+
+ expect(
+ decodePaginationCursor(encodePaginationCursor(cursor), {
+ column: "publishedAt",
+ primaryKey: "id",
+ }),
+ ).toEqual(cursor);
+ });
+
+ it.each([
+ ["a string", "Zebra"],
+ ["a number", 12],
+ ["a boolean", true],
+ ])("round-trips %s value", (_why, value) => {
+ const cursor = { column: "name", id: 3, value };
+
+ expect(
+ decodePaginationCursor(encodePaginationCursor(cursor), {
+ column: "name",
+ primaryKey: "id",
+ }).value,
+ ).toEqual(value);
+ });
+});
+
+describe("decoding refuses what it cannot trust", () => {
+ const decode = (raw: string, column = "updatedAt") =>
+ decodePaginationCursor(raw, { column, primaryKey: "id" });
+
+ it.each([
+ ["garbage", "not-a-cursor!!"],
+ ["an empty string", " "],
+ [
+ "valid base64 that is not JSON",
+ Buffer.from("hello").toString("base64url"),
+ ],
+ [
+ "JSON that is not an object",
+ Buffer.from("[1,2,3]").toString("base64url"),
+ ],
+ [
+ "an object with no id",
+ Buffer.from(JSON.stringify({ column: "updatedAt", value: 1 })).toString(
+ "base64url",
+ ),
+ ],
+ [
+ "a non-integer id",
+ Buffer.from(
+ JSON.stringify({ column: "updatedAt", id: 1.5, value: 1 }),
+ ).toString("base64url"),
+ ],
+ [
+ "a zero id",
+ Buffer.from(
+ JSON.stringify({ column: "updatedAt", id: 0, value: 1 }),
+ ).toString("base64url"),
+ ],
+ [
+ "an object value",
+ Buffer.from(
+ JSON.stringify({ column: "updatedAt", id: 1, value: { a: 1 } }),
+ ).toString("base64url"),
+ ],
+ ])("answers 400 for %s", (_why, raw) => {
+ expect(() => decode(raw)).toThrow(HTTPException);
+ try {
+ decode(raw);
+ } catch (error) {
+ expect(statusOf(error)).toBe(400);
+ }
+ });
+
+ it("refuses a cursor minted for another ordering", () => {
+ // The two describe different sequences, so the position means nothing -
+ // and using it anyway is exactly how rows get skipped.
+ const encoded = encodePaginationCursor({
+ column: "updatedAt",
+ id: 42,
+ value: "2026-08-08T12:00:00.000Z",
+ });
+
+ expect(() => decode(encoded, "title")).toThrow(/different ordering/);
+ });
+});
+
+describe("legacy numeric cursors", () => {
+ it("still works when the list is ordered by its identifier", () => {
+ // There the identifier really is the whole ordered tuple, so an old
+ // bookmark keeps working.
+ expect(
+ decodePaginationCursor("42", { column: "id", primaryKey: "id" }),
+ ).toEqual({ column: "id", id: 42, value: 42 });
+ });
+
+ it("is refused for any other ordering rather than guessed at", () => {
+ // The regression this whole change exists for: a bare number says nothing
+ // about where `updatedAt` was, so interpreting it as one would silently
+ // skip rows.
+ expect(() =>
+ decodePaginationCursor("42", { column: "updatedAt", primaryKey: "id" }),
+ ).toThrow(/cannot be used with the "updatedAt" ordering/);
+ });
+});
+
+describe("column values", () => {
+ it("keeps a timestamp as text on both sides", () => {
+ // Deliberately *not* a `Date` round trip. A `Date` holds milliseconds and
+ // Postgres holds microseconds, so turning the text back into one would
+ // truncate the value the next comparison is parsed from - and exclude the
+ // whole millisecond the cursor came from. The predicate casts this text
+ // back to the column's type instead, and lets Postgres do the parsing.
+ const flattened = cursorValueOf(
+ core_users.createdAt,
+ "2026-08-08 12:00:00.123456",
+ );
+
+ expect(flattened).toBe("2026-08-08 12:00:00.123456");
+ expect(cursorValueForColumn(core_users.createdAt, flattened)).toBe(
+ "2026-08-08 12:00:00.123456",
+ );
+ });
+
+ it("keeps a string a string", () => {
+ expect(cursorValueOf(core_users.name, "Ada")).toBe("Ada");
+ expect(cursorValueForColumn(core_users.name, "Ada")).toBe("Ada");
+ });
+
+ it("keeps a number a number", () => {
+ expect(cursorValueOf(core_users.id, 7)).toBe(7);
+ expect(cursorValueForColumn(core_users.id, 7)).toBe(7);
+ });
+
+ it("treats null as null on both sides", () => {
+ expect(cursorValueOf(core_users.createdAt, null)).toBeNull();
+ expect(cursorValueForColumn(core_users.createdAt, null)).toBeNull();
+ });
+
+ it("refuses an unparseable date rather than letting Postgres fail the cast", () => {
+ expect(() =>
+ cursorValueForColumn(core_users.createdAt, "not-a-date"),
+ ).toThrow(HTTPException);
+ });
+
+ it("accepts the sortable column kinds", () => {
+ expect(isCursorSortableColumn(core_users.id)).toBe(true);
+ expect(isCursorSortableColumn(core_users.name)).toBe(true);
+ expect(isCursorSortableColumn(core_users.createdAt)).toBe(true);
+ expect(isCursorSortableColumn(core_users.newsletter)).toBe(true);
+ expect(isCursorSortableColumn(probes.big)).toBe(true);
+ });
+});
+
+/**
+ * A cursor is opaque, not signed. A client can edit it.
+ *
+ * So every field is hostile input, checked against the column it claims to
+ * describe. Coercion is the failure mode to avoid, not just an inelegance:
+ * `Boolean("false")` is `true`, `Number("")` is `0`, and `BigInt("nonsense")`
+ * throws a `SyntaxError` that would leave the route as a 500. Each of those is
+ * a wrong page or a wrong status code handed to somebody who asked for neither.
+ */
+describe("a tampered cursor value is refused, never coerced", () => {
+ const refuses = (
+ column: Parameters[0],
+ value: unknown,
+ ) => {
+ expect(() => cursorValueForColumn(column, value as never)).toThrow(
+ HTTPException,
+ );
+ try {
+ cursorValueForColumn(column, value as never);
+ } catch (error) {
+ expect(statusOf(error)).toBe(400);
+ }
+ };
+
+ describe("boolean", () => {
+ it('refuses the string "false", which coercion would read as true', () => {
+ refuses(core_users.newsletter, "false");
+ });
+
+ it.each([
+ ["a number", 0],
+ ["a string", "true"],
+ ["an empty string", ""],
+ ])("refuses %s", (_why, value) => {
+ refuses(core_users.newsletter, value);
+ });
+
+ it("accepts a real boolean, and null", () => {
+ expect(cursorValueForColumn(core_users.newsletter, false)).toBe(false);
+ expect(cursorValueForColumn(core_users.newsletter, true)).toBe(true);
+ expect(cursorValueForColumn(core_users.newsletter, null)).toBeNull();
+ });
+ });
+
+ describe("number", () => {
+ it.each([
+ ["a numeric string", "42"],
+ ["an empty string", ""],
+ ["a boolean", true],
+ ["nonsense", "not-a-number"],
+ ])("refuses %s", (_why, value) => {
+ refuses(core_users.id, value);
+ });
+
+ it("accepts a finite number, and null", () => {
+ expect(cursorValueForColumn(core_users.id, 42)).toBe(42);
+ expect(cursorValueForColumn(core_users.id, null)).toBeNull();
+ });
+ });
+
+ describe("bigint", () => {
+ it("refuses a value that would make BigInt() throw", () => {
+ // The one that used to escape as a native `SyntaxError`, and therefore
+ // as a 500.
+ refuses(probes.big, "not-a-bigint");
+ });
+
+ it.each([
+ ["a fractional string", "1.5"],
+ ["an empty string", ""],
+ ["a number", 12],
+ ["a boolean", false],
+ ["whitespace", " 12 "],
+ ])("refuses %s", (_why, value) => {
+ refuses(probes.big, value);
+ });
+
+ it("accepts a decimal integer string, signed or not, and null", () => {
+ expect(cursorValueForColumn(probes.big, "9007199254740993")).toBe(
+ 9007199254740993n,
+ );
+ expect(cursorValueForColumn(probes.big, "-4")).toBe(-4n);
+ expect(cursorValueForColumn(probes.big, null)).toBeNull();
+ });
+ });
+
+ describe("timestamp", () => {
+ it.each([
+ ["nonsense", "not-a-date"],
+ ["a number", 1_700_000_000],
+ ["a boolean", true],
+ ["a half-written date", "2026-08"],
+ ["an injection attempt", "2026-08-09'; DROP TABLE users; --"],
+ ])("refuses %s", (_why, value) => {
+ // Reaching Postgres with any of these would be an invalid-cast 500
+ // rather than a 400 - and the last one has no business getting near a
+ // cast at all.
+ refuses(core_users.createdAt, value);
+ });
+
+ it.each([
+ ["a Postgres timestamp", "2026-08-09 10:00:00.123456"],
+ ["a Postgres timestamptz", "2026-08-09 10:00:00.123456+00"],
+ ["a plain date", "2026-08-09"],
+ ["an ISO string", "2026-08-09T10:00:00.123Z"],
+ ])("accepts %s, unchanged", (_why, value) => {
+ // Unchanged is the point: the predicate casts this text back to the
+ // column's type, so Postgres parses it at the precision it stored.
+ expect(cursorValueForColumn(core_users.createdAt, value)).toBe(value);
+ });
+
+ it("is the one kind bound as text plus a cast", () => {
+ expect(cursorValueIsCanonicalText(core_users.createdAt)).toBe(true);
+ expect(cursorValueIsCanonicalText(core_users.id)).toBe(false);
+ expect(cursorValueIsCanonicalText(core_users.name)).toBe(false);
+ });
+
+ /**
+ * The gap a pattern alone leaves open.
+ *
+ * Every one of these has the shape of a timestamp and is not a moment, so a
+ * shape check waves it through and Postgres answers the cast with
+ * `invalid input syntax` - a 500 produced by a query string, on a route
+ * whose whole promise is that it does not do that.
+ */
+ describe("impossible values that still look like timestamps", () => {
+ it.each([
+ ["month 13", "2026-13-01"],
+ ["month 0", "2026-00-01"],
+ ["a day past the end of the month", "2026-02-30"],
+ ["a day past the end of a short month", "2026-04-31"],
+ ["29 February in a common year", "2025-02-29"],
+ ["29 February in a century that is not a leap year", "1900-02-29"],
+ ["day 0", "2026-08-00"],
+ ["day 32", "2026-01-32"],
+ ["hour 24", "2026-08-09 24:00:00"],
+ ["hour 99", "2026-08-09 99:00:00"],
+ ["minute 60", "2026-08-09 23:60:00"],
+ ["second 61", "2026-08-09 23:59:61"],
+ ["year 0", "0000-01-01"],
+ ])("refuses %s", (_why, value) => {
+ refuses(core_users.createdAt, value);
+ });
+
+ it.each([
+ ["an offset past the maximum", "2026-08-09 10:00:00+25:00"],
+ ["an offset with 99 minutes", "2026-08-09 10:00:00+12:99"],
+ ["an offset that is not a number", "2026-08-09 10:00:00+ab"],
+ ["a seven-digit fraction", "2026-08-09 10:00:00.1234567"],
+ ["trailing rubbish", "2026-08-09 10:00:00 OR 1=1"],
+ [
+ "an era suffix, which is outside the supported domain",
+ "2026-08-09 BC",
+ ],
+ ])("refuses %s", (_why, value) => {
+ refuses(core_users.createdAt, value);
+ });
+
+ it.each([
+ ["29 February in a leap year", "2024-02-29"],
+ ["29 February in a leap century", "2000-02-29"],
+ ["the last second of a day", "2026-08-09 23:59:59"],
+ ["the first second of a day", "2026-08-09 00:00:00"],
+ ["31 December", "2026-12-31"],
+ ["microseconds", "2026-08-09 10:00:00.123456"],
+ ["a whole-hour offset", "2026-08-09 10:00:00+02"],
+ ["a half-hour offset", "2026-08-09 10:00:00+05:30"],
+ ["a compact offset", "2026-08-09 10:00:00-0400"],
+ ["a negative offset", "2026-08-09 10:00:00-04"],
+ ])("accepts %s", (_why, value) => {
+ expect(cursorValueForColumn(core_users.createdAt, value)).toBe(value);
+ });
+
+ it("preserves microseconds through validation, digit for digit", () => {
+ // The reason the value is kept as text at all. Anything that reformats
+ // it here is a truncation the next comparison inherits.
+ const value = "2026-08-09 10:00:00.000001";
+
+ expect(cursorValueForColumn(core_users.createdAt, value)).toBe(value);
+ });
+ });
+ });
+
+ /**
+ * A `date` and a `time` column look like strings to Drizzle - `dataType` says
+ * `"string"` - but Postgres still has to parse them. Classifying from the SQL
+ * type is what stops `'nonsense'::date` being a 500.
+ */
+ describe("other temporal columns", () => {
+ it("holds a date column to a date, and nothing more", () => {
+ expect(cursorValueForColumn(probes.day, "2026-08-09")).toBe("2026-08-09");
+ refuses(probes.day, "2026-08-09 10:00:00");
+ refuses(probes.day, "2026-02-30");
+ refuses(probes.day, "not-a-date");
+ });
+
+ it("holds a time column to a time, and refuses a zone it has not got", () => {
+ expect(cursorValueForColumn(probes.clock, "10:00:00")).toBe("10:00:00");
+ expect(cursorValueForColumn(probes.clock, "10:00:00.123456")).toBe(
+ "10:00:00.123456",
+ );
+ refuses(probes.clock, "10:00:00+02");
+ refuses(probes.clock, "24:00:00");
+ refuses(probes.clock, "2026-08-09");
+ });
+
+ it("lets a time-with-zone column carry its zone", () => {
+ expect(cursorValueForColumn(probes.clockTz, "10:00:00+02")).toBe(
+ "10:00:00+02",
+ );
+ refuses(probes.clockTz, "10:00:00+25");
+ });
+
+ it("binds every temporal column as text plus a cast", () => {
+ for (const column of [probes.day, probes.clock, probes.clockTz]) {
+ expect(cursorValueIsCanonicalText(column)).toBe(true);
+ expect(isCursorSortableColumn(column)).toBe(true);
+ }
+ });
+ });
+
+ describe("string", () => {
+ it.each([
+ ["a number", 12],
+ ["a boolean", true],
+ ])("refuses %s", (_why, value) => {
+ refuses(core_users.name, value);
+ });
+
+ it("accepts a string, and null", () => {
+ expect(cursorValueForColumn(core_users.name, "Ada")).toBe("Ada");
+ expect(cursorValueForColumn(core_users.name, null)).toBeNull();
+ });
+ });
+
+ it("never lets a native parser error escape", () => {
+ // Whatever is thrown, it is an `HTTPException` - not a `SyntaxError`, a
+ // `RangeError`, or anything else that would surface as a 500.
+ const hostile = [
+ [probes.big, "nope"],
+ [core_users.createdAt, "nope"],
+ [core_users.newsletter, "nope"],
+ [core_users.id, "nope"],
+ ] as const;
+
+ for (const [column, value] of hostile) {
+ try {
+ cursorValueForColumn(column, value);
+ throw new Error(`Expected ${column.name} to refuse ${value}.`);
+ } catch (error) {
+ expect(error).toBeInstanceOf(HTTPException);
+ }
+ }
+ });
+});
+
+describe("minting keeps the database's own representation", () => {
+ it("keeps a Postgres timestamp string exactly as it was read", () => {
+ // Microseconds and all: this is the value the next comparison is parsed
+ // from, so anything lost here is lost from the ordering.
+ expect(
+ cursorValueOf(core_users.createdAt, "2026-08-09 10:00:00.123456"),
+ ).toBe("2026-08-09 10:00:00.123456");
+ });
+
+ it("rewrites a Date into the form the column would have been read in", () => {
+ // A `Date` only arrives when a caller mints from a value it is holding -
+ // the paginated path reads `::text`. Writing it the way Postgres writes a
+ // `timestamp` keeps minting and validation speaking one grammar, so a
+ // cursor this module produced can never be one it later refuses.
+ expect(
+ cursorValueOf(core_users.createdAt, new Date("2026-08-09T10:00:00.123Z")),
+ ).toBe("2026-08-09 10:00:00.123");
+ });
+
+ it("refuses a Date that is not a moment, rather than minting nonsense", () => {
+ expect(() =>
+ cursorValueOf(core_users.createdAt, new Date("not-a-date")),
+ ).toThrow(/invalid date/i);
+ });
+
+ it("carries a bigint as a decimal string, which JSON can hold", () => {
+ expect(cursorValueOf(probes.big, 9007199254740993n)).toBe(
+ "9007199254740993",
+ );
+ });
+});
diff --git a/packages/vitnode/src/api/lib/pagination-cursor.ts b/packages/vitnode/src/api/lib/pagination-cursor.ts
new file mode 100644
index 000000000..289222d30
--- /dev/null
+++ b/packages/vitnode/src/api/lib/pagination-cursor.ts
@@ -0,0 +1,488 @@
+import type { PgColumn } from "drizzle-orm/pg-core";
+
+import { HTTPException } from "hono/http-exception";
+
+/**
+ * The opaque cursor a paginated list hands out, and takes back.
+ *
+ * Two properties, and both of them are load-bearing.
+ *
+ * **It is the ordered tuple.** A cursor has to describe a position in an
+ * ordering, and an ordering is `(orderColumn, id)` - so the cursor is that pair.
+ * An identifier on its own is only a position when the list is ordered by the
+ * identifier; for any other column it names a row whose place in the sequence
+ * nobody knows.
+ *
+ * **It is self-contained.** The value it carries *is* the boundary, and nothing
+ * re-reads the row it came from. That is the difference between a cursor and a
+ * pointer: a cursor is the position as it stood when the page was generated, and
+ * editing or deleting the row that happened to sit on the boundary must not move
+ * it. Re-reading would mean an edit to one row silently skips every row the
+ * ordering used to have between the old position and the new one.
+ *
+ * The wire form is `base64url(JSON)`: opaque, so no client starts depending on
+ * the shape, and self-describing, so a cursor minted for one order column is
+ * refused by a request that has since changed to another.
+ *
+ * It is **not signed**, so every field is treated as hostile input and validated
+ * against the column it claims to describe - see {@link cursorValueForColumn}.
+ */
+
+/** What an order column's value can be, once it has been through JSON. */
+export type PaginationCursorValue = boolean | null | number | string;
+
+export interface PaginationCursor {
+ /** The order column this cursor was minted for. */
+ column: string;
+ /** The row's primary key - the tiebreaker half of the ordered tuple. */
+ id: number;
+ /** The order column's value on that row. `null` is a real position. */
+ value: PaginationCursorValue;
+}
+
+/**
+ * How one column's values travel in a cursor.
+ *
+ * Named per kind rather than inferred, because "how do I serialise this" and
+ * "what am I willing to accept back" are the same question asked twice, and
+ * answering it in one place is what stops the second answer being looser than
+ * the first.
+ */
+type CursorKind = "bigint" | "boolean" | "number" | "string" | "temporal";
+
+const KIND_BY_DATA_TYPE: Record = {
+ bigint: "bigint",
+ boolean: "boolean",
+ date: "temporal",
+ number: "number",
+ string: "string",
+};
+
+const badRequest = (message: string): HTTPException =>
+ new HTTPException(400, { message });
+
+/** The one message a tampered or stale cursor ever produces. */
+const INVALID_CURSOR = "Invalid pagination cursor.";
+
+/**
+ * The three shapes a temporal value comes in, keyed by what Postgres will parse.
+ *
+ * Classified from the **SQL** type rather than the JavaScript one, because the
+ * two disagree in exactly the case that matters: `date()` and `time()` hand back
+ * plain strings, so `dataType` calls them `"string"` - and a string cursor bound
+ * straight into `column > $1` would reach Postgres as `'nonsense'::date` and
+ * come back as a 500 rather than a 400.
+ */
+type TemporalType = "date" | "time" | "timestamp";
+
+const temporalTypeOf = (column: PgColumn): null | TemporalType => {
+ const sqlType = column.getSQLType().toLowerCase();
+
+ // Order matters: "timestamp with time zone" also starts with "time".
+ if (sqlType.startsWith("timestamp")) return "timestamp";
+ if (sqlType.startsWith("time")) return "time";
+ if (sqlType.startsWith("date")) return "date";
+
+ return null;
+};
+
+const hasTimeZone = (column: PgColumn): boolean =>
+ column.getSQLType().toLowerCase().includes("with time zone");
+
+/**
+ * Whether a column can be paged through at all.
+ *
+ * A `json`, `array` or custom column has no total order Postgres and JavaScript
+ * agree on, so a cursor over one would be a value the next page cannot compare
+ * against. Refused rather than approximated.
+ */
+export const isCursorSortableColumn = (column: PgColumn): boolean =>
+ temporalTypeOf(column) !== null || column.dataType in KIND_BY_DATA_TYPE;
+
+const kindOf = (column: PgColumn): CursorKind => {
+ if (temporalTypeOf(column)) return "temporal";
+
+ const kind = KIND_BY_DATA_TYPE[column.dataType];
+ if (!kind) {
+ throw badRequest(
+ `The "${column.name}" column cannot be used as a pagination cursor.`,
+ );
+ }
+
+ return kind;
+};
+
+/**
+ * The grammar of a Postgres temporal value, as `::text` renders it.
+ *
+ * One pattern per SQL type, because a `date` column and a `timestamp` column do
+ * not accept the same strings and pretending they do is how a cursor for one
+ * ends up being parsed as the other:
+ *
+ * | SQL type | accepted |
+ * | --------------------------- | ------------------------------------------- |
+ * | `date` | `2026-08-09` |
+ * | `time` | `10:00:00`, `10:00:00.123456` |
+ * | `time with time zone` | the above, optionally `+02` / `Z` |
+ * | `timestamp` | a date, optionally a time, optionally a zone |
+ * | `timestamp with time zone` | the same, and that is what `::text` writes |
+ *
+ * A `T` separator and a `Z` designator are accepted alongside the space-and-
+ * offset form Postgres writes, because a JavaScript `Date` is the one input this
+ * module takes that has no database text behind it.
+ *
+ * Matching the shape is only half of it. These patterns cannot tell `2026-02-30`
+ * from `2026-02-28`, so every capture is range-checked afterwards - see
+ * {@link isRealTemporal}.
+ */
+const TEMPORAL_GRAMMAR: Record = {
+ date: /^(?\d{4,6})-(?\d{2})-(?\d{2})$/,
+ time: /^(?\d{2}):(?\d{2}):(?\d{2})(?:\.\d{1,6})?(?.*)$/,
+ timestamp:
+ /^(?\d{4,6})-(?\d{2})-(?\d{2})(?:[ T](?\d{2}):(?\d{2}):(?\d{2})(?:\.\d{1,6})?(?.*))?$/,
+};
+
+/**
+ * Whatever the trailing group swallowed, checked rather than trusted.
+ *
+ * `(?.*)` is deliberately greedy: it catches a seventh fractional digit,
+ * an era suffix and `OR 1=1` alike, and hands all of them here to be refused.
+ */
+type TemporalParts = Partial<
+ Record<
+ "day" | "hour" | "minute" | "month" | "second" | "year" | "zone",
+ string
+ >
+>;
+
+/** `Z`, or `±HH`, `±HH:MM`, `±HHMM`, `±HH:MM:SS` - the forms Postgres writes. */
+const ZONE = /^([+-])(\d{2})(?::?(\d{2}))?(?::?(\d{2}))?$/;
+
+const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+
+const isLeapYear = (year: number): boolean =>
+ (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+
+const isRealZone = (raw: string, allowed: boolean): boolean => {
+ if (raw === "") return true;
+ if (!allowed) return false;
+ if (raw === "Z") return true;
+
+ const match = ZONE.exec(raw);
+ if (!match) return false;
+
+ const [, , hours, minutes = "0", seconds = "0"] = match;
+
+ // Postgres refuses anything past ±15:59:59, and so does this.
+ return Number(hours) <= 15 && Number(minutes) <= 59 && Number(seconds) <= 59;
+};
+
+/**
+ * Whether a shaped temporal string is a moment that exists.
+ *
+ * The reason a pattern is not enough: `2026-02-30`, `2025-02-29`, `2026-13-01`
+ * and `2026-08-09 23:60:00` all match the shape and all make Postgres raise
+ * `invalid input syntax`, which is a 500 arriving from a query string. Every one
+ * of them is refused here instead, before anything is bound.
+ *
+ * Deliberately stricter than Postgres in one place: Postgres reads `24:00:00` as
+ * the following midnight, but its own `::text` never writes it, so a cursor
+ * carrying one did not come from a row.
+ */
+const isRealTemporal = (
+ column: PgColumn,
+ temporal: TemporalType,
+ value: string,
+): boolean => {
+ const match = TEMPORAL_GRAMMAR[temporal].exec(value);
+ if (!match) return false;
+
+ const { day, hour, minute, month, second, year, zone } =
+ (match.groups as TemporalParts | undefined) ?? {};
+
+ if (year !== undefined) {
+ const [y, m, d] = [Number(year), Number(month), Number(day)];
+ if (y < 1 || y > 294276 || m < 1 || m > 12) return false;
+
+ const limit = m === 2 && isLeapYear(y) ? 29 : DAYS_IN_MONTH[m - 1];
+ if (d < 1 || d > limit) return false;
+ }
+
+ // A bare `date`, or a `timestamp` written as one: no time to check.
+ if (hour === undefined) return true;
+
+ if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) {
+ return false;
+ }
+
+ // A `timestamp` takes an offset and throws it away, which is what lets a
+ // `Date`-minted value carry `+00`. A `time` without a zone does not.
+ return isRealZone(
+ zone ?? "",
+ temporal === "timestamp" || hasTimeZone(column),
+ );
+};
+
+const DECIMAL_INTEGER = /^-?\d+$/;
+
+const pad = (value: number, width = 2): string =>
+ String(value).padStart(width, "0");
+
+/**
+ * A `Date` written the way Postgres writes the column it belongs to.
+ *
+ * Only reachable when a caller mints a cursor from a value it already holds
+ * rather than from a row - the paginated path selects `::text` and never sees a
+ * `Date`. Even so it goes through the same grammar as everything else, so a
+ * minted cursor and an accepted cursor can never disagree about what a value
+ * looks like.
+ */
+const canonicalFromDate = (column: PgColumn, value: Date): string => {
+ const time = value.getTime();
+ if (!Number.isFinite(time)) {
+ throw new Error(
+ `Cannot build a pagination cursor from an invalid date on "${column.name}".`,
+ );
+ }
+
+ const zone = hasTimeZone(column) ? "+00" : "";
+ const date = `${pad(value.getUTCFullYear(), 4)}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}`;
+ const clock = `${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}.${pad(value.getUTCMilliseconds(), 3)}`;
+
+ switch (temporalTypeOf(column)) {
+ case "date":
+ return date;
+ case "time":
+ return `${clock}${zone}`;
+ default:
+ return `${date} ${clock}${zone}`;
+ }
+};
+
+/**
+ * One column value, flattened into the cursor's canonical representation.
+ *
+ * Per kind, and deliberately not a generic coercion:
+ *
+ * | kind | carried as |
+ * | --------- | --------------------------------------------- |
+ * | number | a JSON number |
+ * | boolean | a JSON boolean |
+ * | string | a JSON string |
+ * | bigint | a decimal string, because JSON has no bigint |
+ * | temporal | the database's own `::text`, microseconds and all |
+ * | null | `null` |
+ *
+ * The temporal row is the one worth reading twice. A Postgres `timestamp` keeps
+ * microseconds and a JavaScript `Date` keeps milliseconds, so a value that has
+ * been through a `Date` is *strictly smaller* than the one still in the table -
+ * and comparing against it would exclude the entire millisecond it came from.
+ * Since `now()` stamps every row in one statement identically, that is not an
+ * edge case: it would end a bulk-imported collection's walk after page one. So
+ * the page query selects `column::text` and this function keeps it exactly as
+ * Postgres wrote it.
+ */
+export const cursorValueOf = (
+ column: PgColumn,
+ value: unknown,
+): PaginationCursorValue => {
+ if (value === null || value === undefined) return null;
+
+ switch (kindOf(column)) {
+ case "bigint": {
+ if (typeof value === "bigint") return value.toString();
+ if (typeof value === "number") return String(value);
+ break;
+ }
+ case "boolean": {
+ if (typeof value === "boolean") return value;
+ break;
+ }
+ case "number": {
+ if (typeof value === "number") return value;
+ break;
+ }
+ case "temporal": {
+ // Already `::text` from the database on the paginated path. A `Date` only
+ // reaches here when a caller mints from a value it is holding, and it is
+ // rewritten into the form Postgres would have written.
+ if (value instanceof Date) return canonicalFromDate(column, value);
+ if (typeof value === "string") return value;
+ break;
+ }
+ default: {
+ if (typeof value === "string") return value;
+ break;
+ }
+ }
+
+ // The value came off a row of the very column it is being minted for, so
+ // anything else is a wiring bug rather than bad input - and quietly writing
+ // `"[object Object]"` into a cursor would hide it until somebody turned a
+ // page.
+ throw new Error(
+ `Cannot build a pagination cursor from a ${typeof value} value of "${column.name}".`,
+ );
+};
+
+/**
+ * The cursor value, validated against the column it claims to describe.
+ *
+ * Validation rather than coercion, because the cursor is opaque but not signed:
+ * a client can edit it. `Boolean("false")` is `true`, `Number("")` is `0`, and
+ * `BigInt("nonsense")` throws a `SyntaxError` that would surface as a 500 - so
+ * every kind checks the shape it expects and refuses anything else with a 400.
+ *
+ * Returns the value in the form the SQL comparison needs: a real `boolean`,
+ * `number` or `bigint` for those kinds, and for a temporal column the
+ * **canonical text**, which the predicate binds with an explicit cast so
+ * Postgres parses it at full precision.
+ *
+ * A temporal value is checked for more than shape. `2026-02-30` and
+ * `2026-08-09 23:60:00` look like timestamps and are not moments, and Postgres
+ * answers a cast of either with `invalid input syntax` - a 500 produced by a
+ * query string. Both are refused here, so nothing impossible is ever bound.
+ */
+export const cursorValueForColumn = (
+ column: PgColumn,
+ value: PaginationCursorValue,
+): unknown => {
+ if (value === null) return null;
+
+ switch (kindOf(column)) {
+ case "bigint": {
+ // A decimal string, and nothing else: `BigInt("1.5")` and `BigInt("")`
+ // are a `SyntaxError` and a `0` respectively, and neither is an answer.
+ if (typeof value !== "string" || !DECIMAL_INTEGER.test(value)) {
+ throw badRequest(INVALID_CURSOR);
+ }
+
+ return BigInt(value);
+ }
+ case "boolean": {
+ if (typeof value !== "boolean") throw badRequest(INVALID_CURSOR);
+
+ return value;
+ }
+ case "number": {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ throw badRequest(INVALID_CURSOR);
+ }
+
+ return value;
+ }
+ case "temporal": {
+ const temporal = temporalTypeOf(column);
+ if (
+ typeof value !== "string" ||
+ !temporal ||
+ !isRealTemporal(column, temporal, value)
+ ) {
+ throw badRequest(INVALID_CURSOR);
+ }
+
+ // Kept as text. The predicate casts it back to the column's own type, so
+ // Postgres does the parsing - at the precision it stored.
+ return value;
+ }
+ default: {
+ if (typeof value !== "string") throw badRequest(INVALID_CURSOR);
+
+ return value;
+ }
+ }
+};
+
+/**
+ * Whether this column's value travels as canonical text plus a cast.
+ *
+ * True for every temporal type, and it decides two things at once: the page
+ * query selects `column::text` rather than the column, and the predicate binds
+ * the cursor back with an explicit cast. Both halves exist so the microseconds
+ * Postgres stored survive a round trip that JavaScript's millisecond `Date`
+ * would otherwise truncate.
+ */
+export const cursorValueIsCanonicalText = (column: PgColumn): boolean =>
+ kindOf(column) === "temporal";
+
+export const encodePaginationCursor = (cursor: PaginationCursor): string =>
+ Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
+
+/** A bare integer, which is what every cursor was before this. */
+const LEGACY_CURSOR = /^[1-9]\d{0,14}$/;
+
+const isCursorValue = (value: unknown): value is PaginationCursorValue =>
+ value === null ||
+ typeof value === "boolean" ||
+ typeof value === "number" ||
+ typeof value === "string";
+
+/**
+ * Reads a cursor, or refuses the request.
+ *
+ * Three ways this says no, and each of them is a `400` rather than a page of
+ * wrong rows:
+ *
+ * 1. **garbage** - not base64url, not JSON, or not the shape;
+ * 2. **the wrong column** - a cursor minted while the list was ordered by
+ * `updatedAt`, replayed against a list now ordered by `title`. The two
+ * describe different sequences, so the position means nothing;
+ * 3. **a legacy numeric cursor on a non-primary-key ordering** - the exact case
+ * that used to skip rows. A bare number is still accepted when the list is
+ * ordered by its identifier, because there it really is the whole tuple.
+ *
+ * The *value* is checked separately, against the column - see
+ * {@link cursorValueForColumn} - because only the caller knows which column this
+ * request is ordered by.
+ */
+export const decodePaginationCursor = (
+ raw: string,
+ { column, primaryKey }: { column: string; primaryKey: string },
+): PaginationCursor => {
+ const trimmed = raw.trim();
+ if (trimmed === "") throw badRequest(INVALID_CURSOR);
+
+ if (LEGACY_CURSOR.test(trimmed)) {
+ if (column !== primaryKey) {
+ throw badRequest(
+ `This cursor cannot be used with the "${column}" ordering. Start from the first page.`,
+ );
+ }
+ const id = Number(trimmed);
+
+ return { column, id, value: id };
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(
+ Buffer.from(trimmed, "base64url").toString("utf8"),
+ ) as unknown;
+ } catch {
+ throw badRequest(INVALID_CURSOR);
+ }
+
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ throw badRequest(INVALID_CURSOR);
+ }
+
+ const candidate = parsed as Record;
+ const id = candidate.id;
+ if (
+ typeof candidate.column !== "string" ||
+ typeof id !== "number" ||
+ !Number.isSafeInteger(id) ||
+ id <= 0 ||
+ !isCursorValue(candidate.value)
+ ) {
+ throw badRequest(INVALID_CURSOR);
+ }
+
+ if (candidate.column !== column) {
+ throw badRequest(
+ `This cursor was issued for a different ordering. Start from the first page.`,
+ );
+ }
+
+ return { column, id, value: candidate.value };
+};
diff --git a/packages/vitnode/src/api/lib/with-pagination.ts b/packages/vitnode/src/api/lib/with-pagination.ts
index 5a0964d8f..99a9823c3 100644
--- a/packages/vitnode/src/api/lib/with-pagination.ts
+++ b/packages/vitnode/src/api/lib/with-pagination.ts
@@ -8,65 +8,222 @@ import type {
import type { Context } from "hono";
import { z } from "@hono/zod-openapi";
-import { and, asc, count, desc, gt, ilike, lt, or } from "drizzle-orm";
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ gt,
+ ilike,
+ isNotNull,
+ isNull,
+ lt,
+ or,
+ sql,
+} from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
+import type { PaginationCursor } from "./pagination-cursor";
+
+import {
+ cursorValueForColumn,
+ cursorValueIsCanonicalText,
+ cursorValueOf,
+ decodePaginationCursor,
+ encodePaginationCursor,
+ isCursorSortableColumn,
+} from "./pagination-cursor";
+
+/** Nobody may ask for more than this in one page, whatever they send. */
+const MAX_PAGE_SIZE = 100;
+
+/**
+ * The column a page query carries purely so its rows can be turned into cursors.
+ *
+ * Selected by the **same statement** that returns the rows, and removed again
+ * before anything leaves this module. It exists because a cursor has to describe
+ * the position the returned row actually occupied, and the only way to be
+ * certain of that is to read the two out of one snapshot.
+ *
+ * Prefixed so it cannot collide with a column name, and stripped rather than
+ * documented, because it is pagination's business and nobody else's.
+ */
+export const PAGINATION_CURSOR_FIELD = "__cursorValue";
+
+/** What a page query must spread into its projection. */
+export type PaginationCursorSelection = Record<
+ typeof PAGINATION_CURSOR_FIELD,
+ PgColumn | SQL
+>;
+
+/**
+ * Reads `first`, `last` and `cursor`, or refuses the request with a 400.
+ *
+ * Refusing rather than repairing is the change worth noting. `first=0` used to
+ * clamp its way into a one-row page that reported `hasNextPage: true`, and
+ * `first=abc` became `NaN` and fell through to the default page size - both of
+ * them a request nobody made, answered as if they had. Every one of these is now
+ * a stable 400, and the route schema rejects most of them a step earlier.
+ */
function parsePaginationParams(params: {
query: { cursor?: string; first?: string; last?: string };
-}): { cursor?: number; first?: number; last?: number } {
- const cursor = params.query.cursor
- ? parseInt(params.query.cursor, 10)
- : undefined;
- const first = params.query.first
- ? Math.min(parseInt(params.query.first, 10), 100)
- : undefined;
- const last = params.query.last
- ? Math.min(parseInt(params.query.last, 10), 100)
- : undefined;
+}): { cursor?: string; first?: number; last?: number } {
+ const size = (raw: string | undefined, name: string): number | undefined => {
+ if (raw === undefined || raw === "") return undefined;
+
+ const parsed = Number(raw);
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
+ throw new HTTPException(400, {
+ message: `"${name}" must be a whole number greater than zero.`,
+ });
+ }
+
+ return Math.min(parsed, MAX_PAGE_SIZE);
+ };
+
+ const first = size(params.query.first, "first");
+ const last = size(params.query.last, "last");
if (first !== undefined && last !== undefined) {
- throw new Error("Cannot specify both first and last");
- }
- if (first !== undefined && first < 0) {
- throw new Error("first must be positive");
- }
- if (last !== undefined && last < 0) {
- throw new Error("last must be positive");
+ throw new HTTPException(400, {
+ message: 'Use either "first" or "last", not both.',
+ });
}
- return { cursor, first, last };
+ const cursor = params.query.cursor?.trim();
+
+ return { cursor: cursor === "" ? undefined : cursor, first, last };
}
-function getOrderFn(
+/**
+ * Which way the rows really come back.
+ *
+ * Backward pagination runs the query in reverse and flips the page afterwards,
+ * so the *effective* SQL direction is not the one the caller asked for - and
+ * the cursor predicate has to describe the effective one, or it would be reading
+ * a sequence the `ORDER BY` is not producing.
+ */
+function effectiveDirection(
isForward: boolean,
order: "asc" | "desc",
-): typeof asc | typeof desc {
- if (isForward) {
- return order === "asc" ? asc : desc;
- }
+): "asc" | "desc" {
+ if (isForward) return order;
- return order === "asc" ? desc : asc;
+ return order === "asc" ? "desc" : "asc";
}
-function buildWhereWithCursor<
- Primary extends ColumnBaseConfig<"number", string>,
->(
- baseWhere: SQL | undefined,
- cursor: number | undefined,
- isForward: boolean,
- order: "asc" | "desc",
- table: PgTable,
- primaryCursor: PgColumn,
-): SQL | undefined {
- if (!cursor) return baseWhere;
+/**
+ * `and`/`or` given at least one defined condition always produce SQL.
+ *
+ * Stated as a check rather than a non-null assertion: the assertion would be a
+ * claim about code somewhere else, and this is a claim about the two lines above
+ * it - which is the kind that stays true.
+ */
+function required(value: SQL | undefined): SQL {
+ if (!value) throw new Error("Expected a pagination condition.");
+
+ return value;
+}
+
+/**
+ * "Strictly after this position, in this direction."
+ *
+ * The whole keyset, written out. `(column, id)` is the ordered tuple, so the
+ * predicate is the tuple comparison - not a comparison of one half of it:
+ *
+ * ```sql
+ * column > :value OR (column = :value AND id > :id) -- ascending
+ * column < :value OR (column = :value AND id < :id) -- descending
+ * ```
+ *
+ * `:value` comes from the **cursor** and nowhere else. That is the invariant a
+ * cursor exists to provide: it is the position as it stood when the page was
+ * generated, so editing the row that happened to sit on the boundary must not
+ * move it. Reading the row's current value instead would mean one edit silently
+ * skips every row the ordering used to have between the old position and the
+ * new one - and deleting it would leave no position at all.
+ *
+ * The `NULL` branches are the part that is easy to get wrong. Postgres sorts
+ * `NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`, and `column > NULL` is
+ * `NULL` rather than true - so a nullable order column needs the null block
+ * named explicitly, or a page boundary landing on it would end the walk early
+ * and silently.
+ */
+function buildCursorCondition({
+ column,
+ cursor,
+ direction,
+ isPrimaryOrder,
+ primary,
+}: {
+ column: PgColumn;
+ cursor: PaginationCursor;
+ direction: "asc" | "desc";
+ isPrimaryOrder: boolean;
+ primary: PgColumn;
+}): SQL {
+ const after = direction === "asc" ? gt : lt;
+
+ // The identifier is the whole tuple when the list is ordered by it, so there
+ // is no second half to compare and no null block to worry about.
+ if (isPrimaryOrder) return after(primary, cursor.id);
+
+ const boundary = boundaryValue(column, cursor);
+
+ if (direction === "asc") {
+ // NULLS LAST: a null cursor is inside the trailing block, and everything
+ // that is not null is already behind us.
+ if (cursor.value === null) {
+ return required(and(isNull(column), gt(primary, cursor.id)));
+ }
+
+ return required(
+ or(
+ gt(column, boundary),
+ and(eq(column, boundary), gt(primary, cursor.id)),
+ isNull(column),
+ ),
+ );
+ }
+
+ // NULLS FIRST: a null cursor is inside the *leading* block, so the rest of
+ // that block comes first and every non-null row follows it.
+ if (cursor.value === null) {
+ return required(
+ or(and(isNull(column), lt(primary, cursor.id)), isNotNull(column)),
+ );
+ }
+
+ return required(
+ or(lt(column, boundary), and(eq(column, boundary), lt(primary, cursor.id))),
+ );
+}
- const cursorFilter =
- (isForward && order === "asc") || (!isForward && order === "desc")
- ? gt
- : lt;
+/**
+ * The cursor's own value, bound so Postgres compares it at full precision.
+ *
+ * Two shapes, because two kinds of value survive a round trip differently:
+ *
+ * - a **temporal** value travels as the database's own `::text` and is bound
+ * back with an explicit cast, so Postgres parses the microseconds it wrote.
+ * Binding a JavaScript `Date` here would silently truncate to milliseconds and
+ * exclude the whole millisecond the cursor came from.
+ * - **everything else** - a number, a string, a boolean, a bigint - is exact in
+ * JavaScript already, so it goes through the column's own encoder.
+ *
+ * `getSQLType()` is derived from the schema rather than from the request, which
+ * is what makes `sql.raw` safe here; the value itself is always a bound
+ * parameter.
+ */
+function boundaryValue(column: PgColumn, cursor: PaginationCursor): SQL {
+ const value = cursorValueForColumn(column, cursor.value);
- const cursorWhere = cursorFilter(table[primaryCursor.name], cursor);
+ if (cursorValueIsCanonicalText(column)) {
+ return sql`${String(value)}::${sql.raw(column.getSQLType())}`;
+ }
- return baseWhere ? and(baseWhere, cursorWhere) : cursorWhere;
+ return sql`${sql.param(value, column)}`;
}
function buildSearchWhere(
@@ -122,6 +279,14 @@ export async function withPagination<
};
primaryCursor: PgColumn;
query: (args: {
+ /**
+ * Spread this into the projection: `.select({ ...fields, ...cursorSelection })`.
+ *
+ * Not optional in practice. It is how the cursor value is read out of the
+ * same statement as the row, and a query that omits it can only be paged by
+ * a column it happens to have selected itself.
+ */
+ cursorSelection: PaginationCursorSelection;
limit: number | Placeholder;
orderBy: SQL;
where: SQL | undefined;
@@ -130,21 +295,57 @@ export async function withPagination<
table: Omit, "enableRLS">;
where?: SQL;
}): Promise<{
- edges: QueryMin[];
+ edges: Omit[];
pageInfo: {
count: number;
- endCursor: null | number;
+ /** An opaque cursor. Hand it back as `cursor`; never parse it. */
+ endCursor: null | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
- startCursor: null | number;
+ startCursor: null | string;
totalCount: number;
};
}> {
- const { cursor, first, last } = parsePaginationParams(params);
+ const { cursor: rawCursor, first, last } = parsePaginationParams(params);
const isForward = last === undefined;
- const orderFn = getOrderFn(isForward, orderByFromParams.order);
- const orderBy: SQL = orderFn(table[orderByFromParams.column.name]);
+ const direction = effectiveDirection(isForward, orderByFromParams.order);
+ const orderFn = direction === "asc" ? asc : desc;
+
+ const primary = table[primaryCursor.name];
+ const orderName = orderByFromParams.column.name;
+ const orderColumn = table[orderName] as PgColumn;
+ const isPrimaryOrder = orderName === primaryCursor.name;
+
+ // A column with no total order Postgres and JavaScript agree on cannot be
+ // paged at all, so it is refused rather than served for one page and then
+ // quietly wrong on the next.
+ if (!isCursorSortableColumn(orderColumn)) {
+ throw new HTTPException(400, {
+ message: `Results cannot be ordered by "${orderName}".`,
+ });
+ }
+
+ /**
+ * The ordered tuple, `(requested column, identifier)`.
+ *
+ * The tiebreaker is not decoration: without it the ordering is partial, so
+ * every row sharing an `updatedAt` sits wherever Postgres feels like putting
+ * it, and a page boundary landing inside a tie skips or repeats rows. With it
+ * the ordering is total - and the cursor predicate below compares the *same*
+ * tuple, which is the invariant this whole module rests on.
+ */
+ const orderBy: SQL = isPrimaryOrder
+ ? orderFn(primary)
+ : sql`${orderFn(orderColumn)}, ${orderFn(primary)}`;
+
+ const cursor =
+ rawCursor === undefined
+ ? undefined
+ : decodePaginationCursor(rawCursor, {
+ column: orderName,
+ primaryKey: primaryCursor.name,
+ });
const searchWhere = buildSearchWhere(search, params.query.search);
const baseWhere =
@@ -152,54 +353,195 @@ export async function withPagination<
? and(whereFromParams, searchWhere)
: (whereFromParams ?? searchWhere);
- const where = buildWhereWithCursor(
- baseWhere,
- cursor,
- isForward,
- orderByFromParams.order,
- table,
- primaryCursor,
- );
+ const cursorWhere = cursor
+ ? buildCursorCondition({
+ column: orderColumn,
+ cursor,
+ direction,
+ isPrimaryOrder,
+ primary,
+ })
+ : undefined;
+ const where =
+ baseWhere && cursorWhere
+ ? and(baseWhere, cursorWhere)
+ : (baseWhere ?? cursorWhere);
const totalCount = await fetchTotalCount(c, table, baseWhere);
+ /**
+ * The cursor value, projected by the page query itself.
+ *
+ * A temporal column goes through `::text` so no microsecond is lost on the way
+ * out; everything else is exact in JavaScript already and is selected as it
+ * is. Either way it rides along with the row, which is the point: a cursor
+ * minted from a *second* read would describe wherever the boundary row had got
+ * to by then, not where it was when it was chosen for this page.
+ */
+ const cursorSelection: PaginationCursorSelection = {
+ [PAGINATION_CURSOR_FIELD]: cursorValueIsCanonicalText(orderColumn)
+ ? sql`${orderColumn}::text`
+ : orderColumn,
+ };
+
const limit = (first ?? last ?? 50) + 1;
- const edges = await query({ limit, where, orderBy });
+ const edges = await query({ cursorSelection, limit, where, orderBy });
const requested = first ?? last ?? edges.length;
const hasMore = edges.length > requested;
const slicedEdges = edges.slice(0, requested);
const finalEdges = isForward ? slicedEdges : slicedEdges.reverse();
- const startCursor: null | number =
- (finalEdges[0]?.[primaryCursor.name] as number) ?? null;
- const endCursor: null | number =
- (finalEdges.at(-1)?.[primaryCursor.name] as number) ?? null;
+ const boundaries = cursorsFrom({
+ edges: finalEdges,
+ orderColumn,
+ orderName,
+ primaryName: primaryCursor.name,
+ });
return {
pageInfo: {
totalCount,
count: finalEdges.length,
- hasNextPage: isForward ? hasMore : !!cursor,
- hasPreviousPage: isForward ? !!cursor : hasMore,
- startCursor,
- endCursor,
+ // An empty page has nothing to page from, so it never advertises a
+ // neighbour it cannot hand out a cursor for.
+ hasNextPage:
+ finalEdges.length === 0 ? false : isForward ? hasMore : Boolean(cursor),
+ hasPreviousPage:
+ finalEdges.length === 0 ? false : isForward ? Boolean(cursor) : hasMore,
+ ...boundaries,
},
- edges: finalEdges,
+ edges: finalEdges.map(withoutCursorField),
};
}
+/**
+ * The row as the caller asked for it, with pagination's own column taken back.
+ *
+ * The internal value is projected for one purpose and has no business in an
+ * admin response, a public response, an OpenAPI schema, a search document or a
+ * revision snapshot - all of which are built from what this returns.
+ */
+function withoutCursorField>(
+ row: QueryMin,
+): Omit {
+ if (!(PAGINATION_CURSOR_FIELD in row)) return row;
+
+ const { [PAGINATION_CURSOR_FIELD]: _cursorValue, ...rest } = row;
+
+ return rest;
+}
+
+/**
+ * The two cursors a page hands back, read off the page itself.
+ *
+ * No query. That is the entire design: the value and the row come out of one
+ * `SELECT`, so the tuple a cursor names is the tuple that actually decided where
+ * the row sat.
+ *
+ * It used to be a second `SELECT` of the boundary rows by id, which looked
+ * harmless and was not. Between the page query and that lookup another writer
+ * can move the boundary row - so a row chosen at `(10:00, 42)` would be handed
+ * back as a cursor saying `(14:00, 42)`, and the next page would start after
+ * 14:00 and skip everything in between. A `DELETE` in the same window was worse:
+ * the lookup returned nothing, the value became `null`, and for a nullable
+ * ordering `null` is a *real* position inside the null block - so the walk
+ * jumped there and abandoned the rest of the collection. Both are gone by
+ * construction rather than by locking.
+ */
+function cursorsFrom({
+ edges,
+ orderColumn,
+ orderName,
+ primaryName,
+}: {
+ edges: readonly Record[];
+ orderColumn: PgColumn;
+ orderName: string;
+ primaryName: string;
+}): { endCursor: null | string; startCursor: null | string } {
+ const first = edges[0];
+ const last = edges.at(-1);
+ if (!first || !last) return { endCursor: null, startCursor: null };
+
+ /**
+ * Where the boundary value comes from, in order of preference.
+ *
+ * The projected field is the answer for every query built through this module.
+ * A query that omits it can still be paged by a column it selected itself -
+ * exact for a number, a string or a boolean, and from the same statement, so
+ * the invariant holds. A temporal column is the one case with no safe
+ * fallback: the row carries a `Date` that has already dropped the microseconds
+ * the next comparison needs, so minting from it would hand out a cursor that
+ * silently re-reads part of the page it came from.
+ */
+ const valueOf = (row: Record): unknown => {
+ if (PAGINATION_CURSOR_FIELD in row) return row[PAGINATION_CURSOR_FIELD];
+ if (!cursorValueIsCanonicalText(orderColumn) && orderName in row) {
+ return row[orderName];
+ }
+
+ throw new Error(
+ `The page query for "${orderName}" must spread \`cursorSelection\` into its projection, so the cursor value is read from the same statement as the row.`,
+ );
+ };
+
+ const mint = (row: Record): string =>
+ encodePaginationCursor({
+ column: orderName,
+ id: Number(row[primaryName]),
+ value: cursorValueOf(orderColumn, valueOf(row)),
+ });
+
+ return { endCursor: mint(last), startCursor: mint(first) };
+}
+
+/** A positive whole number, as a query string carries it. */
+const zodPageSize = z
+ .string()
+ .regex(/^\d+$/, "Must be a whole number.")
+ .refine(value => Number(value) >= 1, "Must be greater than zero.")
+ .refine(value => Number.isSafeInteger(Number(value)), "Too large.");
+
export const zodPaginationPageInfo = z.object({
totalCount: z.number(),
count: z.number(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
- startCursor: z.number().nullable(),
- endCursor: z.number().nullable(),
+ /**
+ * Opaque. It encodes the ordered tuple the next page continues from, so it is
+ * meaningless outside the ordering that produced it - hand it back unchanged.
+ */
+ startCursor: z.string().nullable(),
+ endCursor: z.string().nullable(),
});
-export const zodPaginationQuery = z.object({
- cursor: z.string().optional(),
- first: z.string().optional(),
- last: z.string().optional(),
-});
+/**
+ * The pagination half of a list route's query, validated at the edge.
+ *
+ * Every rule that can be stated here is stated here rather than left to the
+ * internals, so a bad page size is a 400 from the route's own contract - and
+ * appears in the OpenAPI document - instead of something the handler discovers
+ * later. `parsePaginationParams` re-checks all of it, because a service can be
+ * called directly and a plugin can build a route without this schema.
+ *
+ * The cursor is only shape-checked here: it is opaque, so "looks like a cursor"
+ * is all a request schema can honestly say. Whether it decodes, and whether it
+ * belongs to *this* ordering, is decided where the ordering is known.
+ */
+export const zodPaginationQuery = z
+ .object({
+ cursor: z
+ .string()
+ .min(1)
+ .max(512)
+ // base64url, or a legacy numeric cursor. Anything else cannot be one.
+ .regex(/^[A-Za-z0-9_-]+$/, "Invalid cursor.")
+ .optional(),
+ first: zodPageSize.optional(),
+ last: zodPageSize.optional(),
+ })
+ .refine(
+ query => query.first === undefined || query.last === undefined,
+ 'Use either "first" or "last", not both.',
+ );
diff --git a/packages/vitnode/src/api/models/search.test.ts b/packages/vitnode/src/api/models/search.test.ts
index c38cfe079..f9f9a034c 100644
--- a/packages/vitnode/src/api/models/search.test.ts
+++ b/packages/vitnode/src/api/models/search.test.ts
@@ -7,6 +7,7 @@ import { core_search_index } from "@/database/search";
import type { SearchDocument, SearchProviderApiPlugin } from "./search";
+import { PostgresSearchAdapter } from "../adapters/search/postgres";
import {
assertSearchProviderCapabilities,
normalizeSearchIndexerPage,
@@ -376,3 +377,49 @@ describe("assertSearchProviderCapabilities", () => {
).not.toThrow();
});
});
+
+/**
+ * The provider half of a search diagnostic.
+ *
+ * `SearchModel.index` writes the canonical row and *then* hands the document to
+ * the provider, so the two can disagree - and a diagnostic that cannot ask the
+ * provider would report the canonical table's health as the whole story.
+ */
+describe("provider diagnostics", () => {
+ const modelFor = (provider: SearchProviderApiPlugin) =>
+ new SearchModel({
+ get: (key: string) =>
+ key === "core" ? { search: { adapter: provider } } : undefined,
+ } as never);
+
+ it("reports the bundled Postgres provider as canonical storage", () => {
+ // Its store *is* `core_search_index`, so a diagnostic can use the canonical
+ // count rather than paying for a second one over the same rows.
+ expect(modelFor(PostgresSearchAdapter()).isCanonicalStorage()).toBe(true);
+ });
+
+ it("reports a mirroring provider as not canonical", () => {
+ expect(modelFor(createProvider()).isCanonicalStorage()).toBe(false);
+ });
+
+ it("answers null when the provider offers no count", async () => {
+ // `null` is not zero and not healthy - it means nobody looked, and the
+ // caller has to report that as unverified.
+ await expect(
+ modelFor(createProvider()).countDocuments({ itemType: "blog_post" }),
+ ).resolves.toBeNull();
+ });
+
+ it("passes the item type and language straight through", async () => {
+ const count = vi.fn().mockResolvedValue(12);
+ const model = modelFor({ ...createProvider(), count });
+
+ await expect(
+ model.countDocuments({ itemType: "blog_post", languageCode: "pl" }),
+ ).resolves.toBe(12);
+ expect(count.mock.calls[0][1]).toEqual({
+ itemType: "blog_post",
+ languageCode: "pl",
+ });
+ });
+});
diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts
index cdd0e98e6..4d3db855b 100644
--- a/packages/vitnode/src/api/models/search.ts
+++ b/packages/vitnode/src/api/models/search.ts
@@ -86,6 +86,17 @@ export interface SearchResult {
export interface SearchProviderCapabilities {
authorBoost: boolean;
+ /**
+ * Whether the provider's store **is** `core_search_index`.
+ *
+ * True only for the bundled Postgres provider, which queries the canonical
+ * table directly rather than mirroring it. Diagnostics use this to skip a
+ * second count of the same rows: canonical and provider are one storage, so
+ * asking twice would cost a query to learn something already known.
+ *
+ * A mirroring provider - anything with its own store - must leave it unset.
+ */
+ canonicalStorage?: boolean;
facets: boolean;
/**
* Whether {@link SearchProviderApiPlugin.delete} honours its `languageCode`.
@@ -285,6 +296,22 @@ export interface SearchProviderApiPlugin {
bulkIndex: (c: Context, docs: SearchDocument[]) => Promise;
capabilities?: SearchProviderCapabilities;
clear: (c: Context, itemType?: string) => Promise;
+ /**
+ * How many documents the provider holds for one collection.
+ *
+ * Optional, and its absence is meaningful: a provider that cannot be counted
+ * is reported as **unverified** rather than healthy, because "we did not look"
+ * and "we looked and it was fine" are different answers and only one of them
+ * is worth acting on.
+ *
+ * It must count rather than fetch - `_count` on Elasticsearch, `COUNT(*)` on a
+ * table - and honour `languageCode` where the provider stores one document per
+ * translation. Omitting the language means every language.
+ */
+ count?: (
+ c: Context,
+ args: { itemType: string; languageCode?: string },
+ ) => Promise;
/**
* Removes one item's documents.
*
@@ -444,6 +471,23 @@ export class SearchModel {
await this.provider().clear(this.c, itemType);
}
+ /**
+ * How many documents the **provider** holds, or `null` when it cannot say.
+ *
+ * `null` is not zero and not healthy: it means the provider offers no
+ * diagnostics, and a caller has to report that as unverified rather than
+ * turning an absence of evidence into a clean bill of health.
+ */
+ async countDocuments(args: {
+ itemType: string;
+ languageCode?: string;
+ }): Promise {
+ const provider = this.provider();
+ if (!provider.count) return null;
+
+ return await provider.count(this.c, args);
+ }
+
/**
* Removes one item from the index, in one language or in all of them.
*
@@ -484,6 +528,16 @@ export class SearchModel {
await this.provider().index(this.c, clean);
}
+ /**
+ * Whether the active provider's store is the canonical table itself.
+ *
+ * Diagnostics ask this before counting twice - see
+ * {@link SearchProviderCapabilities.canonicalStorage}.
+ */
+ isCanonicalStorage(): boolean {
+ return this.provider().capabilities?.canonicalStorage === true;
+ }
+
name(): string {
return this.provider().name;
}
diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts
index b49a25c9c..d12a9d01a 100644
--- a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts
+++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts
@@ -1,3 +1,4 @@
+import { getTableColumns } from "drizzle-orm";
import z from "zod";
import { buildRoute } from "@/api/lib/route";
@@ -56,10 +57,10 @@ export const getCronsRoute = buildRoute({
},
c,
primaryCursor: core_cron.id,
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
- .select()
+ .select({ ...getTableColumns(core_cron), ...cursorSelection })
.from(core_cron)
.where(where)
.orderBy(orderBy)
diff --git a/packages/vitnode/src/api/modules/admin/advanced/queue/routes/get.route.ts b/packages/vitnode/src/api/modules/admin/advanced/queue/routes/get.route.ts
index 40181afa9..affd8a4ae 100644
--- a/packages/vitnode/src/api/modules/admin/advanced/queue/routes/get.route.ts
+++ b/packages/vitnode/src/api/modules/admin/advanced/queue/routes/get.route.ts
@@ -1,4 +1,4 @@
-import { inArray } from "drizzle-orm";
+import { getTableColumns, inArray } from "drizzle-orm";
import z from "zod";
import { buildRoute } from "@/api/lib/route";
@@ -73,10 +73,10 @@ export const getQueueTasksRoute = buildRoute({
c,
primaryCursor: core_queue.id,
where: statuses.length ? inArray(core_queue.status, statuses) : undefined,
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
- .select()
+ .select({ ...getTableColumns(core_queue), ...cursorSelection })
.from(core_queue)
.where(where)
.orderBy(orderBy)
diff --git a/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts b/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts
index dc77d8d7d..dd9af296c 100644
--- a/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts
+++ b/packages/vitnode/src/api/modules/admin/debug/debug.admin.module.ts
@@ -1,6 +1,7 @@
import { CONFIG_PLUGIN } from "../../../../config";
import { buildModule } from "../../../lib/module";
import { clearSearchDebugAdminRoute } from "./routes/clear-search.route";
+import { contentStatusDebugAdminRoute } from "./routes/content-status.route";
import { integrationsDebugAdminRoute } from "./routes/integrations.route";
import { logsDebugAdminRoute } from "./routes/logs.route";
import { queueDebugAdminRoute } from "./routes/queue.route";
@@ -20,6 +21,7 @@ export const debugAdminModule = buildModule({
searchStatusDebugAdminRoute,
rebuildSearchDebugAdminRoute,
clearSearchDebugAdminRoute,
+ contentStatusDebugAdminRoute,
sendTestEmailDebugAdminRoute,
testAiDebugAdminRoute,
testStorageUploadDebugAdminRoute,
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/content-status.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/content-status.route.ts
new file mode 100644
index 000000000..fe7c2ae66
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/content-status.route.ts
@@ -0,0 +1,112 @@
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+import { contentEngineDiagnostics } from "@/content/server/diagnostics";
+
+const localeDriftSchema = z.object({
+ /** Documents `core_search_index` holds for this locale. */
+ canonicalIndexed: z.number(),
+ canonicalHealthy: z.boolean(),
+ /** Published rows - or published translations - the database holds. */
+ expected: z.number(),
+ /** `""` for a content type that is not localized. */
+ locale: z.string(),
+ /** `null` when the provider offers no diagnostics - unverified, not healthy. */
+ providerHealthy: z.boolean().nullable(),
+ providerIndexed: z.number().nullable(),
+});
+
+const contentTypeSchema = z.object({
+ contentTypeId: z.string(),
+ features: z.object({
+ editorial: z.boolean(),
+ localization: z.boolean(),
+ publicApi: z.boolean(),
+ publication: z.boolean(),
+ scheduling: z.boolean(),
+ search: z.boolean(),
+ }),
+ pluginId: z.string(),
+ /** `null` for a content type without `search`. */
+ search: z
+ .object({
+ canonicalHealthy: z.boolean(),
+ /** Documents `core_search_index` holds, every locale. */
+ canonicalIndexedTotal: z.number(),
+ contentTypeId: z.string(),
+ /** Published rows - or translations - the database holds, every locale. */
+ expectedTotal: z.number(),
+ /** Canonical **and** provider both agree. Unverified is not healthy. */
+ healthy: z.boolean(),
+ locales: z.array(localeDriftSchema),
+ provider: z.object({
+ /** Why the provider could not be counted, when that is the answer. */
+ error: z.string().optional(),
+ healthy: z.boolean().nullable(),
+ /**
+ * Every document the provider holds, in any locale.
+ *
+ * The guard against a document left behind in a locale the database no
+ * longer knows about, which per-locale counts can never ask for.
+ */
+ indexedTotal: z.number().nullable(),
+ name: z.string(),
+ /** Whether the provider was actually asked. */
+ verified: z.boolean(),
+ }),
+ })
+ .nullable(),
+ /** `null` for a content type without scheduling. */
+ schedules: z
+ .object({
+ /** Transitions that committed but were never announced. */
+ failedEffects: z.number(),
+ pending: z.number(),
+ withErrors: z.number(),
+ })
+ .nullable(),
+});
+
+/**
+ * What the Content Engine looks like from the outside, right now.
+ *
+ * Sits beside `/search/status` under the same `system: can_view` permission,
+ * and answers the questions that one cannot: `/search/status` reports what is
+ * *in* the index, and this reports what the **database** says should be there.
+ * A collection can be 100% covered by the first and still be missing every
+ * Polish document, because coverage is measured against the indexer's own count
+ * and drift is measured against the rows.
+ *
+ * Aggregates only - two counts per content type - so it is safe to open on an
+ * install with a large table. Nothing here mutates anything; repairing drift is
+ * `/search/rebuild`, which is a separate decision and a separate route.
+ */
+export const contentStatusDebugAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "system", permission: "can_view" },
+ route: {
+ method: "get",
+ description:
+ "Report every registered content type, its search index drift per locale, and its outstanding scheduled-effect failures.",
+ path: "/content/status",
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ contentTypes: z.array(contentTypeSchema),
+ /** No scheduled transition committed without being announced. */
+ effectsHealthy: z.boolean(),
+ /** `searchHealthy && effectsHealthy`. */
+ healthy: z.boolean(),
+ searchHealthy: z.boolean(),
+ }),
+ },
+ },
+ description: "Content Engine status",
+ },
+ },
+ },
+ handler: async c => c.json(await contentEngineDiagnostics(c)),
+});
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/logs.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/logs.route.ts
index 74266f343..0ec2eb213 100644
--- a/packages/vitnode/src/api/modules/admin/debug/routes/logs.route.ts
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/logs.route.ts
@@ -66,10 +66,11 @@ export const logsDebugAdminRoute = buildRoute({
query,
},
primaryCursor: core_logs.id,
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select({
+ ...cursorSelection,
id: core_logs.id,
pluginId: core_logs.pluginId,
type: core_logs.type,
diff --git a/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts
index 873605bf3..5ce24159c 100644
--- a/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts
+++ b/packages/vitnode/src/api/modules/admin/files/routes/list.route.ts
@@ -90,10 +90,11 @@ export const listFilesAdminRoute = buildRoute({
c,
primaryCursor: core_files.id,
search: [core_files.name],
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select({
+ ...cursorSelection,
id: core_files.id,
name: core_files.name,
key: core_files.key,
diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts
index efe9f5138..4985eba3b 100644
--- a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts
+++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts
@@ -100,10 +100,11 @@ export const listRolesAdminRoute = buildRoute({
)
: undefined,
primaryCursor: core_roles.id,
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select({
+ ...cursorSelection,
id: core_roles.id,
color: core_roles.color,
protected: core_roles.protected,
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/admins.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/admins.route.ts
index f1bfbd19f..1b5b4d786 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/admins.route.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/admins.route.ts
@@ -38,10 +38,11 @@ export const listAdminsStaffAdminRoute = buildRoute({
query,
},
primaryCursor: core_admin_permissions.id,
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select({
+ ...cursorSelection,
id: core_admin_permissions.id,
roleId: core_admin_permissions.roleId,
userId: core_admin_permissions.userId,
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/moderators.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/moderators.route.ts
index 1ac4f1eb7..b0bcb0353 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/moderators.route.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/moderators.route.ts
@@ -38,10 +38,11 @@ export const listModeratorsStaffAdminRoute = buildRoute({
query,
},
primaryCursor: core_moderators_permissions.id,
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select({
+ ...cursorSelection,
id: core_moderators_permissions.id,
roleId: core_moderators_permissions.roleId,
userId: core_moderators_permissions.userId,
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts
index bd04bd9dc..6ddc5104a 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts
@@ -90,10 +90,11 @@ export const listUsersAdminRoute = buildRoute({
search: [core_users.name, core_users.email, core_users.nameCode],
where: roleIds.length ? inArray(core_users.roleId, roleIds) : undefined,
primaryCursor: core_users.id,
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select({
+ ...cursorSelection,
id: core_users.id,
name: core_users.name,
email: core_users.email,
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/users.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/users.route.ts
index a2e6fe2cc..cb9f70af2 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/users.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/users.route.ts
@@ -59,10 +59,11 @@ export const usersAdminRoute = buildRoute({
query,
},
primaryCursor: core_users.id,
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select({
+ ...cursorSelection,
id: core_users.id,
name: core_users.name,
email: core_users.email,
diff --git a/packages/vitnode/src/api/modules/search/routes/search.route.ts b/packages/vitnode/src/api/modules/search/routes/search.route.ts
index ee4433064..0256d91ed 100644
--- a/packages/vitnode/src/api/modules/search/routes/search.route.ts
+++ b/packages/vitnode/src/api/modules/search/routes/search.route.ts
@@ -3,10 +3,24 @@ import { z } from "@hono/zod-openapi";
import { CONFIG_PLUGIN } from "@/config";
import { buildRoute } from "../../../lib/route";
-import {
- zodPaginationPageInfo,
- zodPaginationQuery,
-} from "../../../lib/with-pagination";
+import { zodPaginationQuery } from "../../../lib/with-pagination";
+
+/**
+ * The search index's own page info.
+ *
+ * Deliberately not `zodPaginationPageInfo`: that one describes a keyset walk
+ * over a table and hands out an opaque cursor for the ordered tuple. A search
+ * page is not that - a relevance-sorted one walks by offset and an ordinary one
+ * by row id - so it keeps the numeric cursors it has always had.
+ */
+const zodSearchPageInfo = z.object({
+ totalCount: z.number(),
+ count: z.number(),
+ hasNextPage: z.boolean(),
+ hasPreviousPage: z.boolean(),
+ startCursor: z.number().nullable(),
+ endCursor: z.number().nullable(),
+});
export const zodSearchHitSchema = z.object({
id: z.number(),
@@ -57,7 +71,11 @@ export const searchRoute = buildRoute({
"application/json": {
schema: z.object({
edges: z.array(zodSearchHitSchema),
- pageInfo: zodPaginationPageInfo,
+ // The search index has its own pagination - a relevance-sorted
+ // page walks by offset, and an ordinary one by row id - so it
+ // keeps the numeric cursors it has always had rather than the
+ // opaque keyset cursor `withPagination` mints for a table.
+ pageInfo: zodSearchPageInfo,
}),
},
},
diff --git a/packages/vitnode/src/api/modules/users/files/routes/list.route.ts b/packages/vitnode/src/api/modules/users/files/routes/list.route.ts
index d9a40c7d4..dce44b888 100644
--- a/packages/vitnode/src/api/modules/users/files/routes/list.route.ts
+++ b/packages/vitnode/src/api/modules/users/files/routes/list.route.ts
@@ -72,10 +72,11 @@ export const listUserFilesRoute = buildRoute({
primaryCursor: core_files.id,
search: [core_files.name],
where: eq(core_files.userId, user.id),
- query: async ({ limit, where, orderBy }) =>
+ query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get("db")
.select({
+ ...cursorSelection,
id: core_files.id,
name: core_files.name,
key: core_files.key,
diff --git a/packages/vitnode/src/components/table/content.test.tsx b/packages/vitnode/src/components/table/content.test.tsx
index 5099e6650..ce4aa3ebb 100644
--- a/packages/vitnode/src/components/table/content.test.tsx
+++ b/packages/vitnode/src/components/table/content.test.tsx
@@ -32,10 +32,11 @@ const edges: DemoUser[] = [
const pageInfo = {
count: edges.length,
- endCursor: 2,
+ // Opaque strings, as `withPagination` mints them - never row ids.
+ endCursor: "eyJjb2x1bW4iOiJpZCIsImlkIjoyLCJ2YWx1ZSI6Mn0",
hasNextPage: false,
hasPreviousPage: false,
- startCursor: 1,
+ startCursor: "eyJjb2x1bW4iOiJpZCIsImlkIjoxLCJ2YWx1ZSI6MX0",
totalCount: edges.length,
};
diff --git a/packages/vitnode/src/components/table/pagination.tsx b/packages/vitnode/src/components/table/pagination.tsx
index 8badd5ff9..461ca4e21 100644
--- a/packages/vitnode/src/components/table/pagination.tsx
+++ b/packages/vitnode/src/components/table/pagination.tsx
@@ -24,10 +24,10 @@ export const PaginationDataTable = ({
}: {
pageInfo: {
count: number;
- endCursor: null | number;
+ endCursor: null | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
- startCursor: null | number;
+ startCursor: null | string;
totalCount: number;
};
}) => {
@@ -93,7 +93,7 @@ export const PaginationDataTable = ({
const params = new URLSearchParams(searchParams.toString());
params.set("last", `${Number(pageSize)}`);
if (startCursor) {
- params.set("cursor", `${startCursor}`);
+ params.set("cursor", startCursor);
} else {
params.delete("cursor");
}
@@ -122,7 +122,7 @@ export const PaginationDataTable = ({
const params = new URLSearchParams(searchParams.toString());
params.set("first", `${Number(pageSize)}`);
if (endCursor) {
- params.set("cursor", `${endCursor}`);
+ params.set("cursor", endCursor);
} else {
params.delete("cursor");
}
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
+ // `` 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 `` 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 ``. 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 `` 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
+ * `` 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 = 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,
+ ContentDeliveryTitleField,
+ ContentDeliveryDescriptionField,
+ ContentDeliveryNoIndexField
+ >
+ | { enabled: false } = { enabled: false },
>({
admin,
+ delivery,
editorial,
fields,
id,
@@ -1427,6 +1449,13 @@ export const defineContentType = <
TPublication,
ContentEditorialEnabled
>;
+ /**
+ * 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,
ContentPreviewEnabled,
ContentSchedulingEnabled,
- ContentLocalizationEnabled
+ ContentLocalizationEnabled,
+ ContentDeliveryEnabled
> => {
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,
ContentPreviewEnabled,
ContentSchedulingEnabled,
- ContentLocalizationEnabled
+ ContentLocalizationEnabled,
+ ContentDeliveryEnabled
> = {
admin: resolvedAdmin,
advanced: resolvedAdvanced,
+ delivery: resolvedDelivery as ResolvedContentDeliveryConfig<
+ ContentDeliveryEnabled
+ >,
editorial: resolvedEditorial as ResolvedContentEditorialConfig<
ContentEditorialEnabled,
ContentPreviewEnabled,
@@ -1688,7 +1743,8 @@ export const defineContentType = <
ContentEditorialEnabled,
ContentPreviewEnabled,
ContentSchedulingEnabled,
- ContentLocalizationEnabled
+ ContentLocalizationEnabled,
+ ContentDeliveryEnabled
>
>({
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();
+ expectTypeOf(plainType.delivery.enabled).toEqualTypeOf();
+ });
+
+ // The whole Stage 8 type design rests on this: an eleventh type parameter on
+ // `ContentTypeDefinition` must not break the erased form every relation thunk,
+ // registry and route builder is written against.
+ it("stays assignable to AnyContentTypeDefinition", () => {
+ expectTypeOf().toExtend();
+ assertType(deliveredType);
+ assertType(plainType);
+ });
+
+ it("narrows to DeliverableContentTypeDefinition only with delivery", () => {
+ expectTypeOf<
+ typeof deliveredType
+ >().toExtend();
+ expectTypeOf<
+ typeof plainType
+ >().not.toExtend();
+ });
+
+ it("accepts a group leaf and a plain field in the SEO slots", () => {
+ expectTypeOf(deliveredType.delivery.seo.titleField).toEqualTypeOf<
+ null | string
+ >();
+ expectTypeOf(
+ deliveredType.delivery.sitemap.changeFrequency,
+ ).toEqualTypeOf();
+ });
+
+ it("records the slug scope", () => {
+ expectTypeOf(deliveredType.delivery.slugScope).toEqualTypeOf<
+ "localized" | "none" | "shared"
+ >();
+ });
+});
+
+describe("delivery requires a public API", () => {
+ it("refuses `enabled: true` without one", () => {
+ defineContentType({
+ admin: { label: { plural: "Articles", singular: "Article" } },
+ id: "typed.no-public",
+ // @ts-expect-error - delivery needs `publicApi: { enabled: true }`: without a
+ // public allowlist there is no canonical URL for delivery to be about.
+ delivery: { enabled: true },
+ fields,
+ publication: { enabled: true },
+ tableName: "typed_no_public",
+ });
+ });
+
+ it("still accepts an explicit `enabled: false`", () => {
+ const off = defineContentType({
+ admin: { label: { plural: "Articles", singular: "Article" } },
+ id: "typed.off",
+ delivery: { enabled: false },
+ fields,
+ publication: { enabled: true },
+ tableName: "typed_off",
+ });
+
+ expectTypeOf(off.delivery.enabled).toEqualTypeOf();
+ });
+});
+
+describe("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();
+ });
+
+ 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();
+ });
+
+ 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();
+ expectTypeOf(reads.editorial.enabled).toEqualTypeOf();
+ });
+});
+
+describe("SEO field references", () => {
+ it("refuses a field the public allowlist withholds", () => {
+ defineContentType({
+ ...shared,
+ id: "typed.private-seo",
+ delivery: {
+ enabled: true,
+ // @ts-expect-error - `internalCode` is a text field, but it is not in
+ // `publicApi.fields`, and a `` is rendered into a public page.
+ seo: { titleField: "internalCode" },
+ },
+ tableName: "typed_private_seo",
+ });
+ });
+
+ it("refuses prose in a title slot", () => {
+ defineContentType({
+ ...shared,
+ id: "typed.prose-title",
+ delivery: {
+ enabled: true,
+ // @ts-expect-error - `excerpt` is a textarea. A `` 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();
+ });
+
+ 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
+ >();
+ });
+
+ it("pins `false` for one without", () => {
+ expectTypeOf(plainType.delivery).toExtend<
+ ResolvedContentDeliveryConfig
+ >();
+ });
+});
+
+describe("Stage 1-7 backward compatibility", () => {
+ it("leaves the existing fixtures assignable and unchanged", () => {
+ assertType(testArticleContentType);
+ assertType(testPostContentType);
+ expectTypeOf(
+ testArticleContentType.delivery.enabled,
+ ).toEqualTypeOf();
+ expectTypeOf(testPostContentType.delivery.enabled).toEqualTypeOf();
+ });
+});
+
+describe("delivery events", () => {
+ it("adds both keys for a content type with redirects", () => {
+ expectTypeOf>().toHaveProperty(
+ "content.typed.delivered.delivery_slug_changed",
+ );
+ expectTypeOf>().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>().not.toHaveProperty(
+ "content.typed.plain.delivery_slug_changed",
+ );
+ expectTypeOf>().not.toHaveProperty(
+ "content.typed.plain.delivery_redirect_created",
+ );
+ });
+
+ it("keeps the ordinary events in place alongside them", () => {
+ expectTypeOf>().toHaveProperty(
+ "content.typed.delivered.updated",
+ );
+ expectTypeOf>().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 `` 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 ``,
+ * 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 = new Set(CONTENT_DELIVERY_TITLE_KINDS);
+const descriptionKinds: ReadonlySet = new Set(
+ CONTENT_DELIVERY_DESCRIPTION_KINDS,
+);
+const noIndexKinds: ReadonlySet = new Set(
+ CONTENT_DELIVERY_NO_INDEX_KINDS,
+);
+
+/** The disabled default every content type without `delivery` carries. */
+export const contentDeliveryDisabled: ResolvedContentDeliveryConfig = {
+ 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 `` 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;
+ fields: ContentFieldMap;
+ id: string;
+ kinds: ReadonlySet;
+ 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;
+ /** 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 = {};
+ 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 `` of three spaces is
+ * a missing title with extra steps - and that is exactly when the fallback should
+ * take over.
+ */
+const readSeoText = (
+ row: Record,
+ 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 `` and `` 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,
+): 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,
+): 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,
+): 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 =
>
: Record);
+/**
+ * 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 { delivery: { enabled: true } }
+ ? Record<
+ `content.${TDefinition["id"]}.delivery_redirect_created`,
+ ContentDeliveryRedirectCreatedPayload
+ > &
+ Record<
+ `content.${TDefinition["id"]}.delivery_slug_changed`,
+ ContentDeliverySlugChangedPayload
+ >
+ : Record;
+
/**
* The events a content type emits, as a literal-keyed map.
*
@@ -289,7 +361,8 @@ type ContentLocalizationEventsFor =
* payloads stay minimal.
*/
export type ContentEventsFor =
- ContentEditorialEventsFor &
+ ContentDeliveryEventsFor &
+ ContentEditorialEventsFor &
ContentLocalizationEventsFor &
ContentPublicationEventsFor &
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/cache-privacy.test.ts b/packages/vitnode/src/content/next/cache-privacy.test.ts
new file mode 100644
index 000000000..d46ff8904
--- /dev/null
+++ b/packages/vitnode/src/content/next/cache-privacy.test.ts
@@ -0,0 +1,188 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ testEditorialPostContentType,
+ testLocalizedPageContentType,
+} from "@/tests/content-fixtures";
+
+/**
+ * Which responses are allowed into a shared cache, and under which key.
+ *
+ * Three questions, and getting any of them wrong is a data leak rather than a
+ * performance problem:
+ *
+ * 1. **Is anything private cached at all?** A preview is an unpublished record
+ * behind a short-lived credential, and an AdminCP read is a staff response
+ * carrying private columns. Neither may be stored.
+ * 2. **Can a private response land on a public key?** Only tagged, cached
+ * responses can, so this reduces to (1) - but it is asserted from the tag
+ * side as well, because "no tags" is what makes it true.
+ * 3. **Do two spellings of one locale share a key?** `PL`, `pl` and ` pl `
+ * address the same page, so they have to expire together. A tag is a string
+ * comparison, so the normalisation has to happen when the tag is built.
+ */
+
+interface FetchArgs {
+ module: string;
+ options?: { cache?: string; next?: { tags?: string[] } };
+ path: string;
+ prefixPath?: string;
+ query?: Record;
+}
+
+const calls = vi.hoisted(() => [] as FetchArgs[]);
+
+vi.mock("server-only", () => ({}));
+vi.mock("next/headers", () => ({
+ cookies: async () => await Promise.resolve({ toString: () => "session=x" }),
+ headers: async () => await Promise.resolve(new Headers()),
+}));
+
+vi.mock("../../lib/fetcher/raw", () => ({
+ rawApiFetch: async (args: FetchArgs) => {
+ calls.push(args);
+
+ return await Promise.resolve(
+ new Response(JSON.stringify({ id: 7, title: "Hello" }), { status: 200 }),
+ );
+ },
+}));
+
+const { contentPublicItemTag, contentPublicListTag, contentPublicSlugTag } =
+ await import("../cache");
+const { contentPreviewFetch, contentPublicFetch } =
+ await import("./fetch.server");
+const { contentApiFetch } = await import("../admin/fetch.server");
+
+const PLUGIN_ID = "@vitnode/example";
+
+const lastCall = (): FetchArgs => {
+ const call = calls.at(-1);
+ if (!call) throw new Error("Expected a request.");
+
+ return call;
+};
+
+beforeEach(() => {
+ calls.length = 0;
+});
+
+describe("a private response is never cached and never tagged", () => {
+ it("reads a preview with no store and no tags", async () => {
+ await contentPreviewFetch({
+ definition: testEditorialPostContentType,
+ pluginId: PLUGIN_ID,
+ token: "signed-token",
+ });
+
+ const call = lastCall();
+ expect(call.options?.cache).toBe("no-store");
+ expect(call.options?.next?.tags).toBeUndefined();
+ });
+
+ it("reads a localized preview the same way", async () => {
+ await contentPreviewFetch({
+ definition: testLocalizedPageContentType,
+ locale: "pl",
+ pluginId: PLUGIN_ID,
+ token: "signed-token",
+ });
+
+ const call = lastCall();
+ expect(call.options?.cache).toBe("no-store");
+ expect(call.options?.next?.tags).toBeUndefined();
+ });
+
+ it("reads the AdminCP API with no cache options at all", async () => {
+ // An admin response carries every private column the content type has. It
+ // is also per-session: it forwards the staff cookie, so a cached one would
+ // be one editor's view served to the next.
+ await contentApiFetch({
+ definition: testEditorialPostContentType,
+ method: "get",
+ path: "/7",
+ pluginId: PLUGIN_ID,
+ });
+
+ const call = lastCall();
+ expect(call.options).toBeUndefined();
+ expect(call.prefixPath).toBe("/admin");
+ });
+
+ it("keeps the admin module out of the public tag namespace", async () => {
+ // Belt and braces: even if an admin read were cached one day, it addresses
+ // `content/{permissionModule}` under `/admin`, and the public tags are
+ // built from `publicApi.path`. The two cannot be confused for each other.
+ await contentApiFetch({
+ definition: testEditorialPostContentType,
+ method: "get",
+ pluginId: PLUGIN_ID,
+ });
+ const adminCall = lastCall();
+
+ await contentPublicFetch({
+ definition: testEditorialPostContentType,
+ pluginId: PLUGIN_ID,
+ });
+ const publicCall = lastCall();
+
+ expect(adminCall.module).not.toBe(publicCall.module);
+ expect(publicCall.options?.next?.tags).toContain(
+ contentPublicListTag(testEditorialPostContentType.id),
+ );
+ });
+});
+
+describe("a public response is cached under exactly one key per locale", () => {
+ it.each([["pl"], ["PL"], [" pl "], ["pL"]])(
+ "normalises %s onto the same tags",
+ locale => {
+ const id = testLocalizedPageContentType.id;
+
+ expect(contentPublicListTag(id, locale)).toBe(
+ contentPublicListTag(id, "pl"),
+ );
+ expect(contentPublicItemTag(id, 7, locale)).toBe(
+ contentPublicItemTag(id, 7, "pl"),
+ );
+ expect(contentPublicSlugTag(id, "witaj", locale)).toBe(
+ contentPublicSlugTag(id, "witaj", "pl"),
+ );
+ },
+ );
+
+ it("sends the same tags however the caller spelled the locale", async () => {
+ const tagsFor = async (locale: string) => {
+ await contentPublicFetch({
+ definition: testLocalizedPageContentType,
+ locale,
+ pluginId: PLUGIN_ID,
+ slug: "witaj",
+ });
+
+ return lastCall().options?.next?.tags;
+ };
+
+ expect(await tagsFor("PL")).toEqual(await tagsFor("pl"));
+ });
+
+ it("keeps two languages on one slug apart", () => {
+ // The whole reason the locale is in the slug tag: `/en/about` and
+ // `/pl/about` are two pages, and expiring one must not expire the other.
+ const id = testLocalizedPageContentType.id;
+
+ expect(contentPublicSlugTag(id, "about", "en")).not.toBe(
+ contentPublicSlugTag(id, "about", "pl"),
+ );
+ });
+
+ it("keeps a localized tag out of the locale-less namespace", () => {
+ // A localized content type has no locale-less public URL, so a tag without
+ // a locale segment would name a page that does not exist - and one that a
+ // non-localized content type might legitimately own.
+ const id = testLocalizedPageContentType.id;
+
+ expect(contentPublicListTag(id, "en")).not.toBe(contentPublicListTag(id));
+ });
+});
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 `` and an absent one not at
+ * all - and an empty `` is worse than none.
+ */
+
+const response = (
+ overrides: Partial = {},
+): 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; 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;
+ };
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 };
+ 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 & {
+ 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 ;
+ * };
+ * ```
+ *
+ * `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 => {
+ 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..64afd614c 100644
--- a/packages/vitnode/src/content/registry.test.ts
+++ b/packages/vitnode/src/content/registry.test.ts
@@ -393,3 +393,393 @@ describe("public paths", () => {
).not.toThrow();
});
});
+
+/**
+ * Names nobody wrote down.
+ *
+ * A junction or repeatable child table is generated from a field name, snake-
+ * cased and clamped to Postgres' 63-character limit - so two content types can
+ * reach the same physical table without either author ever typing it. Postgres
+ * would not complain: the second `CREATE TABLE` simply never runs, and from then
+ * on two definitions read and write one table. The same applies one level down,
+ * to the `UNIQUE (itemId, position)` constraint each of them carries.
+ */
+describe("generated database identifiers", () => {
+ const related = (
+ id: string,
+ tableName: string,
+ fields: Parameters[0]["fields"],
+ ) =>
+ defineContentType({
+ id,
+ tableName,
+ fields,
+ admin: {
+ label: { plural: "Things", singular: "Thing" },
+ permissionModule: tableName,
+ form: { fields: Object.keys(fields) },
+ },
+ });
+
+ const withRelation = (id: string, tableName: string, field_: string) =>
+ related(id, tableName, {
+ title: field.text({ required: true }),
+ [field_]: field.relation({
+ multiple: true,
+ target: () => testCategoryContentType,
+ }),
+ });
+
+ const withRepeatable = (id: string, tableName: string, field_: string) =>
+ related(id, tableName, {
+ title: field.text({ required: true }),
+ [field_]: field.repeatable({
+ fields: { question: field.text({ required: true }) },
+ }),
+ });
+
+ it("accepts advanced content types whose generated names differ", () => {
+ expect(() =>
+ validateContentTypes([
+ entry(withRelation("test.one", "test_ones", "tags")),
+ entry(withRepeatable("test.two", "test_twos", "faq")),
+ ]),
+ ).not.toThrow();
+ });
+
+ it("rejects a base table that collides with another type's junction table", () => {
+ // `test_ones` + `tags` generates `test_ones_tags`, which is exactly the base
+ // table the second content type declares. Silently sharing it would give one
+ // definition's rows two owners.
+ expect(() =>
+ validateContentTypes([
+ entry(withRelation("test.one", "test_ones", "tags"), "@vitnode/a"),
+ entry(
+ related("test.two", "test_ones_tags", {
+ title: field.text({ required: true }),
+ }),
+ "@vitnode/b",
+ ),
+ ]),
+ ).toThrow(/Table "test_ones_tags" is claimed by both/);
+ });
+
+ it("rejects a base table that collides with another type's repeatable table", () => {
+ expect(() =>
+ validateContentTypes([
+ entry(
+ related("test.two", "test_ones_faq", {
+ title: field.text({ required: true }),
+ }),
+ "@vitnode/b",
+ ),
+ entry(withRepeatable("test.one", "test_ones", "faq"), "@vitnode/a"),
+ ]),
+ ).toThrow(/Table "test_ones_faq" is claimed by both/);
+ });
+
+ it("names the field that generated the colliding table", () => {
+ // A boot error is only useful if it says which of the two to rename, and the
+ // generated side is the one that has no obvious name to look for.
+ expect(() =>
+ validateContentTypes([
+ entry(withRelation("test.one", "test_ones", "tags"), "@vitnode/a"),
+ entry(
+ related("test.two", "test_ones_tags", {
+ title: field.text({ required: true }),
+ }),
+ "@vitnode/b",
+ ),
+ ]),
+ ).toThrow(/the junction table of "tags"/);
+ });
+
+ it("rejects two content types generating the same junction table", () => {
+ // Different content types, different fields, one physical table: the
+ // second's junction is named after the first's.
+ expect(() =>
+ validateContentTypes([
+ entry(withRelation("test.one", "test_ones", "tags"), "@vitnode/a"),
+ entry(
+ withRelation("test.two", "test_ones_tags", "labels"),
+ "@vitnode/b",
+ ),
+ ]),
+ ).toThrow(/Table "test_ones_tags" is claimed by both/);
+ });
+
+ it("registers the generated position constraint in the index namespace", () => {
+ // `test_ones` + `faq` generates `test_ones_faq_position_key`. An explicit
+ // index of that name on another content type would be the same identifier
+ // in the same schema, and only one of the two would exist.
+ expect(() =>
+ validateContentTypes([
+ entry(withRepeatable("test.one", "test_ones", "faq"), "@vitnode/a"),
+ entry(
+ related("test.two", "test_twos", {
+ title: field.text({ required: true }),
+ }),
+ "@vitnode/b",
+ ),
+ ]),
+ ).not.toThrow();
+
+ expect(() =>
+ validateContentTypes([
+ entry(withRepeatable("test.one", "test_ones", "faq"), "@vitnode/a"),
+ entry(
+ defineContentType({
+ id: "test.two",
+ tableName: "test_twos",
+ fields: { title: field.text({ required: true }) },
+ indexes: [{ name: "test_ones_faq_position_key", on: ["title"] }],
+ admin: {
+ label: { plural: "Twos", singular: "Two" },
+ permissionModule: "test_twos",
+ },
+ }),
+ "@vitnode/b",
+ ),
+ ]),
+ ).toThrow(/Index name "test_ones_faq_position_key" is used by both/);
+ });
+
+ it("registers a junction's primary key and target index too", () => {
+ for (const name of [
+ "test_ones_tags_pk",
+ "test_ones_tags_related_item_id_idx",
+ ]) {
+ expect(() =>
+ validateContentTypes([
+ entry(withRelation("test.one", "test_ones", "tags"), "@vitnode/a"),
+ entry(
+ defineContentType({
+ id: "test.two",
+ tableName: "test_twos",
+ fields: { title: field.text({ required: true }) },
+ indexes: [{ name, on: ["title"] }],
+ admin: {
+ label: { plural: "Twos", singular: "Two" },
+ permissionModule: "test_twos",
+ },
+ }),
+ "@vitnode/b",
+ ),
+ ]),
+ ).toThrow(new RegExp(`Index name "${name}" is used by both`));
+ }
+ });
+
+ it("keeps two long generated table names apart by their fingerprint", () => {
+ // The clamp is what makes a collision reachable at all; the fingerprint is
+ // what makes it vanishingly unlikely. Both names fill the limit exactly and
+ // still differ, so the pair boots.
+ const base = `t_${"a".repeat(56)}`;
+ const first = withRepeatable("test.long1", `${base}_x`, "faq");
+ const second = withRepeatable("test.long2", `${base}_y`, "faq");
+
+ const [firstTable, secondTable] = [first, second].map(
+ definition => definition.advanced.repeatables[0].tableName,
+ );
+
+ expect(firstTable).toHaveLength(63);
+ expect(secondTable).toHaveLength(63);
+ expect(firstTable).not.toBe(secondTable);
+ expect(() =>
+ validateContentTypes([
+ entry(first, "@vitnode/a"),
+ entry(second, "@vitnode/b"),
+ ]),
+ ).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..05230e15b 100644
--- a/packages/vitnode/src/content/registry.ts
+++ b/packages/vitnode/src/content/registry.ts
@@ -31,6 +31,96 @@ interface IndexOwner {
entry: RegisteredContentType;
}
+/** A physical table name, and the field or content type that generated it. */
+interface TableOwner {
+ entry: RegisteredContentType;
+ /** How the name came about, for an error message somebody has to act on. */
+ origin: string;
+}
+
+const describeTableOwner = (owner: TableOwner): string =>
+ `${describe(owner.entry)} (${owner.origin})`;
+
+/**
+ * Every physical table one content type puts into the schema.
+ *
+ * The base table and the translation table are declared; a junction and a
+ * repeatable child table are **generated** from a field name, clamped to
+ * Postgres' 63-character limit. Two content types can therefore reach the same
+ * physical name without either author writing it down - which Postgres would
+ * accept, because the second `CREATE TABLE` simply never happens and both
+ * definitions then read and write one table.
+ */
+const physicalTables = (
+ entry: RegisteredContentType,
+): { name: string; origin: string }[] => {
+ const { definition } = entry;
+
+ return [
+ { name: definition.tableName, origin: "its base table" },
+ ...(definition.localization.enabled
+ ? [
+ {
+ name: definition.localization.translationTableName,
+ origin: "its translation table",
+ },
+ ]
+ : []),
+ ...definition.advanced.junctions.map(junction => ({
+ name: junction.tableName,
+ origin: `the junction table of "${junction.field}"`,
+ })),
+ ...definition.advanced.repeatables.map(repeatable => ({
+ name: repeatable.tableName,
+ origin: `the child table of "${repeatable.field}"`,
+ })),
+ ];
+};
+
+/**
+ * Every index and constraint name one content type puts into the schema.
+ *
+ * Same namespace problem as the tables, one level down: `UNIQUE (itemId,
+ * position)` on a junction is named after the junction, which is named after a
+ * field, which is clamped. Postgres keeps index and constraint names unique per
+ * schema, so two of them colliding is a migration that fails at deploy time -
+ * or, with the truncation, one that silently constrains the wrong table.
+ */
+const physicalIndexes = (
+ entry: RegisteredContentType,
+): { columns: string[]; name: string }[] => {
+ const { definition } = entry;
+
+ return [
+ ...definition.indexes.map(index => ({
+ columns: index.on,
+ name: index.name,
+ })),
+ ...definition.localization.translationIndexes.map(index => ({
+ columns: index.on,
+ name: index.name,
+ })),
+ ...definition.advanced.junctions.flatMap(junction => [
+ {
+ columns: [`${junction.field} primary key`],
+ name: junction.primaryKeyName,
+ },
+ {
+ columns: [`${junction.field} position`],
+ name: junction.positionIndexName,
+ },
+ {
+ columns: [`${junction.field} target`],
+ name: junction.relatedIndexName,
+ },
+ ]),
+ ...definition.advanced.repeatables.map(repeatable => ({
+ columns: [`${repeatable.field} position`],
+ name: repeatable.positionIndexName,
+ })),
+ ];
+};
+
/**
* Validates a set of content types coming from one or more plugins.
*
@@ -43,14 +133,30 @@ 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[],
): RegisteredContentType[] => {
const byId = new Map();
- const byTable = new Map();
+ const byTable = new Map();
const byPermission = new Map();
const byPublicPath = new Map();
+ /**
+ * 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();
const byIndexName = new Map();
for (const entry of entries) {
@@ -65,30 +171,22 @@ export const validateContentTypes = (
}
byId.set(definition.id, entry);
- const duplicateTable = byTable.get(definition.tableName);
- if (duplicateTable) {
- throw new ContentEngineError(
- `Table "${definition.tableName}" is claimed by both ${describe(duplicateTable)} and ${describe(entry)}.`,
- { contentTypeId: definition.id },
- );
- }
- byTable.set(definition.tableName, entry);
-
- // The generated translation table shares one namespace with every base
- // table, so a content type called `example_articles_translations` and a
- // localized `example_articles` would collide - and the shortening clamp
- // makes that reachable with two long names that differ only past character
- // 63. Both directions are caught by holding one map.
- if (definition.localization.enabled) {
- const translationTable = definition.localization.translationTableName;
- const duplicateTranslationTable = byTable.get(translationTable);
- if (duplicateTranslationTable) {
+ // Base, translation, junction and child tables all in one map: they share
+ // one Postgres namespace, so a content type called
+ // `example_articles_translations` and a localized `example_articles` would
+ // collide - and so would a repeatable called `tags` on `example_articles`
+ // and a content type whose own table is `example_articles_tags`. The
+ // shortening clamp makes every one of those reachable with two long names
+ // that differ only past character 63, and Postgres truncates silently.
+ for (const { name, origin } of physicalTables(entry)) {
+ const owner = byTable.get(name);
+ if (owner) {
throw new ContentEngineError(
- `Translation table "${translationTable}" is claimed by both ${describe(duplicateTranslationTable)} and ${describe(entry)}. Rename one of the base tables.`,
+ `Table "${name}" is claimed by both ${describeTableOwner(owner)} and ${describeTableOwner({ entry, origin })}. Two content types cannot share a physical table - rename one of them, or the field that generates it.`,
{ contentTypeId: definition.id },
);
}
- byTable.set(translationTable, entry);
+ byTable.set(name, { entry, origin });
}
// Permission modules are scoped per plugin, so only a collision inside one
@@ -122,23 +220,44 @@ 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
- // type. Postgres index names are unique per *schema*, though, so two
- // content types - from one plugin or from two - cannot share one either.
- for (const index of [
- ...definition.indexes,
- ...definition.localization.translationIndexes,
- ]) {
+ // type. Postgres index and constraint names are unique per *schema*,
+ // though, so two content types - from one plugin or from two - cannot share
+ // one either. The generated junction and repeatable names are in here too:
+ // they are derived from a field name and clamped, so they are exactly the
+ // ones nobody wrote down and nobody would think to check.
+ for (const index of physicalIndexes(entry)) {
const owner = byIndexName.get(index.name);
if (owner) {
throw new ContentEngineError(
- `Index name "${index.name}" is used by both ${describeIndexOwner(owner)} and ${describeIndexOwner({ columns: index.on, entry })}. Postgres index names are unique per schema, so rename one of them.`,
+ `Index name "${index.name}" is used by both ${describeIndexOwner(owner)} and ${describeIndexOwner({ columns: index.columns, entry })}. Postgres index names are unique per schema, so rename one of them.`,
{ contentTypeId: definition.id },
);
}
- byIndexName.set(index.name, { columns: index.on, entry });
+ byIndexName.set(index.name, { columns: index.columns, entry });
}
}
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[];
+ 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[];
+ };
+
+ // `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;
+}): Promise => {
+ 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;
+}): Promise