Initial commit

This commit is contained in:
2026-04-17 23:26:01 +00:00
commit 2ea4ca5d52
409 changed files with 63459 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
# PocketBase Best Practices
**Version 1.3.0**
Community
April 2026
> Comprehensive PocketBase development best practices and performance optimization guide. Contains 63 rules across 9 categories, prioritized by impact from critical (collection design, API rules, authentication) to incremental (production deployment). Includes server-side extending rules for Go and JavaScript (JSVM): event hooks, custom routing, transactions with scoped txApp, server-side filter binding, filesystem handling, cron job scheduling, and Go schema migrations. Updated for PocketBase v0.36.8 and JS SDK v0.26.8 (getFullList default batch size 1000, authWithOAuth2 cancellation fix, getURL null param handling, v0.36.7 fixed-window rate limiter, v0.36.0 strftime() filter function, OTP auth flow). Each rule includes detailed explanations, incorrect vs. correct code examples, and specific guidance to help AI agents generate better PocketBase code.
---
## Categories
Detailed rules are split by category. Load only the relevant file:
### 1. [Collection Design](references/collection-design.md) - **CRITICAL**
Schema design, field types, relations, indexes, and collection type selection. Foundation for application architecture and long-term maintainability.
- 1.1 Use Auth Collections for User Accounts
- 1.2 Choose Appropriate Field Types for Your Data
- 1.3 Use GeoPoint Fields for Location Data
- 1.4 Create Indexes for Frequently Filtered Fields
- 1.5 Configure Relations with Proper Cascade Options
- 1.6 Use View Collections for Complex Read-Only Queries
### 2. [API Rules & Security](references/api-rules-security.md) - **CRITICAL**
Access control rules, filter expressions, request context usage, and security patterns. Critical for protecting data and enforcing authorization.
- 2.1 Understand API Rule Types and Defaults
- 2.2 Use @collection for Cross-Collection Lookups
- 2.3 Master Filter Expression Syntax
- 2.4 Default to Locked Rules, Open Explicitly
- 2.5 Use @request Context in API Rules
- 2.6 Use strftime() for Date Arithmetic in Filter Expressions
### 3. [Authentication](references/authentication.md) - **CRITICAL**
Password authentication, OAuth2 integration, token management, MFA setup, and auth collection configuration.
- 3.1 Use Impersonation for Admin Operations
- 3.2 Implement Multi-Factor Authentication
- 3.3 Integrate OAuth2 Providers Correctly
- 3.4 Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
- 3.5 Implement Secure Password Authentication
- 3.6 Manage Auth Tokens Properly
### 4. [SDK Usage](references/sdk-usage.md) - **HIGH**
JavaScript SDK initialization, auth store patterns, error handling, request cancellation, and safe parameter binding.
- 4.1 Use Appropriate Auth Store for Your Platform
- 4.2 Understand and Control Auto-Cancellation
- 4.3 Handle SDK Errors Properly
- 4.4 Use Field Modifiers for Incremental Updates
- 4.5 Use Safe Parameter Binding in Filters
- 4.6 Initialize PocketBase Client Correctly
- 4.7 Use Send Hooks for Request Customization
### 5. [Query Performance](references/query-performance.md) - **HIGH**
Pagination strategies, relation expansion, field selection, batch operations, and N+1 query prevention.
- 5.1 Use Back-Relations for Inverse Lookups
- 5.2 Use Batch Operations for Multiple Writes
- 5.3 Expand Relations Efficiently
- 5.4 Select Only Required Fields
- 5.5 Use getFirstListItem for Single Record Lookups
- 5.6 Prevent N+1 Query Problems
- 5.7 Use Efficient Pagination Strategies
### 6. [Realtime](references/realtime.md) - **MEDIUM**
SSE subscriptions, event handling, connection management, and authentication with realtime.
- 6.1 Authenticate Realtime Connections
- 6.2 Handle Realtime Events Properly
- 6.3 Handle Realtime Connection Issues
- 6.4 Implement Realtime Subscriptions Correctly
### 7. [File Handling](references/file-handling.md) - **MEDIUM**
File uploads, URL generation, thumbnail creation, and validation patterns.
- 7.1 Generate File URLs Correctly
- 7.2 Upload Files Correctly
- 7.3 Validate File Uploads
### 8. [Production & Deployment](references/production-deployment.md) - **LOW-MEDIUM**
Backup strategies, configuration management, reverse proxy setup, and SQLite optimization.
- 8.1 Implement Proper Backup Strategies
- 8.2 Configure Production Settings Properly
- 8.3 Enable Rate Limiting for API Protection
- 8.4 Configure Reverse Proxy Correctly
- 8.5 Tune OS and Runtime for PocketBase Scale
- 8.6 Optimize SQLite for Production
### 9. [Server-Side Extending](references/server-side-extending.md) - **HIGH**
Extending PocketBase with Go or embedded JavaScript (JSVM) - event hooks, custom routes, transactions, cron jobs, filesystem, migrations, and safe server-side filter binding.
- 9.1 Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
- 9.2 Schedule Recurring Jobs with the Builtin Cron Scheduler
- 9.3 Always Close the Filesystem Handle Returned by NewFilesystem
- 9.4 Bind User Input in Server-Side Filters with {:placeholder} Params
- 9.5 Use DBConnect Only When You Need a Custom SQLite Driver
- 9.6 Version Your Schema with Go Migrations
- 9.7 Set Up a Go-Extended PocketBase Application
- 9.8 Always Call e.Next() and Use e.App Inside Hook Handlers
- 9.9 Pick the Right Record Hook - Model vs Request vs Enrich
- 9.10 Write JSVM Migrations as pb_migrations/*.js Files
- 9.11 Set Up JSVM (pb_hooks) for Server-Side JavaScript
- 9.12 Load Shared Code with CommonJS require() in pb_hooks
- 9.13 Avoid Capturing Variables Outside JSVM Handler Scope
- 9.14 Send Email via app.NewMailClient, Never the Default example.com Sender
- 9.15 Register Custom Routes Safely with Built-in Middlewares
- 9.16 Read Settings via app.Settings(), Encrypt at Rest with PB_ENCRYPTION
- 9.17 Test Hooks and Routes with tests.NewTestApp and ApiScenario
- 9.18 Use RunInTransaction with the Scoped txApp, Never the Outer App
---
## References
- https://pocketbase.io/docs/
- https://github.com/pocketbase/pocketbase
- https://github.com/pocketbase/js-sdk
- https://pocketbase.io/docs/api-records/
- https://pocketbase.io/docs/api-rules-and-filters/
- https://pocketbase.io/docs/go-overview/
- https://pocketbase.io/docs/js-overview/
- https://pocketbase.io/docs/go-migrations/
- https://pocketbase.io/docs/go-jobs-scheduling/
- https://pocketbase.io/docs/js-jobs-scheduling/
- https://pocketbase.io/docs/going-to-production/

View File

@@ -0,0 +1,155 @@
---
name: pocketbase-best-practices
description: PocketBase development best practices covering collection design, API rules, authentication, SDK usage, query optimization, realtime subscriptions, file handling, and deployment. Use when building PocketBase backends, designing schemas, implementing access control, setting up auth flows, or optimizing performance.
license: MIT
compatibility: Works with any agent. Requires PocketBase v0.36+.
metadata:
author: community
version: "1.2.0"
repository: https://github.com/greendesertsnow/pocketbase-skills
documentation: https://pocketbase.io/docs/
---
# PocketBase Best Practices
63 rules across 9 categories for PocketBase v0.36+, prioritized by impact.
## Categories by Priority
| Priority | Category | Impact | Rules |
|----------|----------|--------|-------|
| 1 | Collection Design | CRITICAL | coll-field-types, coll-auth-vs-base, coll-relations, coll-indexes, coll-view-collections, coll-geopoint |
| 2 | API Rules & Security | CRITICAL | rules-basics, rules-filter-syntax, rules-request-context, rules-cross-collection, rules-locked-vs-open, rules-strftime |
| 3 | Authentication | CRITICAL | auth-password, auth-oauth2, auth-otp, auth-token-management, auth-mfa, auth-impersonation |
| 4 | SDK Usage | HIGH | sdk-initialization, sdk-auth-store, sdk-error-handling, sdk-auto-cancellation, sdk-filter-binding, sdk-field-modifiers, sdk-send-hooks |
| 5 | Query Performance | HIGH | query-pagination, query-expand, query-field-selection, query-batch-operations, query-n-plus-one, query-first-item, query-back-relations |
| 6 | Realtime | MEDIUM | realtime-subscribe, realtime-events, realtime-auth, realtime-reconnection |
| 7 | File Handling | MEDIUM | file-upload, file-serving, file-validation |
| 8 | Production & Deployment | MEDIUM | deploy-backup, deploy-configuration, deploy-reverse-proxy, deploy-sqlite-considerations, deploy-rate-limiting, deploy-scaling |
| 9 | Server-Side Extending | HIGH | ext-go-setup, ext-js-setup, ext-hooks-chain, ext-hooks-record-vs-request, ext-routing-custom, ext-transactions, ext-filter-binding-server, ext-filesystem, ext-cron-jobs, ext-go-migrations, ext-js-migrations, ext-mailer, ext-settings, ext-testing, ext-compose-request-flow, ext-go-custom-sqlite, ext-jsvm-scope, ext-jsvm-modules |
## Quick Reference
### Collection Design (CRITICAL)
- **coll-field-types**: Use appropriate field types (json for objects, select for enums)
- **coll-auth-vs-base**: Extend auth collection for users, base for non-auth data
- **coll-relations**: Use relation fields, not manual ID strings
- **coll-indexes**: Create indexes on frequently filtered/sorted fields
- **coll-view-collections**: Use views for complex aggregations
- **coll-geopoint**: Store coordinates as json field with lat/lng
### API Rules (CRITICAL)
- **rules-basics**: Always set API rules; empty = public access
- **rules-filter-syntax**: Use @request.auth, @collection, @now in rules
- **rules-request-context**: Access request data via @request.body, @request.query; `@request.context` values: `default`/`oauth2`/`otp`/`password`/`realtime`/`protectedFile`
- **rules-cross-collection**: Use @collection.name.field for cross-collection checks
- **rules-locked-vs-open**: Start locked, open selectively
- **rules-strftime**: Use `strftime('%Y-%m-%d', created)` for date arithmetic (v0.36+)
### Authentication (CRITICAL)
- **auth-password**: Use authWithPassword for email/password login
- **auth-oauth2**: Configure OAuth2 providers via Admin UI
- **auth-otp**: Two-step `requestOTP``authWithOTP`; rate-limit requestOTP and never leak email existence
- **auth-token-management**: Store tokens securely, refresh before expiry
- **auth-mfa**: Enable MFA for sensitive applications
- **auth-impersonation**: Use impersonation for admin actions on behalf of users
### SDK Usage (HIGH)
- **sdk-initialization**: Initialize client once, reuse instance
- **sdk-auth-store**: Use AsyncAuthStore for React Native/SSR
- **sdk-error-handling**: Catch ClientResponseError, check status codes
- **sdk-auto-cancellation**: Disable auto-cancel for concurrent requests
- **sdk-filter-binding**: Use filter binding to prevent injection
### Query Performance (HIGH)
- **query-expand**: Expand relations to avoid N+1 queries
- **query-field-selection**: Select only needed fields
- **query-pagination**: Use cursor pagination for large datasets
- **query-batch-operations**: Batch creates/updates when possible
### Realtime (MEDIUM)
- **realtime-subscribe**: Subscribe to specific records or collections
- **realtime-events**: Handle create, update, delete events separately
- **realtime-auth**: Realtime respects API rules automatically
- **realtime-reconnection**: Implement reconnection logic
### File Handling (MEDIUM)
- **file-upload**: Use FormData for uploads, set proper content types
- **file-serving**: Use pb.files.getURL() for file URLs
- **file-validation**: Validate file types and sizes server-side
### Deployment (MEDIUM)
- **deploy-backup**: Schedule regular backups of pb_data
- **deploy-configuration**: Use environment variables for config
- **deploy-reverse-proxy**: Put behind nginx/caddy in production
- **deploy-sqlite-considerations**: Optimize SQLite for production workloads
- **deploy-rate-limiting**: Enable the built-in rate limiter (fixed-window as of v0.36.7); front with Nginx/Caddy for defense in depth
- **deploy-scaling**: Raise `ulimit -n` for realtime, set `GOMEMLIMIT`, enable settings encryption
### Server-Side Extending (HIGH)
- **ext-go-setup**: Use `app.OnServe()` to register routes; use `e.App` inside hooks, not the parent-scope app
- **ext-js-setup**: Drop `*.pb.js` in `pb_hooks/`; add `/// <reference path="../pb_data/types.d.ts" />`
- **ext-hooks-chain**: Always call `e.Next()`/`e.next()`; use `Bind` with an Id for later `Unbind`
- **ext-hooks-record-vs-request**: Use `OnRecordEnrich` to shape responses (incl. realtime); `OnRecordRequest` for HTTP-only
- **ext-routing-custom**: Namespace routes under `/api/{yourapp}/`; attach `RequireAuth()` middleware
- **ext-transactions**: Use the scoped `txApp` inside `RunInTransaction`; never capture the outer `app`
- **ext-filter-binding-server**: Bind user input with `{:name}` + `dbx.Params` in `FindFirstRecordByFilter` / `FindRecordsByFilter`
- **ext-filesystem**: `defer fs.Close()` on every `NewFilesystem()` / `NewBackupsFilesystem()` handle
- **ext-cron-jobs**: Register with `app.Cron().MustAdd(id, expr, fn)` / `cronAdd()`; stable ids, no `__pb*__` prefix
- **ext-go-migrations**: Versioned `.go` files under `migrations/`; `Automigrate: osutils.IsProbablyGoRun()`
- **ext-js-migrations**: `pb_migrations/<unix>_*.js` with `migrate(upFn, downFn)`; auto-discovered by filename
- **ext-mailer**: Resolve sender from `app.Settings().Meta` at send-time; never ship `no-reply@example.com`; create the mail client per send
- **ext-settings**: Read via `app.Settings()` at call time; set `PB_ENCRYPTION` (32 chars) to encrypt `_params` at rest
- **ext-testing**: `tests.NewTestApp(testDataDir)` + `tests.ApiScenario`; `defer app.Cleanup()`, assert `ExpectedEvents`
- **ext-compose-request-flow**: Composite walkthrough showing which app instance is active at each layer (route → tx → hook → enrich)
- **ext-go-custom-sqlite**: Only use `DBConnect` when you need FTS5/ICU; `DBConnect` is called twice (data.db + auxiliary.db)
- **ext-jsvm-scope**: Variables outside handlers are undefined at runtime — load shared config via `require()` inside the handler
- **ext-jsvm-modules**: Only CJS (`require()`) works in goja; bundle ESM first; avoid mutable module state
## Example Prompts
Try these with your AI agent to see the skill in action:
**Building a new feature:**
- "Design a PocketBase schema for an e-commerce app with products, orders, and reviews"
- "Implement OAuth2 login with Google and GitHub for my app"
- "Build a real-time notification system with PocketBase subscriptions"
- "Create a file upload form with image validation and thumbnail previews"
**Fixing issues:**
- "My list query is slow on 100k records -- optimize it"
- "I'm getting 403 errors on my batch operations"
- "Fix the N+1 query problem in my posts list that loads author data in a loop"
- "My realtime subscriptions stop working after a few minutes"
**Security review:**
- "Review my API rules -- users should only access their own data"
- "Set up proper access control: admins manage all content, users edit only their own"
- "Are my authentication cookies configured securely for SSR?"
- "Audit my collection rules for IDOR vulnerabilities"
**Going to production:**
- "Configure Nginx with HTTPS, rate limiting, and security headers for PocketBase"
- "Set up automated backups for my PocketBase database"
- "Optimize SQLite settings for a production workload with ~500 concurrent users"
- "Deploy PocketBase with Docker Compose and Caddy"
**Extending PocketBase:**
- "Add a custom Go route that sends a Slack notification after a record is created"
- "Write a pb_hooks script that validates an email domain before user signup"
- "Set up FTS5 full-text search with a custom SQLite driver in my Go app"
- "Share a config object across multiple pb_hooks files without race conditions"
## Detailed Rules
Load the relevant category for complete rule documentation with code examples:
- [Collection Design](references/collection-design.md) - Schema patterns, field types, relations, indexes
- [API Rules & Security](references/api-rules-security.md) - Access control, filter expressions, security patterns
- [Authentication](references/authentication.md) - Password auth, OAuth2, MFA, token management
- [SDK Usage](references/sdk-usage.md) - Client initialization, auth stores, error handling, hooks
- [Query Performance](references/query-performance.md) - Pagination, expansion, batch operations, N+1 prevention
- [Realtime](references/realtime.md) - SSE subscriptions, event handling, reconnection
- [File Handling](references/file-handling.md) - Uploads, serving, validation
- [Production & Deployment](references/production-deployment.md) - Backup, configuration, reverse proxy, SQLite optimization
- [Server-Side Extending](references/server-side-extending.md) - Go/JSVM setup, event hooks, custom routes, modules, custom SQLite

View File

@@ -0,0 +1,489 @@
# API Rules & Security
**Impact: CRITICAL**
Access control rules, filter expressions, request context usage, and security patterns. Critical for protecting data and enforcing authorization.
---
## 1. Understand API Rule Types and Defaults
**Impact: CRITICAL (Prevents unauthorized access, data leaks, and security vulnerabilities)**
PocketBase uses five collection-level rules to control access. Understanding the difference between locked (null), open (""), and expression rules is critical for security.
**Incorrect (leaving rules open unintentionally):**
```javascript
// Collection with overly permissive rules
const collection = {
name: 'messages',
listRule: '', // Anyone can list all messages!
viewRule: '', // Anyone can view any message!
createRule: '', // Anyone can create messages!
updateRule: '', // Anyone can update any message!
deleteRule: '' // Anyone can delete any message!
};
// Complete security bypass - all data exposed
```
**Correct (explicit, restrictive rules):**
```javascript
// Collection with proper access control
const collection = {
name: 'messages',
// null = locked, only superusers can access
listRule: null, // Default: locked to superusers
// '' (empty string) = open to everyone (use sparingly)
viewRule: '@request.auth.id != ""', // Any authenticated user
// Expression = conditional access
createRule: '@request.auth.id != ""', // Must be logged in
updateRule: 'author = @request.auth.id', // Only author
deleteRule: 'author = @request.auth.id' // Only author
};
```
**Rule types explained:**
| Rule Value | Meaning | Use Case |
|------------|---------|----------|
| `null` | Locked (superusers only) | Admin-only data, system tables |
| `''` (empty string) | Open to everyone | Public content, no auth required |
| `'expression'` | Conditional access | Most common - check auth, ownership |
**Common patterns:**
```javascript
// Public read, authenticated write (enforce ownership on create)
listRule: '',
viewRule: '',
createRule: '@request.auth.id != "" && @request.body.author = @request.auth.id',
updateRule: 'author = @request.auth.id',
deleteRule: 'author = @request.auth.id'
// Private to owner only
listRule: 'owner = @request.auth.id',
viewRule: 'owner = @request.auth.id',
createRule: '@request.auth.id != ""',
updateRule: 'owner = @request.auth.id',
deleteRule: 'owner = @request.auth.id'
// Read-only public data
listRule: '',
viewRule: '',
createRule: null,
updateRule: null,
deleteRule: null
```
**Error responses by rule type:**
- List rule fail: 200 with empty items
- View/Update/Delete fail: 404 (hides existence)
- Create fail: 400
- Locked rule violation: 403
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/)
## 2. Use @collection for Cross-Collection Lookups
**Impact: HIGH (Enables complex authorization without denormalization)**
The `@collection` reference allows rules to query other collections, enabling complex authorization patterns like role-based access, team membership, and resource permissions.
**Incorrect (denormalizing data for access control):**
```javascript
// Duplicating team membership in every resource
const documentsSchema = [
{ name: 'title', type: 'text' },
{ name: 'team', type: 'relation' },
// Duplicated member list for access control - gets out of sync!
{ name: 'allowedUsers', type: 'relation', options: { maxSelect: 999 } }
];
// Rule checks duplicated data
listRule: 'allowedUsers ?= @request.auth.id'
// Problem: must update allowedUsers whenever team membership changes
```
**Correct (using @collection lookup):**
```javascript
// Clean schema - no duplication
const documentsSchema = [
{ name: 'title', type: 'text' },
{ name: 'team', type: 'relation', options: { collectionId: 'teams' } }
];
// Check team membership via @collection lookup
listRule: '@collection.team_members.user ?= @request.auth.id && @collection.team_members.team ?= team'
// Alternative: check if user is in team's members array
listRule: 'team.members ?= @request.auth.id'
// Role-based access via separate roles collection
listRule: '@collection.user_roles.user = @request.auth.id && @collection.user_roles.role = "admin"'
```
**Common patterns:**
```javascript
// Team-based access
// teams: { name, members (relation to users) }
// documents: { title, team (relation to teams) }
viewRule: 'team.members ?= @request.auth.id'
// Organization hierarchy
// orgs: { name }
// org_members: { org, user, role }
// projects: { name, org }
listRule: '@collection.org_members.org = org && @collection.org_members.user = @request.auth.id'
// Permission-based access
// permissions: { resource, user, level }
updateRule: '@collection.permissions.resource = id && @collection.permissions.user = @request.auth.id && @collection.permissions.level = "write"'
// Using aliases for complex queries
listRule: '@collection.memberships:m.user = @request.auth.id && @collection.memberships:m.team = team'
```
**Performance considerations:**
- Cross-collection lookups add query complexity
- Ensure referenced fields are indexed
- Consider caching for frequently accessed permissions
- Test performance with realistic data volumes
Reference: [PocketBase Collection Reference](https://pocketbase.io/docs/api-rules-and-filters/#collection-fields)
## 3. Master Filter Expression Syntax
**Impact: CRITICAL (Enables complex access control and efficient querying)**
PocketBase filter expressions use a specific syntax for both API rules and client-side queries. Understanding operators and composition is essential.
**Incorrect (invalid filter syntax):**
```javascript
// Wrong operator syntax
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status == "published"' // Wrong: == instead of =
});
// Missing quotes around strings
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = published' // Wrong: unquoted string
});
// Wrong boolean logic
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = "published" AND featured = true' // Wrong: AND instead of &&
});
```
**Correct (proper filter syntax):**
```javascript
// Equality and comparison operators
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = "published"' // Equals
});
filter: 'views != 0' // Not equals
filter: 'views > 100' // Greater than
filter: 'views >= 100' // Greater or equal
filter: 'price < 50.00' // Less than
filter: 'created <= "2024-01-01 00:00:00"' // Less or equal
// String operators
filter: 'title ~ "hello"' // Contains (case-insensitive)
filter: 'title !~ "spam"' // Does not contain
// Logical operators
filter: 'status = "published" && featured = true' // AND
filter: 'category = "news" || category = "blog"' // OR
filter: '(status = "draft" || status = "review") && author = "abc"' // Grouping
// Array/multi-value operators (for select, relation fields)
filter: 'tags ?= "featured"' // Any tag equals "featured"
filter: 'tags ?~ "tech"' // Any tag contains "tech"
// Null checks
filter: 'deletedAt = null' // Is null
filter: 'avatar != null' // Is not null
// Date comparisons
filter: 'created > "2024-01-01 00:00:00"'
filter: 'created >= @now' // Current timestamp
filter: 'expires < @today' // Start of today (UTC)
```
**Available operators:**
| Operator | Description |
|----------|-------------|
| `=` | Equal |
| `!=` | Not equal |
| `>` `>=` `<` `<=` | Comparison |
| `~` | Contains (LIKE %value%) |
| `!~` | Does not contain |
| `?=` `?!=` `?>` `?~` | Any element matches |
| `&&` | AND |
| `\|\|` | OR |
| `()` | Grouping |
**Date macros:**
- `@now` - Current UTC datetime
- `@today` - Start of today UTC
- `@month` - Start of current month UTC
- `@year` - Start of current year UTC
**Filter functions:**
- `strftime(fmt, datetime)` - Format/extract datetime parts (v0.36+). E.g. `strftime('%Y-%m', created) = "2026-03"`. See `rules-strftime.md` for the full format specifier list.
- `length(field)` - Element count of a multi-value field (file, relation, select). E.g. `length(tags) > 0`.
- `each(field, expr)` - Iterate a multi-value field: `each(tags, ? ~ "urgent")`.
- `issetIf(field, val)` - Conditional presence check for complex rules.
Reference: [PocketBase Filters](https://pocketbase.io/docs/api-rules-and-filters/#filters-syntax)
## 4. Default to Locked Rules, Open Explicitly
**Impact: CRITICAL (Defense in depth, prevents accidental data exposure)**
New collections should start with locked (null) rules and explicitly open only what's needed. This prevents accidental data exposure and follows the principle of least privilege.
**Incorrect (starting with open rules):**
```javascript
// Dangerous: copying rules from examples without thinking
const collection = {
name: 'user_settings',
listRule: '', // Open - leaks all user settings!
viewRule: '', // Open - anyone can view any setting
createRule: '', // Open - no auth required
updateRule: '', // Open - anyone can modify!
deleteRule: '' // Open - anyone can delete!
};
// Also dangerous: using auth check when ownership needed
const collection = {
name: 'private_notes',
listRule: '@request.auth.id != ""', // Any logged-in user sees ALL notes
viewRule: '@request.auth.id != ""',
updateRule: '@request.auth.id != ""', // Any user can edit ANY note!
};
```
**Correct (locked by default, explicitly opened):**
```javascript
// Step 1: Start locked
const collection = {
name: 'user_settings',
listRule: null, // Locked - superusers only
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null
};
// Step 2: Open only what's needed with proper checks
const collection = {
name: 'user_settings',
// Users can only see their own settings
listRule: 'user = @request.auth.id',
viewRule: 'user = @request.auth.id',
// Users can only create settings for themselves
createRule: '@request.auth.id != "" && @request.body.user = @request.auth.id',
// Users can only update their own settings
updateRule: 'user = @request.auth.id',
// Prevent deletion or restrict to owner
deleteRule: 'user = @request.auth.id'
};
// For truly public data, document why it's open
const collection = {
name: 'public_announcements',
// Intentionally public - these are site-wide announcements
listRule: '',
viewRule: '',
// Only admins can manage (using custom "role" field on auth collection)
// IMPORTANT: Prevent role self-assignment in the users collection updateRule:
// updateRule: 'id = @request.auth.id && @request.body.role:isset = false'
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"'
};
```
**Rule development workflow:**
1. **Start locked** - All rules `null`
2. **Identify access needs** - Who needs what access?
3. **Write minimal rules** - Open only required operations
4. **Test thoroughly** - Verify both allowed and denied cases
5. **Document decisions** - Comment why rules are set as they are
**Security checklist:**
- [ ] No empty string rules without justification
- [ ] Ownership checks on personal data
- [ ] Auth checks on write operations
- [ ] Admin-only rules for sensitive operations
- [ ] Tested with different user contexts
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/)
## 5. Use @request Context in API Rules
**Impact: CRITICAL (Enables dynamic, user-aware access control)**
The `@request` object provides access to the current request context including authenticated user, request body, query parameters, and headers. Use it to build dynamic access rules.
**Incorrect (hardcoded or missing auth checks):**
```javascript
// No authentication check
const collection = {
listRule: '', // Anyone can see everything
createRule: '' // Anyone can create
};
// Hardcoded user ID (never do this)
const collection = {
listRule: 'owner = "specific_user_id"' // Only one user can access
};
```
**Correct (using @request context):**
```javascript
// Check if user is authenticated
createRule: '@request.auth.id != ""'
// Check ownership via auth record
listRule: 'owner = @request.auth.id'
viewRule: 'owner = @request.auth.id'
updateRule: 'owner = @request.auth.id'
deleteRule: 'owner = @request.auth.id'
// Access auth record fields
// IMPORTANT: If using custom role fields, ensure update rules prevent
// users from modifying their own role: @request.body.role:isset = false
listRule: '@request.auth.role = "admin"'
listRule: '@request.auth.verified = true'
// Validate request body on create/update
createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id'
// Prevent changing certain fields
updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false'
// WARNING: Query parameters are user-controlled and should NOT be used
// for authorization decisions. Use them only for optional filtering behavior
// where the fallback is equally safe.
// listRule: '@request.query.publicOnly = "true" || owner = @request.auth.id'
// The above is UNSAFE - users can bypass ownership by adding ?publicOnly=true
// Instead, use separate endpoints or server-side logic for public vs. private views.
listRule: 'owner = @request.auth.id || public = true' // Use a record field, not query param
// Access nested auth relations
listRule: 'team.members ?= @request.auth.id'
```
**Available @request fields:**
| Field | Description |
|-------|-------------|
| `@request.auth.id` | Authenticated user's ID (empty string if not authenticated) |
| `@request.auth.*` | Any field from auth record (role, verified, email, etc.) |
| `@request.body.*` | Request body fields (create/update only) |
| `@request.query.*` | URL query parameters |
| `@request.headers.*` | Request headers |
| `@request.method` | HTTP method (GET, POST, etc.) |
| `@request.context` | Request context: `default`, `oauth2`, `otp`, `password`, `realtime`, `protectedFile` |
**Body field modifiers:**
```javascript
// Check if field is being set
updateRule: '@request.body.status:isset = false' // Can't change status
// Check if field changed from current value
updateRule: '@request.body.owner:changed = false' // Can't change owner
// Get length of array/string
createRule: '@request.body.tags:length <= 5' // Max 5 tags
```
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/#available-fields)
## 6. Use strftime() for Date Arithmetic in Filter Expressions
**Impact: MEDIUM (strftime() (added in v0.36) replaces brittle string prefix comparisons on datetime fields)**
PocketBase v0.36 added the `strftime()` function to the filter expression grammar. It maps directly to SQLite's [strftime](https://sqlite.org/lang_datefunc.html) and is the correct way to bucket, compare, or extract parts of a datetime field. Before v0.36 people worked around this with `~` (substring) matches against the ISO string; those workarounds are fragile (they break at midnight UTC, ignore timezones, and can't handle ranges).
**Incorrect (substring match on the ISO datetime string):**
```javascript
// ❌ "matches anything whose ISO string contains 2026-04-08" - breaks as soon
// as your DB stores sub-second precision or you cross a month boundary
const todayPrefix = new Date().toISOString().slice(0, 10);
const results = await pb.collection("orders").getList(1, 50, {
filter: `created ~ "${todayPrefix}"`, // ❌
});
```
**Correct (strftime with named format specifiers):**
```javascript
// "all orders created today (UTC)"
const results = await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)`,
});
// "all orders from March 2026"
await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m', created) = "2026-03"`,
});
// "orders created this hour"
await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m-%d %H', created) = strftime('%Y-%m-%d %H', @now)`,
});
```
```javascript
// Same function is available inside API rules:
// collection "orders" - List rule:
// @request.auth.id != "" &&
// user = @request.auth.id &&
// strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)
```
**Common format specifiers:**
| Specifier | Meaning |
|---|---|
| `%Y` | 4-digit year |
| `%m` | month (01-12) |
| `%d` | day of month (01-31) |
| `%H` | hour (00-23) |
| `%M` | minute (00-59) |
| `%S` | second (00-59) |
| `%W` | ISO week (00-53) |
| `%j` | day of year (001-366) |
| `%w` | day of week (0=Sunday) |
**Other filter functions worth knowing:**
| Function | Use |
|---|---|
| `strftime(fmt, datetime)` | Format/extract datetime parts (v0.36+) |
| `length(field)` | Count elements in a multi-value field (file, relation, select) |
| `each(field, expr)` | Iterate over multi-value fields: `each(tags, ? ~ "urgent")` |
| `issetIf(field, val)` | Conditional presence check used in complex rules |
Reference: [Filter Syntax - Functions](https://pocketbase.io/docs/api-rules-and-filters/#filters) · [v0.36.0 release](https://github.com/pocketbase/pocketbase/releases/tag/v0.36.0)

View File

@@ -0,0 +1,695 @@
# Authentication
**Impact: CRITICAL**
Password authentication, OAuth2 integration, token management, MFA setup, and auth collection configuration.
---
## 1. Use Impersonation for Admin Operations
**Impact: MEDIUM (Safe admin access to user data without password sharing)**
Impersonation allows superusers to generate tokens for other users, enabling admin support tasks and API key functionality without sharing passwords.
**Incorrect (sharing credentials or bypassing auth):**
```javascript
// Bad: sharing user passwords for support
async function helpUser(userId, userPassword) {
await pb.collection('users').authWithPassword(userEmail, userPassword);
// Support team knows user's password!
}
// Bad: directly modifying records without proper context
async function fixUserData(userId) {
// Bypasses user's perspective and rules
await pb.collection('posts').update(postId, { fixed: true });
}
```
**Correct (using impersonation):**
```javascript
import PocketBase from 'pocketbase';
// Admin client with superuser auth (use environment variables, never hardcode)
const adminPb = new PocketBase(process.env.PB_URL);
await adminPb.collection('_superusers').authWithPassword(
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD
);
async function impersonateUser(userId) {
// Generate impersonation token (non-renewable)
const impersonatedClient = await adminPb
.collection('users')
.impersonate(userId, 3600); // 1 hour duration
// impersonatedClient has user's auth context
console.log('Acting as:', impersonatedClient.authStore.record.email);
// Operations use user's permissions
const userPosts = await impersonatedClient.collection('posts').getList();
return impersonatedClient;
}
// Use case: Admin viewing user's data
async function adminViewUserPosts(userId) {
const userClient = await impersonateUser(userId);
// See exactly what the user sees (respects API rules)
const posts = await userClient.collection('posts').getList();
return posts;
}
// Use case: API keys for server-to-server communication
async function createApiKey(serviceUserId) {
// Create a service impersonation token (use short durations, rotate regularly)
const serviceClient = await adminPb
.collection('service_accounts')
.impersonate(serviceUserId, 86400); // 24 hours max, rotate via scheduled task
// Return token for service to use
return serviceClient.authStore.token;
}
// Using API key token in another service
async function useApiKey(apiToken) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Manually set the token
pb.authStore.save(apiToken, null);
// Now requests use the service account's permissions
const data = await pb.collection('data').getList();
return data;
}
```
**Important considerations:**
```javascript
// Impersonation tokens are non-renewable
const client = await adminPb.collection('users').impersonate(userId, 3600);
// This will fail - can't refresh impersonation tokens
try {
await client.collection('users').authRefresh();
} catch (error) {
// Expected: impersonation tokens can't be refreshed
}
// For continuous access, generate new token when needed
async function getImpersonatedClient(userId) {
// Check if existing token is still valid
if (cachedClient?.authStore.isValid) {
return cachedClient;
}
// Generate fresh token
return await adminPb.collection('users').impersonate(userId, 3600);
}
```
**Security best practices:**
- Use short durations for support tasks
- Log all impersonation events
- Restrict impersonation to specific admin roles
- Never expose impersonation capability in client code
- Use dedicated service accounts for API keys
Reference: [PocketBase Impersonation](https://pocketbase.io/docs/authentication/#impersonate-authentication)
## 2. Implement Multi-Factor Authentication
**Impact: HIGH (Additional security layer for sensitive applications)**
MFA requires users to authenticate with two different methods. PocketBase supports OTP (One-Time Password) via email as the second factor.
**Incorrect (single-factor only for sensitive apps):**
```javascript
// Insufficient for sensitive applications
async function login(email, password) {
const authData = await pb.collection('users').authWithPassword(email, password);
// User immediately has full access - no second factor
return authData;
}
```
**Correct (MFA flow with OTP):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function loginWithMFA(email, password) {
try {
// First factor: password
const authData = await pb.collection('users').authWithPassword(email, password);
// If MFA not required, auth succeeds immediately
return { success: true, authData };
} catch (error) {
// MFA required - returns 401 with mfaId
if (error.status === 401 && error.response?.mfaId) {
return {
success: false,
mfaRequired: true,
mfaId: error.response.mfaId
};
}
throw error;
}
}
async function requestOTP(email) {
// Request OTP to be sent via email
const result = await pb.collection('users').requestOTP(email);
// Returns otpId - needed to verify the OTP
// Note: Returns otpId even if email doesn't exist (prevents enumeration)
return result.otpId;
}
async function completeMFAWithOTP(mfaId, otpId, otpCode) {
try {
// Second factor: OTP verification
const authData = await pb.collection('users').authWithOTP(
otpId,
otpCode,
{ mfaId } // Include mfaId from first factor
);
return { success: true, authData };
} catch (error) {
if (error.status === 400) {
throw new Error('Invalid or expired code');
}
throw error;
}
}
// Complete flow example
async function fullMFAFlow(email, password, otpCode = null) {
// Step 1: Password authentication
const step1 = await loginWithMFA(email, password);
if (step1.success) {
return step1.authData; // MFA not required
}
if (step1.mfaRequired) {
// Step 2: Request OTP
const otpId = await requestOTP(email);
// Step 3: UI prompts user for OTP code...
// (In real app, wait for user input)
if (otpCode) {
// Step 4: Complete MFA
const step2 = await completeMFAWithOTP(step1.mfaId, otpId, otpCode);
return step2.authData;
}
return { pendingMFA: true, mfaId: step1.mfaId, otpId };
}
}
```
**Configure MFA (Admin UI or API):**
```javascript
// Enable MFA on auth collection (superuser only)
await pb.collections.update('users', {
mfa: {
enabled: true,
duration: 1800, // MFA session duration (30 min)
rule: '' // When to require MFA (empty = always for all users)
// rule: '@request.auth.role = "admin"' // Only for admins
},
otp: {
enabled: true,
duration: 300, // OTP validity (5 min)
length: 6, // OTP code length
emailTemplate: {
subject: 'Your verification code',
body: 'Your code is: {OTP}'
}
}
});
```
**MFA best practices:**
- Always enable for admin accounts
- Consider making MFA optional for regular users
- Use short OTP durations (5-10 minutes)
- Implement rate limiting on OTP requests
- Log MFA events for security auditing
Reference: [PocketBase MFA](https://pocketbase.io/docs/authentication/#mfa)
## 3. Integrate OAuth2 Providers Correctly
**Impact: CRITICAL (Secure third-party authentication with proper flow handling)**
OAuth2 integration should use the all-in-one method for simplicity and security. Manual code exchange should only be used when necessary (e.g., mobile apps with deep links).
**Incorrect (manual implementation without SDK):**
```javascript
// Don't manually handle OAuth flow
async function loginWithGoogle() {
// Redirect user to Google manually
window.location.href = 'https://accounts.google.com/oauth/authorize?...';
}
// Manual callback handling
async function handleCallback(code) {
// Exchange code manually - error prone!
const response = await fetch('/api/auth/callback', {
method: 'POST',
body: JSON.stringify({ code })
});
}
```
**Correct (using SDK's all-in-one method):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// All-in-one OAuth2 (recommended for web apps)
async function loginWithOAuth2(providerName) {
try {
const authData = await pb.collection('users').authWithOAuth2({
provider: providerName, // 'google', 'github', 'microsoft', etc.
// Optional: create new user data if not exists
createData: {
emailVisibility: true,
name: '' // Will be populated from OAuth provider
}
});
console.log('Logged in via', providerName);
console.log('User:', authData.record.email);
console.log('Is new user:', authData.meta?.isNew);
return authData;
} catch (error) {
if (error.isAbort) {
console.log('OAuth popup was closed');
return null;
}
throw error;
}
}
// Usage
document.getElementById('google-btn').onclick = () => loginWithOAuth2('google');
document.getElementById('github-btn').onclick = () => loginWithOAuth2('github');
```
**Manual code exchange (for React Native / deep links):**
```javascript
// Only use when all-in-one isn't possible
async function loginWithOAuth2Manual() {
// Get auth methods - PocketBase provides state and codeVerifier
const authMethods = await pb.collection('users').listAuthMethods();
const provider = authMethods.oauth2.providers.find(p => p.name === 'google');
// Store the provider's state and codeVerifier for callback verification
// PocketBase generates these for you - don't create your own
sessionStorage.setItem('oauth_state', provider.state);
sessionStorage.setItem('oauth_code_verifier', provider.codeVerifier);
// Build the OAuth URL using provider.authURL + redirect
const redirectUrl = window.location.origin + '/oauth-callback';
const authUrl = provider.authURL + encodeURIComponent(redirectUrl);
// Redirect to OAuth provider
window.location.href = authUrl;
}
// In your callback handler (e.g., /oauth-callback page):
async function handleOAuth2Callback() {
const params = new URLSearchParams(window.location.search);
// CSRF protection: verify state matches
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - potential CSRF attack');
}
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
const redirectUrl = window.location.origin + '/oauth-callback';
// Exchange code for auth token
const authData = await pb.collection('users').authWithOAuth2Code(
'google',
code,
codeVerifier,
redirectUrl,
{ emailVisibility: true }
);
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_code_verifier');
return authData;
}
```
**Configure OAuth2 provider (Admin UI or API):**
```javascript
// Via API (superuser only) - usually done in Admin UI
// IMPORTANT: Never hardcode client secrets. Use environment variables.
await pb.collections.update('users', {
oauth2: {
enabled: true,
providers: [{
name: 'google',
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET
}],
mappedFields: {
avatarURL: 'avatar' // Map OAuth field to collection field
}
}
});
```
Reference: [PocketBase OAuth2](https://pocketbase.io/docs/authentication/#oauth2-authentication)
## 4. Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
**Impact: HIGH (OTP endpoints are unauthenticated; unthrottled requestOTP enables email bombing and enumeration)**
Auth collections can enable **OTP login** from the admin UI (Collection → Options → "Enable OTP"). The client flow is two steps: `requestOTP(email)` returns an `otpId`, then `authWithOTP(otpId, code)` exchanges the id + code for an auth token. Two things trip people up: (1) the OTP response is **the same whether the email exists or not** - do not break that by leaking a distinct error; (2) `requestOTP` sends an email, so **it must be rate-limited** or an attacker can use it to spam any address.
**Incorrect (leaks existence, custom requestOTP with no rate limit):**
```javascript
// ❌ Client-side existence check - ignore the 404 and expose it to the user
try {
await pb.collection("users").getFirstListItem(`email="${email}"`);
} catch (e) {
alert("No account with that email"); // ❌ account enumeration
return;
}
// ❌ Ad-hoc route with no rate limit - attacker hammers this to spam mailboxes
routerAdd("POST", "/api/myapp/otp", (e) => {
const body = e.requestInfo().body;
const user = $app.findAuthRecordByEmail("users", body.email);
// send custom email...
return e.json(200, { ok: true });
});
```
**Correct (use the built-in flow, step 1 always returns an otpId):**
```javascript
// Step 1: request the code. Always returns { otpId } - even if the email
// does not exist, PocketBase returns a synthetic id so enumeration is
// impossible. Treat every response as success from the UI perspective.
const { otpId } = await pb.collection("users").requestOTP("user@example.com");
// Step 2: exchange otpId + the 8-digit code the user typed
const authData = await pb.collection("users").authWithOTP(
otpId,
"12345678",
);
// pb.authStore is now populated
```
```go
// Go side - rate-limit and log if you wrap your own endpoint
app.OnRecordRequestOTPRequest("users").BindFunc(func(e *core.RecordRequestOTPRequestEvent) error {
// e.Collection, e.Record (may be nil - synthetic id path),
// e.Email (always present), e.Password (unused for OTP)
e.App.Logger().Info("otp requested",
"email", e.Email,
"ip", e.RequestInfo.Headers["x_forwarded_for"])
return e.Next() // REQUIRED
})
```
**Rules:**
- `requestOTP` **always returns 200 with an otpId**, even for non-existent emails - preserve that by never adding a pre-check or a different error path.
- Enable the built-in rate limiter (see `deploy-rate-limiting.md`) and raise the cost for the `*:requestOTP` label. Without this, an attacker can email-bomb arbitrary users.
- The OTP code is 8 digits by default, with a 3-minute TTL. Do not extend the TTL - short windows are the whole point.
- `authWithOTP` consumes the code; a successful call invalidates the `otpId`. Always show a generic "invalid or expired code" on failure.
- If you want OTP **without a password**, set the collection's `Password` option to off and `OTP` on. If both are enabled, users can use either.
- OTP emails are sent via the configured SMTP server. In dev, point SMTP at Mailpit or a console logger before testing - do **not** ship with the default "no-reply@example.com" sender.
Reference: [Auth with OTP](https://pocketbase.io/docs/authentication/#auth-with-otp) · [JS SDK - authWithOTP](https://github.com/pocketbase/js-sdk#authwithotp)
## 5. Implement Secure Password Authentication
**Impact: CRITICAL (Secure user login with proper error handling and token management)**
Password authentication should include proper error handling, avoid exposing whether emails exist, and correctly manage the auth store.
**Incorrect (exposing information and poor error handling):**
```javascript
// Dangerous: exposes whether email exists
async function login(email, password) {
const user = await pb.collection('users').getFirstListItem(`email = "${email}"`);
if (!user) {
throw new Error('Email not found'); // Reveals email doesn't exist
}
// Manual password check - never do this!
if (user.password !== password) {
throw new Error('Wrong password'); // Reveals password is wrong
}
return user;
}
```
**Correct (secure authentication):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function login(email, password) {
try {
// authWithPassword handles hashing and returns token
const authData = await pb.collection('users').authWithPassword(email, password);
// Token is automatically stored in pb.authStore
console.log('Logged in as:', authData.record.email);
console.log('Token valid:', pb.authStore.isValid);
return authData;
} catch (error) {
// Generic error message - don't reveal if email exists
if (error.status === 400) {
throw new Error('Invalid email or password');
}
throw error;
}
}
// Check if user is authenticated
function isAuthenticated() {
return pb.authStore.isValid;
}
// Get current user
function getCurrentUser() {
return pb.authStore.record;
}
// Logout
function logout() {
pb.authStore.clear();
}
// Listen for auth changes
pb.authStore.onChange((token, record) => {
console.log('Auth state changed:', record?.email || 'logged out');
}, true); // true = fire immediately with current state
```
**Auth collection configuration for password auth:**
```javascript
// When creating auth collection via API (superuser only)
await pb.collections.create({
name: 'users',
type: 'auth',
fields: [
{ name: 'name', type: 'text' },
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
],
passwordAuth: {
enabled: true,
identityFields: ['email', 'username'] // Fields that can be used to login
},
// Require minimum password length
// (configured in Admin UI under collection options)
});
```
**Security considerations:**
- Never store passwords in plain text
- Use generic error messages
- Implement rate limiting on your server
- Consider adding MFA for sensitive applications
Reference: [PocketBase Auth](https://pocketbase.io/docs/authentication/)
## 6. Manage Auth Tokens Properly
**Impact: CRITICAL (Prevents unauthorized access, handles token expiration gracefully)**
Auth tokens should be refreshed before expiration, validated on critical operations, and properly cleared on logout. The SDK's authStore handles most of this automatically.
**Incorrect (ignoring token expiration):**
```javascript
// Bad: never checking token validity
async function fetchUserData() {
// Token might be expired!
const records = await pb.collection('posts').getList();
return records;
}
// Bad: manually managing tokens
let authToken = localStorage.getItem('token');
fetch('/api/posts', {
headers: { 'Authorization': authToken } // Token might be invalid
});
```
**Correct (proper token management):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Check token validity before operations
async function fetchSecureData() {
// authStore.isValid is a client-side check only (JWT expiry parsing).
// Always verify server-side with authRefresh() for critical operations.
if (!pb.authStore.isValid) {
throw new Error('Please log in');
}
return pb.collection('posts').getList();
}
// Refresh token periodically or before expiration
async function refreshAuthIfNeeded() {
if (!pb.authStore.isValid) {
return false;
}
try {
// Verifies current token and returns fresh one
await pb.collection('users').authRefresh();
console.log('Token refreshed');
return true;
} catch (error) {
// Token invalid - user needs to re-authenticate
pb.authStore.clear();
return false;
}
}
// Auto-refresh on app initialization
async function initializeAuth() {
if (pb.authStore.token) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
}
// Listen for auth changes and handle expiration
pb.authStore.onChange((token, record) => {
if (!token) {
// User logged out or token cleared
redirectToLogin();
}
});
// Setup periodic refresh (e.g., every 10 minutes)
setInterval(async () => {
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
}, 10 * 60 * 1000);
```
**SSR / Server-side token handling:**
```javascript
// Server-side: create fresh client per request
export async function handleRequest(request) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Load auth from cookie
pb.authStore.loadFromCookie(request.headers.get('cookie') || '');
// Validate and refresh
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
// ... handle request ...
// Send updated cookie with secure options
const response = new Response();
response.headers.set('set-cookie', pb.authStore.exportToCookie({
httpOnly: true, // Prevent XSS access to auth token
secure: true, // HTTPS only
sameSite: 'Lax', // CSRF protection
}));
return response;
}
```
**Token configuration (Admin UI or migration):**
```javascript
// Configure token durations (superuser only)
await pb.collections.update('users', {
authToken: {
duration: 1209600 // 14 days in seconds
},
verificationToken: {
duration: 604800 // 7 days
}
});
```
Reference: [PocketBase Auth Store](https://pocketbase.io/docs/authentication/)

View File

@@ -0,0 +1,480 @@
# Collection Design
**Impact: CRITICAL**
Schema design, field types, relations, indexes, and collection type selection. Foundation for application architecture and long-term maintainability.
---
## 1. Use Auth Collections for User Accounts
**Impact: CRITICAL (Built-in authentication, password hashing, OAuth2 support)**
Auth collections provide built-in authentication features including secure password hashing, email verification, OAuth2 support, and token management. Using base collections for users requires reimplementing these security-critical features.
**Incorrect (using base collection for users):**
```javascript
// Base collection loses all auth features
const usersCollection = {
name: 'users',
type: 'base', // Wrong! No auth capabilities
schema: [
{ name: 'email', type: 'email' },
{ name: 'password', type: 'text' }, // Stored in plain text!
{ name: 'name', type: 'text' }
]
};
// Manual login implementation - insecure
const user = await pb.collection('users').getFirstListItem(
`email = "${email}" && password = "${password}"` // SQL injection risk!
);
```
**Correct (using auth collection):**
```javascript
// Auth collection with built-in security
const usersCollection = {
name: 'users',
type: 'auth', // Enables authentication features
schema: [
{ name: 'name', type: 'text' },
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
],
options: {
allowEmailAuth: true,
allowOAuth2Auth: true,
requireEmail: true,
minPasswordLength: 8
}
};
// Secure authentication with password hashing
const authData = await pb.collection('users').authWithPassword(
'user@example.com',
'securePassword123'
);
// Token automatically stored in authStore
// NOTE: Never log tokens in production - shown here for illustration only
console.log('Authenticated as:', pb.authStore.record.id);
```
**When to use each type:**
- **Auth collection**: User accounts, admin accounts, any entity that needs to log in
- **Base collection**: Regular data like posts, products, orders, comments
- **View collection**: Read-only aggregations or complex queries
Reference: [PocketBase Auth Collections](https://pocketbase.io/docs/collections/#auth-collection)
## 2. Choose Appropriate Field Types for Your Data
**Impact: CRITICAL (Prevents data corruption, improves query performance, reduces storage)**
Selecting the wrong field type leads to data validation issues, wasted storage, and poor query performance. PocketBase provides specialized field types that enforce constraints at the database level.
**Incorrect (using text for everything):**
```javascript
// Using plain text fields for structured data
const collection = {
name: 'products',
schema: [
{ name: 'price', type: 'text' }, // Should be number
{ name: 'email', type: 'text' }, // Should be email
{ name: 'website', type: 'text' }, // Should be url
{ name: 'active', type: 'text' }, // Should be bool
{ name: 'tags', type: 'text' }, // Should be select or json
{ name: 'created', type: 'text' } // Should be autodate
]
};
// No validation, inconsistent data, manual parsing required
```
**Correct (using appropriate field types):**
```javascript
// Using specialized field types with proper validation
const collection = {
name: 'products',
type: 'base',
schema: [
{ name: 'price', type: 'number', options: { min: 0 } },
{ name: 'email', type: 'email' },
{ name: 'website', type: 'url' },
{ name: 'active', type: 'bool' },
{ name: 'tags', type: 'select', options: {
maxSelect: 5,
values: ['electronics', 'clothing', 'food', 'other']
}},
{ name: 'metadata', type: 'json' }
// created/updated are automatic system fields
]
};
// Built-in validation, proper indexing, type-safe queries
```
**Available field types:**
- `text` - Plain text with optional min/max length, regex pattern
- `number` - Integer or decimal with optional min/max
- `bool` - True/false values
- `email` - Email with format validation
- `url` - URL with format validation
- `date` - Date/datetime values
- `autodate` - Auto-set on create/update
- `select` - Single or multi-select from predefined values
- `json` - Arbitrary JSON data
- `file` - File attachments
- `relation` - References to other collections
- `editor` - Rich text HTML content
Reference: [PocketBase Collections](https://pocketbase.io/docs/collections/)
## 3. Use GeoPoint Fields for Location Data
**Impact: MEDIUM (Built-in geographic queries, distance calculations)**
PocketBase provides a dedicated GeoPoint field type for storing geographic coordinates with built-in distance query support via `geoDistance()`.
**Incorrect (storing coordinates as separate fields):**
```javascript
// Separate lat/lon fields - no built-in distance queries
const placesSchema = [
{ name: 'name', type: 'text' },
{ name: 'latitude', type: 'number' },
{ name: 'longitude', type: 'number' }
];
// Manual distance calculation - complex and slow
async function findNearby(lat, lon, maxKm) {
const places = await pb.collection('places').getFullList();
// Calculate distance for every record client-side
return places.filter(place => {
const dist = haversine(lat, lon, place.latitude, place.longitude);
return dist <= maxKm;
});
}
```
**Correct (using GeoPoint field):**
```javascript
// GeoPoint field stores coordinates as { lon, lat } object
const placesSchema = [
{ name: 'name', type: 'text' },
{ name: 'location', type: 'geopoint' }
];
// Creating a record with GeoPoint
await pb.collection('places').create({
name: 'Coffee Shop',
location: { lon: -73.9857, lat: 40.7484 } // Note: lon first!
});
// Or using "lon,lat" string format
await pb.collection('places').create({
name: 'Restaurant',
location: '-73.9857,40.7484' // String format also works
});
// Query nearby locations using geoDistance()
async function findNearby(lon, lat, maxKm) {
// geoDistance returns distance in kilometers
const places = await pb.collection('places').getList(1, 50, {
filter: pb.filter(
'geoDistance(location, {:point}) <= {:maxKm}',
{
point: { lon, lat },
maxKm: maxKm
}
),
sort: pb.filter('geoDistance(location, {:point})', { point: { lon, lat } })
});
return places;
}
// Find places within 5km of Times Square
const nearbyPlaces = await findNearby(-73.9857, 40.7580, 5);
// Use in API rules for location-based access
// listRule: geoDistance(location, @request.query.point) <= 10
```
**geoDistance() function:**
```javascript
// Syntax: geoDistance(geopointField, referencePoint)
// Returns: distance in kilometers
// In filter expressions
filter: 'geoDistance(location, "-73.9857,40.7484") <= 5'
// With parameter binding (recommended)
filter: pb.filter('geoDistance(location, {:center}) <= {:radius}', {
center: { lon: -73.9857, lat: 40.7484 },
radius: 5
})
// Sorting by distance
sort: 'geoDistance(location, "-73.9857,40.7484")' // Closest first
sort: '-geoDistance(location, "-73.9857,40.7484")' // Farthest first
```
**GeoPoint data format:**
```javascript
// Object format (recommended)
{ lon: -73.9857, lat: 40.7484 }
// String format
"-73.9857,40.7484" // "lon,lat" order
// Important: longitude comes FIRST (GeoJSON convention)
```
**Use cases:**
- Store-locator / find nearby
- Delivery radius validation
- Geofencing in API rules
- Location-based search results
**Limitations:**
- Spherical Earth calculation (accurate to ~0.3%)
- No polygon/area containment queries
- Single point per field (use multiple fields for routes)
Reference: [PocketBase GeoPoint](https://pocketbase.io/docs/collections/#geopoint)
## 4. Create Indexes for Frequently Filtered Fields
**Impact: CRITICAL (10-100x faster queries on large collections)**
PocketBase uses SQLite which benefits significantly from proper indexing. Queries filtering or sorting on unindexed fields perform full table scans.
**Incorrect (no indexes on filtered fields):**
```javascript
// Querying without indexes
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'author = "user123" && status = "published"',
sort: '-publishedAt'
});
// Full table scan on large collections - very slow
// API rules also query without indexes
// listRule: "author = @request.auth.id"
// Every list request scans entire table
```
**Correct (indexed fields):**
```javascript
// Create collection with indexes via Admin UI or migration
// In PocketBase Admin: Collection > Indexes > Add Index
// Common index patterns:
// 1. Single field index for equality filters
// CREATE INDEX idx_posts_author ON posts(author)
// 2. Composite index for multiple filters
// CREATE INDEX idx_posts_author_status ON posts(author, status)
// 3. Index with sort field
// CREATE INDEX idx_posts_status_published ON posts(status, publishedAt DESC)
// Queries now use indexes
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'author = "user123" && status = "published"',
sort: '-publishedAt'
});
// Index scan - fast even with millions of records
// For unique constraints (e.g., slug)
// CREATE UNIQUE INDEX idx_posts_slug ON posts(slug)
```
**Index recommendations:**
- Fields used in `filter` expressions
- Fields used in `sort` parameters
- Fields used in API rules (`listRule`, `viewRule`, etc.)
- Relation fields (automatically indexed)
- Unique fields like slugs or codes
**Index considerations for SQLite:**
- Composite indexes work left-to-right (order matters)
- Too many indexes slow down writes
- Use `EXPLAIN QUERY PLAN` in SQL to verify index usage
- Partial indexes for filtered subsets
```sql
-- Check if index is used
EXPLAIN QUERY PLAN
SELECT * FROM posts WHERE author = 'user123' AND status = 'published';
-- Should show "USING INDEX" not "SCAN"
```
Reference: [SQLite Query Planning](https://www.sqlite.org/queryplanner.html)
## 5. Configure Relations with Proper Cascade Options
**Impact: CRITICAL (Maintains referential integrity, prevents orphaned records, controls deletion behavior)**
Relation fields connect collections together. Proper cascade configuration ensures data integrity when referenced records are deleted.
**Incorrect (default cascade behavior not considered):**
```javascript
// Relation without considering deletion behavior
const ordersSchema = [
{ name: 'customer', type: 'relation', options: {
collectionId: 'customers_collection_id',
maxSelect: 1
// No cascade options specified - defaults may cause issues
}},
{ name: 'products', type: 'relation', options: {
collectionId: 'products_collection_id'
// Multiple products, no cascade handling
}}
];
// Deleting a customer may fail or orphan orders
await pb.collection('customers').delete(customerId);
// Error: record is referenced by other records
```
**Correct (explicit cascade configuration):**
```javascript
// Carefully configured relations
const ordersSchema = [
{
name: 'customer',
type: 'relation',
required: true,
options: {
collectionId: 'customers_collection_id',
maxSelect: 1,
cascadeDelete: false // Prevent accidental mass deletion
}
},
{
name: 'products',
type: 'relation',
options: {
collectionId: 'products_collection_id',
maxSelect: 99,
cascadeDelete: false
}
}
];
// For dependent data like comments - cascade delete makes sense
const commentsSchema = [
{
name: 'post',
type: 'relation',
options: {
collectionId: 'posts_collection_id',
maxSelect: 1,
cascadeDelete: true // Delete comments when post is deleted
}
}
];
// NOTE: For audit logs, avoid cascadeDelete - logs should be retained
// for compliance/forensics even after the referenced user is deleted.
// Use cascadeDelete: false and handle user deletion separately.
// Handle deletion manually when cascade is false
try {
await pb.collection('customers').delete(customerId);
} catch (e) {
if (e.status === 400) {
// Customer has orders - handle appropriately
// Option 1: Soft delete (set 'deleted' flag)
// Option 2: Reassign orders
// Option 3: Delete orders first
}
}
```
**Cascade options:**
- `cascadeDelete: true` - Delete referencing records when referenced record is deleted
- `cascadeDelete: false` - Block deletion if references exist (default for required relations)
**Best practices:**
- Use `cascadeDelete: true` for dependent data (comments on posts, logs for users)
- Use `cascadeDelete: false` for important data (orders, transactions)
- Consider soft deletes for audit trails
- Document your cascade strategy
Reference: [PocketBase Relations](https://pocketbase.io/docs/collections/#relation)
## 6. Use View Collections for Complex Read-Only Queries
**Impact: HIGH (Simplifies complex queries, improves maintainability, enables aggregations)**
View collections execute custom SQL queries and expose results through the standard API. They're ideal for aggregations, joins, and computed fields without duplicating logic across your application.
**Incorrect (computing aggregations client-side):**
```javascript
// Fetching all records to compute stats client-side
const orders = await pb.collection('orders').getFullList();
const products = await pb.collection('products').getFullList();
// Expensive client-side computation
const stats = orders.reduce((acc, order) => {
const product = products.find(p => p.id === order.product);
acc.totalRevenue += order.quantity * product.price;
acc.orderCount++;
return acc;
}, { totalRevenue: 0, orderCount: 0 });
// Fetches all data, slow, memory-intensive
```
**Correct (using view collection):**
```javascript
// Create a view collection in PocketBase Admin UI or via API
// View SQL:
// SELECT
// p.id,
// p.name,
// COUNT(o.id) as order_count,
// SUM(o.quantity) as total_sold,
// SUM(o.quantity * p.price) as revenue
// FROM products p
// LEFT JOIN orders o ON o.product = p.id
// GROUP BY p.id
// Simple, efficient query
const productStats = await pb.collection('product_stats').getList(1, 20, {
sort: '-revenue'
});
// Each record has computed fields
productStats.items.forEach(stat => {
console.log(`${stat.name}: ${stat.order_count} orders, $${stat.revenue}`);
});
```
**View collection use cases:**
- Aggregations (COUNT, SUM, AVG)
- Joining data from multiple collections
- Computed/derived fields
- Denormalized read models
- Dashboard statistics
**Limitations:**
- Read-only (no create/update/delete)
- Must return `id` column
- No realtime subscriptions
- API rules still apply for access control
Reference: [PocketBase View Collections](https://pocketbase.io/docs/collections/#view-collection)

View File

@@ -0,0 +1,539 @@
# File Handling
**Impact: MEDIUM**
File uploads, URL generation, thumbnail creation, and validation patterns.
---
## 1. Generate File URLs Correctly
**Impact: MEDIUM (Proper URLs with thumbnails and access control)**
Use the SDK's `getURL` method to generate proper file URLs with thumbnail support and access tokens for protected files.
**Incorrect (manually constructing URLs):**
```javascript
// Hardcoded URL construction - brittle
const imageUrl = `http://localhost:8090/api/files/${record.collectionId}/${record.id}/${record.image}`;
// Missing token for protected files
const privateUrl = pb.files.getURL(record, record.document);
// Returns URL but file access denied if protected!
// Wrong thumbnail syntax
const thumb = `${imageUrl}?thumb=100x100`; // Wrong format
```
**Correct (using SDK methods):**
```javascript
// Basic file URL
const imageUrl = pb.files.getURL(record, record.image);
// Returns: http://host/api/files/COLLECTION/RECORD_ID/filename.jpg
// With thumbnail (for images only)
const thumbUrl = pb.files.getURL(record, record.image, {
thumb: '100x100' // Width x Height
});
// Thumbnail options
const thumbs = {
square: pb.files.getURL(record, record.image, { thumb: '100x100' }),
fit: pb.files.getURL(record, record.image, { thumb: '100x0' }), // Fit width
fitHeight: pb.files.getURL(record, record.image, { thumb: '0x100' }), // Fit height
crop: pb.files.getURL(record, record.image, { thumb: '100x100t' }), // Top crop
cropBottom: pb.files.getURL(record, record.image, { thumb: '100x100b' }), // Bottom
force: pb.files.getURL(record, record.image, { thumb: '100x100f' }), // Force exact
};
// Protected files (require auth)
async function getProtectedFileUrl(record, filename) {
// Get file access token (valid for limited time)
const token = await pb.files.getToken();
// Include token in URL
return pb.files.getURL(record, filename, { token });
}
// Example with protected document
async function downloadDocument(record) {
const token = await pb.files.getToken();
const url = pb.files.getURL(record, record.document, { token });
// Token is appended: ...?token=xxx
window.open(url, '_blank');
}
```
**React component example:**
```jsx
function UserAvatar({ user, size = 50 }) {
if (!user.avatar) {
return <DefaultAvatar size={size} />;
}
const avatarUrl = pb.files.getURL(user, user.avatar, {
thumb: `${size}x${size}`
});
return (
<img
src={avatarUrl}
alt={user.name}
width={size}
height={size}
loading="lazy"
/>
);
}
function ImageGallery({ record }) {
// Record has multiple images
const images = record.images || [];
return (
<div className="gallery">
{images.map((filename, index) => (
<img
key={filename}
src={pb.files.getURL(record, filename, { thumb: '200x200' })}
onClick={() => openFullSize(record, filename)}
loading="lazy"
/>
))}
</div>
);
}
function openFullSize(record, filename) {
const fullUrl = pb.files.getURL(record, filename);
window.open(fullUrl, '_blank');
}
```
**Handling file URLs in lists:**
```javascript
// Efficiently generate URLs for list of records
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author'
});
const postsWithUrls = posts.items.map(post => ({
...post,
thumbnailUrl: post.image
? pb.files.getURL(post, post.image, { thumb: '300x200' })
: null,
authorAvatarUrl: post.expand?.author?.avatar
? pb.files.getURL(post.expand.author, post.expand.author.avatar, { thumb: '40x40' })
: null
}));
```
**Thumbnail format reference:**
| Format | Description |
|--------|-------------|
| `WxH` | Fit within dimensions |
| `Wx0` | Fit width, auto height |
| `0xH` | Auto width, fit height |
| `WxHt` | Crop from top |
| `WxHb` | Crop from bottom |
| `WxHf` | Force exact dimensions |
**Performance and caching:**
```javascript
// File URLs are effectively immutable (randomized filenames on upload).
// This makes them ideal for aggressive caching.
// Configure Cache-Control via reverse proxy (Nginx/Caddy):
// location /api/files/ { add_header Cache-Control "public, immutable, max-age=86400"; }
// Thumbnails are generated on first request and cached by PocketBase.
// Pre-generate expected thumb sizes after upload to avoid cold-start latency:
async function uploadWithThumbs(record, file) {
const updated = await pb.collection('posts').update(record.id, { image: file });
// Pre-warm thumbnail cache by requesting expected sizes
const sizes = ['100x100', '300x200', '800x600'];
await Promise.all(sizes.map(size =>
fetch(pb.files.getURL(updated, updated.image, { thumb: size }))
));
return updated;
}
```
**S3 file serving optimization:**
When using S3 storage, PocketBase proxies all file requests through the server. For better performance with public files, serve directly from your S3 CDN:
```javascript
// Default: All file requests proxy through PocketBase
const url = pb.files.getURL(record, record.image);
// -> https://myapp.com/api/files/COLLECTION/ID/filename.jpg (proxied)
// For public files with S3 + CDN, construct CDN URL directly:
const cdnBase = 'https://cdn.myapp.com'; // Your S3 CDN domain
const cdnUrl = `${cdnBase}/${record.collectionId}/${record.id}/${record.image}`;
// Bypasses PocketBase, served directly from CDN edge
// NOTE: This only works for public files (no access token needed).
// Protected files must go through PocketBase for token validation.
```
Reference: [PocketBase Files](https://pocketbase.io/docs/files-handling/)
> **Note (JS SDK v0.26.7):** `pb.files.getURL()` now serializes query parameters the same way as the fetch methods — passing `null` or `undefined` as a query param value is silently skipped from the generated URL, so you no longer need to guard optional params before passing them to `getURL()`.
## 2. Upload Files Correctly
**Impact: MEDIUM (Reliable uploads with progress tracking and validation)**
File uploads can use plain objects or FormData. Handle large files properly with progress tracking and appropriate error handling.
**Incorrect (naive file upload):**
```javascript
// Missing error handling
async function uploadFile(file) {
await pb.collection('documents').create({
title: file.name,
file: file
});
// No error handling, no progress feedback
}
// Uploading without validation
async function uploadAvatar(file) {
await pb.collection('users').update(userId, {
avatar: file // No size/type check - might fail server-side
});
}
// Base64 upload (inefficient)
async function uploadImage(base64) {
await pb.collection('images').create({
image: base64 // Wrong! PocketBase expects File/Blob
});
}
```
**Correct (proper file uploads):**
```javascript
// Basic upload with object (auto-converts to FormData)
async function uploadDocument(file, metadata) {
try {
const record = await pb.collection('documents').create({
title: metadata.title,
description: metadata.description,
file: file // File object from input
});
return record;
} catch (error) {
if (error.response?.data?.file) {
throw new Error(`File error: ${error.response.data.file.message}`);
}
throw error;
}
}
// Upload multiple files
async function uploadGallery(files, albumId) {
const record = await pb.collection('albums').update(albumId, {
images: files // Array of File objects
});
return record;
}
// FormData for more control
async function uploadWithProgress(file, onProgress) {
const formData = new FormData();
formData.append('title', file.name);
formData.append('file', file);
// Using fetch directly for progress (SDK doesn't expose progress)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.open('POST', `${pb.baseURL}/api/collections/documents/records`);
xhr.setRequestHeader('Authorization', pb.authStore.token);
xhr.send(formData);
});
}
// Client-side validation before upload
function validateFile(file, options = {}) {
const {
maxSize = 10 * 1024 * 1024, // 10MB default
allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'],
maxNameLength = 100
} = options;
const errors = [];
if (file.size > maxSize) {
errors.push(`File too large. Max: ${maxSize / 1024 / 1024}MB`);
}
if (!allowedTypes.includes(file.type)) {
errors.push(`Invalid file type: ${file.type}`);
}
if (file.name.length > maxNameLength) {
errors.push(`Filename too long`);
}
return { valid: errors.length === 0, errors };
}
// Complete upload flow
async function handleFileUpload(inputEvent) {
const file = inputEvent.target.files[0];
if (!file) return;
// Validate
const validation = validateFile(file, {
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png']
});
if (!validation.valid) {
showError(validation.errors.join(', '));
return;
}
// Upload with progress
try {
setUploading(true);
const record = await uploadWithProgress(file, setProgress);
showSuccess('Upload complete!');
return record;
} catch (error) {
showError(error.message);
} finally {
setUploading(false);
}
}
```
**Deleting files:**
```javascript
// Remove specific file(s) from record
await pb.collection('albums').update(albumId, {
'images-': ['filename1.jpg', 'filename2.jpg'] // Remove these files
});
// Clear all files
await pb.collection('documents').update(docId, {
file: null // Removes the file
});
```
Reference: [PocketBase File Upload](https://pocketbase.io/docs/files-handling/)
## 3. Validate File Uploads
**Impact: MEDIUM (Prevents invalid uploads, improves security and UX)**
Validate files on both client and server side. Client validation improves UX; server validation (via collection settings) enforces security.
**Incorrect (no validation):**
```javascript
// Accepting any file without checks
async function uploadFile(file) {
return pb.collection('uploads').create({ file });
// Could upload 1GB executable!
}
// Only checking extension (easily bypassed)
function validateFile(file) {
const ext = file.name.split('.').pop();
return ['jpg', 'png'].includes(ext);
// User can rename virus.exe to virus.jpg
}
// Client-only validation (can be bypassed)
async function uploadAvatar(file) {
if (file.size > 1024 * 1024) {
throw new Error('Too large');
}
// Attacker can bypass this with dev tools
return pb.collection('users').update(userId, { avatar: file });
}
```
**Correct (comprehensive validation):**
```javascript
// 1. Configure server-side validation in collection settings
// In Admin UI or via API:
const collectionConfig = {
schema: [
{
name: 'avatar',
type: 'file',
options: {
maxSelect: 1, // Single file only
maxSize: 5242880, // 5MB in bytes
mimeTypes: [ // Allowed types
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
],
thumbs: ['100x100', '200x200'] // Auto-generate thumbnails
}
},
{
name: 'documents',
type: 'file',
options: {
maxSelect: 10,
maxSize: 10485760, // 10MB
mimeTypes: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
]
}
}
]
};
// 2. Client-side validation for better UX
const FILE_CONSTRAINTS = {
avatar: {
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
maxFiles: 1
},
documents: {
maxSize: 10 * 1024 * 1024,
allowedTypes: ['application/pdf'],
maxFiles: 10
}
};
function validateFiles(files, constraintKey) {
const constraints = FILE_CONSTRAINTS[constraintKey];
const errors = [];
const validFiles = [];
if (files.length > constraints.maxFiles) {
errors.push(`Maximum ${constraints.maxFiles} file(s) allowed`);
}
for (const file of files) {
const fileErrors = [];
// Check size
if (file.size > constraints.maxSize) {
const maxMB = constraints.maxSize / 1024 / 1024;
fileErrors.push(`${file.name}: exceeds ${maxMB}MB limit`);
}
// Check MIME type (more reliable than extension, but still spoofable)
// Client-side file.type is based on extension, not file content.
// Always enforce mimeTypes in PocketBase collection settings for server-side validation.
if (!constraints.allowedTypes.includes(file.type)) {
fileErrors.push(`${file.name}: invalid file type (${file.type || 'unknown'})`);
}
// Check for suspicious patterns
if (file.name.includes('..') || file.name.includes('/')) {
fileErrors.push(`${file.name}: invalid filename`);
}
if (fileErrors.length === 0) {
validFiles.push(file);
} else {
errors.push(...fileErrors);
}
}
return {
valid: errors.length === 0,
errors,
validFiles
};
}
// 3. Complete upload with validation
async function handleAvatarUpload(inputElement) {
const files = Array.from(inputElement.files);
// Client validation
const validation = validateFiles(files, 'avatar');
if (!validation.valid) {
showErrors(validation.errors);
return null;
}
// Upload (server will also validate)
try {
const updated = await pb.collection('users').update(userId, {
avatar: validation.validFiles[0]
});
showSuccess('Avatar updated!');
return updated;
} catch (error) {
// Handle server validation errors
if (error.response?.data?.avatar) {
showError(error.response.data.avatar.message);
} else {
showError('Upload failed');
}
return null;
}
}
// 4. Image-specific validation
async function validateImage(file, options = {}) {
const { minWidth = 0, minHeight = 0, maxWidth = Infinity, maxHeight = Infinity } = options;
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const errors = [];
if (img.width < minWidth || img.height < minHeight) {
errors.push(`Image must be at least ${minWidth}x${minHeight}px`);
}
if (img.width > maxWidth || img.height > maxHeight) {
errors.push(`Image must be at most ${maxWidth}x${maxHeight}px`);
}
resolve({ valid: errors.length === 0, errors, width: img.width, height: img.height });
};
img.onerror = () => resolve({ valid: false, errors: ['Invalid image file'] });
img.src = URL.createObjectURL(file);
});
}
```
Reference: [PocketBase Files Configuration](https://pocketbase.io/docs/files-handling/)

View File

@@ -0,0 +1,974 @@
# Production & Deployment
**Impact: LOW-MEDIUM**
Backup strategies, configuration management, reverse proxy setup, and SQLite optimization.
---
## 1. Implement Proper Backup Strategies
**Impact: LOW-MEDIUM (Prevents data loss, enables disaster recovery)**
Regular backups are essential for production deployments. PocketBase provides built-in backup functionality and supports external S3 storage.
**Incorrect (no backup strategy):**
```javascript
// No backups at all - disaster waiting to happen
// Just running: ./pocketbase serve
// Manual file copy while server running - can corrupt data
// cp pb_data/data.db backup/
// Only backing up database, missing files
// sqlite3 pb_data/data.db ".backup backup.db"
```
**Correct (comprehensive backup strategy):**
```javascript
// 1. Using PocketBase Admin API for backups
const adminPb = new PocketBase('http://127.0.0.1:8090');
await adminPb.collection('_superusers').authWithPassword(admin, password);
// Create backup (includes database and files)
async function createBackup(name = '') {
const backup = await adminPb.backups.create(name);
console.log('Backup created:', backup.key);
return backup;
}
// List available backups
async function listBackups() {
const backups = await adminPb.backups.getFullList();
backups.forEach(b => {
console.log(`${b.key} - ${b.size} bytes - ${b.modified}`);
});
return backups;
}
// Download backup
async function downloadBackup(key) {
const token = await adminPb.files.getToken();
const url = adminPb.backups.getDownloadURL(token, key);
// url can be used to download the backup file
return url;
}
// Restore from backup (CAUTION: overwrites current data!)
async function restoreBackup(key) {
await adminPb.backups.restore(key);
console.log('Restore initiated - server will restart');
}
// Delete old backups
async function cleanupOldBackups(keepCount = 7) {
const backups = await adminPb.backups.getFullList();
// Sort by date, keep newest
const sorted = backups.sort((a, b) =>
new Date(b.modified) - new Date(a.modified)
);
const toDelete = sorted.slice(keepCount);
for (const backup of toDelete) {
await adminPb.backups.delete(backup.key);
console.log('Deleted old backup:', backup.key);
}
}
```
**Automated backup script (cron job):**
```bash
#!/bin/bash
# backup.sh - Run daily via cron
POCKETBASE_URL="http://127.0.0.1:8090"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="your-secure-password"
BACKUP_DIR="/path/to/backups"
KEEP_DAYS=7
# Create timestamp
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Create backup via API
curl -X POST "${POCKETBASE_URL}/api/backups" \
-H "Authorization: $(curl -s -X POST "${POCKETBASE_URL}/api/collections/_superusers/auth-with-password" \
-d "identity=${ADMIN_EMAIL}&password=${ADMIN_PASSWORD}" | jq -r '.token')" \
-d "name=backup_${TIMESTAMP}"
# Clean old local backups
find "${BACKUP_DIR}" -name "*.zip" -mtime +${KEEP_DAYS} -delete
echo "Backup completed: backup_${TIMESTAMP}"
```
**Configure S3 for backup storage:**
```javascript
// In Admin UI: Settings > Backups > S3
// Or via API:
await adminPb.settings.update({
backups: {
s3: {
enabled: true,
bucket: 'my-pocketbase-backups',
region: 'us-east-1',
endpoint: 's3.amazonaws.com',
accessKey: process.env.AWS_ACCESS_KEY,
secret: process.env.AWS_SECRET_KEY
}
}
});
```
**Backup best practices:**
| Aspect | Recommendation |
|--------|---------------|
| Frequency | Daily minimum, hourly for critical apps |
| Retention | 7-30 days of daily backups |
| Storage | Off-site (S3, separate server) |
| Testing | Monthly restore tests |
| Monitoring | Alert on backup failures |
**Pre-backup checklist:**
- [ ] S3 or external storage configured
- [ ] Automated schedule set up
- [ ] Retention policy defined
- [ ] Restore procedure documented
- [ ] Restore tested successfully
Reference: [PocketBase Backups](https://pocketbase.io/docs/going-to-production/#backups)
## 2. Configure Production Settings Properly
**Impact: LOW-MEDIUM (Secure and optimized production environment)**
Production deployments require proper configuration of URLs, secrets, SMTP, and security settings.
**Incorrect (development defaults in production):**
```bash
# Running with defaults - insecure!
./pocketbase serve
# Hardcoded secrets
./pocketbase serve --encryptionEnv="mySecretKey123"
# Wrong origin for CORS
# Leaving http://localhost:8090 as allowed origin
```
**Correct (production configuration):**
```bash
# Production startup with essential flags
./pocketbase serve \
--http="0.0.0.0:8090" \
--origins="https://myapp.com,https://www.myapp.com" \
--encryptionEnv="PB_ENCRYPTION_KEY"
# Using environment variables
export PB_ENCRYPTION_KEY="your-32-char-encryption-key-here"
export SMTP_HOST="smtp.sendgrid.net"
export SMTP_PORT="587"
export SMTP_USER="apikey"
export SMTP_PASS="your-sendgrid-api-key"
./pocketbase serve --http="0.0.0.0:8090"
```
**Configure SMTP for emails:**
```javascript
// Via Admin UI or API
await adminPb.settings.update({
smtp: {
enabled: true,
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
username: process.env.SMTP_USER,
password: process.env.SMTP_PASS,
tls: true
},
meta: {
appName: 'My App',
appURL: 'https://myapp.com',
senderName: 'My App',
senderAddress: 'noreply@myapp.com'
}
});
// Test email configuration
await adminPb.settings.testEmail('users', 'test@example.com', 'verification');
```
**Configure S3 for file storage:**
```javascript
// Move file storage to S3 for scalability
await adminPb.settings.update({
s3: {
enabled: true,
bucket: 'my-app-files',
region: 'us-east-1',
endpoint: 's3.amazonaws.com',
accessKey: process.env.AWS_ACCESS_KEY,
secret: process.env.AWS_SECRET_KEY,
forcePathStyle: false
}
});
// Test S3 connection
await adminPb.settings.testS3('storage');
```
**Systemd service file:**
```ini
# /etc/systemd/system/pocketbase.service
[Unit]
Description=PocketBase
After=network.target
[Service]
Type=simple
User=pocketbase
Group=pocketbase
LimitNOFILE=4096
Restart=always
RestartSec=5s
WorkingDirectory=/opt/pocketbase
ExecStart=/opt/pocketbase/pocketbase serve --http="127.0.0.1:8090"
# Environment variables
EnvironmentFile=/opt/pocketbase/.env
# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/pocketbase/pb_data
[Install]
WantedBy=multi-user.target
```
**Environment file (.env):**
```bash
# /opt/pocketbase/.env
# SECURITY: Set restrictive permissions: chmod 600 /opt/pocketbase/.env
# SECURITY: Add to .gitignore - NEVER commit this file to version control
# For production, consider a secrets manager (Vault, AWS Secrets Manager, etc.)
PB_ENCRYPTION_KEY= # Generate with: openssl rand -hex 16
# SMTP
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS= # Set your SMTP password here
# S3 (optional)
AWS_ACCESS_KEY= # Set your AWS access key
AWS_SECRET_KEY= # Set your AWS secret key
# OAuth (optional)
GOOGLE_CLIENT_ID= # Set your Google client ID
GOOGLE_CLIENT_SECRET= # Set your Google client secret
```
**Protect your environment file:**
```bash
# Set restrictive permissions (owner read/write only)
chmod 600 /opt/pocketbase/.env
chown pocketbase:pocketbase /opt/pocketbase/.env
# Ensure .env is in .gitignore
echo ".env" >> .gitignore
```
**Production checklist:**
- [ ] HTTPS enabled (via reverse proxy)
- [ ] Strong encryption key set
- [ ] CORS origins configured
- [ ] SMTP configured and tested
- [ ] Superuser password changed
- [ ] S3 configured (for scalability)
- [ ] Backup schedule configured
- [ ] Rate limiting enabled (via reverse proxy)
- [ ] Logging configured
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)
## 3. Enable Rate Limiting for API Protection
**Impact: MEDIUM (Prevents abuse, brute-force attacks, and DoS)**
PocketBase v0.23+ includes built-in rate limiting. Enable it to protect against brute-force attacks, API abuse, and excessive resource consumption.
> **v0.36.7 behavioral change:** the built-in limiter switched from a sliding-window to a **fixed-window** strategy. This is cheaper and more predictable, but it means a client can send `2 * maxRequests` in quick succession if they straddle the window boundary. Size your limits with that worst case in mind, and put Nginx/Caddy rate limiting in front of PocketBase for defense in depth (see examples below).
**Incorrect (no rate limiting):**
```bash
# Running without rate limiting
./pocketbase serve
# Vulnerable to:
# - Brute-force password attacks
# - API abuse and scraping
# - DoS from excessive requests
# - Account enumeration attempts
```
**Correct (enable rate limiting):**
```bash
# Enable via command line flag
./pocketbase serve --rateLimiter=true
# Or configure specific limits (requests per second per IP)
./pocketbase serve --rateLimiter=true --rateLimiterRPS=10
```
**Configure via Admin Dashboard:**
Navigate to Settings > Rate Limiter:
- **Enable rate limiter**: Toggle on
- **Max requests/second**: Default 10, adjust based on needs
- **Exempt endpoints**: Optionally whitelist certain paths
**Configure programmatically (Go/JS hooks):**
```javascript
// In pb_hooks/rate_limit.pb.js
routerAdd("GET", "/api/public/*", (e) => {
// Custom rate limit for specific endpoints
}, $apis.rateLimit(100, "10s")); // 100 requests per 10 seconds
// Stricter limit for auth endpoints
routerAdd("POST", "/api/collections/users/auth-*", (e) => {
// Auth endpoints need stricter limits
}, $apis.rateLimit(5, "1m")); // 5 attempts per minute
```
**Rate limiting with reverse proxy (additional layer):**
```nginx
# Nginx rate limiting (defense in depth)
http {
# Define rate limit zones
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
server {
# General API rate limit
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://pocketbase;
}
# Strict limit for auth endpoints
location /api/collections/users/auth {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://pocketbase;
}
# Stricter limit for superuser auth
location /api/collections/_superusers/auth {
limit_req zone=auth burst=3 nodelay;
proxy_pass http://pocketbase;
}
}
}
```
```caddyfile
# Caddy with rate limiting plugin
myapp.com {
rate_limit {
zone api {
key {remote_host}
events 100
window 10s
}
zone auth {
key {remote_host}
events 5
window 1m
}
}
@auth path /api/collections/*/auth*
handle @auth {
rate_limit { zone auth }
reverse_proxy 127.0.0.1:8090
}
handle {
rate_limit { zone api }
reverse_proxy 127.0.0.1:8090
}
}
```
**Handle rate limit errors in client:**
```javascript
async function makeRequest(fn, retries = 0, maxRetries = 3) {
try {
return await fn();
} catch (error) {
if (error.status === 429 && retries < maxRetries) {
// Rate limited - wait and retry with limit
const retryAfter = error.response?.retryAfter || 60;
console.log(`Rate limited. Retry ${retries + 1}/${maxRetries} after ${retryAfter}s`);
// Show user-friendly message
showMessage('Too many requests. Please wait a moment.');
await sleep(retryAfter * 1000);
return makeRequest(fn, retries + 1, maxRetries);
}
throw error;
}
}
// Usage
const result = await makeRequest(() =>
pb.collection('posts').getList(1, 20)
);
```
**Recommended limits by endpoint type:**
| Endpoint Type | Suggested Limit | Reason |
|--------------|-----------------|--------|
| Auth endpoints | 5-10/min | Prevent brute-force |
| Password reset | 3/hour | Prevent enumeration |
| Record creation | 30/min | Prevent spam |
| General API | 60-100/min | Normal usage |
| Public read | 100-200/min | Higher for reads |
| File uploads | 10/min | Resource-intensive |
**Monitoring rate limit hits:**
```javascript
// Check PocketBase logs for rate limit events
// Or set up alerting in your monitoring system
// Client-side tracking
pb.afterSend = function(response, data) {
if (response.status === 429) {
trackEvent('rate_limit_hit', {
endpoint: response.url,
timestamp: new Date()
});
}
return data;
};
```
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)
## 4. Configure Reverse Proxy Correctly
**Impact: LOW-MEDIUM (HTTPS, caching, rate limiting, and security headers)**
Use a reverse proxy (Nginx, Caddy) for HTTPS termination, caching, rate limiting, and security headers.
**Incorrect (exposing PocketBase directly):**
```bash
# Direct exposure - no HTTPS, no rate limiting
./pocketbase serve --http="0.0.0.0:8090"
# Port forwarding without proxy
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8090
# Still no HTTPS!
```
**Correct (Caddy - simplest option):**
```caddyfile
# /etc/caddy/Caddyfile
myapp.com {
# Automatic HTTPS via Let's Encrypt
reverse_proxy 127.0.0.1:8090 {
# Required for SSE/Realtime
flush_interval -1
}
# Security headers
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Restrict admin UI to internal/VPN networks
# @admin path /_/*
# handle @admin {
# @blocked not remote_ip 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
# respond @blocked 403
# reverse_proxy 127.0.0.1:8090
# }
# Rate limiting (requires caddy-ratelimit plugin)
# Install: xcaddy build --with github.com/mholt/caddy-ratelimit
# Without this plugin, use PocketBase's built-in rate limiter (--rateLimiter=true)
# rate_limit {
# zone api {
# key {remote_host}
# events 100
# window 1m
# }
# }
}
```
**Correct (Nginx configuration):**
```nginx
# /etc/nginx/sites-available/pocketbase
# Rate limit zones must be defined in http context (e.g., /etc/nginx/nginx.conf)
# limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
upstream pocketbase {
server 127.0.0.1:8090;
keepalive 64;
}
server {
listen 80;
server_name myapp.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name myapp.com;
# SSL configuration
ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Note: X-XSS-Protection is deprecated and can introduce vulnerabilities.
# Use Content-Security-Policy instead.
location / {
proxy_pass http://pocketbase;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE/Realtime support
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
# Timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Rate limit API endpoints
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://pocketbase;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off;
}
# Static file caching
location /api/files/ {
proxy_pass http://pocketbase;
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_types text/plain application/json application/javascript text/css;
gzip_min_length 1000;
}
```
**Docker Compose with Caddy:**
```yaml
# docker-compose.yml
version: '3.8'
services:
pocketbase:
# NOTE: This is a third-party community image, not officially maintained by PocketBase.
# For production, consider building your own image from the official PocketBase binary.
# See: https://pocketbase.io/docs/going-to-production/
image: ghcr.io/muchobien/pocketbase:latest
restart: unless-stopped
volumes:
- ./pb_data:/pb_data
environment:
- PB_ENCRYPTION_KEY=${PB_ENCRYPTION_KEY}
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- pocketbase
volumes:
caddy_data:
caddy_config:
```
**Key configuration points:**
| Feature | Why It Matters |
|---------|---------------|
| HTTPS | Encrypts traffic, required for auth |
| SSE support | `proxy_buffering off` for realtime |
| Rate limiting | Prevents abuse |
| Security headers | XSS/clickjacking protection |
| Keepalive | Connection reuse, better performance |
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)
## 5. Tune OS and Runtime for PocketBase Scale
**Impact: MEDIUM (Prevents file descriptor exhaustion, OOM kills, and exposes secure config for production deployments)**
Three low-effort OS/runtime knobs have outsized impact on production stability: open-file limits for realtime connections, Go memory limits for constrained hosts, and settings encryption for shared or externally-backed infrastructure. None of these are set automatically.
**Incorrect (default OS limits, no memory governor, plain-text settings):**
```bash
# Start without raising the file descriptor limit
/root/pb/pocketbase serve yourdomain.com
# → "Too many open files" once concurrent realtime connections exceed ~1024
# Start in a container that has a 512 MB RAM cap without GOMEMLIMIT
docker run -m 512m pocketbase serve ...
# → OOM kill during large file upload because Go GC doesn't respect cgroup limits
# Store SMTP password and S3 secret as plain JSON in pb_data/data.db
pocketbase serve # no --encryptionEnv
# → Anyone who obtains the database backup can read all credentials
```
**Correct:**
```bash
# 1. Raise the open-file limit before starting (Linux/macOS)
# Check current limit first:
ulimit -a | grep "open files"
# Temporarily raise to 4096 for the current session:
ulimit -n 4096
/root/pb/pocketbase serve yourdomain.com
# Or persist it via systemd (recommended for production):
# /lib/systemd/system/pocketbase.service
# [Service]
# LimitNOFILE = 4096
# ...
# 2. Cap Go's soft memory target on memory-constrained hosts
# (instructs the GC to be more aggressive before the kernel OOM-kills the process)
GOMEMLIMIT=512MiB /root/pb/pocketbase serve yourdomain.com
# 3. Encrypt application settings at rest
# Generate a random 32-character key once:
export PB_ENCRYPTION_KEY="z76NX9WWiB05UmQGxw367B6zM39T11fF"
# Start with the env-var name (not the value) as the flag argument:
pocketbase serve --encryptionEnv=PB_ENCRYPTION_KEY
```
**Docker deployment pattern (v0.36.8):**
```dockerfile
FROM alpine:latest
ARG PB_VERSION=0.36.8
RUN apk add --no-cache unzip ca-certificates
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
# Uncomment to bundle pre-written migrations or hooks:
# COPY ./pb_migrations /pb/pb_migrations
# COPY ./pb_hooks /pb/pb_hooks
EXPOSE 8080
# Mount a volume at /pb/pb_data to persist data across container restarts
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]
```
```yaml
# docker-compose.yml
services:
pocketbase:
build: .
ports:
- "8080:8080"
volumes:
- pb_data:/pb/pb_data
environment:
GOMEMLIMIT: "512MiB"
PB_ENCRYPTION_KEY: "${PB_ENCRYPTION_KEY}"
command: ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080", "--encryptionEnv=PB_ENCRYPTION_KEY"]
volumes:
pb_data:
```
**Quick-reference checklist:**
| Concern | Fix |
|---------|-----|
| `Too many open files` errors | `ulimit -n 4096` (or `LimitNOFILE=4096` in systemd) |
| OOM kill on constrained host | `GOMEMLIMIT=512MiB` env var |
| Credentials visible in DB backup | `--encryptionEnv=YOUR_VAR` with a 32-char random key |
| Persistent data in Docker | Mount volume at `/pb/pb_data` |
Reference: [Going to production](https://pocketbase.io/docs/going-to-production/)
## 6. Optimize SQLite for Production
**Impact: LOW-MEDIUM (Better performance and reliability for SQLite database)**
PocketBase uses SQLite with optimized defaults. Understanding its characteristics helps optimize performance and avoid common pitfalls. PocketBase uses two separate databases: `data.db` (application data) and `auxiliary.db` (logs and ephemeral data), which reduces write contention.
**Incorrect (ignoring SQLite characteristics):**
```javascript
// Heavy concurrent writes - SQLite bottleneck
async function bulkInsert(items) {
// Parallel writes cause lock contention
await Promise.all(items.map(item =>
pb.collection('items').create(item)
));
}
// Not using transactions for batch operations
async function updateMany(items) {
for (const item of items) {
await pb.collection('items').update(item.id, item);
}
// Each write is a separate transaction - slow!
}
// Large text fields without consideration
const schema = [{
name: 'content',
type: 'text' // Could be megabytes - affects all queries
}];
```
**Correct (SQLite-optimized patterns):**
```javascript
// Use batch operations for multiple writes
async function bulkInsert(items) {
const batch = pb.createBatch();
items.forEach(item => {
batch.collection('items').create(item);
});
await batch.send(); // Single transaction, much faster
}
// Batch updates
async function updateMany(items) {
const batch = pb.createBatch();
items.forEach(item => {
batch.collection('items').update(item.id, item);
});
await batch.send();
}
// For very large batches, chunk them
async function bulkInsertLarge(items, chunkSize = 100) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const batch = pb.createBatch();
chunk.forEach(item => batch.collection('items').create(item));
await batch.send();
}
}
```
**Schema considerations:**
```javascript
// Separate large content into dedicated collection
const postsSchema = [
{ name: 'title', type: 'text' },
{ name: 'summary', type: 'text', options: { maxLength: 500 } },
{ name: 'author', type: 'relation' }
// Content in separate collection
];
const postContentsSchema = [
{ name: 'post', type: 'relation', required: true },
{ name: 'content', type: 'editor' } // Large HTML content
];
// Fetch content only when needed
async function getPostList() {
return pb.collection('posts').getList(1, 20); // Fast, no content
}
async function getPostWithContent(id) {
const post = await pb.collection('posts').getOne(id);
const content = await pb.collection('post_contents').getFirstListItem(
pb.filter('post = {:id}', { id })
);
return { ...post, content: content.content };
}
```
**PocketBase default PRAGMA settings:**
PocketBase already configures optimal SQLite settings. You do not need to set these manually unless using a custom SQLite driver:
```sql
PRAGMA busy_timeout = 10000; -- Wait 10s for locks instead of failing immediately
PRAGMA journal_mode = WAL; -- Write-Ahead Logging: concurrent reads during writes
PRAGMA journal_size_limit = 200000000; -- Limit WAL file to ~200MB
PRAGMA synchronous = NORMAL; -- Balanced durability/performance (safe with WAL)
PRAGMA foreign_keys = ON; -- Enforce relation integrity
PRAGMA temp_store = MEMORY; -- Temp tables in memory (faster sorts/joins)
PRAGMA cache_size = -32000; -- 32MB page cache
```
WAL mode is the most impactful setting -- it allows multiple concurrent readers while a single writer is active, which is critical for PocketBase's concurrent API request handling.
**Index optimization:**
```sql
-- Create indexes for commonly filtered/sorted fields
CREATE INDEX idx_posts_author ON posts(author);
CREATE INDEX idx_posts_created ON posts(created DESC);
CREATE INDEX idx_posts_status_created ON posts(status, created DESC);
-- Verify indexes are being used
EXPLAIN QUERY PLAN
SELECT * FROM posts WHERE author = 'xxx' ORDER BY created DESC;
-- Should show: "USING INDEX idx_posts_author"
```
**SQLite limitations and workarounds:**
| Limitation | Workaround |
|------------|------------|
| Single writer | Use batch operations, queue writes |
| No full-text by default | Use view collections with FTS5 |
| File-based | SSD storage, avoid network mounts |
| Memory for large queries | Pagination, limit result sizes |
**Performance monitoring:**
```javascript
// Monitor slow queries via hooks (requires custom PocketBase build)
// Or use SQLite's built-in profiling
// From sqlite3 CLI:
// .timer on
// SELECT * FROM posts WHERE author = 'xxx';
// Run Time: real 0.003 user 0.002 sys 0.001
// Check database size
// ls -lh pb_data/data.db
// Vacuum to reclaim space after deletes
// sqlite3 pb_data/data.db "VACUUM;"
```
**When to consider alternatives:**
Consider migrating from single PocketBase if:
- Write throughput consistently > 1000/sec needed
- Database size > 100GB
- Complex transactions across tables
- Multi-region deployment required
**Custom SQLite driver (advanced):**
PocketBase supports custom SQLite drivers via `DBConnect`. The CGO driver (`mattn/go-sqlite3`) can offer better performance for some workloads and enables extensions like ICU and FTS5. This requires a custom PocketBase build:
```go
// main.go (custom PocketBase build with CGO driver)
package main
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
)
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
// Called twice: once for data.db, once for auxiliary.db
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("sqlite3", dbPath)
},
})
if err := app.Start(); err != nil {
panic(err)
}
}
// Build with: CGO_ENABLED=1 go build
```
Note: CGO requires C compiler toolchain and cannot be cross-compiled as easily as pure Go.
**Scaling options:**
1. **Read replicas**: Litestream for SQLite replication
2. **Sharding**: Multiple PocketBase instances by tenant/feature
3. **Caching**: Redis/Memcached for read-heavy loads
4. **Alternative backend**: If requirements exceed SQLite, evaluate PostgreSQL-based frameworks
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)

View File

@@ -0,0 +1,989 @@
# Query Performance
**Impact: HIGH**
Pagination strategies, relation expansion, field selection, batch operations, and N+1 query prevention.
---
## 1. Use Back-Relations for Inverse Lookups
**Impact: HIGH (Fetch related records without separate queries)**
Back-relations allow you to expand records that reference the current record, enabling inverse lookups in a single request. Use the `collectionName_via_fieldName` syntax.
**Incorrect (manual inverse lookup):**
```javascript
// Fetching a user, then their posts separately
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId);
// Extra request for posts
const posts = await pb.collection('posts').getList(1, 100, {
filter: pb.filter('author = {:userId}', { userId })
});
return { ...user, posts: posts.items };
}
// 2 API calls
// Fetching a post, then its comments
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId);
const comments = await pb.collection('comments').getFullList({
filter: pb.filter('post = {:postId}', { postId }),
expand: 'author'
});
return { ...post, comments };
}
// 2 API calls
```
**Correct (using back-relation expand):**
```javascript
// Expand posts that reference this user
// posts collection has: author (relation to users)
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author' // collectionName_via_fieldName
});
console.log('User:', user.name);
console.log('Posts:', user.expand?.posts_via_author);
return user;
}
// 1 API call!
// Expand comments that reference this post
// comments collection has: post (relation to posts)
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId, {
expand: 'comments_via_post,comments_via_post.author'
});
const comments = post.expand?.comments_via_post || [];
comments.forEach(comment => {
console.log(`${comment.expand?.author?.name}: ${comment.content}`);
});
return post;
}
// 1 API call with nested expansion!
// Multiple back-relations
async function getUserWithAllContent(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author,comments_via_author,likes_via_user'
});
return {
user,
posts: user.expand?.posts_via_author || [],
comments: user.expand?.comments_via_author || [],
likes: user.expand?.likes_via_user || []
};
}
```
**Back-relation syntax:**
```
{referencing_collection}_via_{relation_field}
Examples:
- posts_via_author -> posts where author = current record
- comments_via_post -> comments where post = current record
- order_items_via_order -> order_items where order = current record
- team_members_via_team -> team_members where team = current record
```
**Nested back-relations:**
```javascript
// Get user with posts and each post's comments
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author.comments_via_post'
});
// Access nested data
const posts = user.expand?.posts_via_author || [];
posts.forEach(post => {
console.log('Post:', post.title);
const comments = post.expand?.comments_via_post || [];
comments.forEach(c => console.log(' Comment:', c.content));
});
```
**Important considerations:**
```javascript
// Back-relations always return arrays, even if the relation field
// is marked as single (maxSelect: 1)
// Limited to 1000 records per back-relation
// For more, use separate paginated query
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author'
});
// If user has 1500 posts, only first 1000 are included
// For large datasets, use paginated approach
async function getUserPostsPaginated(userId, page = 1) {
return pb.collection('posts').getList(page, 50, {
filter: pb.filter('author = {:userId}', { userId }),
sort: '-created'
});
}
```
**Use in list queries:**
```javascript
// Get all users with their post counts
// (Use view collection for actual counts)
const users = await pb.collection('users').getList(1, 20, {
expand: 'posts_via_author'
});
users.items.forEach(user => {
const postCount = user.expand?.posts_via_author?.length || 0;
console.log(`${user.name}: ${postCount} posts`);
});
```
**When to use back-relations vs separate queries:**
| Scenario | Approach |
|----------|----------|
| < 1000 related records | Back-relation expand |
| Need pagination | Separate query with filter |
| Need sorting/filtering | Separate query |
| Just need count | View collection |
| Display in list | Back-relation (if small) |
Reference: [PocketBase Back-Relations](https://pocketbase.io/docs/working-with-relations/#back-relation-expand)
## 2. Use Batch Operations for Multiple Writes
**Impact: HIGH (Atomic transactions, 10x fewer API calls, consistent state)**
Batch operations combine multiple create/update/delete operations into a single atomic transaction. This ensures consistency and dramatically reduces API calls.
**Incorrect (individual requests):**
```javascript
// Creating multiple records individually
async function createOrderWithItems(order, items) {
// If any fails, partial data remains!
const createdOrder = await pb.collection('orders').create(order);
for (const item of items) {
await pb.collection('order_items').create({
...item,
order: createdOrder.id
});
}
// 1 + N API calls, not atomic
}
// Updating multiple records
async function updatePrices(products) {
for (const product of products) {
await pb.collection('products').update(product.id, {
price: product.newPrice
});
}
// N API calls, some might fail leaving inconsistent state
}
// Mixed operations
async function transferFunds(fromId, toId, amount) {
// NOT ATOMIC - can leave invalid state!
await pb.collection('accounts').update(fromId, { 'balance-': amount });
// If this fails, money disappears!
await pb.collection('accounts').update(toId, { 'balance+': amount });
}
```
**Correct (using batch operations):**
```javascript
// Atomic batch create
async function createOrderWithItems(order, items) {
const batch = pb.createBatch();
// Pre-generate order ID so items can reference it in the same batch
// PocketBase accepts custom IDs (15-char alphanumeric)
const orderId = crypto.randomUUID().replaceAll('-', '').slice(0, 15);
// Queue order creation with known ID
batch.collection('orders').create({ ...order, id: orderId });
// Queue all items referencing the pre-generated order ID
items.forEach(item => {
batch.collection('order_items').create({
...item,
order: orderId
});
});
// Execute atomically
const results = await batch.send();
// All succeed or all fail together
return {
order: results[0],
items: results.slice(1)
};
}
// Batch updates
async function updatePrices(products) {
const batch = pb.createBatch();
products.forEach(product => {
batch.collection('products').update(product.id, {
price: product.newPrice
});
});
const results = await batch.send();
// 1 API call, atomic
return results;
}
// Batch upsert (create or update)
async function syncProducts(products) {
const batch = pb.createBatch();
products.forEach(product => {
batch.collection('products').upsert({
id: product.sku, // Use SKU as ID for upsert matching
name: product.name,
price: product.price,
stock: product.stock
});
});
return batch.send();
}
// Mixed operations in transaction
// NOTE: Batch operations respect API rules per-operation, but ensure your
// business logic validates inputs (e.g., sufficient balance) server-side
// via hooks or API rules to prevent unauthorized transfers.
async function transferFunds(fromId, toId, amount) {
const batch = pb.createBatch();
batch.collection('accounts').update(fromId, { 'balance-': amount });
batch.collection('accounts').update(toId, { 'balance+': amount });
// Create audit record
batch.collection('transfers').create({
from: fromId,
to: toId,
amount,
timestamp: new Date()
});
// All three operations atomic
const [fromAccount, toAccount, transfer] = await batch.send();
return { fromAccount, toAccount, transfer };
}
// Batch delete
async function deletePostWithComments(postId) {
// First get comment IDs
const comments = await pb.collection('comments').getFullList({
filter: pb.filter('post = {:postId}', { postId }),
fields: 'id'
});
const batch = pb.createBatch();
// Queue all deletions
comments.forEach(comment => {
batch.collection('comments').delete(comment.id);
});
batch.collection('posts').delete(postId);
await batch.send();
// Post and all comments deleted atomically
}
```
**Batch operation limits:**
- **Must be enabled first** in Dashboard > Settings > Application (disabled by default; returns 403 otherwise)
- Operations execute in a single database transaction
- All succeed or all rollback
- Respects API rules for each operation
- Configurable limits: `maxRequests`, `timeout`, and `maxBodySize` (set in Dashboard)
- **Avoid large file uploads** in batches over slow networks -- they block the entire transaction
- Avoid custom hooks that call slow external APIs within batch operations
**When to use batch:**
| Scenario | Use Batch? |
|----------|-----------|
| Creating parent + children | Yes |
| Bulk import/update | Yes |
| Financial transactions | Yes |
| Single record operations | No |
| Independent operations | Optional |
Reference: [PocketBase Batch API](https://pocketbase.io/docs/api-records/#batch-operations)
## 3. Expand Relations Efficiently
**Impact: HIGH (Eliminates N+1 queries, reduces API calls by 90%+)**
Use the `expand` parameter to fetch related records in a single request. This eliminates N+1 query problems and dramatically reduces API calls.
**Incorrect (N+1 queries):**
```javascript
// Fetching posts then authors separately - N+1 problem
async function getPostsWithAuthors() {
const posts = await pb.collection('posts').getList(1, 20);
// N additional requests for N posts!
for (const post of posts.items) {
post.authorData = await pb.collection('users').getOne(post.author);
}
return posts;
}
// 21 API calls for 20 posts!
// Even worse with multiple relations
async function getPostsWithAll() {
const posts = await pb.collection('posts').getList(1, 20);
for (const post of posts.items) {
post.author = await pb.collection('users').getOne(post.author);
post.category = await pb.collection('categories').getOne(post.category);
post.tags = await Promise.all(
post.tags.map(id => pb.collection('tags').getOne(id))
);
}
// 60+ API calls!
}
```
**Correct (using expand):**
```javascript
// Single request with expanded relations
async function getPostsWithAuthors() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author'
});
// Access expanded data
posts.items.forEach(post => {
console.log('Author:', post.expand?.author?.name);
});
return posts;
}
// 1 API call!
// Multiple relations
async function getPostsWithAll() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category,tags'
});
posts.items.forEach(post => {
console.log('Author:', post.expand?.author?.name);
console.log('Category:', post.expand?.category?.name);
console.log('Tags:', post.expand?.tags?.map(t => t.name));
});
}
// Still just 1 API call!
// Nested expansion (up to 6 levels)
async function getPostsWithNestedData() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author.profile,category.parent,comments_via_post.author'
});
posts.items.forEach(post => {
// Nested relations
console.log('Author profile:', post.expand?.author?.expand?.profile);
console.log('Parent category:', post.expand?.category?.expand?.parent);
// Back-relations (comments that reference this post)
console.log('Comments:', post.expand?.['comments_via_post']);
});
}
// Back-relation expansion
// If comments collection has a 'post' relation field pointing to posts
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId, {
expand: 'comments_via_post,comments_via_post.author'
});
// Access comments that reference this post
const comments = post.expand?.['comments_via_post'] || [];
comments.forEach(comment => {
console.log(`${comment.expand?.author?.name}: ${comment.text}`);
});
return post;
}
```
**Expand syntax:**
| Syntax | Description |
|--------|-------------|
| `expand: 'author'` | Single relation |
| `expand: 'author,tags'` | Multiple relations |
| `expand: 'author.profile'` | Nested relation (2 levels) |
| `expand: 'comments_via_post'` | Back-relation (records pointing to this) |
**Handling optional expand data:**
```javascript
// Always use optional chaining - expand may be undefined
const authorName = post.expand?.author?.name || 'Unknown';
// Type-safe access with TypeScript
interface Post {
id: string;
title: string;
author: string; // Relation ID
expand?: {
author?: User;
};
}
const posts = await pb.collection('posts').getList<Post>(1, 20, {
expand: 'author'
});
```
**Limitations:**
- Maximum 6 levels of nesting
- Respects API rules on expanded collections
- Large expansions may impact performance
Reference: [PocketBase Expand](https://pocketbase.io/docs/api-records/#expand)
## 4. Select Only Required Fields
**Impact: MEDIUM (Reduces payload size, improves response time)**
Use the `fields` parameter to request only the data you need. This reduces bandwidth and can improve query performance, especially with large text or file fields.
**Incorrect (fetching everything):**
```javascript
// Fetching all fields when only a few are needed
const posts = await pb.collection('posts').getList(1, 20);
// Returns: id, title, content (10KB), thumbnail, author, tags, created, updated...
// Only displaying titles in a list
posts.items.forEach(post => {
renderListItem(post.title); // Only using title!
});
// Wasted bandwidth on content, thumbnail URLs, etc.
// Fetching user data with large profile fields
const users = await pb.collection('users').getFullList();
// Includes: avatar (file), bio (text), settings (json)...
// When you only need names for a dropdown
```
**Correct (selecting specific fields):**
```javascript
// Select only needed fields
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title,created'
});
// Returns only: { id, title, created }
// For a dropdown/autocomplete
const users = await pb.collection('users').getFullList({
fields: 'id,name,avatar'
});
// Include expanded relation fields
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author',
fields: 'id,title,expand.author.name,expand.author.avatar'
});
// Returns: { id, title, expand: { author: { name, avatar } } }
// Wildcard for all direct fields, specific for expand
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category',
fields: '*,expand.author.name,expand.category.name'
});
// All post fields + only name from expanded relations
```
**Using excerpt modifier:**
```javascript
// Get truncated text content
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title,content:excerpt(200,true)'
});
// content is truncated to 200 chars with "..." appended
// Multiple excerpts
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title:excerpt(50),content:excerpt(150,true)'
});
// Excerpt syntax: field:excerpt(maxLength, withEllipsis?)
// - maxLength: maximum characters
// - withEllipsis: append "..." if truncated (default: false)
```
**Common field selection patterns:**
```javascript
// List view - minimal data
const listFields = 'id,title,thumbnail,author,created';
// Card view - slightly more
const cardFields = 'id,title,content:excerpt(200,true),thumbnail,author,created';
// Detail view - most fields
const detailFields = '*,expand.author.name,expand.author.avatar';
// Autocomplete - just id and display text
const autocompleteFields = 'id,name';
// Table export - specific columns
const exportFields = 'id,email,name,created,status';
// Usage
async function getPostsList() {
return pb.collection('posts').getList(1, 20, {
fields: listFields,
expand: 'author'
});
}
```
**Performance impact:**
| Field Type | Impact of Selecting |
|------------|-------------------|
| text/editor | High (can be large) |
| file | Medium (URLs generated) |
| json | Medium (can be large) |
| relation | Low (just IDs) |
| number/bool | Low |
**Note:** Field selection happens after data is fetched from database, so it primarily saves bandwidth, not database queries. For database-level optimization, ensure proper indexes.
Reference: [PocketBase Fields Parameter](https://pocketbase.io/docs/api-records/#fields)
## 5. Use getFirstListItem for Single Record Lookups
**Impact: MEDIUM (Cleaner code, automatic error handling for not found)**
Use `getFirstListItem()` when you need to find a single record by a field value other than ID. It's cleaner than `getList()` with limit 1 and provides proper error handling.
**Incorrect (manual single-record lookup):**
```javascript
// Using getList with limit 1 - verbose
async function findUserByEmail(email) {
const result = await pb.collection('users').getList(1, 1, {
filter: pb.filter('email = {:email}', { email })
});
if (result.items.length === 0) {
throw new Error('User not found');
}
return result.items[0];
}
// Using getFullList then filtering - wasteful
async function findUserByUsername(username) {
const users = await pb.collection('users').getFullList({
filter: pb.filter('username = {:username}', { username })
});
return users[0]; // Might be undefined!
}
// Fetching by ID when you have a different identifier
async function findProductBySku(sku) {
// Wrong: getOne expects the record ID
const product = await pb.collection('products').getOne(sku); // Fails!
}
```
**Correct (using getFirstListItem):**
```javascript
// Clean single-record lookup by any field
async function findUserByEmail(email) {
try {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email })
);
return user;
} catch (error) {
if (error.status === 404) {
return null; // Not found
}
throw error;
}
}
// Lookup by unique field
async function findProductBySku(sku) {
return pb.collection('products').getFirstListItem(
pb.filter('sku = {:sku}', { sku })
);
}
// Lookup with expand
async function findOrderByNumber(orderNumber) {
return pb.collection('orders').getFirstListItem(
pb.filter('orderNumber = {:num}', { num: orderNumber }),
{ expand: 'customer,items' }
);
}
// Complex filter conditions
async function findActiveSubscription(userId) {
return pb.collection('subscriptions').getFirstListItem(
pb.filter(
'user = {:userId} && status = "active" && expiresAt > @now',
{ userId }
)
);
}
// With field selection
async function getUserIdByEmail(email) {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email }),
{ fields: 'id' }
);
return user.id;
}
```
**Comparison with getOne:**
```javascript
// getOne - fetch by record ID
const post = await pb.collection('posts').getOne('abc123');
// getFirstListItem - fetch by any filter (use pb.filter for safe binding)
const post = await pb.collection('posts').getFirstListItem(
pb.filter('slug = {:slug}', { slug: 'hello-world' })
);
const user = await pb.collection('users').getFirstListItem(
pb.filter('username = {:name}', { name: 'john' })
);
const order = await pb.collection('orders').getFirstListItem(
pb.filter('orderNumber = {:num}', { num: 12345 })
);
```
**Error handling:**
```javascript
// getFirstListItem throws 404 if no match found
try {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email })
);
return user;
} catch (error) {
if (error.status === 404) {
// No matching record - handle appropriately
return null;
}
// Other error (network, auth, etc.)
throw error;
}
// Wrapper function for optional lookup
async function findFirst(collection, filter, options = {}) {
try {
return await pb.collection(collection).getFirstListItem(filter, options);
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
// Usage
const user = await findFirst('users', pb.filter('email = {:e}', { e: email }));
if (!user) {
console.log('User not found');
}
```
**When to use each method:**
| Method | Use When |
|--------|----------|
| `getOne(id)` | You have the record ID |
| `getFirstListItem(filter)` | Finding by unique field (email, slug, sku) |
| `getList(1, 1, { filter })` | Need pagination metadata |
| `getFullList({ filter })` | Expecting multiple results |
Reference: [PocketBase Records API](https://pocketbase.io/docs/api-records/)
## 6. Prevent N+1 Query Problems
**Impact: HIGH (Reduces API calls from N+1 to 1-2, dramatically faster page loads)**
N+1 queries occur when you fetch a list of records, then make additional requests for each record's related data. This pattern causes severe performance issues at scale.
**Incorrect (N+1 patterns):**
```javascript
// Classic N+1: fetching related data in a loop
async function getPostsWithDetails() {
const posts = await pb.collection('posts').getList(1, 20); // 1 query
for (const post of posts.items) {
// N additional queries!
post.author = await pb.collection('users').getOne(post.author);
post.category = await pb.collection('categories').getOne(post.category);
}
// Total: 1 + 20 + 20 = 41 queries for 20 posts
}
// N+1 with Promise.all (faster but still N+1)
async function getPostsParallel() {
const posts = await pb.collection('posts').getList(1, 20);
await Promise.all(posts.items.map(async post => {
post.author = await pb.collection('users').getOne(post.author);
}));
// Still 21 API calls, just parallel
}
// Hidden N+1 in rendering
function PostList({ posts }) {
return posts.map(post => (
<PostCard
post={post}
author={useAuthor(post.author)} // Each triggers a fetch!
/>
));
}
```
**Correct (eliminate N+1):**
```javascript
// Solution 1: Use expand for relations
async function getPostsWithDetails() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category,tags'
});
// All data in one request
posts.items.forEach(post => {
console.log(post.expand?.author?.name);
console.log(post.expand?.category?.name);
});
// Total: 1 query
}
// Solution 2: Batch fetch related records
async function getPostsWithAuthorsBatch() {
const posts = await pb.collection('posts').getList(1, 20);
// Collect unique author IDs
const authorIds = [...new Set(posts.items.map(p => p.author))];
// Single query for all authors (use pb.filter for safe binding)
const filter = authorIds.map(id => pb.filter('id = {:id}', { id })).join(' || ');
const authors = await pb.collection('users').getList(1, authorIds.length, {
filter
});
// Create lookup map
const authorMap = Object.fromEntries(
authors.items.map(a => [a.id, a])
);
// Attach to posts
posts.items.forEach(post => {
post.authorData = authorMap[post.author];
});
// Total: 2 queries regardless of post count
}
// Solution 3: Use view collection for complex joins
// Create a view that joins posts with authors:
// SELECT p.*, u.name as author_name, u.avatar as author_avatar
// FROM posts p LEFT JOIN users u ON p.author = u.id
async function getPostsFromView() {
const posts = await pb.collection('posts_with_authors').getList(1, 20);
// Single query, data already joined
}
// Solution 4: Back-relations with expand
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author' // All posts by this user
});
console.log('Posts by user:', user.expand?.posts_via_author);
// 1 query gets user + all their posts
}
```
**Detecting N+1 in your code:**
```javascript
// Add request logging to detect N+1
let requestCount = 0;
pb.beforeSend = (url, options) => {
requestCount++;
console.log(`Request #${requestCount}: ${options.method} ${url}`);
return { url, options };
};
// Monitor during development
async function loadPage() {
requestCount = 0;
await loadAllData();
console.log(`Total requests: ${requestCount}`);
// If this is >> number of records, you have N+1
}
```
**Prevention checklist:**
- [ ] Always use `expand` for displaying related data
- [ ] Never fetch related records in loops
- [ ] Batch fetch when expand isn't available
- [ ] Consider view collections for complex joins
- [ ] Monitor request counts during development
Reference: [PocketBase Expand](https://pocketbase.io/docs/api-records/#expand)
## 7. Use Efficient Pagination Strategies
**Impact: HIGH (10-100x faster list queries on large collections)**
Pagination impacts performance significantly. Use `skipTotal` for large datasets, cursor-based pagination for infinite scroll, and appropriate page sizes.
**Incorrect (inefficient pagination):**
```javascript
// Fetching all records - memory and performance disaster
const allPosts = await pb.collection('posts').getFullList();
// Downloads entire table, crashes on large datasets
// Default pagination without skipTotal
const posts = await pb.collection('posts').getList(100, 20);
// COUNT(*) runs on every request - slow on large tables
// Using offset for infinite scroll
async function loadMore(page) {
// As page increases, offset queries get slower
return pb.collection('posts').getList(page, 20);
// Page 1000: skips 19,980 rows before returning 20
}
```
**Correct (optimized pagination):**
```javascript
// Use skipTotal for better performance on large collections
const posts = await pb.collection('posts').getList(1, 20, {
skipTotal: true, // Skip COUNT(*) query
sort: '-created'
});
// Returns items without totalItems/totalPages (faster)
// Cursor-based pagination for infinite scroll
async function loadMorePosts(lastCreated = null) {
const filter = lastCreated
? pb.filter('created < {:cursor}', { cursor: lastCreated })
: '';
const result = await pb.collection('posts').getList(1, 20, {
filter,
sort: '-created',
skipTotal: true
});
// Next cursor is the last item's created date
const nextCursor = result.items.length > 0
? result.items[result.items.length - 1].created
: null;
return { items: result.items, nextCursor };
}
// Usage for infinite scroll
let cursor = null;
async function loadNextPage() {
const { items, nextCursor } = await loadMorePosts(cursor);
cursor = nextCursor;
appendToList(items);
}
// Batched fetching when you need all records
async function getAllPostsEfficiently() {
const allPosts = [];
let page = 1;
const perPage = 1000; // Larger batches = fewer requests (max 1000 per API limit)
while (true) {
const result = await pb.collection('posts').getList(page, perPage, {
skipTotal: true
});
allPosts.push(...result.items);
if (result.items.length < perPage) {
break; // No more records
}
page++;
}
return allPosts;
}
// Or use getFullList with batch option
const allPosts = await pb.collection('posts').getFullList({
batch: 1000, // Records per request (default 1000 since JS SDK v0.26.6; max 1000)
sort: '-created'
});
```
**Choose the right approach:**
| Use Case | Approach |
|----------|----------|
| Standard list with page numbers | `getList()` with page/perPage |
| Large dataset, no total needed | `getList()` with `skipTotal: true` |
| Infinite scroll | Cursor-based with `skipTotal: true` |
| Export all data | `getFullList()` with batch size |
| First N records only | `getList(1, N, { skipTotal: true })` |
**Performance tips:**
- Use `skipTotal: true` unless you need page count
- Keep `perPage` reasonable (20-100 for UI, up to 1000 for batch exports)
- Index fields used in sort and filter
- Cursor pagination scales better than offset
Reference: [PocketBase Records API](https://pocketbase.io/docs/api-records/)

View File

@@ -0,0 +1,693 @@
# Realtime
**Impact: MEDIUM**
SSE subscriptions, event handling, connection management, and authentication with realtime.
---
## 1. Authenticate Realtime Connections
**Impact: MEDIUM (Secure subscriptions respecting API rules)**
Realtime subscriptions respect collection API rules. Ensure the connection is authenticated before subscribing to protected data.
**Incorrect (subscribing without auth context):**
```javascript
// Subscribing before authentication
const pb = new PocketBase('http://127.0.0.1:8090');
// This will fail or return no data if collection requires auth
pb.collection('private_messages').subscribe('*', (e) => {
// Won't receive events - not authenticated!
console.log(e.record);
});
// Later user logs in, but subscription doesn't update
await pb.collection('users').authWithPassword(email, password);
// Existing subscription still unauthenticated!
```
**Correct (authenticated subscriptions):**
```javascript
// Subscribe after authentication
const pb = new PocketBase('http://127.0.0.1:8090');
async function initRealtime() {
// First authenticate
await pb.collection('users').authWithPassword(email, password);
// Now subscribe - will use auth context
pb.collection('private_messages').subscribe('*', (e) => {
// Receives events for messages user can access
console.log('New message:', e.record);
});
}
// Re-subscribe after auth changes
function useAuthenticatedRealtime() {
const [messages, setMessages] = useState([]);
const unsubRef = useRef(null);
// Watch auth changes
useEffect(() => {
const removeListener = pb.authStore.onChange((token, record) => {
// Unsubscribe old connection
if (unsubRef.current) {
unsubRef.current();
unsubRef.current = null;
}
// Re-subscribe with new auth context if logged in
if (record) {
setupSubscription();
} else {
setMessages([]);
}
}, true);
return () => {
removeListener();
if (unsubRef.current) unsubRef.current();
};
}, []);
async function setupSubscription() {
unsubRef.current = await pb.collection('private_messages').subscribe('*', (e) => {
handleMessage(e);
});
}
}
// Handle auth token refresh with realtime
pb.realtime.subscribe('PB_CONNECT', async (e) => {
console.log('Realtime connected');
// Verify auth is still valid
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
// Redirect to login
}
}
});
```
**API rules apply to subscriptions:**
```javascript
// Collection rule: listRule: 'owner = @request.auth.id'
// User A subscribed
await pb.collection('users').authWithPassword('a@test.com', 'password');
pb.collection('notes').subscribe('*', handler);
// Only receives events for notes where owner = User A
// Events from other users' notes are filtered out automatically
```
**Subscription authorization flow:**
1. SSE connection established (no auth check)
2. First subscription triggers authorization
3. Auth token from `pb.authStore` is used
4. Collection rules evaluated for each event
5. Only matching events sent to client
**Handling auth expiration:**
```javascript
// Setup disconnect handler
pb.realtime.onDisconnect = (subscriptions) => {
console.log('Disconnected, had subscriptions:', subscriptions);
// Check if auth expired
if (!pb.authStore.isValid) {
// Token expired - need to re-authenticate
redirectToLogin();
return;
}
// Connection issue - realtime will auto-reconnect
// Re-subscribe after reconnection
pb.realtime.subscribe('PB_CONNECT', () => {
resubscribeAll(subscriptions);
});
};
function resubscribeAll(subscriptions) {
subscriptions.forEach(sub => {
const [collection, topic] = sub.split('/');
pb.collection(collection).subscribe(topic, handlers[sub]);
});
}
```
Reference: [PocketBase Realtime Auth](https://pocketbase.io/docs/api-realtime/)
## 2. Handle Realtime Events Properly
**Impact: MEDIUM (Consistent UI state, proper optimistic updates)**
Realtime events should update local state correctly, handle edge cases, and maintain UI consistency.
**Incorrect (naive event handling):**
```javascript
// Blindly appending creates - may add duplicates
pb.collection('posts').subscribe('*', (e) => {
if (e.action === 'create') {
posts.push(e.record); // Might already exist from optimistic update!
}
});
// Not handling own actions
pb.collection('posts').subscribe('*', (e) => {
// User creates post -> optimistic update
// Realtime event arrives -> duplicate!
setPosts(prev => [...prev, e.record]);
});
// Missing action types
pb.collection('posts').subscribe('*', (e) => {
if (e.action === 'create') handleCreate(e);
// Ignoring update and delete!
});
```
**Correct (robust event handling):**
```javascript
// Handle all action types with deduplication
function useRealtimePosts() {
const [posts, setPosts] = useState([]);
const pendingCreates = useRef(new Set());
useEffect(() => {
loadPosts();
const unsub = pb.collection('posts').subscribe('*', (e) => {
switch (e.action) {
case 'create':
// Skip if we created it (optimistic update already applied)
if (pendingCreates.current.has(e.record.id)) {
pendingCreates.current.delete(e.record.id);
return;
}
setPosts(prev => {
// Deduplicate - might already exist
if (prev.some(p => p.id === e.record.id)) return prev;
return [e.record, ...prev];
});
break;
case 'update':
setPosts(prev => prev.map(p =>
p.id === e.record.id ? e.record : p
));
break;
case 'delete':
setPosts(prev => prev.filter(p => p.id !== e.record.id));
break;
}
});
return unsub;
}, []);
async function createPost(data) {
// Optimistic update
const tempId = `temp_${Date.now()}`;
const optimisticPost = { ...data, id: tempId };
setPosts(prev => [optimisticPost, ...prev]);
try {
const created = await pb.collection('posts').create(data);
// Mark as pending so realtime event is ignored
pendingCreates.current.add(created.id);
// Replace optimistic with real
setPosts(prev => prev.map(p =>
p.id === tempId ? created : p
));
return created;
} catch (error) {
// Rollback optimistic update
setPosts(prev => prev.filter(p => p.id !== tempId));
throw error;
}
}
return { posts, createPost };
}
// Batched updates for high-frequency changes
function useRealtimeWithBatching() {
const [posts, setPosts] = useState([]);
const pendingUpdates = useRef([]);
const flushTimeout = useRef(null);
useEffect(() => {
const unsub = pb.collection('posts').subscribe('*', (e) => {
pendingUpdates.current.push(e);
// Batch updates every 100ms
if (!flushTimeout.current) {
flushTimeout.current = setTimeout(() => {
flushUpdates();
flushTimeout.current = null;
}, 100);
}
});
return () => {
unsub();
if (flushTimeout.current) clearTimeout(flushTimeout.current);
};
}, []);
function flushUpdates() {
const updates = pendingUpdates.current;
pendingUpdates.current = [];
setPosts(prev => {
let next = [...prev];
for (const e of updates) {
if (e.action === 'create') {
if (!next.some(p => p.id === e.record.id)) {
next.unshift(e.record);
}
} else if (e.action === 'update') {
next = next.map(p => p.id === e.record.id ? e.record : p);
} else if (e.action === 'delete') {
next = next.filter(p => p.id !== e.record.id);
}
}
return next;
});
}
}
```
**Filtering events:**
```javascript
// Only handle events matching certain criteria
pb.collection('posts').subscribe('*', (e) => {
// Only published posts
if (e.record.status !== 'published') return;
// Only posts by current user
if (e.record.author !== pb.authStore.record?.id) return;
handleEvent(e);
});
// Subscribe with expand to get related data
pb.collection('posts').subscribe('*', (e) => {
// Note: expand data is included in realtime events
// if the subscription options include expand
console.log(e.record.expand?.author?.name);
}, { expand: 'author' });
```
Reference: [PocketBase Realtime Events](https://pocketbase.io/docs/api-realtime/)
## 3. Handle Realtime Connection Issues
**Impact: MEDIUM (Reliable realtime even with network interruptions)**
Realtime connections can disconnect due to network issues or server restarts. Implement proper reconnection handling and state synchronization.
**Incorrect (ignoring connection issues):**
```javascript
// No reconnection handling - stale data after disconnect
pb.collection('posts').subscribe('*', (e) => {
updateUI(e.record);
});
// If connection drops, UI shows stale data indefinitely
// Assuming connection is always stable
function PostList() {
useEffect(() => {
pb.collection('posts').subscribe('*', handleChange);
}, []);
// No awareness of connection state
}
```
**Correct (robust connection handling):**
```javascript
// Monitor connection state
function useRealtimeConnection() {
const [connected, setConnected] = useState(false);
const [lastSync, setLastSync] = useState(null);
useEffect(() => {
// Track connection state
const unsubConnect = pb.realtime.subscribe('PB_CONNECT', (e) => {
console.log('Connected, client ID:', e.clientId);
setConnected(true);
// Re-sync data after reconnection
if (lastSync) {
syncMissedUpdates(lastSync);
}
setLastSync(new Date());
});
// Handle disconnection
pb.realtime.onDisconnect = (activeSubscriptions) => {
console.log('Disconnected');
setConnected(false);
showOfflineIndicator();
};
return () => {
unsubConnect();
};
}, [lastSync]);
return { connected };
}
// Sync missed updates after reconnection
async function syncMissedUpdates(since) {
// Fetch records modified since last sync
const updatedPosts = await pb.collection('posts').getList(1, 100, {
filter: pb.filter('updated > {:since}', { since }),
sort: '-updated'
});
// Merge with local state
updateLocalState(updatedPosts.items);
}
// Full implementation with resilience
class RealtimeManager {
constructor(pb) {
this.pb = pb;
this.subscriptions = new Map();
this.lastSyncTimes = new Map();
this.reconnectAttempts = 0;
this.maxReconnectDelay = 30000;
this.setupConnectionHandlers();
}
setupConnectionHandlers() {
this.pb.realtime.subscribe('PB_CONNECT', () => {
console.log('Realtime connected');
this.reconnectAttempts = 0;
this.onReconnect();
});
this.pb.realtime.onDisconnect = (subs) => {
console.log('Realtime disconnected');
this.scheduleReconnect();
};
}
scheduleReconnect() {
// Exponential backoff with jitter
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
this.maxReconnectDelay
);
this.reconnectAttempts++;
setTimeout(() => {
if (!this.pb.realtime.isConnected) {
this.resubscribeAll();
}
}, delay);
}
async onReconnect() {
// Sync data for each tracked collection
for (const [collection, lastSync] of this.lastSyncTimes) {
await this.syncCollection(collection, lastSync);
}
}
async syncCollection(collection, since) {
try {
const updates = await this.pb.collection(collection).getList(1, 1000, {
filter: this.pb.filter('updated > {:since}', { since }),
sort: 'updated'
});
// Notify subscribers of missed updates
const handler = this.subscriptions.get(collection);
if (handler) {
updates.items.forEach(record => {
handler({ action: 'update', record });
});
}
this.lastSyncTimes.set(collection, new Date());
} catch (error) {
console.error(`Failed to sync ${collection}:`, error);
}
}
async subscribe(collection, handler) {
this.subscriptions.set(collection, handler);
this.lastSyncTimes.set(collection, new Date());
return this.pb.collection(collection).subscribe('*', (e) => {
this.lastSyncTimes.set(collection, new Date());
handler(e);
});
}
async resubscribeAll() {
// Refresh auth token before resubscribing to ensure valid credentials
if (this.pb.authStore.isValid) {
try {
await this.pb.collection('users').authRefresh();
} catch {
this.pb.authStore.clear();
}
}
for (const [collection, handler] of this.subscriptions) {
this.pb.collection(collection).subscribe('*', handler);
}
}
}
// Usage
const realtime = new RealtimeManager(pb);
await realtime.subscribe('posts', handlePostChange);
```
**Connection timeout handling:**
```javascript
// Server sends disconnect after 5 min of no messages
// SDK auto-reconnects, but you can handle it explicitly
let lastHeartbeat = Date.now();
pb.realtime.subscribe('PB_CONNECT', () => {
lastHeartbeat = Date.now();
});
// Check for stale connection
setInterval(() => {
if (Date.now() - lastHeartbeat > 6 * 60 * 1000) {
console.log('Connection may be stale, refreshing...');
pb.realtime.unsubscribe();
resubscribeAll();
}
}, 60000);
```
Reference: [PocketBase Realtime](https://pocketbase.io/docs/api-realtime/)
## 4. Implement Realtime Subscriptions Correctly
**Impact: MEDIUM (Live updates without polling, reduced server load)**
PocketBase uses Server-Sent Events (SSE) for realtime updates. Proper subscription management prevents memory leaks and ensures reliable event delivery.
**Incorrect (memory leaks and poor management):**
```javascript
// Missing unsubscribe - memory leak!
function PostList() {
useEffect(() => {
pb.collection('posts').subscribe('*', (e) => {
updatePosts(e);
});
// No cleanup - subscription persists forever!
}, []);
}
// Subscribing multiple times
function loadPosts() {
// Called on every render - creates duplicate subscriptions!
pb.collection('posts').subscribe('*', handleChange);
}
// Not handling reconnection
pb.collection('posts').subscribe('*', (e) => {
// Assumes connection is always stable
updateUI(e);
});
```
**Correct (proper subscription management):**
```javascript
// React example with cleanup
function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
// Initial load
loadPosts();
// Subscribe to changes
const unsubscribe = pb.collection('posts').subscribe('*', (e) => {
if (e.action === 'create') {
setPosts(prev => [e.record, ...prev]);
} else if (e.action === 'update') {
setPosts(prev => prev.map(p =>
p.id === e.record.id ? e.record : p
));
} else if (e.action === 'delete') {
setPosts(prev => prev.filter(p => p.id !== e.record.id));
}
});
// Cleanup on unmount
return () => {
unsubscribe();
};
}, []);
async function loadPosts() {
const result = await pb.collection('posts').getList(1, 50);
setPosts(result.items);
}
return <PostListUI posts={posts} />;
}
// Subscribe to specific record
async function watchPost(postId) {
return pb.collection('posts').subscribe(postId, (e) => {
console.log('Post changed:', e.action, e.record);
});
}
// Subscribe to collection changes
async function watchAllPosts() {
return pb.collection('posts').subscribe('*', (e) => {
console.log(`Post ${e.action}:`, e.record.title);
});
}
// Handle connection events
pb.realtime.subscribe('PB_CONNECT', (e) => {
console.log('Realtime connected, client ID:', e.clientId);
// Re-sync data after reconnection
refreshData();
});
// Vanilla JS with proper cleanup
class PostManager {
unsubscribes = [];
async init() {
this.unsubscribes.push(
await pb.collection('posts').subscribe('*', this.handlePostChange)
);
this.unsubscribes.push(
await pb.collection('comments').subscribe('*', this.handleCommentChange)
);
}
destroy() {
this.unsubscribes.forEach(unsub => unsub());
this.unsubscribes = [];
}
handlePostChange = (e) => { /* ... */ };
handleCommentChange = (e) => { /* ... */ };
}
```
**Subscription event structure:**
```javascript
pb.collection('posts').subscribe('*', (event) => {
event.action; // 'create' | 'update' | 'delete'
event.record; // The affected record
});
// Full event type
interface RealtimeEvent {
action: 'create' | 'update' | 'delete';
record: RecordModel;
}
```
**Unsubscribe patterns:**
```javascript
// Unsubscribe from specific callback
const unsub = await pb.collection('posts').subscribe('*', callback);
unsub(); // Remove this specific subscription
// Unsubscribe from all subscriptions on a topic
pb.collection('posts').unsubscribe('*'); // All collection subs
pb.collection('posts').unsubscribe('RECORD_ID'); // Specific record
// Unsubscribe from all collection subscriptions
pb.collection('posts').unsubscribe();
// Unsubscribe from everything
pb.realtime.unsubscribe();
```
**Performance considerations:**
```javascript
// Prefer specific record subscriptions over collection-wide when possible.
// subscribe('*') checks ListRule for every connected client on each change.
// subscribe(recordId) checks ViewRule -- fewer records to evaluate.
// For high-traffic collections, subscribe to specific records:
await pb.collection('orders').subscribe(orderId, handleOrderUpdate);
// Instead of: pb.collection('orders').subscribe('*', handleAllOrders);
// Use subscription options to reduce payload size (SDK v0.21+):
await pb.collection('posts').subscribe('*', handleChange, {
fields: 'id,title,updated', // Only receive specific fields
expand: 'author', // Include expanded relations
filter: 'status = "published"' // Only receive matching records
});
```
**Subscription scope guidelines:**
| Scenario | Recommended Scope |
|----------|-------------------|
| Watching a specific document | `subscribe(recordId)` |
| Chat room messages | `subscribe('*')` with filter for room |
| User notifications | `subscribe('*')` with filter for user |
| Admin dashboard | `subscribe('*')` (need to see all) |
| High-frequency data (IoT) | `subscribe(recordId)` per device |
Reference: [PocketBase Realtime](https://pocketbase.io/docs/api-realtime/)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
# Section Definitions
This file defines the rule categories for PocketBase best practices. Rules are automatically assigned to sections based on their filename prefix.
---
## 1. Collection Design (coll)
**Impact:** CRITICAL
**Description:** Schema design, field types, relations, indexes, and collection type selection. Foundation for application architecture and long-term maintainability.
## 2. API Rules & Security (rules)
**Impact:** CRITICAL
**Description:** Access control rules, filter expressions, request context usage, and security patterns. Critical for protecting data and enforcing authorization.
## 3. Authentication (auth)
**Impact:** CRITICAL
**Description:** Password authentication, OAuth2 integration, token management, MFA setup, and auth collection configuration.
## 4. SDK Usage (sdk)
**Impact:** HIGH
**Description:** JavaScript SDK initialization, auth store patterns, error handling, request cancellation, and safe parameter binding.
## 5. Query Performance (query)
**Impact:** HIGH
**Description:** Pagination strategies, relation expansion, field selection, batch operations, and N+1 query prevention.
## 6. Realtime (realtime)
**Impact:** MEDIUM
**Description:** SSE subscriptions, event handling, connection management, and authentication with realtime.
## 7. File Handling (file)
**Impact:** MEDIUM
**Description:** File uploads, URL generation, thumbnail creation, and validation patterns.
## 8. Production & Deployment (deploy)
**Impact:** LOW-MEDIUM
**Description:** Backup strategies, configuration management, reverse proxy setup, and SQLite optimization.
## 9. Server-Side Extending (ext)
**Impact:** HIGH
**Description:** Extending PocketBase with Go or embedded JavaScript (JSVM) - event hooks, custom routes, transactions, cron jobs, filesystem, migrations, and safe server-side filter binding.

View File

@@ -0,0 +1,33 @@
---
title: Clear, Action-Oriented Title (e.g., "Use Cursor-Based Pagination for Large Lists")
impact: MEDIUM
impactDescription: Brief description of performance/security impact
tags: relevant, comma-separated, tags
---
## [Rule Title]
[1-2 sentence explanation of the problem and why it matters. Focus on impact.]
**Incorrect (describe the problem):**
```javascript
// Comment explaining what makes this problematic
const result = await pb.collection('posts').getList();
// Problem explanation
```
**Correct (describe the solution):**
```javascript
// Comment explaining why this is better
const result = await pb.collection('posts').getList(1, 20, {
filter: 'published = true',
sort: '-created'
});
// Benefit explanation
```
[Optional: Additional context, edge cases, or trade-offs]
Reference: [PocketBase Docs](https://pocketbase.io/docs/)

View File

@@ -0,0 +1,121 @@
---
title: Use Impersonation for Admin Operations
impact: MEDIUM
impactDescription: Safe admin access to user data without password sharing
tags: authentication, admin, impersonation, superuser
---
## Use Impersonation for Admin Operations
Impersonation allows superusers to generate tokens for other users, enabling admin support tasks and API key functionality without sharing passwords.
**Incorrect (sharing credentials or bypassing auth):**
```javascript
// Bad: sharing user passwords for support
async function helpUser(userId, userPassword) {
await pb.collection('users').authWithPassword(userEmail, userPassword);
// Support team knows user's password!
}
// Bad: directly modifying records without proper context
async function fixUserData(userId) {
// Bypasses user's perspective and rules
await pb.collection('posts').update(postId, { fixed: true });
}
```
**Correct (using impersonation):**
```javascript
import PocketBase from 'pocketbase';
// Admin client with superuser auth (use environment variables, never hardcode)
const adminPb = new PocketBase(process.env.PB_URL);
await adminPb.collection('_superusers').authWithPassword(
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD
);
async function impersonateUser(userId) {
// Generate impersonation token (non-renewable)
const impersonatedClient = await adminPb
.collection('users')
.impersonate(userId, 3600); // 1 hour duration
// impersonatedClient has user's auth context
console.log('Acting as:', impersonatedClient.authStore.record.email);
// Operations use user's permissions
const userPosts = await impersonatedClient.collection('posts').getList();
return impersonatedClient;
}
// Use case: Admin viewing user's data
async function adminViewUserPosts(userId) {
const userClient = await impersonateUser(userId);
// See exactly what the user sees (respects API rules)
const posts = await userClient.collection('posts').getList();
return posts;
}
// Use case: API keys for server-to-server communication
async function createApiKey(serviceUserId) {
// Create a service impersonation token (use short durations, rotate regularly)
const serviceClient = await adminPb
.collection('service_accounts')
.impersonate(serviceUserId, 86400); // 24 hours max, rotate via scheduled task
// Return token for service to use
return serviceClient.authStore.token;
}
// Using API key token in another service
async function useApiKey(apiToken) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Manually set the token
pb.authStore.save(apiToken, null);
// Now requests use the service account's permissions
const data = await pb.collection('data').getList();
return data;
}
```
**Important considerations:**
```javascript
// Impersonation tokens are non-renewable
const client = await adminPb.collection('users').impersonate(userId, 3600);
// This will fail - can't refresh impersonation tokens
try {
await client.collection('users').authRefresh();
} catch (error) {
// Expected: impersonation tokens can't be refreshed
}
// For continuous access, generate new token when needed
async function getImpersonatedClient(userId) {
// Check if existing token is still valid
if (cachedClient?.authStore.isValid) {
return cachedClient;
}
// Generate fresh token
return await adminPb.collection('users').impersonate(userId, 3600);
}
```
**Security best practices:**
- Use short durations for support tasks
- Log all impersonation events
- Restrict impersonation to specific admin roles
- Never expose impersonation capability in client code
- Use dedicated service accounts for API keys
Reference: [PocketBase Impersonation](https://pocketbase.io/docs/authentication/#impersonate-authentication)

View File

@@ -0,0 +1,135 @@
---
title: Implement Multi-Factor Authentication
impact: HIGH
impactDescription: Additional security layer for sensitive applications
tags: authentication, mfa, security, 2fa, otp
---
## Implement Multi-Factor Authentication
MFA requires users to authenticate with two different methods. PocketBase supports OTP (One-Time Password) via email as the second factor.
**Incorrect (single-factor only for sensitive apps):**
```javascript
// Insufficient for sensitive applications
async function login(email, password) {
const authData = await pb.collection('users').authWithPassword(email, password);
// User immediately has full access - no second factor
return authData;
}
```
**Correct (MFA flow with OTP):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function loginWithMFA(email, password) {
try {
// First factor: password
const authData = await pb.collection('users').authWithPassword(email, password);
// If MFA not required, auth succeeds immediately
return { success: true, authData };
} catch (error) {
// MFA required - returns 401 with mfaId
if (error.status === 401 && error.response?.mfaId) {
return {
success: false,
mfaRequired: true,
mfaId: error.response.mfaId
};
}
throw error;
}
}
async function requestOTP(email) {
// Request OTP to be sent via email
const result = await pb.collection('users').requestOTP(email);
// Returns otpId - needed to verify the OTP
// Note: Returns otpId even if email doesn't exist (prevents enumeration)
return result.otpId;
}
async function completeMFAWithOTP(mfaId, otpId, otpCode) {
try {
// Second factor: OTP verification
const authData = await pb.collection('users').authWithOTP(
otpId,
otpCode,
{ mfaId } // Include mfaId from first factor
);
return { success: true, authData };
} catch (error) {
if (error.status === 400) {
throw new Error('Invalid or expired code');
}
throw error;
}
}
// Complete flow example
async function fullMFAFlow(email, password, otpCode = null) {
// Step 1: Password authentication
const step1 = await loginWithMFA(email, password);
if (step1.success) {
return step1.authData; // MFA not required
}
if (step1.mfaRequired) {
// Step 2: Request OTP
const otpId = await requestOTP(email);
// Step 3: UI prompts user for OTP code...
// (In real app, wait for user input)
if (otpCode) {
// Step 4: Complete MFA
const step2 = await completeMFAWithOTP(step1.mfaId, otpId, otpCode);
return step2.authData;
}
return { pendingMFA: true, mfaId: step1.mfaId, otpId };
}
}
```
**Configure MFA (Admin UI or API):**
```javascript
// Enable MFA on auth collection (superuser only)
await pb.collections.update('users', {
mfa: {
enabled: true,
duration: 1800, // MFA session duration (30 min)
rule: '' // When to require MFA (empty = always for all users)
// rule: '@request.auth.role = "admin"' // Only for admins
},
otp: {
enabled: true,
duration: 300, // OTP validity (5 min)
length: 6, // OTP code length
emailTemplate: {
subject: 'Your verification code',
body: 'Your code is: {OTP}'
}
}
});
```
**MFA best practices:**
- Always enable for admin accounts
- Consider making MFA optional for regular users
- Use short OTP durations (5-10 minutes)
- Implement rate limiting on OTP requests
- Log MFA events for security auditing
Reference: [PocketBase MFA](https://pocketbase.io/docs/authentication/#mfa)

View File

@@ -0,0 +1,141 @@
---
title: Integrate OAuth2 Providers Correctly
impact: CRITICAL
impactDescription: Secure third-party authentication with proper flow handling
tags: authentication, oauth2, google, github, social-login
---
## Integrate OAuth2 Providers Correctly
OAuth2 integration should use the all-in-one method for simplicity and security. Manual code exchange should only be used when necessary (e.g., mobile apps with deep links).
**Incorrect (manual implementation without SDK):**
```javascript
// Don't manually handle OAuth flow
async function loginWithGoogle() {
// Redirect user to Google manually
window.location.href = 'https://accounts.google.com/oauth/authorize?...';
}
// Manual callback handling
async function handleCallback(code) {
// Exchange code manually - error prone!
const response = await fetch('/api/auth/callback', {
method: 'POST',
body: JSON.stringify({ code })
});
}
```
**Correct (using SDK's all-in-one method):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// All-in-one OAuth2 (recommended for web apps)
async function loginWithOAuth2(providerName) {
try {
const authData = await pb.collection('users').authWithOAuth2({
provider: providerName, // 'google', 'github', 'microsoft', etc.
// Optional: create new user data if not exists
createData: {
emailVisibility: true,
name: '' // Will be populated from OAuth provider
}
});
console.log('Logged in via', providerName);
console.log('User:', authData.record.email);
console.log('Is new user:', authData.meta?.isNew);
return authData;
} catch (error) {
if (error.isAbort) {
console.log('OAuth popup was closed');
return null;
}
throw error;
}
}
// Usage
document.getElementById('google-btn').onclick = () => loginWithOAuth2('google');
document.getElementById('github-btn').onclick = () => loginWithOAuth2('github');
```
**Manual code exchange (for React Native / deep links):**
```javascript
// Only use when all-in-one isn't possible
async function loginWithOAuth2Manual() {
// Get auth methods - PocketBase provides state and codeVerifier
const authMethods = await pb.collection('users').listAuthMethods();
const provider = authMethods.oauth2.providers.find(p => p.name === 'google');
// Store the provider's state and codeVerifier for callback verification
// PocketBase generates these for you - don't create your own
sessionStorage.setItem('oauth_state', provider.state);
sessionStorage.setItem('oauth_code_verifier', provider.codeVerifier);
// Build the OAuth URL using provider.authURL + redirect
const redirectUrl = window.location.origin + '/oauth-callback';
const authUrl = provider.authURL + encodeURIComponent(redirectUrl);
// Redirect to OAuth provider
window.location.href = authUrl;
}
// In your callback handler (e.g., /oauth-callback page):
async function handleOAuth2Callback() {
const params = new URLSearchParams(window.location.search);
// CSRF protection: verify state matches
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - potential CSRF attack');
}
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
const redirectUrl = window.location.origin + '/oauth-callback';
// Exchange code for auth token
const authData = await pb.collection('users').authWithOAuth2Code(
'google',
code,
codeVerifier,
redirectUrl,
{ emailVisibility: true }
);
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_code_verifier');
return authData;
}
```
**Configure OAuth2 provider (Admin UI or API):**
```javascript
// Via API (superuser only) - usually done in Admin UI
// IMPORTANT: Never hardcode client secrets. Use environment variables.
await pb.collections.update('users', {
oauth2: {
enabled: true,
providers: [{
name: 'google',
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET
}],
mappedFields: {
avatarURL: 'avatar' // Map OAuth field to collection field
}
}
});
```
Reference: [PocketBase OAuth2](https://pocketbase.io/docs/authentication/#oauth2-authentication)

View File

@@ -0,0 +1,68 @@
---
title: Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
impact: HIGH
impactDescription: OTP endpoints are unauthenticated; unthrottled requestOTP enables email bombing and enumeration
tags: auth, otp, one-time-password, rate-limiting, enumeration
---
## Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
Auth collections can enable **OTP login** from the admin UI (Collection → Options → "Enable OTP"). The client flow is two steps: `requestOTP(email)` returns an `otpId`, then `authWithOTP(otpId, code)` exchanges the id + code for an auth token. Two things trip people up: (1) the OTP response is **the same whether the email exists or not** - do not break that by leaking a distinct error; (2) `requestOTP` sends an email, so **it must be rate-limited** or an attacker can use it to spam any address.
**Incorrect (leaks existence, custom requestOTP with no rate limit):**
```javascript
// ❌ Client-side existence check - ignore the 404 and expose it to the user
try {
await pb.collection("users").getFirstListItem(`email="${email}"`);
} catch (e) {
alert("No account with that email"); // ❌ account enumeration
return;
}
// ❌ Ad-hoc route with no rate limit - attacker hammers this to spam mailboxes
routerAdd("POST", "/api/myapp/otp", (e) => {
const body = e.requestInfo().body;
const user = $app.findAuthRecordByEmail("users", body.email);
// send custom email...
return e.json(200, { ok: true });
});
```
**Correct (use the built-in flow, step 1 always returns an otpId):**
```javascript
// Step 1: request the code. Always returns { otpId } - even if the email
// does not exist, PocketBase returns a synthetic id so enumeration is
// impossible. Treat every response as success from the UI perspective.
const { otpId } = await pb.collection("users").requestOTP("user@example.com");
// Step 2: exchange otpId + the 8-digit code the user typed
const authData = await pb.collection("users").authWithOTP(
otpId,
"12345678",
);
// pb.authStore is now populated
```
```go
// Go side - rate-limit and log if you wrap your own endpoint
app.OnRecordRequestOTPRequest("users").BindFunc(func(e *core.RecordRequestOTPRequestEvent) error {
// e.Collection, e.Record (may be nil - synthetic id path),
// e.Email (always present), e.Password (unused for OTP)
e.App.Logger().Info("otp requested",
"email", e.Email,
"ip", e.RequestInfo.Headers["x_forwarded_for"])
return e.Next() // REQUIRED
})
```
**Rules:**
- `requestOTP` **always returns 200 with an otpId**, even for non-existent emails - preserve that by never adding a pre-check or a different error path.
- Enable the built-in rate limiter (see `deploy-rate-limiting.md`) and raise the cost for the `*:requestOTP` label. Without this, an attacker can email-bomb arbitrary users.
- The OTP code is 8 digits by default, with a 3-minute TTL. Do not extend the TTL - short windows are the whole point.
- `authWithOTP` consumes the code; a successful call invalidates the `otpId`. Always show a generic "invalid or expired code" on failure.
- If you want OTP **without a password**, set the collection's `Password` option to off and `OTP` on. If both are enabled, users can use either.
- OTP emails are sent via the configured SMTP server. In dev, point SMTP at Mailpit or a console logger before testing - do **not** ship with the default "no-reply@example.com" sender.
Reference: [Auth with OTP](https://pocketbase.io/docs/authentication/#auth-with-otp) · [JS SDK - authWithOTP](https://github.com/pocketbase/js-sdk#authwithotp)

View File

@@ -0,0 +1,104 @@
---
title: Implement Secure Password Authentication
impact: CRITICAL
impactDescription: Secure user login with proper error handling and token management
tags: authentication, password, login, security
---
## Implement Secure Password Authentication
Password authentication should include proper error handling, avoid exposing whether emails exist, and correctly manage the auth store.
**Incorrect (exposing information and poor error handling):**
```javascript
// Dangerous: exposes whether email exists
async function login(email, password) {
const user = await pb.collection('users').getFirstListItem(`email = "${email}"`);
if (!user) {
throw new Error('Email not found'); // Reveals email doesn't exist
}
// Manual password check - never do this!
if (user.password !== password) {
throw new Error('Wrong password'); // Reveals password is wrong
}
return user;
}
```
**Correct (secure authentication):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function login(email, password) {
try {
// authWithPassword handles hashing and returns token
const authData = await pb.collection('users').authWithPassword(email, password);
// Token is automatically stored in pb.authStore
console.log('Logged in as:', authData.record.email);
console.log('Token valid:', pb.authStore.isValid);
return authData;
} catch (error) {
// Generic error message - don't reveal if email exists
if (error.status === 400) {
throw new Error('Invalid email or password');
}
throw error;
}
}
// Check if user is authenticated
function isAuthenticated() {
return pb.authStore.isValid;
}
// Get current user
function getCurrentUser() {
return pb.authStore.record;
}
// Logout
function logout() {
pb.authStore.clear();
}
// Listen for auth changes
pb.authStore.onChange((token, record) => {
console.log('Auth state changed:', record?.email || 'logged out');
}, true); // true = fire immediately with current state
```
**Auth collection configuration for password auth:**
```javascript
// When creating auth collection via API (superuser only)
await pb.collections.create({
name: 'users',
type: 'auth',
fields: [
{ name: 'name', type: 'text' },
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
],
passwordAuth: {
enabled: true,
identityFields: ['email', 'username'] // Fields that can be used to login
},
// Require minimum password length
// (configured in Admin UI under collection options)
});
```
**Security considerations:**
- Never store passwords in plain text
- Use generic error messages
- Implement rate limiting on your server
- Consider adding MFA for sensitive applications
Reference: [PocketBase Auth](https://pocketbase.io/docs/authentication/)

View File

@@ -0,0 +1,142 @@
---
title: Manage Auth Tokens Properly
impact: CRITICAL
impactDescription: Prevents unauthorized access, handles token expiration gracefully
tags: authentication, tokens, refresh, security, session
---
## Manage Auth Tokens Properly
Auth tokens should be refreshed before expiration, validated on critical operations, and properly cleared on logout. The SDK's authStore handles most of this automatically.
**Incorrect (ignoring token expiration):**
```javascript
// Bad: never checking token validity
async function fetchUserData() {
// Token might be expired!
const records = await pb.collection('posts').getList();
return records;
}
// Bad: manually managing tokens
let authToken = localStorage.getItem('token');
fetch('/api/posts', {
headers: { 'Authorization': authToken } // Token might be invalid
});
```
**Correct (proper token management):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Check token validity before operations
async function fetchSecureData() {
// authStore.isValid is a client-side check only (JWT expiry parsing).
// Always verify server-side with authRefresh() for critical operations.
if (!pb.authStore.isValid) {
throw new Error('Please log in');
}
return pb.collection('posts').getList();
}
// Refresh token periodically or before expiration
async function refreshAuthIfNeeded() {
if (!pb.authStore.isValid) {
return false;
}
try {
// Verifies current token and returns fresh one
await pb.collection('users').authRefresh();
console.log('Token refreshed');
return true;
} catch (error) {
// Token invalid - user needs to re-authenticate
pb.authStore.clear();
return false;
}
}
// Auto-refresh on app initialization
async function initializeAuth() {
if (pb.authStore.token) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
}
// Listen for auth changes and handle expiration
pb.authStore.onChange((token, record) => {
if (!token) {
// User logged out or token cleared
redirectToLogin();
}
});
// Setup periodic refresh (e.g., every 10 minutes)
setInterval(async () => {
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
}, 10 * 60 * 1000);
```
**SSR / Server-side token handling:**
```javascript
// Server-side: create fresh client per request
export async function handleRequest(request) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Load auth from cookie
pb.authStore.loadFromCookie(request.headers.get('cookie') || '');
// Validate and refresh
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
// ... handle request ...
// Send updated cookie with secure options
const response = new Response();
response.headers.set('set-cookie', pb.authStore.exportToCookie({
httpOnly: true, // Prevent XSS access to auth token
secure: true, // HTTPS only
sameSite: 'Lax', // CSRF protection
}));
return response;
}
```
**Token configuration (Admin UI or migration):**
```javascript
// Configure token durations (superuser only)
await pb.collections.update('users', {
authToken: {
duration: 1209600 // 14 days in seconds
},
verificationToken: {
duration: 604800 // 7 days
}
});
```
Reference: [PocketBase Auth Store](https://pocketbase.io/docs/authentication/)

View File

@@ -0,0 +1,67 @@
---
title: Use Auth Collections for User Accounts
impact: CRITICAL
impactDescription: Built-in authentication, password hashing, OAuth2 support
tags: collections, auth, users, authentication, design
---
## Use Auth Collections for User Accounts
Auth collections provide built-in authentication features including secure password hashing, email verification, OAuth2 support, and token management. Using base collections for users requires reimplementing these security-critical features.
**Incorrect (using base collection for users):**
```javascript
// Base collection loses all auth features
const usersCollection = {
name: 'users',
type: 'base', // Wrong! No auth capabilities
schema: [
{ name: 'email', type: 'email' },
{ name: 'password', type: 'text' }, // Stored in plain text!
{ name: 'name', type: 'text' }
]
};
// Manual login implementation - insecure
const user = await pb.collection('users').getFirstListItem(
`email = "${email}" && password = "${password}"` // SQL injection risk!
);
```
**Correct (using auth collection):**
```javascript
// Auth collection with built-in security
const usersCollection = {
name: 'users',
type: 'auth', // Enables authentication features
schema: [
{ name: 'name', type: 'text' },
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
],
options: {
allowEmailAuth: true,
allowOAuth2Auth: true,
requireEmail: true,
minPasswordLength: 8
}
};
// Secure authentication with password hashing
const authData = await pb.collection('users').authWithPassword(
'user@example.com',
'securePassword123'
);
// Token automatically stored in authStore
// NOTE: Never log tokens in production - shown here for illustration only
console.log('Authenticated as:', pb.authStore.record.id);
```
**When to use each type:**
- **Auth collection**: User accounts, admin accounts, any entity that needs to log in
- **Base collection**: Regular data like posts, products, orders, comments
- **View collection**: Read-only aggregations or complex queries
Reference: [PocketBase Auth Collections](https://pocketbase.io/docs/collections/#auth-collection)

View File

@@ -0,0 +1,67 @@
---
title: Choose Appropriate Field Types for Your Data
impact: CRITICAL
impactDescription: Prevents data corruption, improves query performance, reduces storage
tags: collections, schema, field-types, design
---
## Choose Appropriate Field Types for Your Data
Selecting the wrong field type leads to data validation issues, wasted storage, and poor query performance. PocketBase provides specialized field types that enforce constraints at the database level.
**Incorrect (using text for everything):**
```javascript
// Using plain text fields for structured data
const collection = {
name: 'products',
schema: [
{ name: 'price', type: 'text' }, // Should be number
{ name: 'email', type: 'text' }, // Should be email
{ name: 'website', type: 'text' }, // Should be url
{ name: 'active', type: 'text' }, // Should be bool
{ name: 'tags', type: 'text' }, // Should be select or json
{ name: 'created', type: 'text' } // Should be autodate
]
};
// No validation, inconsistent data, manual parsing required
```
**Correct (using appropriate field types):**
```javascript
// Using specialized field types with proper validation
const collection = {
name: 'products',
type: 'base',
schema: [
{ name: 'price', type: 'number', options: { min: 0 } },
{ name: 'email', type: 'email' },
{ name: 'website', type: 'url' },
{ name: 'active', type: 'bool' },
{ name: 'tags', type: 'select', options: {
maxSelect: 5,
values: ['electronics', 'clothing', 'food', 'other']
}},
{ name: 'metadata', type: 'json' }
// created/updated are automatic system fields
]
};
// Built-in validation, proper indexing, type-safe queries
```
**Available field types:**
- `text` - Plain text with optional min/max length, regex pattern
- `number` - Integer or decimal with optional min/max
- `bool` - True/false values
- `email` - Email with format validation
- `url` - URL with format validation
- `date` - Date/datetime values
- `autodate` - Auto-set on create/update
- `select` - Single or multi-select from predefined values
- `json` - Arbitrary JSON data
- `file` - File attachments
- `relation` - References to other collections
- `editor` - Rich text HTML content
Reference: [PocketBase Collections](https://pocketbase.io/docs/collections/)

View File

@@ -0,0 +1,122 @@
---
title: Use GeoPoint Fields for Location Data
impact: MEDIUM
impactDescription: Built-in geographic queries, distance calculations
tags: collections, geopoint, location, geographic, maps
---
## Use GeoPoint Fields for Location Data
PocketBase provides a dedicated GeoPoint field type for storing geographic coordinates with built-in distance query support via `geoDistance()`.
**Incorrect (storing coordinates as separate fields):**
```javascript
// Separate lat/lon fields - no built-in distance queries
const placesSchema = [
{ name: 'name', type: 'text' },
{ name: 'latitude', type: 'number' },
{ name: 'longitude', type: 'number' }
];
// Manual distance calculation - complex and slow
async function findNearby(lat, lon, maxKm) {
const places = await pb.collection('places').getFullList();
// Calculate distance for every record client-side
return places.filter(place => {
const dist = haversine(lat, lon, place.latitude, place.longitude);
return dist <= maxKm;
});
}
```
**Correct (using GeoPoint field):**
```javascript
// GeoPoint field stores coordinates as { lon, lat } object
const placesSchema = [
{ name: 'name', type: 'text' },
{ name: 'location', type: 'geopoint' }
];
// Creating a record with GeoPoint
await pb.collection('places').create({
name: 'Coffee Shop',
location: { lon: -73.9857, lat: 40.7484 } // Note: lon first!
});
// Or using "lon,lat" string format
await pb.collection('places').create({
name: 'Restaurant',
location: '-73.9857,40.7484' // String format also works
});
// Query nearby locations using geoDistance()
async function findNearby(lon, lat, maxKm) {
// geoDistance returns distance in kilometers
const places = await pb.collection('places').getList(1, 50, {
filter: pb.filter(
'geoDistance(location, {:point}) <= {:maxKm}',
{
point: { lon, lat },
maxKm: maxKm
}
),
sort: pb.filter('geoDistance(location, {:point})', { point: { lon, lat } })
});
return places;
}
// Find places within 5km of Times Square
const nearbyPlaces = await findNearby(-73.9857, 40.7580, 5);
// Use in API rules for location-based access
// listRule: geoDistance(location, @request.query.point) <= 10
```
**geoDistance() function:**
```javascript
// Syntax: geoDistance(geopointField, referencePoint)
// Returns: distance in kilometers
// In filter expressions
filter: 'geoDistance(location, "-73.9857,40.7484") <= 5'
// With parameter binding (recommended)
filter: pb.filter('geoDistance(location, {:center}) <= {:radius}', {
center: { lon: -73.9857, lat: 40.7484 },
radius: 5
})
// Sorting by distance
sort: 'geoDistance(location, "-73.9857,40.7484")' // Closest first
sort: '-geoDistance(location, "-73.9857,40.7484")' // Farthest first
```
**GeoPoint data format:**
```javascript
// Object format (recommended)
{ lon: -73.9857, lat: 40.7484 }
// String format
"-73.9857,40.7484" // "lon,lat" order
// Important: longitude comes FIRST (GeoJSON convention)
```
**Use cases:**
- Store-locator / find nearby
- Delivery radius validation
- Geofencing in API rules
- Location-based search results
**Limitations:**
- Spherical Earth calculation (accurate to ~0.3%)
- No polygon/area containment queries
- Single point per field (use multiple fields for routes)
Reference: [PocketBase GeoPoint](https://pocketbase.io/docs/collections/#geopoint)

View File

@@ -0,0 +1,74 @@
---
title: Create Indexes for Frequently Filtered Fields
impact: CRITICAL
impactDescription: 10-100x faster queries on large collections
tags: collections, indexes, performance, query-optimization
---
## Create Indexes for Frequently Filtered Fields
PocketBase uses SQLite which benefits significantly from proper indexing. Queries filtering or sorting on unindexed fields perform full table scans.
**Incorrect (no indexes on filtered fields):**
```javascript
// Querying without indexes
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'author = "user123" && status = "published"',
sort: '-publishedAt'
});
// Full table scan on large collections - very slow
// API rules also query without indexes
// listRule: "author = @request.auth.id"
// Every list request scans entire table
```
**Correct (indexed fields):**
```javascript
// Create collection with indexes via Admin UI or migration
// In PocketBase Admin: Collection > Indexes > Add Index
// Common index patterns:
// 1. Single field index for equality filters
// CREATE INDEX idx_posts_author ON posts(author)
// 2. Composite index for multiple filters
// CREATE INDEX idx_posts_author_status ON posts(author, status)
// 3. Index with sort field
// CREATE INDEX idx_posts_status_published ON posts(status, publishedAt DESC)
// Queries now use indexes
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'author = "user123" && status = "published"',
sort: '-publishedAt'
});
// Index scan - fast even with millions of records
// For unique constraints (e.g., slug)
// CREATE UNIQUE INDEX idx_posts_slug ON posts(slug)
```
**Index recommendations:**
- Fields used in `filter` expressions
- Fields used in `sort` parameters
- Fields used in API rules (`listRule`, `viewRule`, etc.)
- Relation fields (automatically indexed)
- Unique fields like slugs or codes
**Index considerations for SQLite:**
- Composite indexes work left-to-right (order matters)
- Too many indexes slow down writes
- Use `EXPLAIN QUERY PLAN` in SQL to verify index usage
- Partial indexes for filtered subsets
```sql
-- Check if index is used
EXPLAIN QUERY PLAN
SELECT * FROM posts WHERE author = 'user123' AND status = 'published';
-- Should show "USING INDEX" not "SCAN"
```
Reference: [SQLite Query Planning](https://www.sqlite.org/queryplanner.html)

View File

@@ -0,0 +1,98 @@
---
title: Configure Relations with Proper Cascade Options
impact: CRITICAL
impactDescription: Maintains referential integrity, prevents orphaned records, controls deletion behavior
tags: collections, relations, foreign-keys, cascade, design
---
## Configure Relations with Proper Cascade Options
Relation fields connect collections together. Proper cascade configuration ensures data integrity when referenced records are deleted.
**Incorrect (default cascade behavior not considered):**
```javascript
// Relation without considering deletion behavior
const ordersSchema = [
{ name: 'customer', type: 'relation', options: {
collectionId: 'customers_collection_id',
maxSelect: 1
// No cascade options specified - defaults may cause issues
}},
{ name: 'products', type: 'relation', options: {
collectionId: 'products_collection_id'
// Multiple products, no cascade handling
}}
];
// Deleting a customer may fail or orphan orders
await pb.collection('customers').delete(customerId);
// Error: record is referenced by other records
```
**Correct (explicit cascade configuration):**
```javascript
// Carefully configured relations
const ordersSchema = [
{
name: 'customer',
type: 'relation',
required: true,
options: {
collectionId: 'customers_collection_id',
maxSelect: 1,
cascadeDelete: false // Prevent accidental mass deletion
}
},
{
name: 'products',
type: 'relation',
options: {
collectionId: 'products_collection_id',
maxSelect: 99,
cascadeDelete: false
}
}
];
// For dependent data like comments - cascade delete makes sense
const commentsSchema = [
{
name: 'post',
type: 'relation',
options: {
collectionId: 'posts_collection_id',
maxSelect: 1,
cascadeDelete: true // Delete comments when post is deleted
}
}
];
// NOTE: For audit logs, avoid cascadeDelete - logs should be retained
// for compliance/forensics even after the referenced user is deleted.
// Use cascadeDelete: false and handle user deletion separately.
// Handle deletion manually when cascade is false
try {
await pb.collection('customers').delete(customerId);
} catch (e) {
if (e.status === 400) {
// Customer has orders - handle appropriately
// Option 1: Soft delete (set 'deleted' flag)
// Option 2: Reassign orders
// Option 3: Delete orders first
}
}
```
**Cascade options:**
- `cascadeDelete: true` - Delete referencing records when referenced record is deleted
- `cascadeDelete: false` - Block deletion if references exist (default for required relations)
**Best practices:**
- Use `cascadeDelete: true` for dependent data (comments on posts, logs for users)
- Use `cascadeDelete: false` for important data (orders, transactions)
- Consider soft deletes for audit trails
- Document your cascade strategy
Reference: [PocketBase Relations](https://pocketbase.io/docs/collections/#relation)

View File

@@ -0,0 +1,68 @@
---
title: Use View Collections for Complex Read-Only Queries
impact: HIGH
impactDescription: Simplifies complex queries, improves maintainability, enables aggregations
tags: collections, views, sql, aggregation, design
---
## Use View Collections for Complex Read-Only Queries
View collections execute custom SQL queries and expose results through the standard API. They're ideal for aggregations, joins, and computed fields without duplicating logic across your application.
**Incorrect (computing aggregations client-side):**
```javascript
// Fetching all records to compute stats client-side
const orders = await pb.collection('orders').getFullList();
const products = await pb.collection('products').getFullList();
// Expensive client-side computation
const stats = orders.reduce((acc, order) => {
const product = products.find(p => p.id === order.product);
acc.totalRevenue += order.quantity * product.price;
acc.orderCount++;
return acc;
}, { totalRevenue: 0, orderCount: 0 });
// Fetches all data, slow, memory-intensive
```
**Correct (using view collection):**
```javascript
// Create a view collection in PocketBase Admin UI or via API
// View SQL:
// SELECT
// p.id,
// p.name,
// COUNT(o.id) as order_count,
// SUM(o.quantity) as total_sold,
// SUM(o.quantity * p.price) as revenue
// FROM products p
// LEFT JOIN orders o ON o.product = p.id
// GROUP BY p.id
// Simple, efficient query
const productStats = await pb.collection('product_stats').getList(1, 20, {
sort: '-revenue'
});
// Each record has computed fields
productStats.items.forEach(stat => {
console.log(`${stat.name}: ${stat.order_count} orders, $${stat.revenue}`);
});
```
**View collection use cases:**
- Aggregations (COUNT, SUM, AVG)
- Joining data from multiple collections
- Computed/derived fields
- Denormalized read models
- Dashboard statistics
**Limitations:**
- Read-only (no create/update/delete)
- Must return `id` column
- No realtime subscriptions
- API rules still apply for access control
Reference: [PocketBase View Collections](https://pocketbase.io/docs/collections/#view-collection)

View File

@@ -0,0 +1,142 @@
---
title: Implement Proper Backup Strategies
impact: LOW-MEDIUM
impactDescription: Prevents data loss, enables disaster recovery
tags: production, backup, disaster-recovery, data-protection
---
## Implement Proper Backup Strategies
Regular backups are essential for production deployments. PocketBase provides built-in backup functionality and supports external S3 storage.
**Incorrect (no backup strategy):**
```javascript
// No backups at all - disaster waiting to happen
// Just running: ./pocketbase serve
// Manual file copy while server running - can corrupt data
// cp pb_data/data.db backup/
// Only backing up database, missing files
// sqlite3 pb_data/data.db ".backup backup.db"
```
**Correct (comprehensive backup strategy):**
```javascript
// 1. Using PocketBase Admin API for backups
const adminPb = new PocketBase('http://127.0.0.1:8090');
await adminPb.collection('_superusers').authWithPassword(admin, password);
// Create backup (includes database and files)
async function createBackup(name = '') {
const backup = await adminPb.backups.create(name);
console.log('Backup created:', backup.key);
return backup;
}
// List available backups
async function listBackups() {
const backups = await adminPb.backups.getFullList();
backups.forEach(b => {
console.log(`${b.key} - ${b.size} bytes - ${b.modified}`);
});
return backups;
}
// Download backup
async function downloadBackup(key) {
const token = await adminPb.files.getToken();
const url = adminPb.backups.getDownloadURL(token, key);
// url can be used to download the backup file
return url;
}
// Restore from backup (CAUTION: overwrites current data!)
async function restoreBackup(key) {
await adminPb.backups.restore(key);
console.log('Restore initiated - server will restart');
}
// Delete old backups
async function cleanupOldBackups(keepCount = 7) {
const backups = await adminPb.backups.getFullList();
// Sort by date, keep newest
const sorted = backups.sort((a, b) =>
new Date(b.modified) - new Date(a.modified)
);
const toDelete = sorted.slice(keepCount);
for (const backup of toDelete) {
await adminPb.backups.delete(backup.key);
console.log('Deleted old backup:', backup.key);
}
}
```
**Automated backup script (cron job):**
```bash
#!/bin/bash
# backup.sh - Run daily via cron
POCKETBASE_URL="http://127.0.0.1:8090"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="your-secure-password"
BACKUP_DIR="/path/to/backups"
KEEP_DAYS=7
# Create timestamp
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Create backup via API
curl -X POST "${POCKETBASE_URL}/api/backups" \
-H "Authorization: $(curl -s -X POST "${POCKETBASE_URL}/api/collections/_superusers/auth-with-password" \
-d "identity=${ADMIN_EMAIL}&password=${ADMIN_PASSWORD}" | jq -r '.token')" \
-d "name=backup_${TIMESTAMP}"
# Clean old local backups
find "${BACKUP_DIR}" -name "*.zip" -mtime +${KEEP_DAYS} -delete
echo "Backup completed: backup_${TIMESTAMP}"
```
**Configure S3 for backup storage:**
```javascript
// In Admin UI: Settings > Backups > S3
// Or via API:
await adminPb.settings.update({
backups: {
s3: {
enabled: true,
bucket: 'my-pocketbase-backups',
region: 'us-east-1',
endpoint: 's3.amazonaws.com',
accessKey: process.env.AWS_ACCESS_KEY,
secret: process.env.AWS_SECRET_KEY
}
}
});
```
**Backup best practices:**
| Aspect | Recommendation |
|--------|---------------|
| Frequency | Daily minimum, hourly for critical apps |
| Retention | 7-30 days of daily backups |
| Storage | Off-site (S3, separate server) |
| Testing | Monthly restore tests |
| Monitoring | Alert on backup failures |
**Pre-backup checklist:**
- [ ] S3 or external storage configured
- [ ] Automated schedule set up
- [ ] Retention policy defined
- [ ] Restore procedure documented
- [ ] Restore tested successfully
Reference: [PocketBase Backups](https://pocketbase.io/docs/going-to-production/#backups)

View File

@@ -0,0 +1,169 @@
---
title: Configure Production Settings Properly
impact: LOW-MEDIUM
impactDescription: Secure and optimized production environment
tags: production, configuration, security, environment
---
## Configure Production Settings Properly
Production deployments require proper configuration of URLs, secrets, SMTP, and security settings.
**Incorrect (development defaults in production):**
```bash
# Running with defaults - insecure!
./pocketbase serve
# Hardcoded secrets
./pocketbase serve --encryptionEnv="mySecretKey123"
# Wrong origin for CORS
# Leaving http://localhost:8090 as allowed origin
```
**Correct (production configuration):**
```bash
# Production startup with essential flags
./pocketbase serve \
--http="0.0.0.0:8090" \
--origins="https://myapp.com,https://www.myapp.com" \
--encryptionEnv="PB_ENCRYPTION_KEY"
# Using environment variables
export PB_ENCRYPTION_KEY="your-32-char-encryption-key-here"
export SMTP_HOST="smtp.sendgrid.net"
export SMTP_PORT="587"
export SMTP_USER="apikey"
export SMTP_PASS="your-sendgrid-api-key"
./pocketbase serve --http="0.0.0.0:8090"
```
**Configure SMTP for emails:**
```javascript
// Via Admin UI or API
await adminPb.settings.update({
smtp: {
enabled: true,
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
username: process.env.SMTP_USER,
password: process.env.SMTP_PASS,
tls: true
},
meta: {
appName: 'My App',
appURL: 'https://myapp.com',
senderName: 'My App',
senderAddress: 'noreply@myapp.com'
}
});
// Test email configuration
await adminPb.settings.testEmail('users', 'test@example.com', 'verification');
```
**Configure S3 for file storage:**
```javascript
// Move file storage to S3 for scalability
await adminPb.settings.update({
s3: {
enabled: true,
bucket: 'my-app-files',
region: 'us-east-1',
endpoint: 's3.amazonaws.com',
accessKey: process.env.AWS_ACCESS_KEY,
secret: process.env.AWS_SECRET_KEY,
forcePathStyle: false
}
});
// Test S3 connection
await adminPb.settings.testS3('storage');
```
**Systemd service file:**
```ini
# /etc/systemd/system/pocketbase.service
[Unit]
Description=PocketBase
After=network.target
[Service]
Type=simple
User=pocketbase
Group=pocketbase
LimitNOFILE=4096
Restart=always
RestartSec=5s
WorkingDirectory=/opt/pocketbase
ExecStart=/opt/pocketbase/pocketbase serve --http="127.0.0.1:8090"
# Environment variables
EnvironmentFile=/opt/pocketbase/.env
# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/pocketbase/pb_data
[Install]
WantedBy=multi-user.target
```
**Environment file (.env):**
```bash
# /opt/pocketbase/.env
# SECURITY: Set restrictive permissions: chmod 600 /opt/pocketbase/.env
# SECURITY: Add to .gitignore - NEVER commit this file to version control
# For production, consider a secrets manager (Vault, AWS Secrets Manager, etc.)
PB_ENCRYPTION_KEY= # Generate with: openssl rand -hex 16
# SMTP
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS= # Set your SMTP password here
# S3 (optional)
AWS_ACCESS_KEY= # Set your AWS access key
AWS_SECRET_KEY= # Set your AWS secret key
# OAuth (optional)
GOOGLE_CLIENT_ID= # Set your Google client ID
GOOGLE_CLIENT_SECRET= # Set your Google client secret
```
**Protect your environment file:**
```bash
# Set restrictive permissions (owner read/write only)
chmod 600 /opt/pocketbase/.env
chown pocketbase:pocketbase /opt/pocketbase/.env
# Ensure .env is in .gitignore
echo ".env" >> .gitignore
```
**Production checklist:**
- [ ] HTTPS enabled (via reverse proxy)
- [ ] Strong encryption key set
- [ ] CORS origins configured
- [ ] SMTP configured and tested
- [ ] Superuser password changed
- [ ] S3 configured (for scalability)
- [ ] Backup schedule configured
- [ ] Rate limiting enabled (via reverse proxy)
- [ ] Logging configured
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)

View File

@@ -0,0 +1,175 @@
---
title: Enable Rate Limiting for API Protection
impact: MEDIUM
impactDescription: Prevents abuse, brute-force attacks, and DoS
tags: production, security, rate-limiting, abuse-prevention
---
## Enable Rate Limiting for API Protection
PocketBase v0.23+ includes built-in rate limiting. Enable it to protect against brute-force attacks, API abuse, and excessive resource consumption.
> **v0.36.7 behavioral change:** the built-in limiter switched from a sliding-window to a **fixed-window** strategy. This is cheaper and more predictable, but it means a client can send `2 * maxRequests` in quick succession if they straddle the window boundary. Size your limits with that worst case in mind, and put Nginx/Caddy rate limiting in front of PocketBase for defense in depth (see examples below).
**Incorrect (no rate limiting):**
```bash
# Running without rate limiting
./pocketbase serve
# Vulnerable to:
# - Brute-force password attacks
# - API abuse and scraping
# - DoS from excessive requests
# - Account enumeration attempts
```
**Correct (enable rate limiting):**
```bash
# Enable via command line flag
./pocketbase serve --rateLimiter=true
# Or configure specific limits (requests per second per IP)
./pocketbase serve --rateLimiter=true --rateLimiterRPS=10
```
**Configure via Admin Dashboard:**
Navigate to Settings > Rate Limiter:
- **Enable rate limiter**: Toggle on
- **Max requests/second**: Default 10, adjust based on needs
- **Exempt endpoints**: Optionally whitelist certain paths
**Configure programmatically (Go/JS hooks):**
```javascript
// In pb_hooks/rate_limit.pb.js
routerAdd("GET", "/api/public/*", (e) => {
// Custom rate limit for specific endpoints
}, $apis.rateLimit(100, "10s")); // 100 requests per 10 seconds
// Stricter limit for auth endpoints
routerAdd("POST", "/api/collections/users/auth-*", (e) => {
// Auth endpoints need stricter limits
}, $apis.rateLimit(5, "1m")); // 5 attempts per minute
```
**Rate limiting with reverse proxy (additional layer):**
```nginx
# Nginx rate limiting (defense in depth)
http {
# Define rate limit zones
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
server {
# General API rate limit
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://pocketbase;
}
# Strict limit for auth endpoints
location /api/collections/users/auth {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://pocketbase;
}
# Stricter limit for superuser auth
location /api/collections/_superusers/auth {
limit_req zone=auth burst=3 nodelay;
proxy_pass http://pocketbase;
}
}
}
```
```caddyfile
# Caddy with rate limiting plugin
myapp.com {
rate_limit {
zone api {
key {remote_host}
events 100
window 10s
}
zone auth {
key {remote_host}
events 5
window 1m
}
}
@auth path /api/collections/*/auth*
handle @auth {
rate_limit { zone auth }
reverse_proxy 127.0.0.1:8090
}
handle {
rate_limit { zone api }
reverse_proxy 127.0.0.1:8090
}
}
```
**Handle rate limit errors in client:**
```javascript
async function makeRequest(fn, retries = 0, maxRetries = 3) {
try {
return await fn();
} catch (error) {
if (error.status === 429 && retries < maxRetries) {
// Rate limited - wait and retry with limit
const retryAfter = error.response?.retryAfter || 60;
console.log(`Rate limited. Retry ${retries + 1}/${maxRetries} after ${retryAfter}s`);
// Show user-friendly message
showMessage('Too many requests. Please wait a moment.');
await sleep(retryAfter * 1000);
return makeRequest(fn, retries + 1, maxRetries);
}
throw error;
}
}
// Usage
const result = await makeRequest(() =>
pb.collection('posts').getList(1, 20)
);
```
**Recommended limits by endpoint type:**
| Endpoint Type | Suggested Limit | Reason |
|--------------|-----------------|--------|
| Auth endpoints | 5-10/min | Prevent brute-force |
| Password reset | 3/hour | Prevent enumeration |
| Record creation | 30/min | Prevent spam |
| General API | 60-100/min | Normal usage |
| Public read | 100-200/min | Higher for reads |
| File uploads | 10/min | Resource-intensive |
**Monitoring rate limit hits:**
```javascript
// Check PocketBase logs for rate limit events
// Or set up alerting in your monitoring system
// Client-side tracking
pb.afterSend = function(response, data) {
if (response.status === 429) {
trackEvent('rate_limit_hit', {
endpoint: response.url,
timestamp: new Date()
});
}
return data;
};
```
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)

View File

@@ -0,0 +1,200 @@
---
title: Configure Reverse Proxy Correctly
impact: LOW-MEDIUM
impactDescription: HTTPS, caching, rate limiting, and security headers
tags: production, nginx, caddy, https, proxy
---
## Configure Reverse Proxy Correctly
Use a reverse proxy (Nginx, Caddy) for HTTPS termination, caching, rate limiting, and security headers.
**Incorrect (exposing PocketBase directly):**
```bash
# Direct exposure - no HTTPS, no rate limiting
./pocketbase serve --http="0.0.0.0:8090"
# Port forwarding without proxy
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8090
# Still no HTTPS!
```
**Correct (Caddy - simplest option):**
```caddyfile
# /etc/caddy/Caddyfile
myapp.com {
# Automatic HTTPS via Let's Encrypt
reverse_proxy 127.0.0.1:8090 {
# Required for SSE/Realtime
flush_interval -1
}
# Security headers
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Restrict admin UI to internal/VPN networks
# @admin path /_/*
# handle @admin {
# @blocked not remote_ip 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
# respond @blocked 403
# reverse_proxy 127.0.0.1:8090
# }
# Rate limiting (requires caddy-ratelimit plugin)
# Install: xcaddy build --with github.com/mholt/caddy-ratelimit
# Without this plugin, use PocketBase's built-in rate limiter (--rateLimiter=true)
# rate_limit {
# zone api {
# key {remote_host}
# events 100
# window 1m
# }
# }
}
```
**Correct (Nginx configuration):**
```nginx
# /etc/nginx/sites-available/pocketbase
# Rate limit zones must be defined in http context (e.g., /etc/nginx/nginx.conf)
# limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
upstream pocketbase {
server 127.0.0.1:8090;
keepalive 64;
}
server {
listen 80;
server_name myapp.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name myapp.com;
# SSL configuration
ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Note: X-XSS-Protection is deprecated and can introduce vulnerabilities.
# Use Content-Security-Policy instead.
location / {
proxy_pass http://pocketbase;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE/Realtime support
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
# Timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Rate limit API endpoints
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://pocketbase;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off;
}
# Static file caching
location /api/files/ {
proxy_pass http://pocketbase;
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_types text/plain application/json application/javascript text/css;
gzip_min_length 1000;
}
```
**Docker Compose with Caddy:**
```yaml
# docker-compose.yml
version: '3.8'
services:
pocketbase:
# NOTE: This is a third-party community image, not officially maintained by PocketBase.
# For production, consider building your own image from the official PocketBase binary.
# See: https://pocketbase.io/docs/going-to-production/
image: ghcr.io/muchobien/pocketbase:latest
restart: unless-stopped
volumes:
- ./pb_data:/pb_data
environment:
- PB_ENCRYPTION_KEY=${PB_ENCRYPTION_KEY}
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- pocketbase
volumes:
caddy_data:
caddy_config:
```
**Key configuration points:**
| Feature | Why It Matters |
|---------|---------------|
| HTTPS | Encrypts traffic, required for auth |
| SSE support | `proxy_buffering off` for realtime |
| Rate limiting | Prevents abuse |
| Security headers | XSS/clickjacking protection |
| Keepalive | Connection reuse, better performance |
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)

View File

@@ -0,0 +1,102 @@
---
title: Tune OS and Runtime for PocketBase Scale
impact: MEDIUM
impactDescription: Prevents file descriptor exhaustion, OOM kills, and exposes secure config for production deployments
tags: production, scaling, ulimit, gomemlimit, docker, encryption, deployment
---
## Tune OS and Runtime for PocketBase Scale
Three low-effort OS/runtime knobs have outsized impact on production stability: open-file limits for realtime connections, Go memory limits for constrained hosts, and settings encryption for shared or externally-backed infrastructure. None of these are set automatically.
**Incorrect (default OS limits, no memory governor, plain-text settings):**
```bash
# Start without raising the file descriptor limit
/root/pb/pocketbase serve yourdomain.com
# → "Too many open files" once concurrent realtime connections exceed ~1024
# Start in a container that has a 512 MB RAM cap without GOMEMLIMIT
docker run -m 512m pocketbase serve ...
# → OOM kill during large file upload because Go GC doesn't respect cgroup limits
# Store SMTP password and S3 secret as plain JSON in pb_data/data.db
pocketbase serve # no --encryptionEnv
# → Anyone who obtains the database backup can read all credentials
```
**Correct:**
```bash
# 1. Raise the open-file limit before starting (Linux/macOS)
# Check current limit first:
ulimit -a | grep "open files"
# Temporarily raise to 4096 for the current session:
ulimit -n 4096
/root/pb/pocketbase serve yourdomain.com
# Or persist it via systemd (recommended for production):
# /lib/systemd/system/pocketbase.service
# [Service]
# LimitNOFILE = 4096
# ...
# 2. Cap Go's soft memory target on memory-constrained hosts
# (instructs the GC to be more aggressive before the kernel OOM-kills the process)
GOMEMLIMIT=512MiB /root/pb/pocketbase serve yourdomain.com
# 3. Encrypt application settings at rest
# Generate a random 32-character key once:
export PB_ENCRYPTION_KEY="z76NX9WWiB05UmQGxw367B6zM39T11fF"
# Start with the env-var name (not the value) as the flag argument:
pocketbase serve --encryptionEnv=PB_ENCRYPTION_KEY
```
**Docker deployment pattern (v0.36.8):**
```dockerfile
FROM alpine:latest
ARG PB_VERSION=0.36.8
RUN apk add --no-cache unzip ca-certificates
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
# Uncomment to bundle pre-written migrations or hooks:
# COPY ./pb_migrations /pb/pb_migrations
# COPY ./pb_hooks /pb/pb_hooks
EXPOSE 8080
# Mount a volume at /pb/pb_data to persist data across container restarts
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]
```
```yaml
# docker-compose.yml
services:
pocketbase:
build: .
ports:
- "8080:8080"
volumes:
- pb_data:/pb/pb_data
environment:
GOMEMLIMIT: "512MiB"
PB_ENCRYPTION_KEY: "${PB_ENCRYPTION_KEY}"
command: ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080", "--encryptionEnv=PB_ENCRYPTION_KEY"]
volumes:
pb_data:
```
**Quick-reference checklist:**
| Concern | Fix |
|---------|-----|
| `Too many open files` errors | `ulimit -n 4096` (or `LimitNOFILE=4096` in systemd) |
| OOM kill on constrained host | `GOMEMLIMIT=512MiB` env var |
| Credentials visible in DB backup | `--encryptionEnv=YOUR_VAR` with a 32-char random key |
| Persistent data in Docker | Mount volume at `/pb/pb_data` |
Reference: [Going to production](https://pocketbase.io/docs/going-to-production/)

View File

@@ -0,0 +1,202 @@
---
title: Optimize SQLite for Production
impact: LOW-MEDIUM
impactDescription: Better performance and reliability for SQLite database
tags: production, sqlite, database, performance
---
## Optimize SQLite for Production
PocketBase uses SQLite with optimized defaults. Understanding its characteristics helps optimize performance and avoid common pitfalls. PocketBase uses two separate databases: `data.db` (application data) and `auxiliary.db` (logs and ephemeral data), which reduces write contention.
**Incorrect (ignoring SQLite characteristics):**
```javascript
// Heavy concurrent writes - SQLite bottleneck
async function bulkInsert(items) {
// Parallel writes cause lock contention
await Promise.all(items.map(item =>
pb.collection('items').create(item)
));
}
// Not using transactions for batch operations
async function updateMany(items) {
for (const item of items) {
await pb.collection('items').update(item.id, item);
}
// Each write is a separate transaction - slow!
}
// Large text fields without consideration
const schema = [{
name: 'content',
type: 'text' // Could be megabytes - affects all queries
}];
```
**Correct (SQLite-optimized patterns):**
```javascript
// Use batch operations for multiple writes
async function bulkInsert(items) {
const batch = pb.createBatch();
items.forEach(item => {
batch.collection('items').create(item);
});
await batch.send(); // Single transaction, much faster
}
// Batch updates
async function updateMany(items) {
const batch = pb.createBatch();
items.forEach(item => {
batch.collection('items').update(item.id, item);
});
await batch.send();
}
// For very large batches, chunk them
async function bulkInsertLarge(items, chunkSize = 100) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const batch = pb.createBatch();
chunk.forEach(item => batch.collection('items').create(item));
await batch.send();
}
}
```
**Schema considerations:**
```javascript
// Separate large content into dedicated collection
const postsSchema = [
{ name: 'title', type: 'text' },
{ name: 'summary', type: 'text', options: { maxLength: 500 } },
{ name: 'author', type: 'relation' }
// Content in separate collection
];
const postContentsSchema = [
{ name: 'post', type: 'relation', required: true },
{ name: 'content', type: 'editor' } // Large HTML content
];
// Fetch content only when needed
async function getPostList() {
return pb.collection('posts').getList(1, 20); // Fast, no content
}
async function getPostWithContent(id) {
const post = await pb.collection('posts').getOne(id);
const content = await pb.collection('post_contents').getFirstListItem(
pb.filter('post = {:id}', { id })
);
return { ...post, content: content.content };
}
```
**PocketBase default PRAGMA settings:**
PocketBase already configures optimal SQLite settings. You do not need to set these manually unless using a custom SQLite driver:
```sql
PRAGMA busy_timeout = 10000; -- Wait 10s for locks instead of failing immediately
PRAGMA journal_mode = WAL; -- Write-Ahead Logging: concurrent reads during writes
PRAGMA journal_size_limit = 200000000; -- Limit WAL file to ~200MB
PRAGMA synchronous = NORMAL; -- Balanced durability/performance (safe with WAL)
PRAGMA foreign_keys = ON; -- Enforce relation integrity
PRAGMA temp_store = MEMORY; -- Temp tables in memory (faster sorts/joins)
PRAGMA cache_size = -32000; -- 32MB page cache
```
WAL mode is the most impactful setting -- it allows multiple concurrent readers while a single writer is active, which is critical for PocketBase's concurrent API request handling.
**Index optimization:**
```sql
-- Create indexes for commonly filtered/sorted fields
CREATE INDEX idx_posts_author ON posts(author);
CREATE INDEX idx_posts_created ON posts(created DESC);
CREATE INDEX idx_posts_status_created ON posts(status, created DESC);
-- Verify indexes are being used
EXPLAIN QUERY PLAN
SELECT * FROM posts WHERE author = 'xxx' ORDER BY created DESC;
-- Should show: "USING INDEX idx_posts_author"
```
**SQLite limitations and workarounds:**
| Limitation | Workaround |
|------------|------------|
| Single writer | Use batch operations, queue writes |
| No full-text by default | Use view collections with FTS5 |
| File-based | SSD storage, avoid network mounts |
| Memory for large queries | Pagination, limit result sizes |
**Performance monitoring:**
```javascript
// Monitor slow queries via hooks (requires custom PocketBase build)
// Or use SQLite's built-in profiling
// From sqlite3 CLI:
// .timer on
// SELECT * FROM posts WHERE author = 'xxx';
// Run Time: real 0.003 user 0.002 sys 0.001
// Check database size
// ls -lh pb_data/data.db
// Vacuum to reclaim space after deletes
// sqlite3 pb_data/data.db "VACUUM;"
```
**When to consider alternatives:**
Consider migrating from single PocketBase if:
- Write throughput consistently > 1000/sec needed
- Database size > 100GB
- Complex transactions across tables
- Multi-region deployment required
**Custom SQLite driver (advanced):**
PocketBase supports custom SQLite drivers via `DBConnect`. The CGO driver (`mattn/go-sqlite3`) can offer better performance for some workloads and enables extensions like ICU and FTS5. This requires a custom PocketBase build:
```go
// main.go (custom PocketBase build with CGO driver)
package main
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
)
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
// Called twice: once for data.db, once for auxiliary.db
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("sqlite3", dbPath)
},
})
if err := app.Start(); err != nil {
panic(err)
}
}
// Build with: CGO_ENABLED=1 go build
```
Note: CGO requires C compiler toolchain and cannot be cross-compiled as easily as pure Go.
**Scaling options:**
1. **Read replicas**: Litestream for SQLite replication
2. **Sharding**: Multiple PocketBase instances by tenant/feature
3. **Caching**: Redis/Memcached for read-heavy loads
4. **Alternative backend**: If requirements exceed SQLite, evaluate PostgreSQL-based frameworks
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)

View File

@@ -0,0 +1,198 @@
---
title: Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
impact: HIGH
impactDescription: Individual rules are atomic; this composite example shows which app instance applies at each layer and how errors propagate
tags: extending, composition, transactions, hooks, enrich, routing, mental-model
---
## Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
The atomic rules (`ext-hooks-chain`, `ext-transactions`, `ext-routing-custom`, `ext-hooks-record-vs-request`, `ext-filesystem`, `ext-filter-binding-server`) each teach a single trap. Real extending code touches **all of them in the same handler**. This rule walks through one complete request flow and annotates **which app instance is active at each layer** - the single most common source of extending bugs is reaching for the wrong one.
### The flow
`POST /api/myapp/posts` that: authenticates the caller, validates uniqueness with a bound filter, creates a record inside a transaction, uploads a thumbnail through a scoped filesystem, writes an audit log from an `OnRecordAfterCreateSuccess` hook, and shapes the response (including the realtime broadcast) in `OnRecordEnrich`.
```
HTTP request
[group middleware] apis.RequireAuth("users") ◄── e.Auth is set after this
[route handler] se.App.RunInTransaction(func(txApp) {
│ // ⚠️ inside the block, use ONLY txApp, never se.App or outer `app`
│ FindFirstRecordByFilter(txApp, ...) // bound {:slug}
│ txApp.Save(post) // fires OnRecord*Create / *Request
│ │
│ ▼
│ [OnRecordAfterCreateSuccess hook] ◄── e.App IS txApp here
│ │ (hook fires inside the tx)
│ e.App.Save(auditRecord) → participates in rollback
│ e.Next() → REQUIRED
│ │
│ ▼
│ return to route handler
│ fs := txApp.NewFilesystem()
│ defer fs.Close()
│ post.Set("thumb", file); txApp.Save(post)
│ return nil // commit
│ })
[enrich pass] OnRecordEnrich fires ◄── RUNS AFTER the tx committed
│ (also fires for realtime SSE and list responses)
│ e.App is the outer app; tx is already closed
[response serialization] e.JSON(...)
```
### The code
```go
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
g := se.Router.Group("/api/myapp")
g.Bind(apis.RequireAuth("users"))
g.POST("/posts", func(e *core.RequestEvent) error {
// ── Layer 1: route handler ────────────────────────────────────────
// e.App is the top-level app. e.Auth is populated by RequireAuth.
// e.RequestInfo holds headers/body/query.
body := struct {
Slug string `json:"slug"`
Title string `json:"title"`
}{}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("invalid body", err)
}
var created *core.Record
// ── Layer 2: transaction ──────────────────────────────────────────
txErr := e.App.RunInTransaction(func(txApp core.App) error {
// ⚠️ From here until the closure returns, every DB call MUST go
// through txApp. Capturing e.App or the outer `app` deadlocks
// on the writer lock.
// Bound filter - see ext-filter-binding-server
existing, _ := txApp.FindFirstRecordByFilter(
"posts",
"slug = {:slug}",
dbx.Params{"slug": body.Slug},
)
if existing != nil {
return apis.NewBadRequestError("slug already taken", nil)
}
col, err := txApp.FindCollectionByNameOrId("posts")
if err != nil {
return err
}
post := core.NewRecord(col)
post.Set("slug", body.Slug)
post.Set("title", body.Title)
post.Set("author", e.Auth.Id)
// txApp.Save fires record hooks INSIDE the tx
if err := txApp.Save(post); err != nil {
return err
}
// ── Layer 3: filesystem (scoped to this request) ─────────────
fs, err := txApp.NewFilesystem()
if err != nil {
return err
}
defer fs.Close() // REQUIRED - see ext-filesystem
if uploaded, ok := e.RequestInfo.Body["thumb"].(*filesystem.File); ok {
post.Set("thumb", uploaded)
if err := txApp.Save(post); err != nil {
return err
}
}
created = post
return nil // commit
})
if txErr != nil {
return txErr // framework maps it to a proper HTTP error
}
// ── Layer 5: response (enrich runs automatically) ────────────────
// e.App is the OUTER app again here - the tx has committed.
// OnRecordEnrich will fire during JSON serialization and for any
// realtime subscribers receiving the "create" event.
return e.JSON(http.StatusOK, created)
})
return se.Next()
})
// ── Layer 4: hooks ──────────────────────────────────────────────────────
// These are registered once at startup, NOT inside the route handler.
app.OnRecordAfterCreateSuccess("posts").Bind(&hook.Handler[*core.RecordEvent]{
Id: "audit-post-create",
Func: func(e *core.RecordEvent) error {
// ⚠️ e.App here is txApp when the parent Save happened inside a tx.
// Always use e.App - never a captured outer `app` - so that the
// audit record participates in the same transaction (and the
// same rollback) as the parent Save.
col, err := e.App.FindCollectionByNameOrId("audit")
if err != nil {
return err
}
audit := core.NewRecord(col)
audit.Set("action", "post.create")
audit.Set("record", e.Record.Id)
audit.Set("actor", e.Record.GetString("author"))
if err := e.App.Save(audit); err != nil {
return err // rolls back the whole request
}
return e.Next() // REQUIRED - see ext-hooks-chain
},
})
app.OnRecordEnrich("posts").BindFunc(func(e *core.RecordEnrichEvent) error {
// Runs for:
// - GET /api/collections/posts/records (list)
// - GET /api/collections/posts/records/{id} (view)
// - realtime SSE create/update broadcasts
// - any apis.EnrichRecord call in a custom route
// Does NOT run inside a transaction; e.App is the outer app.
e.Record.Hide("internalNotes")
if e.RequestInfo != nil && e.RequestInfo.Auth != nil {
e.Record.WithCustomData(true)
e.Record.Set("isMine", e.Record.GetString("author") == e.RequestInfo.Auth.Id)
}
return e.Next()
})
```
### The cheat sheet: "which app am I holding?"
| Where you are | Use | Why |
|---|---|---|
| Top of a route handler (`func(e *core.RequestEvent)`) | `e.App` | Framework's top-level app; same object the server started with |
| Inside `RunInTransaction(func(txApp) { ... })` | `txApp` **only** | Capturing the outer app deadlocks on the SQLite writer lock |
| Inside a record hook fired from a `Save` inside a tx | `e.App` | The framework has already rebound `e.App` to `txApp` for you |
| Inside a record hook fired from a non-tx `Save` | `e.App` | Same identifier, same rules, just points to the top-level app |
| Inside `OnRecordEnrich` | `e.App` | Runs during response serialization, **after** the tx has committed |
| Inside a `app.Cron()` callback | captured `app` / `se.App` | Cron has no per-run scoped app; wrap in `RunInTransaction` if you need atomicity |
| Inside a migration function | the `app` argument | `m.Register(func(app core.App) error { ... })` - already transactional |
### Error propagation in the chain
- `return err` inside `RunInTransaction`**rolls back everything**, including any audit records written by hooks that fired from nested `Save` calls.
- `return err` from a hook handler → propagates back through the `Save` call → propagates out of the tx closure → rolls back.
- **Not** calling `e.Next()` in a hook → the chain is broken **silently**. The framework's own post-save work (realtime broadcast, enrich pass, activity log) is skipped but no error is reported.
- A panic inside the tx closure is recovered by PocketBase, the tx rolls back, and the panic is converted to a 500 response.
- A panic inside a cron callback is recovered and logged - it does **not** take down the process.
### When NOT to compose this much
This example is realistic but also the ceiling of what should live in a single handler. If you find yourself stacking six concerns in one route, consider splitting the logic into a service function that takes `txApp` as a parameter and is called by the route. The same function is then reusable from cron jobs, migrations, and tests.
Reference: cross-references `ext-hooks-chain.md`, `ext-transactions.md`, `ext-routing-custom.md`, `ext-hooks-record-vs-request.md`, `ext-filesystem.md`, `ext-filter-binding-server.md`.

View File

@@ -0,0 +1,126 @@
---
title: Schedule Recurring Jobs with the Builtin Cron Scheduler
impact: MEDIUM
impactDescription: Avoids external schedulers and correctly integrates background tasks with the PocketBase lifecycle
tags: cron, scheduling, jobs, go, jsvm, extending
---
## Schedule Recurring Jobs with the Builtin Cron Scheduler
PocketBase includes a cron scheduler that starts automatically with `serve`. Register jobs before calling `app.Start()` (Go) or at the top level of a `pb_hooks` file (JSVM). Each job runs in its own goroutine and receives a standard cron expression.
**Incorrect (external timer, blocking hook, replacing system jobs):**
```go
// ❌ Using a raw Go timer instead of the app cron misses lifecycle management
go func() {
for range time.Tick(2 * time.Minute) {
log.Println("cleanup")
}
}()
// ❌ Blocking inside a hook instead of scheduling
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
for {
time.Sleep(2 * time.Minute)
log.Println("cleanup") // ❌ blocks the hook and never returns se.Next()
}
})
// ❌ Removing all cron jobs wipes PocketBase's own log-cleanup and auto-backup jobs
app.Cron().RemoveAll()
```
```javascript
// ❌ JSVM: using setTimeout not supported in the embedded goja engine
setTimeout(() => console.log("run"), 120_000); // ReferenceError
```
**Correct Go:**
```go
package main
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
// Register before app.Start() so the scheduler knows about the job at launch.
// MustAdd panics on an invalid cron expression (use Add if you prefer an error return).
app.Cron().MustAdd("cleanup-drafts", "0 3 * * *", func() {
// Runs every day at 03:00 UTC in its own goroutine.
// Use app directly here (not e.App) because this is not inside a hook.
records, err := app.FindAllRecords("posts",
core.FilterData("status = 'draft' && created < {:cutoff}"),
)
if err != nil {
app.Logger().Error("cron cleanup-drafts", "err", err)
return
}
for _, r := range records {
if err := app.Delete(r); err != nil {
app.Logger().Error("cron delete", "id", r.Id, "err", err)
}
}
})
// Remove a job by ID (e.g. during a feature flag toggle)
// app.Cron().Remove("cleanup-drafts")
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
**Correct JSVM:**
```javascript
// pb_hooks/crons.pb.js
/// <reference path="../pb_data/types.d.ts" />
// Top-level cronAdd() registers the job at hook-load time.
// The handler runs in its own goroutine and has access to $app.
cronAdd("notify-unpublished", "*/30 * * * *", () => {
// Runs every 30 minutes
const records = $app.findAllRecords("posts",
$dbx.hashExp({ status: "draft" })
);
console.log(`Found ${records.length} unpublished posts`);
});
// Remove a registered job by ID (useful in tests or feature toggles)
// cronRemove("notify-unpublished");
```
**Cron expression reference:**
```
┌─── minute (0 - 59)
│ ┌── hour (0 - 23)
│ │ ┌─ day-of-month (1 - 31)
│ │ │ ┌ month (1 - 12)
│ │ │ │ ┌ day-of-week (0 - 6, Sunday = 0)
│ │ │ │ │
* * * * *
Examples:
*/2 * * * * every 2 minutes
0 3 * * * daily at 03:00
0 0 * * 0 weekly on Sunday midnight
@hourly macro equivalent to 0 * * * *
```
**Key rules:**
- System jobs use the `__pb*__` ID prefix (e.g. `__pbLogsCleanup__`). Never call `RemoveAll()` or use that prefix for your own jobs.
- All registered cron jobs are visible and can be manually triggered from _Dashboard > Settings > Crons_.
- JSVM handlers have access to `$app` but **not** to outer-scope variables (see JSVM scope rule).
- Go jobs can use `app` directly (not `e.App`) because they run outside the hook/transaction context.
Reference: [Go Jobs scheduling](https://pocketbase.io/docs/go-jobs-scheduling/) | [JS Jobs scheduling](https://pocketbase.io/docs/js-jobs-scheduling/)

View File

@@ -0,0 +1,93 @@
---
title: Always Close the Filesystem Handle Returned by NewFilesystem
impact: HIGH
impactDescription: Leaked filesystem clients keep S3 connections and file descriptors open until the process exits
tags: filesystem, extending, files, s3, NewFilesystem, close
---
## Always Close the Filesystem Handle Returned by NewFilesystem
`app.NewFilesystem()` (Go) and `$app.newFilesystem()` (JS) return a filesystem client backed by either the local disk or S3, depending on the app settings. **The caller owns the handle** and must close it - there is no finalizer and no automatic pooling. Leaking handles leaks TCP connections to S3 and file descriptors on disk, and eventually the server will stop accepting uploads.
PocketBase also ships a second client: `app.NewBackupsFilesystem()` for the backups bucket/directory, with the same ownership rules.
**Incorrect (no close, raw bytes buffered in memory):**
```go
// ❌ Forgets to close fs - connection leaks
func downloadAvatar(app core.App, key string) ([]byte, error) {
fs, err := app.NewFilesystem()
if err != nil {
return nil, err
}
// ❌ no defer fs.Close()
// ❌ GetFile loads the whole file into a reader; reading it all into a
// byte slice defeats streaming for large files
r, err := fs.GetFile(key)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
```
**Correct (defer Close, stream to the HTTP response):**
```go
func serveAvatar(app core.App, key string) echo.HandlerFunc {
return func(e *core.RequestEvent) error {
fs, err := app.NewFilesystem()
if err != nil {
return e.InternalServerError("filesystem init failed", err)
}
defer fs.Close() // REQUIRED
// Serve directly from the filesystem - handles ranges, content-type,
// and the X-Accel-Redirect / X-Sendfile headers when available
return fs.Serve(e.Response, e.Request, key, "avatar.jpg")
}
}
// Uploading a local file to the PocketBase-managed filesystem
func importAvatar(app core.App, record *core.Record, path string) error {
f, err := filesystem.NewFileFromPath(path)
if err != nil {
return err
}
record.Set("avatar", f) // assignment + app.Save() persist it
return app.Save(record)
}
```
```javascript
// JSVM - file factories live on the $filesystem global
const file1 = $filesystem.fileFromPath("/tmp/import.jpg");
const file2 = $filesystem.fileFromBytes(new Uint8Array([0xff, 0xd8]), "logo.jpg");
const file3 = $filesystem.fileFromURL("https://example.com/a.jpg");
// Assigning to a record field triggers upload on save
record.set("avatar", file1);
$app.save(record);
// Low-level client - MUST be closed
const fs = $app.newFilesystem();
try {
const list = fs.list("thumbs/");
for (const obj of list) {
console.log(obj.key, obj.size);
}
} finally {
fs.close(); // REQUIRED
}
```
**Rules:**
- `defer fs.Close()` **immediately** after a successful `NewFilesystem()` / `NewBackupsFilesystem()` call (Go). In JS, wrap in `try { ... } finally { fs.close() }`.
- Prefer the high-level record-field API (`record.Set("field", file)` + `app.Save`) over direct `fs.Upload` calls - it handles thumbs regeneration, orphan cleanup, and hook integration.
- File factory functions (`filesystem.NewFileFromPath`, `NewFileFromBytes`, `NewFileFromURL` / JS `$filesystem.fileFromPath|Bytes|URL`) capture their input; they do not stream until save.
- `fileFromURL` performs an HTTP GET and loads the body into memory - not appropriate for large files.
- Do not share a single long-lived `fs` across unrelated requests; the object is cheap to create per request.
Reference: [Go Filesystem](https://pocketbase.io/docs/go-filesystem/) · [JS Filesystem](https://pocketbase.io/docs/js-filesystem/)

View File

@@ -0,0 +1,81 @@
---
title: Bind User Input in Server-Side Filters with {:placeholder} Params
impact: CRITICAL
impactDescription: String-concatenating user input into filter expressions is a direct injection vulnerability
tags: extending, filter, injection, security, FindRecordsByFilter, dbx
---
## Bind User Input in Server-Side Filters with {:placeholder} Params
Server-side helpers like `FindFirstRecordByFilter`, `FindRecordsByFilter`, and `dbx.NewExp` accept a filter string that supports `{:name}` placeholders. **Never** concatenate user input into the filter - PocketBase's filter parser has its own syntax that is sensitive to quoting, and concatenation allows an attacker to alter the query (same class of bug as SQL injection).
**Incorrect (string interpolation - filter injection):**
```go
// ❌ attacker sets email to: x' || 1=1 || email='
// resulting filter bypasses the intended match entirely
email := e.Request.URL.Query().Get("email")
record, err := app.FindFirstRecordByFilter(
"users",
"email = '"+email+"' && verified = true", // ❌
)
```
```javascript
// JSVM - same class of bug
const email = e.request.url.query().get("email");
const record = $app.findFirstRecordByFilter(
"users",
`email = '${email}' && verified = true`, // ❌
);
```
**Correct (named placeholders + params map):**
```go
import "github.com/pocketbase/dbx"
email := e.Request.URL.Query().Get("email")
record, err := app.FindFirstRecordByFilter(
"users",
"email = {:email} && verified = true",
dbx.Params{"email": email}, // values are quoted/escaped by the framework
)
if err != nil {
return e.NotFoundError("user not found", err)
}
// Paginated variant: FindRecordsByFilter(collection, filter, sort, limit, offset, params...)
recs, err := app.FindRecordsByFilter(
"posts",
"author = {:author} && status = {:status}",
"-created",
20, 0,
dbx.Params{"author": e.Auth.Id, "status": "published"},
)
```
```javascript
// JSVM - second argument after the filter is the params object
const record = $app.findFirstRecordByFilter(
"users",
"email = {:email} && verified = true",
{ email: email },
);
const recs = $app.findRecordsByFilter(
"posts",
"author = {:author} && status = {:status}",
"-created", 20, 0,
{ author: e.auth.id, status: "published" },
);
```
**Rules:**
- Placeholder syntax is `{:name}` inside the filter string, and the value is supplied via `dbx.Params{"name": value}` (Go) or a plain object (JS).
- The same applies to `dbx.NewExp("LOWER(email) = {:email}", dbx.Params{"email": email})` when writing raw `dbx` expressions.
- Passing a `types.DateTime` / `DateTime` value binds it correctly - do not stringify dates manually.
- `nil` / `null` binds as SQL NULL; use `field = null` or `field != null` in the filter expression.
- The filter grammar is the same as used by collection API rules - consult [Filter Syntax](https://pocketbase.io/docs/api-rules-and-filters/#filters) for operators.
Reference: [Go database - FindRecordsByFilter](https://pocketbase.io/docs/go-records/#fetch-records-via-filter-expression) · [JS database - findRecordsByFilter](https://pocketbase.io/docs/js-records/#fetch-records-via-filter-expression)

View File

@@ -0,0 +1,136 @@
---
title: Use DBConnect Only When You Need a Custom SQLite Driver
impact: MEDIUM
impactDescription: Incorrect driver setup breaks both data.db and auxiliary.db, or introduces unnecessary CGO
tags: go, extending, sqlite, custom-driver, cgo, fts5, dbconnect
---
## Use DBConnect Only When You Need a Custom SQLite Driver
PocketBase ships with the **pure-Go** `modernc.org/sqlite` driver (no CGO required). Only reach for a custom driver when you specifically need SQLite extensions like ICU, FTS5, or spatialite that the default driver doesn't expose. `DBConnect` is called **twice** — once for `pb_data/data.db` and once for `pb_data/auxiliary.db` — so driver registration and PRAGMAs must be idempotent.
**Incorrect (unnecessary custom driver, mismatched builder, CGO without justification):**
```go
// ❌ Adding a CGO dependency with no need for extensions
import _ "github.com/mattn/go-sqlite3"
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
// ❌ "sqlite3" builder name used but "pb_sqlite3" driver was registered —
// or vice versa — causing "unknown driver" / broken query generation
return dbx.Open("sqlite3", dbPath)
},
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
**Correct (mattn/go-sqlite3 with CGO — proper PRAGMA init hook and builder map entry):**
```go
package main
import (
"database/sql"
"log"
"github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
)
func init() {
// Use a unique driver name to avoid conflicts with other packages.
// sql.Register panics if called twice with the same name, so put it in init().
sql.Register("pb_sqlite3", &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
_, err := conn.Exec(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -32000;
`, nil)
return err
},
})
// Mirror the sqlite3 query builder so PocketBase generates correct SQL
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
}
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("pb_sqlite3", dbPath)
},
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
**Correct (ncruces/go-sqlite3 — no CGO, PRAGMAs via DSN query string):**
```go
package main
import (
"log"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
)
func main() {
const pragmas = "?_pragma=busy_timeout(10000)" +
"&_pragma=journal_mode(WAL)" +
"&_pragma=journal_size_limit(200000000)" +
"&_pragma=synchronous(NORMAL)" +
"&_pragma=foreign_keys(ON)" +
"&_pragma=temp_store(MEMORY)" +
"&_pragma=cache_size(-32000)"
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("sqlite3", "file:"+dbPath+pragmas)
},
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
**Conditional custom driver with default fallback:**
```go
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
// Use custom driver only for the main data file; fall back for auxiliary
if strings.HasSuffix(dbPath, "data.db") {
return dbx.Open("pb_sqlite3", dbPath)
}
return core.DefaultDBConnect(dbPath)
},
})
```
**Decision guide:**
| Need | Driver |
|------|--------|
| Default (no extensions) | Built-in `modernc.org/sqlite` — no `DBConnect` config needed |
| FTS5, ICU, spatialite | `mattn/go-sqlite3` (CGO) or `ncruces/go-sqlite3` (WASM, no CGO) |
| Reduce binary size | `go build -tags no_default_driver` to exclude the default driver (~4 MB saved) |
| Conditional fallback | Call `core.DefaultDBConnect(dbPath)` inside your `DBConnect` function |
Reference: [Extend with Go - Custom SQLite driver](https://pocketbase.io/docs/go-overview/#custom-sqlite-driver)

View File

@@ -0,0 +1,139 @@
---
title: Version Your Schema with Go Migrations
impact: HIGH
impactDescription: Guarantees repeatable, transactional schema evolution and eliminates manual dashboard changes in production
tags: go, migrations, schema, database, migratecmd, extending
---
## Version Your Schema with Go Migrations
PocketBase ships with a `migratecmd` plugin that generates versioned `.go` migration files, applies them automatically on `serve`, and lets you roll back with `migrate down`. Because the files are compiled into your binary, no extra migration tool is needed.
**Incorrect (one-off SQL or dashboard changes in production):**
```go
// ❌ Running raw SQL directly at startup without a migration file
// the change is applied every restart and has no rollback path.
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
_, err := app.DB().NewQuery(
"ALTER TABLE posts ADD COLUMN summary TEXT DEFAULT ''",
).Execute()
return err
})
// ❌ Forgetting to import the migrations package means
// registered migrations are never executed.
package main
import (
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
// _ "myapp/migrations" ← omitted: migrations never run
)
```
**Correct (register migratecmd, import migrations package):**
```go
// main.go
package main
import (
"log"
"os"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/osutils"
// Import side-effects only; this registers all init() migrations.
_ "myapp/migrations"
)
func main() {
app := pocketbase.New()
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
// Automigrate generates a new .go file whenever you make
// collection changes in the Dashboard (dev-only).
Automigrate: osutils.IsProbablyGoRun(),
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
**Create and write a migration:**
```bash
# Create a blank migration file in ./migrations/
go run . migrate create "add_summary_to_posts"
```
```go
// migrations/1687801090_add_summary_to_posts.go
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
// app is a transactional App instance safe to use directly.
collection, err := app.FindCollectionByNameOrId("posts")
if err != nil {
return err
}
collection.Fields.Add(&core.TextField{
Name: "summary",
Required: false,
})
return app.Save(collection)
}, func(app core.App) error {
// Optional rollback
collection, err := app.FindCollectionByNameOrId("posts")
if err != nil {
return err
}
collection.Fields.RemoveByName("summary")
return app.Save(collection)
})
}
```
**Snapshot all collections (useful for a fresh repo):**
```bash
# Generates a migration file that recreates your current schema from scratch.
go run . migrate collections
```
**Clean up dev migration history:**
```bash
# Remove _migrations table entries that have no matching .go file.
# Run after squashing or deleting intermediate dev migration files.
go run . migrate history-sync
```
**Apply / roll back manually:**
```bash
go run . migrate up # apply all unapplied migrations
go run . migrate down 1 # revert the last applied migration
```
**Key details:**
- Migration functions receive a **transactional** `core.App` treat it as the database source of truth. Never use the outer `app` variable inside migration callbacks.
- New unapplied migrations run automatically on every `serve` start no manual step in production.
- `Automigrate: osutils.IsProbablyGoRun()` limits auto-generation to `go run` (development) and prevents accidental file creation in production binaries.
- Prefer the collection API (`app.Save(collection)`) over raw SQL `ALTER TABLE` so PocketBase's internal schema cache stays consistent.
- Commit all generated `.go` files to version control; do **not** commit `pb_data/`.
Reference: [Extend with Go Migrations](https://pocketbase.io/docs/go-migrations/)

View File

@@ -0,0 +1,93 @@
---
title: Set Up a Go-Extended PocketBase Application
impact: HIGH
impactDescription: Foundation for all custom Go business logic, hooks, and routing
tags: go, extending, setup, main, bootstrap
---
## Set Up a Go-Extended PocketBase Application
When extending PocketBase as a Go framework (v0.36+), the entry point is a small `main.go` that creates the app, registers hooks on `OnServe()`, and calls `app.Start()`. Avoid reaching for a global `app` variable inside hook handlers - use `e.App` instead so code works inside transactions.
**Incorrect (global app reuse, no OnServe hook, bare http.Handler):**
```go
package main
import (
"log"
"net/http"
"github.com/pocketbase/pocketbase"
)
var app = pocketbase.New() // global reference used inside handlers
func main() {
// Routes registered directly via net/http - bypasses PocketBase's router,
// middleware chain, auth, rate limiter and body limit
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
**Correct (register routes inside `OnServe`, use `e.App` in handlers):**
```go
package main
import (
"log"
"net/http"
"os"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Serve static assets from ./pb_public (if present)
se.Router.GET("/{path...}", apis.Static(os.DirFS("./pb_public"), false))
// Custom API route - namespaced under /api/{yourapp}/ to avoid
// colliding with built-in /api/collections, /api/realtime, etc.
se.Router.GET("/api/myapp/hello/{name}", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"message": "hello " + e.Request.PathValue("name"),
})
}).Bind(apis.RequireAuth())
return se.Next()
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
**Project bootstrap:**
```bash
go mod init myapp
go mod tidy
go run . serve # development
go build && ./myapp serve # production (statically linked binary)
```
**Key details:**
- Requires **Go 1.25.0+** (PocketBase v0.36.7+ bumped the minimum to Go 1.25.0).
- PocketBase ships with the pure-Go `modernc.org/sqlite` driver - **no CGO required** by default.
- If you need FTS5, ICU, or a custom SQLite build, pass `core.DBConnect` in `pocketbase.NewWithConfig(...)` - it is called twice (once for `pb_data/data.db`, once for `pb_data/auxiliary.db`).
- Inside hooks, prefer `e.App` over a captured parent-scope `app` - the hook may run inside a transaction and the parent `app` would deadlock.
Reference: [Extend with Go - Overview](https://pocketbase.io/docs/go-overview/)

View File

@@ -0,0 +1,82 @@
---
title: Always Call e.Next() and Use e.App Inside Hook Handlers
impact: CRITICAL
impactDescription: Forgetting e.Next() silently breaks the execution chain; reusing parent-scope app causes deadlocks
tags: hooks, events, extending, transactions, deadlock
---
## Always Call e.Next() and Use e.App Inside Hook Handlers
Every PocketBase event hook handler is part of an execution chain. If the handler does not call `e.Next()` (Go) or `e.next()` (JS), **the remaining handlers and the core framework action are skipped silently**. Also, hooks may run inside a DB transaction - any database call made through a captured parent-scope `app`/`$app` instead of the event's own `e.App`/`e.app` will deadlock against the transaction.
**Incorrect (missing `Next`, captured parent-scope app, global mutex):**
```go
var mu sync.Mutex // ❌ global lock invoked recursively by cascade hooks = deadlock
app := pocketbase.New()
app.OnRecordAfterCreateSuccess("articles").BindFunc(func(e *core.RecordEvent) error {
mu.Lock()
defer mu.Unlock()
// ❌ uses outer `app`, not `e.App` - deadlocks when the hook fires
// inside a transaction, because the outer app is blocked on the
// transaction's write lock
_, err := app.FindRecordById("audit", e.Record.Id)
if err != nil {
return err
}
return nil // ❌ forgot e.Next() - framework never persists the record
})
```
```javascript
// JSVM
onRecordAfterCreateSuccess((e) => {
// ❌ no e.next() = downstream hooks and response serialization skipped
console.log("created", e.record.id);
}, "articles");
```
**Correct (call Next, use `e.App`, attach an Id for later unbinding):**
```go
app := pocketbase.New()
app.OnRecordAfterCreateSuccess("articles").Bind(&hook.Handler[*core.RecordEvent]{
Id: "audit-article-create",
Priority: 10, // higher = later; default 0 = order of registration
Func: func(e *core.RecordEvent) error {
// Always use e.App - it is the transactional app when inside a tx
audit := core.NewRecord(/* ... */)
audit.Set("record", e.Record.Id)
if err := e.App.Save(audit); err != nil {
return err
}
return e.Next() // REQUIRED
},
})
// Later: app.OnRecordAfterCreateSuccess("articles").Unbind("audit-article-create")
```
```javascript
// JSVM - e.app is the transactional app instance
onRecordAfterCreateSuccess((e) => {
const audit = new Record($app.findCollectionByNameOrId("audit"));
audit.set("record", e.record.id);
e.app.save(audit);
e.next(); // REQUIRED
}, "articles");
```
**Rules of the execution chain:**
- `Bind(handler)` vs `BindFunc(func)`: `Bind` lets you set `Id` (for `Unbind`) and `Priority`; `BindFunc` auto-generates both.
- Priority defaults to `0` = order of source registration. Lower numbers run first, negative priorities run before defaults (the built-in middlewares use priorities like `-1010`, `-1000`, `-990`).
- **Never hold a global mutex across `e.Next()`** - cascade-delete and nested saves can re-enter the same hook and deadlock.
- `Unbind(id)` removes a specific handler; `UnbindAll()` also removes **system handlers**, so only call it if you really mean to replace the default behavior.
- `Trigger(event, ...)` is almost never needed in user code.
Reference: [Go Event hooks](https://pocketbase.io/docs/go-event-hooks/) · [JS Event hooks](https://pocketbase.io/docs/js-event-hooks/)

View File

@@ -0,0 +1,64 @@
---
title: Pick the Right Record Hook - Model vs Request vs Enrich
impact: HIGH
impactDescription: Wrong hook = missing request context, double-fired logic, or leaked fields in realtime events
tags: hooks, onRecordEnrich, onRecordRequest, model-hooks, extending
---
## Pick the Right Record Hook - Model vs Request vs Enrich
PocketBase v0.23+ splits record hooks into three families. Using the wrong one is the #1 source of "my hook doesn't fire" and "my hidden field still shows up in realtime events" bugs.
| Family | Examples | Fires for | Has request context? |
|--------|----------|-----------|----------------------|
| **Model hooks** | `OnRecordCreate`, `OnRecordAfterCreateSuccess`, `OnRecordValidate` | Any save path - Web API **and** cron jobs, custom commands, migrations, calls from other hooks | No - `e.Record`, `e.App`, **no** `e.RequestInfo` |
| **Request hooks** | `OnRecordCreateRequest`, `OnRecordsListRequest`, `OnRecordViewRequest` | **Only** the built-in Web API endpoints | Yes - `e.RequestInfo`, `e.Auth`, HTTP headers/body |
| **Enrich hook** | `OnRecordEnrich` | Every response serialization, **including realtime SSE events** and `apis.enrichRecord` | Yes, via `e.RequestInfo` |
**Incorrect (hiding a field in the request hook - leaks in realtime):**
```go
// ❌ Only runs for GET /api/collections/users/records/{id}.
// Realtime SSE subscribers still receive the "role" field.
app.OnRecordViewRequest("users").BindFunc(func(e *core.RecordRequestEvent) error {
e.Record.Hide("role")
return e.Next()
})
```
**Correct (use `OnRecordEnrich` so realtime and list responses also hide the field):**
```go
app.OnRecordEnrich("users").BindFunc(func(e *core.RecordEnrichEvent) error {
e.Record.Hide("role")
// Add a computed field only for authenticated users
if e.RequestInfo.Auth != nil {
e.Record.WithCustomData(true) // required to attach non-schema data
e.Record.Set("isOwner", e.Record.Id == e.RequestInfo.Auth.Id)
}
return e.Next()
})
```
```javascript
// JSVM
onRecordEnrich((e) => {
e.record.hide("role");
if (e.requestInfo.auth?.collection()?.name === "users") {
e.record.withCustomData(true);
e.record.set("computedScore",
e.record.get("score") * e.requestInfo.auth.get("base"));
}
e.next();
}, "users");
```
**Selection guide:**
- Need to mutate the record before **any** save (API, cron, migration, nested hook)? → `OnRecordCreate` / `OnRecordUpdate` (pre-save) or `OnRecord*Success` (post-save).
- Need access to HTTP headers, query params, or the authenticated client? → `OnRecord*Request`.
- Need to hide fields, redact values, or attach computed props on responses including realtime? → **`OnRecordEnrich`** - this is the safest default for response shaping.
- Need to validate before save? → `OnRecordValidate` (proxy over `OnModelValidate`).
Reference: [Go Record request hooks](https://pocketbase.io/docs/go-event-hooks/#record-crud-request-hooks) · [JS Record model hooks](https://pocketbase.io/docs/js-event-hooks/#record-model-hooks)

View File

@@ -0,0 +1,105 @@
---
title: Write JSVM Migrations as pb_migrations/*.js Files
impact: HIGH
impactDescription: JSVM migrations look different from Go ones; missing the timestamp prefix or the down-callback silently breaks replay
tags: jsvm, migrations, pb_migrations, schema, extending
---
## Write JSVM Migrations as pb_migrations/*.js Files
JSVM migrations live in `pb_migrations/` next to the executable. Unlike Go migrations (which use `init()` + `m.Register(...)` inside a package imported from `main.go`), JSVM migrations are **auto-discovered by filename** and call the global `migrate()` function with an `up` callback and an optional `down` callback. `--automigrate` is on by default in v0.36+, so admin-UI changes generate these files for you; you also write them by hand for data migrations, seed data, and index changes that the UI can't express.
**Incorrect (wrong filename format, missing down, raw SQL without cache invalidation):**
```javascript
// pb_migrations/add_audit.js ❌ missing <unix>_ prefix - never runs
migrate((app) => {
// ❌ Raw ALTER TABLE leaves PocketBase's internal collection cache stale
app.db().newQuery(
"ALTER TABLE posts ADD COLUMN summary TEXT DEFAULT ''"
).execute();
});
// ❌ No down callback - `migrate down` cannot revert this in dev
```
**Correct (timestamped filename, collection API, both up and down):**
```javascript
// pb_migrations/1712500000_add_audit_collection.js
/// <reference path="../pb_data/types.d.ts" />
migrate(
// UP - runs on `serve` / `migrate up`
(app) => {
const collection = new Collection({
type: "base",
name: "audit",
fields: [
{ name: "action", type: "text", required: true },
{ name: "actor", type: "relation", collectionId: "_pb_users_auth_", cascadeDelete: false },
{ name: "meta", type: "json" },
{ name: "created", type: "autodate", onCreate: true },
],
indexes: [
"CREATE INDEX idx_audit_actor ON audit (actor)",
"CREATE INDEX idx_audit_created ON audit (created)",
],
});
app.save(collection);
},
// DOWN - runs on `migrate down N`
(app) => {
const collection = app.findCollectionByNameOrId("audit");
app.delete(collection);
},
);
```
**Seed data migration (common pattern):**
```javascript
// pb_migrations/1712500100_seed_default_tags.js
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const tags = app.findCollectionByNameOrId("tags");
for (const name of ["urgent", "bug", "feature", "docs"]) {
const r = new Record(tags);
r.set("name", name);
app.save(r); // `app` here is the transactional app - all or nothing
}
},
(app) => {
const tags = app.findCollectionByNameOrId("tags");
for (const name of ["urgent", "bug", "feature", "docs"]) {
const r = app.findFirstRecordByFilter(
"tags",
"name = {:name}",
{ name },
);
if (r) app.delete(r);
}
},
);
```
**CLI commands (same as Go migrations):**
```bash
./pocketbase migrate create "add_audit_collection" # templated blank file
./pocketbase migrate up # apply pending
./pocketbase migrate down 1 # revert last
./pocketbase migrate history-sync # reconcile _migrations table
```
**Rules:**
- **Filename format**: `<unix_timestamp>_<description>.js`. The timestamp sets ordering. Never renumber a committed file.
- **The `app` argument is transactional**: every migration runs inside its own transaction. Throw to roll back. Do not capture `$app` from the outer scope - use the `app` parameter so the work participates in the tx.
- **Use the collection API** (`new Collection`, `app.save(collection)`), not raw `ALTER TABLE`. Raw SQL leaves PocketBase's in-memory schema cache stale until the next restart.
- **Always write the down callback** in development. In production, down migrations are rare but the callback is what makes `migrate down 1` work during emergency rollbacks.
- **Do not import from other files** - goja has no ES modules, and at migration time the `pb_hooks` loader has not necessarily run. Keep each migration self-contained.
- **Commit `pb_migrations/` to version control**. Never commit `pb_data/`.
- **Conflicting with Go migrations**: you can run either Go or JS migrations, not a mix of both in the same project. JSVM migrations are enabled by default; Go migrations require `migratecmd.MustRegister(...)` in `main.go`.
Reference: [Extend with JavaScript - Migrations](https://pocketbase.io/docs/js-migrations/)

View File

@@ -0,0 +1,57 @@
---
title: Set Up JSVM (pb_hooks) for Server-Side JavaScript
impact: HIGH
impactDescription: Correct setup unlocks hot-reload, type-completion, and the full JSVM API
tags: jsvm, pb_hooks, extending, setup, typescript
---
## Set Up JSVM (pb_hooks) for Server-Side JavaScript
The prebuilt PocketBase executable embeds an ES5 JavaScript engine (goja). Drop `*.pb.js` files into a `pb_hooks` directory next to the executable and they load automatically at startup. Files are loaded in **filename sort order**, and on UNIX platforms the process auto-reloads when any `pb_hooks` file changes.
**Incorrect (TypeScript without transpile, wrong filename, missing types reference):**
```typescript
// pb_hooks/main.ts ❌ PocketBase loads ONLY *.pb.js - a .ts file is ignored
import { something } from "./lib"; // ❌ ES modules not supported in goja
routerAdd("GET", "/hello", (e) => e.json(200, { ok: true }));
```
```javascript
// pb_hooks/hooks.js ❌ wrong extension - must be *.pb.js
// No /// reference -> editor shows every call as "any"
onRecordAfterUpdateSuccess((e) => {
console.log(e.record.get("email"));
// Missing e.next() - stops the execution chain silently
}, "users");
```
**Correct (valid filename, types reference, `e.next()` called):**
```javascript
// pb_hooks/main.pb.js
/// <reference path="../pb_data/types.d.ts" />
// Hooks defined earlier in the filename sort order run first.
// Use prefixes like "01_", "10_", "99_" if order matters.
routerAdd("GET", "/api/myapp/hello/{name}", (e) => {
const name = e.request.pathValue("name");
return e.json(200, { message: "Hello " + name });
});
onRecordAfterUpdateSuccess((e) => {
console.log("user updated:", e.record.get("email"));
e.next(); // REQUIRED - otherwise the execution chain is broken
}, "users");
```
**Key details:**
- JS method names are **camelCase** versions of their Go equivalents (`FindRecordById``$app.findRecordById`).
- Errors are thrown as regular JS exceptions, not returned as values.
- Global objects: `$app` (the app), `$apis` (routing helpers/middlewares), `$os` (OS primitives), `$security` (JWT, random strings, AES), `$filesystem` (file factories), `$dbx` (query builder), `$mails` (email helpers), `__hooks` (absolute path to `pb_hooks`).
- `pb_data/types.d.ts` is regenerated automatically - commit the triple-slash reference but not the file itself if you prefer.
- Auto-reload on file change works on UNIX only. On Windows, restart the process manually.
Reference: [Extend with JavaScript - Overview](https://pocketbase.io/docs/js-overview/)

View File

@@ -0,0 +1,105 @@
---
title: Load Shared Code with CommonJS require() in pb_hooks
impact: MEDIUM
impactDescription: Correct module usage prevents require() failures, race conditions, and ESM import errors
tags: jsvm, pb_hooks, modules, require, commonjs, esm, filesystem
---
## Load Shared Code with CommonJS require() in pb_hooks
The embedded JSVM (goja) supports **only CommonJS** (`require()`). ES module `import` syntax is not supported without pre-bundling. Modules use a shared registry — they are evaluated once and cached, so avoid mutable module-level state to prevent race conditions across concurrent requests.
**Incorrect (ESM imports, mutable shared state, Node.js APIs):**
```javascript
// ❌ ESM import syntax — not supported by goja
import { sendEmail } from "./utils.js";
// ❌ Node.js APIs don't exist in the JSVM sandbox
const fs = require("fs");
fs.writeFileSync("output.txt", "hello"); // ReferenceError
// ❌ Mutable module-level state is shared across concurrent requests
// pb_hooks/counter.js
let requestCount = 0;
module.exports = { increment: () => ++requestCount }; // race condition
```
**Correct (CommonJS require, stateless helpers, JSVM bindings for OS/file ops):**
```javascript
// pb_hooks/utils.js — stateless helper module
module.exports = {
formatDate: (d) => new Date(d).toISOString().slice(0, 10),
validateEmail: (addr) => /^[^@]+@[^@]+\.[^@]+$/.test(addr),
};
// pb_hooks/main.pb.js
/// <reference path="../pb_data/types.d.ts" />
onRecordAfterCreateSuccess((e) => {
const utils = require(`${__hooks}/utils.js`);
const date = utils.formatDate(e.record.get("created"));
console.log("Record created on:", date);
e.next();
}, "posts");
// Use $os.* for file system operations (not Node.js fs)
routerAdd("GET", "/api/myapp/read-config", (e) => {
const raw = $os.readFile(`${__hooks}/config.json`);
const cfg = JSON.parse(raw);
return e.json(200, { name: cfg.appName });
});
// Use $filesystem.s3(...) or $filesystem.local(...) for storage (v0.36.4+)
routerAdd("POST", "/api/myapp/upload", (e) => {
const bucket = $filesystem.s3({
endpoint: "s3.amazonaws.com",
bucket: "my-bucket",
region: "us-east-1",
accessKey: $app.settings().s3.accessKey,
secret: $app.settings().s3.secret,
});
// ... use bucket to store/retrieve files
return e.json(200, { ok: true });
}, $apis.requireAuth());
```
**Using third-party CJS packages:**
```javascript
// node_modules/ is searched automatically alongside __hooks.
// Install packages with npm next to the pb_hooks directory, then require by name.
onBootstrap((e) => {
e.next();
// Only CJS-compatible packages work without bundling
const slugify = require("slugify");
console.log(slugify("Hello World")); // "Hello-World"
});
```
**Using ESM-only packages (bundle to CJS first):**
```bash
# Bundle an ESM package to CJS with rollup before committing it to pb_hooks
npx rollup node_modules/some-esm-pkg/index.js \
--file pb_hooks/vendor/some-esm-pkg.js \
--format cjs
```
```javascript
onBootstrap((e) => {
e.next();
const pkg = require(`${__hooks}/vendor/some-esm-pkg.js`);
});
```
**JSVM engine limitations:**
- No `setTimeout` / `setInterval` — no async scheduling inside handlers.
- No Node.js APIs (`fs`, `Buffer`, `process`, etc.) — use `$os.*` and `$filesystem.*` JSVM bindings instead.
- No browser APIs (`fetch`, `window`, `localStorage`) — use `$app.newHttpClient()` for outbound HTTP requests.
- ES6 is mostly supported but not fully spec-compliant (goja engine).
- The prebuilt PocketBase executable starts a **pool of 15 JS runtimes** by default; adjust with `--hooksPool=N` for high-concurrency workloads (more runtimes = more memory, better throughput).
- `nullString()`, `nullInt()`, `nullFloat()`, `nullBool()`, `nullArray()`, `nullObject()` helpers are available (v0.35.0+) for scanning nullable DB columns safely.
Reference: [Extend with JavaScript - Loading modules](https://pocketbase.io/docs/js-overview/#loading-modules)

View File

@@ -0,0 +1,69 @@
---
title: Avoid Capturing Variables Outside JSVM Handler Scope
impact: HIGH
impactDescription: Variables defined outside a handler are undefined at runtime due to handler serialization
tags: jsvm, pb_hooks, scope, isolation, variables
---
## Avoid Capturing Variables Outside JSVM Handler Scope
Each JSVM handler (hook, route, middleware) is **serialized and executed as an isolated program**. Variables or functions declared at the module/file scope are NOT accessible inside handler bodies. This is the most common source of `undefined` errors in `pb_hooks` code.
**Incorrect (accessing outer-scope variable inside handler):**
```javascript
// pb_hooks/main.pb.js
const APP_NAME = "myapp"; // ❌ will be undefined inside handlers
onBootstrap((e) => {
e.next();
console.log(APP_NAME); // ❌ undefined — APP_NAME is not in handler scope
});
// ❌ Even $app references captured here may not work as expected
const helper = (id) => $app.findRecordById("posts", id);
onRecordAfterCreateSuccess((e) => {
helper(e.record.id); // ❌ helper is undefined inside the handler
}, "posts");
```
**Correct (move shared state into a required module, or use `$app`/`e.app` directly):**
```javascript
// pb_hooks/config.js — stateless CommonJS module
module.exports = {
APP_NAME: "myapp",
MAX_RETRIES: 3,
};
// pb_hooks/main.pb.js
/// <reference path="../pb_data/types.d.ts" />
onBootstrap((e) => {
e.next();
// Load the shared module INSIDE the handler
const config = require(`${__hooks}/config.js`);
console.log(config.APP_NAME); // ✅ "myapp"
});
routerAdd("GET", "/api/myapp/status", (e) => {
const config = require(`${__hooks}/config.js`);
return e.json(200, { app: config.APP_NAME });
});
onRecordAfterCreateSuccess((e) => {
// Access the app directly via e.app inside the handler
const post = e.app.findRecordById("posts", e.record.id);
e.next();
}, "posts");
```
**Key rules:**
- Every handler body is serialized to a string and executed in its own isolated goja runtime context. There is no shared global state between handlers at runtime.
- `require()` loads modules from a **shared registry** — modules are evaluated once and cached. Keep module-level code stateless; avoid mutable module exports to prevent data races under concurrent requests.
- `__hooks` is always available inside handlers and resolves to the absolute path of the `pb_hooks` directory.
- Error stack trace line numbers may not be accurate because of the handler serialization — log meaningful context manually when debugging.
- Workaround for simple constants: move them to a `config.js` module and `require()` it inside each handler that needs it.
Reference: [Extend with JavaScript - Handlers scope](https://pocketbase.io/docs/js-overview/#handlers-scope)

View File

@@ -0,0 +1,114 @@
---
title: Send Email via app.NewMailClient, Never the Default example.com Sender
impact: HIGH
impactDescription: Default sender is no-reply@example.com; shipping it bounces every email and damages your SMTP reputation
tags: mailer, email, smtp, $mails, extending, templates
---
## Send Email via app.NewMailClient, Never the Default example.com Sender
PocketBase ships with a mailer accessible through `app.NewMailClient()` (Go) or `$app.newMailClient()` (JS). It reads the SMTP settings configured in **Admin UI → Settings → Mail settings**, or falls back to a local `sendmail`-like client if SMTP is not configured. Two things bite people: (1) the default `Meta.senderAddress` is `no-reply@example.com` - shipping with that bounces every email and poisons your sender reputation; (2) there is no connection pooling, so long-lived mail client handles are **not** safe to share across requests - create one per send.
**Incorrect (default sender, shared client, no error handling):**
```go
// ❌ Default sender is example.com, and this mailer instance is captured
// for the process lifetime - SMTP connections go stale
var mailer = app.NewMailClient()
app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error {
msg := &mailer.Message{
From: mail.Address{Address: "no-reply@example.com"}, // ❌
To: []mail.Address{{Address: e.Record.GetString("email")}},
Subject: "Order confirmed",
HTML: "<p>Thanks</p>",
}
mailer.Send(msg) // ❌ error swallowed
return e.Next()
})
```
**Correct (sender from settings, per-send client, explicit error path):**
```go
import (
"net/mail"
pbmail "github.com/pocketbase/pocketbase/tools/mailer"
)
app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error {
// IMPORTANT: resolve the sender from settings at send-time, not at
// startup - an admin can change it live from the UI
meta := e.App.Settings().Meta
from := mail.Address{
Name: meta.SenderName,
Address: meta.SenderAddress,
}
msg := &pbmail.Message{
From: from,
To: []mail.Address{{Address: e.Record.GetString("email")}},
Subject: "Order confirmed",
HTML: renderOrderEmail(e.Record), // your template function
}
// Create the client per send - avoids stale TCP sessions
if err := e.App.NewMailClient().Send(msg); err != nil {
e.App.Logger().Error("order email send failed",
"err", err,
"recordId", e.Record.Id,
)
// Do NOT return the error - a failed email should not roll back the order
}
return e.Next()
})
```
```javascript
// JSVM - $mails global exposes message factories
onRecordAfterCreateSuccess((e) => {
const meta = $app.settings().meta;
const message = new MailerMessage({
from: {
address: meta.senderAddress,
name: meta.senderName,
},
to: [{ address: e.record.get("email") }],
subject: "Order confirmed",
html: `<p>Thanks for order ${e.record.id}</p>`,
});
try {
$app.newMailClient().send(message);
} catch (err) {
$app.logger().error("order email send failed", "err", err, "id", e.record.id);
// swallow - do not rollback the order
}
e.next();
}, "orders");
```
**Templated emails via the built-in verification/reset templates:**
```go
// PocketBase has baked-in templates for verification, password reset, and
// email change. Trigger them via apis.*Request helpers rather than building
// your own message:
// apis.RecordRequestPasswordReset(app, authRecord)
// apis.RecordRequestVerification(app, authRecord)
// apis.RecordRequestEmailChange(app, authRecord, newEmail)
//
// These use the templates configured in Admin UI → Settings → Mail templates.
```
**Rules:**
- **Always change `Meta.SenderAddress`** before shipping. In development, use Mailpit or MailHog; in production, use a verified domain that matches your SPF/DKIM records.
- **Resolve the sender from `app.Settings().Meta` at send-time**, not at startup. Settings are mutable from the admin UI.
- **Create the client per send** (`app.NewMailClient()` / `$app.newMailClient()`). It is cheap - it re-reads the SMTP settings each time, so config changes take effect without a restart.
- **Never return a send error from a hook** unless the user's action genuinely depends on the email going out. Email failure is common (transient SMTP, address typo) and should not roll back a business transaction.
- **Log failures with context** (record id, recipient domain) so you can grep them later. PocketBase does not retry failed sends.
- **For bulk sending, queue it**. The mailer is synchronous - looping `Send()` over 10k records blocks the request. Push to a cron-drained queue collection instead.
- **Template rendering**: Go users should use `html/template`; JS users can use template literals or pull in a tiny template lib. PocketBase itself only renders templates for its baked-in flows.
Reference: [Go Mailer](https://pocketbase.io/docs/go-sending-emails/) · [JS Mailer](https://pocketbase.io/docs/js-sending-emails/)

View File

@@ -0,0 +1,88 @@
---
title: Register Custom Routes Safely with Built-in Middlewares
impact: HIGH
impactDescription: Protects custom endpoints with auth, avoids /api path collisions, inherits rate limiting
tags: routing, middleware, extending, requireAuth, apis
---
## Register Custom Routes Safely with Built-in Middlewares
PocketBase routing is built on top of `net/http.ServeMux`. Custom routes are registered inside the `OnServe()` hook (Go) or via `routerAdd()` / `routerUse()` (JSVM). **Always** namespace custom routes under `/api/{yourapp}/...` to avoid colliding with built-in endpoints, and attach `apis.RequireAuth()` / `$apis.requireAuth()` (or stricter) to anything that is not meant to be public.
**Incorrect (path collision, no auth, raw ResponseWriter):**
```go
// ❌ "/api/records" collides with /api/collections/{name}/records built-in
se.Router.POST("/api/records", func(e *core.RequestEvent) error {
// ❌ no auth check - anyone can call this
// ❌ returns raw text; no content-type
e.Response.Write([]byte("ok"))
return nil
})
```
**Correct (namespaced, authenticated, group-scoped middleware):**
```go
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Group everything under /api/myapp/ and require auth for the entire group
g := se.Router.Group("/api/myapp")
g.Bind(apis.RequireAuth()) // authenticated users only
g.Bind(apis.Gzip()) // compress responses
g.Bind(apis.BodyLimit(10 << 20)) // per-route override of default 32MB limit
g.GET("/profile", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{
"id": e.Auth.Id,
"email": e.Auth.GetString("email"),
})
})
// Superuser-only admin endpoint
g.POST("/admin/rebuild-index", func(e *core.RequestEvent) error {
// ... do the work
return e.JSON(http.StatusOK, map[string]bool{"ok": true})
}).Bind(apis.RequireSuperuserAuth())
// Resource the owner (or a superuser) can access
g.GET("/users/{id}/private", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"private": "data"})
}).Bind(apis.RequireSuperuserOrOwnerAuth("id"))
return se.Next()
})
```
```javascript
// JSVM
routerAdd("GET", "/api/myapp/profile", (e) => {
return e.json(200, {
id: e.auth.id,
email: e.auth.getString("email"),
});
}, $apis.requireAuth());
routerAdd("POST", "/api/myapp/admin/rebuild-index", (e) => {
return e.json(200, { ok: true });
}, $apis.requireSuperuserAuth());
```
**Built-in middlewares (Go: `apis.*`, JS: `$apis.*`):**
| Middleware | Use |
|---|---|
| `RequireGuestOnly()` | Reject authenticated clients (e.g. public signup forms) |
| `RequireAuth(...collections)` | Require any auth record; optionally restrict to specific auth collections |
| `RequireSuperuserAuth()` | Alias for `RequireAuth("_superusers")` |
| `RequireSuperuserOrOwnerAuth("id")` | Allow superusers OR the auth record whose id matches the named path param |
| `Gzip()` | Gzip-compress the response |
| `BodyLimit(bytes)` | Override the default 32MB request body cap (0 = no limit) |
| `SkipSuccessActivityLog()` | Suppress activity log for successful responses |
**Path details:**
- Patterns follow `net/http.ServeMux`: `{name}` = single segment, `{name...}` = catch-all.
- A trailing `/` acts as a prefix wildcard; use `{$}` to anchor to the exact path only.
- **Always** prefix custom routes with `/api/{yourapp}/` - do not put them under `/api/` alone, which collides with built-in collection / realtime / settings endpoints.
- Order: global middlewares → group middlewares → route middlewares → handler. Use negative priorities to run before built-ins if needed.
Reference: [Go Routing](https://pocketbase.io/docs/go-routing/) · [JS Routing](https://pocketbase.io/docs/js-routing/)

View File

@@ -0,0 +1,88 @@
---
title: Read Settings via app.Settings(), Encrypt at Rest with PB_ENCRYPTION
impact: HIGH
impactDescription: Hardcoded secrets and unencrypted settings storage are the #1 source of credential leaks
tags: settings, configuration, encryption, secrets, PB_ENCRYPTION, extending
---
## Read Settings via app.Settings(), Encrypt at Rest with PB_ENCRYPTION
PocketBase stores every runtime-mutable setting (SMTP credentials, S3 keys, OAuth2 client secrets, JWT secrets for each auth collection) in the `_params` table as JSON. Admin UI edits write to the same place. There are two knobs that matter: (1) **how you read settings from Go/JS** - always via `app.Settings()` at call time, never captured at startup; (2) **how they are stored on disk** - set the `PB_ENCRYPTION` env var to a 32-char key so the whole blob is encrypted at rest. Without encryption, anyone with a copy of `data.db` has your SMTP password, OAuth2 secrets, and every collection's signing key.
**Incorrect (hardcoded secret, captured at startup, unencrypted at rest):**
```go
// ❌ Secret compiled into the binary - leaks via `strings ./pocketbase`
const slackWebhook = "https://hooks.slack.com/services/T00/B00/XXXX"
// ❌ Captured once at startup - if an admin rotates the SMTP password via the
// UI, this stale value keeps trying until restart
var smtpHost = app.Settings().SMTP.Host
// ❌ No PB_ENCRYPTION set - `sqlite3 pb_data/data.db "SELECT * FROM _params"`
// prints every secret in plaintext
./pocketbase serve
```
**Correct (env + settings lookup at call time + encryption at rest):**
```bash
# Generate a 32-char encryption key once and store it in your secrets manager
# (1Password, SOPS, AWS SSM, etc). Commit NOTHING related to this value.
openssl rand -hex 16 # 32 hex chars
# Start with the key exported - PocketBase AES-encrypts _params on write
# and decrypts on read. Losing the key == losing access to settings.
export PB_ENCRYPTION="3a7c...deadbeef32charsexactly"
./pocketbase serve
```
```go
// Reading mutable settings at call time - reflects live UI changes
func notifyAdmin(app core.App, msg string) error {
meta := app.Settings().Meta
from := mail.Address{Name: meta.SenderName, Address: meta.SenderAddress}
// ...
}
// Mutating settings programmatically (e.g. during a migration)
settings := app.Settings()
settings.Meta.AppName = "MyApp"
settings.SMTP.Enabled = true
settings.SMTP.Host = os.Getenv("SMTP_HOST") // inject from env at write time
if err := app.Save(settings); err != nil {
return err
}
```
```javascript
// JSVM
onBootstrap((e) => {
e.next();
const settings = $app.settings();
settings.meta.appName = "MyApp";
$app.save(settings);
});
// At send-time
const meta = $app.settings().meta;
```
**Secrets that do NOT belong in `app.Settings()`:**
- Database encryption key itself → `PB_ENCRYPTION` env var (not in the DB, obviously)
- Third-party webhooks your code calls (Slack, Stripe, etc) → env vars, read via `os.Getenv` / `$os.getenv`
- CI tokens, deploy keys → your secrets manager, not PocketBase
`app.Settings()` is for things an **admin** should be able to rotate through the UI. Everything else lives in env vars, injected by your process supervisor (systemd, Docker, Kubernetes).
**Key details:**
- **`PB_ENCRYPTION` must be exactly 32 characters.** Anything else crashes at startup.
- **Losing the key is unrecoverable** - the settings blob cannot be decrypted, and the server refuses to boot. Back up the key alongside (but separately from) your `pb_data` backups.
- **Rotating the key**: start with the old key set, call `app.Settings()``app.Save(settings)` to re-encrypt under the new key, then restart with the new key. Do this under a maintenance window.
- **Settings changes fire `OnSettingsReload`** - use it if you have in-memory state that depends on a setting (e.g. a rate limiter sized from `app.Settings().RateLimits.Default`).
- **Do not call `app.Settings()` in a hot loop.** It returns a fresh copy each time. Cache for the duration of a single request, not the process.
- **`app.Save(settings)`** persists and broadcasts the reload event. Mutating the returned struct without saving is a no-op.
Reference: [Settings](https://pocketbase.io/docs/going-to-production/#use-encryption-for-the-pb_data-settings) · [OnSettingsReload hook](https://pocketbase.io/docs/go-event-hooks/#app-hooks)

View File

@@ -0,0 +1,173 @@
---
title: Test Hooks and Routes with tests.NewTestApp and ApiScenario
instead of Curl
impact: HIGH
impactDescription: Without the tests package you cannot exercise hooks, middleware, and transactions in isolation
tags: testing, tests, NewTestApp, ApiScenario, go, extending
---
## Test Hooks and Routes with tests.NewTestApp and ApiScenario
PocketBase ships a `tests` package specifically for integration-testing Go extensions. `tests.NewTestApp(testDataDir)` builds a fully-wired `core.App` over a **temp copy** of your test data directory, so you can register hooks, fire requests through the real router, and assert on the resulting DB state without spinning up a real HTTP server or touching `pb_data/`. The `tests.ApiScenario` struct drives the router the same way a real HTTP client would, including middleware and transactions. Curl-based shell tests cannot do either of these things.
**Incorrect (hand-rolled HTTP client, shared dev DB, no hook reset):**
```go
// ❌ Hits the actual dev server - depends on side-effects from a previous run
func TestCreatePost(t *testing.T) {
resp, _ := http.Post("http://localhost:8090/api/collections/posts/records",
"application/json",
strings.NewReader(`{"title":"hi"}`))
if resp.StatusCode != 200 {
t.Fatal("bad status")
}
// ❌ No DB assertion, no cleanup, no hook verification
}
```
**Correct (NewTestApp + ApiScenario + AfterTestFunc assertions):**
```go
// internal/app/posts_test.go
package app_test
import (
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"myapp/internal/hooks" // your hook registration
)
// testDataDir is a checked-in pb_data snapshot with your collections.
// Create it once with `./pocketbase --dir ./test_pb_data migrate up`
// and commit it to your test fixtures.
const testDataDir = "../../test_pb_data"
func TestCreatePostFiresAudit(t *testing.T) {
// Each test gets its own copy of testDataDir - parallel-safe
app, err := tests.NewTestApp(testDataDir)
if err != nil {
t.Fatal(err)
}
defer app.Cleanup() // REQUIRED - removes the temp copy
// Register the hook under test against this isolated app
hooks.RegisterPostHooks(app)
scenario := tests.ApiScenario{
Name: "POST /api/collections/posts/records as verified user",
Method: http.MethodPost,
URL: "/api/collections/posts/records",
Body: strings.NewReader(`{"title":"hello","slug":"hello"}`),
Headers: map[string]string{
"Authorization": testAuthHeader(app, "users", "alice@example.com"),
"Content-Type": "application/json",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"title":"hello"`,
`"slug":"hello"`,
},
NotExpectedContent: []string{
`"internalNotes"`, // the enrich hook should hide this
},
ExpectedEvents: map[string]int{
"OnRecordCreateRequest": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordEnrich": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
// Assert side-effects in the DB using the SAME app instance
audits, err := app.FindRecordsByFilter(
"audit",
"action = 'post.create'",
"-created", 10, 0,
)
if err != nil {
t.Fatal(err)
}
if len(audits) != 1 {
t.Fatalf("expected 1 audit record, got %d", len(audits))
}
},
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
}
scenario.Test(t)
}
```
**Table-driven variant (authz matrix):**
```go
func TestPostsListAuthz(t *testing.T) {
for _, tc := range []struct {
name string
auth string // "", "users:alice", "users:bob", "_superusers:root"
expect int
}{
{"guest gets public posts", "", 200},
{"authed gets own + public", "users:alice", 200},
{"superuser sees everything", "_superusers:root",200},
} {
t.Run(tc.name, func(t *testing.T) {
app, _ := tests.NewTestApp(testDataDir)
defer app.Cleanup()
hooks.RegisterPostHooks(app)
tests.ApiScenario{
Method: http.MethodGet,
URL: "/api/collections/posts/records",
Headers: authHeaderFor(app, tc.auth),
ExpectedStatus: tc.expect,
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
}.Test(t)
})
}
}
```
**Unit-testing a hook in isolation (no HTTP layer):**
```go
func TestAuditHookRollsBackOnAuditFailure(t *testing.T) {
app, _ := tests.NewTestApp(testDataDir)
defer app.Cleanup()
hooks.RegisterPostHooks(app)
// Delete the audit collection so the hook's Save fails
audit, _ := app.FindCollectionByNameOrId("audit")
_ = app.Delete(audit)
col, _ := app.FindCollectionByNameOrId("posts")
post := core.NewRecord(col)
post.Set("title", "should rollback")
post.Set("slug", "rollback")
if err := app.Save(post); err == nil {
t.Fatal("expected Save to fail because audit hook errored")
}
// Assert the post was NOT persisted (tx rolled back)
_, err := app.FindFirstRecordByFilter("posts", "slug = 'rollback'", nil)
if err == nil {
t.Fatal("post should not exist after rollback")
}
}
```
**Rules:**
- **Always `defer app.Cleanup()`** - otherwise temp directories leak under `/tmp`.
- **Use a checked-in `test_pb_data/` fixture** with the collections you need. Do not depend on the dev `pb_data/` - tests must be hermetic.
- **Register hooks against the test app**, not against a package-level `app` singleton. The test app is a fresh instance each time.
- **`ExpectedEvents`** asserts that specific hooks fired the expected number of times - use it to catch "hook silently skipped because someone forgot `e.Next()`" regressions.
- **`AfterTestFunc`** runs with the same app instance the scenario used, so you can query the DB to verify side-effects.
- **Parallelize with `t.Parallel()`** - `NewTestApp` gives each goroutine its own copy, so there's no shared state.
- **Tests run pure-Go SQLite** (`modernc.org/sqlite`) - no CGO, no extra setup, works on `go test ./...` out of the box.
- **For JSVM**, there is no equivalent test harness yet - test pb_hooks by booting `tests.NewTestApp` with the `pb_hooks/` directory populated and exercising the router from Go. Pure-JS unit testing of hook bodies requires extracting the logic into a `require()`able module.
Reference: [Testing](https://pocketbase.io/docs/go-testing/) · [tests package GoDoc](https://pkg.go.dev/github.com/pocketbase/pocketbase/tests)

View File

@@ -0,0 +1,74 @@
---
title: Use RunInTransaction with the Scoped txApp, Never the Outer App
impact: CRITICAL
impactDescription: Mixing scoped and outer app inside a transaction silently deadlocks or writes outside the tx
tags: transactions, extending, deadlock, runInTransaction, atomicity
---
## Use RunInTransaction with the Scoped txApp, Never the Outer App
`app.RunInTransaction` (Go) and `$app.runInTransaction` (JS) wrap a block of work in a SQLite write transaction. The callback receives a **transaction-scoped app instance** (`txApp` / `txApp`). Every database call inside the block must go through that scoped instance - reusing the outer `app` / `$app` bypasses the transaction (silent partial writes) or deadlocks (SQLite allows only one writer).
**Incorrect (outer `app` used inside the tx block):**
```go
// ❌ Uses the outer app for the second Save - deadlocks on the writer lock
err := app.RunInTransaction(func(txApp core.App) error {
user := core.NewRecord(usersCol)
user.Set("email", "a@b.co")
if err := txApp.Save(user); err != nil {
return err
}
audit := core.NewRecord(auditCol)
audit.Set("user", user.Id)
return app.Save(audit) // ❌ NOT txApp - blocks forever
})
```
**Correct (always `txApp` inside the block, return errors to roll back):**
```go
err := app.RunInTransaction(func(txApp core.App) error {
user := core.NewRecord(usersCol)
user.Set("email", "a@b.co")
if err := txApp.Save(user); err != nil {
return err // rollback
}
audit := core.NewRecord(auditCol)
audit.Set("user", user.Id)
if err := txApp.Save(audit); err != nil {
return err // rollback
}
return nil // commit
})
if err != nil {
return err
}
```
```javascript
// JSVM - the callback receives the transactional app
$app.runInTransaction((txApp) => {
const user = new Record(txApp.findCollectionByNameOrId("users"));
user.set("email", "a@b.co");
txApp.save(user);
const audit = new Record(txApp.findCollectionByNameOrId("audit"));
audit.set("user", user.id);
txApp.save(audit);
// throw anywhere in this block to roll back the whole tx
});
```
**Rules of the transaction:**
- **Use only `txApp` / the callback's scoped app** inside the block. Capturing the outer `app` defeats the purpose and can deadlock.
- Inside event hooks, `e.App` is already the transactional app when the hook fires inside a tx - prefer it over a captured parent-scope `app` for the same reason.
- Return an error (Go) or `throw` (JS) to roll back. A successful return commits.
- SQLite serializes writers - keep transactions **short**. Do not make HTTP calls, send emails, or wait on external systems inside the block.
- Do not start a transaction inside another transaction on the same app - nested `RunInTransaction` on `txApp` is supported and reuses the existing transaction, but nested calls on the outer `app` will deadlock.
- Hooks (`OnRecordAfterCreateSuccess`, etc.) fired from a `Save` inside a tx run **inside that tx**. Anything they do through `e.App` participates in the rollback; anything they do through a captured outer `app` does not.
Reference: [Go database](https://pocketbase.io/docs/go-database/#transaction) · [JS database](https://pocketbase.io/docs/js-database/#transaction)

View File

@@ -0,0 +1,188 @@
---
title: Generate File URLs Correctly
impact: MEDIUM
impactDescription: Proper URLs with thumbnails and access control
tags: files, urls, thumbnails, serving
---
## Generate File URLs Correctly
Use the SDK's `getURL` method to generate proper file URLs with thumbnail support and access tokens for protected files.
**Incorrect (manually constructing URLs):**
```javascript
// Hardcoded URL construction - brittle
const imageUrl = `http://localhost:8090/api/files/${record.collectionId}/${record.id}/${record.image}`;
// Missing token for protected files
const privateUrl = pb.files.getURL(record, record.document);
// Returns URL but file access denied if protected!
// Wrong thumbnail syntax
const thumb = `${imageUrl}?thumb=100x100`; // Wrong format
```
**Correct (using SDK methods):**
```javascript
// Basic file URL
const imageUrl = pb.files.getURL(record, record.image);
// Returns: http://host/api/files/COLLECTION/RECORD_ID/filename.jpg
// With thumbnail (for images only)
const thumbUrl = pb.files.getURL(record, record.image, {
thumb: '100x100' // Width x Height
});
// Thumbnail options
const thumbs = {
square: pb.files.getURL(record, record.image, { thumb: '100x100' }),
fit: pb.files.getURL(record, record.image, { thumb: '100x0' }), // Fit width
fitHeight: pb.files.getURL(record, record.image, { thumb: '0x100' }), // Fit height
crop: pb.files.getURL(record, record.image, { thumb: '100x100t' }), // Top crop
cropBottom: pb.files.getURL(record, record.image, { thumb: '100x100b' }), // Bottom
force: pb.files.getURL(record, record.image, { thumb: '100x100f' }), // Force exact
};
// Protected files (require auth)
async function getProtectedFileUrl(record, filename) {
// Get file access token (valid for limited time)
const token = await pb.files.getToken();
// Include token in URL
return pb.files.getURL(record, filename, { token });
}
// Example with protected document
async function downloadDocument(record) {
const token = await pb.files.getToken();
const url = pb.files.getURL(record, record.document, { token });
// Token is appended: ...?token=xxx
window.open(url, '_blank');
}
```
**React component example:**
```jsx
function UserAvatar({ user, size = 50 }) {
if (!user.avatar) {
return <DefaultAvatar size={size} />;
}
const avatarUrl = pb.files.getURL(user, user.avatar, {
thumb: `${size}x${size}`
});
return (
<img
src={avatarUrl}
alt={user.name}
width={size}
height={size}
loading="lazy"
/>
);
}
function ImageGallery({ record }) {
// Record has multiple images
const images = record.images || [];
return (
<div className="gallery">
{images.map((filename, index) => (
<img
key={filename}
src={pb.files.getURL(record, filename, { thumb: '200x200' })}
onClick={() => openFullSize(record, filename)}
loading="lazy"
/>
))}
</div>
);
}
function openFullSize(record, filename) {
const fullUrl = pb.files.getURL(record, filename);
window.open(fullUrl, '_blank');
}
```
**Handling file URLs in lists:**
```javascript
// Efficiently generate URLs for list of records
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author'
});
const postsWithUrls = posts.items.map(post => ({
...post,
thumbnailUrl: post.image
? pb.files.getURL(post, post.image, { thumb: '300x200' })
: null,
authorAvatarUrl: post.expand?.author?.avatar
? pb.files.getURL(post.expand.author, post.expand.author.avatar, { thumb: '40x40' })
: null
}));
```
**Thumbnail format reference:**
| Format | Description |
|--------|-------------|
| `WxH` | Fit within dimensions |
| `Wx0` | Fit width, auto height |
| `0xH` | Auto width, fit height |
| `WxHt` | Crop from top |
| `WxHb` | Crop from bottom |
| `WxHf` | Force exact dimensions |
**Performance and caching:**
```javascript
// File URLs are effectively immutable (randomized filenames on upload).
// This makes them ideal for aggressive caching.
// Configure Cache-Control via reverse proxy (Nginx/Caddy):
// location /api/files/ { add_header Cache-Control "public, immutable, max-age=86400"; }
// Thumbnails are generated on first request and cached by PocketBase.
// Pre-generate expected thumb sizes after upload to avoid cold-start latency:
async function uploadWithThumbs(record, file) {
const updated = await pb.collection('posts').update(record.id, { image: file });
// Pre-warm thumbnail cache by requesting expected sizes
const sizes = ['100x100', '300x200', '800x600'];
await Promise.all(sizes.map(size =>
fetch(pb.files.getURL(updated, updated.image, { thumb: size }))
));
return updated;
}
```
**S3 file serving optimization:**
When using S3 storage, PocketBase proxies all file requests through the server. For better performance with public files, serve directly from your S3 CDN:
```javascript
// Default: All file requests proxy through PocketBase
const url = pb.files.getURL(record, record.image);
// -> https://myapp.com/api/files/COLLECTION/ID/filename.jpg (proxied)
// For public files with S3 + CDN, construct CDN URL directly:
const cdnBase = 'https://cdn.myapp.com'; // Your S3 CDN domain
const cdnUrl = `${cdnBase}/${record.collectionId}/${record.id}/${record.image}`;
// Bypasses PocketBase, served directly from CDN edge
// NOTE: This only works for public files (no access token needed).
// Protected files must go through PocketBase for token validation.
```
Reference: [PocketBase Files](https://pocketbase.io/docs/files-handling/)
> **Note (JS SDK v0.26.7):** `pb.files.getURL()` now serializes query parameters the same way as the fetch methods — passing `null` or `undefined` as a query param value is silently skipped from the generated URL, so you no longer need to guard optional params before passing them to `getURL()`.

View File

@@ -0,0 +1,168 @@
---
title: Upload Files Correctly
impact: MEDIUM
impactDescription: Reliable uploads with progress tracking and validation
tags: files, upload, storage, attachments
---
## Upload Files Correctly
File uploads can use plain objects or FormData. Handle large files properly with progress tracking and appropriate error handling.
**Incorrect (naive file upload):**
```javascript
// Missing error handling
async function uploadFile(file) {
await pb.collection('documents').create({
title: file.name,
file: file
});
// No error handling, no progress feedback
}
// Uploading without validation
async function uploadAvatar(file) {
await pb.collection('users').update(userId, {
avatar: file // No size/type check - might fail server-side
});
}
// Base64 upload (inefficient)
async function uploadImage(base64) {
await pb.collection('images').create({
image: base64 // Wrong! PocketBase expects File/Blob
});
}
```
**Correct (proper file uploads):**
```javascript
// Basic upload with object (auto-converts to FormData)
async function uploadDocument(file, metadata) {
try {
const record = await pb.collection('documents').create({
title: metadata.title,
description: metadata.description,
file: file // File object from input
});
return record;
} catch (error) {
if (error.response?.data?.file) {
throw new Error(`File error: ${error.response.data.file.message}`);
}
throw error;
}
}
// Upload multiple files
async function uploadGallery(files, albumId) {
const record = await pb.collection('albums').update(albumId, {
images: files // Array of File objects
});
return record;
}
// FormData for more control
async function uploadWithProgress(file, onProgress) {
const formData = new FormData();
formData.append('title', file.name);
formData.append('file', file);
// Using fetch directly for progress (SDK doesn't expose progress)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.open('POST', `${pb.baseURL}/api/collections/documents/records`);
xhr.setRequestHeader('Authorization', pb.authStore.token);
xhr.send(formData);
});
}
// Client-side validation before upload
function validateFile(file, options = {}) {
const {
maxSize = 10 * 1024 * 1024, // 10MB default
allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'],
maxNameLength = 100
} = options;
const errors = [];
if (file.size > maxSize) {
errors.push(`File too large. Max: ${maxSize / 1024 / 1024}MB`);
}
if (!allowedTypes.includes(file.type)) {
errors.push(`Invalid file type: ${file.type}`);
}
if (file.name.length > maxNameLength) {
errors.push(`Filename too long`);
}
return { valid: errors.length === 0, errors };
}
// Complete upload flow
async function handleFileUpload(inputEvent) {
const file = inputEvent.target.files[0];
if (!file) return;
// Validate
const validation = validateFile(file, {
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png']
});
if (!validation.valid) {
showError(validation.errors.join(', '));
return;
}
// Upload with progress
try {
setUploading(true);
const record = await uploadWithProgress(file, setProgress);
showSuccess('Upload complete!');
return record;
} catch (error) {
showError(error.message);
} finally {
setUploading(false);
}
}
```
**Deleting files:**
```javascript
// Remove specific file(s) from record
await pb.collection('albums').update(albumId, {
'images-': ['filename1.jpg', 'filename2.jpg'] // Remove these files
});
// Clear all files
await pb.collection('documents').update(docId, {
file: null // Removes the file
});
```
Reference: [PocketBase File Upload](https://pocketbase.io/docs/files-handling/)

View File

@@ -0,0 +1,187 @@
---
title: Validate File Uploads
impact: MEDIUM
impactDescription: Prevents invalid uploads, improves security and UX
tags: files, validation, security, upload
---
## Validate File Uploads
Validate files on both client and server side. Client validation improves UX; server validation (via collection settings) enforces security.
**Incorrect (no validation):**
```javascript
// Accepting any file without checks
async function uploadFile(file) {
return pb.collection('uploads').create({ file });
// Could upload 1GB executable!
}
// Only checking extension (easily bypassed)
function validateFile(file) {
const ext = file.name.split('.').pop();
return ['jpg', 'png'].includes(ext);
// User can rename virus.exe to virus.jpg
}
// Client-only validation (can be bypassed)
async function uploadAvatar(file) {
if (file.size > 1024 * 1024) {
throw new Error('Too large');
}
// Attacker can bypass this with dev tools
return pb.collection('users').update(userId, { avatar: file });
}
```
**Correct (comprehensive validation):**
```javascript
// 1. Configure server-side validation in collection settings
// In Admin UI or via API:
const collectionConfig = {
schema: [
{
name: 'avatar',
type: 'file',
options: {
maxSelect: 1, // Single file only
maxSize: 5242880, // 5MB in bytes
mimeTypes: [ // Allowed types
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
],
thumbs: ['100x100', '200x200'] // Auto-generate thumbnails
}
},
{
name: 'documents',
type: 'file',
options: {
maxSelect: 10,
maxSize: 10485760, // 10MB
mimeTypes: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
]
}
}
]
};
// 2. Client-side validation for better UX
const FILE_CONSTRAINTS = {
avatar: {
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
maxFiles: 1
},
documents: {
maxSize: 10 * 1024 * 1024,
allowedTypes: ['application/pdf'],
maxFiles: 10
}
};
function validateFiles(files, constraintKey) {
const constraints = FILE_CONSTRAINTS[constraintKey];
const errors = [];
const validFiles = [];
if (files.length > constraints.maxFiles) {
errors.push(`Maximum ${constraints.maxFiles} file(s) allowed`);
}
for (const file of files) {
const fileErrors = [];
// Check size
if (file.size > constraints.maxSize) {
const maxMB = constraints.maxSize / 1024 / 1024;
fileErrors.push(`${file.name}: exceeds ${maxMB}MB limit`);
}
// Check MIME type (more reliable than extension, but still spoofable)
// Client-side file.type is based on extension, not file content.
// Always enforce mimeTypes in PocketBase collection settings for server-side validation.
if (!constraints.allowedTypes.includes(file.type)) {
fileErrors.push(`${file.name}: invalid file type (${file.type || 'unknown'})`);
}
// Check for suspicious patterns
if (file.name.includes('..') || file.name.includes('/')) {
fileErrors.push(`${file.name}: invalid filename`);
}
if (fileErrors.length === 0) {
validFiles.push(file);
} else {
errors.push(...fileErrors);
}
}
return {
valid: errors.length === 0,
errors,
validFiles
};
}
// 3. Complete upload with validation
async function handleAvatarUpload(inputElement) {
const files = Array.from(inputElement.files);
// Client validation
const validation = validateFiles(files, 'avatar');
if (!validation.valid) {
showErrors(validation.errors);
return null;
}
// Upload (server will also validate)
try {
const updated = await pb.collection('users').update(userId, {
avatar: validation.validFiles[0]
});
showSuccess('Avatar updated!');
return updated;
} catch (error) {
// Handle server validation errors
if (error.response?.data?.avatar) {
showError(error.response.data.avatar.message);
} else {
showError('Upload failed');
}
return null;
}
}
// 4. Image-specific validation
async function validateImage(file, options = {}) {
const { minWidth = 0, minHeight = 0, maxWidth = Infinity, maxHeight = Infinity } = options;
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const errors = [];
if (img.width < minWidth || img.height < minHeight) {
errors.push(`Image must be at least ${minWidth}x${minHeight}px`);
}
if (img.width > maxWidth || img.height > maxHeight) {
errors.push(`Image must be at most ${maxWidth}x${maxHeight}px`);
}
resolve({ valid: errors.length === 0, errors, width: img.width, height: img.height });
};
img.onerror = () => resolve({ valid: false, errors: ['Invalid image file'] });
img.src = URL.createObjectURL(file);
});
}
```
Reference: [PocketBase Files Configuration](https://pocketbase.io/docs/files-handling/)

View File

@@ -0,0 +1,164 @@
---
title: Use Back-Relations for Inverse Lookups
impact: HIGH
impactDescription: Fetch related records without separate queries
tags: query, relations, back-relations, expand, inverse
---
## Use Back-Relations for Inverse Lookups
Back-relations allow you to expand records that reference the current record, enabling inverse lookups in a single request. Use the `collectionName_via_fieldName` syntax.
**Incorrect (manual inverse lookup):**
```javascript
// Fetching a user, then their posts separately
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId);
// Extra request for posts
const posts = await pb.collection('posts').getList(1, 100, {
filter: pb.filter('author = {:userId}', { userId })
});
return { ...user, posts: posts.items };
}
// 2 API calls
// Fetching a post, then its comments
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId);
const comments = await pb.collection('comments').getFullList({
filter: pb.filter('post = {:postId}', { postId }),
expand: 'author'
});
return { ...post, comments };
}
// 2 API calls
```
**Correct (using back-relation expand):**
```javascript
// Expand posts that reference this user
// posts collection has: author (relation to users)
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author' // collectionName_via_fieldName
});
console.log('User:', user.name);
console.log('Posts:', user.expand?.posts_via_author);
return user;
}
// 1 API call!
// Expand comments that reference this post
// comments collection has: post (relation to posts)
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId, {
expand: 'comments_via_post,comments_via_post.author'
});
const comments = post.expand?.comments_via_post || [];
comments.forEach(comment => {
console.log(`${comment.expand?.author?.name}: ${comment.content}`);
});
return post;
}
// 1 API call with nested expansion!
// Multiple back-relations
async function getUserWithAllContent(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author,comments_via_author,likes_via_user'
});
return {
user,
posts: user.expand?.posts_via_author || [],
comments: user.expand?.comments_via_author || [],
likes: user.expand?.likes_via_user || []
};
}
```
**Back-relation syntax:**
```
{referencing_collection}_via_{relation_field}
Examples:
- posts_via_author -> posts where author = current record
- comments_via_post -> comments where post = current record
- order_items_via_order -> order_items where order = current record
- team_members_via_team -> team_members where team = current record
```
**Nested back-relations:**
```javascript
// Get user with posts and each post's comments
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author.comments_via_post'
});
// Access nested data
const posts = user.expand?.posts_via_author || [];
posts.forEach(post => {
console.log('Post:', post.title);
const comments = post.expand?.comments_via_post || [];
comments.forEach(c => console.log(' Comment:', c.content));
});
```
**Important considerations:**
```javascript
// Back-relations always return arrays, even if the relation field
// is marked as single (maxSelect: 1)
// Limited to 1000 records per back-relation
// For more, use separate paginated query
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author'
});
// If user has 1500 posts, only first 1000 are included
// For large datasets, use paginated approach
async function getUserPostsPaginated(userId, page = 1) {
return pb.collection('posts').getList(page, 50, {
filter: pb.filter('author = {:userId}', { userId }),
sort: '-created'
});
}
```
**Use in list queries:**
```javascript
// Get all users with their post counts
// (Use view collection for actual counts)
const users = await pb.collection('users').getList(1, 20, {
expand: 'posts_via_author'
});
users.items.forEach(user => {
const postCount = user.expand?.posts_via_author?.length || 0;
console.log(`${user.name}: ${postCount} posts`);
});
```
**When to use back-relations vs separate queries:**
| Scenario | Approach |
|----------|----------|
| < 1000 related records | Back-relation expand |
| Need pagination | Separate query with filter |
| Need sorting/filtering | Separate query |
| Just need count | View collection |
| Display in list | Back-relation (if small) |
Reference: [PocketBase Back-Relations](https://pocketbase.io/docs/working-with-relations/#back-relation-expand)

View File

@@ -0,0 +1,174 @@
---
title: Use Batch Operations for Multiple Writes
impact: HIGH
impactDescription: Atomic transactions, 10x fewer API calls, consistent state
tags: query, batch, transactions, performance
---
## Use Batch Operations for Multiple Writes
Batch operations combine multiple create/update/delete operations into a single atomic transaction. This ensures consistency and dramatically reduces API calls.
**Incorrect (individual requests):**
```javascript
// Creating multiple records individually
async function createOrderWithItems(order, items) {
// If any fails, partial data remains!
const createdOrder = await pb.collection('orders').create(order);
for (const item of items) {
await pb.collection('order_items').create({
...item,
order: createdOrder.id
});
}
// 1 + N API calls, not atomic
}
// Updating multiple records
async function updatePrices(products) {
for (const product of products) {
await pb.collection('products').update(product.id, {
price: product.newPrice
});
}
// N API calls, some might fail leaving inconsistent state
}
// Mixed operations
async function transferFunds(fromId, toId, amount) {
// NOT ATOMIC - can leave invalid state!
await pb.collection('accounts').update(fromId, { 'balance-': amount });
// If this fails, money disappears!
await pb.collection('accounts').update(toId, { 'balance+': amount });
}
```
**Correct (using batch operations):**
```javascript
// Atomic batch create
async function createOrderWithItems(order, items) {
const batch = pb.createBatch();
// Pre-generate order ID so items can reference it in the same batch
// PocketBase accepts custom IDs (15-char alphanumeric)
const orderId = crypto.randomUUID().replaceAll('-', '').slice(0, 15);
// Queue order creation with known ID
batch.collection('orders').create({ ...order, id: orderId });
// Queue all items referencing the pre-generated order ID
items.forEach(item => {
batch.collection('order_items').create({
...item,
order: orderId
});
});
// Execute atomically
const results = await batch.send();
// All succeed or all fail together
return {
order: results[0],
items: results.slice(1)
};
}
// Batch updates
async function updatePrices(products) {
const batch = pb.createBatch();
products.forEach(product => {
batch.collection('products').update(product.id, {
price: product.newPrice
});
});
const results = await batch.send();
// 1 API call, atomic
return results;
}
// Batch upsert (create or update)
async function syncProducts(products) {
const batch = pb.createBatch();
products.forEach(product => {
batch.collection('products').upsert({
id: product.sku, // Use SKU as ID for upsert matching
name: product.name,
price: product.price,
stock: product.stock
});
});
return batch.send();
}
// Mixed operations in transaction
// NOTE: Batch operations respect API rules per-operation, but ensure your
// business logic validates inputs (e.g., sufficient balance) server-side
// via hooks or API rules to prevent unauthorized transfers.
async function transferFunds(fromId, toId, amount) {
const batch = pb.createBatch();
batch.collection('accounts').update(fromId, { 'balance-': amount });
batch.collection('accounts').update(toId, { 'balance+': amount });
// Create audit record
batch.collection('transfers').create({
from: fromId,
to: toId,
amount,
timestamp: new Date()
});
// All three operations atomic
const [fromAccount, toAccount, transfer] = await batch.send();
return { fromAccount, toAccount, transfer };
}
// Batch delete
async function deletePostWithComments(postId) {
// First get comment IDs
const comments = await pb.collection('comments').getFullList({
filter: pb.filter('post = {:postId}', { postId }),
fields: 'id'
});
const batch = pb.createBatch();
// Queue all deletions
comments.forEach(comment => {
batch.collection('comments').delete(comment.id);
});
batch.collection('posts').delete(postId);
await batch.send();
// Post and all comments deleted atomically
}
```
**Batch operation limits:**
- **Must be enabled first** in Dashboard > Settings > Application (disabled by default; returns 403 otherwise)
- Operations execute in a single database transaction
- All succeed or all rollback
- Respects API rules for each operation
- Configurable limits: `maxRequests`, `timeout`, and `maxBodySize` (set in Dashboard)
- **Avoid large file uploads** in batches over slow networks -- they block the entire transaction
- Avoid custom hooks that call slow external APIs within batch operations
**When to use batch:**
| Scenario | Use Batch? |
|----------|-----------|
| Creating parent + children | Yes |
| Bulk import/update | Yes |
| Financial transactions | Yes |
| Single record operations | No |
| Independent operations | Optional |
Reference: [PocketBase Batch API](https://pocketbase.io/docs/api-records/#batch-operations)

View File

@@ -0,0 +1,143 @@
---
title: Expand Relations Efficiently
impact: HIGH
impactDescription: Eliminates N+1 queries, reduces API calls by 90%+
tags: query, relations, expand, joins, performance
---
## Expand Relations Efficiently
Use the `expand` parameter to fetch related records in a single request. This eliminates N+1 query problems and dramatically reduces API calls.
**Incorrect (N+1 queries):**
```javascript
// Fetching posts then authors separately - N+1 problem
async function getPostsWithAuthors() {
const posts = await pb.collection('posts').getList(1, 20);
// N additional requests for N posts!
for (const post of posts.items) {
post.authorData = await pb.collection('users').getOne(post.author);
}
return posts;
}
// 21 API calls for 20 posts!
// Even worse with multiple relations
async function getPostsWithAll() {
const posts = await pb.collection('posts').getList(1, 20);
for (const post of posts.items) {
post.author = await pb.collection('users').getOne(post.author);
post.category = await pb.collection('categories').getOne(post.category);
post.tags = await Promise.all(
post.tags.map(id => pb.collection('tags').getOne(id))
);
}
// 60+ API calls!
}
```
**Correct (using expand):**
```javascript
// Single request with expanded relations
async function getPostsWithAuthors() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author'
});
// Access expanded data
posts.items.forEach(post => {
console.log('Author:', post.expand?.author?.name);
});
return posts;
}
// 1 API call!
// Multiple relations
async function getPostsWithAll() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category,tags'
});
posts.items.forEach(post => {
console.log('Author:', post.expand?.author?.name);
console.log('Category:', post.expand?.category?.name);
console.log('Tags:', post.expand?.tags?.map(t => t.name));
});
}
// Still just 1 API call!
// Nested expansion (up to 6 levels)
async function getPostsWithNestedData() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author.profile,category.parent,comments_via_post.author'
});
posts.items.forEach(post => {
// Nested relations
console.log('Author profile:', post.expand?.author?.expand?.profile);
console.log('Parent category:', post.expand?.category?.expand?.parent);
// Back-relations (comments that reference this post)
console.log('Comments:', post.expand?.['comments_via_post']);
});
}
// Back-relation expansion
// If comments collection has a 'post' relation field pointing to posts
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId, {
expand: 'comments_via_post,comments_via_post.author'
});
// Access comments that reference this post
const comments = post.expand?.['comments_via_post'] || [];
comments.forEach(comment => {
console.log(`${comment.expand?.author?.name}: ${comment.text}`);
});
return post;
}
```
**Expand syntax:**
| Syntax | Description |
|--------|-------------|
| `expand: 'author'` | Single relation |
| `expand: 'author,tags'` | Multiple relations |
| `expand: 'author.profile'` | Nested relation (2 levels) |
| `expand: 'comments_via_post'` | Back-relation (records pointing to this) |
**Handling optional expand data:**
```javascript
// Always use optional chaining - expand may be undefined
const authorName = post.expand?.author?.name || 'Unknown';
// Type-safe access with TypeScript
interface Post {
id: string;
title: string;
author: string; // Relation ID
expand?: {
author?: User;
};
}
const posts = await pb.collection('posts').getList<Post>(1, 20, {
expand: 'author'
});
```
**Limitations:**
- Maximum 6 levels of nesting
- Respects API rules on expanded collections
- Large expansions may impact performance
Reference: [PocketBase Expand](https://pocketbase.io/docs/api-records/#expand)

View File

@@ -0,0 +1,118 @@
---
title: Select Only Required Fields
impact: MEDIUM
impactDescription: Reduces payload size, improves response time
tags: query, fields, performance, bandwidth
---
## Select Only Required Fields
Use the `fields` parameter to request only the data you need. This reduces bandwidth and can improve query performance, especially with large text or file fields.
**Incorrect (fetching everything):**
```javascript
// Fetching all fields when only a few are needed
const posts = await pb.collection('posts').getList(1, 20);
// Returns: id, title, content (10KB), thumbnail, author, tags, created, updated...
// Only displaying titles in a list
posts.items.forEach(post => {
renderListItem(post.title); // Only using title!
});
// Wasted bandwidth on content, thumbnail URLs, etc.
// Fetching user data with large profile fields
const users = await pb.collection('users').getFullList();
// Includes: avatar (file), bio (text), settings (json)...
// When you only need names for a dropdown
```
**Correct (selecting specific fields):**
```javascript
// Select only needed fields
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title,created'
});
// Returns only: { id, title, created }
// For a dropdown/autocomplete
const users = await pb.collection('users').getFullList({
fields: 'id,name,avatar'
});
// Include expanded relation fields
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author',
fields: 'id,title,expand.author.name,expand.author.avatar'
});
// Returns: { id, title, expand: { author: { name, avatar } } }
// Wildcard for all direct fields, specific for expand
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category',
fields: '*,expand.author.name,expand.category.name'
});
// All post fields + only name from expanded relations
```
**Using excerpt modifier:**
```javascript
// Get truncated text content
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title,content:excerpt(200,true)'
});
// content is truncated to 200 chars with "..." appended
// Multiple excerpts
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title:excerpt(50),content:excerpt(150,true)'
});
// Excerpt syntax: field:excerpt(maxLength, withEllipsis?)
// - maxLength: maximum characters
// - withEllipsis: append "..." if truncated (default: false)
```
**Common field selection patterns:**
```javascript
// List view - minimal data
const listFields = 'id,title,thumbnail,author,created';
// Card view - slightly more
const cardFields = 'id,title,content:excerpt(200,true),thumbnail,author,created';
// Detail view - most fields
const detailFields = '*,expand.author.name,expand.author.avatar';
// Autocomplete - just id and display text
const autocompleteFields = 'id,name';
// Table export - specific columns
const exportFields = 'id,email,name,created,status';
// Usage
async function getPostsList() {
return pb.collection('posts').getList(1, 20, {
fields: listFields,
expand: 'author'
});
}
```
**Performance impact:**
| Field Type | Impact of Selecting |
|------------|-------------------|
| text/editor | High (can be large) |
| file | Medium (URLs generated) |
| json | Medium (can be large) |
| relation | Low (just IDs) |
| number/bool | Low |
**Note:** Field selection happens after data is fetched from database, so it primarily saves bandwidth, not database queries. For database-level optimization, ensure proper indexes.
Reference: [PocketBase Fields Parameter](https://pocketbase.io/docs/api-records/#fields)

View File

@@ -0,0 +1,158 @@
---
title: Use getFirstListItem for Single Record Lookups
impact: MEDIUM
impactDescription: Cleaner code, automatic error handling for not found
tags: query, lookup, find, getFirstListItem
---
## Use getFirstListItem for Single Record Lookups
Use `getFirstListItem()` when you need to find a single record by a field value other than ID. It's cleaner than `getList()` with limit 1 and provides proper error handling.
**Incorrect (manual single-record lookup):**
```javascript
// Using getList with limit 1 - verbose
async function findUserByEmail(email) {
const result = await pb.collection('users').getList(1, 1, {
filter: pb.filter('email = {:email}', { email })
});
if (result.items.length === 0) {
throw new Error('User not found');
}
return result.items[0];
}
// Using getFullList then filtering - wasteful
async function findUserByUsername(username) {
const users = await pb.collection('users').getFullList({
filter: pb.filter('username = {:username}', { username })
});
return users[0]; // Might be undefined!
}
// Fetching by ID when you have a different identifier
async function findProductBySku(sku) {
// Wrong: getOne expects the record ID
const product = await pb.collection('products').getOne(sku); // Fails!
}
```
**Correct (using getFirstListItem):**
```javascript
// Clean single-record lookup by any field
async function findUserByEmail(email) {
try {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email })
);
return user;
} catch (error) {
if (error.status === 404) {
return null; // Not found
}
throw error;
}
}
// Lookup by unique field
async function findProductBySku(sku) {
return pb.collection('products').getFirstListItem(
pb.filter('sku = {:sku}', { sku })
);
}
// Lookup with expand
async function findOrderByNumber(orderNumber) {
return pb.collection('orders').getFirstListItem(
pb.filter('orderNumber = {:num}', { num: orderNumber }),
{ expand: 'customer,items' }
);
}
// Complex filter conditions
async function findActiveSubscription(userId) {
return pb.collection('subscriptions').getFirstListItem(
pb.filter(
'user = {:userId} && status = "active" && expiresAt > @now',
{ userId }
)
);
}
// With field selection
async function getUserIdByEmail(email) {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email }),
{ fields: 'id' }
);
return user.id;
}
```
**Comparison with getOne:**
```javascript
// getOne - fetch by record ID
const post = await pb.collection('posts').getOne('abc123');
// getFirstListItem - fetch by any filter (use pb.filter for safe binding)
const post = await pb.collection('posts').getFirstListItem(
pb.filter('slug = {:slug}', { slug: 'hello-world' })
);
const user = await pb.collection('users').getFirstListItem(
pb.filter('username = {:name}', { name: 'john' })
);
const order = await pb.collection('orders').getFirstListItem(
pb.filter('orderNumber = {:num}', { num: 12345 })
);
```
**Error handling:**
```javascript
// getFirstListItem throws 404 if no match found
try {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email })
);
return user;
} catch (error) {
if (error.status === 404) {
// No matching record - handle appropriately
return null;
}
// Other error (network, auth, etc.)
throw error;
}
// Wrapper function for optional lookup
async function findFirst(collection, filter, options = {}) {
try {
return await pb.collection(collection).getFirstListItem(filter, options);
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
// Usage
const user = await findFirst('users', pb.filter('email = {:e}', { e: email }));
if (!user) {
console.log('User not found');
}
```
**When to use each method:**
| Method | Use When |
|--------|----------|
| `getOne(id)` | You have the record ID |
| `getFirstListItem(filter)` | Finding by unique field (email, slug, sku) |
| `getList(1, 1, { filter })` | Need pagination metadata |
| `getFullList({ filter })` | Expecting multiple results |
Reference: [PocketBase Records API](https://pocketbase.io/docs/api-records/)

View File

@@ -0,0 +1,138 @@
---
title: Prevent N+1 Query Problems
impact: HIGH
impactDescription: Reduces API calls from N+1 to 1-2, dramatically faster page loads
tags: query, performance, n-plus-one, optimization
---
## Prevent N+1 Query Problems
N+1 queries occur when you fetch a list of records, then make additional requests for each record's related data. This pattern causes severe performance issues at scale.
**Incorrect (N+1 patterns):**
```javascript
// Classic N+1: fetching related data in a loop
async function getPostsWithDetails() {
const posts = await pb.collection('posts').getList(1, 20); // 1 query
for (const post of posts.items) {
// N additional queries!
post.author = await pb.collection('users').getOne(post.author);
post.category = await pb.collection('categories').getOne(post.category);
}
// Total: 1 + 20 + 20 = 41 queries for 20 posts
}
// N+1 with Promise.all (faster but still N+1)
async function getPostsParallel() {
const posts = await pb.collection('posts').getList(1, 20);
await Promise.all(posts.items.map(async post => {
post.author = await pb.collection('users').getOne(post.author);
}));
// Still 21 API calls, just parallel
}
// Hidden N+1 in rendering
function PostList({ posts }) {
return posts.map(post => (
<PostCard
post={post}
author={useAuthor(post.author)} // Each triggers a fetch!
/>
));
}
```
**Correct (eliminate N+1):**
```javascript
// Solution 1: Use expand for relations
async function getPostsWithDetails() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category,tags'
});
// All data in one request
posts.items.forEach(post => {
console.log(post.expand?.author?.name);
console.log(post.expand?.category?.name);
});
// Total: 1 query
}
// Solution 2: Batch fetch related records
async function getPostsWithAuthorsBatch() {
const posts = await pb.collection('posts').getList(1, 20);
// Collect unique author IDs
const authorIds = [...new Set(posts.items.map(p => p.author))];
// Single query for all authors (use pb.filter for safe binding)
const filter = authorIds.map(id => pb.filter('id = {:id}', { id })).join(' || ');
const authors = await pb.collection('users').getList(1, authorIds.length, {
filter
});
// Create lookup map
const authorMap = Object.fromEntries(
authors.items.map(a => [a.id, a])
);
// Attach to posts
posts.items.forEach(post => {
post.authorData = authorMap[post.author];
});
// Total: 2 queries regardless of post count
}
// Solution 3: Use view collection for complex joins
// Create a view that joins posts with authors:
// SELECT p.*, u.name as author_name, u.avatar as author_avatar
// FROM posts p LEFT JOIN users u ON p.author = u.id
async function getPostsFromView() {
const posts = await pb.collection('posts_with_authors').getList(1, 20);
// Single query, data already joined
}
// Solution 4: Back-relations with expand
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author' // All posts by this user
});
console.log('Posts by user:', user.expand?.posts_via_author);
// 1 query gets user + all their posts
}
```
**Detecting N+1 in your code:**
```javascript
// Add request logging to detect N+1
let requestCount = 0;
pb.beforeSend = (url, options) => {
requestCount++;
console.log(`Request #${requestCount}: ${options.method} ${url}`);
return { url, options };
};
// Monitor during development
async function loadPage() {
requestCount = 0;
await loadAllData();
console.log(`Total requests: ${requestCount}`);
// If this is >> number of records, you have N+1
}
```
**Prevention checklist:**
- [ ] Always use `expand` for displaying related data
- [ ] Never fetch related records in loops
- [ ] Batch fetch when expand isn't available
- [ ] Consider view collections for complex joins
- [ ] Monitor request counts during development
Reference: [PocketBase Expand](https://pocketbase.io/docs/api-records/#expand)

View File

@@ -0,0 +1,114 @@
---
title: Use Efficient Pagination Strategies
impact: HIGH
impactDescription: 10-100x faster list queries on large collections
tags: query, pagination, performance, lists
---
## Use Efficient Pagination Strategies
Pagination impacts performance significantly. Use `skipTotal` for large datasets, cursor-based pagination for infinite scroll, and appropriate page sizes.
**Incorrect (inefficient pagination):**
```javascript
// Fetching all records - memory and performance disaster
const allPosts = await pb.collection('posts').getFullList();
// Downloads entire table, crashes on large datasets
// Default pagination without skipTotal
const posts = await pb.collection('posts').getList(100, 20);
// COUNT(*) runs on every request - slow on large tables
// Using offset for infinite scroll
async function loadMore(page) {
// As page increases, offset queries get slower
return pb.collection('posts').getList(page, 20);
// Page 1000: skips 19,980 rows before returning 20
}
```
**Correct (optimized pagination):**
```javascript
// Use skipTotal for better performance on large collections
const posts = await pb.collection('posts').getList(1, 20, {
skipTotal: true, // Skip COUNT(*) query
sort: '-created'
});
// Returns items without totalItems/totalPages (faster)
// Cursor-based pagination for infinite scroll
async function loadMorePosts(lastCreated = null) {
const filter = lastCreated
? pb.filter('created < {:cursor}', { cursor: lastCreated })
: '';
const result = await pb.collection('posts').getList(1, 20, {
filter,
sort: '-created',
skipTotal: true
});
// Next cursor is the last item's created date
const nextCursor = result.items.length > 0
? result.items[result.items.length - 1].created
: null;
return { items: result.items, nextCursor };
}
// Usage for infinite scroll
let cursor = null;
async function loadNextPage() {
const { items, nextCursor } = await loadMorePosts(cursor);
cursor = nextCursor;
appendToList(items);
}
// Batched fetching when you need all records
async function getAllPostsEfficiently() {
const allPosts = [];
let page = 1;
const perPage = 1000; // Larger batches = fewer requests (max 1000 per API limit)
while (true) {
const result = await pb.collection('posts').getList(page, perPage, {
skipTotal: true
});
allPosts.push(...result.items);
if (result.items.length < perPage) {
break; // No more records
}
page++;
}
return allPosts;
}
// Or use getFullList with batch option
const allPosts = await pb.collection('posts').getFullList({
batch: 1000, // Records per request (default 1000 since JS SDK v0.26.6; max 1000)
sort: '-created'
});
```
**Choose the right approach:**
| Use Case | Approach |
|----------|----------|
| Standard list with page numbers | `getList()` with page/perPage |
| Large dataset, no total needed | `getList()` with `skipTotal: true` |
| Infinite scroll | Cursor-based with `skipTotal: true` |
| Export all data | `getFullList()` with batch size |
| First N records only | `getList(1, N, { skipTotal: true })` |
**Performance tips:**
- Use `skipTotal: true` unless you need page count
- Keep `perPage` reasonable (20-100 for UI, up to 1000 for batch exports)
- Index fields used in sort and filter
- Cursor pagination scales better than offset
Reference: [PocketBase Records API](https://pocketbase.io/docs/api-records/)

View File

@@ -0,0 +1,147 @@
---
title: Authenticate Realtime Connections
impact: MEDIUM
impactDescription: Secure subscriptions respecting API rules
tags: realtime, authentication, security, subscriptions
---
## Authenticate Realtime Connections
Realtime subscriptions respect collection API rules. Ensure the connection is authenticated before subscribing to protected data.
**Incorrect (subscribing without auth context):**
```javascript
// Subscribing before authentication
const pb = new PocketBase('http://127.0.0.1:8090');
// This will fail or return no data if collection requires auth
pb.collection('private_messages').subscribe('*', (e) => {
// Won't receive events - not authenticated!
console.log(e.record);
});
// Later user logs in, but subscription doesn't update
await pb.collection('users').authWithPassword(email, password);
// Existing subscription still unauthenticated!
```
**Correct (authenticated subscriptions):**
```javascript
// Subscribe after authentication
const pb = new PocketBase('http://127.0.0.1:8090');
async function initRealtime() {
// First authenticate
await pb.collection('users').authWithPassword(email, password);
// Now subscribe - will use auth context
pb.collection('private_messages').subscribe('*', (e) => {
// Receives events for messages user can access
console.log('New message:', e.record);
});
}
// Re-subscribe after auth changes
function useAuthenticatedRealtime() {
const [messages, setMessages] = useState([]);
const unsubRef = useRef(null);
// Watch auth changes
useEffect(() => {
const removeListener = pb.authStore.onChange((token, record) => {
// Unsubscribe old connection
if (unsubRef.current) {
unsubRef.current();
unsubRef.current = null;
}
// Re-subscribe with new auth context if logged in
if (record) {
setupSubscription();
} else {
setMessages([]);
}
}, true);
return () => {
removeListener();
if (unsubRef.current) unsubRef.current();
};
}, []);
async function setupSubscription() {
unsubRef.current = await pb.collection('private_messages').subscribe('*', (e) => {
handleMessage(e);
});
}
}
// Handle auth token refresh with realtime
pb.realtime.subscribe('PB_CONNECT', async (e) => {
console.log('Realtime connected');
// Verify auth is still valid
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
// Redirect to login
}
}
});
```
**API rules apply to subscriptions:**
```javascript
// Collection rule: listRule: 'owner = @request.auth.id'
// User A subscribed
await pb.collection('users').authWithPassword('a@test.com', 'password');
pb.collection('notes').subscribe('*', handler);
// Only receives events for notes where owner = User A
// Events from other users' notes are filtered out automatically
```
**Subscription authorization flow:**
1. SSE connection established (no auth check)
2. First subscription triggers authorization
3. Auth token from `pb.authStore` is used
4. Collection rules evaluated for each event
5. Only matching events sent to client
**Handling auth expiration:**
```javascript
// Setup disconnect handler
pb.realtime.onDisconnect = (subscriptions) => {
console.log('Disconnected, had subscriptions:', subscriptions);
// Check if auth expired
if (!pb.authStore.isValid) {
// Token expired - need to re-authenticate
redirectToLogin();
return;
}
// Connection issue - realtime will auto-reconnect
// Re-subscribe after reconnection
pb.realtime.subscribe('PB_CONNECT', () => {
resubscribeAll(subscriptions);
});
};
function resubscribeAll(subscriptions) {
subscriptions.forEach(sub => {
const [collection, topic] = sub.split('/');
pb.collection(collection).subscribe(topic, handlers[sub]);
});
}
```
Reference: [PocketBase Realtime Auth](https://pocketbase.io/docs/api-realtime/)

View File

@@ -0,0 +1,172 @@
---
title: Handle Realtime Events Properly
impact: MEDIUM
impactDescription: Consistent UI state, proper optimistic updates
tags: realtime, events, state-management, ui
---
## Handle Realtime Events Properly
Realtime events should update local state correctly, handle edge cases, and maintain UI consistency.
**Incorrect (naive event handling):**
```javascript
// Blindly appending creates - may add duplicates
pb.collection('posts').subscribe('*', (e) => {
if (e.action === 'create') {
posts.push(e.record); // Might already exist from optimistic update!
}
});
// Not handling own actions
pb.collection('posts').subscribe('*', (e) => {
// User creates post -> optimistic update
// Realtime event arrives -> duplicate!
setPosts(prev => [...prev, e.record]);
});
// Missing action types
pb.collection('posts').subscribe('*', (e) => {
if (e.action === 'create') handleCreate(e);
// Ignoring update and delete!
});
```
**Correct (robust event handling):**
```javascript
// Handle all action types with deduplication
function useRealtimePosts() {
const [posts, setPosts] = useState([]);
const pendingCreates = useRef(new Set());
useEffect(() => {
loadPosts();
const unsub = pb.collection('posts').subscribe('*', (e) => {
switch (e.action) {
case 'create':
// Skip if we created it (optimistic update already applied)
if (pendingCreates.current.has(e.record.id)) {
pendingCreates.current.delete(e.record.id);
return;
}
setPosts(prev => {
// Deduplicate - might already exist
if (prev.some(p => p.id === e.record.id)) return prev;
return [e.record, ...prev];
});
break;
case 'update':
setPosts(prev => prev.map(p =>
p.id === e.record.id ? e.record : p
));
break;
case 'delete':
setPosts(prev => prev.filter(p => p.id !== e.record.id));
break;
}
});
return unsub;
}, []);
async function createPost(data) {
// Optimistic update
const tempId = `temp_${Date.now()}`;
const optimisticPost = { ...data, id: tempId };
setPosts(prev => [optimisticPost, ...prev]);
try {
const created = await pb.collection('posts').create(data);
// Mark as pending so realtime event is ignored
pendingCreates.current.add(created.id);
// Replace optimistic with real
setPosts(prev => prev.map(p =>
p.id === tempId ? created : p
));
return created;
} catch (error) {
// Rollback optimistic update
setPosts(prev => prev.filter(p => p.id !== tempId));
throw error;
}
}
return { posts, createPost };
}
// Batched updates for high-frequency changes
function useRealtimeWithBatching() {
const [posts, setPosts] = useState([]);
const pendingUpdates = useRef([]);
const flushTimeout = useRef(null);
useEffect(() => {
const unsub = pb.collection('posts').subscribe('*', (e) => {
pendingUpdates.current.push(e);
// Batch updates every 100ms
if (!flushTimeout.current) {
flushTimeout.current = setTimeout(() => {
flushUpdates();
flushTimeout.current = null;
}, 100);
}
});
return () => {
unsub();
if (flushTimeout.current) clearTimeout(flushTimeout.current);
};
}, []);
function flushUpdates() {
const updates = pendingUpdates.current;
pendingUpdates.current = [];
setPosts(prev => {
let next = [...prev];
for (const e of updates) {
if (e.action === 'create') {
if (!next.some(p => p.id === e.record.id)) {
next.unshift(e.record);
}
} else if (e.action === 'update') {
next = next.map(p => p.id === e.record.id ? e.record : p);
} else if (e.action === 'delete') {
next = next.filter(p => p.id !== e.record.id);
}
}
return next;
});
}
}
```
**Filtering events:**
```javascript
// Only handle events matching certain criteria
pb.collection('posts').subscribe('*', (e) => {
// Only published posts
if (e.record.status !== 'published') return;
// Only posts by current user
if (e.record.author !== pb.authStore.record?.id) return;
handleEvent(e);
});
// Subscribe with expand to get related data
pb.collection('posts').subscribe('*', (e) => {
// Note: expand data is included in realtime events
// if the subscription options include expand
console.log(e.record.expand?.author?.name);
}, { expand: 'author' });
```
Reference: [PocketBase Realtime Events](https://pocketbase.io/docs/api-realtime/)

View File

@@ -0,0 +1,200 @@
---
title: Handle Realtime Connection Issues
impact: MEDIUM
impactDescription: Reliable realtime even with network interruptions
tags: realtime, reconnection, resilience, offline
---
## Handle Realtime Connection Issues
Realtime connections can disconnect due to network issues or server restarts. Implement proper reconnection handling and state synchronization.
**Incorrect (ignoring connection issues):**
```javascript
// No reconnection handling - stale data after disconnect
pb.collection('posts').subscribe('*', (e) => {
updateUI(e.record);
});
// If connection drops, UI shows stale data indefinitely
// Assuming connection is always stable
function PostList() {
useEffect(() => {
pb.collection('posts').subscribe('*', handleChange);
}, []);
// No awareness of connection state
}
```
**Correct (robust connection handling):**
```javascript
// Monitor connection state
function useRealtimeConnection() {
const [connected, setConnected] = useState(false);
const [lastSync, setLastSync] = useState(null);
useEffect(() => {
// Track connection state
const unsubConnect = pb.realtime.subscribe('PB_CONNECT', (e) => {
console.log('Connected, client ID:', e.clientId);
setConnected(true);
// Re-sync data after reconnection
if (lastSync) {
syncMissedUpdates(lastSync);
}
setLastSync(new Date());
});
// Handle disconnection
pb.realtime.onDisconnect = (activeSubscriptions) => {
console.log('Disconnected');
setConnected(false);
showOfflineIndicator();
};
return () => {
unsubConnect();
};
}, [lastSync]);
return { connected };
}
// Sync missed updates after reconnection
async function syncMissedUpdates(since) {
// Fetch records modified since last sync
const updatedPosts = await pb.collection('posts').getList(1, 100, {
filter: pb.filter('updated > {:since}', { since }),
sort: '-updated'
});
// Merge with local state
updateLocalState(updatedPosts.items);
}
// Full implementation with resilience
class RealtimeManager {
constructor(pb) {
this.pb = pb;
this.subscriptions = new Map();
this.lastSyncTimes = new Map();
this.reconnectAttempts = 0;
this.maxReconnectDelay = 30000;
this.setupConnectionHandlers();
}
setupConnectionHandlers() {
this.pb.realtime.subscribe('PB_CONNECT', () => {
console.log('Realtime connected');
this.reconnectAttempts = 0;
this.onReconnect();
});
this.pb.realtime.onDisconnect = (subs) => {
console.log('Realtime disconnected');
this.scheduleReconnect();
};
}
scheduleReconnect() {
// Exponential backoff with jitter
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
this.maxReconnectDelay
);
this.reconnectAttempts++;
setTimeout(() => {
if (!this.pb.realtime.isConnected) {
this.resubscribeAll();
}
}, delay);
}
async onReconnect() {
// Sync data for each tracked collection
for (const [collection, lastSync] of this.lastSyncTimes) {
await this.syncCollection(collection, lastSync);
}
}
async syncCollection(collection, since) {
try {
const updates = await this.pb.collection(collection).getList(1, 1000, {
filter: this.pb.filter('updated > {:since}', { since }),
sort: 'updated'
});
// Notify subscribers of missed updates
const handler = this.subscriptions.get(collection);
if (handler) {
updates.items.forEach(record => {
handler({ action: 'update', record });
});
}
this.lastSyncTimes.set(collection, new Date());
} catch (error) {
console.error(`Failed to sync ${collection}:`, error);
}
}
async subscribe(collection, handler) {
this.subscriptions.set(collection, handler);
this.lastSyncTimes.set(collection, new Date());
return this.pb.collection(collection).subscribe('*', (e) => {
this.lastSyncTimes.set(collection, new Date());
handler(e);
});
}
async resubscribeAll() {
// Refresh auth token before resubscribing to ensure valid credentials
if (this.pb.authStore.isValid) {
try {
await this.pb.collection('users').authRefresh();
} catch {
this.pb.authStore.clear();
}
}
for (const [collection, handler] of this.subscriptions) {
this.pb.collection(collection).subscribe('*', handler);
}
}
}
// Usage
const realtime = new RealtimeManager(pb);
await realtime.subscribe('posts', handlePostChange);
```
**Connection timeout handling:**
```javascript
// Server sends disconnect after 5 min of no messages
// SDK auto-reconnects, but you can handle it explicitly
let lastHeartbeat = Date.now();
pb.realtime.subscribe('PB_CONNECT', () => {
lastHeartbeat = Date.now();
});
// Check for stale connection
setInterval(() => {
if (Date.now() - lastHeartbeat > 6 * 60 * 1000) {
console.log('Connection may be stale, refreshing...');
pb.realtime.unsubscribe();
resubscribeAll();
}
}, 60000);
```
Reference: [PocketBase Realtime](https://pocketbase.io/docs/api-realtime/)

View File

@@ -0,0 +1,182 @@
---
title: Implement Realtime Subscriptions Correctly
impact: MEDIUM
impactDescription: Live updates without polling, reduced server load
tags: realtime, subscriptions, sse, websocket
---
## Implement Realtime Subscriptions Correctly
PocketBase uses Server-Sent Events (SSE) for realtime updates. Proper subscription management prevents memory leaks and ensures reliable event delivery.
**Incorrect (memory leaks and poor management):**
```javascript
// Missing unsubscribe - memory leak!
function PostList() {
useEffect(() => {
pb.collection('posts').subscribe('*', (e) => {
updatePosts(e);
});
// No cleanup - subscription persists forever!
}, []);
}
// Subscribing multiple times
function loadPosts() {
// Called on every render - creates duplicate subscriptions!
pb.collection('posts').subscribe('*', handleChange);
}
// Not handling reconnection
pb.collection('posts').subscribe('*', (e) => {
// Assumes connection is always stable
updateUI(e);
});
```
**Correct (proper subscription management):**
```javascript
// React example with cleanup
function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
// Initial load
loadPosts();
// Subscribe to changes
const unsubscribe = pb.collection('posts').subscribe('*', (e) => {
if (e.action === 'create') {
setPosts(prev => [e.record, ...prev]);
} else if (e.action === 'update') {
setPosts(prev => prev.map(p =>
p.id === e.record.id ? e.record : p
));
} else if (e.action === 'delete') {
setPosts(prev => prev.filter(p => p.id !== e.record.id));
}
});
// Cleanup on unmount
return () => {
unsubscribe();
};
}, []);
async function loadPosts() {
const result = await pb.collection('posts').getList(1, 50);
setPosts(result.items);
}
return <PostListUI posts={posts} />;
}
// Subscribe to specific record
async function watchPost(postId) {
return pb.collection('posts').subscribe(postId, (e) => {
console.log('Post changed:', e.action, e.record);
});
}
// Subscribe to collection changes
async function watchAllPosts() {
return pb.collection('posts').subscribe('*', (e) => {
console.log(`Post ${e.action}:`, e.record.title);
});
}
// Handle connection events
pb.realtime.subscribe('PB_CONNECT', (e) => {
console.log('Realtime connected, client ID:', e.clientId);
// Re-sync data after reconnection
refreshData();
});
// Vanilla JS with proper cleanup
class PostManager {
unsubscribes = [];
async init() {
this.unsubscribes.push(
await pb.collection('posts').subscribe('*', this.handlePostChange)
);
this.unsubscribes.push(
await pb.collection('comments').subscribe('*', this.handleCommentChange)
);
}
destroy() {
this.unsubscribes.forEach(unsub => unsub());
this.unsubscribes = [];
}
handlePostChange = (e) => { /* ... */ };
handleCommentChange = (e) => { /* ... */ };
}
```
**Subscription event structure:**
```javascript
pb.collection('posts').subscribe('*', (event) => {
event.action; // 'create' | 'update' | 'delete'
event.record; // The affected record
});
// Full event type
interface RealtimeEvent {
action: 'create' | 'update' | 'delete';
record: RecordModel;
}
```
**Unsubscribe patterns:**
```javascript
// Unsubscribe from specific callback
const unsub = await pb.collection('posts').subscribe('*', callback);
unsub(); // Remove this specific subscription
// Unsubscribe from all subscriptions on a topic
pb.collection('posts').unsubscribe('*'); // All collection subs
pb.collection('posts').unsubscribe('RECORD_ID'); // Specific record
// Unsubscribe from all collection subscriptions
pb.collection('posts').unsubscribe();
// Unsubscribe from everything
pb.realtime.unsubscribe();
```
**Performance considerations:**
```javascript
// Prefer specific record subscriptions over collection-wide when possible.
// subscribe('*') checks ListRule for every connected client on each change.
// subscribe(recordId) checks ViewRule -- fewer records to evaluate.
// For high-traffic collections, subscribe to specific records:
await pb.collection('orders').subscribe(orderId, handleOrderUpdate);
// Instead of: pb.collection('orders').subscribe('*', handleAllOrders);
// Use subscription options to reduce payload size (SDK v0.21+):
await pb.collection('posts').subscribe('*', handleChange, {
fields: 'id,title,updated', // Only receive specific fields
expand: 'author', // Include expanded relations
filter: 'status = "published"' // Only receive matching records
});
```
**Subscription scope guidelines:**
| Scenario | Recommended Scope |
|----------|-------------------|
| Watching a specific document | `subscribe(recordId)` |
| Chat room messages | `subscribe('*')` with filter for room |
| User notifications | `subscribe('*')` with filter for user |
| Admin dashboard | `subscribe('*')` (need to see all) |
| High-frequency data (IoT) | `subscribe(recordId)` per device |
Reference: [PocketBase Realtime](https://pocketbase.io/docs/api-realtime/)

View File

@@ -0,0 +1,85 @@
---
title: Understand API Rule Types and Defaults
impact: CRITICAL
impactDescription: Prevents unauthorized access, data leaks, and security vulnerabilities
tags: api-rules, security, access-control, authorization
---
## Understand API Rule Types and Defaults
PocketBase uses five collection-level rules to control access. Understanding the difference between locked (null), open (""), and expression rules is critical for security.
**Incorrect (leaving rules open unintentionally):**
```javascript
// Collection with overly permissive rules
const collection = {
name: 'messages',
listRule: '', // Anyone can list all messages!
viewRule: '', // Anyone can view any message!
createRule: '', // Anyone can create messages!
updateRule: '', // Anyone can update any message!
deleteRule: '' // Anyone can delete any message!
};
// Complete security bypass - all data exposed
```
**Correct (explicit, restrictive rules):**
```javascript
// Collection with proper access control
const collection = {
name: 'messages',
// null = locked, only superusers can access
listRule: null, // Default: locked to superusers
// '' (empty string) = open to everyone (use sparingly)
viewRule: '@request.auth.id != ""', // Any authenticated user
// Expression = conditional access
createRule: '@request.auth.id != ""', // Must be logged in
updateRule: 'author = @request.auth.id', // Only author
deleteRule: 'author = @request.auth.id' // Only author
};
```
**Rule types explained:**
| Rule Value | Meaning | Use Case |
|------------|---------|----------|
| `null` | Locked (superusers only) | Admin-only data, system tables |
| `''` (empty string) | Open to everyone | Public content, no auth required |
| `'expression'` | Conditional access | Most common - check auth, ownership |
**Common patterns:**
```javascript
// Public read, authenticated write (enforce ownership on create)
listRule: '',
viewRule: '',
createRule: '@request.auth.id != "" && @request.body.author = @request.auth.id',
updateRule: 'author = @request.auth.id',
deleteRule: 'author = @request.auth.id'
// Private to owner only
listRule: 'owner = @request.auth.id',
viewRule: 'owner = @request.auth.id',
createRule: '@request.auth.id != ""',
updateRule: 'owner = @request.auth.id',
deleteRule: 'owner = @request.auth.id'
// Read-only public data
listRule: '',
viewRule: '',
createRule: null,
updateRule: null,
deleteRule: null
```
**Error responses by rule type:**
- List rule fail: 200 with empty items
- View/Update/Delete fail: 404 (hides existence)
- Create fail: 400
- Locked rule violation: 403
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/)

View File

@@ -0,0 +1,75 @@
---
title: Use @collection for Cross-Collection Lookups
impact: HIGH
impactDescription: Enables complex authorization without denormalization
tags: api-rules, security, cross-collection, relations
---
## Use @collection for Cross-Collection Lookups
The `@collection` reference allows rules to query other collections, enabling complex authorization patterns like role-based access, team membership, and resource permissions.
**Incorrect (denormalizing data for access control):**
```javascript
// Duplicating team membership in every resource
const documentsSchema = [
{ name: 'title', type: 'text' },
{ name: 'team', type: 'relation' },
// Duplicated member list for access control - gets out of sync!
{ name: 'allowedUsers', type: 'relation', options: { maxSelect: 999 } }
];
// Rule checks duplicated data
listRule: 'allowedUsers ?= @request.auth.id'
// Problem: must update allowedUsers whenever team membership changes
```
**Correct (using @collection lookup):**
```javascript
// Clean schema - no duplication
const documentsSchema = [
{ name: 'title', type: 'text' },
{ name: 'team', type: 'relation', options: { collectionId: 'teams' } }
];
// Check team membership via @collection lookup
listRule: '@collection.team_members.user ?= @request.auth.id && @collection.team_members.team ?= team'
// Alternative: check if user is in team's members array
listRule: 'team.members ?= @request.auth.id'
// Role-based access via separate roles collection
listRule: '@collection.user_roles.user = @request.auth.id && @collection.user_roles.role = "admin"'
```
**Common patterns:**
```javascript
// Team-based access
// teams: { name, members (relation to users) }
// documents: { title, team (relation to teams) }
viewRule: 'team.members ?= @request.auth.id'
// Organization hierarchy
// orgs: { name }
// org_members: { org, user, role }
// projects: { name, org }
listRule: '@collection.org_members.org = org && @collection.org_members.user = @request.auth.id'
// Permission-based access
// permissions: { resource, user, level }
updateRule: '@collection.permissions.resource = id && @collection.permissions.user = @request.auth.id && @collection.permissions.level = "write"'
// Using aliases for complex queries
listRule: '@collection.memberships:m.user = @request.auth.id && @collection.memberships:m.team = team'
```
**Performance considerations:**
- Cross-collection lookups add query complexity
- Ensure referenced fields are indexed
- Consider caching for frequently accessed permissions
- Test performance with realistic data volumes
Reference: [PocketBase Collection Reference](https://pocketbase.io/docs/api-rules-and-filters/#collection-fields)

View File

@@ -0,0 +1,93 @@
---
title: Master Filter Expression Syntax
impact: CRITICAL
impactDescription: Enables complex access control and efficient querying
tags: api-rules, filters, syntax, operators, security
---
## Master Filter Expression Syntax
PocketBase filter expressions use a specific syntax for both API rules and client-side queries. Understanding operators and composition is essential.
**Incorrect (invalid filter syntax):**
```javascript
// Wrong operator syntax
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status == "published"' // Wrong: == instead of =
});
// Missing quotes around strings
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = published' // Wrong: unquoted string
});
// Wrong boolean logic
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = "published" AND featured = true' // Wrong: AND instead of &&
});
```
**Correct (proper filter syntax):**
```javascript
// Equality and comparison operators
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status = "published"' // Equals
});
filter: 'views != 0' // Not equals
filter: 'views > 100' // Greater than
filter: 'views >= 100' // Greater or equal
filter: 'price < 50.00' // Less than
filter: 'created <= "2024-01-01 00:00:00"' // Less or equal
// String operators
filter: 'title ~ "hello"' // Contains (case-insensitive)
filter: 'title !~ "spam"' // Does not contain
// Logical operators
filter: 'status = "published" && featured = true' // AND
filter: 'category = "news" || category = "blog"' // OR
filter: '(status = "draft" || status = "review") && author = "abc"' // Grouping
// Array/multi-value operators (for select, relation fields)
filter: 'tags ?= "featured"' // Any tag equals "featured"
filter: 'tags ?~ "tech"' // Any tag contains "tech"
// Null checks
filter: 'deletedAt = null' // Is null
filter: 'avatar != null' // Is not null
// Date comparisons
filter: 'created > "2024-01-01 00:00:00"'
filter: 'created >= @now' // Current timestamp
filter: 'expires < @today' // Start of today (UTC)
```
**Available operators:**
| Operator | Description |
|----------|-------------|
| `=` | Equal |
| `!=` | Not equal |
| `>` `>=` `<` `<=` | Comparison |
| `~` | Contains (LIKE %value%) |
| `!~` | Does not contain |
| `?=` `?!=` `?>` `?~` | Any element matches |
| `&&` | AND |
| `\|\|` | OR |
| `()` | Grouping |
**Date macros:**
- `@now` - Current UTC datetime
- `@today` - Start of today UTC
- `@month` - Start of current month UTC
- `@year` - Start of current year UTC
**Filter functions:**
- `strftime(fmt, datetime)` - Format/extract datetime parts (v0.36+). E.g. `strftime('%Y-%m', created) = "2026-03"`. See `rules-strftime.md` for the full format specifier list.
- `length(field)` - Element count of a multi-value field (file, relation, select). E.g. `length(tags) > 0`.
- `each(field, expr)` - Iterate a multi-value field: `each(tags, ? ~ "urgent")`.
- `issetIf(field, val)` - Conditional presence check for complex rules.
Reference: [PocketBase Filters](https://pocketbase.io/docs/api-rules-and-filters/#filters-syntax)

View File

@@ -0,0 +1,91 @@
---
title: Default to Locked Rules, Open Explicitly
impact: CRITICAL
impactDescription: Defense in depth, prevents accidental data exposure
tags: api-rules, security, defaults, best-practices
---
## Default to Locked Rules, Open Explicitly
New collections should start with locked (null) rules and explicitly open only what's needed. This prevents accidental data exposure and follows the principle of least privilege.
**Incorrect (starting with open rules):**
```javascript
// Dangerous: copying rules from examples without thinking
const collection = {
name: 'user_settings',
listRule: '', // Open - leaks all user settings!
viewRule: '', // Open - anyone can view any setting
createRule: '', // Open - no auth required
updateRule: '', // Open - anyone can modify!
deleteRule: '' // Open - anyone can delete!
};
// Also dangerous: using auth check when ownership needed
const collection = {
name: 'private_notes',
listRule: '@request.auth.id != ""', // Any logged-in user sees ALL notes
viewRule: '@request.auth.id != ""',
updateRule: '@request.auth.id != ""', // Any user can edit ANY note!
};
```
**Correct (locked by default, explicitly opened):**
```javascript
// Step 1: Start locked
const collection = {
name: 'user_settings',
listRule: null, // Locked - superusers only
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null
};
// Step 2: Open only what's needed with proper checks
const collection = {
name: 'user_settings',
// Users can only see their own settings
listRule: 'user = @request.auth.id',
viewRule: 'user = @request.auth.id',
// Users can only create settings for themselves
createRule: '@request.auth.id != "" && @request.body.user = @request.auth.id',
// Users can only update their own settings
updateRule: 'user = @request.auth.id',
// Prevent deletion or restrict to owner
deleteRule: 'user = @request.auth.id'
};
// For truly public data, document why it's open
const collection = {
name: 'public_announcements',
// Intentionally public - these are site-wide announcements
listRule: '',
viewRule: '',
// Only admins can manage (using custom "role" field on auth collection)
// IMPORTANT: Prevent role self-assignment in the users collection updateRule:
// updateRule: 'id = @request.auth.id && @request.body.role:isset = false'
createRule: '@request.auth.role = "admin"',
updateRule: '@request.auth.role = "admin"',
deleteRule: '@request.auth.role = "admin"'
};
```
**Rule development workflow:**
1. **Start locked** - All rules `null`
2. **Identify access needs** - Who needs what access?
3. **Write minimal rules** - Open only required operations
4. **Test thoroughly** - Verify both allowed and denied cases
5. **Document decisions** - Comment why rules are set as they are
**Security checklist:**
- [ ] No empty string rules without justification
- [ ] Ownership checks on personal data
- [ ] Auth checks on write operations
- [ ] Admin-only rules for sensitive operations
- [ ] Tested with different user contexts
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/)

View File

@@ -0,0 +1,88 @@
---
title: Use @request Context in API Rules
impact: CRITICAL
impactDescription: Enables dynamic, user-aware access control
tags: api-rules, security, request-context, authentication
---
## Use @request Context in API Rules
The `@request` object provides access to the current request context including authenticated user, request body, query parameters, and headers. Use it to build dynamic access rules.
**Incorrect (hardcoded or missing auth checks):**
```javascript
// No authentication check
const collection = {
listRule: '', // Anyone can see everything
createRule: '' // Anyone can create
};
// Hardcoded user ID (never do this)
const collection = {
listRule: 'owner = "specific_user_id"' // Only one user can access
};
```
**Correct (using @request context):**
```javascript
// Check if user is authenticated
createRule: '@request.auth.id != ""'
// Check ownership via auth record
listRule: 'owner = @request.auth.id'
viewRule: 'owner = @request.auth.id'
updateRule: 'owner = @request.auth.id'
deleteRule: 'owner = @request.auth.id'
// Access auth record fields
// IMPORTANT: If using custom role fields, ensure update rules prevent
// users from modifying their own role: @request.body.role:isset = false
listRule: '@request.auth.role = "admin"'
listRule: '@request.auth.verified = true'
// Validate request body on create/update
createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id'
// Prevent changing certain fields
updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false'
// WARNING: Query parameters are user-controlled and should NOT be used
// for authorization decisions. Use them only for optional filtering behavior
// where the fallback is equally safe.
// listRule: '@request.query.publicOnly = "true" || owner = @request.auth.id'
// The above is UNSAFE - users can bypass ownership by adding ?publicOnly=true
// Instead, use separate endpoints or server-side logic for public vs. private views.
listRule: 'owner = @request.auth.id || public = true' // Use a record field, not query param
// Access nested auth relations
listRule: 'team.members ?= @request.auth.id'
```
**Available @request fields:**
| Field | Description |
|-------|-------------|
| `@request.auth.id` | Authenticated user's ID (empty string if not authenticated) |
| `@request.auth.*` | Any field from auth record (role, verified, email, etc.) |
| `@request.body.*` | Request body fields (create/update only) |
| `@request.query.*` | URL query parameters |
| `@request.headers.*` | Request headers |
| `@request.method` | HTTP method (GET, POST, etc.) |
| `@request.context` | Request context: `default`, `oauth2`, `otp`, `password`, `realtime`, `protectedFile` |
**Body field modifiers:**
```javascript
// Check if field is being set
updateRule: '@request.body.status:isset = false' // Can't change status
// Check if field changed from current value
updateRule: '@request.body.owner:changed = false' // Can't change owner
// Get length of array/string
createRule: '@request.body.tags:length <= 5' // Max 5 tags
```
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/#available-fields)

View File

@@ -0,0 +1,73 @@
---
title: Use strftime() for Date Arithmetic in Filter Expressions
impact: MEDIUM
impactDescription: strftime() (added in v0.36) replaces brittle string prefix comparisons on datetime fields
tags: filter, strftime, datetime, rules, v0.36
---
## Use strftime() for Date Arithmetic in Filter Expressions
PocketBase v0.36 added the `strftime()` function to the filter expression grammar. It maps directly to SQLite's [strftime](https://sqlite.org/lang_datefunc.html) and is the correct way to bucket, compare, or extract parts of a datetime field. Before v0.36 people worked around this with `~` (substring) matches against the ISO string; those workarounds are fragile (they break at midnight UTC, ignore timezones, and can't handle ranges).
**Incorrect (substring match on the ISO datetime string):**
```javascript
// ❌ "matches anything whose ISO string contains 2026-04-08" - breaks as soon
// as your DB stores sub-second precision or you cross a month boundary
const todayPrefix = new Date().toISOString().slice(0, 10);
const results = await pb.collection("orders").getList(1, 50, {
filter: `created ~ "${todayPrefix}"`, // ❌
});
```
**Correct (strftime with named format specifiers):**
```javascript
// "all orders created today (UTC)"
const results = await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)`,
});
// "all orders from March 2026"
await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m', created) = "2026-03"`,
});
// "orders created this hour"
await pb.collection("orders").getList(1, 50, {
filter: `strftime('%Y-%m-%d %H', created) = strftime('%Y-%m-%d %H', @now)`,
});
```
```javascript
// Same function is available inside API rules:
// collection "orders" - List rule:
// @request.auth.id != "" &&
// user = @request.auth.id &&
// strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)
```
**Common format specifiers:**
| Specifier | Meaning |
|---|---|
| `%Y` | 4-digit year |
| `%m` | month (01-12) |
| `%d` | day of month (01-31) |
| `%H` | hour (00-23) |
| `%M` | minute (00-59) |
| `%S` | second (00-59) |
| `%W` | ISO week (00-53) |
| `%j` | day of year (001-366) |
| `%w` | day of week (0=Sunday) |
**Other filter functions worth knowing:**
| Function | Use |
|---|---|
| `strftime(fmt, datetime)` | Format/extract datetime parts (v0.36+) |
| `length(field)` | Count elements in a multi-value field (file, relation, select) |
| `each(field, expr)` | Iterate over multi-value fields: `each(tags, ? ~ "urgent")` |
| `issetIf(field, val)` | Conditional presence check used in complex rules |
Reference: [Filter Syntax - Functions](https://pocketbase.io/docs/api-rules-and-filters/#filters) · [v0.36.0 release](https://github.com/pocketbase/pocketbase/releases/tag/v0.36.0)

View File

@@ -0,0 +1,128 @@
---
title: Use Appropriate Auth Store for Your Platform
impact: HIGH
impactDescription: Proper auth persistence across sessions and page reloads
tags: sdk, auth-store, persistence, storage
---
## Use Appropriate Auth Store for Your Platform
The auth store persists authentication state. Choose the right store type based on your platform: LocalAuthStore for browsers, AsyncAuthStore for React Native, or custom stores for specific needs.
**Incorrect (wrong store for platform):**
```javascript
// React Native: LocalAuthStore doesn't work correctly
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Auth state lost on app restart!
// Deno server: LocalStorage shared between all clients
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// All clients share the same auth state!
// Server-side: Reusing single client for multiple users
const pb = new PocketBase('http://127.0.0.1:8090');
// User A logs in...
// User B's request uses User A's auth!
```
**Correct (platform-appropriate stores):**
```javascript
// Browser (default LocalAuthStore - works automatically)
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Automatically persists to localStorage and syncs between tabs
// React Native (AsyncAuthStore)
import PocketBase, { AsyncAuthStore } from 'pocketbase';
import AsyncStorage from '@react-native-async-storage/async-storage';
const store = new AsyncAuthStore({
save: async (serialized) => {
await AsyncStorage.setItem('pb_auth', serialized);
},
initial: AsyncStorage.getItem('pb_auth'),
clear: async () => {
await AsyncStorage.removeItem('pb_auth');
}
});
const pb = new PocketBase('http://127.0.0.1:8090', store);
// Server-side / SSR (create client per request)
import PocketBase from 'pocketbase';
export function createServerClient(cookieHeader) {
const pb = new PocketBase('http://127.0.0.1:8090');
pb.authStore.loadFromCookie(cookieHeader || '');
return pb;
}
// Deno/Cloudflare Workers (memory-only store)
import PocketBase, { BaseAuthStore } from 'pocketbase';
class MemoryAuthStore extends BaseAuthStore {
// Token only persists for request duration
// Each request must include auth via cookie/header
}
const pb = new PocketBase('http://127.0.0.1:8090', new MemoryAuthStore());
```
**Custom auth store example:**
```javascript
import PocketBase, { BaseAuthStore } from 'pocketbase';
class SecureAuthStore extends BaseAuthStore {
constructor() {
super();
// Load initial state from secure storage
const data = secureStorage.get('pb_auth');
if (data) {
const { token, record } = JSON.parse(data);
this.save(token, record);
}
}
save(token, record) {
super.save(token, record);
// Persist to secure storage
secureStorage.set('pb_auth', JSON.stringify({ token, record }));
}
clear() {
super.clear();
secureStorage.remove('pb_auth');
}
}
const pb = new PocketBase('http://127.0.0.1:8090', new SecureAuthStore());
```
**Auth store methods:**
```javascript
// Available on all auth stores
pb.authStore.token; // Current token
pb.authStore.record; // Current auth record
pb.authStore.isValid; // Token exists and not expired
pb.authStore.isSuperuser; // Is superuser token
pb.authStore.save(token, record); // Save auth state
pb.authStore.clear(); // Clear auth state
// Listen for changes
const unsubscribe = pb.authStore.onChange((token, record) => {
console.log('Auth changed:', record?.email);
}, true); // true = fire immediately
// Cookie helpers (for SSR)
pb.authStore.loadFromCookie(cookieString);
pb.authStore.exportToCookie({ httpOnly: false, secure: true });
```
Reference: [PocketBase Authentication](https://pocketbase.io/docs/authentication/)

View File

@@ -0,0 +1,140 @@
---
title: Understand and Control Auto-Cancellation
impact: MEDIUM
impactDescription: Prevents race conditions, improves UX for search/typeahead
tags: sdk, cancellation, requests, performance
---
## Understand and Control Auto-Cancellation
The SDK automatically cancels duplicate pending requests. This prevents race conditions but requires understanding for proper use in concurrent scenarios.
**Incorrect (confused by auto-cancellation):**
```javascript
// These requests will interfere with each other!
async function loadDashboard() {
// Only the last one executes, others cancelled
const posts = pb.collection('posts').getList(1, 20);
const users = pb.collection('posts').getList(1, 10); // Different params but same path
const comments = pb.collection('posts').getList(1, 5);
// posts and users are cancelled, only comments executes
return Promise.all([posts, users, comments]); // First two fail!
}
// Realtime combined with polling causes cancellation
pb.collection('posts').subscribe('*', callback);
setInterval(() => {
pb.collection('posts').getList(); // May cancel realtime!
}, 5000);
```
**Correct (controlling auto-cancellation):**
```javascript
// Disable auto-cancellation for parallel requests
async function loadDashboard() {
const [posts, users, recent] = await Promise.all([
pb.collection('posts').getList(1, 20, { requestKey: null }),
pb.collection('users').getList(1, 10, { requestKey: null }),
pb.collection('posts').getList(1, 5, { requestKey: 'recent' })
]);
// All requests complete independently
return { posts, users, recent };
}
// Use unique request keys for different purposes
async function searchPosts(query) {
return pb.collection('posts').getList(1, 20, {
filter: pb.filter('title ~ {:q}', { q: query }),
requestKey: 'post-search' // Cancels previous searches only
});
}
async function loadPostDetails(postId) {
return pb.collection('posts').getOne(postId, {
requestKey: `post-${postId}` // Unique per post
});
}
// Typeahead search - auto-cancellation is helpful here
async function typeaheadSearch(query) {
// Previous search automatically cancelled when user types more
return pb.collection('products').getList(1, 10, {
filter: pb.filter('name ~ {:q}', { q: query })
// No requestKey = uses default (path-based), previous cancelled
});
}
// Globally disable auto-cancellation (use carefully)
pb.autoCancellation(false);
// Now all requests are independent
await Promise.all([
pb.collection('posts').getList(1, 20),
pb.collection('posts').getList(1, 10),
pb.collection('posts').getList(1, 5)
]);
// Re-enable
pb.autoCancellation(true);
```
**Manual cancellation:**
```javascript
// Cancel all pending requests
pb.cancelAllRequests();
// Cancel specific request by key
pb.cancelRequest('post-search');
// Example: Cancel on component unmount
function PostList() {
useEffect(() => {
loadPosts();
return () => {
// Cleanup: cancel pending requests
pb.cancelRequest('post-list');
};
}, []);
async function loadPosts() {
const result = await pb.collection('posts').getList(1, 20, {
requestKey: 'post-list'
});
setPosts(result.items);
}
}
// Handle cancellation in catch
async function fetchWithCancellation() {
try {
return await pb.collection('posts').getList();
} catch (error) {
if (error.isAbort) {
// Request was cancelled - this is expected
console.log('Request cancelled');
return null;
}
throw error;
}
}
```
**When to use each approach:**
| Scenario | Approach |
|----------|----------|
| Search/typeahead | Default (let it cancel) |
| Parallel data loading | `requestKey: null` |
| Grouped requests | Custom `requestKey` |
| Component cleanup | `cancelRequest(key)` |
| Testing/debugging | `autoCancellation(false)` |
| OAuth2 flow cancel | `cancelRequest(requestKey)` — properly rejects the `authWithOAuth2()` Promise (JS SDK v0.26.8+) |
> **Note (JS SDK v0.26.8):** Calling `pb.cancelRequest(requestKey)` while `authWithOAuth2()` is waiting now properly rejects the returned Promise. In earlier versions the manual cancellation did not account for the waiting realtime subscription, so the Promise could hang indefinitely.
Reference: [PocketBase Auto-Cancellation](https://github.com/pocketbase/js-sdk#auto-cancellation)

View File

@@ -0,0 +1,165 @@
---
title: Handle SDK Errors Properly
impact: HIGH
impactDescription: Graceful error recovery, better UX, easier debugging
tags: sdk, errors, error-handling, exceptions
---
## Handle SDK Errors Properly
All SDK methods return Promises that may reject with `ClientResponseError`. Proper error handling improves user experience and simplifies debugging.
**Incorrect (ignoring or poorly handling errors):**
```javascript
// No error handling
const posts = await pb.collection('posts').getList();
// Generic catch that loses information
try {
await pb.collection('posts').create({ title: '' });
} catch (e) {
alert('Something went wrong'); // No useful info
}
// Not checking specific error types
try {
await pb.collection('posts').getOne('nonexistent');
} catch (e) {
console.log(e.message); // Missing status, response details
}
```
**Correct (comprehensive error handling):**
```javascript
import PocketBase, { ClientResponseError } from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Basic error handling with ClientResponseError
async function createPost(data) {
try {
return await pb.collection('posts').create(data);
} catch (error) {
if (error instanceof ClientResponseError) {
console.log('Status:', error.status);
console.log('Response:', error.response);
console.log('URL:', error.url);
console.log('Is abort:', error.isAbort);
// Handle specific status codes
switch (error.status) {
case 400:
// Validation error - extract user-friendly messages only
// IMPORTANT: Don't expose raw error.response.data to clients
// as it may leak internal field names and validation rules
const fieldErrors = {};
if (error.response?.data) {
for (const [field, details] of Object.entries(error.response.data)) {
fieldErrors[field] = details.message;
}
}
return { error: 'validation', fields: fieldErrors };
case 401:
// Unauthorized - need to login
return { error: 'unauthorized' };
case 403:
// Forbidden - no permission
return { error: 'forbidden' };
case 404:
// Not found
return { error: 'not_found' };
default:
return { error: 'server_error' };
}
}
throw error; // Re-throw non-PocketBase errors
}
}
// Handle validation errors with field details
async function updateProfile(userId, data) {
try {
return await pb.collection('users').update(userId, data);
} catch (error) {
if (error.status === 400 && error.response?.data) {
// Extract field-specific errors
const fieldErrors = {};
for (const [field, details] of Object.entries(error.response.data)) {
fieldErrors[field] = details.message;
}
return { success: false, errors: fieldErrors };
// { errors: { email: "Invalid email format", name: "Required field" } }
}
throw error;
}
}
// Handle request cancellation
async function searchWithCancel(query) {
try {
return await pb.collection('posts').getList(1, 20, {
filter: pb.filter('title ~ {:query}', { query })
});
} catch (error) {
if (error.isAbort) {
// Request was cancelled (e.g., user typed again)
console.log('Search cancelled');
return null;
}
throw error;
}
}
// Wrapper function for consistent error handling
async function pbRequest(fn) {
try {
return { data: await fn(), error: null };
} catch (error) {
if (error instanceof ClientResponseError) {
return {
data: null,
error: {
status: error.status,
message: error.response?.message || 'Request failed',
data: error.response?.data || null
}
};
}
return {
data: null,
error: { status: 0, message: error.message, data: null }
};
}
}
// Usage
const { data, error } = await pbRequest(() =>
pb.collection('posts').getList(1, 20)
);
if (error) {
console.log('Failed:', error.message);
} else {
console.log('Posts:', data.items);
}
```
**ClientResponseError structure:**
```typescript
interface ClientResponseError {
url: string; // The request URL
status: number; // HTTP status code (0 if network error)
response: { // API response body
code: number;
message: string;
data: { [field: string]: { code: string; message: string } };
};
isAbort: boolean; // True if request was cancelled
cause: Error | null; // Original error (added in JS SDK v0.26.1)
}
```
Reference: [PocketBase Error Handling](https://github.com/pocketbase/js-sdk#error-handling)

View File

@@ -0,0 +1,129 @@
---
title: Use Field Modifiers for Incremental Updates
impact: HIGH
impactDescription: Atomic updates, prevents race conditions, cleaner code
tags: sdk, modifiers, relations, files, numbers, atomic
---
## Use Field Modifiers for Incremental Updates
PocketBase supports `+` and `-` modifiers for incrementing numbers, appending/removing relation IDs, and managing file arrays without replacing the entire value.
**Incorrect (read-modify-write pattern):**
```javascript
// Race condition: two users adding tags simultaneously
async function addTag(postId, newTagId) {
const post = await pb.collection('posts').getOne(postId);
const currentTags = post.tags || [];
// Another user might have added a tag in between!
await pb.collection('posts').update(postId, {
tags: [...currentTags, newTagId] // Might overwrite the other user's tag
});
}
// Inefficient for incrementing counters
async function incrementViews(postId) {
const post = await pb.collection('posts').getOne(postId);
await pb.collection('posts').update(postId, {
views: post.views + 1 // Extra read, race condition
});
}
```
**Correct (using field modifiers):**
```javascript
// Atomic relation append with + modifier
async function addTag(postId, newTagId) {
await pb.collection('posts').update(postId, {
'tags+': newTagId // Appends to existing tags atomically
});
}
// Append multiple relations
async function addTags(postId, tagIds) {
await pb.collection('posts').update(postId, {
'tags+': tagIds // Appends array of IDs
});
}
// Prepend relations (+ prefix)
async function prependTag(postId, tagId) {
await pb.collection('posts').update(postId, {
'+tags': tagId // Prepends to start of array
});
}
// Remove relations with - modifier
async function removeTag(postId, tagId) {
await pb.collection('posts').update(postId, {
'tags-': tagId // Removes specific tag
});
}
// Remove multiple relations
async function removeTags(postId, tagIds) {
await pb.collection('posts').update(postId, {
'tags-': tagIds // Removes all specified tags
});
}
// Atomic number increment
async function incrementViews(postId) {
await pb.collection('posts').update(postId, {
'views+': 1 // Atomic increment, no race condition
});
}
// Atomic number decrement
async function decrementStock(productId, quantity) {
await pb.collection('products').update(productId, {
'stock-': quantity // Atomic decrement
});
}
// File append (for multi-file fields)
async function addImage(albumId, newImage) {
await pb.collection('albums').update(albumId, {
'images+': newImage // Appends new file to existing
});
}
// File removal
async function removeImage(albumId, filename) {
await pb.collection('albums').update(albumId, {
'images-': filename // Removes specific file by name
});
}
// Combined modifiers in single update
async function updatePost(postId, data) {
await pb.collection('posts').update(postId, {
title: data.title, // Replace field
'views+': 1, // Increment number
'tags+': data.newTagId, // Append relation
'tags-': data.oldTagId, // Remove relation
'images+': data.newImage // Append file
});
}
```
**Modifier reference:**
| Modifier | Field Types | Description |
|----------|-------------|-------------|
| `field+` or `+field` | relation, file | Append/prepend to array |
| `field-` | relation, file | Remove from array |
| `field+` | number | Increment by value |
| `field-` | number | Decrement by value |
**Benefits:**
- **Atomic**: No read-modify-write race conditions
- **Efficient**: Single request, no extra read needed
- **Clean**: Expresses intent clearly
**Note:** Modifiers only work with `update()`, not `create()`.
Reference: [PocketBase Relations](https://pocketbase.io/docs/working-with-relations/)

View File

@@ -0,0 +1,151 @@
---
title: Use Safe Parameter Binding in Filters
impact: CRITICAL
impactDescription: Prevents injection attacks, handles special characters correctly
tags: sdk, filters, security, injection, parameters
---
## Use Safe Parameter Binding in Filters
Always use `pb.filter()` with parameter binding when constructing filters with user input. String concatenation is vulnerable to injection attacks.
**Incorrect (string concatenation - DANGEROUS):**
```javascript
// SQL/filter injection vulnerability!
async function searchPosts(userInput) {
// User input: `test" || id != "` breaks out of string
const posts = await pb.collection('posts').getList(1, 20, {
filter: `title ~ "${userInput}"` // VULNERABLE!
});
return posts;
}
// Even with escaping, easy to get wrong
async function searchByEmail(email) {
const escaped = email.replace(/"/g, '\\"'); // Incomplete escaping
const users = await pb.collection('users').getList(1, 1, {
filter: `email = "${escaped}"` // Still potentially vulnerable
});
return users;
}
// Template literals are just as dangerous
const filter = `status = "${status}" && author = "${authorId}"`;
```
**Correct (using pb.filter with parameters):**
```javascript
// Safe parameter binding
async function searchPosts(userInput) {
const posts = await pb.collection('posts').getList(1, 20, {
filter: pb.filter('title ~ {:search}', { search: userInput })
});
return posts;
}
// Multiple parameters
async function filterPosts(status, authorId, minViews) {
const posts = await pb.collection('posts').getList(1, 20, {
filter: pb.filter(
'status = {:status} && author = {:author} && views >= {:views}',
{ status, author: authorId, views: minViews }
)
});
return posts;
}
// Reusing parameters
async function searchBothFields(query) {
const results = await pb.collection('posts').getList(1, 20, {
filter: pb.filter(
'title ~ {:q} || content ~ {:q}',
{ q: query } // Same parameter used twice
)
});
return results;
}
// Different parameter types
async function complexFilter(options) {
const filter = pb.filter(
'created > {:date} && active = {:active} && category = {:cat}',
{
date: new Date('2024-01-01'), // Date objects handled correctly
active: true, // Booleans
cat: options.category // Strings auto-escaped
}
);
return pb.collection('posts').getList(1, 20, { filter });
}
// Null handling
async function filterWithOptional(category) {
// Only include filter if value provided
const filter = category
? pb.filter('category = {:cat}', { cat: category })
: '';
return pb.collection('posts').getList(1, 20, { filter });
}
// Building dynamic filters
async function dynamicSearch(filters) {
const conditions = [];
const params = {};
if (filters.title) {
conditions.push('title ~ {:title}');
params.title = filters.title;
}
if (filters.author) {
conditions.push('author = {:author}');
params.author = filters.author;
}
if (filters.minDate) {
conditions.push('created >= {:minDate}');
params.minDate = filters.minDate;
}
const filter = conditions.length > 0
? pb.filter(conditions.join(' && '), params)
: '';
return pb.collection('posts').getList(1, 20, { filter });
}
```
**Supported parameter types:**
| Type | Example | Notes |
|------|---------|-------|
| string | `'hello'` | Auto-escaped, quotes handled |
| number | `123`, `45.67` | No quotes added |
| boolean | `true`, `false` | Converted correctly |
| Date | `new Date()` | Formatted for PocketBase |
| null | `null` | For null comparisons |
| other | `{...}` | JSON.stringify() applied |
**Server-side is especially critical:**
```javascript
// Server-side code (Node.js, Deno, etc.) MUST use binding
// because malicious users control the input directly
export async function searchHandler(req) {
const userQuery = req.query.q; // Untrusted input!
// ALWAYS use pb.filter() on server
const results = await pb.collection('posts').getList(1, 20, {
filter: pb.filter('title ~ {:q}', { q: userQuery })
});
return results;
}
```
Reference: [PocketBase Filters](https://pocketbase.io/docs/api-rules-and-filters/)

View File

@@ -0,0 +1,135 @@
---
title: Initialize PocketBase Client Correctly
impact: HIGH
impactDescription: Proper setup enables auth persistence, SSR support, and optimal performance
tags: sdk, initialization, client, setup
---
## Initialize PocketBase Client Correctly
Client initialization should consider the environment (browser, Node.js, SSR), auth store persistence, and any required polyfills.
**Incorrect (environment-agnostic initialization):**
```javascript
// Missing polyfills in Node.js
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Node.js: EventSource not defined error on realtime
pb.collection('posts').subscribe('*', callback); // Fails!
// Missing base URL
const pb = new PocketBase(); // Uses '/' - likely wrong
```
**Correct (environment-aware initialization):**
```javascript
// Browser setup (no polyfills needed)
// IMPORTANT: Use HTTPS in production (http is only for local development)
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090'); // Use https:// in production
// Node.js setup (requires polyfills for realtime)
import PocketBase from 'pocketbase';
import { EventSource } from 'eventsource';
// Global polyfill for realtime
global.EventSource = EventSource;
const pb = new PocketBase('http://127.0.0.1:8090');
// React Native setup (async auth store)
import PocketBase, { AsyncAuthStore } from 'pocketbase';
import AsyncStorage from '@react-native-async-storage/async-storage';
import EventSource from 'react-native-sse';
global.EventSource = EventSource;
const store = new AsyncAuthStore({
save: async (serialized) => AsyncStorage.setItem('pb_auth', serialized),
initial: AsyncStorage.getItem('pb_auth'),
});
const pb = new PocketBase('http://127.0.0.1:8090', store);
```
**SSR initialization (per-request client):**
```javascript
// SvelteKit example
// src/hooks.server.js
import PocketBase from 'pocketbase';
export async function handle({ event, resolve }) {
// Create fresh client for each request
event.locals.pb = new PocketBase('http://127.0.0.1:8090');
// Load auth from request cookie
event.locals.pb.authStore.loadFromCookie(
event.request.headers.get('cookie') || ''
);
// Validate token
if (event.locals.pb.authStore.isValid) {
try {
await event.locals.pb.collection('users').authRefresh();
} catch {
event.locals.pb.authStore.clear();
}
}
const response = await resolve(event);
// Send updated auth cookie with secure options
response.headers.append(
'set-cookie',
event.locals.pb.authStore.exportToCookie({
httpOnly: true, // Prevent XSS access to auth token
secure: true, // HTTPS only
sameSite: 'Lax', // CSRF protection
})
);
return response;
}
```
**TypeScript with typed collections:**
```typescript
import PocketBase, { RecordService } from 'pocketbase';
// Define your record types
interface User {
id: string;
email: string;
name: string;
avatar?: string;
}
interface Post {
id: string;
title: string;
content: string;
author: string;
published: boolean;
}
// Create typed client interface
interface TypedPocketBase extends PocketBase {
collection(idOrName: string): RecordService;
collection(idOrName: 'users'): RecordService<User>;
collection(idOrName: 'posts'): RecordService<Post>;
}
const pb = new PocketBase('http://127.0.0.1:8090') as TypedPocketBase;
// Now methods are typed
const post = await pb.collection('posts').getOne('abc'); // Returns Post
const users = await pb.collection('users').getList(); // Returns ListResult<User>
```
Reference: [PocketBase JS SDK](https://github.com/pocketbase/js-sdk)

View File

@@ -0,0 +1,175 @@
---
title: Use Send Hooks for Request Customization
impact: MEDIUM
impactDescription: Custom headers, logging, response transformation
tags: sdk, hooks, middleware, headers, logging
---
## Use Send Hooks for Request Customization
The SDK provides `beforeSend` and `afterSend` hooks for intercepting and modifying requests and responses globally.
**Incorrect (repeating logic in every request):**
```javascript
// Adding headers to every request manually
const posts = await pb.collection('posts').getList(1, 20, {
headers: { 'X-Custom-Header': 'value' }
});
const users = await pb.collection('users').getList(1, 20, {
headers: { 'X-Custom-Header': 'value' } // Repeated!
});
// Logging each request manually
console.log('Fetching posts...');
const posts = await pb.collection('posts').getList();
console.log('Done');
```
**Correct (using send hooks):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// beforeSend - modify requests before they're sent
pb.beforeSend = function(url, options) {
// Add custom headers to all requests
options.headers = Object.assign({}, options.headers, {
'X-Custom-Header': 'value',
'X-Request-ID': crypto.randomUUID()
});
// Log outgoing requests
console.log(`[${options.method}] ${url}`);
// Must return { url, options }
return { url, options };
};
// afterSend - process responses
pb.afterSend = function(response, data) {
// Log response status
console.log(`Response: ${response.status}`);
// Transform or extend response data
if (data && typeof data === 'object') {
data._fetchedAt = new Date().toISOString();
}
// Return the (possibly modified) data
return data;
};
// All requests now automatically have headers and logging
const posts = await pb.collection('posts').getList();
const users = await pb.collection('users').getList();
```
**Practical examples:**
```javascript
// Request timing / performance monitoring
let requestStart;
pb.beforeSend = function(url, options) {
requestStart = performance.now();
return { url, options };
};
pb.afterSend = function(response, data) {
const duration = performance.now() - requestStart;
console.log(`${response.url}: ${duration.toFixed(2)}ms`);
// Send to analytics
trackApiPerformance(response.url, duration);
return data;
};
// Add auth token from different source
pb.beforeSend = function(url, options) {
const externalToken = getTokenFromExternalAuth();
if (externalToken) {
options.headers = Object.assign({}, options.headers, {
'X-External-Auth': externalToken
});
}
return { url, options };
};
// Handle specific response codes globally
pb.afterSend = function(response, data) {
if (response.status === 401) {
// Token expired - trigger re-auth
handleAuthExpired();
}
if (response.status === 503) {
// Service unavailable - show maintenance message
showMaintenanceMode();
}
return data;
};
// Retry failed requests (simplified example)
const originalSend = pb.send.bind(pb);
pb.send = async function(path, options) {
try {
return await originalSend(path, options);
} catch (error) {
if (error.status === 429) { // Rate limited
await sleep(1000);
return originalSend(path, options); // Retry once
}
throw error;
}
};
// Add request correlation for debugging
let requestId = 0;
pb.beforeSend = function(url, options) {
requestId++;
const correlationId = `req-${Date.now()}-${requestId}`;
options.headers = Object.assign({}, options.headers, {
'X-Correlation-ID': correlationId
});
console.log(`[${correlationId}] Starting: ${url}`);
return { url, options };
};
pb.afterSend = function(response, data) {
console.log(`Complete: ${response.status}`);
return data;
};
```
**Hook signatures:**
```typescript
// beforeSend
beforeSend?: (
url: string,
options: SendOptions
) => { url: string; options: SendOptions } | Promise<{ url: string; options: SendOptions }>;
// afterSend
afterSend?: (
response: Response,
data: any
) => any | Promise<any>;
```
**Use cases:**
- Add custom headers (API keys, correlation IDs)
- Request/response logging
- Performance monitoring
- Global error handling
- Response transformation
- Authentication middleware
Reference: [PocketBase Send Hooks](https://github.com/pocketbase/js-sdk#send-hooks)