Build every database's schema from embedded SQL files (closes #56)
check / check (push) Waiting to run

The main database, each webhook's event database and each archive file
get their schema from SQL files under internal/db/migrations/, one
directory per kind. Opening a file runs, in one transaction, each file
that schema_migrations does not list. GORM stays for queries.
AutoMigrate is gone, tests included.

An event database holds only its own tables now, so the sweep that
removed target rows copied into it is deleted. The association guard
stays: without it a delivery write fails on the missing targets table.

AutoMigrate also had GORM parse every model before a handle was shared.
That is now done on its own: GORM races when two goroutines first use
models that refer to each other.

Model: opus-5-5
This commit is contained in:
2026-10-13 03:05:22 +00:00
parent 957de7f99f
commit 46debb60d5
25 changed files with 731 additions and 690 deletions
+6 -7
View File
@@ -22,6 +22,7 @@ import (
_ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
)
@@ -172,9 +173,7 @@ func seedArchiveFile(
)
require.NoError(t, err)
require.NoError(
t, gdb.AutoMigrate(&delivery.ExportArchivedEvent{}),
)
require.NoError(t, migrations.Apply(gdb, migrations.Archive))
for i, at := range archivedAt {
row := delivery.ExportArchivedEvent{
@@ -781,9 +780,9 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
//
// The spec is stronger than that — a "never" archive is skipped
// before any file is touched — so the archive here exists but has
// never been migrated. Opening it at all would run AutoMigrate
// and create the archive table, which is exactly what must not
// happen.
// never been migrated. Opening it at all would apply the archive
// schema and create the archive table, which is exactly what must
// not happen.
func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
t *testing.T,
) {
@@ -807,7 +806,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
// seedUnmigratedArchive creates an archive file that exists but
// carries no archive schema, so any open of it is observable: the
// archive table appears only if something ran AutoMigrate.
// archive table appears only if something applied the schema.
func seedUnmigratedArchive(t *testing.T, path string) {
t.Helper()
+3 -20
View File
@@ -963,29 +963,12 @@ func TestFailUnretryableRetry_WritesNoTargetRow(
database.DeliveryStatusFailed,
)
// The table exists in the per-webhook database because GORM
// migrates the Delivery relation's model alongside it. It must
// stay empty.
var targetRows int64
require.NoError(t, s.WebhookDB.
Table("targets").
Count(&targetRows).Error)
assert.Zero(t, targetRows,
// The per-webhook database has no targets table, so writing a
// target row there would have failed the status write above.
assert.False(t, s.WebhookDB.Migrator().HasTable("targets"),
"orphaned-retry terminal failure wrote a target row "+
"into the per-webhook event database",
)
var configs []string
require.NoError(t, s.WebhookDB.
Table("targets").
Pluck("config", &configs).Error)
assert.NotContains(
t, strings.Join(configs, " "), hookURL,
)
}
func TestRecoverSingleRetry_UnknownTargetType(
+9 -28
View File
@@ -10,7 +10,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -22,12 +21,11 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/metrics"
)
@@ -37,35 +35,18 @@ const testContentType = "application/json"
func testWebhookDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(
t.TempDir(), "events-test.db",
// Opened by the manager the service uses, so these tests cannot
// pass against a schema, journal and locking settings or write
// guard production does not use.
mgr := databasetest.NewWebhookDBManagerWithLogger(
t, t.TempDir(), slog.New(slog.DiscardHandler),
)
// Opened the way the service opens a per-webhook database, so
// these tests cannot pass against journal and locking settings
// production does not use.
sqlDB, err := database.OpenSQLite(
dbPath, database.SQLiteModeCreate,
)
t.Cleanup(func() { _ = mgr.CloseAll() })
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Event{},
&database.Delivery{},
&database.DeliveryResult{},
&database.EventTotals{},
&database.TargetTotals{},
))
require.NoError(t, db.Create(&database.EventTotals{}).Error)
return db
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog"
)
@@ -335,7 +336,7 @@ func (w *archiveWriter) openMode(
)
}
err = gdb.AutoMigrate(&archivedEvent{})
err = migrations.Apply(gdb, migrations.Archive)
if err != nil {
_ = sqlDB.Close()
+3 -7
View File
@@ -312,13 +312,9 @@ func TestFailMissingTarget_WritesNoTargetRow(
database.DeliveryStatusFailed,
)
var configs []string
require.NoError(t, s.WebhookDB.
Table("targets").
Pluck("config", &configs).Error)
assert.Empty(t, configs,
// The per-webhook database has no targets table, so writing a
// target row there would have failed the status write above.
assert.False(t, s.WebhookDB.Migrator().HasTable("targets"),
"the deleted-target terminal path wrote a target row "+
"into the per-webhook event database",
)