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

@@ -115,7 +115,7 @@ summer make:model Comment
| `summer make:job` | `jobs/<name>.go` | A typed job built with `conga.Job`; the plugin never imports the queue library. |
| `summer make:admin-controller` | `controllers/<name>.go`, `controllers/<name>/config_form.yaml`, `controllers/<name>/config_list.yaml`, `models/<name>/fields.yaml`, `models/<name>/columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. It already requires the permission `<plugin>.access_<name>`, which you declare in the plugin's `Permissions()`, and its `NewRecord` returns `nil` until you return your model, so the screens stay closed until you finish it. |
File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit timestamp, so they sort in the order you created them. A second migration with the same name in the same second gets the next second's timestamp, but migrations with different names created in the same second share one timestamp and sort by name; check the order in `updates/`, as [Porting a plugin](../setup/porting-a-plugin.md) describes.
File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit UTC timestamp. Each new migration gets a timestamp after every file already in `updates/`: the current second, or one second past the newest existing migration when that is not earlier. File names therefore sort in creation order, and so does the list in `registry.gen.go`, even for migrations made within one second.
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.

View File

@@ -16,7 +16,7 @@ WinterCMS plugins keep their schema in the `updates/` directory and list the ste
summer make:migration acme.blog AddPublishedAt
```
The file is `updates/<timestamp>_add_published_at.go`, with a migration ID that starts with the same timestamp. `summer build` generates the plugin's list of migrations in file name order, so the timestamp is also the order in which they run. The scaffolded plugin returns that generated list from its `Migrations` method.
The file is `updates/<timestamp>_add_published_at.go`, with a migration ID that starts with the same timestamp. The timestamp is always later than every migration already in `updates/`, even when you create several in one second. `summer build` generates the plugin's list of migrations in file name order, so the timestamp is also the order in which they run. The scaffolded plugin returns that generated list from its `Migrations` method.
A migration has an ID, a `Migrate` function and a `Rollback` function, both given the transaction to run in. Write DDL as SQL with `tx.Exec`: the migration then says exactly what the database gets, and it keeps working when the model struct changes later. This is the whole set of an example plugin, written out by hand:

View File

@@ -379,7 +379,7 @@ class CreatePostsTable extends Migration
}
```
In SummerCMS each migration is a gormigrate entry in `updates/`, in a file named after a UTC timestamp so the files sort in the order they run. There is no `version.yaml`: the plugin returns its migrations in order from `pact.HasMigrations`, and each plugin keeps its own history table. The migration `summer make:model` wrote creates the table with `id` and the timestamps; add the model's columns to it:
In SummerCMS each migration is a gormigrate entry in `updates/`, in a file named after a UTC timestamp so the files sort in the order they run. Each new migration gets a timestamp after every file already in `updates/`, so the order is the order you created them in. The example uses fixed timestamps, `20260101000000` and `20260101000100`; yours carry the time you ran the command. There is no `version.yaml`: the plugin returns its migrations in order from `pact.HasMigrations`, and each plugin keeps its own history table. The migration `summer make:model` wrote creates the table with `id` and the timestamps; add the model's columns to it:
```go src=docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go#CreatePosts
// CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry.
@@ -820,7 +820,6 @@ See [Writing commands](../console/writing-commands.md) for arguments, flags, pro
The `make:` commands write files that compile, not a finished plugin. These are the steps this walkthrough had to do by hand, and the scaffolder behaviour behind them:
- **The generated-code header stays.** Files the `make:` commands write start with `// Code generated by summer make. DO NOT EDIT.`, although you are meant to edit them. Keep the line: the commands rebuild `registry.gen.go` from the files that carry it, so a model, migration, command or admin controller whose header you delete disappears from the accessors the next time you run a `make:` command. Linters also treat these files as generated and skip them.
- **Check the migration order.** A migration's file name and ID start with the second it was created in. Migrations with different names created in the same second get the same timestamp and run in file-name order, so `add_published_at` would run before `create_acme_blog_posts` and fail on the missing table. Look at `updates/` after scaffolding; if two files share a timestamp, rename the later one and its ID. The example uses fixed timestamps, `20260101000000` and `20260101000100`.
- **The admin controller names the model after itself.** `make:admin-controller acme.blog Posts` returns `Posts` from `ModelName` and writes `modelClass: Posts`, and it puts `fields.yaml` and `columns.yaml` under `models/posts/`. Change `ModelName` and both `modelClass` values to the model, `Post`; the YAML can stay where it is, as in this example.
- **The admin controller needs more than the scaffolder writes.** The generated controller implements only `pact.AdminController`. Add `NewRecord` (`pact.AdminRecordSource`) so the admin API has a model to query, and `RequiredPermissions` (`pact.AdminPermissioned`): without it, any signed-in administrator can open the controller. The model needs `Fillable` and `Rules` before the admin API can save it. The plugin needs `AdminFS` (`pact.AdminAssets`) to embed the YAML; without it the application refuses to start once the admin is enabled.
- **A command that needs the application takes it as a parameter.** The function `make:command` writes takes no arguments, which is what the generated accessor looks for, so it cannot reach the database or the config. Give it the dependency as a parameter and return it from `Commands` yourself, as `blog:publish` does.

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