Skip to content

feat: Add content engine Stage 3 — Search - #735

Open
aXenDeveloper wants to merge 18 commits into
feat/Universal-Content-Engine-mvp-2from
feat/Universal-Content-Engine-mvp-3
Open

feat: Add content engine Stage 3 — Search#735
aXenDeveloper wants to merge 18 commits into
feat/Universal-Content-Engine-mvp-2from
feat/Universal-Content-Engine-mvp-3

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

Description

What?

Why?

@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vitnode-docs Ready Ready Preview Aug 4, 2026 9:13pm

aXenDeveloper and others added 6 commits August 4, 2026 19:30
`SearchModel` derived a document's owner from `c.get("plugin")`, and a rebuild
runs inside the core cron request - so the same record was stored as
`@vitnode/example` when a mutation route indexed it and `core` when a rebuild
did. The Elasticsearch adapter made it worse by hardcoding `pluginId: "core"`
in `toSource`, so the mirrored document could disagree with the canonical row.

`SearchDocument` gains an optional `pluginId`, and ownership is resolved in one
place - `SearchModel.resolveOwner` - as document, then request, then `"core"`.
An explicit owner now wins over the request, `pluginId` joins the conflict
`set` so a rebuild can repair a row written before its indexer declared one,
and the adapter serializes what it was given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SearchIndexer.load` returned documents only, and the rebuild treated an empty
array as end-of-source. A page can read rows and project none of them - every
row on it published with an unusable title, say - so the rebuild stopped there
and never reached the valid records behind it.

`load` now returns `{ documents, itemsRead }`. The rebuild advances its cursor
by `itemsRead` and terminates only on `itemsRead === 0`, which also keeps
multi-document-per-item indexers correct: a document count was never a source
count. Every implementation in the repository is updated, so there is no
transitional union to remove later.

The rebuild also stamps the registering plugin on any document that names no
owner, and generated indexers carry their plugin id into the mapper - the queue
drains inside the core cron request, where the request's plugin is not the
owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status route raised a collection's source total to `Math.max(total,
indexed)` and the UI called anything with `indexed >= total` healthy. So 10
documents for 9 published records was rewritten to 10/10, 100%, "Indexed" -
the one state that most needs attention was the one guaranteed to be hidden.

Both counts are now reported as measured, and a collection is "Indexed" only
when they match exactly; any mismatch in either direction is stale. Coverage can
read past 100% because that is the signal, while the progress bar is clamped so
it cannot draw past its track.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ResolvedContentSearchConfig.enabled` was `boolean`, so a searchable definition
only satisfied `SearchableContentTypeDefinition` after an `as` - and the type
test asserted the cast rather than the behaviour.

`defineContentType` now infers the whole `search` argument as one type parameter
and reads the literal back off it, so `enabled` resolves to `true` or `false`
and the public type needs no assertion. Inferring the object rather than its
parts is what makes this work: an intersection member is not an inference site,
and the field rules stay in the parameter's *constraint*, which is checked once
`TPublicField` is resolved.

`titleField` also has to be non-nullable now, at compile time and at runtime: a
`null` heading is not a search result, and a record without one is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`syncContentSearch` caught the search engine's error and then awaited
`c.get("log").error(...)`, which writes to the database - so a logger that was
down for the same reason the engine was turned a committed content write into an
HTTP 500. That is exactly the guarantee the feature documents it keeps.

Logging is now wrapped the way `LocalEventsAdapter` wraps its own, falling back
to the console, and the returned outcome still carries the original search error
rather than the logger's.

The Postgres suite also gains a case for a published row the mapper cannot
project: page one reads it and yields nothing, and `itemsRead` is what carries
the rebuild through to the valid rows behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents plugin ownership across live sync and both rebuild shapes, the
difference between source rows and produced documents, why an empty document
page does not end a rebuild, the real meaning of the AdminCP counts (including
over-indexed as stale, and malformed data as under-indexed), and that a logger
failure is as harmless to the mutation as a search engine failure.

States plainly that there is no durable retry mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aXenDeveloper and others added 3 commits August 4, 2026 20:49
Stage 3 changed `load` to return `SearchIndexerPage`, which is the right contract
but broke every external plugin using the documented public import - a bare
`SearchDocument[]` no longer compiled.

`load` now returns `SearchIndexerLoadResult`, and one helper decides what a
result means. A page passes through; a non-empty array reports the requested
limit, because that is what the old rebuild advanced by and an array carries no
source count - `documents.length` would skip rows for any indexer that emits
several documents per item. An empty array is the only end signal it has, which
is exactly why the array form cannot express "rows read, none projected" and is
deprecated rather than merely older.

`ContentSearchIndexer` pins the generated adapter to the page result, so the
widened contract does not weaken what the engine itself guarantees. A blank
`pluginId` is now treated as absent everywhere ownership is resolved, through a
shared `searchDocumentOwner`, since it became public input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A collection with documents but no registered indexer was reported as owned by
core, because `indexer?.pluginId ?? "core"` had no other source to consult. The
rows themselves know better: they carry the plugin that wrote them.

