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:
2026-08-13 19:56:46 -07:00
co-authored by Hanzo Dev
parent 6f05491bcc
commit 616e4b58d7
6 changed files with 419 additions and 31 deletions
+1 -1
View File
@@ -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
View File
@@ -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)
}
}
+280
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+62
View File
@@ -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")
}
}