mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
a hook is about a Base, and which Base arrives with the event
An extension's hooks were stated once, on the Base the process started with, so a write to a tenant's Base fired nothing an extension had asked for. The file says what to do when a record is created; it does not say where, and the where comes with the event. So a declaration is recorded as something that can be stated on any Base, and stated on each as it is constructed — the same place realtime is bound, for the same reason. A hook still sees only the Base it fired on. This grants an extension nothing it did not have. A file on disk is the operator's, and it already ran with the process's authority; what changes is which data its hooks see. An extension written when there was one Base now runs against each, which is a behaviour change and is the thing to know. Routes and crons an extension declares stay on the Base that loaded it: a route is registered once on a router, and a schedule belongs to the process. A tenant's Base was bootstrapped and never served, so nothing it scheduled ever ran — its logs were never cleaned and its database never optimised. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+1
-1
@@ -280,7 +280,7 @@ func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
app.initHooks()
|
||||
app.registerBaseHooks()
|
||||
registerNATSHooks(app)
|
||||
AppBindings.apply(app)
|
||||
AppBindings.Apply(app)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
+22
-5
@@ -1,5 +1,7 @@
|
||||
package core
|
||||
|
||||
import "sync"
|
||||
|
||||
// AppBindings is what every Base this process opens binds to its own hooks, and
|
||||
// it is read where a Base is constructed — so a binding registered here reaches
|
||||
// an org's Base as well as the process's own.
|
||||
@@ -8,25 +10,40 @@ package core
|
||||
// there is a single answer to what a Base does. Anything bound instead at the
|
||||
// process's front door belongs to the one Base that door opens onto.
|
||||
//
|
||||
// Register from an init. A binding added once a Base exists does not reach it.
|
||||
// Register before the Base that should carry it is constructed. The process's
|
||||
// own Base is built at startup, so a plugin that registers after that says the
|
||||
// same thing twice: once on the app it was handed, and once here for every Base
|
||||
// opened later.
|
||||
//
|
||||
// What goes here runs on every Base, which is the whole point and also the
|
||||
// limit: it may hold nothing that reaches past the Base it is handed.
|
||||
var AppBindings BindingsList
|
||||
|
||||
// BindingsList is an ordered list of functions applied to a newly constructed App.
|
||||
// BindingsList is an ordered list of functions applied to a newly constructed
|
||||
// App.
|
||||
//
|
||||
// A plugin holds one of its own to collect what it has to say before deciding
|
||||
// which Bases hear it: Register records a statement, Apply makes it on a Base.
|
||||
type BindingsList struct {
|
||||
mu sync.RWMutex
|
||||
list []func(App)
|
||||
}
|
||||
|
||||
// Register adds fn to what every Base binds.
|
||||
func (l *BindingsList) Register(fn func(App)) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.list = append(l.list, fn)
|
||||
}
|
||||
|
||||
// apply runs each registered binding against app, in registration order.
|
||||
func (l *BindingsList) apply(app App) {
|
||||
for _, bind := range l.list {
|
||||
// Apply runs each registered binding against app, in registration order.
|
||||
func (l *BindingsList) Apply(app App) {
|
||||
l.mu.RLock()
|
||||
list := l.list
|
||||
l.mu.RUnlock()
|
||||
|
||||
for _, bind := range list {
|
||||
bind(app)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/hanzoai/base/tests"
|
||||
)
|
||||
|
||||
// firing is one call of an extension's hook: the Base it fired on, and what
|
||||
// that Base could see through $app at the time.
|
||||
type firing struct {
|
||||
dir string
|
||||
rows string
|
||||
}
|
||||
|
||||
// witness collects firings from the hook body. The executor pool runs a hook on
|
||||
// whatever goroutine the write was on, so this is written from several.
|
||||
type witness struct {
|
||||
mu sync.Mutex
|
||||
list []firing
|
||||
}
|
||||
|
||||
func (w *witness) saw(dir, rows string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.list = append(w.list, firing{dir: dir, rows: rows})
|
||||
}
|
||||
|
||||
func (w *witness) rowsFor(dir string) []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
var out []string
|
||||
for _, f := range w.list {
|
||||
if f.dir == dir {
|
||||
out = append(out, f.rows)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *witness) dirs() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, f := range w.list {
|
||||
if !seen[f.dir] {
|
||||
seen[f.dir] = true
|
||||
out = append(out, f.dir)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// The one hook the tests below load. It is tagged, so the Bases every other
|
||||
// test in this package builds bind a hook that names a collection they do not
|
||||
// have and therefore never fires.
|
||||
const hookSource = `
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
e.next()
|
||||
|
||||
const names = $app.findAllRecords("hooked").map((r) => r.get("name"))
|
||||
names.sort()
|
||||
|
||||
__saw($app.dataDir(), names.join(","))
|
||||
}, "hooked")
|
||||
`
|
||||
|
||||
// loadExtension writes hookSource into a hooks directory and registers the
|
||||
// plugin against app, exactly as a deployment with a .base.js file does.
|
||||
func loadExtension(t *testing.T, app core.App) *witness {
|
||||
t.Helper()
|
||||
|
||||
hooksDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(hooksDir, "test.base.js"), []byte(hookSource), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := &witness{}
|
||||
|
||||
err := Register(app, Config{
|
||||
HooksDir: hooksDir,
|
||||
MigrationsDir: filepath.Join(hooksDir, "migrations"),
|
||||
TypesDir: t.TempDir(),
|
||||
HooksPoolSize: 1,
|
||||
OnInit: func(vm *goja.Runtime) {
|
||||
vm.Set("__saw", w.saw)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// openBase builds a Base of its own, the way plugins/org opens an org's: a data
|
||||
// directory, a constructor, a bootstrap. Nothing about it mentions extensions.
|
||||
func openBase(t *testing.T) core.App {
|
||||
t.Helper()
|
||||
|
||||
app := core.NewBaseApp(core.BaseAppConfig{DataDir: t.TempDir(), IsDev: true})
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = app.ResetBootstrapState() })
|
||||
|
||||
collection := core.NewBaseCollection("hooked")
|
||||
collection.Fields.Add(&core.TextField{Name: "name"})
|
||||
if err := app.Save(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// write puts one row into a Base's "hooked" collection.
|
||||
func write(t *testing.T, app core.App, name string) {
|
||||
t.Helper()
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("hooked")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("name", name)
|
||||
if err := app.Save(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A hook is a statement about a Base — "when a record is created here, do this"
|
||||
// — and the Base it is about arrives with the event. So it holds for every Base
|
||||
// this process opens, not only the one whose data directory the hook files were
|
||||
// read from.
|
||||
//
|
||||
// Before this held, an operator's own guard, written in JS, ran on the platform
|
||||
// Base alone: in a deployment where the tenants are the data, it fired for
|
||||
// almost nothing and said nothing about it. JS migrations have always applied to
|
||||
// every Base; a hook that did not was the odd half of one surface.
|
||||
func TestAnExtensionsHooksReachEveryBase(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
w := loadExtension(t, app)
|
||||
|
||||
// A Base opened after the files were read.
|
||||
tenant := openBase(t)
|
||||
write(t, tenant, "t1")
|
||||
|
||||
rows := w.rowsFor(tenant.DataDir())
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("the hook fired %d times on a Base opened after the extension loaded, want 1", len(rows))
|
||||
}
|
||||
if rows[0] != "t1" {
|
||||
t.Fatalf("the hook read %q, want t1", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The Base a hook fires on is the Base it reads. Two tenants writing the same
|
||||
// collection see their own rows and no others — the isolation is the file, and
|
||||
// binding a hook on every Base does not reach across it.
|
||||
func TestAHookSeesOnlyTheBaseItFiredOn(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
w := loadExtension(t, app)
|
||||
|
||||
first := openBase(t)
|
||||
second := openBase(t)
|
||||
|
||||
write(t, first, "a1")
|
||||
write(t, second, "b1")
|
||||
write(t, second, "b2")
|
||||
|
||||
if got, want := w.rowsFor(first.DataDir()), []string{"a1"}; !equal(got, want) {
|
||||
t.Fatalf("the first Base's hook saw %v, want %v", got, want)
|
||||
}
|
||||
if got, want := w.rowsFor(second.DataDir()), []string{"b1", "b1,b2"}; !equal(got, want) {
|
||||
t.Fatalf("the second Base's hook saw %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Nothing fired anywhere but on the two Bases that were written to.
|
||||
for _, dir := range w.dirs() {
|
||||
if dir != first.DataDir() && dir != second.DataDir() {
|
||||
t.Fatalf("the hook fired on %q, which nothing wrote to", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// And neither Base's rows ever appeared in the other's firing.
|
||||
for _, rows := range w.rowsFor(first.DataDir()) {
|
||||
if strings.Contains(rows, "b") {
|
||||
t.Fatalf("the first Base's hook read the second's rows: %q", rows)
|
||||
}
|
||||
}
|
||||
for _, rows := range w.rowsFor(second.DataDir()) {
|
||||
if strings.Contains(rows, "a") {
|
||||
t.Fatalf("the second Base's hook read the first's rows: %q", rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Base the extension was loaded on keeps its hooks. Stating them on every
|
||||
// Base is an addition, not a move.
|
||||
func TestAnExtensionsHooksStayOnTheBaseThatLoadedIt(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
w := loadExtension(t, app)
|
||||
|
||||
collection := core.NewBaseCollection("hooked")
|
||||
collection.Fields.Add(&core.TextField{Name: "name"})
|
||||
if err := app.Save(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, app, "p1")
|
||||
|
||||
if got, want := w.rowsFor(app.DataDir()), []string{"p1"}; !equal(got, want) {
|
||||
t.Fatalf("the loading Base's hook saw %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A tick arrives on no Base and a process has one router, so cronAdd and
|
||||
// routerAdd have nothing per-Base to be handed and stay where they are said.
|
||||
// A hook is handed the Base its event fired on, which is the whole difference.
|
||||
func TestACronAndARouteStayOnTheBaseThatLoadedThem(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
hooksDir := t.TempDir()
|
||||
source := `
|
||||
cronAdd("extension", "0 0 * * *", () => {})
|
||||
routerAdd("GET", "/extension", (e) => e.json(200, {}))
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(hooksDir, "test.base.js"), []byte(source), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := Register(app, Config{
|
||||
HooksDir: hooksDir,
|
||||
MigrationsDir: filepath.Join(hooksDir, "migrations"),
|
||||
TypesDir: t.TempDir(),
|
||||
HooksPoolSize: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !app.Cron().HasJob("extension") {
|
||||
t.Fatal("the Base that loaded the extension did not get its cron job")
|
||||
}
|
||||
|
||||
tenant := openBase(t)
|
||||
if tenant.Cron().HasJob("extension") {
|
||||
t.Fatal("an extension's cron job was registered on a second Base")
|
||||
}
|
||||
}
|
||||
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+42
-24
@@ -37,11 +37,17 @@ import (
|
||||
)
|
||||
|
||||
// hooksBinds adds wrapped "on*" hook methods by reflecting on core.App.
|
||||
func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
||||
//
|
||||
// A hook is a statement about a Base — "when a record is created here, do this"
|
||||
// — and which Base it is about arrives with the event, not with the file. So
|
||||
// each declaration is recorded as a function that will state the same hook on
|
||||
// any Base, and stated straight away on app. The returned list is those
|
||||
// statements; registerHooks says which other Bases hear them.
|
||||
func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) *core.BindingsList {
|
||||
fm := FieldMapper{}
|
||||
declared := &core.BindingsList{}
|
||||
|
||||
appType := reflect.TypeOf(app)
|
||||
appValue := reflect.ValueOf(app)
|
||||
totalMethods := appType.NumMethod()
|
||||
excludeHooks := []string{"OnServe"}
|
||||
|
||||
@@ -64,38 +70,50 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
||||
tagsAsValues[i] = reflect.ValueOf(tag)
|
||||
}
|
||||
|
||||
hookInstance := appValue.MethodByName(method.Name).Call(tagsAsValues)[0]
|
||||
hookBindFunc := hookInstance.MethodByName("BindFunc")
|
||||
|
||||
handlerType := hookBindFunc.Type().In(0)
|
||||
|
||||
handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) {
|
||||
handlerArgs := make([]any, len(args))
|
||||
for i, arg := range args {
|
||||
handlerArgs[i] = arg.Interface()
|
||||
bind := func(target core.App) {
|
||||
hookValue := reflect.ValueOf(target).MethodByName(method.Name)
|
||||
if !hookValue.IsValid() {
|
||||
return // a hook a Base does not have is not one it can fire
|
||||
}
|
||||
|
||||
err := executors.run(func(executor *goja.Runtime) error {
|
||||
executor.Set("$app", goja.Undefined())
|
||||
executor.Set("__args", handlerArgs)
|
||||
res, err := executor.RunProgram(pr)
|
||||
executor.Set("__args", goja.Undefined())
|
||||
hookInstance := hookValue.Call(tagsAsValues)[0]
|
||||
hookBindFunc := hookInstance.MethodByName("BindFunc")
|
||||
|
||||
// check for returned Go error value
|
||||
if resErr := checkGojaValueForError(app, res); resErr != nil {
|
||||
return resErr
|
||||
handlerType := hookBindFunc.Type().In(0)
|
||||
|
||||
handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) {
|
||||
handlerArgs := make([]any, len(args))
|
||||
for i, arg := range args {
|
||||
handlerArgs[i] = arg.Interface()
|
||||
}
|
||||
|
||||
return normalizeException(err)
|
||||
err := executors.run(func(executor *goja.Runtime) error {
|
||||
executor.Set("$app", goja.Undefined())
|
||||
executor.Set("__args", handlerArgs)
|
||||
res, err := executor.RunProgram(pr)
|
||||
executor.Set("__args", goja.Undefined())
|
||||
|
||||
// check for returned Go error value
|
||||
if resErr := checkGojaValueForError(target, res); resErr != nil {
|
||||
return resErr
|
||||
}
|
||||
|
||||
return normalizeException(err)
|
||||
})
|
||||
|
||||
return []reflect.Value{reflect.ValueOf(&err).Elem()}
|
||||
})
|
||||
|
||||
return []reflect.Value{reflect.ValueOf(&err).Elem()}
|
||||
})
|
||||
// register the wrapped hook handler
|
||||
hookBindFunc.Call([]reflect.Value{handler})
|
||||
}
|
||||
|
||||
// register the wrapped hook handler
|
||||
hookBindFunc.Call([]reflect.Value{handler})
|
||||
bind(app)
|
||||
declared.Register(bind)
|
||||
})
|
||||
}
|
||||
|
||||
return declared
|
||||
}
|
||||
|
||||
func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
||||
|
||||
+12
-1
@@ -316,7 +316,7 @@ func (p *plugin) registerHooks() error {
|
||||
// initialize the loader vm
|
||||
loader := goja.New()
|
||||
sharedBinds(loader)
|
||||
hooksBinds(p.app, loader, executors)
|
||||
declared := hooksBinds(p.app, loader, executors)
|
||||
cronBinds(p.app, loader, executors)
|
||||
routerBinds(p.app, loader, executors)
|
||||
|
||||
@@ -341,6 +341,17 @@ func (p *plugin) registerHooks() error {
|
||||
}()
|
||||
}
|
||||
|
||||
// An extension's hooks belong to every Base this process opens. hooksBinds
|
||||
// stated them on the one that loaded the files; this states them on each
|
||||
// org's Base as it is opened, the same way JS migrations already apply to
|
||||
// all of them. Inside a hook body $app is the Base the event fired on, so
|
||||
// what a hook reads and writes is that Base and no other.
|
||||
//
|
||||
// cronAdd and routerAdd stay on this Base alone. A tick arrives on no Base
|
||||
// and a process has one router, so neither has anything per-Base to be
|
||||
// handed; a hook does, and that is the whole difference.
|
||||
core.AppBindings.Register(declared.Apply)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/base/tests"
|
||||
)
|
||||
|
||||
// A Base looks after itself. The periodic checkpoint and the log cleanup are
|
||||
// registered where a Base is constructed and where its logger is built, and a
|
||||
// schedule starts ticking when it is added — so an org's Base keeps its own
|
||||
// write-ahead log checkpointed and its own logs inside its own retention,
|
||||
// without anything serving on it.
|
||||
//
|
||||
// The alternative reading is easy to arrive at and wrong: __hzCronStart__ is
|
||||
// bound on OnServe, and a tenant's Base is bootstrapped and never served, so it
|
||||
// looks as though its schedules never start. Start resumes what Stop paused;
|
||||
// nothing here is ever paused. The two schedules below are what would silently
|
||||
// stop if that ever became true, and neither failure announces itself — a Base
|
||||
// that is not checkpointed grows a write-ahead log, and one whose logs are not
|
||||
// cleaned keeps them past the days its settings allow.
|
||||
//
|
||||
// The autobackup schedule is the third of the set and is absent until a Base's
|
||||
// settings name a cron for it, which is the same on every Base.
|
||||
func TestATenantsBaseMaintainsItself(t *testing.T) {
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Cleanup()
|
||||
|
||||
b := newBases(&plugin{app: app, orgDB: NewOrgDB(app, "")})
|
||||
|
||||
tenant, err := b.base("acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range []string{"__hzDBOptimize__", "__hzLogsCleanup__"} {
|
||||
if !tenant.Cron().HasJob(id) {
|
||||
t.Fatalf("an org's Base does not run %s, so nothing maintains it", id)
|
||||
}
|
||||
}
|
||||
|
||||
if tenant.Cron().HasJob("__hzAutoBackup__") {
|
||||
t.Fatal("an org's Base scheduled a backup its settings did not ask for")
|
||||
}
|
||||
|
||||
// Registered is not running. This is the half that the OnServe reading
|
||||
// would take away.
|
||||
if !tenant.Cron().HasStarted() {
|
||||
t.Fatal("an org's Base registered its maintenance and never started it")
|
||||
}
|
||||
|
||||
// And they belong to the Base: closing it stops them, so a process that
|
||||
// opens many orgs is not left ticking for the ones it has released.
|
||||
b.close()
|
||||
|
||||
if tenant.Cron().HasStarted() {
|
||||
t.Fatal("an org's Base kept ticking after it was closed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user