feat(03-01): add shared postgres pool and plugin migrations
Open one pgx stdlib *sql.DB, hand it to GORM, and run per-plugin gormigrate sets with isolated history tables after an ICU pl-PL check. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -82,7 +82,7 @@ func (in cobraInput) Flag(name string) (string, bool) {
|
||||
|
||||
func validCommandName(name string) bool {
|
||||
switch name {
|
||||
case "build", "dev":
|
||||
case "build", "dev", "serve", "migrate":
|
||||
return true
|
||||
}
|
||||
ns, verb, ok := strings.Cut(name, ":")
|
||||
|
||||
14
bonfire/command_test.go
Normal file
14
bonfire/command_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidBareRuntimeCommandNames(t *testing.T) {
|
||||
for _, name := range []string{"serve", "migrate", "migrate:rollback", "migrate:status", "build", "dev"} {
|
||||
if _, err := NewRoot("app", []Command{{Name: name}}, io.Discard); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ func toolCommands() []bonfire.Command {
|
||||
parityProxyCommand(),
|
||||
parityRecordCommand(),
|
||||
parityReplayCommand(),
|
||||
delegateCommand("migrate", "Run plugin migrations in the app binary"),
|
||||
delegateRollbackCommand(),
|
||||
delegateCommand("migrate:status", "Show per-plugin migration history in the app binary"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
|
||||
for _, c := range toolCommands() {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
for _, want := range []string{"build", "make:plugin", "plugin:add", "dev"} {
|
||||
for _, want := range []string{"build", "make:plugin", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("missing %s in %v", want, names)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestToolDoesNotImportExamplePlugins(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(path, "examples/hello") {
|
||||
if strings.Contains(path, "examples/hello") || strings.Contains(path, "golem15/fonoteka") {
|
||||
t.Fatalf("%s imports %s", name, path)
|
||||
}
|
||||
}
|
||||
|
||||
96
cmd/summer/runtime.go
Normal file
96
cmd/summer/runtime.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/bonfire"
|
||||
"git.golem15.com/golem15/summercms/internal/build"
|
||||
)
|
||||
|
||||
func delegateCommand(name, description string) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
argv := []string{name}
|
||||
argv = append(argv, in.Args()...)
|
||||
return runAppBinary(ctx, argv, out)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func delegateRollbackCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "migrate:rollback",
|
||||
Description: "Roll back the last migration of a plugin in the app binary",
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "plugin",
|
||||
Description: "Plugin ID whose last migration to roll back",
|
||||
}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
argv := []string{"migrate:rollback"}
|
||||
if plugin, ok := in.Flag("plugin"); ok && plugin != "" {
|
||||
argv = append(argv, "--plugin", plugin)
|
||||
}
|
||||
argv = append(argv, in.Args()...)
|
||||
return runAppBinary(ctx, argv, out)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error {
|
||||
if len(argv) == 0 {
|
||||
return fmt.Errorf("summer: missing app command")
|
||||
}
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appDir, err := build.FindAppDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bin := filepath.Join(appDir, "bin", m.Binary)
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
if err := build.App(ctx, appDir, out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err == nil {
|
||||
if same, _ := sameFile(self, bin); same {
|
||||
return fmt.Errorf("summer: refusing to recurse into %s", bin)
|
||||
}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, bin, argv...)
|
||||
cmd.Dir = appDir
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameFile(a, b string) (bool, error) {
|
||||
ai, err := os.Stat(a)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
bi, err := os.Stat(b)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return os.SameFile(ai, bi), nil
|
||||
}
|
||||
@@ -104,6 +104,14 @@ func TestBuiltBinaryGreeterHello(t *testing.T) {
|
||||
assertGreeting(t, got, want)
|
||||
}
|
||||
|
||||
func TestMigrateWithoutDSNFailsLoudly(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := run([]string{"migrate"}, &buf)
|
||||
if err == nil || !strings.Contains(err.Error(), "database.dsn") {
|
||||
t.Fatalf("want empty DSN error, got %v\n%s", err, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltBinaryUnknownCommandExitsNonZero(t *testing.T) {
|
||||
runSummerBuild(t)
|
||||
cmd := exec.Command("./bin/hello", "does:not-exist")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bonfire"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"git.golem15.com/golem15/summercms/lagoon"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
)
|
||||
@@ -31,7 +32,7 @@ func run(args []string, out io.Writer) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var commands []bonfire.Command
|
||||
commands := lagoon.RuntimeCommands(app, plugins)
|
||||
for _, plugin := range plugins {
|
||||
if hasCommands, ok := plugin.(pact.HasCommands); ok {
|
||||
commands = append(commands, hasCommands.Commands()...)
|
||||
|
||||
12
go.mod
12
go.mod
@@ -6,7 +6,9 @@ toolchain go1.27.0
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1
|
||||
github.com/knadh/koanf/providers/confmap v1.0.1
|
||||
github.com/knadh/koanf/providers/env/v2 v2.0.1
|
||||
@@ -14,15 +16,25 @@ require (
|
||||
github.com/knadh/koanf/v2 v2.3.6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/term v0.46.0
|
||||
gorm.io/driver/postgres v1.6.3
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
||||
45
go.sum
45
go.sum
@@ -1,14 +1,29 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
|
||||
@@ -21,31 +36,49 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP
|
||||
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
||||
github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
|
||||
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
|
||||
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
@@ -85,6 +85,7 @@ func generateMain(m Manifest) ([]byte, error) {
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/bonfire\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/compass\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/lagoon\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/pact\"\n")
|
||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n")
|
||||
b.WriteString(")\n\n")
|
||||
@@ -104,7 +105,7 @@ func generateMain(m Manifest) ([]byte, error) {
|
||||
b.WriteString("\tif err != nil {\n")
|
||||
b.WriteString("\t\treturn err\n")
|
||||
b.WriteString("\t}\n")
|
||||
b.WriteString("\tvar commands []bonfire.Command\n")
|
||||
b.WriteString("\tcommands := lagoon.RuntimeCommands(app, plugins)\n")
|
||||
b.WriteString("\tfor _, plugin := range plugins {\n")
|
||||
b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n")
|
||||
b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n")
|
||||
|
||||
@@ -94,6 +94,9 @@ func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) {
|
||||
if !bytes.Contains(mainSrc, []byte(`bonfire.NewRoot("hello"`)) {
|
||||
t.Fatalf("main does not use manifest binary:\n%s", mainSrc)
|
||||
}
|
||||
if !bytes.Contains(mainSrc, []byte("lagoon.RuntimeCommands")) {
|
||||
t.Fatalf("main does not register lagoon runtime commands:\n%s", mainSrc)
|
||||
}
|
||||
if bytes.Contains(mainSrc, []byte("examples/hello")) {
|
||||
t.Fatal("generated main hard-codes examples/hello")
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ func ValidatePluginID(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindAppDir walks up from start looking for summer.yaml.
|
||||
func FindAppDir(start string) (string, error) {
|
||||
return findAppDir(start)
|
||||
}
|
||||
|
||||
func findAppDir(start string) (string, error) {
|
||||
if start == "" {
|
||||
return "", fmt.Errorf("build: app directory is empty")
|
||||
|
||||
86
lagoon/commands.go
Normal file
86
lagoon/commands.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bonfire"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RuntimeCommands returns migrate, migrate:rollback and migrate:status.
|
||||
// Serve is registered once the HTTP layer exists.
|
||||
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
|
||||
return []bonfire.Command{
|
||||
{
|
||||
Name: "migrate",
|
||||
Description: "Run plugin migrations in dependency order",
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
if err := Migrate(gdb, plugins); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Success("migrations applied")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "migrate:rollback",
|
||||
Description: "Roll back the last migration of a plugin",
|
||||
Flags: []bonfire.Flag{{
|
||||
Name: "plugin",
|
||||
Description: "Plugin ID whose last migration to roll back",
|
||||
}},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
plugin, _ := in.Flag("plugin")
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
if err := RollbackLast(gdb, plugins, plugin); err != nil {
|
||||
return err
|
||||
}
|
||||
if plugin == "" {
|
||||
plugin = lastMigrationPlugin(plugins)
|
||||
}
|
||||
out.Success(fmt.Sprintf("rolled back last migration of %s", plugin))
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "migrate:status",
|
||||
Description: "Show per-plugin migration history",
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
rows, err := Status(gdb, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableRows := make([][]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids := "(none)"
|
||||
if len(row.IDs) > 0 {
|
||||
ids = fmt.Sprintf("%d: %s", len(row.IDs), row.IDs[len(row.IDs)-1])
|
||||
}
|
||||
tableRows = append(tableRows, []string{row.Plugin, row.Table, ids})
|
||||
}
|
||||
out.Table([]string{"plugin", "table", "applied"}, tableRows)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func withDB(ctx context.Context, app *backpack.App, fn func(*gorm.DB) error) error {
|
||||
sqlDB, gdb, err := OpenFromApp(ctx, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
if err := Publish(app, sqlDB, gdb); err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(gdb)
|
||||
}
|
||||
136
lagoon/connection.go
Normal file
136
lagoon/connection.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
requiredLocaleProvider = "i"
|
||||
requiredICULocale = "pl-PL"
|
||||
)
|
||||
|
||||
// Open pings dsn through pgx stdlib, requires Postgres 16 ICU pl-PL, and
|
||||
// returns that exact *sql.DB plus a GORM handle opened on it.
|
||||
//
|
||||
// Phase 11 owns a separate pgxpool.Pool for River LISTEN/NOTIFY. Do not
|
||||
// create that listener pool here; application queries share this *sql.DB.
|
||||
func Open(ctx context.Context, dsn string) (*sql.DB, *gorm.DB, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return nil, nil, fmt.Errorf("lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)")
|
||||
}
|
||||
sqlDB, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("lagoon: open postgres: %w", err)
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, nil, fmt.Errorf("lagoon: ping postgres: %w", err)
|
||||
}
|
||||
if err := CheckLocale(ctx, sqlDB); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
gdb, err := gormFromSQL(sqlDB)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return sqlDB, gdb, nil
|
||||
}
|
||||
|
||||
// Use pings an existing pool, requires ICU pl-PL, and returns a GORM handle
|
||||
// opened on that exact *sql.DB. Callers that already hold a pool (tests,
|
||||
// the app boot seam) must not open a second connection.
|
||||
func Use(ctx context.Context, sqlDB *sql.DB) (*gorm.DB, error) {
|
||||
if sqlDB == nil {
|
||||
return nil, fmt.Errorf("lagoon: sql db is nil")
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("lagoon: ping postgres: %w", err)
|
||||
}
|
||||
if err := CheckLocale(ctx, sqlDB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gormFromSQL(sqlDB)
|
||||
}
|
||||
|
||||
func gormFromSQL(sqlDB *sql.DB) (*gorm.DB, error) {
|
||||
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: gorm open: %w", err)
|
||||
}
|
||||
got, err := gdb.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: gorm sql handle: %w", err)
|
||||
}
|
||||
if got != sqlDB {
|
||||
return nil, fmt.Errorf("lagoon: GORM is not using the shared *sql.DB")
|
||||
}
|
||||
return gdb, nil
|
||||
}
|
||||
|
||||
// OpenFromApp reads database.dsn from app config and opens the shared pool.
|
||||
func OpenFromApp(ctx context.Context, app *backpack.App) (*sql.DB, *gorm.DB, error) {
|
||||
if app == nil || app.Config == nil {
|
||||
return nil, nil, fmt.Errorf("lagoon: app config is missing")
|
||||
}
|
||||
return Open(ctx, DSN(app.Config))
|
||||
}
|
||||
|
||||
// DSN returns database.dsn from layered config (env SUMMER_DATABASE__DSN).
|
||||
func DSN(cfg *compass.Config) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.String("database.dsn"))
|
||||
}
|
||||
|
||||
// Publish stores the shared SQL pool and GORM handle on the app. Both
|
||||
// handles refer to the same *sql.DB.
|
||||
func Publish(app *backpack.App, sqlDB *sql.DB, gdb *gorm.DB) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("lagoon: app is nil")
|
||||
}
|
||||
if sqlDB == nil || gdb == nil {
|
||||
return fmt.Errorf("lagoon: database handles are nil")
|
||||
}
|
||||
if err := app.Publish(sqlDB); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Publish(gdb)
|
||||
}
|
||||
|
||||
// CheckLocale fails unless the connected database uses ICU locale pl-PL.
|
||||
func CheckLocale(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("lagoon: sql db is nil")
|
||||
}
|
||||
var provider, icu string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT datlocprovider::text, COALESCE(daticulocale, '')
|
||||
FROM pg_database
|
||||
WHERE datname = current_database()`).Scan(&provider, &icu)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lagoon: read database locale: %w", err)
|
||||
}
|
||||
return checkLocale(provider, icu)
|
||||
}
|
||||
|
||||
func checkLocale(provider, icu string) error {
|
||||
provider = strings.TrimSpace(provider)
|
||||
icu = strings.TrimSpace(icu)
|
||||
if provider == requiredLocaleProvider && icu == requiredICULocale {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("lagoon: database locale must be ICU pl-PL (datlocprovider=%q, daticulocale=%q); got provider %q locale %q. Create the database with: CREATE DATABASE ... TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'", requiredLocaleProvider, requiredICULocale, provider, icu)
|
||||
}
|
||||
155
lagoon/migrations.go
Normal file
155
lagoon/migrations.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const historyTablePrefix = "summer_migrations_"
|
||||
|
||||
// HistoryTableName returns the isolated gormigrate table for pluginID.
|
||||
func HistoryTableName(pluginID string) (string, error) {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return historyTablePrefix + strings.ReplaceAll(pluginID, ".", "_"), nil
|
||||
}
|
||||
|
||||
func validatePluginID(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("lagoon: plugin id is empty")
|
||||
}
|
||||
for _, r := range id {
|
||||
if r == '.' || unicode.IsLower(r) || unicode.IsDigit(r) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("lagoon: plugin id %q is not a valid history table name", id)
|
||||
}
|
||||
if strings.Contains(id, "..") || strings.HasPrefix(id, ".") || strings.HasSuffix(id, ".") {
|
||||
return fmt.Errorf("lagoon: plugin id %q is not a valid history table name", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrator(gdb *gorm.DB, pluginID string, migrations []*gormigrate.Migration) (*gormigrate.Gormigrate, error) {
|
||||
table, err := HistoryTableName(pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gormigrate.New(gdb, &gormigrate.Options{
|
||||
TableName: table,
|
||||
UseTransaction: true,
|
||||
}, migrations), nil
|
||||
}
|
||||
|
||||
// Migrate runs each plugin's HasMigrations set in party.Activate order.
|
||||
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error {
|
||||
if gdb == nil {
|
||||
return fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
for _, p := range plugins {
|
||||
hm, ok := p.(pact.HasMigrations)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
migrations := hm.Migrations()
|
||||
if len(migrations) == 0 {
|
||||
continue
|
||||
}
|
||||
m, err := migrator(gdb, p.ID(), migrations)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Migrate(); err != nil {
|
||||
return fmt.Errorf("lagoon: migrate %s: %w", p.ID(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackLast rolls back the last migration of pluginID.
|
||||
func RollbackLast(gdb *gorm.DB, plugins []party.Plugin, pluginID string) error {
|
||||
if gdb == nil {
|
||||
return fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
pluginID = strings.TrimSpace(pluginID)
|
||||
if pluginID == "" {
|
||||
pluginID = lastMigrationPlugin(plugins)
|
||||
}
|
||||
if pluginID == "" {
|
||||
return fmt.Errorf("lagoon: no plugin migrations to roll back")
|
||||
}
|
||||
for _, p := range plugins {
|
||||
if p.ID() != pluginID {
|
||||
continue
|
||||
}
|
||||
hm, ok := p.(pact.HasMigrations)
|
||||
if !ok {
|
||||
return fmt.Errorf("lagoon: plugin %q has no migrations", pluginID)
|
||||
}
|
||||
m, err := migrator(gdb, p.ID(), hm.Migrations())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
return fmt.Errorf("lagoon: rollback %s: %w", pluginID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("lagoon: plugin %q is not activated", pluginID)
|
||||
}
|
||||
|
||||
func lastMigrationPlugin(plugins []party.Plugin) string {
|
||||
for i := len(plugins) - 1; i >= 0; i-- {
|
||||
p := plugins[i]
|
||||
hm, ok := p.(pact.HasMigrations)
|
||||
if !ok || len(hm.Migrations()) == 0 {
|
||||
continue
|
||||
}
|
||||
return p.ID()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StatusRow is one plugin's recorded migration ids.
|
||||
type StatusRow struct {
|
||||
Plugin string
|
||||
Table string
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// Status reads each plugin history table.
|
||||
func Status(gdb *gorm.DB, plugins []party.Plugin) ([]StatusRow, error) {
|
||||
if gdb == nil {
|
||||
return nil, fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
var rows []StatusRow
|
||||
for _, p := range plugins {
|
||||
if _, ok := p.(pact.HasMigrations); !ok {
|
||||
continue
|
||||
}
|
||||
table, err := HistoryTableName(p.ID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := StatusRow{Plugin: p.ID(), Table: table}
|
||||
if !gdb.Migrator().HasTable(table) {
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if err := gdb.Table(table).Order("id").Pluck("id", &row.IDs).Error; err != nil {
|
||||
return nil, fmt.Errorf("lagoon: status %s: %w", p.ID(), err)
|
||||
}
|
||||
if row.IDs == nil {
|
||||
row.IDs = []string{}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
97
lagoon/migrations_test.go
Normal file
97
lagoon/migrations_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/bonfire"
|
||||
)
|
||||
|
||||
func TestHistoryTableName(t *testing.T) {
|
||||
got, err := HistoryTableName("golem15.user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "summer_migrations_golem15_user" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
got, err = HistoryTableName("golem15.fonoteka")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "summer_migrations_golem15_fonoteka" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if _, err := HistoryTableName("golem15.user;drop"); err == nil {
|
||||
t.Fatal("want invalid plugin id error")
|
||||
}
|
||||
if _, err := HistoryTableName(""); err == nil {
|
||||
t.Fatal("want empty plugin id error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLocaleMessage(t *testing.T) {
|
||||
if err := checkLocale("i", "pl-PL"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := checkLocale("c", "en_US")
|
||||
if err == nil {
|
||||
t.Fatal("want locale error")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"ICU", "pl-PL", "CREATE DATABASE", "LOCALE_PROVIDER icu", "en_US"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("missing %q in %s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCommandsRegisterBareAndColonNames(t *testing.T) {
|
||||
cmds := RuntimeCommands(nil, nil)
|
||||
names := map[string]bool{}
|
||||
for _, c := range cmds {
|
||||
names[c.Name] = true
|
||||
}
|
||||
for _, want := range []string{"migrate", "migrate:rollback", "migrate:status"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("missing %s", want)
|
||||
}
|
||||
}
|
||||
root, err := bonfire.NewRoot("app", cmds, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root.SetArgs([]string{"migrate", "--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoAutoMigrate(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(entry.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(body, []byte("AutoMigrate")) {
|
||||
t.Fatalf("%s must not call AutoMigrate", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRequiresDSN(t *testing.T) {
|
||||
_, _, err := Open(t.Context(), " ")
|
||||
if err == nil || !strings.Contains(err.Error(), "database.dsn") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/summercms/bonfire"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
// HasCommands is implemented by plugins that register console commands.
|
||||
@@ -18,19 +19,24 @@ type HasConfig interface {
|
||||
ConfigFS() fs.FS
|
||||
}
|
||||
|
||||
// HasMigrations is implemented by plugins that ship an ordered gormigrate
|
||||
// set. The kernel runs each set in party.Activate order with a separate
|
||||
// history table per plugin ID.
|
||||
type HasMigrations interface {
|
||||
Migrations() []*gormigrate.Migration
|
||||
}
|
||||
|
||||
// OptionalMessage is a service an optional plugin may publish so other
|
||||
// plugins can integrate without importing that plugin's package.
|
||||
type OptionalMessage interface {
|
||||
Message() string
|
||||
}
|
||||
|
||||
// Future KERN-03 capability families are type-asserted when their first
|
||||
// consumer packages exist. Method payloads are not declared here so pact
|
||||
// does not import surf, lagoon, festival, or other packages that Phase 1
|
||||
// does not yet ship:
|
||||
// Future capability families are type-asserted when their first consumer
|
||||
// packages exist. Method payloads for HTTP/model surfaces are declared in
|
||||
// the phase that ships surf:
|
||||
//
|
||||
// HasModels
|
||||
// HasMigrations
|
||||
// HasRoutes
|
||||
// HasMiddleware
|
||||
// HasJobs
|
||||
@@ -42,5 +48,5 @@ type OptionalMessage interface {
|
||||
// HasMailTemplates
|
||||
// HasLang
|
||||
//
|
||||
// Phase 1 type-asserts only HasConfig (party, before Register) and
|
||||
// HasCommands (generated app main, after Boot).
|
||||
// The kernel type-asserts HasConfig (party, before Register), HasCommands
|
||||
// (generated app main, after Boot), and HasMigrations (lagoon migrate).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/bonfire"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
type configOnly struct{}
|
||||
@@ -27,14 +28,21 @@ type bothCaps struct {
|
||||
|
||||
type neither struct{}
|
||||
|
||||
func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) {
|
||||
plugins := []any{configOnly{}, commandsOnly{}, bothCaps{}, neither{}, extraMessage{}}
|
||||
type migrationsOnly struct{}
|
||||
|
||||
var configs, commands, messages, none int
|
||||
func (migrationsOnly) Migrations() []*gormigrate.Migration {
|
||||
return []*gormigrate.Migration{{ID: "202609170001_create_users"}}
|
||||
}
|
||||
|
||||
func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) {
|
||||
plugins := []any{configOnly{}, commandsOnly{}, bothCaps{}, neither{}, extraMessage{}, migrationsOnly{}}
|
||||
|
||||
var configs, commands, messages, migrations, none int
|
||||
for _, p := range plugins {
|
||||
_, hasCfg := p.(HasConfig)
|
||||
_, hasCmd := p.(HasCommands)
|
||||
_, hasMsg := p.(OptionalMessage)
|
||||
_, hasMig := p.(HasMigrations)
|
||||
if hasCfg {
|
||||
configs++
|
||||
}
|
||||
@@ -44,7 +52,10 @@ func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) {
|
||||
if hasMsg {
|
||||
messages++
|
||||
}
|
||||
if !hasCfg && !hasCmd && !hasMsg {
|
||||
if hasMig {
|
||||
migrations++
|
||||
}
|
||||
if !hasCfg && !hasCmd && !hasMsg && !hasMig {
|
||||
none++
|
||||
}
|
||||
}
|
||||
@@ -57,11 +68,22 @@ func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) {
|
||||
if messages != 1 {
|
||||
t.Fatalf("OptionalMessage count = %d, want 1", messages)
|
||||
}
|
||||
if migrations != 1 {
|
||||
t.Fatalf("HasMigrations count = %d, want 1", migrations)
|
||||
}
|
||||
if none != 1 {
|
||||
t.Fatalf("neither count = %d, want 1", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasMigrationsReturnsOrderedSet(t *testing.T) {
|
||||
var p HasMigrations = migrationsOnly{}
|
||||
set := p.Migrations()
|
||||
if len(set) != 1 || set[0].ID != "202609170001_create_users" {
|
||||
t.Fatalf("Migrations = %+v", set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasConfigReturnsFilesystem(t *testing.T) {
|
||||
var p HasConfig = configOnly{}
|
||||
fsys := p.ConfigFS()
|
||||
|
||||
Reference in New Issue
Block a user