Skip to content

fix: index extracted Portable Text prose in FTS, not raw JSON - #2313

Draft
edrpls wants to merge 2 commits into
emdash-cms:mainfrom
edrpls:fix/fts-plain-text
Draft

fix: index extracted Portable Text prose in FTS, not raw JSON#2313
edrpls wants to merge 2 commits into
emdash-cms:mainfrom
edrpls:fix/fts-plain-text

Conversation

@edrpls

@edrpls edrpls commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes full-text search indexing raw Portable Text JSON instead of prose. On the audited production deployment (Macabro festival site, emdash 0.31.1), structural tokens were 27–29% of the FTS index: searching normal (a PT style value) matched 870/906 posts, _type matched 2,096 documents, and snippets showed JSON fragments.

Why the fix isn't "just call extractPlainText": the FTS sync is done by SQL triggers, which can't call into JS — and the tables were external-content FTS5 (content='ec_<slug>'), which requires the index to exactly mirror the raw column values (snippet() reads them, and the 'delete' command must be fed the inserted values or the index corrupts — the exact corruption class migration 039 exists to fix). So indexing extracted text under external content is structurally impossible without materializing it into real columns on every ec_* table, which would touch every content write path in the codebase.

Instead, this PR rebuilds the FTS tables as self-contained FTS5 whose Portable Text columns hold extracted prose, with the extraction done in SQL so triggers and population share one source of truth:

  • json_tree() collects every JSON string under a text, alt, caption, or code key — span text, image alt/caption, and code blocks, the same semantics as extractPlainText in text-extraction.ts. json_valid() guards legacy rows holding a bare string (indexed as-is).
  • Self-contained tables retire the external-content 'delete' choreography entirely: removal is a plain DELETE, a harmless no-op for never-indexed (soft-deleted) rows, so the SQLITE_CORRUPT_VTAB corruption class from SQLite Database Corrupt Error on new site #649/migration 039 can no longer occur. INSERT OR REPLACE makes concurrent D1 populates converge (verified: a plain duplicate-rowid insert throws on self-contained FTS5).
  • Snippets now come from the stored prose — searching content returns readable text, not {"_type":"block"....

Existing deployments rebuild automatically: migration 055_fts_plain_text drops and recreates every search-enabled collection's FTS table and triggers and repopulates from content, following 039's structure (self-contained lock-step SQL copy, IF NOT EXISTS forms for D1's lockless concurrent migrators, defensive identifier validation). No manual reindex step.

The search query layer is unchanged: it joins ec_* by id for metadata, bm25 weight positions and the snippet column index are preserved (same column order), and Postgres is unaffected (FTS5 is SQLite-gated throughout). Trade-off worth noting for review: a self-contained FTS table stores its own copy of the indexed text (external content stored none), while the inverted index itself shrinks by the removed structural tokens; net size impact on the audited data set is roughly neutral, and the correctness/corruption-immunity gains are structural.

The 039 migration test's fixture was updated to explicitly construct the historical external-content table shape it needs (the current FTSManager now builds self-contained tables, on which the historical "broken" triggers are actually correct) — 039's own frozen migration SQL is untouched.

Coordinates with the upcoming write-amplification fix (WHEN guards on these same triggers): whichever lands second rebases and ships its own lock-step migration, per 039's rule.

Found during a measured database audit of a production deployment.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. — n/a: no admin UI strings changed
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — n/a: bug fix

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Fable 5 (Claude Code)

Screenshots / test output

Failing first (on main, before the fix):

× does not match Portable Text structural tokens
  → "normal" must not match: expected [ …(1) ] to deeply equal []
× returns prose snippets, not JSON fragments

After the fix — new search-quality suite, migration 055 upgrade-path suite (pre-migration assertion proves the polluted baseline, post-migration assertions prove the cleanup and working triggers), plus all existing search/FTS/registry/migration suites:

Tests  36 passed   (tests/integration/search/, 039 migration suite)
Tests  17 passed   (055 migration suite, migrations integration)
Full packages/core suite: 5074 passed — the single virtual-modules.test.ts failure is
pre-existing on a clean main checkout in this environment (macOS temp-dir realpath).

🤖 Generated with Claude Code

edrpls and others added 2 commits July 31, 2026 09:22
FTS5 tables were external-content (content='ec_<slug>'), which forces
the index to mirror raw column values — and Portable Text fields store
JSON, so structural tokens polluted the index (27-29% of it on an
audited production database). Searching "normal" (a PT style value)
matched 870/906 posts, "_type" matched every document, and snippets
showed JSON fragments.

Rebuild the FTS tables as self-contained FTS5 whose Portable Text
columns hold extracted prose: every JSON string under a text, alt,
caption, or code key (span text, image alt/caption, code blocks —
the same semantics as extractPlainText). Extraction lives in SQL
(json_tree) because the sync triggers cannot call into JS, with
json_valid guarding legacy bare-string rows. Self-contained tables
also retire the external-content 'delete' choreography and its
corruption modes (migration 039's subject): removal is a plain DELETE,
a harmless no-op for never-indexed rows, and INSERT OR REPLACE makes
concurrent D1 populates converge.

Migration 055 rebuilds every search-enabled collection's index and
triggers on upgrade; the trigger SQL is lock-step with FTSManager per
039's precedent. The search query layer is unchanged — it joins ec_*
by id for metadata, and snippet() now reads the stored prose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
json_tree exposes output columns named key/value/type/path and friends;
a bare column reference inside the extraction subquery binds to those
instead of the outer ec_* column, so populating a Portable Text field
slugged with one of these names silently indexed NULL. Triggers were
unaffected (NEW.-qualified). Qualify the populate and migration
references with the content table name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 954cc3e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 772 lines across 8 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2313

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2313

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2313

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2313

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2313

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2313

emdash

npm i https://pkg.pr.new/emdash@2313

create-emdash

npm i https://pkg.pr.new/create-emdash@2313

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2313

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2313

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2313

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2313

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2313

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2313

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2313

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2313

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2313

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2313

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2313

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2313

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2313

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2313

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2313

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2313

commit: 954cc3e

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant