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:
Jakub Zych
2026-10-28 18:49:17 +01:00
parent 017e0e9a6a
commit a941ab2394
5 changed files with 157 additions and 10 deletions

View File

@@ -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 {

View File

@@ -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)
}
})
}