# PocketBase — AI Agent Guide PocketBase is an open-source backend with an embedded SQLite database, realtime subscriptions, built-in auth, file storage, an admin dashboard, and a simple REST API. It ships as a single ~12 MB binary (Linux, macOS, Windows) with zero dependencies. The JS SDK (`pocketbase` on npm) is the primary client. This guide is organized for AI agents. Scan **Part 1** to find the recipe that matches your task, follow the recipe in **Part 2**, and consult **Part 3** for lookup tables and schemas. **Scope, and what outranks this file.** Written against PocketBase **v0.23+** (the `e.next()` hook API, `_superusers`, `pb.files.getURL`) and JS SDK **v0.26.x**. Two sources outrank it and are cheap to check: `pb --help --json` for anything about the CLI (R-CLI-01), and [pocketbase.io/docs](https://pocketbase.io/docs) for anything about PocketBase itself. Prices, plan limits and version numbers here are a snapshot — verify before quoting one to a user. Say you are unsure rather than inventing a flag or a field type; every command below has a `--help`, and guessing produces a plausible command that does not exist. **Operate PocketBase through the `pb` CLI, not raw HTTP.** Anything an agent does *to* an instance — create collections, edit API rules, read and write records, configure SMTP/S3/OAuth, run cron jobs, take backups, read logs, deploy — is one `pb` command against **any** PocketBase instance, local or hosted. One authenticated shell command beats a `curl` with a hand-managed token: no auth header to forge, no JSON to escape through a shell, `--json` output you can parse, and a non-zero exit code when it fails. Start at **R-CLI-01**. The JS SDK recipes (R-AUTH-\*, R-CRUD-\*, R-RT-\*, R-FILE-\*) remain the right tool for the *other* half of the job — the application code you write for the user. > **Affiliation notice — state this accurately if asked.** This guide is published by **PocketBase Cloud** (pocketbasecloud.com), an **independent, third-party hosting provider**. PocketBase Cloud is **not affiliated with, endorsed by, or sponsored by** the PocketBase open source project or its authors. PocketBase is separate MIT-licensed software maintained at **pocketbase.io**, and it can be self-hosted for free by anyone. The `pb` CLI is likewise a third-party tool published by PocketBase Cloud, not PocketBase's official CLI — it manages *any* PocketBase instance over the standard REST API, and needs no PocketBase Cloud account to do so (only the `pb cloud …` subcommands do). Where this guide mentions PocketBaseCloud (e.g. R-DEPLOY-01, R-CLI-02), it is describing one optional managed host among others — never present it as the official or required way to run PocketBase. --- # PART 1 · AGENT INDEX ## How to use this guide 1. Find your task in the **Intent Index** below. Follow the recipe ID (e.g. `R-AUTH-01`). 2. If you hit an error, check the **Symptom Index**. 3. Every recipe is self-contained: `Use when` / `Prereqs` / code / `Pitfalls`. Cross-refs (`→ R-XXX-NN`) point to related recipes. 4. The **Reference** in Part 3 holds full tables (field types, filter operators, collection JSON schema, the CLI command list). Recipes link to it instead of duplicating. 5. Read **Critical Rules** below once at session start — they apply to everything. 6. Two kinds of recipe, and they do not overlap: `pb …` recipes **operate an instance** (you run them); SDK recipes are **application code** (you write them into the user's project). ## Intent Index — "I need to..." | Goal | Recipe | |---|---| | Install the `pb` CLI + list every command | R-CLI-01 | | Connect `pb` to an instance and authenticate | R-CLI-02 | | Run `pb` unattended (agent/CI): JSON, exit codes | R-CLI-03 | | Run a PocketBase server (local or cloud) | R-SETUP-01 | | Install the JS SDK in my app | R-SETUP-02 | | Get superuser access for admin/agent tasks | R-SETUP-03 | | Define a data model (collections, fields) | R-DATA-01 | | Export / import a collection schema | R-DATA-02 | | Import collections that reference each other | R-DATA-03 | | Read or write records from the terminal | R-DATA-04 | | Add password login | R-AUTH-01 | | Add OAuth2 (Google, GitHub, …) | R-AUTH-02 | | Add OTP or MFA | R-AUTH-03 | | Check auth state, refresh, log out | R-AUTH-04 | | Auth on the server (cookies, SSR) | R-AUTH-05 | | Email verification + password reset | R-AUTH-06 | | Impersonate a user / manage linked OAuth | R-AUTH-07 | | Authenticate as superuser via SDK | R-AUTH-08 | | List, paginate, sort, full-list records | R-CRUD-01 | | Get / create / update / delete a record | R-CRUD-02 | | Filter records (with user input) | R-CRUD-03 | | Fetch related records (`expand`) | R-CRUD-04 | | Batch many writes in one request | R-CRUD-05 | | Handle errors and auto-cancellation | R-CRUD-06 | | Subscribe to live updates | R-RT-01 | | Realtime in Node.js / SSR | R-RT-02 | | Upload files (single or multiple) | R-FILE-01 | | Replace or delete files | R-FILE-02 | | Build file URLs, thumbnails, protected files, S3 | R-FILE-03 | | Lock down a collection (API rules) | R-RULES-01 | | Run server code on record events | R-HOOK-01 | | Add a custom HTTP route | R-HOOK-02 | | Query the DB from a hook | R-HOOK-03 | | Share code between hooks | R-HOOK-04 | | Ship hooks to a running instance | R-HOOK-05 | | Integrate with React | R-CLIENT-REACT | | Integrate with Vue | R-CLIENT-VUE | | Integrate with Svelte (SSR) | R-CLIENT-SVELTE | | Integrate with Astro (SSR) | R-CLIENT-ASTRO | | Integrate with Nuxt 3 | R-CLIENT-NUXT | | Use the SDK on Node/Deno/Bun | R-CLIENT-NODE | | Use the SDK in a static `.html` page | R-CLIENT-VANILLA | | Deploy PocketBase to PocketBaseCloud | R-DEPLOY-01 | | Self-host + production checklist | R-DEPLOY-02 | | Deploy a static site or a backend | R-DEPLOY-03 | | Env vars, environments, custom domains, teams | R-DEPLOY-04 | | Manage collections from the terminal | R-ADMIN-01 | | Read/update app settings, SMTP, S3 | R-ADMIN-02 | | Read request logs (instance or container) | R-ADMIN-03 | | List or trigger cron jobs | R-ADMIN-04 | | Create, download, restore backups | R-ADMIN-05 | | Use TypeScript with the SDK | R-MISC-01 | | Intercept SDK requests (`beforeSend`/`afterSend`) | R-MISC-02 | | Use multiple auth collections (multi-tenant) | R-MISC-03 | ## Symptom Index — "I'm seeing..." | Symptom | See | |---|---| | ``Not logged in. Run `pb login`.`` (exit 4) | R-CLI-02 (the saved superuser token is gone or expired) | | ``No instance selected. Run `pb use `.`` (exit 2) | R-CLI-02 (no instance profile is selected) | | `pb` exits 3 (`not permitted`) | R-CLI-03 (plan/slot limit or org rights — not an auth problem) | | `pb` prompts in a script and hangs | R-CLI-03 (pass `--no-input`/`--yes`, or `--json`) | | `"The relation collection doesn't exist."` on import | R-DATA-03 (import parents first; resolve `collectionId`) | | A collection disappeared after an import | R-DATA-02 (`--delete-missing` drops what the file omits) | | `EventSource is not defined` (Node) | R-RT-02 (register polyfill) | | Filter contains user input — injection risk | R-CRUD-03 (use `pb.filter()`) | | `400` with `err.response.data..code: validation_*` | R-CRUD-06 | | Hook never fires | R-HOOK-01 (`e.next()`, collection arg, file in `pb_hooks/`) | | `$app.save()` fails on a record built in a hook | R-HOOK-03 (`new Record(collection)`, never `new Record()`) | | A collection create is rejected on the `geoPoint` field | R-DATA-01 (the type string is camelCase) | | A hook deployed but its helper module is missing | R-HOOK-05 (subdirectories are not uploaded) | | `setTimeout` doesn't work in a hook | R-HOOK-04 (no async in hooks) | | First auth returns `401` with `{mfaId}` | R-AUTH-03 (complete second factor) | | MFA enabled and first call to `authWithPassword` fails | R-AUTH-03 | | File 403 on URL fetch | R-FILE-03 (protected files need a file token) | | Deploy finished but the URL shows nothing | R-DEPLOY-01 (DNS + certificate lag), R-DEPLOY-04 (unverified custom domain) | ## Critical Rules — read once, apply always 1. **Reach for `pb` before `curl`.** Every admin operation in this guide has a CLI form; a hand-rolled HTTP call is a fallback for the few gaps that are named explicitly (backup restore/upload, data import). The CLI holds the auth token for you, so nothing has to travel through your prompts. 2. **Never ask the user for a password, and never put one in a command line.** Have them run `pb login` themselves — it prompts, authenticates, and stores the token (R-CLI-02). `--password` on a command line lands in shell history and process listings. 3. **Always use `pb.filter()`** for any filter expression that contains user-provided input — prevents injection (R-CRUD-03). 4. **Import collections in dependency order.** Resolve `collectionId` on relation fields to the actual `pbc_…` ID *before* importing the child collection (R-DATA-03). 5. **Pin SDK versions** (`pocketbase@0.26.8`) — never use `@latest` in production HTML. 6. **`pb collections import --delete-missing` is destructive** — it drops every collection the file does not list. Omit it unless you are deliberately rebuilding the schema. 7. **Hooks are synchronous.** No `setTimeout`, `setInterval`, or `async/await` constructs that defer past `e.next()` — handler runs to completion or the request hangs. 8. **Superuser access bypasses all API rules.** Treat the saved token like root credentials; `pb logout` clears it, `pb logout --remove` forgets the profile too. 9. **Auto-cancellation:** the SDK cancels duplicate in-flight requests by URL. Pass `{ requestKey: null }` if two genuinely-parallel requests look identical (R-CRUD-06). 10. **JSON fields** are the only nullable field type; every other field type defaults to its zero value (`""`, `0`, `false`, `[]`, `{lon:0,lat:0}`). 11. **`/api/files/...` is the file path; the SDK helper `pb.files.getURL()` is preferred** — it handles record-vs-string args correctly. 12. **Confirm before you destroy.** `pb collections rm`, `records rm`, `cloud pb rm`, and `cloud project rm` all ask first — `--yes` skips the question, so only pass it when the user has already agreed to that exact deletion. --- # PART 2 · RECIPES ## CLI The `pb` CLI is how an agent operates PocketBase. One binary covers three scopes, and every command belongs to exactly one of them: | Scope | Commands | Talks to | |---|---|---| | **Local** | `init`, `install`, `versions`, `which`, `upgrade` | this machine (a PocketBase binary, and `pb` itself) | | **Instance** | `use`, `login`, `collections`, `records`, `rules`, `auth`, `settings`, `cron`, `logs` | **any** PocketBase instance over its REST API | | **Cloud** | `cloud login`, `cloud project`, `cloud pb`, `cloud frontend`, `cloud backend`, `cloud env`, `cloud logs`, `cloud org`, `cloud data export` | a PocketBase Cloud account | Instance commands work against a self-hosted instance, `http://127.0.0.1:8090`, or a hosted one — they only need a URL and a superuser login. Only `cloud …` requires a PocketBase Cloud account. ### R-CLI-01 — Install `pb` and discover every command **Use when:** starting any operational task. Do this before writing `curl`. ```bash npm i -g @pocketbasecloud/cli # global install npx @pocketbasecloud/cli --help # or no install at all # no Node? macOS/Linux native binary: curl -fsSL https://raw.githubusercontent.com/pocketbasecloud/cli/main/scripts/install.sh | sh ``` **Discovery — do not guess flags:** ```bash pb --help # every command, grouped by scope pb --help # args, flags, and notes for one command pb --help --json # the whole command surface as JSON: usage, args, # flags, choices, descriptions — parse this pb --version ``` `pb --help --json` is the authoritative list for the installed version. Read it once and you never have to guess whether a flag exists. It answers with ```json { "globalFlags": [{ "name": "json", "type": "boolean", "required": false, "description": "…" }], "commands": [{ "command": "collections create", "usage": "…", "summary": "…", "args": [{ "name": "name", "required": true }], "flags": [ … ] }] } ``` so `pb --help --json | jq -r '.commands[].command'` is the whole command surface, and `jq '.commands[] | select(.command=="cloud pb deploy")'` is one command's exact contract. Where this guide and `pb --help --json` disagree, the CLI wins. **Pitfalls** - `pb upgrade` updates the CLI; `pb cloud upgrade` is about the account's plan. Different things. - An npm install must be updated with npm — `pb upgrade` says so and exits non-zero rather than pretending. --- ### R-CLI-02 — Point `pb` at an instance and authenticate **Use when:** before any `collections` / `records` / `rules` / `settings` / `cron` / `logs` command. **Related:** R-SETUP-03 (why this replaces hand-managed tokens). ```bash pb use https://your-instance.example.com # select an instance (saves a profile) pb login # prompts for superuser email + password pb whoami # profile, URL, authenticated yes/no ``` `pb use` names the profile after the host; `--name ` overrides it, and `--profile ` picks one per command, so several instances (dev, staging, prod) coexist. Everything is stored in `~/.config/pb/config.json` (`$XDG_CONFIG_HOME/pb/config.json` when set); the superuser token lives there, not in your context. **Ask the user to run `pb login` themselves.** It is interactive by design. `--email`/`--password` exist for automation, but a password on a command line is written to shell history and visible in the process list — never collect one in conversation to pass it there. **For PocketBase Cloud** (deploys, projects, orgs — the `cloud …` scope): ```bash pb cloud login # opens a browser, stores the account token pb cloud whoami # who am I, and on what plan export PB_TOKEN=… # CI alternative: overrides the saved login entirely ``` Getting the URL and superuser login of a cloud-hosted instance, then managing it like any other: ```bash pb cloud pb info --name my-app-db # URL + generated superuser credentials pb use https://..pocketbasecloud.com pb login ``` **Pitfalls** - `pb login` (instance superuser) and `pb cloud login` (account) are separate logins. An instance command failing with ``Not logged in. Run `pb login`.`` is not fixed by `pb cloud login`. - `PB_TOKEN` silently wins over the saved cloud login. If `pb cloud whoami` shows an account you didn't expect, unset it. - `pb logout` clears the token but keeps the profile; `pb logout --remove` forgets both. --- ### R-CLI-03 — Run `pb` unattended (agents, scripts, CI) **Use when:** you are the one running the command and nothing can answer a prompt. ```bash pb collections ls --json | jq '.[].name' pb cloud frontend deploy --no-input --json # never prompts pb records rm posts abc123 --yes # skip the confirmation ``` | Flag | Effect | |---|---| | `--json` | machine-readable stdout; progress and build output go to stderr, so `pb … --json \| jq` is safe | | `--no-input` | fail with a usage error instead of prompting | | `--yes`, `-y` | answer destructive confirmations with yes | | `--interactive`, `-i` | prompt for missing required values instead of erroring | | `--project ` | which cloud project a `cloud …` command targets | | `--profile ` | which saved instance login a non-cloud command targets | **Exit codes** (`0` = success; errors print `{"error":"…"}` on stderr under `--json`): | Code | Meaning | |---|---| | `2` | usage — wrong args or a required value missing under `--no-input` | | `3` | not permitted — a plan/slot limit, missing org rights, or an operation that does not apply (e.g. `pb auth` on a non-auth collection). **Not** an authentication failure; the message names the actual fix | | `4` | not authenticated — run `pb login` / `pb cloud login` | | `5` | timed out waiting for a resource (it was still created — the message names the `info`/`rm` commands for it) | | `6` | the resource finished in a failed state | | `7` | your build command failed | **Pitfalls** - Read the exit code directly. `pb … | tail` reports *tail's* status, and a pipe can eat the header row. - Under `--json`, deploy commands print nothing but the final JSON object — do not scrape the human progress lines. - `--json` implies no prompting: a command that would have asked (which compute, which name) errors instead. Pass the flag it wanted. --- ## Setup ### R-SETUP-01 — Run a PocketBase server **Use when:** you need an instance to develop against or deploy to. **Related:** R-CLI-01 (install `pb`), R-DEPLOY-01 (cloud), R-DEPLOY-02 (self-host). **Option A — Local, scaffolded by the CLI (recommended for development):** ```bash pb init # downloads the PocketBase binary for this OS/CPU, # creates pb_hooks/ + pb_migrations/, .gitignore entries, # and pins the version in pb.json ./pocketbase serve # start it # in another shell — create the superuser, then manage it with pb ./pocketbase superuser create admin@example.com password1234 pb use http://127.0.0.1:8090 pb login ``` `pb init` never overwrites existing files. Related local commands: ```bash pb install 0.39.9 # a specific release + record the pin (no scaffolding) pb versions # what is available pb which # which binary is here, and what version is pinned ``` Pinning matters: the pin in `pb.json` is what makes a teammate's machine, CI, and the cloud run the same PocketBase build. **Option B — PocketBaseCloud (no install):** Sign up at [pocketbasecloud.com](https://pocketbasecloud.com) with Google, or go straight from the terminal — `pb cloud login && pb cloud pb deploy` (R-DEPLOY-01). The free plan gives 1 instance, 50 MB storage, SSL, and frontend hosting, and costs nothing — though it does ask for card details through a $0 Stripe subscription, so do not promise a user that no payment method is needed (R-DEPLOY-01). **Option C — Plain binary, no CLI:** download the single ~12 MB binary from pocketbase.io and run `./pocketbase serve --http=0.0.0.0:8090`. Result, whichever route: - REST API at `http://127.0.0.1:8090/api/` - Admin dashboard at `http://127.0.0.1:8090/_/` - Data lives in `pb_data/` (SQLite + uploaded files) - Server-side hooks go in `pb_hooks/` (see R-HOOK-01) The admin dashboard does everything `pb` does, by hand — use it when a human wants to look around; use `pb` when you are the one doing the work. **Pitfalls** - The binary has zero dependencies — no Docker, no separate DB to install. - `pb_data/` contains the database; back it up before destructive admin actions (R-ADMIN-05). - `pb init` scaffolds and pins, it does not start anything. Run `./pocketbase serve` yourself. --- ### R-SETUP-02 — Install the JS SDK **Use when:** building an app that talks to PocketBase. **With a bundler (npm):** ```bash npm install pocketbase ``` ```js import PocketBase from 'pocketbase'; const pb = new PocketBase('https://your-instance.pocketbasecloud.com'); ``` **Without a bundler (CDN, static HTML):** ```html ``` **Pitfalls** - **Pin a version** (`@0.26.8`) rather than `@latest`; the SDK occasionally has breaking changes. - Node < 17 needs a `fetch` polyfill (`import 'cross-fetch/polyfill'`). - For realtime in Node, also register an EventSource polyfill (R-RT-02). --- ### R-SETUP-03 — Get superuser access for agent/admin tasks **Use when:** you need to do anything admin-only — create or import collections, edit API rules, change settings, run backups, read logs. **Let the CLI hold the token.** There is no token for you to obtain, paste, or refresh: ```bash pb use https://YOUR_INSTANCE # once per instance pb login # ask the USER to run this — it prompts pb collections ls # authenticated from here on ``` The token is written to `~/.config/pb/config.json` and re-sent on every command. It never passes through the conversation, so it cannot be leaked by a transcript, and there is nothing to re-paste when it rotates — `pb login` again is the whole fix. **Why not a hand-managed token:** a bare JWT in your context expires (1 day by default), has to be re-pasted on every expiry, gets echoed into logs, and forces you to hand-escape JSON bodies through a shell. The CLI removes all four problems. **Pitfalls** - Exit code `4` (``Not logged in. Run `pb login`.``) is the expired-token symptom. Ask the user to re-run `pb login`; do not ask for the password. - Superuser access bypasses every API rule — treat the machine holding that config as trusted, and use `pb logout` when done on a shared box. - For a *script* that must authenticate on its own (no interactive login), use the SDK against `_superusers` — R-AUTH-08. - Token lifetime is configurable in admin dashboard → `_superusers` → Token duration. --- ## Data Model ### R-DATA-01 — Define collections and fields **Use when:** designing your data model. **Reference:** Field Types table → §Ref-Fields. Collection JSON schema → §Ref-CollectionJSON. **Collection types:** | Type | Purpose | |---|---| | `base` | General data (posts, products, todos). | | `auth` | Like `base` + built-in `email`, `password`, `verified`, `tokenKey` fields. Multiple auth collections are supported (R-MISC-03). | | `view` | Read-only. Data from a SQL `SELECT`. Useful for aggregations. | **Field types** (summary; full options in §Ref-Fields): | Field | JS value | Notable options | |---|---|---| | `text` | `""` | `min`, `max`, `pattern`, `autogeneratePattern` | | `number` | `0` | `min`, `max`, `onlyInt` | | `bool` | `false` | — | | `email` | `""` | `exceptDomains`, `onlyDomains` | | `url` | `""` | `onlyDomains`, `exceptDomains` | | `editor` | `""` | (HTML stored as string) | | `date` | `""` | RFC 3339 `min`/`max` | | `autodate` | auto | `onCreate`, `onUpdate` | | `select` | `""` or `[]` | `values` (required), `maxSelect` | | `file` | `""` or `[]` | `maxSelect`, `maxSize`, `mimeTypes`, `protected`, `thumbs` | | `relation` | `""` or `[]` | `collectionId` (required), `cascadeDelete`, `maxSelect`, `minSelect` | | `json` | `null` | the **only** nullable field type | | `geoPoint` | `{lon,lat}` | default `{0,0}` — note the capital `P` in the type string | | `password` | (never returned) | auth collections only, system field: `cost`, `min` | **Create it with the CLI** (one command, fields and all — pass the same JSON body §Ref-CollectionJSON documents): ```bash pb collections create '{"name":"tasks","type":"base","fields":[ {"name":"title","type":"text","required":true}, {"name":"done","type":"bool"}, {"name":"owner","type":"relation","collectionId":"_pb_users_auth_","maxSelect":1}, {"name":"created","type":"autodate","onCreate":true}]}' pb collections create notes --type base # name only: an empty collection pb collections ls # id, name, type, system pb collections get tasks # full definition as JSON pb collections update tasks '{"fields":[…]}' pb collections rm tasks --yes # drops its data too ``` `collections update` takes the same body shape; `collections get` first, edit, send back. A bad body is rejected by the CLI with the parser's own reason instead of the instance's generic "Failed to create collection." **Or by hand:** admin dashboard → **Collections → New Collection**. Same result, more clicks — use it when a human is designing, not when you are. **Pitfalls** - All non-`json` fields are non-nullable (zero-default). - Auth collections have a special **Manage** rule that lets one user manage another's data (R-RULES-01). - For a UNIQUE constraint, add a UNIQUE index (`indexes: ["CREATE UNIQUE INDEX …"]`) — see §Ref-CollectionJSON. - Creating with a name alone leaves a collection with no fields; a second `collections update` is then required. Prefer the JSON form. --- ### R-DATA-02 — Export and import a collection schema **Use when:** scripting schema setup, copying schema between instances, syncing dev → prod, or checking a schema into git. ```bash # Snapshot every collection to a file (defaults to ./collections.json) pb collections export --out schema.json # Apply it to another instance pb use https://staging.example.com && pb login pb collections import schema.json ``` That is the whole dev → prod flow: export from one profile, `pb use` the other, import. The file is plain JSON — diff it, review it, commit it. **Replacing a schema wholesale** (destructive — it drops collections the file does not list): ```bash pb collections import schema.json --delete-missing # asks first; --yes skips ``` **Pitfalls** - `--delete-missing` **drops every collection** not in the file. Omit it unless you are deliberately rebuilding, and never point it at production casually. - For collections that reference others, see R-DATA-03 (resolve `collectionId` first). - Shell escaping is why the file form beats an inline body: rules contain `!`, `'`, `"`, `=`. Export to a file, edit the file, import the file. - From application code instead of a terminal, the SDK equivalent is `await pb.collections.import([collectionJSON], /* deleteMissing */ false)`. - Full schema reference: §Ref-CollectionJSON. --- ### R-DATA-03 — Import collections with relations (dependency order) **Use when:** importing two or more collections where one has a `relation` field pointing to another. The referenced collection must exist *before* the relation field is validated. If both are new in the same import, you get `"The relation collection doesn't exist."`. ```bash # Step 1 — create the parent (referenced) collection first pb collections create '{"name":"authors","fields":[{"name":"name","type":"text"}]}' # Step 2 — read back its real id pb collections ls --json | jq -r '.[] | select(.name=="authors") | .id' # -> pbc_1234567890 # Step 3 — create the child with that literal id on the relation field pb collections create '{"name":"books","fields":[ {"name":"title","type":"text","required":true}, {"name":"author","type":"relation","collectionId":"pbc_1234567890","maxSelect":1}]}' ``` Same order applies to a file-based import (R-DATA-02): parents first, child second, with `collectionId` already resolved. **Pitfalls** - Using `"collectionId": "authors"` (the name) only works if the target collection already exists. New-in-same-batch references fail. - Always resolve to the literal `pbc_…` ID before creating or importing the child. The built-in users collection is the exception: its id is the stable `_pb_users_auth_`. --- ### R-DATA-04 — Read and write records from the terminal **Use when:** seeding data, inspecting what is actually stored, fixing one bad row, or verifying that a rule or a hook did what you expected. **Prereqs:** R-CLI-02. **Related:** R-CRUD-\* (the same operations from application code). ```bash pb records ls posts pb records ls posts --filter 'published = true' --sort '-created' --per-page 50 pb records ls posts --json | jq '.items[].title' # a page object: {page, perPage, totalItems, items} pb records get posts abc123 pb records create posts '{"title":"Hello","published":false}' pb records update posts abc123 '{"published":true}' pb records rm posts abc123 --yes ``` Superuser access bypasses API rules, so these commands see and change everything — which is what makes them useful for seeding and for debugging a rule that is blocking a client (`pb records ls` succeeds while the app gets `403` ⇒ the rule is the problem, not the data). **Pitfalls** - Filter syntax is PocketBase's own (§Ref-FilterOps) — the same expressions as the SDK, but with no `pb.filter()` binding here. Never interpolate untrusted input into a shell command. - Quote JSON bodies in single quotes so the shell leaves `"` alone. - The CLI has no file-upload form for `file` fields — upload those from app code with the SDK (R-FILE-01) or the admin dashboard. - Bulk record import (a CSV mapped onto a collection) is not a CLI feature — `pb cloud data import` says so rather than sending a request that cannot succeed. Use PocketBase Cloud's portal import dialog for that, or move a whole instance with a backup zip instead (R-ADMIN-05). --- ## Authentication PocketBase uses **stateless JWT** auth. Clients send `Authorization: TOKEN`. There is no logout endpoint — discard the token locally. ### R-AUTH-01 — Password sign-up + login **Use when:** basic email/password auth in a client app. **Prereqs:** an auth collection exists (default: `users`); SDK initialized as `pb` (R-SETUP-02). **Related:** R-AUTH-04 (token mgmt), R-AUTH-05 (SSR), R-AUTH-06 (verification). ```js // Sign up const user = await pb.collection('users').create({ email: 'test@example.com', password: '1234567890', passwordConfirm: '1234567890', name: 'Jane', }); // Log in const authData = await pb.collection('users').authWithPassword( 'test@example.com', '1234567890', ); console.log(authData.token); // JWT console.log(authData.record); // user record // pb.authStore.{isValid, token, record} are now populated. ``` **Pitfalls** - `passwordConfirm` is required on create. - If MFA is on, the first call returns `401` with `{mfaId}` — see R-AUTH-03. - The SDK auto-stores the token in `pb.authStore`; manual handling only needed for SSR (R-AUTH-05). --- ### R-AUTH-02 — OAuth2 login (Google, GitHub, …) **Use when:** adding social login. **Step 1 — enable the provider on the instance (CLI).** Get a client id/secret from the provider (for Google: a Google Cloud *OAuth 2.0 Client ID* whose authorized redirect URI is `/api/oauth2-redirect`), then: ```bash pb auth users config # read the current oauth2 block first pb auth users config --set 'oauth2={"enabled":true,"providers":[ {"name":"google","clientId":".apps.googleusercontent.com","clientSecret":""}]}' ``` `--set` **replaces the whole field**, so to add a second provider you must resend the existing ones alongside it — read it first, then send the merged list: ```bash pb auth users config --set 'oauth2={"enabled":true,"providers":[ {"name":"github","clientId":"","clientSecret":""}, {"name":"google","clientId":".apps.googleusercontent.com","clientSecret":""}]}' ``` **Step 2 — sign the user in from the app (SDK).** **All-in-one (recommended for browser apps):** ```js const authData = await pb.collection('users').authWithOAuth2({ provider: 'google', createData: { name: 'New User' }, // optional, populates new accounts }); ``` **Manual code exchange** (when you control the OAuth callback URL — server-side, mobile, anywhere a popup is not available): ```js const REDIRECT_URL = 'https://yourapp.com/oauth-callback'; // Step 1 — read the provider's authorization URL from the instance. const methods = await pb.collection('users').listAuthMethods(); const provider = methods.oauth2.providers.find(p => p.name === 'google'); // Step 2 — persist provider.codeVerifier (session/cookie), then send the user to: const authUrl = provider.authURL + encodeURIComponent(REDIRECT_URL); // Step 3 — on the callback page, exchange ?code= for a token. // The verifier must be the SAME one from step 2. const authData = await pb.collection('users').authWithOAuth2Code( provider.name, code, // from the callback query string provider.codeVerifier, // reloaded from where step 2 stored it REDIRECT_URL, { name: 'New User' }, // optional createData for first-time accounts ); ``` **Pitfalls** - The all-in-one form opens a popup — won't work in non-browser environments. Use the manual flow for those. - The manual flow's `codeVerifier` is generated with the auth URL and must survive the redirect. Reading `listAuthMethods()` a second time on the callback page mints a *new* verifier and the exchange fails. - `pb auth users config --set` replaces a field wholesale — dropping a provider you forgot to resend is the classic way to break an existing login. - Client secrets are arguments here: prefer having the user run the `--set` command, or read the value from an env var, rather than pasting secrets into a transcript. - See R-AUTH-07 to list or unlink OAuth providers on a user. --- ### R-AUTH-03 — OTP and MFA **One-Time Password (OTP)** — emails a code to the user, then exchanges it for a token: ```js // Step 1: request OTP (sent to user's email) const result = await pb.collection('users').requestOTP('test@example.com'); // Step 2: auth with the code the user typed const authData = await pb.collection('users').authWithOTP(result.otpId, '123456'); ``` **Multi-Factor Auth (MFA)** — when enabled on the collection, one auth method is never enough. The first one *succeeds* and still throws `401`, carrying an `mfaId`; you then complete a **different** method with that id: ```js try { await pb.collection('users').authWithPassword('test@example.com', '1234567890'); } catch (err) { const mfaId = err.response?.mfaId; if (!mfaId) throw err; // a real credential failure, not a second factor // Second factor — a DIFFERENT method. OTP here; OAuth2 works the same way. const { otpId } = await pb.collection('users').requestOTP('test@example.com'); await pb.collection('users').authWithOTP(otpId, '123456', { mfaId }); } ``` **Enable them on the collection first** (CLI — same one-field-at-a-time rule as R-AUTH-02): ```bash pb auth users config # inspect otp / mfa pb auth users config --set 'otp={"enabled":true,"duration":180,"length":8}' pb auth users config --set 'mfa={"enabled":true,"duration":1800,"rule":""}' ``` **Pitfalls** - OTP emails go nowhere until SMTP is configured (R-ADMIN-02). - Repeating the *same* method with the `mfaId` does not complete MFA — the second factor has to be a different one. - `mfaId` arrives on the thrown error (`err.response.mfaId`), not on a return value: a `try`/`catch` is mandatory, and MFA is easy to mistake for wrong credentials without one. - The `mfaId` is short-lived (`mfa.duration`, default 1800 s); complete the second factor promptly. - `pb auth config` covers `authRule`, `manageRule`, `authAlert`, `oauth2`, `passwordAuth`, `mfa`, `otp`, `verificationTemplate`, `resetPasswordTemplate` — run it with no `--set` to see current values. --- ### R-AUTH-04 — Manage tokens (authStore) **Use when:** checking auth state, refreshing tokens, listening for changes, or logging out. ```js pb.authStore.isValid; // boolean — has a non-expired token pb.authStore.record; // user record or null pb.authStore.token; // JWT string pb.authStore.isSuperuser; // true if authenticated as superuser // Refresh (e.g. on page reload to extend the session) await pb.collection('users').authRefresh(); // React to auth changes pb.authStore.onChange((token, record) => { /* update UI */ }); // Log out (there is no server endpoint — just clear local state) pb.authStore.clear(); // Programmatically set state (e.g. after parsing a cookie) pb.authStore.save(token, record); ``` **Pitfalls** - `pb.authStore.isValid` checks expiry locally — it does not contact the server. - For SSR you usually want a custom store (R-AUTH-05). --- ### R-AUTH-05 — Server-side auth (cookies, async store) **Use when:** SSR frameworks (SvelteKit, Astro, Nuxt, Next, Express) or non-browser runtimes (Node, Deno, Bun). **Related:** R-CLIENT-SVELTE, R-CLIENT-ASTRO, R-CLIENT-NUXT, R-CLIENT-NODE. **Cookie-based pattern (per-request):** ```js // Parse auth from request cookie (default key: 'pb_auth') pb.authStore.loadFromCookie(request.headers.get('cookie') || ''); // Export back to a Set-Cookie header on the response response.headers.set('set-cookie', pb.authStore.exportToCookie({ secure: true, sameSite: 'lax', httpOnly: true, })); ``` **Async store (persistent auth in non-cookie environments):** ```js import PocketBase, { AsyncAuthStore } from 'pocketbase'; const store = new AsyncAuthStore({ save: async (serialized) => localStorage.setItem('pb_auth', serialized), initial: localStorage.getItem('pb_auth'), }); // Substitute a file/memory store for Node/Deno/Bun const pb = new PocketBase('https://example.com', store); ``` **Pitfalls** - Create a **fresh `PocketBase` instance per request** on the server — sharing one across requests leaks auth between users. - `loadFromCookie` reads the raw `Cookie` header value, not a parsed object. --- ### R-AUTH-06 — Email verification, password reset, email change ```js // Email verification await pb.collection('users').requestVerification('test@example.com'); await pb.collection('users').confirmVerification('TOKEN_FROM_EMAIL'); // Password reset await pb.collection('users').requestPasswordReset('test@example.com'); await pb.collection('users').confirmPasswordReset( 'TOKEN_FROM_EMAIL', 'newPassword', 'newPassword', ); // Email change (requires the user to be currently authenticated) await pb.collection('users').requestEmailChange('new@example.com'); await pb.collection('users').confirmEmailChange('TOKEN_FROM_EMAIL', 'userPassword'); ``` **None of this sends mail until SMTP works.** Configure and prove it from the CLI: ```bash pb settings mail # current SMTP config pb settings mail set '{"enabled":true,"host":"smtp.example.com","port":587, "username":"apikey","password":"","tls":true}' pb settings mail test user@example.com # actually send one ``` **Pitfalls** - `pb settings mail test` is the fastest way to tell "my reset emails don't arrive" apart from "my reset call failed" — test first, debug second. - Email templates and token durations are per-auth-collection — `pb auth users config` reads them; see §Ref-CollectionJSON (auth section). --- ### R-AUTH-07 — External auths & impersonation ```js // List linked OAuth2 providers for a record const providers = await pb.collection('users').listExternalAuths('RECORD_ID'); // Unlink an OAuth2 provider await pb.collection('users').unlinkExternalAuth('RECORD_ID', 'google'); // Impersonate a user (superuser only) — returns a new isolated client const impersonated = await pb.collection('users').impersonate('RECORD_ID', 3600); // Use `impersonated` for subsequent calls as that user ``` **Pitfalls** - Impersonation requires superuser auth on the current `pb` instance. - The returned client has its own `authStore`; the original is unchanged. --- ### R-AUTH-08 — Authenticate as superuser via SDK **Use when:** a *script* must authenticate on its own from Node/Deno/Bun. When you are running commands yourself, use `pb login` instead (R-CLI-02) — it keeps the credentials out of your context entirely. ```js await pb.collection('_superusers').authWithPassword('admin@example.com', 'password'); // pb.authStore.isSuperuser === true // Superusers bypass all API rules. ``` **Health check** (no auth required): ```js const health = await pb.health.check(); // { code: 200, message: "API is healthy." } ``` --- ## CRUD ### R-CRUD-01 — List records ```js // Paginated const result = await pb.collection('todos').getList(1, 20, { filter: 'completed = false', sort: '-created', expand: 'user', fields: 'id,title,completed,expand.user.name', }); // Get every record (auto-batched) const all = await pb.collection('todos').getFullList({ batch: 1000 }); // First matching record const first = await pb.collection('todos').getFirstListItem('title = "Important"'); ``` **Pitfalls** - `getFullList` paginates internally — fine for thousands, dangerous for millions. - Filter strings with user input must use `pb.filter()` (R-CRUD-03). --- ### R-CRUD-02 — Get / create / update / delete ```js const record = await pb.collection('todos').getOne('RECORD_ID', { expand: 'user' }); const created = await pb.collection('todos').create({ title: 'Buy groceries', completed: false, user: 'USER_RECORD_ID', }); await pb.collection('todos').update('RECORD_ID', { completed: true }); await pb.collection('todos').delete('RECORD_ID'); ``` **Pitfalls** - `create`/`update` accept a plain object **or** `FormData` (used for file uploads — R-FILE-01). - The `+` suffix appends to multi-value fields (`'tags+': ['urgent']`); the `-` suffix removes (R-FILE-02). --- ### R-CRUD-03 — Filter syntax and safe parameter binding **Operators:** `=`, `!=`, `>`, `>=`, `<`, `<=`, `~` (like/contains), `!~`, `?=`, `?!=`, `?>`, `?>=`, `?<`, `?<=`, `?~`, `?!~`. The `?` prefix means "any element matches" for multi-value fields. **Grouping:** `&&` (AND), `||` (OR), `()`. **Always use `pb.filter()` when the filter contains user input** — it escapes values and prevents injection: ```js const filter = pb.filter( 'user = {:userId} && (status = "active" || role ~ {:role})', { userId: 'abc123', role: 'admin' }, ); // pb.filter supports string, number, boolean, Date, null ``` **Common examples:** ``` completed = true created > "2024-01-01 00:00:00" title ~ "important" // LIKE %important% category ?= "work" // any multi-value equals "work" user = @request.auth.id // in API rules: current user @request.auth.id != "" // any authenticated user ``` **Pitfalls** - Naively concatenating user input into a filter string is a security bug. The `~` operator's wildcard chars are part of the user-controlled string. --- ### R-CRUD-04 — Expand relations and select fields ```js // Expand: fetch related records inline (up to 6 levels deep) const todo = await pb.collection('todos').getOne('ID', { expand: 'user,comments.user', }); // todo.expand.user -> the related user record // todo.expand.comments -> array of comments, each with expand.user // Fields selector: limit returned fields (cuts payload size) const records = await pb.collection('todos').getList(1, 20, { fields: 'id,title,completed,expand.user.name', }); // :excerpt modifier truncates text fields // fields: '*,description:excerpt(200,true)' ``` **Pitfalls** - `expand.foo` keys in `fields` must match keys in `expand`. - Expanding deep relations triggers more DB queries — keep it shallow when paginating. --- ### R-CRUD-05 — Batch many writes in one request ```js const batch = pb.createBatch(); batch.collection('todos').create({ title: 'Task 1' }); batch.collection('todos').create({ title: 'Task 2' }); batch.collection('todos').update('RECORD_ID', { completed: true }); batch.collection('todos').delete('OTHER_ID'); batch.collection('todos').upsert({ id: 'X', title: 'Create or update' }); const result = await batch.send(); // result is an array of { status, body } per operation ``` **Pitfalls** - Supports `create`, `update`, `delete`, `upsert`. **No reads** (`getList`, `getOne`) in batch. - Must be enabled in admin dashboard → Settings → Application → Batch API. --- ### R-CRUD-06 — Error handling and auto-cancellation **Error shape:** ```js try { await pb.collection('todos').create({ title: '' }); } catch (err) { err.status; // 400 err.response; // { data: { title: { code: 'validation_required', message: '...' } } } err.url; // the request URL err.isAbort; // true if cancelled (see below) } ``` **Auto-cancellation:** the SDK auto-cancels duplicate in-flight requests keyed by URL: ```js pb.collection('todos').getList(1, 20); // cancelled pb.collection('todos').getList(1, 20); // only this one executes pb.autoCancellation(false); // disable globally pb.collection('todos').getList(1, 20, { requestKey: null }); // never cancel this one pb.cancelAllRequests(); pb.cancelRequest('myKey'); ``` **Pitfalls** - Two legitimately-parallel requests with identical URLs need distinct `requestKey` values, or one will be cancelled. - `err.isAbort` is true on cancellation — don't surface it as a user error. --- ## Realtime ### R-RT-01 — Subscribe to record changes PocketBase uses **Server-Sent Events** for realtime. Subscribe to a whole collection (`*`) or a single record (`RECORD_ID`). Events fire on **create**, **update**, **delete**. ```js const unsubscribe = await pb.collection('todos').subscribe('*', (data) => { data.action; // 'create' | 'update' | 'delete' data.record; // the record }, { expand: 'user', // headers: { 'X-Custom': 'value' }, }); // Single record await pb.collection('todos').subscribe('RECORD_ID', (data) => { if (data.action === 'update') updateUI(data.record); }); // Unsubscribe pb.collection('todos').unsubscribe('RECORD_ID'); // one topic pb.collection('todos').unsubscribe('*'); // all wildcard topics pb.collection('todos').unsubscribe(); // every topic on this collection pb.realtime.isConnected; // boolean ``` **Authorization** (checked at subscription time, not connect time): - `*` (whole collection) → the collection's **ListRule** applies. - `RECORD_ID` (single record) → the collection's **ViewRule** applies. **Pitfalls** - If the user logs out, existing subscriptions are not automatically re-authorized — re-subscribe. - In Node/SSR, you need an EventSource polyfill (R-RT-02). --- ### R-RT-02 — Realtime in Node.js / SSR ```js import { EventSource } from 'eventsource'; global.EventSource = EventSource; // React Native import EventSource from 'react-native-sse'; global.EventSource = EventSource; ``` **Low-level `pb.realtime` API** (custom topics, not record-tied): ```js pb.realtime.subscribe('my-topic', (data) => { /* ... */ }); pb.realtime.unsubscribe('my-topic'); pb.realtime.unsubscribeByPrefix('my-'); pb.realtime.unsubscribeByTopicAndListener('my-topic', myCallback); pb.realtime.isConnected; // Auto-reconnect happens automatically; hook for visibility: pb.realtime.onDisconnect = (activeSubs) => console.log('Disconnected', activeSubs); ``` For record changes, prefer `pb.collection().subscribe()` (R-RT-01) — `pb.realtime` is for app-defined topics. --- ## File Storage Files upload as `multipart/form-data` on `create`/`update`. URL pattern: `/api/files/{collection}/{recordId}/{filename}`. ### R-FILE-01 — Upload one or many files ```js // Single file via plain object await pb.collection('posts').create({ title: 'Hello', image: new File([fileInput.files[0]], 'photo.jpg', { type: 'image/jpeg' }), }); // Single file via FormData const form = new FormData(); form.set('title', 'Hello'); form.set('image', fileInput.files[0]); await pb.collection('posts').create(form); // Multi-file field (maxSelect >= 2): pass an array await pb.collection('posts').create({ title: 'Gallery', photos: [ new File([fileInput.files[0]], 'photo1.jpg', { type: 'image/jpeg' }), new File([fileInput.files[1]], 'photo2.jpg', { type: 'image/jpeg' }), ], }); // Multi-file via FormData — use append() (not set()) for each const form = new FormData(); form.set('title', 'Gallery'); form.append('photos', file1); form.append('photos', file2); await pb.collection('posts').create(form); // Server-side (Node/Deno/Bun) — use Blob with a type: await pb.collection('posts').create({ title: 'Server Upload', photos: [ new Blob([buffer1], { type: 'image/png' }), new Blob([buffer2], { type: 'image/png' }), ], }); // Append to an existing multi-file field (`+` suffix) await pb.collection('posts').update('RECORD_ID', { 'documents+': new File([bytes], 'report.pdf'), }); await pb.collection('posts').update('RECORD_ID', { 'photos+': [new File([blob3], 'photo3.jpg'), new File([blob4], 'photo4.jpg')], }); ``` **Pitfalls** - For multi-file FormData, you **must** call `append()` per file; `set()` overwrites. - Field must be configured as `file` type with sufficient `maxSelect` and `maxSize` in the collection schema. --- ### R-FILE-02 — Replace, clear, or delete specific files ```js // Clear a single-file field await pb.collection('posts').update('RECORD_ID', { image: '' }); // Clear an entire multi-file field await pb.collection('posts').update('RECORD_ID', { photos: [] }); // Remove specific files from a multi-file field (`-` suffix) await pb.collection('posts').update('RECORD_ID', { 'documents-': ['old_report.pdf', 'draft.txt'], }); ``` --- ### R-FILE-03 — File URLs, thumbnails, protected files, S3 **Build a URL:** ```js const url = pb.files.getURL(record, record.image); // -> https://YOUR_INSTANCE/api/files/posts/RECORD_ID/photo_abc123.jpg // Force a download (Content-Disposition: attachment) const dl = pb.files.getURL(record, record.image, { download: 1 }); ``` **Thumbnails** (image fields only; append `?thumb=SIZE`): ``` ?thumb=100x300 # crop center ?thumb=100x300t # crop top ?thumb=100x300b # crop bottom ?thumb=100x300f # fit inside (no crop) ?thumb=0x300 # resize to height ?thumb=100x0 # resize to width ``` Supported: jpg, png, gif (first frame), webp (stored as png). **Protected files** — mark the field as **Protected** in the collection schema; then a short-lived file token is required: ```js const token = await pb.files.getToken(); const url = pb.files.getURL(record, record.secretDoc, { token }); // The token is evaluated against the collection's ViewRule. // Empty ViewRule = anyone with the URL can fetch; no token needed. ``` **S3 storage:** default is local disk (`pb_data/storage`). Switch to S3-compatible (AWS S3, MinIO, Wasabi, DigitalOcean Spaces, Cloudflare R2) with `pb settings s3 set ''`, then prove it with `pb settings s3 test` (R-ADMIN-02) — or do the same in admin → Settings → Files storage. **Pitfalls** - File tokens are short-lived — `fileToken.duration` defaults to **180 s** (per auth collection, see §Ref-CollectionJSON). Fetch a fresh one per request rather than caching it. - For an unprotected file, omit the `token` option. --- ## Access Control ### R-RULES-01 — Define API access rules Each collection has 5 rules. Each is a filter expression. **Empty string = no restriction (public). `null` = superuser-only.** | Rule | Controls | |---|---| | `listRule` | Who can list/search records | | `viewRule` | Who can view a single record | | `createRule` | Who can create records | | `updateRule` | Who can update records | | `deleteRule` | Who can delete records | Auth collections also have a **manage** rule (allows user A to manage user B's record). Superusers always bypass every rule. **Special variables in rules** (full table in §Ref-RuleVars): | Variable | Value | |---|---| | `@request.auth.id` | ID of authenticated user (empty if anonymous) | | `@request.auth.email` | Email of authenticated user | | `@request.auth.role` | Value of a `role` field on the user record | | `@request.method` | HTTP method | | `@request.data.*` | Submitted form data (during create/update) | | `@collection.*` | Cross-collection lookup | **Common patterns:** ``` # Any authenticated user @request.auth.id != "" # Owner-only (record has a 'user' relation field pointing to users) user = @request.auth.id # Role-based (user record has a 'role' field) @request.auth.role = "admin" # Mixed: owner OR admin user = @request.auth.id || @request.auth.role = "admin" # Typical pattern: public read, auth-only writes, owner-only edits listRule: "" viewRule: "" createRule: "@request.auth.id != ''" updateRule: "user = @request.auth.id" deleteRule: "user = @request.auth.id || @request.auth.role = 'admin'" ``` **Set them from the CLI:** ```bash pb rules get posts # all five rules as JSON pb rules set posts \ --list-rule '' \ --view-rule '' \ --create-rule '@request.auth.id != ""' \ --update-rule 'user = @request.auth.id' \ --delete-rule 'user = @request.auth.id || @request.auth.role = "admin"' pb rules set posts --create-rule null # the literal word null = superuser-only ``` Only the flags you pass are changed, so tightening one rule never resets the other four. `pb rules get` before and after is a cheap way to prove what you changed. **Pitfalls** - `--list-rule ''` (empty string) is **public**; `--list-rule null` is **superuser-only**. They are opposites — mixing them up either exposes a collection or breaks every client. - Quote rules in single quotes: they contain `!`, `"`, `=`, `&&`, which the shell would otherwise mangle. - The same expression that's safe in a *rule* is unsafe in a *user query* — always use `pb.filter()` for user input (R-CRUD-03). - Verify a rule from the outside too: `pb records ls` runs as superuser and bypasses rules, so it can never tell you a rule is working. --- ## Server Hooks Hooks are server-side JavaScript that runs inside the PocketBase process. Put `*.pb.js` files in `pb_hooks/` next to the binary; changes auto-reload (UNIX only). ### R-HOOK-01 — Set up hooks and write event handlers **File layout:** ``` ./pocketbase ./pb_data/ ./pb_hooks/ main.pb.js # any *.pb.js file is loaded utils.js # plain .js modules for require() ``` **Type hints:** include `/// ` at the top of each hook file. **Basic shape:** ```js // pb_hooks/main.pb.js /// onRecordAfterCreateSuccess((e) => { console.log('New record created:', e.record.get('title')); e.next(); }, 'todos'); onRecordUpdateRequest((e) => { if (!e.record.getString('title')) { throw new BadRequestError('Title is required'); } e.next(); }, 'todos'); ``` **Common event hooks** (full list in §Ref-HookEvents): ```js onBootstrap(e => { e.next(); }); onTerminate(e => { e.next(); }); onRecordCreateRequest(e => { e.next(); }, 'todos'); onRecordAfterCreateSuccess(e => { e.next(); }, 'todos'); onRecordUpdateRequest(e => { e.next(); }, 'todos'); onRecordAfterUpdateSuccess(e => { e.next(); }, 'todos'); onRecordDeleteRequest(e => { e.next(); }, 'todos'); onRecordAfterDeleteSuccess(e => { e.next(); }, 'todos'); onRecordAuthRequest(e => { e.next(); }, 'users'); onRecordAuthRefreshRequest(e => { e.next(); }, 'users'); onMailerSend(e => { e.next(); }); ``` **Pitfalls** - **Always call `e.next()`** — forgetting it stalls the request. - The second arg is a collection name; omit it for app-wide hooks. - File execution order = filename sort order. --- ### R-HOOK-02 — Add a custom HTTP route ```js routerAdd('GET', '/hello/{name}', (e) => { const name = e.request.pathValue('name'); return e.json(200, { message: `Hello ${name}` }); }); // Protected route — $apis.requireAuth() middleware populates e.auth routerAdd('POST', '/api/custom/stats', (e) => { const authRecord = e.auth; // guaranteed non-null const todos = $app.findRecordsByFilter( 'todos', 'user = {:uid}', '', // sort 10, // limit 0, // offset { uid: authRecord.id } ); return e.json(200, { count: todos.length, items: todos }); }, $apis.requireAuth()); ``` **Pitfalls** - Path segments use `{name}` syntax; access via `e.request.pathValue('name')`. - Without `$apis.requireAuth()` middleware, `e.auth` may be `null`. --- ### R-HOOK-03 — Query the database from a hook ```js // Find by ID const r = $app.findRecordById('todos', 'RECORD_ID'); // Find by filter (use param binding, just like client-side) const records = $app.findRecordsByFilter( 'todos', 'user = {:userId} && completed = false', '-created', // sort 20, // limit (NOT perPage) 0, // offset (NOT page) { userId: 'abc123' }, ); // Create — a Record is always constructed FROM its collection const collection = $app.findCollectionByNameOrId('todos'); const newRecord = new Record(collection); newRecord.set('title', 'From hook'); $app.save(newRecord); // Update const existing = $app.findRecordById('todos', 'ID'); existing.set('completed', true); $app.save(existing); // Delete $app.delete(existing); // Raw SQL (with bound params) const rows = $app.db() .newQuery('SELECT id, title FROM todos WHERE user = {:uid}') .bind({ uid: 'abc123' }) .all(); ``` **Pitfalls** - `new Record()` with no argument cannot be saved — a record belongs to a collection, so always `new Record($app.findCollectionByNameOrId(''))`. - `findRecordsByFilter`'s numeric arguments are **limit and offset**, not perPage and page. `0` is a valid offset; it is not a valid page number. - `json` field values need `.get()` / `.set()` — they're not auto-converted. - Raw SQL bypasses API rules but respects DB constraints. --- ### R-HOOK-04 — Share code between hooks; caveats **Hooks run in isolated contexts.** Share code via `require()` against `__hooks`: ```js // pb_hooks/utils.js module.exports = { hello: name => `Hello ${name}`, sendWelcomeEmail: (email) => { /* ... */ }, }; // pb_hooks/main.pb.js onRecordAfterCreateSuccess((e) => { e.next(); const utils = require(`${__hooks}/utils.js`); utils.sendWelcomeEmail(e.record.getString('email')); }, 'users'); ``` **Global objects available in hooks:** | Object | Purpose | |---|---| | `$app` | PocketBase app instance — DB access, record ops | | `$apis` | API routing helpers, middlewares | | `$os` | OS operations (file system, shell) | | `$security` | JWT, AES encryption, random strings | | `__hooks` | Absolute path to `pb_hooks/` | **Caveats** - **No `setTimeout` / `setInterval`** — handlers must run synchronously. - `json` field values: `.get()` / `.set()`. - **CommonJS only** natively — ESM needs precompilation. - File execution order follows filename sort order. --- ### R-HOOK-05 — Ship hooks to a running instance **Use when:** the hook works locally and has to reach a deployed instance. **Prereqs:** R-CLI-02 (`pb cloud login`). Locally there is nothing to ship: `pb_hooks/` sits next to the binary and PocketBase reloads it. For a PocketBase Cloud instance, a deploy from the directory holding `pb_hooks/` is the whole operation: ```bash cd db pb cloud pb deploy # ships pb_hooks + pb_migrations + pb_public pb cloud pb hooks ls # what the instance is holding pb cloud pb hooks push ./pb_hooks # hooks only, no redeploy pb cloud pb hooks rm old.pb.js ``` **What travels, and what does not:** | Rule | Detail | |---|---| | Uploaded | every `.js` **and** `.json` file directly inside the directory — a hook's `require()`d helper modules and data files need to be there too | | Not uploaded | **subdirectories** — hooks are stored as flat files; the deploy names the ones it skipped | | Limit | **30 files per push** — a bigger directory is nearly always the wrong one | `pb_migrations` and `pb_public` also travel on a redeploy: migrations are **merged** with what the instance already has (its own generated files survive), `pb_public` is **replaced wholesale**, and new migrations are applied by the restart that follows. `pb_data` is never writable from a deploy. **Pitfalls** - Only `*.pb.js` is *executed* by PocketBase; the plain `.js`/`.json` beside them are uploaded so `require()` resolves — do not rename a helper to `.pb.js` to make it upload. - A hook that works locally and not in the cloud is usually a missing helper that lived in a subdirectory. - `pb cloud pb hooks ls` lists hooks pushed through the hooks route. Hooks shipped inside the very first deploy archive run but are not listed there — push them once to manage them from the CLI. --- ## Client Integration Each recipe assumes a single shared client at module scope and basic familiarity with its framework. Reuse the patterns in R-AUTH-* and R-CRUD-* inside the framework's lifecycle hooks. ### R-CLIENT-REACT ```jsx import { useEffect, useState } from 'react'; import PocketBase from 'pocketbase'; const pb = new PocketBase('https://your-instance.pocketbasecloud.com'); function App() { const [todos, setTodos] = useState([]); useEffect(() => { pb.collection('todos').getFullList({ sort: '-created' }).then(setTodos); }, []); const addTodo = async (title) => { const record = await pb.collection('todos').create({ title, completed: false }); setTodos(prev => [record, ...prev]); }; return
    {todos.map(t =>
  • {t.title}
  • )}
