feat(backups): scheduled database backups via cron tick

- database_services gains backup_schedule + next_backup_at (goose migration,
  sqlc schema, new queries).
- PUT /databases/:id accepts backup_schedule — validated cron expression,
  empty string disables; invalid returns 400.
- Cron scheduler tick now also folds due databases into the existing
  snapshot pipeline (createBackupProcess).
- Databases page backups section gets a schedule row showing current expr
  and next run.
- resolveBackupConfig reports real next_backup/enabled instead of a fake
  last+24h estimate.
- Verified e2e: '* * * * *' produced three consecutive completed archives
  containing the seeded payload; invalid schedule rejected.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 17:46:02 +02:00
1 parent aeb91b03e9
commit 08d92fb10f
13 files changed
+387 -99

No files matched your search

@@ -150,7 +150,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
}
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2
`
@@ -161,16 +161,18 @@ type GetDatabaseServiceByIDAndUserParams struct {
}
type GetDatabaseServiceByIDAndUserRow struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) {
@@ -185,6 +187,8 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -238,23 +242,25 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
}
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC
`
type ListDatabaseServicesByUserRow struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) {
@@ -275,6 +281,8 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -291,6 +299,66 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
return items, nil
}
const listDueDatabaseBackups = `-- name: ListDueDatabaseBackups :many
SELECT id, backup_schedule
FROM database_services
WHERE backup_schedule IS NOT NULL AND backup_schedule <> ''
AND next_backup_at IS NOT NULL AND next_backup_at <= NOW()
`
type ListDueDatabaseBackupsRow struct {
ID string `json:"id"`
BackupSchedule sql.NullString `json:"backup_schedule"`
}
func (q *Queries) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) {
rows, err := q.db.QueryContext(ctx, listDueDatabaseBackups)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListDueDatabaseBackupsRow{}
for rows.Next() {
var i ListDueDatabaseBackupsRow
if err := rows.Scan(&i.ID, &i.BackupSchedule); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setDatabaseBackupScheduleByIDAndUser = `-- name: SetDatabaseBackupScheduleByIDAndUser :exec
UPDATE database_services
SET backup_schedule = $1, next_backup_at = $2, updated_at = $3
WHERE id = $4 AND user_id = $5
`
type SetDatabaseBackupScheduleByIDAndUserParams struct {
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
}
func (q *Queries) SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, setDatabaseBackupScheduleByIDAndUser,
arg.BackupSchedule,
arg.NextBackupAt,
arg.UpdatedAt,
arg.ID,
arg.UserID,
)
return err
}
const setDatabaseBackupStatusByID = `-- name: SetDatabaseBackupStatusByID :exec
UPDATE database_backups
SET status = $1, size = $2, completed_at = $3
@@ -314,6 +382,22 @@ func (q *Queries) SetDatabaseBackupStatusByID(ctx context.Context, arg SetDataba
return err
}
const setDatabaseNextBackupAt = `-- name: SetDatabaseNextBackupAt :exec
UPDATE database_services
SET next_backup_at = $1
WHERE id = $2
`
type SetDatabaseNextBackupAtParams struct {
NextBackupAt sql.NullTime `json:"next_backup_at"`
ID string `json:"id"`
}
func (q *Queries) SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error {
_, err := q.db.ExecContext(ctx, setDatabaseNextBackupAt, arg.NextBackupAt, arg.ID)
return err
}
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
UPDATE database_services
SET status = $1, connection_url = $2, updated_at = $3