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", () => {