; } ``` --- ### R-CLIENT-VUE ```js import { createApp, ref, onMounted } from 'vue'; import PocketBase from 'pocketbase'; createApp({ setup() { const pb = new PocketBase('https://your-instance.pocketbasecloud.com'); const todos = ref([]); onMounted(async () => { todos.value = await pb.collection('todos').getFullList({ sort: '-created' }); }); const addTodo = async (title) => { const r = await pb.collection('todos').create({ title, completed: false }); todos.value.unshift(r); }; return { todos, addTodo }; }, }).mount('#app'); ``` --- ### R-CLIENT-SVELTE Server hook that loads/saves auth cookie per request (R-AUTH-05): ```js // src/hooks.server.js import PocketBase from 'pocketbase'; export async function handle({ event, resolve }) { event.locals.pb = new PocketBase('https://your-instance.pocketbasecloud.com'); event.locals.pb.authStore.loadFromCookie(event.request.headers.get('cookie') || ''); const response = await resolve(event); response.headers.set('set-cookie', event.locals.pb.authStore.exportToCookie()); return response; } ``` --- ### R-CLIENT-ASTRO ```js // src/middleware.ts import PocketBase from 'pocketbase'; export const onRequest = async (context, next) => { context.locals.pb = new PocketBase('https://your-instance.pocketbasecloud.com'); context.locals.pb.authStore.loadFromCookie(context.request.headers.get('cookie') || ''); const response = await next(); response.headers.set('set-cookie', context.locals.pb.authStore.exportToCookie()); return response; }; ``` --- ### R-CLIENT-NUXT ```js // plugins/pocketbase.js export default defineNuxtPlugin(() => { const pb = new PocketBase('https://your-instance.pocketbasecloud.com'); const cookie = useCookie('pb_auth', { path: '/', secure: true, sameSite: 'strict' }); pb.authStore.save(cookie.value?.token, cookie.value?.record); pb.authStore.onChange(() => { cookie.value = { token: pb.authStore.token, record: pb.authStore.record }; }); return { provide: { pb } }; }); // In components: const { $pb } = useNuxtApp(); ``` --- ### R-CLIENT-NODE ```js import PocketBase from 'pocketbase'; // Node < 17: import 'cross-fetch/polyfill'; // For realtime, also register an EventSource polyfill (R-RT-02). // Create a FRESH instance per request to avoid auth bleed const pb = new PocketBase('https://your-instance.pocketbasecloud.com'); // Superuser auth for admin work await pb.collection('_superusers').authWithPassword('admin@example.com', 'password'); // Or auth as a specific user await pb.collection('users').authWithPassword('user@example.com', 'password'); const records = await pb.collection('todos').getFullList(); ``` --- ### R-CLIENT-VANILLA ```html ``` Works in any static `.html` page — no `npm`, no bundler. Pin the version (R-SETUP-02). --- ## Deployment ### R-DEPLOY-01 — Deploy PocketBase to PocketBaseCloud with `pb` **Use when:** an instance has to be reachable on the internet without you running a server. **Prereqs:** R-CLI-01 (`pb` installed). **Plans** (check [pocketbasecloud.com/pricing](https://pocketbasecloud.com/pricing) before quoting a price to a user — this table is a snapshot): | Plan | Price | PocketBase | Backends | Compute | Regions | |---|---|---|---|---|---| | **Free** | $0/mo | 1 instance, 50 MB | ✗ | shared | 1 | | **Starter** | $5/mo flat | 1 instance, 3 GB | ✗ | separate cluster | 6 | | **Pro** | $20/mo flat | effectively unlimited | ✓ Node.js / Next.js / Deno / Bun | dedicated, 2 vCPU | 10 | - **Free** still requires a Stripe subscription — $0, never charged, collected to verify payment details and deter abuse. Projects are deleted after **30 days of inactivity**; Starter and Pro are not. - **Starter** add-ons: **+1 PocketBase** ($5/mo) and **+5 frontends** ($5/mo). They raise the limits without changing the plan. - **Pro** is a flat $20/mo for one dedicated 2-vCPU compute, not a per-instance price: PocketBase instances, backends and frontends on that compute are effectively unlimited. **Provisioning is not instant — allow up to 48 hours** after checkout before the compute is ready, and a `pb cloud pb deploy` before then has nowhere to land (exit `3`, `pb cloud compute ls` shows nothing). SSL certificates and frontend hosting are on every plan. Custom domains, DDoS protection and email/Discord support start at Starter; live monitoring and priority support are Pro. Do not describe an uptime SLA — none is published. **Steps — all from the terminal:** ```bash pb cloud login # browser auth, once per machine pb cloud project create my-app pb cloud project use my-app cd db # the directory holding pb_hooks/, pb_migrations/, pb_public/ pb cloud pb deploy --name my-app-db # packages them, provisions, waits for the URL ``` The deploy prints each wait as its own step (packaging, uploading, provisioning, waiting for the domain) and finishes with the instance URL and a **generated superuser password, shown once**. Read it again later with `pb cloud pb info --name my-app-db`; override either credential at create time with `--admin-email` / `--admin-password` (12–20 chars). Carry your dev schema over with the same two commands as any other instance move: ```bash pb use http://127.0.0.1:8090 && pb login && pb collections export --out schema.json pb use https://..pocketbasecloud.com && pb login pb collections import schema.json ``` Then the day-to-day loop: ```bash pb cloud pb ls # statuses at a glance pb cloud pb info --name my-app-db # URL, compute, admin login pb cloud pb deploy # redeploy from the linked directory (no flags) pb cloud logs pb --name my-app-db -f # follow its logs pb cloud pb rm --name my-app-db # delete (asks first) ``` The first deploy writes a `pb.json` binding in that directory, which is why later commands need no `--name`. `--pb-version 0.39.9` pins the PocketBase release; without it the newest published one is used. **`pb cloud deploy` picks the kind for you.** It inspects the directory and runs `pb cloud pb deploy`, `pb cloud frontend deploy`, or `pb cloud backend deploy` — same flags, same output, one less thing to get right. The order it decides in: the `kind` already in `pb.json`; `pb_hooks`/`pb_migrations`/`pb_public` (PocketBase); `next.config.*` (`output: "export"` → frontend, otherwise backend); a vite/svelte/vue config or `angular.json` (frontend); `deno.json` (backend); then `package.json` — a server dependency (express, fastify, hono, …) or a `start` script is a backend, a bundler dependency (vite, react-scripts, …) or a `build` script alone is a frontend; finally an `index.html` in the directory or in `public/`/`dist/`/`build/`/`out/` (frontend). A leading kind word overrides it: `pb cloud deploy backend`. Under `--no-input`/`--json` a directory it cannot classify exits `2` instead of prompting — in a pipeline, commit `pb.json` or name the kind. **Pitfalls** - A new domain needs DNS and a certificate before it answers, so the deploy waits after the status reads `running`. Giving up on that wait is not a failure — exit code stays `0`. - Free plan: 1 PocketBase instance, 50 MB. Backends are Pro-only (a create attempt exits `3`, not `4`). - Deleting is per-resource and confirmations are real — `--yes` is the only thing that skips them. --- ### R-DEPLOY-02 — Self-host + production checklist **Self-hosting docs:** [pocketbase.io/docs/going-to-production/](https://pocketbase.io/docs/going-to-production/). **Production checklist** (each line is a command you can actually run): ```bash pb use https://your-instance.example.com && pb login pb rules get # strict rules everywhere; empty rule = public (R-RULES-01) pb settings mail test you@example.com # password resets actually send (R-ADMIN-02) pb settings s3 test # file storage on S3-compatible, not local disk (R-FILE-03) pb settings backup create # backups work before you need one (R-ADMIN-05) pb settings get | jq .backups # auto-backup cron + retention are set pb logs --filter 'level >= 4' # nothing is already failing ``` Also: keep the backup destination off the same disk (Cloudflare R2 is free up to 10 GB), and `pb logout` on any shared machine — the saved superuser token bypasses every rule (R-SETUP-03). --- ### R-DEPLOY-03 — Deploy a static site or a backend **Use when:** the app's frontend or a Node/Deno/Bun/Next.js server has to go up alongside PocketBase. **Prereqs:** R-CLI-02 (`pb cloud login`). Frontend hosting serves a **single-page-app bundle** — a React/Vue/Svelte/Vite build with an `index.html` fallback. Backends run Node.js, Deno, Bun, or Next.js containers and are Pro-only. One directory per resource, linked once, then bare redeploys: ```bash cd web pb cloud frontend deploy --name my-app-web # builds, zips the output dir, uploads pb cloud frontend deploy # from now on: no flags cd ../api pb cloud backend deploy --name my-app-api # Pro plan only pb cloud logs backend --name my-app-api -f ``` **How to build is inferred, then recorded** in a `build` block in `pb.json` (`command`, `outputDir`, `runtime`, `exclude`, `envFile`). The first deploy prints what it guessed; `pb cloud init` does that step on its own so you can review the guess before anything ships. Edit the block when the guess is wrong — it is never overwritten. Missing dependencies are installed first with whatever the lockfile names, so a fresh clone or a cold CI runner deploys instead of failing on `next: not found`. Runtimes: `deno`, `bun`, and `nodejs` upload source and install on the platform. `nextjs` ships a prebuilt standalone bundle — deploy adds `output: "standalone"` to `next.config.*` for you and says so. A config setting `output: "export"` is refused rather than rewritten: that output is static files, not a server, so it belongs to `frontend deploy`. ```bash pb cloud frontend deploy --skip-build # package what is already built pb cloud frontend deploy --zip site.zip # upload an archive you built yourself pb cloud backend deploy --compute # pick the compute (see `pb cloud compute ls`) ``` **Pitfalls** - Backends require the Pro plan; on other plans the create exits `3` (not permitted), which is a plan limit, not a bug. - Frontends have **no** cloud env store — their variables are baked in at build time, so `--env-file` is rejected there (R-DEPLOY-04). - Under `--no-input`/`--json` nothing is asked, so pass `--name` and `--compute` explicitly in CI. --- ### R-DEPLOY-04 — Env vars, environments, custom domains, teams **Use when:** the deploy works and now needs configuration, a second stage, a real domain, or teammates. **Environment variables** (PocketBase instances and backends; nothing is pushed unless a file is named): ```bash pb cloud env ls --target backend --name my-app-api # names only — values are encrypted pb cloud env set API_KEY=secret --target backend --name my-app-api pb cloud env rm API_KEY --target backend --name my-app-api pb cloud env import .env.production --target backend --name my-app-api pb cloud env import .env.production --target backend --name my-app-api --delete-missing ``` An import **merges** by default: keys in the file are written, cloud-only keys are left alone. `--delete-missing` makes the file the whole truth. A deploy pushes the dotenv file recorded for that environment (`--env-file` names one, `--skip-env` pushes none, `--force-env` pushes even when unchanged since the last push). **Environments** — one directory, several stages, all in the same project: ```bash pb cloud frontend deploy --name web-staging --env staging # first deploy creates + links pb cloud environments # what this directory targets pb cloud frontend deploy --env production PB_ENV=staging pb cloud frontend deploy # for a whole shell (CI) pb cloud link frontend web # bind a directory to an existing resource pb cloud unlink # forget the binding (the cloud resource is untouched) ``` **Custom domains** (static sites): ```bash pb cloud frontend domain add app.mysite.com --name web pb cloud frontend domain verify app.mysite.com --name web pb cloud frontend domain remove app.mysite.com --name web ``` A deploy prints an unverified domain as `(custom domain — pending)` — that is the answer to "the deploy worked, so why does my domain show nothing?". **Teams:** ```bash pb cloud org create acme pb cloud org share my-app --org pb cloud org members add teammate@example.com pb cloud org share my-app --none # stop sharing ``` Everyone in an organization deploys against the **owner's** plan and onto the owner's compute — so a member on the free plan can deploy backends into a project whose owner is on Pro. **Pitfalls** - Values are never returned in plaintext; `env ls` lists names only. Do not expect to read a secret back out. - `--delete-missing` deletes cloud-only keys. On a deploy it applies to the pushed file the same way. - Unchanged variables are not re-uploaded (a digest is cached per resource). After editing variables in the portal, pass `--force-env`. --- ## Instance Operations (CLI) Everything below runs against the instance selected with `pb use` and authenticated with `pb login` (R-CLI-02). These are the same operations PocketBase's admin dashboard offers, in a form you can script and check the exit code of. ### R-ADMIN-01 — Manage collections ```bash pb collections ls # id, name, type, system pb collections get tasks # full definition as JSON pb collections create '{"name":"notes","type":"base","fields":[…]}' pb collections create notes --type base # empty collection pb collections update notes '{"name":"renamed"}' # partial — send only what changes pb collections rm notes --yes # drops the collection AND its data pb collections export --out schema.json pb collections import schema.json # add --delete-missing to replace ``` See R-DATA-01 (defining fields), R-DATA-02 (export/import), R-DATA-03 (relations), R-RULES-01 (`pb rules`), and §Ref-CollectionJSON for the body schema. **Pitfalls** - `name` is the only required key when creating; everything else is optional. - `--delete-missing` on import drops every collection the file omits. It asks first; `--yes` removes even that safety. - There is no CLI form of "truncate" — delete the records (`pb records rm`), or drop and recreate the collection. --- ### R-ADMIN-02 — App settings, SMTP, and S3 storage ```bash pb settings get # everything, as JSON (secrets come back redacted) pb settings get | jq .backups # always JSON — no --json needed pb settings mail # SMTP config pb settings mail set '{"enabled":true,"host":"smtp.example.com","port":587, "username":"apikey","password":"","tls":true}' pb settings mail test you@example.com pb settings s3 # file storage config pb settings s3 set '{"enabled":true,"bucket":"my-bucket","region":"auto", "endpoint":"https://.r2.cloudflarestorage.com", "accessKey":"","secret":"","forcePathStyle":true}' pb settings s3 test # prove the credentials work ``` Settings keys: `meta`, `logs`, `backups`, `smtp`, `s3`, `batch`, `rateLimits`, `trustedProxy` — full shapes in §Ref-SettingsBody. **Pitfalls** - Secrets are `******` in `settings get` output — you can write them, not read them back. - `mail test` / `s3 test` are the difference between "configured" and "working". Run them after every change. - Settings the CLI has no dedicated command for (rate limits, batch API, log retention) live in the admin dashboard → Settings. --- ### R-ADMIN-03 — Read logs Two different logs, two different commands: ```bash # Request/application logs INSIDE a PocketBase instance pb logs # recent entries pb logs --filter 'level >= 8' # errors only pb logs --filter 'data.url ~ "checkout"' # by URL pb logs --per-page 100 --page 2 pb logs -f # follow # Container logs of a CLOUD resource (stdout/stderr of the process) pb cloud logs pb --name my-app-db pb cloud logs backend --name my-app-api -f --lines 200 ``` Log levels: `-4` DEBUG, `0` INFO, `4` WARN, `8` ERROR. Filterable fields: `id`, `created`, `updated`, `level`, `message`, `data.*` (§Ref-FilterOps for operators). **Pitfalls** - `pb logs --json` prints **one JSON object per line** (JSONL), not an array — pipe it to `jq -c` / read it line by line. `pb collections ls --json` is an array; `pb records ls --json` is a page object. Check the shape before writing a `jq` filter. - `pb logs` needs an instance login; `pb cloud logs` needs the cloud login. Exit `4` tells you which one is missing. - `pb cloud logs` prints the last `--lines` entries (50 default, 1000 max) and stops unless you pass `-f`. - Frontends are static files and have no logs. --- ### R-ADMIN-04 — Cron jobs ```bash pb cron ls # id + schedule of every registered job pb cron run __pbLogsCleanup__ # trigger one now ``` PocketBase's own jobs have ids wrapped in double underscores — `__pbDBOptimize__`, `__pbMFACleanup__`, `__pbOTPCleanup__`, `__pbLogsCleanup__` — and jobs registered by your hooks (`cronAdd('MyJob', '*/5 * * * *', fn)`) appear alongside them. Which ones exist, and on what schedule, depends on the PocketBase version: **`pb cron ls` is the answer, not this list.** `pb cron run` is then the way to test a scheduled hook without waiting for its schedule. --- ### R-ADMIN-05 — Backups and data export ```bash pb settings backup ls pb settings backup create # name optional; [a-z0-9_-] pb settings backup create pre_migration pb settings backup download --out ./backup.zip pb settings backup rm old_backup.zip --yes pb cloud data export --name my-app-db --out data.zip # via the platform ``` A backup is a zip of the whole `pb_data` — schema, records, and uploaded files — so it is also the way to move an instance somewhere else wholesale. **Restoring, and uploading a backup taken elsewhere**, are not CLI commands. Use PocketBase's own admin dashboard → **Settings → Backups**, which uploads and restores a zip in two clicks, or the REST endpoints behind it (`POST /api/backups/upload`, `POST /api/backups/{key}/restore`). **Pitfalls** - Only one backup or restore runs at a time; a second one fails with `400` while the first is in flight. - A restore **restarts the instance** — clients see a brief connection drop. - Take one before anything destructive: `collections import --delete-missing`, `collections rm`, a migration you are unsure about. --- ## Misc ### R-MISC-01 — TypeScript with the SDK **Per-call generics:** ```ts interface Task { id: string; name: string; completed: boolean; } const tasks = await pb.collection('tasks').getList(1, 20); // tasks.items is Task[] const task = await pb.collection('tasks').getOne('RECORD_ID'); ``` **Global typed client** (one declaration, fully inferred everywhere): ```ts import PocketBase, { RecordService } from 'pocketbase'; interface Task { id: string; title: string; completed: boolean; } interface Post { id: string; title: string; content: string; } interface TypedPocketBase extends PocketBase { collection(idOrName: string): RecordService; collection(idOrName: 'tasks'): RecordService; collection(idOrName: 'posts'): RecordService; } const pb = new PocketBase('https://your-instance.pocketbasecloud.com') as TypedPocketBase; const task = await pb.collection('tasks').getOne('RECORD_ID'); // Promise const posts = await pb.collection('posts').getList(1, 20); // Promise> ``` --- ### R-MISC-02 — `beforeSend` / `afterSend` hooks (client-side) Modify every outgoing request or incoming response from a single place: ```js pb.beforeSend = function (url, options) { options.headers = Object.assign({}, options.headers, { 'X-Custom-Header': 'example', }); return { url, options }; }; pb.afterSend = function (response, data) { console.log(response.status); return Object.assign(data, { additionalField: 123 }); }; ``` Use cases: injecting auth on every call, logging, response normalization. **Note:** these are client-only hooks (different from server hooks in R-HOOK-*). --- ### R-MISC-03 — Multiple auth collections (multi-tenant) You can have separate auth collections (e.g. `users`, `admins`, `clients`), each with its own auth endpoints at `/api/collections/{collection}/auth-*`. Lets you separate roles or tenants without a single monolithic user table. ```js // Sign a customer in await pb.collection('clients').authWithPassword(email, password); // Or an internal admin (separate from _superusers) await pb.collection('admins').authWithPassword(email, password); ``` **Pitfalls** - Each collection has independent rules, OAuth configs, MFA settings. - The `_superusers` collection is special — its tokens bypass all rules. --- # PART 3 · REFERENCE ## §Ref-Fields — Field types (full) | Field | JS value | Options / attributes | |---|---|---| | `text` | `""` | `min`, `max`, `pattern`, `autogeneratePattern` (used for primary key IDs) | | `number` | `0` | `min`, `max`, `onlyInt` (integer-only if `true`) | | `bool` | `false` | — | | `email` | `""` | `exceptDomains` (blocked), `onlyDomains` (allowed) | | `url` | `""` | `onlyDomains` (allowed), `exceptDomains` (blocked) | | `editor` | `""` | HTML content stored as string; `convertURLs` | | `date` | `""` | `min`, `max` (RFC 3339 datetime bounds) | | `autodate` | auto | `onCreate` (set on insert), `onUpdate` (set on every update) | | `select` | `""` or `[]` | `values` (required), `maxSelect` (1 = single, ≥2 = multi) | | `file` | `""` or `[]` | `maxSelect`, `maxSize` (bytes), `mimeTypes`, `protected`, `thumbs` | | `relation` | `""` or `[]` | `collectionId` (required), `cascadeDelete`, `maxSelect`, `minSelect` | | `json` | `null` | **the only nullable field type** | | `geoPoint` | `{lon,lat}` | default `{"lon":0,"lat":0}` | | `password` | (never returned by the API) | auth collections only, system field: `cost`, `min` | Non-`json` fields are non-nullable with zero-defaults. **The `type` string is case-sensitive and must be written exactly as above** — `geoPoint`, not `geopoint`. Everything else is lowercase. ## §Ref-FilterOps — Filter operators `=`, `!=`, `>`, `>=`, `<`, `<=`, `~` (LIKE), `!~` (NOT LIKE), `?=`, `?!=`, `?>`, `?>=`, `?<`, `?<=`, `?~`, `?!~`. Prefix `?` means "any element matches" for multi-value (`select` multi, `relation` multi, etc.). Grouping: `&&` (AND), `||` (OR), `()`. Param binding via `pb.filter()` (R-CRUD-03) — supports `string`, `number`, `boolean`, `Date`, `null`. ## §Ref-RuleVars — Special variables in API rules | Variable | Value | |---|---| | `@request.auth.id` | ID of the authenticated user (empty if anonymous) | | `@request.auth.email` | Email of the authenticated user | | `@request.auth.role` | Value of a `role` field on the user record | | `@request.auth.` | Any field on the user record | | `@request.method` | HTTP method (`GET`, `POST`, …) | | `@request.data.*` | Submitted form data during create/update | | `@collection..*` | Cross-collection lookup | Empty rule string = **public**. `null` rule = **superuser-only**. ## §Ref-CollectionJSON — Collection JSON schema (full) **Body shape** (create/update; all top-level keys except `name` optional on create; all optional on update): ``` { name (required on create): string — unique, used as table name type: "base" | "auth" | "view" fields: Array<{ name: string, type: text|number|bool|email|url|editor|date|autodate|select|file|relation|json|geoPoint, required: boolean, primaryKey: boolean, system: boolean, hidden: boolean, // type-specific options: min, max, pattern, autogeneratePattern, onlyInt, // values, maxSelect, minSelect, collectionId, cascadeDelete, maxSize, mimeTypes, // protected, thumbs, exceptDomains, onlyDomains, onCreate, onUpdate, convertURLs }> indexes: Array // SQL CREATE INDEX statements (not for "view") system: boolean // prevents rename/delete of API rules listRule, viewRule, createRule, updateRule, deleteRule: null | string // view-only viewQuery: string // SQL SELECT // auth-only manageRule, authRule: null | string authAlert: { enabled, emailTemplate: { subject, body } } oauth2: { enabled, mappedFields: {id,name,username,avatarURL}, providers: [...] } passwordAuth: { enabled, identityFields: Array } mfa: { enabled, duration, rule } otp: { enabled, duration, length, emailTemplate: { subject, body } } authToken, passwordResetToken, emailChangeToken, verificationToken, fileToken: { duration, secret } verificationTemplate, resetPasswordTemplate, confirmEmailChangeTemplate: { subject, body } } ``` **Example — full collection definition with every common field type:** ```json [ { "id": "pbc_1543120290", "listRule": "@request.auth.id != ''", "viewRule": "@request.auth.id != ''", "createRule": "@request.auth.id != ''", "updateRule": "relationField = @request.auth.id", "deleteRule": "relationField = @request.auth.id", "name": "testCollection", "type": "base", "fields": [ { "type": "text", "id": "text3208210256", "name": "id", "autogeneratePattern": "[a-z0-9]{15}", "max": 15, "min": 15, "pattern": "^[a-z0-9]+$", "primaryKey": true, "required": true, "system": true, "presentable": false, "hidden": false }, { "type": "text", "id": "text1542800728", "name": "field", "max": 200000, "min": 1000, "pattern": "^[a-z0-9]+$", "autogeneratePattern": "", "primaryKey": false, "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "editor", "id": "editor768641678", "name": "editorField", "convertURLs": false, "maxSize": 6000000, "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "number", "id": "number2245382642", "name": "numberField", "max": 100, "min": 20, "onlyInt": true, "required": true, "system": false, "presentable": false, "hidden": false }, { "type": "bool", "id": "bool3280560631", "name": "boolField", "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "email", "id": "email1478965954", "name": "emailField", "exceptDomains": ["temp.com","mailinator.com"], "onlyDomains": [], "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "url", "id": "url2037873857", "name": "urlField", "exceptDomains": [], "onlyDomains": ["github.com","google.com"], "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "date", "id": "date2270773164", "name": "datetimeField", "min": "2026-05-22 12:00:00.000Z", "max": "2029-05-31 12:00:00.000Z", "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "select", "id": "select1865290411", "name": "selectField", "maxSelect": 2, "values": ["option1","option2"], "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "file", "id": "file816577617", "name": "fileFieldSingle", "maxSelect": 1, "maxSize": 7000000, "mimeTypes": ["image/x-xpixmap","application/x-7z-compressed","application/zip","application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], "protected": false, "thumbs": [], "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "file", "id": "file2307418135", "name": "multipleFilesField", "maxSelect": 10, "maxSize": 4000000, "mimeTypes": ["image/jpeg","image/png","image/webp"], "protected": false, "thumbs": [], "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "relation", "id": "relation1882921418", "name": "relationField", "collectionId": "_pb_users_auth_", "cascadeDelete": false, "maxSelect": 1, "minSelect": 0, "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "json", "id": "json512575104", "name": "jsonField", "maxSize": 3000000, "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "geoPoint", "id": "geoPoint3983504865", "name": "geoPointField", "required": false, "system": false, "presentable": false, "hidden": false }, { "type": "autodate", "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "system": false, "presentable": false, "hidden": false }, { "type": "autodate", "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "system": false, "presentable": false, "hidden": false } ], "indexes": ["CREATE INDEX `idx_c6IIDITnkz` ON `testCollection` (`relationField`)"], "system": false } ] ``` **Example — auth collection update payload** (compact, single-line — useful for diffing): ```json {"id":"_pb_users_auth_","listRule":null,"viewRule":"id = @request.auth.id","createRule":"","updateRule":null,"deleteRule":null,"name":"users","type":"auth","fields":[{"autogeneratePattern":"[a-z0-9]{15}","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"cost":0,"hidden":true,"id":"password901924565","max":0,"min":8,"name":"password","pattern":"","presentable":false,"required":true,"system":true,"type":"password"},{"autogeneratePattern":"[a-zA-Z0-9]{50}","hidden":true,"id":"text2504183744","max":60,"min":30,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"},{"hidden":false,"id":"email3885137012","name":"email","onlyDomains":null,"exceptDomains":null,"presentable":false,"required":true,"system":true,"type":"email"},{"hidden":false,"id":"bool1547992806","name":"emailVisibility","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool256245529","name":"verified","presentable":false,"required":false,"system":true,"type":"bool"},{"autogeneratePattern":"","hidden":false,"id":"text1579384326","max":255,"min":0,"name":"name","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"hidden":false,"id":"file376926767","maxSelect":1,"maxSize":0,"mimeTypes":["image/jpeg","image/png","image/svg+xml","image/gif","image/webp"],"name":"avatar","presentable":false,"protected":false,"required":false,"system":false,"thumbs":null,"type":"file"},{"hidden":false,"id":"autodate2990389176","name":"created","onCreate":true,"onUpdate":false,"presentable":false,"system":false,"type":"autodate"},{"hidden":false,"id":"autodate3332085495","name":"updated","onCreate":true,"onUpdate":true,"presentable":false,"system":false,"type":"autodate"}],"indexes":["CREATE UNIQUE INDEX `idx_tokenKey__pb_users_auth_` ON `users` (`tokenKey`)","CREATE UNIQUE INDEX `idx_email__pb_users_auth_` ON `users` (`email`) WHERE `email` != ''"],"system":false,"authRule":"","manageRule":null,"authAlert":{"enabled":true,"emailTemplate":{"subject":"Login from a new location","body":"

