ship: v0.1 Foundation milestone complete (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-06-03 20:08:57 +00:00
parent 55aae5347e
commit be9afa2d2c
59 changed files with 4152 additions and 36 deletions
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
type AuditEntry struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
Actor string `json:"actor"`
Action string `json:"action"`
Resource string `json:"resource"`
Result string `json:"result"`
Error string `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AuditRepo struct {
db *sql.DB
}
func NewAuditRepo(db *sql.DB) *AuditRepo {
return &AuditRepo{db: db}
}
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
if e.Timestamp.IsZero() {
e.Timestamp = time.Now().UTC()
}
if e.Actor == "" {
e.Actor = "system"
}
metaJSON, _ := json.Marshal(e.Metadata)
if e.Error == "" {
_, err := r.db.ExecContext(ctx,
`INSERT INTO audit_log (timestamp, actor, action, resource, result, metadata) VALUES (?, ?, ?, ?, ?, ?)`,
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, string(metaJSON))
if err != nil {
return fmt.Errorf("insert audit: %w", err)
}
return nil
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
if err != nil {
return fmt.Errorf("insert audit (with error): %w", err)
}
return nil
}
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
if limit <= 0 {
limit = 100
}
rows, err := r.db.QueryContext(ctx,
`SELECT id, timestamp, actor, action, resource, result, COALESCE(error, ''), COALESCE(metadata, '') FROM audit_log ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list audit: %w", err)
}
defer rows.Close()
var entries []*AuditEntry
for rows.Next() {
var (
e AuditEntry
metaJSON string
)
if err := rows.Scan(&e.ID, &e.Timestamp, &e.Actor, &e.Action, &e.Resource, &e.Result, &e.Error, &metaJSON); err != nil {
return nil, fmt.Errorf("scan audit: %w", err)
}
if metaJSON != "" {
_ = json.Unmarshal([]byte(metaJSON), &e.Metadata)
}
entries = append(entries, &e)
}
return entries, rows.Err()
}
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"context"
"path/filepath"
"testing"
)
func openAuditTestDB(t *testing.T) (*AuditRepo, func()) {
t.Helper()
path := filepath.Join(t.TempDir(), "audit.db")
db, err := Open(path)
if err != nil {
t.Fatalf("open db: %v", err)
}
return NewAuditRepo(db), func() { _ = db.Close() }
}
func TestAuditRepo_AppendAndList(t *testing.T) {
repo, cleanup := openAuditTestDB(t)
defer cleanup()
ctx := context.Background()
for i := 0; i < 5; i++ {
err := repo.Append(ctx, &AuditEntry{
Actor: "cli",
Action: "node.join",
Resource: "node-1",
Result: "success",
})
if err != nil {
t.Fatalf("append[%d]: %v", i, err)
}
}
entries, err := repo.List(ctx, 10)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 5 {
t.Errorf("expected 5 entries, got %d", len(entries))
}
}
func TestAuditRepo_WithError(t *testing.T) {
repo, cleanup := openAuditTestDB(t)
defer cleanup()
ctx := context.Background()
err := repo.Append(ctx, &AuditEntry{
Actor: "system",
Action: "task.run",
Resource: "task-1",
Result: "failure",
Error: "exit status 1",
Metadata: map[string]any{"exit_code": 1},
})
if err != nil {
t.Fatalf("append: %v", err)
}
entries, _ := repo.List(ctx, 1)
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Error != "exit status 1" {
t.Errorf("expected error 'exit status 1', got %q", entries[0].Error)
}
}
+246
View File
@@ -0,0 +1,246 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.cloudinit.dev/coreci/orca/internal/model"
)
type JobRepo struct {
db *sql.DB
}
func NewJobRepo(db *sql.DB) *JobRepo {
return &JobRepo{db: db}
}
func (r *JobRepo) Insert(ctx context.Context, j *model.Job) error {
if j.CreatedAt.IsZero() {
j.CreatedAt = time.Now().UTC()
}
if j.Status == "" {
j.Status = model.JobStatusPending
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO jobs (id, name, spec, status, exit_code, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
j.ID, j.Name, j.Spec, string(j.Status), j.ExitCode, j.CreatedAt)
if err != nil {
return fmt.Errorf("insert job: %w", err)
}
return nil
}
func (r *JobRepo) Get(ctx context.Context, id string) (*model.Job, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, name, spec, status, exit_code, created_at, started_at, ended_at FROM jobs WHERE id = ?`, id)
return scanJob(row)
}
func (r *JobRepo) List(ctx context.Context) ([]*model.Job, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, spec, status, exit_code, created_at, started_at, ended_at FROM jobs ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("list jobs: %w", err)
}
defer rows.Close()
var jobs []*model.Job
for rows.Next() {
j, err := scanJob(rows)
if err != nil {
return nil, err
}
jobs = append(jobs, j)
}
return jobs, rows.Err()
}
func (r *JobRepo) UpdateStatus(ctx context.Context, id string, status model.JobStatus, exitCode int) error {
now := time.Now().UTC()
var startedAt, endedAt *time.Time
switch status {
case model.JobStatusRunning:
startedAt = &now
case model.JobStatusComplete, model.JobStatusFailed, model.JobStatusStopped:
endedAt = &now
}
_, err := r.db.ExecContext(ctx,
`UPDATE jobs SET status = ?, exit_code = ?, started_at = COALESCE(?, started_at), ended_at = COALESCE(?, ended_at) WHERE id = ?`,
string(status), exitCode, startedAt, endedAt, id)
if err != nil {
return fmt.Errorf("update job: %w", err)
}
return nil
}
func scanJob(s scanner) (*model.Job, error) {
var (
j model.Job
status string
startedAt sql.NullTime
endedAt sql.NullTime
)
err := s.Scan(&j.ID, &j.Name, &j.Spec, &status, &j.ExitCode, &j.CreatedAt, &startedAt, &endedAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("scan job: %w", err)
}
j.Status = model.JobStatus(status)
if startedAt.Valid {
j.StartedAt = &startedAt.Time
}
if endedAt.Valid {
j.EndedAt = &endedAt.Time
}
return &j, nil
}
type TaskRepo struct {
db *sql.DB
}
func NewTaskRepo(db *sql.DB) *TaskRepo {
return &TaskRepo{db: db}
}
func (r *TaskRepo) Insert(ctx context.Context, t *model.Task) error {
if t.CreatedAt.IsZero() {
t.CreatedAt = time.Now().UTC()
}
if t.Status == "" {
t.Status = model.TaskStatusPending
}
argsJSON, _ := json.Marshal(t.Args)
envJSON, _ := json.Marshal(t.Env)
_, err := r.db.ExecContext(ctx,
`INSERT INTO tasks (id, job_id, command, args, env, pid, exit_code, status, created_at, stdout, stderr)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.JobID, t.Command, string(argsJSON), string(envJSON),
t.PID, t.ExitCode, string(t.Status), t.CreatedAt, t.Stdout, t.Stderr)
if err != nil {
return fmt.Errorf("insert task: %w", err)
}
return nil
}
func (r *TaskRepo) Get(ctx context.Context, id string) (*model.Task, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, job_id, command, args, env, pid, exit_code, status, created_at, started_at, ended_at, stdout, stderr FROM tasks WHERE id = ?`, id)
return scanTask(row)
}
func (r *TaskRepo) ListByJob(ctx context.Context, jobID string) ([]*model.Task, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, job_id, command, args, env, pid, exit_code, status, created_at, started_at, ended_at, stdout, stderr FROM tasks WHERE job_id = ? ORDER BY created_at ASC`, jobID)
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
defer rows.Close()
var tasks []*model.Task
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
func (r *TaskRepo) UpdateRunning(ctx context.Context, id string, pid int) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`UPDATE tasks SET pid = ?, status = ?, started_at = ? WHERE id = ?`,
pid, string(model.TaskStatusRunning), now, id)
if err != nil {
return fmt.Errorf("update task running: %w", err)
}
return nil
}
func (r *TaskRepo) UpdateDone(ctx context.Context, id string, exitCode int, stdout, stderr string) error {
now := time.Now().UTC()
status := model.TaskStatusComplete
if exitCode != 0 {
status = model.TaskStatusFailed
}
_, err := r.db.ExecContext(ctx,
`UPDATE tasks SET status = ?, exit_code = ?, ended_at = ?, stdout = ?, stderr = ? WHERE id = ?`,
string(status), exitCode, now, stdout, stderr, id)
if err != nil {
return fmt.Errorf("update task done: %w", err)
}
return nil
}
func (r *TaskRepo) UpdateKilled(ctx context.Context, id string) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx,
`UPDATE tasks SET status = ?, ended_at = ? WHERE id = ?`,
string(model.TaskStatusKilled), now, id)
if err != nil {
return fmt.Errorf("update task killed: %w", err)
}
return nil
}
// ListRecent returns up to limit tasks ordered by created_at DESC.
// Used by the API to expose recent activity without a job filter.
func (r *TaskRepo) ListRecent(ctx context.Context, limit int) ([]*model.Task, error) {
if limit <= 0 {
limit = 100
}
rows, err := r.db.QueryContext(ctx,
`SELECT id, job_id, command, args, env, pid, exit_code, status, created_at, started_at, ended_at, stdout, stderr FROM tasks ORDER BY created_at DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list tasks recent: %w", err)
}
defer rows.Close()
var tasks []*model.Task
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
var _ = errors.New
var _ = json.Marshal
func scanTask(s scanner) (*model.Task, error) {
var (
t model.Task
status string
argsJSON string
envJSON string
startedAt sql.NullTime
endedAt sql.NullTime
)
err := s.Scan(&t.ID, &t.JobID, &t.Command, &argsJSON, &envJSON,
&t.PID, &t.ExitCode, &status, &t.CreatedAt, &startedAt, &endedAt, &t.Stdout, &t.Stderr)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
t.Status = model.TaskStatus(status)
if startedAt.Valid {
t.StartedAt = &startedAt.Time
}
if endedAt.Valid {
t.EndedAt = &endedAt.Time
}
_ = json.Unmarshal([]byte(argsJSON), &t.Args)
_ = json.Unmarshal([]byte(envJSON), &t.Env)
return &t, nil
}
+53
View File
@@ -0,0 +1,53 @@
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"sort"
"strings"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func migrate(db *sql.DB) error {
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
if _, err := db.ExecContext(context.Background(), `CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, name := range names {
var existing string
err := db.QueryRowContext(context.Background(), `SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&existing)
if err == nil {
continue
}
if err != sql.ErrNoRows {
return fmt.Errorf("check migration %s: %w", name, err)
}
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
if _, err := db.ExecContext(context.Background(), string(sqlBytes)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := db.ExecContext(context.Background(), `INSERT INTO schema_migrations (name, applied_at) VALUES (?, datetime('now'))`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
}
return nil
}
+13
View File
@@ -0,0 +1,13 @@
-- Node registry
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending',
joined_at DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_nodes_state ON nodes(state);
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
@@ -0,0 +1,34 @@
-- Jobs and tasks
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
spec TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
exit_code INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
started_at DATETIME,
ended_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
command TEXT NOT NULL,
args TEXT NOT NULL DEFAULT '[]',
env TEXT NOT NULL DEFAULT '[]',
pid INTEGER NOT NULL DEFAULT 0,
exit_code INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
started_at DATETIME,
ended_at DATETIME,
stdout TEXT NOT NULL DEFAULT '',
stderr TEXT NOT NULL DEFAULT '',
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tasks_job ON tasks(job_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
@@ -0,0 +1,15 @@
-- Audit log for security-first observability
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
action TEXT NOT NULL,
resource TEXT NOT NULL,
result TEXT NOT NULL,
error TEXT,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_log(resource);
+122
View File
@@ -0,0 +1,122 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.cloudinit.dev/coreci/orca/internal/model"
)
var ErrNotFound = errors.New("not found")
type NodeRepo struct {
db *sql.DB
}
func NewNodeRepo(db *sql.DB) *NodeRepo {
return &NodeRepo{db: db}
}
func (r *NodeRepo) Insert(ctx context.Context, n *model.Node) error {
if n.JoinedAt.IsZero() {
n.JoinedAt = time.Now().UTC()
}
if n.LastSeen.IsZero() {
n.LastSeen = n.JoinedAt
}
if n.State == "" {
n.State = model.NodeStateReady
}
metaJSON, err := json.Marshal(n.Metadata)
if err != nil {
return fmt.Errorf("marshal metadata: %w", err)
}
_, err = r.db.ExecContext(ctx,
`INSERT INTO nodes (id, name, address, state, joined_at, last_seen, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.Name, n.Address, string(n.State), n.JoinedAt, n.LastSeen, string(metaJSON))
if err != nil {
return fmt.Errorf("insert node: %w", err)
}
return nil
}
func (r *NodeRepo) Get(ctx context.Context, id string) (*model.Node, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes WHERE id = ?`, id)
return scanNode(row)
}
func (r *NodeRepo) List(ctx context.Context) ([]*model.Node, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes ORDER BY joined_at ASC`)
if err != nil {
return nil, fmt.Errorf("list nodes: %w", err)
}
defer rows.Close()
var nodes []*model.Node
for rows.Next() {
n, err := scanNode(rows)
if err != nil {
return nil, err
}
nodes = append(nodes, n)
}
return nodes, rows.Err()
}
func (r *NodeRepo) UpdateState(ctx context.Context, id string, state model.NodeState) error {
res, err := r.db.ExecContext(ctx,
`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
string(state), time.Now().UTC(), id)
if err != nil {
return fmt.Errorf("update node state: %w", err)
}
rows, _ := res.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
}
func (r *NodeRepo) Delete(ctx context.Context, id string) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete node: %w", err)
}
rows, _ := res.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanNode(s scanner) (*model.Node, error) {
var (
n model.Node
state string
metaJSON sql.NullString
)
err := s.Scan(&n.ID, &n.Name, &n.Address, &state, &n.JoinedAt, &n.LastSeen, &metaJSON)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("scan node: %w", err)
}
n.State = model.NodeState(state)
if metaJSON.Valid && metaJSON.String != "" {
if err := json.Unmarshal([]byte(metaJSON.String), &n.Metadata); err != nil {
return nil, fmt.Errorf("unmarshal metadata: %w", err)
}
}
return &n, nil
}
+102
View File
@@ -0,0 +1,102 @@
package store
import (
"context"
"path/filepath"
"testing"
"time"
"git.cloudinit.dev/coreci/orca/internal/model"
)
func openTestDB(t *testing.T) (*NodeRepo, func()) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("open db: %v", err)
}
return NewNodeRepo(db), func() { _ = db.Close() }
}
func TestNodeRepo_InsertAndGet(t *testing.T) {
repo, cleanup := openTestDB(t)
defer cleanup()
ctx := context.Background()
n := &model.Node{
ID: "test-id-1",
Name: "alpha",
Address: "localhost:8443",
State: model.NodeStateReady,
JoinedAt: time.Now().UTC(),
LastSeen: time.Now().UTC(),
}
if err := repo.Insert(ctx, n); err != nil {
t.Fatalf("insert: %v", err)
}
got, err := repo.Get(ctx, "test-id-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Name != "alpha" || got.Address != "localhost:8443" {
t.Errorf("unexpected node: %+v", got)
}
if got.State != model.NodeStateReady {
t.Errorf("expected state ready, got %s", got.State)
}
}
func TestNodeRepo_List(t *testing.T) {
repo, cleanup := openTestDB(t)
defer cleanup()
ctx := context.Background()
for _, name := range []string{"a", "b", "c"} {
_ = repo.Insert(ctx, &model.Node{
ID: name, Name: name, Address: "addr",
JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
})
}
nodes, err := repo.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(nodes) != 3 {
t.Errorf("expected 3 nodes, got %d", len(nodes))
}
}
func TestNodeRepo_UpdateState(t *testing.T) {
repo, cleanup := openTestDB(t)
defer cleanup()
ctx := context.Background()
_ = repo.Insert(ctx, &model.Node{
ID: "x", Name: "x", Address: "a", JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
})
if err := repo.UpdateState(ctx, "x", model.NodeStateLeft); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := repo.Get(ctx, "x")
if got.State != model.NodeStateLeft {
t.Errorf("expected left, got %s", got.State)
}
}
func TestNodeRepo_Delete(t *testing.T) {
repo, cleanup := openTestDB(t)
defer cleanup()
ctx := context.Background()
_ = repo.Insert(ctx, &model.Node{
ID: "y", Name: "y", Address: "a", JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
})
if err := repo.Delete(ctx, "y"); err != nil {
t.Fatalf("delete: %v", err)
}
_, err := repo.Get(ctx, "y")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
func Open(path string) (*sql.DB, error) {
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("get home dir: %w", err)
}
path = filepath.Join(home, ".orca", "orca.db")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)")
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := migrate(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}