feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
- 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
This commit is contained in:
37
internal/database/database.go
Normal file
37
internal/database/database.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Open configures the one SQLite connection used by the single-replica app and
|
||||
// migrates it before returning it to callers.
|
||||
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create database directory: %w", err)
|
||||
}
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("configure database: %w", err)
|
||||
}
|
||||
if err := migrate(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
69
internal/database/database_test.go
Normal file
69
internal/database/database_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"havenllo/internal/database"
|
||||
)
|
||||
|
||||
func TestOpenMigratesSeedsAndEnforcesCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "havenllo.db")
|
||||
db, err := database.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM lists").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("seeded lists = %d, want 3", count)
|
||||
}
|
||||
var listID int64
|
||||
if err := db.QueryRowContext(ctx, "SELECT id FROM lists ORDER BY position LIMIT 1").Scan(&listID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, done, created_at, updated_at)
|
||||
VALUES (?, 'test', '', 1024, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, listID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "DELETE FROM lists WHERE id = ?", listID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM cards").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("cascade left %d cards", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "havenllo.db")
|
||||
db, err := database.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.Close()
|
||||
db, err = database.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil && err != sql.ErrNoRows {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("migrations = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
87
internal/database/migrations.go
Normal file
87
internal/database/migrations.go
Normal file
@@ -0,0 +1,87 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user