diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx
index 2f64c5295..c9759c68b 100644
--- a/apps/docs/content/docs/dev/content-engine/limitations.mdx
+++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx
@@ -26,7 +26,7 @@ other 20%, so you find out here rather than halfway through building.
| Deep relation nesting in a public response | One level, one key. Anything more is a custom route |
| Origin `Cache-Control` headers | [Tags](/docs/dev/content-engine/caching) handle Next-side caching; add headers in your own route |
| Per-route rate limits | The global IP bucket applies; anything finer is yours |
-| Search indexing | Register a `SearchIndexer` yourself |
+| Search: author facet, per-locale documents, relation containers | [`search`](/docs/dev/content-engine/search) indexes public text fields. Anything richer needs your own `SearchIndexer` |
| Automatic field renames | See below |
| Unique constraints on more than one column | Declare them in `indexes`, not on the field |
| Uniqueness on non-`text` fields | Declare the index in `indexes` |
@@ -132,16 +132,60 @@ suffixes in the [slug backfill
recipe](/docs/dev/content-engine/slug-field#migrations) are a one-off,
deterministic migration device for rows that predate the column.
-## Direct service calls invalidate no cache
+## Direct service calls invalidate no cache and index nothing
`service.publish()` and friends change rows and return the result. They emit no
-event and expire no cache tag: they may be inside an uncommitted transaction,
-they may be running in `apps/api` where there is no Next runtime at all, and the
-Next cache APIs need a request scope a repository does not own.
+event, expire no cache tag and touch no search document: they may be inside an
+uncommitted transaction, they may be running in `apps/api` where there is no Next
+runtime at all, and the Next cache APIs need a request scope a repository does
+not own.
Every generated write path goes through an AdminCP server action, which does all
three. A direct caller does its own follow-up, after committing - see
-[Caching](/docs/dev/content-engine/caching#writing-who-expires-what).
+[Caching](/docs/dev/content-engine/caching#writing-who-expires-what) and
+[Search](/docs/dev/content-engine/search#direct-service-calls-do-not-synchronize).
+
+## A search engine outage does not undo a write
+
+Search synchronization is best effort. A content mutation succeeds, the failure is
+logged with a `[content-search]` prefix and surfaced in the AdminCP, and a rebuild
+repairs the drift. There is no automatic retry and no durable outbox, so the index
+is eventually consistent - bounded by the next publish or the next rebuild.
+
+Logging is best effort in the same way: it writes to the database, so it can fail
+alongside the search engine. When it does, the message falls back to the console
+and the mutation still succeeds. Neither failure can turn a committed write into a
+failed request.
+
+## A collection with no rebuild indexer cannot be rebuilt
+
+A collection with indexed documents but no registered `SearchIndexer` is labelled
+**Unmanaged**. That happens when a content type drops its `search` block or its
+plugin goes away - and also when a plugin simply indexes through `search.index()`
+and never registers an indexer, which is supported and may be perfectly healthy.
+The label therefore says only what is known: there is no way to rebuild it.
+
+The AdminCP names the owner stored on those documents rather than reassigning them
+to core, and falls back to `unknown` only when neither a registered indexer nor the
+stored rows know. It reports no coverage, because there is no source to compare
+against.
+
+A scoped rebuild of it is refused - it would clear the documents and refill nothing
+- so the only action offered is an explicit, confirmed **Remove documents**. That
+clears the current indexed state and nothing more: a live-writing plugin may
+recreate those documents immediately. A full rebuild clears them too, as part of
+clearing everything, and recreates only collections that have an indexer.
+
+Registering an indexer for the same `itemType` makes the collection ordinary again.
+Nothing recovers it automatically.
+
+## Malformed published data reads as under-indexed
+
+A published record the search mapper cannot project - a title that is blank in the
+database, say - counts toward a collection's source total and produces no document.
+The AdminCP shows that as **stale** rather than hiding it. Extra documents, for
+records that no longer qualify, are stale too: the counts have to match exactly for
+a collection to read as indexed.
## The public cursor is always the row id
diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json
index 53139393c..844221bf8 100644
--- a/apps/docs/content/docs/dev/content-engine/meta.json
+++ b/apps/docs/content/docs/dev/content-engine/meta.json
@@ -13,6 +13,7 @@
"slug-field",
"public-api",
"public-service",
+ "search",
"caching",
"admincp",
"permissions",
diff --git a/apps/docs/content/docs/dev/content-engine/public-api.mdx b/apps/docs/content/docs/dev/content-engine/public-api.mdx
index bdcb7f3ec..55bc87477 100644
--- a/apps/docs/content/docs/dev/content-engine/public-api.mdx
+++ b/apps/docs/content/docs/dev/content-engine/public-api.mdx
@@ -274,6 +274,13 @@ response schema generated from the projection. Only `get` operations are ever
built - there is no public create, update, delete, publish or unpublish, and no
flag that would add one.
+## The allowlist bounds search too
+
+[`search`](/docs/dev/content-engine/search) can only index fields that are in
+`fields`, and that is a compile error rather than a convention - a private value
+must not surface through a result snippet, a highlighted match or an exact-match
+probe either.
+
## What this is not
No public writes, ever. No `Cache-Control` headers - HTTP caching is handled
diff --git a/apps/docs/content/docs/dev/content-engine/publication.mdx b/apps/docs/content/docs/dev/content-engine/publication.mdx
index 6050abf2f..99f4294cc 100644
--- a/apps/docs/content/docs/dev/content-engine/publication.mdx
+++ b/apps/docs/content/docs/dev/content-engine/publication.mdx
@@ -271,6 +271,17 @@ path - which is exactly what the
[public field allowlist](/docs/dev/content-engine/public-api#the-field-allowlist)
does for you on the generated one.
+## Publishing can do more than flip a column
+
+Two things hang off the lifecycle, and both are opt-in:
+
+- [`publicApi`](/docs/dev/content-engine/public-api) makes a published record
+ readable over HTTP.
+- [`search`](/docs/dev/content-engine/search) keeps a published record in the
+ site-wide search index - added on publish, removed on unpublish.
+
+Enabling publication on its own still publishes nothing anywhere.
+
## What this is not
Scheduled publishing, approval workflows and revisions are not here. The
diff --git a/apps/docs/content/docs/dev/content-engine/search.mdx b/apps/docs/content/docs/dev/content-engine/search.mdx
new file mode 100644
index 000000000..d2f018e8a
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/search.mdx
@@ -0,0 +1,366 @@
+---
+title: Search
+description: One config block and every published record stays in the site-wide search index - added on publish, rewritten on edit, removed on unpublish.
+icon: Search
+---
+
+Add `search` to a content type and VitNode keeps its published records in the
+[Search Engine](/docs/dev/search) for you. No indexer to write, no reindex call
+to remember in five different routes.
+
+```ts title="src/content/article.ts"
+export const articleContentType = defineContentType({
+ id: "example.article",
+ tableName: "example_articles",
+
+ fields: {
+ title: field.text({ required: true, maxLength: 200 }),
+ slug: field.slug({ source: "title" }),
+ excerpt: field.textarea({ maxLength: 500, nullable: true }),
+ code: field.text({ required: true }),
+ author: field.user(),
+ },
+
+ publication: { enabled: true },
+
+ publicApi: {
+ enabled: true,
+ path: "articles",
+ fields: ["title", "slug", "excerpt", "publishedAt"],
+ },
+
+ search: {
+ enabled: true,
+ titleField: "title",
+ descriptionField: "excerpt",
+ contentFields: ["title", "excerpt"],
+ pathTemplate: "/articles/{slug}",
+ },
+
+ admin: {/* ... */},
+});
+```
+
+That is the whole feature. Publish an article and it is findable at `/search`,
+linking to `/articles/its-slug`.
+
+## The options
+
+| Option | Required | What it is |
+| ------------------ | -------- | ----------------------------------------------------------------------------------------- |
+| `enabled` | yes | Literal `true`. Omit the block, or pass `{ enabled: false }`, and nothing is indexed |
+| `titleField` | yes | The result heading. A **non-nullable** `text` field, weighted above the body by the index |
+| `contentFields` | yes | Concatenated into the searchable body, in order. At least one |
+| `pathTemplate` | yes | The public URL of one record. Relative, with `{slug}` as the only placeholder |
+| `descriptionField` | no | Leads the body, so it shows up first in a result excerpt |
+
+`search` is **opt-in**. Every existing content type keeps working untouched, and
+so does every hand-written `SearchIndexer`.
+
+## Supported field kinds
+
+| Kind | `titleField` | `descriptionField` | `contentFields` |
+| --------------------------------------- | :----------: | :----------------: | :-------------: |
+| `text` | ✅ | ✅ | ✅ |
+| `textarea` | ❌ | ✅ | ✅ |
+| `slug` | ❌ | ❌ | ✅ |
+| `enum`, `number`, `boolean`, `dateTime` | ❌ | ❌ | ❌ |
+| `relation` | ❌ | ❌ | ❌ |
+| `user` | ❌ | ❌ | ❌ |
+
+A title is one line, so prose from a `textarea` in that slot would drag down
+ranking for every other document in the index. It also cannot be nullable: a
+`null` heading is not a search result, and a record without one is skipped - which
+shows up as a collection that never reaches full coverage. The facet kinds are not text:
+full-text-indexing `"draft"` or `"42"` is noise, and it would let somebody probe
+values through the search box. A `relation` is a foreign key, and a `user` field
+is [never public](#every-indexed-field-must-be-public).
+
+## Search needs publication and a public API
+
+```ts
+publication: {
+ enabled: true;
+} // only published records are indexed
+publicApi: {
+ enabled: true;
+} // a hit links to a public URL
+```
+
+Both are checked when the definition is built, so a missing one is a startup
+error naming the content type - not a surprise at runtime.
+
+### Every indexed field must be public
+
+Every field you name in `search` has to be in `publicApi.fields`. This is a
+**compile error**, not a review checklist:
+
+```ts
+publicApi: { enabled: true, path: "articles", fields: ["title", "slug"] },
+search: {
+ enabled: true,
+ titleField: "title",
+ contentFields: ["title", "code"], // ❌ `code` is not in publicApi.fields
+ pathTemplate: "/articles/{slug}",
+},
+```
+
+
+ A search index leaks in more ways than a response body does. A private value
+ in an indexed document can surface through a result snippet, a highlighted
+ match, ranking, an exact-match probe - and through the plain fact that a
+ record *matched* something the searcher typed. Keeping the indexed set inside
+ the published set means none of those are possible.
+
+
+A `user` field can never be in `publicApi.fields`, so it can never be indexed
+either. The search document carries **no author** as a result: content records do
+not appear in the author facet or the AdminCP user timeline. That is deliberate -
+see [Limitations](#limitations).
+
+## URLs
+
+`pathTemplate` is a plain string with one placeholder:
+
+```ts
+pathTemplate: "/articles/{slug}"; // -> /articles/getting-started
+```
+
+- **Relative only.** Search results are rendered into links client-side.
+- **`{slug}` only.** It resolves to the one slug field `publicApi.fields`
+ exposes. Any other placeholder is a startup error, so a typo cannot end up in a
+ URL.
+- **Percent-encoded** on the way in, which matters only for a slug written
+ straight into the database - `slugify` never produces anything that needs it.
+- **No locale prefix** in this version.
+
+A slug change rewrites the URL in place. There is no stale document to clean up,
+because a document is identified by its content type and row id, not by its URL.
+
+## What gets synchronized
+
+| You do this | Search does this |
+| ----------------------------------------- | -------------------------- |
+| Create a draft | nothing |
+| Update a draft | nothing |
+| **Publish** | add or update the document |
+| Publish something already published | nothing |
+| Update a published record's indexed field | update the document |
+| Change a published slug | update the document's URL |
+| Update a field that is not indexed | nothing |
+| **Unpublish** | remove the document |
+| Unpublish something already a draft | nothing |
+| Delete a published record | remove the document |
+| Delete a record that was published before | remove the document |
+| Delete a draft that was never published | nothing |
+
+"Published" is the same rule the public API uses:
+`status = 'published' AND publishedAt IS NOT NULL AND publishedAt <= now()`.
+There is no second definition to drift from it, so a record with a cleared or
+future `publishedAt` is not indexed for exactly the reason it is not public.
+
+A no-op writes nothing, on purpose. There is no outbox, so a listener firing on
+every button press would be doing duplicate work for free.
+
+## Direct service calls do not synchronize
+
+The generated admin routes call the synchronizer for you. A direct service call
+does **not** - the same rule direct calls follow for
+[cache invalidation](/docs/dev/content-engine/caching#writing-who-expires-what),
+and for the same reason: your call may be inside a transaction that has not
+committed, and a search document pointing at a row that got rolled back is worse
+than a missing one.
+
+Opt in explicitly, after the write has returned:
+
+```ts
+import { syncContentSearch } from "@vitnode/core/content/server";
+
+const result = await model.service(c).publish(id);
+if (result) {
+ await syncContentSearch(c, articleContentType, {
+ operation: "publish",
+ changed: result.changed,
+ row: result.row,
+ });
+}
+```
+
+Inside a transaction, put the call **after** the commit:
+
+```ts
+const result = await db.transaction(async tx =>
+ model.service(c).publish(id, { tx }),
+);
+if (result)
+ await syncContentSearch(c, definition, { operation: "publish", ...result });
+```
+
+It needs a Hono `Context` (for `c.get("search")`), so it runs in the API process.
+A Next.js server action cannot reach the search engine at all.
+
+## When search fails
+
+A search engine outage never turns a successful write into a failed one.
+
+```
+the record is saved -> the response is a normal 200 / 201
+the document is not -> logged, and repaired by a rebuild
+```
+
+The failure is written to `core_logs` behind a `[content-search]` prefix with the
+content type, the owning plugin, the record id, the operation and the error, and
+the newest few show up in **AdminCP → Advanced → Search** as _Recent sync
+failures_. There is no automatic retry.
+
+Logging is best effort too. It writes to the database, so it can be down for the
+same reason the search engine is - and a failed log entry must not fail the
+request either. When the structured entry cannot be persisted, the message goes to
+the console instead, and the returned outcome still carries the **original search
+error** rather than the logger's.
+
+
+ With the bundled Postgres engine the window is tiny: the document is written
+ to the same database as the record, so it fails essentially only when that
+ database is down - in which case the record did not save either. With an
+ external engine like [Elasticsearch](/docs/dev/search) the index can genuinely
+ drift, and a rebuild is what closes the gap.
+
+
+## Rebuild
+
+**AdminCP → Advanced → Search** lists every collection with its coverage, and
+rebuilds one or all of them. A content type with `search` appears there
+automatically, labelled with its `admin.label.plural`.
+
+A rebuild reads only published records, in pages, projecting only the columns the
+document needs - a private column is not even fetched. It is scoped to one
+collection at a time, so rebuilding one content type can never touch another
+plugin's documents, and rerunning it is harmless.
+
+### Source rows and documents are different counts
+
+A page of a rebuild reports both, and they are not interchangeable:
+
+- one record can produce **several** documents (one per locale, in a plugin that
+ indexes translations), and
+- one record can produce **none** - a published row whose title is blank cannot
+ be a search result.
+
+So the rebuild advances its cursor by **source rows read**, and it stops only when
+a page reads no rows at all. An empty document list does not end it: a page of
+records the mapper refused would otherwise stop the rebuild before the valid
+records behind it.
+
+Generated indexers always report both counts. A hand-written indexer may still
+return a bare `SearchDocument[]` - that is [deprecated but
+supported](/docs/dev/search#the-older-array-result), and it is the one case where
+a filtered-empty page does end the rebuild early, because an array cannot say how
+many rows produced it.
+
+### Coverage, and what "stale" means
+
+Coverage compares **published** records against indexed ones, so a mostly-draft
+collection still reads 100%. Both numbers are reported as measured, and a
+collection is _Indexed_ only when they match exactly:
+
+| Counts | Status |
+| --------- | ------------------------------ |
+| `0 / 0` | Not indexed |
+| `0 / 10` | Not indexed |
+| `5 / 10` | Stale - documents missing |
+| `10 / 10` | Indexed |
+| `11 / 10` | Stale - documents left over |
+| `11 / —` | Unmanaged - no rebuild indexer |
+
+Over-indexing is a real state: documents that survive for records which no longer
+qualify. The source count is never raised to hide it, and the progress bar is
+clamped so it cannot draw past its track while the numbers stay truthful.
+
+A published record the mapper cannot project counts toward the source total but
+produces no document, so **malformed data reads as under-indexed**. That is
+deliberate: the alternative is reproducing every mapper rule in SQL, where the two
+would eventually drift apart and hide the problem instead of showing it.
+
+
+ A rebuild is a queue task, drained by the cron job. Without a cron adapter it
+ never runs - the AdminCP warns you when one is missing. A large collection is
+ also worth an index on `(status, publishedAt)`; declare it in `indexes`.
+
+
+## Plugin ownership
+
+Every search document records the plugin that owns it, and the value is the same
+whichever path wrote it:
+
+```text
+publish through the AdminCP -> pluginId = @vitnode/example
+full rebuild -> pluginId = @vitnode/example
+one-collection rebuild -> pluginId = @vitnode/example
+```
+
+That is worth stating because a rebuild runs inside the **core** cron request, so
+the request's plugin is not the owner. Generated indexers carry their plugin id
+and stamp it on every document; the search model uses an explicit owner in
+preference to the request, and falls back to the request only when a document
+names none. A generated Content Engine document is never owned by `core`.
+
+A hand-written indexer that names no owner is stamped with the plugin that
+registered it, so it does not need changing. A blank owner counts as naming
+nobody.
+
+A collection with documents but no registered rebuild indexer shows the owner
+stored on its documents. - shows the owner stored on its
+documents, and `unknown` only when neither source knows. It is never relabelled as
+core.
+
+Such a collection is **unmanaged by the rebuild system**: it is labelled
+_Unmanaged_, its coverage is left blank because there is no source to measure
+against, and _Reindex_ is replaced by _Remove documents_. A scoped rebuild of it is
+refused rather than silently clearing it - see [unmanaged
+collections](/docs/dev/search#unmanaged-collections).
+
+Removing a content type's `search` block, or the plugin itself, is one way to
+produce one. It is not the only way, and the label does not claim it was: a plugin
+that indexes through `search.index()` without registering an indexer looks
+identical, and may be entirely healthy. A full rebuild clears these documents
+either way, because it recreates only collections that have an indexer.
+
+## Migrating a hand-written indexer
+
+Existing `SearchIndexer` registrations are untouched and keep working. To replace
+one with the generated adapter:
+
+1. Add `search` to the content type, and make sure every field you index is in
+ `publicApi.fields`.
+2. Delete your `reindex*` calls from the mutation routes, and the indexer from
+ `searchIndexers` in `config.api.ts`.
+3. Rebuild the index once. The item type changes from your own string to the
+ content type id, so old documents are replaced rather than updated.
+
+The generated adapter does not do everything a hand-written one can. One document
+per locale (the way the blog plugin indexes translations from
+`core_languages_words`), an author facet, or a container relation still need your
+own indexer - and one item type may only have one indexer, so it is one or the
+other.
+
+## Limitations
+
+| Not supported | Why |
+| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| Author facet | A `user` field is never public, and the public search route resolves `authorId` into a person |
+| One document per locale | Content fields are single-language columns. A document is language-agnostic and matches every locale |
+| Per-locale stemming | Same reason - a language-agnostic document uses the `simple` text-search configuration |
+| Relation expansion | A relation is a foreign key; the index has no place to put one |
+| Locale-prefixed URLs | `pathTemplate` produces one relative path |
+| A custom icon or label in the public feed | Content hits use the generic renderer. The registry in core is not plugin-extensible yet |
+| Automatic retry | Best effort plus a rebuild. There is no durable retry mechanism - an outbox is a later addition |
+| Keyset paging during a rebuild | The indexer contract pages by offset |
+| Blank titles written straight into the database | Rejected by the mapper, and visible as an under-indexed collection |
+
+## Related
+
+- [Search Engine](/docs/dev/search) - the engine itself, and swapping it
+- [Publication](/docs/dev/content-engine/publication) - the lifecycle search follows
+- [Public API](/docs/dev/content-engine/public-api) - the allowlist search is bound by
+- [Caching](/docs/dev/content-engine/caching) - the other thing a write has to expire
diff --git a/apps/docs/content/docs/dev/search.mdx b/apps/docs/content/docs/dev/search.mdx
index 9012af24a..fdaef7fe3 100644
--- a/apps/docs/content/docs/dev/search.mdx
+++ b/apps/docs/content/docs/dev/search.mdx
@@ -28,7 +28,16 @@ config change followed by a rebuild.
## Indexing content
-Any API handler can (re)index or remove an item through `c.get("search")`:
+
+ A content type only needs a
+ [`search` block](/docs/dev/content-engine/search) - publishing, editing,
+ unpublishing and deleting a record then keep its document in step
+ automatically, and it joins the rebuild without any of the wiring below.
+
+
+Any API handler can (re)index or remove an item through `c.get("search")`. It is
+an **API-process** model: a Next.js server action or server component cannot
+reach `c.get("search")` at all, so anything Next-side goes over HTTP.
```ts
// After creating or updating an item
@@ -83,9 +92,11 @@ translated can leave `languageCode` empty (`""`) - those rows are **language
agnostic** and match every locale, so single-language plugins need no changes.
- Postgres full-text ranking uses the `english` text-search configuration for
- all languages (there is no bundled dictionary for most locales). Matching
- still works across languages; only stemming and stop-words are English-tuned.
+ Postgres full-text ranking picks a text-search configuration per locale
+ (`polish` for `pl`, `german` for `de`, and so on), falling back to `simple` for
+ a locale with no bundled dictionary - and for a document with no
+ `languageCode`, which matches every locale. Matching works across languages
+ either way; only stemming and stop-words differ.
## Registering a rebuild indexer
@@ -106,9 +117,13 @@ export const blogPostSearchIndexer: SearchIndexer = {
.limit(limit)
.offset(offset);
- // An indexer may emit several documents per item (e.g. one per language).
- // `offset`/`limit` page over items, not documents.
- return rows.flatMap(buildSearchDocumentsForPost);
+ // Two counts, and they are not interchangeable: one item can emit several
+ // documents (one per language) or none at all. `itemsRead` is what the
+ // rebuild pages by, and `itemsRead: 0` is what ends it.
+ return {
+ documents: rows.flatMap(buildSearchDocumentsForPost),
+ itemsRead: rows.length,
+ };
},
};
@@ -120,9 +135,119 @@ export const blogApiPlugin = () =>
});
```
-Trigger a rebuild from **AdminCP → System → Search → Rebuild index**. It runs as a
-background queue task, so a [cron adapter](/docs/dev/cron) must be configured for
-the queue to drain.
+One item type may only have **one** indexer. Two plugins claiming the same
+`itemType` is a startup error naming both, because they would otherwise overwrite
+each other's documents on every rebuild.
+
+`itemsRead` is the number of **source items** the page read, not the number of
+search documents it produced. They differ whenever one item emits several
+documents (one per language) or none at all (a row that cannot be projected).
+
+
+ Returning `documents.length` as `itemsRead`, or ending the loop on an empty
+ `documents` array, silently truncates the index: a page whose rows all fail to
+ project would stop the rebuild before the valid rows behind it. Report the rows
+ you read.
+
+
+### The older array result
+
+`load` may still return a bare `SearchDocument[]`:
+
+```ts
+load: async (c, offset, limit) => await buildDocuments(c, offset, limit),
+```
+
+It keeps working, so an indexer written before the page contract needs no
+changes. It is **deprecated**, though, and it is not equally safe:
+
+- it carries no source count, so the rebuild advances by the **requested page
+ size** and waits for an empty array to stop;
+- it therefore cannot express "rows were read, none could be projected" - such a
+ page looks exactly like the end of the source, and the rows behind it are never
+ indexed.
+
+Return a `SearchIndexerPage` in new code. The array form is removed only in a
+future major release.
+
+### Ownership
+
+A document may name the plugin that owns it:
+
+```ts
+{ itemType: "blog_post", itemId: 1, pluginId: "@vitnode/blog", /* ... */ }
+```
+
+Set it whenever you know the owner. A rebuild runs inside the **core** cron
+request, so the request's plugin is not the owner - an explicit `pluginId` is what
+makes a rebuilt document identical to a live-indexed one. An indexer that names
+none has its registering plugin stamped on during the rebuild, so existing
+indexers keep working; the request's plugin, then `"core"`, are the remaining
+fallbacks. An empty or whitespace-only value counts as naming nobody. Ownership is
+resolved once, in the search model, so the canonical row and the mirrored document
+can never disagree.
+
+**AdminCP → Advanced → Search** shows the registered indexer's plugin. When a
+collection has documents but no registered indexer, it shows the owner stored on
+those documents instead, and `unknown` only when neither source knows. Such a
+collection is never relabelled as core.
+
+Trigger a rebuild from **AdminCP → Advanced → Search → Rebuild index**. It runs as
+a background queue task, so a [cron adapter](/docs/dev/cron) must be configured
+for the queue to drain.
+
+
+ Rebuilding drops the documents it is about to replace first - the whole index
+ for "rebuild everything", or one collection for a single reindex. So search
+ returns less while it runs.
+
+
+The two rebuilds differ in what they are allowed to destroy:
+
+- **A scoped rebuild requires a registered `SearchIndexer`.** It never clears an
+ item type it cannot rebuild - the request is rejected with a 404, and the queue
+ task refuses it too, in case it was dispatched directly or queued while the
+ indexer was still there.
+- **A full rebuild clears the whole index and recreates only collections with a
+ registered indexer.** Documents belonging to an item type with no indexer are
+ therefore removed, and come back only when their plugin writes them again. On a
+ site with live-only indexers, that is worth knowing before clicking it.
+
+## Unmanaged collections
+
+Indexing and rebuilding are **two independent capabilities**:
+
+- `c.get("search").index(document)` keeps a collection current as your plugin
+ writes. This is all a plugin needs to be searchable.
+- A registered `SearchIndexer` additionally lets the whole collection be
+ reproduced from its source, which is what a rebuild does.
+
+Registering an indexer is optional, so a plugin may write documents live and
+register none. That collection works perfectly during normal operation; it simply
+cannot take part in a deterministic rebuild.
+
+A collection is **unmanaged by the rebuild system** when documents for its
+`itemType` are in the index but no `SearchIndexer` is registered for it.
+
+
+ It does **not** prove the plugin is uninstalled or inactive. A live-only plugin
+ looks exactly the same from the index's point of view, and may be keeping the
+ collection completely up to date. All VitNode can tell is that it has no way to
+ rebuild it.
+
+
+**AdminCP → Advanced → Search** labels those rows *Unmanaged*, shows the plugin
+stored on their documents, and says no rebuild indexer is registered. Coverage is
+left blank rather than calculated: with no indexer there is no source count, and
+`11 / 11` would claim a collection nothing can rebuild is fully covered.
+
+*Reindex* is replaced by **Remove documents**, behind a confirmation. It deletes
+what is currently indexed and rebuilds nothing - but it does not stop anything
+either, so a live-writing plugin may recreate those documents on its next write.
+It is a way to clear a stale indexed state, not a way to uninstall a collection.
+
+Registering an indexer for the item type makes the collection rebuildable, and the
+row becomes an ordinary one.
## Giving a type an icon and label
diff --git a/packages/elasticsearch/src/index.test.ts b/packages/elasticsearch/src/index.test.ts
index ad217f6bd..6c4f426a2 100644
--- a/packages/elasticsearch/src/index.test.ts
+++ b/packages/elasticsearch/src/index.test.ts
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const {
Client,
ResponseError,
+ bulk,
index,
deleteByQuery,
ping,
@@ -40,6 +41,7 @@ const {
return {
Client,
ResponseError,
+ bulk,
index,
deleteByQuery,
ping,
@@ -159,6 +161,48 @@ describe("ElasticsearchSearchAdapter.index", () => {
});
});
+describe("ElasticsearchSearchAdapter plugin ownership", () => {
+ it("serializes the document's owning plugin", async () => {
+ await ElasticsearchSearchAdapter(config).index(c, {
+ ...doc,
+ pluginId: "@vitnode/example",
+ });
+
+ expect(index).toHaveBeenCalledWith(
+ expect.objectContaining({
+ document: expect.objectContaining({ pluginId: "@vitnode/example" }),
+ }),
+ );
+ });
+
+ it("serializes each owner in a bulk write", async () => {
+ await ElasticsearchSearchAdapter(config).bulkIndex(c, [
+ { ...doc, itemId: 1, pluginId: "@vitnode/example" },
+ { ...doc, itemId: 2, pluginId: "@vitnode/blog" },
+ ]);
+
+ const { operations } = bulk.mock.calls[0][0] as {
+ operations: { pluginId?: string }[];
+ };
+
+ // Alternating action/document pairs, so the sources are the odd entries.
+ expect(operations[1].pluginId).toBe("@vitnode/example");
+ expect(operations[3].pluginId).toBe("@vitnode/blog");
+ });
+
+ it("falls back to core for a document with no owner", async () => {
+ // `SearchModel` resolves ownership before any provider sees a document, so
+ // this only covers a provider called directly.
+ await ElasticsearchSearchAdapter(config).index(c, doc);
+
+ expect(index).toHaveBeenCalledWith(
+ expect.objectContaining({
+ document: expect.objectContaining({ pluginId: "core" }),
+ }),
+ );
+ });
+});
+
describe("ElasticsearchSearchAdapter index initialization", () => {
it("creates the index only once under concurrent calls", async () => {
exists.mockResolvedValue(false);
diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts
index e1dddb680..8778787f8 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 ?? "",
diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts
index 9c2f93ef3..f033df311 100644
--- a/packages/vitnode/src/api/lib/module.ts
+++ b/packages/vitnode/src/api/lib/module.ts
@@ -2,6 +2,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
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";
@@ -33,6 +34,7 @@ export interface BaseBuildModuleReturn<
pluginId: P;
queueTasks: BuildQueueTaskReturn[];
routes: Routes;
+ searchIndexers?: SearchIndexer[];
webSockets: BuildWebSocketReturn[];
}
@@ -59,6 +61,7 @@ export function buildModule<
cronJobs = [],
events = [],
queueTasks = [],
+ searchIndexers,
webSockets = [],
}: {
contentTypes?: AnyContentTypeDefinition[];
@@ -69,6 +72,7 @@ export function buildModule<
pluginId: P;
queueTasks?: BuildQueueTaskReturn[];
routes: Routes;
+ searchIndexers?: SearchIndexer[];
webSockets?: BuildWebSocketReturn[];
}): BuildModuleReturn
({
routes: [],
modules,
contentTypes: contentTypes.map(model => model.definition),
+ // A content type without `search` contributes nothing, so the two module
+ // builders can keep taking the same array. The plugin id travels with the
+ // indexer so a rebuild - which runs in the core cron request - still stores
+ // the owning plugin on every document.
+ searchIndexers: contentTypes
+ .filter(model => model.definition.search.enabled)
+ .map(model => createContentSearchIndexer(model, { pluginId })),
});
};
diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts
index 00304fd09..6926f561d 100644
--- a/packages/vitnode/src/content/server/routes.ts
+++ b/packages/vitnode/src/content/server/routes.ts
@@ -20,6 +20,7 @@ import { orderableColumns } from "../registry";
import { emitContentEvent } from "./emit";
import { withHttpErrors } from "./http-errors";
import { publicationMethods } from "./publication";
+import { syncContentSearch } from "./search-sync";
const zodLabels = z.record(z.string(), z.string().nullable());
@@ -242,6 +243,15 @@ export const buildContentRoutes = <
// Emitted only once the write has returned, never inside a transaction.
await emitContentEvent(c, definition, "created", { contentId: row.id });
+ // A new record is a draft, so this normally indexes nothing - but it is
+ // computed from the row rather than assumed, the same way the Server
+ // Action computes its cache tags.
+ await syncContentSearch(c, definition, {
+ operation: "create",
+ pluginId,
+ row,
+ });
+
return c.json(row, 201);
},
});
@@ -280,6 +290,15 @@ export const buildContentRoutes = <
});
}
+ // A slug change is just a rewritten `url`: the search document is keyed by
+ // item type and id, so there is no stale document to clean up.
+ await syncContentSearch(c, definition, {
+ changedFields: result.changedFields,
+ operation: "update",
+ pluginId,
+ row: result.row,
+ });
+
return c.json(result.row, 200);
},
});
@@ -329,6 +348,13 @@ export const buildContentRoutes = <
);
}
+ await syncContentSearch(c, definition, {
+ changed: result.changed,
+ operation: action,
+ pluginId,
+ row: result.row,
+ });
+
return c.json({ changed: result.changed, row: result.row }, 200);
},
});
@@ -359,6 +385,15 @@ export const buildContentRoutes = <
await emitContentEvent(c, definition, "deleted", { contentId: row.id });
+ // `publishedAt` survives an unpublish, so a record that was ever published
+ // is removed from the index defensively - a delete of a document that is
+ // not there costs one statement and repairs any drift.
+ await syncContentSearch(c, definition, {
+ operation: "delete",
+ pluginId,
+ row,
+ });
+
return c.json(row, 200);
},
});
diff --git a/packages/vitnode/src/content/server/search-document.test.ts b/packages/vitnode/src/content/server/search-document.test.ts
new file mode 100644
index 000000000..973c83696
--- /dev/null
+++ b/packages/vitnode/src/content/server/search-document.test.ts
@@ -0,0 +1,162 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import {
+ testPostContentType,
+ testSearchablePostContentType,
+} from "@/tests/content-fixtures";
+
+import { contentSearchDocument } from "./search-document";
+
+const PUBLISHED_AT = new Date("2026-02-01T10:00:00.000Z");
+const CREATED_AT = new Date("2026-01-01T00:00:00.000Z");
+const UPDATED_AT = new Date("2026-03-01T00:00:00.000Z");
+
+const row = {
+ author: 3,
+ body: "The body of the post.",
+ code: "SECRET-CODE",
+ createdAt: CREATED_AT,
+ excerpt: "A short excerpt.",
+ id: 7,
+ publishedAt: PUBLISHED_AT,
+ slug: "hello-world",
+ status: "published",
+ title: "Hello world",
+ updatedAt: UPDATED_AT,
+ views: 42,
+};
+
+const document = (overrides: Record = {}) =>
+ contentSearchDocument(testSearchablePostContentType, {
+ ...row,
+ ...overrides,
+ });
+
+describe("content search document", () => {
+ it("maps a published row", () => {
+ expect(document()).toEqual({
+ content: "A short excerpt.\n\nThe body of the post.",
+ createdAt: PUBLISHED_AT,
+ isPublic: true,
+ itemId: 7,
+ itemType: "test.searchable",
+ title: "Hello world",
+ updatedAt: UPDATED_AT,
+ url: "/searchable/hello-world",
+ });
+ });
+
+ it("uses the content type id as the item type", () => {
+ expect(document()?.itemType).toBe(testSearchablePostContentType.id);
+ });
+
+ it("leaves the language code unset so every locale matches", () => {
+ expect(document()).not.toHaveProperty("languageCode");
+ });
+
+ it("omits the owner unless one was given", () => {
+ expect(document()).not.toHaveProperty("pluginId");
+ });
+
+ it("carries the owning plugin when one is given", () => {
+ expect(
+ contentSearchDocument(testSearchablePostContentType, row, {
+ pluginId: "@vitnode/example",
+ })?.pluginId,
+ ).toBe("@vitnode/example");
+ });
+
+ it("never carries an author, container or metadata", () => {
+ const result = document();
+
+ expect(result?.authorId).toBeUndefined();
+ expect(result?.containerId).toBeUndefined();
+ expect(result?.containerType).toBeUndefined();
+ expect(result?.metadata).toBeUndefined();
+ });
+
+ it("prefers publishedAt over createdAt", () => {
+ expect(document()?.createdAt).toEqual(PUBLISHED_AT);
+ expect(
+ contentSearchDocument(testSearchablePostContentType, {
+ ...row,
+ publishedAt: "2026-02-01T10:00:00.000Z",
+ })?.createdAt,
+ ).toEqual(PUBLISHED_AT);
+ });
+
+ describe("security", () => {
+ it("excludes every private field value", () => {
+ const serialized = JSON.stringify(document());
+
+ expect(serialized).not.toContain("SECRET-CODE");
+ expect(serialized).not.toContain("42");
+ expect(serialized).not.toContain('"author"');
+ });
+
+ it("returns null for a draft", () => {
+ expect(document({ publishedAt: null, status: "draft" })).toBeNull();
+ });
+
+ it("returns null when publishedAt is missing", () => {
+ expect(document({ publishedAt: null })).toBeNull();
+ });
+
+ it("returns null when publishedAt is in the future", () => {
+ expect(
+ document({ publishedAt: new Date(Date.now() + 60_000) }),
+ ).toBeNull();
+ });
+
+ it("returns null when search is disabled", () => {
+ expect(contentSearchDocument(testPostContentType, row)).toBeNull();
+ });
+ });
+
+ describe("degenerate values", () => {
+ it("returns null for a blank title", () => {
+ expect(document({ title: " " })).toBeNull();
+ expect(document({ title: null })).toBeNull();
+ });
+
+ it("returns null for a blank slug", () => {
+ expect(document({ slug: "" })).toBeNull();
+ });
+
+ it("returns null for a non-numeric id", () => {
+ expect(document({ id: "7" })).toBeNull();
+ });
+
+ it("keeps a document with no body at all", () => {
+ const result = document({ body: null, excerpt: null });
+
+ expect(result?.content).toBe("");
+ expect(result?.title).toBe("Hello world");
+ });
+ });
+
+ describe("text handling", () => {
+ it("collapses whitespace in the title", () => {
+ expect(document({ title: " Hello \n\t world " })?.title).toBe(
+ "Hello world",
+ );
+ });
+
+ it("concatenates content fields in order and skips the empty ones", () => {
+ expect(document({ excerpt: null })?.content).toBe(
+ "The body of the post.",
+ );
+ expect(document({ body: null })?.content).toBe("A short excerpt.");
+ });
+
+ it("does not index the description twice", () => {
+ // `excerpt` is both the description and the first content field.
+ expect(document()?.content.split("A short excerpt.")).toHaveLength(2);
+ });
+
+ it("percent-encodes an unusual slug", () => {
+ expect(document({ slug: "a b" })?.url).toBe("/searchable/a%20b");
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts
new file mode 100644
index 000000000..a3c77fc36
--- /dev/null
+++ b/packages/vitnode/src/content/server/search-document.ts
@@ -0,0 +1,131 @@
+import type { SearchDocument } from "../../api/models/search";
+import type { AnyContentTypeDefinition } from "../types";
+
+import { isContentPubliclyVisible } from "../cache";
+import { contentSearchUrl } from "../search";
+
+/** Collapses whitespace so a multi-line value cannot break a result heading. */
+const normalize = (value: unknown): string =>
+ typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
+
+const toDate = (value: unknown): Date | undefined => {
+ if (value instanceof Date) return value;
+ if (typeof value !== "string") return undefined;
+
+ const parsed = new Date(value);
+
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
+};
+
+const toTimestamp = (value: unknown): Date | null | string | undefined => {
+ if (value instanceof Date || typeof value === "string") return value;
+
+ return value === null ? null : undefined;
+};
+
+/**
+ * Whether one row is currently publicly visible.
+ *
+ * {@link isContentPubliclyVisible} with the column coercion in front of it, so
+ * "is this public" and "can this be indexed" stay two separate questions - a
+ * published record whose title is blank is the first but not the second, and it
+ * needs its stale document removed rather than left alone.
+ */
+export const isContentRowPublic = (row: object): boolean => {
+ const values = row as Record;
+
+ return isContentPubliclyVisible({
+ publishedAt: toTimestamp(values.publishedAt),
+ status: normalize(values.status) || undefined,
+ });
+};
+
+/**
+ * Projects one content record into a search document.
+ *
+ * Returns `null` - not a partial document - whenever the record must not be
+ * indexed: search is off, the row is not publicly visible, or the title or slug
+ * is missing. Every caller treats `null` as "make sure nothing is indexed for
+ * this record", so there is one decision and not one per call site.
+ *
+ * Visibility is {@link isContentPubliclyVisible}, the same predicate the Server
+ * Actions use to decide which cache tags to expire. The engine has exactly two
+ * definitions of "public" - that one and `publishedCondition` in SQL - and this
+ * adds no third.
+ *
+ * Nothing outside `publicApi.fields` can reach the document: `defineContentType`
+ * has already proven every indexed field name is in that allowlist, so a private
+ * column cannot be read here even by mistake.
+ *
+ * `row` is `object` rather than `ContentSelect` because columns are
+ * read by a name resolved at runtime, and an unresolved generic row type is not
+ * assignable to an index signature - typing it strictly would push a cast to
+ * every call site instead of keeping the one honest cast here.
+ */
+export const contentSearchDocument = (
+ definition: AnyContentTypeDefinition,
+ row: object,
+ { pluginId }: { pluginId?: string } = {},
+): null | SearchDocument => {
+ const { publicApi, search } = definition;
+ if (!search.enabled) return null;
+
+ const values = row as Record;
+
+ const itemId = values.id;
+ if (typeof itemId !== "number") return null;
+
+ if (!isContentRowPublic(row)) return null;
+
+ const title = normalize(values[search.titleField]);
+ if (title === "") return null;
+
+ const url = contentSearchUrl(
+ definition,
+ normalize(values[publicApi.slugField]),
+ );
+ if (url === null) return null;
+
+ // A published row always carries `publishedAt`, so this resolves to the
+ // publication date - which is the date the feed and the timeline sort by.
+ const createdAt = toDate(values.publishedAt) ?? toDate(values.createdAt);
+ if (!createdAt) return null;
+
+ // The description leads the body so it shows up first in a result excerpt.
+ // Skipped when it is already one of the content fields, so it is not indexed
+ // twice and does not distort ranking.
+ const sources = search.contentFields.includes(search.descriptionField ?? "")
+ ? search.contentFields
+ : [
+ ...(search.descriptionField ? [search.descriptionField] : []),
+ ...search.contentFields,
+ ];
+
+ return {
+ // `SearchModel.index` strips HTML from `content` (but never from `title`,
+ // which is why the title is normalized above).
+ content: sources
+ .map(name => normalize(values[name]))
+ .filter(value => value !== "")
+ .join("\n\n"),
+ createdAt,
+ // Only publicly visible rows get this far, and an unpublished record is
+ // deleted from the index rather than hidden in it.
+ isPublic: true,
+ itemId,
+ // Content type ids are globally unique and already namespaced as
+ // `plugin.entity`, so the id alone is a collision-free item type.
+ itemType: definition.id,
+ // The owning plugin, so a rebuild - which runs in the core cron request -
+ // stores the same ownership a live write did.
+ ...(pluginId === undefined ? {} : { pluginId }),
+ // Deliberately absent: `authorId` (a `user` field can never be public, and
+ // the public search route resolves it into a person), `containerType` /
+ // `containerId` (there is no `containerType` query filter to qualify them
+ // with), and `metadata` (nothing reads it). Also `languageCode`, which
+ // defaults to "" - the language-agnostic value that matches every locale.
+ title,
+ updatedAt: toDate(values.updatedAt),
+ url,
+ };
+};
diff --git a/packages/vitnode/src/content/server/search-indexer.test.ts b/packages/vitnode/src/content/server/search-indexer.test.ts
new file mode 100644
index 000000000..d16bce27b
--- /dev/null
+++ b/packages/vitnode/src/content/server/search-indexer.test.ts
@@ -0,0 +1,232 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ testCategoryContentType,
+ testPostContentType,
+ testSearchablePostContentType,
+} from "@/tests/content-fixtures";
+
+import { createContentModel } from "./model";
+import { createContentSearchIndexer } from "./search-indexer";
+
+interface RecordedCall {
+ arg: unknown;
+ op: string;
+}
+
+/** The same chainable Drizzle stand-in `service.test.ts` uses, plus `offset`. */
+const createDbMock = (results: unknown[][]) => {
+ const calls: RecordedCall[] = [];
+ const queue = [...results];
+
+ const chain = (rows: unknown[]) => {
+ const record = (op: string, arg: unknown) => {
+ calls.push({ arg, op });
+
+ return builder;
+ };
+
+ const builder = {
+ from: (value: unknown) => record("from", value),
+ limit: (value: unknown) => record("limit", value),
+ offset: (value: unknown) => record("offset", value),
+ orderBy: (value: unknown) => record("orderBy", value),
+ then: async (resolve: (rows: unknown[]) => TResult) =>
+ Promise.resolve(rows).then(resolve),
+ where: (value: unknown) => record("where", value),
+ };
+
+ return builder;
+ };
+
+ const db = {
+ select: (arg: unknown) => {
+ calls.push({ arg, op: "select" });
+
+ return chain(queue.shift() ?? []);
+ },
+ };
+
+ const c = {
+ get: (key: string) => (key === "db" ? db : undefined),
+ } as Context;
+
+ return { c, calls };
+};
+
+const opOf = (calls: RecordedCall[], op: string) =>
+ calls.find(call => call.op === op)?.arg;
+
+const categories = createContentModel(testCategoryContentType);
+const searchable = createContentModel(testSearchablePostContentType);
+const plain = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
+
+const PLUGIN_ID = "@vitnode/example";
+
+const indexerFor = (model: typeof plain | typeof searchable) =>
+ createContentSearchIndexer(model as typeof searchable, {
+ pluginId: PLUGIN_ID,
+ });
+
+const PUBLISHED_AT = new Date("2026-02-01T10:00:00.000Z");
+
+const dbRow = (id: number, slug: string) => ({
+ body: "Body copy.",
+ code: "SECRET",
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ excerpt: "Excerpt.",
+ id,
+ publishedAt: PUBLISHED_AT,
+ slug,
+ status: "published",
+ title: `Post ${id}`,
+ updatedAt: new Date("2026-01-02T00:00:00.000Z"),
+});
+
+describe("generated content search indexer", () => {
+ it("uses the content type id as the item type", () => {
+ expect(indexerFor(searchable).itemType).toBe("test.searchable");
+ });
+
+ describe("count", () => {
+ it("counts only published rows", async () => {
+ const { c, calls } = createDbMock([[{ value: 12 }]]);
+
+ const total = await indexerFor(searchable).count?.(c);
+
+ expect(total).toBe(12);
+ // The published predicate is not optional, so there is always a `where`.
+ expect(calls.some(call => call.op === "where" && call.arg)).toBe(true);
+ });
+
+ it("reports zero for an empty table", async () => {
+ const { c } = createDbMock([[]]);
+
+ await expect(indexerFor(searchable).count?.(c)).resolves.toBe(0);
+ });
+ });
+
+ describe("load", () => {
+ it("projects only the columns the document needs", async () => {
+ const { c, calls } = createDbMock([[]]);
+
+ await indexerFor(searchable).load(c, 0, 200);
+
+ const selection = opOf(calls, "select") as Record;
+
+ expect(Object.keys(selection).sort()).toEqual([
+ "body",
+ "createdAt",
+ "excerpt",
+ "id",
+ "publishedAt",
+ "slug",
+ "status",
+ "title",
+ "updatedAt",
+ ]);
+ // The private columns are never even fetched.
+ expect(selection).not.toHaveProperty("code");
+ expect(selection).not.toHaveProperty("views");
+ expect(selection).not.toHaveProperty("author");
+ });
+
+ it("orders deterministically and honours the page window", async () => {
+ const { c, calls } = createDbMock([[]]);
+
+ await indexerFor(searchable).load(c, 400, 200);
+
+ expect(opOf(calls, "orderBy")).toBeDefined();
+ expect(opOf(calls, "limit")).toBe(200);
+ expect(opOf(calls, "offset")).toBe(400);
+ });
+
+ it("maps every row into a document, and stamps the owning plugin", async () => {
+ const { c } = createDbMock([[dbRow(1, "one"), dbRow(2, "two")]]);
+
+ const page = await indexerFor(searchable).load(c, 0, 200);
+
+ expect(page.itemsRead).toBe(2);
+ expect(page.documents).toHaveLength(2);
+ expect(page.documents[0]).toMatchObject({
+ itemId: 1,
+ itemType: "test.searchable",
+ pluginId: PLUGIN_ID,
+ title: "Post 1",
+ url: "/searchable/one",
+ });
+ expect(page.documents[1]).toMatchObject({
+ pluginId: PLUGIN_ID,
+ url: "/searchable/two",
+ });
+ });
+
+ it("reports zero items read past the end of the source", async () => {
+ const { c } = createDbMock([[]]);
+
+ await expect(indexerFor(searchable).load(c, 1000, 200)).resolves.toEqual({
+ documents: [],
+ itemsRead: 0,
+ });
+ });
+
+ it("counts rows the mapper rejected as items read", async () => {
+ // The regression this contract exists for: a page can read rows and
+ // project none of them, and reporting that as "no items" would end the
+ // rebuild before the valid rows behind it.
+ const { c } = createDbMock([
+ [
+ { ...dbRow(1, "one"), title: " " },
+ { ...dbRow(2, "two"), title: null },
+ ],
+ ]);
+
+ const page = await indexerFor(searchable).load(c, 0, 200);
+
+ expect(page.itemsRead).toBe(2);
+ expect(page.documents).toEqual([]);
+ });
+
+ it("separates valid from invalid rows on a mixed page", async () => {
+ const { c, calls } = createDbMock([
+ [
+ dbRow(1, "one"),
+ { ...dbRow(2, "two"), title: " " },
+ dbRow(3, "three"),
+ ],
+ ]);
+
+ const page = await indexerFor(searchable).load(c, 0, 200);
+
+ // `itemsRead` is the row count; only the valid rows became documents.
+ expect(page.itemsRead).toBe(3);
+ expect(page.documents.map(document => document.itemId)).toEqual([1, 3]);
+
+ const selection = opOf(calls, "select") as Record;
+ expect(selection).not.toHaveProperty("code");
+ expect(JSON.stringify(page.documents)).not.toContain("SECRET");
+ });
+ });
+
+ it("refuses a content type without publication", () => {
+ expect(() =>
+ indexerFor(categories as unknown as typeof searchable),
+ ).toThrow(/publication/i);
+ });
+
+ it("builds for a content type with search off, but yields no documents", async () => {
+ const { c } = createDbMock([[dbRow(1, "one")]]);
+
+ // `buildContentAdminModule` filters these out; the mapper is the backstop.
+ // The row is still read, so `itemsRead` reflects it.
+ await expect(indexerFor(plain).load(c, 0, 200)).resolves.toEqual({
+ documents: [],
+ itemsRead: 1,
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/server/search-indexer.ts b/packages/vitnode/src/content/server/search-indexer.ts
new file mode 100644
index 000000000..3a95739d1
--- /dev/null
+++ b/packages/vitnode/src/content/server/search-indexer.ts
@@ -0,0 +1,131 @@
+import type {
+ PgColumn,
+ PgTableWithColumns,
+ TableConfig,
+} from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+
+import { asc, count } from "drizzle-orm";
+
+import type {
+ SearchDocument,
+ SearchIndexer,
+ SearchIndexerPage,
+} from "../../api/models/search";
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentModel } from "./model";
+
+import { contentSearchIndexedFieldNames } from "../search";
+import { publicationColumns, publishedCondition } from "./publication";
+import { contentSearchDocument } from "./search-document";
+
+/** System columns the mapper reads, on top of the configured search fields. */
+const REQUIRED_COLUMNS = [
+ "id",
+ "createdAt",
+ "updatedAt",
+ "status",
+ "publishedAt",
+] as const;
+
+/**
+ * A generated indexer, pinned to the modern page contract.
+ *
+ * `SearchIndexer.load` also accepts the deprecated bare-array result, for
+ * hand-written indexers that predate it. A generated one never returns that, and
+ * saying so keeps the guarantee in the type rather than in a comment.
+ */
+export interface ContentSearchIndexer extends SearchIndexer {
+ load: (
+ c: Context,
+ offset: number,
+ limit: number,
+ ) => Promise;
+}
+
+/**
+ * Adapts one content type to the engine's {@link SearchIndexer} contract, so a
+ * full or per-collection rebuild can stream its published records.
+ *
+ * Registered automatically by `buildContentAdminModule` for every content type
+ * with `search: { enabled: true }` - manual indexers registered by a plugin are
+ * untouched and keep working exactly as before.
+ *
+ * Two properties matter for review:
+ *
+ * 1. **Only published rows are read.** `publishedCondition` is not a parameter -
+ * both queries `and` it in themselves, so there is no argument a caller could
+ * forget, and it is the same SQL predicate the public read layer uses.
+ * 2. **Only projected columns are read.** The `SELECT` is built from the
+ * configured search fields, all of which `defineContentType` has already
+ * proven are in `publicApi.fields`. A private column is never fetched, and
+ * column names are resolved into Drizzle columns rather than interpolated
+ * into SQL.
+ */
+export const createContentSearchIndexer = <
+ TDefinition extends AnyContentTypeDefinition,
+>(
+ model: ContentModel,
+ { pluginId }: { pluginId: string },
+): ContentSearchIndexer => {
+ const { definition } = model;
+ // Widened the same way `createContentPublicService` takes it: the query
+ // builders are written against the erased table, not this content type's.
+ const table: PgTableWithColumns = model.table;
+ const columns = model.columns as Record;
+ const published = publicationColumns(definition, columns);
+ const primaryCursor = columns.id;
+
+ const selection: Record = Object.fromEntries(
+ [
+ ...new Set([
+ ...REQUIRED_COLUMNS,
+ ...contentSearchIndexedFieldNames(definition),
+ ]),
+ ].map(name => [name, columns[name]]),
+ );
+
+ return {
+ itemType: definition.id,
+
+ // Published rows, not every row: the AdminCP coverage bar compares this
+ // against the number of indexed items, and counting drafts would pin a
+ // mostly-unpublished collection at "stale" forever.
+ count: async c => {
+ const [row] = await c
+ .get("db")
+ .select({ value: count() })
+ .from(table)
+ .where(publishedCondition(published));
+
+ return row?.value ?? 0;
+ },
+
+ // Offset paging, which is what the contract exposes. Ordering by the primary
+ // key keeps pages from overlapping within one rebuild; a row whose
+ // publication state changes mid-rebuild can still shift, and that is what
+ // the next publish - or the next rebuild - repairs.
+ //
+ // `itemsRead` is the row count, not the document count. A published row with
+ // no usable title projects to nothing, and reporting that as "no items" would
+ // end the rebuild before the valid rows after it.
+ load: async (c, offset, limit) => {
+ const rows = await c
+ .get("db")
+ .select(selection)
+ .from(table)
+ .where(publishedCondition(published))
+ .orderBy(asc(primaryCursor))
+ .limit(limit)
+ .offset(offset);
+
+ const documents = rows.flatMap(row => {
+ const document = contentSearchDocument(definition, row, { pluginId });
+
+ return document ? [document] : [];
+ }) satisfies SearchDocument[];
+
+ return { documents, itemsRead: rows.length };
+ },
+ };
+};
diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts
new file mode 100644
index 000000000..5ccd529b9
--- /dev/null
+++ b/packages/vitnode/src/content/server/search-sync.test.ts
@@ -0,0 +1,481 @@
+// @vitest-environment node
+import type { Context, MiddlewareHandler } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ testCategoryContentType,
+ testPostContentType,
+ testSearchablePostContentType,
+} from "@/tests/content-fixtures";
+
+import { createContentModel } from "./model";
+import { buildContentRoutes } from "./routes";
+
+vi.mock("../../api/lib/check-staff-permission", () => ({
+ assertStaffPermission: async () => {
+ await Promise.resolve();
+ },
+}));
+
+const categories = createContentModel(testCategoryContentType);
+const searchable = createContentModel(testSearchablePostContentType);
+const plain = createContentModel(testPostContentType, {
+ references: { category: () => categories.table.id },
+});
+
+const PLUGIN_ID = "@vitnode/example";
+
+const PUBLISHED_AT = new Date("2026-02-01T10:00:00.000Z");
+const CREATED_AT = new Date("2026-01-01T00:00:00.000Z");
+
+const publishedRow = {
+ author: 3,
+ body: "Body copy.",
+ code: "SECRET",
+ createdAt: CREATED_AT,
+ excerpt: "Excerpt.",
+ id: 7,
+ publishedAt: PUBLISHED_AT,
+ slug: "hello-world",
+ status: "published" as const,
+ title: "Hello world",
+ updatedAt: CREATED_AT,
+ views: 0,
+};
+
+const draftRow = {
+ ...publishedRow,
+ publishedAt: null,
+ status: "draft" as const,
+};
+
+/**
+ * The generated routes with the service, the search engine and the logger
+ * stubbed, so each case drives the real handler and asserts on what reached
+ * `c.get("search")`.
+ */
+const harness = ({
+ logFails = false,
+ model = searchable,
+ searchFails = false,
+}: {
+ logFails?: boolean;
+ model?: typeof plain | typeof searchable;
+ searchFails?: boolean;
+} = {}) => {
+ const logged: string[] = [];
+ const search = {
+ delete: vi.fn(async () => {
+ if (searchFails) throw new Error("engine unavailable");
+ await Promise.resolve();
+ }),
+ index: vi.fn(async () => {
+ if (searchFails) throw new Error("engine unavailable");
+ await Promise.resolve();
+ }),
+ };
+ const service = {
+ create: vi.fn(),
+ delete: vi.fn(),
+ findById: vi.fn(),
+ findMany: vi.fn(),
+ options: vi.fn(),
+ publish: vi.fn(),
+ unpublish: vi.fn(),
+ update: vi.fn(),
+ };
+
+ vi.spyOn(model, "service").mockReturnValue(service);
+
+ const app = new OpenAPIHono();
+ const context: MiddlewareHandler = async (c, next) => {
+ c.set("events", {
+ emit: async () => {
+ await Promise.resolve();
+ },
+ } as unknown as Context["var"]["events"]);
+ c.set("search", search as unknown as Context["var"]["search"]);
+ c.set("log", {
+ debug: async () => {
+ await Promise.resolve();
+ },
+ error: async (content: string) => {
+ await Promise.resolve();
+ logged.push(content);
+ if (logFails) throw new Error("core_logs unavailable");
+ },
+ warn: async () => {
+ await Promise.resolve();
+ },
+ });
+ c.set("admin", { user: { id: 1 } } as unknown as Context["var"]["admin"]);
+ await next();
+ };
+ app.use("*", context);
+
+ // The two fixtures are structurally different content types, and the harness
+ // only needs their routes - so the union collapses here rather than making
+ // every caller generic.
+ for (const { handler, route } of buildContentRoutes(
+ model as typeof searchable,
+ { pluginId: PLUGIN_ID },
+ )) {
+ app.openapi(route, handler);
+ }
+
+ return { app, logged, search, service };
+};
+
+const json = (body: unknown) => ({
+ body: JSON.stringify(body),
+ headers: { "Content-Type": "application/json" },
+});
+
+describe("content search lifecycle synchronization", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("create", () => {
+ it("indexes nothing for a new draft", async () => {
+ const { app, search, service } = harness();
+ service.create.mockResolvedValue(draftRow);
+
+ const res = await app.request("/", {
+ ...json({ code: "a", slug: "hello-world", title: "Hello world" }),
+ method: "POST",
+ });
+
+ expect(res.status).toBe(201);
+ expect(search.index).not.toHaveBeenCalled();
+ expect(search.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("update", () => {
+ it("indexes nothing for a draft", async () => {
+ const { app, search, service } = harness();
+ service.update.mockResolvedValue({
+ changedFields: ["title"],
+ row: draftRow,
+ });
+
+ const res = await app.request("/7", {
+ ...json({ title: "Changed" }),
+ method: "PUT",
+ });
+
+ expect(res.status).toBe(200);
+ expect(search.index).not.toHaveBeenCalled();
+ });
+
+ it("upserts when a published record's indexed field changes", async () => {
+ const { app, search, service } = harness();
+ service.update.mockResolvedValue({
+ changedFields: ["title"],
+ row: publishedRow,
+ });
+
+ await app.request("/7", { ...json({ title: "Changed" }), method: "PUT" });
+
+ expect(search.index).toHaveBeenCalledTimes(1);
+ expect(search.index).toHaveBeenCalledWith(
+ expect.objectContaining({
+ itemId: 7,
+ itemType: "test.searchable",
+ title: "Hello world",
+ }),
+ );
+ });
+
+ it("indexes nothing when only a non-indexed field changes", async () => {
+ const { app, search, service } = harness();
+ service.update.mockResolvedValue({
+ changedFields: ["views"],
+ row: publishedRow,
+ });
+
+ await app.request("/7", { ...json({ views: 1 }), method: "PUT" });
+
+ expect(search.index).not.toHaveBeenCalled();
+ });
+
+ it("rewrites the url when the slug changes", async () => {
+ const { app, search, service } = harness();
+ service.update.mockResolvedValue({
+ changedFields: ["slug"],
+ row: { ...publishedRow, slug: "renamed" },
+ });
+
+ await app.request("/7", { ...json({ slug: "renamed" }), method: "PUT" });
+
+ expect(search.index).toHaveBeenCalledWith(
+ expect.objectContaining({ url: "/searchable/renamed" }),
+ );
+ // No stale document: the key is the item type and id, not the url.
+ expect(search.delete).not.toHaveBeenCalled();
+ });
+
+ it("removes the document when a published record stops being indexable", async () => {
+ const { app, search, service } = harness();
+ // Still published, but there is no longer a title to show in a result.
+ service.update.mockResolvedValue({
+ changedFields: ["title"],
+ row: { ...publishedRow, title: " " },
+ });
+
+ await app.request("/7", { ...json({ title: " " }), method: "PUT" });
+
+ expect(search.delete).toHaveBeenCalledWith("test.searchable", 7);
+ expect(search.index).not.toHaveBeenCalled();
+ });
+
+ it("indexes nothing when nothing changed", async () => {
+ const { app, search, service } = harness();
+ service.update.mockResolvedValue({
+ changedFields: [],
+ row: publishedRow,
+ });
+
+ await app.request("/7", {
+ ...json({ title: "Hello world" }),
+ method: "PUT",
+ });
+
+ expect(search.index).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("publish", () => {
+ it("upserts once on a real transition", async () => {
+ const { app, search, service } = harness();
+ service.publish.mockResolvedValue({
+ changed: true,
+ publishedAt: PUBLISHED_AT,
+ row: publishedRow,
+ });
+
+ const res = await app.request("/7/publish", { method: "POST" });
+
+ expect(res.status).toBe(200);
+ expect(search.index).toHaveBeenCalledTimes(1);
+ expect(search.index).toHaveBeenCalledWith(
+ expect.objectContaining({
+ content: "Excerpt.\n\nBody copy.",
+ createdAt: PUBLISHED_AT,
+ isPublic: true,
+ // Stamped by the route, so a rebuild reproduces the same ownership.
+ pluginId: PLUGIN_ID,
+ url: "/searchable/hello-world",
+ }),
+ );
+ });
+
+ it("does nothing when the record was already published", async () => {
+ const { app, search, service } = harness();
+ service.publish.mockResolvedValue({
+ changed: false,
+ publishedAt: PUBLISHED_AT,
+ row: publishedRow,
+ });
+
+ await app.request("/7/publish", { method: "POST" });
+
+ expect(search.index).not.toHaveBeenCalled();
+ expect(search.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("unpublish", () => {
+ it("deletes the document on a real transition", async () => {
+ const { app, search, service } = harness();
+ service.unpublish.mockResolvedValue({
+ changed: true,
+ publishedAt: PUBLISHED_AT,
+ row: { ...publishedRow, status: "draft" as const },
+ });
+
+ await app.request("/7/unpublish", { method: "POST" });
+
+ expect(search.delete).toHaveBeenCalledTimes(1);
+ expect(search.delete).toHaveBeenCalledWith("test.searchable", 7);
+ expect(search.index).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when the record was already a draft", async () => {
+ const { app, search, service } = harness();
+ service.unpublish.mockResolvedValue({
+ changed: false,
+ publishedAt: null,
+ row: draftRow,
+ });
+
+ await app.request("/7/unpublish", { method: "POST" });
+
+ expect(search.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("delete", () => {
+ it("deletes the document for a published record", async () => {
+ const { app, search, service } = harness();
+ service.delete.mockResolvedValue(publishedRow);
+
+ await app.request("/7", { method: "DELETE" });
+
+ expect(search.delete).toHaveBeenCalledWith("test.searchable", 7);
+ });
+
+ it("deletes defensively for a record that was published before", async () => {
+ const { app, search, service } = harness();
+ // `publishedAt` survives an unpublish, so this row was indexed once.
+ service.delete.mockResolvedValue({
+ ...publishedRow,
+ status: "draft" as const,
+ });
+
+ await app.request("/7", { method: "DELETE" });
+
+ expect(search.delete).toHaveBeenCalledWith("test.searchable", 7);
+ });
+
+ it("does nothing for a never-published draft", async () => {
+ const { app, search, service } = harness();
+ service.delete.mockResolvedValue(draftRow);
+
+ await app.request("/7", { method: "DELETE" });
+
+ expect(search.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("failure handling", () => {
+ it("keeps the mutation successful when the engine throws", async () => {
+ const { app, logged, service } = harness({ searchFails: true });
+ service.publish.mockResolvedValue({
+ changed: true,
+ publishedAt: PUBLISHED_AT,
+ row: publishedRow,
+ });
+
+ const res = await app.request("/7/publish", { method: "POST" });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({ changed: true });
+ expect(logged).toHaveLength(1);
+ expect(logged[0]).toContain("[content-search]");
+ expect(logged[0]).toContain("test.searchable");
+ expect(logged[0]).toContain("engine unavailable");
+ });
+
+ it("logs structured context", async () => {
+ const { app, logged, service } = harness({ searchFails: true });
+ service.delete.mockResolvedValue(publishedRow);
+
+ await app.request("/7", { method: "DELETE" });
+
+ const payload: unknown = JSON.parse(
+ logged[0].slice(logged[0].indexOf("{")),
+ );
+
+ expect(payload).toMatchObject({
+ action: "delete",
+ contentTypeId: "test.searchable",
+ documentId: "test.searchable:7",
+ itemId: 7,
+ itemType: "test.searchable",
+ operation: "delete",
+ pluginId: PLUGIN_ID,
+ });
+ });
+
+ it("writes no error log when synchronization succeeds", async () => {
+ const { app, logged, search, service } = harness();
+ service.publish.mockResolvedValue({
+ changed: true,
+ publishedAt: PUBLISHED_AT,
+ row: publishedRow,
+ });
+
+ const res = await app.request("/7/publish", { method: "POST" });
+
+ expect(res.status).toBe(200);
+ expect(search.index).toHaveBeenCalledTimes(1);
+ expect(logged).toEqual([]);
+ });
+
+ it("keeps the mutation successful when the logger fails too", async () => {
+ // The logger writes to the database, so it can be down for the same reason
+ // the search engine is. Both are best effort after a committed write.
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ try {
+ const { app, logged, service } = harness({
+ logFails: true,
+ searchFails: true,
+ });
+ service.publish.mockResolvedValue({
+ changed: true,
+ publishedAt: PUBLISHED_AT,
+ row: publishedRow,
+ });
+
+ const res = await app.request("/7/publish", { method: "POST" });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({ changed: true });
+
+ // The structured line was attempted, then the console stood in for it -
+ // still carrying the original search error, not the logger's.
+ expect(logged).toHaveLength(1);
+ expect(consoleError).toHaveBeenCalledTimes(1);
+ const fallback = String(consoleError.mock.calls[0][0]);
+ expect(fallback).toContain("Failed to log content search failure");
+ expect(fallback).toContain("engine unavailable");
+ expect(fallback).not.toContain("core_logs unavailable");
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("does not reach the console when only the engine fails", async () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ try {
+ const { app, service } = harness({ searchFails: true });
+ service.delete.mockResolvedValue(publishedRow);
+
+ const res = await app.request("/7", { method: "DELETE" });
+
+ expect(res.status).toBe(200);
+ expect(consoleError).not.toHaveBeenCalled();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+ });
+
+ describe("content types without search", () => {
+ it("never touches the search engine", async () => {
+ const { app, search, service } = harness({ model: plain });
+ service.publish.mockResolvedValue({
+ changed: true,
+ publishedAt: PUBLISHED_AT,
+ row: { ...publishedRow, category: 1 },
+ });
+ service.delete.mockResolvedValue({ ...publishedRow, category: 1 });
+
+ await app.request("/7/publish", { method: "POST" });
+ await app.request("/7", { method: "DELETE" });
+
+ expect(search.index).not.toHaveBeenCalled();
+ expect(search.delete).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts
new file mode 100644
index 000000000..5324af904
--- /dev/null
+++ b/packages/vitnode/src/content/server/search-sync.ts
@@ -0,0 +1,179 @@
+import type { Context } from "hono";
+
+import type { AnyContentTypeDefinition } from "../types";
+
+import {
+ contentSearchDocumentId,
+ contentSearchIndexedFieldNames,
+} from "../search";
+import { contentSearchDocument, isContentRowPublic } from "./search-document";
+
+/** Which mutation just returned. Not an event name: nothing is emitted here. */
+export type ContentSearchOperation =
+ "create" | "delete" | "publish" | "unpublish" | "update";
+
+export interface ContentSearchSyncInput {
+ /**
+ * `publish` / `unpublish` only: `false` when the record was already in the
+ * requested state, which means the index already agrees and there is nothing
+ * to do.
+ */
+ changed?: boolean;
+ /** `update` only. An update that touched no indexed field changes no document. */
+ changedFields?: readonly string[];
+ operation: ContentSearchOperation;
+ /**
+ * The plugin that owns the content type. Stamped on the document so a rebuild
+ * reproduces the same ownership; omit it and the request's plugin is used,
+ * which is only correct while the request belongs to the owner.
+ */
+ pluginId?: string;
+ /** The full row the mutation returned, including `status` and `publishedAt`. */
+ row: object;
+}
+
+export interface ContentSearchSyncOutcome {
+ action: "delete" | "skip" | "upsert";
+ /** `"example.article:7"` - for logs and diagnostics, never a storage key. */
+ documentId: string;
+ /** Set when the search engine threw. The mutation itself still succeeded. */
+ error?: Error;
+}
+
+/** What the row is, and what the index therefore has to hold. */
+const decide = (
+ definition: AnyContentTypeDefinition,
+ { changed, changedFields, operation, row }: ContentSearchSyncInput,
+ isPublic: boolean,
+): "delete" | "skip" | "upsert" => {
+ const values = row as Record;
+
+ if (operation === "delete") {
+ // `publishedAt` survives an unpublish, so this covers both "currently
+ // published" and "was published, is a draft now" - and skips a record that
+ // was never published, which was never indexed.
+ return values.publishedAt === null || values.publishedAt === undefined
+ ? "skip"
+ : "delete";
+ }
+
+ if (operation === "unpublish") return changed === true ? "delete" : "skip";
+
+ // An idempotent publish is a no-op for the same reason it emits no event:
+ // the document is already there and would be rewritten byte for byte.
+ if (operation === "publish") {
+ return changed === true && isPublic ? "upsert" : "skip";
+ }
+
+ if (operation === "create") return isPublic ? "upsert" : "skip";
+
+ // `update` cannot change `status`, so a draft stays a draft and a published
+ // record stays published. Nothing to delete, and nothing to write unless a
+ // field the document is built from actually moved.
+ if (!isPublic) return "skip";
+
+ const indexed = new Set(contentSearchIndexedFieldNames(definition));
+
+ return (changedFields ?? []).some(name => indexed.has(name))
+ ? "upsert"
+ : "skip";
+};
+
+/**
+ * Brings the search index in line with one content mutation.
+ *
+ * **Call it only once the database write has returned - never inside a
+ * transaction callback.** A rolled-back transaction would leave a document
+ * pointing at a record that does not exist, and the search index is not part of
+ * the transaction that could undo it. This is the same rule the Next cache
+ * invalidation follows, for the same reason.
+ *
+ * The generated admin routes call it for you. A direct `service.publish(id)`
+ * call does not, deliberately: it may be running inside a caller-provided
+ * transaction. Application code opts in explicitly, after commit:
+ *
+ * ```ts
+ * const result = await model.service(c).publish(id);
+ * if (result) {
+ * await syncContentSearch(c, articleContentType, {
+ * operation: "publish",
+ * changed: result.changed,
+ * row: result.row,
+ * });
+ * }
+ * ```
+ *
+ * A failing search engine never turns a successful write into a failed one. The
+ * error is logged with enough context to find the record, the outcome carries it
+ * for a caller that wants it, and a manual rebuild repairs the drift. That makes
+ * the index eventually consistent, with "eventually" bounded by the next publish
+ * or the next rebuild.
+ */
+export const syncContentSearch = async (
+ c: Context,
+ definition: AnyContentTypeDefinition,
+ input: ContentSearchSyncInput,
+): Promise => {
+ const values = input.row as Record;
+ const itemId = typeof values.id === "number" ? values.id : 0;
+ const documentId = contentSearchDocumentId(definition, itemId);
+
+ if (!definition.search.enabled || itemId === 0) {
+ return { action: "skip", documentId };
+ }
+
+ const decided = decide(definition, input, isContentRowPublic(input.row));
+ if (decided === "skip") return { action: decided, documentId };
+
+ // A publicly visible row the mapper will not build - a title that is only
+ // whitespace, say - has its document removed rather than left holding whatever
+ // text it was indexed with last time.
+ const document =
+ decided === "upsert"
+ ? contentSearchDocument(definition, input.row, {
+ pluginId: input.pluginId,
+ })
+ : null;
+ const action = decided === "upsert" && !document ? "delete" : decided;
+
+ try {
+ if (document) {
+ await c.get("search").index(document);
+ } else {
+ await c.get("search").delete(definition.id, itemId);
+ }
+
+ return { action, documentId };
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err));
+
+ // `c.get("log")` takes a string, so the context goes in as JSON behind a
+ // greppable prefix. The logger middleware adds the plugin id, path, method,
+ // user and timestamp on its way into `core_logs`.
+ const message = `[content-search] ${JSON.stringify({
+ action,
+ contentTypeId: definition.id,
+ documentId,
+ error: error.message,
+ itemId,
+ itemType: definition.id,
+ operation: input.operation,
+ pluginId: input.pluginId,
+ })}`;
+
+ // The logger writes to the database, so it can fail for the same reason the
+ // search engine just did. Both are best effort *after* a committed write, and
+ // neither may turn it into a failed request - so the fallback is the console,
+ // and the outcome keeps the original search error rather than this one.
+ try {
+ await c.get("log").error(message);
+ } catch {
+ // eslint-disable-next-line no-console
+ console.error(
+ `[VitNode] Failed to log content search failure: ${message}`,
+ );
+ }
+
+ return { action, documentId, error };
+ }
+};
diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts
index 2f2d4c236..dfcc4e4ba 100644
--- a/packages/vitnode/src/content/types.ts
+++ b/packages/vitnode/src/content/types.ts
@@ -3,6 +3,9 @@ import type {
CONTENT_PUBLIC_EXPOSABLE_COLUMNS,
CONTENT_PUBLICATION_FIELDS,
CONTENT_PUBLICATION_STATUSES,
+ CONTENT_SEARCH_DESCRIPTION_KINDS,
+ CONTENT_SEARCH_TEXT_KINDS,
+ CONTENT_SEARCH_TITLE_KINDS,
CONTENT_SYSTEM_FIELDS,
} from "./const";
import type { ContentSchemas } from "./schemas";
@@ -484,10 +487,146 @@ export interface ResolvedContentPublicApiConfig<
slugField: string;
}
+// ---------------------------------------------------------------------------
+// Search
+// ---------------------------------------------------------------------------
+
+/** Field names of one or more kinds, as a union. */
+type ContentFieldNamesOfKind = string &
+ {
+ [K in keyof TFields]: TFields[K] extends { kind: TKind } ? K : never;
+ }[keyof TFields];
+
+/** Field names of one or more kinds that also accept no `null`. */
+type ContentNonNullableFieldNamesOfKind<
+ TFields,
+ TKind extends string,
+> = string &
+ {
+ [K in keyof TFields]: TFields[K] extends { kind: TKind; nullable: false }
+ ? K
+ : never;
+ }[keyof TFields];
+
+/**
+ * Field names `search.titleField` accepts.
+ *
+ * Three rules, one `Extract`: `TPublicField` is the public allowlist, so a field
+ * that is not published cannot be indexed; the kind union keeps prose out of the
+ * title slot; and the field cannot be nullable, because a `null` heading is not a
+ * search result. That is why a private field is a compile error and not a lint.
+ */
+export type ContentSearchTitleField<
+ TFields,
+ TPublicField extends string,
+> = Extract<
+ TPublicField,
+ ContentNonNullableFieldNamesOfKind<
+ TFields,
+ (typeof CONTENT_SEARCH_TITLE_KINDS)[number]
+ >
+>;
+
+export type ContentSearchDescriptionField<
+ TFields,
+ TPublicField extends string,
+> = Extract<
+ TPublicField,
+ ContentFieldNamesOfKind<
+ TFields,
+ (typeof CONTENT_SEARCH_DESCRIPTION_KINDS)[number]
+ >
+>;
+
+export type ContentSearchTextField<
+ TFields,
+ TPublicField extends string,
+> = Extract<
+ TPublicField,
+ ContentFieldNamesOfKind
+>;
+
+/**
+ * Opts a content type into automatic search synchronization.
+ *
+ * Requires `publication` *and* `publicApi`: only published rows are ever
+ * indexed, and every indexed field has to be publicly readable already - a
+ * private value would otherwise leak through a result snippet, a highlighted
+ * match, ranking, or the mere fact that a record matched an exact-match probe.
+ *
+ * `enabled` is literal `true` for the same reason publication's and publicApi's
+ * are: a widened `boolean` would silently resolve to "no search".
+ *
+ * Generic over the three field-name *unions* rather than over the field map, so
+ * `defineContentType` can infer each one from the literal it was given and then
+ * check it against `ContentSearchTitleField` and friends. Spelling those out
+ * inside the property types instead looks equivalent and is not: `TPublicField`
+ * falls back to its constraint while the argument that infers it is still being
+ * checked, and a private field name would slip through.
+ */
+export interface ContentSearchConfig<
+ TTitle extends string = string,
+ TDescription extends string = string,
+ TText extends string = string,
+> {
+ /** Concatenated into the indexed body, in order. At least one. */
+ contentFields: readonly [TText, ...TText[]];
+ /** Prepended to the indexed body so it shows up in result excerpts. */
+ descriptionField?: TDescription;
+ enabled: true;
+ /**
+ * The public URL of one record, e.g. `/articles/{slug}`. Relative, and
+ * `{slug}` - the exposed slug field - is the only placeholder.
+ */
+ pathTemplate: string;
+ /** The result heading. Weighted above the body by the index. */
+ titleField: TTitle;
+}
+
+/**
+ * Whether a `search` argument opted in.
+ *
+ * `defineContentType` infers the whole `search` object as one type parameter -
+ * an intersection member like `{ enabled: TEnabled }` is not an inference site,
+ * so reading the literal back off the argument is the only way to keep it.
+ */
+export type ContentSearchEnabled = TSearch extends { enabled: true }
+ ? true
+ : false;
+
+/**
+ * `search` after `defineContentType` has filled in every default.
+ *
+ * Generic over `enabled` for the same reason `publication` and `publicApi` are:
+ * a widened `boolean` would make every definition equally (un)searchable, so
+ * `SearchableContentTypeDefinition` would only ever match after a cast.
+ */
+export interface ResolvedContentSearchConfig<
+ TEnabled extends boolean = boolean,
+> {
+ contentFields: string[];
+ descriptionField: null | string;
+ enabled: TEnabled;
+ pathTemplate: string;
+ titleField: string;
+}
+
// ---------------------------------------------------------------------------
// Definition
// ---------------------------------------------------------------------------
+/**
+ * A content type whose records are synchronized with the search index.
+ *
+ * An intersection rather than a sixth type argument, for the same reason
+ * {@link PublicContentTypeDefinition} is one: `enabled` is the only thing a
+ * caller of the search layer needs pinned, and narrowing just that keeps every
+ * concrete definition assignable.
+ */
+export type SearchableContentTypeDefinition = AnyContentTypeDefinition & {
+ search: { enabled: true };
+};
+
/**
* A content type that actually has a generated public API.
*
@@ -511,6 +650,7 @@ export interface ContentTypeDefinition<
TPublication extends boolean = boolean,
TPublicField extends string = string,
TPublicEnabled extends boolean = boolean,
+ TSearchEnabled extends boolean = boolean,
> {
admin: ResolvedContentAdminConfig;
fields: TFields;
@@ -528,9 +668,12 @@ export interface ContentTypeDefinition<
TFields,
TPublication,
TPublicField,
- TPublicEnabled
+ TPublicEnabled,
+ TSearchEnabled
>
>;
+ /** Search synchronization, or the disabled default when `search` is omitted. */
+ search: ResolvedContentSearchConfig;
tableName: string;
}
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index b6c3bf39b..c0c9c3bed 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -83,6 +83,10 @@
"rebuildError": "Could not queue the rebuild.",
"reindex": "Reindex",
"reindexQueued": "Reindex queued for {collection}.",
+ "syncErrors": {
+ "title": "Recent sync failures",
+ "desc": "These records were saved, but their search documents weren't updated. Rebuild the index to repair them."
+ },
"cron": {
"title": "Background jobs require a configured cron adapter",
"desc": "A rebuild won't run until cron is active. Configure an adapter in Integrations to enable scheduled reindexing.",
@@ -113,8 +117,17 @@
"status": {
"indexed": "Indexed",
"stale": "Stale",
- "empty": "Not indexed"
+ "empty": "Not indexed",
+ "unmanaged": "Unmanaged"
},
+ "noIndexer": "No rebuild indexer",
+ "noIndexerDesc": "No rebuild indexer is registered. Documents may still be maintained by live plugin writes.",
+ "removeDocuments": "Remove documents",
+ "removeConfirmTitle": "Remove indexed documents for \u201c{collection}\u201d?",
+ "removeConfirmDescription": "No rebuild indexer is registered for this collection. Its documents may still be maintained by live plugin writes. This removes their current indexed state, and future writes may add them again.",
+ "removeSuccess": "Removed the indexed documents for {collection}.",
+ "removeSuccessDesc": "Live plugin writes may recreate them.",
+ "removeError": "Could not remove the indexed documents.",
"empty": "No collections found",
"emptyDesc": "No collection matches your search. Try a different term."
}
diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts
index 162879350..7ee988755 100644
--- a/packages/vitnode/src/tests/content-fixtures.ts
+++ b/packages/vitnode/src/tests/content-fixtures.ts
@@ -97,3 +97,44 @@ export const testPostContentType = defineContentType({
},
},
});
+
+/**
+ * The Stage 3 shape: `testPostContentType` plus `search`.
+ *
+ * A separate fixture rather than a flag on the post: keeping the post exactly as
+ * it was is what proves a Stage 2 content type is untouched by search existing.
+ * `code` and `author` stay out of `publicApi.fields` so "an indexed field is a
+ * public field" has something to be wrong about.
+ */
+export const testSearchablePostContentType = defineContentType({
+ id: "test.searchable",
+ tableName: "test_searchable_posts",
+ fields: {
+ title: field.text({ required: true, minLength: 3, maxLength: 200 }),
+ slug: field.slug({ source: "title" }),
+ excerpt: field.textarea({ maxLength: 500, nullable: true }),
+ body: field.textarea({ nullable: true }),
+ code: field.text({ required: true, maxLength: 100 }),
+ views: field.number({ integer: true, min: 0, defaultValue: 0 }),
+ author: field.user(),
+ },
+ publication: { enabled: true },
+ publicApi: {
+ enabled: true,
+ path: "searchable",
+ fields: ["title", "slug", "excerpt", "body", "publishedAt"],
+ defaultOrderBy: "publishedAt",
+ },
+ search: {
+ enabled: true,
+ titleField: "title",
+ descriptionField: "excerpt",
+ contentFields: ["excerpt", "body"],
+ pathTemplate: "/searchable/{slug}",
+ },
+ admin: {
+ label: { plural: "Test Searchables", singular: "Test Searchable" },
+ titleField: "title",
+ list: { defaultOrderBy: "publishedAt" },
+ },
+});
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-label.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-label.ts
new file mode 100644
index 000000000..69dadb6da
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-label.ts
@@ -0,0 +1,18 @@
+import { getFrontendContentTypes } from "@/content/admin/config";
+
+/**
+ * Human labels for the collections a Content Engine content type contributes.
+ *
+ * A generated indexer uses the content type id as its item type
+ * (`example.article`), and the renderer registry in `views/search/registry` is a
+ * hardcoded core map - so without this every content collection would read
+ * "Content". Resolved from the frontend registry, which is why this is called
+ * from the server component and not from the shared table.
+ */
+export const getContentCollectionLabels = (): Map =>
+ new Map(
+ getFrontendContentTypes().map(({ definition }) => [
+ definition.id,
+ definition.admin.label.plural,
+ ]),
+ );
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.test.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.test.ts
index 4c29342c0..1c7bd2bf0 100644
--- a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.test.ts
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.test.ts
@@ -2,39 +2,118 @@ import { describe, expect, it } from "vitest";
import {
getCollectionCoverage,
+ getCollectionCoverageBar,
getCollectionStatus,
} from "./collection-status";
+/** A registered collection: an indexer exists, so `total` is a real count. */
+const registered = (indexed: number, total: number) => ({
+ hasIndexer: true,
+ indexed,
+ total,
+});
+
+/**
+ * A collection with no rebuild indexer, and so no source count. It may still be
+ * kept current by live `search.index()` writes - that is why the status only
+ * claims it is outside the rebuild system.
+ */
+const unmanaged = (indexed: number) => ({
+ hasIndexer: false,
+ indexed,
+ total: null,
+});
+
describe("getCollectionStatus", () => {
it("reports empty when nothing is indexed", () => {
- expect(getCollectionStatus({ indexed: 0, total: 12 })).toBe("empty");
- expect(getCollectionStatus({ indexed: 0, total: 0 })).toBe("empty");
+ expect(getCollectionStatus(registered(0, 0))).toBe("empty");
+ expect(getCollectionStatus(registered(0, 10))).toBe("empty");
});
it("reports stale when fewer items are indexed than the source has", () => {
- expect(getCollectionStatus({ indexed: 6, total: 8 })).toBe("stale");
+ expect(getCollectionStatus(registered(5, 10))).toBe("stale");
+ });
+
+ it("reports indexed only when the counts match exactly", () => {
+ expect(getCollectionStatus(registered(10, 10))).toBe("indexed");
});
- it("reports indexed when coverage is complete", () => {
- expect(getCollectionStatus({ indexed: 2, total: 2 })).toBe("indexed");
+ it("reports stale when more items are indexed than the source has", () => {
+ // Documents surviving for records that no longer qualify. Calling this
+ // healthy is how a stale index stays invisible.
+ expect(getCollectionStatus(registered(11, 10))).toBe("stale");
+ expect(getCollectionStatus(registered(10, 0))).toBe("stale");
});
- it("never reports stale when the indexed count exceeds the source count", () => {
- expect(getCollectionStatus({ indexed: 5, total: 3 })).toBe("indexed");
+ it("never calls an over-indexed collection healthy", () => {
+ for (const indexed of [1, 2, 11, 100]) {
+ expect(getCollectionStatus(registered(indexed, 0))).not.toBe("indexed");
+ }
+ expect(getCollectionStatus(registered(11, 10))).not.toBe("indexed");
+ });
+
+ describe("collections with no rebuild indexer", () => {
+ it("reports unmanaged when documents have no indexer", () => {
+ expect(getCollectionStatus(unmanaged(11))).toBe("unmanaged");
+ });
+
+ it("decides on indexer availability before comparing counts", () => {
+ // The trap this exists for: the old `total = indexed` fallback made
+ // `indexed === total` true, so an unrebuildable collection read as healthy.
+ expect(
+ getCollectionStatus({ hasIndexer: false, indexed: 11, total: 11 }),
+ ).toBe("unmanaged");
+ expect(
+ getCollectionStatus({ hasIndexer: false, indexed: 1, total: 1 }),
+ ).toBe("unmanaged");
+ });
+
+ it("reports empty rather than unmanaged when there is nothing indexed", () => {
+ // Nothing indexed, so nothing to say about how it would be rebuilt.
+ expect(getCollectionStatus(unmanaged(0))).toBe("empty");
+ });
});
});
describe("getCollectionCoverage", () => {
it("returns a whole-percent ratio of indexed to total", () => {
- expect(getCollectionCoverage({ indexed: 6, total: 8 })).toBe(75);
- expect(getCollectionCoverage({ indexed: 2, total: 2 })).toBe(100);
+ expect(getCollectionCoverage(registered(5, 10))).toBe(50);
+ expect(getCollectionCoverage(registered(10, 10))).toBe(100);
});
it("returns 0 when there is nothing to cover", () => {
- expect(getCollectionCoverage({ indexed: 0, total: 0 })).toBe(0);
+ expect(getCollectionCoverage(registered(0, 0))).toBe(0);
+ expect(getCollectionCoverage(registered(0, 10))).toBe(0);
});
it("rounds to the nearest percent", () => {
- expect(getCollectionCoverage({ indexed: 1, total: 3 })).toBe(33);
+ expect(getCollectionCoverage(registered(1, 3))).toBe(33);
+ });
+
+ it("reports past 100 rather than hiding an over-indexed collection", () => {
+ expect(getCollectionCoverage(registered(11, 10))).toBe(110);
+ expect(getCollectionCoverage(registered(10, 0))).toBe(100);
+ });
+
+ it("returns null when there is no source count", () => {
+ // Not 100: there is nothing to be complete against.
+ expect(getCollectionCoverage(unmanaged(11))).toBeNull();
+ expect(getCollectionCoverage(unmanaged(0))).toBeNull();
+ });
+});
+
+describe("getCollectionCoverageBar", () => {
+ it("clamps the drawn width to the track", () => {
+ expect(getCollectionCoverageBar(registered(11, 10))).toBe(100);
+ expect(getCollectionCoverageBar(registered(200, 10))).toBe(100);
+ });
+
+ it("matches the measured coverage below the cap", () => {
+ expect(getCollectionCoverageBar(registered(5, 10))).toBe(50);
+ expect(getCollectionCoverageBar(registered(0, 10))).toBe(0);
+ });
+
+ it("draws nothing without a source count", () => {
+ expect(getCollectionCoverageBar(unmanaged(11))).toBeNull();
});
});
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts
index eb9a75628..875d985e8 100644
--- a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts
@@ -1,27 +1,67 @@
export interface SearchCollection {
+ /** Whether a rebuild indexer is registered for this item type right now. */
+ hasIndexer: boolean;
indexed: number;
itemType: string;
lastIndexedAt: Date | null | string;
pluginId: string;
- total: number;
+ /** Source items the indexer reports; `null` when there is no indexer. */
+ total: null | number;
}
-export type CollectionStatus = "empty" | "indexed" | "stale";
+export type CollectionStatus = "empty" | "indexed" | "stale" | "unmanaged";
-// Nothing indexed yet, partially indexed (fewer items than the source has), or
-// fully covered - the three states the coverage report distinguishes.
+/**
+ * Nothing indexed, exactly covered, out of step, or outside the rebuild system.
+ *
+ * "Out of step" is any mismatch, in **either** direction. Fewer documents than
+ * source records means something was missed; more means documents survive for
+ * records that no longer qualify - and calling that one healthy is how a stale
+ * index stays invisible.
+ *
+ * "Unmanaged" comes first and does not look at the counts at all: without an
+ * indexer there is no source to compare against, so `indexed` matching `total`
+ * would only mean the fallback matched itself. It says nothing about the plugin -
+ * registering an indexer is optional, and a plugin that only ever calls
+ * `search.index()` keeps its collection perfectly current without one. All that
+ * is known is that a rebuild cannot reproduce it.
+ */
export const getCollectionStatus = ({
+ hasIndexer,
indexed,
total,
-}: Pick): CollectionStatus => {
+}: Pick<
+ SearchCollection,
+ "hasIndexer" | "indexed" | "total"
+>): CollectionStatus => {
+ if (!hasIndexer && indexed > 0) return "unmanaged";
if (indexed === 0) return "empty";
- if (indexed < total) return "stale";
+ if (indexed === total) return "indexed";
- return "indexed";
+ return "stale";
};
+/**
+ * Indexed items as a percentage of source items, or `null` when there is no
+ * source count to divide by.
+ *
+ * Can exceed 100 - that is the point, and the number is shown as it is. Use
+ * {@link getCollectionCoverageBar} for the width of anything drawn.
+ */
export const getCollectionCoverage = ({
indexed,
total,
-}: Pick): number =>
- total > 0 ? Math.round((indexed / total) * 100) : 0;
+}: Pick): null | number => {
+ if (total === null) return null;
+ if (total > 0) return Math.round((indexed / total) * 100);
+
+ return indexed > 0 ? 100 : 0;
+};
+
+export const getCollectionCoverageBar = (
+ collection: Pick,
+): null | number => {
+ const coverage = getCollectionCoverage(collection);
+
+ return coverage === null ? null : Math.min(coverage, 100);
+};
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table.tsx
index d55bc2ce0..1842aa4ea 100644
--- a/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table.tsx
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table.tsx
@@ -19,9 +19,11 @@ import type { CollectionStatus, SearchCollection } from "./collection-status";
import {
getCollectionCoverage,
+ getCollectionCoverageBar,
getCollectionStatus,
} from "./collection-status";
import { ReindexCollectionAction } from "./reindex-action";
+import { RemoveCollectionDocumentsAction } from "./remove-documents-action";
interface CollectionRow extends SearchCollection {
id: number;
@@ -47,13 +49,21 @@ const statusStyles: Record<
dot: "bg-muted-foreground/50",
text: "text-muted-foreground",
},
+ unmanaged: {
+ bar: "bg-destructive",
+ dot: "bg-destructive",
+ text: "text-destructive",
+ },
};
export const CollectionsTable = async ({
collections,
+ labels,
search,
}: {
collections: SearchCollection[];
+ /** Content type id -> label, for collections the renderer registry has no entry for. */
+ labels?: Map;
search?: string;
}) => {
const t = await getTranslations("core.search");
@@ -62,7 +72,9 @@ export const CollectionsTable = async ({
.map((collection, index) => ({
...collection,
id: index,
- label: t(getSearchTypeRenderer(collection.itemType).labelKey),
+ label:
+ labels?.get(collection.itemType) ??
+ t(getSearchTypeRenderer(collection.itemType).labelKey),
}))
.sort((a, b) => b.indexed - a.indexed || a.label.localeCompare(b.label));
@@ -98,7 +110,19 @@ export const CollectionsTable = async ({
>
{t(`admin.collections.status.${status}`)}
+
+ ·
+ {row.pluginId}
+
+ {status === "unmanaged" && (
+ // Says only what is known. A plugin may index live and never
+ // register a rebuild indexer, so "unmanaged" is about the
+ // rebuild system - not about the plugin being gone.
+
+ {t("admin.collections.noIndexerDesc")}
+
+ )}
);
@@ -110,7 +134,10 @@ export const CollectionsTable = async ({
cell: ({ row }) => (
{row.indexed}
- / {row.total}
+ {/* An em dash, not `indexed`: with no indexer there is no source count
+ to compare against, and repeating the left number would read as
+ full coverage. */}
+ / {row.total ?? "—"}
),
},
@@ -120,17 +147,28 @@ export const CollectionsTable = async ({
className: "w-52",
cell: ({ row }) => {
const coverage = getCollectionCoverage(row);
+ const bar = getCollectionCoverageBar(row);
const styles = statusStyles[getCollectionStatus(row)];
+ if (coverage === null || bar === null) {
+ return (
+
+ {t("admin.collections.noIndexer")}
+
+ );
+ }
+
return (
-
+
{coverage}%
@@ -154,9 +192,17 @@ export const CollectionsTable = async ({
id: "actions",
header: "",
align: "right",
- cell: ({ row }) => (
-
- ),
+ cell: ({ row }) =>
+ row.hasIndexer ? (
+
+ ) : (
+ // Rebuilding this would clear it and refill nothing, so the only offer
+ // is the honest one: remove what is currently indexed.
+
+ ),
},
];
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/mutation-api.server.ts
index 91add929b..471d5aaf6 100644
--- a/packages/vitnode/src/views/admin/views/core/advanced/search/mutation-api.server.ts
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/mutation-api.server.ts
@@ -20,3 +20,26 @@ export const rebuildSearchIndexMutation = async (itemType?: string) => {
return { data: await res.json() };
};
+
+/**
+ * Removes the documents of a collection with no rebuild indexer. Destructive:
+ * nothing *rebuilds* them afterwards, though the owning plugin may write them
+ * again live. The API refuses it for any collection that has an indexer.
+ */
+export const clearSearchCollectionMutation = async (itemType: string) => {
+ const res = await fetcher(debugAdminModule, {
+ prefixPath: "/admin",
+ path: "/search/clear",
+ method: "post",
+ module: "debug",
+ args: {
+ body: { itemType },
+ },
+ });
+
+ if (!res.ok) {
+ return { error: await res.text() };
+ }
+
+ return { data: await res.json() };
+};
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx
new file mode 100644
index 000000000..50296f3c0
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { Trash2Icon } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+
+import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog";
+import { Button } from "@/components/ui/button";
+import { useRouter } from "@/lib/navigation";
+
+import { clearSearchCollectionMutation } from "./mutation-api.server";
+
+/**
+ * The only way to clear a collection with no rebuild indexer, and deliberately
+ * not called "reindex": nothing *rebuilds* these documents afterwards.
+ *
+ * They may still come back. Registering an indexer is optional, so the owning
+ * plugin may be writing them live through `search.index()` - which is why the
+ * confirmation says the current indexed state is what goes, and not that the
+ * documents are gone for good.
+ */
+export const RemoveCollectionDocumentsAction = ({
+ itemType,
+ label,
+}: {
+ itemType: string;
+ label: string;
+}) => {
+ const t = useTranslations("core.search.admin.collections");
+ const router = useRouter();
+
+ return (
+ {
+ const result = await clearSearchCollectionMutation(itemType);
+
+ if (result.error) {
+ toast.error(t("removeError"), { description: label });
+
+ return;
+ }
+
+ toast.success(t("removeSuccess", { collection: label }), {
+ description: t("removeSuccessDesc"),
+ });
+ onClose();
+ router.refresh();
+ }}
+ submitVariant="destructive"
+ textSubmit={t("removeDocuments")}
+ title={t("removeConfirmTitle", { collection: label })}
+ >
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/search-view.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/search-view.tsx
index 58fc12f0b..c27e4c2d4 100644
--- a/packages/vitnode/src/views/admin/views/core/advanced/search/search-view.tsx
+++ b/packages/vitnode/src/views/admin/views/core/advanced/search/search-view.tsx
@@ -15,8 +15,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { fetcher } from "@/lib/fetcher";
import { cn } from "@/lib/utils";
+import { getContentCollectionLabels } from "./collection-label";
import { CollectionsTable } from "./collections-table";
import { CronWarning } from "./cron-warning";
+import { SyncErrorsCard } from "./sync-errors-card";
const getStatus = async () => {
const res = await fetcher(debugAdminModule, {
@@ -78,6 +80,11 @@ export const SearchAdminView = async ({
searchParams,
]);
+ // Content Engine collections use the content type id as their item type, which
+ // the renderer registry has no entry for - resolve their labels here, where the
+ // frontend content type registry is readable.
+ const labels = getContentCollectionLabels();
+
return (