diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx
index 8d838e3ce..b0c4e390f 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
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-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."
/>
+
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/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/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/registry.test.ts b/packages/vitnode/src/content/registry.test.ts
index 541df4c8f..e7ec20307 100644
--- a/packages/vitnode/src/content/registry.test.ts
+++ b/packages/vitnode/src/content/registry.test.ts
@@ -393,3 +393,203 @@ 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();
+ });
+});
diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts
index 2c71a5723..ba49e703d 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.
*
@@ -48,7 +138,7 @@ 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();
const byIndexName = new Map();
@@ -65,30 +155,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
@@ -125,20 +207,20 @@ export const validateContentTypes = (
}
// `resolveContentIndexes` already rejects a collision inside one content
- // type. Postgres index names are unique per *schema*, though, so two
- // content types - from one plugin or from two - cannot share one either.
- for (const index of [
- ...definition.indexes,
- ...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/diagnostics.ts b/packages/vitnode/src/content/server/diagnostics.ts
new file mode 100644
index 000000000..fdc497836
--- /dev/null
+++ b/packages/vitnode/src/content/server/diagnostics.ts
@@ -0,0 +1,584 @@
+import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+
+import { and, count, eq, inArray, sql } from "drizzle-orm";
+
+import type { RegisteredContentModel } from "./model";
+
+import { core_content_schedules } from "../../database/content";
+import { core_search_index } from "../../database/search";
+import { normalizeContentLocale } from "../locale";
+import { listContentLanguages } from "./language-resolver";
+import {
+ contentTranslationPublicationColumns,
+ publicationColumns,
+ publishedCondition,
+} from "./publication";
+
+/**
+ * Operational diagnostics for the Content Engine.
+ *
+ * Deliberately small, and deliberately **not** a monitoring product. It answers
+ * three questions an operator actually asks at three in the morning, and
+ * nothing else:
+ *
+ * 1. *Is the search index telling the truth?* - the database is the source of
+ * truth, so "how many documents should there be" is a `COUNT` over published
+ * rows, and "how many are there" is a `COUNT` over `core_search_index`. A
+ * difference is drift, and drift is repaired by a rebuild.
+ * 2. *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 as `effectsError`. One count per content type
+ * turns "somewhere in the install" into "this content type".
+ * 3. *Which content types are even in play?* - what is registered, and which of
+ * the optional subsystems each one has switched on.
+ *
+ * No Prometheus, no time series, no dashboards - the install has none of those
+ * and Stage 7 does not add them. Everything here is a handful of aggregate
+ * queries, computed on demand, behind an admin permission.
+ */
+
+/** One locale's share of a content type's index, from every storage that has one. */
+export interface ContentSearchDriftLocale {
+ /**
+ * `true` when the canonical table matches the database.
+ *
+ * A count is not a checksum: two documents can be stale and still count as
+ * two. It is the cheap check that catches the failure that actually happens -
+ * a live sync that threw, or a rebuild that stopped halfway - and it costs two
+ * aggregates rather than a full comparison.
+ */
+ canonicalHealthy: boolean;
+ /** Documents the canonical `core_search_index` holds for this locale. */
+ canonicalIndexed: number;
+ /** Published rows - or published translations - the database holds. */
+ expected: number;
+ /**
+ * `""` for a content type that is not localized.
+ *
+ * The empty string is what `core_search_index` stores for language-agnostic
+ * content, so it is the honest key here rather than `null` - it is the value
+ * the row really holds.
+ */
+ locale: string;
+ /**
+ * `true` when the active provider matches, `false` when it does not, and
+ * `null` when nobody looked.
+ *
+ * The three-way answer is the point. A provider that offers no diagnostics
+ * cannot be called healthy, and calling it healthy anyway is how an
+ * Elasticsearch outage hides behind a perfectly good canonical table.
+ */
+ providerHealthy: boolean | null;
+ /** Documents the active provider holds, or `null` when it cannot say. */
+ providerIndexed: null | number;
+}
+
+/** What the active search provider could be asked, and what it answered. */
+export interface ContentSearchDriftProvider {
+ /** Why the provider could not be counted, when that is the answer. */
+ error?: string;
+ /** `null` when unverified - see {@link ContentSearchDriftLocale.providerHealthy}. */
+ healthy: boolean | null;
+ /**
+ * Every document the provider holds for this content type, in **any** locale.
+ *
+ * The guard against ghosts. Per-locale counts can only ask about locales the
+ * database still knows about, so a document left behind in a locale that has
+ * since been removed - or one whose canonical row was deleted while the
+ * provider's delete failed - is invisible to them. A total is not: it is
+ * larger than `expectedTotal`, and that is enough to say something is wrong.
+ *
+ * `null` when the provider offers no diagnostics.
+ */
+ indexedTotal: null | number;
+ name: string;
+ /**
+ * Whether the provider was actually asked.
+ *
+ * `false` means it offers no `count`, so nothing about its contents is known.
+ * `true` with `healthy: false` means it was asked and it disagreed - or it
+ * threw, and `error` says so.
+ */
+ verified: boolean;
+}
+
+export interface ContentSearchDrift {
+ /** `true` when `core_search_index` matches the database, per locale and in total. */
+ canonicalHealthy: boolean;
+ /** Documents `core_search_index` holds for this content type, all locales. */
+ canonicalIndexedTotal: number;
+ contentTypeId: string;
+ /** Published rows - or published translations - the database holds, all locales. */
+ expectedTotal: number;
+ /**
+ * `true` only when the canonical table **and** the active provider both agree,
+ * per locale and in total.
+ *
+ * An unverified provider is not healthy: absence of evidence is reported as
+ * absence of evidence, and the operator decides what to do about it.
+ */
+ healthy: boolean;
+ locales: ContentSearchDriftLocale[];
+ provider: ContentSearchDriftProvider;
+}
+
+/**
+ * Compares what the database says should be indexed against what is - in the
+ * canonical table **and** in the active search provider.
+ *
+ * The two are not the same question, and conflating them was the first gap this
+ * closes. `SearchModel.index` writes `core_search_index` and then hands the
+ * document to the provider; an Elasticsearch that refuses the second half leaves
+ * a canonical table that is perfectly correct and a search box that is missing
+ * results.
+ *
+ * The second gap is the opposite direction, and it needs a different instrument.
+ * Deletion runs canonical-first: `SearchModel.delete` removes the row and then
+ * asks the provider. If the provider's half fails, the document survives in a
+ * locale that no longer appears in either the database or the canonical table -
+ * so per-locale enumeration, which is built from those two, can never ask about
+ * it. Hence `indexedTotal`: one unfiltered count that no amount of missing
+ * enumeration can hide from.
+ *
+ * Costs: two aggregates for the canonical side, whatever the collection's size,
+ * plus one provider total and one provider count per locale. The bundled
+ * Postgres provider is skipped entirely - its store *is* the canonical table, so
+ * asking twice would buy nothing.
+ */
+export const contentSearchDrift = async (
+ c: Context,
+ { model }: Pick,
+): Promise => {
+ const { definition } = model;
+ const contentTypeId = definition.id;
+
+ const indexedRows = await c
+ .get("db")
+ .select({
+ documents: count(),
+ languageCode: core_search_index.languageCode,
+ })
+ .from(core_search_index)
+ .where(eq(core_search_index.itemType, contentTypeId))
+ .groupBy(core_search_index.languageCode);
+
+ const indexedByLocale = new Map(
+ indexedRows.map(row => [
+ normalizeContentLocale(row.languageCode),
+ row.documents,
+ ]),
+ );
+
+ const expectedByLocale = await expectedDocuments(c, model);
+
+ const keys = [
+ ...new Set([...expectedByLocale.keys(), ...indexedByLocale.keys()]),
+ ].sort();
+
+ const sum = (values: Iterable): number => {
+ let total = 0;
+ for (const value of values) total += value;
+
+ return total;
+ };
+ const expectedTotal = sum(expectedByLocale.values());
+ const canonicalIndexedTotal = sum(indexedByLocale.values());
+
+ const provider = await providerCounts(c, {
+ canonicalTotal: canonicalIndexedTotal,
+ contentTypeId,
+ locales: keys,
+ });
+
+ const locales: ContentSearchDriftLocale[] = keys.map(locale => {
+ const expected = expectedByLocale.get(locale) ?? 0;
+ const canonicalIndexed = indexedByLocale.get(locale) ?? 0;
+ // The canonical count stands in for the provider only when they are one
+ // storage. Everywhere else an absent count means unverified, never healthy.
+ const providerIndexed = provider.canonical
+ ? canonicalIndexed
+ : (provider.byLocale.get(locale) ?? null);
+
+ return {
+ canonicalHealthy: expected === canonicalIndexed,
+ canonicalIndexed,
+ expected,
+ locale,
+ providerHealthy:
+ providerIndexed === null ? null : providerIndexed === expected,
+ providerIndexed,
+ };
+ });
+
+ // The total is part of *both* verdicts, not decoration on the provider one:
+ // a canonical row in a locale nothing expects is a ghost too, and the grouped
+ // query above already sees every locale the table holds.
+ const canonicalHealthy =
+ canonicalIndexedTotal === expectedTotal &&
+ locales.every(entry => entry.canonicalHealthy);
+
+ const providerHealthy = provider.verified
+ ? provider.error === undefined &&
+ provider.total === expectedTotal &&
+ locales.every(entry => entry.providerHealthy === true)
+ : null;
+
+ return {
+ canonicalHealthy,
+ canonicalIndexedTotal,
+ contentTypeId,
+ expectedTotal,
+ healthy: canonicalHealthy && providerHealthy === true,
+ locales,
+ provider: {
+ ...(provider.error === undefined ? {} : { error: provider.error }),
+ healthy: providerHealthy,
+ indexedTotal: provider.total,
+ name: provider.name,
+ verified: provider.verified,
+ },
+ };
+};
+
+/**
+ * Asks the active provider how many documents it holds - in total, and per
+ * locale.
+ *
+ * The total goes first and is asked **unconditionally**, including for a content
+ * type with no rows and no canonical documents at all. That is the case the
+ * per-locale loop cannot cover: with nothing to enumerate, `[].every(...)` is
+ * `true`, and a ghost document would sail through as healthy.
+ *
+ * Three outcomes:
+ *
+ * - **canonical storage** - the bundled Postgres provider. Verified, and the
+ * canonical counts are its counts; no query is issued.
+ * - **no `count`** - a provider that offers no diagnostics. Unverified, which is
+ * reported as such rather than turned into `healthy: true`.
+ * - **it threw** - Elasticsearch is down. Verified and unhealthy, with the
+ * reason attached, and the status route still answers: a diagnostic that
+ * crashes when the thing it diagnoses is broken is a diagnostic nobody can
+ * use. A failure in either call is handled the same way.
+ */
+const providerCounts = async (
+ c: Context,
+ {
+ canonicalTotal,
+ contentTypeId,
+ locales,
+ }: { canonicalTotal: number; contentTypeId: string; locales: string[] },
+): Promise<{
+ byLocale: Map;
+ canonical: boolean;
+ error?: string;
+ name: string;
+ total: null | number;
+ verified: boolean;
+}> => {
+ const search = c.get("search");
+ const name = search.name();
+
+ if (search.isCanonicalStorage()) {
+ return {
+ byLocale: new Map(),
+ canonical: true,
+ name,
+ total: canonicalTotal,
+ verified: true,
+ };
+ }
+
+ const unverified = {
+ byLocale: new Map(),
+ canonical: false,
+ name,
+ total: null,
+ verified: false,
+ };
+
+ const byLocale = new Map();
+ try {
+ // Every locale, including ones nothing here knows the name of.
+ const total = await search.countDocuments({ itemType: contentTypeId });
+ if (total === null) return unverified;
+
+ for (const locale of locales) {
+ const counted = await search.countDocuments({
+ itemType: contentTypeId,
+ // The empty locale is what a language-agnostic document is stored
+ // under, and it is a real value to filter on rather than "any".
+ languageCode: locale,
+ });
+ if (counted === null) return unverified;
+ byLocale.set(locale, counted);
+ }
+
+ return { byLocale, canonical: false, name, total, verified: true };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "unknown error";
+ await logDiagnosticFailure(
+ c,
+ `${CONTENT_DIAGNOSTICS_LOG_PREFIX} ${JSON.stringify({
+ contentTypeId,
+ error: message,
+ provider: name,
+ })}`,
+ );
+
+ return {
+ byLocale: new Map(),
+ canonical: false,
+ error: message,
+ name,
+ total: null,
+ verified: true,
+ };
+ }
+};
+
+/** The prefix a provider diagnostic failure is logged behind. */
+export const CONTENT_DIAGNOSTICS_LOG_PREFIX = "[content-diagnostics]";
+
+/** Logging is best effort: it writes to the database, so it can fail too. */
+const logDiagnosticFailure = async (
+ c: Context,
+ message: string,
+): Promise => {
+ try {
+ await c.get("log").error(message);
+ } catch {
+ // eslint-disable-next-line no-console
+ console.error(`[VitNode] ${message}`);
+ }
+};
+
+/**
+ * How many documents this content type's rows *should* produce, per locale.
+ *
+ * `publication` is what makes a row indexable at all, so a content type without
+ * it has nothing to expect and returns an empty map rather than counting every
+ * draft - the indexer would not have written them either.
+ */
+const expectedDocuments = async (
+ c: Context,
+ model: RegisteredContentModel["model"],
+): Promise