Extending slonbase
slonbase can be extended in two ways. JavaScript hooks are *.
files that need no build step. The Rust library lets you build your own binary with
native hooks and routes.
#JavaScript hooks
Put *. files in the hooks directory: slonbase_hooks by default, / in Docker,
or / for the packages. slonbase loads them on start and restarts when
they change. The JavaScript API is compatible with PocketBase's, so hooks written for PocketBase
mostly work as they are (see what's different). The engine
is QuickJS (ES2023); there's no Node.js API or npm.
// slonbase_hooks/main.pb.js
onRecordCreateRequest((e) => {
e.record.set("status", "draft");
e.next(); // validates and saves the record
}, "posts");
onRecordAfterCreateSuccess((e) => {
console.log("new post", e.record.id);
e.next();
}, "posts");
routerAdd("GET", "/api/hello/{name}", (e) => {
const posts = $app.findRecordsByFilter("posts", "status = {:s}", "-created", 10, 0, { s: "draft" });
return e.json(200, { hello: e.request.pathValue("name"), drafts: posts.length });
}, $apis.requireAuth());
cronAdd("cleanup", "0 3 * * *", () => {
$app.db().newQuery("DELETE FROM posts WHERE status = {:s}").bind({ s: "spam" }).execute();
});#What's available
Hooks:
| Hook | When it runs |
|---|---|
onRecord{, onRecordsListRequest | Around record API requests. A handler can change e., throw, or answer itself instead of calling e.. |
onRecord{ | Model hooks: every write (API, batches, $app., the Rust record service), inside its transaction, before the record is written |
onRecordValidate | Every create and update, after the built-in validation passed |
onRecord{ | The write itself, after validation, inside its transaction: e. writes the record, and code after it sees it as written. Field changes before e. are written (checked like the request's; files and passwords can't change here). Throwing, before or after e., rolls the write back; a handler that doesn't call e. cancels it, and the write fails. Cascaded deletes run them too. |
onRecordAfter{ | After the change is committed, in the background |
onRecordAfter{ | After a write failed, in the background: e. (what a create would have written, or the stored record with an update's data), e. (an ApiError). Validation and hook errors count, and so do writes a failed transaction undid (the other requests of a failed batch). Requests the rules refuse don't. |
onCollection{, onCollectionAfter{ | Changes through the collections API, imports included |
onCollectionValidate, onCollection{, onCollectionAfter{ | Collection changes through the API (imports run only the hooks above): after validation, around the schema change (e. applies it), and after a failure (e.). Changes to e. in these are ignored; make them in onCollectionCreate. |
onModel{, onModel{, onModelAfter{ | The record and collection hooks above for any model: e. is the record or the collection. Tags are collection names, and "_collections" for collections; untagged handlers see both. They run inside the record or collection hooks' e., as in PocketBase. |
onRecordAuthRequest | Every sign-in, token refresh and impersonation, before the MFA check. A handler can change e. (in the response only), e. and e. (e. says how the record signed in), or throw. |
onRecordAuthWithPasswordRequest, onRecordAuthWithOAuth2Request, onRecordAuthWithOTPRequest, onRecordAuthRefreshRequest | Around each sign-in. e. is null when no record matched the identity, and a handler can set it, to find records its own way. Password: e., e., e.; OAuth2: e., e., e., e.; OTP: e.. |
onRecordRequestOTPRequest, onRecordRequest{, onRecordConfirm{ | Around the email flows. e. is the one-time password about to be sent, e. the new address. A handler that doesn't call e. skips the action (and the email). |
onRecordEnrich | Every record sent to a client: in responses, batches, auth responses, expanded relations and realtime messages. e. adds keys, hide( and unhide( change which fields are sent (superusers always get every field). |
onMailerSend, onMailerRecord{ | Before an email is sent |
onRealtimeConnectRequest | A realtime connection being opened: e. (id(, subscriptions(, get(), e., e. (nanoseconds, like Go's durations; 0, the default, means none). Throw to refuse it. Unlike PocketBase, e. returns once the connection is open, not when it closes. |
onRealtimeSubscribeRequest | A client setting its subscriptions: change e. (topic strings) or throw |
onRealtimeMessageSend | Every message to a realtime client, PB_CONNECT included: e., e. (JSON text; toString( works as in PocketBase). Skip it by not calling e.; throwing closes the connection. |
onFileDownloadRequest | A file download (can be limited to collections): e., e., e. (a storage key), e., e.. Change what's served, throw, or answer yourself. |
onFileTokenRequest | A protected-file token request (can be limited to the caller's collection): e. (the caller), e. |
onSettingsListRequest, onSettingsUpdateRequest | The settings API: e. (secrets masked); e. and e. (what's saved, validated like the request) |
onSettingsReload | After the settings changed (here, on another instance, after a restore) and once at startup, in the background: e. |
onBackupCreate, onBackupRestore | Every backup (the API, automatic backups, $app.) and restore: e. can be changed; e. is accepted but ignored (backups always hold the database and the files). Throw to cancel. |
onCollectionsListRequest | The collections list (superusers): e. (Collection objects, the same array as e.), e. (the page) |
onCollectionViewRequest, onCollectionDeleteRequest | One collection: e. |
onCollectionCreateRequest, onCollectionUpdateRequest | e., what's saved (change it, or throw); after e., as saved |
onCollectionsImportRequest | e. (the imported definitions), e. |
onBatchRequest | A batch: e. (the requests, each with method, url, headers, body). Change them, throw, or answer yourself; each request still runs its own record hooks. |
onBootstrapClear | When the server stops, with onTerminate |
onTerminate | When the server stops, after it stopped taking requests: e. (a restart after the hook files changed). Handlers get at most 5 seconds. |
onBootstrap | Once the app is ready |
routerAdd( | A custom route. Middlewares: $apis., requireSuperuserAuth(, requireSuperuserOrOwnerAuth(, requireGuestOnly(. |
routerUse( | Middleware around every request; e. continues |
cronAdd(, cronRemove( | Scheduled jobs (UTC). They run on one instance per minute and show in the dashboard. |
Record and file hooks take collection names (or ids) after the handler to limit them to those collections.
Globals:
$app:findRecordById,findRecordsByFilter,findFirstRecordByFilter,findAuthRecordByEmail,countRecords,findCollectionByNameOrId,findAllCollections,saveanddelete(of records, collections and, forsave, the settings),importCollections,db(and) recordQuery((see SQL),collection) settings(,) newMailClient(,). send( . . . ) createBackup(andname) restoreBackup((an optional context argument first is accepted and ignored).name) Collection,Field(andTextField,NumberField, ...),unmarshal: collection definitions, as in migrations.Record:new Record(,collection) get/set/getString/getInt/getBool/...,email(,) setPassword,validatePassword,hide/unhide, and tokens:newAuthToken,newStaticAuthToken(,duration) newVerificationToken,newPasswordResetToken,newEmailChangeToken(,newEmail) newFileToken. As in PocketBase,set(takes modifiers:) tags+/+tags/tags-on select, relation and file fields,views+/views-on numbers,code:autogenerateon text fields;set(and"expand", . . . ) get(are the expand, and other keys are kept as given."expand") $mails:sendRecordVerification(,$app, record) sendRecordPasswordReset,sendRecordChangeEmail(,app, record, newEmail) sendRecordOTP(,app, record, otpId, password) sendRecordAuthAlert(: the emails the auth endpoints send, with the collection's templates.app, record, info) $dbx: query expressions (see SQL).$template: Go templates, rendered to HTML (see below).$filesystem:fileFromBytes,fileFromPath,fileFromURL(http(only); in routes,s) e.returns a multipart request's files. Files are limited to 32 MiB.findUploadedFiles( key) $http..send( { url, method, headers, body, timeout}) $security:randomString,randomStringWithAlphabet,randomStringByRegex,sha256,sha512,hs256,md5,equal, ...$os.,getenv console., andlog/ info/ warn/ error require(for modules in the hooks directory.) toString,toBytes(andvalue, maxBytes) readerToString(text is UTF-8; readers are strings or bytes here), andnullString(,) nullInt(, ... for nullable) DynamicModelcolumns.- Errors:
BadRequestError,NotFoundError,ForbiddenError,UnauthorizedErrorandApiError(become API errors with that status. Other errors answer 400 and are logged.status, message, data)
#SQL
$app. is a query builder, and $dbx its expressions (the same API as PocketBase's dbx).
They write PostgreSQL. Unqualified names are collections (tables in the slonbase schema), then tables in
public.
const rows = arrayOf(new DynamicModel({ title: "", total: 0 }));
$app.db()
.select("title", "count(*) as total")
.from("posts")
.where($dbx.and($dbx.hashExp({ status: "published" }), $dbx.like("title", "news")))
.groupBy("title")
.orderBy("total DESC")
.limit(10)
.all(rows);
// records, with their field types
const records = $app.recordQuery("posts").where($dbx.in("status", "draft", "review")).all();
$app.db().newQuery("DELETE FROM {{posts}} WHERE [[status]] = {:s}").bind({ s: "spam" }).execute();- Queries:
select(with. . . cols) andSelect,distinct,from,where/andWhere/orWhere,innerJoin/leftJoin/rightJoin,groupBy,having,orderBy,limit,offset,union,bind, thenbuild(,) all(,result) one((throws when there's no row) orresult) column(.) newQuery(takes SQL withsql) {parameters,:name} {and{ table}} [[column]]. - Writes:
insert(,table, cols) upsert(,table, cols, . . . conflictColumns) update(,table, cols, where) delete(, thentable, where) execute(. These skip record hooks and validation; use) $app.for records.save( ) - Schema:
createTable,dropTable,renameTable,truncateTable,addColumn,dropColumn,renameColumn,alterColumn,createIndex,createUniqueIndex,dropIndex,addPrimaryKey,addForeignKeyand their drops. New tables go to theslonbaseschema unless you name another (public.).my_table $dbx:exp(,sql, params) hashExp(({ col: value}) nullisIS NULL, a list isIN),not,and,or,in,notIn,like,orLike,notLike,orNotLike(with.andmatch( left, right) .),escape( . . . ) exists,notExists,between,notBetween.
#Templates
$template renders Go's html/ language:
routerAdd("GET", "/hello/{name}", (e) => {
const html = $template
.loadFiles(`${__hooks}/views/layout.html`, `${__hooks}/views/hello.html`)
.render({ name: e.request.pathValue("name") });
return e.html(200, html);
});The first file is rendered, and it usually uses blocks the others define
({). loadString( works the same for a single template, and
$template. adds functions templates can call.
Everything printed is HTML-escaped, except what raw returns. Unlike Go, the escaping doesn't
depend on where the value is: inside <script> or a URL, use the js and urlquery
functions.
#How it runs
- Runtimes: each of the
--hooks-poolruntimes (10 by default) loads every file, so top-level code runs once per runtime. Keep state in the database rather than in variables. When all of them are busy, extra runtimes start, and they stop again when idle. Handlers can nest: arouterUsehandler waits while the route runs, and the route may run a model hook. - Model hooks run before the record is written, inside its transaction. Changes to
e.are written, and throwing cancels the write. The record is written once the handlers return, so code afterrecord e.still sees it unsaved; usenext( ) onRecordAfter*Successfor work after the write. Don't write the same record through$appfrom its own model hook; changee.instead. Records deleted by a cascade runrecord onRecordDeletetoo, and a handler's error cancels the whole delete. - Validation hooks run after the built-in validation passed. Throw (e.g. a
BadRequestErrorwith field data) to reject the record. - Collection hooks see
e., acollection Collectionwith the collection's JSON. Create and update handlers may change it before it's validated and applied. - Mail hooks see
e.(message to,subject,html) and, for auth emails,e.andrecord e.. A handler that doesn't callkind e.skips the email.next( ) - Files: uploaded and
$filesystemfiles can be set on a file field before$app.. When they're mixed with stored file names, the names are kept and the files are appended.save( record) - Reloading: when the hook files change, slonbase restarts itself. It's the same process,
re-executed with the same arguments, after up to 5 seconds for open requests. Turn this off
with
hooks..watch = false
Build with --no-default-features to leave the JavaScript engine out.
#Rust
slonbase is also a Rust library. To add hooks, routes and cron jobs in Rust, make a binary
crate that depends on slonbase and runs its command line from your own main:
[dependencies]
slonbase = { git = "https://github.com/ayeshrodz/slonbase", tag = "v2.1.3" }
axum = "0.8"
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"use axum::routing::get;
#[tokio::main]
async fn main() -> std::process::ExitCode {
let mut ext = slonbase::Extensions::default();
// runs before the record is written; `next` writes it
ext.hooks.on_record_create_request.add(&["posts"], |mut e, next| async move {
e.record.insert("status".into(), "draft".into());
next.run(e).await
});
// runs after the change is committed (API writes, batches and the record service)
ext.hooks.on_record_after_create_success.add(&["posts"], |e, next| async move {
tracing::info!(id = %e.record["id"], "new post");
next.run(e).await
});
ext.hooks.cron("nightly", "0 3 * * *", |core| async move {
let old = core.count_records("posts", "created < {:t}", &serde_json::Map::from_iter([
("t".into(), "2025-01-01".into()),
])).await?;
tracing::info!(old, "old posts");
Ok(())
})
.expect("a valid cron expression");
ext.route("/api/hello", get(|| async { "Hello!" }));
slonbase::cli::run(ext).await
}Hooks: on_record_{, on_records_list_request (around API
requests; a hook can change the record, return an error, or set e. instead of calling
next) and on_record_after_{ (after commit, in the background).
hooks./_update/_delete, on_record_validate, on_collection_* and
on_mailer_send are the model, validation, collection and mail hooks described above.
hooks. is middleware around every request (e., e.; next routes it and
sets e.). hooks. runs when the app is ready. Cron jobs run on one instance per scheduled minute
and show up in the dashboard. Hooks, routes and cron jobs can read and write records with
core., find_records_by_filter, find_first_record_by_filter,
count_records, save_record and delete_record (no API rules; filters take {
placeholders). Changes made with these reach realtime subscribers at once. SQL run on core.
directly reaches them too, through the tables' triggers, but within about 5 seconds unless you
call slonbase::realtime::written( after committing (slonbase's connections don't send
a notification per write).