diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml
index a9503bc88..99dcc6dea 100644
--- a/.github/workflows/build-lint-test.yml
+++ b/.github/workflows/build-lint-test.yml
@@ -2,11 +2,11 @@ name: Build, Lint & Test
on:
pull_request:
- branches: "*"
types:
- opened
- edited
- synchronize
+ - reopened
jobs:
build:
@@ -47,3 +47,6 @@ jobs:
- name: Run tests
run: pnpm test
+
+ - name: Run type tests
+ run: pnpm test:types
diff --git a/AGENTS.md b/AGENTS.md
index fdf412912..a1303e51c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@ You are VitNode, an expert AI coding assistant. Follow repository conventions an
# React / Next.js
-- Arrow functions for components — never `React.FC`.
+- Arrow functions for components - never `React.FC`.
- No `any`; use `unknown` as rarely as possible.
- Use `AutoForm` for forms instead of hand-built form components.
- `React.lazy` + `Suspense` for content-heavy dialogs (e.g. dialogs in forms).
@@ -25,16 +25,16 @@ import { Activity } from "react";
### Caching APIs
-- `revalidateTag(tag, profile)` — profile is required for SWR: `revalidateTag("blog-posts", "max")` (prefer `'max'`; also `'days'`, `'hours'`) or inline `{ revalidate: 3600 }`.
-- `updateTag(\`user-${userId}\`)` — Server Actions only, read-your-writes semantics.
-- `refresh()` — Server Actions only, refreshes uncached data, never touches the cache.
+- `revalidateTag(tag, profile)` - profile is required for SWR: `revalidateTag("blog-posts", "max")` (prefer `'max'`; also `'days'`, `'hours'`) or inline `{ revalidate: 3600 }`.
+- `updateTag(\`user-${userId}\`)` - Server Actions only, read-your-writes semantics.
+- `refresh()` - Server Actions only, refreshes uncached data, never touches the cache.
# Coding Guidelines
- Always implement best practices for performance, security, and accessibility.
- Semantic HTML (`main`, `header`) with correct ARIA roles/attributes, `sr-only` for screen-reader-only text, and alt text on all images unless decorative or repetitive.
- Emit events for important actions (create, update, delete) so other components can react; use events instead of prop drilling or context. Document them in `apps/docs/content/docs/dev/events/built-in-events.mdx`.
-- AI features use the Vercel AI SDK only — resolve models via the `c.get("ai")` registry and call native SDK functions.
+- AI features use the Vercel AI SDK only - resolve models via the `c.get("ai")` registry and call native SDK functions.
# Design
@@ -43,7 +43,7 @@ import { Activity } from "react";
- Exactly 3–5 colors total. Never use purple or violet prominently.
- If you override a background color, you MUST override its text color for contrast.
- Prefer semantic design tokens (`bg-background`, `text-foreground`).
-- Use the Tailwind spacing scale (`p-4`, `mx-2`) — never arbitrary values (`p-[16px]`).
+- Use the Tailwind spacing scale (`p-4`, `mx-2`) - never arbitrary values (`p-[16px]`).
- Use `gap-*` classes for spacing. Never use `space-*`, and never mix margin/padding with gap on the same element.
- Use semantic (`items-center`, `justify-between`) and responsive (`md:grid-cols-2`) classes.
- No floats or absolute positioning unless absolutely necessary.
@@ -53,7 +53,7 @@ import { Activity } from "react";
# Documentation
- Document every new feature. Keep it simple, SEO-friendly, and understandable at any skill level.
-- Friendly and lightly funny tone — don't overdo it.
+- Friendly and lightly funny tone - don't overdo it.
- Skip big comments; code is self-documenting.
- Request images with a comment: `// Image prompt: {here_prompt_to_generate_image}`
- Put install commands in tabbed code blocks with correct syntax highlighting:
@@ -81,5 +81,8 @@ npm i x
# Testing
- Admin login: `test@test.com` / `Test123!`
-- Write and run vitest unit tests for all new features and bug fixes — skip only if vitest isn't configured.
-- Don't write tests for trivial code unless they have complex logic or edge cases.
+- Write and run vitest unit tests for all new features and bug fixes - skip only if vitest isn't configured.
+- Don't write tests:
+ - for trivial code unless they have complex logic or edge cases
+ - for tests where it uses a database or external API
+ - how UI should be rendered (use playwright for that to write e2e tests)
diff --git a/apps/api/package.json b/apps/api/package.json
index 291fc14ac..52db9277d 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -43,6 +43,7 @@
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitnode/blog": "workspace:*",
+ "@vitnode/example": "workspace:*",
"@vitnode/config": "workspace:*",
"@vitnode/nodemailer": "workspace:*",
"dotenv": "^17.4.2",
diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts
index 8667b7cc2..b9ed1119f 100644
--- a/apps/api/src/vitnode.api.config.ts
+++ b/apps/api/src/vitnode.api.config.ts
@@ -2,6 +2,7 @@ import { google } from "@ai-sdk/google";
import { blogApiPlugin } from "@vitnode/blog/config.api";
// import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local";
import { buildApiConfig } from "@vitnode/core/vitnode.config";
+import { exampleApiPlugin } from "@vitnode/example/config.api";
import { NodeCronAdapter } from "@vitnode/node-cron";
import { NodemailerEmailAdapter } from "@vitnode/nodemailer";
// import { S3StorageAdapter } from "@vitnode/s3";
@@ -17,7 +18,7 @@ export const POSTGRES_URL =
process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode";
export const vitNodeApiConfig = buildApiConfig({
- plugins: [blogApiPlugin()],
+ plugins: [blogApiPlugin(), exampleApiPlugin()],
ai: {
models: [
{
diff --git a/apps/docs/content/docs/dev/advanced/queue.mdx b/apps/docs/content/docs/dev/advanced/queue.mdx
index 6ac581513..dbb5ca90c 100644
--- a/apps/docs/content/docs/dev/advanced/queue.mdx
+++ b/apps/docs/content/docs/dev/advanced/queue.mdx
@@ -145,9 +145,86 @@ import { TypeTable } from "fumadocs-ui/components/type-table";
type: "Date",
default: "now",
},
+ pluginId: {
+ description:
+ "Who owns the handler, when that is not the plugin handling the request. The worker resolves handlers by `${pluginId}:${name}`, so dispatching a core task from a plugin route needs this - otherwise nothing ever claims the row.",
+ type: "string",
+ default: "the requesting plugin, or @vitnode/core",
+ },
+ tx: {
+ description:
+ "Join an existing transaction instead of using the request handle. Needed whenever the row the task refers to is written in the same unit of work, or the queue row can commit while that row rolls back.",
+ type: "Transaction",
+ },
}}
/>
+
+ The [Content Engine](/docs/dev/content-engine/publication-and-editorial#4-scheduled-publishing) books a schedule row
+ and its queue task in one transaction, from a plugin's route, against a task
+ core registers. That needs `tx` for atomicity and `pluginId` so the worker can
+ find the handler - and it is a shape any plugin can reuse.
+
+
+## Tasks core ships
+
+| Task | Attempts | What it does |
+| --- | --- | --- |
+| `send-email` | 3 | Delivers a rendered email through the configured provider |
+| `rebuild-search-index` | 3 | Clears and rebuilds the [search](/docs/dev/content-engine/public-api-and-caching#2-full-text-search-indexing) index, whole or per collection |
+| `content-schedule` | 3 | Runs one [scheduled publication](/docs/dev/content-engine/publication-and-editorial#4-scheduled-publishing). A cancelled, rescheduled or already-executed schedule is a no-op |
+| `content-schedule-effects` | 5 | Emits the event, syncs search and expires the cache for a scheduled transition that has **already committed**. Never republishes |
+
+`content-schedule` is worth reading as a pattern: its payload is
+`{ scheduleId, generation }` and nothing else. Every real value is re-read from
+the row under `FOR UPDATE`, so a task left over from a plan that has since
+changed finds a mismatch and quietly does nothing - which is far more reliable
+than trying to delete a queued row. It holds that lock from the claim all the
+way to the commit, so a cancel arriving mid-flight waits and then honestly
+reports that the schedule already ran.
+
+### Why the effects are a second task
+
+The pair is worth reading as a pattern too, because splitting them is the whole
+point:
+
+```text
+content-schedule claim → publish → revision → settle → enqueue effects
+ ── one transaction ──────────────────────────────────
+content-schedule-effects event → search → cache bridge
+```
+
+A transition is a database write that either committed or did not. Announcing it
+is three calls to systems a transaction cannot reach. Retrying them **together**
+would re-run a publish that is idempotent - so the second run finds nothing
+changed and skips the announcements entirely, which is how a scheduled unpublish
+ends up permanently serving a page it should have expired.
+
+The effects row is written inside the transition's transaction, so it exists if
+and only if the transition committed, and it carries everything frozen rather
+than re-reading a record that may have moved on.
+
+All three have to land for the task to succeed - a listener that threw, a search
+engine that refused the write, or any configured web origin that did not accept
+its invalidation each fail the run, and the reasons are combined into one
+`effectsError`. `EventsModel.emit()` never throws, so the task reads
+`EventEmitResult.failures` rather than waiting for an exception that is not
+coming.
+
+Delivery is at-least-once: the search write and the cache expiry are idempotent,
+but a listener can see one `published` twice, so a listener that must act once
+keys off the `scheduleId` in the payload.
+
+The event's envelope is stamped with the plugin that owns the **content type**,
+not with core. Core owns the handler; `content.example.article.published`
+belongs to the example plugin however it was triggered.
+
+
+ The schedule stays `completed` and the reason lands in `effectsError` on the
+ schedule row. Nothing is ever moved back to `pending` because an event
+ bounced - the record really did publish.
+
+
## Retries and backoff
If a handler throws, the task is retried with an exponential backoff
diff --git a/apps/docs/content/docs/dev/advanced/redis.mdx b/apps/docs/content/docs/dev/advanced/redis.mdx
index a2887abb4..181bdd053 100644
--- a/apps/docs/content/docs/dev/advanced/redis.mdx
+++ b/apps/docs/content/docs/dev/advanced/redis.mdx
@@ -88,8 +88,8 @@ export const vitNodeApiConfig = buildApiConfig({
### Options
-The `redis` object accepts a `url` plus any
-[ioredis](https://github.com/redis/ioredis) `RedisOptions`.
+The `redis` object is a
+[node-redis](https://github.com/redis/node-redis) `RedisClientOptions`.
import { TypeTable } from "fumadocs-ui/components/type-table";
@@ -97,17 +97,34 @@ import { TypeTable } from "fumadocs-ui/components/type-table";
type={{
url: {
description:
- "The Redis connection string (redis:// or rediss://). When omitted, ioredis connects to localhost:6379 using the other options.",
+ "The Redis connection string (redis:// or rediss://). When omitted, node-redis connects to localhost:6379 using the other options.",
type: "string",
},
- "...RedisOptions": {
+ password: {
description:
- "Any ioredis option, e.g. host, port, password, db, tls. enableOfflineQueue defaults to false so cache commands fail fast (and fall through) when Redis is unreachable - override it if you prefer queuing.",
- type: "RedisOptions",
+ "Password used to authenticate. Pair it with username when your Redis uses ACL users.",
+ type: "string",
+ },
+ socket: {
+ description:
+ "Connection-level options: host, port, tls, connectTimeout, reconnectStrategy.",
+ type: "RedisSocketOptions",
+ },
+ "...RedisClientOptions": {
+ description:
+ "Any other node-redis option, e.g. database or name. disableOfflineQueue defaults to true so cache commands fail fast (and fall through) when Redis is unreachable - override it if you prefer queuing.",
+ type: "RedisClientOptions",
},
}}
/>
+
+ VitNode opens the connection at boot without blocking startup, and node-redis
+ keeps retrying with an exponential backoff if Redis is unreachable. Requests
+ that land before the socket is ready behave exactly like a Redis outage: the
+ cache reports a miss and the request goes to the database.
+
+
## Using the cache
Access the cache in any route or model with `c.get("cache")`.
diff --git a/apps/docs/content/docs/dev/ai/index.mdx b/apps/docs/content/docs/dev/ai/index.mdx
index b78ff5b69..de5470902 100644
--- a/apps/docs/content/docs/dev/ai/index.mdx
+++ b/apps/docs/content/docs/dev/ai/index.mdx
@@ -7,7 +7,7 @@ description: Easily configure and resolve AI models in VitNode using the native
VitNode brings AI powers straight into your backend via the built-in [Vercel AI SDK](https://ai-sdk.dev/). Configure your models once in `buildApiConfig`, grab them in any route handler using `c.get("ai")`, and call native AI SDK functions directly. Zero wrappers, zero hassle! 🚀
-AI is completely **optional** — if you don't configure any models, VitNode won't touch any external services.
+AI is completely **optional** - if you don't configure any models, VitNode won't touch any external services.
## Quick Setup
@@ -59,11 +59,23 @@ Define your model registry in `buildApiConfig`. The **first model** listed acts
export const vitNodeApiConfig = buildApiConfig({
ai: {
models: [
- { id: "default", name: "Claude Sonnet 5", model: "anthropic/claude-sonnet-5" },
- { id: "fast", name: "Claude Haiku 4.5", model: "anthropic/claude-haiku-4.5" },
+ {
+ id: "default",
+ name: "Claude Sonnet 5",
+ model: "anthropic/claude-sonnet-5",
+ },
+ {
+ id: "fast",
+ name: "Claude Haiku 4.5",
+ model: "anthropic/claude-haiku-4.5",
+ },
],
embeddingModels: [
- { id: "default", name: "Text Embedding 3 Small", model: "openai/text-embedding-3-small" },
+ {
+ id: "default",
+ name: "Text Embedding 3 Small",
+ model: "openai/text-embedding-3-small",
+ },
],
imageModels: [
{ id: "default", name: "GPT Image 1", model: "openai/gpt-image-1" },
@@ -78,8 +90,16 @@ import { anthropic } from "@ai-sdk/anthropic";
export const vitNodeApiConfig = buildApiConfig({
ai: {
models: [
- { id: "default", name: "Claude Sonnet 5", model: anthropic("claude-sonnet-5") },
- { id: "fast", name: "Claude Haiku 4.5", model: anthropic("claude-haiku-4.5") },
+ {
+ id: "default",
+ name: "Claude Sonnet 5",
+ model: anthropic("claude-sonnet-5"),
+ },
+ {
+ id: "fast",
+ name: "Claude Haiku 4.5",
+ model: anthropic("claude-haiku-4.5"),
+ },
],
},
});
@@ -115,4 +135,3 @@ import { TypeTable } from "fumadocs-ui/components/type-table";
/>
Ready to generate text or create embeddings? Check out the [Usage](/docs/dev/ai/usage) guide!
-
diff --git a/apps/docs/content/docs/dev/content-engine/admincp.mdx b/apps/docs/content/docs/dev/content-engine/admincp.mdx
new file mode 100644
index 000000000..e8702a1a8
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/admincp.mdx
@@ -0,0 +1,335 @@
+---
+title: AdminCP Integration
+description: Step-by-step guide to zero-code AdminCP management screens, custom form layouts, AutoForm dialogs, and screen overrides.
+icon: Layout
+---
+
+Content Engine automatically builds interactive management screens in the VitNode AdminCP without requiring you to write Next.js page components or custom form components.
+
+## Prerequisites & Context
+
+AdminCP UI generation requires registering your content definition in `src/config.tsx` using `contentTypeAdmin`:
+
+```tsx title="src/config.tsx"
+import { buildPlugin, contentTypeAdmin } from "@vitnode/core/lib/plugin";
+import { NotebookPenIcon } from "lucide-react";
+import { articleContentType } from "@/content/article";
+
+export const examplePlugin = () =>
+ buildPlugin({
+ pluginId: "@vitnode/example",
+ messages,
+ contentTypes: [
+ contentTypeAdmin({
+ definition: articleContentType,
+ icon: ,
+ }),
+ ],
+ });
+```
+
+- **Zero-Code Navigation**: Generates `/admin/content/{admin.path}` automatically,
+ defaulting to the id with its dots as slashes (`example.article` →
+ `example/article`).
+- **i18n Localization**: The screen title, the record's own noun, table headers, form
+ labels, enum values and form section headings all resolve from your plugin's locale
+ JSON (`src/locales/en.json`). Every key is optional, and every one has a readable
+ fallback - so a screen works before it is translated, and no string in it is stuck
+ in English afterwards.
+
+---
+
+## Step-by-Step Customization
+
+
+
+
+### Step 1: Name the Record & Configure the List
+
+In `src/content/article.ts`, define the record's name, columns, search, and sorting
+behavior:
+
+```ts title="src/content/article.ts"
+export const articleContentType = defineContentType({
+ id: "example.article",
+ tableName: "example_articles",
+ fields: {
+ title: field.text({ required: true }),
+ code: field.text({ required: true }),
+ status: field.enum({ values: ["draft", "published"], defaultValue: "draft" }),
+ author: field.user(),
+ },
+ admin: { // [!code ++]
+ titleField: "title", // [!code ++]
+ list: { // [!code ++]
+ columns: ["title", "code", "status", "author", "updatedAt"], // [!code ++]
+ searchableFields: ["title", "code"], // [!code ++]
+ orderableFields: ["title", "code", "status"], // [!code ++]
+ defaultOrderBy: "updatedAt", // [!code ++]
+ defaultOrder: "desc", // [!code ++]
+ }, // [!code ++]
+ }, // [!code ++]
+});
+```
+
+#### The record's noun is a translation, and only a translation
+
+There is **no display name in the definition**. A name written there would be one
+language's answer, in the one file that is read in every language - so the noun lives
+in your messages, written as an [ICU cardinal
+plural](https://next-intl.dev/docs/usage/translations#cardinal-pluralization):
+
+```json title="src/locales/en.json"
+{
+ "@vitnode/example": {
+ "content": {
+ "article": {
+ "label": "{count, plural, one {Article} other {Articles}}", // [!code ++]
+ "title": "Articles",
+ "desc": "Write and manage articles."
+ }
+ }
+ }
+}
+```
+
+The AdminCP asks this message for the form it needs - the singular for
+`Create {name}`, `{name} has been updated` and every confirmation dialog, the
+plural for `Back to {name}` and the screen heading.
+
+
+ A `{ singular, plural }` pair can only hold two forms, and plenty of languages
+ have more. Polish needs three for one noun, and ICU is what knows which one a
+ number selects:
+
+ ```json title="src/locales/pl.json"
+ {
+ "label": "{count, plural, one {Artykuł} few {Artykuły} many {Artykułów} other {Artykułu}}"
+ }
+ ```
+
+ `one` → *Artykuł*, `few` → *Artykuły*. A pair of strings in the definition could
+ not express that, and no amount of translating the frames around it would fix a
+ Polish screen reading "Utwórz Article".
+
+
+| Key | Falls back to |
+| --- | --- |
+| `content.{entity}.label` | a name derived from the id ("Article"), in every number |
+| `content.{entity}.title` | the translated plural, then that derived name |
+| `content.{entity}.desc` | nothing - the screen simply has no lead-in |
+| `content.{entity}.fields.{field}` | the humanised field name ("Published at") |
+| `content.{entity}.enums.{field}.{value}` | the humanised value |
+
+`{entity}` is the content type id without its plugin segment: `example.article` →
+`article`, `example.kb.article` → `kb_article`.
+
+#### The URL says what the screen says
+
+The screens live at `/admin/content/{admin.path}`, and the default path is the id
+with its dots as slashes. That is right until the id and the screen disagree - a
+content type called `blog.post` whose every heading reads "Articles" should not be
+opened at an address that says `post`. `admin.path` is where the URL is written
+down:
+
+```ts title="src/content/post.ts"
+export const blogPostContentType = defineContentType({
+ id: "blog.post",
+ tableName: "blog_posts",
+ admin: {
+ path: "blog/articles", // [!code ++]
+ },
+});
+```
+
+```text
+/admin/content/blog/articles list
+/admin/content/blog/articles/create create
+/admin/content/blog/articles/42/edit edit
+```
+
+The id does **not** move with it, and that is the point: `blog.post` is the event
+name, the permission key and the message key - contracts written into roles, into
+listeners and into every locale file - while the path is the only part of a content
+type a person ever types. Renaming one to improve the other is churn with no payer.
+
+| Rule | Why |
+| --- | --- |
+| Lowercase segments of letters, digits and dashes, split by `/` | It is a URL, and a `[...slug]` route matches it segment by segment |
+| Cannot end in `create` or `edit` | Those two words are what tells a list screen from a generated form page |
+| Site-wide unique | `/admin/content/{path}` carries no plugin id, so two content types claiming one path would leave one of them with no screen at all. The app refuses to boot instead |
+
+
+ Changing `admin.path` on a content type that has shipped breaks every link to it -
+ there is no redirect, because the old path is simply not registered any more. If
+ the old address is worth keeping, a one-line page that redirects to
+ `contentAdminHref(definition)` is what the blog plugin does with
+ `/admin/blog/posts`.
+
+
+
+
+### Step 2: Organize Form Fields into Sections
+
+`admin.form.sections` groups the generated form - dialog or page - into titled cards.
+A section carries a **`name`**, not a heading: the heading is a translation, so the
+same form reads correctly in every language the AdminCP is installed in.
+
+```ts title="src/content/article.ts"
+export const articleContentType = defineContentType({
+ id: "example.article",
+ tableName: "example_articles",
+ fields: {
+ title: field.text({ required: true }),
+ code: field.text({ required: true }),
+ excerpt: field.textarea({ nullable: true }),
+ status: field.enum({ values: ["draft", "published"] }),
+ author: field.user(),
+ },
+ admin: {
+ form: { // [!code ++]
+ sections: [ // [!code ++]
+ { name: "general", fields: ["title", "code", "excerpt"] }, // [!code ++]
+ { name: "publishing", fields: ["status", "author"] }, // [!code ++]
+ ], // [!code ++]
+ }, // [!code ++]
+ },
+});
+```
+
+The headings come from your plugin's messages, under the content type's own key:
+
+```json title="src/locales/en.json"
+{
+ "@vitnode/example": {
+ "content": {
+ "article": {
+ "form": {
+ "general": { // [!code ++]
+ "title": "General information", // [!code ++]
+ "desc": "Main content details" // [!code ++]
+ }, // [!code ++]
+ "publishing": { // [!code ++]
+ "title": "Publishing settings", // [!code ++]
+ "desc": "Status and author options" // [!code ++]
+ } // [!code ++]
+ }
+ }
+ }
+ }
+}
+```
+
+| Key | Required | What it does |
+| --- | --- | --- |
+| `name` | yes | Stable id and i18n key segment. Lowercase letters, digits, `_` |
+| `fields` | yes | The fields in this section, in order |
+| `…form.{name}.title` | no | The heading. Falls back to the humanised `name` ("General") |
+| `…form.{name}.desc` | no | A line under the heading. Omitted entirely when absent |
+
+
+ When `sections` are declared they replace `admin.form.fields` - the concatenation
+ of their `fields`, in section order, *is* the form. Declaring both is an error,
+ because they are two answers to one question. A field in no section is not on the
+ form, exactly as it would be if left out of `admin.form.fields`.
+
+ Each field belongs to one section, each `name` is used once, and no section is
+ empty. All three are checked when the content type is defined, not when the form
+ is opened.
+
+
+A plugin that registers its own `forms.layout` component keeps full control: an
+explicit layout wins over declared sections. Sections are the shorthand - they are
+rendered with the very same `ContentFormSection` primitive a hand-written layout
+uses, so the two look identical.
+
+
+
+### Step 3: Take Over the Layout, With i18n Hooks
+
+When declared sections are not enough - a wide writing column beside a metadata
+sidebar, say - register a **layout**. It decides where fields go and nothing else:
+the schema, validation, defaults, mutation, version precondition, structured errors,
+toast and cache invalidation all stay with the Content Engine.
+
+Any text the layout adds itself is yours to translate, so reach for
+`useTranslations` rather than a literal:
+
+```tsx title="src/views/admin/article/form-layout.tsx"
+"use client";
+
+import {
+ ContentFormActions,
+ ContentFormField,
+ ContentFormLayoutGrid,
+ ContentFormMain,
+ ContentFormSection,
+ ContentFormSidebar,
+ ContentFormStatus,
+} from "@vitnode/core/content/admin-form";
+import { useTranslations } from "next-intl"; // [!code ++]
+
+export const ArticleFormLayout = () => {
+ const t = useTranslations("@vitnode/example.admin.article.form"); // [!code ++]
+
+ return (
+
+
+
+
+
+
+
+
+
+ {/* [!code ++] */}
+
+
+
+
+ {/* [!code ++] */}
+
+
+
+
+
+ );
+};
+```
+
+Register it under `forms` in `src/config.tsx`:
+
+```tsx title="src/config.tsx"
+import { buildPlugin, contentTypeAdmin } from "@vitnode/core/lib/plugin";
+import { NotebookPenIcon } from "lucide-react";
+import { articleContentType } from "@/content/article";
+import { ArticleFormLayout } from "@/views/admin/article/form-layout"; // [!code ++]
+
+export const examplePlugin = () =>
+ buildPlugin({
+ pluginId: "@vitnode/example",
+ messages,
+ contentTypes: [
+ contentTypeAdmin({
+ definition: articleContentType,
+ icon: ,
+ // One layout for create and edit. Pass `{ create: { layout }, edit: { layout } }`
+ // instead to give each its own.
+ forms: { layout: ArticleFormLayout }, // [!code ++]
+ }),
+ ],
+ });
+```
+
+
+ A field the layout never names is dropped from the payload. `ContentFormRemainingFields`
+ places whatever is left, and in development the engine logs the names of any fields a
+ layout forgot.
+
+
+Field *components* are a separate override, and they compose with either arrangement -
+`fields: { content: { component: MyEditor } }` keeps the engine's label, validation and
+language switcher and swaps only the input.
+
+
+
diff --git a/apps/docs/content/docs/dev/content-engine/content-delivery-and-seo.mdx b/apps/docs/content/docs/dev/content-engine/content-delivery-and-seo.mdx
new file mode 100644
index 000000000..ce9340b51
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/content-delivery-and-seo.mdx
@@ -0,0 +1,122 @@
+---
+title: Content Delivery & SEO
+description: Step-by-step guide to integrating content types with Next.js App Router, managing slug redirect history, auto-generating SEO metadata, canonical links, hreflang tags, and XML sitemaps.
+icon: Compass
+---
+
+Content Engine provides frontend content delivery abstractions (`delivery: true`) to streamline routing, automatic 308 redirects, SEO meta generation, and XML sitemap creation.
+
+## Prerequisites & Context
+
+Frontend content delivery maps a client definition (e.g. `articleContentType`) and server model (`articleContent`) directly into Next.js App Router dynamic routes:
+
+- **`delivery: { basePath: "/articles", ... }`**: Configures route bases, automatic slug 308 redirects, SEO meta fields, and sitemap frequency.
+- **`resolveContentDelivery(articleContent, { slug, locale })`**: A Next.js server utility from `@vitnode/core/content/next` that fetches content, resolves slug redirects, generates SEO metadata, and handles 404 fallbacks.
+
+---
+
+## Step-by-Step Delivery Setup
+
+
+
+
+### Step 1: Enable Content Delivery in Definition
+
+Add `delivery` settings to `src/content/article.ts`:
+
+```ts title="src/content/article.ts"
+export const articleContentType = defineContentType({
+ id: "example.article",
+ tableName: "example_articles",
+ publication: true,
+ editorial: true,
+ delivery: { // [!code ++]
+ basePath: "/articles", // [!code ++]
+ redirects: true, // Auto 308 redirects on slug rename // [!code ++]
+ seo: { // [!code ++]
+ titleField: "title", // [!code ++]
+ descriptionField: "excerpt", // [!code ++]
+ }, // [!code ++]
+ sitemap: { // [!code ++]
+ changefreq: "weekly", // [!code ++]
+ priority: 0.8, // [!code ++]
+ }, // [!code ++]
+ }, // [!code ++]
+ fields: {
+ title: field.text({ required: true }),
+ slug: field.slug({ from: "title" }),
+ excerpt: field.textarea({ nullable: true }),
+ },
+});
+```
+
+
+
+### Step 2: Implement Next.js Dynamic Route with i18n & Metadata
+
+Create `app/[locale]/articles/[slug]/page.tsx`. Use `useTranslations` from `next-intl` to localize UI labels instead of hardcoding plain text strings:
+
+```tsx title="app/[locale]/articles/[slug]/page.tsx"
+import { notFound, redirect } from "next/navigation";
+import { useTranslations } from "next-intl"; // [!code ++]
+import { resolveContentDelivery } from "@vitnode/core/content/next";
+import { articleContent } from "@/database/articles";
+
+interface Props {
+ params: Promise<{ locale: string; slug: string }>;
+}
+
+export async function generateMetadata({ params }: Props) {
+ const { locale, slug } = await params;
+ const delivery = await resolveContentDelivery(articleContent, { slug, locale });
+
+ if (delivery.type !== "found") return {};
+
+ return delivery.seo; // Generates title, description, openGraph, canonical URLs
+}
+
+export default async function ArticlePage({ params }: Props) {
+ const { locale, slug } = await params;
+ const delivery = await resolveContentDelivery(articleContent, { slug, locale });
+
+ if (delivery.type === "redirect") {
+ redirect(delivery.destination, delivery.status); // 308 permanent redirect
+ }
+
+ if (delivery.type === "not_found") {
+ notFound();
+ }
+
+ const t = useTranslations("@vitnode/example"); // [!code ++]
+ const { item } = delivery;
+
+ return (
+
+
+
+);
diff --git a/apps/docs/src/examples/data-table.tsx b/apps/docs/src/examples/data-table.tsx
index d0d86a2c0..a1c091107 100644
--- a/apps/docs/src/examples/data-table.tsx
+++ b/apps/docs/src/examples/data-table.tsx
@@ -73,8 +73,8 @@ export default function DataTableExample() {
pageInfo={{
hasNextPage: false,
hasPreviousPage: false,
- startCursor: 1,
- endCursor: 1,
+ startCursor: null,
+ endCursor: null,
count: 1,
totalCount: 1,
}}
diff --git a/apps/docs/src/examples/roles.tsx b/apps/docs/src/examples/roles.tsx
new file mode 100644
index 000000000..2f83cc113
--- /dev/null
+++ b/apps/docs/src/examples/roles.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import type { RoleOption } from "@vitnode/core/components/form/fields/search-roles.action.server";
+
+import { AutoForm } from "@vitnode/core/components/form/auto-form";
+import { AutoFormRoles } from "@vitnode/core/components/form/fields/input-roles";
+import { z } from "zod";
+
+const formSchema = z.object({
+ roleId: z.number(),
+ roleIds: z.array(z.number()),
+});
+
+const ROLES: RoleOption[] = [
+ {
+ color: "#ef4444",
+ id: 1,
+ name: [{ languageCode: "en", name: "Administrator" }],
+ },
+ { color: "#3b82f6", id: 2, name: [{ languageCode: "en", name: "Editor" }] },
+ { color: null, id: 3, name: [{ languageCode: "en", name: "Member" }] },
+];
+
+const search = async (value: string) =>
+ Promise.resolve(
+ ROLES.filter(role =>
+ role.name[0].name.toLowerCase().includes(value.toLowerCase()),
+ ),
+ );
+
+export default function RolesExample() {
+ return (
+ (
+
+ ),
+ },
+ {
+ id: "roleIds",
+ component: props => (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ />
+ );
+}
diff --git a/apps/docs/src/examples/user.tsx b/apps/docs/src/examples/user.tsx
new file mode 100644
index 000000000..6763ac803
--- /dev/null
+++ b/apps/docs/src/examples/user.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { AutoForm } from "@vitnode/core/components/form/auto-form";
+import {
+ AutoFormUser,
+ type UserOption,
+} from "@vitnode/core/components/form/fields/input-users";
+import { z } from "zod";
+
+const formSchema = z.object({
+ authorId: z.number(),
+});
+
+const PEOPLE: UserOption[] = [
+ { avatarColor: "3b82f6", id: 1, name: "Ada Lovelace", nameCode: "ada" },
+ { avatarColor: "ef4444", id: 2, name: "Grace Hopper", nameCode: "grace" },
+ { avatarColor: "22c55e", id: 3, name: "Alan Turing", nameCode: "alan" },
+];
+
+export default function UserExample() {
+ return (
+ (
+
+ Promise.resolve(
+ PEOPLE.filter(person =>
+ person.name.toLowerCase().includes(value.toLowerCase()),
+ ),
+ )
+ }
+ />
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ />
+ );
+}
diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json
index c4dd50874..59f71db9c 100644
--- a/apps/docs/src/locales/@vitnode/blog/pl.json
+++ b/apps/docs/src/locales/@vitnode/blog/pl.json
@@ -1,89 +1,61 @@
{
"@vitnode/blog": {
"title": "Blog",
- "admin": {
- "nav": {
- "posts": "Wpisy",
- "categories": "Kategorie"
- },
- "categories": {
- "desc": "Zarządzaj kategoriami wpisów na blogu.",
- "table": {
+ "content": {
+ "post": {
+ "label": "{count, plural, one {Artykuł} few {Artykuły} many {Artykułów} other {Artykułu}}",
+ "title": "Artykuły",
+ "desc": "Pisz artykuły na blogu i zarządzaj nimi.",
+ "fields": {
"title": "Tytuł",
+ "friendlyUrl": "Przyjazny adres URL",
+ "content": "Treść",
+ "categoryId": "Kategoria",
+ "authorId": "Autor",
+ "status": "Status",
+ "publishedAt": "Opublikowano",
+ "updatedAt": "Zaktualizowano"
+ }
+ },
+ "category": {
+ "label": "{count, plural, one {Kategoria} few {Kategorie} many {Kategorii} other {Kategorii}}",
+ "title": "Kategorie",
+ "desc": "Grupuj artykuły razem.",
+ "fields": {
+ "name": "Nazwa",
"color": "Kolor",
- "updated_at": "Zaktualizowano"
- },
- "delete": {
- "title": "Usuń kategorię",
- "desc": "Czy na pewno chcesz usunąć kategorię ? Tej akcji nie można cofnąć.",
- "confirm": "Tak, usuń tę kategorię",
- "success": "Kategoria została pomyślnie usunięta."
- },
- "create": {
- "title": "Utwórz kategorię",
- "desc": "Nowa kategoria dla wpisów na blogu.",
- "form": {
- "title": {
- "label": "Tytuł",
- "already_exists": "Kategoria o tym tytule już istnieje."
- },
- "color": "Kolor"
- },
- "submit": "Utwórz",
- "success": "Kategoria została pomyślnie utworzona."
+ "updatedAt": "Zaktualizowano"
+ }
+ }
+ },
+ "admin": {
+ "article": {
+ "content": {
+ "label": "Treść"
},
- "edit": {
- "title": "Edytuj kategorię",
- "submit": "Zapisz zmiany",
- "success": "Kategoria została pomyślnie zaktualizowana."
+ "form": {
+ "publish": "Publikacja",
+ "settings": {
+ "title": "Ustawienia artykułu"
+ }
}
},
- "posts": {
- "desc": "Twórz wpisy na blogu i zarządzaj nimi.",
- "table": {
- "title": "Tytuł",
- "category": "Kategoria",
- "author": "Autor",
- "updated_at": "Zaktualizowano"
- },
- "create": {
- "title": "Utwórz wpis",
- "desc": "Napisz nowy artykuł na swój blog.",
- "form": {
- "title": {
- "label": "Tytuł",
- "already_exists": "Wpis o tym tytule już istnieje."
- },
- "friendly_url": {
- "label": "Przyjazny adres URL",
- "desc": "Używany w adresie wpisu. Wypełniany automatycznie na podstawie tytułu.",
- "already_exists": "Taki przyjazny adres URL już istnieje."
- },
- "content": "Treść",
- "category": "Kategoria"
- },
- "submit": "Utwórz wpis",
- "success": "Wpis został pomyślnie utworzony."
- },
- "edit": {
- "title": "Edytuj wpis",
- "submit": "Zapisz zmiany",
- "success": "Wpis został pomyślnie zaktualizowany."
- },
- "delete": {
- "title": "Usuń wpis",
- "desc": "Czy na pewno chcesz usunąć wpis ? Tej akcji nie można cofnąć.",
- "confirm": "Tak, usuń ten wpis",
- "success": "Wpis został pomyślnie usunięty."
+ "category": {
+ "color": {
+ "label": "Kolor",
+ "desc": "Wyświetlany obok kategorii na listach.",
+ "none": "Brak koloru"
}
}
}
},
- "@vitnode/blog:posts": "Wpisy",
- "@vitnode/blog:posts:can_view": "Wyświetlanie listy wpisów",
- "@vitnode/blog:posts:can_create": "Tworzenie wpisów",
- "@vitnode/blog:posts:can_edit": "Edytowanie wpisów",
- "@vitnode/blog:posts:can_delete": "Usuwanie wpisów",
+ "@vitnode/blog:posts": "Artykuły",
+ "@vitnode/blog:posts:can_view": "Wyświetlanie listy artykułów",
+ "@vitnode/blog:posts:can_create": "Tworzenie artykułów",
+ "@vitnode/blog:posts:can_edit": "Edytowanie artykułów",
+ "@vitnode/blog:posts:can_delete": "Usuwanie artykułów",
+ "@vitnode/blog:posts:can_publish": "Publikowanie i cofanie publikacji artykułów",
+ "@vitnode/blog:posts:can_restore": "Przywracanie wcześniejszej wersji artykułu",
"@vitnode/blog:categories": "Kategorie",
"@vitnode/blog:categories:can_view": "Wyświetlanie listy kategorii",
"@vitnode/blog:categories:can_create": "Tworzenie kategorii",
diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json
index f87acb19a..6c1886d04 100644
--- a/apps/docs/src/locales/@vitnode/core/pl.json
+++ b/apps/docs/src/locales/@vitnode/core/pl.json
@@ -220,6 +220,8 @@
"desc": "Przepraszamy, występują problemy techniczne po stronie serwera."
},
"title": "Ups! Coś poszło nie tak.",
+ "try_again": "Spróbuj ponownie",
+ "reference": "Identyfikator błędu: {digest}",
"internal_server_error": "Wewnętrzny błąd serwera.",
"field_required": "To pole jest wymagane.",
"field_min_length": "To pole musi mieć co najmniej {min} znaków.",
@@ -409,8 +411,6 @@
"drop_here": "Upuść tutaj widżet",
"empty_title": "Twój pulpit jest pusty",
"empty_desc": "Dodaj widżety, aby mieć ważne dane zawsze pod ręką.",
- "saved_title": "Pulpit zapisany",
- "saved_desc": "Ten układ należy tylko do Ciebie - inni administratorzy mają swój własny.",
"error_title": "Nie udało się zapisać pulpitu",
"error_desc": "Coś poszło nie tak po drodze do serwera. Spróbuj ponownie.",
"refresh_error": "Nie udało się odświeżyć tego widżetu. Odśwież stronę, aby zobaczyć zmiany.",
@@ -433,8 +433,6 @@
"open": "Skonfiguruj {title}",
"title": "Skonfiguruj {title}",
"desc": "Te ustawienia dotyczą tylko tej karty - inni administratorzy mają swoje własne.",
- "saved_title": "Ustawienia zapisane",
- "saved_desc": "Karta użyje ich, gdy skończysz układać pulpit.",
"error_title": "Nie udało się zapisać ustawień",
"error_desc": "Coś poszło nie tak po drodze do serwera. Spróbuj ponownie.",
"load_error": "Nie udało się wczytać tych ustawień. Zamknij i spróbuj ponownie."
diff --git a/apps/docs/src/vitnode.api.config.ts b/apps/docs/src/vitnode.api.config.ts
index a146553c9..0b63906c9 100644
--- a/apps/docs/src/vitnode.api.config.ts
+++ b/apps/docs/src/vitnode.api.config.ts
@@ -4,6 +4,7 @@ import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord";
import { FacebookSSOApiPlugin } from "@vitnode/core/api/adapters/sso/facebook";
import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google";
import { buildApiConfig } from "@vitnode/core/vitnode.config";
+import { exampleApiPlugin } from "@vitnode/example/config.api";
import { NodeCronAdapter } from "@vitnode/node-cron";
import { NodemailerEmailAdapter } from "@vitnode/nodemailer";
// import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local";
@@ -29,7 +30,7 @@ export const vitNodeApiConfig = buildApiConfig({
title: "VitNode API",
shortTitle: "VitNode",
},
- plugins: [blogApiPlugin()],
+ plugins: [blogApiPlugin(), exampleApiPlugin()],
dbProvider: drizzle({
connection: POSTGRES_URL,
casing: "camelCase",
diff --git a/apps/docs/src/vitnode.config.ts b/apps/docs/src/vitnode.config.ts
index 40b227c47..0c360d790 100644
--- a/apps/docs/src/vitnode.config.ts
+++ b/apps/docs/src/vitnode.config.ts
@@ -1,5 +1,6 @@
import { blogPlugin } from "@vitnode/blog/config";
import { buildConfig, handleRequestConfig } from "@vitnode/core/vitnode.config";
+import { examplePlugin } from "@vitnode/example/config";
import { getRequestConfig } from "next-intl/server";
import { i18n } from "./i18n";
@@ -9,7 +10,7 @@ export const vitNodeConfig = buildConfig({
title: "VitNode",
shortTitle: "VitNode",
},
- plugins: [blogPlugin()],
+ plugins: [blogPlugin(), examplePlugin()],
debug: false,
i18n,
theme: {
diff --git a/package.json b/package.json
index 81e733438..d38312371 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"lint": "turbo lint",
"lint:fix": "turbo lint:fix",
"test": "turbo test",
+ "test:types": "turbo test:types",
"test:e2e": "turbo test:e2e",
"i18n:create": "turbo i18n:create",
"i18n:check": "turbo i18n:check",
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts
new file mode 100644
index 000000000..983263af6
--- /dev/null
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/api/vitnode/content/revalidate/route.ts
@@ -0,0 +1 @@
+export { POST } from "@vitnode/core/content/next/revalidate-route";
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error copy.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error copy.tsx
deleted file mode 100644
index 786d47b66..000000000
--- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error copy.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-"use client";
-
-import { GlobalErrorView } from "@vitnode/core/views/error/global-error-view";
-import { Geist } from "next/font/google";
-
-import "./global.css";
-
-const geist = Geist({
- subsets: ["latin"],
-});
-
-export default function GlobalError() {
- return ;
-}
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error.tsx
index 786d47b66..ccb9d5ed2 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error.tsx
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/global-error.tsx
@@ -9,6 +9,18 @@ const geist = Geist({
subsets: ["latin"],
});
-export default function GlobalError() {
- return ;
+export default function GlobalError({
+ error,
+ retry,
+}: {
+ error: Error & { digest?: string };
+ retry: () => void;
+}) {
+ return (
+
+ );
}
diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json
index 4f63f4d29..f7b819da1 100644
--- a/packages/elasticsearch/package.json
+++ b/packages/elasticsearch/package.json
@@ -27,9 +27,7 @@
"build:plugins": "vitnode build",
"dev:plugins": "vitnode dev",
"lint": "eslint .",
- "lint:fix": "eslint . --fix",
- "test": "vitest run",
- "test:watch": "vitest"
+ "lint:fix": "eslint . --fix"
},
"dependencies": {
"@elastic/elasticsearch": "^9.4.2"
@@ -41,7 +39,6 @@
"@vitnode/core": "workspace:*",
"eslint": "^10.7.0",
"tsc-alias": "^1.9.1",
- "typescript": "^6.0.3",
- "vitest": "^4.1.10"
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/elasticsearch/src/index.test.ts b/packages/elasticsearch/src/index.test.ts
deleted file mode 100644
index ad217f6bd..000000000
--- a/packages/elasticsearch/src/index.test.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const {
- Client,
- ResponseError,
- index,
- deleteByQuery,
- ping,
- search,
- exists,
- create,
-} = vi.hoisted(() => {
- const index = vi.fn();
- const bulk = vi.fn();
- const del = vi.fn();
- const deleteByQuery = vi.fn();
- const ping = vi.fn();
- const search = vi.fn();
- const exists = vi.fn();
- const create = vi.fn();
- const Client = vi.fn(function () {
- return {
- index,
- bulk,
- delete: del,
- deleteByQuery,
- ping,
- search,
- indices: { exists, create },
- };
- });
- class ResponseError extends Error {
- constructor(body: unknown) {
- super("elasticsearch response error");
- this.body = body;
- }
- body: unknown;
- }
-
- return {
- Client,
- ResponseError,
- index,
- deleteByQuery,
- ping,
- search,
- exists,
- create,
- };
-});
-
-vi.mock("@elastic/elasticsearch", () => ({
- Client,
- errors: { ResponseError },
-}));
-
-const alreadyExistsError = () =>
- new ResponseError({ error: { type: "resource_already_exists_exception" } });
-
-import { ElasticsearchSearchAdapter } from "./index";
-
-const c = {} as never;
-const config = { node: "http://localhost:9200", index: "test" };
-const doc = {
- itemType: "blog_post",
- itemId: 1,
- title: "Hi",
- content: "Hello world",
- authorId: 5,
- createdAt: new Date("2026-01-01T00:00:00.000Z"),
-};
-
-beforeEach(() => {
- vi.clearAllMocks();
- exists.mockReset().mockResolvedValue(true);
- create.mockReset().mockResolvedValue(undefined);
-});
-
-describe("ElasticsearchSearchAdapter configuration", () => {
- it("throws when neither node nor cloudId is provided", async () => {
- await expect(
- ElasticsearchSearchAdapter({}).delete(c, "blog_post", 1),
- ).rejects.toThrow("Missing Elasticsearch configuration");
- expect(Client).not.toHaveBeenCalled();
- });
-
- it("constructs the client lazily and reuses it", async () => {
- const adapter = ElasticsearchSearchAdapter(config);
- expect(Client).not.toHaveBeenCalled();
-
- await adapter.delete(c, "blog_post", 1);
- await adapter.delete(c, "blog_post", 2);
-
- expect(Client).toHaveBeenCalledTimes(1);
- });
-});
-
-describe("ElasticsearchSearchAdapter.index", () => {
- it("indexes a document with a deterministic id and ISO date", async () => {
- await ElasticsearchSearchAdapter(config).index(c, doc);
-
- expect(index).toHaveBeenCalledWith({
- index: "test",
- id: "blog_post:1:",
- document: expect.objectContaining({
- itemType: "blog_post",
- itemId: 1,
- authorId: 5,
- title: "Hi",
- content: "Hello world",
- isPublic: true,
- createdAt: "2026-01-01T00:00:00.000Z",
- }),
- });
- });
-
- it("creates the index with a mapping when it does not exist", async () => {
- exists.mockResolvedValue(false);
-
- await ElasticsearchSearchAdapter(config).index(c, doc);
-
- expect(create).toHaveBeenCalledWith(
- expect.objectContaining({ index: "test" }),
- );
- });
-
- it("defaults languageCode to an empty string when omitted", async () => {
- await ElasticsearchSearchAdapter(config).index(c, doc);
-
- expect(index).toHaveBeenCalledWith(
- expect.objectContaining({
- document: expect.objectContaining({ languageCode: "" }),
- }),
- );
- });
-
- it("indexes the document languageCode and scopes the id by language", async () => {
- await ElasticsearchSearchAdapter(config).index(c, {
- ...doc,
- languageCode: "pl",
- });
-
- expect(index).toHaveBeenCalledWith(
- expect.objectContaining({
- id: "blog_post:1:pl",
- document: expect.objectContaining({ languageCode: "pl" }),
- }),
- );
- });
-
- it("keeps language variants of one item as distinct documents", async () => {
- const adapter = ElasticsearchSearchAdapter(config);
-
- await adapter.index(c, { ...doc, languageCode: "en" });
- await adapter.index(c, { ...doc, languageCode: "pl" });
-
- const ids = index.mock.calls.map(call => call[0].id);
- expect(ids).toEqual(["blog_post:1:en", "blog_post:1:pl"]);
- });
-});
-
-describe("ElasticsearchSearchAdapter index initialization", () => {
- it("creates the index only once under concurrent calls", async () => {
- exists.mockResolvedValue(false);
- const adapter = ElasticsearchSearchAdapter(config);
-
- await Promise.all([
- adapter.index(c, doc),
- adapter.index(c, doc),
- adapter.bulkIndex(c, [doc]),
- ]);
-
- expect(exists).toHaveBeenCalledTimes(1);
- expect(create).toHaveBeenCalledTimes(1);
- });
-
- it("does not re-check the index after it is ensured", async () => {
- exists.mockResolvedValue(true);
- const adapter = ElasticsearchSearchAdapter(config);
-
- await adapter.index(c, doc);
- await adapter.index(c, doc);
-
- expect(exists).toHaveBeenCalledTimes(1);
- });
-
- it("swallows resource_already_exists_exception from a concurrent creator", async () => {
- exists.mockResolvedValue(false);
- create.mockRejectedValue(alreadyExistsError());
-
- await expect(
- ElasticsearchSearchAdapter(config).index(c, doc),
- ).resolves.toBeUndefined();
-
- expect(index).toHaveBeenCalledTimes(1);
- });
-
- it("propagates unexpected errors from create", async () => {
- exists.mockResolvedValue(false);
- create.mockRejectedValue(new Error("cluster unavailable"));
-
- await expect(
- ElasticsearchSearchAdapter(config).index(c, doc),
- ).rejects.toThrow("cluster unavailable");
- });
-
- it("retries initialization after a transient failure", async () => {
- exists.mockRejectedValueOnce(new Error("network")).mockResolvedValue(true);
- const adapter = ElasticsearchSearchAdapter(config);
-
- await expect(adapter.index(c, doc)).rejects.toThrow("network");
- await expect(adapter.index(c, doc)).resolves.toBeUndefined();
-
- expect(exists).toHaveBeenCalledTimes(2);
- expect(index).toHaveBeenCalledTimes(1);
- });
-});
-
-describe("ElasticsearchSearchAdapter.delete", () => {
- it("removes every language variant of an item by query", async () => {
- await ElasticsearchSearchAdapter(config).delete(c, "blog_post", 1);
-
- expect(deleteByQuery).toHaveBeenCalledWith(
- expect.objectContaining({
- query: {
- bool: {
- filter: [
- { term: { itemType: "blog_post" } },
- { term: { itemId: 1 } },
- ],
- },
- },
- }),
- { ignore: [404] },
- );
- });
-});
-
-describe("ElasticsearchSearchAdapter.clear", () => {
- it("clears one type with a term query", async () => {
- await ElasticsearchSearchAdapter(config).clear(c, "blog_post");
-
- expect(deleteByQuery).toHaveBeenCalledWith(
- expect.objectContaining({ query: { term: { itemType: "blog_post" } } }),
- { ignore: [404] },
- );
- });
-
- it("clears everything with match_all", async () => {
- await ElasticsearchSearchAdapter(config).clear(c);
-
- expect(deleteByQuery).toHaveBeenCalledWith(
- expect.objectContaining({ query: { match_all: {} } }),
- { ignore: [404] },
- );
- });
-});
-
-describe("ElasticsearchSearchAdapter.search", () => {
- beforeEach(() => {
- search.mockResolvedValue({
- hits: {
- total: { value: 1 },
- hits: [
- {
- _id: "blog_post:1",
- _score: 1.23,
- _source: {
- pluginId: "core",
- itemType: "blog_post",
- itemId: 1,
- languageCode: "en",
- authorId: 5,
- title: "Hi",
- content: "Hello world",
- containerType: null,
- containerId: null,
- url: "/blog/1/hi",
- isPublic: true,
- metadata: {},
- createdAt: "2026-01-01T00:00:00.000Z",
- },
- },
- ],
- },
- });
- });
-
- it("maps hits and pagination, leaving author for the model to hydrate", async () => {
- const result = await ElasticsearchSearchAdapter(config).search(c, {
- term: "hello",
- sort: "relevance",
- first: 20,
- });
-
- expect(result.pageInfo.totalCount).toBe(1);
- expect(result.edges[0]).toMatchObject({
- itemId: 1,
- languageCode: "en",
- authorId: 5,
- score: 1.23,
- author: null,
- url: "/blog/1/hi",
- });
- });
-
- it("filters by languageCode, matching the locale and language-agnostic rows", async () => {
- await ElasticsearchSearchAdapter(config).search(c, {
- term: "hello",
- sort: "relevance",
- languageCode: "en",
- });
-
- const arg = search.mock.calls[0][0];
- expect(arg.query.bool.filter).toContainEqual({
- terms: { languageCode: ["en", ""] },
- });
- });
-
- it("omits the languageCode filter when no locale is requested", async () => {
- await ElasticsearchSearchAdapter(config).search(c, {
- term: "hello",
- sort: "relevance",
- });
-
- const arg = search.mock.calls[0][0];
- expect(arg.query.bool.filter).not.toContainEqual(
- expect.objectContaining({
- terms: expect.objectContaining({ languageCode: expect.anything() }),
- }),
- );
- });
-
- it("builds a multi_match query for a term", async () => {
- await ElasticsearchSearchAdapter(config).search(c, {
- term: "hello",
- sort: "relevance",
- });
-
- const arg = search.mock.calls[0][0];
- expect(arg.query.bool.must[0].multi_match.query).toBe("hello");
- });
-
- it("wraps the query in function_score when ranking is configured", async () => {
- await ElasticsearchSearchAdapter({
- ...config,
- ranking: { timeDecay: { scale: "30d" } },
- }).search(c, { term: "hello", sort: "relevance" });
-
- const arg = search.mock.calls[0][0];
- expect(arg.query.function_score).toBeDefined();
- expect(arg.query.function_score.functions[0].gauss).toBeDefined();
- });
-
- it("sorts by date without a term", async () => {
- await ElasticsearchSearchAdapter(config).search(c, { sort: "newest" });
-
- const arg = search.mock.calls[0][0];
- expect(arg.sort).toEqual([{ createdAt: { order: "desc" } }]);
- });
-});
-
-describe("ElasticsearchSearchAdapter.ping", () => {
- it("returns the client ping result", async () => {
- ping.mockResolvedValue(true);
-
- expect(await ElasticsearchSearchAdapter(config).ping?.(c)).toBe(true);
- });
-
- it("returns false when ping throws", async () => {
- ping.mockRejectedValue(new Error("down"));
-
- expect(await ElasticsearchSearchAdapter(config).ping?.(c)).toBe(false);
- });
-});
diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts
index e1dddb680..08fee597f 100644
--- a/packages/elasticsearch/src/index.ts
+++ b/packages/elasticsearch/src/index.ts
@@ -60,7 +60,10 @@ const isIndexAlreadyExistsError = (error: unknown): boolean =>
"resource_already_exists_exception";
const toSource = (doc: SearchDocument): EsSource => ({
- pluginId: "core",
+ // `SearchModel` resolves ownership before any provider sees the document, so
+ // this fallback is only for a provider called directly - it must never be the
+ // reason a mirrored document disagrees with the canonical row.
+ pluginId: doc.pluginId ?? "core",
itemType: doc.itemType,
itemId: doc.itemId,
languageCode: doc.languageCode ?? "",
@@ -281,7 +284,48 @@ export const ElasticsearchSearchAdapter = (
return {
name: "elasticsearch",
- capabilities: { facets: true, timeDecay: true, authorBoost: true },
+ // `languageScopedDelete`: `delete` below filters the delete-by-query on
+ // `languageCode` when it is given one, so taking the Polish translation down
+ // leaves the English document in place.
+ capabilities: {
+ facets: true,
+ timeDecay: true,
+ authorBoost: true,
+ 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();
@@ -310,13 +354,23 @@ export const ElasticsearchSearchAdapter = (
},
// One document per language shares an (itemType, itemId), so remove every
- // language variant with a query rather than a single id.
- delete: async (_c, itemType, itemId) => {
+ // language variant with a query rather than a single id - unless the caller
+ // named one, which is how a single translation is taken down without
+ // touching the others.
+ delete: async (_c, itemType, itemId, languageCode) => {
await getClient().deleteByQuery(
{
index,
query: {
- bool: { filter: [{ term: { itemType } }, { term: { itemId } }] },
+ bool: {
+ filter: [
+ { term: { itemType } },
+ { term: { itemId } },
+ ...(languageCode === undefined
+ ? []
+ : [{ term: { languageCode } }]),
+ ],
+ },
},
},
{ ignore: [404] },
diff --git a/packages/s3/package.json b/packages/s3/package.json
index de11e6359..86ec514c7 100644
--- a/packages/s3/package.json
+++ b/packages/s3/package.json
@@ -28,9 +28,7 @@
"build:plugins": "vitnode build",
"dev:plugins": "vitnode dev",
"lint": "eslint .",
- "lint:fix": "eslint . --fix",
- "test": "vitest run",
- "test:watch": "vitest"
+ "lint:fix": "eslint . --fix"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1092.0"
@@ -42,7 +40,6 @@
"@vitnode/core": "workspace:*",
"eslint": "^10.7.0",
"tsc-alias": "^1.9.1",
- "typescript": "^6.0.3",
- "vitest": "^4.1.10"
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/s3/src/index.test.ts b/packages/s3/src/index.test.ts
deleted file mode 100644
index b922c4141..000000000
--- a/packages/s3/src/index.test.ts
+++ /dev/null
@@ -1,242 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const { DeleteObjectCommand, PutObjectCommand, S3Client, send } = vi.hoisted(
- () => {
- const send = vi.fn();
- const S3Client = vi.fn(function () {
- return { send };
- });
- const DeleteObjectCommand = vi.fn(function (
- input: Record,
- ) {
- return { input };
- });
- const PutObjectCommand = vi.fn(function (input: Record) {
- return { input };
- });
-
- return { DeleteObjectCommand, PutObjectCommand, S3Client, send };
- },
-);
-
-vi.mock("@aws-sdk/client-s3", () => ({
- DeleteObjectCommand,
- PutObjectCommand,
- S3Client,
-}));
-
-import { S3StorageAdapter } from "./index";
-
-const config = {
- accessKeyId: "AKIAEXAMPLE",
- bucket: "media",
- secretAccessKey: "secret",
-};
-
-beforeEach(() => {
- vi.clearAllMocks();
- send.mockResolvedValue({});
-});
-
-describe("S3StorageAdapter configuration", () => {
- it.each([
- ["bucket", { ...config, bucket: "" }],
- ["accessKeyId", { ...config, accessKeyId: "" }],
- ["secretAccessKey", { ...config, secretAccessKey: "" }],
- ])("rejects from delete when %s is missing", async (_, partial) => {
- await expect(S3StorageAdapter(partial).delete("photo.png")).rejects.toThrow(
- "Missing S3 configuration",
- );
- });
-
- it("rejects from upload when configuration is incomplete", async () => {
- await expect(
- S3StorageAdapter({ ...config, bucket: "" }).upload({
- body: Buffer.from("x"),
- key: "photo.png",
- }),
- ).rejects.toThrow("Missing S3 configuration");
- });
-
- it("never constructs a client when configuration is incomplete", async () => {
- await expect(
- S3StorageAdapter({ ...config, accessKeyId: "" }).delete("photo.png"),
- ).rejects.toThrow();
- expect(S3Client).not.toHaveBeenCalled();
- });
-});
-
-describe("S3StorageAdapter client", () => {
- it("passes region and credentials to the client", async () => {
- await S3StorageAdapter({ ...config, region: "us-east-1" }).delete(
- "photo.png",
- );
-
- expect(S3Client).toHaveBeenCalledWith({
- region: "us-east-1",
- endpoint: undefined,
- forcePathStyle: false,
- credentials: {
- accessKeyId: "AKIAEXAMPLE",
- secretAccessKey: "secret",
- },
- });
- });
-
- it("defaults the region to auto", async () => {
- await S3StorageAdapter(config).delete("photo.png");
-
- expect(S3Client).toHaveBeenCalledWith(
- expect.objectContaining({ region: "auto" }),
- );
- });
-
- it("enables path-style addressing when a custom endpoint is set", async () => {
- await S3StorageAdapter({
- ...config,
- endpoint: "https://account.r2.cloudflarestorage.com",
- }).delete("photo.png");
-
- expect(S3Client).toHaveBeenCalledWith(
- expect.objectContaining({
- endpoint: "https://account.r2.cloudflarestorage.com",
- forcePathStyle: true,
- }),
- );
- });
-
- it("honors an explicit forcePathStyle over the endpoint default", async () => {
- await S3StorageAdapter({
- ...config,
- endpoint: "https://account.r2.cloudflarestorage.com",
- forcePathStyle: false,
- }).delete("photo.png");
-
- expect(S3Client).toHaveBeenCalledWith(
- expect.objectContaining({ forcePathStyle: false }),
- );
- });
-
- it("constructs the client lazily and reuses it across calls", async () => {
- const adapter = S3StorageAdapter(config);
- expect(S3Client).not.toHaveBeenCalled();
-
- await adapter.delete("a.png");
- await adapter.delete("b.png");
-
- expect(S3Client).toHaveBeenCalledTimes(1);
- });
-});
-
-describe("S3StorageAdapter.getUrl", () => {
- it("uses publicUrl when set, stripping a trailing slash", () => {
- const url = S3StorageAdapter({
- ...config,
- publicUrl: "https://cdn.example.com/",
- }).getUrl("photo.png");
-
- expect(url).toBe("https://cdn.example.com/photo.png");
- });
-
- it("uses the endpoint and bucket when no publicUrl is set", () => {
- const url = S3StorageAdapter({
- ...config,
- endpoint: "https://account.r2.cloudflarestorage.com/",
- }).getUrl("photo.png");
-
- expect(url).toBe(
- "https://account.r2.cloudflarestorage.com/media/photo.png",
- );
- });
-
- it("prefers publicUrl over the endpoint", () => {
- const url = S3StorageAdapter({
- ...config,
- endpoint: "https://account.r2.cloudflarestorage.com",
- publicUrl: "https://cdn.example.com",
- }).getUrl("photo.png");
-
- expect(url).toBe("https://cdn.example.com/photo.png");
- });
-
- it("falls back to the regional AWS url", () => {
- const url = S3StorageAdapter({ ...config, region: "eu-west-1" }).getUrl(
- "photo.png",
- );
-
- expect(url).toBe("https://media.s3.eu-west-1.amazonaws.com/photo.png");
- });
-
- it("does not construct a client", () => {
- S3StorageAdapter(config).getUrl("photo.png");
-
- expect(S3Client).not.toHaveBeenCalled();
- });
-});
-
-describe("S3StorageAdapter.delete", () => {
- it("sends a DeleteObjectCommand for the key", async () => {
- await S3StorageAdapter(config).delete("photo.png");
-
- expect(DeleteObjectCommand).toHaveBeenCalledWith({
- Bucket: "media",
- Key: "photo.png",
- });
- expect(PutObjectCommand).not.toHaveBeenCalled();
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("propagates errors from the client", async () => {
- const error = new Error("access denied");
- send.mockRejectedValue(error);
-
- await expect(S3StorageAdapter(config).delete("photo.png")).rejects.toBe(
- error,
- );
- });
-});
-
-describe("S3StorageAdapter.upload", () => {
- it("sends a PutObjectCommand and returns the key and url", async () => {
- const body = Buffer.from("file-contents");
-
- const result = await S3StorageAdapter(config).upload({
- body,
- contentType: "image/png",
- key: "photo.png",
- });
-
- expect(PutObjectCommand).toHaveBeenCalledWith({
- Bucket: "media",
- Key: "photo.png",
- Body: body,
- ContentType: "image/png",
- });
- expect(send).toHaveBeenCalledTimes(1);
- expect(result).toEqual({
- key: "photo.png",
- url: "https://media.s3.auto.amazonaws.com/photo.png",
- });
- });
-
- it("returns a publicUrl-based url when configured", async () => {
- const result = await S3StorageAdapter({
- ...config,
- publicUrl: "https://cdn.example.com",
- }).upload({ body: Buffer.from("x"), key: "photo.png" });
-
- expect(result.url).toBe("https://cdn.example.com/photo.png");
- });
-
- it("propagates errors from the client", async () => {
- const error = new Error("upload failed");
- send.mockRejectedValue(error);
-
- await expect(
- S3StorageAdapter(config).upload({
- body: Buffer.from("x"),
- key: "photo.png",
- }),
- ).rejects.toBe(error);
- });
-});
diff --git a/packages/s3/vitest.config.ts b/packages/s3/vitest.config.ts
deleted file mode 100644
index 28e3aea5c..000000000
--- a/packages/s3/vitest.config.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- environment: "node",
- exclude: ["**/node_modules/**", "**/dist/**"],
- },
-});
diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json
index 728b0f006..f937c4bb4 100644
--- a/packages/supabase-storage/package.json
+++ b/packages/supabase-storage/package.json
@@ -27,9 +27,7 @@
"build:plugins": "vitnode build",
"dev:plugins": "vitnode dev",
"lint": "eslint .",
- "lint:fix": "eslint . --fix",
- "test": "vitest run",
- "test:watch": "vitest"
+ "lint:fix": "eslint . --fix"
},
"dependencies": {
"@supabase/storage-js": "^2.110.8"
@@ -41,7 +39,6 @@
"@vitnode/core": "workspace:*",
"eslint": "^10.7.0",
"tsc-alias": "^1.9.1",
- "typescript": "^6.0.3",
- "vitest": "^4.1.10"
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/supabase-storage/src/index.test.ts b/packages/supabase-storage/src/index.test.ts
deleted file mode 100644
index b0fe26c13..000000000
--- a/packages/supabase-storage/src/index.test.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const { StorageClient, from, getPublicUrl, remove, upload } = vi.hoisted(() => {
- const remove = vi.fn();
- const getPublicUrl = vi.fn();
- const upload = vi.fn();
- const from = vi.fn(() => ({ getPublicUrl, remove, upload }));
- const StorageClient = vi.fn(function () {
- return { from };
- });
-
- return { StorageClient, from, getPublicUrl, remove, upload };
-});
-
-vi.mock("@supabase/storage-js", () => ({ StorageClient }));
-
-import { SupabaseStorageAdapter } from "./index";
-
-const config = {
- bucket: "media",
- secretKey: "sb_secret_123",
- url: "https://project.supabase.co",
-};
-
-beforeEach(() => {
- vi.clearAllMocks();
- remove.mockResolvedValue({ error: null });
- upload.mockResolvedValue({ error: null });
- getPublicUrl.mockReturnValue({
- data: { publicUrl: "https://project.supabase.co/public/media/photo.png" },
- });
-});
-
-describe("SupabaseStorageAdapter configuration", () => {
- it.each([
- ["bucket", { ...config, bucket: "" }],
- ["secretKey", { ...config, secretKey: "" }],
- ["url", { ...config, url: "" }],
- ])("throws from getUrl when %s is missing", (_, partial) => {
- expect(() => SupabaseStorageAdapter(partial).getUrl("photo.png")).toThrow(
- "Missing Supabase Storage configuration",
- );
- });
-
- it("rejects from delete when configuration is incomplete", async () => {
- await expect(
- SupabaseStorageAdapter({ ...config, url: "" }).delete("photo.png"),
- ).rejects.toThrow("Missing Supabase Storage configuration");
- });
-
- it("rejects from upload when configuration is incomplete", async () => {
- await expect(
- SupabaseStorageAdapter({ ...config, secretKey: "" }).upload({
- body: Buffer.from("x"),
- key: "photo.png",
- }),
- ).rejects.toThrow("Missing Supabase Storage configuration");
- });
-
- it("never constructs a client when configuration is incomplete", () => {
- expect(() =>
- SupabaseStorageAdapter({ ...config, bucket: "" }).getUrl("photo.png"),
- ).toThrow();
- expect(StorageClient).not.toHaveBeenCalled();
- });
-});
-
-describe("SupabaseStorageAdapter client", () => {
- it("builds the storage endpoint and auth headers from config", () => {
- SupabaseStorageAdapter(config).getUrl("photo.png");
-
- expect(StorageClient).toHaveBeenCalledWith(
- "https://project.supabase.co/storage/v1",
- {
- apikey: "sb_secret_123",
- Authorization: "Bearer sb_secret_123",
- },
- );
- });
-
- it("strips a trailing slash from the url", () => {
- SupabaseStorageAdapter({
- ...config,
- url: "https://project.supabase.co/",
- }).getUrl("photo.png");
-
- expect(StorageClient).toHaveBeenCalledWith(
- "https://project.supabase.co/storage/v1",
- expect.anything(),
- );
- });
-
- it("constructs the client lazily and reuses it across calls", () => {
- const adapter = SupabaseStorageAdapter(config);
- expect(StorageClient).not.toHaveBeenCalled();
-
- adapter.getUrl("a.png");
- adapter.getUrl("b.png");
-
- expect(StorageClient).toHaveBeenCalledTimes(1);
- });
-
- it("isolates the client between adapter instances", () => {
- SupabaseStorageAdapter(config).getUrl("a.png");
- SupabaseStorageAdapter(config).getUrl("b.png");
-
- expect(StorageClient).toHaveBeenCalledTimes(2);
- });
-});
-
-describe("SupabaseStorageAdapter.getUrl", () => {
- it("returns the public url for the key from the configured bucket", () => {
- const url = SupabaseStorageAdapter(config).getUrl("photo.png");
-
- expect(from).toHaveBeenCalledWith("media");
- expect(getPublicUrl).toHaveBeenCalledWith("photo.png");
- expect(url).toBe("https://project.supabase.co/public/media/photo.png");
- });
-});
-
-describe("SupabaseStorageAdapter.delete", () => {
- it("removes the key from the configured bucket", async () => {
- await SupabaseStorageAdapter(config).delete("photo.png");
-
- expect(from).toHaveBeenCalledWith("media");
- expect(remove).toHaveBeenCalledWith(["photo.png"]);
- });
-
- it("throws the error returned by the storage client", async () => {
- const error = new Error("remove failed");
- remove.mockResolvedValue({ error });
-
- await expect(
- SupabaseStorageAdapter(config).delete("photo.png"),
- ).rejects.toBe(error);
- });
-});
-
-describe("SupabaseStorageAdapter.upload", () => {
- it("uploads with upsert and returns the key and public url", async () => {
- const body = Buffer.from("file-contents");
-
- const result = await SupabaseStorageAdapter(config).upload({
- body,
- contentType: "image/png",
- key: "photo.png",
- });
-
- expect(from).toHaveBeenCalledWith("media");
- expect(upload).toHaveBeenCalledWith("photo.png", body, {
- contentType: "image/png",
- upsert: true,
- });
- expect(result).toEqual({
- key: "photo.png",
- url: "https://project.supabase.co/public/media/photo.png",
- });
- });
-
- it("passes an undefined contentType through untouched", async () => {
- await SupabaseStorageAdapter(config).upload({
- body: Buffer.from("x"),
- key: "photo.png",
- });
-
- expect(upload).toHaveBeenCalledWith("photo.png", expect.any(Buffer), {
- contentType: undefined,
- upsert: true,
- });
- });
-
- it("throws the error returned by the storage client", async () => {
- const error = new Error("upload failed");
- upload.mockResolvedValue({ error });
-
- await expect(
- SupabaseStorageAdapter(config).upload({
- body: Buffer.from("x"),
- key: "photo.png",
- }),
- ).rejects.toBe(error);
- });
-
- it("does not build a public url when the upload fails", async () => {
- upload.mockResolvedValue({ error: new Error("upload failed") });
-
- await expect(
- SupabaseStorageAdapter(config).upload({
- body: Buffer.from("x"),
- key: "photo.png",
- }),
- ).rejects.toThrow();
- expect(getPublicUrl).not.toHaveBeenCalled();
- });
-});
diff --git a/packages/supabase-storage/vitest.config.ts b/packages/supabase-storage/vitest.config.ts
deleted file mode 100644
index 28e3aea5c..000000000
--- a/packages/supabase-storage/vitest.config.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- environment: "node",
- exclude: ["**/node_modules/**", "**/dist/**"],
- },
-});
diff --git a/packages/vitnode/.swcrc b/packages/vitnode/.swcrc
index 8f099dc7a..91fc9a4b0 100644
--- a/packages/vitnode/.swcrc
+++ b/packages/vitnode/.swcrc
@@ -1,6 +1,6 @@
{
"$schema": "https://swc.rs/schema.json",
- "exclude": ["\\.test\\.tsx?$"],
+ "exclude": ["\\.test\\.tsx?$", "\\.test-d\\.ts$", "^src/tests/"],
"minify": true,
"jsc": {
"baseUrl": "./",
diff --git a/packages/vitnode/config/next.config.ts b/packages/vitnode/config/next.config.ts
index 90cd4de8d..9317acb61 100644
--- a/packages/vitnode/config/next.config.ts
+++ b/packages/vitnode/config/next.config.ts
@@ -8,6 +8,6 @@ export const vitNodeNextConfig = (config: NextConfig): NextConfig =>
...config,
serverExternalPackages: [
...(config.serverExternalPackages ?? []),
- "ioredis",
+ "redis",
],
});
diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json
index 4c63edb60..7c91a77b0 100644
--- a/packages/vitnode/package.json
+++ b/packages/vitnode/package.json
@@ -43,8 +43,6 @@
"@react-email/ui": "^6.9.0",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.46",
- "@testing-library/dom": "^10.4.1",
- "@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
@@ -81,6 +79,31 @@
"vitnode": "./cli.mjs"
},
"exports": {
+ "./content": {
+ "import": "./dist/src/content/index.js",
+ "types": "./dist/src/content/index.d.ts",
+ "default": "./dist/src/content/index.js"
+ },
+ "./content/server": {
+ "import": "./dist/src/content/server/index.js",
+ "types": "./dist/src/content/server/index.d.ts",
+ "default": "./dist/src/content/server/index.js"
+ },
+ "./content/next": {
+ "import": "./dist/src/content/next/index.js",
+ "types": "./dist/src/content/next/index.d.ts",
+ "default": "./dist/src/content/next/index.js"
+ },
+ "./content/next/revalidate-route": {
+ "import": "./dist/src/content/next/revalidate-route.server.js",
+ "types": "./dist/src/content/next/revalidate-route.server.d.ts",
+ "default": "./dist/src/content/next/revalidate-route.server.js"
+ },
+ "./content/admin-form": {
+ "import": "./dist/src/views/admin/views/content/form/index.js",
+ "types": "./dist/src/views/admin/views/content/form/index.d.ts",
+ "default": "./dist/src/views/admin/views/content/form/index.js"
+ },
"./api/config": {
"import": "./dist/src/api/config.js",
"types": "./dist/src/api/config.d.ts",
@@ -104,6 +127,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
+ "test:types": "vitest run --typecheck.only",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
@@ -131,7 +155,6 @@
"dotenv": "^17.4.2",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
- "ioredis": "^5.11.1",
"motion": "^12.42.2",
"next-themes": "^0.4.6",
"postgres": "^3.4.9",
@@ -141,6 +164,7 @@
"react-resizable-panels": "^4.12.2",
"react-scan": "^0.5.7",
"recharts": "^3.10.0",
+ "redis": "^6.2.1",
"server-only": "^0.0.1",
"shadcn": "^4.14.0",
"sharp": "^0.35.3",
diff --git a/packages/vitnode/scripts/build.test.ts b/packages/vitnode/scripts/build.test.ts
deleted file mode 100644
index ac13c04bc..000000000
--- a/packages/vitnode/scripts/build.test.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const { runInteractiveShellCommand } = vi.hoisted(() => ({
- runInteractiveShellCommand: vi.fn(),
-}));
-
-vi.mock("./run-interactive-shell-command.js", () => ({
- runInteractiveShellCommand,
-}));
-
-import { buildPlugin } from "./build";
-
-describe("buildPlugin", () => {
- beforeEach(() => {
- runInteractiveShellCommand.mockReset();
- runInteractiveShellCommand.mockResolvedValue(true);
- });
-
- it("runs tsc, swc and tsc-alias in order", async () => {
- await buildPlugin();
-
- expect(runInteractiveShellCommand.mock.calls.map(call => call[0])).toEqual([
- "tsc",
- "swc",
- "tsc-alias",
- ]);
- });
-
- it("passes the build tsconfig and swc config to the tools", async () => {
- await buildPlugin();
-
- expect(runInteractiveShellCommand).toHaveBeenNthCalledWith(1, "tsc", [
- "-p",
- "tsconfig.build.json",
- ]);
- expect(runInteractiveShellCommand).toHaveBeenNthCalledWith(2, "swc", [
- "src",
- "-d",
- "dist",
- "--config-file",
- ".swcrc",
- "--copy-files",
- ]);
- expect(runInteractiveShellCommand).toHaveBeenNthCalledWith(3, "tsc-alias", [
- "-p",
- "tsconfig.build.json",
- ]);
- });
-
- it("stops on the first failing step", async () => {
- runInteractiveShellCommand.mockReset();
- runInteractiveShellCommand.mockRejectedValueOnce(new Error("tsc failed"));
-
- await expect(buildPlugin()).rejects.toThrow("tsc failed");
- expect(runInteractiveShellCommand).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts
index 9f8f3ec43..3a4ee863f 100644
--- a/packages/vitnode/src/api/adapters/search/postgres.ts
+++ b/packages/vitnode/src/api/adapters/search/postgres.ts
@@ -75,7 +75,19 @@ const buildFilters = (params: SearchQueryParams): SQL | undefined => {
export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({
name: "postgres",
- capabilities: { authorBoost: false, facets: false, timeDecay: false },
+ // 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,
+ },
// The SearchModel owns the canonical `core_search_index` table, which is this
// provider's store, so the write methods are intentionally no-ops.
diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts
index 90b1683ef..90bfc81c6 100644
--- a/packages/vitnode/src/api/config.ts
+++ b/packages/vitnode/src/api/config.ts
@@ -83,6 +83,7 @@ export function VitNodeAPI({
authorization: vitNodeApiConfig.authorization,
dbProvider: vitNodeApiConfig.dbProvider,
captcha: vitNodeApiConfig.captcha,
+ content: vitNodeApiConfig.content,
cron: vitNodeApiConfig.cron,
events: vitNodeApiConfig.events,
search: vitNodeApiConfig.search,
diff --git a/packages/vitnode/src/api/lib/cache-client.ts b/packages/vitnode/src/api/lib/cache-client.ts
index 4abb0bd6c..1bfa310f3 100644
--- a/packages/vitnode/src/api/lib/cache-client.ts
+++ b/packages/vitnode/src/api/lib/cache-client.ts
@@ -1,17 +1,14 @@
-import { Redis } from "ioredis";
+import { createClient } from "redis";
-import type { CacheConfig } from "./cache";
+import type { CacheClient, CacheConfig } from "./cache";
-export const createCacheClient = (config?: CacheConfig): null | Redis => {
+export const createCacheClient = (config?: CacheConfig): CacheClient | null => {
if (!config) return null;
- const { url, ...options } = config;
- // `enableOfflineQueue: false` makes commands fail fast (instead of queueing)
+ // `disableOfflineQueue: true` makes commands fail fast (instead of queueing)
// when Redis is unreachable, so `remember` falls through to its loader rather
// than hanging. Callers can override it via the config.
- const client = url
- ? new Redis(url, { enableOfflineQueue: false, ...options })
- : new Redis({ enableOfflineQueue: false, ...options });
+ const client = createClient({ disableOfflineQueue: true, ...config });
// Without a listener, an "error" event (e.g. Redis down) is thrown as an
// unhandled exception and crashes the process. Swallow it here.
@@ -19,5 +16,13 @@ export const createCacheClient = (config?: CacheConfig): null | Redis => {
/* cache methods handle failures individually */
});
+ // node-redis does not connect on construction. Kick the connection off here
+ // and let the built-in reconnect strategy keep retrying in the background -
+ // until the socket is ready every command rejects, which the cache and the
+ // rate limiter already treat as "no Redis".
+ void client.connect().catch(() => {
+ /* the reconnect strategy keeps retrying; commands fail fast meanwhile */
+ });
+
return client;
};
diff --git a/packages/vitnode/src/api/lib/cache.test.ts b/packages/vitnode/src/api/lib/cache.test.ts
deleted file mode 100644
index e57cf4ed1..000000000
--- a/packages/vitnode/src/api/lib/cache.test.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { Context } from "hono";
-import type { Redis } from "ioredis";
-
-import { describe, expect, it, vi } from "vitest";
-
-import { CacheModel } from "./cache";
-
-const fakeContext = { get: () => undefined } as unknown as Context;
-const LOCK_KEY = "vitnode:cache:__system__:lock:queue:process";
-
-describe("CacheModel locks", () => {
- describe("without Redis", () => {
- const cache = new CacheModel(null, fakeContext);
-
- it("acquireLock returns true so cache-less deployments still proceed", async () => {
- await expect(cache.acquireLock("queue:process", 55)).resolves.toBe(true);
- });
-
- it("releaseLock is a no-op", async () => {
- await expect(cache.releaseLock("queue:process")).resolves.toBeUndefined();
- });
- });
-
- describe("with Redis", () => {
- it("acquires the lock with SET NX EX in the system namespace", async () => {
- const set = vi.fn().mockResolvedValue("OK");
- const cache = new CacheModel({ set } as unknown as Redis, fakeContext);
-
- await expect(cache.acquireLock("queue:process", 55)).resolves.toBe(true);
- expect(set).toHaveBeenCalledWith(LOCK_KEY, "1", "EX", 55, "NX");
- });
-
- it("returns false when the lock is already held", async () => {
- const set = vi.fn().mockResolvedValue(null);
- const cache = new CacheModel({ set } as unknown as Redis, fakeContext);
-
- await expect(cache.acquireLock("queue:process", 55)).resolves.toBe(false);
- });
-
- it("returns false when Redis errors, to skip rather than double-run", async () => {
- const set = vi.fn().mockRejectedValue(new Error("down"));
- const cache = new CacheModel({ set } as unknown as Redis, fakeContext);
-
- await expect(cache.acquireLock("queue:process", 55)).resolves.toBe(false);
- });
-
- it("releaseLock deletes the lock key", async () => {
- const del = vi.fn().mockResolvedValue(1);
- const cache = new CacheModel({ del } as unknown as Redis, fakeContext);
-
- await cache.releaseLock("queue:process");
- expect(del).toHaveBeenCalledWith(LOCK_KEY);
- });
- });
-});
diff --git a/packages/vitnode/src/api/lib/cache.ts b/packages/vitnode/src/api/lib/cache.ts
index 95f710859..9564dd6f1 100644
--- a/packages/vitnode/src/api/lib/cache.ts
+++ b/packages/vitnode/src/api/lib/cache.ts
@@ -1,7 +1,11 @@
import type { Context } from "hono";
-import type { Redis, RedisOptions } from "ioredis";
+import type { RedisClientOptions, RedisClientType } from "redis";
-export type CacheConfig = RedisOptions & { url?: string };
+/** Connection options accepted by `redis` - `url` plus any client option. */
+export type CacheConfig = RedisClientOptions;
+
+/** The connected `node-redis` client shared by the cache, rate limiter and ws. */
+export type CacheClient = RedisClientType;
/**
* Root prefix applied to every key VitNode writes, so the cache can be flushed
@@ -21,13 +25,13 @@ const SYSTEM_NAMESPACE = "__system__";
* never break a request.
*/
export class CacheModel {
- constructor(client: null | Redis, c: Context) {
+ constructor(client: CacheClient | null, c: Context) {
this.c = c;
this.client = client;
}
protected readonly c: Context;
- protected readonly client: null | Redis;
+ protected readonly client: CacheClient | null;
private key(key: string): string {
return `${this.prefix()}${key}`;
@@ -61,7 +65,7 @@ export class CacheModel {
if (!this.client) return;
try {
- if (fullKeys.length > 0) await this.client.del(...fullKeys);
+ if (fullKeys.length > 0) await this.client.del(fullKeys);
} catch {
/* swallow */
}
@@ -81,7 +85,9 @@ export class CacheModel {
try {
const raw = JSON.stringify(value);
if (ttlSeconds && ttlSeconds > 0) {
- await this.client.set(fullKey, raw, "EX", ttlSeconds);
+ await this.client.set(fullKey, raw, {
+ expiration: { type: "EX", value: ttlSeconds },
+ });
} else {
await this.client.set(fullKey, raw);
}
@@ -102,13 +108,10 @@ export class CacheModel {
if (!this.client) return true;
try {
- const result = await this.client.set(
- this.systemKey(`lock:${key}`),
- "1",
- "EX",
- ttlSeconds,
- "NX",
- );
+ const result = await this.client.set(this.systemKey(`lock:${key}`), "1", {
+ condition: "NX",
+ expiration: { type: "EX", value: ttlSeconds },
+ });
return result === "OK";
} catch {
@@ -140,14 +143,14 @@ export class CacheModel {
if (!this.client) return;
try {
- const stream = this.client.scanStream({
- match: `${this.prefix()}*`,
- count: 100,
+ // `scanIterator` yields a batch of keys per SCAN round-trip.
+ const scan = this.client.scanIterator({
+ MATCH: `${this.prefix()}*`,
+ COUNT: 100,
});
- for await (const keys of stream) {
- const batch = keys as string[];
- if (batch.length > 0) await this.client.del(...batch);
+ for await (const keys of scan) {
+ if (keys.length > 0) await this.client.del(keys);
}
} catch {
/* swallow */
diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts
index 1c8bcbf61..00fcd8246 100644
--- a/packages/vitnode/src/api/lib/module.ts
+++ b/packages/vitnode/src/api/lib/module.ts
@@ -1,5 +1,9 @@
import { OpenAPIHono } from "@hono/zod-openapi";
+import type { AnyContentModel } from "@/content/server/model";
+import type { AnyContentTypeDefinition } from "@/content/types";
+
+import type { SearchIndexer } from "../models/search";
import type { BuildCronReturn } from "./cron";
import type { BuildEventListenerReturn } from "./events";
import type { BuildQueueTaskReturn } from "./queue";
@@ -16,6 +20,23 @@ export interface BaseBuildModuleReturn<
M extends string = string,
Routes extends Route
[] = Route
[],
> {
+ /**
+ * The models behind those content types - table, columns, schemas and
+ * services, not just the definition.
+ *
+ * Collected recursively like `contentTypes`, and exposed on the request
+ * context so background work can find the model for a content type id. The
+ * scheduled-publication queue task is the reason it exists: it runs in a cron
+ * request that has no idea which plugin owns the record it is publishing.
+ */
+ contentModels?: AnyContentModel[];
+ /**
+ * Content types whose CRUD routes this module serves. Unlike `events` and
+ * `cronJobs`, these are collected recursively by `buildApiPlugin`, so a
+ * generated content module can sit wherever it reads best in the tree -
+ * usually nested inside the plugin's own `admin` module.
+ */
+ contentTypes?: AnyContentTypeDefinition[];
cronJobs: BuildCronReturn[];
events: BuildEventListenerReturn[];
hono: OpenAPIHono;
@@ -24,6 +45,7 @@ export interface BaseBuildModuleReturn<
pluginId: P;
queueTasks: BuildQueueTaskReturn[];
routes: Routes;
+ searchIndexers?: SearchIndexer[];
webSockets: BuildWebSocketReturn[];
}
@@ -46,11 +68,16 @@ export function buildModule<
pluginId,
name,
modules,
+ contentModels,
+ contentTypes,
cronJobs = [],
events = [],
queueTasks = [],
+ searchIndexers,
webSockets = [],
}: {
+ contentModels?: AnyContentModel[];
+ contentTypes?: AnyContentTypeDefinition[];
cronJobs?: BuildCronReturn[];
events?: BuildEventListenerReturn[];
modules?: Modules;
@@ -58,6 +85,7 @@ export function buildModule<
pluginId: P;
queueTasks?: BuildQueueTaskReturn[];
routes: Routes;
+ searchIndexers?: SearchIndexer[];
webSockets?: BuildWebSocketReturn[];
}): BuildModuleReturn
{
const hono = new OpenAPIHono();
@@ -80,9 +108,12 @@ export function buildModule<
hono,
name,
modules,
+ contentModels,
+ contentTypes,
cronJobs,
events,
queueTasks,
+ searchIndexers,
webSockets,
};
}
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..a6286b2f7
--- /dev/null
+++ b/packages/vitnode/src/api/lib/pagination-cursor.test.ts
@@ -0,0 +1,445 @@
+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";
+
+const probes = pgTable("cursor_probes", {
+ big: bigint({ mode: "bigint" }),
+ clock: time(),
+ clockTz: time({ withTimezone: true }),
+ day: date(),
+ moment: timestamp(),
+});
+
+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",
+ });
+
+ 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", () => {
+ 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", () => {
+ 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", () => {
+ 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", () => {
+ 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);
+ });
+});
+
+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", () => {
+ 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) => {
+ 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) => {
+ 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);
+ });
+
+ 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", () => {
+ const value = "2026-08-09 10:00:00.000001";
+
+ expect(cursorValueForColumn(core_users.createdAt, value)).toBe(value);
+ });
+ });
+ });
+
+ 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", () => {
+ 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", () => {
+ 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", () => {
+ 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/plugin.test.ts b/packages/vitnode/src/api/lib/plugin.test.ts
new file mode 100644
index 000000000..e00296f1a
--- /dev/null
+++ b/packages/vitnode/src/api/lib/plugin.test.ts
@@ -0,0 +1,219 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import {
+ testArticleContentType,
+ testCategoryContentType,
+} from "@/tests/content-fixtures";
+
+import type { SearchIndexer } from "../models/search";
+
+import { validateSearchIndexers } from "../models/search";
+import { buildModule } from "./module";
+import { buildApiPlugin } from "./plugin";
+
+const contentModule = buildModule({
+ pluginId: "@vitnode/example",
+ name: "content",
+ routes: [],
+ contentTypes: [testArticleContentType, testCategoryContentType],
+});
+
+const adminModule = buildModule({
+ pluginId: "@vitnode/example",
+ name: "admin",
+ routes: [],
+ modules: [contentModule],
+});
+
+describe("buildApiPlugin content types", () => {
+ it("collects content types from nested modules", () => {
+ const plugin = buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [adminModule],
+ });
+
+ expect(plugin.contentTypes?.map(item => item.id)).toEqual([
+ "test.article",
+ "test.category",
+ ]);
+ });
+
+ it("derives staff permissions for each collected content type", () => {
+ const plugin = buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [adminModule],
+ });
+
+ expect(plugin.permissionStaff?.admin?.article).toEqual([
+ "can_view",
+ { dependsOn: ["can_view"], permission: "can_create" },
+ { dependsOn: ["can_view"], permission: "can_edit" },
+ { dependsOn: ["can_view"], permission: "can_delete" },
+ ]);
+ expect(plugin.permissionStaff?.admin?.category).toBeDefined();
+ });
+
+ it("keeps hand-declared permissions and other modules intact", () => {
+ const plugin = buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [adminModule],
+ permissionStaff: {
+ admin: { article: ["can_view"], posts: ["can_view"] },
+ moderator: { posts: ["can_edit"] },
+ },
+ });
+
+ expect(plugin.permissionStaff?.admin?.article).toEqual(["can_view"]);
+ expect(plugin.permissionStaff?.admin?.posts).toEqual(["can_view"]);
+ expect(plugin.permissionStaff?.moderator?.posts).toEqual(["can_edit"]);
+ });
+
+ it("leaves permissionStaff untouched for a plugin with no content types", () => {
+ const plugin = buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [],
+ });
+
+ expect(plugin.contentTypes).toEqual([]);
+ expect(plugin.permissionStaff).toBeUndefined();
+ });
+
+ it("rejects two content types sharing a table inside one plugin", () => {
+ const duplicate = buildModule({
+ pluginId: "@vitnode/example",
+ name: "content",
+ routes: [],
+ contentTypes: [testArticleContentType, testArticleContentType],
+ });
+
+ expect(() =>
+ buildApiPlugin({ pluginId: "@vitnode/example", modules: [duplicate] }),
+ ).toThrow(/Duplicate content type id/);
+ });
+});
+
+const indexer = (itemType: string): SearchIndexer => ({
+ itemType,
+ load: async () => await Promise.resolve({ documents: [], itemsRead: 0 }),
+});
+
+describe("buildApiPlugin search indexers", () => {
+ it("collects indexers from nested modules", () => {
+ const nested = buildModule({
+ pluginId: "@vitnode/example",
+ name: "content",
+ routes: [],
+ searchIndexers: [indexer("test.article")],
+ });
+
+ const plugin = buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [
+ buildModule({
+ pluginId: "@vitnode/example",
+ name: "admin",
+ routes: [],
+ modules: [nested],
+ }),
+ ],
+ });
+
+ expect(plugin.searchIndexers?.map(item => item.itemType)).toEqual([
+ "test.article",
+ ]);
+ });
+
+ it("merges root-level indexers with collected ones", () => {
+ const plugin = buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [
+ buildModule({
+ pluginId: "@vitnode/example",
+ name: "content",
+ routes: [],
+ searchIndexers: [indexer("test.article")],
+ }),
+ ],
+ searchIndexers: [indexer("blog_post")],
+ });
+
+ expect(plugin.searchIndexers?.map(item => item.itemType)).toEqual([
+ "blog_post",
+ "test.article",
+ ]);
+ });
+
+ it("leaves a plugin with no indexers alone", () => {
+ const plugin = buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [adminModule],
+ });
+
+ expect(plugin.searchIndexers).toEqual([]);
+ });
+
+ it("rejects the same item type registered twice", () => {
+ expect(() =>
+ buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [
+ buildModule({
+ pluginId: "@vitnode/example",
+ name: "content",
+ routes: [],
+ searchIndexers: [indexer("test.article")],
+ }),
+ ],
+ searchIndexers: [indexer("test.article")],
+ }),
+ ).toThrow(/Duplicate search indexer for item type "test.article"/);
+ });
+});
+
+describe("validateSearchIndexers", () => {
+ it("retains each plugin's ownership after collection", () => {
+ // What the AdminCP reports a collection's owner from, and what the rebuild
+ // stamps on a legacy document.
+ const collected = [
+ ...(buildApiPlugin({
+ pluginId: "@vitnode/example",
+ searchIndexers: [indexer("test.article")],
+ }).searchIndexers ?? []),
+ ].map(item => ({ ...item, pluginId: "@vitnode/example" }));
+ const other = [
+ ...(buildApiPlugin({
+ pluginId: "@vitnode/blog",
+ searchIndexers: [indexer("blog_post")],
+ }).searchIndexers ?? []),
+ ].map(item => ({ ...item, pluginId: "@vitnode/blog" }));
+
+ expect(
+ validateSearchIndexers([...collected, ...other]).map(item => [
+ item.itemType,
+ item.pluginId,
+ ]),
+ ).toEqual([
+ ["test.article", "@vitnode/example"],
+ ["blog_post", "@vitnode/blog"],
+ ]);
+ });
+
+ it("names both owners of a collision", () => {
+ expect(() =>
+ validateSearchIndexers([
+ { ...indexer("blog_post"), pluginId: "@vitnode/blog" },
+ { ...indexer("blog_post"), pluginId: "@vitnode/other" },
+ ]),
+ ).toThrow(/both "@vitnode\/blog" and "@vitnode\/other"/);
+ });
+
+ it("passes distinct item types through", () => {
+ expect(
+ validateSearchIndexers([
+ { ...indexer("blog_post"), pluginId: "@vitnode/blog" },
+ { ...indexer("test.article"), pluginId: "@vitnode/example" },
+ ]).map(item => item.itemType),
+ ).toEqual(["blog_post", "test.article"]);
+ });
+});
diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts
index 24e165a69..c559072e5 100644
--- a/packages/vitnode/src/api/lib/plugin.ts
+++ b/packages/vitnode/src/api/lib/plugin.ts
@@ -1,18 +1,29 @@
import { OpenAPIHono } from "@hono/zod-openapi";
+import type { RegisteredContentType } from "@/content/registry";
+import type { AnyContentModel } from "@/content/server/model";
+import type { AnyContentTypeDefinition } from "@/content/types";
import type { LocaleMessagesMap } from "@/lib/i18n/types";
+import {
+ validateContentTypes,
+ withContentPermissions,
+} from "@/content/registry";
+
import type { SearchIndexer } from "../models/search";
import type { CronJobConfig } from "./cron";
import type { EventListenerConfig } from "./events";
-import type { BuildModuleReturn } from "./module";
+import type { BaseBuildModuleReturn, BuildModuleReturn } from "./module";
import type { PermissionStaffConfig } from "./permission-staff";
import type { QueueTaskConfig } from "./queue";
import type { WebSocketConfig } from "./websocket";
+import { validateSearchIndexers } from "../models/search";
import { checkPluginId } from "./check-plugin-id";
export interface BuildPluginApiReturn {
+ contentModels?: AnyContentModel[];
+ contentTypes?: AnyContentTypeDefinition[];
cronJobs?: Omit[];
events?: Omit[];
hono: OpenAPIHono;
@@ -47,13 +58,20 @@ export function buildApiPlugin
({
});
});
+ const registered: RegisteredContentType[] = validateContentTypes(
+ contentTypes.map(definition => ({ definition, pluginId })),
+ );
+
+ validateSearchIndexers(indexers.map(indexer => ({ ...indexer, pluginId })));
+
return {
pluginId,
messages,
hono,
+ contentModels,
+ contentTypes: registered.map(entry => entry.definition),
cronJobs,
events,
queueTasks,
- searchIndexers,
+ searchIndexers: indexers,
webSockets,
- permissionStaff,
+ // Every content type contributes can_view/can_create/can_edit/can_delete
+ // unless the plugin declared that module itself.
+ permissionStaff: withContentPermissions(permissionStaff, registered),
};
}
+
+/**
+ * Walks the whole module tree. Content types are collected recursively - unlike
+ * `events`, `cronJobs` and friends, which only come from top-level modules - so
+ * a generated content module can be nested inside the plugin's `admin` module
+ * and still register its permissions.
+ */
+function collectContentTypes(
+ module: BaseBuildModuleReturn,
+): AnyContentTypeDefinition[] {
+ return [
+ ...(module.contentTypes ?? []),
+ ...(module.modules ?? []).flatMap(collectContentTypes),
+ ];
+}
+
+/** Same walk as {@link collectContentTypes}, and for the same reason. */
+function collectContentModels(
+ module: BaseBuildModuleReturn,
+): AnyContentModel[] {
+ return [
+ ...(module.contentModels ?? []),
+ ...(module.modules ?? []).flatMap(collectContentModels),
+ ];
+}
+
+function collectSearchIndexers(module: BaseBuildModuleReturn): SearchIndexer[] {
+ return [
+ ...(module.searchIndexers ?? []),
+ ...(module.modules ?? []).flatMap(collectSearchIndexers),
+ ];
+}
diff --git a/packages/vitnode/src/api/lib/route.test.ts b/packages/vitnode/src/api/lib/route.test.ts
new file mode 100644
index 000000000..e9272f91e
--- /dev/null
+++ b/packages/vitnode/src/api/lib/route.test.ts
@@ -0,0 +1,88 @@
+// @vitest-environment node
+import type { MiddlewareHandler } from "hono";
+
+import { OpenAPIHono, z } from "@hono/zod-openapi";
+import { describe, expect, it } from "vitest";
+
+import { buildRoute } from "./route";
+
+const okResponse = {
+ 200: {
+ content: {
+ "application/json": { schema: z.object({ plugin: z.string() }) },
+ },
+ description: "ok",
+ },
+} as const;
+
+const mount = (route: ReturnType) => {
+ const app = new OpenAPIHono();
+ app.openapi(route.route, route.handler);
+
+ return app;
+};
+
+describe("buildRoute", () => {
+ it("keeps pluginMiddleware when the route brings its own middleware", async () => {
+ const marks: string[] = [];
+ const custom: MiddlewareHandler = async (_c, next) => {
+ marks.push("custom");
+ await next();
+ };
+
+ const route = buildRoute({
+ pluginId: "@vitnode/test",
+ route: {
+ method: "get",
+ path: "/",
+ middleware: [custom],
+ responses: okResponse,
+ },
+ handler: c => c.json({ plugin: c.get("plugin").id }, 200),
+ });
+
+ const res = await mount(route).request("/");
+
+ expect(res.status).toBe(200);
+ // Without the fix the `...route` spread replaced the composed array and
+ // `c.get("plugin")` was undefined.
+ expect(await res.json()).toEqual({ plugin: "@vitnode/test" });
+ expect(marks).toEqual(["custom"]);
+ });
+
+ it("composes pluginMiddleware, the permission guard and route middleware in order", () => {
+ const custom: MiddlewareHandler = async (_c, next) => next();
+
+ const { route } = buildRoute({
+ pluginId: "@vitnode/test",
+ adminStaffPermission: { module: "articles", permission: "can_view" },
+ route: {
+ method: "get",
+ path: "/",
+ middleware: [custom],
+ responses: okResponse,
+ },
+ handler: c => c.json({ plugin: c.get("plugin").id }, 200),
+ });
+
+ const middleware = route.middleware;
+
+ expect(middleware).toHaveLength(3);
+ expect(middleware.at(-1)).toBe(custom);
+ });
+
+ it("prepends the plugin tag and keeps route tags", () => {
+ const { route } = buildRoute({
+ pluginId: "@vitnode/test_plugin",
+ route: {
+ method: "get",
+ path: "/",
+ tags: ["Articles"],
+ responses: okResponse,
+ },
+ handler: c => c.json({ plugin: c.get("plugin").id }, 200),
+ });
+
+ expect(route.tags).toEqual(["@vitnode/test Plugin", "Articles"]);
+ });
+});
diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts
index e4e4f7830..c40ddb664 100644
--- a/packages/vitnode/src/api/lib/route.ts
+++ b/packages/vitnode/src/api/lib/route.ts
@@ -67,9 +67,12 @@ export const buildRoute = <
return {
route: createRouteHono({
+ // `route` is spread first on purpose: `tags` and `middleware` already
+ // merge the route's own values, so letting the spread win would drop
+ // `pluginMiddleware` and the staff-permission guard.
+ ...route,
tags,
middleware,
- ...route,
}),
handler: handler as Route["handler"],
pluginId,
diff --git a/packages/vitnode/src/api/lib/save-language-words.test.ts b/packages/vitnode/src/api/lib/save-language-words.test.ts
deleted file mode 100644
index 9247a8c0b..000000000
--- a/packages/vitnode/src/api/lib/save-language-words.test.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-// @vitest-environment node
-import type { Context } from "hono";
-
-import { describe, expect, it, vi } from "vitest";
-
-import { core_languages_words } from "@/database/languages";
-
-import { saveLanguageWords } from "./save-language-words";
-
-const createDbMock = () => {
- const insertValues = vi.fn();
- const where = vi.fn();
- const tx = {
- delete: vi.fn(() => ({ where })),
- insert: vi.fn(() => ({ values: insertValues })),
- };
- const db = {
- transaction: vi.fn(async (cb: (t: typeof tx) => Promise) => cb(tx)),
- };
-
- return { db, tx, insertValues, where };
-};
-
-const createContext = (db: unknown): Context =>
- ({ get: (key: string) => (key === "db" ? db : undefined) }) as Context;
-
-describe("saveLanguageWords", () => {
- it("replaces the tuple rows: delete then insert the mapped values", async () => {
- const { db, tx, insertValues } = createDbMock();
-
- await saveLanguageWords(createContext(db), {
- pluginCode: "blog",
- tableName: "blog_categories",
- variable: "name",
- itemId: 7,
- values: [
- { languageCode: "en", value: "News" },
- { languageCode: "pl", value: "Aktualności" },
- ],
- });
-
- expect(db.transaction).toHaveBeenCalledOnce();
- expect(tx.delete).toHaveBeenCalledWith(core_languages_words);
- expect(tx.insert).toHaveBeenCalledWith(core_languages_words);
- expect(insertValues).toHaveBeenCalledWith([
- {
- languageCode: "en",
- pluginCode: "blog",
- itemId: 7,
- value: "News",
- tableName: "blog_categories",
- variable: "name",
- },
- {
- languageCode: "pl",
- pluginCode: "blog",
- itemId: 7,
- value: "Aktualności",
- tableName: "blog_categories",
- variable: "name",
- },
- ]);
- });
-
- it("deletes but does not insert when values is empty", async () => {
- const { db, tx, insertValues } = createDbMock();
-
- await saveLanguageWords(createContext(db), {
- pluginCode: "blog",
- tableName: "blog_categories",
- variable: "name",
- itemId: 7,
- values: [],
- });
-
- expect(tx.delete).toHaveBeenCalledOnce();
- expect(tx.insert).not.toHaveBeenCalled();
- expect(insertValues).not.toHaveBeenCalled();
- });
-});
diff --git a/packages/vitnode/src/api/lib/with-pagination.ts b/packages/vitnode/src/api/lib/with-pagination.ts
index 5a0964d8f..fbaf2d2c4 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,196 @@ 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 rest: Partial = { ...row };
+ delete rest[PAGINATION_CURSOR_FIELD];
+
+ return rest as Omit;
+}
+
+/**
+ * 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/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index 0a6e738e3..9dd4fc79e 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -1,8 +1,10 @@
import type { Context, Env, Next } from "hono";
-import type { Redis } from "ioredis";
import { HTTPException } from "hono/http-exception";
+import type { CacheClient } from "@/api/lib/cache";
+import type { RegisteredContentType } from "@/content/registry";
+import type { RegisteredContentModel } from "@/content/server/model";
import type { LocaleConfig, MessagesSource } from "@/lib/i18n/types";
import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config";
import type { VitNodeRealtime } from "@/ws/registry";
@@ -15,10 +17,18 @@ import { EmailModel } from "@/api/models/email";
import { EventsModel } from "@/api/models/events";
import { I18nModel } from "@/api/models/i18n";
import { QueueModel } from "@/api/models/queue";
-import { SearchModel } from "@/api/models/search";
+import {
+ assertSearchProviderCapabilities,
+ SearchModel,
+ validateSearchIndexers,
+} from "@/api/models/search";
import { SessionModel } from "@/api/models/session";
import { SessionAdminModel } from "@/api/models/session-admin";
import { StorageModel } from "@/api/models/storage";
+import { validateContentTypes } from "@/content/registry";
+import { ensureContentLocalizationLanguages } from "@/content/server/language-resolver";
+import { warnAboutContentPreviewConfig } from "@/content/server/preview-config";
+import { ensureContentPreviewSecret } from "@/content/server/preview-secret";
import { CONFIG } from "@/lib/config";
import { collectLocaleCodes } from "@/lib/i18n/load-messages";
import { buildApiMessagesSources } from "@/lib/i18n/sources";
@@ -83,6 +93,25 @@ export interface EnvVariablesVitNode {
ssoAdapters: SSOApiPlugin[];
};
captcha?: Pick["captcha"];
+ /**
+ * Every registered content type's *model*, with the plugin that owns it.
+ *
+ * Background work has only a content type id to go on - a queue handler
+ * runs in a cron request with no plugin context at all - so the lookup from
+ * id to table, service and owner has to live somewhere it can reach.
+ */
+ contentModels: RegisteredContentModel[];
+ /**
+ * Signs content preview links.
+ *
+ * Generated by the install and stored in `core_secrets`. Absent only when
+ * no content type has `editorial.preview` enabled, i.e. when there is
+ * nothing to sign.
+ */
+ contentPreviewSecret?: string;
+ /** Web origins the background cache bridge posts to. */
+ contentRevalidateOrigins?: string[];
+ contentTypes: RegisteredContentType[];
cron: (BuildCronReturn & { module: string; pluginId: string })[];
cronSecret?: string;
email?: VitNodeApiConfig["email"];
@@ -139,6 +168,7 @@ export interface EnvVariablesVitNode {
export const globalMiddleware = ({
ai,
authorization,
+ content,
metadata,
email,
dbProvider,
@@ -155,6 +185,7 @@ export const globalMiddleware = ({
| "ai"
| "authorization"
| "captcha"
+ | "content"
| "cron"
| "dbProvider"
| "email"
@@ -164,7 +195,7 @@ export const globalMiddleware = ({
| "search"
| "storage"
> &
- Pick & { cacheClient: null | Redis }) => {
+ Pick & { cacheClient: CacheClient | null }) => {
const pluginsMetadata = plugins.map(plugin => ({
id: plugin.pluginId,
}));
@@ -212,14 +243,75 @@ export const globalMiddleware = ({
})),
);
- const searchIndexersMetadata: SearchIndexerConfig[] = plugins.flatMap(
- plugin =>
+ // Validated across *all* plugins, for the same reason content types are:
+ // `buildApiPlugin` can only catch collisions inside a single plugin.
+ const searchIndexersMetadata: SearchIndexerConfig[] = validateSearchIndexers(
+ plugins.flatMap(plugin =>
(plugin.searchIndexers ?? []).map(indexer => ({
...indexer,
pluginId: plugin.pluginId,
})),
+ ),
+ );
+
+ // Validated once more across *all* plugins: `buildApiPlugin` can only catch
+ // collisions inside a single plugin.
+ const contentTypesMetadata: RegisteredContentType[] = validateContentTypes(
+ plugins.flatMap(plugin =>
+ (plugin.contentTypes ?? []).map(definition => ({
+ definition,
+ pluginId: plugin.pluginId,
+ })),
+ ),
+ );
+
+ // Once, here, because "does anything have preview enabled" is only answerable
+ // after every plugin's content types are in. A warning, never a boot failure:
+ // preview is one content type's opt-in feature, not a prerequisite for the
+ // API.
+ warnAboutContentPreviewConfig({ contentTypes: contentTypesMetadata });
+
+ // Whether anything can mint a preview link at all - and so whether this
+ // install has any reason to hold a signing key. An install with no previewable
+ // content type never touches `core_secrets` because of this.
+ const hasPreviewableContentTypes = contentTypesMetadata.some(
+ entry => entry.definition.editorial.preview.enabled,
+ );
+
+ // Not validated: a model carries the definition that `contentTypesMetadata`
+ // already checked, so a second pass would only repeat the same errors.
+ const contentModelsMetadata: RegisteredContentModel[] = plugins.flatMap(
+ plugin =>
+ (plugin.contentModels ?? []).map(model => ({
+ model,
+ pluginId: plugin.pluginId,
+ })),
);
+ // Computed at boot, outside the request: "does anything need the languages
+ // table" is a property of the installed plugins, not of a request.
+ const hasLocalizedContentTypes = contentTypesMetadata.some(
+ entry => entry.definition.localization.enabled,
+ );
+
+ // Resolved once rather than per request - the adapter cannot change between
+ // them - which is also what makes the capability check below a boot-time fact.
+ const searchAdapter = search?.adapter ?? PostgresSearchAdapter();
+
+ // A localized searchable content type is indexed once per translation, so
+ // taking one translation down has to remove one document. A provider that
+ // ignores the language would remove them all and say nothing, so the pairing
+ // is refused here rather than discovered by whoever deletes a translation.
+ assertSearchProviderCapabilities(searchAdapter, {
+ localizedSearchContentTypes: contentTypesMetadata
+ .filter(
+ entry =>
+ entry.definition.localization.enabled &&
+ entry.definition.search.enabled,
+ )
+ .map(entry => entry.definition.id),
+ });
+
const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map(
plugin => ({
pluginId: plugin.pluginId,
@@ -274,6 +366,13 @@ export const globalMiddleware = ({
c.set("storage", new StorageModel(c));
c.set("realtime", realtime);
+ // Resolved before `core` is set rather than per mint, so the integrations
+ // panel and the routes read the same value. Memoised, so this is one query
+ // on the first request of the process and nothing afterwards.
+ const contentPreviewSecret = hasPreviewableContentTypes
+ ? await ensureContentPreviewSecret(dbProvider)
+ : undefined;
+
c.set("core", {
ai,
i18n: i18nMetadata,
@@ -283,7 +382,7 @@ export const globalMiddleware = ({
adapter: events?.adapter ?? LocalEventsAdapter(),
listeners: eventsMetadata,
},
- search: { adapter: search?.adapter ?? PostgresSearchAdapter() },
+ search: { adapter: searchAdapter },
searchIndexers: searchIndexersMetadata,
storage,
authorization: {
@@ -300,6 +399,7 @@ export const globalMiddleware = ({
cookieSecure: authorization?.cookieSecure ?? true,
},
captcha,
+ contentPreviewSecret,
cronSecret: CONFIG.cronJobSecret,
hasCronAdapter: !!cron,
plugins: pluginsMetadata,
@@ -307,8 +407,21 @@ export const globalMiddleware = ({
queue: queueMetadata,
webSockets: webSocketsMetadata,
permissionStaff: permissionStaffMetadata,
+ contentModels: contentModelsMetadata,
+ contentRevalidateOrigins: content?.revalidateOrigins,
+ contentTypes: contentTypesMetadata,
});
+ // Whether a localized content type's `defaultLocale` names a row in
+ // `core_languages` is a fact about the *installation*, so it cannot be
+ // checked when the definition is built - there is no connection yet. This is
+ // that check, run at most once per process and skipped entirely when nothing
+ // is localized, so an install with no localized content types never touches
+ // the languages table because of this.
+ if (hasLocalizedContentTypes) {
+ await ensureContentLocalizationLanguages(c, contentTypesMetadata);
+ }
+
const user = await new SessionModel(c).getUser();
c.set("user", user);
c.set("admin", null);
diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts
index 47e09f4a7..e832f3674 100644
--- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts
@@ -1,5 +1,4 @@
import type { Context, Next } from "hono";
-import type { Redis } from "ioredis";
import {
type IRateLimiterOptions,
@@ -9,6 +8,8 @@ import {
type RateLimiterRes,
} from "rate-limiter-flexible";
+import type { CacheClient } from "@/api/lib/cache";
+
import { CONFIG } from "../../lib/config";
const createRateLimiter = ({
@@ -17,7 +18,7 @@ const createRateLimiter = ({
...options
}: Omit & {
keyPrefix: string;
- storeClient?: null | Redis;
+ storeClient?: CacheClient | null;
}): RateLimiterAbstract => {
// With a Redis client the counters are shared across all instances, so rate
// limits hold up behind a load balancer. `insuranceLimiter` falls back to
@@ -25,6 +26,10 @@ const createRateLimiter = ({
if (storeClient) {
return new RateLimiterRedis({
storeClient,
+ // `rate-limiter-flexible` sniffs the client library from the store
+ // client's constructor name, which node-redis does not expose. Without
+ // this it assumes ioredis and calls a command that doesn't exist.
+ useRedisPackage: true,
keyPrefix,
...options,
insuranceLimiter: new RateLimiterMemory({ keyPrefix, ...options }),
@@ -39,7 +44,7 @@ const createRateLimiter = ({
export const rateLimiterMiddleware = (
options?: Omit,
- storeClient?: null | Redis,
+ storeClient?: CacheClient | null,
) => {
if (CONFIG.node_development) {
// In development, we disable the rate limiter for easier testing
diff --git a/packages/vitnode/src/api/models/events.test.ts b/packages/vitnode/src/api/models/events.test.ts
index f9b834a32..95cbee53e 100644
--- a/packages/vitnode/src/api/models/events.test.ts
+++ b/packages/vitnode/src/api/models/events.test.ts
@@ -210,6 +210,42 @@ describe("EventsModel.emit envelope", () => {
expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/blog");
});
+ it("an explicit owner wins over the context plugin", async () => {
+ // The queue case: core owns the handler, so the context says core, but the
+ // domain event belongs to whoever owns the thing it happened to.
+ const { adapter, publish } = captureEnvelope();
+ const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/core" } });
+
+ await new EventsModel(ctx).emit("user.created", PAYLOAD, {
+ pluginId: "@vitnode/example",
+ });
+
+ expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/example");
+ });
+
+ it("an omitted override changes nothing for existing callers", async () => {
+ const { adapter, publish } = captureEnvelope();
+ const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/blog" } });
+
+ await new EventsModel(ctx).emit("user.created", PAYLOAD, {});
+
+ expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/blog");
+ });
+
+ it("does not impersonate the plugin on the shared context", async () => {
+ // Overriding by swapping `c.get("plugin")` would change the logger, the
+ // permission checks and every other model on the request to fix one field.
+ const { adapter, publish } = captureEnvelope();
+ const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/core" } });
+
+ await new EventsModel(ctx).emit("user.created", PAYLOAD, {
+ pluginId: "@vitnode/example",
+ });
+
+ expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/example");
+ expect(ctx.get("plugin").id).toBe("@vitnode/core");
+ });
+
it("derives the actor: admin wins over user, then user, then system", async () => {
const { adapter, publish } = captureEnvelope();
diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts
index 4b5426581..96ad07aa2 100644
--- a/packages/vitnode/src/api/models/events.ts
+++ b/packages/vitnode/src/api/models/events.ts
@@ -100,6 +100,26 @@ export interface EventsApiPlugin {
publish: (c: Context, envelope: EventEnvelope) => Promise;
}
+export interface EventEmitOptions {
+ /**
+ * Who owns the *domain event*, when that is not the plugin handling the
+ * request.
+ *
+ * Ownership normally comes from `c.get("plugin")`, which is right for a route:
+ * whoever handled the request emitted the event. It is wrong for anything that
+ * runs on someone else's behalf. A queue handler is the clear case - core owns
+ * the handler, so the context says `@vitnode/core`, but a scheduled
+ * `content.example.article.published` is the example plugin's event and always
+ * was.
+ *
+ * Pass it explicitly rather than swapping `c.get("plugin")` for the duration.
+ * The context is shared with the logger, the permission checks and every other
+ * model on the request; impersonating a plugin inside it would change all of
+ * them to fix one field.
+ */
+ pluginId?: string;
+}
+
export class EventsModel {
constructor(c: Context) {
this.c = c;
@@ -117,10 +137,17 @@ export class EventsModel {
* AFTER the writes the event describes have committed - after your awaited
* inserts/updates, and after any enclosing `db.transaction` callback has
* returned.
+ *
+ * **Not throwing is the contract, not an oversight.** An interactive mutation
+ * has already committed by the time this runs, and a listener that fell over
+ * is not a reason to tell the person their save failed. A caller that *does*
+ * need delivery to be retried - the scheduled-effects task is the one in
+ * core - reads `failures` and decides for itself.
*/
async emit(
name: K,
payload: VitNodeEvents[K],
+ options?: EventEmitOptions,
): Promise {
const admin = this.c.get("admin");
const user = this.c.get("user");
@@ -129,7 +156,8 @@ export class EventsModel {
name,
payload,
emittedAt: new Date(),
- pluginId: this.c.get("plugin")?.id ?? "@vitnode/core",
+ pluginId:
+ options?.pluginId ?? this.c.get("plugin")?.id ?? "@vitnode/core",
actor: admin
? { type: "admin", id: admin.user.id }
: user
diff --git a/packages/vitnode/src/api/models/queue.test.ts b/packages/vitnode/src/api/models/queue.test.ts
index c0cd10117..382f9eb89 100644
--- a/packages/vitnode/src/api/models/queue.test.ts
+++ b/packages/vitnode/src/api/models/queue.test.ts
@@ -1,86 +1,126 @@
+// @vitest-environment node
import type { Context } from "hono";
import { describe, expect, it, vi } from "vitest";
import { QueueModel } from "./queue";
-const makeCtx = (
- overrides: {
- plugin?: { id: string };
- queue?: { maxAttempts?: number; name: string; pluginId: string }[];
- } = {},
-): {
- ctx: Context;
- values: ReturnType;
-} => {
- const values = vi.fn().mockReturnValue({
- returning: vi.fn().mockResolvedValue([{ id: 1 }]),
+/** Records what was inserted, and through which handle. */
+const harness = ({ plugin }: { plugin?: string } = {}) => {
+ const inserts: { handle: string; values: Record }[] = [];
+
+ const handle = (name: string) => ({
+ insert: () => ({
+ values: (values: Record) => ({
+ returning: async () => {
+ inserts.push({ handle: name, values });
+
+ return await Promise.resolve([{ id: 1 }]);
+ },
+ }),
+ }),
});
- const store: Record = {
- db: { insert: vi.fn().mockReturnValue({ values }) },
- core: { queue: overrides.queue ?? [] },
- plugin: overrides.plugin,
- };
-
- return {
- ctx: { get: (k: string) => store[k] } as unknown as Context,
- values,
- };
+
+ const c = {
+ get: (key: string) =>
+ key === "db"
+ ? handle("request")
+ : key === "core"
+ ? { queue: [{ maxAttempts: 7, name: "known", pluginId: plugin }] }
+ : key === "plugin"
+ ? plugin
+ ? { id: plugin }
+ : undefined
+ : undefined,
+ } as unknown as Context;
+
+ return { c, inserts, tx: handle("transaction") };
};
describe("QueueModel.dispatch", () => {
- it("uses the explicit maxAttempts when provided", async () => {
- const { ctx, values } = makeCtx({
- queue: [{ name: "job", pluginId: "@vitnode/core", maxAttempts: 5 }],
- });
+ it("stamps the requesting plugin by default", async () => {
+ const { c, inserts } = harness({ plugin: "@vitnode/example" });
- await new QueueModel(ctx).dispatch({ name: "job", maxAttempts: 7 });
+ await new QueueModel(c).dispatch({ name: "do-something" });
- expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 7 });
+ expect(inserts[0].values.pluginId).toBe("@vitnode/example");
});
- it("falls back to the registered task maxAttempts", async () => {
- const { ctx, values } = makeCtx({
- queue: [{ name: "job", pluginId: "@vitnode/core", maxAttempts: 5 }],
- });
+ it("falls back to core when no plugin is handling the request", async () => {
+ const { c, inserts } = harness();
- await new QueueModel(ctx).dispatch({ name: "job" });
+ await new QueueModel(c).dispatch({ name: "do-something" });
- expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 5 });
+ expect(inserts[0].values.pluginId).toBe("@vitnode/core");
});
- it("defaults to 3 when the registered task has no maxAttempts", async () => {
- const { ctx, values } = makeCtx({
- queue: [{ name: "job", pluginId: "@vitnode/core" }],
- });
+ it("stamps an explicit plugin instead", async () => {
+ // The case that makes scheduled publication work at all: a plugin's route
+ // dispatches a task core owns. The worker resolves handlers by
+ // `${pluginId}:${name}`, so the plugin's own id would leave the row
+ // unclaimable forever.
+ const { c, inserts } = harness({ plugin: "@vitnode/example" });
- await new QueueModel(ctx).dispatch({ name: "job" });
+ await new QueueModel(c).dispatch({
+ name: "content-schedule",
+ pluginId: "@vitnode/core",
+ });
- expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 3 });
+ expect(inserts[0].values.pluginId).toBe("@vitnode/core");
});
- it("defaults to 3 when the task is not registered", async () => {
- const { ctx, values } = makeCtx({ queue: [] });
+ it("uses the request handle when no transaction is given", async () => {
+ const { c, inserts } = harness();
- await new QueueModel(ctx).dispatch({ name: "job" });
+ await new QueueModel(c).dispatch({ name: "do-something" });
- expect(values.mock.calls[0][0]).toMatchObject({ maxAttempts: 3 });
+ expect(inserts[0].handle).toBe("request");
});
- it("scopes the task lookup by pluginId", async () => {
- const { ctx, values } = makeCtx({
- plugin: { id: "@vitnode/blog" },
- queue: [
- { name: "job", pluginId: "@vitnode/core", maxAttempts: 5 },
- { name: "job", pluginId: "@vitnode/blog", maxAttempts: 9 },
- ],
+ it("joins a transaction when one is given", async () => {
+ // Without this the queue row can commit while the row it points at rolls
+ // back, and the task wakes up to find nothing there.
+ const { c, inserts, tx } = harness();
+
+ await new QueueModel(c).dispatch({
+ name: "do-something",
+ tx: tx as never,
});
- await new QueueModel(ctx).dispatch({ name: "job" });
+ expect(inserts[0].handle).toBe("transaction");
+ });
+
+ it("still reads the registered task's maxAttempts", async () => {
+ const { c, inserts } = harness({ plugin: "@vitnode/example" });
- expect(values.mock.calls[0][0]).toMatchObject({
- pluginId: "@vitnode/blog",
- maxAttempts: 9,
+ await new QueueModel(c).dispatch({ name: "known" });
+
+ expect(inserts[0].values.maxAttempts).toBe(7);
+ });
+
+ it("looks the task up under the plugin it is dispatched for", async () => {
+ // `known` is registered under `@vitnode/example`, so dispatching it as core
+ // finds no registration and falls back to the default.
+ const { c, inserts } = harness({ plugin: "@vitnode/example" });
+
+ await new QueueModel(c).dispatch({
+ name: "known",
+ pluginId: "@vitnode/core",
});
+
+ expect(inserts[0].values.maxAttempts).toBe(3);
+ });
+
+ it("defaults availableAt to now, so a task runs on the next tick", async () => {
+ const now = new Date("2026-08-05T10:00:00.000Z");
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+
+ const { c, inserts } = harness();
+ await new QueueModel(c).dispatch({ name: "do-something" });
+
+ expect(inserts[0].values.availableAt).toEqual(now);
+
+ vi.useRealTimers();
});
});
diff --git a/packages/vitnode/src/api/models/queue.ts b/packages/vitnode/src/api/models/queue.ts
index 38f66309f..7cd68b19c 100644
--- a/packages/vitnode/src/api/models/queue.ts
+++ b/packages/vitnode/src/api/models/queue.ts
@@ -7,8 +7,26 @@ export interface QueueDispatchArgs {
maxAttempts?: number;
name: string;
payload?: Record;
+ /**
+ * Who owns the handler, when that is not the plugin handling the request.
+ *
+ * The worker resolves a handler by `` `${pluginId}:${name}` ``, so a task
+ * registered by core but dispatched from a plugin's route needs to say so -
+ * otherwise the row is stamped with the plugin's id and nothing will ever
+ * claim it. Defaults to the requesting plugin, which is right for the
+ * ordinary case where a plugin dispatches its own task.
+ */
+ pluginId?: string;
priority?: number;
queue?: string;
+ /**
+ * Join an existing transaction instead of using the request handle.
+ *
+ * Needed whenever the row that the task refers to is written in the same
+ * unit of work: without it, the queue row can commit while the row it points
+ * at rolls back, and the task wakes up to find nothing there.
+ */
+ tx?: Omit;
}
/**
@@ -27,19 +45,21 @@ export class QueueModel {
async dispatch({
name,
payload = {},
+ pluginId: explicitPluginId,
queue = "default",
priority = 0,
maxAttempts,
availableAt,
+ tx,
}: QueueDispatchArgs): Promise<{ id: number }> {
- const pluginId = this.c.get("plugin")?.id ?? "@vitnode/core";
+ const pluginId =
+ explicitPluginId ?? this.c.get("plugin")?.id ?? "@vitnode/core";
const registeredTask = this.c
.get("core")
.queue.find(task => task.pluginId === pluginId && task.name === name);
- const [row] = await this.c
- .get("db")
+ const [row] = await (tx ?? this.c.get("db"))
.insert(core_queue)
.values({
pluginId,
diff --git a/packages/vitnode/src/api/models/search.test-d.ts b/packages/vitnode/src/api/models/search.test-d.ts
new file mode 100644
index 000000000..20234211b
--- /dev/null
+++ b/packages/vitnode/src/api/models/search.test-d.ts
@@ -0,0 +1,90 @@
+/* eslint-disable @typescript-eslint/no-deprecated -- Asserting that the
+ deprecated result shape still compiles is the point of this file. */
+import { assertType, describe, expectTypeOf, it } from "vitest";
+
+import type { ContentSearchIndexer } from "@/content/server";
+
+import type {
+ LegacySearchIndexerPage,
+ SearchDocument,
+ SearchIndexer,
+ SearchIndexerLoadResult,
+ SearchIndexerPage,
+} from "./search";
+
+const document: SearchDocument = {
+ content: "body",
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ itemId: 1,
+ itemType: "custom_item",
+ title: "Hello",
+};
+
+describe("SearchIndexer.load", () => {
+ it("accepts the preferred page result", () => {
+ assertType({
+ itemType: "custom_item",
+ load: async (_c, _offset, limit) =>
+ await Promise.resolve({ documents: [document], itemsRead: limit }),
+ });
+ });
+
+ it("still accepts the deprecated array result", () => {
+ // Exactly what a plugin written before Stage 3 looks like. It has to keep
+ // compiling: `SearchIndexer` is a documented public import.
+ const legacyIndexer: SearchIndexer = {
+ itemType: "legacy.item",
+ load: async () => await Promise.resolve([]),
+ };
+
+ assertType(legacyIndexer);
+ assertType({
+ itemType: "legacy.item",
+ load: async () => await Promise.resolve([document]),
+ });
+ });
+
+ it("accepts an indexer that returns either shape", () => {
+ assertType({
+ count: async () => await Promise.resolve(1),
+ itemType: "either",
+ load: async (_c, offset) =>
+ await Promise.resolve(
+ offset === 0 ? { documents: [document], itemsRead: 1 } : [],
+ ),
+ });
+ });
+
+ it("rejects a result that is neither shape", () => {
+ assertType({
+ itemType: "wrong",
+ // @ts-expect-error - a bare document is not a page and not an array.
+ load: async () => await Promise.resolve(document),
+ });
+ });
+
+ it("rejects a page missing its source count", () => {
+ assertType({
+ itemType: "wrong",
+ // @ts-expect-error - `itemsRead` is what the rebuild pages by.
+ load: async () => await Promise.resolve({ documents: [document] }),
+ });
+ });
+});
+
+describe("SearchIndexerLoadResult", () => {
+ it("is the union of the page and the deprecated array", () => {
+ expectTypeOf().toExtend();
+ expectTypeOf().toExtend();
+ expectTypeOf().toEqualTypeOf();
+ });
+});
+
+describe("ContentSearchIndexer", () => {
+ it("is a SearchIndexer pinned to the page result", () => {
+ expectTypeOf().toExtend();
+ expectTypeOf<
+ Awaited>
+ >().toEqualTypeOf();
+ });
+});
diff --git a/packages/vitnode/src/api/models/search.test.ts b/packages/vitnode/src/api/models/search.test.ts
index 74323a5b9..f9f9a034c 100644
--- a/packages/vitnode/src/api/models/search.test.ts
+++ b/packages/vitnode/src/api/models/search.test.ts
@@ -5,9 +5,14 @@ import { describe, expect, it, vi } from "vitest";
import { core_search_index } from "@/database/search";
-import type { SearchProviderApiPlugin } from "./search";
+import type { SearchDocument, SearchProviderApiPlugin } from "./search";
-import { SearchModel } from "./search";
+import { PostgresSearchAdapter } from "../adapters/search/postgres";
+import {
+ assertSearchProviderCapabilities,
+ normalizeSearchIndexerPage,
+ SearchModel,
+} from "./search";
const createProvider = (): SearchProviderApiPlugin => ({
name: "postgres",
@@ -28,7 +33,10 @@ const createProvider = (): SearchProviderApiPlugin => ({
}),
});
-const createContext = (provider: SearchProviderApiPlugin) => {
+const createContext = (
+ provider: SearchProviderApiPlugin,
+ requestPluginId?: string,
+) => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn<
(row: { content: string; isPublic: boolean; pluginId: string }) => {
@@ -44,7 +52,9 @@ const createContext = (provider: SearchProviderApiPlugin) => {
get: (key: string) => {
if (key === "db") return db;
if (key === "core") return { search: { adapter: provider } };
- if (key === "plugin") return undefined;
+ if (key === "plugin") {
+ return requestPluginId ? { id: requestPluginId } : undefined;
+ }
return undefined;
},
@@ -75,6 +85,112 @@ describe("SearchModel", () => {
expect(provider.index).toHaveBeenCalledWith(c, {
...doc,
content: "Hello world",
+ pluginId: "core",
+ });
+ });
+
+ describe("plugin ownership", () => {
+ const doc = {
+ content: "body",
+ createdAt: new Date("2026-01-01"),
+ itemId: 1,
+ itemType: "example.article",
+ title: "Hello",
+ };
+
+ it("prefers an explicit document owner over the request", async () => {
+ // A rebuild runs inside the core cron request, so the request's plugin is
+ // not the owner - the document has to win, or the same record would be
+ // stored differently depending on which path wrote it.
+ const provider = createProvider();
+ const { c, values } = createContext(provider, "@vitnode/core");
+
+ await new SearchModel(c).index({ ...doc, pluginId: "@vitnode/example" });
+
+ expect(values.mock.calls[0][0].pluginId).toBe("@vitnode/example");
+ expect(provider.index).toHaveBeenCalledWith(
+ c,
+ expect.objectContaining({ pluginId: "@vitnode/example" }),
+ );
+ });
+
+ it("falls back to the request's plugin", async () => {
+ const provider = createProvider();
+ const { c, values } = createContext(provider, "@vitnode/example");
+
+ await new SearchModel(c).index(doc);
+
+ expect(values.mock.calls[0][0].pluginId).toBe("@vitnode/example");
+ expect(provider.index).toHaveBeenCalledWith(
+ c,
+ expect.objectContaining({ pluginId: "@vitnode/example" }),
+ );
+ });
+
+ it("treats a blank owner as absent", async () => {
+ // `pluginId` is public input, so an empty or whitespace-only string is a
+ // missing owner - not a collection called "".
+ const provider = createProvider();
+ const { c, values } = createContext(provider, "@vitnode/example");
+
+ await new SearchModel(c).index({ ...doc, pluginId: " " });
+
+ expect(values.mock.calls[0][0].pluginId).toBe("@vitnode/example");
+ });
+
+ it("falls back to core for a blank owner outside a plugin request", async () => {
+ const provider = createProvider();
+ const { c, values } = createContext(provider);
+
+ await new SearchModel(c).index({ ...doc, pluginId: "" });
+
+ expect(values.mock.calls[0][0].pluginId).toBe("core");
+ });
+
+ it("falls back to core outside a plugin request", async () => {
+ const provider = createProvider();
+ const { c, values } = createContext(provider);
+
+ await new SearchModel(c).index(doc);
+
+ expect(values.mock.calls[0][0].pluginId).toBe("core");
+ });
+
+ it("resolves every document in a bulk write", async () => {
+ const provider = createProvider();
+ const { c, values } = createContext(provider, "@vitnode/core");
+
+ await new SearchModel(c).bulkIndex([
+ { ...doc, itemId: 1, pluginId: "@vitnode/example" },
+ { ...doc, itemId: 2, pluginId: "@vitnode/blog" },
+ // No owner declared: the request's plugin stands in.
+ { ...doc, itemId: 3 },
+ ]);
+
+ expect(values.mock.calls.map(call => call[0].pluginId)).toEqual([
+ "@vitnode/example",
+ "@vitnode/blog",
+ "@vitnode/core",
+ ]);
+ expect(provider.bulkIndex).toHaveBeenCalledWith(c, [
+ expect.objectContaining({ itemId: 1, pluginId: "@vitnode/example" }),
+ expect.objectContaining({ itemId: 2, pluginId: "@vitnode/blog" }),
+ expect.objectContaining({ itemId: 3, pluginId: "@vitnode/core" }),
+ ]);
+ });
+
+ it("rewrites the owner of an existing row on conflict", async () => {
+ // Otherwise a row written before its indexer declared an owner would keep
+ // the first writer's guess forever, and a rebuild could not repair it.
+ const provider = createProvider();
+ const { c, values } = createContext(provider, "@vitnode/example");
+
+ await new SearchModel(c).index(doc);
+
+ const { onConflictDoUpdate } = values.mock.results[0].value;
+ expect(onConflictDoUpdate.mock.calls[0][0].set).toMatchObject({
+ pluginId: "@vitnode/example",
+ });
});
});
@@ -85,7 +201,20 @@ describe("SearchModel", () => {
await new SearchModel(c).delete("blog_post", 5);
expect(deleteFn).toHaveBeenCalledWith(core_search_index);
- expect(provider.delete).toHaveBeenCalledWith(c, "blog_post", 5);
+ // No language: deleting the record means every language of it.
+ expect(provider.delete).toHaveBeenCalledWith(c, "blog_post", 5, undefined);
+ });
+
+ it("deletes one language without touching the others", async () => {
+ const provider = createProvider();
+ const { c, deleteFn } = createContext(provider);
+
+ // Multi-language content is one row per `(itemType, itemId, languageCode)`,
+ // so taking the Polish translation down must leave the English one indexed.
+ await new SearchModel(c).delete("blog_post", 5, "pl");
+
+ expect(deleteFn).toHaveBeenCalledWith(core_search_index);
+ expect(provider.delete).toHaveBeenCalledWith(c, "blog_post", 5, "pl");
});
it("delegates search to the provider", async () => {
@@ -100,3 +229,197 @@ describe("SearchModel", () => {
});
});
});
+
+describe("normalizeSearchIndexerPage", () => {
+ const document: SearchDocument = {
+ content: "body",
+ createdAt: new Date("2026-01-01"),
+ itemId: 1,
+ itemType: "legacy.item",
+ title: "Hello",
+ };
+
+ it("passes a modern page through untouched", () => {
+ const page = { documents: [document], itemsRead: 7 };
+
+ expect(normalizeSearchIndexerPage(page, 200)).toBe(page);
+ });
+
+ it("keeps a modern page that read rows but produced nothing", () => {
+ // The whole reason the object form exists: this must not read as exhausted.
+ expect(
+ normalizeSearchIndexerPage({ documents: [], itemsRead: 200 }, 200),
+ ).toEqual({ documents: [], itemsRead: 200 });
+ });
+
+ it("reports the requested limit for a non-empty legacy array", () => {
+ // Not `documents.length`: a legacy indexer may emit several documents per
+ // source row, so the array length would skip rows on every page.
+ expect(normalizeSearchIndexerPage([document], 200)).toEqual({
+ documents: [document],
+ itemsRead: 200,
+ });
+ });
+
+ it("reports the requested limit however many documents a page holds", () => {
+ expect(
+ normalizeSearchIndexerPage([document, document, document, document], 200)
+ .itemsRead,
+ ).toBe(200);
+ });
+
+ it("treats an empty legacy array as an exhausted source", () => {
+ expect(normalizeSearchIndexerPage([], 200)).toEqual({
+ documents: [],
+ itemsRead: 0,
+ });
+ });
+});
+
+/**
+ * The one provider capability that is not a nicety.
+ *
+ * `delete(c, itemType, itemId, languageCode)` is a JavaScript call: a provider
+ * written before per-locale content accepts the fourth argument and drops it, so
+ * taking one translation down removes every language from that provider's store
+ * while the canonical `core_search_index` removes one. Nothing throws, nothing is
+ * logged, and the two disagree from then on - which is why the pairing is refused
+ * at boot instead of being discovered by whoever deletes a translation.
+ */
+describe("assertSearchProviderCapabilities", () => {
+ const legacy = (): SearchProviderApiPlugin => ({
+ ...createProvider(),
+ name: "legacy-engine",
+ });
+
+ const scoped = (): SearchProviderApiPlugin => ({
+ ...createProvider(),
+ name: "scoped-engine",
+ capabilities: {
+ authorBoost: false,
+ facets: false,
+ languageScopedDelete: true,
+ timeDecay: false,
+ },
+ });
+
+ it("allows a provider that declares nothing when nothing is localized", () => {
+ expect(() =>
+ assertSearchProviderCapabilities(legacy(), {
+ localizedSearchContentTypes: [],
+ }),
+ ).not.toThrow();
+ });
+
+ it("refuses a provider that cannot scope a delete to one language", () => {
+ expect(() =>
+ assertSearchProviderCapabilities(legacy(), {
+ localizedSearchContentTypes: ["example.article"],
+ }),
+ ).toThrow(/legacy-engine/);
+ });
+
+ it("names the content type and the missing capability", () => {
+ // A boot failure is only useful if it says what to change.
+ expect(() =>
+ assertSearchProviderCapabilities(legacy(), {
+ localizedSearchContentTypes: ["example.article"],
+ }),
+ ).toThrow(/example\.article/);
+ expect(() =>
+ assertSearchProviderCapabilities(legacy(), {
+ localizedSearchContentTypes: ["example.article"],
+ }),
+ ).toThrow(/languageScopedDelete/);
+ });
+
+ it("refuses a provider that declares the other capabilities but not this one", () => {
+ // Declaring `capabilities` is not the same as declaring this capability.
+ const partial: SearchProviderApiPlugin = {
+ ...createProvider(),
+ name: "facets-only",
+ capabilities: { authorBoost: true, facets: true, timeDecay: true },
+ };
+
+ expect(() =>
+ assertSearchProviderCapabilities(partial, {
+ localizedSearchContentTypes: ["example.article"],
+ }),
+ ).toThrow(/facets-only/);
+ });
+
+ it("allows a provider that declares it", () => {
+ expect(() =>
+ assertSearchProviderCapabilities(scoped(), {
+ localizedSearchContentTypes: ["example.article", "example.page"],
+ }),
+ ).not.toThrow();
+ });
+
+ it("lists every offending content type, not just the first", () => {
+ expect(() =>
+ assertSearchProviderCapabilities(legacy(), {
+ localizedSearchContentTypes: ["example.article", "example.page"],
+ }),
+ ).toThrow(/example\.article", "example\.page/);
+ });
+
+ it("says yes to the bundled Postgres provider", async () => {
+ // Its store *is* `core_search_index`, which `SearchModel.delete` already
+ // narrows by language before the provider is reached.
+ const { PostgresSearchAdapter } =
+ await import("@/api/adapters/search/postgres");
+
+ expect(() =>
+ assertSearchProviderCapabilities(PostgresSearchAdapter(), {
+ localizedSearchContentTypes: ["example.article"],
+ }),
+ ).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 7a29d88fa..4d3db855b 100644
--- a/packages/vitnode/src/api/models/search.ts
+++ b/packages/vitnode/src/api/models/search.ts
@@ -19,6 +19,11 @@ export interface SearchDocument {
// language; single-language content may leave it empty.
languageCode?: string;
metadata?: Record;
+ // The plugin that owns this item. Omit it and {@link SearchModel} falls back
+ // to the request's plugin - which is only right while the request *is* the
+ // owning plugin's, so a rebuild (it runs in the core cron request) must set it
+ // explicitly.
+ pluginId?: string;
title: string;
updatedAt?: Date;
url?: string;
@@ -81,14 +86,80 @@ 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`.
+ *
+ * Declared rather than inferred, because JavaScript cannot tell the difference:
+ * a provider written as `delete(c, itemType, itemId)` accepts the fourth
+ * argument at runtime and silently ignores it, so taking down one translation
+ * would remove every language from that provider's store while the canonical
+ * `core_search_index` removed one. The two would then disagree forever, and
+ * nothing would say so.
+ *
+ * Optional, so a provider written before localized content still compiles and
+ * still serves single-language content. Absent means "no", and
+ * {@link assertSearchProviderCapabilities} refuses to boot an install that
+ * pairs such a provider with a localized searchable content type.
+ */
+ languageScopedDelete?: boolean;
timeDecay: boolean;
}
+/**
+ * One page of a rebuild.
+ *
+ * The two counts are separate on purpose. An indexer may emit several documents
+ * per item (one per language, say) or none at all (a row whose data cannot be
+ * projected), so a document count can never stand in for a source count - using
+ * it would either skip items or end the rebuild while rows remain.
+ */
+export interface SearchIndexerPage {
+ documents: SearchDocument[];
+ /** Source rows this page read. `0` means the source is exhausted. */
+ itemsRead: number;
+}
+
+/**
+ * The pre-{@link SearchIndexerPage} result: documents with no source count.
+ *
+ * @deprecated Return a {@link SearchIndexerPage}. An array cannot say how many
+ * source rows produced it, so the rebuild has to assume a full page was read and
+ * wait for an empty one to stop - which means a page that reads rows and projects
+ * none of them (every row on it malformed, say) ends the rebuild early and the
+ * rows behind it are never indexed. Supported for now; removed in a future major
+ * release.
+ */
+export type LegacySearchIndexerPage = SearchDocument[];
+
+export type SearchIndexerLoadResult =
+ // The one intentional use of the deprecated shape: this union is what keeps
+ // pre-Stage-3 indexers compiling, so the lint rule has nothing to warn about
+ // here. Every *other* reference should be flagged.
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ LegacySearchIndexerPage | SearchIndexerPage;
+
/**
* Streams every existing item of one content type so the whole index can be
- * rebuilt (e.g. after switching engines). `load` returns one page at a time;
- * return fewer than `limit` rows to signal the end.
+ * rebuilt (e.g. after switching engines).
+ *
+ * `load` is called with `offset` advanced by the previous page's `itemsRead`.
+ * Report `itemsRead: 0` to end the rebuild; an empty `documents` array does not,
+ * because a page can legitimately read rows and project none of them.
+ *
+ * Returning a bare `SearchDocument[]` still works - see
+ * {@link LegacySearchIndexerPage} for what it gives up.
*/
export interface SearchIndexer {
// Total number of source items available to index for this type. Powers the
@@ -100,13 +171,120 @@ export interface SearchIndexer {
c: Context,
offset: number,
limit: number,
- ) => Promise;
+ ) => Promise;
}
+/**
+ * A declared document owner, or `undefined` when there is not really one.
+ *
+ * `pluginId` is public input, so an empty or whitespace-only string is a missing
+ * owner rather than a collection named `""`. Every place that resolves ownership
+ * goes through this, so the fallback chains cannot drift apart.
+ */
+export const searchDocumentOwner = (
+ pluginId: null | string | undefined,
+): string | undefined => {
+ const trimmed = pluginId?.trim();
+
+ return trimmed === "" ? undefined : trimmed;
+};
+
+/**
+ * Turns either `load` result into a page, so the rebuild has one shape to reason
+ * about and the compatibility rule lives in exactly one place.
+ *
+ * A non-empty legacy array reports `requestedLimit` rather than
+ * `documents.length`, because that is what the old rebuild advanced by: an
+ * indexer may emit several documents per source row (one per language), so a
+ * document count would skip rows on every page. An empty array is the only end
+ * signal it has.
+ */
+export const normalizeSearchIndexerPage = (
+ result: SearchIndexerLoadResult,
+ requestedLimit: number,
+): SearchIndexerPage => {
+ if (!Array.isArray(result)) return result;
+
+ return {
+ documents: result,
+ itemsRead: result.length === 0 ? 0 : requestedLimit,
+ };
+};
+
export interface SearchIndexerConfig extends SearchIndexer {
pluginId: string;
}
+/**
+ * Rejects two indexers claiming the same `itemType`.
+ *
+ * `itemType` is the index's only namespace, so a collision is not a cosmetic
+ * problem: both indexers would `load` on every rebuild, writing over each
+ * other's documents whenever their item ids overlap, and the admin coverage
+ * report would silently describe only the first one. Failing at boot is the only
+ * place this is cheap to notice.
+ *
+ * Called once per plugin by `buildApiPlugin` and again across every plugin by
+ * the global middleware, which is the only place that sees them all.
+ */
+export const validateSearchIndexers = (
+ indexers: readonly SearchIndexerConfig[],
+): SearchIndexerConfig[] => {
+ const seen = new Map();
+
+ for (const indexer of indexers) {
+ const owner = seen.get(indexer.itemType);
+ if (owner !== undefined) {
+ throw new Error(
+ `[Search] Duplicate search indexer for item type "${indexer.itemType}": registered by both "${owner}" and "${indexer.pluginId}". An item type may only be indexed by one indexer.`,
+ );
+ }
+
+ seen.set(indexer.itemType, indexer.pluginId);
+ }
+
+ return [...indexers];
+};
+
+/**
+ * Refuses to boot a provider that cannot express what the installed content
+ * types need.
+ *
+ * Only one requirement so far, and it is narrow on purpose: a content type that
+ * is both localized and searchable is indexed once per published translation, so
+ * unpublishing or deleting one of them has to remove exactly one document. A
+ * provider that ignores `languageCode` would take every language out instead, and
+ * because the extra argument is simply dropped there is no error, no log line and
+ * no way to notice until somebody searches for content that should still be
+ * there.
+ *
+ * Fails at boot rather than at the delete for the obvious reason: the delete is
+ * the moment the damage happens, and by then the install has been running.
+ *
+ * Content types are passed as plain ids so this stays where the rest of the
+ * search contract lives, with no dependency on the Content Engine.
+ */
+export const assertSearchProviderCapabilities = (
+ provider: SearchProviderApiPlugin,
+ {
+ localizedSearchContentTypes,
+ }: {
+ /** Ids of content types indexed once per translation. */
+ localizedSearchContentTypes: readonly string[];
+ },
+): void => {
+ if (localizedSearchContentTypes.length === 0) return;
+ if (provider.capabilities?.languageScopedDelete === true) return;
+
+ throw new Error(
+ `[Search] The "${provider.name}" search provider does not support language-scoped deletion, but ${localizedSearchContentTypes.length === 1 ? "the content type" : "the content types"} ${localizedSearchContentTypes
+ .map(id => `"${id}"`)
+ .join(
+ ", ",
+ )} ${localizedSearchContentTypes.length === 1 ? "is" : "are"} localized and searchable - each publishes one search document per translation. Taking one translation down must remove one document, and a provider that ignores the "languageCode" argument of "delete" would remove every language instead. Declare "capabilities: { languageScopedDelete: true }" on the provider once its "delete" honours that argument, or turn "search" off for ${localizedSearchContentTypes.length === 1 ? "that content type" : "those content types"}.`,
+ );
+};
+
/**
* A pluggable search engine. The {@link SearchModel} owns the canonical
* `core_search_index` table for every provider, so a provider that queries that
@@ -118,7 +296,43 @@ export interface SearchProviderApiPlugin {
bulkIndex: (c: Context, docs: SearchDocument[]) => Promise;
capabilities?: SearchProviderCapabilities;
clear: (c: Context, itemType?: string) => Promise;
- delete: (c: Context, itemType: string, itemId: number) => 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.
+ *
+ * `languageCode` narrows it to a single language, for content that is indexed
+ * once per translation: unpublishing the Polish copy of an article must not
+ * take the English one out of the index. Omit it and every language goes, which
+ * is what deleting the record itself means.
+ *
+ * Optional on the signature so a provider written before per-locale content
+ * still compiles - but ignoring it is **not** silently tolerated. A provider
+ * that honours it says so with
+ * `capabilities: { languageScopedDelete: true }`, and an install that pairs one
+ * that does not with a localized searchable content type refuses to boot. See
+ * {@link assertSearchProviderCapabilities}.
+ */
+ delete: (
+ c: Context,
+ itemType: string,
+ itemId: number,
+ languageCode?: string,
+ ) => Promise;
index: (c: Context, doc: SearchDocument) => Promise;
name: string;
ping?: (c: Context) => Promise;
@@ -126,7 +340,7 @@ export interface SearchProviderApiPlugin {
}
const toRow = (doc: SearchDocument) => ({
- pluginId: "core",
+ pluginId: doc.pluginId ?? "core",
itemType: doc.itemType,
itemId: doc.itemId,
languageCode: doc.languageCode ?? "",
@@ -186,8 +400,26 @@ export class SearchModel {
return this.c.get("core").search.adapter;
}
+ /**
+ * Fills in the document's owner, once, for every write path.
+ *
+ * The request's plugin is only a *fallback*: it is the owner when a mutation
+ * route indexes its own content, and it is `@vitnode/core` during a rebuild,
+ * which runs inside the core cron request. So an explicit `pluginId` always
+ * wins - that is how a rebuild reproduces the same ownership a live write
+ * produced. Resolving it here rather than in each adapter is what keeps the
+ * canonical row and the mirrored document from disagreeing.
+ */
+ private resolveOwner(doc: SearchDocument): SearchDocument {
+ return {
+ ...doc,
+ pluginId:
+ searchDocumentOwner(doc.pluginId) ?? this.c.get("plugin")?.id ?? "core",
+ };
+ }
+
private async upsertRow(doc: SearchDocument): Promise {
- const row = { ...toRow(doc), pluginId: this.c.get("plugin")?.id ?? "core" };
+ const row = toRow(doc);
await this.c
.get("db")
@@ -200,6 +432,9 @@ export class SearchModel {
core_search_index.languageCode,
],
set: {
+ // Included so a rebuild corrects the owner of a row written before the
+ // indexer declared one, rather than leaving the first writer's guess.
+ pluginId: row.pluginId,
authorId: row.authorId,
title: row.title,
content: row.content,
@@ -216,10 +451,9 @@ export class SearchModel {
}
async bulkIndex(docs: SearchDocument[]): Promise {
- const clean = docs.map(doc => ({
- ...doc,
- content: stripHtml(doc.content),
- }));
+ const clean = docs.map(doc =>
+ this.resolveOwner({ ...doc, content: stripHtml(doc.content) }),
+ );
for (const doc of clean) {
await this.upsertRow(doc);
@@ -237,7 +471,36 @@ export class SearchModel {
await this.provider().clear(this.c, itemType);
}
- async delete(itemType: string, itemId: number): Promise {
+ /**
+ * 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.
+ *
+ * `languageCode` is the whole point of the overload: multi-language content is
+ * one row per `(itemType, itemId, languageCode)`, so taking the Polish
+ * translation down must leave the English document exactly where it is.
+ * Omitting it removes every language, which is what deleting the record means.
+ */
+ async delete(
+ itemType: string,
+ itemId: number,
+ languageCode?: string,
+ ): Promise {
await this.c
.get("db")
.delete(core_search_index)
@@ -245,20 +508,36 @@ export class SearchModel {
and(
eq(core_search_index.itemType, itemType),
eq(core_search_index.itemId, itemId),
+ languageCode === undefined
+ ? undefined
+ : eq(core_search_index.languageCode, languageCode),
),
);
- await this.provider().delete(this.c, itemType, itemId);
+ await this.provider().delete(this.c, itemType, itemId, languageCode);
}
/** Canonical projection lives in `core_search_index`; the provider mirrors it. */
async index(doc: SearchDocument): Promise {
- const clean = { ...doc, content: stripHtml(doc.content) };
+ const clean = this.resolveOwner({
+ ...doc,
+ content: stripHtml(doc.content),
+ });
await this.upsertRow(clean);
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 bf3a6e73b..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,5 +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";
@@ -18,6 +20,8 @@ export const debugAdminModule = buildModule({
queueDebugAdminRoute,
searchStatusDebugAdminRoute,
rebuildSearchDebugAdminRoute,
+ clearSearchDebugAdminRoute,
+ contentStatusDebugAdminRoute,
sendTestEmailDebugAdminRoute,
testAiDebugAdminRoute,
testStorageUploadDebugAdminRoute,
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts
new file mode 100644
index 000000000..404097a12
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts
@@ -0,0 +1,89 @@
+import { HTTPException } from "hono/http-exception";
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+
+export const zodClearSearchSchema = z.object({
+ itemType: z.string().min(1),
+});
+
+/**
+ * Deletes the documents of one collection that has no registered rebuild
+ * indexer.
+ *
+ * Deliberately not part of `/search/rebuild`: this removes documents and puts
+ * nothing back, so it must not hide behind an action called "reindex". It is
+ * refused for a collection that *does* have an indexer - that one has a rebuild,
+ * which is the non-destructive way to get the same freshness.
+ *
+ * What it does **not** mean is that the collection is abandoned. Registering an
+ * indexer is optional, and a plugin that writes through `search.index()` keeps
+ * its collection current without one - so a cleared collection can reappear on
+ * that plugin's next write. This clears the current indexed state; it does not
+ * stop anything from writing again.
+ *
+ * `itemType` is required and non-empty, so there is no payload that clears the
+ * whole index by omission. A full rebuild is the only thing that does that, and
+ * it refills what it can.
+ */
+export const clearSearchDebugAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "system", permission: "can_view" },
+ route: {
+ method: "post",
+ description:
+ "Permanently remove the currently indexed documents of one collection that has no registered rebuild indexer.",
+ path: "/search/clear",
+ request: {
+ body: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: zodClearSearchSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({ cleared: z.boolean() }),
+ },
+ },
+ description: "Collection cleared",
+ },
+ 409: {
+ description: "The collection has a registered rebuild indexer",
+ },
+ },
+ },
+ handler: async c => {
+ const { itemType } = c.req.valid("json");
+
+ if (c.get("core").searchIndexers.some(i => i.itemType === itemType)) {
+ throw new HTTPException(409, {
+ message: `"${itemType}" has a registered rebuild indexer. Rebuild it instead of deleting its documents.`,
+ });
+ }
+
+ await c.get("search").clear(itemType);
+
+ // The documents are already gone, so the audit trail is best effort: the
+ // logger writes to the database and can fail on its own, and reporting a
+ // failed cleanup for a cleanup that happened would send an administrator
+ // looking for documents that are not there.
+ const message = `[Search] Removed the indexed documents of unmanaged collection "${itemType}".`;
+ try {
+ await c.get("log").warn(message);
+ } catch {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[VitNode] Failed to persist search cleanup audit: ${message}`,
+ );
+ }
+
+ return c.json({ cleared: true });
+ },
+});
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/integrations.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts
index 000a41381..30d8dfb14 100644
--- a/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/integrations.route.ts
@@ -38,6 +38,13 @@ export const integrationsDebugAdminRoute = buildRoute({
.enum(["cloudflare_turnstile", "recaptcha_v3"])
.nullable(),
}),
+ contentPreview: z.object({
+ // `true` when at least one content type has
+ // `editorial.preview.enabled`, i.e. the preview routes exist.
+ active: z.boolean(),
+ // How many content types can mint preview links.
+ contentTypes: z.number(),
+ }),
cron: z.object({
// `true` when a cron adapter is configured, i.e. an in-process
// scheduler is running the registered jobs automatically.
@@ -133,6 +140,9 @@ export const integrationsDebugAdminRoute = buildRoute({
cronActivity?.lastActivity ? new Date(cronActivity.lastActivity) : null,
);
const cronActive = core.hasCronAdapter;
+ const previewContentTypes = core.contentTypes.filter(
+ entry => entry.definition.editorial.preview.enabled,
+ ).length;
const queueStatus = getQueueStatus({
cronActive,
cronStale,
@@ -152,6 +162,10 @@ export const integrationsDebugAdminRoute = buildRoute({
active: !!(captcha?.secretKey && captcha.siteKey),
type: captcha?.type ?? null,
},
+ contentPreview: {
+ active: previewContentTypes > 0,
+ contentTypes: previewContentTypes,
+ },
cron: {
active: cronActive,
jobs: core.cron.length,
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/debug/routes/rebuild-search.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/rebuild-search.route.ts
index 48bbeebd3..7e8f0516f 100644
--- a/packages/vitnode/src/api/modules/admin/debug/routes/rebuild-search.route.ts
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/rebuild-search.route.ts
@@ -1,3 +1,4 @@
+import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import { buildRoute } from "@/api/lib/route";
@@ -35,11 +36,24 @@ export const rebuildSearchDebugAdminRoute = buildRoute({
},
description: "Rebuild queued",
},
+ 404: { description: "No indexer is registered for that collection" },
},
},
handler: async c => {
const { itemType } = c.req.valid("json") ?? {};
+ // A rebuild of a collection with no indexer would clear it and refill
+ // nothing, so it is refused here rather than queued and discovered later.
+ // The task repeats the check for callers that bypass this route.
+ if (
+ itemType &&
+ !c.get("core").searchIndexers.some(i => i.itemType === itemType)
+ ) {
+ throw new HTTPException(404, {
+ message: `No search indexer is registered for "${itemType}".`,
+ });
+ }
+
await c.get("queue").dispatch({
name: "rebuild-search-index",
payload: itemType ? { itemType } : {},
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts
new file mode 100644
index 000000000..4f9bdc463
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts
@@ -0,0 +1,279 @@
+// @vitest-environment node
+import { HTTPException } from "hono/http-exception";
+import { describe, expect, it, vi } from "vitest";
+
+import type { SearchIndexerConfig } from "@/api/models/search";
+
+import { clearSearchDebugAdminRoute } from "./clear-search.route";
+import { rebuildSearchDebugAdminRoute } from "./rebuild-search.route";
+
+const indexer = (itemType: string): SearchIndexerConfig => ({
+ itemType,
+ load: async () => await Promise.resolve({ documents: [], itemsRead: 0 }),
+ pluginId: "@vitnode/example",
+});
+
+interface Dispatched {
+ name: string;
+ payload?: Record;
+}
+
+/**
+ * Both handlers are called directly, the way the queue-task tests call theirs:
+ * `Route.handler` is deliberately erased, and neither route needs Hono for
+ * anything but reading a body these tests can hand it outright.
+ */
+const harness = ({
+ body,
+ clearFails = false,
+ indexers = [],
+ logFails = false,
+}: {
+ body?: Record;
+ clearFails?: boolean;
+ indexers?: SearchIndexerConfig[];
+ logFails?: boolean;
+} = {}) => {
+ const dispatched: Dispatched[] = [];
+ const cleared: (string | undefined)[] = [];
+ const warnings: string[] = [];
+
+ const clear = vi.fn(async (itemType?: string) => {
+ if (clearFails) throw new Error("engine unavailable");
+ cleared.push(itemType);
+ await Promise.resolve();
+ });
+
+ const c = {
+ get: (key: string) => {
+ if (key === "core") return { searchIndexers: indexers };
+ if (key === "queue") {
+ return {
+ dispatch: async (task: Dispatched) => {
+ dispatched.push(task);
+ await Promise.resolve();
+ },
+ };
+ }
+ if (key === "search") {
+ return {
+ clear: clear as unknown,
+ };
+ }
+ if (key === "log") {
+ return {
+ warn: async (content: string) => {
+ warnings.push(content);
+ await Promise.resolve();
+ if (logFails) throw new Error("core_logs unavailable");
+ },
+ };
+ }
+
+ return undefined;
+ },
+ json: (value: unknown) => new Response(JSON.stringify(value)),
+ req: { valid: () => body },
+ };
+
+ return { c, clear, cleared, dispatched, warnings };
+};
+
+const statusOf = async (run: () => Promise) => {
+ try {
+ await run();
+ } catch (error) {
+ if (error instanceof HTTPException) return error.status;
+ throw error;
+ }
+
+ return 200;
+};
+
+describe("POST /search/rebuild", () => {
+ it("queues a scoped rebuild for a registered collection", async () => {
+ const { c, dispatched } = harness({
+ body: { itemType: "example.article" },
+ indexers: [indexer("example.article")],
+ });
+
+ await rebuildSearchDebugAdminRoute.handler(c);
+
+ expect(dispatched).toEqual([
+ {
+ name: "rebuild-search-index",
+ payload: { itemType: "example.article" },
+ },
+ ]);
+ });
+
+ it("rejects a scoped rebuild for a collection with no indexer", async () => {
+ // Queuing this would clear the collection and refill nothing, so the button
+ // that offers it must fail before anything is dispatched.
+ const { c, dispatched } = harness({
+ body: { itemType: "removed.collection" },
+ indexers: [indexer("example.article")],
+ });
+
+ await expect(
+ statusOf(async () => await rebuildSearchDebugAdminRoute.handler(c)),
+ ).resolves.toBe(404);
+ expect(dispatched).toEqual([]);
+ });
+
+ it("queues a full rebuild with no item type", async () => {
+ const { c, dispatched } = harness({
+ indexers: [indexer("example.article")],
+ });
+
+ await rebuildSearchDebugAdminRoute.handler(c);
+
+ expect(dispatched).toEqual([{ name: "rebuild-search-index", payload: {} }]);
+ });
+
+ it("queues a full rebuild even with no indexers at all", async () => {
+ // The guard is about *scoped* rebuilds; a full one is allowed to clear an
+ // index it cannot fully refill, which is how documents with no indexer get
+ // removed.
+ const { c, dispatched } = harness({ body: {} });
+
+ await rebuildSearchDebugAdminRoute.handler(c);
+
+ expect(dispatched).toHaveLength(1);
+ });
+});
+
+describe("POST /search/clear", () => {
+ it("clears only the requested collection", async () => {
+ const { c, cleared, warnings } = harness({
+ body: { itemType: "removed.collection" },
+ indexers: [indexer("example.article")],
+ });
+
+ await clearSearchDebugAdminRoute.handler(c);
+
+ expect(cleared).toEqual(["removed.collection"]);
+ expect(warnings[0]).toContain("removed.collection");
+ });
+
+ it("refuses a collection that still has an indexer", async () => {
+ // That one has a rebuild, which gets the same freshness without deleting.
+ const { c, cleared } = harness({
+ body: { itemType: "example.article" },
+ indexers: [indexer("example.article")],
+ });
+
+ await expect(
+ statusOf(async () => await clearSearchDebugAdminRoute.handler(c)),
+ ).resolves.toBe(409);
+ expect(cleared).toEqual([]);
+ });
+
+ it("never clears the whole index", async () => {
+ // `itemType` is required and non-empty in the schema, so there is no payload
+ // that reaches `clear(undefined)` through this route.
+ const { c, cleared } = harness({
+ body: { itemType: "removed.collection" },
+ });
+
+ await clearSearchDebugAdminRoute.handler(c);
+
+ expect(cleared).not.toContain(undefined);
+ });
+
+ it("writes a neutral audit warning", async () => {
+ const { c, warnings } = harness({
+ body: { itemType: "live.only" },
+ });
+
+ await clearSearchDebugAdminRoute.handler(c);
+
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain("unmanaged collection");
+ expect(warnings[0]).toContain("live.only");
+ // Nothing claims the plugin is gone: registering an indexer is optional.
+ expect(warnings[0]).not.toContain("orphan");
+ });
+
+ it("stays successful when the audit log fails", async () => {
+ // The documents are already gone. Reporting a failure would send an
+ // administrator looking for documents that are not there.
+ const consoleWarn = vi
+ .spyOn(console, "warn")
+ .mockImplementation(() => undefined);
+
+ try {
+ const { c, clear, cleared, warnings } = harness({
+ body: { itemType: "live.only" },
+ logFails: true,
+ });
+
+ const res = await clearSearchDebugAdminRoute.handler(c);
+
+ await expect(new Response(res.body).json()).resolves.toEqual({
+ cleared: true,
+ });
+ expect(cleared).toEqual(["live.only"]);
+ // Attempted once, and not retried because the log failed.
+ expect(clear).toHaveBeenCalledTimes(1);
+ expect(warnings).toHaveLength(1);
+ expect(consoleWarn).toHaveBeenCalledTimes(1);
+ expect(String(consoleWarn.mock.calls[0][0])).toContain(
+ "Failed to persist search cleanup audit",
+ );
+ } finally {
+ consoleWarn.mockRestore();
+ }
+ });
+
+ it("does not reach the console when the audit log succeeds", async () => {
+ const consoleWarn = vi
+ .spyOn(console, "warn")
+ .mockImplementation(() => undefined);
+
+ try {
+ const { c } = harness({ body: { itemType: "live.only" } });
+
+ await clearSearchDebugAdminRoute.handler(c);
+
+ expect(consoleWarn).not.toHaveBeenCalled();
+ } finally {
+ consoleWarn.mockRestore();
+ }
+ });
+
+ it("propagates a failed clear and writes no success audit", async () => {
+ const { c, warnings } = harness({
+ body: { itemType: "live.only" },
+ clearFails: true,
+ });
+
+ await expect(clearSearchDebugAdminRoute.handler(c)).rejects.toThrow(
+ "engine unavailable",
+ );
+ expect(warnings).toEqual([]);
+ });
+
+ it("rejects an empty item type at the schema", () => {
+ expect(
+ zodBody(clearSearchDebugAdminRoute).safeParse({ itemType: "" }).success,
+ ).toBe(false);
+ expect(zodBody(clearSearchDebugAdminRoute).safeParse({}).success).toBe(
+ false,
+ );
+ expect(
+ zodBody(clearSearchDebugAdminRoute).safeParse({ itemType: "a.b" })
+ .success,
+ ).toBe(true);
+ });
+});
+
+/** Reaches the body schema the route declared, so the test asserts on the real one. */
+function zodBody(route: typeof clearSearchDebugAdminRoute) {
+ const body = route.route.request?.body;
+ if (!body || !("content" in body)) throw new Error("No body schema.");
+
+ return body.content["application/json"].schema as {
+ safeParse: (value: unknown) => { success: boolean };
+ };
+}
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts
new file mode 100644
index 000000000..0dc884621
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts
@@ -0,0 +1,263 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import type { SearchIndexerConfig } from "@/api/models/search";
+
+import { searchStatusDebugAdminRoute } from "./search-status.route";
+
+interface IndexedRow {
+ indexed: number;
+ itemType: string;
+ lastIndexedAt: Date | null;
+ pluginId: null | string;
+}
+
+interface Collection {
+ hasIndexer: boolean;
+ indexed: number;
+ itemType: string;
+ pluginId: string;
+ total: null | number;
+}
+
+const indexer = (
+ itemType: string,
+ pluginId: string,
+ total?: number,
+): SearchIndexerConfig => ({
+ itemType,
+ ...(total === undefined
+ ? {}
+ : { count: async () => await Promise.resolve(total) }),
+ load: async () => await Promise.resolve({ documents: [], itemsRead: 0 }),
+ pluginId,
+});
+
+/**
+ * The handler is called directly, the way the queue-task tests call theirs: it
+ * takes no request input, so routing it through Hono would only add a cast -
+ * `Route.handler` is deliberately erased to `(...args: unknown[])`.
+ *
+ * The stub answers the coverage query with the given rows and every later query
+ * (the sync-error panel) with nothing.
+ */
+const harness = ({
+ indexers = [],
+ rows = [],
+}: {
+ indexers?: SearchIndexerConfig[];
+ rows?: IndexedRow[];
+} = {}) => {
+ const results: unknown[][] = [rows, []];
+ let call = 0;
+
+ const chain = (value: unknown[]) => {
+ const builder: Record = {};
+ for (const op of ["from", "groupBy", "where", "orderBy", "limit"]) {
+ builder[op] = () => builder;
+ }
+ builder.then = async (resolve: (rows: unknown[]) => TResult) =>
+ await Promise.resolve(value).then(resolve);
+
+ return builder;
+ };
+
+ const db = {
+ select: () => {
+ const value = results[call] ?? [];
+ call++;
+
+ return chain(value);
+ },
+ };
+
+ let body: undefined | { collections: Collection[] };
+
+ const c = {
+ get: (key: string) => {
+ if (key === "db") return db;
+ if (key === "search") {
+ return {
+ name: () => "postgres",
+ ping: async () => await Promise.resolve(true),
+ };
+ }
+ if (key === "core") {
+ return { hasCronAdapter: true, searchIndexers: indexers };
+ }
+
+ return undefined;
+ },
+ json: (value: { collections: Collection[] }) => {
+ body = value;
+
+ return new Response();
+ },
+ };
+
+ return {
+ collections: async (): Promise => {
+ await searchStatusDebugAdminRoute.handler(c);
+
+ if (!body) throw new Error("The handler returned no body.");
+
+ return body.collections;
+ },
+ };
+};
+
+const indexedRow = (
+ itemType: string,
+ pluginId: null | string,
+ indexed: number,
+): IndexedRow => ({ indexed, itemType, lastIndexedAt: null, pluginId });
+
+describe("search status collection ownership", () => {
+ it("uses the registered indexer's plugin", async () => {
+ const { collections } = harness({
+ indexers: [indexer("example.article", "@vitnode/example", 3)],
+ rows: [indexedRow("example.article", "@vitnode/example", 3)],
+ });
+
+ await expect(collections()).resolves.toEqual([
+ expect.objectContaining({
+ hasIndexer: true,
+ indexed: 3,
+ itemType: "example.article",
+ pluginId: "@vitnode/example",
+ total: 3,
+ }),
+ ]);
+ });
+
+ it("falls back to the stored owner when no indexer is registered", async () => {
+ // No rebuild indexer, for any reason - never registered, or its plugin is
+ // gone - but the rows still say who wrote them. Reassigning them to core
+ // would be a lie.
+ const { collections } = harness({
+ rows: [indexedRow("example.article", "@vitnode/example", 3)],
+ });
+
+ const [collection] = await collections();
+
+ expect(collection.pluginId).toBe("@vitnode/example");
+ expect(collection.hasIndexer).toBe(false);
+ // No indexer to ask for a source count, so there is none to report.
+ expect(collection.total).toBeNull();
+ });
+
+ it("reports `unknown` when neither source names an owner", async () => {
+ const { collections } = harness({
+ rows: [indexedRow("mystery.item", null, 2)],
+ });
+
+ const [collection] = await collections();
+
+ expect(collection.pluginId).toBe("unknown");
+ });
+
+ it("treats a blank stored owner as unknown", async () => {
+ const { collections } = harness({
+ rows: [indexedRow("mystery.item", " ", 2)],
+ });
+
+ const [collection] = await collections();
+
+ expect(collection.pluginId).toBe("unknown");
+ });
+
+ it("keeps the registered owner when the stored one disagrees", async () => {
+ // The next rebuild rewrites the rows, so the live indexer is canonical.
+ const { collections } = harness({
+ indexers: [indexer("example.article", "@vitnode/example", 3)],
+ rows: [indexedRow("example.article", "@vitnode/old-example", 3)],
+ });
+
+ const [collection] = await collections();
+
+ expect(collection.pluginId).toBe("@vitnode/example");
+ });
+
+ it("keeps an over-indexed collection with no indexer truthful", async () => {
+ // The regression: `total` used to fall back to `indexed`, so a collection
+ // with no indexer reported 11/11 and read as fully indexed. There is no
+ // source to count, so there is no total - and it must still not be called
+ // core.
+ const { collections } = harness({
+ rows: [indexedRow("example.article", "@vitnode/example", 11)],
+ });
+
+ await expect(collections()).resolves.toEqual([
+ expect.objectContaining({
+ hasIndexer: false,
+ indexed: 11,
+ pluginId: "@vitnode/example",
+ total: null,
+ }),
+ ]);
+ });
+
+ it("reports the real counts of an over-indexed registered collection", async () => {
+ const { collections } = harness({
+ indexers: [indexer("example.article", "@vitnode/example", 9)],
+ rows: [indexedRow("example.article", "@vitnode/example", 11)],
+ });
+
+ const [collection] = await collections();
+
+ // Neither number is rewritten to hide the extra documents, so the AdminCP
+ // still reads this as stale.
+ expect(collection).toMatchObject({
+ hasIndexer: true,
+ indexed: 11,
+ pluginId: "@vitnode/example",
+ total: 9,
+ });
+ });
+
+ it("lists a registered collection with nothing indexed yet", async () => {
+ const { collections } = harness({
+ indexers: [indexer("example.article", "@vitnode/example", 5)],
+ });
+
+ await expect(collections()).resolves.toEqual([
+ expect.objectContaining({
+ hasIndexer: true,
+ indexed: 0,
+ pluginId: "@vitnode/example",
+ total: 5,
+ }),
+ ]);
+ });
+
+ it("reports an indexer with no `count` against the indexed total", async () => {
+ // `count` is optional, and leaving it out is documented as "assume covered".
+ // That is still a registered collection, so `hasIndexer` stays true.
+ const { collections } = harness({
+ indexers: [indexer("example.article", "@vitnode/example")],
+ rows: [indexedRow("example.article", "@vitnode/example", 4)],
+ });
+
+ const [collection] = await collections();
+
+ expect(collection).toMatchObject({
+ hasIndexer: true,
+ indexed: 4,
+ total: 4,
+ });
+ });
+
+ it("does not infer an indexer from a stored owner", async () => {
+ // The rule the field exists for: rows knowing who wrote them says nothing
+ // about whether a rebuild indexer is registered. It says nothing the other
+ // way either - a plugin may be installed, active, and writing live.
+ const { collections } = harness({
+ rows: [indexedRow("example.article", "@vitnode/example", 3)],
+ });
+
+ const [collection] = await collections();
+
+ expect(collection.pluginId).toBe("@vitnode/example");
+ expect(collection.hasIndexer).toBe(false);
+ });
+});
diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts
index 629145a46..20229f4e6 100644
--- a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts
+++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts
@@ -1,16 +1,67 @@
-import { countDistinct, max } from "drizzle-orm";
+import {
+ and,
+ count,
+ countDistinct,
+ desc,
+ eq,
+ like,
+ max,
+ ne,
+} from "drizzle-orm";
import { z } from "zod";
import { buildRoute } from "@/api/lib/route";
+import { searchDocumentOwner } from "@/api/models/search";
import { CONFIG_PLUGIN } from "@/config";
+import { core_logs } from "@/database/logs";
import { core_search_index } from "@/database/search";
+const CONTENT_SEARCH_LOG_PREFIX = "[content-search]";
+
+const SYNC_ERROR_LIMIT = 10;
+
const collectionSchema = z.object({
+ /**
+ * Index rows, counting one per language.
+ *
+ * Separate from `indexed`, which counts distinct items: multi-language content
+ * is indexed once per translation, so its coverage has to be measured in
+ * documents or a fully-indexed collection would read as 33%.
+ */
+ documents: z.number(),
+ /**
+ * Whether an indexer is registered for this item type *right now*. A stored
+ * plugin owner does not imply one: the plugin may be uninstalled, renamed, or
+ * simply not loaded in this process.
+ */
+ hasIndexer: z.boolean(),
indexed: z.number(),
itemType: z.string(),
+ /**
+ * One entry per language present in the index, newest first by count.
+ *
+ * Empty for a collection that is entirely language-agnostic. It is what makes
+ * "Polish is missing 40 documents" visible at all - a single total cannot say
+ * which language a rebuild failed halfway through.
+ */
+ languages: z.array(
+ z.object({
+ documents: z.number(),
+ languageCode: z.string(),
+ lastIndexedAt: z.date().nullable(),
+ }),
+ ),
lastIndexedAt: z.date().nullable(),
pluginId: z.string(),
- total: z.number(),
+ /** Source items the indexer reports. `null` when there is no indexer to ask. */
+ total: z.number().nullable(),
+});
+
+const syncErrorSchema = z.object({
+ content: z.string(),
+ createdAt: z.date(),
+ id: z.number(),
+ pluginId: z.string(),
});
export const searchStatusDebugAdminRoute = buildRoute({
@@ -31,6 +82,7 @@ export const searchStatusDebugAdminRoute = buildRoute({
hasCronAdapter: z.boolean(),
healthy: z.boolean(),
lastIndexedAt: z.date().nullable(),
+ syncErrors: z.array(syncErrorSchema),
total: z.number(),
}),
},
@@ -46,17 +98,73 @@ export const searchStatusDebugAdminRoute = buildRoute({
// One item can emit several index rows (e.g. one per language), so coverage
// is measured in distinct items - not documents.
+ //
+ // `pluginId` comes along so a collection whose indexer is gone can still name
+ // its owner. An item type has one owner, so the aggregate is a formality -
+ // `max` picks deterministically if rows ever disagree mid-rebuild.
const indexedByType = await db
.select({
itemType: core_search_index.itemType,
+ documents: count(),
indexed: countDistinct(core_search_index.itemId),
lastIndexedAt: max(core_search_index.indexedAt),
+ pluginId: max(core_search_index.pluginId),
})
.from(core_search_index)
.groupBy(core_search_index.itemType);
const statsByType = new Map(indexedByType.map(row => [row.itemType, row]));
+ // Per language, so a rebuild that stopped halfway through one locale is
+ // visible as that locale rather than as a slightly-low total. The
+ // language-agnostic rows (`""`) are dropped: they are not a language, and
+ // listing them as one would put an unnamed row in every collection.
+ const byLanguage = await db
+ .select({
+ itemType: core_search_index.itemType,
+ documents: count(),
+ languageCode: core_search_index.languageCode,
+ lastIndexedAt: max(core_search_index.indexedAt),
+ })
+ .from(core_search_index)
+ .where(ne(core_search_index.languageCode, ""))
+ .groupBy(core_search_index.itemType, core_search_index.languageCode)
+ .orderBy(desc(count()));
+
+ const languagesByType = new Map<
+ string,
+ { documents: number; languageCode: string; lastIndexedAt: Date | null }[]
+ >();
+ for (const row of byLanguage) {
+ const entries = languagesByType.get(row.itemType) ?? [];
+ entries.push({
+ documents: row.documents,
+ languageCode: row.languageCode,
+ lastIndexedAt: row.lastIndexedAt,
+ });
+ languagesByType.set(row.itemType, entries);
+ }
+
+ // Newest first, and bounded: this is a "what went wrong lately" panel, not a
+ // log viewer. `LIKE 'prefix%'` needs no escaping - the prefix contains
+ // neither `%` nor `_`.
+ const syncErrors = await db
+ .select({
+ id: core_logs.id,
+ pluginId: core_logs.pluginId,
+ content: core_logs.content,
+ createdAt: core_logs.createdAt,
+ })
+ .from(core_logs)
+ .where(
+ and(
+ eq(core_logs.type, "error"),
+ like(core_logs.content, `${CONTENT_SEARCH_LOG_PREFIX}%`),
+ ),
+ )
+ .orderBy(desc(core_logs.id))
+ .limit(SYNC_ERROR_LIMIT);
+
// Start from every registered indexer so a collection with nothing indexed
// yet still appears; then fold in any indexed type without a live indexer.
const itemTypes = [
@@ -71,15 +179,34 @@ export const searchStatusDebugAdminRoute = buildRoute({
const indexer = core.searchIndexers.find(i => i.itemType === itemType);
const stats = statsByType.get(itemType);
const indexed = stats?.indexed ?? 0;
- const total = indexer?.count ? await indexer.count(c) : indexed;
+ // No indexer, no source count - and inventing `total = indexed` would
+ // report a collection nothing can rebuild as fully covered. An indexer
+ // without the optional `count` still falls back to the indexed count,
+ // which is the documented behaviour of leaving `count` out.
+ //
+ // `hasIndexer` says only whether a rebuild indexer exists. A plugin may
+ // keep its collection current through `search.index()` and register none,
+ // so this is not a statement about the plugin.
+ const total = indexer ? ((await indexer.count?.(c)) ?? indexed) : null;
return {
+ hasIndexer: indexer !== undefined,
itemType,
- pluginId: indexer?.pluginId ?? "core",
+ // The registered indexer is canonical - it is what the next rebuild
+ // will stamp on the rows. Falling back to the stored owner is what
+ // stops a collection with no indexer being reassigned to core, and
+ // `"unknown"` is honest when neither source knows.
+ pluginId:
+ indexer?.pluginId ??
+ searchDocumentOwner(stats?.pluginId) ??
+ "unknown",
+ documents: stats?.documents ?? 0,
indexed,
- // A source count below the indexed count (e.g. items deleted since the
- // last rebuild) would break the coverage bar; never report less.
- total: Math.max(total, indexed),
+ languages: languagesByType.get(itemType) ?? [],
+ // Reported as measured, even when it is below `indexed`: more documents
+ // than source records is a stale index, and raising the source count to
+ // hide it is how that goes unnoticed. The UI clamps the bar instead.
+ total,
lastIndexedAt: stats?.lastIndexedAt ?? null,
};
}),
@@ -97,6 +224,7 @@ export const searchStatusDebugAdminRoute = buildRoute({
hasCronAdapter: core.hasCronAdapter,
healthy: await search.ping(),
lastIndexedAt,
+ syncErrors,
total: collections.reduce((sum, row) => sum + row.indexed, 0),
});
},
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/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts
new file mode 100644
index 000000000..682152133
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/content.module.ts
@@ -0,0 +1,33 @@
+import { buildModule } from "@/api/lib/module";
+import { CONFIG_PLUGIN } from "@/config";
+
+import { contentEditorialCleanupCron } from "./cron/content-editorial-cleanup.cron";
+import { contentScheduleEffectsQueueTask } from "./tasks/content-schedule-effects.task";
+import { contentScheduleQueueTask } from "./tasks/content-schedule.task";
+
+/**
+ * Core's own Content Engine module: the background half.
+ *
+ * It serves no routes. It exists because `queueTasks` and `cronJobs` are
+ * collected from **top-level** modules only, while `buildContentAdminModule` is
+ * nested inside a plugin's `admin` module - so a task registered there would be
+ * silently dropped, with no error and no handler.
+ *
+ * One task for every schedulable content type in the install, rather than one
+ * per type. The handler resolves the model from `c.get("core").contentModels`,
+ * so adding a content type adds no task, no name to collide with, and no
+ * registration to forget.
+ *
+ * Two tasks rather than one, because a scheduled publication is two units of
+ * work with two different failure meanings: `content-schedule` moves the
+ * database and either commits or does not, and `content-schedule-effects`
+ * announces what committed and can be retried on its own without ever
+ * republishing.
+ */
+export const contentModule = buildModule({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ name: "content",
+ routes: [],
+ cronJobs: [contentEditorialCleanupCron],
+ queueTasks: [contentScheduleQueueTask, contentScheduleEffectsQueueTask],
+});
diff --git a/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts
new file mode 100644
index 000000000..3130c3358
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts
@@ -0,0 +1,59 @@
+import { buildCron } from "@/api/lib/cron";
+import {
+ CONTENT_REVISION_MAX_RETENTION,
+ CONTENT_SCHEDULE_RETENTION_DAYS,
+} from "@/content/const";
+import { pruneContentRevisions } from "@/content/server/revisions-model";
+import { pruneContentSchedules } from "@/content/server/schedules-model";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Sweeps up editorial rows that no longer describe anything.
+ *
+ * Revision retention is enforced inline, in the same transaction as the write,
+ * so this is **not** the thing that keeps the table bounded on a healthy
+ * install - an install with no cron adapter must not grow forever, and it does
+ * not. What this handles is the case inline pruning structurally cannot: rows
+ * whose content type stopped existing, so nothing will ever write to them again
+ * and trigger a prune.
+ *
+ * Daily rather than hourly. Nothing here is urgent, and a plugin removed at
+ * lunchtime does not need its history gone by teatime.
+ */
+export const contentEditorialCleanupCron = buildCron({
+ name: "content-editorial-cleanup",
+ description:
+ "Remove revisions and schedules for content types that are no longer registered, and settled schedules past their retention window.",
+ // 03:20 daily, off the hour so it does not pile onto every other daily job.
+ schedule: "20 3 * * *",
+ handler: async c => {
+ const known = c
+ .get("core")
+ .contentTypes.filter(entry => entry.definition.editorial.enabled)
+ .map(entry => entry.definition.id);
+
+ const schedules = await pruneContentSchedules({
+ db: c.get("db"),
+ knownContentTypeIds: known,
+ olderThan: new Date(
+ Date.now() - CONTENT_SCHEDULE_RETENTION_DAYS * DAY_MS,
+ ),
+ });
+
+ const revisions = await pruneContentRevisions({
+ db: c.get("db"),
+ knownContentTypeIds: known,
+ });
+
+ if (schedules.orphaned + revisions.orphaned === 0) return;
+
+ // Worth saying out loud: an unexpected number here usually means a plugin
+ // id or a content type id was renamed without the documented UPDATE.
+ await c
+ .get("log")
+ .debug(
+ `[content-editorial-cleanup] removed ${revisions.orphaned} orphaned revisions and ${schedules.orphaned} orphaned schedules (${schedules.settled} settled schedules aged out; revision retention stays capped at ${CONTENT_REVISION_MAX_RETENTION} per record).`,
+ );
+ },
+});
diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts
new file mode 100644
index 000000000..7f2b98ac8
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts
@@ -0,0 +1,402 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { testEditorialPostContentType } from "@/tests/content-fixtures";
+
+const claimContentSchedule = vi.fn();
+const settleContentSchedule = vi.fn();
+
+vi.mock("@/content/server/schedules-model", () => ({
+ claimContentSchedule: (...args: unknown[]) => claimContentSchedule(...args),
+ settleContentSchedule: (...args: unknown[]) => settleContentSchedule(...args),
+}));
+
+const { executeContentSchedule } = await import("./execute-content-schedule");
+
+const PLUGIN_ID = "@vitnode/example";
+
+const claimed = {
+ action: "publish" as const,
+ contentTypeId: testEditorialPostContentType.id,
+ createdBy: 3,
+ id: 55,
+ itemId: 7,
+ pluginId: PLUGIN_ID,
+};
+
+const row = {
+ createdAt: new Date("2026-08-01T09:00:00.000Z"),
+ id: 7,
+ publishedAt: new Date("2026-08-05T12:00:00.000Z"),
+ slug: "hello-world",
+ status: "published",
+ title: "Hello world",
+ updatedAt: new Date("2026-08-05T12:00:00.000Z"),
+ version: 4,
+};
+
+const outcome = {
+ changed: true,
+ changedFields: [],
+ operation: "publish" as const,
+ previousSlug: "hello-world",
+ restoredFromRevisionId: null,
+ revisionId: 90,
+ row,
+ version: 4,
+};
+
+const harness = ({
+ editorial,
+ registered = true,
+}: {
+ editorial?: Partial>;
+ registered?: boolean;
+} = {}) => {
+ const publish = vi.fn().mockResolvedValue(outcome);
+ const unpublish = vi.fn().mockResolvedValue(outcome);
+
+ const model = {
+ definition: testEditorialPostContentType,
+ editorialService: () => ({ publish, unpublish, ...editorial }),
+ };
+
+ const dispatch = vi.fn().mockResolvedValue({ id: 1 });
+ let committed = false;
+
+ const db = {
+ transaction: async (fn: (tx: unknown) => Promise) => {
+ const result = await fn({ tx: true });
+ committed = true;
+
+ return result;
+ },
+ };
+
+ const c = {
+ get: (key: string) =>
+ key === "db"
+ ? db
+ : key === "queue"
+ ? { dispatch }
+ : key === "core"
+ ? {
+ contentModels: registered
+ ? [{ model, pluginId: PLUGIN_ID }]
+ : [],
+ }
+ : undefined,
+ } as unknown as Context;
+
+ return { c, committed: () => committed, dispatch, publish, unpublish };
+};
+
+/** The single argument every effects dispatch carries. */
+const dispatchedPayload = (dispatch: ReturnType) =>
+ dispatch.mock.calls[0][0] as {
+ name: string;
+ payload: Record;
+ pluginId: string;
+ tx?: unknown;
+ };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ settleContentSchedule.mockResolvedValue(true);
+});
+
+describe("executeContentSchedule", () => {
+ it("publishes, settles the schedule, and queues the announcements", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch, publish } = harness();
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("executed");
+ expect(publish).toHaveBeenCalledTimes(1);
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ expect(dispatchedPayload(dispatch).name).toBe("content-schedule-effects");
+ });
+
+ describe("one transaction, from the claim to the commit", () => {
+ it("claims, transitions, settles and dispatches on the same handle", async () => {
+ // The whole point of the fix. Every one of these ran against the same
+ // `tx`, so the row lock `claimContentSchedule` takes is still held when
+ // the transition commits - which is what makes a concurrent cancel wait
+ // rather than succeed and then be ignored.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch, publish } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ const tx = { tx: true };
+ expect(claimContentSchedule).toHaveBeenCalledWith(tx, expect.anything());
+ expect(publish.mock.calls[0][1]).toMatchObject({ tx });
+ expect(settleContentSchedule).toHaveBeenCalledWith(
+ tx,
+ 55,
+ expect.anything(),
+ );
+ expect(dispatchedPayload(dispatch).tx).toEqual(tx);
+ });
+
+ it("dispatches the effects before the transaction commits", async () => {
+ // If the queue row could land after the commit, a crash in between would
+ // leave a published record nobody was ever told about.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, committed, dispatch } = harness();
+
+ dispatch.mockImplementation(async () => {
+ expect(committed()).toBe(false);
+
+ return Promise.resolve({ id: 1 });
+ });
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ });
+
+ it("settles only while the schedule is still pending", async () => {
+ // The guard that stops a stale worker overwriting `cancelled` with
+ // `completed`.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(settleContentSchedule).toHaveBeenCalledWith(
+ expect.anything(),
+ 55,
+ {
+ expectedStatus: "pending",
+ lastError: null,
+ status: "completed",
+ },
+ );
+ });
+
+ it("rolls the transition back when the schedule is no longer pending", async () => {
+ // Structurally impossible while the lock is held - so if it happens the
+ // lock was not held, and publishing a cancelled plan is the worse of the
+ // two outcomes.
+ claimContentSchedule.mockResolvedValue(claimed);
+ settleContentSchedule.mockResolvedValue(false);
+ const { c, dispatch } = harness();
+
+ await expect(
+ executeContentSchedule(c, { generation: 1, scheduleId: 55 }),
+ ).rejects.toThrow(/no longer pending/);
+
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+ });
+
+ it("runs as the system, never as a made-up user", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, publish } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(publish.mock.calls[0][1]).toMatchObject({
+ actor: { type: "system", userId: null },
+ });
+ });
+
+ describe("the effects payload", () => {
+ it("names the person who booked it", async () => {
+ // The actor is genuinely the system, so "on whose instruction" has to
+ // come from somewhere else - and it is the whole point of the audit
+ // trail.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatchedPayload(dispatch).payload).toMatchObject({
+ contentTypeId: testEditorialPostContentType.id,
+ itemId: 7,
+ operation: "publish",
+ pluginId: PLUGIN_ID,
+ revisionId: 90,
+ scheduleId: 55,
+ scheduledBy: 3,
+ version: 4,
+ });
+ });
+
+ it("says the record was private before a publish", async () => {
+ // Derived from the transition's own guard rather than read back outside
+ // the lock: `publish` only changes a row that was not published.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatchedPayload(dispatch).payload.wasPublic).toBe(false);
+ });
+
+ it("says the record was public before an unpublish", async () => {
+ claimContentSchedule.mockResolvedValue({
+ ...claimed,
+ action: "unpublish",
+ });
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatchedPayload(dispatch).payload.wasPublic).toBe(true);
+ });
+
+ it("is JSON, so the queue can store and replay it", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ const { row: stored } = dispatchedPayload(dispatch).payload as {
+ row: Record;
+ };
+ expect(stored.publishedAt).toBe("2026-08-05T12:00:00.000Z");
+ expect(stored.title).toBe("Hello world");
+ });
+
+ it("is stamped with core, so the worker can find the handler", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness();
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(dispatchedPayload(dispatch).pluginId).toBe("@vitnode/core");
+ });
+ });
+
+ describe("no-ops", () => {
+ it("does nothing when the row is cancelled, superseded or not yet due", async () => {
+ // All four guards collapse to the same answer from `claim`, so this is
+ // one test rather than four identical ones.
+ claimContentSchedule.mockResolvedValue(null);
+ const { c, dispatch, publish } = harness();
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("skipped");
+ expect(publish).not.toHaveBeenCalled();
+ // The load-bearing part: a superseded task must not touch search or the
+ // cache, or a cancelled plan would still expire a live page.
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(settleContentSchedule).not.toHaveBeenCalled();
+ });
+
+ it("does nothing more when the record is already published", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness({
+ editorial: {
+ publish: vi.fn().mockResolvedValue({ ...outcome, changed: false }),
+ },
+ });
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("skipped");
+ expect(dispatch).not.toHaveBeenCalled();
+ // Still settled, or it would be retried forever for a record that is
+ // already in the state the schedule wanted.
+ expect(settleContentSchedule).toHaveBeenCalledWith(
+ expect.anything(),
+ 55,
+ {
+ expectedStatus: "pending",
+ lastError: null,
+ status: "completed",
+ },
+ );
+ });
+
+ it("does nothing when the record was deleted first", async () => {
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness({
+ editorial: { publish: vi.fn().mockResolvedValue(null) },
+ });
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("skipped");
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+ });
+
+ it("cancels rather than retrying when the content type is gone", async () => {
+ // A plugin removed, or `editorial` turned off. An error every ten minutes
+ // forever is not a useful way to report a config change.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c, dispatch } = harness({ registered: false });
+
+ const result = await executeContentSchedule(c, {
+ generation: 1,
+ scheduleId: 55,
+ });
+
+ expect(result.status).toBe("unregistered");
+ expect(settleContentSchedule).toHaveBeenCalledWith(
+ expect.anything(),
+ 55,
+ expect.objectContaining({
+ expectedStatus: "pending",
+ status: "cancelled",
+ }),
+ );
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ it("records the error and rethrows a real failure", async () => {
+ // This one *is* worth retrying, and the queue's backoff is the policy.
+ claimContentSchedule.mockResolvedValue(claimed);
+ const { c } = harness({
+ editorial: {
+ publish: vi.fn().mockRejectedValue(new Error("deadlock detected")),
+ },
+ });
+
+ await expect(
+ executeContentSchedule(c, { generation: 1, scheduleId: 55 }),
+ ).rejects.toThrow("deadlock detected");
+
+ expect(settleContentSchedule).toHaveBeenCalledWith(expect.anything(), 55, {
+ expectedStatus: "pending",
+ lastError: "deadlock detected",
+ });
+ // Left pending, so the AdminCP shows it as overdue rather than done.
+ expect(settleContentSchedule).not.toHaveBeenCalledWith(
+ expect.anything(),
+ 55,
+ expect.objectContaining({ status: "completed" }),
+ );
+ });
+
+ it("passes the generation straight through to the claim", async () => {
+ claimContentSchedule.mockResolvedValue(null);
+ const { c } = harness();
+
+ await executeContentSchedule(c, { generation: 4, scheduleId: 55 });
+
+ expect(claimContentSchedule).toHaveBeenCalledWith(expect.anything(), {
+ generation: 4,
+ scheduleId: 55,
+ });
+ });
+});
diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts
new file mode 100644
index 000000000..b76415c59
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts
@@ -0,0 +1,242 @@
+import type { Context } from "hono";
+
+import type { ContentEditorialOutcome } from "@/content/server/editorial-service";
+import type { ContentScheduleEffectsPayload } from "@/content/server/schedule-effects";
+import type { AnyContentTypeDefinition } from "@/content/types";
+
+import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const";
+import { CONTENT_SYSTEM_ACTOR } from "@/content/server/actor";
+import { findContentModel } from "@/content/server/model";
+import {
+ claimContentSchedule,
+ settleContentSchedule,
+} from "@/content/server/schedules-model";
+
+/** What the run decided, so the task logs something worth reading. */
+export interface ContentScheduleOutcome {
+ reason?: string;
+ status: "executed" | "skipped" | "unregistered";
+}
+
+/**
+ * Thrown when a claimed schedule is no longer `pending` at settlement time.
+ *
+ * Structurally impossible: the row is locked `FOR UPDATE` from the claim to the
+ * commit, so nothing else can have moved it. If it ever happens the lock was
+ * not held, and rolling the whole transition back is the only safe answer -
+ * publishing a record whose schedule somebody cancelled is worse than not
+ * publishing it.
+ */
+class ContentScheduleSettlementError extends Error {
+ constructor(scheduleId: number) {
+ super(
+ `Schedule ${scheduleId} was no longer pending at settlement time. Rolling the transition back.`,
+ );
+
+ this.name = "ContentScheduleSettlementError";
+ }
+}
+
+type ScheduleTransaction =
+ | { contentTypeId: string; kind: "unregistered" }
+ | { effects: ContentScheduleEffectsPayload; kind: "executed" }
+ | { kind: "skipped"; reason: string };
+
+const slugOf = (
+ definition: AnyContentTypeDefinition,
+ row: null | Record | undefined,
+): null | string => {
+ if (!definition.publicApi.enabled) return null;
+
+ const value = row?.[definition.publicApi.slugField];
+
+ return typeof value === "string" ? value : null;
+};
+
+/**
+ * Everything the announcements need, frozen at the moment the transition
+ * committed.
+ *
+ * `wasPublic` is derived rather than read back: the transition guards on the
+ * state it is leaving (`status <> 'published'` to publish, `= 'published'` to
+ * unpublish), so a *changed* publish came from a non-public row and a changed
+ * unpublish from a public one. That removes the extra `SELECT` the old code did
+ * outside the lock, and removes with it the window where the answer could have
+ * been someone else's write.
+ */
+const effectsPayload = ({
+ claimed,
+ definition,
+ outcome,
+ pluginId,
+}: {
+ claimed: {
+ action: "publish" | "unpublish";
+ createdBy: null | number;
+ id: number;
+ itemId: number;
+ };
+ definition: AnyContentTypeDefinition;
+ outcome: ContentEditorialOutcome;
+ pluginId: string;
+}): ContentScheduleEffectsPayload => {
+ const row = outcome.row as unknown as Record;
+
+ return {
+ changedFields: [...outcome.changedFields] as string[],
+ contentTypeId: definition.id,
+ itemId: claimed.itemId,
+ operation: claimed.action,
+ pluginId,
+ // A publish and an unpublish move `status`, never a field value, so the
+ // slug the record answered to before is the one it answers to now. Carried
+ // anyway, because the cache bridge takes a list and a future action that
+ // *does* move it should not need this file to change.
+ previousSlug: outcome.previousSlug ?? slugOf(definition, row),
+ revisionId: outcome.revisionId,
+ row: JSON.parse(JSON.stringify(row)) as Record,
+ scheduleId: claimed.id,
+ scheduledBy: claimed.createdBy,
+ version: outcome.version,
+ wasPublic: claimed.action === "unpublish",
+ };
+};
+
+/**
+ * Runs one scheduled transition, or decides not to.
+ *
+ * **One transaction, from the claim to the commit.** The old shape claimed in a
+ * short transaction of its own and released the row lock before publishing,
+ * which left a real window: an administrator could cancel, be told it worked,
+ * and watch the article go live anyway. Now the `FOR UPDATE` taken by
+ * `claimContentSchedule` is held until the transition, its revision, the
+ * settlement *and* the effects task have all committed - so a concurrent cancel
+ * either wins outright (before the claim) or waits and then finds the schedule
+ * already `completed`, which is a truthful 404 rather than a lie.
+ *
+ * What is deliberately **not** in the transaction: the event, the search write
+ * and the cache bridge. They talk to systems a rollback cannot reach, so they
+ * are handed to `content-schedule-effects` - a queue row written in this same
+ * transaction, and therefore present exactly when the transition committed.
+ *
+ * Almost every guard here is a **silent no-op**, and that is the design rather
+ * than laziness: each one describes a schedule that is no longer the plan -
+ * cancelled, rescheduled, or already run. Throwing would send the queue into a
+ * retry loop over a decision that is never going to change.
+ */
+export const executeContentSchedule = async (
+ c: Context,
+ { generation, scheduleId }: { generation: number; scheduleId: number },
+): Promise => {
+ const db = c.get("db");
+
+ let result: ScheduleTransaction;
+ try {
+ result = await db.transaction(async (tx): Promise => {
+ const claimed = await claimContentSchedule(tx, {
+ generation,
+ scheduleId,
+ });
+
+ if (!claimed) {
+ return {
+ kind: "skipped",
+ reason: "not pending, superseded, or not yet due",
+ };
+ }
+
+ const entry = findContentModel(
+ c.get("core").contentModels,
+ claimed.contentTypeId,
+ );
+ const editorialService = entry?.model.editorialService;
+
+ // The plugin was removed, or the content type dropped its editorial
+ // block. There is nothing to publish and there never will be, so cancel
+ // rather than retrying until the queue gives up - an error every ten
+ // minutes forever is not a useful way to report a config change.
+ if (!entry || !editorialService) {
+ await settleContentSchedule(tx, claimed.id, {
+ expectedStatus: "pending",
+ lastError: `Content type "${claimed.contentTypeId}" is no longer registered with an editorial workflow.`,
+ status: "cancelled",
+ });
+
+ return { contentTypeId: claimed.contentTypeId, kind: "unregistered" };
+ }
+
+ const { model, pluginId } = entry;
+
+ const outcome = await editorialService(c, { pluginId })[claimed.action](
+ claimed.itemId,
+ {
+ // No fake user id anywhere. Who *asked* for this is on the schedule
+ // row and travels in the event as `scheduledBy`.
+ actor: CONTENT_SYSTEM_ACTOR,
+ tx,
+ },
+ );
+
+ // Settled whatever happened. A record that was deleted first, or is
+ // already in the state the schedule wanted, is still a schedule that
+ // has had its answer - leaving it pending would retry it forever.
+ if (
+ !(await settleContentSchedule(tx, claimed.id, {
+ expectedStatus: "pending",
+ lastError: null,
+ status: "completed",
+ }))
+ ) {
+ throw new ContentScheduleSettlementError(claimed.id);
+ }
+
+ if (!outcome) {
+ return { kind: "skipped", reason: "record no longer exists" };
+ }
+ if (!outcome.changed) {
+ return { kind: "skipped", reason: "already in that state" };
+ }
+
+ const effects = effectsPayload({
+ claimed,
+ definition: model.definition,
+ outcome,
+ pluginId,
+ });
+
+ // In the transaction, so the announcement task exists if and only if
+ // the transition it announces committed. A crash a millisecond later
+ // loses nothing: the row is durable and the queue will drain it.
+ await c.get("queue").dispatch({
+ name: CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS,
+ payload: effects,
+ // Core owns the handler. Without this the row would be stamped with
+ // the requesting plugin's id and nothing would ever claim it.
+ pluginId: "@vitnode/core",
+ tx,
+ });
+
+ return { effects, kind: "executed" };
+ });
+ } catch (error) {
+ // Outside the rolled-back transaction, and guarded on `pending`: by now the
+ // lock is gone, so a cancel may legitimately have won the row.
+ await settleContentSchedule(db, scheduleId, {
+ expectedStatus: "pending",
+ lastError: error instanceof Error ? error.message : "Unknown error",
+ });
+
+ // Rethrown on purpose: this one *is* worth retrying, and the queue's
+ // backoff is the retry policy.
+ throw error;
+ }
+
+ if (result.kind === "unregistered") {
+ return { reason: result.contentTypeId, status: "unregistered" };
+ }
+ if (result.kind === "skipped") {
+ return { reason: result.reason, status: "skipped" };
+ }
+
+ return { status: "executed" };
+};
diff --git a/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts b/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts
new file mode 100644
index 000000000..4583100f7
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts
@@ -0,0 +1,38 @@
+import { buildQueueTask } from "@/api/lib/queue";
+import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const";
+import {
+ contentScheduleEffectsPayloadSchema,
+ runContentScheduleEffects,
+} from "@/content/server/schedule-effects";
+
+/**
+ * Announces a scheduled transition that has already committed.
+ *
+ * Unlike `content-schedule`, the payload here **is** data rather than a pointer,
+ * and deliberately so: the record may have been edited again by the time this
+ * runs, and an event describing the record's current state would announce
+ * something other than the publication it is reporting. Everything travels
+ * frozen from the transaction that wrote it.
+ *
+ * Five attempts rather than three. The failures this retries are transient by
+ * nature - a search node restarting, a web app redeploying - and the backoff
+ * (10s, 20s, 40s, 80s) is a far better fit for those than for a deadlock.
+ */
+export const contentScheduleEffectsQueueTask = buildQueueTask({
+ name: CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS,
+ description:
+ "Emit the event, sync search and expire the cache for a scheduled publish or unpublish that has already committed. Never republishes.",
+ maxAttempts: 5,
+ handler: async (c, payload) => {
+ const input = contentScheduleEffectsPayloadSchema.parse(payload);
+ const outcome = await runContentScheduleEffects(c, input);
+
+ if (outcome.status === "unregistered") {
+ await c
+ .get("log")
+ .warn(
+ `[content-schedule-effects] ${input.scheduleId}: ${input.contentTypeId} is no longer registered, so nothing was announced.`,
+ );
+ }
+ },
+});
diff --git a/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts b/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts
new file mode 100644
index 000000000..206152bd4
--- /dev/null
+++ b/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+
+import { buildQueueTask } from "@/api/lib/queue";
+import { CONTENT_QUEUE_TASK_SCHEDULE } from "@/content/const";
+
+import { executeContentSchedule } from "../helpers/execute-content-schedule";
+
+/**
+ * The payload is a **pointer**, not data.
+ *
+ * Everything that matters - which record, which action, whether it is still
+ * wanted - is re-read from the schedule row under a lock. A payload carrying
+ * the action would go stale the moment somebody rescheduled, and a payload
+ * carrying the item id would be a way to publish an arbitrary record by
+ * inserting a queue row.
+ */
+export const contentSchedulePayloadSchema = z.object({
+ generation: z.number().int().positive(),
+ scheduleId: z.number().int().positive(),
+});
+
+export const contentScheduleQueueTask = buildQueueTask({
+ name: CONTENT_QUEUE_TASK_SCHEDULE,
+ description:
+ "Publish or unpublish a content record at its scheduled time. A cancelled, rescheduled or already-executed schedule is a no-op.",
+ handler: async (c, payload) => {
+ const { generation, scheduleId } =
+ contentSchedulePayloadSchema.parse(payload);
+
+ const outcome = await executeContentSchedule(c, { generation, scheduleId });
+ if (outcome.status === "executed") return;
+
+ const message = `[content-schedule] ${scheduleId}: ${outcome.status}${outcome.reason ? ` (${outcome.reason})` : ""}`;
+
+ // A skip is the normal, healthy outcome for a superseded task, so it is
+ // `debug` - but silence would make "the schedule never fired" impossible to
+ // tell from "it fired and correctly did nothing". An unregistered content
+ // type is a real misconfiguration, so that one is a warning.
+ await (outcome.status === "unregistered"
+ ? c.get("log").warn(message)
+ : c.get("log").debug(message));
+ },
+});
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/search/tasks/rebuild-index.task.test.ts b/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.test.ts
new file mode 100644
index 000000000..ea7e488be
--- /dev/null
+++ b/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.test.ts
@@ -0,0 +1,518 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { describe, expect, it, vi } from "vitest";
+
+import type { EnvVitNode } from "@/api/middlewares/global.middleware";
+import type {
+ SearchDocument,
+ SearchIndexerConfig,
+ SearchIndexerLoadResult,
+} from "@/api/models/search";
+
+import { rebuildSearchIndexTask } from "./rebuild-index.task";
+
+const document = (
+ itemType: string,
+ itemId: number,
+ extra: Partial = {},
+): SearchDocument => ({
+ content: "body",
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ itemId,
+ itemType,
+ title: `Item ${itemId}`,
+ ...extra,
+});
+
+/**
+ * An indexer that replays a fixed list of pages, recording the offsets it was
+ * asked for. Page contents are what each test is about; the offsets are what the
+ * rebuild is supposed to derive from them.
+ */
+const scriptedIndexer = ({
+ itemType,
+ pages,
+ pluginId,
+}: {
+ itemType: string;
+ /**
+ * Either result shape, so the same script drives a modern and a legacy
+ * indexer. Past the end, each keeps returning its own "no more rows" signal.
+ */
+ pages: SearchIndexerLoadResult[];
+ pluginId: string;
+}) => {
+ const offsets: number[] = [];
+ const exhausted: SearchIndexerLoadResult = Array.isArray(pages[0])
+ ? []
+ : { documents: [], itemsRead: 0 };
+ let call = 0;
+
+ const config: SearchIndexerConfig = {
+ itemType,
+ load: async (_c, offset) => {
+ offsets.push(offset);
+ const page = pages[call] ?? exhausted;
+ call++;
+
+ return await Promise.resolve(page);
+ },
+ pluginId,
+ };
+
+ return { config, offsets };
+};
+
+const harness = (indexers: SearchIndexerConfig[]) => {
+ const cleared: (string | undefined)[] = [];
+ const indexed: SearchDocument[][] = [];
+
+ const search = {
+ bulkIndex: vi.fn(async (docs: SearchDocument[]) => {
+ indexed.push(docs);
+ await Promise.resolve();
+ }),
+ clear: vi.fn(async (itemType?: string) => {
+ cleared.push(itemType);
+ await Promise.resolve();
+ }),
+ };
+
+ const c = {
+ get: (key: string) => {
+ if (key === "search") return search;
+ if (key === "core") return { searchIndexers: indexers };
+
+ return undefined;
+ },
+ } as unknown as Context;
+
+ return { c, cleared, indexed, search };
+};
+
+describe("rebuild-search-index", () => {
+ it("keeps paging after a page that produced no documents", async () => {
+ // The regression: every row on page 1 is rejected by the mapper. Treating an
+ // empty document array as end-of-source would stop here and never index the
+ // valid row on page 2.
+ const { config, offsets } = scriptedIndexer({
+ itemType: "test.searchable",
+ pages: [
+ { documents: [], itemsRead: 200 },
+ { documents: [document("test.searchable", 201)], itemsRead: 1 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/example",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(offsets).toEqual([0, 200, 201]);
+ expect(indexed).toHaveLength(1);
+ expect(indexed[0]?.[0]?.itemId).toBe(201);
+ });
+
+ it("stops when a page reads no source rows", async () => {
+ const { config, offsets } = scriptedIndexer({
+ itemType: "test.searchable",
+ pages: [
+ { documents: [document("test.searchable", 1)], itemsRead: 1 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/example",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(offsets).toEqual([0, 1]);
+ expect(indexed).toHaveLength(1);
+ });
+
+ it("never calls the engine for an empty document page", async () => {
+ const { config } = scriptedIndexer({
+ itemType: "test.searchable",
+ pages: [
+ { documents: [], itemsRead: 5 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/example",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(indexed).toEqual([]);
+ });
+
+ it("advances by source items, not by documents", async () => {
+ // A multi-language indexer emits several documents per item. Advancing by
+ // document count would skip items on every page.
+ const { config, offsets } = scriptedIndexer({
+ itemType: "blog_post",
+ pages: [
+ {
+ documents: [
+ document("blog_post", 1, { languageCode: "en" }),
+ document("blog_post", 1, { languageCode: "pl" }),
+ document("blog_post", 2, { languageCode: "en" }),
+ document("blog_post", 2, { languageCode: "pl" }),
+ ],
+ itemsRead: 2,
+ },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/blog",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(offsets).toEqual([0, 2]);
+ expect(indexed[0]).toHaveLength(4);
+ });
+
+ describe("plugin ownership", () => {
+ it("keeps an owner the document already declared", async () => {
+ const { config } = scriptedIndexer({
+ itemType: "test.searchable",
+ pages: [
+ {
+ documents: [
+ document("test.searchable", 1, { pluginId: "@vitnode/example" }),
+ ],
+ itemsRead: 1,
+ },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/example",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(indexed[0]?.[0]?.pluginId).toBe("@vitnode/example");
+ });
+
+ it("stamps the registering plugin on a legacy document", async () => {
+ // A hand-written indexer that predates `SearchDocument.pluginId`. Without
+ // this the rebuild would store it as core, because the queue drains inside
+ // the core cron request.
+ const { config } = scriptedIndexer({
+ itemType: "blog_post",
+ pages: [
+ { documents: [document("blog_post", 7)], itemsRead: 1 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/blog",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(indexed[0]?.[0]?.pluginId).toBe("@vitnode/blog");
+ });
+
+ it("preserves ownership in a single-collection rebuild", async () => {
+ const example = scriptedIndexer({
+ itemType: "test.searchable",
+ pages: [
+ {
+ documents: [
+ document("test.searchable", 1, { pluginId: "@vitnode/example" }),
+ ],
+ itemsRead: 1,
+ },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/example",
+ });
+ const blog = scriptedIndexer({
+ itemType: "blog_post",
+ pages: [{ documents: [document("blog_post", 1)], itemsRead: 1 }],
+ pluginId: "@vitnode/blog",
+ });
+ const { c, cleared, indexed } = harness([example.config, blog.config]);
+
+ await rebuildSearchIndexTask.handler(c, {
+ itemType: "test.searchable",
+ });
+
+ // Scoped: the other plugin's collection is neither cleared nor reloaded.
+ expect(cleared).toEqual(["test.searchable"]);
+ expect(blog.offsets).toEqual([]);
+ expect(indexed.flat().map(doc => doc.pluginId)).toEqual([
+ "@vitnode/example",
+ ]);
+ });
+ });
+
+ it("clears the whole index for a full rebuild and each indexer runs", async () => {
+ const first = scriptedIndexer({
+ itemType: "a.one",
+ pages: [
+ { documents: [document("a.one", 1)], itemsRead: 1 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/a",
+ });
+ const second = scriptedIndexer({
+ itemType: "b.two",
+ pages: [
+ { documents: [document("b.two", 1)], itemsRead: 1 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/b",
+ });
+ const { c, cleared, indexed } = harness([first.config, second.config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(cleared).toEqual([undefined]);
+ expect(indexed.flat().map(doc => doc.pluginId)).toEqual([
+ "@vitnode/a",
+ "@vitnode/b",
+ ]);
+ });
+
+ describe("the deprecated array result", () => {
+ it("indexes a legacy page and stops on the empty one", async () => {
+ const { config, offsets } = scriptedIndexer({
+ itemType: "legacy.item",
+ pages: [[document("legacy.item", 1)], []],
+ pluginId: "@vitnode/legacy",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ // No source count to advance by, so the cursor moves a whole page - which
+ // is exactly what the old rebuild did.
+ expect(offsets).toEqual([0, 200]);
+ expect(indexed).toHaveLength(1);
+ expect(indexed[0]?.[0]?.itemId).toBe(1);
+ });
+
+ it("stamps the registering plugin on a legacy document", async () => {
+ const { config } = scriptedIndexer({
+ itemType: "legacy.item",
+ pages: [[document("legacy.item", 1)], []],
+ pluginId: "@vitnode/legacy",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(indexed[0]?.[0]?.pluginId).toBe("@vitnode/legacy");
+ });
+
+ it("keeps an owner a legacy document declared itself", async () => {
+ const { config } = scriptedIndexer({
+ itemType: "legacy.item",
+ pages: [
+ [document("legacy.item", 1, { pluginId: "@vitnode/elsewhere" })],
+ [],
+ ],
+ pluginId: "@vitnode/legacy",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(indexed[0]?.[0]?.pluginId).toBe("@vitnode/elsewhere");
+ });
+
+ it("treats a blank declared owner as absent", async () => {
+ const { config } = scriptedIndexer({
+ itemType: "legacy.item",
+ pages: [[document("legacy.item", 1, { pluginId: " " })], []],
+ pluginId: "@vitnode/legacy",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(indexed[0]?.[0]?.pluginId).toBe("@vitnode/legacy");
+ });
+
+ it("does not mistake a multilingual document count for a source count", async () => {
+ // Four documents, two source items, two languages. Advancing by the array
+ // length would jump to offset 4 and skip most of a 200-row page.
+ const { config, offsets } = scriptedIndexer({
+ itemType: "legacy.multilingual",
+ pages: [
+ [
+ document("legacy.multilingual", 1, { languageCode: "en" }),
+ document("legacy.multilingual", 1, { languageCode: "pl" }),
+ document("legacy.multilingual", 2, { languageCode: "en" }),
+ document("legacy.multilingual", 2, { languageCode: "pl" }),
+ ],
+ [],
+ ],
+ pluginId: "@vitnode/legacy",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(offsets).toEqual([0, 200]);
+ expect(offsets).not.toContain(4);
+ expect(indexed[0]).toHaveLength(4);
+ });
+
+ it("indexes an empty first page as an exhausted source", async () => {
+ // The ambiguity the modern contract exists to remove: an array cannot say
+ // whether rows were read and all filtered out, so this ends the rebuild.
+ const { config, offsets } = scriptedIndexer({
+ itemType: "legacy.item",
+ pages: [[], [document("legacy.item", 1)]],
+ pluginId: "@vitnode/legacy",
+ });
+ const { c, indexed } = harness([config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(offsets).toEqual([0]);
+ expect(indexed).toEqual([]);
+ });
+
+ it("runs alongside a modern indexer", async () => {
+ const legacy = scriptedIndexer({
+ itemType: "legacy.item",
+ pages: [[document("legacy.item", 1)], []],
+ pluginId: "@vitnode/legacy",
+ });
+ const modern = scriptedIndexer({
+ itemType: "modern.item",
+ pages: [
+ { documents: [], itemsRead: 200 },
+ { documents: [document("modern.item", 2)], itemsRead: 1 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/modern",
+ });
+ const { c, indexed } = harness([legacy.config, modern.config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(legacy.offsets).toEqual([0, 200]);
+ expect(modern.offsets).toEqual([0, 200, 201]);
+ expect(indexed.flat().map(doc => [doc.itemId, doc.pluginId])).toEqual([
+ [1, "@vitnode/legacy"],
+ [2, "@vitnode/modern"],
+ ]);
+ });
+ });
+
+ describe("a collection with no rebuild indexer", () => {
+ it("refuses a scoped rebuild before clearing anything", async () => {
+ // The action offering this is called "reindex", so it must not be a delete:
+ // clearing here would remove the documents and refill nothing.
+ const other = scriptedIndexer({
+ itemType: "example.article",
+ pages: [{ documents: [], itemsRead: 0 }],
+ pluginId: "@vitnode/example",
+ });
+ const { c, indexed, search } = harness([other.config]);
+
+ await expect(
+ rebuildSearchIndexTask.handler(c, { itemType: "removed.collection" }),
+ ).rejects.toThrow(/no search indexer is registered/i);
+
+ expect(search.clear).not.toHaveBeenCalled();
+ expect(other.offsets).toEqual([]);
+ expect(indexed).toEqual([]);
+ });
+
+ it("refuses even when no indexer is registered at all", async () => {
+ const { c, search } = harness([]);
+
+ await expect(
+ rebuildSearchIndexTask.handler(c, { itemType: "removed.collection" }),
+ ).rejects.toThrow(/removed.collection/);
+
+ expect(search.clear).not.toHaveBeenCalled();
+ });
+
+ it("names the collection it refused", async () => {
+ const { c } = harness([]);
+
+ await expect(
+ rebuildSearchIndexTask.handler(c, { itemType: "removed.collection" }),
+ ).rejects.toThrow(/Cannot rebuild collection "removed.collection"/);
+ });
+
+ it("still lets a full rebuild clear the whole index", async () => {
+ // A full rebuild refills only what has an indexer, so documents without one
+ // are removed by it. That is the documented behaviour, and it must not be
+ // blocked by the scoped guard.
+ const registered = scriptedIndexer({
+ itemType: "example.article",
+ pages: [
+ { documents: [document("example.article", 1)], itemsRead: 1 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/example",
+ });
+ const { c, cleared, indexed } = harness([registered.config]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(cleared).toEqual([undefined]);
+ expect(indexed.flat().map(doc => doc.itemId)).toEqual([1]);
+ });
+
+ it("still lets a full rebuild run with no indexers registered", async () => {
+ const { c, cleared, indexed } = harness([]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(cleared).toEqual([undefined]);
+ expect(indexed).toEqual([]);
+ });
+ });
+
+ it("clears and rebuilds only the scoped collection when it has an indexer", async () => {
+ const target = scriptedIndexer({
+ itemType: "example.article",
+ pages: [
+ { documents: [document("example.article", 1)], itemsRead: 1 },
+ { documents: [], itemsRead: 0 },
+ ],
+ pluginId: "@vitnode/example",
+ });
+ const other = scriptedIndexer({
+ itemType: "blog_post",
+ pages: [{ documents: [document("blog_post", 9)], itemsRead: 1 }],
+ pluginId: "@vitnode/blog",
+ });
+ const { c, cleared, indexed } = harness([target.config, other.config]);
+
+ await rebuildSearchIndexTask.handler(c, { itemType: "example.article" });
+
+ expect(cleared).toEqual(["example.article"]);
+ expect(other.offsets).toEqual([]);
+ expect(indexed.flat().map(doc => doc.itemId)).toEqual([1]);
+ });
+
+ it("does not loop forever on a broken indexer", async () => {
+ // A page that reports rows but never advances past them would spin. The
+ // cursor is the indexer's own `itemsRead`, so this asserts the loop is driven
+ // by data rather than by a fixed page size.
+ const load = vi.fn(
+ async (_c: Context, offset: number) =>
+ await Promise.resolve(
+ offset === 0
+ ? { documents: [], itemsRead: 3 }
+ : { documents: [], itemsRead: 0 },
+ ),
+ );
+ const { c } = harness([{ itemType: "x.y", load, pluginId: "@vitnode/x" }]);
+
+ await rebuildSearchIndexTask.handler(c, {});
+
+ expect(load).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.ts b/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.ts
index 03d396dda..ddd07ad58 100644
--- a/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.ts
+++ b/packages/vitnode/src/api/modules/search/tasks/rebuild-index.task.ts
@@ -1,4 +1,8 @@
import { buildQueueTask } from "@/api/lib/queue";
+import {
+ normalizeSearchIndexerPage,
+ searchDocumentOwner,
+} from "@/api/models/search";
const PAGE_SIZE = 200;
@@ -16,20 +20,50 @@ export const rebuildSearchIndexTask = buildQueueTask({
indexer => !itemType || indexer.itemType === itemType,
);
+ // Clearing a collection nothing can rebuild is a delete, not a rebuild. The
+ // route rejects this too, but the check has to be here as well: a queue task
+ // can be dispatched directly, and a job queued while an indexer was still
+ // registered can drain after the plugin is gone.
+ if (itemType && indexers.length === 0) {
+ throw new Error(
+ `[Search] Cannot rebuild collection "${itemType}": no search indexer is registered. Its documents were left alone - they may still be maintained by live writes, so remove them explicitly if that is what you meant.`,
+ );
+ }
+
// Scope the clear to the target collection so a single-collection reindex
// never wipes the rest of the index.
await search.clear(itemType);
for (const indexer of indexers) {
- // Offset advances by whole pages of items, not by document count: an
- // indexer may emit several documents per item (e.g. one per language), so
- // the two are not interchangeable. A page that yields no documents ends
- // the loop.
- for (let page = 0; ; page++) {
- const docs = await indexer.load(c, page * PAGE_SIZE, PAGE_SIZE);
- if (docs.length === 0) break;
+ // The cursor advances by source rows read, never by documents produced: an
+ // indexer may emit several documents per item, or none for a page whose
+ // rows it cannot project. Ending on an empty document array would stop the
+ // rebuild at the first such page and never reach the rows after it.
+ //
+ // A legacy indexer returning a bare array is normalized to the same shape,
+ // where a non-empty page reports the requested limit - the only cursor the
+ // old contract ever had.
+ for (let offset = 0; ;) {
+ const page = normalizeSearchIndexerPage(
+ await indexer.load(c, offset, PAGE_SIZE),
+ PAGE_SIZE,
+ );
+ if (page.itemsRead === 0) break;
+
+ if (page.documents.length > 0) {
+ // This task runs inside the core cron request, so the request's plugin
+ // is not the owner. Stamp the registering plugin on any document that
+ // did not name one, or a rebuild would relabel it as core.
+ await search.bulkIndex(
+ page.documents.map(document => ({
+ ...document,
+ pluginId:
+ searchDocumentOwner(document.pluginId) ?? indexer.pluginId,
+ })),
+ );
+ }
- await search.bulkIndex(docs);
+ offset += page.itemsRead;
}
}
},
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/api/plugin.ts b/packages/vitnode/src/api/plugin.ts
index 9119a04f9..45cd3deb4 100644
--- a/packages/vitnode/src/api/plugin.ts
+++ b/packages/vitnode/src/api/plugin.ts
@@ -2,6 +2,7 @@ import { CONFIG_PLUGIN } from "@/config";
import { buildApiPlugin } from "./lib/plugin";
import { adminModule } from "./modules/admin/admin.module";
+import { contentModule } from "./modules/content/content.module";
import { cronModule } from "./modules/cron/cron.module";
import { middlewareModule } from "./modules/middleware/middleware.module";
import { queueModule } from "./modules/queue/queue.module";
@@ -14,6 +15,7 @@ export const newBuildPluginApiCore = buildApiPlugin({
middlewareModule,
usersModule,
adminModule,
+ contentModule,
cronModule,
queueModule,
searchModule,
diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
index b8ad44b19..1cee0c08a 100644
--- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
+++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
@@ -24,22 +24,33 @@ export const ConfirmActionAlertDialog = ({
children,
title,
description,
+ finalFocus,
+ submitVariant,
textSubmit,
onSubmit,
...props
}: Omit, "children"> &
React.ComponentProps & {
- children: React.ReactElement;
+ /**
+ * The element that opens the dialog.
+ *
+ * Optional, for a caller that owns `open` itself - a confirmation opened from
+ * a menu item has no trigger to render, because the item is gone by the time
+ * the dialog is on screen.
+ */
+ children?: React.ReactElement;
description?: React.ReactNode;
+ /** Where focus goes on close, for a dialog whose trigger no longer exists. */
+ finalFocus?: React.ComponentProps["finalFocus"];
title?: React.ReactNode;
}) => {
const t = useTranslations("core.global.confirm_action");
return (
-
+ {children ? : null}
-
+ {title ?? t("title")}
@@ -48,7 +59,11 @@ export const ConfirmActionAlertDialog = ({
}>
-
+
diff --git a/packages/vitnode/src/components/confirm-action/content.tsx b/packages/vitnode/src/components/confirm-action/content.tsx
index 85158bace..b3dd88e70 100644
--- a/packages/vitnode/src/components/confirm-action/content.tsx
+++ b/packages/vitnode/src/components/confirm-action/content.tsx
@@ -10,9 +10,15 @@ import { Button } from "../ui/button";
export const ContentConfirmAction = ({
onSubmit,
+ submitVariant = "destructive",
textSubmit,
}: {
onSubmit: (props: { onClose: () => void }) => Promise | void;
+ /**
+ * Defaults to `destructive`, which is right for the deletes this dialog was
+ * built for - and wrong for a confirmation that publishes something.
+ */
+ submitVariant?: React.ComponentProps["variant"];
textSubmit?: string;
}) => {
const t = useTranslations("core.global.confirm_action");
@@ -27,7 +33,7 @@ export const ContentConfirmAction = ({
" },
- ],
- },
- expect.anything(),
- );
- });
- });
-});
diff --git a/packages/vitnode/src/components/form/fields/input-roles.tsx b/packages/vitnode/src/components/form/fields/input-roles.tsx
new file mode 100644
index 000000000..fc369a907
--- /dev/null
+++ b/packages/vitnode/src/components/form/fields/input-roles.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { XIcon } from "lucide-react";
+import { useLocale, useTranslations } from "next-intl";
+import React from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { FormMessage } from "@/components/ui/form";
+
+import type { ItemAutoFormComponentProps } from "../auto-form";
+import type { RoleOption } from "./search-roles.action.server";
+
+import { AsyncPicker } from "../common/async-picker";
+import { AutoFormDesc } from "../common/desc";
+import { AutoFormLabel } from "../common/label";
+import { searchRoles } from "./search-roles.action.server";
+
+export type { RoleOption };
+
+/**
+ * A role's name in the reader's language.
+ *
+ * Falls back to the first translation rather than to the id: a role with no
+ * English name is still a role somebody named, and showing `4` helps nobody.
+ */
+export const roleOptionName = (role: RoleOption, locale: string): string =>
+ role.name.find(item => item.languageCode === locale)?.name ??
+ role.name[0]?.name ??
+ String(role.id);
+
+/**
+ * Picks one role, or several, for an `AutoForm` field.
+ *
+ * One component rather than two, because the difference is the *shape of the
+ * value* and nothing else - the search, the colour, the language resolution and
+ * the empty state are identical, and two copies is how they drift:
+ *
+ * ```ts
+ * z.object({ roleId: z.number() }) // multiple omitted
+ * z.object({ roleIds: z.array(z.number()).min(1) }) // multiple
+ * ```
+ *
+ * With `multiple` the chosen roles are listed as removable chips and the picker
+ * stays open for business - it appends rather than replaces, and picking one
+ * that is already there removes it, which is what the tick in the list means.
+ *
+ * `selected` seeds the names for ids the field starts with, exactly as
+ * `AutoFormUser` does and for the same reason: an edit form knows its roles
+ * before the picker has searched for anything.
+ */
+export const AutoFormRoles = ({
+ description,
+ disabled,
+ excludeIds = [],
+ field,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ itemParams,
+ label,
+ labelRight,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ multiLang,
+ multiple = false,
+ otherProps,
+ placeholder,
+ search = searchRoles,
+ searchPlaceholder,
+ selected = [],
+}: ItemAutoFormComponentProps & {
+ disabled?: boolean;
+ /** Roles the picker must not offer - ones another field already owns. */
+ excludeIds?: number[];
+ /** `number[]` instead of `number`, and a chip list instead of one label. */
+ multiple?: boolean;
+ placeholder?: string;
+ search?: (value: string) => Promise;
+ searchPlaceholder?: string;
+ /** Roles the field opens on, for an edit form that already has some. */
+ selected?: RoleOption[];
+}) => {
+ const t = useTranslations("core.global");
+ const locale = useLocale();
+ const [known, setKnown] = React.useState>(() =>
+ Object.fromEntries(selected.map(role => [role.id, role])),
+ );
+
+ const ids: number[] = multiple
+ ? Array.isArray(field.value)
+ ? (field.value as number[])
+ : []
+ : typeof field.value === "number"
+ ? [field.value]
+ : [];
+
+ const nameOf = (id: number): string => {
+ const role = known[id];
+
+ return role ? roleOptionName(role, locale) : String(id);
+ };
+ const colorOf = (id: number): string | undefined =>
+ known[id]?.color ?? undefined;
+
+ const remove = (id: number) => {
+ field.onChange(multiple ? ids.filter(item => item !== id) : null);
+ };
+
+ const label_ = !!label && (
+
+ {label}
+
+ );
+
+ const picker = (
+
+ disabled={disabled}
+ invalid={otherProps["aria-invalid"]}
+ onSelect={option => {
+ setKnown(seen => ({ ...seen, [option.id]: option }));
+
+ if (!multiple) {
+ field.onChange(option.id);
+
+ return;
+ }
+
+ // A second pick of the same role removes it, which is what the tick
+ // beside it in the list is promising.
+ field.onChange(
+ ids.includes(option.id)
+ ? ids.filter(item => item !== option.id)
+ : [...ids, option.id],
+ );
+ }}
+ renderOption={option => (
+
+ {roleOptionName(option, locale)}
+
+ )}
+ search={async value =>
+ (await search(value)).filter(role => !excludeIds.includes(role.id))
+ }
+ searchPlaceholder={searchPlaceholder}
+ selectedIds={ids}
+ trigger={
+ !multiple && ids.length > 0 ? (
+
+ {nameOf(ids[0])}
+
+ ) : (
+
+ {placeholder ?? t("select_option")}
+
+ )
+ }
+ />
+ );
+
+ if (!multiple) {
+ return (
+ <>
+ {label_}
+ {picker}
+ {!!description && {description}}
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {label_}
+
+ {ids.length > 0 && (
+
+ {ids.map(id => (
+
+
+
+ {nameOf(id)}
+
+
+
+
+ ))}
+
+ )}
+
+ {picker}
+ {!!description && {description}}
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/components/form/fields/input-users.tsx b/packages/vitnode/src/components/form/fields/input-users.tsx
new file mode 100644
index 000000000..74cf1ae15
--- /dev/null
+++ b/packages/vitnode/src/components/form/fields/input-users.tsx
@@ -0,0 +1,236 @@
+"use client";
+
+import { UserIcon, XIcon } from "lucide-react";
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import { Avatar } from "@/components/avatar";
+import { Button } from "@/components/ui/button";
+import { FormMessage } from "@/components/ui/form";
+import { cn } from "@/lib/utils";
+
+import type { ItemAutoFormComponentProps } from "../auto-form";
+import type { UserOption } from "./search-users.action.server";
+
+import { AsyncPicker } from "../common/async-picker";
+import { AutoFormDesc } from "../common/desc";
+import { AutoFormLabel } from "../common/label";
+import { searchUsers } from "./search-users.action.server";
+
+export type { UserOption };
+
+/**
+ * A person the field can label but has not necessarily fetched.
+ *
+ * `avatarColor` is optional because the caller often knows only a name and an
+ * id - the Content Engine resolves a `user` field's label alongside the record
+ * and never carries a colour with it.
+ */
+export type PartialUserOption = Omit &
+ Partial>;
+
+/**
+ * A person's face, or the space where it will be.
+ *
+ * A generated avatar needs a colour, and a colour is the one column a caller
+ * that only resolved a *name* does not have. Inventing one is not an option -
+ * the wrong colour reads as a different person - so the gap is filled with a
+ * neutral placeholder rather than left empty.
+ *
+ * Same box either way, which is the point: the name sits in the same place
+ * before and after the real avatar arrives, so nothing jumps sideways when a
+ * search fills the colour in.
+ */
+const UserAvatar = ({
+ size,
+ user,
+}: {
+ size: number;
+ user: PartialUserOption;
+}) =>
+ user.avatarColor ? (
+
+ ) : (
+
+
+
+ );
+
+/**
+ * One person as a picker row: a face, a name, and the handle behind it.
+ *
+ * Exported because "what a person looks like in a list" is a decision, and the
+ * to-many people picker in the Content Engine has to make the same one - a set
+ * of authors that rendered them differently from the list they were chosen from
+ * would read as two different controls.
+ */
+export const UserOptionRow = ({
+ size = 24,
+ user,
+}: {
+ size?: number;
+ user: PartialUserOption;
+}) => (
+
+);
+
+/**
+ * Picks one person, by name, for an `AutoForm` field.
+ *
+ * The author selector, the "assign this to" selector, and every other place a
+ * form needs a person rather than a string. The value is the **user id**, so a
+ * schema is `z.number()` and the payload needs no unwrapping:
+ *
+ * ```ts
+ * const formSchema = z.object({ authorId: z.number() });
+ * ```
+ *
+ * A picker cannot show a name it has never fetched, so an *edit* form passes the
+ * person it already knows about as `selected`. Without it the field would open
+ * showing a bare id, or - worse - showing the placeholder as though nothing were
+ * chosen. Whatever the search returns is remembered on top of that, so a person
+ * picked a moment ago still reads as their name.
+ */
+export const AutoFormUser = ({
+ clearable = false,
+ description,
+ disabled,
+ field,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ itemParams,
+ label,
+ labelRight,
+ // Language-aware inputs only - dropped so it never reaches the DOM.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ multiLang,
+ otherProps,
+ placeholder,
+ search = searchUsers,
+ searchPlaceholder,
+ selected,
+}: ItemAutoFormComponentProps & {
+ className?: string;
+ /**
+ * Offers a way back to *nobody*, for a field that allows it.
+ *
+ * Off by default: on a required field a clear button is a button whose only
+ * outcome is a validation error.
+ */
+ clearable?: boolean;
+ disabled?: boolean;
+ placeholder?: string;
+ /** Swap the lookup - a plugin scoping to its own members, or a test. */
+ search?: (value: string) => Promise;
+ searchPlaceholder?: string;
+ /** The person the field opens on, for an edit form that already has one. */
+ selected?: null | PartialUserOption;
+}) => {
+ const t = useTranslations("core.global");
+ // Everyone this field has *learned* about, from its own searches.
+ const [known, setKnown] = React.useState>(
+ {},
+ );
+
+ const value = typeof field.value === "number" ? field.value : null;
+ // Only where there is something to clear: on an empty field the button would
+ // be an affordance for a state it is already in.
+ const clearButton = clearable && value !== null;
+ // A search wins over `selected`, because it carries the colour a caller that
+ // only resolved a name does not have. `selected` is read on every render
+ // rather than seeded into state once: a caller may resolve the person
+ // *after* the first paint - the Content Engine does exactly that - and a
+ // one-time seed would leave the field showing a placeholder for good.
+ const current =
+ value === null
+ ? null
+ : (known[value] ?? (selected?.id === value ? selected : null));
+
+ return (
+ <>
+ {!!label && (
+
+ {label}
+
+ )}
+
+ {/* `relative`, because the clear button sits *inside* the control. It
+ cannot be a child of the trigger - that is a `