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