fix(build): order scaffolded migrations after every existing one (quick-261028-pq8)
- nextMigrationFile starts at one second past the newest updates/ timestamp when that is not earlier than now, so registry order is creation order - frozen-clock unit test and a registry-order check in the scaffold test - docs: drop the same-second caveat and the manual order check
This commit is contained in:
@@ -562,12 +562,31 @@ func identKind(pkg string) identScanKind {
|
||||
}
|
||||
}
|
||||
|
||||
// migrationStampLen is the length of the timestamp that prefixes every
|
||||
// scaffolded migration file name.
|
||||
const migrationStampLen = 14
|
||||
|
||||
const migrationStampLayout = "20060102150405"
|
||||
|
||||
// nextMigrationFile picks the updates/ file for a new migration. Its
|
||||
// timestamp is now, or one second after the newest migration already in
|
||||
// updates/ when that one is not earlier than now, so the registry, which sorts
|
||||
// by file name, lists migrations in creation order even when several are made
|
||||
// within one second.
|
||||
func nextMigrationFile(pluginDir, slug string) (id, rel string, err error) {
|
||||
if err := validateTableName(slug); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
updates := filepath.Join(pluginDir, "updates")
|
||||
ts := migrationNow().Format("20060102150405")
|
||||
next := migrationNow().Truncate(time.Second)
|
||||
newest, ok, err := newestMigrationStamp(updates)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ok && !next.After(newest) {
|
||||
next = newest.Add(time.Second)
|
||||
}
|
||||
ts := next.Format(migrationStampLayout)
|
||||
for i := 0; i < 60; i++ {
|
||||
id = fmt.Sprintf("%s_%s", ts, slug)
|
||||
rel = filepath.Join("updates", id+".go")
|
||||
@@ -585,15 +604,45 @@ func nextMigrationFile(pluginDir, slug string) (id, rel string, err error) {
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("build: stat %s: %w", path, err)
|
||||
}
|
||||
next, parseErr := time.Parse("20060102150405", ts)
|
||||
if parseErr != nil {
|
||||
return "", "", fmt.Errorf("build: migration timestamp: %w", parseErr)
|
||||
}
|
||||
ts = next.Add(time.Second).Format("20060102150405")
|
||||
next = next.Add(time.Second)
|
||||
ts = next.Format(migrationStampLayout)
|
||||
}
|
||||
return "", "", fmt.Errorf("build: could not allocate a unique migration id for %s", slug)
|
||||
}
|
||||
|
||||
// newestMigrationStamp returns the latest timestamp among the .go files in
|
||||
// updates whose name starts with 14 digits and an underscore. Other files
|
||||
// (doc.go, tests) are ignored; a missing directory has none.
|
||||
func newestMigrationStamp(updates string) (time.Time, bool, error) {
|
||||
entries, err := os.ReadDir(updates)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return time.Time{}, false, fmt.Errorf("build: read %s: %w", updates, err)
|
||||
}
|
||||
var newest time.Time
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || len(name) <= migrationStampLen || name[migrationStampLen] != '_' {
|
||||
continue
|
||||
}
|
||||
stamp := name[:migrationStampLen]
|
||||
if strings.IndexFunc(stamp, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
|
||||
continue
|
||||
}
|
||||
ts, err := time.Parse(migrationStampLayout, stamp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !found || ts.After(newest) {
|
||||
newest, found = ts, true
|
||||
}
|
||||
}
|
||||
return newest, found, nil
|
||||
}
|
||||
|
||||
func modelTableName(pluginID, typeName string) (string, error) {
|
||||
table := strings.ReplaceAll(pluginID, ".", "_") + "_" + pluralSnake(typeName)
|
||||
if err := validateTableName(table); err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
@@ -539,6 +540,9 @@ func TestScaffoldPluginSmoke(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScaffoldCoreArtifacts(t *testing.T) {
|
||||
// One frozen second for make:model and make:migration: the registry must
|
||||
// still list CreateAlbums before AddWidgetIndex.
|
||||
freezeMigrationClock(t, time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC))
|
||||
dir := copyHelloApp(t)
|
||||
ctx := t.Context()
|
||||
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
|
||||
@@ -655,6 +659,9 @@ func TestScaffoldCoreArtifacts(t *testing.T) {
|
||||
t.Fatalf("registry.gen.go missing %s:\n%s", want, reg1)
|
||||
}
|
||||
}
|
||||
if create, add := bytes.Index(reg1, []byte("updates.CreateAlbums()")), bytes.Index(reg1, []byte("updates.AddWidgetIndex()")); create > add {
|
||||
t.Fatalf("registry.gen.go lists AddWidgetIndex before CreateAlbums:\n%s", reg1)
|
||||
}
|
||||
|
||||
pluginAfter, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go"))
|
||||
if err != nil {
|
||||
@@ -1325,3 +1332,95 @@ func write(t *testing.T, path, body string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// freezeMigrationClock pins migrationNow to at for the rest of the test.
|
||||
func freezeMigrationClock(t *testing.T, at time.Time) {
|
||||
t.Helper()
|
||||
prev := migrationNow
|
||||
migrationNow = func() time.Time { return at }
|
||||
t.Cleanup(func() { migrationNow = prev })
|
||||
}
|
||||
|
||||
// TestNextMigrationFileSortsAfterExisting: a new migration's timestamp is
|
||||
// later than every migration already in updates/, so file-name order is
|
||||
// creation order even within one second.
|
||||
func TestNextMigrationFileSortsAfterExisting(t *testing.T) {
|
||||
now := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("same second", func(t *testing.T) {
|
||||
freezeMigrationClock(t, now)
|
||||
dir := t.TempDir()
|
||||
firstID, firstRel, err := nextMigrationFile(dir, "create_widgets")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstID != "20261028120000_create_widgets" {
|
||||
t.Fatalf("first id = %s", firstID)
|
||||
}
|
||||
write(t, filepath.Join(dir, firstRel), "package updates\n")
|
||||
secondID, secondRel, err := nextMigrationFile(dir, "add_index")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondID != "20261028120001_add_index" {
|
||||
t.Fatalf("second id = %s", secondID)
|
||||
}
|
||||
if !(firstRel < secondRel) {
|
||||
t.Fatalf("%s does not sort before %s", firstRel, secondRel)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing later than now", func(t *testing.T) {
|
||||
freezeMigrationClock(t, now)
|
||||
dir := t.TempDir()
|
||||
updates := filepath.Join(dir, "updates")
|
||||
if err := os.MkdirAll(updates, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(updates, "20990101000000_x.go"), "package updates\n")
|
||||
write(t, filepath.Join(updates, "20200101000000_old.go"), "package updates\n")
|
||||
id, _, err := nextMigrationFile(dir, "next")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "20990101000001_next" {
|
||||
t.Fatalf("id = %s, want 20990101000001_next", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-migration files ignored", func(t *testing.T) {
|
||||
freezeMigrationClock(t, now)
|
||||
dir := t.TempDir()
|
||||
updates := filepath.Join(dir, "updates")
|
||||
if err := os.MkdirAll(updates, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"doc.go", "99990101000000x.go", "migrations_test.go", "20990101000000_notes.txt", "2099010100000a_bad.go"} {
|
||||
write(t, filepath.Join(updates, name), "package updates\n")
|
||||
}
|
||||
id, _, err := nextMigrationFile(dir, "plain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "20261028120000_plain" {
|
||||
t.Fatalf("id = %s, want the frozen now", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("older existing keeps now", func(t *testing.T) {
|
||||
freezeMigrationClock(t, now)
|
||||
dir := t.TempDir()
|
||||
updates := filepath.Join(dir, "updates")
|
||||
if err := os.MkdirAll(updates, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(updates, "20200101000000_old.go"), "package updates\n")
|
||||
id, _, err := nextMigrationFile(dir, "plain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "20261028120000_plain" {
|
||||
t.Fatalf("id = %s, want the frozen now", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user