Compare commits

...
27 Commits
Author SHA1 Message Date
Gani Georgiev a12bccaafc normalize negative pool size 2026-09-18 01:29:34 +03:00
Gani Georgiev caee2a55f4 fixed typo and updated changelog 2026-09-18 00:54:59 +03:00
Gani Georgiev a47551804c updated modernc.org/sqlite to 1.59.0 2026-09-16 14:42:48 +03:00
Gani Georgiev 86ce1be8fe [#7781] added panic stack trace to routine.SafeWrap and wrapped JSVM migrate arguments so that we can return a clean error with the failed filename 2026-09-15 17:39:59 +03:00
Gani Georgiev 457e094d41 fixed hook name 2026-09-12 23:07:08 +03:00
Gani Georgiev 5cec579da9 bumped app version 2026-09-12 15:27:58 +03:00
Gani Georgiev 5f496665c7 bumped golang.org/x/* deps 2026-09-12 15:27:51 +03:00
Gani Georgiev adf4eb822f renamed OnClearBootstrap to OnBootstrapClear for consistency with the other hooks 2026-09-07 19:58:29 +03:00
Gani Georgiev 114c01ac12 [#7836] fixed migration logs write deadlock and added app.ClearBootstrap/OnClearBootstrap helpers 2026-09-07 19:31:34 +03:00
Gani Georgiev 5684ee24f1 updated changelog 2026-09-06 20:14:05 +03:00
Gani Georgiev b3de909790 [#7834] changed definition to interface 2026-09-06 20:11:04 +03:00
Gani Georgiev a53f189f74 bumped app version 2026-09-06 19:54:38 +03:00
Gani Georgiev 7ff7e0f043 handled io.ReadAll error with exact payload size 2026-09-06 19:51:06 +03:00
Gani Georgiev 6e29284e6a updated changelogs 2026-09-06 17:22:45 +03:00
Gani Georgiev 1984952e4e fixed multiline index column and utrigger index validations if failing to parse all columns 2026-09-06 17:18:49 +03:00
Gani Georgiev f611256d90 removed manual unescape as jsonv2 does it alreadyfor us 2026-09-06 16:29:52 +03:00
Gani Georgiev 10f4f28312 updated test 2026-09-06 15:37:16 +03:00
Gani Georgiev 44f817e7f1 fixed nested self-referenced cascade delete 2026-09-06 15:35:16 +03:00
Gani Georgiev 75c6a4fd1f updated json field validator and allow duplicated keys when serializing record models 2026-09-06 12:31:52 +03:00
Gani Georgiev 57c0b034e6 fixed non-ascii dash in hinted proxy header 2026-09-06 11:22:01 +03:00
Gani Georgiev 4709f63183 optional assert in case the provider doesn't return id_token as string 2026-09-06 11:18:03 +03:00
Gani Georgiev 53a6cd04e2 refactored body limit middleware to prevent unnecessery reads if already beyound the limit 2026-09-06 10:47:59 +03:00
Gani Georgiev df4e6eeb35 clamped arccosine to [-1,1] to prevent rounding errors in geoDistance 2026-09-06 00:45:12 +03:00
Gani Georgiev dbea21f1b2 fixed index validator to allow expressions in the optional where clause 2026-09-05 22:31:16 +03:00
Gani Georgiev 97f9d63a1b commit json status write after checking the fields picker 2026-09-05 18:32:30 +03:00
Gani Georgiev bf12ae0244 updated dark primary btn colors 2026-09-04 00:12:32 +03:00
Gani Georgiev 7ac159b61b bumped golang.org/x/* deps and updated dark mode primary btn contrast 2026-09-03 23:41:10 +03:00
52 changed files with 5083 additions and 4309 deletions
+43
View File
@@ -1,3 +1,46 @@
## v0.40.5 (WIP)
- Minor improvements for the JSVM migration error handling:
- Included the recovered panic stack trace of `routine.SafeWrap` in the returned error (max 2KB).
- Wrap the individual `up`/`down` JSVM migration arguments in `routine.SafeWrap` so that in case of panic we can still print the failed js migration filename.
- Updated `modernc.org/sqlite` to 1.59.0 _(minor performance improvement by switching to Go's `memmove`)_.
- Other minor fixes (godoc typos, normalize negative jsvm pool size, etc.).
## v0.40.4
- Fixed migration deadlock if a logs db write happens to run while the migration is still executing ([#7836](https://github.com/pocketbase/pocketbase/issues/7836)).
- `app.ResetBootstrapState()` was soft-deprecated in favour of `app.ClearBootstrap()`.
_Additionally a new `app.OnBootstrapClear()` hook was added to allow clearing custom allocated `OnBootstrap` resources in case the app uses a non-standard initialization (e.g. doesn't call `Start()` or intentionally skip the `OnTerminate` hook)._
- Bumped `golang.org/x/*` dependencies.
## v0.40.3
- Write the status header for JSON responses only if the fields picker succeed or has acceptable fallback.
_This is to allow custom response status code for failed json writes._
- Fixed collection index validator to allow expressions with parenthesis in the optional `WHERE` clause.
- Clamped arccosine to [-1,1] in the Harvesine formula for the `geoDistance()` filter function to workaround edge case related to float rounding errors for some coordinates.
- Prevent unnecessary body chunk read if we already known that we are beyond the allowed limit.
- Updated the `json` field validator to check the `encoding/json/v2` semantics and allow duplicated keys on record marshalize for compliance with old jsonv1 data.
- Fixed nested cascade delete of self-referenced relation records.
- Minor UI fixes (updated dark primary btn color contrast, force reload the records list if the deleted record has self-referenced cascade relation field, etc.).
- Changed JSVM `$app` variable definition from TS type to interface ([#7834](https://github.com/pocketbase/pocketbase/issues/7834)).
- Bumped `golang.org/x/*` dependencies to silence security scanners ([#7829](https://github.com/pocketbase/pocketbase/discussions/7829)).
## v0.40.2
- Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
+9
View File
@@ -2,6 +2,15 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.55
- (_Backported from v0.40.3_) Fixed collection index validator to allow expressions with parenthesis in the optional `WHERE` clause.
- (_Backported from v0.40.3_) Fixed nested cascade delete of self-referenced relation records.
- (_Backported from v0.40.3_) Bumped `golang.org/x/*` dependencies to silence security scanners ([#7829](https://github.com/pocketbase/pocketbase/discussions/7829)).
## v0.22.54
- (_Backported from v0.40.2_) Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
+1 -1
View File
@@ -37,7 +37,7 @@ func healthCheck(e *core.RequestEvent) error {
headersToCheck := append(
slices.Clone(e.App.Settings().TrustedProxy.Headers),
// common proxy headers
"CF-Connecting-IP", "Fly-Client-IP", "X‑Forwarded-For",
"CF-Connecting-IP", "Fly-Client-IP", "X-Forwarded-For",
)
for _, header := range headersToCheck {
if e.Request.Header.Get(header) != "" {
+46 -22
View File
@@ -84,49 +84,73 @@ func applyBodyLimit(e *core.RequestEvent, limitBytes int64) error {
}
// replace the request body
//
// note: we don't use sync.Pool since the size of the elements could vary too much
// and it might not be efficient (see https://github.com/golang/go/issues/23199)
e.Request.Body = &limitedReader{ReadCloser: e.Request.Body, limit: limitBytes}
e.Request.Body = newMaxBytesReader(e.Request.Body, limitBytes)
return nil
}
type limitedReader struct {
io.ReadCloser
limit int64
totalRead int64
func newMaxBytesReader(body io.ReadCloser, limitBytes int64) *maxBytesReader {
return &maxBytesReader{
ReadCloser: body,
limit: limitBytes,
remaining: limitBytes,
}
}
func (r *limitedReader) Read(b []byte) (int, error) {
// maxBytesReader is very similar to the http.MaxBytesReader but support
// rereads and doesn't try to prematurely close the related response
// to allow consequent middlewares to operate correctly.
type maxBytesReader struct {
io.ReadCloser
limit int64
remaining int64
stickyErr error
}
func (r *maxBytesReader) Read(b []byte) (int, error) {
if r.stickyErr != nil {
return 0, r.stickyErr
}
if len(b) == 0 {
return 0, nil
}
// if possible no need to read the entire chunk since
// remaining+1 is enough to determine whether it exceed the limit
if int64(len(b))-1 > r.remaining {
b = b[:r.remaining+1]
}
n, err := r.ReadCloser.Read(b)
if err != nil {
if int64(n) <= r.remaining {
r.remaining -= int64(n)
r.stickyErr = err
return n, err
}
r.totalRead += int64(n)
if r.totalRead > r.limit {
return n, ErrRequestEntityTooLarge
}
n = int(r.remaining)
return n, nil
r.remaining = 0
r.stickyErr = ErrRequestEntityTooLarge
return n, r.stickyErr
}
// explicit casts to ensure that the main struct methods will be invoked
// (extra precautions in case of nested interface wrapping erasure)
// ---
func (r *limitedReader) Reread() {
func (r *maxBytesReader) Reread() {
rereader, ok := r.ReadCloser.(router.Rereader)
if ok {
rereader.Reread()
r.remaining = r.limit
r.stickyErr = nil
}
}
func (r *limitedReader) Close() error {
closer, ok := r.ReadCloser.(io.Closer)
if ok {
return closer.Close()
}
return nil
func (r *maxBytesReader) Close() error {
return r.ReadCloser.Close()
}
+110 -17
View File
@@ -1,9 +1,10 @@
package apis_test
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
@@ -19,13 +20,35 @@ func TestBodyLimitMiddleware(t *testing.T) {
if err != nil {
t.Fatal(err)
}
pbRouter.POST("/a", func(e *core.RequestEvent) error {
return e.String(200, "a")
}) // default global BodyLimit check
pbRouter.POST("/b", func(e *core.RequestEvent) error {
return e.String(200, "b")
}).Bind(apis.BodyLimit(20))
testHandler := func(e *core.RequestEvent) error {
// read the body multiple times to ensure that the limited
// reader guards and rereads are invoked
var result any
if err := e.BindBody(&result); err != nil {
return err
}
if err := e.BindBody(&result); err != nil {
return err
}
return e.JSON(200, result)
}
const customLimit = 20
pbRouter.POST("/a", testHandler) // default global BodyLimit check
pbRouter.POST("/b", testHandler).Bind(apis.BodyLimit(customLimit))
pbRouter.POST("/iof", func(e *core.RequestEvent) error {
// ensure that normal io methods still operate correctly
b, err := io.ReadAll(e.Request.Body)
if err != nil {
return err
}
return e.String(http.StatusOK, string(b))
}).Bind(apis.BodyLimit(customLimit))
mux, err := pbRouter.BuildMux()
if err != nil {
@@ -33,20 +56,90 @@ func TestBodyLimitMiddleware(t *testing.T) {
}
scenarios := []struct {
url string
size int64
expectedStatus int
name string
url string
body string
lazyContentLength bool
expectedStatus int
}{
{"/a", 21, 200},
{"/a", apis.DefaultMaxBodySize + 1, 413},
{"/b", 20, 200},
{"/b", 21, 413},
{
"(eager content-length check) with body = default limit",
"/a",
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize-2)) + `"`,
false,
http.StatusOK,
},
{
"(eager content-length check) with body > default limit",
"/a",
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize)) + `"`,
false,
http.StatusRequestEntityTooLarge,
},
{
"(lazy content-length check) with body = default limit",
"/a",
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize-2)) + `"`,
true,
http.StatusOK,
},
{
"(lazy content-length check) with body > default limit",
"/a",
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize)) + `"`,
true,
http.StatusRequestEntityTooLarge,
},
// ---
{
"(eager content-length check) with body = custom limit",
"/b",
`"` + strings.Repeat("a", customLimit-2) + `"`,
false,
http.StatusOK,
},
{
"(eager content-length check) with body > custom limit",
"/b",
`"` + strings.Repeat("a", customLimit) + `"`,
false,
http.StatusRequestEntityTooLarge,
},
{
"(lazy content-length check) with body = custom limit",
"/b",
`"` + strings.Repeat("a", customLimit-2) + `"`,
true,
http.StatusOK,
},
{
"(lazy content-length check) with body > custom limit",
"/b",
`"` + strings.Repeat("a", customLimit) + `"`,
true,
http.StatusRequestEntityTooLarge,
},
// ---
{
"io.ReadAll io.EOF exact limit check",
"/iof",
`"` + strings.Repeat("a", customLimit-2) + `"`,
true,
http.StatusOK,
},
}
for _, s := range scenarios {
t.Run(fmt.Sprintf("%s_%d", s.url, s.size), func(t *testing.T) {
t.Run(s.name, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", s.url, bytes.NewReader(make([]byte, s.size)))
req := httptest.NewRequest("POST", s.url, strings.NewReader(s.body))
req.Header.Set("Content-Type", "application/json")
if s.lazyContentLength {
req.ContentLength = -1
}
mux.ServeHTTP(rec, req)
result := rec.Result()
+14 -2
View File
@@ -54,11 +54,16 @@ type App interface {
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
// It will call ResetBootstrapState() if the application was already bootstrapped.
// It calls ClearBootstrap() if the application was already bootstrapped.
Bootstrap() error
// ResetBootstrapState releases the initialized core app resources
// ClearBootstrap releases the initialized core app resources
// (closing db connections, stopping cron ticker, etc.).
//
// This method is no-op if the application is not bootstrapped yet.
ClearBootstrap() error
// Deprecated: use ClearBootstrap().
ResetBootstrapState() error
// DataDir returns the app data directory path.
@@ -713,6 +718,13 @@ type App interface {
// resources (db, app settings, etc).
OnBootstrap() *hook.Hook[*BootstrapEvent]
// OnBootstrapClear hook is triggered when clearing the main application
// resources (db connections, cron, logger, etc.)
//
// It is usually invoked automatically right before app termination
// or when manually calling app.ClearBootstrap().
OnBootstrapClear() *hook.Hook[*BootstrapEvent]
// OnServe hook is triggered when the app web server is started
// (after starting the TCP listener but before initializing the blocking serve task),
// allowing you to adjust its options and attach new routes or middlewares.
+120 -65
View File
@@ -12,6 +12,7 @@ import (
"regexp"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/fatih/color"
@@ -89,11 +90,12 @@ type BaseApp struct {
auxNonconcurrentDB dbx.Builder
// app event hooks
onBootstrap *hook.Hook[*BootstrapEvent]
onServe *hook.Hook[*ServeEvent]
onTerminate *hook.Hook[*TerminateEvent]
onBackupCreate *hook.Hook[*BackupEvent]
onBackupRestore *hook.Hook[*BackupEvent]
onBootstrap *hook.Hook[*BootstrapEvent]
onBootstrapClear *hook.Hook[*BootstrapEvent]
onServe *hook.Hook[*ServeEvent]
onTerminate *hook.Hook[*TerminateEvent]
onBackupCreate *hook.Hook[*BackupEvent]
onBackupRestore *hook.Hook[*BackupEvent]
// db model hooks
onModelValidate *hook.Hook[*ModelEvent]
@@ -249,6 +251,7 @@ func NewBaseApp(config BaseAppConfig) *BaseApp {
func (app *BaseApp) initHooks() {
// app event hooks
app.onBootstrap = &hook.Hook[*BootstrapEvent]{}
app.onBootstrapClear = &hook.Hook[*BootstrapEvent]{}
app.onServe = &hook.Hook[*ServeEvent]{}
app.onTerminate = &hook.Hook[*TerminateEvent]{}
app.onBackupCreate = &hook.Hook[*BackupEvent]{}
@@ -405,14 +408,14 @@ func (app *BaseApp) IsBootstrapped() bool {
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
// It will call ResetBootstrapState() if the application was already bootstrapped.
// It calls ClearBootstrap() if the application was already bootstrapped.
func (app *BaseApp) Bootstrap() error {
event := &BootstrapEvent{}
event.App = app
err := app.OnBootstrap().Trigger(event, func(e *BootstrapEvent) error {
// clear resources of previous core state (if any)
if err := app.ResetBootstrapState(); err != nil {
// clear previous bootstrap state (if any)
if err := app.ClearBootstrap(); err != nil {
return err
}
@@ -460,41 +463,55 @@ func (app *BaseApp) Bootstrap() error {
return err
}
type closer interface {
Close() error
// Deprecated: use [ClearBootstrap].
func (app *BaseApp) ResetBootstrapState() error {
return app.ClearBootstrap()
}
// ResetBootstrapState releases the initialized core app resources
// ClearBootstrap releases the initialized core app resources
// (closing db connections, stopping cron ticker, etc.).
func (app *BaseApp) ResetBootstrapState() error {
app.Cron().Stop()
var errs []error
dbs := []*dbx.Builder{
&app.concurrentDB,
&app.nonconcurrentDB,
&app.auxConcurrentDB,
&app.auxNonconcurrentDB,
//
// This method is no-op if the application is not bootstrapped yet.
func (app *BaseApp) ClearBootstrap() error {
if !app.IsBootstrapped() {
return nil
}
for _, db := range dbs {
if db == nil {
continue
event := &BootstrapEvent{}
event.App = app
return app.OnBootstrapClear().Trigger(event, func(e *BootstrapEvent) error {
type closer interface {
Close() error
}
if v, ok := (*db).(closer); ok {
if err := v.Close(); err != nil {
errs = append(errs, err)
var errs []error
dbs := []*dbx.Builder{
&app.concurrentDB,
&app.nonconcurrentDB,
&app.auxConcurrentDB,
&app.auxNonconcurrentDB,
}
for _, db := range dbs {
if db == nil {
continue
}
if v, ok := (*db).(closer); ok {
if err := v.Close(); err != nil {
errs = append(errs, err)
}
}
*db = nil
}
*db = nil
}
if len(errs) > 0 {
return errors.Join(errs...)
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
return nil
})
}
// DB returns the default app data.db builder instance.
@@ -817,7 +834,7 @@ func (app *BaseApp) Restart() error {
event.IsRestart = true
return app.OnTerminate().Trigger(event, func(e *TerminateEvent) error {
_ = e.App.ResetBootstrapState()
_ = e.App.ClearBootstrap()
// attempt to restart the bootstrap process in case execve returns an error for some reason
defer func() {
@@ -860,6 +877,10 @@ func (app *BaseApp) OnBootstrap() *hook.Hook[*BootstrapEvent] {
return app.onBootstrap
}
func (app *BaseApp) OnBootstrapClear() *hook.Hook[*BootstrapEvent] {
return app.onBootstrapClear
}
func (app *BaseApp) OnServe() *hook.Hook[*ServeEvent] {
return app.onServe
}
@@ -1413,7 +1434,15 @@ func (app *BaseApp) registerBaseHooks() {
Id: "__pbCronStart__",
Func: func(e *ServeEvent) error {
app.Cron().Start()
return e.Next()
},
Priority: 999,
})
app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
Id: "__pbCronStop__",
Func: func(e *BootstrapEvent) error {
app.Cron().Stop()
return e.Next()
},
Priority: 999,
@@ -1470,9 +1499,41 @@ func getLoggerMinLevel(app App) slog.Level {
}
func (app *BaseApp) initLogger() error {
var stopped atomic.Bool
duration := 3 * time.Second
ticker := time.NewTicker(duration)
done := make(chan bool, 1)
done := make(chan struct{}, 1)
runLogsWrite := func(logs []*logger.Log) {
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
return
}
// write the accumulated logs
//
// note: based on several local tests there is no
// significant performance difference between small number
// of separate write queries vs 1 big INSERT
app.AuxRunInTransaction(func(txApp App) error {
model := &Log{}
for _, l := range logs {
model.MarkAsNew()
model.Id = GenerateDefaultRandomId()
model.Level = int(l.Level)
model.Message = l.Message
model.Data = l.Data
model.Created, _ = types.ParseDateTime(l.Time)
if err := txApp.AuxSave(model); err != nil {
log.Println("Failed to write log", model, err)
}
}
return nil
})
}
handler := logger.NewBatchHandler(logger.BatchOptions{
Level: getLoggerMinLevel(app),
@@ -1487,35 +1548,25 @@ func (app *BaseApp) initLogger() error {
}
}
ticker.Reset(duration)
if !stopped.Load() {
ticker.Reset(duration)
}
return app.Settings().Logs.MaxDays > 0
},
WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
return nil
// don't block and wait for the write transaction to complete
// when we can't be sure if the logs write wasn't triggered while
// inside another AUX db transaction (ticker or batch threshold reached)
// which can block indefinitely and cause deadlock
// (https://github.com/pocketbase/pocketbase/issues/7836)
shouldBlock, _ := ctx.Value(logger.BlockKey).(bool)
if shouldBlock {
runLogsWrite(logs)
} else {
routine.FireAndForget(func() { runLogsWrite(logs) })
}
// write the accumulated logs
// (note: based on several local tests there is no significant performance difference between small number of separate write queries vs 1 big INSERT)
app.AuxRunInTransaction(func(txApp App) error {
model := &Log{}
for _, l := range logs {
model.MarkAsNew()
model.Id = GenerateDefaultRandomId()
model.Level = int(l.Level)
model.Message = l.Message
model.Data = l.Data
model.Created, _ = types.ParseDateTime(l.Time)
if err := txApp.AuxSave(model); err != nil {
log.Println("Failed to write log", model, err)
}
}
return nil
})
return nil
},
})
@@ -1535,17 +1586,21 @@ func (app *BaseApp) initLogger() error {
app.logger = slog.New(handler)
// write all remaining logs before ticker.Stop to avoid races with ResetBootstrap user calls
app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
Id: "__pbAppLoggerOnTerminate__",
Func: func(e *TerminateEvent) error {
handler.WriteAll(context.Background())
// attempt to write all queued logs before clearing the application bootstrap state
app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
Id: "__pbAppLoggerFlushBeforeStop__",
Func: func(e *BootstrapEvent) error {
// extra precaution in case the hook was manually triggered while inside aux db transaction
_, isTx := e.App.AuxNonconcurrentDB().(*dbx.Tx)
ctx := context.WithValue(context.Background(), logger.BlockKey, !isTx)
handler.WriteAll(ctx)
stopped.Store(true)
ticker.Stop()
// don't block in case OnTerminate is triggered more than once
// don't block in case the hook is triggered more than once
select {
case done <- true:
case done <- struct{}{}:
default:
}
+144 -43
View File
@@ -65,7 +65,7 @@ func TestBaseAppBootstrap(t *testing.T) {
app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir,
})
defer app.ResetBootstrapState()
defer app.ClearBootstrap()
if app.IsBootstrapped() {
t.Fatal("Didn't expect the application to be bootstrapped.")
@@ -115,7 +115,7 @@ func TestBaseAppBootstrap(t *testing.T) {
runNilChecks(nilChecksBeforeReset)
// reset
if err := app.ResetBootstrapState(); err != nil {
if err := app.ClearBootstrap(); err != nil {
t.Fatal(err)
}
@@ -141,7 +141,7 @@ func TestNewBaseAppTx(t *testing.T) {
app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir,
})
defer app.ResetBootstrapState()
defer app.ClearBootstrap()
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
@@ -185,7 +185,7 @@ func TestBaseAppNewMailClient(t *testing.T) {
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
})
defer app.ResetBootstrapState()
defer app.ClearBootstrap()
client1 := app.NewMailClient()
m1, ok := client1.(*mailer.Sendmail)
@@ -215,7 +215,7 @@ func TestBaseAppNewFilesystem(t *testing.T) {
app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir,
})
defer app.ResetBootstrapState()
defer app.ClearBootstrap()
// local
local, localErr := app.NewFilesystem()
@@ -244,7 +244,7 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir,
})
defer app.ResetBootstrapState()
defer app.ClearBootstrap()
// local
local, localErr := app.NewBackupsFilesystem()
@@ -266,74 +266,175 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
}
}
const logsThreshold = 200
func assertLogsCount(t *testing.T, app core.App, expected int) {
var total int
err := app.LogQuery().Select("count(*)").Row(&total)
if err != nil {
t.Fatalf("Failed to fetch total logs: %v", err)
}
if total != expected {
t.Fatalf("Expected %d log(s), got %d", expected, total)
}
}
func TestBaseAppLoggerWrites(t *testing.T) {
t.Parallel()
// note: outside of synctest because the bootstrap tickers could deadlock
app, _ := tests.NewTestApp()
defer app.Cleanup()
// reset
if err := app.DeleteOldLogs(time.Now()); err != nil {
// clear old logs
err := app.DeleteOldLogs(time.Now())
if err != nil {
t.Fatal(err)
}
const logsThreshold = 200
totalLogs := func(app core.App, t *testing.T) int {
var total int
err := app.LogQuery().Select("count(*)").Row(&total)
if err != nil {
t.Fatalf("Failed to fetch total logs: %v", err)
}
return total
}
t.Run("disabled logs retention", func(t *testing.T) {
app.Settings().Logs.MaxDays = 0
synctest.Test(t, func(t *testing.T) {
app.Settings().Logs.MaxDays = 0
for i := 0; i < logsThreshold+1; i++ {
app.Logger().Error("test")
}
for i := 0; i < logsThreshold+1; i++ {
app.Logger().Error("test")
}
if total := totalLogs(app, t); total != 0 {
t.Fatalf("Expected no logs, got %d", total)
}
// short delay for the non-blocking write goroutine
synctest.Sleep(time.Nanosecond)
assertLogsCount(t, app, 0)
})
})
t.Run("test batch logs writes", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
app.Settings().Logs.MaxDays = 1
app.Settings().Logs.MaxDays = 2
for i := 0; i < logsThreshold-1; i++ {
app.Logger().Error("test")
}
if total := totalLogs(app, t); total != 0 {
t.Fatalf("Expected no logs, got %d", total)
}
// short delay for the non-blocking write goroutine
synctest.Sleep(time.Nanosecond)
// should trigger batch write
// below threshold
assertLogsCount(t, app, 0)
// threshold reached -> should trigger batch write
app.Logger().Error("test")
// should be added for the next batch write
// should be skipped from this batch and added for the next
app.Logger().Error("test")
if total := totalLogs(app, t); total != logsThreshold {
t.Fatalf("Expected %d logs, got %d", logsThreshold, total)
}
// short delay for the non-blocking write goroutine
synctest.Sleep(time.Nanosecond)
// wait for 3 secs to check the timer trigger
synctest.Sleep(3000 * time.Millisecond)
assertLogsCount(t, app, logsThreshold)
if total := totalLogs(app, t); total != logsThreshold {
t.Fatalf("Expected %d logs, got %d", logsThreshold, total)
}
// note: we can't test the flush timer here because the ticker
// was started out of the synctest buble to avoid deadlocks
// (see TestBaseAppLoggerWritesAwaited for a flaky but real timer test)
})
})
}
func TestBaseAppLoggerWritesAwaited(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
// clear old logs
err := app.DeleteOldLogs(time.Now())
if err != nil {
t.Fatal(err)
}
// enable logs persistence
app.Settings().Logs.MaxDays = 1
err = app.Save(app.Settings())
if err != nil {
t.Fatal(err)
}
t.Run("flush on timer tick", func(t *testing.T) {
timeout := time.After(5 * time.Second)
done := make(chan struct{})
logsHook := app.OnModelAfterCreateSuccess("_logs")
hookId := logsHook.BindFunc(func(e *core.ModelEvent) error {
done <- struct{}{}
return e.Next()
})
defer logsHook.Unbind(hookId)
app.Logger().Error("test")
// short wait to ensure that there is no non-blocking write
time.Sleep(500 * time.Millisecond)
assertLogsCount(t, app, 0)
// wait for the ticker to write the db record
select {
case <-timeout:
t.Fatal("ticker wait timeout")
case <-done:
}
assertLogsCount(t, app, 1)
})
t.Run("before ClearBootstrap flush", func(t *testing.T) {
app.Logger().Error("test")
app.Bootstrap()
assertLogsCount(t, app, 2)
})
t.Run("batch flush inside aux transaction shouldn't hang", func(t *testing.T) {
timeout := time.After(1 * time.Second)
done := make(chan struct{})
totalCreated := 0
logsHook := app.OnModelAfterCreateSuccess("_logs")
hookId := logsHook.BindFunc(func(e *core.ModelEvent) error {
totalCreated++
if totalCreated == 200 {
done <- struct{}{}
}
return e.Next()
})
defer logsHook.Unbind(hookId)
app.AuxRunInTransaction(func(txApp core.App) error {
for range logsThreshold {
txApp.Logger().Error("test")
}
return nil
})
// wait for the non-blocking write
select {
case <-timeout:
t.Fatal("non-blocking write timeout")
case <-done:
}
assertLogsCount(t, app, 202)
// force clear to ensure that there are no other logs
app.Bootstrap()
assertLogsCount(t, app, 202)
})
}
func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
scenarios := []struct {
name string
@@ -373,7 +474,7 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
DataDir: testDataDir,
IsDev: s.isDev,
})
defer app.ResetBootstrapState()
defer app.ClearBootstrap()
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
+14
View File
@@ -2,6 +2,8 @@ package core
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"slices"
"strconv"
"strings"
@@ -171,6 +173,18 @@ func (f *JSONField) ValidateValue(ctx context.Context, app App, record *Record)
return validation.NewError("validation_invalid_json", "Must be a valid json value")
}
// temp extra encoding/json/v2 check since the above validator is
// still using the v1 semantics
//
// @todo remove after updating the string validator
if len(raw) > 0 {
var dummy any
err := json.Unmarshal(raw, &dummy, jsontext.AllowInvalidUTF8(true))
if err != nil {
return validation.NewError("validation_invalid_json", "Must be a valid json value")
}
}
rawStr := strings.TrimSpace(raw.String())
if f.Required && slices.Contains(emptyJSONValues, rawStr) {
+10
View File
@@ -101,6 +101,16 @@ func TestJSONFieldValidateValue(t *testing.T) {
},
true,
},
{
"invalid raw value (v2 semantics)",
&core.JSONField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.JSONRaw(`{"a": 1, "a": 2}`))
return record
},
true,
},
{
"zero field value (not required)",
&core.JSONField{Name: "test"},
+9 -2
View File
@@ -3,17 +3,24 @@ package core
import (
"context"
"database/sql"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/logger"
)
func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
// temp unset to avoid littering the stdout if the test fails when in dev mode
colorOutput := color.Output
color.Output = io.Discard
defer func() { color.Output = colorOutput }()
testLogLevel := 4
scenarios := []struct {
@@ -48,7 +55,7 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
DataDir: testDataDir,
IsDev: s.isDev,
})
defer app.ResetBootstrapState()
defer app.ClearBootstrap()
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
@@ -68,7 +75,7 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
var printedLevels []int
var persistedLevels []int
ctx := context.Background()
ctx := context.WithValue(context.Background(), logger.BlockKey, true)
// track printed logs
originalPrintLog := printLog
+27 -13
View File
@@ -3,6 +3,7 @@ package core
import (
"bytes"
"context"
"database/sql"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
@@ -1333,7 +1334,9 @@ func (m Record) MarshalJSON() ([]byte, error) {
return json.Marshal(
m.PublicExport(),
json.Deterministic(true),
// to preserve the old jsonv1 behavior in case of invalid data
// for compliance with old data (and slightly better performance)
jsontext.AllowDuplicateNames(true),
// preserve the old jsonv1 behavior in case of invalid data
jsontext.AllowInvalidUTF8(true),
)
}
@@ -1532,12 +1535,14 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
continue // skip missing or view collections
}
recordTableName := inflector.Columnify(refCollection.Name)
refTableName := inflector.Columnify(refCollection.Name)
for _, field := range fields {
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName())
prefixedFieldName := refTableName + "." + inflector.Columnify(field.GetName())
query := app.RecordQuery(refCollection)
// fetch only the related ids because they will be queried anyway right
// before delete to ensure that we are working with fresh record data
query := app.DB().Select(refTableName + ".id").From(refTableName)
if opt, ok := field.(MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
@@ -1551,23 +1556,24 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
}
if refCollection.Id == mainRecord.Collection().Id {
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
query.AndWhere(dbx.Not(dbx.HashExp{refTableName + ".id": mainRecord.Id}))
}
// trigger cascade for each batchSize rel items until there is none
batchSize := 4000
rows := make([]*Record, 0, batchSize)
batchSize := 8000
refIds := make([]string, 0, batchSize)
for {
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
err := query.Limit(int64(batchSize)).Column(&refIds)
if err != nil {
return err
}
total := len(rows)
total := len(refIds)
if total == 0 {
break
}
err := deleteRefRecords(app, mainRecord, rows, field)
err = deleteRefRecords(app, mainRecord, refCollection, refIds, field)
if err != nil {
return err
}
@@ -1576,7 +1582,7 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
break // no more items
}
rows = rows[:0] // keep allocated memory
refIds = refIds[:0] // keep allocated memory
}
}
}
@@ -1589,13 +1595,21 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
// just unset the record id from any relation field values (if they are not required).
//
// NB! This method is expected to be called from inside of a transaction.
func deleteRefRecords(app App, mainRecord *Record, refRecords []*Record, field Field) error {
func deleteRefRecords(app App, mainRecord *Record, refCollection *Collection, refIds []string, field Field) error {
relField, _ := field.(*RelationField)
if relField == nil {
return errors.New("only RelationField is supported at the moment, got " + field.Type())
}
for _, refRecord := range refRecords {
for _, refId := range refIds {
refRecord, err := app.FindRecordById(refCollection, refId)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue // already deleted
}
return err
}
ids := refRecord.GetStringSlice(relField.Name)
// unset the record id
+106 -14
View File
@@ -1413,14 +1413,15 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
f3 := &core.SelectField{Name: "field3", MaxSelect: 2, Values: []string{"test1", "test2", "test3"}}
f4 := &core.TextField{Name: "field4", Hidden: true}
f5 := &core.TextField{Name: "field5", Hidden: true}
f6 := &core.JSONField{Name: "field6"}
colBase := core.NewBaseCollection("test_base")
colBase.Id = "_pbc_base_123"
colBase.Fields.Add(f1, f2, f3, f4, f5)
colBase.Fields.Add(f1, f2, f3, f4, f5, f6)
colAuth := core.NewAuthCollection("test_auth")
colAuth.Id = "_pbc_auth_123"
colAuth.Fields.Add(f1, f2, f3, f4, f5)
colAuth.Fields.Add(f1, f2, f3, f4, f5, f6)
scenarios := []struct {
name string
@@ -1439,7 +1440,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
false,
nil,
nil,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id"}`,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id"}`,
},
{
"[base] with email visibility",
@@ -1448,7 +1449,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
false,
nil,
nil,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id"}`,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id"}`,
},
{
"[base] with custom data",
@@ -1457,7 +1458,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true,
nil,
nil,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
},
{
"[base] with explicit hide and unhide fields",
@@ -1466,7 +1467,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true,
[]string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "tokenKey", "unknown"},
[]string{"field4", "@pbInternalAbc"},
`{"emailVisibility":"test_invalid","field2":"field_2.png","field4":"field_4","id":"test_id","password":"test_passwordHash","verified":true}`,
`{"emailVisibility":"test_invalid","field2":"field_2.png","field4":"field_4","field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","verified":true}`,
},
{
"[base] trying to unhide custom fields without explicit WithCustomData",
@@ -1475,7 +1476,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true,
nil,
[]string{"field5", "@pbInternalAbc", "email", "tokenKey", "unknown"},
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
},
// auth
@@ -1486,7 +1487,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
false,
nil,
nil,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","verified":true}`,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
},
{
"[auth] with email visibility",
@@ -1495,7 +1496,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
false,
nil,
nil,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","verified":true}`,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
},
{
"[auth] with custom data",
@@ -1504,7 +1505,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true,
nil,
nil,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","unknown":"test_unknown","verified":true}`,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","unknown":"test_unknown","verified":true}`,
},
{
"[auth] with explicit hide and unhide fields",
@@ -1513,7 +1514,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true,
[]string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "unknown"},
[]string{"field4", "@pbInternalAbc"},
`{"emailVisibility":false,"field2":"field_2.png","field4":"field_4","id":"test_id","verified":true}`,
`{"emailVisibility":false,"field2":"field_2.png","field4":"field_4","field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
},
{
"[auth] trying to unhide custom fields without explicit WithCustomData",
@@ -1522,7 +1523,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true,
nil,
[]string{"field5", "@pbInternalAbc", "tokenKey", "unknown", "email"}, // emailVisibility:false has higher priority
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","id":"test_id","unknown":"test_unknown","verified":true}`,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","field6":{"a":1,"a":2},"id":"test_id","unknown":"test_unknown","verified":true}`,
},
}
@@ -1533,6 +1534,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
"field3": []string{"test1", "test2"},
"field4": "field_4",
"field5": "field_5",
"field6": types.JSONRaw(`{"a":1,"a":2}`), // intentionally duplicated to check serialization
"expand": map[string]any{"test": 123},
"collectionId": "m_id", // should be always ignored
"collectionName": "m_name", // should be always ignored
@@ -1558,6 +1560,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
exportResult, err := json.Marshal(
m.PublicExport(),
json.Deterministic(true),
jsontext.AllowDuplicateNames(true),
jsontext.AllowInvalidUTF8(true),
)
if err != nil {
@@ -2324,16 +2327,105 @@ func TestRecordDelete(t *testing.T) {
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
expectedRelManyPart := "SELECT `demo1`.`id` FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
}
expectedRelOnePart := "SELECT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`='"
expectedRelOnePart := "SELECT `demo1`.`id` FROM `demo1` WHERE (`demo1`.`rel_one`='"
if !strings.Contains(joinedQueries, expectedRelOnePart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
}
}
func TestRecordDeleteWithMultipleRelationCascade(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
// create a mock collection with self referencing multiple relation field
// ---
collection := core.NewBaseCollection("test")
err := app.Save(collection)
if err != nil {
t.Fatal(err)
}
// for simpler mocks
collection.Fields.GetByName("id").(*core.TextField).Min = 1
collection.Fields.Add(&core.RelationField{
Name: "rels",
CollectionId: collection.Id,
MaxSelect: 99,
CascadeDelete: true,
})
err = app.Save(collection)
if err != nil {
t.Fatal(err)
}
// create mock records
// ---
relsData := map[string][]string{
"a": nil,
"b": {"a"},
"c": {"a", "b"},
"d": {},
"e": {"c", "d"},
}
for id, rels := range relsData {
record := core.NewRecord(collection)
record.Set("id", id)
record.Set("rels", rels)
err = app.SaveNoValidate(record) // map is not ordered
if err != nil {
t.Fatalf("failed to create mock record: %v", err)
}
}
// trigger cascade delete for the top record
// ---
aRecord, err := app.FindRecordById(collection, "a")
if err != nil {
t.Fatal(err)
}
err = app.Delete(aRecord)
if err != nil {
t.Fatal(err)
}
// verify cascade delete
// ---
expectedRels := map[string][]string{
"d": {},
"e": {"d"},
}
allRecords, err := app.FindAllRecords(collection)
if err != nil {
t.Fatal(err)
}
if len(allRecords) != len(expectedRels) {
t.Fatalf("Expected %d remaining records, got %d", len(expectedRels), len(allRecords))
}
for _, r := range allRecords {
expected, ok := expectedRels[r.Id]
if !ok {
t.Fatalf("Record %q wasn't found in %v", r.Id, expectedRels)
}
rels := r.GetStringSlice("rels")
if !slices.Equal(rels, expected) {
t.Fatalf("Record %q expected rels\n%v\ngot\n%v", r.Id, expected, rels)
}
}
}
func TestRecordDeleteBatchProcessing(t *testing.T) {
t.Parallel()
+2 -2
View File
@@ -19,7 +19,7 @@ func TestSendSystemAlert(t *testing.T) {
testApp := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
defer testApp.ResetBootstrapState()
defer testApp.ClearBootstrap()
if err := testApp.Bootstrap(); err != nil {
t.Fatal(err)
@@ -72,7 +72,7 @@ func TestSendSystemAlertToAllSuperusers(t *testing.T) {
testApp := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
defer testApp.ResetBootstrapState()
defer testApp.ClearBootstrap()
if err := testApp.Bootstrap(); err != nil {
t.Fatal(err)
+11 -11
View File
@@ -17,12 +17,12 @@ require (
github.com/pocketbase/tygoja v0.1.0
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
modernc.org/sqlite v1.57.0
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0
modernc.org/sqlite v1.59.0
)
require (
@@ -40,11 +40,11 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.8.0 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.49.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.0 // indirect
modernc.org/memory v1.12.1 // indirect
)
+28 -28
View File
@@ -88,27 +88,27 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
@@ -119,30 +119,30 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU=
modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+2 -2
View File
@@ -10,8 +10,8 @@ import (
)
const (
expectedDriverVersion = "v1.57.0"
expectedLibcVersion = "v1.74.4"
expectedDriverVersion = "v1.59.0"
expectedLibcVersion = "v1.75.7"
// ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
// It could be used for removing/unbinding the hook if you don't want the checks.
+1 -1
View File
@@ -1613,7 +1613,7 @@ func TestHooksBindsCount(t *testing.T) {
vm := goja.New()
hooksBinds(app, vm, nil)
testBindsCount(vm, "this", 82, t)
testBindsCount(vm, "this", 83, t)
}
func TestHooksBinds(t *testing.T) {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -129,7 +129,7 @@ type excludeHooks<Type> = {
type CoreApp = excludeHooks<ORIGINAL_CORE_APP>
// pocketbase.PocketBase without the on* hook methods
type PocketBase = excludeHooks<ORIGINAL_POCKETBASE>
interface PocketBase extends excludeHooks<ORIGINAL_POCKETBASE>{}
/**
* ` + "`$app`" + ` is the current running PocketBase instance that is globally
+24 -4
View File
@@ -83,10 +83,10 @@ type Config struct {
HooksFilesPattern string
// HooksPoolSize specifies how many goja.Runtime instances to prewarm
// and keep for the JS app hooks gorotines execution.
// and keep for the JS app hooks goroutine execution to reuse.
//
// Zero or negative value means that it will create a new goja.Runtime
// on every fired goroutine.
// Zero or negative value means that no pool will be maintained and
// instead it will create a new goja.Runtime on every fired goroutine.
HooksPoolSize int
// MigrationsDir specifies the JS migrations directory.
@@ -218,7 +218,27 @@ func (p *plugin) registerMigrations() error {
vm.Set("__hooks", absHooksDir)
vm.Set("migrate", func(up, down func(txApp core.App) error) {
core.AppMigrations.Register(up, down, file)
// note: safe wrap to capture any eventual panic and to allow
// the error message to print the migration filename
core.AppMigrations.Register(
func(txApp core.App) error {
return routine.SafeWrap(func() error {
if up == nil {
return nil
}
return up(txApp)
})()
},
func(txApp core.App) error {
return routine.SafeWrap(func() error {
if down == nil {
return nil
}
return down(txApp)
})()
},
file,
)
})
if p.config.OnInit != nil {
+4
View File
@@ -20,6 +20,10 @@ type vmsPool struct {
// newPool creates a new pool with pre-warmed vms generated from the specified factory.
func newPool(size int, factory func() *goja.Runtime) *vmsPool {
if size < 0 {
size = 0
}
pool := &vmsPool{
factory: factory,
items: make([]*poolItem, size),
+2 -14
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/pocketbase/pocketbase/core"
@@ -667,22 +666,11 @@ func init() {
}
func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error) {
raw, err := json.Marshal(v,
json.Deterministic(true),
return json.Marshal(v,
jsontext.WithIndentPrefix(prefix),
jsontext.WithIndent(indent),
json.Deterministic(true),
)
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 {
+1 -1
View File
@@ -210,7 +210,7 @@ func (pb *PocketBase) Execute() error {
event := new(core.TerminateEvent)
event.App = pb
return pb.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error {
return errors.Join(e.App.ResetBootstrapState(), execErr)
return errors.Join(e.App.ClearBootstrap(), execErr)
})
}
+9 -1
View File
@@ -37,9 +37,9 @@ func (t *TestApp) Cleanup() {
event.App = t
t.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error {
t.ClearBootstrap()
t.TestMailer.Reset()
t.ResetEventCalls()
t.ResetBootstrapState()
return e.Next()
})
@@ -144,6 +144,14 @@ func NewTestAppWithConfig(config core.BaseAppConfig) (*TestApp, error) {
Priority: -99999,
})
t.OnBootstrapClear().Bind(&hook.Handler[*core.BootstrapEvent]{
Func: func(e *core.BootstrapEvent) error {
t.registerEventCall("OnBootstrapClear")
return e.Next()
},
Priority: -99999,
})
t.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Func: func(e *core.ServeEvent) error {
t.registerEventCall("OnServe")
+2 -2
View File
@@ -131,9 +131,9 @@ func (p *OIDC) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
}
func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) {
idToken := token.Extra("id_token").(string)
idToken, _ := token.Extra("id_token").(string)
if idToken == "" {
return nil, errors.New("empty id_token")
return nil, errors.New("empty or invalid id_token")
}
claims := jwt.MapClaims{}
+7 -2
View File
@@ -8,8 +8,8 @@ import (
)
var (
indexRegex = regexp.MustCompile(`(?im)create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?`)
indexColumnRegex = regexp.MustCompile(`(?im)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$`)
indexRegex = regexp.MustCompile(`(?i)\s*create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*?)\)(?:\s+where\s+([\s\S]*?))?\s*$`)
indexColumnRegex = regexp.MustCompile(`(?i)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?\s*$`)
)
// IndexColumn represents a single parsed SQL index column.
@@ -187,6 +187,11 @@ func ParseIndex(createIndexExpr string) Index {
})
}
if len(rawColumns) != len(result.Columns) {
// unset to trigger validation error
result.Columns = []IndexColumn{}
}
// WHERE expression
// ---
result.Where = strings.TrimSpace(matches[6])
+14 -14
View File
@@ -3,7 +3,6 @@ package dbutils_test
import (
"bytes"
"encoding/json/v2"
"fmt"
"strings"
"testing"
@@ -12,27 +11,28 @@ import (
func TestParseIndex(t *testing.T) {
scenarios := []struct {
name string
index string
expected dbutils.Index
}{
// invalid
{
"invalid",
`invalid`,
dbutils.Index{},
},
// no names
{
"no names",
`create index on ()`,
dbutils.Index{},
},
// invalid index name
{
"invalid index name",
`create index a.b.c on ()`,
dbutils.Index{},
},
// simple (multiple spaces between the table and columns list)
{
`create index indexname on tablename (col1)`,
"simple (multiple spaces as prefix, suffix, between the table and columns list)",
` create index indexname on tablename (col1) `,
dbutils.Index{
IndexName: "indexname",
TableName: "tablename",
@@ -41,8 +41,8 @@ func TestParseIndex(t *testing.T) {
},
},
},
// simple (no space between the table and the columns list)
{
"simple (no space between the table and the columns list)",
`create index indexname on tablename(col1)`,
dbutils.Index{
IndexName: "indexname",
@@ -52,15 +52,15 @@ func TestParseIndex(t *testing.T) {
},
},
},
// all fields
{
"all fields",
`CREATE UNIQUE INDEX IF NOT EXISTS "schemaname".[indexname] on 'tablename' (
col0,
` + "`" + `col1` + "`" + `,
json_extract("col2", "$.a") asc,
json_extract("col2_multiline",` + "\n" + ` "$.a") asc,
"col3" collate NOCASE,
"col4" collate RTRIM desc
) where test = 1`,
) where cast(test1 as ` + "\n" + `int) = 1 and test2 != ''`,
dbutils.Index{
Unique: true,
Optional: true,
@@ -70,17 +70,17 @@ func TestParseIndex(t *testing.T) {
Columns: []dbutils.IndexColumn{
{Name: "col0"},
{Name: "col1"},
{Name: `json_extract("col2", "$.a")`, Sort: "ASC"},
{Name: `json_extract("col2_multiline",` + "\n" + ` "$.a")`, Sort: "ASC"},
{Name: `col3`, Collate: "NOCASE"},
{Name: `col4`, Collate: "RTRIM", Sort: "DESC"},
},
Where: "test = 1",
Where: "cast(test1 as \nint) = 1 and test2 != ''",
},
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("scenario_%d", i), func(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := dbutils.ParseIndex(s.index)
resultRaw, err := json.Marshal(result)
+7
View File
@@ -11,6 +11,13 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
// contextKey is an alias type to prevent collisions with other log context keys.
type contextKey int
// BlockKey is a context key usually used to indicate that the
// batched logs write should block until writes are completed.
var BlockKey contextKey
var _ slog.Handler = (*BatchHandler)(nil)
// BatchOptions are options for the BatchHandler.
+28 -6
View File
@@ -3,12 +3,16 @@ package picker
import (
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"strings"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/tokenizer"
)
var ErrInvalidModifierData = errors.New("failed to apply some of the field modifiers for the provided data")
// Pick converts data into a []any, map[string]any, etc. (using json marshal->unmarshal)
// containing only the fields from the parsed rawFields expression.
//
@@ -16,6 +20,10 @@ import (
// Nested fields should be listed with dot-notation.
// Fields value modifiers are also supported using the `:modifier(args)` format (see Modifiers).
//
// In case some data fails to apply against the registered modifiers
// a wrapped [ErrInvalidModifierData] is returned that you can inspect and
// decide to ignore or propagate further up the execution chain.
//
// Example:
//
// data := map[string]any{"a": 1, "b": 2, "c": map[string]any{"c1": 11, "c2": 22}}
@@ -33,15 +41,20 @@ func Pick(data any, rawFields string) (any, error) {
// Note that invalid UTF8 characters are mangled for compatibility
// with earlier versions and to prevent unnecessary causing an error.
//
// Duplicated keys are also enabled for just in case of old jsonv1 data
// (new db entries are usually validated against the jsonv2 semantics).
//
// @todo research other approaches to avoid the double serialization
// ---
encoded, err := json.Marshal(data, jsontext.AllowInvalidUTF8(true))
encoded, err := json.Marshal(data, jsontext.AllowDuplicateNames(true), jsontext.AllowInvalidUTF8(true))
if err != nil {
return nil, err
}
var decoded any
if err := json.Unmarshal(encoded, &decoded); err != nil {
err = json.Unmarshal(encoded, &decoded, jsontext.AllowDuplicateNames(true))
if err != nil {
return nil, err
}
// ---
@@ -55,10 +68,16 @@ func Pick(data any, rawFields string) (any, error) {
if isSearchResult {
if decodedMap, ok := decoded.(map[string]any); ok {
pickParsedFields(decodedMap["items"], parsedFields)
err = pickParsedFields(decodedMap["items"], parsedFields)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidModifierData, err)
}
}
} else {
pickParsedFields(decoded, parsedFields)
err = pickParsedFields(decoded, parsedFields)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidModifierData, err)
}
}
return decoded, nil
@@ -94,7 +113,10 @@ func parseFields(rawFields string) (map[string]Modifier, error) {
func pickParsedFields(data any, fields map[string]Modifier) error {
switch v := data.(type) {
case map[string]any:
pickMapFields(v, fields)
err := pickMapFields(v, fields)
if err != nil {
return err
}
case []map[string]any:
for _, item := range v {
if err := pickMapFields(item, fields); err != nil {
@@ -112,7 +134,7 @@ func pickParsedFields(data any, fields map[string]Modifier) error {
for _, item := range v {
if err := pickMapFields(item.(map[string]any), fields); err != nil {
return nil
return err
}
}
}
+35 -1
View File
@@ -1,13 +1,28 @@
package picker_test
import (
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"testing"
"github.com/pocketbase/pocketbase/tools/picker"
"github.com/pocketbase/pocketbase/tools/search"
)
type brokenModifier struct {
}
func (m *brokenModifier) Modify(val any) (any, error) {
return nil, errors.New("test_error")
}
func init() {
picker.Modifiers["broken"] = func(args ...string) (picker.Modifier, error) {
return &brokenModifier{}, nil
}
}
func TestPickFields(t *testing.T) {
scenarios := []struct {
name string
@@ -223,6 +238,13 @@ func TestPickFields(t *testing.T) {
false,
`{"id":"123","rel":{"id":"456","sub":{"id":"789"},"title":"rel_title"}}`,
},
{
"with modifer.Modify error",
map[string]any{"a": 1},
"*:broken",
true,
`{"a":1}`,
},
{
"invalid excerpt modifier",
map[string]any{"a": 1, "b": 2, "c": "test"},
@@ -248,6 +270,13 @@ func TestPickFields(t *testing.T) {
false,
`{"id":"12","rel":{"title":"rel..."},"title":"lo"}`,
},
{
"jsonv1 values",
map[string]any{"a": "test\xc3", "b": jsontext.Value(`{"a":1,"a":2}`)},
"a, b",
false,
`{"a":"test�","b":{"a":2}}`,
},
}
for _, s := range scenarios {
@@ -263,7 +292,12 @@ func TestPickFields(t *testing.T) {
return
}
serialized, err := json.Marshal(result, json.Deterministic(true))
serialized, err := json.Marshal(
result,
json.Deterministic(true),
jsontext.AllowInvalidUTF8(true),
jsontext.AllowDuplicateNames(true),
)
if err != nil {
t.Fatal(err)
}
+22 -14
View File
@@ -189,23 +189,31 @@ const jsonFieldsParam = "fields"
// Note that invalid UTF8 characters are mangled for compatibility
// with earlier versions and to prevent unnecessary causing a response error.
func (e *Event) JSON(status int, data any) error {
// try to pick only the requested fields (currently allowed only for "success" responses)
rawFields := e.Request.URL.Query().Get(jsonFieldsParam)
if rawFields != "" && status >= 200 && status <= 299 {
modified, err := picker.Pick(data, rawFields)
if err == nil {
data = modified
} else if !errors.Is(err, picker.ErrInvalidModifierData) {
// @todo ignore for now modifier data errors to avoid introducing
// breaking changes but once the router is merged with core consider
// at least logging for dev purposes
return err
}
}
e.setResponseHeaderIfEmpty(headerContentType, "application/json")
e.Response.WriteHeader(status)
rawFields := e.Request.URL.Query().Get(jsonFieldsParam)
// error response or no fields to pick
if rawFields == "" || status < 200 || status > 299 {
return json.MarshalWrite(e.Response, data, jsontext.AllowInvalidUTF8(true))
}
// pick only the requested fields
modified, err := picker.Pick(data, rawFields)
if err != nil {
return err
}
return json.MarshalWrite(e.Response, modified, jsontext.AllowInvalidUTF8(true))
return json.MarshalWrite(
e.Response,
data,
// for compliance with old data (and slightly better performance)
jsontext.AllowDuplicateNames(true),
// preserve the old jsonv1 behavior in case of invalid data
jsontext.AllowInvalidUTF8(true),
)
}
// XML writes an XML response.
+136 -4
View File
@@ -3,6 +3,7 @@ package router_test
import (
"bytes"
"crypto/tls"
"encoding/json/jsontext"
"encoding/json/v2"
"encoding/xml"
"errors"
@@ -19,7 +20,9 @@ import (
"testing"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/tools/picker"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/types"
)
type unwrapTester struct {
@@ -220,6 +223,8 @@ func TestEventRemoteIP(t *testing.T) {
}
func TestFindUploadedFiles(t *testing.T) {
t.Parallel()
scenarios := []struct {
filename string
expectedPattern string
@@ -273,6 +278,8 @@ func TestFindUploadedFiles(t *testing.T) {
}
func TestFindUploadedFilesMissing(t *testing.T) {
t.Parallel()
body := new(bytes.Buffer)
mp := multipart.NewWriter(body)
mp.Close()
@@ -293,6 +300,8 @@ func TestFindUploadedFilesMissing(t *testing.T) {
}
func TestEventSetGet(t *testing.T) {
t.Parallel()
event := router.Event{}
// get before any set (ensures that doesn't panic)
@@ -324,6 +333,8 @@ func TestEventSetGet(t *testing.T) {
}
func TestEventSetAllGetAll(t *testing.T) {
t.Parallel()
data := map[string]any{
"a": 123,
"b": 456,
@@ -351,6 +362,8 @@ func TestEventSetAllGetAll(t *testing.T) {
}
func TestEventString(t *testing.T) {
t.Parallel()
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
@@ -380,6 +393,8 @@ func TestEventString(t *testing.T) {
}
func TestEventHTML(t *testing.T) {
t.Parallel()
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
@@ -409,14 +424,17 @@ func TestEventHTML(t *testing.T) {
}
func TestEventJSON(t *testing.T) {
t.Parallel()
body := map[string]any{
"a": 123,
"b": true,
"c": "test",
"d": "\xc3", /* invalid utf8 char to test mangling */
"d": "\xc3", // invalid utf8 char to test mangling
"e": types.JSONRaw(`{"a":1,"a":2}`), // duplicated keys to ensure compliance with old data
}
expectedPickedBody := `{"a":123,"c":"test"}`
expectedFullBody := `{"a":123,"b":true,"c":"test","d":"�"}`
expectedFullBody := `{"a":123,"b":true,"c":"test","d":"�","e":{"a":2}}`
scenarios := []testResponseWriteScenario[any]{
{
@@ -456,7 +474,93 @@ func TestEventJSON(t *testing.T) {
}
}
func TestEventJSONPickError(t *testing.T) {
t.Parallel()
req, err := http.NewRequest(http.MethodGet, "/?fields=a:excerpt(-1)", nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
event := &router.Event{
Request: req,
Response: &router.ResponseWriter{ResponseWriter: rec},
}
err = event.JSON(200, map[string]any{"a": "test"})
if err == nil {
t.Fatal("Expected JSON to return modifier args err, got nil")
}
// ensure that no explicit status code was written yet by attempting to write one
// (should do nothing if it was already written)
rec.WriteHeader(567)
result := rec.Result()
defer result.Body.Close()
if result.StatusCode != 567 {
t.Fatalf("Expected custom status code, got %d", result.StatusCode)
}
}
type brokenModifier struct {
}
func (m *brokenModifier) Modify(val any) (any, error) {
return nil, errors.New("test_error")
}
func TestEventJSONIgnoredError(t *testing.T) {
t.Parallel()
picker.Modifiers["broken_ignore"] = func(args ...string) (picker.Modifier, error) {
return &brokenModifier{}, nil
}
req, err := http.NewRequest(http.MethodGet, "/?fields=a:broken_ignore", nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
event := &router.Event{
Request: req,
Response: &router.ResponseWriter{ResponseWriter: rec},
}
err = event.JSON(200, map[string]any{"a": "test"})
if err != nil {
t.Fatalf("Expected nil JSON result, got error %v", err)
}
// (should do nothing if it was already written)
rec.WriteHeader(567)
result := rec.Result()
defer result.Body.Close()
rawBody, err := io.ReadAll(result.Body)
if err != nil {
t.Fatal(err)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("Expected %d status code, got %d", http.StatusOK, result.StatusCode)
}
expected := `{"a":"test"}`
if string(rawBody) != expected {
t.Fatalf("Expected json\n%s\ngot\n%s", expected, rawBody)
}
}
func TestEventXML(t *testing.T) {
t.Parallel()
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
@@ -486,6 +590,8 @@ func TestEventXML(t *testing.T) {
}
func TestEventStream(t *testing.T) {
t.Parallel()
scenarios := []testResponseWriteScenario[string]{
{
name: "stream",
@@ -506,6 +612,8 @@ func TestEventStream(t *testing.T) {
}
func TestEventBlob(t *testing.T) {
t.Parallel()
scenarios := []testResponseWriteScenario[[]byte]{
{
name: "blob",
@@ -526,6 +634,8 @@ func TestEventBlob(t *testing.T) {
}
func TestEventNoContent(t *testing.T) {
t.Parallel()
s := testResponseWriteScenario[any]{
name: "no content",
status: 234,
@@ -542,6 +652,8 @@ func TestEventNoContent(t *testing.T) {
}
func TestEventFlush(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
event := &router.Event{
@@ -556,6 +668,8 @@ func TestEventFlush(t *testing.T) {
}
func TestEventRedirect(t *testing.T) {
t.Parallel()
scenarios := []testResponseWriteScenario[any]{
{
name: "non-30x status",
@@ -580,6 +694,8 @@ func TestEventRedirect(t *testing.T) {
}
func TestEventFileFS(t *testing.T) {
t.Parallel()
// stub test files
// ---
dir, err := os.MkdirTemp("", "EventFileFS")
@@ -676,6 +792,8 @@ func TestEventFileFS(t *testing.T) {
}
func TestEventError(t *testing.T) {
t.Parallel()
err := new(router.Event).Error(123, "message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err, json.Deterministic(true))
@@ -687,6 +805,8 @@ func TestEventError(t *testing.T) {
}
func TestEventBadRequestError(t *testing.T) {
t.Parallel()
err := new(router.Event).BadRequestError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err, json.Deterministic(true))
@@ -698,6 +818,8 @@ func TestEventBadRequestError(t *testing.T) {
}
func TestEventNotFoundError(t *testing.T) {
t.Parallel()
err := new(router.Event).NotFoundError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err, json.Deterministic(true))
@@ -709,6 +831,8 @@ func TestEventNotFoundError(t *testing.T) {
}
func TestEventForbiddenError(t *testing.T) {
t.Parallel()
err := new(router.Event).ForbiddenError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err, json.Deterministic(true))
@@ -720,6 +844,8 @@ func TestEventForbiddenError(t *testing.T) {
}
func TestEventUnauthorizedError(t *testing.T) {
t.Parallel()
err := new(router.Event).UnauthorizedError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err, json.Deterministic(true))
@@ -731,6 +857,8 @@ func TestEventUnauthorizedError(t *testing.T) {
}
func TestEventTooManyRequestsError(t *testing.T) {
t.Parallel()
err := new(router.Event).TooManyRequestsError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err, json.Deterministic(true))
@@ -742,6 +870,8 @@ func TestEventTooManyRequestsError(t *testing.T) {
}
func TestEventInternalServerError(t *testing.T) {
t.Parallel()
err := new(router.Event).InternalServerError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err, json.Deterministic(true))
@@ -753,6 +883,8 @@ func TestEventInternalServerError(t *testing.T) {
}
func TestEventBindBody(t *testing.T) {
t.Parallel()
type testDstStruct struct {
A int `json:"a" xml:"a" form:"a"`
B int `json:"b" xml:"b" form:"b"`
@@ -945,9 +1077,9 @@ func testEventResponseWrite[T any](
// try to deserialize into a map and then serialize again in a
// deterministic manner in case it is json
var jsonBody any
err = json.Unmarshal(rawBody, &jsonBody)
err = json.Unmarshal(rawBody, &jsonBody, jsontext.AllowDuplicateNames(true))
if err == nil {
normalized, err := json.Marshal(jsonBody, json.Deterministic(true))
normalized, err := json.Marshal(jsonBody, json.Deterministic(true), jsontext.AllowDuplicateNames(true))
if err != nil {
t.Fatalf("Failed to deterministicly serialize json body\n%s\ngot\n%v", jsonBody, err)
}
+6 -4
View File
@@ -22,8 +22,8 @@ func FireAndForget(f func(), wg ...*sync.WaitGroup) {
}
defer func() {
if err := recover(); err != nil {
log.Println("[FireAndForget] RECOVERED FROM PANIC:", err)
if rec := recover(); rec != nil {
log.Println("[FireAndForget] RECOVERED FROM PANIC:", rec)
stack := make([]byte, 2<<10) // 2 KB
length := runtime.Stack(stack, false)
@@ -40,8 +40,10 @@ func FireAndForget(f func(), wg ...*sync.WaitGroup) {
func SafeWrap(f func() error) func() error {
return func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("[SafeWrap] recovered from panic: %v", r)
if rec := recover(); rec != nil {
stack := make([]byte, 2<<10) // 2 KB
length := runtime.Stack(stack, false)
err = fmt.Errorf("[SafeWrap] recovered from panic: %v\n%s", rec, string(stack[:length]))
}
}()
+1 -1
View File
@@ -144,7 +144,7 @@ func TestFilterDataBuildExpr(t *testing.T) {
"geoDistance function",
"geoDistance(1,2,3,4) < 567",
false,
"(6371 * acos(cos(radians({:TEST})) * cos(radians({:TEST})) * cos(radians({:TEST}) - radians({:TEST})) + sin(radians({:TEST})) * sin(radians({:TEST})))) < {:TEST}",
"(6371 * acos(min(1, max(-1, cos(radians({:TEST})) * cos(radians({:TEST})) * cos(radians({:TEST}) - radians({:TEST})) + sin(radians({:TEST})) * sin(radians({:TEST})))))) < {:TEST}",
},
}
+5 -2
View File
@@ -49,11 +49,14 @@ var TokenFunctions = map[string]func(
return &ResolverResult{
NullFallback: NullFallbackDisabled,
Identifier: `(6371 * acos(` +
// the clamping is to prevent floating point rounding errors for values like
// "1.0002" that could occur for example when comparing identical points
// (see the NULL note for arccosine in https://sqlite.org/lang_mathfunc.html#overview)
Identifier: `(6371 * acos(min(1, max(-1, ` +
`cos(radians(` + latA + `)) * cos(radians(` + latB + `)) * ` +
`cos(radians(` + lonB + `) - radians(` + lonA + `)) + ` +
`sin(radians(` + latA + `)) * sin(radians(` + latB + `))` +
`))`,
`))))`,
Params: mergeParams(resolvedArgs[0].Params, resolvedArgs[1].Params, resolvedArgs[2].Params, resolvedArgs[3].Params),
}, nil
},
+55 -25
View File
@@ -117,7 +117,7 @@ func TestTokenFunctionsGeoDistance(t *testing.T) {
baseTokenResolver,
&ResolverResult{
NullFallback: NullFallbackDisabled,
Identifier: `(6371 * acos(cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))`,
Identifier: `(6371 * acos(min(1, max(-1, cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))))`,
Params: map[string]any{
"lonA": 1,
"latA": 2,
@@ -138,7 +138,7 @@ func TestTokenFunctionsGeoDistance(t *testing.T) {
baseTokenResolver,
&ResolverResult{
NullFallback: NullFallbackDisabled,
Identifier: `(6371 * acos(cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))`,
Identifier: `(6371 * acos(min(1, max(-1, cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))))`,
Params: map[string]any{
"lonA": "null",
"latA": 2,
@@ -178,34 +178,64 @@ func TestTokenFunctionsGeoDistanceExec(t *testing.T) {
t.Error("Expected geoDistance token function to be registered.")
}
result, err := fn(
func(t fexpr.Token) (*ResolverResult, error) {
placeholder := "t" + security.PseudorandomString(5)
return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil
scenarios := []struct {
name string
coords []string // [lonA, latA, lonB, latB]
expected string
}{
{
"distinct points",
[]string{
"23.23033854945808",
"42.713146090563384",
"23.44920680886216",
"42.7078484153991",
},
"17.89",
},
{
"identical points (rounding check with 8 lat)",
[]string{"0", "8", "0", "8"}, // 8 is an example latitude where rounding happens to result in > 1.0
"0.00",
},
{
"identical points (rounding check with 45 lat)",
[]string{"0", "45", "0", "45"}, // 45 is an example latitude where rounding happens to result in > 1.0
"0.00",
},
fexpr.Token{Literal: "23.23033854945808", Type: fexpr.TokenNumber},
fexpr.Token{Literal: "42.713146090563384", Type: fexpr.TokenNumber},
fexpr.Token{Literal: "23.44920680886216", Type: fexpr.TokenNumber},
fexpr.Token{Literal: "42.7078484153991", Type: fexpr.TokenNumber},
)
if err != nil {
t.Fatal(err)
}
column := []float64{}
err = testDB.NewQuery("select " + result.Identifier).Bind(result.Params).Column(&column)
if err != nil {
t.Fatal(err)
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, err := fn(
func(t fexpr.Token) (*ResolverResult, error) {
placeholder := "t" + security.PseudorandomString(5)
return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil
},
fexpr.Token{Literal: s.coords[0], Type: fexpr.TokenNumber},
fexpr.Token{Literal: s.coords[1], Type: fexpr.TokenNumber},
fexpr.Token{Literal: s.coords[2], Type: fexpr.TokenNumber},
fexpr.Token{Literal: s.coords[3], Type: fexpr.TokenNumber},
)
if err != nil {
t.Fatal(err)
}
if len(column) != 1 {
t.Fatalf("Expected exactly 1 column value as result, got %v", column)
}
column := []float64{}
err = testDB.NewQuery("select " + result.Identifier).Bind(result.Params).Column(&column)
if err != nil {
t.Fatal(err)
}
expected := "17.89"
distance := fmt.Sprintf("%.2f", column[0])
if distance != expected {
t.Fatalf("Expected distance value %s, got %s", expected, distance)
if len(column) != 1 {
t.Fatalf("Expected exactly 1 column value as result, got %v", column)
}
distance := fmt.Sprintf("%.2f", column[0])
if distance != s.expected {
t.Fatalf("Expected distance value %s, got %s", s.expected, distance)
}
})
}
}
+2
View File
@@ -7,6 +7,8 @@ import (
"errors"
)
// @todo consider deprecating in favour of jsontext.Value
// JSONRaw defines a json value type that is safe for db read/write.
type JSONRaw []byte
+1 -1
View File
@@ -12,4 +12,4 @@ PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.40.2"
PB_VERSION = "v0.40.5-dev"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{n as e,t as n}from"./index-DoeyJa3f.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.newEmail||!a.collectionId){app.toasts.error(`Invalid or expired email change token.`),window.location.hash=`#/`;return}app.store.title=`Confirm email change`;let o=store({password:``,isSubmitting:!1,isSuccess:!1,showPassword:!1});async function s(){if(o.isSubmitting)return;o.isSubmitting=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmEmailChange(i,o.password),o.isSuccess=!0}catch(e){app.checkApiError(e),o.isSuccess=!1}o.isSubmitting=!1}return t.div({pbEvent:`pageConfirmEmailChange`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isSuccess?t.div({pbEvent:`confirmEmailChangeAlert`,className:`alert success txt-center`},t.p(null,`The email was successfully changed.`),t.p(null,`You can go back and sign in with your new email address.`)):t.form({pbEvent:`confirmEmailChangeForm`,className:`grid confirm-email-change-form`,onsubmit:e=>{e.preventDefault(),s()}},t.div({className:`col-12`},t.div({className:`content txt-center m-b-sm`},`Type your password to confirm changing your email address to `,t.strong(null,a.newEmail),`:`),t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`password_confirm`},`Password`),t.input({id:`password_confirm`,name:`password`,required:!0,autofocus:!0,type:()=>o.showPassword?`text`:`password`,value:()=>o.password,oninput:e=>o.password=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showPassword?`Hide password`:`Show password`),onclick:()=>o.showPassword=!o.showPassword},t.i({className:()=>o.showPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg block ${o.isSubmitting?`loading`:``}`,disabled:()=>o.isSubmitting},t.span({className:`txt`},`Confirm new email`)))))}export{r as pageConfirmEmailChange};
import{n as e,t as n}from"./index-8fnnFCOJ.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.newEmail||!a.collectionId){app.toasts.error(`Invalid or expired email change token.`),window.location.hash=`#/`;return}app.store.title=`Confirm email change`;let o=store({password:``,isSubmitting:!1,isSuccess:!1,showPassword:!1});async function s(){if(o.isSubmitting)return;o.isSubmitting=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmEmailChange(i,o.password),o.isSuccess=!0}catch(e){app.checkApiError(e),o.isSuccess=!1}o.isSubmitting=!1}return t.div({pbEvent:`pageConfirmEmailChange`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isSuccess?t.div({pbEvent:`confirmEmailChangeAlert`,className:`alert success txt-center`},t.p(null,`The email was successfully changed.`),t.p(null,`You can go back and sign in with your new email address.`)):t.form({pbEvent:`confirmEmailChangeForm`,className:`grid confirm-email-change-form`,onsubmit:e=>{e.preventDefault(),s()}},t.div({className:`col-12`},t.div({className:`content txt-center m-b-sm`},`Type your password to confirm changing your email address to `,t.strong(null,a.newEmail),`:`),t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`password_confirm`},`Password`),t.input({id:`password_confirm`,name:`password`,required:!0,autofocus:!0,type:()=>o.showPassword?`text`:`password`,value:()=>o.password,oninput:e=>o.password=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showPassword?`Hide password`:`Show password`),onclick:()=>o.showPassword=!o.showPassword},t.i({className:()=>o.showPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg block ${o.isSubmitting?`loading`:``}`,disabled:()=>o.isSubmitting},t.span({className:`txt`},`Confirm new email`)))))}export{r as pageConfirmEmailChange};
@@ -1 +1 @@
import{n as e,t as n}from"./index-DoeyJa3f.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.email||!a.collectionId){app.toasts.error(`Invalid or expired password reset token.`),window.location.hash=`#/`;return}app.store.title=`Confirm password reset`;let o=store({newPassword:``,newPasswordConfirm:``,showNewPassword:!1,showNewPasswordConfirm:!1,isSubmitting:!1,isSuccess:!1});async function s(){if(o.isSubmitting)return;o.isSubmitting=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmPasswordReset(i,o.newPassword,o.newPasswordConfirm),o.isSuccess=!0}catch(e){app.checkApiError(e)}o.isSubmitting=!1}return t.div({pbEvent:`pageConfirmPasswordReset`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isSuccess?t.div({pbEvent:`confirmPasswordResetAlert`,className:`alert success txt-center`},t.p(null,`The password was successfully changed.`),t.p(null,`You can go back to sign in with your new password.`)):t.form({pbEvent:`confirmPasswordResetForm`,className:`grid confirm-password-reset-form`,onsubmit:e=>{e.preventDefault(),s()}},t.div({className:`col-12`},t.div({className:`content txt-center m-b-sm`},`Type your new password for `,t.strong(null,a.email),`:`),t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`newPassword`},`New password`),t.input({id:`newPassword`,name:`password`,required:!0,autofocus:!0,autocomplete:`new-password`,type:()=>o.showNewPassword?`text`:`password`,value:()=>o.newPassword,oninput:e=>o.newPassword=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showNewPassword?`Hide password`:`Show password`),onclick:()=>o.showNewPassword=!o.showNewPassword},t.i({className:()=>o.showNewPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`newPasswordConfirm`},`New password confirm`),t.input({id:`newPasswordConfirm`,name:`passwordConfirm`,required:!0,autocomplete:`new-password`,type:()=>o.showNewPasswordConfirm?`text`:`password`,value:()=>o.newPasswordConfirm,oninput:e=>o.newPasswordConfirm=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showNewPasswordConfirm?`Hide password`:`Show password`),onclick:()=>o.showNewPasswordConfirm=!o.showNewPasswordConfirm},t.i({className:()=>o.showNewPasswordConfirm?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg block ${o.isSubmitting?`loading`:``}`,disabled:()=>o.isSubmitting},t.span({className:`txt`},`Set new password`)))))}export{r as pageConfirmPasswordReset};
import{n as e,t as n}from"./index-8fnnFCOJ.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.email||!a.collectionId){app.toasts.error(`Invalid or expired password reset token.`),window.location.hash=`#/`;return}app.store.title=`Confirm password reset`;let o=store({newPassword:``,newPasswordConfirm:``,showNewPassword:!1,showNewPasswordConfirm:!1,isSubmitting:!1,isSuccess:!1});async function s(){if(o.isSubmitting)return;o.isSubmitting=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmPasswordReset(i,o.newPassword,o.newPasswordConfirm),o.isSuccess=!0}catch(e){app.checkApiError(e)}o.isSubmitting=!1}return t.div({pbEvent:`pageConfirmPasswordReset`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isSuccess?t.div({pbEvent:`confirmPasswordResetAlert`,className:`alert success txt-center`},t.p(null,`The password was successfully changed.`),t.p(null,`You can go back to sign in with your new password.`)):t.form({pbEvent:`confirmPasswordResetForm`,className:`grid confirm-password-reset-form`,onsubmit:e=>{e.preventDefault(),s()}},t.div({className:`col-12`},t.div({className:`content txt-center m-b-sm`},`Type your new password for `,t.strong(null,a.email),`:`),t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`newPassword`},`New password`),t.input({id:`newPassword`,name:`password`,required:!0,autofocus:!0,autocomplete:`new-password`,type:()=>o.showNewPassword?`text`:`password`,value:()=>o.newPassword,oninput:e=>o.newPassword=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showNewPassword?`Hide password`:`Show password`),onclick:()=>o.showNewPassword=!o.showNewPassword},t.i({className:()=>o.showNewPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`newPasswordConfirm`},`New password confirm`),t.input({id:`newPasswordConfirm`,name:`passwordConfirm`,required:!0,autocomplete:`new-password`,type:()=>o.showNewPasswordConfirm?`text`:`password`,value:()=>o.newPasswordConfirm,oninput:e=>o.newPasswordConfirm=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>o.showNewPasswordConfirm?`Hide password`:`Show password`),onclick:()=>o.showNewPasswordConfirm=!o.showNewPasswordConfirm},t.i({className:()=>o.showNewPasswordConfirm?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg block ${o.isSubmitting?`loading`:``}`,disabled:()=>o.isSubmitting},t.span({className:`txt`},`Set new password`)))))}export{r as pageConfirmPasswordReset};
@@ -1 +1 @@
import{n as e,t as n}from"./index-DoeyJa3f.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.email||!a.collectionId){app.toasts.error(`Invalid or expired verification token.`),window.location.hash=`#/`;return}app.store.title=`Confirm verification`;let o=store({isConfirming:!1,isConfirmSuccess:!1,isResending:!1,isResendSuccess:!1});s();async function s(){if(o.isConfirming)return;o.isConfirming=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmVerification(i),o.isConfirmSuccess=!0}catch{o.isConfirmSuccess=!1}o.isConfirming=!1}async function c(){if(o.isResending)return;o.isResending=!0;let e=new n(`../`);try{await e.collection(a.collectionId).requestVerification(a.email),o.isResendSuccess=!0}catch(e){app.checkApiError(e),o.isResendSuccess=!1}o.isResending=!1}return t.div({pbEvent:`pageConfirmVerification`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isConfirming?t.div({className:`block txt-center`},t.span({className:`loader`},`Please wait...`)):o.isConfirmSuccess?t.div({pbEvent:`confirmVerificationSuccessAlert`,className:`alert success txt-center`},t.p(null,`Successfully verified `,t.strong(null,a.email),`.`)):o.isResendSuccess?t.div({pbEvent:`confirmVerificationResendAlert`,className:`alert success txt-center`},t.p(null,`Please check your email for the new verification link.`)):[t.div({pbEvent:`confirmVerificationErrorAlert`,className:`alert danger txt-center m-b-base`},t.p(null,`Invalid or expired verification token.`)),t.button({type:`button`,className:()=>`btn transparent lg block ${o.isResending?`loading`:``}`,disabled:()=>o.isResending,onclick:()=>c()},t.span({className:`txt`},`Resend`))])}export{r as pageConfirmVerification};
import{n as e,t as n}from"./index-8fnnFCOJ.js";function r(r){let i=r.params?.token||``,a=e(i);if(!a.email||!a.collectionId){app.toasts.error(`Invalid or expired verification token.`),window.location.hash=`#/`;return}app.store.title=`Confirm verification`;let o=store({isConfirming:!1,isConfirmSuccess:!1,isResending:!1,isResendSuccess:!1});s();async function s(){if(o.isConfirming)return;o.isConfirming=!0;let e=new n(app.pb.baseURL);try{await e.collection(a.collectionId).confirmVerification(i),o.isConfirmSuccess=!0}catch{o.isConfirmSuccess=!1}o.isConfirming=!1}async function c(){if(o.isResending)return;o.isResending=!0;let e=new n(`../`);try{await e.collection(a.collectionId).requestVerification(a.email),o.isResendSuccess=!0}catch(e){app.checkApiError(e),o.isResendSuccess=!1}o.isResending=!1}return t.div({pbEvent:`pageConfirmVerification`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),()=>o.isConfirming?t.div({className:`block txt-center`},t.span({className:`loader`},`Please wait...`)):o.isConfirmSuccess?t.div({pbEvent:`confirmVerificationSuccessAlert`,className:`alert success txt-center`},t.p(null,`Successfully verified `,t.strong(null,a.email),`.`)):o.isResendSuccess?t.div({pbEvent:`confirmVerificationResendAlert`,className:`alert success txt-center`},t.p(null,`Please check your email for the new verification link.`)):[t.div({pbEvent:`confirmVerificationErrorAlert`,className:`alert danger txt-center m-b-base`},t.p(null,`Invalid or expired verification token.`)),t.button({type:`button`,className:()=>`btn transparent lg block ${o.isResending?`loading`:``}`,disabled:()=>o.isResending,onclick:()=>c()},t.span({className:`txt`},`Resend`))])}export{r as pageConfirmVerification};
@@ -1 +1 @@
import{n as e,r as n}from"./index-DoeyJa3f.js";function r(r){let i=r.params?.token||``;if(e(i).type!=`auth`||n(i)){app.toasts.error(`The installer token is invalid or has expired.`),window.location.hash=`#/`;return}app.store.title=`Setup your PocketBase instance`;let a=store({email:``,password:``,passwordConfirm:``,showPassword:!1,showPasswordConfirm:!1,isSubmitting:!1,isUploading:!1,get isBusy(){return a.isSubmitting||a.isUploading}});async function o(){if(!a.isBusy){a.isSubmitting=!0;try{await app.pb.collection(`_superusers`).create({email:a.email,password:a.password,passwordConfirm:a.passwordConfirm},{headers:{Authorization:i}}),await app.pb.collection(`_superusers`).authWithPassword(a.email,a.password),window.location.hash=`#/`}catch(e){app.checkApiError(e)}a.isSubmitting=!1}}let s=`backupFileInput`;function c(){let e=document.getElementById(s);e&&(e.value=``)}function l(e){e&&app.modals.confirm(t.h6(null,`Note that we don't perform validations for the uploaded backup files. Proceed with caution and only if you trust the file source.\n\nDo you really want to upload and initialize "${e.name}"?`),()=>{u(e)},()=>{c()})}async function u(e){if(!(!e||a.isBusy)){a.isUploading=!0;try{await app.pb.backups.upload({file:e},{headers:{Authorization:i}}),await app.pb.backups.restore(e.name,{headers:{Authorization:i}}),app.toasts.info(`Please wait while extracting the uploaded archive!`),await new Promise(e=>setTimeout(e,3e3)),window.location.href=`#/`}catch(e){app.checkApiError(e)}c(),a.isUploading=!1}}return t.div({pbEvent:`pageInstaller`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),t.form({pbEvent:`installerForm`,className:`grid installer-form`,onsubmit:e=>{e.preventDefault(),o(a)}},t.div({className:`col-12 txt-center`},`Create your first superuser account in order to continue:`),t.div({className:`col-12`},t.div({className:`field`},t.label({htmlFor:`superuser_email`},`Email`),t.input({id:`superuser_email`,name:`email`,type:`email`,required:!0,autofocus:!0,autocomplete:`off`,disabled:()=>a.isBusy,value:()=>a.email,oninput:e=>a.email=e.target.value}))),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`superuser_password`},`Password`),t.input({id:`superuser_password`,name:`password`,min:10,required:!0,disabled:()=>a.isBusy,type:()=>a.showPassword?`text`:`password`,value:()=>a.password,oninput:e=>a.password=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>a.showPassword?`Hide password`:`Show password`),onclick:()=>a.showPassword=!a.showPassword},t.i({className:()=>a.showPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0})))),t.div({className:`field-help`},`Recommended at least 10 characters.`)),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`superuser_password_confirm`},`Password confirm`),t.input({id:`superuser_password_confirm`,name:`passwordConfirm`,required:!0,disabled:()=>a.isBusy,type:()=>a.showPasswordConfirm?`text`:`password`,value:()=>a.passwordConfirm,oninput:e=>a.passwordConfirm=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>a.showPasswordConfirm?`Hide password`:`Show password`),onclick:()=>a.showPasswordConfirm=!a.showPasswordConfirm},t.i({className:()=>a.showPasswordConfirm?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg next block ${a.isSubmitting?`loading`:``}`,disabled:()=>a.isBusy},t.span({className:`txt`},`Create superuser and login`),t.i({className:`ri-arrow-right-line`,ariaHidden:!0})))),t.hr(),t.label({htmlFor:s,className:()=>`btn secondary transparent lg block ${a.isBusy?`disabled`:``} ${a.isUploading?`loading`:``}`},t.i({className:`ri-upload-cloud-line`,ariaHidden:!0}),t.span({className:`txt`},`Or initialize from backup`)),t.input({id:s,type:`file`,className:`hidden`,accept:`.zip`,onchange:e=>{l(e.target?.files?.[0])}}))}export{r as pageInstaller};
import{n as e,r as n}from"./index-8fnnFCOJ.js";function r(r){let i=r.params?.token||``;if(e(i).type!=`auth`||n(i)){app.toasts.error(`The installer token is invalid or has expired.`),window.location.hash=`#/`;return}app.store.title=`Setup your PocketBase instance`;let a=store({email:``,password:``,passwordConfirm:``,showPassword:!1,showPasswordConfirm:!1,isSubmitting:!1,isUploading:!1,get isBusy(){return a.isSubmitting||a.isUploading}});async function o(){if(!a.isBusy){a.isSubmitting=!0;try{await app.pb.collection(`_superusers`).create({email:a.email,password:a.password,passwordConfirm:a.passwordConfirm},{headers:{Authorization:i}}),await app.pb.collection(`_superusers`).authWithPassword(a.email,a.password),window.location.hash=`#/`}catch(e){app.checkApiError(e)}a.isSubmitting=!1}}let s=`backupFileInput`;function c(){let e=document.getElementById(s);e&&(e.value=``)}function l(e){e&&app.modals.confirm(t.h6(null,`Note that we don't perform validations for the uploaded backup files. Proceed with caution and only if you trust the file source.\n\nDo you really want to upload and initialize "${e.name}"?`),()=>{u(e)},()=>{c()})}async function u(e){if(!(!e||a.isBusy)){a.isUploading=!0;try{await app.pb.backups.upload({file:e},{headers:{Authorization:i}}),await app.pb.backups.restore(e.name,{headers:{Authorization:i}}),app.toasts.info(`Please wait while extracting the uploaded archive!`),await new Promise(e=>setTimeout(e,3e3)),window.location.href=`#/`}catch(e){app.checkApiError(e)}c(),a.isUploading=!1}}return t.div({pbEvent:`pageInstaller`,className:`wrapper sm m-auto p-b-base`},t.header({className:`txt-center m-b-base`},t.img({className:`main-logo`,src:()=>app.store.mainLogo,ariaHidden:!0,alt:`App logo`}),t.h5({className:`m-t-10`},()=>app.store.title)),t.form({pbEvent:`installerForm`,className:`grid installer-form`,onsubmit:e=>{e.preventDefault(),o(a)}},t.div({className:`col-12 txt-center`},`Create your first superuser account in order to continue:`),t.div({className:`col-12`},t.div({className:`field`},t.label({htmlFor:`superuser_email`},`Email`),t.input({id:`superuser_email`,name:`email`,type:`email`,required:!0,autofocus:!0,autocomplete:`off`,disabled:()=>a.isBusy,value:()=>a.email,oninput:e=>a.email=e.target.value}))),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`superuser_password`},`Password`),t.input({id:`superuser_password`,name:`password`,min:10,required:!0,disabled:()=>a.isBusy,type:()=>a.showPassword?`text`:`password`,value:()=>a.password,oninput:e=>a.password=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>a.showPassword?`Hide password`:`Show password`),onclick:()=>a.showPassword=!a.showPassword},t.i({className:()=>a.showPassword?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0})))),t.div({className:`field-help`},`Recommended at least 10 characters.`)),t.div({className:`col-12`},t.div({className:`fields`},t.div({className:`field`},t.label({htmlFor:`superuser_password_confirm`},`Password confirm`),t.input({id:`superuser_password_confirm`,name:`passwordConfirm`,required:!0,disabled:()=>a.isBusy,type:()=>a.showPasswordConfirm?`text`:`password`,value:()=>a.passwordConfirm,oninput:e=>a.passwordConfirm=e.target.value})),t.div({className:`field addon`},t.button({type:`button`,tabIndex:-1,className:`btn sm transparent secondary circle tooltip-right`,ariaLabel:app.attrs.tooltip(()=>a.showPasswordConfirm?`Hide password`:`Show password`),onclick:()=>a.showPasswordConfirm=!a.showPasswordConfirm},t.i({className:()=>a.showPasswordConfirm?`ri-eye-off-line`:`ri-eye-line`,ariaHidden:!0}))))),t.div({className:`col-12`},t.button({className:()=>`btn lg next block ${a.isSubmitting?`loading`:``}`,disabled:()=>a.isBusy},t.span({className:`txt`},`Create superuser and login`),t.i({className:`ri-arrow-right-line`,ariaHidden:!0})))),t.hr(),t.label({htmlFor:s,className:()=>`btn secondary transparent lg block ${a.isBusy?`disabled`:``} ${a.isUploading?`loading`:``}`},t.i({className:`ri-upload-cloud-line`,ariaHidden:!0}),t.span({className:`txt`},`Or initialize from backup`)),t.input({id:s,type:`file`,className:`hidden`,accept:`.zip`,onchange:e=>{l(e.target?.files?.[0])}}))}export{r as pageInstaller};
+2 -2
View File
@@ -13,8 +13,8 @@
<!-- prism -->
<script src="./libs/prism/prism.js" data-manual></script>
<script type="module" crossorigin src="./assets/index-DoeyJa3f.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CZinEv5W.css">
<script type="module" crossorigin src="./assets/index-8fnnFCOJ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-kt0ysE3i.css">
</head>
<body>
</body>
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "PocketBase Superuser UI",
"dependencies": {
"leaflet": "^1.9.4",
"pocketbase": "^0.28.0"
"pocketbase": "^0.28.1"
},
"devDependencies": {
"dprint": "^0.52.0",
@@ -865,9 +865,9 @@
}
},
"node_modules/pocketbase": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.28.0.tgz",
"integrity": "sha512-aCrab5XPPQ40iKIKicQLdUvrAvJQCHZ3cwZD5i22A8PVM/EcemUta7WFw0x4G6WQEOBMa/8kioqCR62ozk4KvQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.28.1.tgz",
"integrity": "sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA==",
"license": "MIT"
},
"node_modules/postcss": {
+1 -1
View File
@@ -8,7 +8,7 @@
},
"dependencies": {
"leaflet": "^1.9.4",
"pocketbase": "^0.28.0"
"pocketbase": "^0.28.1"
},
"devDependencies": {
"dprint": "^0.52.0",
+1 -1
View File
@@ -155,7 +155,7 @@
--secondaryAlt1Color: var(--surfaceAlt3Color);
--secondaryAlt2Color: var(--surfaceAlt4Color);
--primaryColor: #121212;
--primaryColor: var(--surfaceAlt4Color);
--primaryTxtColor: var(--surfaceTxtColor);
--primaryAlt1Color: color-mix(in srgb, var(--primaryColor), black 12%);
--primaryAlt2Color: color-mix(in srgb, var(--primaryColor), black 25%);
+9 -1
View File
@@ -153,6 +153,7 @@ window.app.components.recordsList = function(propsArg = {}) {
});
if (result.page == 1) {
props.suggestReset = false;
clearList();
}
@@ -318,7 +319,14 @@ window.app.components.recordsList = function(propsArg = {}) {
clearTimeout(deleteRefreshTimeoutId);
deleteRefreshTimeoutId = setTimeout(() => {
if (!data.records?.length) {
if (
// no more client-side records
!data.records?.length
// has cascade self-reference field
|| !!props.collection?.fields?.find((f) => {
return f.type == "relation" && f.cascadeDelete && f.collectionId == props.collection.id;
})
) {
loadRecords(true);
}
}, 100);
+4 -4
View File
@@ -1667,14 +1667,14 @@ const utils = {
};
const indexRegex =
/create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?/gim;
/\s*create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*?)\)(?:\s*where\s+([\s\S]*?))?\s*$/gi;
const matches = indexRegex.exec((idx || "").trim());
if (matches?.length != 7) {
return result;
}
const sqlQuoteRegex = /^[\"\'\`\[\{}]|[\"\'\`\]\}]$/gm;
const sqlQuoteRegex = /^[\"\'\`\[\{}]|[\"\'\`\]\}]$/g;
// unique
result.unique = matches[1]?.trim().toLowerCase() === "unique";
@@ -1697,13 +1697,13 @@ const utils = {
// columns
const rawColumns = (matches[5] || "")
.replace(/,(?=[^\(]*\))/gim, "{PB_TEMP}") // temporary replace comma within expressions for easier splitting
.replace(/,(?=[^\(]*\))/gi, "{PB_TEMP}") // temporary replace comma within expressions for easier splitting
.split(","); // split columns
for (let col of rawColumns) {
col = col.trim().replaceAll("{PB_TEMP}", ","); // revert temp replacement
const colRegex = /^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$/gim;
const colRegex = /^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?\s*$/gi;
const colMatches = colRegex.exec(col);
if (colMatches?.length != 4) {
continue;