chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:02 +08:00
commit 171e2a4a0d
1315 changed files with 266099 additions and 0 deletions
@@ -0,0 +1,417 @@
// Package ghupdate implements a new command to selfupdate the current
// PocketBase executable with the latest GitHub release.
//
// Example usage:
//
// ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
package ghupdate
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/spf13/cobra"
)
// HttpClient is a base HTTP client interface (usually used for test purposes).
type HttpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// Config defines the config options of the ghupdate plugin.
//
// NB! This plugin is considered experimental and its config options may change in the future.
type Config struct {
// Owner specifies the account owner of the repository (default to "pocketbase").
Owner string
// Repo specifies the name of the repository (default to "pocketbase").
Repo string
// ArchiveExecutable specifies the name of the executable file in the release archive
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
ArchiveExecutable string
// Optional context to use when fetching and downloading the latest release.
Context context.Context
// The HTTP client to use when fetching and downloading the latest release.
// Defaults to `http.DefaultClient`.
HttpClient HttpClient
}
// MustRegister registers the ghupdate plugin to the provided app instance
// and panic if it fails.
func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
if err := Register(app, rootCmd, config); err != nil {
panic(err)
}
}
// Register registers the ghupdate plugin to the provided app instance.
func Register(app core.App, rootCmd *cobra.Command, config Config) error {
p := &plugin{
app: app,
currentVersion: rootCmd.Version,
config: config,
}
if p.config.Owner == "" {
p.config.Owner = "pocketbase"
}
if p.config.Repo == "" {
p.config.Repo = "pocketbase"
}
if p.config.ArchiveExecutable == "" {
p.config.ArchiveExecutable = "pocketbase"
}
if p.config.HttpClient == nil {
p.config.HttpClient = http.DefaultClient
}
if p.config.Context == nil {
p.config.Context = context.Background()
}
rootCmd.AddCommand(p.updateCmd())
return nil
}
type plugin struct {
app core.App
config Config
currentVersion string
}
func (p *plugin) updateCmd() *cobra.Command {
var withBackup bool
command := &cobra.Command{
Use: "update",
Short: "Automatically updates the current app executable with the latest available version",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
var needConfirm bool
if isMaybeRunningInDocker() {
needConfirm = true
color.Yellow("NB! It seems that you are in a Docker container.")
color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.")
} else if isMaybeRunningInNixOS() {
needConfirm = true
color.Yellow("NB! It seems that you are in a NixOS.")
color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.")
}
if needConfirm {
confirm := osutils.YesNoPrompt("Do you want to proceed with the update?", false)
if !confirm {
fmt.Println("The command has been cancelled.")
return nil
}
}
return p.update(withBackup)
},
}
command.PersistentFlags().BoolVar(
&withBackup,
"backup",
true,
"Creates a pb_data backup at the end of the update process",
)
return command
}
func (p *plugin) update(withBackup bool) error {
color.Yellow("Fetching release information...")
latest, err := fetchLatestRelease(
p.config.Context,
p.config.HttpClient,
p.config.Owner,
p.config.Repo,
)
if err != nil {
return err
}
if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
color.Green("You already have the latest version %s.", p.currentVersion)
return nil
}
suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
if suffix == "" {
return errors.New("unsupported platform")
}
asset, err := latest.findAssetBySuffix(suffix)
if err != nil {
return err
}
releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
defer os.RemoveAll(releaseDir)
color.Yellow("Downloading %s...", asset.Name)
// download the release asset
assetZip := filepath.Join(releaseDir, asset.Name)
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
return err
}
color.Yellow("Extracting %s...", asset.Name)
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
defer os.RemoveAll(extractDir)
if err := archive.Extract(assetZip, extractDir); err != nil {
return err
}
color.Yellow("Replacing the executable...")
oldExec, err := os.Executable()
if err != nil {
return err
}
renamedOldExec := oldExec + ".old"
defer os.Remove(renamedOldExec)
newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
if _, err := os.Stat(newExec); err != nil {
// try again with an .exe extension
newExec = newExec + ".exe"
if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
}
}
// rename the current executable
if err := os.Rename(oldExec, renamedOldExec); err != nil {
return fmt.Errorf("failed to rename the current executable: %w", err)
}
tryToRevertExecChanges := func() {
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
p.app.Logger().Debug(
"Failed to revert executable",
slog.String("old", renamedOldExec),
slog.String("new", oldExec),
slog.String("error", revertErr.Error()),
)
}
}
// replace with the extracted binary
if err := os.Rename(newExec, oldExec); err != nil {
tryToRevertExecChanges()
return fmt.Errorf("failed replacing the executable: %w", err)
}
if withBackup {
color.Yellow("Creating pb_data backup...")
backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
tryToRevertExecChanges()
return err
}
}
color.HiBlack("---")
color.Green("Update completed successfully! You can start the executable as usual.")
// print the release notes
if latest.Body != "" {
fmt.Print("\n")
color.Cyan("Here is a list with some of the %s changes:", latest.Tag)
// remove the update command note to avoid "stuttering"
// (@todo consider moving to a config option)
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
color.Cyan(releaseNotes)
fmt.Print("\n")
}
return nil
}
func fetchLatestRelease(
ctx context.Context,
client HttpClient,
owner string,
repo string,
) (*release, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
rawBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// http.Client doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return nil, fmt.Errorf(
"(%d) failed to fetch latest releases:\n%s",
res.StatusCode,
string(rawBody),
)
}
result := &release{}
if err := json.Unmarshal(rawBody, result); err != nil {
return nil, err
}
return result, nil
}
func downloadFile(
ctx context.Context,
client HttpClient,
url string,
destPath string,
) error {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
// http.Client doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
}
// ensure that the dest parent dir(s) exist
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
return err
}
dest, err := os.Create(destPath)
if err != nil {
return err
}
defer dest.Close()
if _, err := io.Copy(dest, res.Body); err != nil {
return err
}
return nil
}
func archiveSuffix(goos, goarch string) string {
switch goos {
case "linux":
switch goarch {
case "amd64":
return "_linux_amd64.zip"
case "arm64":
return "_linux_arm64.zip"
case "arm":
return "_linux_armv7.zip"
}
case "darwin":
switch goarch {
case "amd64":
return "_darwin_amd64.zip"
case "arm64":
return "_darwin_arm64.zip"
}
case "windows":
switch goarch {
case "amd64":
return "_windows_amd64.zip"
case "arm64":
return "_windows_arm64.zip"
}
}
return ""
}
func compareVersions(a, b string) int {
aSplit := strings.Split(a, ".")
aTotal := len(aSplit)
bSplit := strings.Split(b, ".")
bTotal := len(bSplit)
limit := aTotal
if bTotal > aTotal {
limit = bTotal
}
for i := 0; i < limit; i++ {
var x, y int
if i < aTotal {
x, _ = strconv.Atoi(aSplit[i])
}
if i < bTotal {
y, _ = strconv.Atoi(bSplit[i])
}
if x < y {
return 1 // b is newer
}
if x > y {
return -1 // a is newer
}
}
return 0 // equal
}
// note: not completely reliable as it may not work on all platforms
// but should at least provide a warning for the most common use cases
func isMaybeRunningInDocker() bool {
_, err := os.Stat("/.dockerenv")
return err == nil
}
// note: untested
func isMaybeRunningInNixOS() bool {
_, err := os.Stat("/etc/NIXOS")
return err == nil
}
@@ -0,0 +1,38 @@
package ghupdate
import "testing"
func TestCompareVersions(t *testing.T) {
scenarios := []struct {
a string
b string
expected int
}{
{"", "", 0},
{"0", "", 0},
{"1", "1.0.0", 0},
{"1.1", "1.1.0", 0},
{"1.1", "1.1.1", 1},
{"1.1", "1.0.1", -1},
{"1.0", "1.0.1", 1},
{"1.10", "1.9", -1},
{"1.2", "1.12", 1},
{"3.2", "1.6", -1},
{"0.0.2", "0.0.1", -1},
{"0.16.2", "0.17.0", 1},
{"1.15.0", "0.16.1", -1},
{"1.2.9", "1.2.10", 1},
{"3.2", "4.0", 1},
{"3.2.4", "3.2.3", -1},
}
for _, s := range scenarios {
t.Run(s.a+"VS"+s.b, func(t *testing.T) {
result := compareVersions(s.a, s.b)
if result != s.expected {
t.Fatalf("Expected %q vs %q to result in %d, got %d", s.a, s.b, s.expected, result)
}
})
}
}
@@ -0,0 +1,36 @@
package ghupdate
import (
"errors"
"strings"
)
type releaseAsset struct {
Name string `json:"name"`
DownloadUrl string `json:"browser_download_url"`
Id int `json:"id"`
Size int `json:"size"`
}
type release struct {
Name string `json:"name"`
Tag string `json:"tag_name"`
Published string `json:"published_at"`
Url string `json:"html_url"`
Body string `json:"body"`
Assets []*releaseAsset `json:"assets"`
Id int `json:"id"`
}
// findAssetBySuffix returns the first available asset containing the specified suffix.
func (r *release) findAssetBySuffix(suffix string) (*releaseAsset, error) {
if suffix != "" {
for _, asset := range r.Assets {
if strings.HasSuffix(asset.Name, suffix) {
return asset, nil
}
}
}
return nil, errors.New("missing asset containing " + suffix)
}
@@ -0,0 +1,23 @@
package ghupdate
import "testing"
func TestReleaseFindAssetBySuffix(t *testing.T) {
r := release{
Assets: []*releaseAsset{
{Name: "test1.zip", Id: 1},
{Name: "test2.zip", Id: 2},
{Name: "test22.zip", Id: 22},
{Name: "test3.zip", Id: 3},
},
}
asset, err := r.findAssetBySuffix("2.zip")
if err != nil {
t.Fatalf("Expected nil, got err: %v", err)
}
if asset.Id != 2 {
t.Fatalf("Expected asset with id %d, got %v", 2, asset)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
package jsvm
import (
"bytes"
"io"
"mime/multipart"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/spf13/cast"
)
// FormData represents an interface similar to the browser's [FormData].
//
// The value of each FormData entry must be a string or [*filesystem.File] instance.
//
// It is intended to be used together with the JSVM `$http.send` when
// sending multipart/form-data requests.
//
// [FormData]: https://developer.mozilla.org/en-US/docs/Web/API/FormData.
type FormData map[string][]any
// Append appends a new value onto an existing key inside the current FormData,
// or adds the key if it does not already exist.
func (data FormData) Append(key string, value any) {
data[key] = append(data[key], value)
}
// Set sets a new value for an existing key inside the current FormData,
// or adds the key/value if it does not already exist.
func (data FormData) Set(key string, value any) {
data[key] = []any{value}
}
// Delete deletes a key and its value(s) from the current FormData.
func (data FormData) Delete(key string) {
delete(data, key)
}
// Get returns the first value associated with a given key from
// within the current FormData.
//
// If you expect multiple values and want all of them,
// use the [FormData.GetAll] method instead.
func (data FormData) Get(key string) any {
values, ok := data[key]
if !ok || len(values) == 0 {
return nil
}
return values[0]
}
// GetAll returns all the values associated with a given key
// from within the current FormData.
func (data FormData) GetAll(key string) []any {
values, ok := data[key]
if !ok {
return nil
}
return values
}
// Has returns whether a FormData object contains a certain key.
func (data FormData) Has(key string) bool {
values, ok := data[key]
return ok && len(values) > 0
}
// Keys returns all keys contained in the current FormData.
func (data FormData) Keys() []string {
result := make([]string, 0, len(data))
for k := range data {
result = append(result, k)
}
return result
}
// Keys returns all values contained in the current FormData.
func (data FormData) Values() []any {
result := make([]any, 0, len(data))
for _, values := range data {
result = append(result, values...)
}
return result
}
// Entries returns a [key, value] slice pair for each FormData entry.
func (data FormData) Entries() [][]any {
result := make([][]any, 0, len(data))
for k, values := range data {
for _, v := range values {
result = append(result, []any{k, v})
}
}
return result
}
// toMultipart converts the current FormData entries into multipart encoded data.
func (data FormData) toMultipart() (*bytes.Buffer, *multipart.Writer, error) {
body := new(bytes.Buffer)
mp := multipart.NewWriter(body)
defer mp.Close()
for k, values := range data {
for _, rawValue := range values {
switch v := rawValue.(type) {
case *filesystem.File:
err := func() error {
mpw, err := mp.CreateFormFile(k, v.OriginalName)
if err != nil {
return err
}
file, err := v.Reader.Open()
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(mpw, file)
if err != nil {
return err
}
return nil
}()
if err != nil {
return nil, nil, err
}
default:
err := mp.WriteField(k, cast.ToString(v))
if err != nil {
return nil, nil, err
}
}
}
}
return body, mp, nil
}
@@ -0,0 +1,225 @@
package jsvm
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestFormDataAppendAndSet(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
data.Append("b", 3)
data.Append("b", 4)
data.Set("b", 5) // should overwrite the previous 2 calls
data.Set("c", 6)
data.Set("c", 7)
if len(data["a"]) != 2 {
t.Fatalf("Expected 2 'a' values, got %v", data["a"])
}
if data["a"][0] != 1 || data["a"][1] != 2 {
t.Fatalf("Expected 1 and 2 'a' key values, got %v", data["a"])
}
if len(data["b"]) != 1 {
t.Fatalf("Expected 1 'b' values, got %v", data["b"])
}
if data["b"][0] != 5 {
t.Fatalf("Expected 5 as 'b' key value, got %v", data["b"])
}
if len(data["c"]) != 1 {
t.Fatalf("Expected 1 'c' values, got %v", data["c"])
}
if data["c"][0] != 7 {
t.Fatalf("Expected 7 as 'c' key value, got %v", data["c"])
}
}
func TestFormDataDelete(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
data.Append("b", 3)
data.Delete("missing") // should do nothing
data.Delete("a")
if len(data) != 1 {
t.Fatalf("Expected exactly 1 data remaining key, got %v", data)
}
if data["b"][0] != 3 {
t.Fatalf("Expected 3 as 'b' key value, got %v", data["b"])
}
}
func TestFormDataGet(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
if v := data.Get("missing"); v != nil {
t.Fatalf("Expected %v for key 'missing', got %v", nil, v)
}
if v := data.Get("a"); v != 1 {
t.Fatalf("Expected %v for key 'a', got %v", 1, v)
}
}
func TestFormDataGetAll(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
if v := data.GetAll("missing"); v != nil {
t.Fatalf("Expected %v for key 'a', got %v", nil, v)
}
values := data.GetAll("a")
if len(values) != 2 || values[0] != 1 || values[1] != 2 {
t.Fatalf("Expected 1 and 2 values, got %v", values)
}
}
func TestFormDataHas(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
if v := data.Has("missing"); v {
t.Fatalf("Expected key 'missing' to not exist: %v", v)
}
if v := data.Has("a"); !v {
t.Fatalf("Expected key 'a' to exist: %v", v)
}
}
func TestFormDataKeys(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 1)
data.Append("c", 1)
data.Append("a", 1)
keys := data.Keys()
expectedKeys := []string{"a", "b", "c"}
for _, expected := range expectedKeys {
if !list.ExistInSlice(expected, keys) {
t.Fatalf("Expected key %s to exists in %v", expected, keys)
}
}
}
func TestFormDataValues(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 2)
data.Append("c", 3)
data.Append("a", 4)
values := data.Values()
expectedKeys := []any{1, 2, 3, 4}
for _, expected := range expectedKeys {
if !list.ExistInSlice(expected, values) {
t.Fatalf("Expected key %s to exists in %v", expected, values)
}
}
}
func TestFormDataEntries(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 2)
data.Append("c", 3)
data.Append("a", 4)
entries := data.Entries()
rawEntries, err := json.Marshal(entries)
if err != nil {
t.Fatal(err)
}
if len(entries) != 4 {
t.Fatalf("Expected 4 entries")
}
expectedEntries := []string{`["a",1]`, `["a",4]`, `["b",2]`, `["c",3]`}
for _, expected := range expectedEntries {
if !bytes.Contains(rawEntries, []byte(expected)) {
t.Fatalf("Expected entry %s to exists in %s", expected, rawEntries)
}
}
}
func TestFormDataToMultipart(t *testing.T) {
t.Parallel()
f, err := filesystem.NewFileFromBytes([]byte("abc"), "test")
if err != nil {
t.Fatal(err)
}
data := FormData{}
data.Append("a", 1) // should be casted
data.Append("b", "test1")
data.Append("b", "test2")
data.Append("c", f)
body, mp, err := data.toMultipart()
if err != nil {
t.Fatal(err)
}
bodyStr := body.String()
// content type checks
contentType := mp.FormDataContentType()
expectedContentType := "multipart/form-data; boundary="
if !strings.Contains(contentType, expectedContentType) {
t.Fatalf("Expected to find content-type %s in %s", expectedContentType, contentType)
}
// body checks
expectedBodyParts := []string{
"Content-Disposition: form-data; name=\"a\"\r\n\r\n1",
"Content-Disposition: form-data; name=\"b\"\r\n\r\ntest1",
"Content-Disposition: form-data; name=\"b\"\r\n\r\ntest2",
"Content-Disposition: form-data; name=\"c\"; filename=\"test\"\r\nContent-Type: application/octet-stream\r\n\r\nabc",
}
for _, part := range expectedBodyParts {
if !strings.Contains(bodyStr, part) {
t.Fatalf("Expected to find %s in body\n%s", part, bodyStr)
}
}
}
@@ -0,0 +1,6 @@
package generated
import "embed"
//go:embed types.d.ts
var Types embed.FS
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,562 @@
// Package jsvm implements pluggable utilities for binding a JS goja runtime
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.).
//
// Example:
//
// jsvm.MustRegister(app, jsvm.Config{
// HooksWatch: true,
// })
package jsvm
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/process"
"github.com/dop251/goja_nodejs/require"
"github.com/fatih/color"
"github.com/fsnotify/fsnotify"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/jsvm/internal/types/generated"
"github.com/pocketbase/pocketbase/tools/template"
)
const typesFileName = "types.d.ts"
var defaultScriptPath = "pb.js"
func init() {
// For backward compatibility and consistency with the Go exposed
// methods that operate with relative paths (e.g. `$os.writeFile`),
// we define the "current JS module" as if it is a file in the current working directory
// (the filename itself doesn't really matter and in our case the hook handlers are executed as separate "programs").
//
// This is necessary for `require(module)` to properly traverse parents node_modules (goja_nodejs#95).
cwd, err := os.Getwd()
if err != nil {
// truly rare case, log just for debug purposes
color.Yellow("Failed to retrieve the current working directory: %v", err)
} else {
defaultScriptPath = filepath.Join(cwd, defaultScriptPath)
}
}
// Config defines the config options of the jsvm plugin.
type Config struct {
// OnInit is an optional function that will be called
// after a JS runtime is initialized, allowing you to
// attach custom Go variables and functions.
OnInit func(vm *goja.Runtime)
// HooksWatch enables auto app restarts when a JS app hook file changes.
//
// Note that currently the application cannot be automatically restarted on Windows
// because the restart process relies on execve.
HooksWatch bool
// HooksDir specifies the JS app hooks directory.
//
// If not set it fallbacks to a relative "pb_data/../pb_hooks" directory.
HooksDir string
// HooksFilesPattern specifies a regular expression pattern that
// identify which file to load by the hook vm(s).
//
// If not set it fallbacks to `^.*(\.pb\.js|\.pb\.ts)$`, aka. any
// HookdsDir file ending in ".pb.js" or ".pb.ts" (the last one is to enforce IDE linters).
HooksFilesPattern string
// HooksPoolSize specifies how many goja.Runtime instances to prewarm
// and keep for the JS app hooks gorotines execution.
//
// Zero or negative value means that it will create a new goja.Runtime
// on every fired goroutine.
HooksPoolSize int
// MigrationsDir specifies the JS migrations directory.
//
// If not set it fallbacks to a relative "pb_data/../pb_migrations" directory.
MigrationsDir string
// If not set it fallbacks to `^.*(\.js|\.ts)$`, aka. any MigrationDir file
// ending in ".js" or ".ts" (the last one is to enforce IDE linters).
MigrationsFilesPattern string
// TypesDir specifies the directory where to store the embedded
// TypeScript declarations file.
//
// If not set it fallbacks to "pb_data".
//
// Note: Avoid using the same directory as the HooksDir when HooksWatch is enabled
// to prevent unnecessary app restarts when the types file is initially created.
TypesDir string
}
// MustRegister registers the jsvm plugin in the provided app instance
// and panics if it fails.
//
// Example usage:
//
// jsvm.MustRegister(app, jsvm.Config{
// OnInit: func(vm *goja.Runtime) {
// // register custom bindings
// vm.Set("myCustomVar", 123)
// },
// })
func MustRegister(app core.App, config Config) {
if err := Register(app, config); err != nil {
panic(err)
}
}
// Register registers the jsvm plugin in the provided app instance.
func Register(app core.App, config Config) error {
p := &plugin{app: app, config: config}
if p.config.HooksDir == "" {
p.config.HooksDir = filepath.Join(app.DataDir(), "../pb_hooks")
}
if p.config.MigrationsDir == "" {
p.config.MigrationsDir = filepath.Join(app.DataDir(), "../pb_migrations")
}
if p.config.HooksFilesPattern == "" {
p.config.HooksFilesPattern = `^.*(\.pb\.js|\.pb\.ts)$`
}
if p.config.MigrationsFilesPattern == "" {
p.config.MigrationsFilesPattern = `^.*(\.js|\.ts)$`
}
if p.config.TypesDir == "" {
p.config.TypesDir = app.DataDir()
}
p.app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
err := e.Next()
if err != nil {
return err
}
// ensure that the user has the latest types declaration
err = p.refreshTypesFile()
if err != nil {
color.Yellow("Unable to refresh app types file: %v", err)
}
return nil
})
if err := p.registerMigrations(); err != nil {
return fmt.Errorf("registerMigrations: %w", err)
}
if err := p.registerHooks(); err != nil {
return fmt.Errorf("registerHooks: %w", err)
}
return nil
}
type plugin struct {
app core.App
config Config
}
// registerMigrations registers the JS migrations loader.
func (p *plugin) registerMigrations() error {
// fetch all js migrations sorted by their filename
files, err := filesContent(p.config.MigrationsDir, p.config.MigrationsFilesPattern)
if err != nil {
return err
}
absHooksDir, err := filepath.Abs(p.config.HooksDir)
if err != nil {
return err
}
registry := new(require.Registry) // this can be shared by multiple runtimes
templateRegistry := template.NewRegistry()
for file, content := range files {
vm := goja.New()
registry.Enable(vm)
console.Enable(vm)
process.Enable(vm)
buffer.Enable(vm)
baseBinds(vm)
dbxBinds(vm)
securityBinds(vm)
osBinds(vm)
filepathBinds(vm)
httpClientBinds(vm)
filesystemBinds(vm)
formsBinds(vm)
mailsBinds(vm)
vm.Set("$template", templateRegistry)
vm.Set("__hooks", absHooksDir)
vm.Set("migrate", func(up, down func(txApp core.App) error) {
core.AppMigrations.Register(up, down, file)
})
if p.config.OnInit != nil {
p.config.OnInit(vm)
}
_, err := vm.RunScript(defaultScriptPath, string(content))
if err != nil {
return fmt.Errorf("failed to run migration %s: %w", file, err)
}
}
return nil
}
// registerHooks registers the JS app hooks loader.
func (p *plugin) registerHooks() error {
// fetch all js hooks sorted by their filename
files, err := filesContent(p.config.HooksDir, p.config.HooksFilesPattern)
if err != nil {
return err
}
// prepend the types reference directive
//
// note: it is loaded during startup to handle conveniently also
// the case when the HooksWatch option is enabled and the application
// restart on newly created file
for name, content := range files {
if len(content) != 0 {
// skip non-empty files for now to prevent accidental overwrite
continue
}
path := filepath.Join(p.config.HooksDir, name)
directive := `/// <reference path="` + p.relativeTypesPath(p.config.HooksDir) + `" />`
if err := prependToEmptyFile(path, directive+"\n\n"); err != nil {
color.Yellow("Unable to prepend the types reference: %v", err)
}
}
// initialize the hooks dir watcher
if p.config.HooksWatch {
if err := p.watchHooks(); err != nil {
color.Yellow("Unable to init hooks watcher: %v", err)
}
}
if len(files) == 0 {
// no need to register the vms since there are no entrypoint files anyway
return nil
}
absHooksDir, err := filepath.Abs(p.config.HooksDir)
if err != nil {
return err
}
p.app.OnServe().BindFunc(func(e *core.ServeEvent) error {
e.Router.BindFunc(p.normalizeServeExceptions)
return e.Next()
})
// safe to be shared across multiple vms
requireRegistry := new(require.Registry)
templateRegistry := template.NewRegistry()
sharedBinds := func(vm *goja.Runtime) {
requireRegistry.Enable(vm)
console.Enable(vm)
process.Enable(vm)
buffer.Enable(vm)
baseBinds(vm)
dbxBinds(vm)
filesystemBinds(vm)
securityBinds(vm)
osBinds(vm)
filepathBinds(vm)
httpClientBinds(vm)
formsBinds(vm)
apisBinds(vm)
mailsBinds(vm)
vm.Set("$app", p.app)
vm.Set("$template", templateRegistry)
vm.Set("__hooks", absHooksDir)
if p.config.OnInit != nil {
p.config.OnInit(vm)
}
}
// initiliaze the executor vms
executors := newPool(p.config.HooksPoolSize, func() *goja.Runtime {
executor := goja.New()
sharedBinds(executor)
return executor
})
// initialize the loader vm
loader := goja.New()
sharedBinds(loader)
hooksBinds(p.app, loader, executors)
cronBinds(p.app, loader, executors)
routerBinds(p.app, loader, executors)
for file, content := range files {
func() {
defer func() {
if err := recover(); err != nil {
fmtErr := fmt.Errorf("failed to execute %s:\n - %v", file, err)
if p.config.HooksWatch {
color.Red("%v", fmtErr)
} else {
panic(fmtErr)
}
}
}()
_, err := loader.RunScript(defaultScriptPath, string(content))
if err != nil {
panic(err)
}
}()
}
return nil
}
// normalizeExceptions registers a global error handler that
// wraps the extracted goja exception error value for consistency
// when throwing or returning errors.
func (p *plugin) normalizeServeExceptions(e *core.RequestEvent) error {
err := e.Next()
if err == nil || e.Written() {
return err // no error or already committed
}
return normalizeException(err)
}
// watchHooks initializes a hooks file watcher that will restart the
// application (*if possible) in case of a change in the hooks directory.
//
// This method does nothing if the hooks directory is missing.
func (p *plugin) watchHooks() error {
watchDir := p.config.HooksDir
hooksDirInfo, err := os.Lstat(p.config.HooksDir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil // no hooks dir to watch
}
return err
}
if hooksDirInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
watchDir, err = filepath.EvalSymlinks(p.config.HooksDir)
if err != nil {
return fmt.Errorf("failed to resolve hooksDir symlink: %w", err)
}
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
var debounceTimer *time.Timer
stopDebounceTimer := func() {
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer = nil
}
}
p.app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
watcher.Close()
stopDebounceTimer()
return e.Next()
})
// start listening for events.
go func() {
defer stopDebounceTimer()
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
stopDebounceTimer()
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
// app restart is currently not supported on Windows
if runtime.GOOS == "windows" {
color.Yellow("File %s changed, please restart the app manually", event.Name)
} else {
color.Yellow("File %s changed, restarting...", event.Name)
if err := p.app.Restart(); err != nil {
color.Red("Failed to restart the app:", err)
}
}
})
case err, ok := <-watcher.Errors:
if !ok {
return
}
color.Red("Watch error:", err)
}
}
}()
// add directories to watch
//
// @todo replace once recursive watcher is added (https://github.com/fsnotify/fsnotify/issues/18)
dirsErr := filepath.WalkDir(watchDir, func(path string, entry fs.DirEntry, err error) error {
// ignore hidden directories, node_modules, symlinks, sockets, etc.
if !entry.IsDir() || entry.Name() == "node_modules" || strings.HasPrefix(entry.Name(), ".") {
return nil
}
return watcher.Add(path)
})
if dirsErr != nil {
watcher.Close()
}
return dirsErr
}
// fullTypesPathReturns returns the full path to the generated TS file.
func (p *plugin) fullTypesPath() string {
return filepath.Join(p.config.TypesDir, typesFileName)
}
// relativeTypesPath returns a path to the generated TS file relative
// to the specified basepath.
//
// It fallbacks to the full path if generating the relative path fails.
func (p *plugin) relativeTypesPath(basepath string) string {
fullPath := p.fullTypesPath()
rel, err := filepath.Rel(basepath, fullPath)
if err != nil {
// fallback to the full path
rel = fullPath
}
return rel
}
// refreshTypesFile saves the embedded TS declarations as a file on the disk.
func (p *plugin) refreshTypesFile() error {
fullPath := p.fullTypesPath()
// ensure that the types directory exists
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
// retrieve the types data to write
data, err := generated.Types.ReadFile(typesFileName)
if err != nil {
return err
}
// read the first timestamp line of the old file (if exists) and compare it to the embedded one
// (note: ignore errors to allow always overwriting the file if it is invalid)
existingFile, err := os.Open(fullPath)
if err == nil {
timestamp := make([]byte, 13)
io.ReadFull(existingFile, timestamp)
existingFile.Close()
if len(data) >= len(timestamp) && bytes.Equal(data[:13], timestamp) {
return nil // nothing new to save
}
}
return os.WriteFile(fullPath, data, 0644)
}
// prependToEmptyFile prepends the specified text to an empty file.
//
// If the file is not empty this method does nothing.
func prependToEmptyFile(path, text string) error {
info, err := os.Stat(path)
if err == nil && info.Size() == 0 {
return os.WriteFile(path, []byte(text), 0644)
}
return err
}
// filesContent returns a map with all direct files within the specified dir and their content.
//
// If directory with dirPath is missing or no files matching the pattern were found,
// it returns an empty map and no error.
//
// If pattern is empty string it matches all root files.
func filesContent(dirPath string, pattern string) (map[string][]byte, error) {
files, err := os.ReadDir(dirPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return map[string][]byte{}, nil
}
return nil, err
}
var exp *regexp.Regexp
if pattern != "" {
var err error
if exp, err = regexp.Compile(pattern); err != nil {
return nil, err
}
}
result := map[string][]byte{}
for _, f := range files {
if f.IsDir() || (exp != nil && !exp.MatchString(f.Name())) {
continue
}
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
if err != nil {
return nil, err
}
result[f.Name()] = raw
}
return result, nil
}
@@ -0,0 +1,67 @@
package jsvm
import (
"reflect"
"strings"
"unicode"
"github.com/dop251/goja"
)
var (
_ goja.FieldNameMapper = (*FieldMapper)(nil)
)
// FieldMapper provides custom mapping between Go and JavaScript property names.
//
// It is similar to the builtin "uncapFieldNameMapper" but also converts
// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get").
type FieldMapper struct {
}
// FieldName implements the [FieldNameMapper.FieldName] interface method.
func (u FieldMapper) FieldName(_ reflect.Type, f reflect.StructField) string {
return convertGoToJSName(f.Name)
}
// MethodName implements the [FieldNameMapper.MethodName] interface method.
func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string {
return convertGoToJSName(m.Name)
}
var nameExceptions = map[string]string{"OAuth2": "oauth2"}
func convertGoToJSName(name string) string {
if v, ok := nameExceptions[name]; ok {
return v
}
startUppercase := make([]rune, 0, len(name))
for _, c := range name {
if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) {
break
}
startUppercase = append(startUppercase, c)
}
totalStartUppercase := len(startUppercase)
// all uppercase eg. "JSON" -> "json"
if len(name) == totalStartUppercase {
return strings.ToLower(name)
}
// eg. "JSONField" -> "jsonField"
if totalStartUppercase > 1 {
return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:]
}
// eg. "GetField" -> "getField"
if totalStartUppercase == 1 {
return strings.ToLower(name[0:1]) + name[1:]
}
return name
}
@@ -0,0 +1,42 @@
package jsvm_test
import (
"reflect"
"testing"
"github.com/pocketbase/pocketbase/plugins/jsvm"
)
func TestFieldMapper(t *testing.T) {
mapper := jsvm.FieldMapper{}
scenarios := []struct {
name string
expected string
}{
{"", ""},
{"test", "test"},
{"Test", "test"},
{"miXeD", "miXeD"},
{"MiXeD", "miXeD"},
{"ResolveRequestAsJSON", "resolveRequestAsJSON"},
{"Variable_with_underscore", "variable_with_underscore"},
{"ALLCAPS", "allcaps"},
{"ALL_CAPS_WITH_UNDERSCORE", "all_caps_with_underscore"},
{"OIDCMap", "oidcMap"},
{"MD5", "md5"},
{"OAuth2", "oauth2"},
}
for i, s := range scenarios {
field := reflect.StructField{Name: s.name}
if v := mapper.FieldName(nil, field); v != s.expected {
t.Fatalf("[%d] Expected FieldName %q, got %q", i, s.expected, v)
}
method := reflect.Method{Name: s.name}
if v := mapper.MethodName(nil, method); v != s.expected {
t.Fatalf("[%d] Expected MethodName %q, got %q", i, s.expected, v)
}
}
}
@@ -0,0 +1,73 @@
package jsvm
import (
"sync"
"github.com/dop251/goja"
)
type poolItem struct {
mux sync.Mutex
busy bool
vm *goja.Runtime
}
type vmsPool struct {
mux sync.RWMutex
factory func() *goja.Runtime
items []*poolItem
}
// newPool creates a new pool with pre-warmed vms generated from the specified factory.
func newPool(size int, factory func() *goja.Runtime) *vmsPool {
pool := &vmsPool{
factory: factory,
items: make([]*poolItem, size),
}
for i := 0; i < size; i++ {
vm := pool.factory()
pool.items[i] = &poolItem{vm: vm}
}
return pool
}
// run executes "call" with a vm created from the pool
// (either from the buffer or a new one if all buffered vms are busy)
func (p *vmsPool) run(call func(vm *goja.Runtime) error) error {
p.mux.RLock()
// try to find a free item
var freeItem *poolItem
for _, item := range p.items {
item.mux.Lock()
if item.busy {
item.mux.Unlock()
continue
}
item.busy = true
item.mux.Unlock()
freeItem = item
break
}
p.mux.RUnlock()
// create a new one-off item if of all of the pool items are currently busy
//
// note: if turned out not efficient we may change this in the future
// by adding the created item in the pool with some timer for removal
if freeItem == nil {
return call(p.factory())
}
execErr := call(freeItem.vm)
// "free" the vm
freeItem.mux.Lock()
freeItem.busy = false
freeItem.mux.Unlock()
return execErr
}
@@ -0,0 +1,106 @@
package migratecmd
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// automigrateOnCollectionChange handles the automigration snapshot
// generation on collection change request event (create/update/delete).
func (p *plugin) automigrateOnCollectionChange(e *core.CollectionRequestEvent) error {
var err error
var old *core.Collection
if !e.Collection.IsNew() {
old, err = e.App.FindCollectionByNameOrId(e.Collection.Id)
if err != nil {
return err
}
}
err = e.Next()
if err != nil {
return err
}
new, err := p.app.FindCollectionByNameOrId(e.Collection.Id)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
// for now exclude OAuth2 configs from the migration
if old != nil && old.IsAuth() {
old.OAuth2.Providers = nil
}
if new != nil && new.IsAuth() {
new.OAuth2.Providers = nil
}
var template string
var templateErr error
if p.config.TemplateLang == TemplateLangJS {
template, templateErr = p.jsDiffTemplate(new, old)
} else {
template, templateErr = p.goDiffTemplate(new, old)
}
if templateErr != nil {
if errors.Is(templateErr, ErrEmptyTemplate) {
return nil // no changes
}
return fmt.Errorf("failed to resolve template: %w", templateErr)
}
var action string
switch {
case new == nil:
action = "deleted_" + normalizeCollectionName(old.Name)
case old == nil:
action = "created_" + normalizeCollectionName(new.Name)
default:
action = "updated_" + normalizeCollectionName(old.Name)
}
name := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), action, p.config.TemplateLang)
filePath := filepath.Join(p.config.Dir, name)
return p.app.RunInTransaction(func(txApp core.App) error {
// insert the migration entry
_, err := txApp.DB().Insert(core.DefaultMigrationsTable, dbx.Params{
"file": name,
// use microseconds for more granular applied time in case
// multiple collection changes happens at the ~exact time
"applied": time.Now().UnixMicro(),
}).Execute()
if err != nil {
return err
}
// ensure that the local migrations dir exist
if err := os.MkdirAll(p.config.Dir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create migration dir: %w", err)
}
if err := os.WriteFile(filePath, []byte(template), 0644); err != nil {
return fmt.Errorf("failed to save automigrate file: %w", err)
}
return nil
})
}
func normalizeCollectionName(name string) string {
// adds an extra "_" suffix to the name in case the collection ends
// with "test" to prevent accidentally resulting in "_test.go"/"_test.js" files
if strings.HasSuffix(strings.ToLower(name), "test") {
name += "_"
}
return name
}
@@ -0,0 +1,217 @@
// Package migratecmd adds a new "migrate" command support to a PocketBase instance.
//
// It also comes with automigrations support and templates generation
// (both for JS and GO migration files).
//
// Example usage:
//
// migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
// TemplateLang: migratecmd.TemplateLangJS, // default to migratecmd.TemplateLangGo
// Automigrate: true,
// Dir: "/custom/migrations/dir", // optional template migrations path; default to "pb_migrations" (for JS) and "migrations" (for Go)
// })
//
// Note: To allow running JS migrations you'll need to enable first
// [jsvm.MustRegister()].
package migratecmd
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/spf13/cobra"
)
// Config defines the config options of the migratecmd plugin.
type Config struct {
// 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
}
// MustRegister registers the migratecmd plugin to the provided app instance
// and panic if it fails.
//
// Example usage:
//
// migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{})
func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
if err := Register(app, rootCmd, config); err != nil {
panic(err)
}
}
// Register registers the migratecmd plugin to the provided app instance.
func Register(app core.App, rootCmd *cobra.Command, config Config) error {
p := &plugin{app: app, config: config}
if p.config.TemplateLang == "" {
p.config.TemplateLang = TemplateLangGo
}
if p.config.Dir == "" {
if p.config.TemplateLang == TemplateLangJS {
p.config.Dir = filepath.Join(p.app.DataDir(), "../pb_migrations")
} else {
p.config.Dir = filepath.Join(p.app.DataDir(), "../migrations")
}
}
// attach the migrate command
if rootCmd != nil {
rootCmd.AddCommand(p.createCommand())
}
// watch for collection changes
if p.config.Automigrate {
p.app.OnCollectionCreateRequest().BindFunc(p.automigrateOnCollectionChange)
p.app.OnCollectionUpdateRequest().BindFunc(p.automigrateOnCollectionChange)
p.app.OnCollectionDeleteRequest().BindFunc(p.automigrateOnCollectionChange)
}
return nil
}
type plugin struct {
app core.App
config Config
}
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 - creates new blank migration template file
- collections - creates new migration file with snapshot of the local collections configuration
- history-sync - ensures that the _migrations history table doesn't have references to deleted migration files
`
command := &cobra.Command{
Use: "migrate",
Short: "Executes app DB migration scripts",
Long: cmdDesc,
ValidArgs: []string{"up", "down", "create", "collections"},
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
switch cmd {
case "create":
if _, err := p.migrateCreateHandler("", args[1:], true); err != nil {
return err
}
case "collections":
if _, err := p.migrateCollectionsHandler(args[1:], true); err != nil {
return err
}
default:
// note: system migrations are always applied as part of the bootstrap process
var list = core.MigrationsList{}
list.Copy(core.SystemMigrations)
list.Copy(core.AppMigrations)
runner := core.NewMigrationsRunner(p.app, list)
if err := runner.Run(args...); err != nil {
return err
}
}
return nil
},
}
return command
}
func (p *plugin) migrateCreateHandler(template string, args []string, interactive bool) (string, error) {
if len(args) < 1 {
return "", errors.New("missing migration file name")
}
name := args[0]
dir := p.config.Dir
filename := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), inflector.Snakecase(name), p.config.TemplateLang)
resultFilePath := path.Join(dir, filename)
if interactive {
confirm := osutils.YesNoPrompt(fmt.Sprintf("Do you really want to create migration %q?", resultFilePath), false)
if !confirm {
fmt.Println("The command has been cancelled")
return "", nil
}
}
// get default create template
if template == "" {
var templateErr error
if p.config.TemplateLang == TemplateLangJS {
template, templateErr = p.jsBlankTemplate()
} else {
template, templateErr = p.goBlankTemplate()
}
if templateErr != nil {
return "", fmt.Errorf("failed to resolve create template: %v", 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", resultFilePath, err)
}
if interactive {
fmt.Printf("Successfully created file %q\n", resultFilePath)
}
return filename, nil
}
func (p *plugin) migrateCollectionsHandler(args []string, interactive bool) (string, error) {
createArgs := []string{"collections_snapshot"}
createArgs = append(createArgs, args...)
collections := []*core.Collection{}
if err := p.app.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.config.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, interactive)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,769 @@
package migratecmd
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/pocketbase/pocketbase/core"
)
const (
TemplateLangJS = "js"
TemplateLangGo = "go"
// note: this usually should be configurable similar to the jsvm plugin,
// but for simplicity is static as users can easily change the
// reference path if they use custom dirs structure
jsTypesDirective = `/// <reference path="../pb_data/types.d.ts" />` + "\n"
)
var ErrEmptyTemplate = errors.New("empty template")
// -------------------------------------------------------------------
// JavaScript templates
// -------------------------------------------------------------------
func (p *plugin) jsBlankTemplate() (string, error) {
const template = jsTypesDirective + `migrate((app) => {
// add up queries...
}, (app) => {
// add down queries...
})
`
return template, nil
}
func (p *plugin) jsSnapshotTemplate(collections []*core.Collection) (string, error) {
// unset timestamp fields
var collectionsData = make([]map[string]any, len(collections))
for i, c := range collections {
data, err := toMap(c)
if err != nil {
return "", fmt.Errorf("failed to serialize %q into a map: %w", c.Name, err)
}
delete(data, "created")
delete(data, "updated")
deleteNestedMapKey(data, "oauth2", "providers")
collectionsData[i] = data
}
jsonData, err := marhshalWithoutEscape(collectionsData, " ", " ")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = jsTypesDirective + `migrate((app) => {
const snapshot = %s;
return app.importCollections(snapshot, false);
}, (app) => {
return null;
})
`
return fmt.Sprintf(template, string(jsonData)), nil
}
func (p *plugin) jsCreateTemplate(collection *core.Collection) (string, error) {
// unset timestamp fields
collectionData, err := toMap(collection)
if err != nil {
return "", err
}
delete(collectionData, "created")
delete(collectionData, "updated")
deleteNestedMapKey(collectionData, "oauth2", "providers")
jsonData, err := marhshalWithoutEscape(collectionData, " ", " ")
if err != nil {
return "", fmt.Errorf("failed to serialize collection: %w", err)
}
const template = jsTypesDirective + `migrate((app) => {
const collection = new Collection(%s);
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId(%q);
return app.delete(collection);
})
`
return fmt.Sprintf(template, string(jsonData), collection.Id), nil
}
func (p *plugin) jsDeleteTemplate(collection *core.Collection) (string, error) {
// unset timestamp fields
collectionData, err := toMap(collection)
if err != nil {
return "", err
}
delete(collectionData, "created")
delete(collectionData, "updated")
deleteNestedMapKey(collectionData, "oauth2", "providers")
jsonData, err := marhshalWithoutEscape(collectionData, " ", " ")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = jsTypesDirective + `migrate((app) => {
const collection = app.findCollectionByNameOrId(%q);
return app.delete(collection);
}, (app) => {
const collection = new Collection(%s);
return app.save(collection);
})
`
return fmt.Sprintf(template, collection.Id, string(jsonData)), nil
}
func (p *plugin) jsDiffTemplate(new *core.Collection, old *core.Collection) (string, error) {
if new == nil && old == nil {
return "", errors.New("the diff template require at least one of the collection to be non-nil")
}
if new == nil {
return p.jsDeleteTemplate(old)
}
if old == nil {
return p.jsCreateTemplate(new)
}
upParts := []string{}
downParts := []string{}
varName := "collection"
newMap, err := toMap(new)
if err != nil {
return "", err
}
oldMap, err := toMap(old)
if err != nil {
return "", err
}
// non-fields
// -----------------------------------------------------------------
upDiff := diffMaps(oldMap, newMap, "fields", "created", "updated")
if len(upDiff) > 0 {
downDiff := diffMaps(newMap, oldMap, "fields", "created", "updated")
rawUpDiff, err := marhshalWithoutEscape(upDiff, " ", " ")
if err != nil {
return "", err
}
rawDownDiff, err := marhshalWithoutEscape(downDiff, " ", " ")
if err != nil {
return "", err
}
upParts = append(upParts, "// update collection data")
upParts = append(upParts, fmt.Sprintf("unmarshal(%s, %s)", string(rawUpDiff), varName)+"\n")
// ---
downParts = append(downParts, "// update collection data")
downParts = append(downParts, fmt.Sprintf("unmarshal(%s, %s)", string(rawDownDiff), varName)+"\n")
}
// fields
// -----------------------------------------------------------------
oldFieldsSlice, ok := oldMap["fields"].([]any)
if !ok {
return "", errors.New(`oldMap["fields"] is not []any`)
}
newFieldsSlice, ok := newMap["fields"].([]any)
if !ok {
return "", errors.New(`newMap["fields"] is not []any`)
}
// deleted fields
for i, oldField := range old.Fields {
if new.Fields.GetById(oldField.GetId()) != nil {
continue // exist
}
rawOldField, err := marhshalWithoutEscape(oldFieldsSlice[i], " ", " ")
if err != nil {
return "", err
}
upParts = append(upParts, "// remove field")
upParts = append(upParts, fmt.Sprintf("%s.fields.removeById(%q)\n", varName, oldField.GetId()))
downParts = append(downParts, "// add field")
downParts = append(downParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawOldField))
}
// created fields
for i, newField := range new.Fields {
if old.Fields.GetById(newField.GetId()) != nil {
continue // exist
}
rawNewField, err := marhshalWithoutEscape(newFieldsSlice[i], " ", " ")
if err != nil {
return "", err
}
upParts = append(upParts, "// add field")
upParts = append(upParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawNewField))
downParts = append(downParts, "// remove field")
downParts = append(downParts, fmt.Sprintf("%s.fields.removeById(%q)\n", varName, newField.GetId()))
}
// modified fields
// (note currently ignoring order-only changes as it comes with too many edge-cases)
for i, newField := range new.Fields {
var rawNewField, rawOldField []byte
rawNewField, err = marhshalWithoutEscape(newFieldsSlice[i], " ", " ")
if err != nil {
return "", err
}
var oldFieldIndex int
for j, oldField := range old.Fields {
if oldField.GetId() == newField.GetId() {
rawOldField, err = marhshalWithoutEscape(oldFieldsSlice[j], " ", " ")
if err != nil {
return "", err
}
oldFieldIndex = j
break
}
}
if rawOldField == nil || bytes.Equal(rawNewField, rawOldField) {
continue // new field or no change
}
upParts = append(upParts, "// update field")
upParts = append(upParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawNewField))
downParts = append(downParts, "// update field")
downParts = append(downParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, oldFieldIndex, rawOldField))
}
// -----------------------------------------------------------------
if len(upParts) == 0 && len(downParts) == 0 {
return "", ErrEmptyTemplate
}
up := strings.Join(upParts, "\n ")
down := strings.Join(downParts, "\n ")
const template = jsTypesDirective + `migrate((app) => {
const collection = app.findCollectionByNameOrId(%q)
%s
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId(%q)
%s
return app.save(collection)
})
`
return fmt.Sprintf(
template,
old.Id, strings.TrimSpace(up),
new.Id, strings.TrimSpace(down),
), nil
}
// -------------------------------------------------------------------
// Go templates
// -------------------------------------------------------------------
func (p *plugin) goBlankTemplate() (string, error) {
const template = `package %s
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
// add up queries...
return nil
}, func(app core.App) error {
// add down queries...
return nil
})
}
`
return fmt.Sprintf(template, filepath.Base(p.config.Dir)), nil
}
func (p *plugin) goSnapshotTemplate(collections []*core.Collection) (string, error) {
// unset timestamp fields
var collectionsData = make([]map[string]any, len(collections))
for i, c := range collections {
data, err := toMap(c)
if err != nil {
return "", fmt.Errorf("failed to serialize %q into a map: %w", c.Name, err)
}
delete(data, "created")
delete(data, "updated")
deleteNestedMapKey(data, "oauth2", "providers")
collectionsData[i] = data
}
jsonData, err := marhshalWithoutEscape(collectionsData, "\t\t", "\t")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `package %s
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := ` + "`%s`" + `
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
}, func(app core.App) error {
return nil
})
}
`
return fmt.Sprintf(
template,
filepath.Base(p.config.Dir),
escapeBacktick(string(jsonData)),
), nil
}
func (p *plugin) goCreateTemplate(collection *core.Collection) (string, error) {
// unset timestamp fields
collectionData, err := toMap(collection)
if err != nil {
return "", err
}
delete(collectionData, "created")
delete(collectionData, "updated")
deleteNestedMapKey(collectionData, "oauth2", "providers")
jsonData, err := marhshalWithoutEscape(collectionData, "\t\t", "\t")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `package %s
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := ` + "`%s`" + `
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId(%q)
if err != nil {
return err
}
return app.Delete(collection)
})
}
`
return fmt.Sprintf(
template,
filepath.Base(p.config.Dir),
escapeBacktick(string(jsonData)),
collection.Id,
), nil
}
func (p *plugin) goDeleteTemplate(collection *core.Collection) (string, error) {
// unset timestamp fields
collectionData, err := toMap(collection)
if err != nil {
return "", err
}
delete(collectionData, "created")
delete(collectionData, "updated")
deleteNestedMapKey(collectionData, "oauth2", "providers")
jsonData, err := marhshalWithoutEscape(collectionData, "\t\t", "\t")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `package %s
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId(%q)
if err != nil {
return err
}
return app.Delete(collection)
}, func(app core.App) error {
jsonData := ` + "`%s`" + `
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
})
}
`
return fmt.Sprintf(
template,
filepath.Base(p.config.Dir),
collection.Id,
escapeBacktick(string(jsonData)),
), nil
}
func (p *plugin) goDiffTemplate(new *core.Collection, old *core.Collection) (string, error) {
if new == nil && old == nil {
return "", errors.New("the diff template require at least one of the collection to be non-nil")
}
if new == nil {
return p.goDeleteTemplate(old)
}
if old == nil {
return p.goCreateTemplate(new)
}
upParts := []string{}
downParts := []string{}
varName := "collection"
newMap, err := toMap(new)
if err != nil {
return "", err
}
oldMap, err := toMap(old)
if err != nil {
return "", err
}
// non-fields
// -----------------------------------------------------------------
upDiff := diffMaps(oldMap, newMap, "fields", "created", "updated")
if len(upDiff) > 0 {
downDiff := diffMaps(newMap, oldMap, "fields", "created", "updated")
rawUpDiff, err := marhshalWithoutEscape(upDiff, "\t\t", "\t")
if err != nil {
return "", err
}
rawDownDiff, err := marhshalWithoutEscape(downDiff, "\t\t", "\t")
if err != nil {
return "", err
}
upParts = append(upParts, "// update collection data")
upParts = append(upParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s)", escapeBacktick(string(rawUpDiff)), varName)))
// ---
downParts = append(downParts, "// update collection data")
downParts = append(downParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s)", escapeBacktick(string(rawDownDiff)), varName)))
}
// fields
// -----------------------------------------------------------------
oldFieldsSlice, ok := oldMap["fields"].([]any)
if !ok {
return "", errors.New(`oldMap["fields"] is not []any`)
}
newFieldsSlice, ok := newMap["fields"].([]any)
if !ok {
return "", errors.New(`newMap["fields"] is not []any`)
}
// deleted fields
for i, oldField := range old.Fields {
if new.Fields.GetById(oldField.GetId()) != nil {
continue // exist
}
rawOldField, err := marhshalWithoutEscape(oldFieldsSlice[i], "\t\t", "\t")
if err != nil {
return "", err
}
upParts = append(upParts, "// remove field")
upParts = append(upParts, fmt.Sprintf("%s.Fields.RemoveById(%q)\n", varName, oldField.GetId()))
downParts = append(downParts, "// add field")
downParts = append(downParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawOldField)))))
}
// created fields
for i, newField := range new.Fields {
if old.Fields.GetById(newField.GetId()) != nil {
continue // exist
}
rawNewField, err := marhshalWithoutEscape(newFieldsSlice[i], "\t\t", "\t")
if err != nil {
return "", err
}
upParts = append(upParts, "// add field")
upParts = append(upParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawNewField)))))
downParts = append(downParts, "// remove field")
downParts = append(downParts, fmt.Sprintf("%s.Fields.RemoveById(%q)\n", varName, newField.GetId()))
}
// modified fields
// (note currently ignoring order-only changes as it comes with too many edge-cases)
for i, newField := range new.Fields {
var rawNewField, rawOldField []byte
rawNewField, err = marhshalWithoutEscape(newFieldsSlice[i], "\t\t", "\t")
if err != nil {
return "", err
}
var oldFieldIndex int
for j, oldField := range old.Fields {
if oldField.GetId() == newField.GetId() {
rawOldField, err = marhshalWithoutEscape(oldFieldsSlice[j], "\t\t", "\t")
if err != nil {
return "", err
}
oldFieldIndex = j
break
}
}
if rawOldField == nil || bytes.Equal(rawNewField, rawOldField) {
continue // new field or no change
}
upParts = append(upParts, "// update field")
upParts = append(upParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawNewField)))))
downParts = append(downParts, "// update field")
downParts = append(downParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, oldFieldIndex, escapeBacktick(string(rawOldField)))))
}
// ---------------------------------------------------------------
if len(upParts) == 0 && len(downParts) == 0 {
return "", ErrEmptyTemplate
}
up := strings.Join(upParts, "\n\t\t")
down := strings.Join(downParts, "\n\t\t")
combined := up + down
// generate imports
// ---
var imports string
if strings.Contains(combined, "json.Unmarshal(") ||
strings.Contains(combined, "json.Marshal(") {
imports += "\n\t\"encoding/json\"\n"
}
imports += "\n\t\"github.com/pocketbase/pocketbase/core\""
imports += "\n\tm \"github.com/pocketbase/pocketbase/migrations\""
// ---
const template = `package %s
import (%s
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId(%q)
if err != nil {
return err
}
%s
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId(%q)
if err != nil {
return err
}
%s
return app.Save(collection)
})
}
`
return fmt.Sprintf(
template,
filepath.Base(p.config.Dir),
imports,
old.Id, strings.TrimSpace(up),
new.Id, strings.TrimSpace(down),
), nil
}
func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error) {
raw, err := json.MarshalIndent(v, prefix, indent)
if err != nil {
return nil, err
}
// unescape escaped unicode characters
unescaped, err := strconv.Unquote(strings.ReplaceAll(strconv.Quote(string(raw)), `\\u`, `\u`))
if err != nil {
return nil, err
}
return []byte(unescaped), nil
}
func escapeBacktick(v string) string {
return strings.ReplaceAll(v, "`", "` + \"`\" + `")
}
func goErrIf(v string) string {
return "if err := " + v + "; err != nil {\n\t\t\treturn err\n\t\t}\n"
}
func toMap(v any) (map[string]any, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
result := map[string]any{}
err = json.Unmarshal(raw, &result)
if err != nil {
return nil, err
}
return result, nil
}
func diffMaps(old, new map[string]any, excludeKeys ...string) map[string]any {
diff := map[string]any{}
for k, vNew := range new {
if slices.Contains(excludeKeys, k) {
continue
}
vOld, ok := old[k]
if !ok {
// new field
diff[k] = vNew
continue
}
// compare the serialized version of the values in case of slice or other custom type
rawOld, _ := json.Marshal(vOld)
rawNew, _ := json.Marshal(vNew)
if !bytes.Equal(rawOld, rawNew) {
// if both are maps add recursively only the changed fields
vOldMap, ok1 := vOld.(map[string]any)
vNewMap, ok2 := vNew.(map[string]any)
if ok1 && ok2 {
subDiff := diffMaps(vOldMap, vNewMap)
if len(subDiff) > 0 {
diff[k] = subDiff
}
} else {
diff[k] = vNew
}
}
}
// unset missing fields
for k := range old {
if _, ok := diff[k]; ok || slices.Contains(excludeKeys, k) {
continue // already added
}
if _, ok := new[k]; !ok {
diff[k] = nil
}
}
return diff
}
func deleteNestedMapKey(data map[string]any, parts ...string) {
if len(parts) == 0 {
return
}
if len(parts) == 1 {
delete(data, parts[0])
return
}
v, ok := data[parts[0]].(map[string]any)
if ok {
deleteNestedMapKey(v, parts[1:]...)
}
}