Byline CMS
  • Home
  • Docs
  • About
Byline CMS
View on GitHub
  • Getting Started
    • CLI
    • Development environment and example application
    • Upgrading from 3.21 to 4.x
  • Why Byline
    • Mission & Vision
    • Content Management in the Time of AI
  • Key Architectural Decisions
    • Core Document Storage
    • Core Composition
    • Transactions
    • Path Grammar
    • Deployment Topologies
  • Collections
    • Fields
    • Blocks
    • Relationships
    • Document Trees
    • Document Paths
    • File / Media Uploads
    • Rich Text Editor
    • Collection Versioning
  • Reading & Delivery
    • Search & Document Extraction
    • Client SDK (@byline/client)
    • Routing & API
    • Transports
    • Markdown Export
    • MCP Server
    • Caching
  • Search
    • Configure search
    • Indexing and reindexing
    • Search API
    • Search provider contract
    • Portable multilingual search analysis
    • PostgreSQL and MySQL search providers
    • Attachment extraction for search
  • Auth & Security
    • Authentication & Authorization
    • Auditability
  • Internationalization (i18n)
    • The host i18n system
    • Admin interface translations
    • Content locales
    • Administering content locales
  • Admin UI
    • UI Kit (@byline/ui)
    • Client-config registration
  • Testing
  • Home

Indexing and reindexing

Companions:

  • Configure search — provider and collection configuration must exist before lifecycle synchronization can write projections.
  • Provider contract — upsert, remove, and reindex are the provider operations used here.
  • Transactions — search hooks run after the content transaction commits.
  • Authentication and authorization — reindex() asserts the collection's reindex ability.

Byline treats the search index as a published-content projection, not as content storage. This page is for application developers wiring lifecycle hooks and for operators planning migrations, deploys, and rebuilds.

Published-only synchronization

CollectionHandle.indexDocument(documentId) re-reads the document once for each configured content locale with:

{
status: 'published',
onMissingLocale: 'omit',
_bypassBeforeRead: true,
}

For every locale, it performs one of two provider operations:

  • upsert(SearchDocument) when a published locale view exists;
  • remove({ collectionPath, documentId, locale }) when it does not.

The same idempotent path handles a first publish, an edit over an existing publication, an unpublish, a newer draft over an older published version, and a translation becoming available or unavailable. Draft-only content does not enter the built-in index.

Index maintenance bypasses beforeRead because the shared index must contain every published candidate. Actor-specific row visibility is applied after ranking when a reader searches.

Wire collection hooks

Use the system client from server-only hooks. The system client does not depend on request cookies or a TanStack Start request context.

Edit: apps/webapp/byline/collections/<name>/hooks.ts

import { getSystemBylineClient } from '@byline/client/server'
import { defineHooks } from '@byline/core'
const search = () => getSystemBylineClient().collection('docs')
export default defineHooks({
afterCreate: ({ documentId }) => search().indexDocument(documentId),
afterUpdate: ({ documentId }) => search().indexDocument(documentId),
afterStatusChange: ({ documentId }) => search().indexDocument(documentId),
afterUnpublish: ({ documentId }) => search().indexDocument(documentId),
afterSystemFieldsChange: ({ documentId, requested }) =>
requested.path ? search().indexDocument(documentId) : undefined,
afterDelete: ({ documentId }) => search().removeFromIndex(documentId),
})

Register this module through the server-only hook registry so its @byline/client/server import never enters the browser schema graph.

Hook

Search action

afterCreate

Reconcile every published locale

afterUpdate

Reconcile every published locale

afterStatusChange

Reconcile publish, archive, or other status changes

afterUnpublish

Remove locale rows that no longer have a published view

afterSystemFieldsChange with a path request

Refresh hit paths, including reconciliation retries

afterDelete

Remove every locale for the document

afterTreeChange

No search write unless the provider projection includes tree-derived data

An advertised-locale-only system change does not alter indexed content in the reference application. A path change does, because lightweight search hits carry path.

Use the system client in lifecycle hooks

Do not call getAdminBylineClient() from a collection lifecycle hook. That client resolves a request-scoped admin session and fails when imports, seeds, migrations, tests, or other background work run without an HTTP request. getSystemBylineClient() is the correct authority for published-index maintenance.

Post-commit failure behavior

