diff --git a/internal/data/fts.go b/internal/data/fts.go
index 08907bb6..b6bf2a1f 100644
--- a/internal/data/fts.go
+++ b/internal/data/fts.go
@@ -208,7 +208,247 @@ func (s *Store) setupEntitiesFTS() error {
return fmt.Errorf("create entities FTS table: %w", err)
}
- return s.populateEntitiesFTS()
+ if err := s.populateEntitiesFTS(); err != nil {
+ return err
+ }
+ return s.installEntitiesFTSTriggers()
+}
+
+// installEntitiesFTSTriggers installs the AFTER INSERT/UPDATE/DELETE triggers
+// that keep entities_fts in sync with source-table writes. Parent-table _au
+// triggers get companion cascade triggers that refresh child FTS rows whose
+// entity_name embeds parent fields (project/vendor title → quote.entity_name,
+// maintenance item name → service_log.entity_name).
+//
+// Triggers are DROPped then CREATEd, so upgrades and schema drift self-heal
+// on every app open. Soft-deleted parents are filtered out of cascade JOINs
+// so the child's entity_name degrades when the parent becomes invisible.
+func (s *Store) installEntitiesFTSTriggers() error {
+ stmts := collectEntitiesFTSTriggerSQL()
+ for _, stmt := range stmts {
+ if err := s.db.Exec(stmt).Error; err != nil {
+ return fmt.Errorf("install entities FTS trigger: %w\nSQL: %s", err, stmt)
+ }
+ }
+ return nil
+}
+
+// collectEntitiesFTSTriggerSQL returns all DROP + CREATE statements needed to
+// install the entities_fts triggers. Order: drop every known trigger first
+// (so re-installation is idempotent), then create.
+func collectEntitiesFTSTriggerSQL() []string {
+ var stmts []string
+
+ // Own-row triggers for every source table.
+ for _, spec := range ownRowSpecs() {
+ stmts = append(stmts, ownRowTriggerSQL(spec)...)
+ }
+
+ // Parent → child cascade triggers.
+ stmts = append(stmts, parentCascadeQuoteSQL(TableProjects, ColProjectID)...)
+ stmts = append(stmts, parentCascadeQuoteSQL(TableVendors, ColVendorID)...)
+ stmts = append(stmts, maintenanceCascadeServiceLogSQL()...)
+
+ return stmts
+}
+
+// ownRowSpec describes one source table's own-row trigger config.
+type ownRowSpec struct {
+ table string
+ entityType string
+ nameExpr string // SQL expression for entity_name; uses NEW.
+ textExpr string // SQL expression for entity_text
+}
+
+func ownRowSpecs() []ownRowSpec {
+ col := func(c string) string { return "NEW." + c }
+ coalesceNew := func(c string) string { return "COALESCE(NEW." + c + ", '')" }
+
+ return []ownRowSpec{
+ {
+ table: TableProjects,
+ entityType: DeletionEntityProject,
+ nameExpr: col(ColTitle),
+ textExpr: col(ColTitle) + " || ' ' || " + coalesceNew(ColDescription) +
+ " || ' ' || " + coalesceNew(ColStatus),
+ },
+ {
+ table: TableVendors,
+ entityType: DeletionEntityVendor,
+ nameExpr: col(ColName),
+ textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColContactName) +
+ " || ' ' || " + coalesceNew(ColNotes),
+ },
+ {
+ table: TableAppliances,
+ entityType: DeletionEntityAppliance,
+ nameExpr: col(ColName),
+ textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColBrand) +
+ " || ' ' || " + coalesceNew(ColModelNumber) +
+ " || ' ' || " + coalesceNew(ColLocation) +
+ " || ' ' || " + coalesceNew(ColNotes),
+ },
+ {
+ table: TableMaintenanceItems,
+ entityType: DeletionEntityMaintenance,
+ nameExpr: col(ColName),
+ textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColNotes) +
+ " || ' ' || " + coalesceNew(ColSeason),
+ },
+ {
+ table: TableIncidents,
+ entityType: DeletionEntityIncident,
+ nameExpr: col(ColTitle),
+ textExpr: col(ColTitle) + " || ' ' || " + coalesceNew(ColDescription) +
+ " || ' ' || " + coalesceNew(ColLocation) +
+ " || ' ' || " + coalesceNew(ColNotes) +
+ " || ' ' || " + coalesceNew(ColSeverity),
+ },
+ {
+ // Service log entries: name comes from the joined maintenance_item.
+ // Soft-deleted maintenance items are filtered out so the SLE's
+ // entity_name blanks instead of carrying stale text.
+ table: TableServiceLogEntries,
+ entityType: DeletionEntityServiceLog,
+ nameExpr: "COALESCE((SELECT " + ColName +
+ " FROM " + TableMaintenanceItems +
+ " WHERE " + ColID + " = NEW." + ColMaintenanceItemID +
+ " AND " + ColDeletedAt + " IS NULL), '')",
+ textExpr: coalesceNew(ColNotes),
+ },
+ {
+ // Quotes: entity_name is " - ".
+ // Both parents filtered for soft-delete.
+ table: TableQuotes,
+ entityType: DeletionEntityQuote,
+ nameExpr: "COALESCE((SELECT " + ColTitle +
+ " FROM " + TableProjects +
+ " WHERE " + ColID + " = NEW." + ColProjectID +
+ " AND " + ColDeletedAt + " IS NULL), '')" +
+ " || ' - ' || " +
+ "COALESCE((SELECT " + ColName +
+ " FROM " + TableVendors +
+ " WHERE " + ColID + " = NEW." + ColVendorID +
+ " AND " + ColDeletedAt + " IS NULL), '')",
+ textExpr: coalesceNew(ColNotes),
+ },
+ }
+}
+
+// ownRowTriggerSQL returns DROP + CREATE statements for one source table's
+// AI / AU / AD triggers. The AU trigger deletes the old FTS row and
+// re-inserts it only when the row is still visible (not soft-deleted).
+func ownRowTriggerSQL(spec ownRowSpec) []string {
+ r := strings.NewReplacer(
+ "{TABLE}", spec.table,
+ "{FTS}", tableEntitiesFTS,
+ "{ENTITY}", spec.entityType,
+ "{ID}", ColID,
+ "{DEL}", ColDeletedAt,
+ "{NAME_EXPR}", spec.nameExpr,
+ "{TEXT_EXPR}", spec.textExpr,
+ )
+ return []string{
+ r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_ai`),
+ r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_au`),
+ r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_ad`),
+ r.Replace(`CREATE TRIGGER {TABLE}_fts_ai AFTER INSERT ON {TABLE}
+ WHEN NEW.{DEL} IS NULL
+ BEGIN
+ INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
+ VALUES ('{ENTITY}', NEW.{ID}, {NAME_EXPR}, {TEXT_EXPR});
+ END`),
+ r.Replace(`CREATE TRIGGER {TABLE}_fts_au AFTER UPDATE ON {TABLE}
+ BEGIN
+ DELETE FROM {FTS} WHERE entity_type = '{ENTITY}' AND entity_id = OLD.{ID};
+ INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
+ SELECT '{ENTITY}', NEW.{ID}, {NAME_EXPR}, {TEXT_EXPR}
+ WHERE NEW.{DEL} IS NULL;
+ END`),
+ r.Replace(`CREATE TRIGGER {TABLE}_fts_ad AFTER DELETE ON {TABLE}
+ BEGIN
+ DELETE FROM {FTS} WHERE entity_type = '{ENTITY}' AND entity_id = OLD.{ID};
+ END`),
+ }
+}
+
+// parentCascadeQuoteSQL returns DROP + CREATE statements for a cascade
+// trigger that refreshes quote FTS rows when their parent (project or
+// vendor) is updated. parentTable is projects or vendors; parentFK is the
+// FK column on quotes pointing to that parent.
+func parentCascadeQuoteSQL(parentTable, parentFK string) []string {
+ triggerName := parentTable + "_fts_au_cascade_quotes"
+ r := strings.NewReplacer(
+ "{TRIGGER}", triggerName,
+ "{PARENT_TABLE}", parentTable,
+ "{PARENT_ID}", ColID,
+ "{FTS}", tableEntitiesFTS,
+ "{QUOTE}", DeletionEntityQuote,
+ "{PARENT_FK}", parentFK,
+ "{Q_TABLE}", TableQuotes,
+ "{Q_ID}", ColID,
+ "{P_TABLE}", TableProjects,
+ "{P_FK}", ColProjectID,
+ "{V_TABLE}", TableVendors,
+ "{V_FK}", ColVendorID,
+ "{P_NAME}", ColTitle,
+ "{V_NAME}", ColName,
+ "{Q_NOTES}", ColNotes,
+ "{DEL}", ColDeletedAt,
+ )
+ return []string{
+ r.Replace(`DROP TRIGGER IF EXISTS {TRIGGER}`),
+ r.Replace(`CREATE TRIGGER {TRIGGER} AFTER UPDATE ON {PARENT_TABLE}
+ BEGIN
+ DELETE FROM {FTS}
+ WHERE entity_type = '{QUOTE}'
+ AND entity_id IN (SELECT {Q_ID} FROM {Q_TABLE} WHERE {PARENT_FK} = OLD.{PARENT_ID});
+ INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
+ SELECT '{QUOTE}', q.{Q_ID},
+ COALESCE(p.{P_NAME}, '') || ' - ' || COALESCE(v.{V_NAME}, ''),
+ COALESCE(q.{Q_NOTES}, '')
+ FROM {Q_TABLE} q
+ LEFT JOIN {P_TABLE} p ON q.{P_FK} = p.{PARENT_ID} AND p.{DEL} IS NULL
+ LEFT JOIN {V_TABLE} v ON q.{V_FK} = v.{PARENT_ID} AND v.{DEL} IS NULL
+ WHERE q.{PARENT_FK} = NEW.{PARENT_ID} AND q.{DEL} IS NULL;
+ END`),
+ }
+}
+
+// maintenanceCascadeServiceLogSQL installs the maintenance_items → SLE
+// cascade trigger. When a maintenance item is updated (including soft-delete
+// via deleted_at), every SLE referencing it has its FTS row rebuilt.
+func maintenanceCascadeServiceLogSQL() []string {
+ triggerName := TableMaintenanceItems + "_fts_au_cascade_service_log"
+ r := strings.NewReplacer(
+ "{TRIGGER}", triggerName,
+ "{M_TABLE}", TableMaintenanceItems,
+ "{M_ID}", ColID,
+ "{M_NAME}", ColName,
+ "{FTS}", tableEntitiesFTS,
+ "{SLE}", DeletionEntityServiceLog,
+ "{S_TABLE}", TableServiceLogEntries,
+ "{S_ID}", ColID,
+ "{S_FK}", ColMaintenanceItemID,
+ "{S_NOTES}", ColNotes,
+ "{DEL}", ColDeletedAt,
+ )
+ return []string{
+ r.Replace(`DROP TRIGGER IF EXISTS {TRIGGER}`),
+ r.Replace(`CREATE TRIGGER {TRIGGER} AFTER UPDATE ON {M_TABLE}
+ BEGIN
+ DELETE FROM {FTS}
+ WHERE entity_type = '{SLE}'
+ AND entity_id IN (SELECT {S_ID} FROM {S_TABLE} WHERE {S_FK} = OLD.{M_ID});
+ INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
+ SELECT '{SLE}', s.{S_ID},
+ COALESCE(m.{M_NAME}, ''),
+ COALESCE(s.{S_NOTES}, '')
+ FROM {S_TABLE} s
+ LEFT JOIN {M_TABLE} m ON s.{S_FK} = m.{M_ID} AND m.{DEL} IS NULL
+ WHERE s.{S_FK} = NEW.{M_ID} AND s.{DEL} IS NULL;
+ END`),
+ }
}
// populateEntitiesFTS inserts rows from each entity source table into the
@@ -274,6 +514,9 @@ func (s *Store) populateEntitiesFTS() error {
TableIncidents, ColDeletedAt),
},
{
+ // Soft-deleted maintenance_items are filtered from the JOIN so
+ // the SLE's entity_name blanks out, matching the cascade
+ // trigger's behavior when a parent becomes invisible.
"service_log_entries",
fmt.Sprintf(`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text)
SELECT '%s', s.%s, COALESCE(m.%s, ''), COALESCE(s.%s, '')
@@ -287,6 +530,9 @@ func (s *Store) populateEntitiesFTS() error {
ColDeletedAt),
},
{
+ // Soft-deleted parents are filtered from the JOINs so the
+ // quote's entity_name degrades instead of carrying stale
+ // parent names -- same invariant the cascade triggers enforce.
"quotes",
fmt.Sprintf(`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text)
SELECT '%s', q.%s,
diff --git a/internal/data/fts_test.go b/internal/data/fts_test.go
index f76fd8ff..347560db 100644
--- a/internal/data/fts_test.go
+++ b/internal/data/fts_test.go
@@ -794,3 +794,374 @@ func TestRebuildFTSIndexRefreshesEntities(t *testing.T) {
require.Len(t, results, 1)
assert.Equal(t, "Later Project", results[0].EntityName)
}
+
+// ---------------------------------------------------------------------------
+// Trigger tests: verify that AI / AU / AD triggers keep entities_fts in sync
+// with source-table writes without a manual setupEntitiesFTS rebuild.
+// ---------------------------------------------------------------------------
+
+func TestFTSTriggerInsertSurfacesProject(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ types, _ := store.ProjectTypes()
+ require.NoError(t, store.CreateProject(&Project{
+ Title: "Greenhouse Build",
+ ProjectTypeID: types[0].ID,
+ Status: ProjectStatusPlanned,
+ }))
+
+ results, err := store.SearchEntities("greenhouse")
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, DeletionEntityProject, results[0].EntityType)
+ assert.Equal(t, "Greenhouse Build", results[0].EntityName)
+}
+
+func TestFTSTriggerUpdateSurfacesNewTitle(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ types, _ := store.ProjectTypes()
+ p := &Project{
+ Title: "Old Title",
+ ProjectTypeID: types[0].ID,
+ Status: ProjectStatusPlanned,
+ }
+ require.NoError(t, store.CreateProject(p))
+
+ p.Title = "Fresh Greenhouse"
+ require.NoError(t, store.UpdateProject(*p))
+
+ // Old token no longer surfaces.
+ oldResults, err := store.SearchEntities("old")
+ require.NoError(t, err)
+ assert.Empty(t, oldResults, "old title should be gone from FTS")
+
+ // New token surfaces.
+ newResults, err := store.SearchEntities("greenhouse")
+ require.NoError(t, err)
+ require.Len(t, newResults, 1)
+ assert.Equal(t, "Fresh Greenhouse", newResults[0].EntityName)
+}
+
+func TestFTSTriggerSoftDeleteRemovesRow(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ types, _ := store.ProjectTypes()
+ p := &Project{
+ Title: "Transient Project",
+ ProjectTypeID: types[0].ID,
+ Status: ProjectStatusPlanned,
+ }
+ require.NoError(t, store.CreateProject(p))
+
+ // Sanity: it's indexed.
+ before, err := store.SearchEntities("transient")
+ require.NoError(t, err)
+ require.Len(t, before, 1)
+
+ require.NoError(t, store.DeleteProject(p.ID))
+
+ after, err := store.SearchEntities("transient")
+ require.NoError(t, err)
+ assert.Empty(t, after, "soft-deleted project must not surface")
+}
+
+func TestFTSTriggerCascadeOnProjectRename(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ types, _ := store.ProjectTypes()
+ p := &Project{
+ Title: "Kitchen Remodel",
+ ProjectTypeID: types[0].ID,
+ Status: ProjectStatusPlanned,
+ }
+ require.NoError(t, store.CreateProject(p))
+
+ v := &Vendor{Name: "Pacific Plumbing"}
+ require.NoError(t, store.CreateVendor(v))
+
+ require.NoError(t, store.CreateQuote(&Quote{
+ ProjectID: p.ID,
+ VendorID: v.ID,
+ TotalCents: 1000,
+ }, *v))
+
+ // Rename the project.
+ p.Title = "Greenhouse Build"
+ require.NoError(t, store.UpdateProject(*p))
+
+ // The quote should now be findable by the new project name.
+ results, err := store.SearchEntities("greenhouse")
+ require.NoError(t, err)
+
+ var quoteFound bool
+ for _, r := range results {
+ if r.EntityType == DeletionEntityQuote {
+ quoteFound = true
+ break
+ }
+ }
+ assert.True(
+ t,
+ quoteFound,
+ "cascade should rebuild quote FTS with new project title; got %+v",
+ results,
+ )
+}
+
+func TestFTSTriggerCascadeOnVendorRename(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ types, _ := store.ProjectTypes()
+ p := &Project{
+ Title: "Basement Refinish",
+ ProjectTypeID: types[0].ID,
+ Status: ProjectStatusPlanned,
+ }
+ require.NoError(t, store.CreateProject(p))
+
+ v := &Vendor{Name: "Old Vendor Name"}
+ require.NoError(t, store.CreateVendor(v))
+
+ require.NoError(t, store.CreateQuote(&Quote{
+ ProjectID: p.ID,
+ VendorID: v.ID,
+ TotalCents: 2000,
+ }, *v))
+
+ v.Name = "Aurora Plumbing"
+ require.NoError(t, store.UpdateVendor(*v))
+
+ results, err := store.SearchEntities("aurora")
+ require.NoError(t, err)
+
+ var quoteFound bool
+ for _, r := range results {
+ if r.EntityType == DeletionEntityQuote {
+ quoteFound = true
+ break
+ }
+ }
+ assert.True(
+ t,
+ quoteFound,
+ "cascade should rebuild quote FTS with new vendor name; got %+v",
+ results,
+ )
+}
+
+func TestFTSTriggerCascadeOnMaintenanceRename(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ cats, err := store.MaintenanceCategories()
+ require.NoError(t, err)
+ require.NotEmpty(t, cats)
+
+ m := &MaintenanceItem{
+ Name: "Old Name",
+ CategoryID: cats[0].ID,
+ IntervalMonths: 6,
+ }
+ require.NoError(t, store.CreateMaintenance(m))
+
+ sle := &ServiceLogEntry{
+ MaintenanceItemID: m.ID,
+ ServicedAt: time.Now(),
+ }
+ require.NoError(t, store.CreateServiceLog(sle, Vendor{}))
+
+ m.Name = "Quarterly Furnace Check"
+ require.NoError(t, store.UpdateMaintenance(*m))
+
+ results, err := store.SearchEntities("furnace")
+ require.NoError(t, err)
+
+ var sleFound bool
+ for _, r := range results {
+ if r.EntityType == DeletionEntityServiceLog {
+ sleFound = true
+ break
+ }
+ }
+ assert.True(
+ t,
+ sleFound,
+ "cascade should rebuild SLE FTS with new maintenance item name; got %+v",
+ results,
+ )
+}
+
+func TestFTSTriggerCascadeOnProjectSoftDelete(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ types, _ := store.ProjectTypes()
+ p := &Project{
+ Title: "Attic Insulation",
+ ProjectTypeID: types[0].ID,
+ Status: ProjectStatusPlanned,
+ }
+ require.NoError(t, store.CreateProject(p))
+
+ v := &Vendor{Name: "Summit Insulators"}
+ require.NoError(t, store.CreateVendor(v))
+
+ require.NoError(t, store.CreateQuote(&Quote{
+ ProjectID: p.ID,
+ VendorID: v.ID,
+ TotalCents: 3000,
+ }, *v))
+
+ // App-level DeleteProject refuses soft-delete when a project has live
+ // quotes. The trigger's cascade path is still reachable via sync and
+ // future app changes, so exercise it via raw DML that bypasses the
+ // validation — the goal is to prove the DB trigger behaves correctly
+ // when the scenario arises, not to test DeleteProject's gating.
+ require.NoError(t, store.db.Exec(
+ "UPDATE "+TableProjects+" SET "+ColDeletedAt+" = ? WHERE "+ColID+" = ?",
+ time.Now(), p.ID,
+ ).Error)
+
+ // Searching by vendor name should still surface the quote (with a
+ // degraded entity_name now that the project title is gone).
+ results, err := store.SearchEntities("summit")
+ require.NoError(t, err)
+
+ var quoteFound bool
+ for _, r := range results {
+ if r.EntityType == DeletionEntityQuote {
+ quoteFound = true
+ assert.NotContains(t, r.EntityName, "Attic Insulation",
+ "soft-deleted project title must not be in child entity_name")
+ }
+ }
+ assert.True(t, quoteFound, "quote should still surface via vendor name; got %+v", results)
+
+ // And searching by the now-gone project title should NOT find the quote.
+ attic, err := store.SearchEntities("attic")
+ require.NoError(t, err)
+ assert.Empty(t, attic, "soft-deleted project title should not surface via any entity")
+}
+
+func TestFTSTriggerHardDeleteMaintenanceCascadesSLE(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ cats, err := store.MaintenanceCategories()
+ require.NoError(t, err)
+ require.NotEmpty(t, cats)
+
+ m := &MaintenanceItem{
+ Name: "Gutter Cleaning",
+ CategoryID: cats[0].ID,
+ IntervalMonths: 12,
+ }
+ require.NoError(t, store.CreateMaintenance(m))
+
+ sle := &ServiceLogEntry{
+ MaintenanceItemID: m.ID,
+ ServicedAt: time.Now(),
+ Notes: "fall cleanup",
+ }
+ require.NoError(t, store.CreateServiceLog(sle, Vendor{}))
+
+ require.NoError(t, store.HardDeleteMaintenance(m.ID))
+
+ gutterResults, err := store.SearchEntities("gutter")
+ require.NoError(t, err)
+ assert.Empty(t, gutterResults, "maintenance item FTS row should be gone after hard delete")
+
+ fallResults, err := store.SearchEntities("fall")
+ require.NoError(t, err)
+ assert.Empty(t, fallResults, "child SLE FTS row should be gone via FK cascade + _ad trigger")
+}
+
+func TestFTSPopulateFiltersSoftDeletedMaintenanceInSLEJoin(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ cats, err := store.MaintenanceCategories()
+ require.NoError(t, err)
+ require.NotEmpty(t, cats)
+
+ m := &MaintenanceItem{
+ Name: "Rebuild Maintenance Name",
+ CategoryID: cats[0].ID,
+ IntervalMonths: 12,
+ }
+ require.NoError(t, store.CreateMaintenance(m))
+
+ sle := &ServiceLogEntry{
+ MaintenanceItemID: m.ID,
+ ServicedAt: time.Now(),
+ Notes: "still-alive notes",
+ }
+ require.NoError(t, store.CreateServiceLog(sle, Vendor{}))
+
+ // App-level DeleteMaintenance validation would reject this with a
+ // live SLE, so bypass via raw SQL to simulate the sync / future
+ // scenario where the parent arrives soft-deleted.
+ require.NoError(t, store.db.Exec(
+ "UPDATE "+TableMaintenanceItems+" SET "+ColDeletedAt+" = ? WHERE "+ColID+" = ?",
+ time.Now(), m.ID,
+ ).Error)
+
+ // Force the initial-rebuild path.
+ require.NoError(t, store.setupEntitiesFTS())
+
+ results, err := store.SearchEntities("rebuild")
+ require.NoError(t, err)
+ for _, r := range results {
+ if r.EntityType == DeletionEntityServiceLog {
+ assert.NotContains(t, r.EntityName, "Rebuild Maintenance Name",
+ "initial rebuild must not carry soft-deleted maintenance name into SLE FTS")
+ }
+ }
+}
+
+func TestFTSPopulateFiltersSoftDeletedParentsInQuoteJoin(t *testing.T) {
+ t.Parallel()
+ store := newTestStore(t)
+
+ // Create project + vendor + quote, soft-delete the project via raw
+ // SQL (the app-level DeleteProject rejects parents with live quotes),
+ // then run the initial rebuild path. The quote's FTS row must not
+ // carry the deleted project's title.
+ types, _ := store.ProjectTypes()
+ p := &Project{
+ Title: "Rebuild Project Title",
+ ProjectTypeID: types[0].ID,
+ Status: ProjectStatusPlanned,
+ }
+ require.NoError(t, store.CreateProject(p))
+ v := &Vendor{Name: "Rebuild Vendor Name"}
+ require.NoError(t, store.CreateVendor(v))
+ require.NoError(t, store.CreateQuote(&Quote{
+ ProjectID: p.ID,
+ VendorID: v.ID,
+ TotalCents: 1000,
+ }, *v))
+
+ require.NoError(t, store.db.Exec(
+ "UPDATE "+TableProjects+" SET "+ColDeletedAt+" = ? WHERE "+ColID+" = ?",
+ time.Now(), p.ID,
+ ).Error)
+
+ // Force the initial-rebuild path (mirrors what happens on app open).
+ require.NoError(t, store.setupEntitiesFTS())
+
+ rebuild, err := store.SearchEntities("rebuild")
+ require.NoError(t, err)
+ for _, r := range rebuild {
+ if r.EntityType == DeletionEntityQuote {
+ assert.NotContains(t, r.EntityName, "Rebuild Project Title",
+ "initial rebuild must not carry soft-deleted project title into quote FTS")
+ }
+ }
+}