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/extensions.
⚠ DBConnect called twice: data.db + auxiliary.db.
Option A: ncruces/go-sqlite3 (recommended, no CGO) — pure Go via WASM→Go, suporta ext/unicode, ext/vec1, ext/spellfix1, ext/regexp, FTS5 nativo.
import (
_ "github.com/ncruces/go-sqlite3/driver"
"github.com/ncruces/go-sqlite3"
"github.com/ncruces/go-sqlite3/ext/unicode" // unaccent(), collation
// "github.com/ncruces/go-sqlite3/ext/vec1" // busca vetorial
// "github.com/ncruces/go-sqlite3/ext/spellfix1" // fuzzy matching
)
func init() {
// Auto-registra extensions em toda nova conexão
sqlite3.AutoExtension(unicode.Register)
}
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("sqlite3", "file:"+dbPath+";_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)")
},
})
Option B: mattn/go-sqlite3 (CGO) — requer CGO_ENABLED=1, perde cross-compilation estática.
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)
},
})
Trade-offs ncruces vs modernc:
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
Extrai métricas estruturadas, cálculos e estimativas de transcripts de entrevistas com clientes do Sommelier de IA. Produz um JSON com dores, frequências, tempo gasto, pessoas envolvidas, economia potencial, ROI e recomendações financeiras. Projetado para alimentar o cali-degustia-diagnostico ou integrar com dashboards/planilhas.
tools
Guia a coleta de depoimentos de clientes do Sommelier de IA no momento certo do processo, usando a abordagem de Hormozi: pedir depois da primeira evidência de resultado, nunca na entrega. Gera depoimentos mais autênticos e reduz a sensação de que o cliente está sendo "solicitado".
development
[stelow] Full UX critique for visual interfaces. Accepts a live URL, source code directory, or screenshot image. Evaluates accessibility (WCAG AA), Nielsen's 10 heuristics, visual hierarchy, cognitive load, consistency, mobile responsiveness, AI slop, emotional journey, and design personas — then generates a classified gap report. Standalone or integrated into stelow and stelow-product-testing-execution.
development
Building trust through perception and guarantee mechanisms. Covers ten pillars to materialize trust, guarantee types from unconditional to anti-guarantees, and strategic approaches for different contexts.