Upgrading from 5.2 to 6.x
Companions:
- Richtext field capabilities — the full model behind the richtext change, including the manifest and scan APIs this guide invokes.
- Indexing and reindexing — how
reindex()rebuilds a collection, and where provider migrations are applied. - Upgrading from 4.19 to 5.x — the previous step in the upgrade chain, which a 4.x installation must complete first.
- PostgreSQL and MySQL search providers — the driver-owned schema and analyzer metadata this upgrade repairs.
Byline 6 changes two things that need a deliberate response, and they are unrelated to each other. A richtext field's resolved editor now decides which structures the field accepts, not merely which controls it offers. Separately, @byline/search-postgres ships a migration that repairs installations whose search schema was left incomplete by an earlier release.
Upgrade every registry-backed @byline/* package together — 6.0 is a lockstep major across all of them, and most carry no change of their own beyond the version. Unlike the v5 upgrade, this release needs no database fence, no maintenance window and no credential rotation: no writer contract changed, and no stored document is rewritten at upgrade time. What it does need is two pre-flight checks, described below, and one post-deploy rebuild if your installation is affected by the search repair.
Install 6.0.1 or later rather than 6.0.0. @byline/ui 6.0.0 published a development export condition pointing at TypeScript source inside node_modules, which fails a downstream Vitest run with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING; 6.0.1 removes it from the published package.
Which changes apply to you
Change | Affects | Before deploying | After deploying |
Installations using | Run the pre-flight query to find out whether you are affected | Rebuild every searchable collection, or search returns nothing | |
Every installation with a | Move each | Nothing, unless the scan reported content that will open read-only |
Search index metadata repair (PostgreSQL)
@byline/search-postgres stores analyzer metadata in byline_search_index_metadata and an analyzer fingerprint on each indexed row. Both were added to the driver's 0001_init.sql in place, in 4.9.0, after that file had already shipped. The migration runner records applied versions, not file content, so a database that had already applied 0001 never received them. Any installation whose first boot ran 3.15.0 through 4.8.0 is therefore missing the analyzer_fingerprint column, the NOT NULL constraint on search_vector, and the metadata table itself. Writing to the index fails with:
relation "byline_search_index_metadata" does not existMigration 0002_analyzer_fingerprint repairs such a database. Every statement is conditional, so it is idempotent, and it is a no-op on any installation whose 0001 already carried all three changes.
Check before you upgrade
Run this against the application database:
SELECT (SELECT max(version) FROM byline_search_migrations) AS at_version, to_regclass('public.byline_search_index_metadata') IS NOT NULL AS has_metadata, (SELECT count(*) FROM byline_search_documents) AS index_rows;has_metadata = false means the installation is affected and will need the rebuild below. A database with no byline_search_migrations table has never run the PostgreSQL search driver and needs nothing from this section.
Rows written before portable lexical analysis carry no analyzer fingerprint and cannot be matched by any current query, so 0002 deletes them rather than inventing one. On an affected database the search index is emptied, and search returns no results until you rebuild it. No CMS document is touched: a search index is disposable derived data, rebuilt from published versions.
Apply the migration
The driver owns an independent numbered migration stream — it is not part of the application's Drizzle migrations. Apply it as an explicit deploy step, before the application serves traffic:
import { migrate } from '@byline/search-postgres'
const { applied } = await migrate(db.pool, { log: (m) => logger.info(m) })// applied: [2] (empty when already up to date)Edit: apps/webapp/byline/server.config.ts, which already calls migrate() on the adapter's existing pool. The three supported ways to apply the stream — by hand for managed PostgreSQL, a deliberate migrate() call, or autoMigrate in development — are described in Provider migrations.
The migration is independent of provider selection. A host that calls migrate() from @byline/search-postgres applies 0002 whichever provider is registered on ServerConfig.search, but only the PostgreSQL index is touched — an installation that has since moved to a different provider needs no rebuild.
Rebuild the index
Immediately after deploying, rebuild every searchable collection. Until each one completes, queries against that collection return nothing:
import { getSystemBylineClient } from '@byline/client/server'
const report = await getSystemBylineClient().collection('docs').reindex()
console.log(report)// { collectionPath: 'docs', documents: 240, indexed: 240 }An operator with the collections.<path>.reindex ability can do the same from the collection's list view where the admin config registers ReindexButton. Reindexing is synchronous and pages through 100 published documents at a time, which suits small and medium collections; see Rebuild a collection for the full contract.
Richtext capabilities
In 5.x, narrowing a richtext field hid controls. The field still accepted every structure the editor could register, so a heading arrived in a title field by paste, by Markdown shortcut, or from storage regardless of the toolbar. In 6.0 removing an extension removes its node registration as well as its controls, so a field configured without headings no longer accepts one by any route.
Two separate pieces of work follow from that, and they need different responses: a mechanical configuration rename, and a check against real content.
The configuration API changed
EditorSettings.options is replaced, with no alias, by mode, markdownShortcuts, controls and debug. Every registration must move:
5.x | 6.0 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A 5.x preset:
// 5.xfunction applyMinimalPreset(config: EditorConfig): EditorConfig { const o = config.settings.options o.textAlignment = false o.textStyle = false // hides the block-format dropdown (headings / lists / quote) o.inlineCode = false o.undoRedo = false o.markdownToggle = false o.markdownShortcutPlugin = false return config}becomes:
// 6.0 — apps/webapp/byline/fields/richtext/lexical-richtext-minimal.tsfunction applyMinimalPreset(config: EditorConfig): EditorConfig { const c = config.settings.controls c.textAlignment = false c.blockFormat = false // hides the dropdown; does NOT disable headings c.inlineCode = false c.undoRedo = false c.markdownToggle = false config.settings.markdownShortcuts = false return config}Nodes is renamed READABLE_NODES and is no longer a registration list. It is the vocabulary of node classes Byline can read, used by the normalizer and the capability manifest; a field's registered nodes now come from its resolved extensions.
The rename is not the substance of the upgrade. Read each call site and decide which of three things it meant. Where a registration used textStyle: false to mean "this field should hold no headings", that intent belongs in the extensions list, because the flag only ever hid the dropdown:
// apps/webapp/byline/fields/richtext/lexical-richtext-minimal-admin.tsximport { builtInExtensions, lexicalEditor } from '@byline/richtext-lexical/config'
export function configureMinimalEditor<T extends { extensions?: any }>(c: T): T { c.extensions .remove(builtInExtensions.Heading) .remove(builtInExtensions.Quote) .remove(builtInExtensions.List) .remove(builtInExtensions.CheckList) return c}
export const LexicalRichTextMinimal = lexicalEditor(configureMinimalEditor)Extension references are not JSON-safe and would break tsx-loaded seeds, so removal lives on the admin side and reaches a single field through FieldAdminConfig.editor. Settings stay on the schema side. Richtext field capabilities covers the two-sided model and which extension owns which node.
Bold, italic and inline code are the exception: they are TextNode formats rather than node types, so they have no node to unregister and cannot be constrained by either half.
What a field accepts changed
This is the part to check against real content. A field narrowed in 5.x — where narrowing only hid controls — may hold content it no longer accepts.
Nothing on disk changes when you upgrade. A document written under a wider configuration is adapted when an editor opens it: a heading becomes a paragraph, the text preserved, and the reader is told. Content with no safe conversion, such as an inline image or an embed, is never restructured; that field opens read-only instead, names the content, and discards nothing. Stored values change only when that field is edited and saved. Reads through @byline/client, Markdown export and every other consumer are unaffected either way.
The risk is therefore not data loss. It is an editor meeting an adapted or read-only field without warning.
Scan before you deploy
The check runs in two steps, because capabilities and content are established differently: measuring what a field accepts means building its editor, which needs a DOM, while scanning stored values needs a database connection. A manifest file travels between them.
In this repository, the two steps are:
cd apps/webapppnpm byline:richtext-manifestpnpm tsx byline/scripts/richtext-scan.tsBoth commands are reference-application tooling, and they work here because @byline/* resolve through workspace links, so Vite processes the whole module graph. In an application that installs @byline/* from npm, Vitest externalises those packages, Node performs the import instead, and the run fails on the CSS that @byline/ui's dist imports (ERR_UNKNOWN_FILE_EXTENSION). byline/scripts/richtext-scan.ts is reference-application source as well — byline init does not install it.
Until issue #96 is resolved, a downstream installation has to assemble the pre-flight itself, in two halves.
For the manifest, the interactive page at /admin/richtext-capabilities produces the same file in a browser, where none of the externalisation problem applies — but it is reference-application source too, so you must copy it into your application before you can open it. Four files carry it, and every package they import is published:
src/routes/_byline/admin/richtext-capabilities.tsxsrc/routes/_byline/admin/richtext-capabilities.csssrc/lib/richtext-capability-probe.tsxsrc/lib/richtext-capability-targets.tsThe page is served only in development, so it needs a running application and a browser session.
For the scan, drive the published APIs directly — they are shipped exports and carry none of this problem: buildManifest and capabilitiesFor from @byline/richtext-lexical, and scanDocument and summarise from the runtime-free @byline/richtext-lexical/scan subpath. Using the APIs directly shows both halves.
The first command mounts every richtext field's editor in jsdom, records the node types each registered, and writes byline/generated/richtext-capabilities.json. It reaches only the admin configuration graph — no database, no server configuration — so it is genuinely read-only and needs no browser and no running application. Run it with the v6 packages installed: it measures what the resolved editor accepts, and a 5.x editor registers a different set of nodes.
The second reads every value in byline_store_json — every document, every version including archived ones, every locale — matches each against the manifest, and reports per collection:
Will adapt on open: 6Will open read-only: 44Capabilities unknown: 0byline/scripts/richtext-scan.ts imports byline/server.config.ts to resolve the collection registry and a database connection, and that module applies pending search and analytics migrations as a deliberate startup step. Running the scan therefore applies the search migration described above. On a database the pre-flight query reported as affected, the scan empties the search index, before you have deployed anything.
Run the scan against a staging copy, or run the pre-flight query first and treat whichever step applies 0002 as the point from which search is down until you rebuild.
The scan exits non-zero if any value would open read-only, or if any field's capabilities could not be measured, so it can gate a deployment. Exit zero is not the same as no findings: content that will be adapted is reported and does not fail the run, because adaptation preserves the text and needs no intervention. Read the adaptation count rather than only the exit status — it tells you how much content editors will see change under them, which may still be worth announcing.
If the scan reports content that will open read-only, restore the extension that owns it for that field, or migrate the content, before deploying. Checking existing content documents the report format, the interactive equivalent at /admin/richtext-capabilities, and the @byline/richtext-lexical/scan API for hosts that need their own workflow.
Rollout
- Run the search pre-flight query and record whether the installation is affected.
- In the upgrade checkout, raise every
@byline/*package together to 6.0.1 or later and install. The capability manifest measures the editor the installed packages resolve, so it cannot be generated before this step. - Move every
settings.optionsregistration tomode/markdownShortcuts/controls/debug, deciding per call site whether the intent was to hide a control, remove a capability, or both. Typecheck: the removed API has no alias, so the compiler finds the call sites for you. - Generate the capability manifest and scan stored content, observing the warning above about what the scan applies. Resolve anything reported read-only, and note the adaptation count.
- Deploy. The search migration applies as its explicit startup step, before the application serves traffic.
- If the pre-flight query reported
has_metadata = false, rebuild every searchable collection and confirm that search returns results. Do this whether the migration first applied at scan time or at deploy time.
Rollback
No document content is rewritten by the upgrade itself, and adapted content is written only when an editor saves a field. That adaptation narrows structure — a heading becomes a paragraph — which a 5.x editor still reads, so returning to 5.2 packages does not strand content.
The search repair is one-way. 0002 deletes rows that carry no analyzer fingerprint, and those rows are not recoverable by reverting the application; a 5.x installation reading that index needs the same rebuild. Plan the rollback to include a reindex rather than assuming the index survives it.
Verification boundary
Repository tests verify the migration, the resolved-editor node registration, content adaptation on load, the read-only refusal path, and that the capability manifest agrees with the runtime it predicts. They cannot verify that a downstream operator ran the pre-flight query against the right database, found every copied preset in every application repository and deployment image, or completed a rebuild for each searchable collection. Record those operator actions separately for the installation being upgraded.