fix: index extracted Portable Text prose in FTS, not raw JSON - #2313
Conversation
🦋 Changeset detectedLatest commit: cacf517 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
Scope checkThis 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
954cc3e to
ba31680
Compare
ba31680 to
7b656c2
Compare
4b5201e to
18de1ec
Compare
ascorbic
left a comment
There was a problem hiding this comment.
Reviewed all three of these (#2313, #2314, #2391) – great work. Since they're now coupled by migration order, here's the plan to land them all:
Merge 060 into 059. as separate migrations, every existing site needs to rebuild the full corpus twice back to back, inside runtime init on D1. Add the WHEN clause to 059's trigger SQL here and drop migration 060 from #2314 entirely. That also removes 060's failure mode where the repopulate hard-fails init if the FTS table is missing, and the lost-update heal (no drop/create window of its own any more). #2314 keeps the fts-manager change and the write-amplification tests.
Fixes on this PR before merge:
- The
json_validguard drops legacy scalar values that happen to parse as JSON: aportableTextcolumn holding2024or"Some title"is searchable today and becomes silently unsearchable after this. Filter on shape instead:json_type(x) IN ('array','object')— in both the manager and the migration, plus a test row for a scalar value. - The comment saying the SQL extraction mirrors
extractPlainTextisn't quite right — the SQL is a superset (any-depthtext/alt/caption/coderegardless of_type). This is fine, butextractPlainTextis what vectorize/ai-search index with, so the two now see different text for the same document. Fix the comment to say superset; actually reconciling them can be a follow-up. - Inline the tokenizer allowlist in the migration rather than importing
SEARCH_TOKENIZERS. a replay of this migration after that constant changes would silently reset a site's tokenizer, which is the same class of bug this PR is fixing for 039. You already hardcode the fallback in three places, so this just makes it consistent.
On #2314: with 060 gone it's the manager change + tests. In the tests, the publish-path test writes identical bytes back, so it can't fail by construction — have it call repo.publish() for real, so "publish doesn't re-tokenize" properly tested
On #2391: renumber 061 to 060 once you've merged 060 into 059, and base that branch on #2314's the way #2314 sits on #2313. Optional but free: widen the index to (created_at, id) so the residual sort disappears entirely
I'll merge the three bottom-up with a rebase between each.
18de1ec to
134dfa4
Compare
|
All five points from the landing plan are in — thanks for the thorough pass, @ascorbic. On this PR (four new commits on top of the rebase):
On #2314: migration 060 is gone (file, registration, its test suite); the branch is the manager change + write-amplification tests. The publish test now calls On #2391: renumbered to All three branches: lint clean, full search/migration suites green, typecheck green across packages. Ready for the bottom-up merge. |
134dfa4 to
a768743
Compare
a768743 to
21735ca
Compare
|
Renumbered again: #2194 took slot 060 on main, so this PR's migration is now |
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>
A portableText column holding a JSON scalar ('2024', '"Some title"')
passed json_valid, extracted to NULL, and silently dropped out of the
index. Gate extraction on json_type IN ('array','object') so scalars
stay raw-indexed, in both the manager and the rebuild migration.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
Shipping the guard inside the rebuild spares existing sites a second back-to-back full re-tokenization from a separate follow-up migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
21735ca to
3d04578
Compare
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
3d04578 to
cacf517
Compare
|
Status note on the red Smoke Tests check: it's Google Fonts, not this PR. Three smoke/E2E-adjacent build failures across this chain in the past day are all Astro
Identical trees pass between failures (#2314 is fully green right now on the same base), so it's transient CDN 404s with no retry in the font fetch path — any PR's smoke run can lose this roulette. I've stopped re-triggering to avoid burning CI; happy to re-run once more on request, or this may be worth a CI-side mitigation (retry/backoff or vendored fonts for template builds). Everything code-related on this PR is green. |
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,_typematched 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 everyec_*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 atext,alt,caption, orcodekey — span text, image alt/caption, and code blocks, the same semantics asextractPlainTextintext-extraction.ts.json_valid()guards legacy rows holding a bare string (indexed as-is).'delete'choreography entirely: removal is a plainDELETE, a harmless no-op for never-indexed (soft-deleted) rows, so theSQLITE_CORRUPT_VTABcorruption class from SQLite Database Corrupt Error on new site #649/migration 039 can no longer occur.INSERT OR REPLACEmakes concurrent D1 populates converge (verified: a plain duplicate-rowid insert throws on self-contained FTS5).{"_type":"block"....Existing deployments rebuild automatically: migration
064_fts_plain_textdrops 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 EXISTSforms for D1's lockless concurrent migrators, defensive identifier validation). No manual reindex step. The rebuild keeps each collection's configuredtokenizefrom search_config (added on main by #2257) instead of resetting it to the default.The search query layer is unchanged: it joins
ec_*byidfor 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
FTSManagernow builds self-contained tables, on which the historical "broken" triggers are actually correct) — 039's own frozen migration SQL is untouched.The write-amplification WHEN guard (#2314) ships inside this PR's rebuild migration, so an upgrade re-tokenizes the corpus once, not twice; #2314 itself is now manager + tests only. The rebuild also honors each collection's configured
tokenize(frozen allowlist, no live import) and keeps legacy scalar values raw-indexed (json_typeshape gate).Found during a measured database audit of a production deployment.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. — n/a: no admin UI strings changedAI-generated code disclosure
Screenshots / test output
Failing first (on
main, before the fix):After the fix — new search-quality suite, migration 064 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:
🤖 Generated with Claude Code