PocketBase Hooks — quality + safety report
In the Skillier index (davila7__pb-hooks) · scanned 2026-06-03 · engine: builtin+triage
✓ Clean — no heuristic safety flags surfaced.
Heuristic flags from the builtin scanner, which is known to over-flag (it trips on legitimate env-reading integrations, security skills, and library .eval calls). This is NOT an authoritative malicious verdict — re-scan with SkillSpector for the authoritative result. Run the authoritative scan →
📇 This skill is in the Skillier index (curated · deduped · quality-filtered). Install Skillier to route & load it into your AI client.
Quality notes
About this skill
Server-side JavaScript hooks for PocketBase pb hooks . Use when writing custom routes, event hooks, cron jobs, sending emails, making HTTP requests, querying the database, or extending PocketBase with server-side logic. Covers the goja ES5 runtime, routing, middleware, all event hooks, DB queries,…
📄 Read the SKILL.md
---
name: "PocketBase Hooks"
description: "Server-side JavaScript hooks for PocketBase (pb_hooks). Use when writing custom routes, event hooks, cron jobs, sending emails, making HTTP requests, querying the database, or extending PocketBase with server-side logic. Covers the goja ES5 runtime, routing, middleware, all event hooks, DB queries, record operations, and global APIs."
---
# PocketBase Server-Side JavaScript (pb_hooks)
## Runtime Basics
- Files go in `pb_hooks/*.pb.js` (must end with `.pb.js`)
- Engine: **goja** — ES5.1 + some ES6. **No ES6 modules** (`import`/`export`), **no async/await**, **no arrow functions in older versions**. Use `function(){}` and CommonJS `require()`.
- Each file is loaded on app start and on hot-reload
- `__hooks` — absolute path to the pb_hooks directory
- TypeScript declarations: `pb_data/types.d.ts` (auto-generated, useful for IDE support)
- `--hooksPool=25` flag controls concurrent JS goroutines (default: 25)
- Each handler runs in an isolated context — no shared mutable state between requests
## Routing
### Adding routes
```js
routerAdd("GET", "/api/hello/{name}", function(e) {
var name = e.request.pathValue("name")
return e.json(200, { "message": "Hello " + name })
}, /* optional middleware */)
```
### Path patterns
- `{name}` — named path parameter
- `{path...}` — wildcard (matches rest of path)
- `{$}` — exact match (no trailing slash)
### Response methods
| Method | Usage |
|--------|-------|
| `e.json(status, data)` | JSON response |
| `e.string(status, text)` | Plain text |
| `e.html(status, html)` | HTML response |
| `e.redirect(status, url)` | Redirect (301/302) |
| `e.blob(status, contentType, bytes)` | Binary data |
| `e.stream(status, contentType, reader)` | Streaming response |
| `e.noContent(status)` | No body (204) |
### Reading request data
```js
// Body (JSON)
var body = new DynamicModel({ name: "", age: 0 })
e.bindBody(body)
// Query params
var page = e.request.url.query().get("page")
// Headers
var token = e.request.header.get("Authorization")
// Uploaded files
var files = e.findUploadedFiles("document") // returns array of *filesystem.File
// Auth state
var user = e.auth // current auth record or null
var isSuper = e.hasSuperuserAuth()
```
## Middleware
### Built-in middleware
```js
routerAdd("GET", "/api/protected", handler,
$apis.requireAuth(), // any authenticated user
// OR
$apis.requireAuth("users"), // only "users" collection
// OR
$apis.requireSuperuserAuth(), // superusers only
// OR
$apis.requireGuestOnly(), // unauthenticated only
// OR
$apis.bodyLimit(5 * 1024 * 1024), // 5MB body limit
// OR
$apis.gzip() // gzip compression
)
```
### Global middleware
```js
routerUse(function(e) {
// runs before every request
console.log(e.request.method, e.request.url.path)
return e.next() // MUST call e.next() to continue
})
```
### Custom route middleware
```js
function myMiddleware(e) {
// pre-processing
var result = e.next() // call next handler
// post-processing
return result
}
routerAdd("GET", "/api/test", handler, myMiddleware)
```
Priority: middleware runs in order — first registered, first executed.
## Event Hooks
### Record lifecycle
Each record event has 3 variants:
- `onRecord*Execute` — wraps the default action. Call `e.next()` to proceed.
- `onRecord*AfterSuccess` — runs after successful execution
- `onRecord*AfterError` — runs after execution error
```js
// Before/during create
onRecordCreateExecute(function(e) {
// e.record — the record being created
e.record.set("status", "pending")
return e.next() // proceed with creation
}, "posts") // optional collection filter
// After successful create
onRecordAfterCreateSuccess(function(e) {
// e.record — the created record (has ID now)
console.log("Created:", e.record.id)
}, "posts")
// After failed create
onRecordAfterCreateError(function(e) {
// e.error — the error
console.log("Failed:", e.error)
}, "posts")
```
### All record hooks
| Hook | Event object fields |
|------|-------------------|
| `onRecordCreateExecute` | `e.record` |
| `onRecordUpdateExecute` | `e.record` |
| `onRecordDeleteExecute` | `e.record` |
| `onRecordAfterCreateSuccess` | `e.record` — after successful create |
| `onRecordAfterUpdateSuccess` | `e.record` — after successful update |
| `onRecordAfterDeleteSuccess` | `e.record` — after successful delete |
| `onRecordAfterCreateError` | `e.record`, `e.error` — after failed create |
| `onRecordAfterUpdateError` | `e.record`, `e.error` — after failed update |
| `onRecordAfterDeleteError` | `e.record`, `e.error` — after failed delete |
| `onRecordValidate` | `e.record` — add custom validation errors |
| `onRecordEnrich` | `e.record` — modify API response (hide/add fields) |
| `onRecordsListRequest` | `e.records`, `e.result` — modify list response |
| `onRecordRequestCreate` | `e.record` — during API create request |
| `onRecordRequestUpdate` | `e.record` — during API update request |
| `onRecordRequestDelete` | `e.record` — during API delete request |
### Auth hooks
```js
onRecordAuthWithPasswordRequest(function(e) {
// e.record — the auth record
// e.password — the provided password
return e.next()
}, "users")
onRecordAuthWithOAuth2Request(function(e) {
// e.record — the auth record (may be new)
// e.oAuth2User — OAuth2 user data
// e.isNewRecord — true if first OAuth2 login
return e.next()
}, "users")
onRecordAuthWithOTPRequest(function(e) {
// e.record — the auth record
return e.next()
}, "users")
onRecordAuthRefreshRequest(function(e) {
return e.next()
}, "users")
```
### Realtime hooks
```js
onRealtimeConnectRequest(function(e) {
// e.client — the SSE client
// e.idleTimeout — connection timeout
return e.next()
})
onRealtimeSubscribeRequest(function(e) {
// e.client
// e.subscriptions — requested subscriptions
return e.next()
})
```
### Other hooks
```js
onFileDownloadRequest(function(e) {
// e.record, e.fileField, e.servedPath, e.servedName
return e.next()
}, "documents")
onBatchRequest(function(e) {
// e.batch — array of sub-requests
return e.next()
})
onCollectionCreateExecute(function(e) {
// e.collection
return e.next()
})
// App lifecycle
onBootstrap(function(e) {
// runs once on app start (after DB is ready)
return e.next()
})
onTerminate(function(e) {
// runs on graceful shutdown
return e.next()
})
```
### Validation hook
```js
onRecordValidate(function(e) {
if (e.record.getString("title").length < 3) {
e.error = new ValidationError("title", "Title must be at least 3 characters")
}
return e.next()
}, "posts")
```
### Enrich hook (modify API response)
```js
onRecordEnrich(function(e) {
// Hide field from non-owners
if (!e.requestInfo.auth || e.requestInfo.auth.id !== e.record.getString("author")) {
e.record.hide("private_notes")
}
// Add computed field
e.record.withCustomData(true)
e.record.set("displayName", e.record.getString("first") + " " + e.record.getString("last"))
return e.next()
}, "users")
```
## Database
### Query builder
```js
var results = arrayOf(new DynamicModel({ id: "", title: "", count: 0 }))
$app.db()
.select("id", "title", "COUNT(comments) as count")
.from("posts")
.where($dbx.hashExp({ status: "active" }))
.andWhere($dbx.like("title", "hello"))
.orderBy("created DESC")
.limit(10)
.offset(0)
.all(results) // populates results array
```
### Execution methods
| Method | Returns |
|--------|---------|
| `.all(results)` | Populates array |
| `.one(result)` | Single record |
| `.execute()` | For INSERT/UPDATE/DELETE |
### Raw queries
```js
$app.db().newQuery("SELECT * FROM posts WHERE status = {:status}")
.bind({ status: "active" })
.all(results)
```
**Always use named params `{:param}`** — never concatenate SQL strings.
### $dbx expressions
```js
$dbx.hashExp({ field: "value" }) // field = "value"
$dbx.hashExp({ field: ["a", "b"] }) // field IN ("a", "b")
$dbx.not($dbx.hashExp({ field: "value" })) // NOT (field = "value")
$dbx.and(expr1, expr2) // expr1 AND expr2
$dbx.or(expr1, expr2) // expr1 OR expr2
$dbx.like("field", "val") // field LIKE "%val%"
$dbx.orLike("field", "a", "b") // field LIKE "%a%" OR field LIKE "%b%"
$dbx.notLike("field", "val") // field NOT LIKE "%val%"
$dbx.in("field", "a", "b", "c") // field IN ("a", "b", "c")
$dbx.notIn("field", "a", "b") // field NOT IN ("a", "b")
$dbx.between("field", 1, 10) // field BETWEEN 1 AND 10
$dbx.exists($dbx.exp("SELECT 1 FROM t WHERE ..."))
$dbx.exp("raw SQL expression", optionalParams)
```
### Transactions
```js
$app.runInTransaction(function(txApp) {
// use txApp instead of $app inside transaction
var record = txApp.findRecordById("posts", "RECORD_ID")
record.set("views", record.getInt("views") + 1)
txApp.save(record)
})
```
## Record Operations
### Find records
```js
// By ID
var record = $app.findRecordById("posts", "RECORD_ID")
// By field value
var record = $app.findFirstRecordByData("users", "email", "user@example.com")
// By filter expression (same syntax as API rules)
var record = $app.findFirstRecordByFilter("posts", "slug = {:slug}", { slug: "my-post" })
// Multiple records with filter
var records = $app.findRecordsByFilter(
"posts", // collection
"status = 'active'", // filter
"-created", // sort
10, // limit
0 // offset
)
// All records (no limit)
var records = $app.findAllRecords("posts", $dbx.hashExp({ status: "active" }))
// Count
var total = $app.countRecords("posts", $dbx.hashExp({ status: "active" }))
```
### Create records
```js
var collection = $app.findCollectionByNameOrId("posts")
var record = new Record(collection)
record.set("title", "My Post")
record.set("author", "USER_ID")
record.set("tags", ["tag1", "tag2"]) // multi-relation
$app.save(record)
// record.id is now set
```
### Update records
```js
var record = $app.findRecordById("posts", "RECORD_ID")
record.set("title", "Updated Title")
$app.save(record)
```
### Delete records
```js
var record = $app.findRecordById("posts", "RECORD_ID")
$app.delete(record)
```
### Record getters
```js
record.id
record.getString("title")
record.getInt("count")
record.getFloat("price")
record.getBool("active")
record.getStringSlice("tags") // for multi-valued fields
record.getDateTime("created") // returns DateTime object
record.get("field") // raw interface{} value
```
### Expand relations
```js
$app.expandRecord(record, ["author", "tags"], null)
var author = record.expandedOne("author") // single relation
var tags = record.expandedAll("tags") // multi relation
```
### File operations
```js
// Assign file from path
var file = $filesystem.fileFromPath("/path/to/file.pdf")
record.set("document", file)
// Assign file from bytes
var file = $filesystem.fileFromBytes(byteArray, "report.pdf")
record.set("document", file)
// Assign file from URL
var file = $filesystem.fileFromURL("https://example.com/file.pdf")
record.set("document", file)
$app.save(record)
```
## Cron Jobs
```js
cronAdd("daily_cleanup", "0 3 * * *", function() {
// runs every day at 3:00 AM
var old = $app.findRecordsByFilter("temp", "created < @now - 30d", "", 0, 0)
for (var i = 0; i < old.length; i++) {
$app.delete(old[i])
}
})
cronRemove("daily_cleanup") // remove a previously registered job
```
Cron expressions: `minute hour day month weekday`
Preview registered crons: Dashboard > Settings > Crons
## Email
```js
var message = new MailerMessage()
message.from = { addre
… (truncated)Want a live grade + an embeddable README badge? Run your skill through the free scanner.
Graded independently by Skillproof — nothing to sell the author. Quality is mechanical + corpus-grounded; safety flags are heuristic (builtin+triage), not a malicious verdict.