MongoDB migrations made simple for go
mongodb migration db
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

init

Signed-off-by: softprops <d.tangren@gmail.com>

softprops 197f1aa4

+2309
+1
.gitignore
··· 1 + .jj # jiujistsu
+20
LICENSE
··· 1 + Copyright (c) 2026 Doug Tangren 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining 4 + a copy of this software and associated documentation files (the 5 + "Software"), to deal in the Software without restriction, including 6 + without limitation the rights to use, copy, modify, merge, publish, 7 + distribute, sublicense, and/or sell copies of the Software, and to 8 + permit persons to whom the Software is furnished to do so, subject to 9 + the following conditions: 10 + 11 + The above copyright notice and this permission notice shall be 12 + included in all copies or substantial portions of the Software. 13 + 14 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+9
README.md
··· 1 + # moose 2 + 3 + Moose is a Go-focused [MongoDB](https://www.mongodb.com/) database migration tool, inspired by [Goose](https://github.com/pressly/goose). 4 + 5 + Features: 6 + 7 + * <img height="14" src="https://octicons-col.vercel.app/tools/00ED64"/> A CLI and a library for writing customized migration tooling 8 + * <img height="14" src="https://octicons-col.vercel.app/file-binary/00ED64"/> Write migrations in Go or JSON 9 + * <img height="14" src="https://octicons-col.vercel.app/container/00ED64"/> Supports embedded migrations, simplifying packaging and deployment in CI/CD pipelines
+308
cmd/moose/main.go
··· 1 + package main 2 + 3 + import ( 4 + "cmp" 5 + "context" 6 + "errors" 7 + "flag" 8 + "fmt" 9 + "log" 10 + "os" 11 + "path/filepath" 12 + "runtime/debug" 13 + "sort" 14 + "strconv" 15 + "strings" 16 + "text/tabwriter" 17 + "time" 18 + 19 + "tangled.org/softprops.tngl.sh/moose" 20 + "tangled.org/softprops.tngl.sh/moose/internal/stats" 21 + "go.mongodb.org/mongo-driver/v2/mongo" 22 + "go.mongodb.org/mongo-driver/v2/mongo/options" 23 + ) 24 + 25 + var version string // assigned at compile time with `Go build -ldflags "-X main.version=v0.1.0"` 26 + 27 + func main() { 28 + log.SetFlags(0) // clear log default prefix 29 + 30 + var dir string 31 + var uri string 32 + var db string 33 + var sequential bool 34 + var allowMissing bool 35 + var printVersion bool 36 + 37 + flags := flag.NewFlagSet("moose", flag.ExitOnError) 38 + flags.StringVar(&dir, "dir", cmp.Or(os.Getenv("MOOSE_MIGRATION_DIR"), "migrations"), "directory with migration files, (MOOSE_MIGRATION_DIR env variable supported)") 39 + flags.StringVar(&uri, "uri", cmp.Or(os.Getenv("MOOSE_MONGODB_URI"), "mongodb://127.0.0.1:27017"), "MongoDB connection string, (MONGODB_URI env variable supported)") 40 + flags.StringVar(&db, "db", os.Getenv("MOOSE_MIGRATION_DB"), "database to apply migration files to, (MOOSE_MIGRATION_DB env variable supported)") 41 + flags.BoolVar(&printVersion, "version", false, "print version") 42 + flags.BoolVar(&sequential, "s", false, "use seqential number for new migrations") 43 + flags.BoolVar(&allowMissing, "allow-missing", false, "allow missing (out-of-order) migrations") 44 + flags.Usage = usage(flags) 45 + if err := flags.Parse(os.Args[1:]); err != nil { 46 + log.Fatal(err) 47 + } 48 + 49 + if printVersion { 50 + if info, ok := debug.ReadBuildInfo(); version == "" && ok && info != nil && info.Main.Version == "" { 51 + version = info.Main.Version 52 + } 53 + fmt.Printf("moose version: %s\n", strings.TrimSpace(version)) 54 + return 55 + } 56 + 57 + args := flags.Args() 58 + 59 + if len(args) == 0 { 60 + flags.Usage() 61 + os.Exit(1) 62 + } 63 + 64 + client, err := mongo.Connect(options.Client().ApplyURI(uri)) 65 + if err != nil { 66 + _, _ = fmt.Fprintf(os.Stderr, "moose: %v\n", err) 67 + os.Exit(1) 68 + } 69 + defer func() { _ = client.Disconnect(context.Background()) }() 70 + 71 + if err := Run(args[0], dir, client.Database(db), args[1:], moose.WithAllowMissing(allowMissing), moose.WithSequential(sequential)); err != nil { 72 + 73 + _, _ = fmt.Fprintf(os.Stderr, "moose: %v\n", err) 74 + os.Exit(1) 75 + } 76 + } 77 + 78 + func Run(cmd, dir string, db *mongo.Database, args []string, opts ...moose.ProviderOption) error { 79 + provider, err := moose.NewProvider( 80 + db, os.DirFS(dir), opts..., 81 + ) 82 + if err != nil { 83 + return err 84 + } 85 + 86 + switch cmd { 87 + case "create": 88 + if len(args) == 0 { 89 + return errors.New("create must be of the form: moose [OPTIONS] create NAME [go|json]") 90 + } 91 + migrationType := moose.MigrationTypeGo 92 + if len(args) == 2 { 93 + migrationType = moose.MigrationType(args[1]) 94 + } 95 + file, err := moose.Create(dir, args[0], migrationType, true) 96 + if err != nil { 97 + return err 98 + } 99 + fmt.Printf("Created new migration file: %s\n", file) 100 + case "up": 101 + results, err := provider.Up(context.Background()) 102 + if err != nil { 103 + return err 104 + } 105 + if len(results) == 0 { 106 + version, err := provider.DBVersion(context.Background()) 107 + if err != nil { 108 + return err 109 + } 110 + fmt.Printf("no new migrations to apply. current version %d", version) 111 + } 112 + for _, r := range results { 113 + fmt.Println(r) 114 + } 115 + case "up-by-one": 116 + result, err := provider.UpByOne(context.Background()) 117 + if err != nil { 118 + if errors.Is(err, moose.ErrNoNextVersion) { 119 + version, err := provider.DBVersion(context.Background()) 120 + if err != nil { 121 + return err 122 + } 123 + fmt.Printf("no new migrations to apply. current version %d", version) 124 + return nil 125 + } 126 + return err 127 + } 128 + fmt.Println(result) 129 + case "up-to": 130 + if len(args) == 0 { 131 + return errors.New("up-to must be of the form: moose [OPTIONS] up-to VERSION") 132 + } 133 + 134 + version, err := strconv.ParseInt(args[0], 10, 64) 135 + if err != nil { 136 + return errors.New("version must be an integer") 137 + } 138 + results, err := provider.UpTo(context.Background(), version) 139 + if err != nil { 140 + return err 141 + } 142 + if len(results) == 0 { 143 + version, err := provider.DBVersion(context.Background()) 144 + if err != nil { 145 + return err 146 + } 147 + fmt.Printf("no new migrations to apply. current version %d", version) 148 + } 149 + 150 + for _, r := range results { 151 + fmt.Println(r) 152 + } 153 + case "down": 154 + results, err := provider.Down(context.Background()) 155 + if err != nil { 156 + if errors.Is(err, moose.ErrNoNextVersion) { 157 + version, err := provider.DBVersion(context.Background()) 158 + if err != nil { 159 + return err 160 + } 161 + fmt.Printf("no new migrations to apply. current version %d", version) 162 + return nil 163 + } 164 + return err 165 + } 166 + fmt.Printf("results %s", results) 167 + case "down-to": 168 + if len(args) == 0 { 169 + return errors.New("down-to must be of the form: moose [OPTIONS] down-to VERSION") 170 + } 171 + version, err := strconv.ParseInt(args[0], 10, 64) 172 + if err != nil { 173 + return errors.New("version must be an integer") 174 + } 175 + results, err := provider.DownTo(context.Background(), version) 176 + if err != nil { 177 + return err 178 + } 179 + 180 + if errors.Is(err, moose.ErrNoNextVersion) { 181 + version, err := provider.DBVersion(context.Background()) 182 + if err != nil { 183 + return err 184 + } 185 + fmt.Printf("no new migrations to apply. current version %d", version) 186 + return nil 187 + } 188 + fmt.Printf("results %s", results) 189 + case "validate": 190 + if err := validate(dir); err != nil { 191 + return err 192 + } 193 + case "status": 194 + results, err := provider.Status(context.Background()) 195 + if err != nil { 196 + return err 197 + } 198 + 199 + log.Printf(" Applied At Migration") 200 + log.Printf(" =======================================") 201 + for _, r := range results { 202 + appliedAt := "Pending" 203 + if !r.AppliedAt.IsZero() { 204 + appliedAt = r.AppliedAt.Format(time.ANSIC) 205 + } 206 + log.Printf(" %-24s -- %v", appliedAt, filepath.Base(r.Source.Path)) 207 + } 208 + case "version": 209 + version, err := provider.DBVersion(context.Background()) 210 + if err != nil { 211 + return err 212 + } 213 + log.Printf("moose: version %v", version) 214 + default: 215 + return fmt.Errorf("%q: no such command", cmd) 216 + } 217 + return nil 218 + } 219 + 220 + func validate(dir string) error { 221 + names, err := filenames(dir) 222 + if err != nil { 223 + return err 224 + } 225 + stats, err := stats.Collect( 226 + stats.NewFileWalker(names...), 227 + false, 228 + ) 229 + if err != nil { 230 + return err 231 + } 232 + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) 233 + fmtPattern := "%v\t%v\t%v\t%v\t%v\t\n" 234 + _, _ = fmt.Fprintf(w, fmtPattern, "Type", "Txn", "Up", "Down", "Name") 235 + _, _ = fmt.Fprintf(w, fmtPattern, "────", "───", "──", "────", "────") 236 + for _, m := range stats { 237 + txnStr := "✔" 238 + if !m.Tx { 239 + txnStr = "✘" 240 + } 241 + _, _ = fmt.Fprintf(w, fmtPattern, 242 + strings.TrimPrefix(filepath.Ext(m.FileName), "."), 243 + txnStr, 244 + m.UpCount, 245 + m.DownCount, 246 + filepath.Base(m.FileName), 247 + ) 248 + } 249 + 250 + return w.Flush() 251 + } 252 + 253 + func filenames(dir string) ([]string, error) { 254 + stat, err := os.Stat(dir) 255 + if err != nil { 256 + return nil, err 257 + } 258 + var filenames []string 259 + if stat.IsDir() { 260 + for _, pattern := range []string{"*.json", "*.go"} { 261 + file, err := filepath.Glob(filepath.Join(dir, pattern)) 262 + if err != nil { 263 + return nil, err 264 + } 265 + filenames = append(filenames, file...) 266 + } 267 + } else { 268 + filenames = append(filenames, dir) 269 + } 270 + sort.Strings(filenames) 271 + return filenames, nil 272 + } 273 + 274 + func usage(flags *flag.FlagSet) func() { 275 + return func() { 276 + fmt.Println(usagePrefix) 277 + flags.PrintDefaults() 278 + fmt.Println(usageCommands) 279 + } 280 + } 281 + 282 + var ( 283 + usagePrefix = `Usage: moose [OPTIONS] COMMAND 284 + 285 + Examples: 286 + moose -db example status 287 + moose -db example ./foo.db create init json 288 + moose -db example ./foo.db up 289 + 290 + Options: 291 + ` 292 + 293 + usageCommands = ` 294 + Commands: 295 + up Migrate the DB to the most recent version available 296 + up-by-one Migrate the DB up by 1 297 + up-to VERSION Migrate the DB to a specific VERSION 298 + down Roll back the version by 1 299 + down-to VERSION Roll back to a specific VERSION 300 + redo Re-run the latest migration 301 + reset Roll back all migrations 302 + status Dump the migration status for the current DB 303 + version Print the current version of the database 304 + create NAME [json|go] Creates new migration file with the current timestamp 305 + fix Apply sequential ordering to migrations 306 + validate Check migration files without running them 307 + ` 308 + )
+124
create.go
··· 1 + package moose 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "html/template" 7 + "math" 8 + "os" 9 + "path/filepath" 10 + "time" 11 + ) 12 + 13 + const timestampFormat = "20060102150405" 14 + 15 + type tmplVars struct { 16 + Version string 17 + CamelName string 18 + Package string 19 + } 20 + 21 + const jsonVersionTemplate = "%05v" 22 + 23 + func CreateWithTemplate(dir string, tmpl *template.Template, name string, migrationType MigrationType, sequential bool) (string, error) { 24 + version := time.Now().UTC().Format(timestampFormat) 25 + 26 + if _, err := os.Stat(dir); os.IsNotExist(err) { 27 + if err := os.MkdirAll(dir, os.ModePerm); err != nil { 28 + return "", err 29 + } 30 + } 31 + 32 + if sequential { 33 + // always use osFS here because it's modifying operation 34 + migrations, err := collect(osFS{}, dir, math.MinInt64, math.MaxInt64, registeredGoMigrations) 35 + if err != nil && !errors.Is(err, ErrNoMigrationFiles) { 36 + return "", err 37 + } 38 + 39 + vMigrations, err := Migrations(migrations).versioned(timestampFormat) 40 + if err != nil { 41 + return "", err 42 + } 43 + 44 + if last, err := vMigrations.Last(); err == nil { 45 + version = fmt.Sprintf(jsonVersionTemplate, last.Version+1) 46 + } else { 47 + version = fmt.Sprintf(jsonVersionTemplate, int64(1)) 48 + } 49 + } 50 + 51 + filename := fmt.Sprintf("%v_%v.%v", version, snakeCase(name), migrationType) 52 + 53 + if tmpl == nil { 54 + switch migrationType { 55 + case MigrationTypeGo: 56 + tmpl = goMigrationTemplate 57 + case MigrationTypeJSON: 58 + tmpl = jsonMigrationTemplate 59 + default: 60 + return "", fmt.Errorf("unsupported migration type %q, use go or json", migrationType) 61 + } 62 + } 63 + 64 + path := filepath.Join(dir, filename) 65 + if _, err := os.Stat(path); !os.IsNotExist(err) { 66 + return "", fmt.Errorf("failed to create migration file: %w", err) 67 + } 68 + 69 + f, err := os.Create(path) 70 + if err != nil { 71 + return "", fmt.Errorf("failed to create migration file: %w", err) 72 + } 73 + defer func() { _ = f.Close() }() 74 + 75 + abs, err := filepath.Abs(path) 76 + if err != nil { 77 + return "", fmt.Errorf("failed to create migration file: %w", err) 78 + } 79 + vars := tmplVars{ 80 + Version: version, 81 + CamelName: camelCase(name), 82 + Package: filepath.Base(filepath.Dir(abs)), 83 + } 84 + if err := tmpl.Execute(f, vars); err != nil { 85 + return "", fmt.Errorf("failed to execute tmpl: %w", err) 86 + } 87 + 88 + return f.Name(), nil 89 + } 90 + 91 + // Create writes a new blank migration file. 92 + func Create(dir, name string, migrationType MigrationType, sequential bool) (string, error) { 93 + return CreateWithTemplate(dir, nil, name, migrationType, sequential) 94 + } 95 + 96 + var jsonMigrationTemplate = template.Must(template.New("moose.json-migration").Parse(`{ 97 + "up":{ "tx": true, "cmds": [] }, 98 + "down": { "tx": true, "cmds": [] } 99 + } 100 + `)) 101 + 102 + var goMigrationTemplate = template.Must(template.New("moose.go-migration").Parse(`package {{.Package}} 103 + 104 + import ( 105 + "context" 106 + 107 + "tangled.org/softprops.tngl.sh/moose" 108 + "go.mongodb.org/mongo-driver/v2/mongo" 109 + ) 110 + 111 + func init() { 112 + moose.AddMigration(up{{.CamelName}}, down{{.CamelName}}) 113 + } 114 + 115 + func up{{.CamelName}}(ctx context.Context, client *mongo.Database) error { 116 + // This code is executed when the migration is applied. 117 + return nil 118 + } 119 + 120 + func down{{.CamelName}}(ctx context.Context, client *mongo.Database) error { 121 + // This code is executed when the migration is rolled back. 122 + return nil 123 + } 124 + `))
+2
doc.go
··· 1 + // Package moose ... complete this sentence with the purpose of the library. 2 + package moose
+3
example/README.md
··· 1 + # example 2 + 3 + Go migrations by are supported via custom binary
+109
example/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "embed" 6 + "errors" 7 + "flag" 8 + "fmt" 9 + "io/fs" 10 + "log" 11 + "os" 12 + "path/filepath" 13 + "time" 14 + 15 + "tangled.org/softprops.tngl.sh/moose" 16 + _ "tangled.org/softprops.tngl.sh/moose/example/migrations" // triggers go script init 17 + "go.mongodb.org/mongo-driver/v2/mongo" 18 + "go.mongodb.org/mongo-driver/v2/mongo/options" 19 + ) 20 + 21 + //go:embed migrations/*.json migrations/*.go 22 + var embedMigrations embed.FS 23 + 24 + func main() { 25 + log.SetFlags(0) 26 + 27 + var db string 28 + var uri string 29 + flags := flag.NewFlagSet("example", flag.ExitOnError) 30 + flags.StringVar(&db, "db", "example", "db to apply and record migrations") 31 + flags.StringVar(&uri, "uri", "mongodb://127.0.0.1:27017", "MongoDB connection string") 32 + 33 + if err := flags.Parse(os.Args[1:]); err != nil { 34 + log.Fatalf("moose: failed to parse flags: %v", err) 35 + } 36 + args := flags.Args() 37 + 38 + if len(args) == 0 { 39 + _, _ = fmt.Fprintf(os.Stderr, "example: please provide a command to run. Supported commands: create, up, status\n") 40 + os.Exit(1) 41 + } 42 + 43 + if err := run(args[0], uri, db, args[1:]); err != nil { 44 + _, _ = fmt.Fprintf(os.Stderr, "example %s\n", err) 45 + os.Exit(1) 46 + } 47 + } 48 + 49 + func run(cmd, uri, db string, args []string) error { 50 + dir := "migrations" 51 + client, err := mongo.Connect(options.Client().ApplyURI(uri).SetConnectTimeout(1 * time.Second)) 52 + if err != nil { 53 + return nil 54 + } 55 + defer func() { _ = client.Disconnect(context.Background()) }() 56 + 57 + ctx := context.Background() 58 + dirfs, err := fs.Sub(embedMigrations, dir) 59 + if err != nil { 60 + return err 61 + } 62 + provider, err := moose.NewProvider(client.Database(db), dirfs) 63 + if err != nil { 64 + return err 65 + } 66 + 67 + switch cmd { 68 + case "create": 69 + if len(args) == 0 { 70 + return errors.New("create must be of the form: moose [OPTIONS] create NAME") 71 + } 72 + file, err := moose.Create(dir, args[1], "go", true) 73 + if err != nil { 74 + return err 75 + } 76 + fmt.Printf("Created new migration file: %s\n", file) 77 + case "up": 78 + results, err := provider.Up(ctx) 79 + if err != nil { 80 + return err 81 + } 82 + for _, r := range results { 83 + fmt.Println(r) 84 + } 85 + case "down": 86 + res, err := provider.Down(ctx) 87 + if err != nil { 88 + return err 89 + } 90 + fmt.Println(res) 91 + case "status": 92 + results, err := provider.Status(ctx) 93 + if err != nil { 94 + return err 95 + } 96 + 97 + log.Printf(" Applied At Migration") 98 + log.Printf(" =======================================") 99 + for _, r := range results { 100 + appliedAt := "Pending" 101 + if !r.AppliedAt.IsZero() { 102 + appliedAt = r.AppliedAt.Format(time.ANSIC) 103 + } 104 + log.Printf(" %-24s -- %v", appliedAt, filepath.Base(r.Source.Path)) 105 + } 106 + } 107 + 108 + return nil 109 + }
+27
example/migrations/00001_create_users_indexes.json
··· 1 + { 2 + "up": { 3 + "tx": true, 4 + "cmds": [ 5 + { 6 + "createIndexes": "users", 7 + "indexes": [ 8 + { 9 + "key": { 10 + "username": 1 11 + }, 12 + "name": "username_1" 13 + } 14 + ] 15 + } 16 + ] 17 + }, 18 + "down": { 19 + "tx": true, 20 + "cmds": [ 21 + { 22 + "dropIndexes": "users", 23 + "index": "username_1" 24 + } 25 + ] 26 + } 27 + }
+27
example/migrations/00002_add_user_no_tx.go
··· 1 + package migrations 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + 7 + "tangled.org/softprops.tngl.sh/moose" 8 + "go.mongodb.org/mongo-driver/v2/bson" 9 + "go.mongodb.org/mongo-driver/v2/mongo" 10 + ) 11 + 12 + func init() { 13 + moose.AddMigrationNoTx(upAddUserNoTxt, downAddUserNoTx) 14 + } 15 + 16 + func upAddUserNoTxt(ctx context.Context, db *mongo.Database) error { 17 + _, err := db.Collection("users").InsertOne(ctx, bson.M{"username": "harry"}) 18 + return err 19 + } 20 + 21 + func downAddUserNoTx(ctx context.Context, db *mongo.Database) error { 22 + _, err := db.Collection("users").DeleteOne(ctx, bson.M{"username": "harry"}) 23 + if errors.Is(err, mongo.ErrNoDocuments) { 24 + return nil 25 + } 26 + return err 27 + }
+31
example/migrations/00003_add_field.go
··· 1 + package migrations 2 + 3 + import ( 4 + "context" 5 + 6 + "tangled.org/softprops.tngl.sh/moose" 7 + "go.mongodb.org/mongo-driver/v2/bson" 8 + "go.mongodb.org/mongo-driver/v2/mongo" 9 + ) 10 + 11 + func init() { 12 + moose.AddMigration(upAddField, downAddField) 13 + } 14 + 15 + func upAddField(ctx context.Context, db *mongo.Database) error { 16 + _, err := db.Collection("users").UpdateMany(ctx, bson.M{}, bson.M{ 17 + "$set": bson.M{ 18 + "status": "active", 19 + }, 20 + }) 21 + return err 22 + } 23 + 24 + func downAddField(ctx context.Context, db *mongo.Database) error { 25 + _, err := db.Collection("users").UpdateMany(ctx, bson.M{}, bson.M{ 26 + "$unset": bson.M{ 27 + "status": "", 28 + }, 29 + }) 30 + return err 31 + }
+19
go.mod
··· 1 + module tangled.org/softprops.tngl.sh/moose 2 + 3 + go 1.26.1 4 + 5 + require ( 6 + github.com/google/go-cmp v0.7.0 7 + go.mongodb.org/mongo-driver/v2 v2.5.0 8 + ) 9 + 10 + require ( 11 + github.com/klauspost/compress v1.17.6 // indirect 12 + github.com/xdg-go/pbkdf2 v1.0.0 // indirect 13 + github.com/xdg-go/scram v1.2.0 // indirect 14 + github.com/xdg-go/stringprep v1.0.4 // indirect 15 + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 16 + golang.org/x/crypto v0.33.0 // indirect 17 + golang.org/x/sync v0.11.0 // indirect 18 + golang.org/x/text v0.22.0 // indirect 19 + )
+46
go.sum
··· 1 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 4 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 5 + github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= 6 + github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 7 + github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= 8 + github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 9 + github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= 10 + github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= 11 + github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= 12 + github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= 13 + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= 14 + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 15 + github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 16 + go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= 17 + go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= 18 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 19 + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 20 + golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 21 + golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 22 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 23 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 24 + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 25 + golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 26 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 27 + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 28 + golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 29 + golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 30 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 32 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 36 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 37 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 38 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 39 + golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 40 + golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 41 + golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 42 + golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 43 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 44 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 45 + golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 46 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+2
internal/parse/doc.go
··· 1 + // Package parse provides parsing validation for both Go and JSON sources 2 + package parse
+128
internal/parse/go.go
··· 1 + package parse 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "go/ast" 7 + "go/parser" 8 + "go/token" 9 + "io" 10 + "strings" 11 + ) 12 + 13 + const ( 14 + registerGoFuncName = "AddMigration" 15 + registerGoFuncNameNoTx = "AddMigrationNoTx" 16 + ) 17 + 18 + type goMigration struct { 19 + Name string 20 + UseTx *bool 21 + UpFuncName, DownFuncName string 22 + } 23 + 24 + func Go(r io.Reader) (*goMigration, error) { 25 + parseInit := func(fd *ast.FuncDecl) (*goMigration, error) { 26 + if fd == nil { 27 + return nil, fmt.Errorf("function declaration must not be nil") 28 + } 29 + if fd.Body == nil { 30 + return nil, fmt.Errorf("no function body") 31 + } 32 + if len(fd.Body.List) == 0 { 33 + return nil, fmt.Errorf("no registered moose functions") 34 + } 35 + gf := new(goMigration) 36 + for _, statement := range fd.Body.List { 37 + expr, ok := statement.(*ast.ExprStmt) 38 + if !ok { 39 + continue 40 + } 41 + call, ok := expr.X.(*ast.CallExpr) 42 + if !ok { 43 + continue 44 + } 45 + sel, ok := call.Fun.(*ast.SelectorExpr) 46 + if !ok || sel == nil { 47 + continue 48 + } 49 + funcName := sel.Sel.Name 50 + b := false 51 + switch funcName { 52 + case registerGoFuncName: 53 + b = true 54 + gf.UseTx = &b 55 + case registerGoFuncNameNoTx: 56 + gf.UseTx = &b 57 + default: 58 + continue 59 + } 60 + if gf.Name != "" { 61 + return nil, fmt.Errorf("found duplicate registered functions:\nprevious: %v\ncurrent: %v", gf.Name, funcName) 62 + } 63 + gf.Name = funcName 64 + 65 + if len(call.Args) != 2 { 66 + return nil, fmt.Errorf("registered moose functions have 2 arguments: got %d", len(call.Args)) 67 + } 68 + name := func(expr ast.Expr) (string, error) { 69 + arg, ok := expr.(*ast.Ident) 70 + if !ok { 71 + return "", fmt.Errorf("failed to assert argument identifier: got %T", arg) 72 + } 73 + return arg.Name, nil 74 + } 75 + var err error 76 + gf.UpFuncName, err = name(call.Args[0]) 77 + if err != nil { 78 + return nil, err 79 + } 80 + gf.DownFuncName, err = name(call.Args[1]) 81 + if err != nil { 82 + return nil, err 83 + } 84 + } 85 + // validation 86 + switch gf.Name { 87 + case registerGoFuncName, registerGoFuncNameNoTx: 88 + default: 89 + return nil, fmt.Errorf("moose register function must be one of: %s", 90 + strings.Join([]string{ 91 + registerGoFuncName, 92 + registerGoFuncNameNoTx, 93 + }, ", "), 94 + ) 95 + } 96 + if gf.UseTx == nil { 97 + return nil, errors.New("validation error: failed to identify transaction: got nil bool") 98 + } 99 + // The up and down functions can either be named Go functions or "nil", an 100 + // empty string means there is a flaw in our parsing logic of the Go source code. 101 + if gf.UpFuncName == "" { 102 + return nil, fmt.Errorf("validation error: up function is empty string") 103 + } 104 + if gf.DownFuncName == "" { 105 + return nil, fmt.Errorf("validation error: down function is empty string") 106 + } 107 + return gf, nil 108 + } 109 + astFile, err := parser.ParseFile( 110 + token.NewFileSet(), 111 + "", 112 + r, 113 + parser.SkipObjectResolution, 114 + ) 115 + if err != nil { 116 + return nil, err 117 + } 118 + for _, decl := range astFile.Decls { 119 + fn, ok := decl.(*ast.FuncDecl) 120 + if !ok || fn == nil || fn.Name == nil { 121 + continue 122 + } 123 + if fn.Name.Name == "init" { 124 + return parseInit(fn) 125 + } 126 + } 127 + return nil, errors.New("no init function") 128 + }
+43
internal/parse/json.go
··· 1 + package parse 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "io" 8 + 9 + "go.mongodb.org/mongo-driver/v2/bson" 10 + ) 11 + 12 + type Op struct { 13 + UseTx bool `json:"tx"` 14 + Cmds []bson.D `json:"cmds"` 15 + } 16 + 17 + type JSONMigration struct { 18 + Up Op `json:"up"` 19 + Down Op `json:"down"` 20 + } 21 + 22 + func JSON(r io.Reader) (*JSONMigration, error) { 23 + bytes, err := io.ReadAll(r) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + var jf JSONMigration 29 + if err := json.Unmarshal(bytes, &jf); err != nil { 30 + return nil, err 31 + } 32 + if len(jf.Up.Cmds) == 0 { 33 + return nil, errors.New("missing 'up' commands") 34 + } 35 + if len(jf.Down.Cmds) == 0 { 36 + return nil, errors.New("missing 'down' commands") 37 + } 38 + 39 + if jf.Up.UseTx != jf.Down.UseTx { 40 + return nil, fmt.Errorf("up and down statements must have the same transaction mode") 41 + } 42 + return &jf, nil 43 + }
+102
internal/stats/stats.go
··· 1 + package stats 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "os" 7 + "path/filepath" 8 + 9 + "tangled.org/softprops.tngl.sh/moose" 10 + "tangled.org/softprops.tngl.sh/moose/internal/parse" 11 + ) 12 + 13 + type FileWalker interface { 14 + // Walk invokes fn for each file. 15 + Walk(fn func(filename string, r io.Reader) error) error 16 + } 17 + 18 + func NewFileWalker(names ...string) FileWalker { 19 + return &filenames{ 20 + filenames: names, 21 + } 22 + } 23 + 24 + type filenames struct { 25 + filenames []string 26 + } 27 + 28 + func (f *filenames) Walk(fn func(filename string, r io.Reader) error) error { 29 + walk := func(filename string, fn func(filename string, r io.Reader) error) error { 30 + file, err := os.Open(filename) 31 + if err != nil { 32 + return err 33 + } 34 + defer func() { _ = file.Close() }() 35 + return fn(filename, file) 36 + } 37 + for _, filename := range f.filenames { 38 + ext := filepath.Ext(filename) 39 + if ext != ".json" && ext != ".go" { 40 + continue 41 + } 42 + if err := walk(filename, fn); err != nil { 43 + return err 44 + } 45 + } 46 + return nil 47 + } 48 + 49 + type Stats struct { 50 + FileName string 51 + Version int64 52 + Tx bool 53 + UpCount int 54 + DownCount int 55 + } 56 + 57 + func Collect(fw FileWalker, debug bool) ([]*Stats, error) { 58 + var stats []*Stats 59 + err := fw.Walk(func(filename string, r io.Reader) error { 60 + version, err := moose.ExtractVersion(filename) 61 + if err != nil { 62 + return fmt.Errorf("failed to get version from file %q: %w", filename, err) 63 + } 64 + var up, down int 65 + var tx bool 66 + switch filepath.Ext(filename) { 67 + case ".json": 68 + m, err := parse.JSON(r) 69 + if err != nil { 70 + return fmt.Errorf("failed to parse file %q: %w", filename, err) 71 + } 72 + up, down = len(m.Up.Cmds), len(m.Down.Cmds) 73 + tx = m.Up.UseTx 74 + case ".go": 75 + m, err := parse.Go(r) 76 + if err != nil { 77 + return fmt.Errorf("failed to parse file %q: %w", filename, err) 78 + } 79 + up, down = nilAsNumber(m.UpFuncName), nilAsNumber(m.DownFuncName) 80 + tx = *m.UseTx 81 + } 82 + stats = append(stats, &Stats{ 83 + FileName: filename, 84 + Version: version, 85 + Tx: tx, 86 + UpCount: up, 87 + DownCount: down, 88 + }) 89 + return nil 90 + }) 91 + if err != nil { 92 + return nil, err 93 + } 94 + return stats, nil 95 + } 96 + 97 + func nilAsNumber(s string) int { 98 + if s != "nil" { 99 + return 1 100 + } 101 + return 0 102 + }
+45
lettercase.go
··· 1 + package moose 2 + 3 + import ( 4 + "strings" 5 + "unicode" 6 + "unicode/utf8" 7 + ) 8 + 9 + func snakeCase(s string) string { 10 + nextRune := func(s string) rune { r, _ := utf8.DecodeRuneInString(s); return r } 11 + delimter := func(r rune) bool { return r == '_' || r == '-' || unicode.IsSpace(r) } 12 + var b strings.Builder 13 + var prev rune 14 + for i, v := range s { 15 + if unicode.IsLower(v) || delimter(v) { 16 + b.WriteRune(v) 17 + } else { 18 + if i > 0 && (unicode.IsLower(prev) || 19 + unicode.IsLower(nextRune(s[i+utf8.RuneLen(v):]))) { 20 + _ = b.WriteByte('_') 21 + } 22 + _, _ = b.WriteRune(unicode.ToLower(v)) 23 + } 24 + prev = v 25 + } 26 + return b.String() 27 + } 28 + 29 + func camelCase(s string) string { 30 + parts := strings.FieldsFunc(s, func(r rune) bool { 31 + return r == '_' || r == '-' || unicode.IsSpace(r) 32 + }) 33 + 34 + var b strings.Builder 35 + _, _ = b.WriteString(strings.ToLower(parts[0])) 36 + 37 + for _, part := range parts[1:] { 38 + if len(part) > 0 { 39 + _, _ = b.WriteString(strings.ToUpper(part)[:1]) 40 + _, _ = b.WriteString(strings.ToLower(part)[1:]) 41 + } 42 + } 43 + 44 + return b.String() 45 + }
+39
lettercase_test.go
··· 1 + package moose 2 + 3 + import "testing" 4 + 5 + func TestSnakeCase(t *testing.T) { 6 + t.Parallel() 7 + tests := []struct { 8 + s string 9 + want string 10 + }{ 11 + {"fooBarBaz", "foo_bar_baz"}, 12 + } 13 + for _, tt := range tests { 14 + t.Run(tt.s, func(t *testing.T) { 15 + t.Parallel() 16 + if got := snakeCase(tt.s); got != tt.want { 17 + t.Fatalf("snakeCase(%q) got %q,want %q", tt.s, got, tt.want) 18 + } 19 + }) 20 + } 21 + } 22 + 23 + func TestCamelCase(t *testing.T) { 24 + t.Parallel() 25 + tests := []struct { 26 + s string 27 + want string 28 + }{ 29 + {"foo_bar_baz", "fooBarBaz"}, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.s, func(t *testing.T) { 33 + t.Parallel() 34 + if got := camelCase(tt.s); got != tt.want { 35 + t.Fatalf("camelCase(%q) got %q,want %q", tt.s, got, tt.want) 36 + } 37 + }) 38 + } 39 + }
+937
moose.go
··· 1 + package moose 2 + 3 + import ( 4 + "cmp" 5 + "context" 6 + "errors" 7 + "fmt" 8 + "io/fs" 9 + "math" 10 + "path" 11 + "path/filepath" 12 + "runtime" 13 + "slices" 14 + "sort" 15 + "strconv" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "go.mongodb.org/mongo-driver/v2/mongo" 21 + "go.mongodb.org/mongo-driver/v2/mongo/options" 22 + "go.mongodb.org/mongo-driver/v2/mongo/writeconcern" 23 + "tangled.org/softprops.tngl.sh/moose/internal/parse" 24 + ) 25 + 26 + var ( 27 + ErrNoMigrationFiles = errors.New("no migration files found") 28 + ErrNoCurrentVersion = errors.New("no current version found") 29 + ErrNoNextVersion = errors.New("no next version found") 30 + ) 31 + 32 + type TransactionMode int 33 + 34 + const ( 35 + TransactionEnabled TransactionMode = iota + 1 36 + TransactionDisabled 37 + ) 38 + 39 + // ProviderOption provides a means to customize the behavior of a [Provider] 40 + type ProviderOption func(*Provider) 41 + 42 + func WithAllowMissing(allow bool) ProviderOption { 43 + return func(p *Provider) { 44 + p.allowMissing = allow 45 + } 46 + } 47 + 48 + // WithSequential sets whether new migrations should use sequential numbering rather than timestamps 49 + func WithSequential(s bool) ProviderOption { 50 + return func(p *Provider) { 51 + p.sequential = s 52 + } 53 + } 54 + 55 + // WithStore sets a custom store for tracking applied migrations. 56 + // 57 + // The default is a collection named "migrations". To change the collection use [NewStore] with a 58 + // desired collection name. 59 + func WithStore(s Store) ProviderOption { 60 + return func(p *Provider) { 61 + p.store = s 62 + } 63 + } 64 + 65 + type Provider struct { 66 + db *mongo.Database 67 + migrations []*Migration 68 + dir fs.FS 69 + allowMissing bool 70 + sequential bool 71 + store Store 72 + initOnce sync.Once 73 + executor func(*mongo.Database) executor 74 + } 75 + 76 + type executor interface { 77 + RunCommand(ctx context.Context, runCommand any) error 78 + WithTransaction(ctx context.Context, f func(context.Context) error) error 79 + } 80 + 81 + type noopExecutor struct{} 82 + 83 + func (n *noopExecutor) RunCommand(ctx context.Context, runCommand any) error { 84 + return nil 85 + } 86 + 87 + func (n *noopExecutor) WithTransaction(ctx context.Context, f func(context.Context) error) error { 88 + return f(ctx) 89 + } 90 + 91 + type database struct { 92 + wrapped *mongo.Database 93 + } 94 + 95 + func defaultExecutor(db *mongo.Database) executor { 96 + return &database{wrapped: db} 97 + } 98 + 99 + func (db *database) RunCommand(ctx context.Context, runCommand any) error { 100 + return db.wrapped.RunCommand(ctx, runCommand).Err() 101 + } 102 + 103 + func (db *database) WithTransaction(ctx context.Context, f func(context.Context) error) error { 104 + ses, err := db.wrapped.Client().StartSession() 105 + if err != nil { 106 + return fmt.Errorf("failed to start session: %w", err) 107 + } 108 + defer func() { ses.EndSession(context.Background()) }() 109 + 110 + _, err = ses.WithTransaction( 111 + ctx, 112 + func(ctx context.Context) (any, error) { 113 + return nil, f(ctx) 114 + }, 115 + options.Transaction().SetWriteConcern(writeconcern.Majority()), 116 + ) 117 + 118 + return err 119 + } 120 + 121 + func withExecutor(f func(*mongo.Database) executor) ProviderOption { 122 + return func(p *Provider) { 123 + p.executor = f 124 + } 125 + } 126 + 127 + // State represents the current migration state 128 + type State string 129 + 130 + const ( 131 + StatePending State = "pending" 132 + StateApplied State = "applied" 133 + ) 134 + 135 + // MigrationStatus represents the current status of a migration on disk 136 + // and is returned by calling [Provider.Status]. 137 + type MigrationStatus struct { 138 + Source *Source 139 + State State 140 + AppliedAt time.Time 141 + } 142 + 143 + // NewProvider exposes operations to migrate a database between different applied versions, forwards and backwards if requested 144 + // 145 + // The returned [Provider] reads these migrations from a list of files under directory represented as a [fs.FS], which will often of the format 146 + // [os.DirFS], os.DirFS("path/to/migrations"), for references to local file systems or [embed.FS] for custom binaries with [fs.Sub] stripping directory prefixes 147 + // if not provided a noop [fs.FS] will be used. 148 + func NewProvider(db *mongo.Database, dir fs.FS, opts ...ProviderOption) (*Provider, error) { 149 + if dir == nil { 150 + dir = noopFS{} 151 + } 152 + 153 + fsSources, err := fsSources(dir) 154 + if err != nil { 155 + return nil, err 156 + } 157 + 158 + // todo: add support for explicit user provided migrations 159 + migrations, err := merge(fsSources, registeredGoMigrations) 160 + if err != nil { 161 + return nil, err 162 + } 163 + p := &Provider{ 164 + db: db, 165 + migrations: migrations, 166 + dir: dir, 167 + sequential: false, 168 + store: NewStore("migrations"), 169 + executor: defaultExecutor, 170 + } 171 + 172 + for _, opt := range opts { 173 + opt(p) 174 + } 175 + 176 + return p, nil 177 + } 178 + 179 + func (p *Provider) init(ctx context.Context) (err error) { 180 + p.initOnce.Do(func() { 181 + err = p.store.Init(ctx, p.db) 182 + }) 183 + return err 184 + } 185 + 186 + type fileSources struct { 187 + jsonSources []Source 188 + goSources []Source 189 + } 190 + 191 + // todo: support exclude paths/versions 192 + func fsSources(dir fs.FS) (*fileSources, error) { 193 + if dir == nil { 194 + return new(fileSources), nil 195 + } 196 + sources := new(fileSources) 197 + versions := make(map[int64]string) 198 + for _, pat := range []string{"*.json", "*.go"} { 199 + files, _ := fs.Glob(dir, pat) 200 + for _, path := range files { 201 + base := filepath.Base(path) 202 + if strings.HasSuffix(base, "_test.go") { 203 + continue 204 + } 205 + version, err := ExtractVersion(base) 206 + if err != nil { 207 + return nil, fmt.Errorf("failed to parse version from path %s: %s", path, err) 208 + } 209 + if p, ok := versions[version]; ok { 210 + return nil, fmt.Errorf("found duplicate version %d. existing %s, duplicate path %s", version, p, base) 211 + } 212 + switch filepath.Ext(base) { 213 + case ".json": 214 + sources.jsonSources = append(sources.jsonSources, Source{ 215 + Type: MigrationTypeJSON, 216 + Path: path, 217 + Version: version, 218 + }) 219 + case ".go": 220 + sources.goSources = append(sources.goSources, Source{ 221 + Type: MigrationTypeGo, 222 + Path: path, 223 + Version: version, 224 + }) 225 + } 226 + versions[version] = base 227 + } 228 + } 229 + return sources, nil 230 + } 231 + 232 + func merge(sources *fileSources, registered map[int64]*Migration) ([]*Migration, error) { 233 + var result []*Migration 234 + versions := make(map[int64]*Migration) 235 + 236 + for _, source := range sources.jsonSources { 237 + m := &Migration{ 238 + Type: source.Type, 239 + Version: source.Version, 240 + Source: source.Path, 241 + } 242 + result = append(result, m) 243 + versions[source.Version] = m 244 + } 245 + 246 + var unregistered []string 247 + for _, source := range sources.goSources { 248 + m, ok := registered[source.Version] 249 + if !ok { 250 + unregistered = append(unregistered, source.Path) 251 + } else { 252 + m.Source = source.Path 253 + } 254 + } 255 + 256 + if len(unregistered) > 0 { 257 + return nil, fmt.Errorf("found unregistered go migrations: %v\nhint: go functions must be registered and built into a custom binary", unregistered) 258 + } 259 + 260 + for version, r := range registered { 261 + if existing, ok := versions[version]; ok { 262 + return nil, fmt.Errorf("found duplicate version %d. existing %s, duplicate path %s", version, existing.Source, r.Source) 263 + } 264 + result = append(result, r) 265 + versions[version] = r 266 + } 267 + 268 + sort.Slice(result, func(i, j int) bool { 269 + return result[i].Version < result[j].Version 270 + }) 271 + 272 + return result, nil 273 + } 274 + 275 + // DBVersion returns the most recent migration version applied to the database 276 + func (p *Provider) DBVersion(ctx context.Context) (int64, error) { 277 + if err := p.init(ctx); err != nil { 278 + return -1, err 279 + } 280 + return p.store.Latest(ctx, p.db) 281 + } 282 + 283 + // Status returns a list of current migrations and their current state of application 284 + func (p *Provider) Status(ctx context.Context) ([]*MigrationStatus, error) { 285 + if err := p.init(ctx); err != nil { 286 + return nil, fmt.Errorf("failed to initialize: %w", err) 287 + } 288 + status := make([]*MigrationStatus, 0, len(p.migrations)) 289 + for _, m := range p.migrations { 290 + mg := &MigrationStatus{ 291 + Source: &Source{ 292 + Type: m.Type, 293 + Path: m.Source, 294 + Version: m.Version, 295 + }, 296 + State: StatePending, 297 + } 298 + dbRes, err := p.store.Get(ctx, p.db, m.Version) 299 + if err != nil { //&& !errors.Is(err, ErrVersionNotFound) { 300 + return nil, err 301 + } 302 + if dbRes != nil { 303 + mg.State = StateApplied 304 + mg.AppliedAt = dbRes.Timestamp 305 + } 306 + status = append(status, mg) 307 + } 308 + return status, nil 309 + } 310 + 311 + // HasPending indicates if there are any pending migrations to be applied 312 + func (p *Provider) HasPending(ctx context.Context) (bool, error) { 313 + err := p.init(ctx) 314 + if err != nil { 315 + return false, err 316 + } 317 + 318 + dbMigrations, err := p.store.List(ctx, p.db) 319 + if err != nil { 320 + return false, err 321 + } 322 + 323 + versions, err := upVersions(p.migrations, dbMigrations, math.MaxInt64) 324 + if err != nil { 325 + return false, err 326 + } 327 + 328 + return len(versions) > 0, nil 329 + } 330 + 331 + func upVersions(migrations []*Migration, dbMigrations []*ListMigrationsResult, target int64) ([]int64, error) { 332 + mv := make([]int64, 0, len(migrations)) 333 + for _, m := range migrations { 334 + mv = append(mv, m.Version) 335 + } 336 + 337 + dv := make([]int64, 0, len(dbMigrations)) 338 + for _, m := range dbMigrations { 339 + dv = append(dv, m.Version) 340 + } 341 + 342 + applied := make(map[int64]bool, len(dv)) 343 + var dbMax int64 344 + for _, v := range dv { 345 + applied[v] = true 346 + if v > dbMax { 347 + dbMax = v 348 + } 349 + } 350 + 351 + var missing []int64 352 + for _, v := range mv { 353 + if applied[v] { 354 + continue 355 + } 356 + if v < dbMax && v <= target { 357 + missing = append(missing, v) 358 + } 359 + } 360 + 361 + // todo: if missing and not allow missing, return err 362 + // 363 + var out []int64 364 + 365 + out = append(out, missing...) 366 + for _, v := range mv { 367 + if applied[v] { 368 + continue 369 + } 370 + if v > dbMax && v <= target { 371 + out = append(out, v) 372 + } 373 + } 374 + 375 + slices.SortFunc(out, func(a, b int64) int { 376 + return cmp.Compare(a, b) 377 + }) 378 + return out, nil 379 + } 380 + 381 + // Down rolls back a single migration from the current version 382 + func (p *Provider) Down(ctx context.Context) (*MigrationResult, error) { 383 + res, err := p.down(ctx, true, 0) 384 + if err != nil { 385 + return nil, err 386 + } 387 + if len(res) == 0 { 388 + return nil, ErrNoNextVersion 389 + } 390 + return res[0], nil 391 + } 392 + 393 + // DownTo rolls back to a specific version 394 + func (p *Provider) DownTo(ctx context.Context, version int64) ([]*MigrationResult, error) { 395 + if version < 0 { 396 + return nil, fmt.Errorf("invalid version: must be a valid number of zero: %d", version) 397 + } 398 + return p.down(ctx, false, version) 399 + } 400 + 401 + func (p *Provider) down(ctx context.Context, byOne bool, version int64) ([]*MigrationResult, error) { 402 + if err := p.init(ctx); err != nil { 403 + return nil, fmt.Errorf("failed to initialize: %w", err) 404 + } 405 + if len(p.migrations) == 0 { 406 + return nil, nil 407 + } 408 + dbMigrations, err := p.store.List(ctx, p.db) 409 + if err != nil { 410 + return nil, err 411 + } 412 + var apply []*Migration 413 + for _, dbm := range dbMigrations { 414 + if dbm.Version <= version { 415 + break 416 + } 417 + m, err := p.migration(dbm.Version) 418 + if err != nil { 419 + return nil, err 420 + } 421 + apply = append(apply, m) 422 + } 423 + return p.run(ctx, apply, false, byOne) 424 + } 425 + 426 + // Up runs any pending migrations returning results. Pending migrations 427 + // can be previewed with [Status] 428 + func (p *Provider) Up(ctx context.Context) ([]*MigrationResult, error) { 429 + pending, err := p.HasPending(ctx) 430 + if err != nil || !pending { 431 + return nil, err 432 + } 433 + return p.up(ctx, false, math.MaxInt64) 434 + } 435 + 436 + // UpByOne runs the next pending migration returning its result 437 + func (p *Provider) UpByOne(ctx context.Context) (*MigrationResult, error) { 438 + pending, err := p.HasPending(ctx) 439 + if err != nil { 440 + return nil, err 441 + } 442 + if !pending { 443 + return nil, ErrNoNextVersion 444 + } 445 + res, err := p.up(ctx, true, math.MaxInt64) 446 + if err != nil { 447 + return nil, err 448 + } 449 + if len(res) == 0 { 450 + return nil, ErrNoNextVersion 451 + } 452 + return res[0], nil 453 + } 454 + 455 + // UpTo runs pending migrations up to a specified version 456 + func (p *Provider) UpTo(ctx context.Context, version int64) ([]*MigrationResult, error) { 457 + pending, err := p.HasPending(ctx) 458 + if err != nil { 459 + return nil, err 460 + } 461 + if !pending { 462 + return nil, nil 463 + } 464 + return p.up(ctx, false, version) 465 + } 466 + 467 + func (p *Provider) up(ctx context.Context, byOne bool, version int64) ([]*MigrationResult, error) { 468 + if version < 1 { 469 + return nil, errors.New("invalid version") 470 + } 471 + if err := p.init(ctx); err != nil { 472 + return nil, fmt.Errorf("failed to initialize: %w", err) 473 + } 474 + var apply []*Migration 475 + dbMigrations, err := p.store.List(ctx, p.db) 476 + if err != nil { 477 + return nil, err 478 + } 479 + versions, err := upVersions(p.migrations, dbMigrations, version) 480 + if err != nil { 481 + return nil, err 482 + } 483 + for _, v := range versions { 484 + m, err := p.migration(v) 485 + if err != nil { 486 + return nil, err 487 + } 488 + apply = append(apply, m) 489 + } 490 + return p.run(ctx, apply, true, byOne) 491 + } 492 + 493 + func (p *Provider) migration(version int64) (*Migration, error) { 494 + for _, m := range p.migrations { 495 + if m.Version == version { 496 + return m, nil 497 + } 498 + } 499 + return nil, errors.New("migration not found") 500 + } 501 + 502 + func (p *Provider) run(ctx context.Context, migrations []*Migration, up, byOne bool) ([]*MigrationResult, error) { 503 + if len(migrations) == 0 { 504 + return nil, nil 505 + } 506 + 507 + apply := migrations 508 + if byOne { 509 + apply = migrations[:1] 510 + } 511 + 512 + direction := "down" 513 + if up { 514 + direction = "up" 515 + } 516 + 517 + var results []*MigrationResult 518 + for _, m := range apply { 519 + 520 + // run it! 521 + result := &MigrationResult{ 522 + Source: &Source{ 523 + Type: m.Type, 524 + Path: m.Source, 525 + Version: m.Version, 526 + }, 527 + Direction: direction, 528 + } 529 + 530 + if err := m.Parse(p.dir); err != nil { 531 + return nil, &PartialError{ 532 + Applied: results, 533 + Failed: result, 534 + Err: err, 535 + } 536 + } 537 + 538 + empty := func() bool { 539 + switch m.Type { 540 + case MigrationTypeGo: 541 + if up { 542 + return m.goUp == nil 543 + } 544 + return m.goDown == nil 545 + case MigrationTypeJSON: 546 + if up { 547 + return len(m.json.Up.Cmds) == 0 548 + } 549 + return len(m.json.Down.Cmds) == 0 550 + } 551 + return true 552 + } 553 + result.Empty = empty() 554 + 555 + start := time.Now() 556 + if err := p.runOne(ctx, m, up); err != nil { 557 + result.Error = err 558 + result.Duration = time.Since(start) 559 + return nil, &PartialError{ 560 + Applied: results, 561 + Failed: result, 562 + Err: err, 563 + } 564 + } 565 + result.Duration = time.Since(start) 566 + results = append(results, result) 567 + // log result 568 + } 569 + return results, nil 570 + } 571 + 572 + func (p *Provider) runOne(ctx context.Context, m *Migration, up bool) error { 573 + switch m.Type { 574 + case MigrationTypeGo: 575 + op := m.goDown 576 + if up { 577 + op = m.goUp 578 + } 579 + 580 + if op.Mode == TransactionEnabled { 581 + if err := p.executor(p.db).WithTransaction(ctx, func(ctx context.Context) error { 582 + // go migrations require explicit db 583 + if err := op.Operation(ctx, p.db); err != nil { 584 + return fmt.Errorf("failed to execute operation: %w", err) 585 + } 586 + return nil 587 + }); err != nil { 588 + return fmt.Errorf("failed to execute operation: %w", err) 589 + } 590 + } else { 591 + // go migrations require explicit db 592 + if err := op.Operation(ctx, p.db); err != nil { 593 + return fmt.Errorf("failed to execute operation: %w", err) 594 + } 595 + } 596 + case MigrationTypeJSON: 597 + cmds := m.json.Down.Cmds 598 + if up { 599 + cmds = m.json.Up.Cmds 600 + } 601 + if m.UseTx { 602 + if err := p.executor(p.db).WithTransaction(ctx, func(ctx context.Context) error { 603 + for _, cmd := range cmds { 604 + if err := p.executor(p.db).RunCommand(ctx, cmd); err != nil { 605 + return fmt.Errorf("failed to execute command: %w", err) 606 + } 607 + } 608 + return nil 609 + }); err != nil { 610 + return fmt.Errorf("failed to execute operation: %w", err) 611 + } 612 + } else { 613 + for _, cmd := range cmds { 614 + if err := p.executor(p.db).RunCommand(ctx, cmd); err != nil { 615 + return fmt.Errorf("failed to execute command: %w", err) 616 + } 617 + } 618 + } 619 + } 620 + 621 + record := func() error { 622 + if up { 623 + return p.store.Insert(ctx, p.db, InsertRequest{ 624 + Version: m.Version, 625 + }) 626 + } 627 + 628 + return p.store.Delete(ctx, p.db, m.Version) 629 + } 630 + 631 + return record() 632 + } 633 + 634 + // MigrationType is the type of migration. 635 + type MigrationType string 636 + 637 + const ( 638 + MigrationTypeGo MigrationType = "go" 639 + MigrationTypeJSON MigrationType = "json" 640 + ) 641 + 642 + type Migration struct { 643 + Type MigrationType 644 + Version int64 645 + Source string 646 + Next int64 647 + Previous int64 648 + json *parse.JSONMigration 649 + goUp, goDown *GoFunc 650 + Registered bool 651 + UseTx bool 652 + } 653 + 654 + func (m *Migration) Parse(dir fs.FS) error { 655 + if m.Type == MigrationTypeGo { 656 + return nil 657 + } 658 + f, err := dir.Open(m.Source) 659 + if err != nil { 660 + return fmt.Errorf("ERROR %v: failed to open JSON migration file: %w", filepath.Base(m.Source), err) 661 + } 662 + defer func() { _ = f.Close() }() 663 + jm, err := parse.JSON(f) 664 + if err != nil { 665 + return fmt.Errorf("ERROR %v: failed to parse JSON migration file: %w", filepath.Base(m.Source), err) 666 + } 667 + m.json = jm 668 + return nil 669 + } 670 + 671 + // Source represents where a given migration lives on disk and its type 672 + type Source struct { 673 + Type MigrationType 674 + Path string 675 + Version int64 676 + } 677 + 678 + // MigrationResult captures the result of a migration run, up or down, including migration failures. 679 + type MigrationResult struct { 680 + Source *Source 681 + Duration time.Duration 682 + Direction string 683 + Empty bool 684 + Error error 685 + } 686 + 687 + func (m *MigrationResult) String() string { 688 + truncate := func(d time.Duration) time.Duration { 689 + for _, v := range []time.Duration{ 690 + time.Second, 691 + time.Millisecond, 692 + time.Microsecond, 693 + } { 694 + if d > v { 695 + return d.Round(v / time.Duration(100)) 696 + } 697 + } 698 + return d 699 + } 700 + 701 + var format string 702 + if m.Direction == "up" { 703 + format = "%-5s %-2s %s (%s)" 704 + } else { 705 + format = "%-5s %-4s %s (%s)" 706 + } 707 + var state string 708 + if m.Empty { 709 + state = "EMPTY" 710 + } else { 711 + state = "OK" 712 + } 713 + return fmt.Sprintf(format, 714 + state, 715 + m.Direction, 716 + filepath.Base(m.Source.Path), 717 + truncate(m.Duration), 718 + ) 719 + } 720 + 721 + // PartialError indicates that a migration operation partially failed 722 + type PartialError struct { 723 + Applied []*MigrationResult 724 + Failed *MigrationResult 725 + Err error 726 + } 727 + 728 + func (e *PartialError) Error() string { 729 + return fmt.Sprintf( 730 + "partial migration error (type:%s,version:%d): %v", 731 + e.Failed.Source.Type, e.Failed.Source.Version, e.Err, 732 + ) 733 + } 734 + 735 + func (e *PartialError) Unwrap() error { 736 + return e.Err 737 + } 738 + 739 + type Operation func(ctx context.Context, db *mongo.Database) error 740 + 741 + type GoFunc struct { 742 + Operation Operation 743 + Mode TransactionMode 744 + } 745 + 746 + var registeredGoMigrations = make(map[int64]*Migration) 747 + 748 + // AddMigration registers a new Go migration to be run within a transaction named after the calling file 749 + func AddMigration(up, down Operation) { 750 + _, filename, _, _ := runtime.Caller(1) 751 + addNamedMigration(filename, up, down, true) 752 + } 753 + 754 + // AddMigrationNoTx registers a new Go migration to be run without a transaction named after the calling file 755 + func AddMigrationNoTx(up, down Operation) { 756 + _, filename, _, _ := runtime.Caller(1) 757 + addNamedMigration(filename, up, down, false) 758 + } 759 + 760 + func addNamedMigration(filename string, up, down Operation, useTx bool) { 761 + if err := register( 762 + filename, 763 + useTx, 764 + &GoFunc{Operation: up, Mode: TransactionEnabled}, 765 + &GoFunc{Operation: down, Mode: TransactionEnabled}, 766 + ); err != nil { 767 + panic(err) 768 + } 769 + } 770 + 771 + func register(filename string, useTx bool, up, down *GoFunc) error { 772 + v, _ := ExtractVersion(filename) 773 + if existing, ok := registeredGoMigrations[v]; ok { 774 + return fmt.Errorf("failed to add migration %q: version %d conflicts with %q", 775 + filename, 776 + v, 777 + existing.Source, 778 + ) 779 + } 780 + // Add to global as a registered migration. 781 + m := NewGoMigration(v, up, down) 782 + m.Source = filename 783 + // We explicitly set transaction to maintain existing behavior. Both up and down may be nil, but 784 + // we know based on the register function what the user is requesting. 785 + m.UseTx = useTx 786 + registeredGoMigrations[v] = m 787 + return nil 788 + } 789 + 790 + func NewGoMigration(version int64, up, down *GoFunc) *Migration { 791 + m := &Migration{ 792 + Type: MigrationTypeGo, 793 + Registered: true, 794 + Version: version, 795 + goUp: up, 796 + goDown: down, 797 + } 798 + return m 799 + } 800 + 801 + var ( 802 + ErrFileExt = errors.New("migration file does not have json or go file extension") 803 + ErrNoSeparator = errors.New("no filename separator '_' found") 804 + ErrVersionNumber = errors.New("migration version must a number greater than zero") 805 + ) 806 + 807 + func ExtractVersion(filename string) (int64, error) { 808 + base := filepath.Base(filename) 809 + if ext := filepath.Ext(base); ext != ".go" && ext != ".json" { 810 + return 0, ErrNoSeparator 811 + } 812 + before, _, found := strings.Cut(base, "_") 813 + if !found { 814 + return 0, ErrNoSeparator 815 + } 816 + version, err := strconv.ParseInt(before, 10, 64) 817 + if err != nil { 818 + return 0, fmt.Errorf("failed to parse version from migration file: %s: %w", base, ErrVersionNumber) 819 + } 820 + if version < 1 { 821 + return 0, ErrVersionNumber 822 + } 823 + return version, nil 824 + } 825 + 826 + type Migrations []*Migration 827 + 828 + func (ms Migrations) versioned(timestampFormat string) (Migrations, error) { 829 + var migrations Migrations 830 + 831 + // assume that the user will never have more than 19700101000000 migrations 832 + for _, m := range ms { 833 + // parse version as timestamp 834 + versionTime, err := time.Parse(timestampFormat, fmt.Sprintf("%d", m.Version)) 835 + 836 + if versionTime.Before(time.Unix(0, 0)) || err != nil { 837 + migrations = append(migrations, m) 838 + } 839 + } 840 + 841 + return migrations, nil 842 + } 843 + 844 + func (ms Migrations) Last() (*Migration, error) { 845 + if len(ms) == 0 { 846 + return nil, ErrNoNextVersion 847 + } 848 + 849 + return ms[len(ms)-1], nil 850 + } 851 + 852 + // Current returns the current migration or [ErrNoCurrentVersion] if there is not 853 + func (ms Migrations) Current(current int64) (*Migration, error) { 854 + for i, migration := range ms { 855 + if migration.Version == current { 856 + return ms[i], nil 857 + } 858 + } 859 + 860 + return nil, ErrNoCurrentVersion 861 + } 862 + 863 + func collect(fsys fs.FS, dir string, current, target int64, registered map[int64]*Migration) ([]*Migration, error) { 864 + versionFilter := func(v, current, target int64) bool { 865 + if target > current { 866 + return v > current && v <= target 867 + } 868 + if target < current { 869 + return v <= current && v > target 870 + } 871 + return false 872 + } 873 + 874 + if _, err := fs.Stat(fsys, dir); err != nil { 875 + if errors.Is(err, fs.ErrNotExist) { 876 + return nil, fmt.Errorf("%s directory does not exist", dir) 877 + } 878 + return nil, err 879 + } 880 + var migrations []*Migration 881 + // json files 882 + jsons, _ := fs.Glob(fsys, path.Join(dir, "*.json")) 883 + for _, f := range jsons { 884 + v, err := ExtractVersion(f) 885 + if err != nil { 886 + return nil, fmt.Errorf("could not parse JSON migration file %q: %w", f, err) 887 + } 888 + if versionFilter(v, current, target) { 889 + migrations = append(migrations, &Migration{ 890 + Version: v, 891 + Next: -1, 892 + Previous: -1, 893 + Source: f, 894 + }) 895 + } 896 + } 897 + // Go files 898 + // sanity check 899 + for _, m := range registered { 900 + if _, err := ExtractVersion(m.Source); err != nil { 901 + return nil, fmt.Errorf("could not parse go migration file %s: %w", m.Source, err) 902 + } 903 + } 904 + 905 + gos, _ := fs.Glob(fsys, path.Join(dir, "*.go")) 906 + for _, f := range gos { 907 + v, err := ExtractVersion(f) 908 + if err != nil { 909 + continue // Skip any files that don't have version prefix. 910 + } 911 + if strings.HasSuffix(f, "_test.go") { 912 + continue 913 + } 914 + 915 + if versionFilter(v, current, target) { 916 + if reg, ok := registered[v]; ok { 917 + migrations = append(migrations, reg) 918 + continue 919 + } 920 + migrations = append(migrations, &Migration{ 921 + Version: v, 922 + Source: f, 923 + Registered: false, 924 + }) 925 + } 926 + } 927 + 928 + if len(migrations) == 0 { 929 + return nil, ErrNoMigrationFiles 930 + } 931 + 932 + slices.SortFunc(migrations, func(a, b *Migration) int { 933 + return cmp.Compare(a.Version, b.Version) 934 + }) 935 + 936 + return migrations, nil 937 + }
+1
moose_test.go
··· 1 + package moose
+27
osfs.go
··· 1 + package moose 2 + 3 + import ( 4 + "io/fs" 5 + "os" 6 + "path/filepath" 7 + ) 8 + 9 + type osFS struct{} 10 + 11 + func (osFS) Open(name string) (fs.File, error) { return os.Open(filepath.FromSlash(name)) } 12 + 13 + func (osFS) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(filepath.FromSlash(name)) } 14 + 15 + func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(filepath.FromSlash(name)) } 16 + 17 + func (osFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(filepath.FromSlash(name)) } 18 + 19 + func (osFS) Glob(pattern string) ([]string, error) { return filepath.Glob(filepath.FromSlash(pattern)) } 20 + 21 + type noopFS struct{} 22 + 23 + var _ fs.FS = noopFS{} 24 + 25 + func (f noopFS) Open(name string) (fs.File, error) { 26 + return nil, os.ErrNotExist 27 + }
+178
store.go
··· 1 + package moose 2 + 3 + import ( 4 + "cmp" 5 + "context" 6 + "maps" 7 + "slices" 8 + "time" 9 + 10 + "go.mongodb.org/mongo-driver/v2/bson" 11 + "go.mongodb.org/mongo-driver/v2/mongo" 12 + "go.mongodb.org/mongo-driver/v2/mongo/options" 13 + ) 14 + 15 + // Store is the interface used by the migration provider to persist migration state. 16 + type Store interface { 17 + Init(ctx context.Context, db *mongo.Database) error 18 + Insert(ctx context.Context, db *mongo.Database, req InsertRequest) error 19 + Delete(ctx context.Context, db *mongo.Database, version int64) error 20 + Get(ctx context.Context, db *mongo.Database, version int64) (*GetMigrationResult, error) 21 + Latest(ctx context.Context, db *mongo.Database) (int64, error) 22 + List(ctx context.Context, db *mongo.Database) ([]*ListMigrationsResult, error) 23 + } 24 + 25 + type InsertRequest struct { 26 + Version int64 27 + } 28 + 29 + type GetMigrationResult struct { 30 + Timestamp time.Time 31 + Applied bool 32 + } 33 + 34 + type ListMigrationsResult struct { 35 + Version int64 36 + Applied bool 37 + } 38 + 39 + type doc struct { 40 + Version int64 `bson:"version"` 41 + Timestamp time.Time `bson:"timestamp"` 42 + Applied bool `bson:"applied"` 43 + } 44 + 45 + type collection string 46 + 47 + // NewStore returns the a default [Store] implementation backed by a provided collectionName 48 + func NewStore(name string) Store { 49 + return collection(name) 50 + } 51 + 52 + func (c collection) Init(ctx context.Context, db *mongo.Database) error { 53 + _, err := db.Collection(string(c)).Indexes().CreateOne(ctx, mongo.IndexModel{ 54 + Keys: bson.M{ 55 + "version": -1, // most recent first 56 + }, 57 + Options: options.Index().SetUnique(true), 58 + }) 59 + return err 60 + } 61 + 62 + func (c collection) Insert(ctx context.Context, db *mongo.Database, req InsertRequest) error { 63 + _, err := db.Collection(string(c)).InsertOne(ctx, doc{ 64 + Version: req.Version, 65 + Timestamp: time.Now().UTC(), 66 + Applied: true, 67 + }) 68 + return err 69 + } 70 + 71 + func (c collection) Delete(ctx context.Context, db *mongo.Database, version int64) error { 72 + _, err := db.Collection(string(c)).DeleteOne(ctx, bson.M{"version": version}) 73 + return err 74 + } 75 + 76 + func (c collection) Get(ctx context.Context, db *mongo.Database, version int64) (*GetMigrationResult, error) { 77 + res := db.Collection(string(c)).FindOne(ctx, bson.M{"version": version}) 78 + if res.Err() != nil { 79 + if res.Err() == mongo.ErrNoDocuments { 80 + return nil, nil 81 + } 82 + return nil, res.Err() 83 + } 84 + var doc doc 85 + if err := res.Decode(&doc); err != nil { 86 + return nil, err 87 + } 88 + return &GetMigrationResult{ 89 + Timestamp: doc.Timestamp, 90 + Applied: doc.Applied, 91 + }, nil 92 + } 93 + 94 + func (c collection) Latest(ctx context.Context, db *mongo.Database) (int64, error) { 95 + cur, err := db.Collection(string(c)).Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"version": -1}).SetLimit(1)) 96 + if err != nil { 97 + return -1, err 98 + } 99 + defer func() { _ = cur.Close(context.Background()) }() 100 + if cur.Next(ctx) { 101 + var doc doc 102 + if err := cur.Decode(&doc); err != nil { 103 + return -1, err 104 + } 105 + return doc.Version, nil 106 + } 107 + return 0, err 108 + } 109 + 110 + func (c collection) List(ctx context.Context, db *mongo.Database) ([]*ListMigrationsResult, error) { 111 + cur, err := db.Collection(string(c)).Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"version": -1})) 112 + if err != nil { 113 + return nil, err 114 + } 115 + defer func() { _ = cur.Close(context.Background()) }() 116 + var docs []doc 117 + if err := cur.All(ctx, &docs); err != nil { 118 + return nil, err 119 + } 120 + migrations := make([]*ListMigrationsResult, 0, len(docs)) 121 + for _, doc := range docs { 122 + migrations = append(migrations, &ListMigrationsResult{ 123 + Version: doc.Version, 124 + Applied: doc.Applied, 125 + }) 126 + } 127 + return migrations, nil 128 + } 129 + 130 + // memory is an in-memory storage implementation of for migrations. It's primary use is to faciliate testing. 131 + type memory struct { 132 + data map[int64]time.Time 133 + } 134 + 135 + // newMemoryStore returns in-memory storage implementation of for migrations. It's primary use is to faciliate testing. 136 + func newMemoryStore(data map[int64]time.Time) *memory { 137 + return &memory{data: data} 138 + } 139 + 140 + func (s *memory) Init(ctx context.Context, db *mongo.Database) error { 141 + return nil 142 + } 143 + 144 + func (s *memory) Insert(ctx context.Context, db *mongo.Database, req InsertRequest) error { 145 + s.data[req.Version] = time.Now().UTC() 146 + return nil 147 + } 148 + 149 + func (s *memory) Delete(ctx context.Context, db *mongo.Database, version int64) error { 150 + delete(s.data, version) 151 + return nil 152 + } 153 + 154 + func (s *memory) Get(ctx context.Context, db *mongo.Database, version int64) (*GetMigrationResult, error) { 155 + ts, ok := s.data[version] 156 + if !ok { 157 + return nil, nil 158 + } 159 + return &GetMigrationResult{Timestamp: ts, Applied: true}, nil 160 + } 161 + 162 + func (s *memory) Latest(ctx context.Context, db *mongo.Database) (int64, error) { 163 + if len(s.data) == 0 { 164 + return 0, nil 165 + } 166 + return slices.Max(slices.Collect(maps.Keys(s.data))), nil 167 + } 168 + 169 + func (s *memory) List(ctx context.Context, db *mongo.Database) ([]*ListMigrationsResult, error) { 170 + res := make([]*ListMigrationsResult, len(s.data)) 171 + for k := range s.data { 172 + res = append(res, &ListMigrationsResult{Version: k, Applied: true}) 173 + } 174 + slices.SortFunc(res, func(a, b *ListMigrationsResult) int { 175 + return cmp.Compare(a.Version, b.Version) 176 + }) 177 + return res, nil 178 + }
+81
up_test.go
··· 1 + package moose 2 + 3 + import ( 4 + "testing" 5 + "testing/fstest" 6 + "time" 7 + 8 + "github.com/google/go-cmp/cmp" 9 + "go.mongodb.org/mongo-driver/v2/mongo" 10 + ) 11 + 12 + const example = `{ 13 + "up": { 14 + "tx": true, 15 + "cmds": [ 16 + { 17 + "createIndexes": "users", 18 + "indexes": [ 19 + { 20 + "key": { 21 + "username": 1 22 + }, 23 + "name": "username_1" 24 + } 25 + ] 26 + } 27 + ] 28 + }, 29 + "down": { 30 + "tx": true, 31 + "cmds": [ 32 + { 33 + "dropIndexes": "users", 34 + "index": "username_1" 35 + } 36 + ] 37 + } 38 + } 39 + ` 40 + 41 + func TestUp(t *testing.T) { 42 + ctx := t.Context() 43 + provider, err := NewProvider( 44 + nil, 45 + fstest.MapFS{ 46 + "1_foo.json": {Data: []byte(example)}, 47 + }, 48 + WithStore(newMemoryStore(map[int64]time.Time{})), 49 + withExecutor(func(*mongo.Database) executor { 50 + return &noop{} 51 + }), 52 + ) 53 + if err != nil { 54 + t.Fatal(err) 55 + } 56 + got, err := provider.Up(ctx) 57 + if err != nil { 58 + t.Fatal(err) 59 + } 60 + 61 + // reset duration to zero value for simple comparision 62 + for _, r := range got { 63 + r.Duration = time.Duration(0) 64 + } 65 + 66 + want := []*MigrationResult{ 67 + { 68 + Source: &Source{ 69 + Type: MigrationTypeJSON, 70 + Path: "1_foo.json", 71 + Version: 1, 72 + }, 73 + Direction: "up", 74 + Empty: false, 75 + }, 76 + } 77 + 78 + if diff := cmp.Diff(want, got); diff != "" { 79 + t.Fatalf("Up mismatch (-want +got):\n%s", diff) 80 + } 81 + }