Initial commit
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
693
.claude/skills/pocketbase-best-practices/references/realtime.md
Normal file
693
.claude/skills/pocketbase-best-practices/references/realtime.md
Normal 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/)
|
||||
|
||||
1003
.claude/skills/pocketbase-best-practices/references/sdk-usage.md
Normal file
1003
.claude/skills/pocketbase-best-practices/references/sdk-usage.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user