From cf49ec5268373f4a5a80da25bc73b50d806f7e5d Mon Sep 17 00:00:00 2001
From: Phillip Cloud <417981+cpcloud@users.noreply.github.com>
Date: Sun, 19 Apr 2026 07:11:14 -0400
Subject: [PATCH] feat(data): install entities_fts triggers with cascading
refresh
setupEntitiesFTS now installs AFTER INSERT / UPDATE / DELETE triggers
on every source table that contributes rows to entities_fts (projects,
vendors, appliances, maintenance_items, incidents,
service_log_entries, quotes). Parent tables whose text is embedded in
a child's entity_name (project.title and vendor.name in quote,
maintenance_item.name in SLE) get companion _au_cascade triggers that
rebuild the child's FTS row when the parent is updated.
Cascade JOINs filter on parent.deleted_at IS NULL so a parent
soft-delete degrades the child's entity_name (project title
disappears from the quote; vendor name disappears; SLE name blanks
out) instead of leaving stale text in the index. The populate path
carries the same filter so initial rebuilds on app open match the
trigger invariant.
Trigger installation is idempotent (DROP IF EXISTS + CREATE), so
schema drift across app versions heals on the next Store.Open. FK
constraints (RESTRICT on quote parents, CASCADE on SLE parents)
continue to govern hard-delete feasibility; parent _ad triggers are
plain single-table cleanups, no cascade blocks needed.
Tests cover: insert, rename, soft-delete, parent-rename cascade for
all three relationships, parent-soft-delete cascade via raw DML (the
app gates soft-delete with live children, so the cascade path is
exercised by sync in production; raw DML matches that scenario in
tests), FK cascade on maintenance_item hard-delete, and initial
rebuild preserving the soft-delete filter for both SLE and quote
joins.
Refs #707.
---
internal/data/fts.go | 248 ++++++++++++++++++++++++-
internal/data/fts_test.go | 371 ++++++++++++++++++++++++++++++++++++++
2 files changed, 618 insertions(+), 1 deletion(-)
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")
+ }
+ }
+}