skills/local/pocketbase/SKILL.md
PocketBase v0.39+ development - API rules, auth, collections, SDK, realtime, files, Go/JS extending, deployment, production tuning.
npx skillsauth add renatocaliari/agent-sync-public-skills pocketbaseInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Auto-invoke: triggers on PocketBase, pb_hooks, pb_migrations topics. No external files — all rules inline.
Target: v0.39.1. Verified against current docs. Breaking changes noted inline.
./pocketbase serve # dev on :8090
./pocketbase superuser create [email protected] pass123
./pocketbase --dir=./mydata serve # custom pb_data
./pocketbase --encryptionEnv=PB_ENCRYPTION_KEY serve
Routes: :8090/ (static pb_public), :8090/_/ (admin), :8090/api/ (REST).
| Type | Use |
|------|-----|
| auth | User accounts, any entity that logs in (built-in password, OAuth2, OTP, MFA, token mgmt) |
| base | Regular data: posts, products, orders |
| view | Read-only SQL aggregations (must return id column) |
| Field | When |
|-------|------|
| text | Strings, optional min/max/regex |
| number | Integers/floats, min/max |
| bool | true/false |
| email | Email validation |
| url | URL validation |
| date / autodate | Dates (auto on create/update) |
| select | Single/multi predefined values |
| json | Arbitrary JSON |
| file | Attachments, maxSelect/maxSize/mimeTypes/thumbs |
| relation | Ref to another collection, cascadeDelete |
| editor | Rich text HTML |
| geopoint | {lon, lat} — supports geoDistance() in filters |
Index every field used in filter, sort, or API rules. Composite indexes order LTR.
CREATE INDEX idx_posts_author_status ON posts(author, status);
cascadeDelete: true for dependent data (comments on post)cascadeDelete: false for important data (orders, transactions) — blocks deletion// Create
await pb.collection('places').create({
location: { lon: -73.9857, lat: 40.7484 } // lon FIRST
});
// Query nearby
await pb.collection('places').getList(1, 50, {
filter: pb.filter('geoDistance(location, {:point}) <= {:km}', { point: { lon, lat }, km: 5 }),
sort: pb.filter('geoDistance(location, {:point})', { point: { lon, lat } })
});
| Value | Access | Use |
|-------|--------|-----|
| null | Locked — superusers only | Admin data, system tables |
| "" (empty) | Open to all | Public content |
| "expression" | Conditional | Ownership, role checks |
| Field | Use |
|-------|-----|
| @request.auth.id | Authed user ID (empty if guest) |
| @request.auth.* | Any auth record field (role, verified) |
| @request.body.* | Request body (create/update only) |
| @request.query.* | URL params (user-controlled — don't use for authz!) |
| @request.context | default, oauth2, otp, password, realtime, protectedFile |
| @request.method | HTTP method |
@request.body.field:isset — true if field being sent@request.body.field:changed — true if changed from current@request.body.field:length — array/string length// Owner-only
listRule: 'owner = @request.auth.id'
viewRule: 'owner = @request.auth.id'
createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id'
updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false'
deleteRule: 'owner = @request.auth.id'
// Public read, authenticated write
listRule: '' viewRule: '' createRule: '@request.auth.id != ""'
// Role-based (@collection cross-lookup)
listRule: '@collection.team_members.user ?= @request.auth.id && @collection.team_members.team ?= team'
// Admin only
listRule: '@request.auth.role = "admin"'
strftime(fmt, dt) — date part extraction (v0.36+): strftime('%Y-%m', created) = "2026-03"length(field) — multi-value counteach(field, expr) — iterate multi-value: each(tags, ? ~ "urgent")issetIf(field, val) — conditional presence checkgeoDistance(geopoint, point) — km distance| Rule fail | HTTP | |-----------|------| | ListRule | 200 empty items | | View/Update/DeleteRule | 404 (hides existence) | | CreateRule | 400 | | Locked rule | 403 |
await pb.collection('users').authWithPassword('email', 'password');
// Generic error on fail — never leak email existence
const { otpId } = await pb.collection('users').requestOTP('[email protected]');
// Always returns otpId (even if email missing) — do not break enumeration protection
const authData = await pb.collection('users').authWithOTP(otpId, '12345678');
requestOTP (built-in rate limiter, label *:requestOTP)authWithOTP consumes the code, invalidates otpId// All-in-one (recommended for web)
await pb.collection('users').authWithOAuth2({ provider: 'google', createData: { name: '' } });
// Manual code exchange (React Native, deep links)
const methods = await pb.collection('users').listAuthMethods();
const provider = methods.oauth2.providers.find(p => p.name === 'google');
// Store provider.state and provider.codeVerifier, redirect to provider.authURL + redirectURL
// In callback: pb.collection('users').authWithOAuth2Code(provider.name, code, codeVerifier, redirectURL)
Redirect URL: https://yourdomain.com/api/oauth2-redirect
// Step 1: auth method A → catches 401 with mfaId
try { await pb.collection('users').authWithPassword('email', 'pass'); }
catch (err) {
const mfaId = err.response?.mfaId;
// Step 2: auth method B + mfaId
await pb.collection('users').authWithOTP(otpId, '12345678', { mfaId });
}
// Superuser generates token for another user
const client = await adminPb.collection('users').impersonate(userId, 3600);
// Non-renewable token! Generate fresh when expired.
pb.authStore.isValid — client-side (JWT expiry)authRefresh() — server verification, returns fresh tokenpb.authStore.loadFromCookie(cookie) / exportToCookie({ httpOnly, secure })LocalAuthStore (browser), AsyncAuthStore (React Native), BaseAuthStore (custom)import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Node.js: global.EventSource = EventSource from 'eventsource'
{ requestKey: null }{ requestKey: 'search' }pb.cancelRequest('key') / pb.cancelAllRequests()autoCancellation(false) globally disablesimport { ClientResponseError } from 'pocketbase';
try { ... }
catch (error) {
if (error instanceof ClientResponseError) {
error.status, error.response, error.isAbort;
// Handle 400 (validation), 401, 403, 404, 429
}
}
| Modifier | Field Types | Effect |
|----------|-------------|--------|
| field+ / +field | relation, file | Append/prepend |
| field- | relation, file | Remove |
| field+ | number | Increment |
| field- | number | Decrement |
await pb.collection('posts').update(id, { 'views+': 1, 'tags+': newTagId });
// ALWAYS use pb.filter() with params — never concatenate
pb.filter('title ~ {:q} && status = {:s}', { q: input, s: 'published' });
pb.beforeSend = (url, options) => { /* modify request */ return { url, options }; };
pb.afterSend = (response, data) => { /* process */ return data; };
// Single request, eliminates N+1
await pb.collection('posts').getList(1, 20, { expand: 'author,category,tags' });
// Nested: { expand: 'author.profile,category.parent' }
// Back-relation: { expand: 'comments_via_post,comments_via_post.author' }
// Back-relation syntax: {referencing_collection}_via_{relation_field}
await pb.collection('posts').getList(1, 20, {
fields: 'id,title,created,expand.author.name'
});
// Excerpt: content:excerpt(200,true) — truncate with ellipsis
// Single record by any field
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:e}', { e: email })
);
// Throws 404 if not found
const batch = pb.createBatch();
batch.collection('orders').create({ ...order, id: orderId });
items.forEach(item => batch.collection('order_items').create({ ...item, order: orderId }));
const results = await batch.send(); // all or nothing
⚠ Must be enabled in Settings → Application first.
// Cursor-based (infinite scroll)
const posts = await pb.collection('posts').getList(1, 20, {
skipTotal: true, sort: '-created'
});
const nextCursor = posts.items.at(-1)?.created;
expand for relationsPromise.all// Collection-wide
const unsub = await pb.collection('posts').subscribe('*', (e) => {
e.action; // 'create' | 'update' | 'delete'
e.record;
}, { expand: 'author', fields: 'id,title' });
// Specific record
await pb.collection('posts').subscribe('RECORD_ID', handler);
// Unsubscribe
unsub(); // single callback
pb.collection('posts').unsubscribe(); // all in collection
pb.realtime.unsubscribe(); // all subscriptions
pb.authStore.onChange)*, ViewRule for specific recordpb.realtime.subscribe('PB_CONNECT', (e) => { /* connected, clientId: e.clientId */ });
pb.realtime.onDisconnect = (subscriptions) => { /* handle reconnect */ };
subscribe(recordId) over subscribe('*') for high-trafficfilter, fields, expand to reduce payloadpb.files.getURL(record, record.image);
pb.files.getURL(record, record.image, { thumb: '100x100' });
// Protected files: get token first
const token = await pb.files.getToken();
pb.files.getURL(record, record.file, { token });
Thumbnail formats: WxH (fit), Wx0 (fit width), 0xH (fit height), WxHt (top crop), WxHb (bottom), WxHf (force).
// Plain object (auto-FormData)
await pb.collection('albums').create({ image: fileInput.files[0] });
// Multiple files
await pb.collection('albums').update(id, { images: files });
// Remove
await pb.collection('albums').update(id, { 'images-': filename });
await pb.collection('albums').update(id, { image: null }); // clear all
// Configure in Admin UI or via API:
{
maxSelect: 1,
maxSize: 5242880, // 5MB
mimeTypes: ['image/jpeg', 'image/png'],
thumbs: ['100x100', '200x200']
}
{cdnBase}/{collectionId}/{recordId}/{filename}./pocketbase serve --rateLimiter=true --rateLimiterRPS=10
Strategy (v0.36.7+): fixed-window. Worst case = 2x maxRequests at window boundary. Use Nginx/Caddy in front for defense in depth.
myapp.com {
reverse_proxy 127.0.0.1:8090 { flush_interval -1 }
header { X-Content-Type-Options "nosniff"; Strict-Transport-Security "..."; }
}
Nginx: proxy_buffering off, proxy_read_timeout 3600s (SSE).
await adminPb.backups.create('daily-backup');
await adminPb.backups.getFullList();
await adminPb.backups.restore(key);
Automate with cron; store off-site (S3).
ulimit -n 4096 # open files for realtime connections
GOMEMLIMIT=512MiB # Go GC memory cap in containers
PB_ENCRYPTION="..." # 32-char key for _params encryption at rest
export SMTP_HOST=smtp.sendgrid.net SMTP_PORT=587 SMTP_USER=... SMTP_PASS=...
Never ship [email protected] — configure Meta.senderAddress.
PocketBase defaults (auto-configured): WAL mode, busy_timeout=10s, cache_size=-32MB, foreign_keys=ON.
EXPLAIN QUERY PLANRequires Go 1.25+ (v0.36.7+). No CGO by default.
package main
import (
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/apis"
)
func main() {
app := pocketbase.New()
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/myapp/hello/{name}", func(e *core.RequestEvent) error {
return e.JSON(200, map[string]string{"msg": "hello " + e.Request.PathValue("name")})
}).Bind(apis.RequireAuth())
return se.Next()
})
if err := app.Start(); err != nil { log.Fatal(err) }
}
// pb_hooks/main.pb.js — filename must end in .pb.js
/// <reference path="../pb_data/types.d.ts" />
routerAdd("GET", "/api/myapp/hello/{name}", (e) => {
return e.json(200, { message: "Hello " + e.request.pathValue("name") });
}, $apis.requireAuth());
$app, $apis, $os, $security, $filesystem, $dbx, $mails, __hooksrequire() inside handler.require() works, ESM import does notrequire modules from node_modules/ or ${__hooks}/ pathssetTimeout/setInterval, no Node.js fs, no fetch — use $app.newHttpClient()--hooksPool=N)Always call e.Next() (Go) / e.next() (JS) — skipping it silently breaks the chain.
app.OnRecordAfterCreateSuccess("posts").BindFunc(func(e *core.RecordEvent) error {
// Use e.App (not captured outer `app`) — inside a tx, e.App IS txApp
return e.Next()
})
// Bind with Id for later Unbind:
app.OnRecordAfterCreateSuccess("posts").Bind(&hook.Handler{Id: "my-hook", Func: func(e *core.RecordEvent) error {
return e.Next()
}})
app.OnRecordAfterCreateSuccess("posts").Unbind("my-hook")
| Family | Examples | Has request context? |
|--------|----------|---------------------|
| Model | OnRecordCreate, OnRecord*Success | No — any save path |
| Request | OnRecord*Request, OnRecordsListRequest | Yes — HTTP only |
| Enrich | OnRecordEnrich | Yes — response serialization + realtime SSE |
⚠ Use OnRecordEnrich to hide/redact fields — it applies to realtime events too. Model/request hooks alone leak.
The single most common extending bug: using the wrong app instance. Here is which one is active at each layer of a request.
| Where you are | Use | Why |
|---|---|---|
| Route handler (func(e *core.RequestEvent)) | e.App | Top-level app; same object server started with |
| Inside RunInTransaction(func(txApp) { ... }) | txApp only | Capturing outer app deadlocks on SQLite writer lock |
| Inside a hook fired from a Save inside a tx | e.App | Framework rebinds e.App to txApp automatically |
| Inside a hook fired from a non-tx Save | e.App | Points to top-level app (no tx active) |
| Inside OnRecordEnrich | e.App | Runs after tx committed — no tx context |
| Cron callback | captured app / se.App | No per-run scoped app; wrap in RunInTransaction if atomicity needed |
| Migration function | the app argument | Already transactional |
Error propagation:
return err inside RunInTransaction → rolls back everything, including audit records written by hooks fired from nested Save callsreturn err from a hook → propagates back through Save → rolls back the txe.Next() → chain broken silently (no error, no realtime broadcast, no enrich pass)err := app.RunInTransaction(func(txApp core.App) error {
// Use ONLY txApp inside — outer app deadlocks (writer lock)
txApp.Save(record1)
txApp.Save(record2)
return nil // commit
// return err // rollback
})
e.App inside hooks is the transactional app when inside a txg := se.Router.Group("/api/myapp")
g.Bind(apis.RequireAuth())
g.Bind(apis.Gzip())
g.POST("/admin/rebuild", handler).Bind(apis.RequireSuperuserAuth())
JS: routerUse("/api/myapp", $apis.requireAuth())
| Middleware | Use |
|---|---|
| RequireGuestOnly() | Reject authed clients |
| RequireAuth(...collections) | Require auth (opt. restrict to specific auth coll) |
| RequireSuperuserAuth() | Superuser only |
| RequireSuperuserOrOwnerAuth("id") | Superuser or matching path param |
| Gzip() | Compress responses |
| BodyLimit(bytes) | Override 32MB default |
| SkipSuccessActivityLog() | Suppress activity log |
Default: pure-Go modernc.org/sqlite (no CGO). Use DBConnect for FTS5/ICU only.
⚠ DBConnect called twice: data.db + auxiliary.db.
import _ "github.com/mattn/go-sqlite3" // CGO
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("pb_sqlite3", dbPath)
},
})
app.Cron().MustAdd("cleanup-drafts", "0 3 * * *", func() {
app.Logger().Info("cleaning drafts...")
})
// Don't use __pb*__ prefix (reserved for system jobs)
JS: cronAdd("id", "*/30 * * * *", () => { ... })
❌ Leaking NewFilesystem() leaks S3 connections.
✅ Always defer fs.Close() (Go) / try { ... } finally { fs.close() } (JS).
fs, err := app.NewFilesystem()
if err != nil { return err }
defer fs.Close()
Prefer record field API (record.Set("file", f) + app.Save) over direct fs.Upload.
// ❌ String interpolation: filter injection
// ✅ Placeholder binding:
record, err := app.FindFirstRecordByFilter("users", "email = {:email} && verified = true",
dbx.Params{"email": email})
JS: $app.findFirstRecordByFilter("users", "email = {:e}", { e: email })
Go: m.Register(upFn, downFn) in migrations/ package. Auto-discovered when migratecmd.MustRegister(app, ...) is called in main.
JS (pb_migrations):
// pb_migrations/1712500000_add_collection.js
migrate((app) => {
const col = new Collection({ type: "base", name: "audit", fields: [...] });
app.save(col);
}, (app) => {
const col = app.findCollectionByNameOrId("audit");
app.delete(col);
});
<unix>_<description>.jsapp in up/down is transactionalpb_migrations/, never pb_data/meta := e.App.Settings().Meta
from := mail.Address{Name: meta.SenderName, Address: meta.SenderAddress}
msg := &pbmail.Message{From: from, To: []mail.Address{{Address: to}}, Subject: "...", HTML: "..."}
if err := e.App.NewMailClient().Send(msg); err != nil {
e.App.Logger().Error("email failed", "err", err)
}
// Don't return email errors from hooks — don't roll back business txn
app, _ := tests.NewTestApp("test_pb_data")
defer app.Cleanup()
tests.ApiScenario{
Name: "create post",
Method: http.MethodPost,
URL: "/api/collections/posts/records",
Body: strings.NewReader(`{"title":"hi"}`),
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRecordCreateRequest": 1,
"OnRecordAfterCreateSuccess": 1,
},
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
}.Test(t)
app.Settings(), never capture at startupsettings := app.Settings() → app.Save(settings)export PB_ENCRYPTION="32-char-exactly" — AES encrypts _paramsOnSettingsReload hook for in-memory cache invalidationposts (base): title(text), slug(text,unique), content(text), excerpt(text), featured_image(file), author(→users), category(→categories), tags(→tags). Rules: public read, author write.
categories (base): name(text,unique), slug(text,unique), description(text).
tags (base): name(text,unique).
products (base): name(text), slug(unique), description(editor), price(number), compare_price(number), sku(text,unique), stock(number), images(file,many), categories(→categories,many). Rules: public read, admin write.
orders (base): orderNumber(text,unique), status(select: pending/paid/shipped/delivered/cancelled), customer(→users), items(json), total(number,required), shippingAddress(editor), notes(editor). Rules: owner view, admin manage.
reviews (base): product(→products), author(→users), rating(number,1-5), content(text). Rules: author write own, public read.
profiles (auth): extends users. displayName(text), bio(text), avatar(file). Rules: owner edit, public view.
posts (base): content(editor), author(→profiles), media(file,many), likes(json). Rules: public read, auth write.
comments (base): post(→posts,cascadeDelete), author(→profiles), content(text). Rules: public read, auth create, author edit.
follows (base): follower(→profiles), following(→profiles), unique index on pair. Rules: auth create own.
projects (base): name(text), description(text), owner(→users), members(→users,many), status(select: active/archived). Rules: member view, owner manage.
tasks (base): title(text), description(editor), status(select: todo/in_progress/done), priority(select: low/medium/high/critical), assignee(→users), project(→projects,cascadeDelete), dueDate(date). Rules: project member view, assignee/owner update.
categories (base): name(text,unique), description(text), sortOrder(number). Rules: public read, admin manage.
threads (base): title(text), content(editor), author(→users), category(→categories,cascadeDelete), pinned(bool), views(number). Rules: public read, auth create, author edit.
posts (base): content(editor), author(→users), thread(→threads,cascadeDelete). Rules: public read, auth create, author edit.
| Pattern | listRule | viewRule | createRule | updateRule | deleteRule |
|---------|----------|----------|------------|------------|------------|
| Public read, auth write | "" | "" | @request.auth.id != "" | owner check | owner check |
| Owner only | owner = @request.auth.id | same | @request.auth.id != "" && @request.body.owner = @request.auth.id | owner = @request.auth.id | owner = @request.auth.id |
| Auth users | @request.auth.id != "" | same | same | same | same |
| Admin only | @request.auth.role = "admin" | same | same | same | same |
| Public read-only | "" | "" | null | null | null |
// roles collection: { user(→users), role(text) }
// resource rules:
listRule: '@collection.roles.user = @request.auth.id && @collection.roles.role = "admin"'
Hide("fieldName") — in OnRecordEnrich hook (Go) or e.record.hide() (JS)@request.body.field:isset = false — prevent field changes via API rulespb.files.getToken() — valid for limited timefinal pb = PocketBase('http://127.0.0.1:8090');
await pb.collection('users').authWithPassword('email', 'pass');
final records = await pb.collection('posts').getList(page: 1, perPage: 20);
final unsub = await pb.collection('posts').subscribe('*', (e) { print(e.record); });
Same API shape as JS SDK. Use AsyncAuthStore for Flutter.
# Export all collections schema as JSON
# (Admin UI > Settings > Export)
# Import via Admin UI or API
// pb_migrations/1712500000_seed_data.js
migrate((app) => {
const col = app.findCollectionByNameOrId("products");
const data = JSON.parse($os.readFile(`${__hooks}/seed/products.json`));
data.forEach(item => {
const r = new Record(col);
Object.entries(item).forEach(([k, v]) => r.set(k, v));
app.save(r);
});
}, (app) => {
// reverse
});
pb.filter() with named params — never string concatenationtools
Auto-initialize structured documentation for any project using lat.md (knowledge graph of markdown files with [[wiki links]], // @lat: code refs, and semantic search). Detects cali-product-workflow artifacts (spec-product.md, spec-tech.md, critiques) and uses them as seed material. Falls back to extracting business rules, architecture, and design decisions directly from the codebase. Use when a project lacks structured documentation or when lat.md/ is missing. After seeding, lat.md extension hooks keep documentation alive automatically.
testing
[Cali] Server security audit and hardening for private servers behind Tailscale. Use when: auditing server security, hardening SSH/firewall/Docker, checking for vulnerabilities, setting up fail2ban, reviewing port exposure, or responding to security alerts. Covers 6 layers: CloudFlare, UFW, Tailscale, SSH, Docker, Application. Triggers: "server security", "security audit", "harden server", "SSH hardening", "firewall rules", "UFW config", "fail2ban", "port security", "Docker security", "vulnerability check", "security review".
tools
Run supply chain security scans before installing packages or before releases. Triggers when: user installs a package (npm, pip, go get, brew), user asks to 'scan dependencies', 'check vulnerabilities', 'supply chain', 'security audit', 'run trivy', 'run socket', or before any release/deployment. Also triggers on mentions of: socket.dev, trivy, OSV-scanner, dotenvx, CVE, dependency audit. Covers all four tools with concrete commands.
tools
Create GitHub releases following project conventions. Triggers when: user says 'release', 'create release', 'push release', 'deploy to main', 'merge to main', user merges a PR to main, or when git push to main is detected. Also triggers on mentions of: gh release, semver, version bump, changelog, release-please. Covers: config-driven (read .release.yml and execute) and fallback (gh CLI) release flows, versioning rules, tag management, and the mandatory release-on-merge convention.