From 39391199ed4195f800556faed9ec8ee6b3a07e3a Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Wed, 17 Jun 2026 07:39:16 -0400 Subject: [PATCH] fix(search): evict stale FTS row when an update clears all searchable fields The idempotent FTS sync returned early when buildFtsContent produced no content, skipping the delete. An update that cleared every searchable field therefore left the prior FTS row in place, so old terms kept matching through the search JOIN. Run the delete unconditionally and gate only the re-insert on content. --- .../fts-no-duplicate-rows-on-backfill.md | 4 ++++ .../contrail-appview/src/core/db/records.ts | 23 +++++++++++-------- packages/contrail/tests/search.test.ts | 15 ++++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.changeset/fts-no-duplicate-rows-on-backfill.md b/.changeset/fts-no-duplicate-rows-on-backfill.md index 51f59ec..551a21c 100644 --- a/.changeset/fts-no-duplicate-rows-on-backfill.md +++ b/.changeset/fts-no-duplicate-rows-on-backfill.md @@ -11,3 +11,7 @@ and appended another FTS row. The FTS virtual table has no uniqueness constraint, so these accumulated, and the search JOIN fanned each event out into one result row per duplicate. Make the delete-then-insert unconditional so FTS sync is idempotent regardless of replay detection. + +The unconditional delete also evicts a stale FTS row when an update clears all +searchable fields: in that case there is no content to re-insert, but the prior +row must still be removed so old terms stop matching. diff --git a/packages/contrail-appview/src/core/db/records.ts b/packages/contrail-appview/src/core/db/records.ts index 86873ec..933df81 100644 --- a/packages/contrail-appview/src/core/db/records.ts +++ b/packages/contrail-appview/src/core/db/records.ts @@ -183,18 +183,21 @@ function buildFtsStatements( const record = event.record ? JSON.parse(event.record) : null; if (!record) return []; - const content = buildFtsContent(record, fields); - if (!content) return []; - - // Always delete-then-insert so FTS sync is idempotent. The FTS virtual table - // has no uniqueness constraint, so a bare insert appends a duplicate row when - // one already exists. existingMap is unreliable here: backfill runs with + // Always delete first so FTS sync is idempotent. The FTS virtual table has no + // uniqueness constraint, so a bare insert appends a duplicate row when one + // already exists. existingMap is unreliable here: backfill runs with // skipReplayDetection, leaving it empty, so a re-applied record would look new - // and accumulate duplicate rows that fan out the search JOIN. + // and accumulate duplicate rows that fan out the search JOIN. The delete is + // unconditional so it also evicts a stale row when an update clears all + // searchable fields (content is null); only the re-insert is gated on content. stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri)); - stmts.push( - db.prepare(`INSERT INTO ${table} (uri, content) VALUES (?, ?)`).bind(event.uri, content) - ); + + const content = buildFtsContent(record, fields); + if (content) { + stmts.push( + db.prepare(`INSERT INTO ${table} (uri, content) VALUES (?, ?)`).bind(event.uri, content) + ); + } } return stmts; diff --git a/packages/contrail/tests/search.test.ts b/packages/contrail/tests/search.test.ts index d56884f..66afe9e 100644 --- a/packages/contrail/tests/search.test.ts +++ b/packages/contrail/tests/search.test.ts @@ -178,6 +178,21 @@ describe.skipIf(!hasFts)("FTS sync", () => { const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Meetup" }); expect(result.records).toHaveLength(1); }); + + it("evicts the stale FTS row when an update clears all searchable fields", async () => { + // The delete must run unconditionally. If an update leaves every searchable + // field empty, buildFtsContent returns null and there is nothing to re-insert, + // but the prior FTS row must still be removed so old terms stop matching. + await applyEvents(db, [ + makeEvent({ uri: "at://did:plc:a/community.lexicon.calendar.event/1", collection, rkey: "1", record: { name: "Searchable Title", mode: "online", description: "find me" }, time_us: 1000 }), + ], SEARCH_CONFIG); + expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "Searchable" })).records).toHaveLength(1); + + await applyEvents(db, [ + makeEvent({ uri: "at://did:plc:a/community.lexicon.calendar.event/1", collection, rkey: "1", record: { startsAt: "2026-01-01T00:00:00Z" }, operation: "update", time_us: 2000 }), + ], SEARCH_CONFIG); + expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "Searchable" })).records).toHaveLength(0); + }); }); describe.skipIf(!hasFts)("explicit searchable fields", () => { -- 2.51.2