Files
base/migrations/1781000000_functions.go
zeekayandHanzo Dev 6f05491bcc a function is a record, and it reads as whoever invoked it
Both halves of this existed and neither was a function: one runtime knew how
to invoke by name and bound no host, the other bound every host and could not
invoke. They are joined through the runtime interface that was already here
and used by nothing outside its own tests.

A function lives in a collection, so it is the tenant's own file, its rules
are the rules, and the two doors — the collection and /v1/functions — refuse
in the same words because there is one rule. The source is the server's: a
caller who may see that a function exists does not get what it says.

What a function may ask for is two reads of the Base it is running in, and
that is the whole host. Not the app, not a process, not a file, not the
network — a name bound in a first version is bound for good, so the list is
what a function needs and nothing anticipated.

It reads as its caller. The collection comes from the Base the credential
already resolved, and the rule from the caller's own identity, so a function
renders what the caller could have read and is never a way around them. The
payload does not answer a rule: `@request.body` is about a write, and letting
an invocation body speak for it would let a caller satisfy a read rule by
naming a field they sent.

Invoking counts under its own action, because running code is the cost.
A function that does not finish is stopped.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 19:56:34 -07:00

69 lines
2.5 KiB
Go

package migrations
import (
"github.com/hanzoai/base/core"
)
// Functions live in the Base they run in.
//
// A collection rather than a directory, because everything a function needs is
// already what a record has: an org's Base is its own file, so a tenant's
// functions are in that file and no query can reach across two; the rules on
// this collection are who may write one and who may call one, evaluated by the
// same record path as any other row; and a function rides the backups, the WAL
// replication and the audit log without any of them learning a new kind of
// thing. Code on disk would be one directory for every tenant in the process.
//
// The id IS the name. A function has one identity, so `POST /v1/functions/greet`
// names the same thing the primary key does, uniqueness is the primary key's
// job, and no route has to turn one identifier into another.
//
// The source is hidden and the rules are nil, which together say the same thing
// twice on purpose: out of the box a function is written and read by a superuser
// and by nobody else. Hidden is the stronger half — a non-superuser's write has
// hidden fields dropped before it reaches the record, so opening the rules alone
// does not open authorship.
//
// It is NOT a system collection, for the one reason that matters: a system
// collection's rules can never be changed, and who may call a function is
// precisely what a deployment has to be able to say. The scheduling collections
// are shipped the same way, by the same list, for the same reason.
func init() {
core.SystemMigrations.Register(func(txApp core.App) error {
if _, err := txApp.FindCollectionByNameOrId(core.CollectionNameFunctions); err == nil {
return nil
}
c := core.NewBaseCollection(core.CollectionNameFunctions)
c.Fields.Add(
&core.TextField{
Name: core.FieldNameId,
System: true,
PrimaryKey: true,
Required: true,
Min: 1,
Max: 63,
// A name a URL can carry whole and a log line can be read out of.
Pattern: `^[a-z0-9][a-z0-9_-]*$`,
},
&core.TextField{
Name: core.FieldNameSource,
Required: true,
Hidden: true,
Max: 256 * 1024,
},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
return txApp.Save(c)
}, func(txApp core.App) error {
c, err := txApp.FindCollectionByNameOrId(core.CollectionNameFunctions)
if err != nil {
return nil
}
return txApp.Delete(c)
})
}