feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s

Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).

Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
  backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged

Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
  apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated

Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
  the static-source inline port + hard legacy cutover (Priority 1)
This commit is contained in:
2026-05-16 02:24:31 +03:00
parent 30133bc1eb
commit 2aff22f565
21 changed files with 7445 additions and 460 deletions
+37
View File
@@ -510,6 +510,43 @@ type Container struct {
UpdatedAt string `json:"updated_at"`
}
// Trigger is a first-class redeploy signal source. Triggers were embedded
// in workload rows (workload.trigger_kind / trigger_config) until the
// trigger-split refactor; they are now standalone records bound to
// workloads via WorkloadTriggerBinding so a single trigger (a webhook,
// registry watcher, schedule, git push) can fan out to many workloads.
//
// Webhook secrets live here, not on the workload — the inbound webhook
// URL identifies a trigger, which then resolves its bindings to decide
// which workloads to fire.
type Trigger struct {
ID string `json:"id"`
Kind string `json:"kind"` // registry | git | manual | schedule | log_scan | ...
Name string `json:"name"` // human-readable, unique
Config string `json:"config"` // JSON-encoded, decoded by the matching plugin
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// WorkloadTriggerBinding joins a Workload to a Trigger. BindingConfig is
// the per-binding override applied on top of Trigger.Config (top-level
// JSON merge: binding fields win). Empty BindingConfig means "use the
// trigger's config verbatim". Enabled false skips the binding without
// deleting it (useful for paused stages).
type WorkloadTriggerBinding struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
TriggerID string `json:"trigger_id"`
BindingConfig string `json:"binding_config"` // JSON-encoded; "{}" = none
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// App is an optional grouping of workloads (e.g., "my-saas" = web project + worker stack + redis stack).
// Schema lives here from day one so future UI work is unblocked, but no UI is wired in v1.
type App struct {
+180
View File
@@ -4,15 +4,41 @@ import (
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
_ "modernc.org/sqlite"
)
// ErrNotFound is returned when a requested entity does not exist.
var ErrNotFound = errors.New("not found")
// ErrUnique is returned when a write violates a UNIQUE constraint.
// Translating the driver-specific message at the store boundary lets
// callers use errors.Is instead of fragile substring matching on
// err.Error(); the SQLite driver's wording is not part of any contract.
var ErrUnique = errors.New("unique constraint violation")
// translateSQLError maps a driver-level error onto one of the store's
// sentinel errors when possible. Returns the original error unchanged
// when no mapping applies. The returned error wraps the original via
// %w so callers that need the raw message still have it.
func translateSQLError(err error) error {
if err == nil {
return nil
}
msg := err.Error()
// modernc.org/sqlite returns text like
// "constraint failed: UNIQUE constraint failed: triggers.name (2067)"
// Match case-insensitively in case the driver wording shifts.
if strings.Contains(strings.ToUpper(msg), "UNIQUE") {
return fmt.Errorf("%w: %v", ErrUnique, err)
}
return err
}
// Store wraps the SQLite database connection and provides access to all query methods.
type Store struct {
db *sql.DB
@@ -274,6 +300,34 @@ func (s *Store) runMigrations() error {
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(workload_id, target)
)`,
// triggers: first-class redeploy signal sources. Webhook secrets
// move from workload onto the trigger so one webhook URL can fan
// out to multiple workloads via workload_trigger_bindings.
`CREATE TABLE IF NOT EXISTS triggers (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL UNIQUE,
config TEXT NOT NULL DEFAULT '{}',
webhook_secret TEXT NOT NULL DEFAULT '',
webhook_signing_secret TEXT NOT NULL DEFAULT '',
webhook_require_signature INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
// workload_trigger_bindings: many-to-many between workloads and
// triggers. binding_config is the per-binding override applied on
// top of trigger.config (top-level JSON merge, binding wins).
`CREATE TABLE IF NOT EXISTS workload_trigger_bindings (
id TEXT PRIMARY KEY,
workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE,
trigger_id TEXT NOT NULL REFERENCES triggers(id) ON DELETE CASCADE,
binding_config TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(workload_id, trigger_id)
)`,
}
for _, t := range workloadTables {
if _, err := s.db.Exec(t); err != nil {
@@ -454,6 +508,11 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`,
`CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`,
`CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`,
// Trigger-split indexes (2026-05-16).
`CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
`CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`,
`CREATE INDEX IF NOT EXISTS idx_bindings_trigger ON workload_trigger_bindings(trigger_id)`,
}
for _, idx := range indexes {
if _, err := s.db.Exec(idx); err != nil {
@@ -474,6 +533,127 @@ func (s *Store) runMigrations() error {
}
}
if err := s.backfillTriggersFromWorkloads(); err != nil {
slog.Warn("trigger backfill", "error", err)
}
return nil
}
// backfillTriggersFromWorkloads converts embedded trigger config on
// workload rows into standalone trigger + binding rows. Runs once per
// boot and is idempotent — only workloads with non-empty trigger_kind
// AND no existing binding produce a new trigger record.
//
// Each per-workload backfill runs inside a transaction so a partial
// failure (binding insert fails after trigger insert succeeds) rolls
// back cleanly; otherwise an orphan trigger row would survive forever
// because the next boot's bindings-count check sees zero bindings and
// tries to re-insert under the same UNIQUE name.
//
// Trigger names are unconditionally suffixed with the workload's id
// short-prefix to make collisions impossible across two workloads with
// identical (name, kind) — the "Foo [registry]" + "Foo [registry]" case
// would otherwise have one of them silently dropped.
//
// Why on every boot: the trigger-split refactor is a clean break (no
// formal migration). Existing dev databases have triggers embedded in
// workloads.trigger_kind / trigger_config; this lifts them into the new
// shape so URLs and behavior survive the upgrade.
func (s *Store) backfillTriggersFromWorkloads() error {
rows, err := s.db.Query(
`SELECT id, name, trigger_kind, trigger_config,
webhook_secret, webhook_signing_secret, webhook_require_signature
FROM workloads
WHERE trigger_kind != ''`,
)
if err != nil {
return fmt.Errorf("scan workloads for backfill: %w", err)
}
defer rows.Close()
type embedded struct {
id, name, kind, config string
webhookSecret, webhookSigningSecret string
requireSig int
}
var pending []embedded
for rows.Next() {
var e embedded
if err := rows.Scan(&e.id, &e.name, &e.kind, &e.config,
&e.webhookSecret, &e.webhookSigningSecret, &e.requireSig); err != nil {
return fmt.Errorf("scan workload row: %w", err)
}
pending = append(pending, e)
}
if err := rows.Err(); err != nil {
return err
}
for _, e := range pending {
if err := s.backfillOneTrigger(e.id, e.name, e.kind, e.config,
e.webhookSecret, e.webhookSigningSecret, e.requireSig); err != nil {
slog.Warn("trigger backfill: workload skipped",
"workload", e.id, "error", err)
}
}
return nil
}
// backfillOneTrigger lifts one embedded trigger into its own row + binding
// inside a single transaction. Idempotent: a workload that already has at
// least one binding is left alone.
func (s *Store) backfillOneTrigger(workloadID, workloadName, kind, config,
webhookSecret, webhookSigningSecret string, requireSig int) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
var existing int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM workload_trigger_bindings WHERE workload_id = ?`,
workloadID,
).Scan(&existing); err != nil {
return fmt.Errorf("count bindings: %w", err)
}
if existing > 0 {
return nil
}
idShort := workloadID
if len(idShort) > 8 {
idShort = idShort[:8]
}
triggerName := fmt.Sprintf("%s [%s] %s", workloadName, kind, idShort)
triggerID := uuid.New().String()
now := Now()
if _, err := tx.Exec(
`INSERT INTO triggers (id, kind, name, config,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
triggerID, kind, triggerName, config,
webhookSecret, webhookSigningSecret, requireSig,
now, now,
); err != nil {
return fmt.Errorf("insert trigger: %w", err)
}
bindingID := uuid.New().String()
if _, err := tx.Exec(
`INSERT INTO workload_trigger_bindings
(id, workload_id, trigger_id, binding_config, enabled, sort_order, created_at, updated_at)
VALUES (?, ?, ?, '{}', 1, 0, ?, ?)`,
bindingID, workloadID, triggerID, now, now,
); err != nil {
return fmt.Errorf("insert binding: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
+303
View File
@@ -0,0 +1,303 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
const triggerColumns = `id, kind, name, config,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at`
func scanTrigger(s rowScanner) (Trigger, error) {
var t Trigger
var requireSig int
if err := s.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
&t.CreatedAt, &t.UpdatedAt); err != nil {
return Trigger{}, err
}
t.WebhookRequireSignature = requireSig != 0
return t, nil
}
// CreateTrigger inserts a new trigger row. Kind + Name are required.
// Config is normalized to "{}" when empty; webhook secret is left empty
// unless the caller pre-populates it.
func (s *Store) CreateTrigger(t Trigger) (Trigger, error) {
if t.ID == "" {
t.ID = uuid.New().String()
}
if t.Config == "" {
t.Config = "{}"
}
t.CreatedAt = Now()
t.UpdatedAt = t.CreatedAt
_, err := s.db.Exec(
`INSERT INTO triggers (`+triggerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Kind, t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.CreatedAt, t.UpdatedAt,
)
if err != nil {
return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
}
return t, nil
}
// GetTriggerByID returns one trigger.
func (s *Store) GetTriggerByID(id string) (Trigger, error) {
t, err := scanTrigger(s.db.QueryRow(
`SELECT `+triggerColumns+` FROM triggers WHERE id = ?`, id,
))
if errors.Is(err, sql.ErrNoRows) {
return Trigger{}, fmt.Errorf("trigger %s: %w", id, ErrNotFound)
}
if err != nil {
return Trigger{}, fmt.Errorf("query trigger: %w", err)
}
return t, nil
}
// GetTriggerByWebhookSecret resolves a trigger by its inbound webhook
// secret. Empty input is treated as not-found to avoid accidental matches
// against rows whose webhook is disabled.
func (s *Store) GetTriggerByWebhookSecret(secret string) (Trigger, error) {
if secret == "" {
return Trigger{}, fmt.Errorf("empty secret: %w", ErrNotFound)
}
t, err := scanTrigger(s.db.QueryRow(
`SELECT `+triggerColumns+` FROM triggers WHERE webhook_secret = ?`, secret,
))
if errors.Is(err, sql.ErrNoRows) {
return Trigger{}, ErrNotFound
}
if err != nil {
return Trigger{}, fmt.Errorf("query trigger by webhook secret: %w", err)
}
return t, nil
}
// GetTriggerByName resolves a trigger by its unique human name.
func (s *Store) GetTriggerByName(name string) (Trigger, error) {
if name == "" {
return Trigger{}, fmt.Errorf("empty name: %w", ErrNotFound)
}
t, err := scanTrigger(s.db.QueryRow(
`SELECT `+triggerColumns+` FROM triggers WHERE name = ?`, name,
))
if errors.Is(err, sql.ErrNoRows) {
return Trigger{}, ErrNotFound
}
if err != nil {
return Trigger{}, fmt.Errorf("query trigger by name: %w", err)
}
return t, nil
}
// ListTriggers returns all triggers, ordered by name. Optional kind
// filter — empty string returns everything.
func (s *Store) ListTriggers(kind string) ([]Trigger, error) {
var rows *sql.Rows
var err error
if kind == "" {
rows, err = s.db.Query(`SELECT ` + triggerColumns + ` FROM triggers ORDER BY name`)
} else {
rows, err = s.db.Query(`SELECT `+triggerColumns+` FROM triggers WHERE kind = ? ORDER BY name`, kind)
}
if err != nil {
return nil, fmt.Errorf("query triggers: %w", err)
}
defer rows.Close()
out := []Trigger{}
for rows.Next() {
t, err := scanTrigger(rows)
if err != nil {
return nil, fmt.Errorf("scan trigger: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// TriggerWithBindingCount projects a Trigger plus its current binding
// count in a single round-trip. Used by /api/triggers list rendering so
// the response avoids one COUNT(*) per trigger row.
type TriggerWithBindingCount struct {
Trigger
BindingCount int
}
// ListTriggersWithBindingCount returns every trigger ordered by name
// with the count of its bindings joined in. Optional kind filter.
func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingCount, error) {
const base = `
SELECT t.id, t.kind, t.name, t.config,
t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature,
t.created_at, t.updated_at,
COALESCE(b.cnt, 0)
FROM triggers t
LEFT JOIN (
SELECT trigger_id, COUNT(*) AS cnt
FROM workload_trigger_bindings
GROUP BY trigger_id
) b ON b.trigger_id = t.id`
var rows *sql.Rows
var err error
if kind == "" {
rows, err = s.db.Query(base + ` ORDER BY t.name`)
} else {
rows, err = s.db.Query(base+` WHERE t.kind = ? ORDER BY t.name`, kind)
}
if err != nil {
return nil, fmt.Errorf("query triggers with binding count: %w", err)
}
defer rows.Close()
out := []TriggerWithBindingCount{}
for rows.Next() {
var t Trigger
var requireSig int
var count int
if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
&t.CreatedAt, &t.UpdatedAt, &count); err != nil {
return nil, fmt.Errorf("scan trigger+count: %w", err)
}
t.WebhookRequireSignature = requireSig != 0
out = append(out, TriggerWithBindingCount{Trigger: t, BindingCount: count})
}
return out, rows.Err()
}
// UpdateTrigger updates the mutable fields of a trigger. Kind is
// immutable post-create — changing kinds would invalidate every
// binding's interpretation of binding_config.
func (s *Store) UpdateTrigger(t Trigger) error {
t.UpdatedAt = Now()
if t.Config == "" {
t.Config = "{}"
}
result, err := s.db.Exec(
`UPDATE triggers SET name=?, config=?,
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
updated_at=?
WHERE id=?`,
t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.UpdatedAt, t.ID,
)
if err != nil {
return fmt.Errorf("update trigger: %w", translateSQLError(err))
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("trigger %s: %w", t.ID, ErrNotFound)
}
return nil
}
// DeleteTrigger removes a trigger row. Bindings cascade away via FK.
func (s *Store) DeleteTrigger(id string) error {
result, err := s.db.Exec(`DELETE FROM triggers WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete trigger: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
}
return nil
}
// CreateTriggerWithBindingTx atomically creates a trigger row and
// binds it to a workload. Used by the workload-side inline-create-and-
// bind endpoint so a binding-insert failure does not leave an orphan
// trigger row behind. Returns the persisted trigger and binding.
func (s *Store) CreateTriggerWithBindingTx(t Trigger, b WorkloadTriggerBinding) (Trigger, WorkloadTriggerBinding, error) {
tx, err := s.db.Begin()
if err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if t.ID == "" {
t.ID = uuid.New().String()
}
if t.Config == "" {
t.Config = "{}"
}
t.CreatedAt = Now()
t.UpdatedAt = t.CreatedAt
if _, err := tx.Exec(
`INSERT INTO triggers (`+triggerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Kind, t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.CreatedAt, t.UpdatedAt,
); err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
}
if b.ID == "" {
b.ID = uuid.New().String()
}
if b.BindingConfig == "" {
b.BindingConfig = "{}"
}
b.TriggerID = t.ID
b.CreatedAt = t.CreatedAt
b.UpdatedAt = t.UpdatedAt
if _, err := tx.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,
); err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert binding: %w", translateSQLError(err))
}
if err := tx.Commit(); err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("commit: %w", err)
}
return t, b, nil
}
// SetTriggerWebhookSecret rotates the inbound webhook URL secret. Pass
// empty string to disable webhook ingress for this trigger.
func (s *Store) SetTriggerWebhookSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE triggers SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("update trigger webhook_secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureTriggerWebhookSecret returns the current secret, generating one
// lazily for triggers that have none. Mirrors the workload helper.
func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) {
t, err := s.GetTriggerByID(id)
if err != nil {
return "", err
}
if t.WebhookSecret != "" {
return t.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetTriggerWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
+270
View File
@@ -0,0 +1,270 @@
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
}