added plugins subpackage and added basic support for js migrations

This commit is contained in:
Gani Georgiev
2022-11-26 09:05:52 +02:00
parent 3e1a19685b
commit d8963c6fc3
19 changed files with 889 additions and 120 deletions
+110
View File
@@ -0,0 +1,110 @@
package jsvm
import (
"os"
"path/filepath"
"github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/require"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
// 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 string
}
// migrationsLoader is the plugin definition.
// Usually it is instantiated via RegisterMigrationsLoader or MustRegisterMigrationsLoader.
type migrationsLoader struct {
app core.App
options *MigrationsLoaderOptions
}
//
// MustRegisterMigrationsLoader registers the plugin to the provided
// app instance and panics if it fails.
//
// It it calls RegisterMigrationsLoader(app, options)
//
// If options is nil, by default the js files from pb_data/migrations are loaded.
// Set custom options.Dir if you want to change it to some other directory.
func MustRegisterMigrationsLoader(app core.App, options *MigrationsLoaderOptions) {
if err := RegisterMigrationsLoader(app, options); err != nil {
panic(err)
}
}
// RegisterMigrationsLoader registers the plugin to the provided app instance.
//
// If options is nil, by default the js files from pb_data/migrations are loaded.
// Set custom options.Dir if you want to change it to some other directory.
func RegisterMigrationsLoader(app core.App, options *MigrationsLoaderOptions) error {
l := &migrationsLoader{app: app}
if options != nil {
l.options = options
} else {
l.options = &MigrationsLoaderOptions{}
}
if l.options.Dir == "" {
l.options.Dir = filepath.Join(app.DataDir(), "../pb_migrations")
}
files, err := readDirFiles(l.options.Dir)
if err != nil {
return err
}
registry := new(require.Registry) // this can be shared by multiple runtimes
for file, content := range files {
vm := NewBaseVM(l.app)
registry.Enable(vm)
console.Enable(vm)
vm.Set("migrate", func(up, down func(db dbx.Builder) error) {
m.AppMigrations.Register(up, down, file)
})
_, err := vm.RunString(string(content))
if err != nil {
return err
}
}
return nil
}
// readDirFiles returns a map with all directory files and their content.
//
// If directory with dirPath is missing, it returns an empty map and no error.
func readDirFiles(dirPath string) (map[string][]byte, error) {
files, err := os.ReadDir(dirPath)
if err != nil {
if os.IsNotExist(err) {
return map[string][]byte{}, nil
}
return nil, err
}
result := map[string][]byte{}
for _, f := range files {
if f.IsDir() {
continue
}
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
if err != nil {
return nil, err
}
result[f.Name()] = raw
}
return result, nil
}
+135
View File
@@ -0,0 +1,135 @@
package jsvm
import (
"encoding/json"
"github.com/dop251/goja"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
func NewBaseVM(app core.App) *goja.Runtime {
vm := goja.New()
vm.SetFieldNameMapper(goja.UncapFieldNameMapper())
vm.Set("$app", app)
vm.Set("unmarshal", func(src map[string]any, dest any) (any, error) {
raw, err := json.Marshal(src)
if err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &dest); err != nil {
return nil, err
}
return dest, nil
})
collectionConstructor(vm)
recordConstructor(vm)
adminConstructor(vm)
daoConstructor(vm)
dbxBinds(vm)
return vm
}
func collectionConstructor(vm *goja.Runtime) {
vm.Set("Collection", func(call goja.ConstructorCall) *goja.Object {
instance := &models.Collection{}
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
}
func recordConstructor(vm *goja.Runtime) {
vm.Set("Record", func(call goja.ConstructorCall) *goja.Object {
instance := &models.Record{}
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
}
func adminConstructor(vm *goja.Runtime) {
vm.Set("Admin", func(call goja.ConstructorCall) *goja.Object {
instance := &models.Admin{}
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
}
func daoConstructor(vm *goja.Runtime) {
vm.Set("Dao", func(call goja.ConstructorCall) *goja.Object {
db, ok := call.Argument(0).Export().(dbx.Builder)
if !ok || db == nil {
panic("missing required Dao(db) argument")
}
instance := daos.New(db)
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
}
func dbxBinds(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$dbx", obj)
obj.Set("exp", dbx.NewExp)
obj.Set("hashExp", func(data map[string]any) dbx.HashExp {
exp := dbx.HashExp{}
for k, v := range data {
exp[k] = v
}
return exp
})
obj.Set("not", dbx.Not)
obj.Set("and", dbx.And)
obj.Set("or", dbx.Or)
obj.Set("in", dbx.In)
obj.Set("notIn", dbx.NotIn)
obj.Set("like", dbx.Like)
obj.Set("orLike", dbx.OrLike)
obj.Set("notLike", dbx.NotLike)
obj.Set("orNotLike", dbx.OrNotLike)
obj.Set("exists", dbx.Exists)
obj.Set("notExists", dbx.NotExists)
obj.Set("between", dbx.Between)
obj.Set("notBetween", dbx.NotBetween)
}
func apisBind(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$apis", obj)
// middlewares
obj.Set("requireRecordAuth", apis.RequireRecordAuth)
obj.Set("requireRecordAuth", apis.RequireRecordAuth)
obj.Set("requireSameContextRecordAuth", apis.RequireSameContextRecordAuth)
obj.Set("requireAdminAuth", apis.RequireAdminAuth)
obj.Set("requireAdminAuthOnlyIfAny", apis.RequireAdminAuthOnlyIfAny)
obj.Set("requireAdminOrRecordAuth", apis.RequireAdminOrRecordAuth)
obj.Set("requireAdminOrOwnerAuth", apis.RequireAdminOrOwnerAuth)
obj.Set("activityLogger", apis.ActivityLogger)
// api errors
obj.Set("notFoundError", apis.NewNotFoundError)
obj.Set("badRequestError", apis.NewBadRequestError)
obj.Set("forbiddenError", apis.NewForbiddenError)
obj.Set("unauthorizedError", apis.NewUnauthorizedError)
// record helpers
obj.Set("getRequestData", apis.GetRequestData)
obj.Set("requestData", apis.RequestData)
obj.Set("enrichRecord", apis.EnrichRecord)
obj.Set("enrichRecords", apis.EnrichRecords)
}
+154
View File
@@ -0,0 +1,154 @@
package migratecmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
)
const migrationsTable = "_migrations"
// 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
}
func (p *plugin) onCollectionChange() func(*core.ModelEvent) error {
return func(e *core.ModelEvent) error {
if e.Model.TableName() != "_collections" {
return nil // not a collection
}
collections := []*models.Collection{}
if err := p.app.Dao().CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("failed to fetch collections list: %v", err)
}
if len(collections) == 0 {
return errors.New("missing collections to automigrate")
}
names, 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 {
template, templateErr = p.jsSnapshotTemplate(collections)
} else {
template, templateErr = p.goSnapshotTemplate(collections)
}
if templateErr != nil {
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))
// ensure that the local migrations dir exist
if err := os.MkdirAll(p.options.Dir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create migration dir: %v", err)
}
return os.WriteFile(fileDest, []byte(template), 0644)
}
}
// getAllMigrationNames return both applied and new local migration file names.
func (p *plugin) getAllMigrationNames() ([]string, error) {
names := []string{}
for _, migration := range m.AppMigrations.Items() {
names = append(names, migration.File)
}
localFiles, err := p.getLocalMigrationNames()
if err != nil {
return nil, err
}
for _, name := range localFiles {
if !list.ExistInSlice(name, names) {
names = append(names, name)
}
}
sort.Slice(names, func(i int, j int) bool {
return names[i] < names[j]
})
return names, nil
}
// getLocalMigrationNames returns a list with all local migration files
//
// Returns an empty slice if the migrations directory doesn't exist.
func (p *plugin) getLocalMigrationNames() ([]string, error) {
files, err := os.ReadDir(p.options.Dir)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
result := make([]string, 0, len(files))
for _, f := range files {
if f.IsDir() {
continue
}
result = append(result, f.Name())
}
return result, nil
}
+210
View File
@@ -0,0 +1,210 @@
package migratecmd
import (
"fmt"
"log"
"os"
"path"
"path/filepath"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
)
type Options struct {
Dir string // the directory with user defined migrations
AutoMigrate bool
TemplateLang string
}
type plugin struct {
app core.App
options *Options
}
func MustRegister(app core.App, rootCmd *cobra.Command, options *Options) {
if err := Register(app, rootCmd, options); err != nil {
panic(err)
}
}
func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
p := &plugin{app: app}
if options != nil {
p.options = options
} else {
p.options = &Options{}
}
if p.options.TemplateLang == "" {
p.options.TemplateLang = TemplateLangGo
}
if p.options.Dir == "" {
if p.options.TemplateLang == TemplateLangJS {
p.options.Dir = filepath.Join(p.app.DataDir(), "../pb_migrations")
} else {
p.options.Dir = filepath.Join(p.app.DataDir(), "../migrations")
}
}
// 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())
}
return nil
}
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).
`
command := &cobra.Command{
Use: "migrate",
Short: "Executes app DB migration scripts",
ValidArgs: []string{"up", "down", "create", "collections"},
Long: cmdDesc,
Run: func(command *cobra.Command, args []string) {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
// additional commands
// ---
if cmd == "create" {
if err := p.migrateCreateHandler("", args[1:]); err != nil {
log.Fatal(err)
}
return
}
if cmd == "collections" {
if err := p.migrateCollectionsHandler(args[1:]); err != nil {
log.Fatal(err)
}
return
}
// ---
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)
}
},
}
return command
}
func (p *plugin) 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 == "" {
dir = p.options.Dir
}
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.%s", time.Now().Unix(), inflector.Snakecase(name), p.options.TemplateLang),
)
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
}
// get default create template
if template == "" {
var templateErr error
if p.options.TemplateLang == TemplateLangJS {
template, templateErr = p.jsCreateTemplate()
} else {
template, templateErr = p.goCreateTemplate()
}
if templateErr != nil {
return fmt.Errorf("Failed to resolve create template: %v\n", templateErr)
}
}
// ensure that the migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
// save the migration file
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q: %v\n", resultFilePath, err)
}
fmt.Printf("Successfully created file %q\n", resultFilePath)
return nil
}
func (p *plugin) migrateCollectionsHandler(args []string) error {
createArgs := []string{"collections_snapshot"}
createArgs = append(createArgs, args...)
collections := []*models.Collection{}
if err := p.app.Dao().CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("Failed to fetch migrations list: %v", err)
}
var template string
var templateErr error
if p.options.TemplateLang == TemplateLangJS {
template, templateErr = p.jsSnapshotTemplate(collections)
} else {
template, templateErr = p.goSnapshotTemplate(collections)
}
if templateErr != nil {
return fmt.Errorf("Failed to resolve template: %v", templateErr)
}
return p.migrateCreateHandler(template, createArgs)
}
+112
View File
@@ -0,0 +1,112 @@
package migratecmd
import (
"encoding/json"
"fmt"
"path/filepath"
"github.com/pocketbase/pocketbase/models"
)
const (
TemplateLangJS = "js"
TemplateLangGo = "go"
)
// -------------------------------------------------------------------
// JavaScript templates
// -------------------------------------------------------------------
func (p *plugin) jsCreateTemplate() (string, error) {
const template = `migrate((db) => {
// add up queries...
}, (db) => {
// add down queries...
})
`
return template, nil
}
func (p *plugin) jsSnapshotTemplate(collections []*models.Collection) (string, error) {
jsonData, err := json.MarshalIndent(collections, " ", " ")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %v", err)
}
const template = `migrate((db) => {
const snapshot = %s;
const collections = snapshot.map((item) => unmarshal(item, new Collection()));
return Dao(db).importCollections(collections, true, null);
}, (db) => {
return null;
})
`
return fmt.Sprintf(template, string(jsonData)), nil
}
// -------------------------------------------------------------------
// Go templates
// -------------------------------------------------------------------
func (p *plugin) goCreateTemplate() (string, error) {
const template = `package %s
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
})
}
`
return fmt.Sprintf(template, filepath.Base(p.options.Dir)), nil
}
func (p *plugin) goSnapshotTemplate(collections []*models.Collection) (string, error) {
jsonData, err := json.MarshalIndent(collections, "\t", "\t\t")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %v", err)
}
const template = `package %s
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
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 {
return nil
})
}
`
return fmt.Sprintf(template, filepath.Base(p.options.Dir), string(jsonData)), nil
}
+84
View File
@@ -0,0 +1,84 @@
// Example
//
// publicdir.MustRegister(app, &publicdir.Options{
// FlagsCmd: app.RootCmd,
// IndexFallback: false,
// })
package publicdir
import (
"os"
"path/filepath"
"strings"
"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 {
app core.App
options *Options
}
func MustRegister(app core.App, options *Options) {
if err := Register(app, options); err != nil {
panic(err)
}
}
func Register(app core.App, options *Options) error {
p := &plugin{app: app}
if options != nil {
p.options = options
} else {
p.options = &Options{}
}
if options.Dir == "" {
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))
return nil
})
return nil
}
func defaultPublicDir() string {
if strings.HasPrefix(os.Args[0], os.TempDir()) {
// most likely ran with go run
return "./pb_public"
}
return filepath.Join(os.Args[0], "../pb_public")
}