diff --git a/.gitignore b/.gitignore index e1fd412a..16854259 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,6 @@ # goreleaser builds folder /.builds/ -# examples app directories -pb_data -pb_public - # tests coverage coverage.out diff --git a/cmd/migrate.go b/cmd/migrate.go deleted file mode 100644 index 541d143d..00000000 --- a/cmd/migrate.go +++ /dev/null @@ -1,241 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path" - "time" - - "github.com/AlecAivazis/survey/v2" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/migrations/logs" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/inflector" - "github.com/pocketbase/pocketbase/tools/migrate" - "github.com/spf13/cobra" -) - -// NewMigrateCommand creates and returns new command for handling DB migrations. -func NewMigrateCommand(app core.App) *cobra.Command { - desc := ` -Supported arguments are: -- up - runs all available migrations. -- down [number] - reverts the last [number] applied migrations. -- create name [folder] - creates new migration template file. -- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration. -` - var databaseFlag string - - command := &cobra.Command{ - Use: "migrate", - Short: "Executes DB migration scripts", - ValidArgs: []string{"up", "down", "create", "collections"}, - Long: desc, - Run: func(command *cobra.Command, args []string) { - cmd := "" - if len(args) > 0 { - cmd = args[0] - } - - // additional commands - // --- - if cmd == "create" { - if err := migrateCreateHandler(defaultMigrateCreateTemplate, args[1:]); err != nil { - log.Fatal(err) - } - return - } - if cmd == "collections" { - if err := migrateCollectionsHandler(app, args[1:]); err != nil { - log.Fatal(err) - } - return - } - // --- - - // normalize - if databaseFlag != "logs" { - databaseFlag = "db" - } - - connections := migrationsConnectionsMap(app) - - runner, err := migrate.NewRunner( - connections[databaseFlag].DB, - connections[databaseFlag].MigrationsList, - ) - if err != nil { - log.Fatal(err) - } - - if err := runner.Run(args...); err != nil { - log.Fatal(err) - } - }, - } - - command.PersistentFlags().StringVar( - &databaseFlag, - "database", - "db", - "specify the database connection to use (db or logs)", - ) - - return command -} - -type migrationsConnection struct { - DB *dbx.DB - MigrationsList migrate.MigrationsList -} - -func migrationsConnectionsMap(app core.App) map[string]migrationsConnection { - return map[string]migrationsConnection{ - "db": { - DB: app.DB(), - MigrationsList: migrations.AppMigrations, - }, - "logs": { - DB: app.LogsDB(), - MigrationsList: logs.LogsMigrations, - }, - } -} - -// ------------------------------------------------------------------- -// migrate create -// ------------------------------------------------------------------- - -const defaultMigrateCreateTemplate = `package migrations - -import ( - "github.com/pocketbase/dbx" - m "github.com/pocketbase/pocketbase/migrations" -) - -func init() { - m.Register(func(db dbx.Builder) error { - // add up queries... - - return nil - }, func(db dbx.Builder) error { - // add down queries... - - return nil - }) -} -` - -func migrateCreateHandler(template string, args []string) error { - if len(args) < 1 { - return fmt.Errorf("Missing migration file name") - } - - name := args[0] - - var dir string - if len(args) == 2 { - dir = args[1] - } - if dir == "" { - // If not specified, auto point to the default migrations folder. - // - // NB! - // Since the create command makes sense only during development, - // it is expected the user to be in the app working directory - // and to be using `go run` - wd, err := os.Getwd() - if err != nil { - return err - } - dir = path.Join(wd, "migrations") - } - - resultFilePath := path.Join( - dir, - fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)), - ) - - confirm := false - prompt := &survey.Confirm{ - Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath), - } - survey.AskOne(prompt, &confirm) - if !confirm { - fmt.Println("The command has been cancelled") - return nil - } - - // ensure that migrations dir exist - if err := os.MkdirAll(dir, os.ModePerm); err != nil { - return err - } - - if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil { - return fmt.Errorf("Failed to save migration file %q\n", resultFilePath) - } - - fmt.Printf("Successfully created file %q\n", resultFilePath) - return nil -} - -// ------------------------------------------------------------------- -// migrate collections -// ------------------------------------------------------------------- - -const collectionsMigrateCreateTemplate = `package migrations - -import ( - "encoding/json" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" - m "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/models" -) - -// Auto generated migration with the most recent collections configuration. -func init() { - m.Register(func(db dbx.Builder) error { - jsonData := ` + "`" + `%s` + "`" + ` - - collections := []*models.Collection{} - if err := json.Unmarshal([]byte(jsonData), &collections); err != nil { - return err - } - - return daos.New(db).ImportCollections(collections, true, nil) - }, func(db dbx.Builder) error { - // no revert since the configuration on the environment, on which - // the migration was executed, could have changed via the UI/API - return nil - }) -} -` - -func migrateCollectionsHandler(app core.App, args []string) error { - createArgs := []string{"collections_snapshot"} - createArgs = append(createArgs, args...) - - dao := daos.New(app.DB()) - - collections := []*models.Collection{} - if err := dao.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil { - return fmt.Errorf("Failed to fetch migrations list: %v", err) - } - - serialized, err := json.MarshalIndent(collections, "\t\t", "\t") - if err != nil { - return fmt.Errorf("Failed to serialize collections list: %v", err) - } - - return migrateCreateHandler( - fmt.Sprintf(collectionsMigrateCreateTemplate, string(serialized)), - createArgs, - ) -} diff --git a/cmd/serve.go b/cmd/serve.go index 1c947151..d63271eb 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -10,8 +10,11 @@ import ( "github.com/fatih/color" "github.com/labstack/echo/v5/middleware" + "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/migrations/logs" "github.com/pocketbase/pocketbase/tools/migrate" "github.com/spf13/cobra" "golang.org/x/crypto/acme" @@ -38,7 +41,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command { // (or if this is the first time the init migration was executed) if err := app.RefreshSettings(); err != nil { color.Yellow("=====================================") - color.Yellow("WARNING - Settings load error! \n%v", err) + color.Yellow("WARNING: Settings load error! \n%v", err) color.Yellow("Fallback to the application defaults.") color.Yellow("=====================================") } @@ -137,8 +140,22 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command { return command } +type migrationsConnection struct { + DB *dbx.DB + MigrationsList migrate.MigrationsList +} + func runMigrations(app core.App) error { - connections := migrationsConnectionsMap(app) + connections := []migrationsConnection{ + { + DB: app.DB(), + MigrationsList: migrations.AppMigrations, + }, + { + DB: app.LogsDB(), + MigrationsList: logs.LogsMigrations, + }, + } for _, c := range connections { runner, err := migrate.NewRunner(c.DB, c.MigrationsList) diff --git a/examples/base/.gitignore b/examples/base/.gitignore new file mode 100644 index 00000000..8901848e --- /dev/null +++ b/examples/base/.gitignore @@ -0,0 +1,3 @@ +pb_data/ +pb_public/ +pb_migrations/ diff --git a/examples/base/main.go b/examples/base/main.go index 815ff4aa..a81a7b6d 100644 --- a/examples/base/main.go +++ b/examples/base/main.go @@ -2,6 +2,8 @@ package main import ( "log" + "os" + "os/exec" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/plugins/jsvm" @@ -12,19 +14,65 @@ import ( func main() { app := pocketbase.New() + // --------------------------------------------------------------- + // Optional plugin flags: + // --------------------------------------------------------------- + + var migrationsDir string + app.RootCmd.PersistentFlags().StringVar( + &migrationsDir, + "migrationsDir", + "", + "the directory with the user defined migrations", + ) + + var automigrate bool + _, gitErr := exec.LookPath("git") + app.RootCmd.PersistentFlags().BoolVar( + &automigrate, + "automigrate", + gitErr == nil, + "enable/disable auto migrations", + ) + + var publicDir string + app.RootCmd.PersistentFlags().StringVar( + &publicDir, + "publicDir", + "", + "the directory to serve static files", + ) + + var indexFallback bool + app.RootCmd.PersistentFlags().BoolVar( + &indexFallback, + "indexFallback", + true, + "fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)", + ) + + app.RootCmd.ParseFlags(os.Args[1:]) + + // --------------------------------------------------------------- + // Plugins: + // --------------------------------------------------------------- + // load js pb_migrations - jsvm.MustRegisterMigrationsLoader(app, nil) + jsvm.MustRegisterMigrationsLoader(app, &jsvm.MigrationsLoaderOptions{ + Dir: migrationsDir, + }) // migrate command (with js templates) migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{ TemplateLang: migratecmd.TemplateLangJS, - AutoMigrate: true, + Automigrate: automigrate, + Dir: migrationsDir, }) // pb_public dir publicdir.MustRegister(app, &publicdir.Options{ - FlagsCmd: app.RootCmd, - IndexFallback: true, + Dir: publicDir, + IndexFallback: indexFallback, }) if err := app.Start(); err != nil { diff --git a/plugins/jsvm/migrations.go b/plugins/jsvm/migrations.go index 2778a9c9..04087f9e 100644 --- a/plugins/jsvm/migrations.go +++ b/plugins/jsvm/migrations.go @@ -13,8 +13,9 @@ import ( // MigrationsLoaderOptions defines optional struct to customize the default plugin behavior. type MigrationsLoaderOptions struct { - // Dir is the app migrations directory from where the js files will be loaded - // (default to pb_data/migrations) + // Dir specifies the directory with the JS migrations. + // + // If not set it fallbacks to a relative "pb_data/../pb_migrations" directory. Dir string } diff --git a/plugins/migratecmd/automigrate.go b/plugins/migratecmd/automigrate.go index 1fa83f72..34dbbe3c 100644 --- a/plugins/migratecmd/automigrate.go +++ b/plugins/migratecmd/automigrate.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -17,23 +18,10 @@ import ( ) const migrationsTable = "_migrations" +const automigrateSuffix = "_automigrate" -// tidyMigrationsTable cleanups the migrations table by removing all -// entries with deleted migration files. -func (p *plugin) tidyMigrationsTable() error { - names, filesErr := p.getAllMigrationNames() - if filesErr != nil { - return fmt.Errorf("failed to fetch migration files list: %v", filesErr) - } - - _, tidyErr := p.app.Dao().DB().Delete(migrationsTable, dbx.NotIn("file", list.ToInterfaceSlice(names)...)).Execute() - if tidyErr != nil { - return fmt.Errorf("failed to delete last automigrates from the db: %v", tidyErr) - } - - return nil -} - +// onCollectionChange handles the automigration snapshot generation on +// collection change event (create/update/delete). func (p *plugin) onCollectionChange() func(*core.ModelEvent) error { return func(e *core.ModelEvent) error { if e.Model.TableName() != "_collections" { @@ -48,35 +36,11 @@ func (p *plugin) onCollectionChange() func(*core.ModelEvent) error { return errors.New("missing collections to automigrate") } - names, err := p.getAllMigrationNames() + oldFiles, err := p.getAllMigrationNames() if err != nil { return fmt.Errorf("failed to fetch migration files list: %v", err) } - // delete last consequitive automigrates - lastAutomigrates := []string{} - for i := len(names) - 1; i >= 0; i-- { - migrationFile := names[i] - if !strings.Contains(migrationFile, "_automigrate.") { - break - } - lastAutomigrates = append(lastAutomigrates, migrationFile) - } - if len(lastAutomigrates) > 0 { - // delete last automigrates from the db - _, err := p.app.Dao().DB().Delete(migrationsTable, dbx.In("file", list.ToInterfaceSlice(lastAutomigrates)...)).Execute() - if err != nil { - return fmt.Errorf("failed to delete last automigrates from the db: %v", err) - } - - // delete last automigrates from the filesystem - for _, f := range lastAutomigrates { - if err := os.Remove(filepath.Join(p.options.Dir, f)); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to delete last automigrates from the filesystem: %v", err) - } - } - } - var template string var templateErr error if p.options.TemplateLang == TemplateLangJS { @@ -88,10 +52,6 @@ func (p *plugin) onCollectionChange() func(*core.ModelEvent) error { return fmt.Errorf("failed to resolve template: %v", templateErr) } - // add a comment to not edit the template - template = ("// Do not edit by hand since this file is autogenerated and may get overwritten.\n" + - "// If you want to do further changes, create a new non '_automigrate' file instead.\n" + template) - appliedTime := time.Now().Unix() fileDest := filepath.Join(p.options.Dir, fmt.Sprintf("%d_automigrate.%s", appliedTime, p.options.TemplateLang)) @@ -100,11 +60,37 @@ func (p *plugin) onCollectionChange() func(*core.ModelEvent) error { return fmt.Errorf("failed to create migration dir: %v", err) } - return os.WriteFile(fileDest, []byte(template), 0644) + if err := os.WriteFile(fileDest, []byte(template), 0644); err != nil { + return fmt.Errorf("failed to save automigrate file: %v", err) + } + + // remove the old untracked automigrate file + // (only if the last one was automigrate!) + if len(oldFiles) > 0 && strings.HasSuffix(oldFiles[len(oldFiles)-1], automigrateSuffix+"."+p.options.TemplateLang) { + olfName := oldFiles[len(oldFiles)-1] + oldPath := filepath.Join(p.options.Dir, olfName) + + isUntracked := exec.Command(p.options.GitPath, "ls-files", "--error-unmatch", oldPath).Run() != nil + if isUntracked { + // delete the old automigrate from the db if it was already applied + _, err := p.app.Dao().DB().Delete(migrationsTable, dbx.HashExp{"file": olfName}).Execute() + if err != nil { + return fmt.Errorf("failed to delete last applied automigrate from the migration db: %v", err) + } + + // delete the old automigrate file from the filesystem + if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete last automigrates from the filesystem: %v", err) + } + } + } + + return nil } } -// getAllMigrationNames return both applied and new local migration file names. +// getAllMigrationNames return sorted slice with both applied and new +// local migration file names. func (p *plugin) getAllMigrationNames() ([]string, error) { names := []string{} diff --git a/plugins/migratecmd/migratecmd.go b/plugins/migratecmd/migratecmd.go index ecb678ab..626fde23 100644 --- a/plugins/migratecmd/migratecmd.go +++ b/plugins/migratecmd/migratecmd.go @@ -4,11 +4,13 @@ import ( "fmt" "log" "os" + "os/exec" "path" "path/filepath" "time" "github.com/AlecAivazis/survey/v2" + "github.com/fatih/color" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/migrations" "github.com/pocketbase/pocketbase/models" @@ -17,10 +19,23 @@ import ( "github.com/spf13/cobra" ) +// Options defines optional struct to customize the default plugin behavior. type Options struct { - Dir string // the directory with user defined migrations - AutoMigrate bool + // Dir specifies the directory with the user defined migrations. + // + // If not set it fallbacks to a relative "pb_data/../pb_migrations" (for js) + // or "pb_data/../migrations" (for go) directory. + Dir string + + // Automigrate specifies whether to enable automigrations. + Automigrate bool + + // TemplateLang specifies the template language to use when + // generating migrations - js or go (default). TemplateLang string + + // GitPath is the git cmd binary path (default to just "git"). + GitPath string } type plugin struct { @@ -55,25 +70,24 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error { } } + if p.options.GitPath == "" { + p.options.GitPath = "git" + } + // attach the migrate command if rootCmd != nil { rootCmd.AddCommand(p.createCommand()) } // watch for collection changes - if p.options.AutoMigrate { - // @todo replace with AfterBootstrap - p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { - if err := p.tidyMigrationsTable(); err != nil && p.app.IsDebug() { - log.Println("Failed to tidy the migrations table.") - } - - return nil - }) - - p.app.OnModelAfterCreate().Add(p.onCollectionChange()) - p.app.OnModelAfterUpdate().Add(p.onCollectionChange()) - p.app.OnModelAfterDelete().Add(p.onCollectionChange()) + if p.options.Automigrate { + if _, err := exec.LookPath(p.options.GitPath); err != nil { + color.Yellow("WARNING: Automigrate cannot be enabled because %s is not installed or accessable.", p.options.GitPath) + } else { + p.app.OnModelAfterCreate().Add(p.onCollectionChange()) + p.app.OnModelAfterUpdate().Add(p.onCollectionChange()) + p.app.OnModelAfterDelete().Add(p.onCollectionChange()) + } } return nil @@ -81,10 +95,10 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error { func (p *plugin) createCommand() *cobra.Command { const cmdDesc = `Supported arguments are: -- up - runs all available migrations. -- down [number] - reverts the last [number] applied migrations. -- create name [folder] - creates new blank migration template file. -- collections [folder] - creates new migration file with the latest local collections snapshot (similar to the automigrate but allows editing). +- up - runs all available migrations +- down [number] - reverts the last [number] applied migrations +- create name [folder] - creates new blank migration template file +- collections [folder] - creates new migration file with the latest local collections snapshot (similar to the automigrate but allows editing) ` command := &cobra.Command{ @@ -98,30 +112,24 @@ func (p *plugin) createCommand() *cobra.Command { cmd = args[0] } - // additional commands - // --- - if cmd == "create" { + switch cmd { + case "create": if err := p.migrateCreateHandler("", args[1:]); err != nil { log.Fatal(err) } - return - } - - if cmd == "collections" { + case "collections": if err := p.migrateCollectionsHandler(args[1:]); err != nil { log.Fatal(err) } - return - } - // --- + default: + runner, err := migrate.NewRunner(p.app.DB(), migrations.AppMigrations) + if err != nil { + log.Fatal(err) + } - runner, err := migrate.NewRunner(p.app.DB(), migrations.AppMigrations) - if err != nil { - log.Fatal(err) - } - - if err := runner.Run(args...); err != nil { - log.Fatal(err) + if err := runner.Run(args...); err != nil { + log.Fatal(err) + } } }, } diff --git a/plugins/publicdir/publicdir.go b/plugins/publicdir/publicdir.go index 115c7931..5152e278 100644 --- a/plugins/publicdir/publicdir.go +++ b/plugins/publicdir/publicdir.go @@ -13,13 +13,11 @@ import ( "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" - "github.com/spf13/cobra" ) type Options struct { Dir string IndexFallback bool - FlagsCmd *cobra.Command } type plugin struct { @@ -46,24 +44,6 @@ func Register(app core.App, options *Options) error { options.Dir = defaultPublicDir() } - if options.FlagsCmd != nil { - // add "--publicDir" option flag - options.FlagsCmd.PersistentFlags().StringVar( - &options.Dir, - "publicDir", - options.Dir, - "the directory to serve static files", - ) - - // add "--indexFallback" option flag - options.FlagsCmd.PersistentFlags().BoolVar( - &options.IndexFallback, - "indexFallback", - options.IndexFallback, - "fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)", - ) - } - p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { // serves static files from the provided public dir (if exists) e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(options.Dir), options.IndexFallback)) @@ -79,6 +59,5 @@ func defaultPublicDir() string { // most likely ran with go run return "./pb_public" } - return filepath.Join(os.Args[0], "../pb_public") } diff --git a/tests/app.go b/tests/app.go index 936f4cfc..8ff287e9 100644 --- a/tests/app.go +++ b/tests/app.go @@ -18,7 +18,13 @@ type TestApp struct { // EventCalls defines a map to inspect which app events // (and how many times) were triggered. + // + // The following events are not counted because they execute always: + // - OnBeforeBootstrap + // - OnAfterBootstrap + // - OnBeforeServe EventCalls map[string]int + TestMailer *TestMailer } @@ -81,12 +87,6 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) { TestMailer: &TestMailer{}, } - // no need to count since this is executed always - // t.OnBeforeServe().Add(func(e *core.ServeEvent) error { - // t.EventCalls["OnBeforeServe"]++ - // return nil - // }) - t.OnModelBeforeCreate().Add(func(e *core.ModelEvent) error { t.EventCalls["OnModelBeforeCreate"]++ return nil diff --git a/tools/migrate/runner.go b/tools/migrate/runner.go index 170c3dac..8e48cc82 100644 --- a/tools/migrate/runner.go +++ b/tools/migrate/runner.go @@ -115,8 +115,11 @@ func (r *Runner) Up() ([]string, error) { continue } - if err := m.Up(tx); err != nil { - return fmt.Errorf("Failed to apply migration %s: %w", m.File, err) + // ignore empty Up action + if m.Up != nil { + if err := m.Up(tx); err != nil { + return fmt.Errorf("Failed to apply migration %s: %w", m.File, err) + } } if err := r.saveAppliedMigration(tx, m.File); err != nil { @@ -155,8 +158,11 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) { break } - if err := m.Down(tx); err != nil { - return fmt.Errorf("Failed to revert migration %s: %w", m.File, err) + // ignore empty Down action + if m.Down != nil { + if err := m.Down(tx); err != nil { + return fmt.Errorf("Failed to revert migration %s: %w", m.File, err) + } } if err := r.saveRevertedMigration(tx, m.File); err != nil {