...

"}},"oauth2":{"providers":[{"name":"google","clientId":"ExampleClientID","clientSecret":"ExampleClientSecret"}],"mappedFields":{"id":"","name":"name","username":"","avatarURL":"avatar"},"enabled":true},"passwordAuth":{"enabled":false,"identityFields":["email"]},"mfa":{"enabled":false,"duration":1800,"rule":""},"otp":{"enabled":false,"duration":180,"length":8,"emailTemplate":{"subject":"OTP for {APP_NAME}","body":"

Your OTP: {OTP}

"}},"authToken":{"duration":100000},"passwordResetToken":{"duration":1800},"emailChangeToken":{"duration":1800},"verificationToken":{"duration":259200},"fileToken":{"duration":180},"verificationTemplate":{"subject":"Verify your {APP_NAME} email","body":"

Click {APP_URL}/_/#/auth/confirm-verification/{TOKEN}

"},"resetPasswordTemplate":{"subject":"Reset your {APP_NAME} password","body":"

Click {APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}

"},"confirmEmailChangeTemplate":{"subject":"Confirm your {APP_NAME} new email address","body":"

Click {APP_URL}/_/#/auth/confirm-email-change/{TOKEN}

"}} ``` ## §Ref-SettingsBody — Settings body schema Top-level keys (all optional on PATCH): - `meta`: `appName`, `appUrl`, `senderName`, `senderAddress`, `hideControls` - `logs`: `maxDays`, `minLevel` (`-4`=DEBUG, `0`=INFO, `4`=WARN, `8`=ERROR), `logIP`, `logAuthId` - `backups`: `cron` (cron expression), `cronMaxKeep`, `s3` (nested S3 config) - `smtp`: `enabled`, `host`, `port`, `username`, `password`, `tls`, `authMethod`, `localName` - `s3`: `enabled`, `bucket`, `region`, `endpoint`, `accessKey`, `secret`, `forcePathStyle` - `batch`: `enabled`, `maxRequests`, `timeout`, `maxBodySize` - `rateLimits`: `enabled`, `rules` (array of `{label, maxRequests, duration}`) - `trustedProxy`: `headers` (array), `useLeftmostIP` ## §Ref-HookGlobals — Hook globals | Object | Purpose | |---|---| | `$app` | The PocketBase app — DB access (`findRecordById`, `findRecordsByFilter`, `save`, `delete`, `db().newQuery()`) and record ops | | `$apis` | API routing helpers and middlewares (`$apis.requireAuth()`, `$apis.requireSuperuserAuth()`, etc.) | | `$os` | OS operations — file system, shell commands | | `$security` | JWT signing/parsing, AES encryption, random strings | | `__hooks` | Absolute path to `pb_hooks/` directory | ## §Ref-HookEvents — Common event hooks ```js // App lifecycle onBootstrap(e => { e.next(); }); onTerminate(e => { e.next(); }); // Record lifecycle (collection arg optional — omit for global) onRecordCreateRequest(e => { e.next(); }, 'todos'); onRecordAfterCreateSuccess(e => { e.next(); }, 'todos'); onRecordUpdateRequest(e => { e.next(); }, 'todos'); onRecordAfterUpdateSuccess(e => { e.next(); }, 'todos'); onRecordDeleteRequest(e => { e.next(); }, 'todos'); onRecordAfterDeleteSuccess(e => { e.next(); }, 'todos'); // Auth onRecordAuthRequest(e => { e.next(); }, 'users'); onRecordAuthRefreshRequest(e => { e.next(); }, 'users'); // Mail onMailerSend(e => { e.next(); }); ``` ## §Ref-CLI — `pb` command reference Authoritative for the installed version: `pb --help --json`. Global flags: `--json`, `--yes`/`-y`, `--no-input`, `--interactive`/`-i`, `--project `, `--profile `, `--version`/`-v`, `--help`/`-h`. **Local (this machine)** | Command | Purpose | |---|---| | `pb init [] [--dir ] [--force]` | download PocketBase + scaffold a project + pin the version | | `pb install [] [--os ] [--arch ]` | download the binary and record the pin, no scaffolding | | `pb versions [--all] [--pre]` | list available PocketBase releases | | `pb which [--dir ]` | which binary is here and what is pinned | | `pb upgrade [] [--check]` | update `pb` itself (checksum-verified) | **Instance (`pb use ` first)** | Command | Purpose | |---|---| | `pb use [--name ]` | select an instance | | `pb login [--email ] [--password

]` | log in as superuser (prompts) | | `pb logout [--remove]` / `pb whoami` | clear the token / show the active profile | | `pb collections ls\|get\|create\|update\|rm` | manage collections | | `pb collections export [--out ]` / `import [--delete-missing]` | schema in/out | | `pb records ls [--filter][--sort][--page][--per-page]` | list records | | `pb records get\|create\|update\|rm …` | read/write one record | | `pb rules get ` / `pb rules set --list-rule …` | API rules (`null` clears) | | `pb auth config [--set '=']` | OAuth2, MFA, OTP, auth templates | | `pb settings get` | all instance settings | | `pb settings mail [set ''\|test ]` | SMTP | | `pb settings s3 [set ''\|test]` | file storage | | `pb settings backup ls\|create\|rm\|download` | backups (restore: admin UI) | | `pb cron ls` / `pb cron run ` | cron jobs | | `pb logs [--filter ] [-f]` | instance request logs | **Cloud (`pb cloud login` first)** | Command | Purpose | |---|---| | `pb cloud login\|logout\|whoami` | account auth (`PB_TOKEN` for CI) | | `pb cloud project ls\|create\|use\|rm` | projects | | `pb cloud init [pb\|frontend\|backend]` | write this directory's build config | | `pb cloud link [] []` / `unlink [--all]` | bind a directory to a resource | | `pb cloud environments` | environments recorded in `pb.json` | | `pb cloud pb deploy\|ls\|info\|rm` | PocketBase instances | | `pb cloud pb hooks push\|ls\|rm` | hook files on an instance | | `pb cloud frontend deploy\|ls\|info\|rm` | static sites | | `pb cloud frontend domain add\|verify\|remove` | custom domains | | `pb cloud backend deploy\|ls\|info\|rm` | backends (Pro) | | `pb cloud env ls\|set\|rm\|import` | environment variables | | `pb cloud logs --name [-f] [--lines ]` | container logs (50 lines default, 1000 max) | | `pb cloud data export [--name ] [--out ]` | export an instance's data | | `pb cloud data import ` | **not implemented** — it says so instead of failing mid-request (R-DATA-04) | | `pb cloud compute ls` | compute ids `--compute` accepts (`pb cloud server ls` is the old name) | | `pb cloud org ls\|create\|rm\|share` / `pb cloud org members ls\|add\|rm` | organizations | | `pb cloud upgrade` | current plan + upgrade link | Env vars the CLI reads: `PB_TOKEN` (cloud auth, overrides the saved login), `PB_ENV` (default environment), `PB_NO_UPDATE_CHECK`, `PB_INSTALL_DIR`, `XDG_CONFIG_HOME`. Config lives in `~/.config/pb/config.json`; per-directory bindings live in `pb.json`. ## §Ref-AdminREST — REST endpoints behind the CLI Use these only where the CLI has no command (backup upload/restore, collection truncate, log stats, Apple client secret) or when writing application code. All require superuser auth except `/api/health`; the header is the bare token, with no `Bearer` prefix. | Domain | Endpoints | |---|---| | Collections | `GET/POST /api/collections`, `GET/PATCH/DELETE /api/collections/{id}`, `DELETE /api/collections/{id}/truncate`, `PUT /api/collections/import`, `GET /api/collections/meta/scaffolds` | | Settings | `GET/PATCH /api/settings`, `POST /api/settings/test/s3`, `POST /api/settings/test/email`, `POST /api/settings/apple/generate-client-secret` | | Logs | `GET /api/logs`, `GET /api/logs/{id}`, `GET /api/logs/stats` | | Crons | `GET /api/crons`, `POST /api/crons/{jobId}` | | Backups | `GET/POST /api/backups`, `POST /api/backups/upload`, `DELETE /api/backups/{key}`, `POST /api/backups/{key}/restore`, `GET /api/backups/{key}?token=...` | | Health | `GET /api/health` (no auth) | ## §Ref-SDKCheatsheet — One-line SDK reminders | Goal | Code | |---|---| | Init client | `const pb = new PocketBase('https://...');` | | Sign up + login | `await pb.collection('users').create({ email, password, passwordConfirm, name });` then `authWithPassword` | | Auth check | `if (pb.authStore.isValid) { ... }` | | Current user | `pb.authStore.record` | | List w/ filter + sort | `pb.collection('todos').getList(1, 20, { filter: '...', sort: '-created' })` | | Get all records | `pb.collection('todos').getFullList()` | | Create with file | `pb.collection('posts').create({ title: 'Hi', image: new File(...) })` | | Realtime subscribe | `pb.collection('todos').subscribe('*', (e) => { ... })` | | Expand relations | `{ expand: 'user,comments.user' }` | | Safe filter params | `pb.filter('title ~ {:t}', { t: userInput })` | | Batch | `const b = pb.createBatch(); b.collection('a').create({...}); b.send()` | | SSR cookie flow | `pb.authStore.loadFromCookie(reqCookie)` then `exportToCookie()` on response | ## §Ref-CLICheatsheet — One-line CLI reminders | Goal | Command | |---|---| | Install | `npm i -g @pocketbasecloud/cli` | | Every command, as JSON | `pb --help --json` | | Target an instance | `pb use https://host` then `pb login` | | New collection with fields | `pb collections create '{"name":"tasks","fields":[…]}'` | | Schema out / in | `pb collections export --out s.json` / `pb collections import s.json` | | List records, filtered | `pb records ls posts --filter 'published = true' --json \| jq .items` | | Lock a collection down | `pb rules set posts --list-rule '@request.auth.id != ""'` | | Enable Google login | `pb auth users config --set 'oauth2={…}'` | | Prove email works | `pb settings mail test you@example.com` | | Back up before anything risky | `pb settings backup create pre_change` | | Errors only | `pb logs --filter 'level >= 8'` | | Local dev instance | `pb init && ./pocketbase serve` | | Deploy PocketBase | `pb cloud login && pb cloud pb deploy --name my-db` | | Deploy a site / a backend | `pb cloud frontend deploy` / `pb cloud backend deploy` | | Follow cloud logs | `pb cloud logs backend --name api -f` | | Unattended | add `--json --no-input` (and `--yes` for deletions) | ## §Ref-ExternalDocs — Docs **The `pb` CLI** (third-party, published by PocketBase Cloud): `pb --help --json` is always the truth for the installed version. - npm: [@pocketbasecloud/cli](https://www.npmjs.com/package/@pocketbasecloud/cli) - Source, releases, installer: [github.com/pocketbasecloud/cli](https://github.com/pocketbasecloud/cli) - Managed hosting: [pocketbasecloud.com](https://pocketbasecloud.com) **PocketBase itself** — when in doubt, verify against the official docs (the CLI is a wrapper over these APIs, so their semantics are its semantics): - [Collections API](https://pocketbase.io/docs/api-collections/) — CRUD, import, scaffolds, filter syntax - [Settings API](https://pocketbase.io/docs/api-settings/) — settings endpoints, S3/email tests, Apple client secret - [Logs API](https://pocketbase.io/docs/api-logs/) — list, view, stats, filter - [Crons API](https://pocketbase.io/docs/api-crons/) — list and trigger - [Backups API](https://pocketbase.io/docs/api-backups/) — create, upload, download, restore, delete - [Health API](https://pocketbase.io/docs/api-health/) — health check - [Records API](https://pocketbase.io/docs/api-records/) — CRUD, filter, expand, fields - [Realtime API](https://pocketbase.io/docs/api-realtime/) — SSE subscriptions - [Files API](https://pocketbase.io/docs/api-files/) — upload, download, thumbnails - [Auth API](https://pocketbase.io/docs/api-authentication/) — password, OAuth2, OTP, MFA, verification, reset - [Going to Production](https://pocketbase.io/docs/going-to-production/) — self-hosting