···11+---
22+"@atmo-dev/contrail-appview": patch
33+---
44+55+Stop accumulating duplicate FTS rows when records are re-applied during backfill.
66+77+The FTS-sync path only deleted an existing FTS row before inserting when the
88+record was already in `existingMap`. Backfill runs with `skipReplayDetection`,
99+which leaves `existingMap` empty, so every re-applied record looked brand-new
1010+and appended another FTS row. The FTS virtual table has no uniqueness
1111+constraint, so these accumulated, and the search JOIN fanned each event out into
1212+one result row per duplicate. Make the delete-then-insert unconditional so FTS
1313+sync is idempotent regardless of replay detection.
1414+1515+The unconditional delete also evicts a stale FTS row when an update clears all
1616+searchable fields: in that case there is no content to re-insert, but the prior
1717+row must still be removed so old terms stop matching.
+15-11
packages/contrail-appview/src/core/db/records.ts
···161161function buildFtsStatements(
162162 db: Database,
163163 event: IngestEvent,
164164- config: ContrailConfig,
165165- existingMap: Map<string, ExistingRecordInfo>
164164+ config: ContrailConfig
166165): Statement[] {
167166 // PostgreSQL: tsvector generated column is auto-maintained, no manual FTS sync
168167 if (getDialect(db).ftsStrategy === "generated-column") return [];
···184183 const record = event.record ? JSON.parse(event.record) : null;
185184 if (!record) return [];
186185186186+ // Always delete first so FTS sync is idempotent. The FTS virtual table has no
187187+ // uniqueness constraint, so a bare insert appends a duplicate row when one
188188+ // already exists. existingMap is unreliable here: backfill runs with
189189+ // skipReplayDetection, leaving it empty, so a re-applied record would look new
190190+ // and accumulate duplicate rows that fan out the search JOIN. The delete is
191191+ // unconditional so it also evicts a stale row when an update clears all
192192+ // searchable fields (content is null); only the re-insert is gated on content.
193193+ stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri));
194194+187195 const content = buildFtsContent(record, fields);
188188- if (!content) return [];
189189-190190- // Only delete existing FTS row if this is an update (record already existed)
191191- if (existingMap.has(event.uri)) {
192192- stmts.push(db.prepare(`DELETE FROM ${table} WHERE uri = ?`).bind(event.uri));
196196+ if (content) {
197197+ stmts.push(
198198+ db.prepare(`INSERT INTO ${table} (uri, content) VALUES (?, ?)`).bind(event.uri, content)
199199+ );
193200 }
194194- stmts.push(
195195- db.prepare(`INSERT INTO ${table} (uri, content) VALUES (?, ?)`).bind(event.uri, content)
196196- );
197201 }
198202199203 return stmts;
···644648 if (!isReplay && !options?.skipFeedFanout) {
645649 batch.push(...buildFeedStatements(db, e, config, existingRecordStrings));
646650 }
647647- batch.push(...buildFtsStatements(db, e, config, existingMap));
651651+ batch.push(...buildFtsStatements(db, e, config));
648652 }
649653 }
650654
+34
packages/contrail/tests/search.test.ts
···159159 ], SEARCH_CONFIG);
160160 expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "Deletable" })).records).toHaveLength(0);
161161 });
162162+163163+ it("does not duplicate FTS rows when the same record is re-applied during backfill", async () => {
164164+ // Backfill passes call applyEvents with skipReplayDetection: true, which leaves
165165+ // existingMap empty so every record looks brand-new. Re-backfilling the same
166166+ // record must not append a second FTS row; otherwise the search JOIN fans out
167167+ // and returns the event more than once (which crashes keyed lists downstream).
168168+ const event = makeEvent({
169169+ uri: "at://did:plc:a/community.lexicon.calendar.event/1",
170170+ collection,
171171+ rkey: "1",
172172+ record: { name: "Backfilled Meetup", mode: "online", description: "test" },
173173+ time_us: 1000,
174174+ });
175175+ await applyEvents(db, [event], SEARCH_CONFIG, { skipReplayDetection: true });
176176+ await applyEvents(db, [event], SEARCH_CONFIG, { skipReplayDetection: true });
177177+178178+ const result = await queryRecords(db, SEARCH_CONFIG, { collection, search: "Meetup" });
179179+ expect(result.records).toHaveLength(1);
180180+ });
181181+182182+ it("evicts the stale FTS row when an update clears all searchable fields", async () => {
183183+ // The delete must run unconditionally. If an update leaves every searchable
184184+ // field empty, buildFtsContent returns null and there is nothing to re-insert,
185185+ // but the prior FTS row must still be removed so old terms stop matching.
186186+ await applyEvents(db, [
187187+ 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 }),
188188+ ], SEARCH_CONFIG);
189189+ expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "Searchable" })).records).toHaveLength(1);
190190+191191+ await applyEvents(db, [
192192+ 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 }),
193193+ ], SEARCH_CONFIG);
194194+ expect((await queryRecords(db, SEARCH_CONFIG, { collection, search: "Searchable" })).records).toHaveLength(0);
195195+ });
162196});
163197164198describe.skipIf(!hasFts)("explicit searchable fields", () => {