package store import ( "database/sql" "errors" "fmt" "github.com/google/uuid" ) const stackCols = `id, name, description, compose_project_name, status, error, current_revision_id, created_at, updated_at` // CreateStack inserts a new stack and returns it. Stack row + matching // workload row are written in a single transaction so a partial failure // leaves no orphan. func (s *Store) CreateStack(st Stack) (Stack, error) { st.ID = uuid.New().String() st.CreatedAt = Now() st.UpdatedAt = st.CreatedAt if st.Status == "" { st.Status = "stopped" } tx, err := s.db.Begin() if err != nil { return Stack{}, fmt.Errorf("begin: %w", err) } defer tx.Rollback() if _, err := tx.Exec( `INSERT INTO stacks (`+stackCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status, st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt, ); err != nil { return Stack{}, fmt.Errorf("insert stack: %w", err) } if err := SyncStackWorkloadTx(tx, st); err != nil { return Stack{}, err } if err := tx.Commit(); err != nil { return Stack{}, fmt.Errorf("commit: %w", err) } return st, nil } // GetStackByID returns a single stack by its ID. func (s *Store) GetStackByID(id string) (Stack, error) { st, err := scanStackRow(s.db.QueryRow( `SELECT `+stackCols+` FROM stacks WHERE id = ?`, id, )) if errors.Is(err, sql.ErrNoRows) { return Stack{}, fmt.Errorf("stack %s: %w", id, ErrNotFound) } if err != nil { return Stack{}, fmt.Errorf("query stack: %w", err) } return st, nil } // GetStackByComposeProjectName looks up a stack by its compose project name. // Compose project names are unique per the stacks table schema, so this is an // O(1) index lookup. Used by the reconciler to resolve compose-managed // containers without scanning every stack. func (s *Store) GetStackByComposeProjectName(name string) (Stack, error) { if name == "" { return Stack{}, ErrNotFound } st, err := scanStackRow(s.db.QueryRow( `SELECT `+stackCols+` FROM stacks WHERE compose_project_name = ?`, name, )) if errors.Is(err, sql.ErrNoRows) { return Stack{}, ErrNotFound } if err != nil { return Stack{}, fmt.Errorf("query stack by compose project: %w", err) } return st, nil } // GetAllStacks returns every stack ordered by name. func (s *Store) GetAllStacks() ([]Stack, error) { rows, err := s.db.Query(`SELECT ` + stackCols + ` FROM stacks ORDER BY name`) if err != nil { return nil, fmt.Errorf("query stacks: %w", err) } defer rows.Close() out := []Stack{} for rows.Next() { st, err := scanStackRows(rows) if err != nil { return nil, err } out = append(out, st) } return out, rows.Err() } // UpdateStack updates the mutable metadata fields (name, description). // Atomic: stack row UPDATE and workload row sync share a transaction so the // workload row's name never lags after a rename. func (s *Store) UpdateStack(st Stack) error { st.UpdatedAt = Now() tx, err := s.db.Begin() if err != nil { return fmt.Errorf("begin: %w", err) } defer tx.Rollback() result, err := tx.Exec( `UPDATE stacks SET name=?, description=?, updated_at=? WHERE id=?`, st.Name, st.Description, st.UpdatedAt, st.ID, ) if err != nil { return fmt.Errorf("update stack: %w", err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if n == 0 { return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound) } if err := SyncStackWorkloadTx(tx, st); err != nil { return err } return tx.Commit() } // UpdateStackStatus updates the deployment status + error fields. func (s *Store) UpdateStackStatus(id, status, errMsg string) error { now := Now() result, err := s.db.Exec( `UPDATE stacks SET status=?, error=?, updated_at=? WHERE id=?`, status, errMsg, now, id, ) if err != nil { return fmt.Errorf("update stack status: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("stack %s: %w", id, ErrNotFound) } return nil } // SetStackCurrentRevision updates the current_revision_id pointer. func (s *Store) SetStackCurrentRevision(id, revisionID string) error { now := Now() result, err := s.db.Exec( `UPDATE stacks SET current_revision_id=?, updated_at=? WHERE id=?`, revisionID, now, id, ) if err != nil { return fmt.Errorf("update stack revision pointer: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("stack %s: %w", id, ErrNotFound) } return nil } // DeleteStack removes a stack by ID. Cascading deletes handle revisions + deploys. // Stack + workload + container index rows are dropped atomically. func (s *Store) DeleteStack(id string) error { tx, err := s.db.Begin() if err != nil { return fmt.Errorf("begin: %w", err) } defer tx.Rollback() var workloadID string if err := tx.QueryRow( `SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`, string(WorkloadKindStack), id, ).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("lookup stack workload: %w", err) } result, err := tx.Exec(`DELETE FROM stacks WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete stack: %w", err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if n == 0 { return fmt.Errorf("stack %s: %w", id, ErrNotFound) } if workloadID != "" { if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil { return fmt.Errorf("delete stack containers: %w", err) } if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil { return fmt.Errorf("delete stack workload: %w", err) } } return tx.Commit() } func scanStackRow(row *sql.Row) (Stack, error) { var st Stack err := row.Scan( &st.ID, &st.Name, &st.Description, &st.ComposeProjectName, &st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt, ) return st, err } func scanStackRows(rows *sql.Rows) (Stack, error) { var st Stack err := rows.Scan( &st.ID, &st.Name, &st.Description, &st.ComposeProjectName, &st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt, ) if err != nil { return Stack{}, fmt.Errorf("scan stack: %w", err) } return st, nil } // --- Stack revisions --- const stackRevisionCols = `id, stack_id, revision, yaml, author, deploy_id, status, created_at` // CreateStackRevision inserts a new revision with the next monotonic revision number. func (s *Store) CreateStackRevision(r StackRevision) (StackRevision, error) { r.ID = uuid.New().String() r.CreatedAt = Now() if r.Status == "" { r.Status = "pending" } tx, err := s.db.Begin() if err != nil { return StackRevision{}, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() var next int if err := tx.QueryRow( `SELECT COALESCE(MAX(revision), 0) + 1 FROM stack_revisions WHERE stack_id = ?`, r.StackID, ).Scan(&next); err != nil { return StackRevision{}, fmt.Errorf("next revision: %w", err) } r.Revision = next if _, err := tx.Exec( `INSERT INTO stack_revisions (`+stackRevisionCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, r.ID, r.StackID, r.Revision, r.YAML, r.Author, r.DeployID, r.Status, r.CreatedAt, ); err != nil { return StackRevision{}, fmt.Errorf("insert revision: %w", err) } if err := tx.Commit(); err != nil { return StackRevision{}, fmt.Errorf("commit revision: %w", err) } return r, nil } // GetStackRevisionByID returns a single revision by ID. func (s *Store) GetStackRevisionByID(id string) (StackRevision, error) { r, err := scanStackRevisionRow(s.db.QueryRow( `SELECT `+stackRevisionCols+` FROM stack_revisions WHERE id = ?`, id, )) if errors.Is(err, sql.ErrNoRows) { return StackRevision{}, fmt.Errorf("revision %s: %w", id, ErrNotFound) } if err != nil { return StackRevision{}, fmt.Errorf("query revision: %w", err) } return r, nil } // GetStackRevisionsByStackID returns revisions newest-first. func (s *Store) GetStackRevisionsByStackID(stackID string) ([]StackRevision, error) { rows, err := s.db.Query( `SELECT `+stackRevisionCols+` FROM stack_revisions WHERE stack_id = ? ORDER BY revision DESC`, stackID, ) if err != nil { return nil, fmt.Errorf("query revisions: %w", err) } defer rows.Close() out := []StackRevision{} for rows.Next() { r, err := scanStackRevisionRows(rows) if err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // UpdateStackRevisionStatus updates status + deploy_id linkage. func (s *Store) UpdateStackRevisionStatus(id, status, deployID string) error { result, err := s.db.Exec( `UPDATE stack_revisions SET status=?, deploy_id=? WHERE id=?`, status, deployID, id, ) if err != nil { return fmt.Errorf("update revision status: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("revision %s: %w", id, ErrNotFound) } return nil } func scanStackRevisionRow(row *sql.Row) (StackRevision, error) { var r StackRevision err := row.Scan( &r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt, ) return r, err } func scanStackRevisionRows(rows *sql.Rows) (StackRevision, error) { var r StackRevision err := rows.Scan( &r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt, ) if err != nil { return StackRevision{}, fmt.Errorf("scan revision: %w", err) } return r, nil } // --- Stack deploys --- const stackDeployCols = `id, stack_id, revision_id, status, log, error, started_at, finished_at` // CreateStackDeploy inserts a new deploy record. func (s *Store) CreateStackDeploy(d StackDeploy) (StackDeploy, error) { d.ID = uuid.New().String() d.StartedAt = Now() if d.Status == "" { d.Status = "pending" } _, err := s.db.Exec( `INSERT INTO stack_deploys (`+stackDeployCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, d.ID, d.StackID, d.RevisionID, d.Status, d.Log, d.Error, d.StartedAt, d.FinishedAt, ) if err != nil { return StackDeploy{}, fmt.Errorf("insert stack deploy: %w", err) } return d, nil } // GetStackDeployByID returns a single deploy by ID. func (s *Store) GetStackDeployByID(id string) (StackDeploy, error) { d, err := scanStackDeployRow(s.db.QueryRow( `SELECT `+stackDeployCols+` FROM stack_deploys WHERE id = ?`, id, )) if errors.Is(err, sql.ErrNoRows) { return StackDeploy{}, fmt.Errorf("stack deploy %s: %w", id, ErrNotFound) } if err != nil { return StackDeploy{}, fmt.Errorf("query stack deploy: %w", err) } return d, nil } // UpdateStackDeploy updates status, log, error, finished_at. func (s *Store) UpdateStackDeploy(d StackDeploy) error { result, err := s.db.Exec( `UPDATE stack_deploys SET status=?, log=?, error=?, finished_at=? WHERE id=?`, d.Status, d.Log, d.Error, d.FinishedAt, d.ID, ) if err != nil { return fmt.Errorf("update stack deploy: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("stack deploy %s: %w", d.ID, ErrNotFound) } return nil } func scanStackDeployRow(row *sql.Row) (StackDeploy, error) { var d StackDeploy err := row.Scan( &d.ID, &d.StackID, &d.RevisionID, &d.Status, &d.Log, &d.Error, &d.StartedAt, &d.FinishedAt, ) return d, err }