Seed from go-template-repo, renamed to simplexcalc

The template's files at a77fd30, without its history or LICENSE, after
script/rename simplexcalc.

Model: opus-5-5
This commit is contained in:
clawbot
2026-09-26 21:38:57 +00:00
parent e336f46e34
commit f8ce8cef83
79 changed files with 7131 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
// Package database owns the sqlite connection and the schema. The
// schema is embedded in the binary, so a deployment is one file: there
// is no migrations directory to ship alongside it and no version of it
// that can be out of step with the code that expects it.
package database
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"go.uber.org/fx"
"sneak.berlin/go/simplexcalc/internal/config"
"sneak.berlin/go/simplexcalc/internal/logger"
// modernc.org/sqlite is the pure-Go driver: no cgo, so the binary
// links statically and the container needs no libc.
_ "modernc.org/sqlite"
)
// schemaFS carries the migrations into the binary.
//
//go:embed schema/*.sql
var schemaFS embed.FS
// dirPerm is the mode for the data directory: owner-only, because it
// holds the database.
const dirPerm = 0o700
// pragmas are applied to every connection. WAL is what makes concurrent
// reads not block on a write; busy_timeout is what turns the remaining
// contention into a short wait rather than an immediate SQLITE_BUSY;
// foreign_keys is off by default in sqlite and has to be asked for.
const pragmas = `
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
PRAGMA synchronous = NORMAL;
`
// Params defines dependencies for Database.
type Params struct {
fx.In
Config *config.Config
Logger *logger.Logger
}
// Database is the handle to the application's sqlite database.
type Database struct {
db *sql.DB
log *slog.Logger
}
// New opens the database, applies the embedded migrations, and
// registers a close hook. Migrations run during OnStart rather than
// lazily on first use: a schema that cannot be applied is a failure to
// start, and the process says so before it accepts a request.
func New(lc fx.Lifecycle, params Params) (*Database, error) {
d := &Database{log: params.Logger.Get()}
err := os.MkdirAll(filepath.Dir(params.Config.DBPath), dirPerm)
if err != nil {
return nil, fmt.Errorf("creating data directory: %w", err)
}
// New runs during graph construction, which has no request or
// lifecycle context of its own; the pragmas are a handful of
// in-process statements against a file that was just created.
db, err := Open(context.Background(), params.Config.DBPath)
if err != nil {
return nil, err
}
d.db = db
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return d.Migrate(ctx)
},
OnStop: func(_ context.Context) error {
d.log.Info("closing database")
closeErr := d.db.Close()
if closeErr != nil {
return fmt.Errorf("closing database: %w", closeErr)
}
return nil
},
})
return d, nil
}
// Open opens a sqlite database at path and applies the connection
// pragmas. Exported so tests can open a scratch database without the
// fx graph.
func Open(ctx context.Context, path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("opening database %s: %w", path, err)
}
// sqlite tolerates exactly one writer. Holding the pool to a
// single connection makes that limit explicit here rather than
// intermittent under load, and WAL keeps readers off the writer's
// back anyway.
db.SetMaxOpenConns(1)
db.SetConnMaxLifetime(time.Hour)
_, err = db.ExecContext(ctx, pragmas)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("applying pragmas: %w", err)
}
return db, nil
}
// NewForTest opens a scratch database in dir and migrates it. Test
// helper, exported so that a project seeded from this template can use
// it from any package's tests.
func NewForTest(ctx context.Context, dir string) (*Database, error) {
d := &Database{log: slog.New(slog.DiscardHandler)}
db, err := Open(ctx, filepath.Join(dir, "test.db"))
if err != nil {
return nil, err
}
d.db = db
err = d.Migrate(ctx)
if err != nil {
_ = db.Close()
return nil, err
}
return d, nil
}
// Migrate applies every embedded migration that has not been applied to
// this database yet.
func (d *Database) Migrate(ctx context.Context) error {
set := migrationSet{fsys: schemaFS, dir: "schema"}
err := set.apply(ctx, d.db, d.log)
if err != nil {
return fmt.Errorf("applying migrations: %w", err)
}
return nil
}
// DB exposes the underlying handle for packages that need to query it.
func (d *Database) DB() *sql.DB {
return d.db
}
// AppliedVersions returns the migration versions recorded as applied,
// ascending. The healthcheck reports the highest of them, so an
// operator can see which schema a running instance is on without
// shelling into it.
func (d *Database) AppliedVersions(ctx context.Context) ([]int, error) {
rows, err := d.db.QueryContext(ctx,
"SELECT version FROM schema_migrations ORDER BY version",
)
if err != nil {
return nil, fmt.Errorf("reading applied migrations: %w", err)
}
defer func() { _ = rows.Close() }()
var versions []int
for rows.Next() {
var v int
scanErr := rows.Scan(&v)
if scanErr != nil {
return nil, fmt.Errorf("scanning migration version: %w", scanErr)
}
versions = append(versions, v)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("iterating applied migrations: %w", err)
}
return versions, nil
}
// Close releases the handle. Production uses the fx OnStop hook; tests
// call this.
func (d *Database) Close() error {
err := d.db.Close()
if err != nil {
return fmt.Errorf("closing database: %w", err)
}
return nil
}
+219
View File
@@ -0,0 +1,219 @@
package database_test
import (
"context"
"errors"
"path/filepath"
"testing"
"sneak.berlin/go/simplexcalc/internal/database"
)
// open returns a migrated scratch database in a directory the test
// framework removes afterwards.
func open(t *testing.T) *database.Database {
t.Helper()
db, err := database.NewForTest(t.Context(), t.TempDir())
if err != nil {
t.Fatalf("opening test database: %v", err)
}
t.Cleanup(func() {
closeErr := db.Close()
if closeErr != nil {
t.Errorf("closing test database: %v", closeErr)
}
})
return db
}
// TestMigrationsApplyFromClean is the claim the healthcheck and the
// container both rest on: an empty directory becomes a usable schema
// with no operator step in between.
func TestMigrationsApplyFromClean(t *testing.T) {
t.Parallel()
db := open(t)
versions, err := db.AppliedVersions(t.Context())
if err != nil {
t.Fatalf("reading applied versions: %v", err)
}
// 000 (the ledger) and 001 (widgets), which is every file the
// schema directory currently embeds.
if len(versions) != 2 || versions[0] != 0 || versions[1] != 1 {
t.Fatalf("applied versions = %v, want [0 1]", versions)
}
}
// TestMigrationsAreIdempotent: a restart re-runs Migrate against a
// database that already has the schema, and must change nothing. A
// migration runner that fails here takes the service down on every
// second start.
func TestMigrationsAreIdempotent(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := t.Context()
first, err := database.NewForTest(ctx, dir)
if err != nil {
t.Fatalf("first open: %v", err)
}
_, err = first.CreateWidget(ctx, "survivor", 1)
if err != nil {
t.Fatalf("creating widget: %v", err)
}
err = first.Close()
if err != nil {
t.Fatalf("closing: %v", err)
}
second, err := database.NewForTest(ctx, dir)
if err != nil {
t.Fatalf("reopening and re-migrating: %v", err)
}
defer func() { _ = second.Close() }()
versions, err := second.AppliedVersions(ctx)
if err != nil {
t.Fatalf("reading applied versions: %v", err)
}
if len(versions) != 2 {
t.Errorf("re-running migrations changed the ledger: %v", versions)
}
// The data has to still be there: a migration runner that "fixes"
// an already-migrated database by recreating tables is worse than
// one that fails.
count, err := second.CountWidgets(ctx)
if err != nil {
t.Fatalf("counting: %v", err)
}
if count != 1 {
t.Errorf("widget count = %d after reopen, want 1", count)
}
}
// TestWidgetRoundTrip exercises the query layer against the real
// schema, including the timestamp format shared between Go and the SQL
// DEFAULT.
func TestWidgetRoundTrip(t *testing.T) {
t.Parallel()
db := open(t)
ctx := t.Context()
created, err := db.CreateWidget(ctx, "widget one", 4096)
if err != nil {
t.Fatalf("creating widget: %v", err)
}
if created.ID == "" {
t.Error("created widget has no id")
}
widgets, err := db.ListWidgets(ctx, 10)
if err != nil {
t.Fatalf("listing widgets: %v", err)
}
if len(widgets) != 1 {
t.Fatalf("listed %d widgets, want 1", len(widgets))
}
got := widgets[0]
if got.ID != created.ID || got.Name != "widget one" || got.SizeBytes != 4096 {
t.Errorf("round trip lost data: %+v", got)
}
if got.CreatedAt.IsZero() {
t.Error("created_at did not survive the round trip")
}
}
// TestListWidgetsRespectsLimit: the index query is bounded, and the
// bound has to actually bind.
func TestListWidgetsRespectsLimit(t *testing.T) {
t.Parallel()
db := open(t)
ctx := t.Context()
for range 5 {
_, err := db.CreateWidget(ctx, "w", 1)
if err != nil {
t.Fatalf("creating widget: %v", err)
}
}
widgets, err := db.ListWidgets(ctx, 2)
if err != nil {
t.Fatalf("listing widgets: %v", err)
}
if len(widgets) != 2 {
t.Errorf("limit 2 returned %d rows", len(widgets))
}
}
// TestParseMigrationVersion covers the naming contract the schema
// directory has to keep. A file this rejects is a file that would
// otherwise be silently skipped.
func TestParseMigrationVersion(t *testing.T) {
t.Parallel()
good := map[string]int{
"000.sql": 0,
"001_widgets.sql": 1,
"017_thing.sql": 17,
}
for name, want := range good {
got, err := database.ParseMigrationVersion(name)
if err != nil {
t.Errorf("%s: unexpected error %v", name, err)
continue
}
if got != want {
t.Errorf("%s: version = %d, want %d", name, got, want)
}
}
for _, name := range []string{"widgets.sql", "_001.sql", "v1_widgets.sql"} {
_, err := database.ParseMigrationVersion(name)
if err == nil {
t.Errorf("%s: wanted a rejection, got none", name)
}
}
}
// TestOpenCreatesFile: Open must produce a database at the path it was
// given, not somewhere else.
func TestOpenCreatesFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db, err := database.Open(t.Context(), filepath.Join(dir, "explicit.db"))
if err != nil {
t.Fatalf("opening: %v", err)
}
defer func() { _ = db.Close() }()
err = db.PingContext(t.Context())
if err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("pinging the opened database: %v", err)
}
}
+199
View File
@@ -0,0 +1,199 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"path"
"sort"
"strconv"
"strings"
)
// bootstrapVersion is 000.sql: the migration that creates the ledger
// the others are recorded in.
const bootstrapVersion = 0
// errBadMigrationName is returned for a schema file whose name does not
// start with a version number. It is a build-time mistake, not a
// runtime condition, and it fails startup rather than being skipped —
// a migration silently not applied is the failure mode this whole
// mechanism exists to prevent.
var errBadMigrationName = errors.New(
"migration filename does not start with a version number",
)
// ParseMigrationVersion extracts the leading integer from a migration
// filename: "001_widgets.sql" is version 1. Exported so that a project
// seeded from this template can validate its own schema directory in a
// test.
func ParseMigrationVersion(name string) (int, error) {
base := name
if i := strings.IndexAny(base, "_."); i > 0 {
base = base[:i]
}
version, err := strconv.Atoi(base)
if err != nil {
return 0, fmt.Errorf("%w: %q", errBadMigrationName, name)
}
return version, nil
}
// migrationSet is one embedded directory of numbered .sql migrations
// (000 bootstrap plus schema files).
type migrationSet struct {
fsys fs.FS
dir string
}
// collect returns the set's migration filenames sorted
// lexicographically, which is why they are zero-padded.
func (m migrationSet) collect() ([]string, error) {
entries, err := fs.ReadDir(m.fsys, m.dir)
if err != nil {
return nil, fmt.Errorf("failed to read schema directory: %w", err)
}
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
return migrations, nil
}
// bootstrap ensures the schema_migrations table exists by applying
// 000.sql if the table is missing.
func (m migrationSet) bootstrap(
ctx context.Context, db *sql.DB, log *slog.Logger,
) error {
var tableExists int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableExists)
if err != nil {
return fmt.Errorf("failed to check for migrations table: %w", err)
}
if tableExists > 0 {
return nil
}
content, err := fs.ReadFile(m.fsys, path.Join(m.dir, "000.sql"))
if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
}
if log != nil {
log.Info("applying bootstrap migration", "version", bootstrapVersion)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
}
return nil
}
// applied reports whether the numbered migration has been recorded.
func (m migrationSet) applied(
ctx context.Context, db *sql.DB, version int,
) (bool, error) {
var count int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return false, fmt.Errorf("failed to check migration status: %w", err)
}
return count > 0, nil
}
// applyOne reads, executes, and records one migration file.
func (m migrationSet) applyOne(
ctx context.Context, db *sql.DB, migration string, version int,
) error {
content, err := fs.ReadFile(m.fsys, path.Join(m.dir, migration))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err)
}
_, execErr := db.ExecContext(ctx, string(content))
if execErr != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
}
_, recErr := db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if recErr != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
}
return nil
}
// apply runs all pending migrations of the set, in order. Idempotent:
// a second run over the same database applies nothing.
func (m migrationSet) apply(ctx context.Context, db *sql.DB, log *slog.Logger) error {
err := m.bootstrap(ctx, db, log)
if err != nil {
return err
}
migrations, err := m.collect()
if err != nil {
return err
}
for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil {
return parseErr
}
done, checkErr := m.applied(ctx, db, version)
if checkErr != nil {
return checkErr
}
if done {
if log != nil {
log.Debug("migration already applied", "version", version)
}
continue
}
if log != nil {
log.Info("applying migration", "version", version)
}
applyErr := m.applyOne(ctx, db, migration, version)
if applyErr != nil {
return applyErr
}
if log != nil {
log.Info("migration applied successfully", "version", version)
}
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package database
import (
"context"
"fmt"
"time"
// Go has no UUID in the standard library as of go1.25 — checked
// against this repo's toolchain, not assumed. Swap this import for
// the stdlib package the moment one lands; nothing else here
// depends on the implementation.
"github.com/google/uuid"
)
// timeFormat matches the strftime pattern the schema uses for its
// defaults, so rows written by Go and rows written by a DEFAULT sort
// against each other correctly.
const timeFormat = "2006-01-02T15:04:05.000Z"
// Widget is the example row type. It exists so that the migration
// runner, the query layer, the templates and the tests all exercise
// real data. Delete it when seeding a real project.
type Widget struct {
ID string
Name string
SizeBytes int64
CreatedAt time.Time
}
// CreateWidget inserts a widget and returns it as stored.
func (d *Database) CreateWidget(
ctx context.Context, name string, size int64,
) (*Widget, error) {
w := &Widget{
ID: uuid.NewString(),
Name: name,
SizeBytes: size,
CreatedAt: time.Now().UTC(),
}
_, err := d.db.ExecContext(ctx,
`INSERT INTO widgets (id, name, size_bytes, created_at) VALUES (?, ?, ?, ?)`,
w.ID, w.Name, w.SizeBytes, w.CreatedAt.Format(timeFormat),
)
if err != nil {
return nil, fmt.Errorf("inserting widget: %w", err)
}
return w, nil
}
// ListWidgets returns the most recently created widgets, newest first,
// up to limit.
func (d *Database) ListWidgets(ctx context.Context, limit int) ([]Widget, error) {
rows, err := d.db.QueryContext(ctx,
`SELECT id, name, size_bytes, created_at
FROM widgets
ORDER BY created_at DESC, id DESC
LIMIT ?`,
limit,
)
if err != nil {
return nil, fmt.Errorf("listing widgets: %w", err)
}
defer func() { _ = rows.Close() }()
widgets := []Widget{}
for rows.Next() {
var (
w Widget
createdAt string
)
scanErr := rows.Scan(&w.ID, &w.Name, &w.SizeBytes, &createdAt)
if scanErr != nil {
return nil, fmt.Errorf("scanning widget: %w", scanErr)
}
w.CreatedAt, scanErr = time.Parse(timeFormat, createdAt)
if scanErr != nil {
return nil, fmt.Errorf("parsing widget created_at %q: %w", createdAt, scanErr)
}
widgets = append(widgets, w)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("iterating widgets: %w", err)
}
return widgets, nil
}
// CountWidgets returns the number of widgets stored.
func (d *Database) CountWidgets(ctx context.Context) (int, error) {
var n int
err := d.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM widgets`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("counting widgets: %w", err)
}
return n, nil
}
+15
View File
@@ -0,0 +1,15 @@
-- 000.sql: the bootstrap migration. It creates only the ledger that
-- records which migrations have run; every other migration is recorded
-- in it. Applied when the schema_migrations table is missing, and never
-- again.
--
-- Never edit an applied migration. Add a new numbered file instead: the
-- ledger records versions, not contents, so an edited file is applied
-- nowhere and diverges everywhere.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
+15
View File
@@ -0,0 +1,15 @@
-- 001_widgets.sql: the example table. Delete it when seeding a real
-- project and start your own schema at 001 — nothing has been deployed
-- yet, so there is no ledger anywhere that would disagree.
--
-- It is here so that the template's migration runner, model layer and
-- tests all exercise a real table rather than an empty database.
CREATE TABLE IF NOT EXISTS widgets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS widgets_created_at ON widgets (created_at);