@@ -208,7 +208,247 @@ func (s *Store) setupEntitiesFTS() error {
208208 return fmt .Errorf ("create entities FTS table: %w" , err )
209209 }
210210
211- return s .populateEntitiesFTS ()
211+ if err := s .populateEntitiesFTS (); err != nil {
212+ return err
213+ }
214+ return s .installEntitiesFTSTriggers ()
215+ }
216+
217+ // installEntitiesFTSTriggers installs the AFTER INSERT/UPDATE/DELETE triggers
218+ // that keep entities_fts in sync with source-table writes. Parent-table _au
219+ // triggers get companion cascade triggers that refresh child FTS rows whose
220+ // entity_name embeds parent fields (project/vendor title → quote.entity_name,
221+ // maintenance item name → service_log.entity_name).
222+ //
223+ // Triggers are DROPped then CREATEd, so upgrades and schema drift self-heal
224+ // on every app open. Soft-deleted parents are filtered out of cascade JOINs
225+ // so the child's entity_name degrades when the parent becomes invisible.
226+ func (s * Store ) installEntitiesFTSTriggers () error {
227+ stmts := collectEntitiesFTSTriggerSQL ()
228+ for _ , stmt := range stmts {
229+ if err := s .db .Exec (stmt ).Error ; err != nil {
230+ return fmt .Errorf ("install entities FTS trigger: %w\n SQL: %s" , err , stmt )
231+ }
232+ }
233+ return nil
234+ }
235+
236+ // collectEntitiesFTSTriggerSQL returns all DROP + CREATE statements needed to
237+ // install the entities_fts triggers. Order: drop every known trigger first
238+ // (so re-installation is idempotent), then create.
239+ func collectEntitiesFTSTriggerSQL () []string {
240+ var stmts []string
241+
242+ // Own-row triggers for every source table.
243+ for _ , spec := range ownRowSpecs () {
244+ stmts = append (stmts , ownRowTriggerSQL (spec )... )
245+ }
246+
247+ // Parent → child cascade triggers.
248+ stmts = append (stmts , parentCascadeQuoteSQL (TableProjects , ColProjectID )... )
249+ stmts = append (stmts , parentCascadeQuoteSQL (TableVendors , ColVendorID )... )
250+ stmts = append (stmts , maintenanceCascadeServiceLogSQL ()... )
251+
252+ return stmts
253+ }
254+
255+ // ownRowSpec describes one source table's own-row trigger config.
256+ type ownRowSpec struct {
257+ table string
258+ entityType string
259+ nameExpr string // SQL expression for entity_name; uses NEW.<col>
260+ textExpr string // SQL expression for entity_text
261+ }
262+
263+ func ownRowSpecs () []ownRowSpec {
264+ col := func (c string ) string { return "NEW." + c }
265+ coalesceNew := func (c string ) string { return "COALESCE(NEW." + c + ", '')" }
266+
267+ return []ownRowSpec {
268+ {
269+ table : TableProjects ,
270+ entityType : DeletionEntityProject ,
271+ nameExpr : col (ColTitle ),
272+ textExpr : col (ColTitle ) + " || ' ' || " + coalesceNew (ColDescription ) +
273+ " || ' ' || " + coalesceNew (ColStatus ),
274+ },
275+ {
276+ table : TableVendors ,
277+ entityType : DeletionEntityVendor ,
278+ nameExpr : col (ColName ),
279+ textExpr : col (ColName ) + " || ' ' || " + coalesceNew (ColContactName ) +
280+ " || ' ' || " + coalesceNew (ColNotes ),
281+ },
282+ {
283+ table : TableAppliances ,
284+ entityType : DeletionEntityAppliance ,
285+ nameExpr : col (ColName ),
286+ textExpr : col (ColName ) + " || ' ' || " + coalesceNew (ColBrand ) +
287+ " || ' ' || " + coalesceNew (ColModelNumber ) +
288+ " || ' ' || " + coalesceNew (ColLocation ) +
289+ " || ' ' || " + coalesceNew (ColNotes ),
290+ },
291+ {
292+ table : TableMaintenanceItems ,
293+ entityType : DeletionEntityMaintenance ,
294+ nameExpr : col (ColName ),
295+ textExpr : col (ColName ) + " || ' ' || " + coalesceNew (ColNotes ) +
296+ " || ' ' || " + coalesceNew (ColSeason ),
297+ },
298+ {
299+ table : TableIncidents ,
300+ entityType : DeletionEntityIncident ,
301+ nameExpr : col (ColTitle ),
302+ textExpr : col (ColTitle ) + " || ' ' || " + coalesceNew (ColDescription ) +
303+ " || ' ' || " + coalesceNew (ColLocation ) +
304+ " || ' ' || " + coalesceNew (ColNotes ) +
305+ " || ' ' || " + coalesceNew (ColSeverity ),
306+ },
307+ {
308+ // Service log entries: name comes from the joined maintenance_item.
309+ // Soft-deleted maintenance items are filtered out so the SLE's
310+ // entity_name blanks instead of carrying stale text.
311+ table : TableServiceLogEntries ,
312+ entityType : DeletionEntityServiceLog ,
313+ nameExpr : "COALESCE((SELECT " + ColName +
314+ " FROM " + TableMaintenanceItems +
315+ " WHERE " + ColID + " = NEW." + ColMaintenanceItemID +
316+ " AND " + ColDeletedAt + " IS NULL), '')" ,
317+ textExpr : coalesceNew (ColNotes ),
318+ },
319+ {
320+ // Quotes: entity_name is "<project_title> - <vendor_name>".
321+ // Both parents filtered for soft-delete.
322+ table : TableQuotes ,
323+ entityType : DeletionEntityQuote ,
324+ nameExpr : "COALESCE((SELECT " + ColTitle +
325+ " FROM " + TableProjects +
326+ " WHERE " + ColID + " = NEW." + ColProjectID +
327+ " AND " + ColDeletedAt + " IS NULL), '')" +
328+ " || ' - ' || " +
329+ "COALESCE((SELECT " + ColName +
330+ " FROM " + TableVendors +
331+ " WHERE " + ColID + " = NEW." + ColVendorID +
332+ " AND " + ColDeletedAt + " IS NULL), '')" ,
333+ textExpr : coalesceNew (ColNotes ),
334+ },
335+ }
336+ }
337+
338+ // ownRowTriggerSQL returns DROP + CREATE statements for one source table's
339+ // AI / AU / AD triggers. The AU trigger deletes the old FTS row and
340+ // re-inserts it only when the row is still visible (not soft-deleted).
341+ func ownRowTriggerSQL (spec ownRowSpec ) []string {
342+ r := strings .NewReplacer (
343+ "{TABLE}" , spec .table ,
344+ "{FTS}" , tableEntitiesFTS ,
345+ "{ENTITY}" , spec .entityType ,
346+ "{ID}" , ColID ,
347+ "{DEL}" , ColDeletedAt ,
348+ "{NAME_EXPR}" , spec .nameExpr ,
349+ "{TEXT_EXPR}" , spec .textExpr ,
350+ )
351+ return []string {
352+ r .Replace (`DROP TRIGGER IF EXISTS {TABLE}_fts_ai` ),
353+ r .Replace (`DROP TRIGGER IF EXISTS {TABLE}_fts_au` ),
354+ r .Replace (`DROP TRIGGER IF EXISTS {TABLE}_fts_ad` ),
355+ r .Replace (`CREATE TRIGGER {TABLE}_fts_ai AFTER INSERT ON {TABLE}
356+ WHEN NEW.{DEL} IS NULL
357+ BEGIN
358+ INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
359+ VALUES ('{ENTITY}', NEW.{ID}, {NAME_EXPR}, {TEXT_EXPR});
360+ END` ),
361+ r .Replace (`CREATE TRIGGER {TABLE}_fts_au AFTER UPDATE ON {TABLE}
362+ BEGIN
363+ DELETE FROM {FTS} WHERE entity_type = '{ENTITY}' AND entity_id = OLD.{ID};
364+ INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
365+ SELECT '{ENTITY}', NEW.{ID}, {NAME_EXPR}, {TEXT_EXPR}
366+ WHERE NEW.{DEL} IS NULL;
367+ END` ),
368+ r .Replace (`CREATE TRIGGER {TABLE}_fts_ad AFTER DELETE ON {TABLE}
369+ BEGIN
370+ DELETE FROM {FTS} WHERE entity_type = '{ENTITY}' AND entity_id = OLD.{ID};
371+ END` ),
372+ }
373+ }
374+
375+ // parentCascadeQuoteSQL returns DROP + CREATE statements for a cascade
376+ // trigger that refreshes quote FTS rows when their parent (project or
377+ // vendor) is updated. parentTable is projects or vendors; parentFK is the
378+ // FK column on quotes pointing to that parent.
379+ func parentCascadeQuoteSQL (parentTable , parentFK string ) []string {
380+ triggerName := parentTable + "_fts_au_cascade_quotes"
381+ r := strings .NewReplacer (
382+ "{TRIGGER}" , triggerName ,
383+ "{PARENT_TABLE}" , parentTable ,
384+ "{PARENT_ID}" , ColID ,
385+ "{FTS}" , tableEntitiesFTS ,
386+ "{QUOTE}" , DeletionEntityQuote ,
387+ "{PARENT_FK}" , parentFK ,
388+ "{Q_TABLE}" , TableQuotes ,
389+ "{Q_ID}" , ColID ,
390+ "{P_TABLE}" , TableProjects ,
391+ "{P_FK}" , ColProjectID ,
392+ "{V_TABLE}" , TableVendors ,
393+ "{V_FK}" , ColVendorID ,
394+ "{P_NAME}" , ColTitle ,
395+ "{V_NAME}" , ColName ,
396+ "{Q_NOTES}" , ColNotes ,
397+ "{DEL}" , ColDeletedAt ,
398+ )
399+ return []string {
400+ r .Replace (`DROP TRIGGER IF EXISTS {TRIGGER}` ),
401+ r .Replace (`CREATE TRIGGER {TRIGGER} AFTER UPDATE ON {PARENT_TABLE}
402+ BEGIN
403+ DELETE FROM {FTS}
404+ WHERE entity_type = '{QUOTE}'
405+ AND entity_id IN (SELECT {Q_ID} FROM {Q_TABLE} WHERE {PARENT_FK} = OLD.{PARENT_ID});
406+ INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
407+ SELECT '{QUOTE}', q.{Q_ID},
408+ COALESCE(p.{P_NAME}, '') || ' - ' || COALESCE(v.{V_NAME}, ''),
409+ COALESCE(q.{Q_NOTES}, '')
410+ FROM {Q_TABLE} q
411+ LEFT JOIN {P_TABLE} p ON q.{P_FK} = p.{PARENT_ID} AND p.{DEL} IS NULL
412+ LEFT JOIN {V_TABLE} v ON q.{V_FK} = v.{PARENT_ID} AND v.{DEL} IS NULL
413+ WHERE q.{PARENT_FK} = NEW.{PARENT_ID} AND q.{DEL} IS NULL;
414+ END` ),
415+ }
416+ }
417+
418+ // maintenanceCascadeServiceLogSQL installs the maintenance_items → SLE
419+ // cascade trigger. When a maintenance item is updated (including soft-delete
420+ // via deleted_at), every SLE referencing it has its FTS row rebuilt.
421+ func maintenanceCascadeServiceLogSQL () []string {
422+ triggerName := TableMaintenanceItems + "_fts_au_cascade_service_log"
423+ r := strings .NewReplacer (
424+ "{TRIGGER}" , triggerName ,
425+ "{M_TABLE}" , TableMaintenanceItems ,
426+ "{M_ID}" , ColID ,
427+ "{M_NAME}" , ColName ,
428+ "{FTS}" , tableEntitiesFTS ,
429+ "{SLE}" , DeletionEntityServiceLog ,
430+ "{S_TABLE}" , TableServiceLogEntries ,
431+ "{S_ID}" , ColID ,
432+ "{S_FK}" , ColMaintenanceItemID ,
433+ "{S_NOTES}" , ColNotes ,
434+ "{DEL}" , ColDeletedAt ,
435+ )
436+ return []string {
437+ r .Replace (`DROP TRIGGER IF EXISTS {TRIGGER}` ),
438+ r .Replace (`CREATE TRIGGER {TRIGGER} AFTER UPDATE ON {M_TABLE}
439+ BEGIN
440+ DELETE FROM {FTS}
441+ WHERE entity_type = '{SLE}'
442+ AND entity_id IN (SELECT {S_ID} FROM {S_TABLE} WHERE {S_FK} = OLD.{M_ID});
443+ INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
444+ SELECT '{SLE}', s.{S_ID},
445+ COALESCE(m.{M_NAME}, ''),
446+ COALESCE(s.{S_NOTES}, '')
447+ FROM {S_TABLE} s
448+ LEFT JOIN {M_TABLE} m ON s.{S_FK} = m.{M_ID} AND m.{DEL} IS NULL
449+ WHERE s.{S_FK} = NEW.{M_ID} AND s.{DEL} IS NULL;
450+ END` ),
451+ }
212452}
213453
214454// populateEntitiesFTS inserts rows from each entity source table into the
@@ -274,6 +514,9 @@ func (s *Store) populateEntitiesFTS() error {
274514 TableIncidents , ColDeletedAt ),
275515 },
276516 {
517+ // Soft-deleted maintenance_items are filtered from the JOIN so
518+ // the SLE's entity_name blanks out, matching the cascade
519+ // trigger's behavior when a parent becomes invisible.
277520 "service_log_entries" ,
278521 fmt .Sprintf (`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text)
279522 SELECT '%s', s.%s, COALESCE(m.%s, ''), COALESCE(s.%s, '')
@@ -287,6 +530,9 @@ func (s *Store) populateEntitiesFTS() error {
287530 ColDeletedAt ),
288531 },
289532 {
533+ // Soft-deleted parents are filtered from the JOINs so the
534+ // quote's entity_name degrades instead of carrying stale
535+ // parent names -- same invariant the cascade triggers enforce.
290536 "quotes" ,
291537 fmt .Sprintf (`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text)
292538 SELECT '%s', q.%s,
0 commit comments