Files
zeekay 6a3066b08e Test apps open a migrated fixture, so core and apis fit the runner
tests/data lacked twelve migrations, and every test app ran them before
its test began: about four seconds an app, which put core and apis past
go test's ten minutes on the runner, and past thirty once the timeout
was raised. The fixture now holds them (go run ./tests/fixture writes
it), and TestTheFixtureIsMigrated fails, naming the migration, while
one is missing. On one host core went from 267 s to 26 s, apis from
432 s to 23 s, and the whole suite runs in 51 s.

The delete tests gave a background goroutine 100 ms to remove a
record's files, which a slow runner did not always meet. BaseApp
counts those deletions and WaitFileDeletes waits for them: the tests
wait on the count instead of the clock, and ResetBootstrapState waits
too, so a Base that stops no longer drops a deletion in flight.

TestSQLRun expected a CREATE TABLE to report one affected row. SQLite
answers a statement that writes no rows with the connection's last
count, and that row was the last migration's _migrations insert; the
handler now reads 0, the number it changed.

go test runs with its default timeout again, and crm_test.go is gofmt'd.
2026-10-19 09:08:39 -07:00

1664 lines
56 KiB
Go

package core
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/hanzoai/base/tools/osutils"
"github.com/fatih/color"
"github.com/hanzoai/base/tools/cron"
"github.com/hanzoai/base/tools/filesystem"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/base/tools/logger"
"github.com/hanzoai/base/tools/mailer"
"github.com/hanzoai/base/tools/routine"
"github.com/hanzoai/base/tools/store"
"github.com/hanzoai/base/tools/subscriptions"
"github.com/hanzoai/base/tools/tasks"
"github.com/hanzoai/base/tools/types"
"github.com/hanzoai/dbx"
"github.com/hanzoai/orm/dialect"
tasksembed "github.com/hanzoai/tasks/pkg/tasks"
"github.com/spf13/cast"
"golang.org/x/sync/semaphore"
)
const (
DefaultDataMaxOpenConns int = 120
DefaultDataMaxIdleConns int = 15
DefaultAuxMaxOpenConns int = 20
DefaultAuxMaxIdleConns int = 3
DefaultQueryTimeout time.Duration = 30 * time.Second
LocalStorageDirName string = "storage"
LocalBackupsDirName string = "backups"
LocalTempDirName string = ".temp_to_delete" // temp data sub directory that will be deleted on each app.Bootstrap()
LocalAutocertCacheDirName string = ".autocert_cache"
LocalNotifyDirName string = ".notify" // optional watched directory used to synchronize runtime state between multiple instances sharing one data dir
// @todo consider removing after backups refactoring
lostFoundDirName string = "lost+found"
)
// FilesManager defines an interface with common methods that files manager models should implement.
type FilesManager interface {
// BaseFilesPath returns the storage dir path used by the interface instance.
BaseFilesPath() string
}
// DBConnectFunc defines a database connection initialization function.
type DBConnectFunc func(dbPath string) (*dbx.DB, error)
// BaseAppConfig defines a BaseApp configuration option
type BaseAppConfig struct {
DBConnect DBConnectFunc
DataDir string
EncryptionEnv string
QueryTimeout time.Duration
DataMaxOpenConns int
DataMaxIdleConns int
AuxMaxOpenConns int
AuxMaxIdleConns int
IsDev bool
// DataDSN is an optional PostgreSQL DSN for the main data database.
// When set, overrides the file-path-based SQLite connection.
DataDSN string
// AuxDSN is an optional PostgreSQL DSN for the auxiliary database.
// When set, overrides the file-path-based SQLite aux connection.
AuxDSN string
// MasterKey is the 32-byte master encryption key for per-org CEK derivation.
// When set, per-org bases is automatically enabled and per-org databases
// are encrypted with HKDF-derived keys.
// Can be set via MASTER_KEY environment variable (hex-encoded).
MasterKey []byte
}
// ensures that the BaseApp implements the App interface.
var _ App = (*BaseApp)(nil)
// BaseApp implements core.App and defines the base Base app structure.
type BaseApp struct {
config *BaseAppConfig
txInfo *TxAppInfo
store *store.Store[string, any]
cron *cron.Cron
tasks *tasks.Client
settings *Settings
subscriptionsBroker *subscriptions.Broker
logger *slog.Logger
concurrentDB dbx.Builder
nonconcurrentDB dbx.Builder
auxConcurrentDB dbx.Builder
auxNonconcurrentDB dbx.Builder
dialect dialect.Dialect
network baseNetwork
// fileDeletes counts the storage deletions running in the background, so a
// Base that stops waits for them rather than leaving a deleted record's
// files behind. A pointer, because a transaction's app is a copy of this one
// and has to count into the same total.
fileDeletes *sync.WaitGroup
// app event hooks
onBootstrap *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]
onModelCreate *hook.Hook[*ModelEvent]
onModelCreateExecute *hook.Hook[*ModelEvent]
onModelAfterCreateSuccess *hook.Hook[*ModelEvent]
onModelAfterCreateError *hook.Hook[*ModelErrorEvent]
onModelUpdate *hook.Hook[*ModelEvent]
onModelUpdateWrite *hook.Hook[*ModelEvent]
onModelAfterUpdateSuccess *hook.Hook[*ModelEvent]
onModelAfterUpdateError *hook.Hook[*ModelErrorEvent]
onModelDelete *hook.Hook[*ModelEvent]
onModelDeleteExecute *hook.Hook[*ModelEvent]
onModelAfterDeleteSuccess *hook.Hook[*ModelEvent]
onModelAfterDeleteError *hook.Hook[*ModelErrorEvent]
// db record hooks
onRecordEnrich *hook.Hook[*RecordEnrichEvent]
onRecordValidate *hook.Hook[*RecordEvent]
onRecordCreate *hook.Hook[*RecordEvent]
onRecordCreateExecute *hook.Hook[*RecordEvent]
onRecordAfterCreateSuccess *hook.Hook[*RecordEvent]
onRecordAfterCreateError *hook.Hook[*RecordErrorEvent]
onRecordUpdate *hook.Hook[*RecordEvent]
onRecordUpdateExecute *hook.Hook[*RecordEvent]
onRecordAfterUpdateSuccess *hook.Hook[*RecordEvent]
onRecordAfterUpdateError *hook.Hook[*RecordErrorEvent]
onRecordDelete *hook.Hook[*RecordEvent]
onRecordDeleteExecute *hook.Hook[*RecordEvent]
onRecordAfterDeleteSuccess *hook.Hook[*RecordEvent]
onRecordAfterDeleteError *hook.Hook[*RecordErrorEvent]
// db collection hooks
onCollectionValidate *hook.Hook[*CollectionEvent]
onCollectionCreate *hook.Hook[*CollectionEvent]
onCollectionCreateExecute *hook.Hook[*CollectionEvent]
onCollectionAfterCreateSuccess *hook.Hook[*CollectionEvent]
onCollectionAfterCreateError *hook.Hook[*CollectionErrorEvent]
onCollectionUpdate *hook.Hook[*CollectionEvent]
onCollectionUpdateExecute *hook.Hook[*CollectionEvent]
onCollectionAfterUpdateSuccess *hook.Hook[*CollectionEvent]
onCollectionAfterUpdateError *hook.Hook[*CollectionErrorEvent]
onCollectionDelete *hook.Hook[*CollectionEvent]
onCollectionDeleteExecute *hook.Hook[*CollectionEvent]
onCollectionAfterDeleteSuccess *hook.Hook[*CollectionEvent]
onCollectionAfterDeleteError *hook.Hook[*CollectionErrorEvent]
// mailer event hooks
onMailerSend *hook.Hook[*MailerEvent]
// realtime api event hooks
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectRequestEvent]
onRealtimeMessageSend *hook.Hook[*RealtimeMessageEvent]
onRealtimeSubscribeRequest *hook.Hook[*RealtimeSubscribeRequestEvent]
// settings event hooks
onSettingsListRequest *hook.Hook[*SettingsListRequestEvent]
onSettingsUpdateRequest *hook.Hook[*SettingsUpdateRequestEvent]
onSettingsReload *hook.Hook[*SettingsReloadEvent]
// file api event hooks
onFileDownloadRequest *hook.Hook[*FileDownloadRequestEvent]
onFileTokenRequest *hook.Hook[*FileTokenRequestEvent]
// record auth API event hooks
onRecordAuthRequest *hook.Hook[*RecordAuthRequestEvent]
onRecordAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2RequestEvent]
onRecordAuthRefreshRequest *hook.Hook[*RecordAuthRefreshRequestEvent]
onRecordRequestVerificationRequest *hook.Hook[*RecordRequestVerificationRequestEvent]
onRecordConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationRequestEvent]
onRecordRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeRequestEvent]
onRecordConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeRequestEvent]
// record crud API event hooks
onRecordsListRequest *hook.Hook[*RecordsListRequestEvent]
onRecordViewRequest *hook.Hook[*RecordRequestEvent]
onRecordCreateRequest *hook.Hook[*RecordRequestEvent]
onRecordUpdateRequest *hook.Hook[*RecordRequestEvent]
onRecordDeleteRequest *hook.Hook[*RecordRequestEvent]
// collection API event hooks
onCollectionsListRequest *hook.Hook[*CollectionsListRequestEvent]
onCollectionViewRequest *hook.Hook[*CollectionRequestEvent]
onCollectionCreateRequest *hook.Hook[*CollectionRequestEvent]
onCollectionUpdateRequest *hook.Hook[*CollectionRequestEvent]
onCollectionDeleteRequest *hook.Hook[*CollectionRequestEvent]
onCollectionsImportRequest *hook.Hook[*CollectionsImportRequestEvent]
onBatchRequest *hook.Hook[*BatchRequestEvent]
}
// NewBaseApp creates and returns a new BaseApp instance
// configured with the provided arguments.
//
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config BaseAppConfig) *BaseApp {
// Tasks transport selection (in priority order):
// 1. TASKS_EMBED=true — boot pkg/tasks.Embed in-process; the
// tasks daemon runs INSIDE the Base binary, ZAP-listened on
// TASKS_EMBED_ADDRESS. A filesystem path binds a unix socket,
// which is the right shape for a listener nothing off this host
// ever dials and needs no port to allocate or collide over;
// anything else is host:port. Workflow execution, schedules,
// namespaces — all native, no external daemon, no gRPC, no
// temporal.io.
// 2. TASKS_ZAP — direct ZAP dial to a remote Hanzo Tasks daemon
// 3. TASKS_URL — HTTP fallback to a remote Hanzo Tasks daemon
// 4. (none) — local goroutine timer mode (dev fallback, no
// durability)
zapAddr := os.Getenv("TASKS_ZAP")
if osutils.Bool("TASKS_EMBED", false) && zapAddr == "" {
// Default the socket under THIS app's data dir, not a shared temp dir. A
// fixed fleet-wide path is a collision: two Base processes on one host
// would fight over one listener and all but the first would come up with
// no durable engine at all — the same failure a fixed default PORT had,
// one layer down. Naming it after whose it is makes that impossible.
addr := os.Getenv("TASKS_EMBED_ADDRESS")
if addr == "" {
dir := config.DataDir
if dir == "" {
dir = os.TempDir()
}
addr = filepath.Join(dir, "tasks.sock")
}
// Boot the in-process tasks daemon. The handle is held on the
// app for clean shutdown; the client below dials it via ZAP.
// Errors are non-fatal — Base falls through to remote/local
// fallback so a misconfigured embed never bricks the app.
if srv, err := tasksembed.Embed(context.Background(), tasksembed.EmbedConfig{
Address: addr,
Namespace: os.Getenv("TASKS_NAMESPACE"),
}); err == nil {
zapAddr = srv.Address()
defer func() { _ = srv }() // hold a reference; actual stop wired below
}
}
tc := tasks.New(os.Getenv("TASKS_URL"), zapAddr, nil)
app := &BaseApp{
settings: newDefaultSettings(),
store: store.New[string, any](nil),
tasks: tc,
cron: cron.NewFromTasks(tc),
subscriptionsBroker: subscriptions.NewBroker(),
config: &config,
fileDeletes: &sync.WaitGroup{},
}
// apply config defaults
if app.config.DBConnect == nil {
app.config.DBConnect = DefaultDBConnect
}
if app.config.DataMaxOpenConns <= 0 {
app.config.DataMaxOpenConns = DefaultDataMaxOpenConns
}
if app.config.DataMaxIdleConns <= 0 {
app.config.DataMaxIdleConns = DefaultDataMaxIdleConns
}
if app.config.AuxMaxOpenConns <= 0 {
app.config.AuxMaxOpenConns = DefaultAuxMaxOpenConns
}
if app.config.AuxMaxIdleConns <= 0 {
app.config.AuxMaxIdleConns = DefaultAuxMaxIdleConns
}
if app.config.QueryTimeout <= 0 {
app.config.QueryTimeout = DefaultQueryTimeout
}
app.initHooks()
app.registerBaseHooks()
registerNATSHooks(app)
AppBindings.Apply(app)
return app
}
// initHooks initializes all app hook handlers.
func (app *BaseApp) initHooks() {
// app event hooks
app.onBootstrap = &hook.Hook[*BootstrapEvent]{}
app.onServe = &hook.Hook[*ServeEvent]{}
app.onTerminate = &hook.Hook[*TerminateEvent]{}
app.onBackupCreate = &hook.Hook[*BackupEvent]{}
app.onBackupRestore = &hook.Hook[*BackupEvent]{}
// db model hooks
app.onModelValidate = &hook.Hook[*ModelEvent]{}
app.onModelCreate = &hook.Hook[*ModelEvent]{}
app.onModelCreateExecute = &hook.Hook[*ModelEvent]{}
app.onModelAfterCreateSuccess = &hook.Hook[*ModelEvent]{}
app.onModelAfterCreateError = &hook.Hook[*ModelErrorEvent]{}
app.onModelUpdate = &hook.Hook[*ModelEvent]{}
app.onModelUpdateWrite = &hook.Hook[*ModelEvent]{}
app.onModelAfterUpdateSuccess = &hook.Hook[*ModelEvent]{}
app.onModelAfterUpdateError = &hook.Hook[*ModelErrorEvent]{}
app.onModelDelete = &hook.Hook[*ModelEvent]{}
app.onModelDeleteExecute = &hook.Hook[*ModelEvent]{}
app.onModelAfterDeleteSuccess = &hook.Hook[*ModelEvent]{}
app.onModelAfterDeleteError = &hook.Hook[*ModelErrorEvent]{}
// db record hooks
app.onRecordEnrich = &hook.Hook[*RecordEnrichEvent]{}
app.onRecordValidate = &hook.Hook[*RecordEvent]{}
app.onRecordCreate = &hook.Hook[*RecordEvent]{}
app.onRecordCreateExecute = &hook.Hook[*RecordEvent]{}
app.onRecordAfterCreateSuccess = &hook.Hook[*RecordEvent]{}
app.onRecordAfterCreateError = &hook.Hook[*RecordErrorEvent]{}
app.onRecordUpdate = &hook.Hook[*RecordEvent]{}
app.onRecordUpdateExecute = &hook.Hook[*RecordEvent]{}
app.onRecordAfterUpdateSuccess = &hook.Hook[*RecordEvent]{}
app.onRecordAfterUpdateError = &hook.Hook[*RecordErrorEvent]{}
app.onRecordDelete = &hook.Hook[*RecordEvent]{}
app.onRecordDeleteExecute = &hook.Hook[*RecordEvent]{}
app.onRecordAfterDeleteSuccess = &hook.Hook[*RecordEvent]{}
app.onRecordAfterDeleteError = &hook.Hook[*RecordErrorEvent]{}
// db collection hooks
app.onCollectionValidate = &hook.Hook[*CollectionEvent]{}
app.onCollectionCreate = &hook.Hook[*CollectionEvent]{}
app.onCollectionCreateExecute = &hook.Hook[*CollectionEvent]{}
app.onCollectionAfterCreateSuccess = &hook.Hook[*CollectionEvent]{}
app.onCollectionAfterCreateError = &hook.Hook[*CollectionErrorEvent]{}
app.onCollectionUpdate = &hook.Hook[*CollectionEvent]{}
app.onCollectionUpdateExecute = &hook.Hook[*CollectionEvent]{}
app.onCollectionAfterUpdateSuccess = &hook.Hook[*CollectionEvent]{}
app.onCollectionAfterUpdateError = &hook.Hook[*CollectionErrorEvent]{}
app.onCollectionDelete = &hook.Hook[*CollectionEvent]{}
app.onCollectionAfterDeleteSuccess = &hook.Hook[*CollectionEvent]{}
app.onCollectionDeleteExecute = &hook.Hook[*CollectionEvent]{}
app.onCollectionAfterDeleteError = &hook.Hook[*CollectionErrorEvent]{}
// mailer event hooks
app.onMailerSend = &hook.Hook[*MailerEvent]{}
// realtime API event hooks
app.onRealtimeConnectRequest = &hook.Hook[*RealtimeConnectRequestEvent]{}
app.onRealtimeMessageSend = &hook.Hook[*RealtimeMessageEvent]{}
app.onRealtimeSubscribeRequest = &hook.Hook[*RealtimeSubscribeRequestEvent]{}
// settings event hooks
app.onSettingsListRequest = &hook.Hook[*SettingsListRequestEvent]{}
app.onSettingsUpdateRequest = &hook.Hook[*SettingsUpdateRequestEvent]{}
app.onSettingsReload = &hook.Hook[*SettingsReloadEvent]{}
// file API event hooks
app.onFileDownloadRequest = &hook.Hook[*FileDownloadRequestEvent]{}
app.onFileTokenRequest = &hook.Hook[*FileTokenRequestEvent]{}
// record auth API event hooks
app.onRecordAuthRequest = &hook.Hook[*RecordAuthRequestEvent]{}
app.onRecordAuthWithOAuth2Request = &hook.Hook[*RecordAuthWithOAuth2RequestEvent]{}
app.onRecordAuthRefreshRequest = &hook.Hook[*RecordAuthRefreshRequestEvent]{}
app.onRecordRequestVerificationRequest = &hook.Hook[*RecordRequestVerificationRequestEvent]{}
app.onRecordConfirmVerificationRequest = &hook.Hook[*RecordConfirmVerificationRequestEvent]{}
app.onRecordRequestEmailChangeRequest = &hook.Hook[*RecordRequestEmailChangeRequestEvent]{}
app.onRecordConfirmEmailChangeRequest = &hook.Hook[*RecordConfirmEmailChangeRequestEvent]{}
// record crud API event hooks
app.onRecordsListRequest = &hook.Hook[*RecordsListRequestEvent]{}
app.onRecordViewRequest = &hook.Hook[*RecordRequestEvent]{}
app.onRecordCreateRequest = &hook.Hook[*RecordRequestEvent]{}
app.onRecordUpdateRequest = &hook.Hook[*RecordRequestEvent]{}
app.onRecordDeleteRequest = &hook.Hook[*RecordRequestEvent]{}
// collection API event hooks
app.onCollectionsListRequest = &hook.Hook[*CollectionsListRequestEvent]{}
app.onCollectionViewRequest = &hook.Hook[*CollectionRequestEvent]{}
app.onCollectionCreateRequest = &hook.Hook[*CollectionRequestEvent]{}
app.onCollectionUpdateRequest = &hook.Hook[*CollectionRequestEvent]{}
app.onCollectionDeleteRequest = &hook.Hook[*CollectionRequestEvent]{}
app.onCollectionsImportRequest = &hook.Hook[*CollectionsImportRequestEvent]{}
app.onBatchRequest = &hook.Hook[*BatchRequestEvent]{}
}
// UnsafeWithoutHooks returns a shallow copy of the current app WITHOUT any registered hooks.
//
// NB! Note that using the returned app instance may cause data integrity errors
// since the Record validations and data normalizations (including files uploads)
// rely on the app hooks to work.
func (app *BaseApp) UnsafeWithoutHooks() App {
clone := *app
// reset all hook handlers
clone.initHooks()
return &clone
}
// Logger returns the default app logger.
//
// If the application is not bootstrapped yet, fallbacks to slog.Default()
// wrapped in a logger.SlogAdapter so that the return type stays stable.
func (app *BaseApp) Logger() logger.Logger {
if app.logger == nil {
return logger.NewSlog(slog.Default())
}
return logger.NewSlog(app.logger)
}
// SlogLogger returns the raw *slog.Logger backing the app. Reserved for
// the BatchHandler reload plumbing in this package and for tests that
// need to type-assert the handler — business code should call Logger().
func (app *BaseApp) SlogLogger() *slog.Logger {
if app.logger == nil {
return slog.Default()
}
return app.logger
}
// TxInfo returns the transaction associated with the current app instance (if any).
//
// Could be used if you want to execute indirectly a function after
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
func (app *BaseApp) TxInfo() *TxAppInfo {
return app.txInfo
}
// IsTransactional checks if the current app instance is part of a transaction.
func (app *BaseApp) IsTransactional() bool {
return app.TxInfo() != nil
}
// IsBootstrapped checks if the application was initialized
// (aka. whether Bootstrap() was called).
func (app *BaseApp) IsBootstrapped() bool {
return app.concurrentDB != nil && app.auxConcurrentDB != nil
}
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
// It will call ResetBootstrapState() 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 {
return err
}
// ensure that data dir exist
if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil {
return err
}
if err := app.initDataDB(); err != nil {
return err
}
if err := app.initAuxDB(); err != nil {
return err
}
if err := app.attachNetwork(context.Background()); err != nil {
return err
}
if err := app.initLogger(); err != nil {
return err
}
if err := app.RunSystemMigrations(); err != nil {
return err
}
if err := app.ReloadCachedCollections(); err != nil {
return err
}
if err := app.ReloadSettings(); err != nil {
return err
}
// try to cleanup the data temp directory (if any)
_ = os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
return nil
})
// add a more user friendly message in case users forgot to call
// e.Next() as part of their bootstrap hook
if err == nil && !app.IsBootstrapped() {
app.Logger().Warn("OnBootstrap hook didn't fail but the app is still not bootstrapped - maybe missing e.Next()?")
}
return err
}
type closer interface {
Close() error
}
// ResetBootstrapState releases the initialized core app resources
// (closing db connections, stopping cron ticker, etc.).
func (app *BaseApp) ResetBootstrapState() error {
app.Cron().Stop()
app.WaitFileDeletes()
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
}
if app.network != nil {
if err := app.network.Stop(context.Background()); err != nil {
errs = append(errs, err)
}
app.network = nil
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
// WaitFileDeletes waits for the storage deletions a delete started in the
// background: a deleted record's or collection's files are removed after its
// transaction commits, so the write does not wait on the filesystem, and this is
// how anything that has to see them gone (shutting down, a test) waits for it.
func (app *BaseApp) WaitFileDeletes() {
app.fileDeletes.Wait()
}
// DB returns the default app data.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything
// else to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly ConcurrentDB() or NonconcurrentDB().
func (app *BaseApp) DB() dbx.Builder {
// transactional or both are nil
if app.concurrentDB == app.nonconcurrentDB {
return app.concurrentDB
}
return &dualDBBuilder{
concurrentDB: app.concurrentDB,
nonconcurrentDB: app.nonconcurrentDB,
}
}
// ConcurrentDB returns the concurrent app data.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) ConcurrentDB() dbx.Builder {
return app.concurrentDB
}
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
//
// On SQLite the returned db instance holds a single open connection, so it
// processes one db operation at a time and the rest queue up; on an engine
// that orders writers itself it holds as many connections as ConcurrentDB().
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) NonconcurrentDB() dbx.Builder {
return app.nonconcurrentDB
}
// Dialect returns how the connected engine spells the SQL that differs
// between engines — the JSON accessors, the schema catalog, the generated
// instant and identifier. It is resolved from the driver the data DB was
// opened with, so nothing downstream reads the storage tier or the DSN.
//
// Before the DB is open it reads as SQLite, which is what an unconfigured
// Base runs on.
func (app *BaseApp) Dialect() dialect.Dialect {
if app.dialect == nil {
return dialect.For("sqlite")
}
return app.dialect
}
// AuxDB returns the app auxiliary.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything
// else to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
func (app *BaseApp) AuxDB() dbx.Builder {
// transactional or both are nil
if app.auxConcurrentDB == app.auxNonconcurrentDB {
return app.auxConcurrentDB
}
return &dualDBBuilder{
concurrentDB: app.auxConcurrentDB,
nonconcurrentDB: app.auxNonconcurrentDB,
}
}
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxConcurrentDB() dbx.Builder {
return app.auxConcurrentDB
}
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
//
// On SQLite the returned db instance holds a single open connection, so it
// processes one db operation at a time and the rest queue up; on an engine
// that orders writers itself it holds as many connections as AuxConcurrentDB().
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxNonconcurrentDB() dbx.Builder {
return app.auxNonconcurrentDB
}
// DataDir returns the app data directory path.
func (app *BaseApp) DataDir() string {
return app.config.DataDir
}
// EncryptionEnv returns the name of the app secret env key
// (currently used primarily for optional settings encryption but this may change in the future).
func (app *BaseApp) EncryptionEnv() string {
return app.config.EncryptionEnv
}
// IsDev returns whether the app is in dev mode.
//
// When enabled logs, executed sql statements, etc. are printed to the stderr.
func (app *BaseApp) IsDev() bool {
return app.config.IsDev
}
// Settings returns the loaded app settings.
func (app *BaseApp) Settings() *Settings {
return app.settings
}
// Store returns the app runtime store.
func (app *BaseApp) Store() *store.Store[string, any] {
return app.store
}
// Cron returns the app cron instance.
func (app *BaseApp) Cron() *cron.Cron {
return app.cron
}
// Tasks returns the durable task client (Hanzo Tasks).
// Use Add() for recurring schedules, Now() for one-shot tasks.
//
// app.Tasks().Add("settlement", "30s", fn)
// app.Tasks().Now("webhook.deliver", payload)
func (app *BaseApp) Tasks() *tasks.Client {
return app.tasks
}
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
func (app *BaseApp) SubscriptionsBroker() *subscriptions.Broker {
return app.subscriptionsBroker
}
// NewMailClient creates and returns a new SMTP or Sendmail client
// based on the current app settings.
func (app *BaseApp) NewMailClient() mailer.Mailer {
var client mailer.Mailer
// init mailer client
if app.Settings().SMTP.Enabled {
client = &mailer.SMTPClient{
Host: app.Settings().SMTP.Host,
Port: app.Settings().SMTP.Port,
Username: app.Settings().SMTP.Username,
Password: app.Settings().SMTP.Password,
TLS: app.Settings().SMTP.TLS,
AuthMethod: app.Settings().SMTP.AuthMethod,
LocalName: app.Settings().SMTP.LocalName,
}
} else {
client = &mailer.Sendmail{}
}
// register the app level hook
if h, ok := client.(mailer.SendInterceptor); ok {
h.OnSend().Bind(&hook.Handler[*mailer.SendEvent]{
Id: "__hzMailerOnSend__",
Func: func(e *mailer.SendEvent) error {
appEvent := new(MailerEvent)
appEvent.App = app
appEvent.Mailer = client
appEvent.Message = e.Message
return app.OnMailerSend().Trigger(appEvent, func(ae *MailerEvent) error {
e.Message = ae.Message
// print the mail in the console to assist with the debugging
if app.IsDev() {
logDate := new(strings.Builder)
log.New(logDate, "", log.LstdFlags).Print()
mailLog := new(strings.Builder)
mailLog.WriteString(strings.TrimSpace(logDate.String()))
mailLog.WriteString(" Mail sent\n")
fmt.Fprintf(mailLog, "├─ From: %v\n", ae.Message.From)
fmt.Fprintf(mailLog, "├─ To: %v\n", ae.Message.To)
fmt.Fprintf(mailLog, "├─ Cc: %v\n", ae.Message.Cc)
fmt.Fprintf(mailLog, "├─ Bcc: %v\n", ae.Message.Bcc)
fmt.Fprintf(mailLog, "├─ Subject: %v\n", ae.Message.Subject)
if len(ae.Message.Attachments) > 0 {
attachmentKeys := make([]string, 0, len(ae.Message.Attachments))
for k := range ae.Message.Attachments {
attachmentKeys = append(attachmentKeys, k)
}
fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
}
if len(ae.Message.InlineAttachments) > 0 {
attachmentKeys := make([]string, 0, len(ae.Message.InlineAttachments))
for k := range ae.Message.InlineAttachments {
attachmentKeys = append(attachmentKeys, k)
}
fmt.Fprintf(mailLog, "├─ InlineAttachments: %v\n", attachmentKeys)
}
const indentation = " "
if ae.Message.Text != "" {
textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
textIndented := indentation + strings.Join(textParts, "\n"+indentation)
fmt.Fprintf(mailLog, "└─ Text:\n%s", textIndented)
} else {
htmlParts := strings.Split(strings.TrimSpace(ae.Message.HTML), "\n")
htmlIndented := indentation + strings.Join(htmlParts, "\n"+indentation)
fmt.Fprintf(mailLog, "└─ HTML:\n%s", htmlIndented)
}
color.HiBlack("%s", mailLog.String())
}
// send the email with the new mailer in case it was replaced
if client != ae.Mailer {
return ae.Mailer.Send(e.Message)
}
return e.Next()
})
},
})
}
return client
}
// NewFilesystem creates a new local or S3 filesystem instance
// for managing regular app files (ex. record uploads)
// based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
if app.settings != nil && app.settings.S3.Enabled {
return filesystem.NewS3(
app.settings.S3.Bucket,
app.settings.S3.Region,
app.settings.S3.Endpoint,
app.settings.S3.AccessKey,
app.settings.S3.Secret,
app.settings.S3.ForcePathStyle,
)
}
// fallback to local filesystem
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}
// NewBackupsFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
if app.settings != nil && app.settings.Backups.S3.Enabled {
return filesystem.NewS3(
app.settings.Backups.S3.Bucket,
app.settings.Backups.S3.Region,
app.settings.Backups.S3.Endpoint,
app.settings.Backups.S3.AccessKey,
app.settings.Backups.S3.Secret,
app.settings.Backups.S3.ForcePathStyle,
)
}
// fallback to local filesystem
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalBackupsDirName))
}
// Restart restarts (aka. replaces) the current running application process.
//
// NB! It relies on execve which is supported only on UNIX based systems.
func (app *BaseApp) Restart() error {
if runtime.GOOS == "windows" {
return errors.New("restart is not supported on windows")
}
execPath, err := os.Executable()
if err != nil {
return err
}
event := &TerminateEvent{}
event.App = app
event.IsRestart = true
return app.OnTerminate().Trigger(event, func(e *TerminateEvent) error {
_ = e.App.ResetBootstrapState()
// attempt to restart the bootstrap process in case execve returns an error for some reason
defer func() {
if err := e.App.Bootstrap(); err != nil {
app.Logger().Error("Failed to rebootstrap the application after failed app.Restart()", "error", err)
}
}()
return execve(execPath, os.Args, os.Environ())
})
}
// RunSystemMigrations applies all new migrations registered in the [core.SystemMigrations] list.
func (app *BaseApp) RunSystemMigrations() error {
_, err := NewMigrationsRunner(app, SystemMigrations).Up()
return err
}
// RunAppMigrations applies all new migrations registered in the [core.AppMigrations] list.
func (app *BaseApp) RunAppMigrations() error {
_, err := NewMigrationsRunner(app, AppMigrations).Up()
return err
}
// RunAllMigrations applies all system and app migrations
// (aka. from both [core.SystemMigrations] and [core.AppMigrations]).
func (app *BaseApp) RunAllMigrations() error {
list := MigrationsList{}
list.Copy(SystemMigrations)
list.Copy(AppMigrations)
_, err := NewMigrationsRunner(app, list).Up()
return err
}
// -------------------------------------------------------------------
// App event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnBootstrap() *hook.Hook[*BootstrapEvent] {
return app.onBootstrap
}
func (app *BaseApp) OnServe() *hook.Hook[*ServeEvent] {
return app.onServe
}
func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
return app.onTerminate
}
func (app *BaseApp) OnBackupCreate() *hook.Hook[*BackupEvent] {
return app.onBackupCreate
}
func (app *BaseApp) OnBackupRestore() *hook.Hook[*BackupEvent] {
return app.onBackupRestore
}
// ---------------------------------------------------------------
func (app *BaseApp) OnModelCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelCreate, tags...)
}
func (app *BaseApp) OnModelCreateExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelCreateExecute, tags...)
}
func (app *BaseApp) OnModelAfterCreateSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterCreateSuccess, tags...)
}
func (app *BaseApp) OnModelAfterCreateError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
return hook.NewTaggedHook(app.onModelAfterCreateError, tags...)
}
func (app *BaseApp) OnModelUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelUpdate, tags...)
}
func (app *BaseApp) OnModelUpdateExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelUpdateWrite, tags...)
}
func (app *BaseApp) OnModelAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterUpdateSuccess, tags...)
}
func (app *BaseApp) OnModelAfterUpdateError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
return hook.NewTaggedHook(app.onModelAfterUpdateError, tags...)
}
func (app *BaseApp) OnModelValidate(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelValidate, tags...)
}
func (app *BaseApp) OnModelDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelDelete, tags...)
}
func (app *BaseApp) OnModelDeleteExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelDeleteExecute, tags...)
}
func (app *BaseApp) OnModelAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
return hook.NewTaggedHook(app.onModelAfterDeleteSuccess, tags...)
}
func (app *BaseApp) OnModelAfterDeleteError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
return hook.NewTaggedHook(app.onModelAfterDeleteError, tags...)
}
func (app *BaseApp) OnRecordEnrich(tags ...string) *hook.TaggedHook[*RecordEnrichEvent] {
return hook.NewTaggedHook(app.onRecordEnrich, tags...)
}
func (app *BaseApp) OnRecordValidate(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordValidate, tags...)
}
func (app *BaseApp) OnRecordCreate(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordCreate, tags...)
}
func (app *BaseApp) OnRecordCreateExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordCreateExecute, tags...)
}
func (app *BaseApp) OnRecordAfterCreateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordAfterCreateSuccess, tags...)
}
func (app *BaseApp) OnRecordAfterCreateError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
return hook.NewTaggedHook(app.onRecordAfterCreateError, tags...)
}
func (app *BaseApp) OnRecordUpdate(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordUpdate, tags...)
}
func (app *BaseApp) OnRecordUpdateExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordUpdateExecute, tags...)
}
func (app *BaseApp) OnRecordAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordAfterUpdateSuccess, tags...)
}
func (app *BaseApp) OnRecordAfterUpdateError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
return hook.NewTaggedHook(app.onRecordAfterUpdateError, tags...)
}
func (app *BaseApp) OnRecordDelete(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordDelete, tags...)
}
func (app *BaseApp) OnRecordDeleteExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordDeleteExecute, tags...)
}
func (app *BaseApp) OnRecordAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
return hook.NewTaggedHook(app.onRecordAfterDeleteSuccess, tags...)
}
func (app *BaseApp) OnRecordAfterDeleteError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
return hook.NewTaggedHook(app.onRecordAfterDeleteError, tags...)
}
func (app *BaseApp) OnCollectionValidate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionValidate, tags...)
}
func (app *BaseApp) OnCollectionCreate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionCreate, tags...)
}
func (app *BaseApp) OnCollectionCreateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionCreateExecute, tags...)
}
func (app *BaseApp) OnCollectionAfterCreateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionAfterCreateSuccess, tags...)
}
func (app *BaseApp) OnCollectionAfterCreateError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
return hook.NewTaggedHook(app.onCollectionAfterCreateError, tags...)
}
func (app *BaseApp) OnCollectionUpdate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionUpdate, tags...)
}
func (app *BaseApp) OnCollectionUpdateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionUpdateExecute, tags...)
}
func (app *BaseApp) OnCollectionAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionAfterUpdateSuccess, tags...)
}
func (app *BaseApp) OnCollectionAfterUpdateError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
return hook.NewTaggedHook(app.onCollectionAfterUpdateError, tags...)
}
func (app *BaseApp) OnCollectionDelete(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionDelete, tags...)
}
func (app *BaseApp) OnCollectionDeleteExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionDeleteExecute, tags...)
}
func (app *BaseApp) OnCollectionAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
return hook.NewTaggedHook(app.onCollectionAfterDeleteSuccess, tags...)
}
func (app *BaseApp) OnCollectionAfterDeleteError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
return hook.NewTaggedHook(app.onCollectionAfterDeleteError, tags...)
}
// -------------------------------------------------------------------
// Mailer event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnMailerSend() *hook.Hook[*MailerEvent] {
return app.onMailerSend
}
// -------------------------------------------------------------------
// Realtime API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectRequestEvent] {
return app.onRealtimeConnectRequest
}
func (app *BaseApp) OnRealtimeMessageSend() *hook.Hook[*RealtimeMessageEvent] {
return app.onRealtimeMessageSend
}
func (app *BaseApp) OnRealtimeSubscribeRequest() *hook.Hook[*RealtimeSubscribeRequestEvent] {
return app.onRealtimeSubscribeRequest
}
// -------------------------------------------------------------------
// Settings API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnSettingsListRequest() *hook.Hook[*SettingsListRequestEvent] {
return app.onSettingsListRequest
}
func (app *BaseApp) OnSettingsUpdateRequest() *hook.Hook[*SettingsUpdateRequestEvent] {
return app.onSettingsUpdateRequest
}
func (app *BaseApp) OnSettingsReload() *hook.Hook[*SettingsReloadEvent] {
return app.onSettingsReload
}
// -------------------------------------------------------------------
// File API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadRequestEvent] {
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
}
func (app *BaseApp) OnFileTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenRequestEvent] {
return hook.NewTaggedHook(app.onFileTokenRequest, tags...)
}
// -------------------------------------------------------------------
// Record auth API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthRequestEvent] {
return hook.NewTaggedHook(app.onRecordAuthRequest, tags...)
}
func (app *BaseApp) OnRecordAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2RequestEvent] {
return hook.NewTaggedHook(app.onRecordAuthWithOAuth2Request, tags...)
}
func (app *BaseApp) OnRecordAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshRequestEvent] {
return hook.NewTaggedHook(app.onRecordAuthRefreshRequest, tags...)
}
func (app *BaseApp) OnRecordRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationRequestEvent] {
return hook.NewTaggedHook(app.onRecordRequestVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationRequestEvent] {
return hook.NewTaggedHook(app.onRecordConfirmVerificationRequest, tags...)
}
func (app *BaseApp) OnRecordRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeRequestEvent] {
return hook.NewTaggedHook(app.onRecordRequestEmailChangeRequest, tags...)
}
func (app *BaseApp) OnRecordConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeRequestEvent] {
return hook.NewTaggedHook(app.onRecordConfirmEmailChangeRequest, tags...)
}
// -------------------------------------------------------------------
// Record CRUD API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListRequestEvent] {
return hook.NewTaggedHook(app.onRecordsListRequest, tags...)
}
func (app *BaseApp) OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
return hook.NewTaggedHook(app.onRecordViewRequest, tags...)
}
func (app *BaseApp) OnRecordCreateRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
return hook.NewTaggedHook(app.onRecordCreateRequest, tags...)
}
func (app *BaseApp) OnRecordUpdateRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
return hook.NewTaggedHook(app.onRecordUpdateRequest, tags...)
}
func (app *BaseApp) OnRecordDeleteRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
return hook.NewTaggedHook(app.onRecordDeleteRequest, tags...)
}
// -------------------------------------------------------------------
// Collection API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnCollectionsListRequest() *hook.Hook[*CollectionsListRequestEvent] {
return app.onCollectionsListRequest
}
func (app *BaseApp) OnCollectionViewRequest() *hook.Hook[*CollectionRequestEvent] {
return app.onCollectionViewRequest
}
func (app *BaseApp) OnCollectionCreateRequest() *hook.Hook[*CollectionRequestEvent] {
return app.onCollectionCreateRequest
}
func (app *BaseApp) OnCollectionUpdateRequest() *hook.Hook[*CollectionRequestEvent] {
return app.onCollectionUpdateRequest
}
func (app *BaseApp) OnCollectionDeleteRequest() *hook.Hook[*CollectionRequestEvent] {
return app.onCollectionDeleteRequest
}
func (app *BaseApp) OnCollectionsImportRequest() *hook.Hook[*CollectionsImportRequestEvent] {
return app.onCollectionsImportRequest
}
func (app *BaseApp) OnBatchRequest() *hook.Hook[*BatchRequestEvent] {
return app.onBatchRequest
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
func (app *BaseApp) initDataDB() error {
var connectFunc func() (*dbx.DB, error)
if app.config.DataDSN != "" {
connectFunc = func() (*dbx.DB, error) {
return PostgresDBConnect(app.config.DataDSN)
}
} else {
dbPath := filepath.Join(app.DataDir(), dataFile)
connectFunc = func() (*dbx.DB, error) {
return app.config.DBConnect(dbPath)
}
}
concurrentDB, err := connectFunc()
if err != nil {
return err
}
setPoolLimits(concurrentDB, app.config.DataMaxOpenConns, app.config.DataMaxIdleConns)
nonconcurrentDB, err := connectFunc()
if err != nil {
return err
}
setWritePoolLimits(nonconcurrentDB, app.config.DataMaxOpenConns, app.config.DataMaxIdleConns)
if app.IsDev() {
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), normalizeSQLLog(sql))
}
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), normalizeSQLLog(sql))
}
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
}
app.concurrentDB = concurrentDB
app.nonconcurrentDB = nonconcurrentDB
app.dialect = dialect.For(concurrentDB.DriverName())
return nil
}
var sqlLogReplacements = []struct {
pattern *regexp.Regexp
replacement string
}{
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
{regexp.MustCompile(`([^'"])?\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])?`), "`$1"},
{regexp.MustCompile(`([^'"])?\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])?`), "`$1"},
{regexp.MustCompile(`<nil>`), "NULL"},
}
// normalizeSQLLog replaces common query builder characters with their plain SQL version for easier debugging.
// The query is still not suitable for execution and should be used only for log and debug purposes
// (the normalization is done here to avoid breaking changes in dbx).
func normalizeSQLLog(sql string) string {
for _, item := range sqlLogReplacements {
sql = item.pattern.ReplaceAllString(sql, item.replacement)
}
return sql
}
func (app *BaseApp) initAuxDB() error {
var connectFunc func() (*dbx.DB, error)
if app.config.AuxDSN != "" {
connectFunc = func() (*dbx.DB, error) {
return PostgresDBConnect(app.config.AuxDSN)
}
} else {
// note: renamed to "auxiliary" because "aux" is a reserved Windows filename
// (see https://github.com/hanzoai/base/issues/5607)
dbPath := filepath.Join(app.DataDir(), auxFile)
connectFunc = func() (*dbx.DB, error) {
return app.config.DBConnect(dbPath)
}
}
concurrentDB, err := connectFunc()
if err != nil {
return err
}
setPoolLimits(concurrentDB, app.config.AuxMaxOpenConns, app.config.AuxMaxIdleConns)
nonconcurrentDB, err := connectFunc()
if err != nil {
return err
}
setWritePoolLimits(nonconcurrentDB, app.config.AuxMaxOpenConns, app.config.AuxMaxIdleConns)
app.auxConcurrentDB = concurrentDB
app.auxNonconcurrentDB = nonconcurrentDB
return nil
}
// setPoolLimits sizes a connection pool.
func setPoolLimits(db *dbx.DB, maxOpen, maxIdle int) {
db.DB().SetMaxOpenConns(maxOpen)
db.DB().SetMaxIdleConns(maxIdle)
db.DB().SetConnMaxIdleTime(3 * time.Minute)
}
// setWritePoolLimits sizes the pool serving writes. SQLite admits one writer at
// a time, so its pool holds a single connection; an engine that orders writers
// itself takes the same limits as the pool serving reads.
func setWritePoolLimits(db *dbx.DB, maxOpen, maxIdle int) {
if _, single := dialect.For(db.DriverName()).(dialect.SQLite); single {
maxOpen, maxIdle = 1, 1
}
setPoolLimits(db, maxOpen, maxIdle)
}
// @todo remove after refactoring the FilesManager interface
func supportFiles(m Model) bool {
var collection *Collection
switch v := m.(type) {
case *Collection:
collection = v
case *Record:
collection = v.Collection()
case RecordProxy:
if v.ProxyRecord() != nil {
collection = v.ProxyRecord().Collection()
}
}
if collection == nil {
return true
}
for _, f := range collection.Fields {
if f.Type() == FieldTypeFile {
return true
}
}
return false
}
func (app *BaseApp) registerBaseHooks() {
deletePrefix := func(prefix string) error {
fs, err := app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
failed := fs.DeletePrefix(prefix)
if len(failed) > 0 {
return errors.New("failed to delete the files at " + prefix)
}
return nil
}
maxFilesDeleteWorkers := cast.ToInt64(os.Getenv("FILES_DELETE_MAX_WORKERS"))
if maxFilesDeleteWorkers <= 0 {
maxFilesDeleteWorkers = 2000 // the value is arbitrary chosen and may change in the future
}
deleteSem := semaphore.NewWeighted(maxFilesDeleteWorkers)
// try to delete the storage files from deleted Collection, Records, etc. model
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
Id: "__hzFilesManagerDelete__",
Func: func(e *ModelEvent) error {
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" && supportFiles(e.Model) {
// ensure that there is a trailing slash so that the list iterator could start walking from the prefix dir
// (https://github.com/hanzoai/base/discussions/5246#discussioncomment-10128955)
prefix := strings.TrimRight(m.BaseFilesPath(), "/") + "/"
// note: for now assume no context cancellation
err := deleteSem.Acquire(context.Background(), 1)
if err != nil {
app.Logger().Error(
"Failed to delete storage prefix (couldn't acquire a worker)",
"prefix", prefix,
"error", err.Error(),
)
} else {
// run in the background for "optimistic" delete to avoid blocking the delete transaction
routine.FireAndForget(func() {
defer deleteSem.Release(1)
if err := deletePrefix(prefix); err != nil {
app.Logger().Error(
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
"prefix", prefix,
"error", err.Error(),
)
}
}, app.fileDeletes)
}
}
return e.Next()
},
Priority: -99,
})
app.OnServe().Bind(&hook.Handler[*ServeEvent]{
Id: "__hzCronStart__",
Func: func(e *ServeEvent) error {
app.Cron().Start()
return e.Next()
},
Priority: 999,
})
// Per-user writer-pin middleware: shard-resolver + write-forward.
// No-op when app.network is nil (standalone) or Enabled() == false.
// Priority 500 puts it after auth (auth is ~100) but before
// record-specific business logic.
app.OnServe().Bind(&hook.Handler[*ServeEvent]{
Id: "__hzNetworkMiddleware__",
Func: func(e *ServeEvent) error {
if err := app.installNetworkMiddleware(e); err != nil {
return err
}
return e.Next()
},
Priority: 500,
})
app.Cron().Add("__hzDBOptimize__", "0 0 * * *", func() {
if checkpoint := app.Dialect().Checkpoint(); checkpoint != "" {
_, execErr := app.NonconcurrentDB().NewQuery(checkpoint).Execute()
if execErr != nil {
app.Logger().Warn("Failed to run the periodic checkpoint for the main DB", "error", execErr.Error())
}
_, execErr = app.AuxNonconcurrentDB().NewQuery(checkpoint).Execute()
if execErr != nil {
app.Logger().Warn("Failed to run the periodic checkpoint for the auxiliary DB", "error", execErr.Error())
}
}
_, execErr := app.NonconcurrentDB().NewQuery(app.Dialect().Optimize()).Execute()
if execErr != nil {
app.Logger().Warn("Failed to refresh statistics", "error", execErr.Error())
}
})
app.registerSettingsHooks()
app.registerAutobackupHooks()
app.registerCollectionHooks()
app.registerRecordHooks()
app.registerHistoryHooks()
app.registerSuperuserHooks()
app.registerNotifyWatcherHooks()
}
// getLoggerMinLevel returns the logger min level based on the
// app configurations (dev mode, settings, etc.).
//
// If not in dev mode - returns the level from the app settings.
//
// If the app is in dev mode it returns -9999 level allowing to print
// practically all logs to the terminal.
// In this case DB logs are still filtered but the checks for the min level are done
// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
func getLoggerMinLevel(app App) slog.Level {
var minLevel slog.Level
if app.IsDev() {
minLevel = -99999
} else if app.Settings() != nil {
minLevel = slog.Level(app.Settings().Logs.MinLevel)
}
return minLevel
}
func (app *BaseApp) initLogger() error {
duration := 3 * time.Second
ticker := time.NewTicker(duration)
done := make(chan bool, 1)
handler := logger.NewBatchHandler(logger.BatchOptions{
Level: getLoggerMinLevel(app),
BatchSize: 200,
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
if app.IsDev() {
printLog(log)
// manually check the log level and skip if necessary
if log.Level < slog.Level(app.Settings().Logs.MinLevel) {
return false
}
}
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
}
// 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
},
})
go func() {
ctx := context.Background()
for {
select {
case <-done:
return
case <-ticker.C:
handler.WriteAll(ctx)
}
}
}()
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: "__hzAppLoggerOnTerminate__",
Func: func(e *TerminateEvent) error {
handler.WriteAll(context.Background())
ticker.Stop()
// don't block in case OnTerminate is triggered more than once
select {
case done <- true:
default:
}
return e.Next()
},
Priority: -999,
})
// reload log handler level (if initialized)
app.OnSettingsReload().Bind(&hook.Handler[*SettingsReloadEvent]{
Id: "__hzAppLoggerOnSettingsReload__",
Func: func(e *SettingsReloadEvent) error {
err := e.Next()
if err != nil {
return err
}
if sl := e.App.SlogLogger(); sl != nil {
if h, ok := sl.Handler().(*logger.BatchHandler); ok {
h.SetLevel(getLoggerMinLevel(e.App))
}
}
// try to clear old logs not matching the new settings
createdBefore := types.NowDateTime().AddDate(0, 0, -1*e.App.Settings().Logs.MaxDays)
expr := dbx.NewExp("[[created]] <= {:date} OR [[level]] < {:level}", dbx.Params{
"date": createdBefore.String(),
"level": e.App.Settings().Logs.MinLevel,
})
_, err = e.App.AuxNonconcurrentDB().Delete((&Log{}).TableName(), expr).Execute()
if err != nil {
e.App.Logger().Debug("Failed to cleanup old logs", "error", err)
}
// no logs are allowed -> try to reclaim preserved disk space after the previous delete operation
if e.App.Settings().Logs.MaxDays == 0 {
err = e.App.AuxVacuum()
if err != nil {
e.App.Logger().Debug("Failed to VACUUM aux database", "error", err)
}
}
return nil
},
Priority: -999,
})
// cleanup old logs
app.Cron().Add("__hzLogsCleanup__", "0 */6 * * *", func() {
deleteErr := app.DeleteOldLogs(time.Now().AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
if deleteErr != nil {
app.Logger().Warn("Failed to delete old logs", "error", deleteErr)
}
})
return nil
}