package store import ( "database/sql" "errors" "fmt" "github.com/google/uuid" ) const bindingColumns = `id, workload_id, trigger_id, binding_config, enabled, sort_order, created_at, updated_at` func scanBinding(s rowScanner) (WorkloadTriggerBinding, error) { var b WorkloadTriggerBinding var enabled int if err := s.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig, &enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt); err != nil { return WorkloadTriggerBinding{}, err } b.Enabled = enabled != 0 return b, nil } // CreateBinding inserts a binding row. The (workload_id, trigger_id) pair // must be unique — re-binding an existing pair is an UpdateBinding call, // not an insert. func (s *Store) CreateBinding(b WorkloadTriggerBinding) (WorkloadTriggerBinding, error) { if b.ID == "" { b.ID = uuid.New().String() } if b.BindingConfig == "" { b.BindingConfig = "{}" } b.CreatedAt = Now() b.UpdatedAt = b.CreatedAt _, err := s.db.Exec( `INSERT INTO workload_trigger_bindings (`+bindingColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, b.ID, b.WorkloadID, b.TriggerID, b.BindingConfig, BoolToInt(b.Enabled), b.SortOrder, b.CreatedAt, b.UpdatedAt, ) if err != nil { return WorkloadTriggerBinding{}, fmt.Errorf("insert binding: %w", translateSQLError(err)) } return b, nil } // GetBindingByID returns one binding by its primary key. func (s *Store) GetBindingByID(id string) (WorkloadTriggerBinding, error) { b, err := scanBinding(s.db.QueryRow( `SELECT `+bindingColumns+` FROM workload_trigger_bindings WHERE id = ?`, id, )) if errors.Is(err, sql.ErrNoRows) { return WorkloadTriggerBinding{}, fmt.Errorf("binding %s: %w", id, ErrNotFound) } if err != nil { return WorkloadTriggerBinding{}, fmt.Errorf("query binding: %w", err) } return b, nil } // ListBindingsForWorkload returns every trigger bound to a workload, // ordered by sort_order then created_at for stable display. func (s *Store) ListBindingsForWorkload(workloadID string) ([]WorkloadTriggerBinding, error) { rows, err := s.db.Query( `SELECT `+bindingColumns+` FROM workload_trigger_bindings WHERE workload_id = ? ORDER BY sort_order, created_at`, workloadID, ) if err != nil { return nil, fmt.Errorf("query bindings for workload: %w", err) } defer rows.Close() out := []WorkloadTriggerBinding{} for rows.Next() { b, err := scanBinding(rows) if err != nil { return nil, fmt.Errorf("scan binding: %w", err) } out = append(out, b) } return out, rows.Err() } // ListBindingsForTrigger returns every workload bound to a trigger, // ordered by sort_order. Used by the webhook fan-out path. func (s *Store) ListBindingsForTrigger(triggerID string) ([]WorkloadTriggerBinding, error) { rows, err := s.db.Query( `SELECT `+bindingColumns+` FROM workload_trigger_bindings WHERE trigger_id = ? ORDER BY sort_order, created_at`, triggerID, ) if err != nil { return nil, fmt.Errorf("query bindings for trigger: %w", err) } defer rows.Close() out := []WorkloadTriggerBinding{} for rows.Next() { b, err := scanBinding(rows) if err != nil { return nil, fmt.Errorf("scan binding: %w", err) } out = append(out, b) } return out, rows.Err() } // GetBindingByPair returns the binding for an exact (workload, trigger) // pair. ErrNotFound when missing. func (s *Store) GetBindingByPair(workloadID, triggerID string) (WorkloadTriggerBinding, error) { b, err := scanBinding(s.db.QueryRow( `SELECT `+bindingColumns+` FROM workload_trigger_bindings WHERE workload_id = ? AND trigger_id = ?`, workloadID, triggerID, )) if errors.Is(err, sql.ErrNoRows) { return WorkloadTriggerBinding{}, ErrNotFound } if err != nil { return WorkloadTriggerBinding{}, fmt.Errorf("query binding by pair: %w", err) } return b, nil } // UpdateBinding updates the mutable fields of a binding (binding_config, // enabled, sort_order). The (workload_id, trigger_id) pair is immutable // — to re-target, delete and re-insert. func (s *Store) UpdateBinding(b WorkloadTriggerBinding) error { b.UpdatedAt = Now() if b.BindingConfig == "" { b.BindingConfig = "{}" } result, err := s.db.Exec( `UPDATE workload_trigger_bindings SET binding_config=?, enabled=?, sort_order=?, updated_at=? WHERE id=?`, b.BindingConfig, BoolToInt(b.Enabled), b.SortOrder, b.UpdatedAt, b.ID, ) if err != nil { return fmt.Errorf("update binding: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("binding %s: %w", b.ID, ErrNotFound) } return nil } // DeleteBinding removes a binding row. func (s *Store) DeleteBinding(id string) error { result, err := s.db.Exec( `DELETE FROM workload_trigger_bindings WHERE id = ?`, id, ) if err != nil { return fmt.Errorf("delete binding: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("binding %s: %w", id, ErrNotFound) } return nil } // DeleteBindingsForWorkload removes every binding for a workload. // Idempotent — returns nil even if no rows existed. func (s *Store) DeleteBindingsForWorkload(workloadID string) error { _, err := s.db.Exec( `DELETE FROM workload_trigger_bindings WHERE workload_id = ?`, workloadID, ) if err != nil { return fmt.Errorf("delete bindings for workload: %w", err) } return nil } // BindingWithNames carries a binding plus the human names of its // workload + trigger so the API listing endpoints render in one // round-trip instead of N+1 lookups. type BindingWithNames struct { WorkloadTriggerBinding WorkloadName string TriggerKind string TriggerName string } // ListBindingsForTriggerWithNames is the join-aware variant of // ListBindingsForTrigger that also surfaces the workload's name. func (s *Store) ListBindingsForTriggerWithNames(triggerID string) ([]BindingWithNames, error) { rows, err := s.db.Query( `SELECT b.id, b.workload_id, b.trigger_id, b.binding_config, b.enabled, b.sort_order, b.created_at, b.updated_at, COALESCE(w.name, '') FROM workload_trigger_bindings b LEFT JOIN workloads w ON w.id = b.workload_id WHERE b.trigger_id = ? ORDER BY b.sort_order, b.created_at`, triggerID, ) if err != nil { return nil, fmt.Errorf("query bindings+names for trigger: %w", err) } defer rows.Close() out := []BindingWithNames{} for rows.Next() { var b WorkloadTriggerBinding var enabled int var workloadName string if err := rows.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig, &enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt, &workloadName); err != nil { return nil, fmt.Errorf("scan binding+name: %w", err) } b.Enabled = enabled != 0 out = append(out, BindingWithNames{WorkloadTriggerBinding: b, WorkloadName: workloadName}) } return out, rows.Err() } // ListBindingsForWorkloadWithNames is the join-aware variant of // ListBindingsForWorkload that also surfaces the trigger's kind + name. func (s *Store) ListBindingsForWorkloadWithNames(workloadID string) ([]BindingWithNames, error) { rows, err := s.db.Query( `SELECT b.id, b.workload_id, b.trigger_id, b.binding_config, b.enabled, b.sort_order, b.created_at, b.updated_at, COALESCE(t.kind, ''), COALESCE(t.name, '') FROM workload_trigger_bindings b LEFT JOIN triggers t ON t.id = b.trigger_id WHERE b.workload_id = ? ORDER BY b.sort_order, b.created_at`, workloadID, ) if err != nil { return nil, fmt.Errorf("query bindings+names for workload: %w", err) } defer rows.Close() out := []BindingWithNames{} for rows.Next() { var b WorkloadTriggerBinding var enabled int var kind, name string if err := rows.Scan(&b.ID, &b.WorkloadID, &b.TriggerID, &b.BindingConfig, &enabled, &b.SortOrder, &b.CreatedAt, &b.UpdatedAt, &kind, &name); err != nil { return nil, fmt.Errorf("scan binding+trigger names: %w", err) } b.Enabled = enabled != 0 out = append(out, BindingWithNames{ WorkloadTriggerBinding: b, TriggerKind: kind, TriggerName: name, }) } return out, rows.Err() } // CountBindingsForTrigger returns the number of bindings a trigger has. // Used by the UI to decide whether deleting a trigger is safe. func (s *Store) CountBindingsForTrigger(triggerID string) (int, error) { var n int err := s.db.QueryRow( `SELECT COUNT(*) FROM workload_trigger_bindings WHERE trigger_id = ?`, triggerID, ).Scan(&n) if err != nil { return 0, fmt.Errorf("count bindings for trigger: %w", err) } return n, nil }