Search hooks run after the source database transaction commits. Awaiting indexDocument() therefore gives callers a visible failure, but it cannot roll back the content write.

For create, update, status, and system-field operations, a hook failure can reject the lifecycle call after content has committed. The index may remain stale until a later reconciliation or rebuild.

Delete is different. A failed afterDelete side effect does not reject the committed database and audit result. The lifecycle returns committed-with-side-effect-failures, and the host can warn the editor. Because the source document no longer exists, there is no retry-by-delete path; a rebuild removes any orphaned search row.

The current reference hooks await search and cache effects. A durable outbox, retries, and background indexing remain future work.

Rebuild a collection

client.collection(path).reindex() performs a full collection rebuild:

  1. assert collections.<path>.reindex;
  2. clear the provider's collection slice and analyzer metadata;
  3. page through every published document;
  4. call indexDocument() for each document; and
  5. return the number of source documents walked.
import { getSystemBylineClient } from '@byline/client/server'
const report = await getSystemBylineClient().collection('docs').reindex()
console.log(report)
// { collectionPath: 'docs', documents: 240, indexed: 240 }

The indexed count currently mirrors source documents walked, not the exact number of locale rows written.

Use a complete rebuild:

  • when enabling search for an existing collection;
  • after changing search.body, weights, facets, filters, or zones;
  • after changing analyzer options or language expanders;
  • after changing the provider-owned schema;
  • after switching providers; or
  • to remove orphan rows after a missed delete side effect.

Reindexing is synchronous and uses pages of 100 published documents. This is appropriate for small and medium collections. Large corpora need a background job with progress, throttling, and durable retry.

Add the admin rebuild action

The TanStack Start host exports a permission-gated list action.

Edit: apps/webapp/byline/collections/<name>/admin.tsx

import { type CollectionAdminConfig, defineAdmin } from '@byline/core'
import { ReindexButton } from '@byline/host-tanstack-start/admin-shell/collections/reindex-button'
import { Docs } from './schema.js'
export const DocsAdmin: CollectionAdminConfig = defineAdmin(Docs, {
listActions: [ReindexButton],
})

defineAdmin() takes the collection definition as its first argument so the admin config is typed against that collection's fields.

The button hides unless the actor has collections.<path>.reindex. Its server function asserts the same ability before calling CollectionHandle.reindex().

Provider migrations

Search providers own independent migration streams:

  • PostgreSQL records versions in byline_search_migrations and applies each file transactionally.
  • MySQL records the same ledger, but MySQL DDL auto-commits. Its migrator uses GET_LOCK, idempotent statements, and writes the ledger only after every statement completes.

Search migrations do not belong in the storage adapter's Drizzle migration stream. The packages embed migration text for bundled runtimes and also ship numbered SQL files for DBA-controlled deployment.

Apply migrations before the application serves search traffic:

import { migrate } from '@byline/search-postgres'
const { applied } = await migrate(db.pool, {
log: (message) => logger.info(message),
})

The index is disposable. When a release changes the initial search schema or analyzer contract, drop only the provider-owned search tables, apply the current schema, and rebuild from published content. Do not copy old physical tokens into a new analyzer.

Analyzer fingerprint changes

Portable providers store one analyzer fingerprint per collection. They compare query and write analyzers with this metadata before using the index.

If they differ, the provider throws SEARCH_INDEX_REINDEX_REQUIRED with the affected collection path. Clear and rebuild that collection using the same provider and analyzer that will serve queries.

Sequence deployment and reindexing so users do not search between installing a new analyzer and completing its rebuild. During that interval, rejecting a query is safer than returning incomplete mixed-analyzer results.

PreviousConfigure search
NextSearch API

On this Page

  • Published-only synchronization
  • Wire collection hooks
  • Post-commit failure behavior
  • Rebuild a collection
  • Add the admin rebuild action
  • Provider migrations
  • Analyzer fingerprint changes
Byline CMS

Building the future of content management, one commit at a time.

Project

  • Documentation
  • Roadmap
  • Contributing
  • Releases

Community

  • GitHub Discussions
  • Blog
  • Newsletter

Legal

  • Privacy Policy
  • Terms of Use
  • Cookies

© 2026 Infonomic Company Limited and contributors. Open source and built with ❤️ by the community.