- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board - Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api - Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme - PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven - Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
88 lines
2.5 KiB
Go
88 lines
2.5 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
type migration struct {
|
|
version int
|
|
apply func(context.Context, *sql.Tx) error
|
|
}
|
|
|
|
var migrations = []migration{
|
|
{version: 1, apply: migrationOne},
|
|
}
|
|
|
|
func migrate(ctx context.Context, db *sql.DB) error {
|
|
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at TEXT NOT NULL
|
|
)`); err != nil {
|
|
return fmt.Errorf("create migrations table: %w", err)
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
var exists int
|
|
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&exists); err != nil {
|
|
return fmt.Errorf("read migration %d: %w", m.version, err)
|
|
}
|
|
if exists != 0 {
|
|
continue
|
|
}
|
|
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin migration %d: %w", m.version, err)
|
|
}
|
|
if err := m.apply(ctx, tx); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("apply migration %d: %w", m.version, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", m.version, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("record migration %d: %w", m.version, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit migration %d: %w", m.version, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrationOne(ctx context.Context, tx *sql.Tx) error {
|
|
statements := []string{
|
|
`CREATE TABLE lists (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
|
|
position REAL NOT NULL
|
|
)`,
|
|
`CREATE TABLE cards (
|
|
id INTEGER PRIMARY KEY,
|
|
list_id INTEGER NOT NULL,
|
|
title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 240),
|
|
description TEXT NOT NULL DEFAULT '',
|
|
position REAL NOT NULL,
|
|
done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE
|
|
)`,
|
|
`CREATE INDEX idx_lists_position ON lists(position, id)`,
|
|
`CREATE INDEX idx_cards_list_position ON cards(list_id, position, id)`,
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for i, name := range []string{"To do", "In progress", "Done"} {
|
|
if _, err := tx.ExecContext(ctx, "INSERT INTO lists (name, position) VALUES (?, ?)", name, float64(i+1)*1024); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|