The coverage query now selects `pluginId` alongside the counts, and ownership
resolves as registered indexer, then stored owner, then `"unknown"`. The
registered indexer stays canonical - it is what the next rebuild will stamp on
the rows - so a disagreement resolves in its favour rather than reporting a
mismatch nobody can act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the preferred page result and what `itemsRead` counts, the deprecated
array result and the one guarantee it cannot make, and how the AdminCP names the
owner of a collection whose indexer is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aXenDeveloper and others added 4 commits August 4, 2026 22:34
`total` fell back to the indexed count when no indexer was registered, so a
collection nothing can rebuild reported 11/11, 100%, "Indexed" - the fallback
matching itself read as full coverage.

The status response now carries `hasIndexer`, taken from whether an indexer was
found and from nothing else: rows knowing which plugin wrote them says nothing
about whether anything can write them again. `total` is `null` when there is no
indexer to ask, so coverage is absent rather than invented, and the UI gains an
`orphaned` status that is decided before the counts are compared.

An indexer without the optional `count` still falls back to the indexed count -
that is the documented meaning of leaving `count` out, and it is a registered
collection either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Reindex` on an orphaned collection deleted it: the task cleared the item type
and then rebuilt nothing, because the filtered indexer list was empty.

The rebuild route now answers 404 for an item type no indexer claims, and the
task refuses it before `search.clear` runs. Both checks are needed - the route
gives the AdminCP an immediate, explainable failure, while the task also covers
direct queue dispatches and a job queued while the indexer was still registered.

A full rebuild is untouched: it clears the whole index and refills every
registered indexer, so orphaned documents are still removed by it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With a scoped rebuild now refused for a collection that has no indexer, the only
way left to remove its stale documents was a full rebuild of everything. That is
too blunt, so orphaned rows get their own action.

`POST /search/clear` deletes one collection's documents and requires a non-empty
`itemType`, so no payload clears the index by omission. It refuses a collection
that still has an indexer with a 409: that one has a rebuild, which reaches the
same freshness without deleting anything.

In the AdminCP an orphaned row shows `Remove documents` behind a confirmation
that says the documents cannot be rebuilt, instead of `Reindex`; its coverage
cell explains that no indexer is registered rather than drawing a bar, and the
status reads destructive rather than muted. Registered rows keep `Reindex`
exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Defines what makes a collection orphaned, what the AdminCP shows for one, and the
distinction that matters: a scoped rebuild requires a registered indexer and never
clears what it cannot rebuild, while a full rebuild clears everything and so does
remove orphaned documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aXenDeveloper and others added 3 commits August 4, 2026 23:03
Registering a `SearchIndexer` is optional - `searchIndexers?` on the plugin API,
and `search.index()` needs nothing else. A plugin that indexes live and registers
no indexer is valid and may be keeping its collection perfectly current, but the
AdminCP labelled it "Orphaned" and told the administrator its plugin had been
uninstalled and that nothing could rebuild its documents. The first claim was
unfounded; the second was misleading, because the next live write recreates them.

The status is now `unmanaged`, and every label, comment and description says only
what is known: no rebuild indexer is registered, so a rebuild cannot reproduce the
collection. It no longer speculates about the plugin.

The model is unchanged - `hasIndexer` from indexer lookup alone, `total: null` with
no indexer, `11 / —` and no percentage, and the status decided before the counts
are compared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`POST /search/clear` awaited `c.get("log").warn` after the documents were already
gone. The logger writes to the database, so a logger outage turned a cleanup that
had happened into a failed request - sending an administrator to look for
documents that were no longer there.

Logging is now wrapped with a console fallback, the same shape the events adapter
and the content search sync use, and the response stays `{ cleared: true }`. A
failed `clear` still propagates, and it writes no success audit.

The route's copy also stops calling the collection orphaned: it removes the
current indexed state of a collection with no rebuild indexer, and a live-writing
plugin may recreate those documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`search.index()` and a registered `SearchIndexer` are two independent
capabilities: the first makes a collection searchable, the second makes it
rebuildable. Documenting only the pair left live-only plugins looking broken.

Defines "unmanaged by the rebuild system" as what it is - documents with no
registered indexer - states plainly that this does not prove the plugin is gone,
and spells out the operational consequence: a full rebuild recreates only
collections that have an indexer, so live-only ones are removed by it and return
only when their plugin writes again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aXenDeveloper aXenDeveloper changed the title feat: Add Content Engine Stage 3 feat: Add content engine Stage 3 — Search Aug 4, 2026
@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Aug 4, 2026
@aXenDeveloper
aXenDeveloper marked this pull request as ready for review August 4, 2026 21:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a23614cc4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

);
}

if (rest.includes("//") || template.includes("..") || /\s/.test(template)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't reject slug placeholders in middle segments

When a valid template puts {slug} between path segments, e.g. /docs/{slug}/comments, replacing the placeholder with an empty string makes rest contain //, so this check throws even though contentSearchUrl would produce a valid URL such as /docs/my-post/comments. This prevents searchable content types from using nested detail routes; check for empty segments on the actual template parts rather than on the string after deleting the placeholder.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant