Initial commit
This commit is contained in:
41
.claude/skills/pocketbase-best-practices/rules/_sections.md
Normal file
41
.claude/skills/pocketbase-best-practices/rules/_sections.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Section Definitions
|
||||
|
||||
This file defines the rule categories for PocketBase best practices. Rules are automatically assigned to sections based on their filename prefix.
|
||||
|
||||
---
|
||||
|
||||
## 1. Collection Design (coll)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Schema design, field types, relations, indexes, and collection type selection. Foundation for application architecture and long-term maintainability.
|
||||
|
||||
## 2. API Rules & Security (rules)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Access control rules, filter expressions, request context usage, and security patterns. Critical for protecting data and enforcing authorization.
|
||||
|
||||
## 3. Authentication (auth)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Password authentication, OAuth2 integration, token management, MFA setup, and auth collection configuration.
|
||||
|
||||
## 4. SDK Usage (sdk)
|
||||
**Impact:** HIGH
|
||||
**Description:** JavaScript SDK initialization, auth store patterns, error handling, request cancellation, and safe parameter binding.
|
||||
|
||||
## 5. Query Performance (query)
|
||||
**Impact:** HIGH
|
||||
**Description:** Pagination strategies, relation expansion, field selection, batch operations, and N+1 query prevention.
|
||||
|
||||
## 6. Realtime (realtime)
|
||||
**Impact:** MEDIUM
|
||||
**Description:** SSE subscriptions, event handling, connection management, and authentication with realtime.
|
||||
|
||||
## 7. File Handling (file)
|
||||
**Impact:** MEDIUM
|
||||
**Description:** File uploads, URL generation, thumbnail creation, and validation patterns.
|
||||
|
||||
## 8. Production & Deployment (deploy)
|
||||
**Impact:** LOW-MEDIUM
|
||||
**Description:** Backup strategies, configuration management, reverse proxy setup, and SQLite optimization.
|
||||
|
||||
## 9. Server-Side Extending (ext)
|
||||
**Impact:** HIGH
|
||||
**Description:** Extending PocketBase with Go or embedded JavaScript (JSVM) - event hooks, custom routes, transactions, cron jobs, filesystem, migrations, and safe server-side filter binding.
|
||||
33
.claude/skills/pocketbase-best-practices/rules/_template.md
Normal file
33
.claude/skills/pocketbase-best-practices/rules/_template.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Clear, Action-Oriented Title (e.g., "Use Cursor-Based Pagination for Large Lists")
|
||||
impact: MEDIUM
|
||||
impactDescription: Brief description of performance/security impact
|
||||
tags: relevant, comma-separated, tags
|
||||
---
|
||||
|
||||
## [Rule Title]
|
||||
|
||||
[1-2 sentence explanation of the problem and why it matters. Focus on impact.]
|
||||
|
||||
**Incorrect (describe the problem):**
|
||||
|
||||
```javascript
|
||||
// Comment explaining what makes this problematic
|
||||
const result = await pb.collection('posts').getList();
|
||||
// Problem explanation
|
||||
```
|
||||
|
||||
**Correct (describe the solution):**
|
||||
|
||||
```javascript
|
||||
// Comment explaining why this is better
|
||||
const result = await pb.collection('posts').getList(1, 20, {
|
||||
filter: 'published = true',
|
||||
sort: '-created'
|
||||
});
|
||||
// Benefit explanation
|
||||
```
|
||||
|
||||
[Optional: Additional context, edge cases, or trade-offs]
|
||||
|
||||
Reference: [PocketBase Docs](https://pocketbase.io/docs/)
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Use Impersonation for Admin Operations
|
||||
impact: MEDIUM
|
||||
impactDescription: Safe admin access to user data without password sharing
|
||||
tags: authentication, admin, impersonation, superuser
|
||||
---
|
||||
|
||||
## Use Impersonation for Admin Operations
|
||||
|
||||
Impersonation allows superusers to generate tokens for other users, enabling admin support tasks and API key functionality without sharing passwords.
|
||||
|
||||
**Incorrect (sharing credentials or bypassing auth):**
|
||||
|
||||
```javascript
|
||||
// Bad: sharing user passwords for support
|
||||
async function helpUser(userId, userPassword) {
|
||||
await pb.collection('users').authWithPassword(userEmail, userPassword);
|
||||
// Support team knows user's password!
|
||||
}
|
||||
|
||||
// Bad: directly modifying records without proper context
|
||||
async function fixUserData(userId) {
|
||||
// Bypasses user's perspective and rules
|
||||
await pb.collection('posts').update(postId, { fixed: true });
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using impersonation):**
|
||||
|
||||
```javascript
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
// Admin client with superuser auth (use environment variables, never hardcode)
|
||||
const adminPb = new PocketBase(process.env.PB_URL);
|
||||
await adminPb.collection('_superusers').authWithPassword(
|
||||
process.env.PB_SUPERUSER_EMAIL,
|
||||
process.env.PB_SUPERUSER_PASSWORD
|
||||
);
|
||||
|
||||
async function impersonateUser(userId) {
|
||||
// Generate impersonation token (non-renewable)
|
||||
const impersonatedClient = await adminPb
|
||||
.collection('users')
|
||||
.impersonate(userId, 3600); // 1 hour duration
|
||||
|
||||
// impersonatedClient has user's auth context
|
||||
console.log('Acting as:', impersonatedClient.authStore.record.email);
|
||||
|
||||
// Operations use user's permissions
|
||||
const userPosts = await impersonatedClient.collection('posts').getList();
|
||||
|
||||
return impersonatedClient;
|
||||
}
|
||||
|
||||
// Use case: Admin viewing user's data
|
||||
async function adminViewUserPosts(userId) {
|
||||
const userClient = await impersonateUser(userId);
|
||||
|
||||
// See exactly what the user sees (respects API rules)
|
||||
const posts = await userClient.collection('posts').getList();
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
// Use case: API keys for server-to-server communication
|
||||
async function createApiKey(serviceUserId) {
|
||||
// Create a service impersonation token (use short durations, rotate regularly)
|
||||
const serviceClient = await adminPb
|
||||
.collection('service_accounts')
|
||||
.impersonate(serviceUserId, 86400); // 24 hours max, rotate via scheduled task
|
||||
|
||||
// Return token for service to use
|
||||
return serviceClient.authStore.token;
|
||||
}
|
||||
|
||||
// Using API key token in another service
|
||||
async function useApiKey(apiToken) {
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// Manually set the token
|
||||
pb.authStore.save(apiToken, null);
|
||||
|
||||
// Now requests use the service account's permissions
|
||||
const data = await pb.collection('data').getList();
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Important considerations:**
|
||||
|
||||
```javascript
|
||||
// Impersonation tokens are non-renewable
|
||||
const client = await adminPb.collection('users').impersonate(userId, 3600);
|
||||
|
||||
// This will fail - can't refresh impersonation tokens
|
||||
try {
|
||||
await client.collection('users').authRefresh();
|
||||
} catch (error) {
|
||||
// Expected: impersonation tokens can't be refreshed
|
||||
}
|
||||
|
||||
// For continuous access, generate new token when needed
|
||||
async function getImpersonatedClient(userId) {
|
||||
// Check if existing token is still valid
|
||||
if (cachedClient?.authStore.isValid) {
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
// Generate fresh token
|
||||
return await adminPb.collection('users').impersonate(userId, 3600);
|
||||
}
|
||||
```
|
||||
|
||||
**Security best practices:**
|
||||
- Use short durations for support tasks
|
||||
- Log all impersonation events
|
||||
- Restrict impersonation to specific admin roles
|
||||
- Never expose impersonation capability in client code
|
||||
- Use dedicated service accounts for API keys
|
||||
|
||||
Reference: [PocketBase Impersonation](https://pocketbase.io/docs/authentication/#impersonate-authentication)
|
||||
135
.claude/skills/pocketbase-best-practices/rules/auth-mfa.md
Normal file
135
.claude/skills/pocketbase-best-practices/rules/auth-mfa.md
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Implement Multi-Factor Authentication
|
||||
impact: HIGH
|
||||
impactDescription: Additional security layer for sensitive applications
|
||||
tags: authentication, mfa, security, 2fa, otp
|
||||
---
|
||||
|
||||
## Implement Multi-Factor Authentication
|
||||
|
||||
MFA requires users to authenticate with two different methods. PocketBase supports OTP (One-Time Password) via email as the second factor.
|
||||
|
||||
**Incorrect (single-factor only for sensitive apps):**
|
||||
|
||||
```javascript
|
||||
// Insufficient for sensitive applications
|
||||
async function login(email, password) {
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
// User immediately has full access - no second factor
|
||||
return authData;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (MFA flow with OTP):**
|
||||
|
||||
```javascript
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
async function loginWithMFA(email, password) {
|
||||
try {
|
||||
// First factor: password
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
// If MFA not required, auth succeeds immediately
|
||||
return { success: true, authData };
|
||||
|
||||
} catch (error) {
|
||||
// MFA required - returns 401 with mfaId
|
||||
if (error.status === 401 && error.response?.mfaId) {
|
||||
return {
|
||||
success: false,
|
||||
mfaRequired: true,
|
||||
mfaId: error.response.mfaId
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOTP(email) {
|
||||
// Request OTP to be sent via email
|
||||
const result = await pb.collection('users').requestOTP(email);
|
||||
|
||||
// Returns otpId - needed to verify the OTP
|
||||
// Note: Returns otpId even if email doesn't exist (prevents enumeration)
|
||||
return result.otpId;
|
||||
}
|
||||
|
||||
async function completeMFAWithOTP(mfaId, otpId, otpCode) {
|
||||
try {
|
||||
// Second factor: OTP verification
|
||||
const authData = await pb.collection('users').authWithOTP(
|
||||
otpId,
|
||||
otpCode,
|
||||
{ mfaId } // Include mfaId from first factor
|
||||
);
|
||||
|
||||
return { success: true, authData };
|
||||
} catch (error) {
|
||||
if (error.status === 400) {
|
||||
throw new Error('Invalid or expired code');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Complete flow example
|
||||
async function fullMFAFlow(email, password, otpCode = null) {
|
||||
// Step 1: Password authentication
|
||||
const step1 = await loginWithMFA(email, password);
|
||||
|
||||
if (step1.success) {
|
||||
return step1.authData; // MFA not required
|
||||
}
|
||||
|
||||
if (step1.mfaRequired) {
|
||||
// Step 2: Request OTP
|
||||
const otpId = await requestOTP(email);
|
||||
|
||||
// Step 3: UI prompts user for OTP code...
|
||||
// (In real app, wait for user input)
|
||||
|
||||
if (otpCode) {
|
||||
// Step 4: Complete MFA
|
||||
const step2 = await completeMFAWithOTP(step1.mfaId, otpId, otpCode);
|
||||
return step2.authData;
|
||||
}
|
||||
|
||||
return { pendingMFA: true, mfaId: step1.mfaId, otpId };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configure MFA (Admin UI or API):**
|
||||
|
||||
```javascript
|
||||
// Enable MFA on auth collection (superuser only)
|
||||
await pb.collections.update('users', {
|
||||
mfa: {
|
||||
enabled: true,
|
||||
duration: 1800, // MFA session duration (30 min)
|
||||
rule: '' // When to require MFA (empty = always for all users)
|
||||
// rule: '@request.auth.role = "admin"' // Only for admins
|
||||
},
|
||||
otp: {
|
||||
enabled: true,
|
||||
duration: 300, // OTP validity (5 min)
|
||||
length: 6, // OTP code length
|
||||
emailTemplate: {
|
||||
subject: 'Your verification code',
|
||||
body: 'Your code is: {OTP}'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**MFA best practices:**
|
||||
- Always enable for admin accounts
|
||||
- Consider making MFA optional for regular users
|
||||
- Use short OTP durations (5-10 minutes)
|
||||
- Implement rate limiting on OTP requests
|
||||
- Log MFA events for security auditing
|
||||
|
||||
Reference: [PocketBase MFA](https://pocketbase.io/docs/authentication/#mfa)
|
||||
141
.claude/skills/pocketbase-best-practices/rules/auth-oauth2.md
Normal file
141
.claude/skills/pocketbase-best-practices/rules/auth-oauth2.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Integrate OAuth2 Providers Correctly
|
||||
impact: CRITICAL
|
||||
impactDescription: Secure third-party authentication with proper flow handling
|
||||
tags: authentication, oauth2, google, github, social-login
|
||||
---
|
||||
|
||||
## Integrate OAuth2 Providers Correctly
|
||||
|
||||
OAuth2 integration should use the all-in-one method for simplicity and security. Manual code exchange should only be used when necessary (e.g., mobile apps with deep links).
|
||||
|
||||
**Incorrect (manual implementation without SDK):**
|
||||
|
||||
```javascript
|
||||
// Don't manually handle OAuth flow
|
||||
async function loginWithGoogle() {
|
||||
// Redirect user to Google manually
|
||||
window.location.href = 'https://accounts.google.com/oauth/authorize?...';
|
||||
}
|
||||
|
||||
// Manual callback handling
|
||||
async function handleCallback(code) {
|
||||
// Exchange code manually - error prone!
|
||||
const response = await fetch('/api/auth/callback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using SDK's all-in-one method):**
|
||||
|
||||
```javascript
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// All-in-one OAuth2 (recommended for web apps)
|
||||
async function loginWithOAuth2(providerName) {
|
||||
try {
|
||||
const authData = await pb.collection('users').authWithOAuth2({
|
||||
provider: providerName, // 'google', 'github', 'microsoft', etc.
|
||||
// Optional: create new user data if not exists
|
||||
createData: {
|
||||
emailVisibility: true,
|
||||
name: '' // Will be populated from OAuth provider
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Logged in via', providerName);
|
||||
console.log('User:', authData.record.email);
|
||||
console.log('Is new user:', authData.meta?.isNew);
|
||||
|
||||
return authData;
|
||||
} catch (error) {
|
||||
if (error.isAbort) {
|
||||
console.log('OAuth popup was closed');
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
document.getElementById('google-btn').onclick = () => loginWithOAuth2('google');
|
||||
document.getElementById('github-btn').onclick = () => loginWithOAuth2('github');
|
||||
```
|
||||
|
||||
**Manual code exchange (for React Native / deep links):**
|
||||
|
||||
```javascript
|
||||
// Only use when all-in-one isn't possible
|
||||
async function loginWithOAuth2Manual() {
|
||||
// Get auth methods - PocketBase provides state and codeVerifier
|
||||
const authMethods = await pb.collection('users').listAuthMethods();
|
||||
const provider = authMethods.oauth2.providers.find(p => p.name === 'google');
|
||||
|
||||
// Store the provider's state and codeVerifier for callback verification
|
||||
// PocketBase generates these for you - don't create your own
|
||||
sessionStorage.setItem('oauth_state', provider.state);
|
||||
sessionStorage.setItem('oauth_code_verifier', provider.codeVerifier);
|
||||
|
||||
// Build the OAuth URL using provider.authURL + redirect
|
||||
const redirectUrl = window.location.origin + '/oauth-callback';
|
||||
const authUrl = provider.authURL + encodeURIComponent(redirectUrl);
|
||||
|
||||
// Redirect to OAuth provider
|
||||
window.location.href = authUrl;
|
||||
}
|
||||
|
||||
// In your callback handler (e.g., /oauth-callback page):
|
||||
async function handleOAuth2Callback() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// CSRF protection: verify state matches
|
||||
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
|
||||
throw new Error('State mismatch - potential CSRF attack');
|
||||
}
|
||||
|
||||
const code = params.get('code');
|
||||
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
|
||||
const redirectUrl = window.location.origin + '/oauth-callback';
|
||||
|
||||
// Exchange code for auth token
|
||||
const authData = await pb.collection('users').authWithOAuth2Code(
|
||||
'google',
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUrl,
|
||||
{ emailVisibility: true }
|
||||
);
|
||||
|
||||
// Clean up
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
sessionStorage.removeItem('oauth_code_verifier');
|
||||
|
||||
return authData;
|
||||
}
|
||||
```
|
||||
|
||||
**Configure OAuth2 provider (Admin UI or API):**
|
||||
|
||||
```javascript
|
||||
// Via API (superuser only) - usually done in Admin UI
|
||||
// IMPORTANT: Never hardcode client secrets. Use environment variables.
|
||||
await pb.collections.update('users', {
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
providers: [{
|
||||
name: 'google',
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET
|
||||
}],
|
||||
mappedFields: {
|
||||
avatarURL: 'avatar' // Map OAuth field to collection field
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [PocketBase OAuth2](https://pocketbase.io/docs/authentication/#oauth2-authentication)
|
||||
68
.claude/skills/pocketbase-best-practices/rules/auth-otp.md
Normal file
68
.claude/skills/pocketbase-best-practices/rules/auth-otp.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
|
||||
impact: HIGH
|
||||
impactDescription: OTP endpoints are unauthenticated; unthrottled requestOTP enables email bombing and enumeration
|
||||
tags: auth, otp, one-time-password, rate-limiting, enumeration
|
||||
---
|
||||
|
||||
## Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
|
||||
|
||||
Auth collections can enable **OTP login** from the admin UI (Collection → Options → "Enable OTP"). The client flow is two steps: `requestOTP(email)` returns an `otpId`, then `authWithOTP(otpId, code)` exchanges the id + code for an auth token. Two things trip people up: (1) the OTP response is **the same whether the email exists or not** - do not break that by leaking a distinct error; (2) `requestOTP` sends an email, so **it must be rate-limited** or an attacker can use it to spam any address.
|
||||
|
||||
**Incorrect (leaks existence, custom requestOTP with no rate limit):**
|
||||
|
||||
```javascript
|
||||
// ❌ Client-side existence check - ignore the 404 and expose it to the user
|
||||
try {
|
||||
await pb.collection("users").getFirstListItem(`email="${email}"`);
|
||||
} catch (e) {
|
||||
alert("No account with that email"); // ❌ account enumeration
|
||||
return;
|
||||
}
|
||||
|
||||
// ❌ Ad-hoc route with no rate limit - attacker hammers this to spam mailboxes
|
||||
routerAdd("POST", "/api/myapp/otp", (e) => {
|
||||
const body = e.requestInfo().body;
|
||||
const user = $app.findAuthRecordByEmail("users", body.email);
|
||||
// send custom email...
|
||||
return e.json(200, { ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (use the built-in flow, step 1 always returns an otpId):**
|
||||
|
||||
```javascript
|
||||
// Step 1: request the code. Always returns { otpId } - even if the email
|
||||
// does not exist, PocketBase returns a synthetic id so enumeration is
|
||||
// impossible. Treat every response as success from the UI perspective.
|
||||
const { otpId } = await pb.collection("users").requestOTP("user@example.com");
|
||||
|
||||
// Step 2: exchange otpId + the 8-digit code the user typed
|
||||
const authData = await pb.collection("users").authWithOTP(
|
||||
otpId,
|
||||
"12345678",
|
||||
);
|
||||
// pb.authStore is now populated
|
||||
```
|
||||
|
||||
```go
|
||||
// Go side - rate-limit and log if you wrap your own endpoint
|
||||
app.OnRecordRequestOTPRequest("users").BindFunc(func(e *core.RecordRequestOTPRequestEvent) error {
|
||||
// e.Collection, e.Record (may be nil - synthetic id path),
|
||||
// e.Email (always present), e.Password (unused for OTP)
|
||||
e.App.Logger().Info("otp requested",
|
||||
"email", e.Email,
|
||||
"ip", e.RequestInfo.Headers["x_forwarded_for"])
|
||||
return e.Next() // REQUIRED
|
||||
})
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- `requestOTP` **always returns 200 with an otpId**, even for non-existent emails - preserve that by never adding a pre-check or a different error path.
|
||||
- Enable the built-in rate limiter (see `deploy-rate-limiting.md`) and raise the cost for the `*:requestOTP` label. Without this, an attacker can email-bomb arbitrary users.
|
||||
- The OTP code is 8 digits by default, with a 3-minute TTL. Do not extend the TTL - short windows are the whole point.
|
||||
- `authWithOTP` consumes the code; a successful call invalidates the `otpId`. Always show a generic "invalid or expired code" on failure.
|
||||
- If you want OTP **without a password**, set the collection's `Password` option to off and `OTP` on. If both are enabled, users can use either.
|
||||
- OTP emails are sent via the configured SMTP server. In dev, point SMTP at Mailpit or a console logger before testing - do **not** ship with the default "no-reply@example.com" sender.
|
||||
|
||||
Reference: [Auth with OTP](https://pocketbase.io/docs/authentication/#auth-with-otp) · [JS SDK - authWithOTP](https://github.com/pocketbase/js-sdk#authwithotp)
|
||||
104
.claude/skills/pocketbase-best-practices/rules/auth-password.md
Normal file
104
.claude/skills/pocketbase-best-practices/rules/auth-password.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Implement Secure Password Authentication
|
||||
impact: CRITICAL
|
||||
impactDescription: Secure user login with proper error handling and token management
|
||||
tags: authentication, password, login, security
|
||||
---
|
||||
|
||||
## Implement Secure Password Authentication
|
||||
|
||||
Password authentication should include proper error handling, avoid exposing whether emails exist, and correctly manage the auth store.
|
||||
|
||||
**Incorrect (exposing information and poor error handling):**
|
||||
|
||||
```javascript
|
||||
// Dangerous: exposes whether email exists
|
||||
async function login(email, password) {
|
||||
const user = await pb.collection('users').getFirstListItem(`email = "${email}"`);
|
||||
if (!user) {
|
||||
throw new Error('Email not found'); // Reveals email doesn't exist
|
||||
}
|
||||
|
||||
// Manual password check - never do this!
|
||||
if (user.password !== password) {
|
||||
throw new Error('Wrong password'); // Reveals password is wrong
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (secure authentication):**
|
||||
|
||||
```javascript
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
async function login(email, password) {
|
||||
try {
|
||||
// authWithPassword handles hashing and returns token
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
// Token is automatically stored in pb.authStore
|
||||
console.log('Logged in as:', authData.record.email);
|
||||
console.log('Token valid:', pb.authStore.isValid);
|
||||
|
||||
return authData;
|
||||
} catch (error) {
|
||||
// Generic error message - don't reveal if email exists
|
||||
if (error.status === 400) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
function isAuthenticated() {
|
||||
return pb.authStore.isValid;
|
||||
}
|
||||
|
||||
// Get current user
|
||||
function getCurrentUser() {
|
||||
return pb.authStore.record;
|
||||
}
|
||||
|
||||
// Logout
|
||||
function logout() {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
|
||||
// Listen for auth changes
|
||||
pb.authStore.onChange((token, record) => {
|
||||
console.log('Auth state changed:', record?.email || 'logged out');
|
||||
}, true); // true = fire immediately with current state
|
||||
```
|
||||
|
||||
**Auth collection configuration for password auth:**
|
||||
|
||||
```javascript
|
||||
// When creating auth collection via API (superuser only)
|
||||
await pb.collections.create({
|
||||
name: 'users',
|
||||
type: 'auth',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text' },
|
||||
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
|
||||
],
|
||||
passwordAuth: {
|
||||
enabled: true,
|
||||
identityFields: ['email', 'username'] // Fields that can be used to login
|
||||
},
|
||||
// Require minimum password length
|
||||
// (configured in Admin UI under collection options)
|
||||
});
|
||||
```
|
||||
|
||||
**Security considerations:**
|
||||
- Never store passwords in plain text
|
||||
- Use generic error messages
|
||||
- Implement rate limiting on your server
|
||||
- Consider adding MFA for sensitive applications
|
||||
|
||||
Reference: [PocketBase Auth](https://pocketbase.io/docs/authentication/)
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Manage Auth Tokens Properly
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevents unauthorized access, handles token expiration gracefully
|
||||
tags: authentication, tokens, refresh, security, session
|
||||
---
|
||||
|
||||
## Manage Auth Tokens Properly
|
||||
|
||||
Auth tokens should be refreshed before expiration, validated on critical operations, and properly cleared on logout. The SDK's authStore handles most of this automatically.
|
||||
|
||||
**Incorrect (ignoring token expiration):**
|
||||
|
||||
```javascript
|
||||
// Bad: never checking token validity
|
||||
async function fetchUserData() {
|
||||
// Token might be expired!
|
||||
const records = await pb.collection('posts').getList();
|
||||
return records;
|
||||
}
|
||||
|
||||
// Bad: manually managing tokens
|
||||
let authToken = localStorage.getItem('token');
|
||||
fetch('/api/posts', {
|
||||
headers: { 'Authorization': authToken } // Token might be invalid
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (proper token management):**
|
||||
|
||||
```javascript
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// Check token validity before operations
|
||||
async function fetchSecureData() {
|
||||
// authStore.isValid is a client-side check only (JWT expiry parsing).
|
||||
// Always verify server-side with authRefresh() for critical operations.
|
||||
if (!pb.authStore.isValid) {
|
||||
throw new Error('Please log in');
|
||||
}
|
||||
|
||||
return pb.collection('posts').getList();
|
||||
}
|
||||
|
||||
// Refresh token periodically or before expiration
|
||||
async function refreshAuthIfNeeded() {
|
||||
if (!pb.authStore.isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verifies current token and returns fresh one
|
||||
await pb.collection('users').authRefresh();
|
||||
console.log('Token refreshed');
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Token invalid - user needs to re-authenticate
|
||||
pb.authStore.clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh on app initialization
|
||||
async function initializeAuth() {
|
||||
if (pb.authStore.token) {
|
||||
try {
|
||||
await pb.collection('users').authRefresh();
|
||||
} catch {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for auth changes and handle expiration
|
||||
pb.authStore.onChange((token, record) => {
|
||||
if (!token) {
|
||||
// User logged out or token cleared
|
||||
redirectToLogin();
|
||||
}
|
||||
});
|
||||
|
||||
// Setup periodic refresh (e.g., every 10 minutes)
|
||||
setInterval(async () => {
|
||||
if (pb.authStore.isValid) {
|
||||
try {
|
||||
await pb.collection('users').authRefresh();
|
||||
} catch {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
```
|
||||
|
||||
**SSR / Server-side token handling:**
|
||||
|
||||
```javascript
|
||||
// Server-side: create fresh client per request
|
||||
export async function handleRequest(request) {
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// Load auth from cookie
|
||||
pb.authStore.loadFromCookie(request.headers.get('cookie') || '');
|
||||
|
||||
// Validate and refresh
|
||||
if (pb.authStore.isValid) {
|
||||
try {
|
||||
await pb.collection('users').authRefresh();
|
||||
} catch {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ... handle request ...
|
||||
|
||||
// Send updated cookie with secure options
|
||||
const response = new Response();
|
||||
response.headers.set('set-cookie', pb.authStore.exportToCookie({
|
||||
httpOnly: true, // Prevent XSS access to auth token
|
||||
secure: true, // HTTPS only
|
||||
sameSite: 'Lax', // CSRF protection
|
||||
}));
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
**Token configuration (Admin UI or migration):**
|
||||
|
||||
```javascript
|
||||
// Configure token durations (superuser only)
|
||||
await pb.collections.update('users', {
|
||||
authToken: {
|
||||
duration: 1209600 // 14 days in seconds
|
||||
},
|
||||
verificationToken: {
|
||||
duration: 604800 // 7 days
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [PocketBase Auth Store](https://pocketbase.io/docs/authentication/)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Use Auth Collections for User Accounts
|
||||
impact: CRITICAL
|
||||
impactDescription: Built-in authentication, password hashing, OAuth2 support
|
||||
tags: collections, auth, users, authentication, design
|
||||
---
|
||||
|
||||
## Use Auth Collections for User Accounts
|
||||
|
||||
Auth collections provide built-in authentication features including secure password hashing, email verification, OAuth2 support, and token management. Using base collections for users requires reimplementing these security-critical features.
|
||||
|
||||
**Incorrect (using base collection for users):**
|
||||
|
||||
```javascript
|
||||
// Base collection loses all auth features
|
||||
const usersCollection = {
|
||||
name: 'users',
|
||||
type: 'base', // Wrong! No auth capabilities
|
||||
schema: [
|
||||
{ name: 'email', type: 'email' },
|
||||
{ name: 'password', type: 'text' }, // Stored in plain text!
|
||||
{ name: 'name', type: 'text' }
|
||||
]
|
||||
};
|
||||
|
||||
// Manual login implementation - insecure
|
||||
const user = await pb.collection('users').getFirstListItem(
|
||||
`email = "${email}" && password = "${password}"` // SQL injection risk!
|
||||
);
|
||||
```
|
||||
|
||||
**Correct (using auth collection):**
|
||||
|
||||
```javascript
|
||||
// Auth collection with built-in security
|
||||
const usersCollection = {
|
||||
name: 'users',
|
||||
type: 'auth', // Enables authentication features
|
||||
schema: [
|
||||
{ name: 'name', type: 'text' },
|
||||
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
|
||||
],
|
||||
options: {
|
||||
allowEmailAuth: true,
|
||||
allowOAuth2Auth: true,
|
||||
requireEmail: true,
|
||||
minPasswordLength: 8
|
||||
}
|
||||
};
|
||||
|
||||
// Secure authentication with password hashing
|
||||
const authData = await pb.collection('users').authWithPassword(
|
||||
'user@example.com',
|
||||
'securePassword123'
|
||||
);
|
||||
|
||||
// Token automatically stored in authStore
|
||||
// NOTE: Never log tokens in production - shown here for illustration only
|
||||
console.log('Authenticated as:', pb.authStore.record.id);
|
||||
```
|
||||
|
||||
**When to use each type:**
|
||||
- **Auth collection**: User accounts, admin accounts, any entity that needs to log in
|
||||
- **Base collection**: Regular data like posts, products, orders, comments
|
||||
- **View collection**: Read-only aggregations or complex queries
|
||||
|
||||
Reference: [PocketBase Auth Collections](https://pocketbase.io/docs/collections/#auth-collection)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Choose Appropriate Field Types for Your Data
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevents data corruption, improves query performance, reduces storage
|
||||
tags: collections, schema, field-types, design
|
||||
---
|
||||
|
||||
## Choose Appropriate Field Types for Your Data
|
||||
|
||||
Selecting the wrong field type leads to data validation issues, wasted storage, and poor query performance. PocketBase provides specialized field types that enforce constraints at the database level.
|
||||
|
||||
**Incorrect (using text for everything):**
|
||||
|
||||
```javascript
|
||||
// Using plain text fields for structured data
|
||||
const collection = {
|
||||
name: 'products',
|
||||
schema: [
|
||||
{ name: 'price', type: 'text' }, // Should be number
|
||||
{ name: 'email', type: 'text' }, // Should be email
|
||||
{ name: 'website', type: 'text' }, // Should be url
|
||||
{ name: 'active', type: 'text' }, // Should be bool
|
||||
{ name: 'tags', type: 'text' }, // Should be select or json
|
||||
{ name: 'created', type: 'text' } // Should be autodate
|
||||
]
|
||||
};
|
||||
// No validation, inconsistent data, manual parsing required
|
||||
```
|
||||
|
||||
**Correct (using appropriate field types):**
|
||||
|
||||
```javascript
|
||||
// Using specialized field types with proper validation
|
||||
const collection = {
|
||||
name: 'products',
|
||||
type: 'base',
|
||||
schema: [
|
||||
{ name: 'price', type: 'number', options: { min: 0 } },
|
||||
{ name: 'email', type: 'email' },
|
||||
{ name: 'website', type: 'url' },
|
||||
{ name: 'active', type: 'bool' },
|
||||
{ name: 'tags', type: 'select', options: {
|
||||
maxSelect: 5,
|
||||
values: ['electronics', 'clothing', 'food', 'other']
|
||||
}},
|
||||
{ name: 'metadata', type: 'json' }
|
||||
// created/updated are automatic system fields
|
||||
]
|
||||
};
|
||||
// Built-in validation, proper indexing, type-safe queries
|
||||
```
|
||||
|
||||
**Available field types:**
|
||||
- `text` - Plain text with optional min/max length, regex pattern
|
||||
- `number` - Integer or decimal with optional min/max
|
||||
- `bool` - True/false values
|
||||
- `email` - Email with format validation
|
||||
- `url` - URL with format validation
|
||||
- `date` - Date/datetime values
|
||||
- `autodate` - Auto-set on create/update
|
||||
- `select` - Single or multi-select from predefined values
|
||||
- `json` - Arbitrary JSON data
|
||||
- `file` - File attachments
|
||||
- `relation` - References to other collections
|
||||
- `editor` - Rich text HTML content
|
||||
|
||||
Reference: [PocketBase Collections](https://pocketbase.io/docs/collections/)
|
||||
122
.claude/skills/pocketbase-best-practices/rules/coll-geopoint.md
Normal file
122
.claude/skills/pocketbase-best-practices/rules/coll-geopoint.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
title: Use GeoPoint Fields for Location Data
|
||||
impact: MEDIUM
|
||||
impactDescription: Built-in geographic queries, distance calculations
|
||||
tags: collections, geopoint, location, geographic, maps
|
||||
---
|
||||
|
||||
## Use GeoPoint Fields for Location Data
|
||||
|
||||
PocketBase provides a dedicated GeoPoint field type for storing geographic coordinates with built-in distance query support via `geoDistance()`.
|
||||
|
||||
**Incorrect (storing coordinates as separate fields):**
|
||||
|
||||
```javascript
|
||||
// Separate lat/lon fields - no built-in distance queries
|
||||
const placesSchema = [
|
||||
{ name: 'name', type: 'text' },
|
||||
{ name: 'latitude', type: 'number' },
|
||||
{ name: 'longitude', type: 'number' }
|
||||
];
|
||||
|
||||
// Manual distance calculation - complex and slow
|
||||
async function findNearby(lat, lon, maxKm) {
|
||||
const places = await pb.collection('places').getFullList();
|
||||
|
||||
// Calculate distance for every record client-side
|
||||
return places.filter(place => {
|
||||
const dist = haversine(lat, lon, place.latitude, place.longitude);
|
||||
return dist <= maxKm;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using GeoPoint field):**
|
||||
|
||||
```javascript
|
||||
// GeoPoint field stores coordinates as { lon, lat } object
|
||||
const placesSchema = [
|
||||
{ name: 'name', type: 'text' },
|
||||
{ name: 'location', type: 'geopoint' }
|
||||
];
|
||||
|
||||
// Creating a record with GeoPoint
|
||||
await pb.collection('places').create({
|
||||
name: 'Coffee Shop',
|
||||
location: { lon: -73.9857, lat: 40.7484 } // Note: lon first!
|
||||
});
|
||||
|
||||
// Or using "lon,lat" string format
|
||||
await pb.collection('places').create({
|
||||
name: 'Restaurant',
|
||||
location: '-73.9857,40.7484' // String format also works
|
||||
});
|
||||
|
||||
// Query nearby locations using geoDistance()
|
||||
async function findNearby(lon, lat, maxKm) {
|
||||
// geoDistance returns distance in kilometers
|
||||
const places = await pb.collection('places').getList(1, 50, {
|
||||
filter: pb.filter(
|
||||
'geoDistance(location, {:point}) <= {:maxKm}',
|
||||
{
|
||||
point: { lon, lat },
|
||||
maxKm: maxKm
|
||||
}
|
||||
),
|
||||
sort: pb.filter('geoDistance(location, {:point})', { point: { lon, lat } })
|
||||
});
|
||||
|
||||
return places;
|
||||
}
|
||||
|
||||
// Find places within 5km of Times Square
|
||||
const nearbyPlaces = await findNearby(-73.9857, 40.7580, 5);
|
||||
|
||||
// Use in API rules for location-based access
|
||||
// listRule: geoDistance(location, @request.query.point) <= 10
|
||||
```
|
||||
|
||||
**geoDistance() function:**
|
||||
|
||||
```javascript
|
||||
// Syntax: geoDistance(geopointField, referencePoint)
|
||||
// Returns: distance in kilometers
|
||||
|
||||
// In filter expressions
|
||||
filter: 'geoDistance(location, "-73.9857,40.7484") <= 5'
|
||||
|
||||
// With parameter binding (recommended)
|
||||
filter: pb.filter('geoDistance(location, {:center}) <= {:radius}', {
|
||||
center: { lon: -73.9857, lat: 40.7484 },
|
||||
radius: 5
|
||||
})
|
||||
|
||||
// Sorting by distance
|
||||
sort: 'geoDistance(location, "-73.9857,40.7484")' // Closest first
|
||||
sort: '-geoDistance(location, "-73.9857,40.7484")' // Farthest first
|
||||
```
|
||||
|
||||
**GeoPoint data format:**
|
||||
|
||||
```javascript
|
||||
// Object format (recommended)
|
||||
{ lon: -73.9857, lat: 40.7484 }
|
||||
|
||||
// String format
|
||||
"-73.9857,40.7484" // "lon,lat" order
|
||||
|
||||
// Important: longitude comes FIRST (GeoJSON convention)
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Store-locator / find nearby
|
||||
- Delivery radius validation
|
||||
- Geofencing in API rules
|
||||
- Location-based search results
|
||||
|
||||
**Limitations:**
|
||||
- Spherical Earth calculation (accurate to ~0.3%)
|
||||
- No polygon/area containment queries
|
||||
- Single point per field (use multiple fields for routes)
|
||||
|
||||
Reference: [PocketBase GeoPoint](https://pocketbase.io/docs/collections/#geopoint)
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Create Indexes for Frequently Filtered Fields
|
||||
impact: CRITICAL
|
||||
impactDescription: 10-100x faster queries on large collections
|
||||
tags: collections, indexes, performance, query-optimization
|
||||
---
|
||||
|
||||
## Create Indexes for Frequently Filtered Fields
|
||||
|
||||
PocketBase uses SQLite which benefits significantly from proper indexing. Queries filtering or sorting on unindexed fields perform full table scans.
|
||||
|
||||
**Incorrect (no indexes on filtered fields):**
|
||||
|
||||
```javascript
|
||||
// Querying without indexes
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: 'author = "user123" && status = "published"',
|
||||
sort: '-publishedAt'
|
||||
});
|
||||
// Full table scan on large collections - very slow
|
||||
|
||||
// API rules also query without indexes
|
||||
// listRule: "author = @request.auth.id"
|
||||
// Every list request scans entire table
|
||||
```
|
||||
|
||||
**Correct (indexed fields):**
|
||||
|
||||
```javascript
|
||||
// Create collection with indexes via Admin UI or migration
|
||||
// In PocketBase Admin: Collection > Indexes > Add Index
|
||||
|
||||
// Common index patterns:
|
||||
// 1. Single field index for equality filters
|
||||
// CREATE INDEX idx_posts_author ON posts(author)
|
||||
|
||||
// 2. Composite index for multiple filters
|
||||
// CREATE INDEX idx_posts_author_status ON posts(author, status)
|
||||
|
||||
// 3. Index with sort field
|
||||
// CREATE INDEX idx_posts_status_published ON posts(status, publishedAt DESC)
|
||||
|
||||
// Queries now use indexes
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: 'author = "user123" && status = "published"',
|
||||
sort: '-publishedAt'
|
||||
});
|
||||
// Index scan - fast even with millions of records
|
||||
|
||||
// For unique constraints (e.g., slug)
|
||||
// CREATE UNIQUE INDEX idx_posts_slug ON posts(slug)
|
||||
```
|
||||
|
||||
**Index recommendations:**
|
||||
- Fields used in `filter` expressions
|
||||
- Fields used in `sort` parameters
|
||||
- Fields used in API rules (`listRule`, `viewRule`, etc.)
|
||||
- Relation fields (automatically indexed)
|
||||
- Unique fields like slugs or codes
|
||||
|
||||
**Index considerations for SQLite:**
|
||||
- Composite indexes work left-to-right (order matters)
|
||||
- Too many indexes slow down writes
|
||||
- Use `EXPLAIN QUERY PLAN` in SQL to verify index usage
|
||||
- Partial indexes for filtered subsets
|
||||
|
||||
```sql
|
||||
-- Check if index is used
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT * FROM posts WHERE author = 'user123' AND status = 'published';
|
||||
-- Should show "USING INDEX" not "SCAN"
|
||||
```
|
||||
|
||||
Reference: [SQLite Query Planning](https://www.sqlite.org/queryplanner.html)
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: Configure Relations with Proper Cascade Options
|
||||
impact: CRITICAL
|
||||
impactDescription: Maintains referential integrity, prevents orphaned records, controls deletion behavior
|
||||
tags: collections, relations, foreign-keys, cascade, design
|
||||
---
|
||||
|
||||
## Configure Relations with Proper Cascade Options
|
||||
|
||||
Relation fields connect collections together. Proper cascade configuration ensures data integrity when referenced records are deleted.
|
||||
|
||||
**Incorrect (default cascade behavior not considered):**
|
||||
|
||||
```javascript
|
||||
// Relation without considering deletion behavior
|
||||
const ordersSchema = [
|
||||
{ name: 'customer', type: 'relation', options: {
|
||||
collectionId: 'customers_collection_id',
|
||||
maxSelect: 1
|
||||
// No cascade options specified - defaults may cause issues
|
||||
}},
|
||||
{ name: 'products', type: 'relation', options: {
|
||||
collectionId: 'products_collection_id'
|
||||
// Multiple products, no cascade handling
|
||||
}}
|
||||
];
|
||||
|
||||
// Deleting a customer may fail or orphan orders
|
||||
await pb.collection('customers').delete(customerId);
|
||||
// Error: record is referenced by other records
|
||||
```
|
||||
|
||||
**Correct (explicit cascade configuration):**
|
||||
|
||||
```javascript
|
||||
// Carefully configured relations
|
||||
const ordersSchema = [
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'relation',
|
||||
required: true,
|
||||
options: {
|
||||
collectionId: 'customers_collection_id',
|
||||
maxSelect: 1,
|
||||
cascadeDelete: false // Prevent accidental mass deletion
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'products',
|
||||
type: 'relation',
|
||||
options: {
|
||||
collectionId: 'products_collection_id',
|
||||
maxSelect: 99,
|
||||
cascadeDelete: false
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// For dependent data like comments - cascade delete makes sense
|
||||
const commentsSchema = [
|
||||
{
|
||||
name: 'post',
|
||||
type: 'relation',
|
||||
options: {
|
||||
collectionId: 'posts_collection_id',
|
||||
maxSelect: 1,
|
||||
cascadeDelete: true // Delete comments when post is deleted
|
||||
}
|
||||
}
|
||||
];
|
||||
// NOTE: For audit logs, avoid cascadeDelete - logs should be retained
|
||||
// for compliance/forensics even after the referenced user is deleted.
|
||||
// Use cascadeDelete: false and handle user deletion separately.
|
||||
|
||||
// Handle deletion manually when cascade is false
|
||||
try {
|
||||
await pb.collection('customers').delete(customerId);
|
||||
} catch (e) {
|
||||
if (e.status === 400) {
|
||||
// Customer has orders - handle appropriately
|
||||
// Option 1: Soft delete (set 'deleted' flag)
|
||||
// Option 2: Reassign orders
|
||||
// Option 3: Delete orders first
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cascade options:**
|
||||
- `cascadeDelete: true` - Delete referencing records when referenced record is deleted
|
||||
- `cascadeDelete: false` - Block deletion if references exist (default for required relations)
|
||||
|
||||
**Best practices:**
|
||||
- Use `cascadeDelete: true` for dependent data (comments on posts, logs for users)
|
||||
- Use `cascadeDelete: false` for important data (orders, transactions)
|
||||
- Consider soft deletes for audit trails
|
||||
- Document your cascade strategy
|
||||
|
||||
Reference: [PocketBase Relations](https://pocketbase.io/docs/collections/#relation)
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Use View Collections for Complex Read-Only Queries
|
||||
impact: HIGH
|
||||
impactDescription: Simplifies complex queries, improves maintainability, enables aggregations
|
||||
tags: collections, views, sql, aggregation, design
|
||||
---
|
||||
|
||||
## Use View Collections for Complex Read-Only Queries
|
||||
|
||||
View collections execute custom SQL queries and expose results through the standard API. They're ideal for aggregations, joins, and computed fields without duplicating logic across your application.
|
||||
|
||||
**Incorrect (computing aggregations client-side):**
|
||||
|
||||
```javascript
|
||||
// Fetching all records to compute stats client-side
|
||||
const orders = await pb.collection('orders').getFullList();
|
||||
const products = await pb.collection('products').getFullList();
|
||||
|
||||
// Expensive client-side computation
|
||||
const stats = orders.reduce((acc, order) => {
|
||||
const product = products.find(p => p.id === order.product);
|
||||
acc.totalRevenue += order.quantity * product.price;
|
||||
acc.orderCount++;
|
||||
return acc;
|
||||
}, { totalRevenue: 0, orderCount: 0 });
|
||||
// Fetches all data, slow, memory-intensive
|
||||
```
|
||||
|
||||
**Correct (using view collection):**
|
||||
|
||||
```javascript
|
||||
// Create a view collection in PocketBase Admin UI or via API
|
||||
// View SQL:
|
||||
// SELECT
|
||||
// p.id,
|
||||
// p.name,
|
||||
// COUNT(o.id) as order_count,
|
||||
// SUM(o.quantity) as total_sold,
|
||||
// SUM(o.quantity * p.price) as revenue
|
||||
// FROM products p
|
||||
// LEFT JOIN orders o ON o.product = p.id
|
||||
// GROUP BY p.id
|
||||
|
||||
// Simple, efficient query
|
||||
const productStats = await pb.collection('product_stats').getList(1, 20, {
|
||||
sort: '-revenue'
|
||||
});
|
||||
|
||||
// Each record has computed fields
|
||||
productStats.items.forEach(stat => {
|
||||
console.log(`${stat.name}: ${stat.order_count} orders, $${stat.revenue}`);
|
||||
});
|
||||
```
|
||||
|
||||
**View collection use cases:**
|
||||
- Aggregations (COUNT, SUM, AVG)
|
||||
- Joining data from multiple collections
|
||||
- Computed/derived fields
|
||||
- Denormalized read models
|
||||
- Dashboard statistics
|
||||
|
||||
**Limitations:**
|
||||
- Read-only (no create/update/delete)
|
||||
- Must return `id` column
|
||||
- No realtime subscriptions
|
||||
- API rules still apply for access control
|
||||
|
||||
Reference: [PocketBase View Collections](https://pocketbase.io/docs/collections/#view-collection)
|
||||
142
.claude/skills/pocketbase-best-practices/rules/deploy-backup.md
Normal file
142
.claude/skills/pocketbase-best-practices/rules/deploy-backup.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Implement Proper Backup Strategies
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Prevents data loss, enables disaster recovery
|
||||
tags: production, backup, disaster-recovery, data-protection
|
||||
---
|
||||
|
||||
## Implement Proper Backup Strategies
|
||||
|
||||
Regular backups are essential for production deployments. PocketBase provides built-in backup functionality and supports external S3 storage.
|
||||
|
||||
**Incorrect (no backup strategy):**
|
||||
|
||||
```javascript
|
||||
// No backups at all - disaster waiting to happen
|
||||
// Just running: ./pocketbase serve
|
||||
|
||||
// Manual file copy while server running - can corrupt data
|
||||
// cp pb_data/data.db backup/
|
||||
|
||||
// Only backing up database, missing files
|
||||
// sqlite3 pb_data/data.db ".backup backup.db"
|
||||
```
|
||||
|
||||
**Correct (comprehensive backup strategy):**
|
||||
|
||||
```javascript
|
||||
// 1. Using PocketBase Admin API for backups
|
||||
const adminPb = new PocketBase('http://127.0.0.1:8090');
|
||||
await adminPb.collection('_superusers').authWithPassword(admin, password);
|
||||
|
||||
// Create backup (includes database and files)
|
||||
async function createBackup(name = '') {
|
||||
const backup = await adminPb.backups.create(name);
|
||||
console.log('Backup created:', backup.key);
|
||||
return backup;
|
||||
}
|
||||
|
||||
// List available backups
|
||||
async function listBackups() {
|
||||
const backups = await adminPb.backups.getFullList();
|
||||
backups.forEach(b => {
|
||||
console.log(`${b.key} - ${b.size} bytes - ${b.modified}`);
|
||||
});
|
||||
return backups;
|
||||
}
|
||||
|
||||
// Download backup
|
||||
async function downloadBackup(key) {
|
||||
const token = await adminPb.files.getToken();
|
||||
const url = adminPb.backups.getDownloadURL(token, key);
|
||||
// url can be used to download the backup file
|
||||
return url;
|
||||
}
|
||||
|
||||
// Restore from backup (CAUTION: overwrites current data!)
|
||||
async function restoreBackup(key) {
|
||||
await adminPb.backups.restore(key);
|
||||
console.log('Restore initiated - server will restart');
|
||||
}
|
||||
|
||||
// Delete old backups
|
||||
async function cleanupOldBackups(keepCount = 7) {
|
||||
const backups = await adminPb.backups.getFullList();
|
||||
|
||||
// Sort by date, keep newest
|
||||
const sorted = backups.sort((a, b) =>
|
||||
new Date(b.modified) - new Date(a.modified)
|
||||
);
|
||||
|
||||
const toDelete = sorted.slice(keepCount);
|
||||
for (const backup of toDelete) {
|
||||
await adminPb.backups.delete(backup.key);
|
||||
console.log('Deleted old backup:', backup.key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Automated backup script (cron job):**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup.sh - Run daily via cron
|
||||
|
||||
POCKETBASE_URL="http://127.0.0.1:8090"
|
||||
ADMIN_EMAIL="admin@example.com"
|
||||
ADMIN_PASSWORD="your-secure-password"
|
||||
BACKUP_DIR="/path/to/backups"
|
||||
KEEP_DAYS=7
|
||||
|
||||
# Create timestamp
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# Create backup via API
|
||||
curl -X POST "${POCKETBASE_URL}/api/backups" \
|
||||
-H "Authorization: $(curl -s -X POST "${POCKETBASE_URL}/api/collections/_superusers/auth-with-password" \
|
||||
-d "identity=${ADMIN_EMAIL}&password=${ADMIN_PASSWORD}" | jq -r '.token')" \
|
||||
-d "name=backup_${TIMESTAMP}"
|
||||
|
||||
# Clean old local backups
|
||||
find "${BACKUP_DIR}" -name "*.zip" -mtime +${KEEP_DAYS} -delete
|
||||
|
||||
echo "Backup completed: backup_${TIMESTAMP}"
|
||||
```
|
||||
|
||||
**Configure S3 for backup storage:**
|
||||
|
||||
```javascript
|
||||
// In Admin UI: Settings > Backups > S3
|
||||
// Or via API:
|
||||
await adminPb.settings.update({
|
||||
backups: {
|
||||
s3: {
|
||||
enabled: true,
|
||||
bucket: 'my-pocketbase-backups',
|
||||
region: 'us-east-1',
|
||||
endpoint: 's3.amazonaws.com',
|
||||
accessKey: process.env.AWS_ACCESS_KEY,
|
||||
secret: process.env.AWS_SECRET_KEY
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Backup best practices:**
|
||||
|
||||
| Aspect | Recommendation |
|
||||
|--------|---------------|
|
||||
| Frequency | Daily minimum, hourly for critical apps |
|
||||
| Retention | 7-30 days of daily backups |
|
||||
| Storage | Off-site (S3, separate server) |
|
||||
| Testing | Monthly restore tests |
|
||||
| Monitoring | Alert on backup failures |
|
||||
|
||||
**Pre-backup checklist:**
|
||||
- [ ] S3 or external storage configured
|
||||
- [ ] Automated schedule set up
|
||||
- [ ] Retention policy defined
|
||||
- [ ] Restore procedure documented
|
||||
- [ ] Restore tested successfully
|
||||
|
||||
Reference: [PocketBase Backups](https://pocketbase.io/docs/going-to-production/#backups)
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: Configure Production Settings Properly
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Secure and optimized production environment
|
||||
tags: production, configuration, security, environment
|
||||
---
|
||||
|
||||
## Configure Production Settings Properly
|
||||
|
||||
Production deployments require proper configuration of URLs, secrets, SMTP, and security settings.
|
||||
|
||||
**Incorrect (development defaults in production):**
|
||||
|
||||
```bash
|
||||
# Running with defaults - insecure!
|
||||
./pocketbase serve
|
||||
|
||||
# Hardcoded secrets
|
||||
./pocketbase serve --encryptionEnv="mySecretKey123"
|
||||
|
||||
# Wrong origin for CORS
|
||||
# Leaving http://localhost:8090 as allowed origin
|
||||
```
|
||||
|
||||
**Correct (production configuration):**
|
||||
|
||||
```bash
|
||||
# Production startup with essential flags
|
||||
./pocketbase serve \
|
||||
--http="0.0.0.0:8090" \
|
||||
--origins="https://myapp.com,https://www.myapp.com" \
|
||||
--encryptionEnv="PB_ENCRYPTION_KEY"
|
||||
|
||||
# Using environment variables
|
||||
export PB_ENCRYPTION_KEY="your-32-char-encryption-key-here"
|
||||
export SMTP_HOST="smtp.sendgrid.net"
|
||||
export SMTP_PORT="587"
|
||||
export SMTP_USER="apikey"
|
||||
export SMTP_PASS="your-sendgrid-api-key"
|
||||
|
||||
./pocketbase serve --http="0.0.0.0:8090"
|
||||
```
|
||||
|
||||
**Configure SMTP for emails:**
|
||||
|
||||
```javascript
|
||||
// Via Admin UI or API
|
||||
await adminPb.settings.update({
|
||||
smtp: {
|
||||
enabled: true,
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT),
|
||||
username: process.env.SMTP_USER,
|
||||
password: process.env.SMTP_PASS,
|
||||
tls: true
|
||||
},
|
||||
meta: {
|
||||
appName: 'My App',
|
||||
appURL: 'https://myapp.com',
|
||||
senderName: 'My App',
|
||||
senderAddress: 'noreply@myapp.com'
|
||||
}
|
||||
});
|
||||
|
||||
// Test email configuration
|
||||
await adminPb.settings.testEmail('users', 'test@example.com', 'verification');
|
||||
```
|
||||
|
||||
**Configure S3 for file storage:**
|
||||
|
||||
```javascript
|
||||
// Move file storage to S3 for scalability
|
||||
await adminPb.settings.update({
|
||||
s3: {
|
||||
enabled: true,
|
||||
bucket: 'my-app-files',
|
||||
region: 'us-east-1',
|
||||
endpoint: 's3.amazonaws.com',
|
||||
accessKey: process.env.AWS_ACCESS_KEY,
|
||||
secret: process.env.AWS_SECRET_KEY,
|
||||
forcePathStyle: false
|
||||
}
|
||||
});
|
||||
|
||||
// Test S3 connection
|
||||
await adminPb.settings.testS3('storage');
|
||||
```
|
||||
|
||||
**Systemd service file:**
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/pocketbase.service
|
||||
[Unit]
|
||||
Description=PocketBase
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pocketbase
|
||||
Group=pocketbase
|
||||
LimitNOFILE=4096
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
WorkingDirectory=/opt/pocketbase
|
||||
ExecStart=/opt/pocketbase/pocketbase serve --http="127.0.0.1:8090"
|
||||
|
||||
# Environment variables
|
||||
EnvironmentFile=/opt/pocketbase/.env
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/opt/pocketbase/pb_data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**Environment file (.env):**
|
||||
|
||||
```bash
|
||||
# /opt/pocketbase/.env
|
||||
# SECURITY: Set restrictive permissions: chmod 600 /opt/pocketbase/.env
|
||||
# SECURITY: Add to .gitignore - NEVER commit this file to version control
|
||||
# For production, consider a secrets manager (Vault, AWS Secrets Manager, etc.)
|
||||
|
||||
PB_ENCRYPTION_KEY= # Generate with: openssl rand -hex 16
|
||||
|
||||
# SMTP
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS= # Set your SMTP password here
|
||||
|
||||
# S3 (optional)
|
||||
AWS_ACCESS_KEY= # Set your AWS access key
|
||||
AWS_SECRET_KEY= # Set your AWS secret key
|
||||
|
||||
# OAuth (optional)
|
||||
GOOGLE_CLIENT_ID= # Set your Google client ID
|
||||
GOOGLE_CLIENT_SECRET= # Set your Google client secret
|
||||
```
|
||||
|
||||
**Protect your environment file:**
|
||||
|
||||
```bash
|
||||
# Set restrictive permissions (owner read/write only)
|
||||
chmod 600 /opt/pocketbase/.env
|
||||
chown pocketbase:pocketbase /opt/pocketbase/.env
|
||||
|
||||
# Ensure .env is in .gitignore
|
||||
echo ".env" >> .gitignore
|
||||
```
|
||||
|
||||
**Production checklist:**
|
||||
|
||||
- [ ] HTTPS enabled (via reverse proxy)
|
||||
- [ ] Strong encryption key set
|
||||
- [ ] CORS origins configured
|
||||
- [ ] SMTP configured and tested
|
||||
- [ ] Superuser password changed
|
||||
- [ ] S3 configured (for scalability)
|
||||
- [ ] Backup schedule configured
|
||||
- [ ] Rate limiting enabled (via reverse proxy)
|
||||
- [ ] Logging configured
|
||||
|
||||
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Enable Rate Limiting for API Protection
|
||||
impact: MEDIUM
|
||||
impactDescription: Prevents abuse, brute-force attacks, and DoS
|
||||
tags: production, security, rate-limiting, abuse-prevention
|
||||
---
|
||||
|
||||
## Enable Rate Limiting for API Protection
|
||||
|
||||
PocketBase v0.23+ includes built-in rate limiting. Enable it to protect against brute-force attacks, API abuse, and excessive resource consumption.
|
||||
|
||||
> **v0.36.7 behavioral change:** the built-in limiter switched from a sliding-window to a **fixed-window** strategy. This is cheaper and more predictable, but it means a client can send `2 * maxRequests` in quick succession if they straddle the window boundary. Size your limits with that worst case in mind, and put Nginx/Caddy rate limiting in front of PocketBase for defense in depth (see examples below).
|
||||
|
||||
**Incorrect (no rate limiting):**
|
||||
|
||||
```bash
|
||||
# Running without rate limiting
|
||||
./pocketbase serve
|
||||
|
||||
# Vulnerable to:
|
||||
# - Brute-force password attacks
|
||||
# - API abuse and scraping
|
||||
# - DoS from excessive requests
|
||||
# - Account enumeration attempts
|
||||
```
|
||||
|
||||
**Correct (enable rate limiting):**
|
||||
|
||||
```bash
|
||||
# Enable via command line flag
|
||||
./pocketbase serve --rateLimiter=true
|
||||
|
||||
# Or configure specific limits (requests per second per IP)
|
||||
./pocketbase serve --rateLimiter=true --rateLimiterRPS=10
|
||||
```
|
||||
|
||||
**Configure via Admin Dashboard:**
|
||||
|
||||
Navigate to Settings > Rate Limiter:
|
||||
- **Enable rate limiter**: Toggle on
|
||||
- **Max requests/second**: Default 10, adjust based on needs
|
||||
- **Exempt endpoints**: Optionally whitelist certain paths
|
||||
|
||||
**Configure programmatically (Go/JS hooks):**
|
||||
|
||||
```javascript
|
||||
// In pb_hooks/rate_limit.pb.js
|
||||
routerAdd("GET", "/api/public/*", (e) => {
|
||||
// Custom rate limit for specific endpoints
|
||||
}, $apis.rateLimit(100, "10s")); // 100 requests per 10 seconds
|
||||
|
||||
// Stricter limit for auth endpoints
|
||||
routerAdd("POST", "/api/collections/users/auth-*", (e) => {
|
||||
// Auth endpoints need stricter limits
|
||||
}, $apis.rateLimit(5, "1m")); // 5 attempts per minute
|
||||
```
|
||||
|
||||
**Rate limiting with reverse proxy (additional layer):**
|
||||
|
||||
```nginx
|
||||
# Nginx rate limiting (defense in depth)
|
||||
http {
|
||||
# Define rate limit zones
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
|
||||
|
||||
server {
|
||||
# General API rate limit
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://pocketbase;
|
||||
}
|
||||
|
||||
# Strict limit for auth endpoints
|
||||
location /api/collections/users/auth {
|
||||
limit_req zone=auth burst=5 nodelay;
|
||||
proxy_pass http://pocketbase;
|
||||
}
|
||||
|
||||
# Stricter limit for superuser auth
|
||||
location /api/collections/_superusers/auth {
|
||||
limit_req zone=auth burst=3 nodelay;
|
||||
proxy_pass http://pocketbase;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```caddyfile
|
||||
# Caddy with rate limiting plugin
|
||||
myapp.com {
|
||||
rate_limit {
|
||||
zone api {
|
||||
key {remote_host}
|
||||
events 100
|
||||
window 10s
|
||||
}
|
||||
zone auth {
|
||||
key {remote_host}
|
||||
events 5
|
||||
window 1m
|
||||
}
|
||||
}
|
||||
|
||||
@auth path /api/collections/*/auth*
|
||||
handle @auth {
|
||||
rate_limit { zone auth }
|
||||
reverse_proxy 127.0.0.1:8090
|
||||
}
|
||||
|
||||
handle {
|
||||
rate_limit { zone api }
|
||||
reverse_proxy 127.0.0.1:8090
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Handle rate limit errors in client:**
|
||||
|
||||
```javascript
|
||||
async function makeRequest(fn, retries = 0, maxRetries = 3) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error.status === 429 && retries < maxRetries) {
|
||||
// Rate limited - wait and retry with limit
|
||||
const retryAfter = error.response?.retryAfter || 60;
|
||||
console.log(`Rate limited. Retry ${retries + 1}/${maxRetries} after ${retryAfter}s`);
|
||||
|
||||
// Show user-friendly message
|
||||
showMessage('Too many requests. Please wait a moment.');
|
||||
|
||||
await sleep(retryAfter * 1000);
|
||||
return makeRequest(fn, retries + 1, maxRetries);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = await makeRequest(() =>
|
||||
pb.collection('posts').getList(1, 20)
|
||||
);
|
||||
```
|
||||
|
||||
**Recommended limits by endpoint type:**
|
||||
|
||||
| Endpoint Type | Suggested Limit | Reason |
|
||||
|--------------|-----------------|--------|
|
||||
| Auth endpoints | 5-10/min | Prevent brute-force |
|
||||
| Password reset | 3/hour | Prevent enumeration |
|
||||
| Record creation | 30/min | Prevent spam |
|
||||
| General API | 60-100/min | Normal usage |
|
||||
| Public read | 100-200/min | Higher for reads |
|
||||
| File uploads | 10/min | Resource-intensive |
|
||||
|
||||
**Monitoring rate limit hits:**
|
||||
|
||||
```javascript
|
||||
// Check PocketBase logs for rate limit events
|
||||
// Or set up alerting in your monitoring system
|
||||
|
||||
// Client-side tracking
|
||||
pb.afterSend = function(response, data) {
|
||||
if (response.status === 429) {
|
||||
trackEvent('rate_limit_hit', {
|
||||
endpoint: response.url,
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
title: Configure Reverse Proxy Correctly
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: HTTPS, caching, rate limiting, and security headers
|
||||
tags: production, nginx, caddy, https, proxy
|
||||
---
|
||||
|
||||
## Configure Reverse Proxy Correctly
|
||||
|
||||
Use a reverse proxy (Nginx, Caddy) for HTTPS termination, caching, rate limiting, and security headers.
|
||||
|
||||
**Incorrect (exposing PocketBase directly):**
|
||||
|
||||
```bash
|
||||
# Direct exposure - no HTTPS, no rate limiting
|
||||
./pocketbase serve --http="0.0.0.0:8090"
|
||||
|
||||
# Port forwarding without proxy
|
||||
iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8090
|
||||
# Still no HTTPS!
|
||||
```
|
||||
|
||||
**Correct (Caddy - simplest option):**
|
||||
|
||||
```caddyfile
|
||||
# /etc/caddy/Caddyfile
|
||||
myapp.com {
|
||||
# Automatic HTTPS via Let's Encrypt
|
||||
reverse_proxy 127.0.0.1:8090 {
|
||||
# Required for SSE/Realtime
|
||||
flush_interval -1
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
-Server
|
||||
}
|
||||
|
||||
# Restrict admin UI to internal/VPN networks
|
||||
# @admin path /_/*
|
||||
# handle @admin {
|
||||
# @blocked not remote_ip 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||
# respond @blocked 403
|
||||
# reverse_proxy 127.0.0.1:8090
|
||||
# }
|
||||
|
||||
# Rate limiting (requires caddy-ratelimit plugin)
|
||||
# Install: xcaddy build --with github.com/mholt/caddy-ratelimit
|
||||
# Without this plugin, use PocketBase's built-in rate limiter (--rateLimiter=true)
|
||||
# rate_limit {
|
||||
# zone api {
|
||||
# key {remote_host}
|
||||
# events 100
|
||||
# window 1m
|
||||
# }
|
||||
# }
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (Nginx configuration):**
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/pocketbase
|
||||
|
||||
# Rate limit zones must be defined in http context (e.g., /etc/nginx/nginx.conf)
|
||||
# limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
|
||||
upstream pocketbase {
|
||||
server 127.0.0.1:8090;
|
||||
keepalive 64;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name myapp.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name myapp.com;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
# Note: X-XSS-Protection is deprecated and can introduce vulnerabilities.
|
||||
# Use Content-Security-Policy instead.
|
||||
|
||||
location / {
|
||||
proxy_pass http://pocketbase;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE/Realtime support
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding off;
|
||||
|
||||
# Timeouts
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Rate limit API endpoints
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
|
||||
proxy_pass http://pocketbase;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Static file caching
|
||||
location /api/files/ {
|
||||
proxy_pass http://pocketbase;
|
||||
proxy_cache_valid 200 1d;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain application/json application/javascript text/css;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
```
|
||||
|
||||
**Docker Compose with Caddy:**
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
# NOTE: This is a third-party community image, not officially maintained by PocketBase.
|
||||
# For production, consider building your own image from the official PocketBase binary.
|
||||
# See: https://pocketbase.io/docs/going-to-production/
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./pb_data:/pb_data
|
||||
environment:
|
||||
- PB_ENCRYPTION_KEY=${PB_ENCRYPTION_KEY}
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- pocketbase
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
```
|
||||
|
||||
**Key configuration points:**
|
||||
|
||||
| Feature | Why It Matters |
|
||||
|---------|---------------|
|
||||
| HTTPS | Encrypts traffic, required for auth |
|
||||
| SSE support | `proxy_buffering off` for realtime |
|
||||
| Rate limiting | Prevents abuse |
|
||||
| Security headers | XSS/clickjacking protection |
|
||||
| Keepalive | Connection reuse, better performance |
|
||||
|
||||
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)
|
||||
102
.claude/skills/pocketbase-best-practices/rules/deploy-scaling.md
Normal file
102
.claude/skills/pocketbase-best-practices/rules/deploy-scaling.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Tune OS and Runtime for PocketBase Scale
|
||||
impact: MEDIUM
|
||||
impactDescription: Prevents file descriptor exhaustion, OOM kills, and exposes secure config for production deployments
|
||||
tags: production, scaling, ulimit, gomemlimit, docker, encryption, deployment
|
||||
---
|
||||
|
||||
## Tune OS and Runtime for PocketBase Scale
|
||||
|
||||
Three low-effort OS/runtime knobs have outsized impact on production stability: open-file limits for realtime connections, Go memory limits for constrained hosts, and settings encryption for shared or externally-backed infrastructure. None of these are set automatically.
|
||||
|
||||
**Incorrect (default OS limits, no memory governor, plain-text settings):**
|
||||
|
||||
```bash
|
||||
# Start without raising the file descriptor limit
|
||||
/root/pb/pocketbase serve yourdomain.com
|
||||
# → "Too many open files" once concurrent realtime connections exceed ~1024
|
||||
|
||||
# Start in a container that has a 512 MB RAM cap without GOMEMLIMIT
|
||||
docker run -m 512m pocketbase serve ...
|
||||
# → OOM kill during large file upload because Go GC doesn't respect cgroup limits
|
||||
|
||||
# Store SMTP password and S3 secret as plain JSON in pb_data/data.db
|
||||
pocketbase serve # no --encryptionEnv
|
||||
# → Anyone who obtains the database backup can read all credentials
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```bash
|
||||
# 1. Raise the open-file limit before starting (Linux/macOS)
|
||||
# Check current limit first:
|
||||
ulimit -a | grep "open files"
|
||||
# Temporarily raise to 4096 for the current session:
|
||||
ulimit -n 4096
|
||||
/root/pb/pocketbase serve yourdomain.com
|
||||
|
||||
# Or persist it via systemd (recommended for production):
|
||||
# /lib/systemd/system/pocketbase.service
|
||||
# [Service]
|
||||
# LimitNOFILE = 4096
|
||||
# ...
|
||||
|
||||
# 2. Cap Go's soft memory target on memory-constrained hosts
|
||||
# (instructs the GC to be more aggressive before the kernel OOM-kills the process)
|
||||
GOMEMLIMIT=512MiB /root/pb/pocketbase serve yourdomain.com
|
||||
|
||||
# 3. Encrypt application settings at rest
|
||||
# Generate a random 32-character key once:
|
||||
export PB_ENCRYPTION_KEY="z76NX9WWiB05UmQGxw367B6zM39T11fF"
|
||||
# Start with the env-var name (not the value) as the flag argument:
|
||||
pocketbase serve --encryptionEnv=PB_ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
**Docker deployment pattern (v0.36.8):**
|
||||
|
||||
```dockerfile
|
||||
FROM alpine:latest
|
||||
ARG PB_VERSION=0.36.8
|
||||
|
||||
RUN apk add --no-cache unzip ca-certificates
|
||||
|
||||
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
|
||||
RUN unzip /tmp/pb.zip -d /pb/
|
||||
|
||||
# Uncomment to bundle pre-written migrations or hooks:
|
||||
# COPY ./pb_migrations /pb/pb_migrations
|
||||
# COPY ./pb_hooks /pb/pb_hooks
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Mount a volume at /pb/pb_data to persist data across container restarts
|
||||
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
pocketbase:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- pb_data:/pb/pb_data
|
||||
environment:
|
||||
GOMEMLIMIT: "512MiB"
|
||||
PB_ENCRYPTION_KEY: "${PB_ENCRYPTION_KEY}"
|
||||
command: ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080", "--encryptionEnv=PB_ENCRYPTION_KEY"]
|
||||
volumes:
|
||||
pb_data:
|
||||
```
|
||||
|
||||
**Quick-reference checklist:**
|
||||
|
||||
| Concern | Fix |
|
||||
|---------|-----|
|
||||
| `Too many open files` errors | `ulimit -n 4096` (or `LimitNOFILE=4096` in systemd) |
|
||||
| OOM kill on constrained host | `GOMEMLIMIT=512MiB` env var |
|
||||
| Credentials visible in DB backup | `--encryptionEnv=YOUR_VAR` with a 32-char random key |
|
||||
| Persistent data in Docker | Mount volume at `/pb/pb_data` |
|
||||
|
||||
Reference: [Going to production](https://pocketbase.io/docs/going-to-production/)
|
||||
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: Optimize SQLite for Production
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Better performance and reliability for SQLite database
|
||||
tags: production, sqlite, database, performance
|
||||
---
|
||||
|
||||
## Optimize SQLite for Production
|
||||
|
||||
PocketBase uses SQLite with optimized defaults. Understanding its characteristics helps optimize performance and avoid common pitfalls. PocketBase uses two separate databases: `data.db` (application data) and `auxiliary.db` (logs and ephemeral data), which reduces write contention.
|
||||
|
||||
**Incorrect (ignoring SQLite characteristics):**
|
||||
|
||||
```javascript
|
||||
// Heavy concurrent writes - SQLite bottleneck
|
||||
async function bulkInsert(items) {
|
||||
// Parallel writes cause lock contention
|
||||
await Promise.all(items.map(item =>
|
||||
pb.collection('items').create(item)
|
||||
));
|
||||
}
|
||||
|
||||
// Not using transactions for batch operations
|
||||
async function updateMany(items) {
|
||||
for (const item of items) {
|
||||
await pb.collection('items').update(item.id, item);
|
||||
}
|
||||
// Each write is a separate transaction - slow!
|
||||
}
|
||||
|
||||
// Large text fields without consideration
|
||||
const schema = [{
|
||||
name: 'content',
|
||||
type: 'text' // Could be megabytes - affects all queries
|
||||
}];
|
||||
```
|
||||
|
||||
**Correct (SQLite-optimized patterns):**
|
||||
|
||||
```javascript
|
||||
// Use batch operations for multiple writes
|
||||
async function bulkInsert(items) {
|
||||
const batch = pb.createBatch();
|
||||
items.forEach(item => {
|
||||
batch.collection('items').create(item);
|
||||
});
|
||||
await batch.send(); // Single transaction, much faster
|
||||
}
|
||||
|
||||
// Batch updates
|
||||
async function updateMany(items) {
|
||||
const batch = pb.createBatch();
|
||||
items.forEach(item => {
|
||||
batch.collection('items').update(item.id, item);
|
||||
});
|
||||
await batch.send();
|
||||
}
|
||||
|
||||
// For very large batches, chunk them
|
||||
async function bulkInsertLarge(items, chunkSize = 100) {
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
const chunk = items.slice(i, i + chunkSize);
|
||||
const batch = pb.createBatch();
|
||||
chunk.forEach(item => batch.collection('items').create(item));
|
||||
await batch.send();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Schema considerations:**
|
||||
|
||||
```javascript
|
||||
// Separate large content into dedicated collection
|
||||
const postsSchema = [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'summary', type: 'text', options: { maxLength: 500 } },
|
||||
{ name: 'author', type: 'relation' }
|
||||
// Content in separate collection
|
||||
];
|
||||
|
||||
const postContentsSchema = [
|
||||
{ name: 'post', type: 'relation', required: true },
|
||||
{ name: 'content', type: 'editor' } // Large HTML content
|
||||
];
|
||||
|
||||
// Fetch content only when needed
|
||||
async function getPostList() {
|
||||
return pb.collection('posts').getList(1, 20); // Fast, no content
|
||||
}
|
||||
|
||||
async function getPostWithContent(id) {
|
||||
const post = await pb.collection('posts').getOne(id);
|
||||
const content = await pb.collection('post_contents').getFirstListItem(
|
||||
pb.filter('post = {:id}', { id })
|
||||
);
|
||||
return { ...post, content: content.content };
|
||||
}
|
||||
```
|
||||
|
||||
**PocketBase default PRAGMA settings:**
|
||||
|
||||
PocketBase already configures optimal SQLite settings. You do not need to set these manually unless using a custom SQLite driver:
|
||||
|
||||
```sql
|
||||
PRAGMA busy_timeout = 10000; -- Wait 10s for locks instead of failing immediately
|
||||
PRAGMA journal_mode = WAL; -- Write-Ahead Logging: concurrent reads during writes
|
||||
PRAGMA journal_size_limit = 200000000; -- Limit WAL file to ~200MB
|
||||
PRAGMA synchronous = NORMAL; -- Balanced durability/performance (safe with WAL)
|
||||
PRAGMA foreign_keys = ON; -- Enforce relation integrity
|
||||
PRAGMA temp_store = MEMORY; -- Temp tables in memory (faster sorts/joins)
|
||||
PRAGMA cache_size = -32000; -- 32MB page cache
|
||||
```
|
||||
|
||||
WAL mode is the most impactful setting -- it allows multiple concurrent readers while a single writer is active, which is critical for PocketBase's concurrent API request handling.
|
||||
|
||||
**Index optimization:**
|
||||
|
||||
```sql
|
||||
-- Create indexes for commonly filtered/sorted fields
|
||||
CREATE INDEX idx_posts_author ON posts(author);
|
||||
CREATE INDEX idx_posts_created ON posts(created DESC);
|
||||
CREATE INDEX idx_posts_status_created ON posts(status, created DESC);
|
||||
|
||||
-- Verify indexes are being used
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT * FROM posts WHERE author = 'xxx' ORDER BY created DESC;
|
||||
-- Should show: "USING INDEX idx_posts_author"
|
||||
```
|
||||
|
||||
**SQLite limitations and workarounds:**
|
||||
|
||||
| Limitation | Workaround |
|
||||
|------------|------------|
|
||||
| Single writer | Use batch operations, queue writes |
|
||||
| No full-text by default | Use view collections with FTS5 |
|
||||
| File-based | SSD storage, avoid network mounts |
|
||||
| Memory for large queries | Pagination, limit result sizes |
|
||||
|
||||
**Performance monitoring:**
|
||||
|
||||
```javascript
|
||||
// Monitor slow queries via hooks (requires custom PocketBase build)
|
||||
// Or use SQLite's built-in profiling
|
||||
|
||||
// From sqlite3 CLI:
|
||||
// .timer on
|
||||
// SELECT * FROM posts WHERE author = 'xxx';
|
||||
// Run Time: real 0.003 user 0.002 sys 0.001
|
||||
|
||||
// Check database size
|
||||
// ls -lh pb_data/data.db
|
||||
|
||||
// Vacuum to reclaim space after deletes
|
||||
// sqlite3 pb_data/data.db "VACUUM;"
|
||||
```
|
||||
|
||||
**When to consider alternatives:**
|
||||
|
||||
Consider migrating from single PocketBase if:
|
||||
- Write throughput consistently > 1000/sec needed
|
||||
- Database size > 100GB
|
||||
- Complex transactions across tables
|
||||
- Multi-region deployment required
|
||||
|
||||
**Custom SQLite driver (advanced):**
|
||||
|
||||
PocketBase supports custom SQLite drivers via `DBConnect`. The CGO driver (`mattn/go-sqlite3`) can offer better performance for some workloads and enables extensions like ICU and FTS5. This requires a custom PocketBase build:
|
||||
|
||||
```go
|
||||
// main.go (custom PocketBase build with CGO driver)
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||
// Called twice: once for data.db, once for auxiliary.db
|
||||
DBConnect: func(dbPath string) (*dbx.DB, error) {
|
||||
return dbx.Open("sqlite3", dbPath)
|
||||
},
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
// Build with: CGO_ENABLED=1 go build
|
||||
```
|
||||
|
||||
Note: CGO requires C compiler toolchain and cannot be cross-compiled as easily as pure Go.
|
||||
|
||||
**Scaling options:**
|
||||
1. **Read replicas**: Litestream for SQLite replication
|
||||
2. **Sharding**: Multiple PocketBase instances by tenant/feature
|
||||
3. **Caching**: Redis/Memcached for read-heavy loads
|
||||
4. **Alternative backend**: If requirements exceed SQLite, evaluate PostgreSQL-based frameworks
|
||||
|
||||
Reference: [PocketBase Going to Production](https://pocketbase.io/docs/going-to-production/)
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
|
||||
impact: HIGH
|
||||
impactDescription: Individual rules are atomic; this composite example shows which app instance applies at each layer and how errors propagate
|
||||
tags: extending, composition, transactions, hooks, enrich, routing, mental-model
|
||||
---
|
||||
|
||||
## Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
|
||||
|
||||
The atomic rules (`ext-hooks-chain`, `ext-transactions`, `ext-routing-custom`, `ext-hooks-record-vs-request`, `ext-filesystem`, `ext-filter-binding-server`) each teach a single trap. Real extending code touches **all of them in the same handler**. This rule walks through one complete request flow and annotates **which app instance is active at each layer** - the single most common source of extending bugs is reaching for the wrong one.
|
||||
|
||||
### The flow
|
||||
|
||||
`POST /api/myapp/posts` that: authenticates the caller, validates uniqueness with a bound filter, creates a record inside a transaction, uploads a thumbnail through a scoped filesystem, writes an audit log from an `OnRecordAfterCreateSuccess` hook, and shapes the response (including the realtime broadcast) in `OnRecordEnrich`.
|
||||
|
||||
```
|
||||
HTTP request
|
||||
│
|
||||
▼
|
||||
[group middleware] apis.RequireAuth("users") ◄── e.Auth is set after this
|
||||
│
|
||||
▼
|
||||
[route handler] se.App.RunInTransaction(func(txApp) {
|
||||
│ // ⚠️ inside the block, use ONLY txApp, never se.App or outer `app`
|
||||
│ FindFirstRecordByFilter(txApp, ...) // bound {:slug}
|
||||
│ txApp.Save(post) // fires OnRecord*Create / *Request
|
||||
│ │
|
||||
│ ▼
|
||||
│ [OnRecordAfterCreateSuccess hook] ◄── e.App IS txApp here
|
||||
│ │ (hook fires inside the tx)
|
||||
│ e.App.Save(auditRecord) → participates in rollback
|
||||
│ e.Next() → REQUIRED
|
||||
│ │
|
||||
│ ▼
|
||||
│ return to route handler
|
||||
│ fs := txApp.NewFilesystem()
|
||||
│ defer fs.Close()
|
||||
│ post.Set("thumb", file); txApp.Save(post)
|
||||
│ return nil // commit
|
||||
│ })
|
||||
│
|
||||
▼
|
||||
[enrich pass] OnRecordEnrich fires ◄── RUNS AFTER the tx committed
|
||||
│ (also fires for realtime SSE and list responses)
|
||||
│ e.App is the outer app; tx is already closed
|
||||
▼
|
||||
[response serialization] e.JSON(...)
|
||||
```
|
||||
|
||||
### The code
|
||||
|
||||
```go
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
g := se.Router.Group("/api/myapp")
|
||||
g.Bind(apis.RequireAuth("users"))
|
||||
|
||||
g.POST("/posts", func(e *core.RequestEvent) error {
|
||||
// ── Layer 1: route handler ────────────────────────────────────────
|
||||
// e.App is the top-level app. e.Auth is populated by RequireAuth.
|
||||
// e.RequestInfo holds headers/body/query.
|
||||
body := struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
}{}
|
||||
if err := e.BindBody(&body); err != nil {
|
||||
return e.BadRequestError("invalid body", err)
|
||||
}
|
||||
|
||||
var created *core.Record
|
||||
|
||||
// ── Layer 2: transaction ──────────────────────────────────────────
|
||||
txErr := e.App.RunInTransaction(func(txApp core.App) error {
|
||||
// ⚠️ From here until the closure returns, every DB call MUST go
|
||||
// through txApp. Capturing e.App or the outer `app` deadlocks
|
||||
// on the writer lock.
|
||||
|
||||
// Bound filter - see ext-filter-binding-server
|
||||
existing, _ := txApp.FindFirstRecordByFilter(
|
||||
"posts",
|
||||
"slug = {:slug}",
|
||||
dbx.Params{"slug": body.Slug},
|
||||
)
|
||||
if existing != nil {
|
||||
return apis.NewBadRequestError("slug already taken", nil)
|
||||
}
|
||||
|
||||
col, err := txApp.FindCollectionByNameOrId("posts")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
post := core.NewRecord(col)
|
||||
post.Set("slug", body.Slug)
|
||||
post.Set("title", body.Title)
|
||||
post.Set("author", e.Auth.Id)
|
||||
|
||||
// txApp.Save fires record hooks INSIDE the tx
|
||||
if err := txApp.Save(post); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Layer 3: filesystem (scoped to this request) ─────────────
|
||||
fs, err := txApp.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Close() // REQUIRED - see ext-filesystem
|
||||
|
||||
if uploaded, ok := e.RequestInfo.Body["thumb"].(*filesystem.File); ok {
|
||||
post.Set("thumb", uploaded)
|
||||
if err := txApp.Save(post); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
created = post
|
||||
return nil // commit
|
||||
})
|
||||
if txErr != nil {
|
||||
return txErr // framework maps it to a proper HTTP error
|
||||
}
|
||||
|
||||
// ── Layer 5: response (enrich runs automatically) ────────────────
|
||||
// e.App is the OUTER app again here - the tx has committed.
|
||||
// OnRecordEnrich will fire during JSON serialization and for any
|
||||
// realtime subscribers receiving the "create" event.
|
||||
return e.JSON(http.StatusOK, created)
|
||||
})
|
||||
|
||||
return se.Next()
|
||||
})
|
||||
|
||||
// ── Layer 4: hooks ──────────────────────────────────────────────────────
|
||||
// These are registered once at startup, NOT inside the route handler.
|
||||
|
||||
app.OnRecordAfterCreateSuccess("posts").Bind(&hook.Handler[*core.RecordEvent]{
|
||||
Id: "audit-post-create",
|
||||
Func: func(e *core.RecordEvent) error {
|
||||
// ⚠️ e.App here is txApp when the parent Save happened inside a tx.
|
||||
// Always use e.App - never a captured outer `app` - so that the
|
||||
// audit record participates in the same transaction (and the
|
||||
// same rollback) as the parent Save.
|
||||
col, err := e.App.FindCollectionByNameOrId("audit")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
audit := core.NewRecord(col)
|
||||
audit.Set("action", "post.create")
|
||||
audit.Set("record", e.Record.Id)
|
||||
audit.Set("actor", e.Record.GetString("author"))
|
||||
if err := e.App.Save(audit); err != nil {
|
||||
return err // rolls back the whole request
|
||||
}
|
||||
return e.Next() // REQUIRED - see ext-hooks-chain
|
||||
},
|
||||
})
|
||||
|
||||
app.OnRecordEnrich("posts").BindFunc(func(e *core.RecordEnrichEvent) error {
|
||||
// Runs for:
|
||||
// - GET /api/collections/posts/records (list)
|
||||
// - GET /api/collections/posts/records/{id} (view)
|
||||
// - realtime SSE create/update broadcasts
|
||||
// - any apis.EnrichRecord call in a custom route
|
||||
// Does NOT run inside a transaction; e.App is the outer app.
|
||||
e.Record.Hide("internalNotes")
|
||||
|
||||
if e.RequestInfo != nil && e.RequestInfo.Auth != nil {
|
||||
e.Record.WithCustomData(true)
|
||||
e.Record.Set("isMine", e.Record.GetString("author") == e.RequestInfo.Auth.Id)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
```
|
||||
|
||||
### The cheat sheet: "which app am I holding?"
|
||||
|
||||
| Where you are | Use | Why |
|
||||
|---|---|---|
|
||||
| Top of a route handler (`func(e *core.RequestEvent)`) | `e.App` | Framework's top-level app; same object the server started with |
|
||||
| Inside `RunInTransaction(func(txApp) { ... })` | `txApp` **only** | Capturing the outer app deadlocks on the SQLite writer lock |
|
||||
| Inside a record hook fired from a `Save` inside a tx | `e.App` | The framework has already rebound `e.App` to `txApp` for you |
|
||||
| Inside a record hook fired from a non-tx `Save` | `e.App` | Same identifier, same rules, just points to the top-level app |
|
||||
| Inside `OnRecordEnrich` | `e.App` | Runs during response serialization, **after** the tx has committed |
|
||||
| Inside a `app.Cron()` callback | captured `app` / `se.App` | Cron has no per-run scoped app; wrap in `RunInTransaction` if you need atomicity |
|
||||
| Inside a migration function | the `app` argument | `m.Register(func(app core.App) error { ... })` - already transactional |
|
||||
|
||||
### Error propagation in the chain
|
||||
|
||||
- `return err` inside `RunInTransaction` → **rolls back everything**, including any audit records written by hooks that fired from nested `Save` calls.
|
||||
- `return err` from a hook handler → propagates back through the `Save` call → propagates out of the tx closure → rolls back.
|
||||
- **Not** calling `e.Next()` in a hook → the chain is broken **silently**. The framework's own post-save work (realtime broadcast, enrich pass, activity log) is skipped but no error is reported.
|
||||
- A panic inside the tx closure is recovered by PocketBase, the tx rolls back, and the panic is converted to a 500 response.
|
||||
- A panic inside a cron callback is recovered and logged - it does **not** take down the process.
|
||||
|
||||
### When NOT to compose this much
|
||||
|
||||
This example is realistic but also the ceiling of what should live in a single handler. If you find yourself stacking six concerns in one route, consider splitting the logic into a service function that takes `txApp` as a parameter and is called by the route. The same function is then reusable from cron jobs, migrations, and tests.
|
||||
|
||||
Reference: cross-references `ext-hooks-chain.md`, `ext-transactions.md`, `ext-routing-custom.md`, `ext-hooks-record-vs-request.md`, `ext-filesystem.md`, `ext-filter-binding-server.md`.
|
||||
126
.claude/skills/pocketbase-best-practices/rules/ext-cron-jobs.md
Normal file
126
.claude/skills/pocketbase-best-practices/rules/ext-cron-jobs.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
title: Schedule Recurring Jobs with the Builtin Cron Scheduler
|
||||
impact: MEDIUM
|
||||
impactDescription: Avoids external schedulers and correctly integrates background tasks with the PocketBase lifecycle
|
||||
tags: cron, scheduling, jobs, go, jsvm, extending
|
||||
---
|
||||
|
||||
## Schedule Recurring Jobs with the Builtin Cron Scheduler
|
||||
|
||||
PocketBase includes a cron scheduler that starts automatically with `serve`. Register jobs before calling `app.Start()` (Go) or at the top level of a `pb_hooks` file (JSVM). Each job runs in its own goroutine and receives a standard cron expression.
|
||||
|
||||
**Incorrect (external timer, blocking hook, replacing system jobs):**
|
||||
|
||||
```go
|
||||
// ❌ Using a raw Go timer instead of the app cron – misses lifecycle management
|
||||
go func() {
|
||||
for range time.Tick(2 * time.Minute) {
|
||||
log.Println("cleanup")
|
||||
}
|
||||
}()
|
||||
|
||||
// ❌ Blocking inside a hook instead of scheduling
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
for {
|
||||
time.Sleep(2 * time.Minute)
|
||||
log.Println("cleanup") // ❌ blocks the hook and never returns se.Next()
|
||||
}
|
||||
})
|
||||
|
||||
// ❌ Removing all cron jobs wipes PocketBase's own log-cleanup and auto-backup jobs
|
||||
app.Cron().RemoveAll()
|
||||
```
|
||||
|
||||
```javascript
|
||||
// ❌ JSVM: using setTimeout – not supported in the embedded goja engine
|
||||
setTimeout(() => console.log("run"), 120_000); // ReferenceError
|
||||
```
|
||||
|
||||
**Correct – Go:**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
// Register before app.Start() so the scheduler knows about the job at launch.
|
||||
// MustAdd panics on an invalid cron expression (use Add if you prefer an error return).
|
||||
app.Cron().MustAdd("cleanup-drafts", "0 3 * * *", func() {
|
||||
// Runs every day at 03:00 UTC in its own goroutine.
|
||||
// Use app directly here (not e.App) because this is not inside a hook.
|
||||
records, err := app.FindAllRecords("posts",
|
||||
core.FilterData("status = 'draft' && created < {:cutoff}"),
|
||||
)
|
||||
if err != nil {
|
||||
app.Logger().Error("cron cleanup-drafts", "err", err)
|
||||
return
|
||||
}
|
||||
for _, r := range records {
|
||||
if err := app.Delete(r); err != nil {
|
||||
app.Logger().Error("cron delete", "id", r.Id, "err", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Remove a job by ID (e.g. during a feature flag toggle)
|
||||
// app.Cron().Remove("cleanup-drafts")
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct – JSVM:**
|
||||
|
||||
```javascript
|
||||
// pb_hooks/crons.pb.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Top-level cronAdd() registers the job at hook-load time.
|
||||
// The handler runs in its own goroutine and has access to $app.
|
||||
cronAdd("notify-unpublished", "*/30 * * * *", () => {
|
||||
// Runs every 30 minutes
|
||||
const records = $app.findAllRecords("posts",
|
||||
$dbx.hashExp({ status: "draft" })
|
||||
);
|
||||
console.log(`Found ${records.length} unpublished posts`);
|
||||
});
|
||||
|
||||
// Remove a registered job by ID (useful in tests or feature toggles)
|
||||
// cronRemove("notify-unpublished");
|
||||
```
|
||||
|
||||
**Cron expression reference:**
|
||||
|
||||
```
|
||||
┌─── minute (0 - 59)
|
||||
│ ┌── hour (0 - 23)
|
||||
│ │ ┌─ day-of-month (1 - 31)
|
||||
│ │ │ ┌ month (1 - 12)
|
||||
│ │ │ │ ┌ day-of-week (0 - 6, Sunday = 0)
|
||||
│ │ │ │ │
|
||||
* * * * *
|
||||
|
||||
Examples:
|
||||
*/2 * * * * every 2 minutes
|
||||
0 3 * * * daily at 03:00
|
||||
0 0 * * 0 weekly on Sunday midnight
|
||||
@hourly macro equivalent to 0 * * * *
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- System jobs use the `__pb*__` ID prefix (e.g. `__pbLogsCleanup__`). Never call `RemoveAll()` or use that prefix for your own jobs.
|
||||
- All registered cron jobs are visible and can be manually triggered from _Dashboard > Settings > Crons_.
|
||||
- JSVM handlers have access to `$app` but **not** to outer-scope variables (see JSVM scope rule).
|
||||
- Go jobs can use `app` directly (not `e.App`) because they run outside the hook/transaction context.
|
||||
|
||||
Reference: [Go – Jobs scheduling](https://pocketbase.io/docs/go-jobs-scheduling/) | [JS – Jobs scheduling](https://pocketbase.io/docs/js-jobs-scheduling/)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Always Close the Filesystem Handle Returned by NewFilesystem
|
||||
impact: HIGH
|
||||
impactDescription: Leaked filesystem clients keep S3 connections and file descriptors open until the process exits
|
||||
tags: filesystem, extending, files, s3, NewFilesystem, close
|
||||
---
|
||||
|
||||
## Always Close the Filesystem Handle Returned by NewFilesystem
|
||||
|
||||
`app.NewFilesystem()` (Go) and `$app.newFilesystem()` (JS) return a filesystem client backed by either the local disk or S3, depending on the app settings. **The caller owns the handle** and must close it - there is no finalizer and no automatic pooling. Leaking handles leaks TCP connections to S3 and file descriptors on disk, and eventually the server will stop accepting uploads.
|
||||
|
||||
PocketBase also ships a second client: `app.NewBackupsFilesystem()` for the backups bucket/directory, with the same ownership rules.
|
||||
|
||||
**Incorrect (no close, raw bytes buffered in memory):**
|
||||
|
||||
```go
|
||||
// ❌ Forgets to close fs - connection leaks
|
||||
func downloadAvatar(app core.App, key string) ([]byte, error) {
|
||||
fs, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ❌ no defer fs.Close()
|
||||
|
||||
// ❌ GetFile loads the whole file into a reader; reading it all into a
|
||||
// byte slice defeats streaming for large files
|
||||
r, err := fs.GetFile(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (defer Close, stream to the HTTP response):**
|
||||
|
||||
```go
|
||||
func serveAvatar(app core.App, key string) echo.HandlerFunc {
|
||||
return func(e *core.RequestEvent) error {
|
||||
fs, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
return e.InternalServerError("filesystem init failed", err)
|
||||
}
|
||||
defer fs.Close() // REQUIRED
|
||||
|
||||
// Serve directly from the filesystem - handles ranges, content-type,
|
||||
// and the X-Accel-Redirect / X-Sendfile headers when available
|
||||
return fs.Serve(e.Response, e.Request, key, "avatar.jpg")
|
||||
}
|
||||
}
|
||||
|
||||
// Uploading a local file to the PocketBase-managed filesystem
|
||||
func importAvatar(app core.App, record *core.Record, path string) error {
|
||||
f, err := filesystem.NewFileFromPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.Set("avatar", f) // assignment + app.Save() persist it
|
||||
return app.Save(record)
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM - file factories live on the $filesystem global
|
||||
const file1 = $filesystem.fileFromPath("/tmp/import.jpg");
|
||||
const file2 = $filesystem.fileFromBytes(new Uint8Array([0xff, 0xd8]), "logo.jpg");
|
||||
const file3 = $filesystem.fileFromURL("https://example.com/a.jpg");
|
||||
|
||||
// Assigning to a record field triggers upload on save
|
||||
record.set("avatar", file1);
|
||||
$app.save(record);
|
||||
|
||||
// Low-level client - MUST be closed
|
||||
const fs = $app.newFilesystem();
|
||||
try {
|
||||
const list = fs.list("thumbs/");
|
||||
for (const obj of list) {
|
||||
console.log(obj.key, obj.size);
|
||||
}
|
||||
} finally {
|
||||
fs.close(); // REQUIRED
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- `defer fs.Close()` **immediately** after a successful `NewFilesystem()` / `NewBackupsFilesystem()` call (Go). In JS, wrap in `try { ... } finally { fs.close() }`.
|
||||
- Prefer the high-level record-field API (`record.Set("field", file)` + `app.Save`) over direct `fs.Upload` calls - it handles thumbs regeneration, orphan cleanup, and hook integration.
|
||||
- File factory functions (`filesystem.NewFileFromPath`, `NewFileFromBytes`, `NewFileFromURL` / JS `$filesystem.fileFromPath|Bytes|URL`) capture their input; they do not stream until save.
|
||||
- `fileFromURL` performs an HTTP GET and loads the body into memory - not appropriate for large files.
|
||||
- Do not share a single long-lived `fs` across unrelated requests; the object is cheap to create per request.
|
||||
|
||||
Reference: [Go Filesystem](https://pocketbase.io/docs/go-filesystem/) · [JS Filesystem](https://pocketbase.io/docs/js-filesystem/)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Bind User Input in Server-Side Filters with {:placeholder} Params
|
||||
impact: CRITICAL
|
||||
impactDescription: String-concatenating user input into filter expressions is a direct injection vulnerability
|
||||
tags: extending, filter, injection, security, FindRecordsByFilter, dbx
|
||||
---
|
||||
|
||||
## Bind User Input in Server-Side Filters with {:placeholder} Params
|
||||
|
||||
Server-side helpers like `FindFirstRecordByFilter`, `FindRecordsByFilter`, and `dbx.NewExp` accept a filter string that supports `{:name}` placeholders. **Never** concatenate user input into the filter - PocketBase's filter parser has its own syntax that is sensitive to quoting, and concatenation allows an attacker to alter the query (same class of bug as SQL injection).
|
||||
|
||||
**Incorrect (string interpolation - filter injection):**
|
||||
|
||||
```go
|
||||
// ❌ attacker sets email to: x' || 1=1 || email='
|
||||
// resulting filter bypasses the intended match entirely
|
||||
email := e.Request.URL.Query().Get("email")
|
||||
record, err := app.FindFirstRecordByFilter(
|
||||
"users",
|
||||
"email = '"+email+"' && verified = true", // ❌
|
||||
)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM - same class of bug
|
||||
const email = e.request.url.query().get("email");
|
||||
const record = $app.findFirstRecordByFilter(
|
||||
"users",
|
||||
`email = '${email}' && verified = true`, // ❌
|
||||
);
|
||||
```
|
||||
|
||||
**Correct (named placeholders + params map):**
|
||||
|
||||
```go
|
||||
import "github.com/pocketbase/dbx"
|
||||
|
||||
email := e.Request.URL.Query().Get("email")
|
||||
record, err := app.FindFirstRecordByFilter(
|
||||
"users",
|
||||
"email = {:email} && verified = true",
|
||||
dbx.Params{"email": email}, // values are quoted/escaped by the framework
|
||||
)
|
||||
if err != nil {
|
||||
return e.NotFoundError("user not found", err)
|
||||
}
|
||||
|
||||
// Paginated variant: FindRecordsByFilter(collection, filter, sort, limit, offset, params...)
|
||||
recs, err := app.FindRecordsByFilter(
|
||||
"posts",
|
||||
"author = {:author} && status = {:status}",
|
||||
"-created",
|
||||
20, 0,
|
||||
dbx.Params{"author": e.Auth.Id, "status": "published"},
|
||||
)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM - second argument after the filter is the params object
|
||||
const record = $app.findFirstRecordByFilter(
|
||||
"users",
|
||||
"email = {:email} && verified = true",
|
||||
{ email: email },
|
||||
);
|
||||
|
||||
const recs = $app.findRecordsByFilter(
|
||||
"posts",
|
||||
"author = {:author} && status = {:status}",
|
||||
"-created", 20, 0,
|
||||
{ author: e.auth.id, status: "published" },
|
||||
);
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Placeholder syntax is `{:name}` inside the filter string, and the value is supplied via `dbx.Params{"name": value}` (Go) or a plain object (JS).
|
||||
- The same applies to `dbx.NewExp("LOWER(email) = {:email}", dbx.Params{"email": email})` when writing raw `dbx` expressions.
|
||||
- Passing a `types.DateTime` / `DateTime` value binds it correctly - do not stringify dates manually.
|
||||
- `nil` / `null` binds as SQL NULL; use `field = null` or `field != null` in the filter expression.
|
||||
- The filter grammar is the same as used by collection API rules - consult [Filter Syntax](https://pocketbase.io/docs/api-rules-and-filters/#filters) for operators.
|
||||
|
||||
Reference: [Go database - FindRecordsByFilter](https://pocketbase.io/docs/go-records/#fetch-records-via-filter-expression) · [JS database - findRecordsByFilter](https://pocketbase.io/docs/js-records/#fetch-records-via-filter-expression)
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: Use DBConnect Only When You Need a Custom SQLite Driver
|
||||
impact: MEDIUM
|
||||
impactDescription: Incorrect driver setup breaks both data.db and auxiliary.db, or introduces unnecessary CGO
|
||||
tags: go, extending, sqlite, custom-driver, cgo, fts5, dbconnect
|
||||
---
|
||||
|
||||
## Use DBConnect Only When You Need a Custom SQLite Driver
|
||||
|
||||
PocketBase ships with the **pure-Go** `modernc.org/sqlite` driver (no CGO required). Only reach for a custom driver when you specifically need SQLite extensions like ICU, FTS5, or spatialite that the default driver doesn't expose. `DBConnect` is called **twice** — once for `pb_data/data.db` and once for `pb_data/auxiliary.db` — so driver registration and PRAGMAs must be idempotent.
|
||||
|
||||
**Incorrect (unnecessary custom driver, mismatched builder, CGO without justification):**
|
||||
|
||||
```go
|
||||
// ❌ Adding a CGO dependency with no need for extensions
|
||||
import _ "github.com/mattn/go-sqlite3"
|
||||
|
||||
func main() {
|
||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||
DBConnect: func(dbPath string) (*dbx.DB, error) {
|
||||
// ❌ "sqlite3" builder name used but "pb_sqlite3" driver was registered —
|
||||
// or vice versa — causing "unknown driver" / broken query generation
|
||||
return dbx.Open("sqlite3", dbPath)
|
||||
},
|
||||
})
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (mattn/go-sqlite3 with CGO — proper PRAGMA init hook and builder map entry):**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Use a unique driver name to avoid conflicts with other packages.
|
||||
// sql.Register panics if called twice with the same name, so put it in init().
|
||||
sql.Register("pb_sqlite3", &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
_, err := conn.Exec(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -32000;
|
||||
`, nil)
|
||||
return err
|
||||
},
|
||||
})
|
||||
// Mirror the sqlite3 query builder so PocketBase generates correct SQL
|
||||
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||
DBConnect: func(dbPath string) (*dbx.DB, error) {
|
||||
return dbx.Open("pb_sqlite3", dbPath)
|
||||
},
|
||||
})
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (ncruces/go-sqlite3 — no CGO, PRAGMAs via DSN query string):**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
_ "github.com/ncruces/go-sqlite3/embed"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const pragmas = "?_pragma=busy_timeout(10000)" +
|
||||
"&_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=journal_size_limit(200000000)" +
|
||||
"&_pragma=synchronous(NORMAL)" +
|
||||
"&_pragma=foreign_keys(ON)" +
|
||||
"&_pragma=temp_store(MEMORY)" +
|
||||
"&_pragma=cache_size(-32000)"
|
||||
|
||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||
DBConnect: func(dbPath string) (*dbx.DB, error) {
|
||||
return dbx.Open("sqlite3", "file:"+dbPath+pragmas)
|
||||
},
|
||||
})
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Conditional custom driver with default fallback:**
|
||||
|
||||
```go
|
||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||
DBConnect: func(dbPath string) (*dbx.DB, error) {
|
||||
// Use custom driver only for the main data file; fall back for auxiliary
|
||||
if strings.HasSuffix(dbPath, "data.db") {
|
||||
return dbx.Open("pb_sqlite3", dbPath)
|
||||
}
|
||||
return core.DefaultDBConnect(dbPath)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Decision guide:**
|
||||
|
||||
| Need | Driver |
|
||||
|------|--------|
|
||||
| Default (no extensions) | Built-in `modernc.org/sqlite` — no `DBConnect` config needed |
|
||||
| FTS5, ICU, spatialite | `mattn/go-sqlite3` (CGO) or `ncruces/go-sqlite3` (WASM, no CGO) |
|
||||
| Reduce binary size | `go build -tags no_default_driver` to exclude the default driver (~4 MB saved) |
|
||||
| Conditional fallback | Call `core.DefaultDBConnect(dbPath)` inside your `DBConnect` function |
|
||||
|
||||
Reference: [Extend with Go - Custom SQLite driver](https://pocketbase.io/docs/go-overview/#custom-sqlite-driver)
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Version Your Schema with Go Migrations
|
||||
impact: HIGH
|
||||
impactDescription: Guarantees repeatable, transactional schema evolution and eliminates manual dashboard changes in production
|
||||
tags: go, migrations, schema, database, migratecmd, extending
|
||||
---
|
||||
|
||||
## Version Your Schema with Go Migrations
|
||||
|
||||
PocketBase ships with a `migratecmd` plugin that generates versioned `.go` migration files, applies them automatically on `serve`, and lets you roll back with `migrate down`. Because the files are compiled into your binary, no extra migration tool is needed.
|
||||
|
||||
**Incorrect (one-off SQL or dashboard changes in production):**
|
||||
|
||||
```go
|
||||
// ❌ Running raw SQL directly at startup without a migration file –
|
||||
// the change is applied every restart and has no rollback path.
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
_, err := app.DB().NewQuery(
|
||||
"ALTER TABLE posts ADD COLUMN summary TEXT DEFAULT ''",
|
||||
).Execute()
|
||||
return err
|
||||
})
|
||||
|
||||
// ❌ Forgetting to import the migrations package means
|
||||
// registered migrations are never executed.
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
// _ "myapp/migrations" ← omitted: migrations never run
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (register migratecmd, import migrations package):**
|
||||
|
||||
```go
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
|
||||
// Import side-effects only; this registers all init() migrations.
|
||||
_ "myapp/migrations"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
// Automigrate generates a new .go file whenever you make
|
||||
// collection changes in the Dashboard (dev-only).
|
||||
Automigrate: osutils.IsProbablyGoRun(),
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Create and write a migration:**
|
||||
|
||||
```bash
|
||||
# Create a blank migration file in ./migrations/
|
||||
go run . migrate create "add_summary_to_posts"
|
||||
```
|
||||
|
||||
```go
|
||||
// migrations/1687801090_add_summary_to_posts.go
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
// app is a transactional App instance – safe to use directly.
|
||||
collection, err := app.FindCollectionByNameOrId("posts")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection.Fields.Add(&core.TextField{
|
||||
Name: "summary",
|
||||
Required: false,
|
||||
})
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
// Optional rollback
|
||||
collection, err := app.FindCollectionByNameOrId("posts")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collection.Fields.RemoveByName("summary")
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Snapshot all collections (useful for a fresh repo):**
|
||||
|
||||
```bash
|
||||
# Generates a migration file that recreates your current schema from scratch.
|
||||
go run . migrate collections
|
||||
```
|
||||
|
||||
**Clean up dev migration history:**
|
||||
|
||||
```bash
|
||||
# Remove _migrations table entries that have no matching .go file.
|
||||
# Run after squashing or deleting intermediate dev migration files.
|
||||
go run . migrate history-sync
|
||||
```
|
||||
|
||||
**Apply / roll back manually:**
|
||||
|
||||
```bash
|
||||
go run . migrate up # apply all unapplied migrations
|
||||
go run . migrate down 1 # revert the last applied migration
|
||||
```
|
||||
|
||||
**Key details:**
|
||||
- Migration functions receive a **transactional** `core.App` – treat it as the database source of truth. Never use the outer `app` variable inside migration callbacks.
|
||||
- New unapplied migrations run automatically on every `serve` start – no manual step in production.
|
||||
- `Automigrate: osutils.IsProbablyGoRun()` limits auto-generation to `go run` (development) and prevents accidental file creation in production binaries.
|
||||
- Prefer the collection API (`app.Save(collection)`) over raw SQL `ALTER TABLE` so PocketBase's internal schema cache stays consistent.
|
||||
- Commit all generated `.go` files to version control; do **not** commit `pb_data/`.
|
||||
|
||||
Reference: [Extend with Go – Migrations](https://pocketbase.io/docs/go-migrations/)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Set Up a Go-Extended PocketBase Application
|
||||
impact: HIGH
|
||||
impactDescription: Foundation for all custom Go business logic, hooks, and routing
|
||||
tags: go, extending, setup, main, bootstrap
|
||||
---
|
||||
|
||||
## Set Up a Go-Extended PocketBase Application
|
||||
|
||||
When extending PocketBase as a Go framework (v0.36+), the entry point is a small `main.go` that creates the app, registers hooks on `OnServe()`, and calls `app.Start()`. Avoid reaching for a global `app` variable inside hook handlers - use `e.App` instead so code works inside transactions.
|
||||
|
||||
**Incorrect (global app reuse, no OnServe hook, bare http.Handler):**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
)
|
||||
|
||||
var app = pocketbase.New() // global reference used inside handlers
|
||||
|
||||
func main() {
|
||||
// Routes registered directly via net/http - bypasses PocketBase's router,
|
||||
// middleware chain, auth, rate limiter and body limit
|
||||
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("hello"))
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (register routes inside `OnServe`, use `e.App` in handlers):**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
// Serve static assets from ./pb_public (if present)
|
||||
se.Router.GET("/{path...}", apis.Static(os.DirFS("./pb_public"), false))
|
||||
|
||||
// Custom API route - namespaced under /api/{yourapp}/ to avoid
|
||||
// colliding with built-in /api/collections, /api/realtime, etc.
|
||||
se.Router.GET("/api/myapp/hello/{name}", func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"message": "hello " + e.Request.PathValue("name"),
|
||||
})
|
||||
}).Bind(apis.RequireAuth())
|
||||
|
||||
return se.Next()
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Project bootstrap:**
|
||||
|
||||
```bash
|
||||
go mod init myapp
|
||||
go mod tidy
|
||||
go run . serve # development
|
||||
go build && ./myapp serve # production (statically linked binary)
|
||||
```
|
||||
|
||||
**Key details:**
|
||||
- Requires **Go 1.25.0+** (PocketBase v0.36.7+ bumped the minimum to Go 1.25.0).
|
||||
- PocketBase ships with the pure-Go `modernc.org/sqlite` driver - **no CGO required** by default.
|
||||
- If you need FTS5, ICU, or a custom SQLite build, pass `core.DBConnect` in `pocketbase.NewWithConfig(...)` - it is called twice (once for `pb_data/data.db`, once for `pb_data/auxiliary.db`).
|
||||
- Inside hooks, prefer `e.App` over a captured parent-scope `app` - the hook may run inside a transaction and the parent `app` would deadlock.
|
||||
|
||||
Reference: [Extend with Go - Overview](https://pocketbase.io/docs/go-overview/)
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Always Call e.Next() and Use e.App Inside Hook Handlers
|
||||
impact: CRITICAL
|
||||
impactDescription: Forgetting e.Next() silently breaks the execution chain; reusing parent-scope app causes deadlocks
|
||||
tags: hooks, events, extending, transactions, deadlock
|
||||
---
|
||||
|
||||
## Always Call e.Next() and Use e.App Inside Hook Handlers
|
||||
|
||||
Every PocketBase event hook handler is part of an execution chain. If the handler does not call `e.Next()` (Go) or `e.next()` (JS), **the remaining handlers and the core framework action are skipped silently**. Also, hooks may run inside a DB transaction - any database call made through a captured parent-scope `app`/`$app` instead of the event's own `e.App`/`e.app` will deadlock against the transaction.
|
||||
|
||||
**Incorrect (missing `Next`, captured parent-scope app, global mutex):**
|
||||
|
||||
```go
|
||||
var mu sync.Mutex // ❌ global lock invoked recursively by cascade hooks = deadlock
|
||||
app := pocketbase.New()
|
||||
|
||||
app.OnRecordAfterCreateSuccess("articles").BindFunc(func(e *core.RecordEvent) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// ❌ uses outer `app`, not `e.App` - deadlocks when the hook fires
|
||||
// inside a transaction, because the outer app is blocked on the
|
||||
// transaction's write lock
|
||||
_, err := app.FindRecordById("audit", e.Record.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil // ❌ forgot e.Next() - framework never persists the record
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
// ❌ no e.next() = downstream hooks and response serialization skipped
|
||||
console.log("created", e.record.id);
|
||||
}, "articles");
|
||||
```
|
||||
|
||||
**Correct (call Next, use `e.App`, attach an Id for later unbinding):**
|
||||
|
||||
```go
|
||||
app := pocketbase.New()
|
||||
|
||||
app.OnRecordAfterCreateSuccess("articles").Bind(&hook.Handler[*core.RecordEvent]{
|
||||
Id: "audit-article-create",
|
||||
Priority: 10, // higher = later; default 0 = order of registration
|
||||
Func: func(e *core.RecordEvent) error {
|
||||
// Always use e.App - it is the transactional app when inside a tx
|
||||
audit := core.NewRecord(/* ... */)
|
||||
audit.Set("record", e.Record.Id)
|
||||
if err := e.App.Save(audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Next() // REQUIRED
|
||||
},
|
||||
})
|
||||
|
||||
// Later: app.OnRecordAfterCreateSuccess("articles").Unbind("audit-article-create")
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM - e.app is the transactional app instance
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
const audit = new Record($app.findCollectionByNameOrId("audit"));
|
||||
audit.set("record", e.record.id);
|
||||
e.app.save(audit);
|
||||
|
||||
e.next(); // REQUIRED
|
||||
}, "articles");
|
||||
```
|
||||
|
||||
**Rules of the execution chain:**
|
||||
|
||||
- `Bind(handler)` vs `BindFunc(func)`: `Bind` lets you set `Id` (for `Unbind`) and `Priority`; `BindFunc` auto-generates both.
|
||||
- Priority defaults to `0` = order of source registration. Lower numbers run first, negative priorities run before defaults (the built-in middlewares use priorities like `-1010`, `-1000`, `-990`).
|
||||
- **Never hold a global mutex across `e.Next()`** - cascade-delete and nested saves can re-enter the same hook and deadlock.
|
||||
- `Unbind(id)` removes a specific handler; `UnbindAll()` also removes **system handlers**, so only call it if you really mean to replace the default behavior.
|
||||
- `Trigger(event, ...)` is almost never needed in user code.
|
||||
|
||||
Reference: [Go Event hooks](https://pocketbase.io/docs/go-event-hooks/) · [JS Event hooks](https://pocketbase.io/docs/js-event-hooks/)
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Pick the Right Record Hook - Model vs Request vs Enrich
|
||||
impact: HIGH
|
||||
impactDescription: Wrong hook = missing request context, double-fired logic, or leaked fields in realtime events
|
||||
tags: hooks, onRecordEnrich, onRecordRequest, model-hooks, extending
|
||||
---
|
||||
|
||||
## Pick the Right Record Hook - Model vs Request vs Enrich
|
||||
|
||||
PocketBase v0.23+ splits record hooks into three families. Using the wrong one is the #1 source of "my hook doesn't fire" and "my hidden field still shows up in realtime events" bugs.
|
||||
|
||||
| Family | Examples | Fires for | Has request context? |
|
||||
|--------|----------|-----------|----------------------|
|
||||
| **Model hooks** | `OnRecordCreate`, `OnRecordAfterCreateSuccess`, `OnRecordValidate` | Any save path - Web API **and** cron jobs, custom commands, migrations, calls from other hooks | No - `e.Record`, `e.App`, **no** `e.RequestInfo` |
|
||||
| **Request hooks** | `OnRecordCreateRequest`, `OnRecordsListRequest`, `OnRecordViewRequest` | **Only** the built-in Web API endpoints | Yes - `e.RequestInfo`, `e.Auth`, HTTP headers/body |
|
||||
| **Enrich hook** | `OnRecordEnrich` | Every response serialization, **including realtime SSE events** and `apis.enrichRecord` | Yes, via `e.RequestInfo` |
|
||||
|
||||
**Incorrect (hiding a field in the request hook - leaks in realtime):**
|
||||
|
||||
```go
|
||||
// ❌ Only runs for GET /api/collections/users/records/{id}.
|
||||
// Realtime SSE subscribers still receive the "role" field.
|
||||
app.OnRecordViewRequest("users").BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
e.Record.Hide("role")
|
||||
return e.Next()
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (use `OnRecordEnrich` so realtime and list responses also hide the field):**
|
||||
|
||||
```go
|
||||
app.OnRecordEnrich("users").BindFunc(func(e *core.RecordEnrichEvent) error {
|
||||
e.Record.Hide("role")
|
||||
|
||||
// Add a computed field only for authenticated users
|
||||
if e.RequestInfo.Auth != nil {
|
||||
e.Record.WithCustomData(true) // required to attach non-schema data
|
||||
e.Record.Set("isOwner", e.Record.Id == e.RequestInfo.Auth.Id)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM
|
||||
onRecordEnrich((e) => {
|
||||
e.record.hide("role");
|
||||
|
||||
if (e.requestInfo.auth?.collection()?.name === "users") {
|
||||
e.record.withCustomData(true);
|
||||
e.record.set("computedScore",
|
||||
e.record.get("score") * e.requestInfo.auth.get("base"));
|
||||
}
|
||||
e.next();
|
||||
}, "users");
|
||||
```
|
||||
|
||||
**Selection guide:**
|
||||
- Need to mutate the record before **any** save (API, cron, migration, nested hook)? → `OnRecordCreate` / `OnRecordUpdate` (pre-save) or `OnRecord*Success` (post-save).
|
||||
- Need access to HTTP headers, query params, or the authenticated client? → `OnRecord*Request`.
|
||||
- Need to hide fields, redact values, or attach computed props on responses including realtime? → **`OnRecordEnrich`** - this is the safest default for response shaping.
|
||||
- Need to validate before save? → `OnRecordValidate` (proxy over `OnModelValidate`).
|
||||
|
||||
Reference: [Go Record request hooks](https://pocketbase.io/docs/go-event-hooks/#record-crud-request-hooks) · [JS Record model hooks](https://pocketbase.io/docs/js-event-hooks/#record-model-hooks)
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Write JSVM Migrations as pb_migrations/*.js Files
|
||||
impact: HIGH
|
||||
impactDescription: JSVM migrations look different from Go ones; missing the timestamp prefix or the down-callback silently breaks replay
|
||||
tags: jsvm, migrations, pb_migrations, schema, extending
|
||||
---
|
||||
|
||||
## Write JSVM Migrations as pb_migrations/*.js Files
|
||||
|
||||
JSVM migrations live in `pb_migrations/` next to the executable. Unlike Go migrations (which use `init()` + `m.Register(...)` inside a package imported from `main.go`), JSVM migrations are **auto-discovered by filename** and call the global `migrate()` function with an `up` callback and an optional `down` callback. `--automigrate` is on by default in v0.36+, so admin-UI changes generate these files for you; you also write them by hand for data migrations, seed data, and index changes that the UI can't express.
|
||||
|
||||
**Incorrect (wrong filename format, missing down, raw SQL without cache invalidation):**
|
||||
|
||||
```javascript
|
||||
// pb_migrations/add_audit.js ❌ missing <unix>_ prefix - never runs
|
||||
migrate((app) => {
|
||||
// ❌ Raw ALTER TABLE leaves PocketBase's internal collection cache stale
|
||||
app.db().newQuery(
|
||||
"ALTER TABLE posts ADD COLUMN summary TEXT DEFAULT ''"
|
||||
).execute();
|
||||
});
|
||||
// ❌ No down callback - `migrate down` cannot revert this in dev
|
||||
```
|
||||
|
||||
**Correct (timestamped filename, collection API, both up and down):**
|
||||
|
||||
```javascript
|
||||
// pb_migrations/1712500000_add_audit_collection.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
// UP - runs on `serve` / `migrate up`
|
||||
(app) => {
|
||||
const collection = new Collection({
|
||||
type: "base",
|
||||
name: "audit",
|
||||
fields: [
|
||||
{ name: "action", type: "text", required: true },
|
||||
{ name: "actor", type: "relation", collectionId: "_pb_users_auth_", cascadeDelete: false },
|
||||
{ name: "meta", type: "json" },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
],
|
||||
indexes: [
|
||||
"CREATE INDEX idx_audit_actor ON audit (actor)",
|
||||
"CREATE INDEX idx_audit_created ON audit (created)",
|
||||
],
|
||||
});
|
||||
app.save(collection);
|
||||
},
|
||||
// DOWN - runs on `migrate down N`
|
||||
(app) => {
|
||||
const collection = app.findCollectionByNameOrId("audit");
|
||||
app.delete(collection);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Seed data migration (common pattern):**
|
||||
|
||||
```javascript
|
||||
// pb_migrations/1712500100_seed_default_tags.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const tags = app.findCollectionByNameOrId("tags");
|
||||
for (const name of ["urgent", "bug", "feature", "docs"]) {
|
||||
const r = new Record(tags);
|
||||
r.set("name", name);
|
||||
app.save(r); // `app` here is the transactional app - all or nothing
|
||||
}
|
||||
},
|
||||
(app) => {
|
||||
const tags = app.findCollectionByNameOrId("tags");
|
||||
for (const name of ["urgent", "bug", "feature", "docs"]) {
|
||||
const r = app.findFirstRecordByFilter(
|
||||
"tags",
|
||||
"name = {:name}",
|
||||
{ name },
|
||||
);
|
||||
if (r) app.delete(r);
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**CLI commands (same as Go migrations):**
|
||||
|
||||
```bash
|
||||
./pocketbase migrate create "add_audit_collection" # templated blank file
|
||||
./pocketbase migrate up # apply pending
|
||||
./pocketbase migrate down 1 # revert last
|
||||
./pocketbase migrate history-sync # reconcile _migrations table
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- **Filename format**: `<unix_timestamp>_<description>.js`. The timestamp sets ordering. Never renumber a committed file.
|
||||
- **The `app` argument is transactional**: every migration runs inside its own transaction. Throw to roll back. Do not capture `$app` from the outer scope - use the `app` parameter so the work participates in the tx.
|
||||
- **Use the collection API** (`new Collection`, `app.save(collection)`), not raw `ALTER TABLE`. Raw SQL leaves PocketBase's in-memory schema cache stale until the next restart.
|
||||
- **Always write the down callback** in development. In production, down migrations are rare but the callback is what makes `migrate down 1` work during emergency rollbacks.
|
||||
- **Do not import from other files** - goja has no ES modules, and at migration time the `pb_hooks` loader has not necessarily run. Keep each migration self-contained.
|
||||
- **Commit `pb_migrations/` to version control**. Never commit `pb_data/`.
|
||||
- **Conflicting with Go migrations**: you can run either Go or JS migrations, not a mix of both in the same project. JSVM migrations are enabled by default; Go migrations require `migratecmd.MustRegister(...)` in `main.go`.
|
||||
|
||||
Reference: [Extend with JavaScript - Migrations](https://pocketbase.io/docs/js-migrations/)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Set Up JSVM (pb_hooks) for Server-Side JavaScript
|
||||
impact: HIGH
|
||||
impactDescription: Correct setup unlocks hot-reload, type-completion, and the full JSVM API
|
||||
tags: jsvm, pb_hooks, extending, setup, typescript
|
||||
---
|
||||
|
||||
## Set Up JSVM (pb_hooks) for Server-Side JavaScript
|
||||
|
||||
The prebuilt PocketBase executable embeds an ES5 JavaScript engine (goja). Drop `*.pb.js` files into a `pb_hooks` directory next to the executable and they load automatically at startup. Files are loaded in **filename sort order**, and on UNIX platforms the process auto-reloads when any `pb_hooks` file changes.
|
||||
|
||||
**Incorrect (TypeScript without transpile, wrong filename, missing types reference):**
|
||||
|
||||
```typescript
|
||||
// pb_hooks/main.ts ❌ PocketBase loads ONLY *.pb.js - a .ts file is ignored
|
||||
import { something } from "./lib"; // ❌ ES modules not supported in goja
|
||||
|
||||
routerAdd("GET", "/hello", (e) => e.json(200, { ok: true }));
|
||||
```
|
||||
|
||||
```javascript
|
||||
// pb_hooks/hooks.js ❌ wrong extension - must be *.pb.js
|
||||
// No /// reference -> editor shows every call as "any"
|
||||
onRecordAfterUpdateSuccess((e) => {
|
||||
console.log(e.record.get("email"));
|
||||
// Missing e.next() - stops the execution chain silently
|
||||
}, "users");
|
||||
```
|
||||
|
||||
**Correct (valid filename, types reference, `e.next()` called):**
|
||||
|
||||
```javascript
|
||||
// pb_hooks/main.pb.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Hooks defined earlier in the filename sort order run first.
|
||||
// Use prefixes like "01_", "10_", "99_" if order matters.
|
||||
|
||||
routerAdd("GET", "/api/myapp/hello/{name}", (e) => {
|
||||
const name = e.request.pathValue("name");
|
||||
return e.json(200, { message: "Hello " + name });
|
||||
});
|
||||
|
||||
onRecordAfterUpdateSuccess((e) => {
|
||||
console.log("user updated:", e.record.get("email"));
|
||||
e.next(); // REQUIRED - otherwise the execution chain is broken
|
||||
}, "users");
|
||||
```
|
||||
|
||||
**Key details:**
|
||||
- JS method names are **camelCase** versions of their Go equivalents (`FindRecordById` → `$app.findRecordById`).
|
||||
- Errors are thrown as regular JS exceptions, not returned as values.
|
||||
- Global objects: `$app` (the app), `$apis` (routing helpers/middlewares), `$os` (OS primitives), `$security` (JWT, random strings, AES), `$filesystem` (file factories), `$dbx` (query builder), `$mails` (email helpers), `__hooks` (absolute path to `pb_hooks`).
|
||||
- `pb_data/types.d.ts` is regenerated automatically - commit the triple-slash reference but not the file itself if you prefer.
|
||||
- Auto-reload on file change works on UNIX only. On Windows, restart the process manually.
|
||||
|
||||
Reference: [Extend with JavaScript - Overview](https://pocketbase.io/docs/js-overview/)
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Load Shared Code with CommonJS require() in pb_hooks
|
||||
impact: MEDIUM
|
||||
impactDescription: Correct module usage prevents require() failures, race conditions, and ESM import errors
|
||||
tags: jsvm, pb_hooks, modules, require, commonjs, esm, filesystem
|
||||
---
|
||||
|
||||
## Load Shared Code with CommonJS require() in pb_hooks
|
||||
|
||||
The embedded JSVM (goja) supports **only CommonJS** (`require()`). ES module `import` syntax is not supported without pre-bundling. Modules use a shared registry — they are evaluated once and cached, so avoid mutable module-level state to prevent race conditions across concurrent requests.
|
||||
|
||||
**Incorrect (ESM imports, mutable shared state, Node.js APIs):**
|
||||
|
||||
```javascript
|
||||
// ❌ ESM import syntax — not supported by goja
|
||||
import { sendEmail } from "./utils.js";
|
||||
|
||||
// ❌ Node.js APIs don't exist in the JSVM sandbox
|
||||
const fs = require("fs");
|
||||
fs.writeFileSync("output.txt", "hello"); // ReferenceError
|
||||
|
||||
// ❌ Mutable module-level state is shared across concurrent requests
|
||||
// pb_hooks/counter.js
|
||||
let requestCount = 0;
|
||||
module.exports = { increment: () => ++requestCount }; // race condition
|
||||
```
|
||||
|
||||
**Correct (CommonJS require, stateless helpers, JSVM bindings for OS/file ops):**
|
||||
|
||||
```javascript
|
||||
// pb_hooks/utils.js — stateless helper module
|
||||
module.exports = {
|
||||
formatDate: (d) => new Date(d).toISOString().slice(0, 10),
|
||||
validateEmail: (addr) => /^[^@]+@[^@]+\.[^@]+$/.test(addr),
|
||||
};
|
||||
|
||||
// pb_hooks/main.pb.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
const utils = require(`${__hooks}/utils.js`);
|
||||
const date = utils.formatDate(e.record.get("created"));
|
||||
console.log("Record created on:", date);
|
||||
e.next();
|
||||
}, "posts");
|
||||
|
||||
// Use $os.* for file system operations (not Node.js fs)
|
||||
routerAdd("GET", "/api/myapp/read-config", (e) => {
|
||||
const raw = $os.readFile(`${__hooks}/config.json`);
|
||||
const cfg = JSON.parse(raw);
|
||||
return e.json(200, { name: cfg.appName });
|
||||
});
|
||||
|
||||
// Use $filesystem.s3(...) or $filesystem.local(...) for storage (v0.36.4+)
|
||||
routerAdd("POST", "/api/myapp/upload", (e) => {
|
||||
const bucket = $filesystem.s3({
|
||||
endpoint: "s3.amazonaws.com",
|
||||
bucket: "my-bucket",
|
||||
region: "us-east-1",
|
||||
accessKey: $app.settings().s3.accessKey,
|
||||
secret: $app.settings().s3.secret,
|
||||
});
|
||||
// ... use bucket to store/retrieve files
|
||||
return e.json(200, { ok: true });
|
||||
}, $apis.requireAuth());
|
||||
```
|
||||
|
||||
**Using third-party CJS packages:**
|
||||
|
||||
```javascript
|
||||
// node_modules/ is searched automatically alongside __hooks.
|
||||
// Install packages with npm next to the pb_hooks directory, then require by name.
|
||||
onBootstrap((e) => {
|
||||
e.next();
|
||||
// Only CJS-compatible packages work without bundling
|
||||
const slugify = require("slugify");
|
||||
console.log(slugify("Hello World")); // "Hello-World"
|
||||
});
|
||||
```
|
||||
|
||||
**Using ESM-only packages (bundle to CJS first):**
|
||||
|
||||
```bash
|
||||
# Bundle an ESM package to CJS with rollup before committing it to pb_hooks
|
||||
npx rollup node_modules/some-esm-pkg/index.js \
|
||||
--file pb_hooks/vendor/some-esm-pkg.js \
|
||||
--format cjs
|
||||
```
|
||||
|
||||
```javascript
|
||||
onBootstrap((e) => {
|
||||
e.next();
|
||||
const pkg = require(`${__hooks}/vendor/some-esm-pkg.js`);
|
||||
});
|
||||
```
|
||||
|
||||
**JSVM engine limitations:**
|
||||
- No `setTimeout` / `setInterval` — no async scheduling inside handlers.
|
||||
- No Node.js APIs (`fs`, `Buffer`, `process`, etc.) — use `$os.*` and `$filesystem.*` JSVM bindings instead.
|
||||
- No browser APIs (`fetch`, `window`, `localStorage`) — use `$app.newHttpClient()` for outbound HTTP requests.
|
||||
- ES6 is mostly supported but not fully spec-compliant (goja engine).
|
||||
- The prebuilt PocketBase executable starts a **pool of 15 JS runtimes** by default; adjust with `--hooksPool=N` for high-concurrency workloads (more runtimes = more memory, better throughput).
|
||||
- `nullString()`, `nullInt()`, `nullFloat()`, `nullBool()`, `nullArray()`, `nullObject()` helpers are available (v0.35.0+) for scanning nullable DB columns safely.
|
||||
|
||||
Reference: [Extend with JavaScript - Loading modules](https://pocketbase.io/docs/js-overview/#loading-modules)
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Avoid Capturing Variables Outside JSVM Handler Scope
|
||||
impact: HIGH
|
||||
impactDescription: Variables defined outside a handler are undefined at runtime due to handler serialization
|
||||
tags: jsvm, pb_hooks, scope, isolation, variables
|
||||
---
|
||||
|
||||
## Avoid Capturing Variables Outside JSVM Handler Scope
|
||||
|
||||
Each JSVM handler (hook, route, middleware) is **serialized and executed as an isolated program**. Variables or functions declared at the module/file scope are NOT accessible inside handler bodies. This is the most common source of `undefined` errors in `pb_hooks` code.
|
||||
|
||||
**Incorrect (accessing outer-scope variable inside handler):**
|
||||
|
||||
```javascript
|
||||
// pb_hooks/main.pb.js
|
||||
const APP_NAME = "myapp"; // ❌ will be undefined inside handlers
|
||||
|
||||
onBootstrap((e) => {
|
||||
e.next();
|
||||
console.log(APP_NAME); // ❌ undefined — APP_NAME is not in handler scope
|
||||
});
|
||||
|
||||
// ❌ Even $app references captured here may not work as expected
|
||||
const helper = (id) => $app.findRecordById("posts", id);
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
helper(e.record.id); // ❌ helper is undefined inside the handler
|
||||
}, "posts");
|
||||
```
|
||||
|
||||
**Correct (move shared state into a required module, or use `$app`/`e.app` directly):**
|
||||
|
||||
```javascript
|
||||
// pb_hooks/config.js — stateless CommonJS module
|
||||
module.exports = {
|
||||
APP_NAME: "myapp",
|
||||
MAX_RETRIES: 3,
|
||||
};
|
||||
|
||||
// pb_hooks/main.pb.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onBootstrap((e) => {
|
||||
e.next();
|
||||
// Load the shared module INSIDE the handler
|
||||
const config = require(`${__hooks}/config.js`);
|
||||
console.log(config.APP_NAME); // ✅ "myapp"
|
||||
});
|
||||
|
||||
routerAdd("GET", "/api/myapp/status", (e) => {
|
||||
const config = require(`${__hooks}/config.js`);
|
||||
return e.json(200, { app: config.APP_NAME });
|
||||
});
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
// Access the app directly via e.app inside the handler
|
||||
const post = e.app.findRecordById("posts", e.record.id);
|
||||
e.next();
|
||||
}, "posts");
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- Every handler body is serialized to a string and executed in its own isolated goja runtime context. There is no shared global state between handlers at runtime.
|
||||
- `require()` loads modules from a **shared registry** — modules are evaluated once and cached. Keep module-level code stateless; avoid mutable module exports to prevent data races under concurrent requests.
|
||||
- `__hooks` is always available inside handlers and resolves to the absolute path of the `pb_hooks` directory.
|
||||
- Error stack trace line numbers may not be accurate because of the handler serialization — log meaningful context manually when debugging.
|
||||
- Workaround for simple constants: move them to a `config.js` module and `require()` it inside each handler that needs it.
|
||||
|
||||
Reference: [Extend with JavaScript - Handlers scope](https://pocketbase.io/docs/js-overview/#handlers-scope)
|
||||
114
.claude/skills/pocketbase-best-practices/rules/ext-mailer.md
Normal file
114
.claude/skills/pocketbase-best-practices/rules/ext-mailer.md
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: Send Email via app.NewMailClient, Never the Default example.com Sender
|
||||
impact: HIGH
|
||||
impactDescription: Default sender is no-reply@example.com; shipping it bounces every email and damages your SMTP reputation
|
||||
tags: mailer, email, smtp, $mails, extending, templates
|
||||
---
|
||||
|
||||
## Send Email via app.NewMailClient, Never the Default example.com Sender
|
||||
|
||||
PocketBase ships with a mailer accessible through `app.NewMailClient()` (Go) or `$app.newMailClient()` (JS). It reads the SMTP settings configured in **Admin UI → Settings → Mail settings**, or falls back to a local `sendmail`-like client if SMTP is not configured. Two things bite people: (1) the default `Meta.senderAddress` is `no-reply@example.com` - shipping with that bounces every email and poisons your sender reputation; (2) there is no connection pooling, so long-lived mail client handles are **not** safe to share across requests - create one per send.
|
||||
|
||||
**Incorrect (default sender, shared client, no error handling):**
|
||||
|
||||
```go
|
||||
// ❌ Default sender is example.com, and this mailer instance is captured
|
||||
// for the process lifetime - SMTP connections go stale
|
||||
var mailer = app.NewMailClient()
|
||||
|
||||
app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error {
|
||||
msg := &mailer.Message{
|
||||
From: mail.Address{Address: "no-reply@example.com"}, // ❌
|
||||
To: []mail.Address{{Address: e.Record.GetString("email")}},
|
||||
Subject: "Order confirmed",
|
||||
HTML: "<p>Thanks</p>",
|
||||
}
|
||||
mailer.Send(msg) // ❌ error swallowed
|
||||
return e.Next()
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (sender from settings, per-send client, explicit error path):**
|
||||
|
||||
```go
|
||||
import (
|
||||
"net/mail"
|
||||
pbmail "github.com/pocketbase/pocketbase/tools/mailer"
|
||||
)
|
||||
|
||||
app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error {
|
||||
// IMPORTANT: resolve the sender from settings at send-time, not at
|
||||
// startup - an admin can change it live from the UI
|
||||
meta := e.App.Settings().Meta
|
||||
from := mail.Address{
|
||||
Name: meta.SenderName,
|
||||
Address: meta.SenderAddress,
|
||||
}
|
||||
|
||||
msg := &pbmail.Message{
|
||||
From: from,
|
||||
To: []mail.Address{{Address: e.Record.GetString("email")}},
|
||||
Subject: "Order confirmed",
|
||||
HTML: renderOrderEmail(e.Record), // your template function
|
||||
}
|
||||
|
||||
// Create the client per send - avoids stale TCP sessions
|
||||
if err := e.App.NewMailClient().Send(msg); err != nil {
|
||||
e.App.Logger().Error("order email send failed",
|
||||
"err", err,
|
||||
"recordId", e.Record.Id,
|
||||
)
|
||||
// Do NOT return the error - a failed email should not roll back the order
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM - $mails global exposes message factories
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
const meta = $app.settings().meta;
|
||||
|
||||
const message = new MailerMessage({
|
||||
from: {
|
||||
address: meta.senderAddress,
|
||||
name: meta.senderName,
|
||||
},
|
||||
to: [{ address: e.record.get("email") }],
|
||||
subject: "Order confirmed",
|
||||
html: `<p>Thanks for order ${e.record.id}</p>`,
|
||||
});
|
||||
|
||||
try {
|
||||
$app.newMailClient().send(message);
|
||||
} catch (err) {
|
||||
$app.logger().error("order email send failed", "err", err, "id", e.record.id);
|
||||
// swallow - do not rollback the order
|
||||
}
|
||||
e.next();
|
||||
}, "orders");
|
||||
```
|
||||
|
||||
**Templated emails via the built-in verification/reset templates:**
|
||||
|
||||
```go
|
||||
// PocketBase has baked-in templates for verification, password reset, and
|
||||
// email change. Trigger them via apis.*Request helpers rather than building
|
||||
// your own message:
|
||||
// apis.RecordRequestPasswordReset(app, authRecord)
|
||||
// apis.RecordRequestVerification(app, authRecord)
|
||||
// apis.RecordRequestEmailChange(app, authRecord, newEmail)
|
||||
//
|
||||
// These use the templates configured in Admin UI → Settings → Mail templates.
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- **Always change `Meta.SenderAddress`** before shipping. In development, use Mailpit or MailHog; in production, use a verified domain that matches your SPF/DKIM records.
|
||||
- **Resolve the sender from `app.Settings().Meta` at send-time**, not at startup. Settings are mutable from the admin UI.
|
||||
- **Create the client per send** (`app.NewMailClient()` / `$app.newMailClient()`). It is cheap - it re-reads the SMTP settings each time, so config changes take effect without a restart.
|
||||
- **Never return a send error from a hook** unless the user's action genuinely depends on the email going out. Email failure is common (transient SMTP, address typo) and should not roll back a business transaction.
|
||||
- **Log failures with context** (record id, recipient domain) so you can grep them later. PocketBase does not retry failed sends.
|
||||
- **For bulk sending, queue it**. The mailer is synchronous - looping `Send()` over 10k records blocks the request. Push to a cron-drained queue collection instead.
|
||||
- **Template rendering**: Go users should use `html/template`; JS users can use template literals or pull in a tiny template lib. PocketBase itself only renders templates for its baked-in flows.
|
||||
|
||||
Reference: [Go Mailer](https://pocketbase.io/docs/go-sending-emails/) · [JS Mailer](https://pocketbase.io/docs/js-sending-emails/)
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Register Custom Routes Safely with Built-in Middlewares
|
||||
impact: HIGH
|
||||
impactDescription: Protects custom endpoints with auth, avoids /api path collisions, inherits rate limiting
|
||||
tags: routing, middleware, extending, requireAuth, apis
|
||||
---
|
||||
|
||||
## Register Custom Routes Safely with Built-in Middlewares
|
||||
|
||||
PocketBase routing is built on top of `net/http.ServeMux`. Custom routes are registered inside the `OnServe()` hook (Go) or via `routerAdd()` / `routerUse()` (JSVM). **Always** namespace custom routes under `/api/{yourapp}/...` to avoid colliding with built-in endpoints, and attach `apis.RequireAuth()` / `$apis.requireAuth()` (or stricter) to anything that is not meant to be public.
|
||||
|
||||
**Incorrect (path collision, no auth, raw ResponseWriter):**
|
||||
|
||||
```go
|
||||
// ❌ "/api/records" collides with /api/collections/{name}/records built-in
|
||||
se.Router.POST("/api/records", func(e *core.RequestEvent) error {
|
||||
// ❌ no auth check - anyone can call this
|
||||
// ❌ returns raw text; no content-type
|
||||
e.Response.Write([]byte("ok"))
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (namespaced, authenticated, group-scoped middleware):**
|
||||
|
||||
```go
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
// Group everything under /api/myapp/ and require auth for the entire group
|
||||
g := se.Router.Group("/api/myapp")
|
||||
g.Bind(apis.RequireAuth()) // authenticated users only
|
||||
g.Bind(apis.Gzip()) // compress responses
|
||||
g.Bind(apis.BodyLimit(10 << 20)) // per-route override of default 32MB limit
|
||||
|
||||
g.GET("/profile", func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"id": e.Auth.Id,
|
||||
"email": e.Auth.GetString("email"),
|
||||
})
|
||||
})
|
||||
|
||||
// Superuser-only admin endpoint
|
||||
g.POST("/admin/rebuild-index", func(e *core.RequestEvent) error {
|
||||
// ... do the work
|
||||
return e.JSON(http.StatusOK, map[string]bool{"ok": true})
|
||||
}).Bind(apis.RequireSuperuserAuth())
|
||||
|
||||
// Resource the owner (or a superuser) can access
|
||||
g.GET("/users/{id}/private", func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"private": "data"})
|
||||
}).Bind(apis.RequireSuperuserOrOwnerAuth("id"))
|
||||
|
||||
return se.Next()
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM
|
||||
routerAdd("GET", "/api/myapp/profile", (e) => {
|
||||
return e.json(200, {
|
||||
id: e.auth.id,
|
||||
email: e.auth.getString("email"),
|
||||
});
|
||||
}, $apis.requireAuth());
|
||||
|
||||
routerAdd("POST", "/api/myapp/admin/rebuild-index", (e) => {
|
||||
return e.json(200, { ok: true });
|
||||
}, $apis.requireSuperuserAuth());
|
||||
```
|
||||
|
||||
**Built-in middlewares (Go: `apis.*`, JS: `$apis.*`):**
|
||||
|
||||
| Middleware | Use |
|
||||
|---|---|
|
||||
| `RequireGuestOnly()` | Reject authenticated clients (e.g. public signup forms) |
|
||||
| `RequireAuth(...collections)` | Require any auth record; optionally restrict to specific auth collections |
|
||||
| `RequireSuperuserAuth()` | Alias for `RequireAuth("_superusers")` |
|
||||
| `RequireSuperuserOrOwnerAuth("id")` | Allow superusers OR the auth record whose id matches the named path param |
|
||||
| `Gzip()` | Gzip-compress the response |
|
||||
| `BodyLimit(bytes)` | Override the default 32MB request body cap (0 = no limit) |
|
||||
| `SkipSuccessActivityLog()` | Suppress activity log for successful responses |
|
||||
|
||||
**Path details:**
|
||||
- Patterns follow `net/http.ServeMux`: `{name}` = single segment, `{name...}` = catch-all.
|
||||
- A trailing `/` acts as a prefix wildcard; use `{$}` to anchor to the exact path only.
|
||||
- **Always** prefix custom routes with `/api/{yourapp}/` - do not put them under `/api/` alone, which collides with built-in collection / realtime / settings endpoints.
|
||||
- Order: global middlewares → group middlewares → route middlewares → handler. Use negative priorities to run before built-ins if needed.
|
||||
|
||||
Reference: [Go Routing](https://pocketbase.io/docs/go-routing/) · [JS Routing](https://pocketbase.io/docs/js-routing/)
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Read Settings via app.Settings(), Encrypt at Rest with PB_ENCRYPTION
|
||||
impact: HIGH
|
||||
impactDescription: Hardcoded secrets and unencrypted settings storage are the #1 source of credential leaks
|
||||
tags: settings, configuration, encryption, secrets, PB_ENCRYPTION, extending
|
||||
---
|
||||
|
||||
## Read Settings via app.Settings(), Encrypt at Rest with PB_ENCRYPTION
|
||||
|
||||
PocketBase stores every runtime-mutable setting (SMTP credentials, S3 keys, OAuth2 client secrets, JWT secrets for each auth collection) in the `_params` table as JSON. Admin UI edits write to the same place. There are two knobs that matter: (1) **how you read settings from Go/JS** - always via `app.Settings()` at call time, never captured at startup; (2) **how they are stored on disk** - set the `PB_ENCRYPTION` env var to a 32-char key so the whole blob is encrypted at rest. Without encryption, anyone with a copy of `data.db` has your SMTP password, OAuth2 secrets, and every collection's signing key.
|
||||
|
||||
**Incorrect (hardcoded secret, captured at startup, unencrypted at rest):**
|
||||
|
||||
```go
|
||||
// ❌ Secret compiled into the binary - leaks via `strings ./pocketbase`
|
||||
const slackWebhook = "https://hooks.slack.com/services/T00/B00/XXXX"
|
||||
|
||||
// ❌ Captured once at startup - if an admin rotates the SMTP password via the
|
||||
// UI, this stale value keeps trying until restart
|
||||
var smtpHost = app.Settings().SMTP.Host
|
||||
|
||||
// ❌ No PB_ENCRYPTION set - `sqlite3 pb_data/data.db "SELECT * FROM _params"`
|
||||
// prints every secret in plaintext
|
||||
./pocketbase serve
|
||||
```
|
||||
|
||||
**Correct (env + settings lookup at call time + encryption at rest):**
|
||||
|
||||
```bash
|
||||
# Generate a 32-char encryption key once and store it in your secrets manager
|
||||
# (1Password, SOPS, AWS SSM, etc). Commit NOTHING related to this value.
|
||||
openssl rand -hex 16 # 32 hex chars
|
||||
|
||||
# Start with the key exported - PocketBase AES-encrypts _params on write
|
||||
# and decrypts on read. Losing the key == losing access to settings.
|
||||
export PB_ENCRYPTION="3a7c...deadbeef32charsexactly"
|
||||
./pocketbase serve
|
||||
```
|
||||
|
||||
```go
|
||||
// Reading mutable settings at call time - reflects live UI changes
|
||||
func notifyAdmin(app core.App, msg string) error {
|
||||
meta := app.Settings().Meta
|
||||
from := mail.Address{Name: meta.SenderName, Address: meta.SenderAddress}
|
||||
// ...
|
||||
}
|
||||
|
||||
// Mutating settings programmatically (e.g. during a migration)
|
||||
settings := app.Settings()
|
||||
settings.Meta.AppName = "MyApp"
|
||||
settings.SMTP.Enabled = true
|
||||
settings.SMTP.Host = os.Getenv("SMTP_HOST") // inject from env at write time
|
||||
if err := app.Save(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM
|
||||
onBootstrap((e) => {
|
||||
e.next();
|
||||
|
||||
const settings = $app.settings();
|
||||
settings.meta.appName = "MyApp";
|
||||
$app.save(settings);
|
||||
});
|
||||
|
||||
// At send-time
|
||||
const meta = $app.settings().meta;
|
||||
```
|
||||
|
||||
**Secrets that do NOT belong in `app.Settings()`:**
|
||||
|
||||
- Database encryption key itself → `PB_ENCRYPTION` env var (not in the DB, obviously)
|
||||
- Third-party webhooks your code calls (Slack, Stripe, etc) → env vars, read via `os.Getenv` / `$os.getenv`
|
||||
- CI tokens, deploy keys → your secrets manager, not PocketBase
|
||||
|
||||
`app.Settings()` is for things an **admin** should be able to rotate through the UI. Everything else lives in env vars, injected by your process supervisor (systemd, Docker, Kubernetes).
|
||||
|
||||
**Key details:**
|
||||
- **`PB_ENCRYPTION` must be exactly 32 characters.** Anything else crashes at startup.
|
||||
- **Losing the key is unrecoverable** - the settings blob cannot be decrypted, and the server refuses to boot. Back up the key alongside (but separately from) your `pb_data` backups.
|
||||
- **Rotating the key**: start with the old key set, call `app.Settings()` → `app.Save(settings)` to re-encrypt under the new key, then restart with the new key. Do this under a maintenance window.
|
||||
- **Settings changes fire `OnSettingsReload`** - use it if you have in-memory state that depends on a setting (e.g. a rate limiter sized from `app.Settings().RateLimits.Default`).
|
||||
- **Do not call `app.Settings()` in a hot loop.** It returns a fresh copy each time. Cache for the duration of a single request, not the process.
|
||||
- **`app.Save(settings)`** persists and broadcasts the reload event. Mutating the returned struct without saving is a no-op.
|
||||
|
||||
Reference: [Settings](https://pocketbase.io/docs/going-to-production/#use-encryption-for-the-pb_data-settings) · [OnSettingsReload hook](https://pocketbase.io/docs/go-event-hooks/#app-hooks)
|
||||
173
.claude/skills/pocketbase-best-practices/rules/ext-testing.md
Normal file
173
.claude/skills/pocketbase-best-practices/rules/ext-testing.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: Test Hooks and Routes with tests.NewTestApp and ApiScenario
|
||||
instead of Curl
|
||||
impact: HIGH
|
||||
impactDescription: Without the tests package you cannot exercise hooks, middleware, and transactions in isolation
|
||||
tags: testing, tests, NewTestApp, ApiScenario, go, extending
|
||||
---
|
||||
|
||||
## Test Hooks and Routes with tests.NewTestApp and ApiScenario
|
||||
|
||||
PocketBase ships a `tests` package specifically for integration-testing Go extensions. `tests.NewTestApp(testDataDir)` builds a fully-wired `core.App` over a **temp copy** of your test data directory, so you can register hooks, fire requests through the real router, and assert on the resulting DB state without spinning up a real HTTP server or touching `pb_data/`. The `tests.ApiScenario` struct drives the router the same way a real HTTP client would, including middleware and transactions. Curl-based shell tests cannot do either of these things.
|
||||
|
||||
**Incorrect (hand-rolled HTTP client, shared dev DB, no hook reset):**
|
||||
|
||||
```go
|
||||
// ❌ Hits the actual dev server - depends on side-effects from a previous run
|
||||
func TestCreatePost(t *testing.T) {
|
||||
resp, _ := http.Post("http://localhost:8090/api/collections/posts/records",
|
||||
"application/json",
|
||||
strings.NewReader(`{"title":"hi"}`))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatal("bad status")
|
||||
}
|
||||
// ❌ No DB assertion, no cleanup, no hook verification
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (NewTestApp + ApiScenario + AfterTestFunc assertions):**
|
||||
|
||||
```go
|
||||
// internal/app/posts_test.go
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
|
||||
"myapp/internal/hooks" // your hook registration
|
||||
)
|
||||
|
||||
// testDataDir is a checked-in pb_data snapshot with your collections.
|
||||
// Create it once with `./pocketbase --dir ./test_pb_data migrate up`
|
||||
// and commit it to your test fixtures.
|
||||
const testDataDir = "../../test_pb_data"
|
||||
|
||||
func TestCreatePostFiresAudit(t *testing.T) {
|
||||
// Each test gets its own copy of testDataDir - parallel-safe
|
||||
app, err := tests.NewTestApp(testDataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Cleanup() // REQUIRED - removes the temp copy
|
||||
|
||||
// Register the hook under test against this isolated app
|
||||
hooks.RegisterPostHooks(app)
|
||||
|
||||
scenario := tests.ApiScenario{
|
||||
Name: "POST /api/collections/posts/records as verified user",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/posts/records",
|
||||
Body: strings.NewReader(`{"title":"hello","slug":"hello"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": testAuthHeader(app, "users", "alice@example.com"),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"title":"hello"`,
|
||||
`"slug":"hello"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"internalNotes"`, // the enrich hook should hide this
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
// Assert side-effects in the DB using the SAME app instance
|
||||
audits, err := app.FindRecordsByFilter(
|
||||
"audit",
|
||||
"action = 'post.create'",
|
||||
"-created", 10, 0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(audits) != 1 {
|
||||
t.Fatalf("expected 1 audit record, got %d", len(audits))
|
||||
}
|
||||
},
|
||||
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
|
||||
}
|
||||
|
||||
scenario.Test(t)
|
||||
}
|
||||
```
|
||||
|
||||
**Table-driven variant (authz matrix):**
|
||||
|
||||
```go
|
||||
func TestPostsListAuthz(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
auth string // "", "users:alice", "users:bob", "_superusers:root"
|
||||
expect int
|
||||
}{
|
||||
{"guest gets public posts", "", 200},
|
||||
{"authed gets own + public", "users:alice", 200},
|
||||
{"superuser sees everything", "_superusers:root",200},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp(testDataDir)
|
||||
defer app.Cleanup()
|
||||
hooks.RegisterPostHooks(app)
|
||||
|
||||
tests.ApiScenario{
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/posts/records",
|
||||
Headers: authHeaderFor(app, tc.auth),
|
||||
ExpectedStatus: tc.expect,
|
||||
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
|
||||
}.Test(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Unit-testing a hook in isolation (no HTTP layer):**
|
||||
|
||||
```go
|
||||
func TestAuditHookRollsBackOnAuditFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp(testDataDir)
|
||||
defer app.Cleanup()
|
||||
hooks.RegisterPostHooks(app)
|
||||
|
||||
// Delete the audit collection so the hook's Save fails
|
||||
audit, _ := app.FindCollectionByNameOrId("audit")
|
||||
_ = app.Delete(audit)
|
||||
|
||||
col, _ := app.FindCollectionByNameOrId("posts")
|
||||
post := core.NewRecord(col)
|
||||
post.Set("title", "should rollback")
|
||||
post.Set("slug", "rollback")
|
||||
|
||||
if err := app.Save(post); err == nil {
|
||||
t.Fatal("expected Save to fail because audit hook errored")
|
||||
}
|
||||
|
||||
// Assert the post was NOT persisted (tx rolled back)
|
||||
_, err := app.FindFirstRecordByFilter("posts", "slug = 'rollback'", nil)
|
||||
if err == nil {
|
||||
t.Fatal("post should not exist after rollback")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- **Always `defer app.Cleanup()`** - otherwise temp directories leak under `/tmp`.
|
||||
- **Use a checked-in `test_pb_data/` fixture** with the collections you need. Do not depend on the dev `pb_data/` - tests must be hermetic.
|
||||
- **Register hooks against the test app**, not against a package-level `app` singleton. The test app is a fresh instance each time.
|
||||
- **`ExpectedEvents`** asserts that specific hooks fired the expected number of times - use it to catch "hook silently skipped because someone forgot `e.Next()`" regressions.
|
||||
- **`AfterTestFunc`** runs with the same app instance the scenario used, so you can query the DB to verify side-effects.
|
||||
- **Parallelize with `t.Parallel()`** - `NewTestApp` gives each goroutine its own copy, so there's no shared state.
|
||||
- **Tests run pure-Go SQLite** (`modernc.org/sqlite`) - no CGO, no extra setup, works on `go test ./...` out of the box.
|
||||
- **For JSVM**, there is no equivalent test harness yet - test pb_hooks by booting `tests.NewTestApp` with the `pb_hooks/` directory populated and exercising the router from Go. Pure-JS unit testing of hook bodies requires extracting the logic into a `require()`able module.
|
||||
|
||||
Reference: [Testing](https://pocketbase.io/docs/go-testing/) · [tests package GoDoc](https://pkg.go.dev/github.com/pocketbase/pocketbase/tests)
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Use RunInTransaction with the Scoped txApp, Never the Outer App
|
||||
impact: CRITICAL
|
||||
impactDescription: Mixing scoped and outer app inside a transaction silently deadlocks or writes outside the tx
|
||||
tags: transactions, extending, deadlock, runInTransaction, atomicity
|
||||
---
|
||||
|
||||
## Use RunInTransaction with the Scoped txApp, Never the Outer App
|
||||
|
||||
`app.RunInTransaction` (Go) and `$app.runInTransaction` (JS) wrap a block of work in a SQLite write transaction. The callback receives a **transaction-scoped app instance** (`txApp` / `txApp`). Every database call inside the block must go through that scoped instance - reusing the outer `app` / `$app` bypasses the transaction (silent partial writes) or deadlocks (SQLite allows only one writer).
|
||||
|
||||
**Incorrect (outer `app` used inside the tx block):**
|
||||
|
||||
```go
|
||||
// ❌ Uses the outer app for the second Save - deadlocks on the writer lock
|
||||
err := app.RunInTransaction(func(txApp core.App) error {
|
||||
user := core.NewRecord(usersCol)
|
||||
user.Set("email", "a@b.co")
|
||||
if err := txApp.Save(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
audit := core.NewRecord(auditCol)
|
||||
audit.Set("user", user.Id)
|
||||
return app.Save(audit) // ❌ NOT txApp - blocks forever
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (always `txApp` inside the block, return errors to roll back):**
|
||||
|
||||
```go
|
||||
err := app.RunInTransaction(func(txApp core.App) error {
|
||||
user := core.NewRecord(usersCol)
|
||||
user.Set("email", "a@b.co")
|
||||
if err := txApp.Save(user); err != nil {
|
||||
return err // rollback
|
||||
}
|
||||
|
||||
audit := core.NewRecord(auditCol)
|
||||
audit.Set("user", user.Id)
|
||||
if err := txApp.Save(audit); err != nil {
|
||||
return err // rollback
|
||||
}
|
||||
return nil // commit
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JSVM - the callback receives the transactional app
|
||||
$app.runInTransaction((txApp) => {
|
||||
const user = new Record(txApp.findCollectionByNameOrId("users"));
|
||||
user.set("email", "a@b.co");
|
||||
txApp.save(user);
|
||||
|
||||
const audit = new Record(txApp.findCollectionByNameOrId("audit"));
|
||||
audit.set("user", user.id);
|
||||
txApp.save(audit);
|
||||
|
||||
// throw anywhere in this block to roll back the whole tx
|
||||
});
|
||||
```
|
||||
|
||||
**Rules of the transaction:**
|
||||
- **Use only `txApp` / the callback's scoped app** inside the block. Capturing the outer `app` defeats the purpose and can deadlock.
|
||||
- Inside event hooks, `e.App` is already the transactional app when the hook fires inside a tx - prefer it over a captured parent-scope `app` for the same reason.
|
||||
- Return an error (Go) or `throw` (JS) to roll back. A successful return commits.
|
||||
- SQLite serializes writers - keep transactions **short**. Do not make HTTP calls, send emails, or wait on external systems inside the block.
|
||||
- Do not start a transaction inside another transaction on the same app - nested `RunInTransaction` on `txApp` is supported and reuses the existing transaction, but nested calls on the outer `app` will deadlock.
|
||||
- Hooks (`OnRecordAfterCreateSuccess`, etc.) fired from a `Save` inside a tx run **inside that tx**. Anything they do through `e.App` participates in the rollback; anything they do through a captured outer `app` does not.
|
||||
|
||||
Reference: [Go database](https://pocketbase.io/docs/go-database/#transaction) · [JS database](https://pocketbase.io/docs/js-database/#transaction)
|
||||
188
.claude/skills/pocketbase-best-practices/rules/file-serving.md
Normal file
188
.claude/skills/pocketbase-best-practices/rules/file-serving.md
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
title: Generate File URLs Correctly
|
||||
impact: MEDIUM
|
||||
impactDescription: Proper URLs with thumbnails and access control
|
||||
tags: files, urls, thumbnails, serving
|
||||
---
|
||||
|
||||
## Generate File URLs Correctly
|
||||
|
||||
Use the SDK's `getURL` method to generate proper file URLs with thumbnail support and access tokens for protected files.
|
||||
|
||||
**Incorrect (manually constructing URLs):**
|
||||
|
||||
```javascript
|
||||
// Hardcoded URL construction - brittle
|
||||
const imageUrl = `http://localhost:8090/api/files/${record.collectionId}/${record.id}/${record.image}`;
|
||||
|
||||
// Missing token for protected files
|
||||
const privateUrl = pb.files.getURL(record, record.document);
|
||||
// Returns URL but file access denied if protected!
|
||||
|
||||
// Wrong thumbnail syntax
|
||||
const thumb = `${imageUrl}?thumb=100x100`; // Wrong format
|
||||
```
|
||||
|
||||
**Correct (using SDK methods):**
|
||||
|
||||
```javascript
|
||||
// Basic file URL
|
||||
const imageUrl = pb.files.getURL(record, record.image);
|
||||
// Returns: http://host/api/files/COLLECTION/RECORD_ID/filename.jpg
|
||||
|
||||
// With thumbnail (for images only)
|
||||
const thumbUrl = pb.files.getURL(record, record.image, {
|
||||
thumb: '100x100' // Width x Height
|
||||
});
|
||||
|
||||
// Thumbnail options
|
||||
const thumbs = {
|
||||
square: pb.files.getURL(record, record.image, { thumb: '100x100' }),
|
||||
fit: pb.files.getURL(record, record.image, { thumb: '100x0' }), // Fit width
|
||||
fitHeight: pb.files.getURL(record, record.image, { thumb: '0x100' }), // Fit height
|
||||
crop: pb.files.getURL(record, record.image, { thumb: '100x100t' }), // Top crop
|
||||
cropBottom: pb.files.getURL(record, record.image, { thumb: '100x100b' }), // Bottom
|
||||
force: pb.files.getURL(record, record.image, { thumb: '100x100f' }), // Force exact
|
||||
};
|
||||
|
||||
// Protected files (require auth)
|
||||
async function getProtectedFileUrl(record, filename) {
|
||||
// Get file access token (valid for limited time)
|
||||
const token = await pb.files.getToken();
|
||||
|
||||
// Include token in URL
|
||||
return pb.files.getURL(record, filename, { token });
|
||||
}
|
||||
|
||||
// Example with protected document
|
||||
async function downloadDocument(record) {
|
||||
const token = await pb.files.getToken();
|
||||
const url = pb.files.getURL(record, record.document, { token });
|
||||
|
||||
// Token is appended: ...?token=xxx
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
```
|
||||
|
||||
**React component example:**
|
||||
|
||||
```jsx
|
||||
function UserAvatar({ user, size = 50 }) {
|
||||
if (!user.avatar) {
|
||||
return <DefaultAvatar size={size} />;
|
||||
}
|
||||
|
||||
const avatarUrl = pb.files.getURL(user, user.avatar, {
|
||||
thumb: `${size}x${size}`
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={user.name}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageGallery({ record }) {
|
||||
// Record has multiple images
|
||||
const images = record.images || [];
|
||||
|
||||
return (
|
||||
<div className="gallery">
|
||||
{images.map((filename, index) => (
|
||||
<img
|
||||
key={filename}
|
||||
src={pb.files.getURL(record, filename, { thumb: '200x200' })}
|
||||
onClick={() => openFullSize(record, filename)}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function openFullSize(record, filename) {
|
||||
const fullUrl = pb.files.getURL(record, filename);
|
||||
window.open(fullUrl, '_blank');
|
||||
}
|
||||
```
|
||||
|
||||
**Handling file URLs in lists:**
|
||||
|
||||
```javascript
|
||||
// Efficiently generate URLs for list of records
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
expand: 'author'
|
||||
});
|
||||
|
||||
const postsWithUrls = posts.items.map(post => ({
|
||||
...post,
|
||||
thumbnailUrl: post.image
|
||||
? pb.files.getURL(post, post.image, { thumb: '300x200' })
|
||||
: null,
|
||||
authorAvatarUrl: post.expand?.author?.avatar
|
||||
? pb.files.getURL(post.expand.author, post.expand.author.avatar, { thumb: '40x40' })
|
||||
: null
|
||||
}));
|
||||
```
|
||||
|
||||
**Thumbnail format reference:**
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `WxH` | Fit within dimensions |
|
||||
| `Wx0` | Fit width, auto height |
|
||||
| `0xH` | Auto width, fit height |
|
||||
| `WxHt` | Crop from top |
|
||||
| `WxHb` | Crop from bottom |
|
||||
| `WxHf` | Force exact dimensions |
|
||||
|
||||
**Performance and caching:**
|
||||
|
||||
```javascript
|
||||
// File URLs are effectively immutable (randomized filenames on upload).
|
||||
// This makes them ideal for aggressive caching.
|
||||
|
||||
// Configure Cache-Control via reverse proxy (Nginx/Caddy):
|
||||
// location /api/files/ { add_header Cache-Control "public, immutable, max-age=86400"; }
|
||||
|
||||
// Thumbnails are generated on first request and cached by PocketBase.
|
||||
// Pre-generate expected thumb sizes after upload to avoid cold-start latency:
|
||||
async function uploadWithThumbs(record, file) {
|
||||
const updated = await pb.collection('posts').update(record.id, { image: file });
|
||||
|
||||
// Pre-warm thumbnail cache by requesting expected sizes
|
||||
const sizes = ['100x100', '300x200', '800x600'];
|
||||
await Promise.all(sizes.map(size =>
|
||||
fetch(pb.files.getURL(updated, updated.image, { thumb: size }))
|
||||
));
|
||||
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
**S3 file serving optimization:**
|
||||
|
||||
When using S3 storage, PocketBase proxies all file requests through the server. For better performance with public files, serve directly from your S3 CDN:
|
||||
|
||||
```javascript
|
||||
// Default: All file requests proxy through PocketBase
|
||||
const url = pb.files.getURL(record, record.image);
|
||||
// -> https://myapp.com/api/files/COLLECTION/ID/filename.jpg (proxied)
|
||||
|
||||
// For public files with S3 + CDN, construct CDN URL directly:
|
||||
const cdnBase = 'https://cdn.myapp.com'; // Your S3 CDN domain
|
||||
const cdnUrl = `${cdnBase}/${record.collectionId}/${record.id}/${record.image}`;
|
||||
// Bypasses PocketBase, served directly from CDN edge
|
||||
|
||||
// NOTE: This only works for public files (no access token needed).
|
||||
// Protected files must go through PocketBase for token validation.
|
||||
```
|
||||
|
||||
Reference: [PocketBase Files](https://pocketbase.io/docs/files-handling/)
|
||||
|
||||
> **Note (JS SDK v0.26.7):** `pb.files.getURL()` now serializes query parameters the same way as the fetch methods — passing `null` or `undefined` as a query param value is silently skipped from the generated URL, so you no longer need to guard optional params before passing them to `getURL()`.
|
||||
168
.claude/skills/pocketbase-best-practices/rules/file-upload.md
Normal file
168
.claude/skills/pocketbase-best-practices/rules/file-upload.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
title: Upload Files Correctly
|
||||
impact: MEDIUM
|
||||
impactDescription: Reliable uploads with progress tracking and validation
|
||||
tags: files, upload, storage, attachments
|
||||
---
|
||||
|
||||
## Upload Files Correctly
|
||||
|
||||
File uploads can use plain objects or FormData. Handle large files properly with progress tracking and appropriate error handling.
|
||||
|
||||
**Incorrect (naive file upload):**
|
||||
|
||||
```javascript
|
||||
// Missing error handling
|
||||
async function uploadFile(file) {
|
||||
await pb.collection('documents').create({
|
||||
title: file.name,
|
||||
file: file
|
||||
});
|
||||
// No error handling, no progress feedback
|
||||
}
|
||||
|
||||
// Uploading without validation
|
||||
async function uploadAvatar(file) {
|
||||
await pb.collection('users').update(userId, {
|
||||
avatar: file // No size/type check - might fail server-side
|
||||
});
|
||||
}
|
||||
|
||||
// Base64 upload (inefficient)
|
||||
async function uploadImage(base64) {
|
||||
await pb.collection('images').create({
|
||||
image: base64 // Wrong! PocketBase expects File/Blob
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (proper file uploads):**
|
||||
|
||||
```javascript
|
||||
// Basic upload with object (auto-converts to FormData)
|
||||
async function uploadDocument(file, metadata) {
|
||||
try {
|
||||
const record = await pb.collection('documents').create({
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
file: file // File object from input
|
||||
});
|
||||
return record;
|
||||
} catch (error) {
|
||||
if (error.response?.data?.file) {
|
||||
throw new Error(`File error: ${error.response.data.file.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload multiple files
|
||||
async function uploadGallery(files, albumId) {
|
||||
const record = await pb.collection('albums').update(albumId, {
|
||||
images: files // Array of File objects
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
// FormData for more control
|
||||
async function uploadWithProgress(file, onProgress) {
|
||||
const formData = new FormData();
|
||||
formData.append('title', file.name);
|
||||
formData.append('file', file);
|
||||
|
||||
// Using fetch directly for progress (SDK doesn't expose progress)
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
reject(new Error(`Upload failed: ${xhr.status}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
|
||||
|
||||
xhr.open('POST', `${pb.baseURL}/api/collections/documents/records`);
|
||||
xhr.setRequestHeader('Authorization', pb.authStore.token);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
// Client-side validation before upload
|
||||
function validateFile(file, options = {}) {
|
||||
const {
|
||||
maxSize = 10 * 1024 * 1024, // 10MB default
|
||||
allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'],
|
||||
maxNameLength = 100
|
||||
} = options;
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (file.size > maxSize) {
|
||||
errors.push(`File too large. Max: ${maxSize / 1024 / 1024}MB`);
|
||||
}
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
errors.push(`Invalid file type: ${file.type}`);
|
||||
}
|
||||
|
||||
if (file.name.length > maxNameLength) {
|
||||
errors.push(`Filename too long`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// Complete upload flow
|
||||
async function handleFileUpload(inputEvent) {
|
||||
const file = inputEvent.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate
|
||||
const validation = validateFile(file, {
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
allowedTypes: ['image/jpeg', 'image/png']
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
showError(validation.errors.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload with progress
|
||||
try {
|
||||
setUploading(true);
|
||||
const record = await uploadWithProgress(file, setProgress);
|
||||
showSuccess('Upload complete!');
|
||||
return record;
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Deleting files:**
|
||||
|
||||
```javascript
|
||||
// Remove specific file(s) from record
|
||||
await pb.collection('albums').update(albumId, {
|
||||
'images-': ['filename1.jpg', 'filename2.jpg'] // Remove these files
|
||||
});
|
||||
|
||||
// Clear all files
|
||||
await pb.collection('documents').update(docId, {
|
||||
file: null // Removes the file
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [PocketBase File Upload](https://pocketbase.io/docs/files-handling/)
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
title: Validate File Uploads
|
||||
impact: MEDIUM
|
||||
impactDescription: Prevents invalid uploads, improves security and UX
|
||||
tags: files, validation, security, upload
|
||||
---
|
||||
|
||||
## Validate File Uploads
|
||||
|
||||
Validate files on both client and server side. Client validation improves UX; server validation (via collection settings) enforces security.
|
||||
|
||||
**Incorrect (no validation):**
|
||||
|
||||
```javascript
|
||||
// Accepting any file without checks
|
||||
async function uploadFile(file) {
|
||||
return pb.collection('uploads').create({ file });
|
||||
// Could upload 1GB executable!
|
||||
}
|
||||
|
||||
// Only checking extension (easily bypassed)
|
||||
function validateFile(file) {
|
||||
const ext = file.name.split('.').pop();
|
||||
return ['jpg', 'png'].includes(ext);
|
||||
// User can rename virus.exe to virus.jpg
|
||||
}
|
||||
|
||||
// Client-only validation (can be bypassed)
|
||||
async function uploadAvatar(file) {
|
||||
if (file.size > 1024 * 1024) {
|
||||
throw new Error('Too large');
|
||||
}
|
||||
// Attacker can bypass this with dev tools
|
||||
return pb.collection('users').update(userId, { avatar: file });
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (comprehensive validation):**
|
||||
|
||||
```javascript
|
||||
// 1. Configure server-side validation in collection settings
|
||||
// In Admin UI or via API:
|
||||
const collectionConfig = {
|
||||
schema: [
|
||||
{
|
||||
name: 'avatar',
|
||||
type: 'file',
|
||||
options: {
|
||||
maxSelect: 1, // Single file only
|
||||
maxSize: 5242880, // 5MB in bytes
|
||||
mimeTypes: [ // Allowed types
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp'
|
||||
],
|
||||
thumbs: ['100x100', '200x200'] // Auto-generate thumbnails
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'documents',
|
||||
type: 'file',
|
||||
options: {
|
||||
maxSelect: 10,
|
||||
maxSize: 10485760, // 10MB
|
||||
mimeTypes: [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 2. Client-side validation for better UX
|
||||
const FILE_CONSTRAINTS = {
|
||||
avatar: {
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
maxFiles: 1
|
||||
},
|
||||
documents: {
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
allowedTypes: ['application/pdf'],
|
||||
maxFiles: 10
|
||||
}
|
||||
};
|
||||
|
||||
function validateFiles(files, constraintKey) {
|
||||
const constraints = FILE_CONSTRAINTS[constraintKey];
|
||||
const errors = [];
|
||||
const validFiles = [];
|
||||
|
||||
if (files.length > constraints.maxFiles) {
|
||||
errors.push(`Maximum ${constraints.maxFiles} file(s) allowed`);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const fileErrors = [];
|
||||
|
||||
// Check size
|
||||
if (file.size > constraints.maxSize) {
|
||||
const maxMB = constraints.maxSize / 1024 / 1024;
|
||||
fileErrors.push(`${file.name}: exceeds ${maxMB}MB limit`);
|
||||
}
|
||||
|
||||
// Check MIME type (more reliable than extension, but still spoofable)
|
||||
// Client-side file.type is based on extension, not file content.
|
||||
// Always enforce mimeTypes in PocketBase collection settings for server-side validation.
|
||||
if (!constraints.allowedTypes.includes(file.type)) {
|
||||
fileErrors.push(`${file.name}: invalid file type (${file.type || 'unknown'})`);
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if (file.name.includes('..') || file.name.includes('/')) {
|
||||
fileErrors.push(`${file.name}: invalid filename`);
|
||||
}
|
||||
|
||||
if (fileErrors.length === 0) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
errors.push(...fileErrors);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
validFiles
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Complete upload with validation
|
||||
async function handleAvatarUpload(inputElement) {
|
||||
const files = Array.from(inputElement.files);
|
||||
|
||||
// Client validation
|
||||
const validation = validateFiles(files, 'avatar');
|
||||
if (!validation.valid) {
|
||||
showErrors(validation.errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Upload (server will also validate)
|
||||
try {
|
||||
const updated = await pb.collection('users').update(userId, {
|
||||
avatar: validation.validFiles[0]
|
||||
});
|
||||
showSuccess('Avatar updated!');
|
||||
return updated;
|
||||
} catch (error) {
|
||||
// Handle server validation errors
|
||||
if (error.response?.data?.avatar) {
|
||||
showError(error.response.data.avatar.message);
|
||||
} else {
|
||||
showError('Upload failed');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Image-specific validation
|
||||
async function validateImage(file, options = {}) {
|
||||
const { minWidth = 0, minHeight = 0, maxWidth = Infinity, maxHeight = Infinity } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const errors = [];
|
||||
|
||||
if (img.width < minWidth || img.height < minHeight) {
|
||||
errors.push(`Image must be at least ${minWidth}x${minHeight}px`);
|
||||
}
|
||||
if (img.width > maxWidth || img.height > maxHeight) {
|
||||
errors.push(`Image must be at most ${maxWidth}x${maxHeight}px`);
|
||||
}
|
||||
|
||||
resolve({ valid: errors.length === 0, errors, width: img.width, height: img.height });
|
||||
};
|
||||
img.onerror = () => resolve({ valid: false, errors: ['Invalid image file'] });
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [PocketBase Files Configuration](https://pocketbase.io/docs/files-handling/)
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
title: Use Back-Relations for Inverse Lookups
|
||||
impact: HIGH
|
||||
impactDescription: Fetch related records without separate queries
|
||||
tags: query, relations, back-relations, expand, inverse
|
||||
---
|
||||
|
||||
## Use Back-Relations for Inverse Lookups
|
||||
|
||||
Back-relations allow you to expand records that reference the current record, enabling inverse lookups in a single request. Use the `collectionName_via_fieldName` syntax.
|
||||
|
||||
**Incorrect (manual inverse lookup):**
|
||||
|
||||
```javascript
|
||||
// Fetching a user, then their posts separately
|
||||
async function getUserWithPosts(userId) {
|
||||
const user = await pb.collection('users').getOne(userId);
|
||||
|
||||
// Extra request for posts
|
||||
const posts = await pb.collection('posts').getList(1, 100, {
|
||||
filter: pb.filter('author = {:userId}', { userId })
|
||||
});
|
||||
|
||||
return { ...user, posts: posts.items };
|
||||
}
|
||||
// 2 API calls
|
||||
|
||||
// Fetching a post, then its comments
|
||||
async function getPostWithComments(postId) {
|
||||
const post = await pb.collection('posts').getOne(postId);
|
||||
const comments = await pb.collection('comments').getFullList({
|
||||
filter: pb.filter('post = {:postId}', { postId }),
|
||||
expand: 'author'
|
||||
});
|
||||
|
||||
return { ...post, comments };
|
||||
}
|
||||
// 2 API calls
|
||||
```
|
||||
|
||||
**Correct (using back-relation expand):**
|
||||
|
||||
```javascript
|
||||
// Expand posts that reference this user
|
||||
// posts collection has: author (relation to users)
|
||||
async function getUserWithPosts(userId) {
|
||||
const user = await pb.collection('users').getOne(userId, {
|
||||
expand: 'posts_via_author' // collectionName_via_fieldName
|
||||
});
|
||||
|
||||
console.log('User:', user.name);
|
||||
console.log('Posts:', user.expand?.posts_via_author);
|
||||
return user;
|
||||
}
|
||||
// 1 API call!
|
||||
|
||||
// Expand comments that reference this post
|
||||
// comments collection has: post (relation to posts)
|
||||
async function getPostWithComments(postId) {
|
||||
const post = await pb.collection('posts').getOne(postId, {
|
||||
expand: 'comments_via_post,comments_via_post.author'
|
||||
});
|
||||
|
||||
const comments = post.expand?.comments_via_post || [];
|
||||
comments.forEach(comment => {
|
||||
console.log(`${comment.expand?.author?.name}: ${comment.content}`);
|
||||
});
|
||||
|
||||
return post;
|
||||
}
|
||||
// 1 API call with nested expansion!
|
||||
|
||||
// Multiple back-relations
|
||||
async function getUserWithAllContent(userId) {
|
||||
const user = await pb.collection('users').getOne(userId, {
|
||||
expand: 'posts_via_author,comments_via_author,likes_via_user'
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
posts: user.expand?.posts_via_author || [],
|
||||
comments: user.expand?.comments_via_author || [],
|
||||
likes: user.expand?.likes_via_user || []
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Back-relation syntax:**
|
||||
|
||||
```
|
||||
{referencing_collection}_via_{relation_field}
|
||||
|
||||
Examples:
|
||||
- posts_via_author -> posts where author = current record
|
||||
- comments_via_post -> comments where post = current record
|
||||
- order_items_via_order -> order_items where order = current record
|
||||
- team_members_via_team -> team_members where team = current record
|
||||
```
|
||||
|
||||
**Nested back-relations:**
|
||||
|
||||
```javascript
|
||||
// Get user with posts and each post's comments
|
||||
const user = await pb.collection('users').getOne(userId, {
|
||||
expand: 'posts_via_author.comments_via_post'
|
||||
});
|
||||
|
||||
// Access nested data
|
||||
const posts = user.expand?.posts_via_author || [];
|
||||
posts.forEach(post => {
|
||||
console.log('Post:', post.title);
|
||||
const comments = post.expand?.comments_via_post || [];
|
||||
comments.forEach(c => console.log(' Comment:', c.content));
|
||||
});
|
||||
```
|
||||
|
||||
**Important considerations:**
|
||||
|
||||
```javascript
|
||||
// Back-relations always return arrays, even if the relation field
|
||||
// is marked as single (maxSelect: 1)
|
||||
|
||||
// Limited to 1000 records per back-relation
|
||||
// For more, use separate paginated query
|
||||
const user = await pb.collection('users').getOne(userId, {
|
||||
expand: 'posts_via_author'
|
||||
});
|
||||
// If user has 1500 posts, only first 1000 are included
|
||||
|
||||
// For large datasets, use paginated approach
|
||||
async function getUserPostsPaginated(userId, page = 1) {
|
||||
return pb.collection('posts').getList(page, 50, {
|
||||
filter: pb.filter('author = {:userId}', { userId }),
|
||||
sort: '-created'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Use in list queries:**
|
||||
|
||||
```javascript
|
||||
// Get all users with their post counts
|
||||
// (Use view collection for actual counts)
|
||||
const users = await pb.collection('users').getList(1, 20, {
|
||||
expand: 'posts_via_author'
|
||||
});
|
||||
|
||||
users.items.forEach(user => {
|
||||
const postCount = user.expand?.posts_via_author?.length || 0;
|
||||
console.log(`${user.name}: ${postCount} posts`);
|
||||
});
|
||||
```
|
||||
|
||||
**When to use back-relations vs separate queries:**
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| < 1000 related records | Back-relation expand |
|
||||
| Need pagination | Separate query with filter |
|
||||
| Need sorting/filtering | Separate query |
|
||||
| Just need count | View collection |
|
||||
| Display in list | Back-relation (if small) |
|
||||
|
||||
Reference: [PocketBase Back-Relations](https://pocketbase.io/docs/working-with-relations/#back-relation-expand)
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Use Batch Operations for Multiple Writes
|
||||
impact: HIGH
|
||||
impactDescription: Atomic transactions, 10x fewer API calls, consistent state
|
||||
tags: query, batch, transactions, performance
|
||||
---
|
||||
|
||||
## Use Batch Operations for Multiple Writes
|
||||
|
||||
Batch operations combine multiple create/update/delete operations into a single atomic transaction. This ensures consistency and dramatically reduces API calls.
|
||||
|
||||
**Incorrect (individual requests):**
|
||||
|
||||
```javascript
|
||||
// Creating multiple records individually
|
||||
async function createOrderWithItems(order, items) {
|
||||
// If any fails, partial data remains!
|
||||
const createdOrder = await pb.collection('orders').create(order);
|
||||
|
||||
for (const item of items) {
|
||||
await pb.collection('order_items').create({
|
||||
...item,
|
||||
order: createdOrder.id
|
||||
});
|
||||
}
|
||||
// 1 + N API calls, not atomic
|
||||
}
|
||||
|
||||
// Updating multiple records
|
||||
async function updatePrices(products) {
|
||||
for (const product of products) {
|
||||
await pb.collection('products').update(product.id, {
|
||||
price: product.newPrice
|
||||
});
|
||||
}
|
||||
// N API calls, some might fail leaving inconsistent state
|
||||
}
|
||||
|
||||
// Mixed operations
|
||||
async function transferFunds(fromId, toId, amount) {
|
||||
// NOT ATOMIC - can leave invalid state!
|
||||
await pb.collection('accounts').update(fromId, { 'balance-': amount });
|
||||
// If this fails, money disappears!
|
||||
await pb.collection('accounts').update(toId, { 'balance+': amount });
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using batch operations):**
|
||||
|
||||
```javascript
|
||||
// Atomic batch create
|
||||
async function createOrderWithItems(order, items) {
|
||||
const batch = pb.createBatch();
|
||||
|
||||
// Pre-generate order ID so items can reference it in the same batch
|
||||
// PocketBase accepts custom IDs (15-char alphanumeric)
|
||||
const orderId = crypto.randomUUID().replaceAll('-', '').slice(0, 15);
|
||||
|
||||
// Queue order creation with known ID
|
||||
batch.collection('orders').create({ ...order, id: orderId });
|
||||
|
||||
// Queue all items referencing the pre-generated order ID
|
||||
items.forEach(item => {
|
||||
batch.collection('order_items').create({
|
||||
...item,
|
||||
order: orderId
|
||||
});
|
||||
});
|
||||
|
||||
// Execute atomically
|
||||
const results = await batch.send();
|
||||
// All succeed or all fail together
|
||||
|
||||
return {
|
||||
order: results[0],
|
||||
items: results.slice(1)
|
||||
};
|
||||
}
|
||||
|
||||
// Batch updates
|
||||
async function updatePrices(products) {
|
||||
const batch = pb.createBatch();
|
||||
|
||||
products.forEach(product => {
|
||||
batch.collection('products').update(product.id, {
|
||||
price: product.newPrice
|
||||
});
|
||||
});
|
||||
|
||||
const results = await batch.send();
|
||||
// 1 API call, atomic
|
||||
return results;
|
||||
}
|
||||
|
||||
// Batch upsert (create or update)
|
||||
async function syncProducts(products) {
|
||||
const batch = pb.createBatch();
|
||||
|
||||
products.forEach(product => {
|
||||
batch.collection('products').upsert({
|
||||
id: product.sku, // Use SKU as ID for upsert matching
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
stock: product.stock
|
||||
});
|
||||
});
|
||||
|
||||
return batch.send();
|
||||
}
|
||||
|
||||
// Mixed operations in transaction
|
||||
// NOTE: Batch operations respect API rules per-operation, but ensure your
|
||||
// business logic validates inputs (e.g., sufficient balance) server-side
|
||||
// via hooks or API rules to prevent unauthorized transfers.
|
||||
async function transferFunds(fromId, toId, amount) {
|
||||
const batch = pb.createBatch();
|
||||
|
||||
batch.collection('accounts').update(fromId, { 'balance-': amount });
|
||||
batch.collection('accounts').update(toId, { 'balance+': amount });
|
||||
|
||||
// Create audit record
|
||||
batch.collection('transfers').create({
|
||||
from: fromId,
|
||||
to: toId,
|
||||
amount,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
// All three operations atomic
|
||||
const [fromAccount, toAccount, transfer] = await batch.send();
|
||||
return { fromAccount, toAccount, transfer };
|
||||
}
|
||||
|
||||
// Batch delete
|
||||
async function deletePostWithComments(postId) {
|
||||
// First get comment IDs
|
||||
const comments = await pb.collection('comments').getFullList({
|
||||
filter: pb.filter('post = {:postId}', { postId }),
|
||||
fields: 'id'
|
||||
});
|
||||
|
||||
const batch = pb.createBatch();
|
||||
|
||||
// Queue all deletions
|
||||
comments.forEach(comment => {
|
||||
batch.collection('comments').delete(comment.id);
|
||||
});
|
||||
batch.collection('posts').delete(postId);
|
||||
|
||||
await batch.send();
|
||||
// Post and all comments deleted atomically
|
||||
}
|
||||
```
|
||||
|
||||
**Batch operation limits:**
|
||||
- **Must be enabled first** in Dashboard > Settings > Application (disabled by default; returns 403 otherwise)
|
||||
- Operations execute in a single database transaction
|
||||
- All succeed or all rollback
|
||||
- Respects API rules for each operation
|
||||
- Configurable limits: `maxRequests`, `timeout`, and `maxBodySize` (set in Dashboard)
|
||||
- **Avoid large file uploads** in batches over slow networks -- they block the entire transaction
|
||||
- Avoid custom hooks that call slow external APIs within batch operations
|
||||
|
||||
**When to use batch:**
|
||||
|
||||
| Scenario | Use Batch? |
|
||||
|----------|-----------|
|
||||
| Creating parent + children | Yes |
|
||||
| Bulk import/update | Yes |
|
||||
| Financial transactions | Yes |
|
||||
| Single record operations | No |
|
||||
| Independent operations | Optional |
|
||||
|
||||
Reference: [PocketBase Batch API](https://pocketbase.io/docs/api-records/#batch-operations)
|
||||
143
.claude/skills/pocketbase-best-practices/rules/query-expand.md
Normal file
143
.claude/skills/pocketbase-best-practices/rules/query-expand.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: Expand Relations Efficiently
|
||||
impact: HIGH
|
||||
impactDescription: Eliminates N+1 queries, reduces API calls by 90%+
|
||||
tags: query, relations, expand, joins, performance
|
||||
---
|
||||
|
||||
## Expand Relations Efficiently
|
||||
|
||||
Use the `expand` parameter to fetch related records in a single request. This eliminates N+1 query problems and dramatically reduces API calls.
|
||||
|
||||
**Incorrect (N+1 queries):**
|
||||
|
||||
```javascript
|
||||
// Fetching posts then authors separately - N+1 problem
|
||||
async function getPostsWithAuthors() {
|
||||
const posts = await pb.collection('posts').getList(1, 20);
|
||||
|
||||
// N additional requests for N posts!
|
||||
for (const post of posts.items) {
|
||||
post.authorData = await pb.collection('users').getOne(post.author);
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
// 21 API calls for 20 posts!
|
||||
|
||||
// Even worse with multiple relations
|
||||
async function getPostsWithAll() {
|
||||
const posts = await pb.collection('posts').getList(1, 20);
|
||||
|
||||
for (const post of posts.items) {
|
||||
post.author = await pb.collection('users').getOne(post.author);
|
||||
post.category = await pb.collection('categories').getOne(post.category);
|
||||
post.tags = await Promise.all(
|
||||
post.tags.map(id => pb.collection('tags').getOne(id))
|
||||
);
|
||||
}
|
||||
// 60+ API calls!
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using expand):**
|
||||
|
||||
```javascript
|
||||
// Single request with expanded relations
|
||||
async function getPostsWithAuthors() {
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
expand: 'author'
|
||||
});
|
||||
|
||||
// Access expanded data
|
||||
posts.items.forEach(post => {
|
||||
console.log('Author:', post.expand?.author?.name);
|
||||
});
|
||||
|
||||
return posts;
|
||||
}
|
||||
// 1 API call!
|
||||
|
||||
// Multiple relations
|
||||
async function getPostsWithAll() {
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
expand: 'author,category,tags'
|
||||
});
|
||||
|
||||
posts.items.forEach(post => {
|
||||
console.log('Author:', post.expand?.author?.name);
|
||||
console.log('Category:', post.expand?.category?.name);
|
||||
console.log('Tags:', post.expand?.tags?.map(t => t.name));
|
||||
});
|
||||
}
|
||||
// Still just 1 API call!
|
||||
|
||||
// Nested expansion (up to 6 levels)
|
||||
async function getPostsWithNestedData() {
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
expand: 'author.profile,category.parent,comments_via_post.author'
|
||||
});
|
||||
|
||||
posts.items.forEach(post => {
|
||||
// Nested relations
|
||||
console.log('Author profile:', post.expand?.author?.expand?.profile);
|
||||
console.log('Parent category:', post.expand?.category?.expand?.parent);
|
||||
|
||||
// Back-relations (comments that reference this post)
|
||||
console.log('Comments:', post.expand?.['comments_via_post']);
|
||||
});
|
||||
}
|
||||
|
||||
// Back-relation expansion
|
||||
// If comments collection has a 'post' relation field pointing to posts
|
||||
async function getPostWithComments(postId) {
|
||||
const post = await pb.collection('posts').getOne(postId, {
|
||||
expand: 'comments_via_post,comments_via_post.author'
|
||||
});
|
||||
|
||||
// Access comments that reference this post
|
||||
const comments = post.expand?.['comments_via_post'] || [];
|
||||
comments.forEach(comment => {
|
||||
console.log(`${comment.expand?.author?.name}: ${comment.text}`);
|
||||
});
|
||||
|
||||
return post;
|
||||
}
|
||||
```
|
||||
|
||||
**Expand syntax:**
|
||||
|
||||
| Syntax | Description |
|
||||
|--------|-------------|
|
||||
| `expand: 'author'` | Single relation |
|
||||
| `expand: 'author,tags'` | Multiple relations |
|
||||
| `expand: 'author.profile'` | Nested relation (2 levels) |
|
||||
| `expand: 'comments_via_post'` | Back-relation (records pointing to this) |
|
||||
|
||||
**Handling optional expand data:**
|
||||
|
||||
```javascript
|
||||
// Always use optional chaining - expand may be undefined
|
||||
const authorName = post.expand?.author?.name || 'Unknown';
|
||||
|
||||
// Type-safe access with TypeScript
|
||||
interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string; // Relation ID
|
||||
expand?: {
|
||||
author?: User;
|
||||
};
|
||||
}
|
||||
|
||||
const posts = await pb.collection('posts').getList<Post>(1, 20, {
|
||||
expand: 'author'
|
||||
});
|
||||
```
|
||||
|
||||
**Limitations:**
|
||||
- Maximum 6 levels of nesting
|
||||
- Respects API rules on expanded collections
|
||||
- Large expansions may impact performance
|
||||
|
||||
Reference: [PocketBase Expand](https://pocketbase.io/docs/api-records/#expand)
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Select Only Required Fields
|
||||
impact: MEDIUM
|
||||
impactDescription: Reduces payload size, improves response time
|
||||
tags: query, fields, performance, bandwidth
|
||||
---
|
||||
|
||||
## Select Only Required Fields
|
||||
|
||||
Use the `fields` parameter to request only the data you need. This reduces bandwidth and can improve query performance, especially with large text or file fields.
|
||||
|
||||
**Incorrect (fetching everything):**
|
||||
|
||||
```javascript
|
||||
// Fetching all fields when only a few are needed
|
||||
const posts = await pb.collection('posts').getList(1, 20);
|
||||
// Returns: id, title, content (10KB), thumbnail, author, tags, created, updated...
|
||||
|
||||
// Only displaying titles in a list
|
||||
posts.items.forEach(post => {
|
||||
renderListItem(post.title); // Only using title!
|
||||
});
|
||||
// Wasted bandwidth on content, thumbnail URLs, etc.
|
||||
|
||||
// Fetching user data with large profile fields
|
||||
const users = await pb.collection('users').getFullList();
|
||||
// Includes: avatar (file), bio (text), settings (json)...
|
||||
// When you only need names for a dropdown
|
||||
```
|
||||
|
||||
**Correct (selecting specific fields):**
|
||||
|
||||
```javascript
|
||||
// Select only needed fields
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
fields: 'id,title,created'
|
||||
});
|
||||
// Returns only: { id, title, created }
|
||||
|
||||
// For a dropdown/autocomplete
|
||||
const users = await pb.collection('users').getFullList({
|
||||
fields: 'id,name,avatar'
|
||||
});
|
||||
|
||||
// Include expanded relation fields
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
expand: 'author',
|
||||
fields: 'id,title,expand.author.name,expand.author.avatar'
|
||||
});
|
||||
// Returns: { id, title, expand: { author: { name, avatar } } }
|
||||
|
||||
// Wildcard for all direct fields, specific for expand
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
expand: 'author,category',
|
||||
fields: '*,expand.author.name,expand.category.name'
|
||||
});
|
||||
// All post fields + only name from expanded relations
|
||||
```
|
||||
|
||||
**Using excerpt modifier:**
|
||||
|
||||
```javascript
|
||||
// Get truncated text content
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
fields: 'id,title,content:excerpt(200,true)'
|
||||
});
|
||||
// content is truncated to 200 chars with "..." appended
|
||||
|
||||
// Multiple excerpts
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
fields: 'id,title:excerpt(50),content:excerpt(150,true)'
|
||||
});
|
||||
|
||||
// Excerpt syntax: field:excerpt(maxLength, withEllipsis?)
|
||||
// - maxLength: maximum characters
|
||||
// - withEllipsis: append "..." if truncated (default: false)
|
||||
```
|
||||
|
||||
**Common field selection patterns:**
|
||||
|
||||
```javascript
|
||||
// List view - minimal data
|
||||
const listFields = 'id,title,thumbnail,author,created';
|
||||
|
||||
// Card view - slightly more
|
||||
const cardFields = 'id,title,content:excerpt(200,true),thumbnail,author,created';
|
||||
|
||||
// Detail view - most fields
|
||||
const detailFields = '*,expand.author.name,expand.author.avatar';
|
||||
|
||||
// Autocomplete - just id and display text
|
||||
const autocompleteFields = 'id,name';
|
||||
|
||||
// Table export - specific columns
|
||||
const exportFields = 'id,email,name,created,status';
|
||||
|
||||
// Usage
|
||||
async function getPostsList() {
|
||||
return pb.collection('posts').getList(1, 20, {
|
||||
fields: listFields,
|
||||
expand: 'author'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
| Field Type | Impact of Selecting |
|
||||
|------------|-------------------|
|
||||
| text/editor | High (can be large) |
|
||||
| file | Medium (URLs generated) |
|
||||
| json | Medium (can be large) |
|
||||
| relation | Low (just IDs) |
|
||||
| number/bool | Low |
|
||||
|
||||
**Note:** Field selection happens after data is fetched from database, so it primarily saves bandwidth, not database queries. For database-level optimization, ensure proper indexes.
|
||||
|
||||
Reference: [PocketBase Fields Parameter](https://pocketbase.io/docs/api-records/#fields)
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Use getFirstListItem for Single Record Lookups
|
||||
impact: MEDIUM
|
||||
impactDescription: Cleaner code, automatic error handling for not found
|
||||
tags: query, lookup, find, getFirstListItem
|
||||
---
|
||||
|
||||
## Use getFirstListItem for Single Record Lookups
|
||||
|
||||
Use `getFirstListItem()` when you need to find a single record by a field value other than ID. It's cleaner than `getList()` with limit 1 and provides proper error handling.
|
||||
|
||||
**Incorrect (manual single-record lookup):**
|
||||
|
||||
```javascript
|
||||
// Using getList with limit 1 - verbose
|
||||
async function findUserByEmail(email) {
|
||||
const result = await pb.collection('users').getList(1, 1, {
|
||||
filter: pb.filter('email = {:email}', { email })
|
||||
});
|
||||
|
||||
if (result.items.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return result.items[0];
|
||||
}
|
||||
|
||||
// Using getFullList then filtering - wasteful
|
||||
async function findUserByUsername(username) {
|
||||
const users = await pb.collection('users').getFullList({
|
||||
filter: pb.filter('username = {:username}', { username })
|
||||
});
|
||||
return users[0]; // Might be undefined!
|
||||
}
|
||||
|
||||
// Fetching by ID when you have a different identifier
|
||||
async function findProductBySku(sku) {
|
||||
// Wrong: getOne expects the record ID
|
||||
const product = await pb.collection('products').getOne(sku); // Fails!
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using getFirstListItem):**
|
||||
|
||||
```javascript
|
||||
// Clean single-record lookup by any field
|
||||
async function findUserByEmail(email) {
|
||||
try {
|
||||
const user = await pb.collection('users').getFirstListItem(
|
||||
pb.filter('email = {:email}', { email })
|
||||
);
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
return null; // Not found
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup by unique field
|
||||
async function findProductBySku(sku) {
|
||||
return pb.collection('products').getFirstListItem(
|
||||
pb.filter('sku = {:sku}', { sku })
|
||||
);
|
||||
}
|
||||
|
||||
// Lookup with expand
|
||||
async function findOrderByNumber(orderNumber) {
|
||||
return pb.collection('orders').getFirstListItem(
|
||||
pb.filter('orderNumber = {:num}', { num: orderNumber }),
|
||||
{ expand: 'customer,items' }
|
||||
);
|
||||
}
|
||||
|
||||
// Complex filter conditions
|
||||
async function findActiveSubscription(userId) {
|
||||
return pb.collection('subscriptions').getFirstListItem(
|
||||
pb.filter(
|
||||
'user = {:userId} && status = "active" && expiresAt > @now',
|
||||
{ userId }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// With field selection
|
||||
async function getUserIdByEmail(email) {
|
||||
const user = await pb.collection('users').getFirstListItem(
|
||||
pb.filter('email = {:email}', { email }),
|
||||
{ fields: 'id' }
|
||||
);
|
||||
return user.id;
|
||||
}
|
||||
```
|
||||
|
||||
**Comparison with getOne:**
|
||||
|
||||
```javascript
|
||||
// getOne - fetch by record ID
|
||||
const post = await pb.collection('posts').getOne('abc123');
|
||||
|
||||
// getFirstListItem - fetch by any filter (use pb.filter for safe binding)
|
||||
const post = await pb.collection('posts').getFirstListItem(
|
||||
pb.filter('slug = {:slug}', { slug: 'hello-world' })
|
||||
);
|
||||
const user = await pb.collection('users').getFirstListItem(
|
||||
pb.filter('username = {:name}', { name: 'john' })
|
||||
);
|
||||
const order = await pb.collection('orders').getFirstListItem(
|
||||
pb.filter('orderNumber = {:num}', { num: 12345 })
|
||||
);
|
||||
```
|
||||
|
||||
**Error handling:**
|
||||
|
||||
```javascript
|
||||
// getFirstListItem throws 404 if no match found
|
||||
try {
|
||||
const user = await pb.collection('users').getFirstListItem(
|
||||
pb.filter('email = {:email}', { email })
|
||||
);
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
// No matching record - handle appropriately
|
||||
return null;
|
||||
}
|
||||
// Other error (network, auth, etc.)
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wrapper function for optional lookup
|
||||
async function findFirst(collection, filter, options = {}) {
|
||||
try {
|
||||
return await pb.collection(collection).getFirstListItem(filter, options);
|
||||
} catch (error) {
|
||||
if (error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const user = await findFirst('users', pb.filter('email = {:e}', { e: email }));
|
||||
if (!user) {
|
||||
console.log('User not found');
|
||||
}
|
||||
```
|
||||
|
||||
**When to use each method:**
|
||||
|
||||
| Method | Use When |
|
||||
|--------|----------|
|
||||
| `getOne(id)` | You have the record ID |
|
||||
| `getFirstListItem(filter)` | Finding by unique field (email, slug, sku) |
|
||||
| `getList(1, 1, { filter })` | Need pagination metadata |
|
||||
| `getFullList({ filter })` | Expecting multiple results |
|
||||
|
||||
Reference: [PocketBase Records API](https://pocketbase.io/docs/api-records/)
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Prevent N+1 Query Problems
|
||||
impact: HIGH
|
||||
impactDescription: Reduces API calls from N+1 to 1-2, dramatically faster page loads
|
||||
tags: query, performance, n-plus-one, optimization
|
||||
---
|
||||
|
||||
## Prevent N+1 Query Problems
|
||||
|
||||
N+1 queries occur when you fetch a list of records, then make additional requests for each record's related data. This pattern causes severe performance issues at scale.
|
||||
|
||||
**Incorrect (N+1 patterns):**
|
||||
|
||||
```javascript
|
||||
// Classic N+1: fetching related data in a loop
|
||||
async function getPostsWithDetails() {
|
||||
const posts = await pb.collection('posts').getList(1, 20); // 1 query
|
||||
|
||||
for (const post of posts.items) {
|
||||
// N additional queries!
|
||||
post.author = await pb.collection('users').getOne(post.author);
|
||||
post.category = await pb.collection('categories').getOne(post.category);
|
||||
}
|
||||
// Total: 1 + 20 + 20 = 41 queries for 20 posts
|
||||
}
|
||||
|
||||
// N+1 with Promise.all (faster but still N+1)
|
||||
async function getPostsParallel() {
|
||||
const posts = await pb.collection('posts').getList(1, 20);
|
||||
|
||||
await Promise.all(posts.items.map(async post => {
|
||||
post.author = await pb.collection('users').getOne(post.author);
|
||||
}));
|
||||
// Still 21 API calls, just parallel
|
||||
}
|
||||
|
||||
// Hidden N+1 in rendering
|
||||
function PostList({ posts }) {
|
||||
return posts.map(post => (
|
||||
<PostCard
|
||||
post={post}
|
||||
author={useAuthor(post.author)} // Each triggers a fetch!
|
||||
/>
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (eliminate N+1):**
|
||||
|
||||
```javascript
|
||||
// Solution 1: Use expand for relations
|
||||
async function getPostsWithDetails() {
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
expand: 'author,category,tags'
|
||||
});
|
||||
|
||||
// All data in one request
|
||||
posts.items.forEach(post => {
|
||||
console.log(post.expand?.author?.name);
|
||||
console.log(post.expand?.category?.name);
|
||||
});
|
||||
// Total: 1 query
|
||||
}
|
||||
|
||||
// Solution 2: Batch fetch related records
|
||||
async function getPostsWithAuthorsBatch() {
|
||||
const posts = await pb.collection('posts').getList(1, 20);
|
||||
|
||||
// Collect unique author IDs
|
||||
const authorIds = [...new Set(posts.items.map(p => p.author))];
|
||||
|
||||
// Single query for all authors (use pb.filter for safe binding)
|
||||
const filter = authorIds.map(id => pb.filter('id = {:id}', { id })).join(' || ');
|
||||
const authors = await pb.collection('users').getList(1, authorIds.length, {
|
||||
filter
|
||||
});
|
||||
|
||||
// Create lookup map
|
||||
const authorMap = Object.fromEntries(
|
||||
authors.items.map(a => [a.id, a])
|
||||
);
|
||||
|
||||
// Attach to posts
|
||||
posts.items.forEach(post => {
|
||||
post.authorData = authorMap[post.author];
|
||||
});
|
||||
// Total: 2 queries regardless of post count
|
||||
}
|
||||
|
||||
// Solution 3: Use view collection for complex joins
|
||||
// Create a view that joins posts with authors:
|
||||
// SELECT p.*, u.name as author_name, u.avatar as author_avatar
|
||||
// FROM posts p LEFT JOIN users u ON p.author = u.id
|
||||
|
||||
async function getPostsFromView() {
|
||||
const posts = await pb.collection('posts_with_authors').getList(1, 20);
|
||||
// Single query, data already joined
|
||||
}
|
||||
|
||||
// Solution 4: Back-relations with expand
|
||||
async function getUserWithPosts(userId) {
|
||||
const user = await pb.collection('users').getOne(userId, {
|
||||
expand: 'posts_via_author' // All posts by this user
|
||||
});
|
||||
|
||||
console.log('Posts by user:', user.expand?.posts_via_author);
|
||||
// 1 query gets user + all their posts
|
||||
}
|
||||
```
|
||||
|
||||
**Detecting N+1 in your code:**
|
||||
|
||||
```javascript
|
||||
// Add request logging to detect N+1
|
||||
let requestCount = 0;
|
||||
pb.beforeSend = (url, options) => {
|
||||
requestCount++;
|
||||
console.log(`Request #${requestCount}: ${options.method} ${url}`);
|
||||
return { url, options };
|
||||
};
|
||||
|
||||
// Monitor during development
|
||||
async function loadPage() {
|
||||
requestCount = 0;
|
||||
await loadAllData();
|
||||
console.log(`Total requests: ${requestCount}`);
|
||||
// If this is >> number of records, you have N+1
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention checklist:**
|
||||
- [ ] Always use `expand` for displaying related data
|
||||
- [ ] Never fetch related records in loops
|
||||
- [ ] Batch fetch when expand isn't available
|
||||
- [ ] Consider view collections for complex joins
|
||||
- [ ] Monitor request counts during development
|
||||
|
||||
Reference: [PocketBase Expand](https://pocketbase.io/docs/api-records/#expand)
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: Use Efficient Pagination Strategies
|
||||
impact: HIGH
|
||||
impactDescription: 10-100x faster list queries on large collections
|
||||
tags: query, pagination, performance, lists
|
||||
---
|
||||
|
||||
## Use Efficient Pagination Strategies
|
||||
|
||||
Pagination impacts performance significantly. Use `skipTotal` for large datasets, cursor-based pagination for infinite scroll, and appropriate page sizes.
|
||||
|
||||
**Incorrect (inefficient pagination):**
|
||||
|
||||
```javascript
|
||||
// Fetching all records - memory and performance disaster
|
||||
const allPosts = await pb.collection('posts').getFullList();
|
||||
// Downloads entire table, crashes on large datasets
|
||||
|
||||
// Default pagination without skipTotal
|
||||
const posts = await pb.collection('posts').getList(100, 20);
|
||||
// COUNT(*) runs on every request - slow on large tables
|
||||
|
||||
// Using offset for infinite scroll
|
||||
async function loadMore(page) {
|
||||
// As page increases, offset queries get slower
|
||||
return pb.collection('posts').getList(page, 20);
|
||||
// Page 1000: skips 19,980 rows before returning 20
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (optimized pagination):**
|
||||
|
||||
```javascript
|
||||
// Use skipTotal for better performance on large collections
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
skipTotal: true, // Skip COUNT(*) query
|
||||
sort: '-created'
|
||||
});
|
||||
// Returns items without totalItems/totalPages (faster)
|
||||
|
||||
// Cursor-based pagination for infinite scroll
|
||||
async function loadMorePosts(lastCreated = null) {
|
||||
const filter = lastCreated
|
||||
? pb.filter('created < {:cursor}', { cursor: lastCreated })
|
||||
: '';
|
||||
|
||||
const result = await pb.collection('posts').getList(1, 20, {
|
||||
filter,
|
||||
sort: '-created',
|
||||
skipTotal: true
|
||||
});
|
||||
|
||||
// Next cursor is the last item's created date
|
||||
const nextCursor = result.items.length > 0
|
||||
? result.items[result.items.length - 1].created
|
||||
: null;
|
||||
|
||||
return { items: result.items, nextCursor };
|
||||
}
|
||||
|
||||
// Usage for infinite scroll
|
||||
let cursor = null;
|
||||
async function loadNextPage() {
|
||||
const { items, nextCursor } = await loadMorePosts(cursor);
|
||||
cursor = nextCursor;
|
||||
appendToList(items);
|
||||
}
|
||||
|
||||
// Batched fetching when you need all records
|
||||
async function getAllPostsEfficiently() {
|
||||
const allPosts = [];
|
||||
let page = 1;
|
||||
const perPage = 1000; // Larger batches = fewer requests (max 1000 per API limit)
|
||||
|
||||
while (true) {
|
||||
const result = await pb.collection('posts').getList(page, perPage, {
|
||||
skipTotal: true
|
||||
});
|
||||
|
||||
allPosts.push(...result.items);
|
||||
|
||||
if (result.items.length < perPage) {
|
||||
break; // No more records
|
||||
}
|
||||
page++;
|
||||
}
|
||||
|
||||
return allPosts;
|
||||
}
|
||||
|
||||
// Or use getFullList with batch option
|
||||
const allPosts = await pb.collection('posts').getFullList({
|
||||
batch: 1000, // Records per request (default 1000 since JS SDK v0.26.6; max 1000)
|
||||
sort: '-created'
|
||||
});
|
||||
```
|
||||
|
||||
**Choose the right approach:**
|
||||
|
||||
| Use Case | Approach |
|
||||
|----------|----------|
|
||||
| Standard list with page numbers | `getList()` with page/perPage |
|
||||
| Large dataset, no total needed | `getList()` with `skipTotal: true` |
|
||||
| Infinite scroll | Cursor-based with `skipTotal: true` |
|
||||
| Export all data | `getFullList()` with batch size |
|
||||
| First N records only | `getList(1, N, { skipTotal: true })` |
|
||||
|
||||
**Performance tips:**
|
||||
- Use `skipTotal: true` unless you need page count
|
||||
- Keep `perPage` reasonable (20-100 for UI, up to 1000 for batch exports)
|
||||
- Index fields used in sort and filter
|
||||
- Cursor pagination scales better than offset
|
||||
|
||||
Reference: [PocketBase Records API](https://pocketbase.io/docs/api-records/)
|
||||
147
.claude/skills/pocketbase-best-practices/rules/realtime-auth.md
Normal file
147
.claude/skills/pocketbase-best-practices/rules/realtime-auth.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Authenticate Realtime Connections
|
||||
impact: MEDIUM
|
||||
impactDescription: Secure subscriptions respecting API rules
|
||||
tags: realtime, authentication, security, subscriptions
|
||||
---
|
||||
|
||||
## Authenticate Realtime Connections
|
||||
|
||||
Realtime subscriptions respect collection API rules. Ensure the connection is authenticated before subscribing to protected data.
|
||||
|
||||
**Incorrect (subscribing without auth context):**
|
||||
|
||||
```javascript
|
||||
// Subscribing before authentication
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// This will fail or return no data if collection requires auth
|
||||
pb.collection('private_messages').subscribe('*', (e) => {
|
||||
// Won't receive events - not authenticated!
|
||||
console.log(e.record);
|
||||
});
|
||||
|
||||
// Later user logs in, but subscription doesn't update
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
// Existing subscription still unauthenticated!
|
||||
```
|
||||
|
||||
**Correct (authenticated subscriptions):**
|
||||
|
||||
```javascript
|
||||
// Subscribe after authentication
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
async function initRealtime() {
|
||||
// First authenticate
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
// Now subscribe - will use auth context
|
||||
pb.collection('private_messages').subscribe('*', (e) => {
|
||||
// Receives events for messages user can access
|
||||
console.log('New message:', e.record);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-subscribe after auth changes
|
||||
function useAuthenticatedRealtime() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const unsubRef = useRef(null);
|
||||
|
||||
// Watch auth changes
|
||||
useEffect(() => {
|
||||
const removeListener = pb.authStore.onChange((token, record) => {
|
||||
// Unsubscribe old connection
|
||||
if (unsubRef.current) {
|
||||
unsubRef.current();
|
||||
unsubRef.current = null;
|
||||
}
|
||||
|
||||
// Re-subscribe with new auth context if logged in
|
||||
if (record) {
|
||||
setupSubscription();
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
}, true);
|
||||
|
||||
return () => {
|
||||
removeListener();
|
||||
if (unsubRef.current) unsubRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function setupSubscription() {
|
||||
unsubRef.current = await pb.collection('private_messages').subscribe('*', (e) => {
|
||||
handleMessage(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auth token refresh with realtime
|
||||
pb.realtime.subscribe('PB_CONNECT', async (e) => {
|
||||
console.log('Realtime connected');
|
||||
|
||||
// Verify auth is still valid
|
||||
if (pb.authStore.isValid) {
|
||||
try {
|
||||
await pb.collection('users').authRefresh();
|
||||
} catch {
|
||||
pb.authStore.clear();
|
||||
// Redirect to login
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**API rules apply to subscriptions:**
|
||||
|
||||
```javascript
|
||||
// Collection rule: listRule: 'owner = @request.auth.id'
|
||||
|
||||
// User A subscribed
|
||||
await pb.collection('users').authWithPassword('a@test.com', 'password');
|
||||
pb.collection('notes').subscribe('*', handler);
|
||||
// Only receives events for notes where owner = User A
|
||||
|
||||
// Events from other users' notes are filtered out automatically
|
||||
```
|
||||
|
||||
**Subscription authorization flow:**
|
||||
|
||||
1. SSE connection established (no auth check)
|
||||
2. First subscription triggers authorization
|
||||
3. Auth token from `pb.authStore` is used
|
||||
4. Collection rules evaluated for each event
|
||||
5. Only matching events sent to client
|
||||
|
||||
**Handling auth expiration:**
|
||||
|
||||
```javascript
|
||||
// Setup disconnect handler
|
||||
pb.realtime.onDisconnect = (subscriptions) => {
|
||||
console.log('Disconnected, had subscriptions:', subscriptions);
|
||||
|
||||
// Check if auth expired
|
||||
if (!pb.authStore.isValid) {
|
||||
// Token expired - need to re-authenticate
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection issue - realtime will auto-reconnect
|
||||
// Re-subscribe after reconnection
|
||||
pb.realtime.subscribe('PB_CONNECT', () => {
|
||||
resubscribeAll(subscriptions);
|
||||
});
|
||||
};
|
||||
|
||||
function resubscribeAll(subscriptions) {
|
||||
subscriptions.forEach(sub => {
|
||||
const [collection, topic] = sub.split('/');
|
||||
pb.collection(collection).subscribe(topic, handlers[sub]);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [PocketBase Realtime Auth](https://pocketbase.io/docs/api-realtime/)
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
title: Handle Realtime Events Properly
|
||||
impact: MEDIUM
|
||||
impactDescription: Consistent UI state, proper optimistic updates
|
||||
tags: realtime, events, state-management, ui
|
||||
---
|
||||
|
||||
## Handle Realtime Events Properly
|
||||
|
||||
Realtime events should update local state correctly, handle edge cases, and maintain UI consistency.
|
||||
|
||||
**Incorrect (naive event handling):**
|
||||
|
||||
```javascript
|
||||
// Blindly appending creates - may add duplicates
|
||||
pb.collection('posts').subscribe('*', (e) => {
|
||||
if (e.action === 'create') {
|
||||
posts.push(e.record); // Might already exist from optimistic update!
|
||||
}
|
||||
});
|
||||
|
||||
// Not handling own actions
|
||||
pb.collection('posts').subscribe('*', (e) => {
|
||||
// User creates post -> optimistic update
|
||||
// Realtime event arrives -> duplicate!
|
||||
setPosts(prev => [...prev, e.record]);
|
||||
});
|
||||
|
||||
// Missing action types
|
||||
pb.collection('posts').subscribe('*', (e) => {
|
||||
if (e.action === 'create') handleCreate(e);
|
||||
// Ignoring update and delete!
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (robust event handling):**
|
||||
|
||||
```javascript
|
||||
// Handle all action types with deduplication
|
||||
function useRealtimePosts() {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const pendingCreates = useRef(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
|
||||
const unsub = pb.collection('posts').subscribe('*', (e) => {
|
||||
switch (e.action) {
|
||||
case 'create':
|
||||
// Skip if we created it (optimistic update already applied)
|
||||
if (pendingCreates.current.has(e.record.id)) {
|
||||
pendingCreates.current.delete(e.record.id);
|
||||
return;
|
||||
}
|
||||
setPosts(prev => {
|
||||
// Deduplicate - might already exist
|
||||
if (prev.some(p => p.id === e.record.id)) return prev;
|
||||
return [e.record, ...prev];
|
||||
});
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
setPosts(prev => prev.map(p =>
|
||||
p.id === e.record.id ? e.record : p
|
||||
));
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
setPosts(prev => prev.filter(p => p.id !== e.record.id));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
async function createPost(data) {
|
||||
// Optimistic update
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const optimisticPost = { ...data, id: tempId };
|
||||
setPosts(prev => [optimisticPost, ...prev]);
|
||||
|
||||
try {
|
||||
const created = await pb.collection('posts').create(data);
|
||||
// Mark as pending so realtime event is ignored
|
||||
pendingCreates.current.add(created.id);
|
||||
// Replace optimistic with real
|
||||
setPosts(prev => prev.map(p =>
|
||||
p.id === tempId ? created : p
|
||||
));
|
||||
return created;
|
||||
} catch (error) {
|
||||
// Rollback optimistic update
|
||||
setPosts(prev => prev.filter(p => p.id !== tempId));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { posts, createPost };
|
||||
}
|
||||
|
||||
// Batched updates for high-frequency changes
|
||||
function useRealtimeWithBatching() {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const pendingUpdates = useRef([]);
|
||||
const flushTimeout = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = pb.collection('posts').subscribe('*', (e) => {
|
||||
pendingUpdates.current.push(e);
|
||||
|
||||
// Batch updates every 100ms
|
||||
if (!flushTimeout.current) {
|
||||
flushTimeout.current = setTimeout(() => {
|
||||
flushUpdates();
|
||||
flushTimeout.current = null;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
if (flushTimeout.current) clearTimeout(flushTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function flushUpdates() {
|
||||
const updates = pendingUpdates.current;
|
||||
pendingUpdates.current = [];
|
||||
|
||||
setPosts(prev => {
|
||||
let next = [...prev];
|
||||
for (const e of updates) {
|
||||
if (e.action === 'create') {
|
||||
if (!next.some(p => p.id === e.record.id)) {
|
||||
next.unshift(e.record);
|
||||
}
|
||||
} else if (e.action === 'update') {
|
||||
next = next.map(p => p.id === e.record.id ? e.record : p);
|
||||
} else if (e.action === 'delete') {
|
||||
next = next.filter(p => p.id !== e.record.id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Filtering events:**
|
||||
|
||||
```javascript
|
||||
// Only handle events matching certain criteria
|
||||
pb.collection('posts').subscribe('*', (e) => {
|
||||
// Only published posts
|
||||
if (e.record.status !== 'published') return;
|
||||
|
||||
// Only posts by current user
|
||||
if (e.record.author !== pb.authStore.record?.id) return;
|
||||
|
||||
handleEvent(e);
|
||||
});
|
||||
|
||||
// Subscribe with expand to get related data
|
||||
pb.collection('posts').subscribe('*', (e) => {
|
||||
// Note: expand data is included in realtime events
|
||||
// if the subscription options include expand
|
||||
console.log(e.record.expand?.author?.name);
|
||||
}, { expand: 'author' });
|
||||
```
|
||||
|
||||
Reference: [PocketBase Realtime Events](https://pocketbase.io/docs/api-realtime/)
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
title: Handle Realtime Connection Issues
|
||||
impact: MEDIUM
|
||||
impactDescription: Reliable realtime even with network interruptions
|
||||
tags: realtime, reconnection, resilience, offline
|
||||
---
|
||||
|
||||
## Handle Realtime Connection Issues
|
||||
|
||||
Realtime connections can disconnect due to network issues or server restarts. Implement proper reconnection handling and state synchronization.
|
||||
|
||||
**Incorrect (ignoring connection issues):**
|
||||
|
||||
```javascript
|
||||
// No reconnection handling - stale data after disconnect
|
||||
pb.collection('posts').subscribe('*', (e) => {
|
||||
updateUI(e.record);
|
||||
});
|
||||
// If connection drops, UI shows stale data indefinitely
|
||||
|
||||
// Assuming connection is always stable
|
||||
function PostList() {
|
||||
useEffect(() => {
|
||||
pb.collection('posts').subscribe('*', handleChange);
|
||||
}, []);
|
||||
// No awareness of connection state
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (robust connection handling):**
|
||||
|
||||
```javascript
|
||||
// Monitor connection state
|
||||
function useRealtimeConnection() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [lastSync, setLastSync] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Track connection state
|
||||
const unsubConnect = pb.realtime.subscribe('PB_CONNECT', (e) => {
|
||||
console.log('Connected, client ID:', e.clientId);
|
||||
setConnected(true);
|
||||
|
||||
// Re-sync data after reconnection
|
||||
if (lastSync) {
|
||||
syncMissedUpdates(lastSync);
|
||||
}
|
||||
setLastSync(new Date());
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
pb.realtime.onDisconnect = (activeSubscriptions) => {
|
||||
console.log('Disconnected');
|
||||
setConnected(false);
|
||||
showOfflineIndicator();
|
||||
};
|
||||
|
||||
return () => {
|
||||
unsubConnect();
|
||||
};
|
||||
}, [lastSync]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
|
||||
// Sync missed updates after reconnection
|
||||
async function syncMissedUpdates(since) {
|
||||
// Fetch records modified since last sync
|
||||
const updatedPosts = await pb.collection('posts').getList(1, 100, {
|
||||
filter: pb.filter('updated > {:since}', { since }),
|
||||
sort: '-updated'
|
||||
});
|
||||
|
||||
// Merge with local state
|
||||
updateLocalState(updatedPosts.items);
|
||||
}
|
||||
|
||||
// Full implementation with resilience
|
||||
class RealtimeManager {
|
||||
constructor(pb) {
|
||||
this.pb = pb;
|
||||
this.subscriptions = new Map();
|
||||
this.lastSyncTimes = new Map();
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectDelay = 30000;
|
||||
|
||||
this.setupConnectionHandlers();
|
||||
}
|
||||
|
||||
setupConnectionHandlers() {
|
||||
this.pb.realtime.subscribe('PB_CONNECT', () => {
|
||||
console.log('Realtime connected');
|
||||
this.reconnectAttempts = 0;
|
||||
this.onReconnect();
|
||||
});
|
||||
|
||||
this.pb.realtime.onDisconnect = (subs) => {
|
||||
console.log('Realtime disconnected');
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
// Exponential backoff with jitter
|
||||
const delay = Math.min(
|
||||
1000 * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
this.reconnectAttempts++;
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.pb.realtime.isConnected) {
|
||||
this.resubscribeAll();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async onReconnect() {
|
||||
// Sync data for each tracked collection
|
||||
for (const [collection, lastSync] of this.lastSyncTimes) {
|
||||
await this.syncCollection(collection, lastSync);
|
||||
}
|
||||
}
|
||||
|
||||
async syncCollection(collection, since) {
|
||||
try {
|
||||
const updates = await this.pb.collection(collection).getList(1, 1000, {
|
||||
filter: this.pb.filter('updated > {:since}', { since }),
|
||||
sort: 'updated'
|
||||
});
|
||||
|
||||
// Notify subscribers of missed updates
|
||||
const handler = this.subscriptions.get(collection);
|
||||
if (handler) {
|
||||
updates.items.forEach(record => {
|
||||
handler({ action: 'update', record });
|
||||
});
|
||||
}
|
||||
|
||||
this.lastSyncTimes.set(collection, new Date());
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync ${collection}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe(collection, handler) {
|
||||
this.subscriptions.set(collection, handler);
|
||||
this.lastSyncTimes.set(collection, new Date());
|
||||
|
||||
return this.pb.collection(collection).subscribe('*', (e) => {
|
||||
this.lastSyncTimes.set(collection, new Date());
|
||||
handler(e);
|
||||
});
|
||||
}
|
||||
|
||||
async resubscribeAll() {
|
||||
// Refresh auth token before resubscribing to ensure valid credentials
|
||||
if (this.pb.authStore.isValid) {
|
||||
try {
|
||||
await this.pb.collection('users').authRefresh();
|
||||
} catch {
|
||||
this.pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
for (const [collection, handler] of this.subscriptions) {
|
||||
this.pb.collection(collection).subscribe('*', handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const realtime = new RealtimeManager(pb);
|
||||
await realtime.subscribe('posts', handlePostChange);
|
||||
```
|
||||
|
||||
**Connection timeout handling:**
|
||||
|
||||
```javascript
|
||||
// Server sends disconnect after 5 min of no messages
|
||||
// SDK auto-reconnects, but you can handle it explicitly
|
||||
|
||||
let lastHeartbeat = Date.now();
|
||||
|
||||
pb.realtime.subscribe('PB_CONNECT', () => {
|
||||
lastHeartbeat = Date.now();
|
||||
});
|
||||
|
||||
// Check for stale connection
|
||||
setInterval(() => {
|
||||
if (Date.now() - lastHeartbeat > 6 * 60 * 1000) {
|
||||
console.log('Connection may be stale, refreshing...');
|
||||
pb.realtime.unsubscribe();
|
||||
resubscribeAll();
|
||||
}
|
||||
}, 60000);
|
||||
```
|
||||
|
||||
Reference: [PocketBase Realtime](https://pocketbase.io/docs/api-realtime/)
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
title: Implement Realtime Subscriptions Correctly
|
||||
impact: MEDIUM
|
||||
impactDescription: Live updates without polling, reduced server load
|
||||
tags: realtime, subscriptions, sse, websocket
|
||||
---
|
||||
|
||||
## Implement Realtime Subscriptions Correctly
|
||||
|
||||
PocketBase uses Server-Sent Events (SSE) for realtime updates. Proper subscription management prevents memory leaks and ensures reliable event delivery.
|
||||
|
||||
**Incorrect (memory leaks and poor management):**
|
||||
|
||||
```javascript
|
||||
// Missing unsubscribe - memory leak!
|
||||
function PostList() {
|
||||
useEffect(() => {
|
||||
pb.collection('posts').subscribe('*', (e) => {
|
||||
updatePosts(e);
|
||||
});
|
||||
// No cleanup - subscription persists forever!
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Subscribing multiple times
|
||||
function loadPosts() {
|
||||
// Called on every render - creates duplicate subscriptions!
|
||||
pb.collection('posts').subscribe('*', handleChange);
|
||||
}
|
||||
|
||||
// Not handling reconnection
|
||||
pb.collection('posts').subscribe('*', (e) => {
|
||||
// Assumes connection is always stable
|
||||
updateUI(e);
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (proper subscription management):**
|
||||
|
||||
```javascript
|
||||
// React example with cleanup
|
||||
function PostList() {
|
||||
const [posts, setPosts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial load
|
||||
loadPosts();
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = pb.collection('posts').subscribe('*', (e) => {
|
||||
if (e.action === 'create') {
|
||||
setPosts(prev => [e.record, ...prev]);
|
||||
} else if (e.action === 'update') {
|
||||
setPosts(prev => prev.map(p =>
|
||||
p.id === e.record.id ? e.record : p
|
||||
));
|
||||
} else if (e.action === 'delete') {
|
||||
setPosts(prev => prev.filter(p => p.id !== e.record.id));
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function loadPosts() {
|
||||
const result = await pb.collection('posts').getList(1, 50);
|
||||
setPosts(result.items);
|
||||
}
|
||||
|
||||
return <PostListUI posts={posts} />;
|
||||
}
|
||||
|
||||
// Subscribe to specific record
|
||||
async function watchPost(postId) {
|
||||
return pb.collection('posts').subscribe(postId, (e) => {
|
||||
console.log('Post changed:', e.action, e.record);
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to collection changes
|
||||
async function watchAllPosts() {
|
||||
return pb.collection('posts').subscribe('*', (e) => {
|
||||
console.log(`Post ${e.action}:`, e.record.title);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle connection events
|
||||
pb.realtime.subscribe('PB_CONNECT', (e) => {
|
||||
console.log('Realtime connected, client ID:', e.clientId);
|
||||
// Re-sync data after reconnection
|
||||
refreshData();
|
||||
});
|
||||
|
||||
// Vanilla JS with proper cleanup
|
||||
class PostManager {
|
||||
unsubscribes = [];
|
||||
|
||||
async init() {
|
||||
this.unsubscribes.push(
|
||||
await pb.collection('posts').subscribe('*', this.handlePostChange)
|
||||
);
|
||||
this.unsubscribes.push(
|
||||
await pb.collection('comments').subscribe('*', this.handleCommentChange)
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unsubscribes.forEach(unsub => unsub());
|
||||
this.unsubscribes = [];
|
||||
}
|
||||
|
||||
handlePostChange = (e) => { /* ... */ };
|
||||
handleCommentChange = (e) => { /* ... */ };
|
||||
}
|
||||
```
|
||||
|
||||
**Subscription event structure:**
|
||||
|
||||
```javascript
|
||||
pb.collection('posts').subscribe('*', (event) => {
|
||||
event.action; // 'create' | 'update' | 'delete'
|
||||
event.record; // The affected record
|
||||
});
|
||||
|
||||
// Full event type
|
||||
interface RealtimeEvent {
|
||||
action: 'create' | 'update' | 'delete';
|
||||
record: RecordModel;
|
||||
}
|
||||
```
|
||||
|
||||
**Unsubscribe patterns:**
|
||||
|
||||
```javascript
|
||||
// Unsubscribe from specific callback
|
||||
const unsub = await pb.collection('posts').subscribe('*', callback);
|
||||
unsub(); // Remove this specific subscription
|
||||
|
||||
// Unsubscribe from all subscriptions on a topic
|
||||
pb.collection('posts').unsubscribe('*'); // All collection subs
|
||||
pb.collection('posts').unsubscribe('RECORD_ID'); // Specific record
|
||||
|
||||
// Unsubscribe from all collection subscriptions
|
||||
pb.collection('posts').unsubscribe();
|
||||
|
||||
// Unsubscribe from everything
|
||||
pb.realtime.unsubscribe();
|
||||
```
|
||||
|
||||
**Performance considerations:**
|
||||
|
||||
```javascript
|
||||
// Prefer specific record subscriptions over collection-wide when possible.
|
||||
// subscribe('*') checks ListRule for every connected client on each change.
|
||||
// subscribe(recordId) checks ViewRule -- fewer records to evaluate.
|
||||
|
||||
// For high-traffic collections, subscribe to specific records:
|
||||
await pb.collection('orders').subscribe(orderId, handleOrderUpdate);
|
||||
// Instead of: pb.collection('orders').subscribe('*', handleAllOrders);
|
||||
|
||||
// Use subscription options to reduce payload size (SDK v0.21+):
|
||||
await pb.collection('posts').subscribe('*', handleChange, {
|
||||
fields: 'id,title,updated', // Only receive specific fields
|
||||
expand: 'author', // Include expanded relations
|
||||
filter: 'status = "published"' // Only receive matching records
|
||||
});
|
||||
```
|
||||
|
||||
**Subscription scope guidelines:**
|
||||
|
||||
| Scenario | Recommended Scope |
|
||||
|----------|-------------------|
|
||||
| Watching a specific document | `subscribe(recordId)` |
|
||||
| Chat room messages | `subscribe('*')` with filter for room |
|
||||
| User notifications | `subscribe('*')` with filter for user |
|
||||
| Admin dashboard | `subscribe('*')` (need to see all) |
|
||||
| High-frequency data (IoT) | `subscribe(recordId)` per device |
|
||||
|
||||
Reference: [PocketBase Realtime](https://pocketbase.io/docs/api-realtime/)
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Understand API Rule Types and Defaults
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevents unauthorized access, data leaks, and security vulnerabilities
|
||||
tags: api-rules, security, access-control, authorization
|
||||
---
|
||||
|
||||
## Understand API Rule Types and Defaults
|
||||
|
||||
PocketBase uses five collection-level rules to control access. Understanding the difference between locked (null), open (""), and expression rules is critical for security.
|
||||
|
||||
**Incorrect (leaving rules open unintentionally):**
|
||||
|
||||
```javascript
|
||||
// Collection with overly permissive rules
|
||||
const collection = {
|
||||
name: 'messages',
|
||||
listRule: '', // Anyone can list all messages!
|
||||
viewRule: '', // Anyone can view any message!
|
||||
createRule: '', // Anyone can create messages!
|
||||
updateRule: '', // Anyone can update any message!
|
||||
deleteRule: '' // Anyone can delete any message!
|
||||
};
|
||||
// Complete security bypass - all data exposed
|
||||
```
|
||||
|
||||
**Correct (explicit, restrictive rules):**
|
||||
|
||||
```javascript
|
||||
// Collection with proper access control
|
||||
const collection = {
|
||||
name: 'messages',
|
||||
// null = locked, only superusers can access
|
||||
listRule: null, // Default: locked to superusers
|
||||
|
||||
// '' (empty string) = open to everyone (use sparingly)
|
||||
viewRule: '@request.auth.id != ""', // Any authenticated user
|
||||
|
||||
// Expression = conditional access
|
||||
createRule: '@request.auth.id != ""', // Must be logged in
|
||||
updateRule: 'author = @request.auth.id', // Only author
|
||||
deleteRule: 'author = @request.auth.id' // Only author
|
||||
};
|
||||
```
|
||||
|
||||
**Rule types explained:**
|
||||
|
||||
| Rule Value | Meaning | Use Case |
|
||||
|------------|---------|----------|
|
||||
| `null` | Locked (superusers only) | Admin-only data, system tables |
|
||||
| `''` (empty string) | Open to everyone | Public content, no auth required |
|
||||
| `'expression'` | Conditional access | Most common - check auth, ownership |
|
||||
|
||||
**Common patterns:**
|
||||
|
||||
```javascript
|
||||
// Public read, authenticated write (enforce ownership on create)
|
||||
listRule: '',
|
||||
viewRule: '',
|
||||
createRule: '@request.auth.id != "" && @request.body.author = @request.auth.id',
|
||||
updateRule: 'author = @request.auth.id',
|
||||
deleteRule: 'author = @request.auth.id'
|
||||
|
||||
// Private to owner only
|
||||
listRule: 'owner = @request.auth.id',
|
||||
viewRule: 'owner = @request.auth.id',
|
||||
createRule: '@request.auth.id != ""',
|
||||
updateRule: 'owner = @request.auth.id',
|
||||
deleteRule: 'owner = @request.auth.id'
|
||||
|
||||
// Read-only public data
|
||||
listRule: '',
|
||||
viewRule: '',
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null
|
||||
```
|
||||
|
||||
**Error responses by rule type:**
|
||||
- List rule fail: 200 with empty items
|
||||
- View/Update/Delete fail: 404 (hides existence)
|
||||
- Create fail: 400
|
||||
- Locked rule violation: 403
|
||||
|
||||
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/)
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Use @collection for Cross-Collection Lookups
|
||||
impact: HIGH
|
||||
impactDescription: Enables complex authorization without denormalization
|
||||
tags: api-rules, security, cross-collection, relations
|
||||
---
|
||||
|
||||
## Use @collection for Cross-Collection Lookups
|
||||
|
||||
The `@collection` reference allows rules to query other collections, enabling complex authorization patterns like role-based access, team membership, and resource permissions.
|
||||
|
||||
**Incorrect (denormalizing data for access control):**
|
||||
|
||||
```javascript
|
||||
// Duplicating team membership in every resource
|
||||
const documentsSchema = [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'team', type: 'relation' },
|
||||
// Duplicated member list for access control - gets out of sync!
|
||||
{ name: 'allowedUsers', type: 'relation', options: { maxSelect: 999 } }
|
||||
];
|
||||
|
||||
// Rule checks duplicated data
|
||||
listRule: 'allowedUsers ?= @request.auth.id'
|
||||
// Problem: must update allowedUsers whenever team membership changes
|
||||
```
|
||||
|
||||
**Correct (using @collection lookup):**
|
||||
|
||||
```javascript
|
||||
// Clean schema - no duplication
|
||||
const documentsSchema = [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'team', type: 'relation', options: { collectionId: 'teams' } }
|
||||
];
|
||||
|
||||
// Check team membership via @collection lookup
|
||||
listRule: '@collection.team_members.user ?= @request.auth.id && @collection.team_members.team ?= team'
|
||||
|
||||
// Alternative: check if user is in team's members array
|
||||
listRule: 'team.members ?= @request.auth.id'
|
||||
|
||||
// Role-based access via separate roles collection
|
||||
listRule: '@collection.user_roles.user = @request.auth.id && @collection.user_roles.role = "admin"'
|
||||
```
|
||||
|
||||
**Common patterns:**
|
||||
|
||||
```javascript
|
||||
// Team-based access
|
||||
// teams: { name, members (relation to users) }
|
||||
// documents: { title, team (relation to teams) }
|
||||
viewRule: 'team.members ?= @request.auth.id'
|
||||
|
||||
// Organization hierarchy
|
||||
// orgs: { name }
|
||||
// org_members: { org, user, role }
|
||||
// projects: { name, org }
|
||||
listRule: '@collection.org_members.org = org && @collection.org_members.user = @request.auth.id'
|
||||
|
||||
// Permission-based access
|
||||
// permissions: { resource, user, level }
|
||||
updateRule: '@collection.permissions.resource = id && @collection.permissions.user = @request.auth.id && @collection.permissions.level = "write"'
|
||||
|
||||
// Using aliases for complex queries
|
||||
listRule: '@collection.memberships:m.user = @request.auth.id && @collection.memberships:m.team = team'
|
||||
```
|
||||
|
||||
**Performance considerations:**
|
||||
- Cross-collection lookups add query complexity
|
||||
- Ensure referenced fields are indexed
|
||||
- Consider caching for frequently accessed permissions
|
||||
- Test performance with realistic data volumes
|
||||
|
||||
Reference: [PocketBase Collection Reference](https://pocketbase.io/docs/api-rules-and-filters/#collection-fields)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Master Filter Expression Syntax
|
||||
impact: CRITICAL
|
||||
impactDescription: Enables complex access control and efficient querying
|
||||
tags: api-rules, filters, syntax, operators, security
|
||||
---
|
||||
|
||||
## Master Filter Expression Syntax
|
||||
|
||||
PocketBase filter expressions use a specific syntax for both API rules and client-side queries. Understanding operators and composition is essential.
|
||||
|
||||
**Incorrect (invalid filter syntax):**
|
||||
|
||||
```javascript
|
||||
// Wrong operator syntax
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: 'status == "published"' // Wrong: == instead of =
|
||||
});
|
||||
|
||||
// Missing quotes around strings
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: 'status = published' // Wrong: unquoted string
|
||||
});
|
||||
|
||||
// Wrong boolean logic
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: 'status = "published" AND featured = true' // Wrong: AND instead of &&
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (proper filter syntax):**
|
||||
|
||||
```javascript
|
||||
// Equality and comparison operators
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: 'status = "published"' // Equals
|
||||
});
|
||||
filter: 'views != 0' // Not equals
|
||||
filter: 'views > 100' // Greater than
|
||||
filter: 'views >= 100' // Greater or equal
|
||||
filter: 'price < 50.00' // Less than
|
||||
filter: 'created <= "2024-01-01 00:00:00"' // Less or equal
|
||||
|
||||
// String operators
|
||||
filter: 'title ~ "hello"' // Contains (case-insensitive)
|
||||
filter: 'title !~ "spam"' // Does not contain
|
||||
|
||||
// Logical operators
|
||||
filter: 'status = "published" && featured = true' // AND
|
||||
filter: 'category = "news" || category = "blog"' // OR
|
||||
filter: '(status = "draft" || status = "review") && author = "abc"' // Grouping
|
||||
|
||||
// Array/multi-value operators (for select, relation fields)
|
||||
filter: 'tags ?= "featured"' // Any tag equals "featured"
|
||||
filter: 'tags ?~ "tech"' // Any tag contains "tech"
|
||||
|
||||
// Null checks
|
||||
filter: 'deletedAt = null' // Is null
|
||||
filter: 'avatar != null' // Is not null
|
||||
|
||||
// Date comparisons
|
||||
filter: 'created > "2024-01-01 00:00:00"'
|
||||
filter: 'created >= @now' // Current timestamp
|
||||
filter: 'expires < @today' // Start of today (UTC)
|
||||
```
|
||||
|
||||
**Available operators:**
|
||||
|
||||
| Operator | Description |
|
||||
|----------|-------------|
|
||||
| `=` | Equal |
|
||||
| `!=` | Not equal |
|
||||
| `>` `>=` `<` `<=` | Comparison |
|
||||
| `~` | Contains (LIKE %value%) |
|
||||
| `!~` | Does not contain |
|
||||
| `?=` `?!=` `?>` `?~` | Any element matches |
|
||||
| `&&` | AND |
|
||||
| `\|\|` | OR |
|
||||
| `()` | Grouping |
|
||||
|
||||
**Date macros:**
|
||||
- `@now` - Current UTC datetime
|
||||
- `@today` - Start of today UTC
|
||||
- `@month` - Start of current month UTC
|
||||
- `@year` - Start of current year UTC
|
||||
|
||||
**Filter functions:**
|
||||
- `strftime(fmt, datetime)` - Format/extract datetime parts (v0.36+). E.g. `strftime('%Y-%m', created) = "2026-03"`. See `rules-strftime.md` for the full format specifier list.
|
||||
- `length(field)` - Element count of a multi-value field (file, relation, select). E.g. `length(tags) > 0`.
|
||||
- `each(field, expr)` - Iterate a multi-value field: `each(tags, ? ~ "urgent")`.
|
||||
- `issetIf(field, val)` - Conditional presence check for complex rules.
|
||||
|
||||
Reference: [PocketBase Filters](https://pocketbase.io/docs/api-rules-and-filters/#filters-syntax)
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: Default to Locked Rules, Open Explicitly
|
||||
impact: CRITICAL
|
||||
impactDescription: Defense in depth, prevents accidental data exposure
|
||||
tags: api-rules, security, defaults, best-practices
|
||||
---
|
||||
|
||||
## Default to Locked Rules, Open Explicitly
|
||||
|
||||
New collections should start with locked (null) rules and explicitly open only what's needed. This prevents accidental data exposure and follows the principle of least privilege.
|
||||
|
||||
**Incorrect (starting with open rules):**
|
||||
|
||||
```javascript
|
||||
// Dangerous: copying rules from examples without thinking
|
||||
const collection = {
|
||||
name: 'user_settings',
|
||||
listRule: '', // Open - leaks all user settings!
|
||||
viewRule: '', // Open - anyone can view any setting
|
||||
createRule: '', // Open - no auth required
|
||||
updateRule: '', // Open - anyone can modify!
|
||||
deleteRule: '' // Open - anyone can delete!
|
||||
};
|
||||
|
||||
// Also dangerous: using auth check when ownership needed
|
||||
const collection = {
|
||||
name: 'private_notes',
|
||||
listRule: '@request.auth.id != ""', // Any logged-in user sees ALL notes
|
||||
viewRule: '@request.auth.id != ""',
|
||||
updateRule: '@request.auth.id != ""', // Any user can edit ANY note!
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (locked by default, explicitly opened):**
|
||||
|
||||
```javascript
|
||||
// Step 1: Start locked
|
||||
const collection = {
|
||||
name: 'user_settings',
|
||||
listRule: null, // Locked - superusers only
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null
|
||||
};
|
||||
|
||||
// Step 2: Open only what's needed with proper checks
|
||||
const collection = {
|
||||
name: 'user_settings',
|
||||
// Users can only see their own settings
|
||||
listRule: 'user = @request.auth.id',
|
||||
viewRule: 'user = @request.auth.id',
|
||||
// Users can only create settings for themselves
|
||||
createRule: '@request.auth.id != "" && @request.body.user = @request.auth.id',
|
||||
// Users can only update their own settings
|
||||
updateRule: 'user = @request.auth.id',
|
||||
// Prevent deletion or restrict to owner
|
||||
deleteRule: 'user = @request.auth.id'
|
||||
};
|
||||
|
||||
// For truly public data, document why it's open
|
||||
const collection = {
|
||||
name: 'public_announcements',
|
||||
// Intentionally public - these are site-wide announcements
|
||||
listRule: '',
|
||||
viewRule: '',
|
||||
// Only admins can manage (using custom "role" field on auth collection)
|
||||
// IMPORTANT: Prevent role self-assignment in the users collection updateRule:
|
||||
// updateRule: 'id = @request.auth.id && @request.body.role:isset = false'
|
||||
createRule: '@request.auth.role = "admin"',
|
||||
updateRule: '@request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"'
|
||||
};
|
||||
```
|
||||
|
||||
**Rule development workflow:**
|
||||
|
||||
1. **Start locked** - All rules `null`
|
||||
2. **Identify access needs** - Who needs what access?
|
||||
3. **Write minimal rules** - Open only required operations
|
||||
4. **Test thoroughly** - Verify both allowed and denied cases
|
||||
5. **Document decisions** - Comment why rules are set as they are
|
||||
|
||||
**Security checklist:**
|
||||
- [ ] No empty string rules without justification
|
||||
- [ ] Ownership checks on personal data
|
||||
- [ ] Auth checks on write operations
|
||||
- [ ] Admin-only rules for sensitive operations
|
||||
- [ ] Tested with different user contexts
|
||||
|
||||
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/)
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Use @request Context in API Rules
|
||||
impact: CRITICAL
|
||||
impactDescription: Enables dynamic, user-aware access control
|
||||
tags: api-rules, security, request-context, authentication
|
||||
---
|
||||
|
||||
## Use @request Context in API Rules
|
||||
|
||||
The `@request` object provides access to the current request context including authenticated user, request body, query parameters, and headers. Use it to build dynamic access rules.
|
||||
|
||||
**Incorrect (hardcoded or missing auth checks):**
|
||||
|
||||
```javascript
|
||||
// No authentication check
|
||||
const collection = {
|
||||
listRule: '', // Anyone can see everything
|
||||
createRule: '' // Anyone can create
|
||||
};
|
||||
|
||||
// Hardcoded user ID (never do this)
|
||||
const collection = {
|
||||
listRule: 'owner = "specific_user_id"' // Only one user can access
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (using @request context):**
|
||||
|
||||
```javascript
|
||||
// Check if user is authenticated
|
||||
createRule: '@request.auth.id != ""'
|
||||
|
||||
// Check ownership via auth record
|
||||
listRule: 'owner = @request.auth.id'
|
||||
viewRule: 'owner = @request.auth.id'
|
||||
updateRule: 'owner = @request.auth.id'
|
||||
deleteRule: 'owner = @request.auth.id'
|
||||
|
||||
// Access auth record fields
|
||||
// IMPORTANT: If using custom role fields, ensure update rules prevent
|
||||
// users from modifying their own role: @request.body.role:isset = false
|
||||
listRule: '@request.auth.role = "admin"'
|
||||
listRule: '@request.auth.verified = true'
|
||||
|
||||
// Validate request body on create/update
|
||||
createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id'
|
||||
|
||||
// Prevent changing certain fields
|
||||
updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false'
|
||||
|
||||
// WARNING: Query parameters are user-controlled and should NOT be used
|
||||
// for authorization decisions. Use them only for optional filtering behavior
|
||||
// where the fallback is equally safe.
|
||||
// listRule: '@request.query.publicOnly = "true" || owner = @request.auth.id'
|
||||
// The above is UNSAFE - users can bypass ownership by adding ?publicOnly=true
|
||||
// Instead, use separate endpoints or server-side logic for public vs. private views.
|
||||
listRule: 'owner = @request.auth.id || public = true' // Use a record field, not query param
|
||||
|
||||
// Access nested auth relations
|
||||
listRule: 'team.members ?= @request.auth.id'
|
||||
```
|
||||
|
||||
**Available @request fields:**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `@request.auth.id` | Authenticated user's ID (empty string if not authenticated) |
|
||||
| `@request.auth.*` | Any field from auth record (role, verified, email, etc.) |
|
||||
| `@request.body.*` | Request body fields (create/update only) |
|
||||
| `@request.query.*` | URL query parameters |
|
||||
| `@request.headers.*` | Request headers |
|
||||
| `@request.method` | HTTP method (GET, POST, etc.) |
|
||||
| `@request.context` | Request context: `default`, `oauth2`, `otp`, `password`, `realtime`, `protectedFile` |
|
||||
|
||||
**Body field modifiers:**
|
||||
|
||||
```javascript
|
||||
// Check if field is being set
|
||||
updateRule: '@request.body.status:isset = false' // Can't change status
|
||||
|
||||
// Check if field changed from current value
|
||||
updateRule: '@request.body.owner:changed = false' // Can't change owner
|
||||
|
||||
// Get length of array/string
|
||||
createRule: '@request.body.tags:length <= 5' // Max 5 tags
|
||||
```
|
||||
|
||||
Reference: [PocketBase API Rules](https://pocketbase.io/docs/api-rules-and-filters/#available-fields)
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Use strftime() for Date Arithmetic in Filter Expressions
|
||||
impact: MEDIUM
|
||||
impactDescription: strftime() (added in v0.36) replaces brittle string prefix comparisons on datetime fields
|
||||
tags: filter, strftime, datetime, rules, v0.36
|
||||
---
|
||||
|
||||
## Use strftime() for Date Arithmetic in Filter Expressions
|
||||
|
||||
PocketBase v0.36 added the `strftime()` function to the filter expression grammar. It maps directly to SQLite's [strftime](https://sqlite.org/lang_datefunc.html) and is the correct way to bucket, compare, or extract parts of a datetime field. Before v0.36 people worked around this with `~` (substring) matches against the ISO string; those workarounds are fragile (they break at midnight UTC, ignore timezones, and can't handle ranges).
|
||||
|
||||
**Incorrect (substring match on the ISO datetime string):**
|
||||
|
||||
```javascript
|
||||
// ❌ "matches anything whose ISO string contains 2026-04-08" - breaks as soon
|
||||
// as your DB stores sub-second precision or you cross a month boundary
|
||||
const todayPrefix = new Date().toISOString().slice(0, 10);
|
||||
const results = await pb.collection("orders").getList(1, 50, {
|
||||
filter: `created ~ "${todayPrefix}"`, // ❌
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (strftime with named format specifiers):**
|
||||
|
||||
```javascript
|
||||
// "all orders created today (UTC)"
|
||||
const results = await pb.collection("orders").getList(1, 50, {
|
||||
filter: `strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)`,
|
||||
});
|
||||
|
||||
// "all orders from March 2026"
|
||||
await pb.collection("orders").getList(1, 50, {
|
||||
filter: `strftime('%Y-%m', created) = "2026-03"`,
|
||||
});
|
||||
|
||||
// "orders created this hour"
|
||||
await pb.collection("orders").getList(1, 50, {
|
||||
filter: `strftime('%Y-%m-%d %H', created) = strftime('%Y-%m-%d %H', @now)`,
|
||||
});
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Same function is available inside API rules:
|
||||
// collection "orders" - List rule:
|
||||
// @request.auth.id != "" &&
|
||||
// user = @request.auth.id &&
|
||||
// strftime('%Y-%m-%d', created) = strftime('%Y-%m-%d', @now)
|
||||
```
|
||||
|
||||
**Common format specifiers:**
|
||||
|
||||
| Specifier | Meaning |
|
||||
|---|---|
|
||||
| `%Y` | 4-digit year |
|
||||
| `%m` | month (01-12) |
|
||||
| `%d` | day of month (01-31) |
|
||||
| `%H` | hour (00-23) |
|
||||
| `%M` | minute (00-59) |
|
||||
| `%S` | second (00-59) |
|
||||
| `%W` | ISO week (00-53) |
|
||||
| `%j` | day of year (001-366) |
|
||||
| `%w` | day of week (0=Sunday) |
|
||||
|
||||
**Other filter functions worth knowing:**
|
||||
|
||||
| Function | Use |
|
||||
|---|---|
|
||||
| `strftime(fmt, datetime)` | Format/extract datetime parts (v0.36+) |
|
||||
| `length(field)` | Count elements in a multi-value field (file, relation, select) |
|
||||
| `each(field, expr)` | Iterate over multi-value fields: `each(tags, ? ~ "urgent")` |
|
||||
| `issetIf(field, val)` | Conditional presence check used in complex rules |
|
||||
|
||||
Reference: [Filter Syntax - Functions](https://pocketbase.io/docs/api-rules-and-filters/#filters) · [v0.36.0 release](https://github.com/pocketbase/pocketbase/releases/tag/v0.36.0)
|
||||
128
.claude/skills/pocketbase-best-practices/rules/sdk-auth-store.md
Normal file
128
.claude/skills/pocketbase-best-practices/rules/sdk-auth-store.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Use Appropriate Auth Store for Your Platform
|
||||
impact: HIGH
|
||||
impactDescription: Proper auth persistence across sessions and page reloads
|
||||
tags: sdk, auth-store, persistence, storage
|
||||
---
|
||||
|
||||
## Use Appropriate Auth Store for Your Platform
|
||||
|
||||
The auth store persists authentication state. Choose the right store type based on your platform: LocalAuthStore for browsers, AsyncAuthStore for React Native, or custom stores for specific needs.
|
||||
|
||||
**Incorrect (wrong store for platform):**
|
||||
|
||||
```javascript
|
||||
// React Native: LocalAuthStore doesn't work correctly
|
||||
import PocketBase from 'pocketbase';
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
// Auth state lost on app restart!
|
||||
|
||||
// Deno server: LocalStorage shared between all clients
|
||||
import PocketBase from 'pocketbase';
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
// All clients share the same auth state!
|
||||
|
||||
// Server-side: Reusing single client for multiple users
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
// User A logs in...
|
||||
// User B's request uses User A's auth!
|
||||
```
|
||||
|
||||
**Correct (platform-appropriate stores):**
|
||||
|
||||
```javascript
|
||||
// Browser (default LocalAuthStore - works automatically)
|
||||
import PocketBase from 'pocketbase';
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
// Automatically persists to localStorage and syncs between tabs
|
||||
|
||||
// React Native (AsyncAuthStore)
|
||||
import PocketBase, { AsyncAuthStore } from 'pocketbase';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const store = new AsyncAuthStore({
|
||||
save: async (serialized) => {
|
||||
await AsyncStorage.setItem('pb_auth', serialized);
|
||||
},
|
||||
initial: AsyncStorage.getItem('pb_auth'),
|
||||
clear: async () => {
|
||||
await AsyncStorage.removeItem('pb_auth');
|
||||
}
|
||||
});
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090', store);
|
||||
|
||||
// Server-side / SSR (create client per request)
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
export function createServerClient(cookieHeader) {
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
pb.authStore.loadFromCookie(cookieHeader || '');
|
||||
return pb;
|
||||
}
|
||||
|
||||
// Deno/Cloudflare Workers (memory-only store)
|
||||
import PocketBase, { BaseAuthStore } from 'pocketbase';
|
||||
|
||||
class MemoryAuthStore extends BaseAuthStore {
|
||||
// Token only persists for request duration
|
||||
// Each request must include auth via cookie/header
|
||||
}
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090', new MemoryAuthStore());
|
||||
```
|
||||
|
||||
**Custom auth store example:**
|
||||
|
||||
```javascript
|
||||
import PocketBase, { BaseAuthStore } from 'pocketbase';
|
||||
|
||||
class SecureAuthStore extends BaseAuthStore {
|
||||
constructor() {
|
||||
super();
|
||||
// Load initial state from secure storage
|
||||
const data = secureStorage.get('pb_auth');
|
||||
if (data) {
|
||||
const { token, record } = JSON.parse(data);
|
||||
this.save(token, record);
|
||||
}
|
||||
}
|
||||
|
||||
save(token, record) {
|
||||
super.save(token, record);
|
||||
// Persist to secure storage
|
||||
secureStorage.set('pb_auth', JSON.stringify({ token, record }));
|
||||
}
|
||||
|
||||
clear() {
|
||||
super.clear();
|
||||
secureStorage.remove('pb_auth');
|
||||
}
|
||||
}
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090', new SecureAuthStore());
|
||||
```
|
||||
|
||||
**Auth store methods:**
|
||||
|
||||
```javascript
|
||||
// Available on all auth stores
|
||||
pb.authStore.token; // Current token
|
||||
pb.authStore.record; // Current auth record
|
||||
pb.authStore.isValid; // Token exists and not expired
|
||||
pb.authStore.isSuperuser; // Is superuser token
|
||||
|
||||
pb.authStore.save(token, record); // Save auth state
|
||||
pb.authStore.clear(); // Clear auth state
|
||||
|
||||
// Listen for changes
|
||||
const unsubscribe = pb.authStore.onChange((token, record) => {
|
||||
console.log('Auth changed:', record?.email);
|
||||
}, true); // true = fire immediately
|
||||
|
||||
// Cookie helpers (for SSR)
|
||||
pb.authStore.loadFromCookie(cookieString);
|
||||
pb.authStore.exportToCookie({ httpOnly: false, secure: true });
|
||||
```
|
||||
|
||||
Reference: [PocketBase Authentication](https://pocketbase.io/docs/authentication/)
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Understand and Control Auto-Cancellation
|
||||
impact: MEDIUM
|
||||
impactDescription: Prevents race conditions, improves UX for search/typeahead
|
||||
tags: sdk, cancellation, requests, performance
|
||||
---
|
||||
|
||||
## Understand and Control Auto-Cancellation
|
||||
|
||||
The SDK automatically cancels duplicate pending requests. This prevents race conditions but requires understanding for proper use in concurrent scenarios.
|
||||
|
||||
**Incorrect (confused by auto-cancellation):**
|
||||
|
||||
```javascript
|
||||
// These requests will interfere with each other!
|
||||
async function loadDashboard() {
|
||||
// Only the last one executes, others cancelled
|
||||
const posts = pb.collection('posts').getList(1, 20);
|
||||
const users = pb.collection('posts').getList(1, 10); // Different params but same path
|
||||
const comments = pb.collection('posts').getList(1, 5);
|
||||
|
||||
// posts and users are cancelled, only comments executes
|
||||
return Promise.all([posts, users, comments]); // First two fail!
|
||||
}
|
||||
|
||||
// Realtime combined with polling causes cancellation
|
||||
pb.collection('posts').subscribe('*', callback);
|
||||
setInterval(() => {
|
||||
pb.collection('posts').getList(); // May cancel realtime!
|
||||
}, 5000);
|
||||
```
|
||||
|
||||
**Correct (controlling auto-cancellation):**
|
||||
|
||||
```javascript
|
||||
// Disable auto-cancellation for parallel requests
|
||||
async function loadDashboard() {
|
||||
const [posts, users, recent] = await Promise.all([
|
||||
pb.collection('posts').getList(1, 20, { requestKey: null }),
|
||||
pb.collection('users').getList(1, 10, { requestKey: null }),
|
||||
pb.collection('posts').getList(1, 5, { requestKey: 'recent' })
|
||||
]);
|
||||
// All requests complete independently
|
||||
return { posts, users, recent };
|
||||
}
|
||||
|
||||
// Use unique request keys for different purposes
|
||||
async function searchPosts(query) {
|
||||
return pb.collection('posts').getList(1, 20, {
|
||||
filter: pb.filter('title ~ {:q}', { q: query }),
|
||||
requestKey: 'post-search' // Cancels previous searches only
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPostDetails(postId) {
|
||||
return pb.collection('posts').getOne(postId, {
|
||||
requestKey: `post-${postId}` // Unique per post
|
||||
});
|
||||
}
|
||||
|
||||
// Typeahead search - auto-cancellation is helpful here
|
||||
async function typeaheadSearch(query) {
|
||||
// Previous search automatically cancelled when user types more
|
||||
return pb.collection('products').getList(1, 10, {
|
||||
filter: pb.filter('name ~ {:q}', { q: query })
|
||||
// No requestKey = uses default (path-based), previous cancelled
|
||||
});
|
||||
}
|
||||
|
||||
// Globally disable auto-cancellation (use carefully)
|
||||
pb.autoCancellation(false);
|
||||
|
||||
// Now all requests are independent
|
||||
await Promise.all([
|
||||
pb.collection('posts').getList(1, 20),
|
||||
pb.collection('posts').getList(1, 10),
|
||||
pb.collection('posts').getList(1, 5)
|
||||
]);
|
||||
|
||||
// Re-enable
|
||||
pb.autoCancellation(true);
|
||||
```
|
||||
|
||||
**Manual cancellation:**
|
||||
|
||||
```javascript
|
||||
// Cancel all pending requests
|
||||
pb.cancelAllRequests();
|
||||
|
||||
// Cancel specific request by key
|
||||
pb.cancelRequest('post-search');
|
||||
|
||||
// Example: Cancel on component unmount
|
||||
function PostList() {
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
|
||||
return () => {
|
||||
// Cleanup: cancel pending requests
|
||||
pb.cancelRequest('post-list');
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function loadPosts() {
|
||||
const result = await pb.collection('posts').getList(1, 20, {
|
||||
requestKey: 'post-list'
|
||||
});
|
||||
setPosts(result.items);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cancellation in catch
|
||||
async function fetchWithCancellation() {
|
||||
try {
|
||||
return await pb.collection('posts').getList();
|
||||
} catch (error) {
|
||||
if (error.isAbort) {
|
||||
// Request was cancelled - this is expected
|
||||
console.log('Request cancelled');
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use each approach:**
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| Search/typeahead | Default (let it cancel) |
|
||||
| Parallel data loading | `requestKey: null` |
|
||||
| Grouped requests | Custom `requestKey` |
|
||||
| Component cleanup | `cancelRequest(key)` |
|
||||
| Testing/debugging | `autoCancellation(false)` |
|
||||
| OAuth2 flow cancel | `cancelRequest(requestKey)` — properly rejects the `authWithOAuth2()` Promise (JS SDK v0.26.8+) |
|
||||
|
||||
> **Note (JS SDK v0.26.8):** Calling `pb.cancelRequest(requestKey)` while `authWithOAuth2()` is waiting now properly rejects the returned Promise. In earlier versions the manual cancellation did not account for the waiting realtime subscription, so the Promise could hang indefinitely.
|
||||
|
||||
Reference: [PocketBase Auto-Cancellation](https://github.com/pocketbase/js-sdk#auto-cancellation)
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: Handle SDK Errors Properly
|
||||
impact: HIGH
|
||||
impactDescription: Graceful error recovery, better UX, easier debugging
|
||||
tags: sdk, errors, error-handling, exceptions
|
||||
---
|
||||
|
||||
## Handle SDK Errors Properly
|
||||
|
||||
All SDK methods return Promises that may reject with `ClientResponseError`. Proper error handling improves user experience and simplifies debugging.
|
||||
|
||||
**Incorrect (ignoring or poorly handling errors):**
|
||||
|
||||
```javascript
|
||||
// No error handling
|
||||
const posts = await pb.collection('posts').getList();
|
||||
|
||||
// Generic catch that loses information
|
||||
try {
|
||||
await pb.collection('posts').create({ title: '' });
|
||||
} catch (e) {
|
||||
alert('Something went wrong'); // No useful info
|
||||
}
|
||||
|
||||
// Not checking specific error types
|
||||
try {
|
||||
await pb.collection('posts').getOne('nonexistent');
|
||||
} catch (e) {
|
||||
console.log(e.message); // Missing status, response details
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (comprehensive error handling):**
|
||||
|
||||
```javascript
|
||||
import PocketBase, { ClientResponseError } from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// Basic error handling with ClientResponseError
|
||||
async function createPost(data) {
|
||||
try {
|
||||
return await pb.collection('posts').create(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ClientResponseError) {
|
||||
console.log('Status:', error.status);
|
||||
console.log('Response:', error.response);
|
||||
console.log('URL:', error.url);
|
||||
console.log('Is abort:', error.isAbort);
|
||||
|
||||
// Handle specific status codes
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
// Validation error - extract user-friendly messages only
|
||||
// IMPORTANT: Don't expose raw error.response.data to clients
|
||||
// as it may leak internal field names and validation rules
|
||||
const fieldErrors = {};
|
||||
if (error.response?.data) {
|
||||
for (const [field, details] of Object.entries(error.response.data)) {
|
||||
fieldErrors[field] = details.message;
|
||||
}
|
||||
}
|
||||
return { error: 'validation', fields: fieldErrors };
|
||||
case 401:
|
||||
// Unauthorized - need to login
|
||||
return { error: 'unauthorized' };
|
||||
case 403:
|
||||
// Forbidden - no permission
|
||||
return { error: 'forbidden' };
|
||||
case 404:
|
||||
// Not found
|
||||
return { error: 'not_found' };
|
||||
default:
|
||||
return { error: 'server_error' };
|
||||
}
|
||||
}
|
||||
throw error; // Re-throw non-PocketBase errors
|
||||
}
|
||||
}
|
||||
|
||||
// Handle validation errors with field details
|
||||
async function updateProfile(userId, data) {
|
||||
try {
|
||||
return await pb.collection('users').update(userId, data);
|
||||
} catch (error) {
|
||||
if (error.status === 400 && error.response?.data) {
|
||||
// Extract field-specific errors
|
||||
const fieldErrors = {};
|
||||
for (const [field, details] of Object.entries(error.response.data)) {
|
||||
fieldErrors[field] = details.message;
|
||||
}
|
||||
return { success: false, errors: fieldErrors };
|
||||
// { errors: { email: "Invalid email format", name: "Required field" } }
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle request cancellation
|
||||
async function searchWithCancel(query) {
|
||||
try {
|
||||
return await pb.collection('posts').getList(1, 20, {
|
||||
filter: pb.filter('title ~ {:query}', { query })
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.isAbort) {
|
||||
// Request was cancelled (e.g., user typed again)
|
||||
console.log('Search cancelled');
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper function for consistent error handling
|
||||
async function pbRequest(fn) {
|
||||
try {
|
||||
return { data: await fn(), error: null };
|
||||
} catch (error) {
|
||||
if (error instanceof ClientResponseError) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
status: error.status,
|
||||
message: error.response?.message || 'Request failed',
|
||||
data: error.response?.data || null
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
error: { status: 0, message: error.message, data: null }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const { data, error } = await pbRequest(() =>
|
||||
pb.collection('posts').getList(1, 20)
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.log('Failed:', error.message);
|
||||
} else {
|
||||
console.log('Posts:', data.items);
|
||||
}
|
||||
```
|
||||
|
||||
**ClientResponseError structure:**
|
||||
|
||||
```typescript
|
||||
interface ClientResponseError {
|
||||
url: string; // The request URL
|
||||
status: number; // HTTP status code (0 if network error)
|
||||
response: { // API response body
|
||||
code: number;
|
||||
message: string;
|
||||
data: { [field: string]: { code: string; message: string } };
|
||||
};
|
||||
isAbort: boolean; // True if request was cancelled
|
||||
cause: Error | null; // Original error (added in JS SDK v0.26.1)
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [PocketBase Error Handling](https://github.com/pocketbase/js-sdk#error-handling)
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Use Field Modifiers for Incremental Updates
|
||||
impact: HIGH
|
||||
impactDescription: Atomic updates, prevents race conditions, cleaner code
|
||||
tags: sdk, modifiers, relations, files, numbers, atomic
|
||||
---
|
||||
|
||||
## Use Field Modifiers for Incremental Updates
|
||||
|
||||
PocketBase supports `+` and `-` modifiers for incrementing numbers, appending/removing relation IDs, and managing file arrays without replacing the entire value.
|
||||
|
||||
**Incorrect (read-modify-write pattern):**
|
||||
|
||||
```javascript
|
||||
// Race condition: two users adding tags simultaneously
|
||||
async function addTag(postId, newTagId) {
|
||||
const post = await pb.collection('posts').getOne(postId);
|
||||
const currentTags = post.tags || [];
|
||||
|
||||
// Another user might have added a tag in between!
|
||||
await pb.collection('posts').update(postId, {
|
||||
tags: [...currentTags, newTagId] // Might overwrite the other user's tag
|
||||
});
|
||||
}
|
||||
|
||||
// Inefficient for incrementing counters
|
||||
async function incrementViews(postId) {
|
||||
const post = await pb.collection('posts').getOne(postId);
|
||||
await pb.collection('posts').update(postId, {
|
||||
views: post.views + 1 // Extra read, race condition
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using field modifiers):**
|
||||
|
||||
```javascript
|
||||
// Atomic relation append with + modifier
|
||||
async function addTag(postId, newTagId) {
|
||||
await pb.collection('posts').update(postId, {
|
||||
'tags+': newTagId // Appends to existing tags atomically
|
||||
});
|
||||
}
|
||||
|
||||
// Append multiple relations
|
||||
async function addTags(postId, tagIds) {
|
||||
await pb.collection('posts').update(postId, {
|
||||
'tags+': tagIds // Appends array of IDs
|
||||
});
|
||||
}
|
||||
|
||||
// Prepend relations (+ prefix)
|
||||
async function prependTag(postId, tagId) {
|
||||
await pb.collection('posts').update(postId, {
|
||||
'+tags': tagId // Prepends to start of array
|
||||
});
|
||||
}
|
||||
|
||||
// Remove relations with - modifier
|
||||
async function removeTag(postId, tagId) {
|
||||
await pb.collection('posts').update(postId, {
|
||||
'tags-': tagId // Removes specific tag
|
||||
});
|
||||
}
|
||||
|
||||
// Remove multiple relations
|
||||
async function removeTags(postId, tagIds) {
|
||||
await pb.collection('posts').update(postId, {
|
||||
'tags-': tagIds // Removes all specified tags
|
||||
});
|
||||
}
|
||||
|
||||
// Atomic number increment
|
||||
async function incrementViews(postId) {
|
||||
await pb.collection('posts').update(postId, {
|
||||
'views+': 1 // Atomic increment, no race condition
|
||||
});
|
||||
}
|
||||
|
||||
// Atomic number decrement
|
||||
async function decrementStock(productId, quantity) {
|
||||
await pb.collection('products').update(productId, {
|
||||
'stock-': quantity // Atomic decrement
|
||||
});
|
||||
}
|
||||
|
||||
// File append (for multi-file fields)
|
||||
async function addImage(albumId, newImage) {
|
||||
await pb.collection('albums').update(albumId, {
|
||||
'images+': newImage // Appends new file to existing
|
||||
});
|
||||
}
|
||||
|
||||
// File removal
|
||||
async function removeImage(albumId, filename) {
|
||||
await pb.collection('albums').update(albumId, {
|
||||
'images-': filename // Removes specific file by name
|
||||
});
|
||||
}
|
||||
|
||||
// Combined modifiers in single update
|
||||
async function updatePost(postId, data) {
|
||||
await pb.collection('posts').update(postId, {
|
||||
title: data.title, // Replace field
|
||||
'views+': 1, // Increment number
|
||||
'tags+': data.newTagId, // Append relation
|
||||
'tags-': data.oldTagId, // Remove relation
|
||||
'images+': data.newImage // Append file
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Modifier reference:**
|
||||
|
||||
| Modifier | Field Types | Description |
|
||||
|----------|-------------|-------------|
|
||||
| `field+` or `+field` | relation, file | Append/prepend to array |
|
||||
| `field-` | relation, file | Remove from array |
|
||||
| `field+` | number | Increment by value |
|
||||
| `field-` | number | Decrement by value |
|
||||
|
||||
**Benefits:**
|
||||
- **Atomic**: No read-modify-write race conditions
|
||||
- **Efficient**: Single request, no extra read needed
|
||||
- **Clean**: Expresses intent clearly
|
||||
|
||||
**Note:** Modifiers only work with `update()`, not `create()`.
|
||||
|
||||
Reference: [PocketBase Relations](https://pocketbase.io/docs/working-with-relations/)
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Use Safe Parameter Binding in Filters
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevents injection attacks, handles special characters correctly
|
||||
tags: sdk, filters, security, injection, parameters
|
||||
---
|
||||
|
||||
## Use Safe Parameter Binding in Filters
|
||||
|
||||
Always use `pb.filter()` with parameter binding when constructing filters with user input. String concatenation is vulnerable to injection attacks.
|
||||
|
||||
**Incorrect (string concatenation - DANGEROUS):**
|
||||
|
||||
```javascript
|
||||
// SQL/filter injection vulnerability!
|
||||
async function searchPosts(userInput) {
|
||||
// User input: `test" || id != "` breaks out of string
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: `title ~ "${userInput}"` // VULNERABLE!
|
||||
});
|
||||
return posts;
|
||||
}
|
||||
|
||||
// Even with escaping, easy to get wrong
|
||||
async function searchByEmail(email) {
|
||||
const escaped = email.replace(/"/g, '\\"'); // Incomplete escaping
|
||||
const users = await pb.collection('users').getList(1, 1, {
|
||||
filter: `email = "${escaped}"` // Still potentially vulnerable
|
||||
});
|
||||
return users;
|
||||
}
|
||||
|
||||
// Template literals are just as dangerous
|
||||
const filter = `status = "${status}" && author = "${authorId}"`;
|
||||
```
|
||||
|
||||
**Correct (using pb.filter with parameters):**
|
||||
|
||||
```javascript
|
||||
// Safe parameter binding
|
||||
async function searchPosts(userInput) {
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: pb.filter('title ~ {:search}', { search: userInput })
|
||||
});
|
||||
return posts;
|
||||
}
|
||||
|
||||
// Multiple parameters
|
||||
async function filterPosts(status, authorId, minViews) {
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
filter: pb.filter(
|
||||
'status = {:status} && author = {:author} && views >= {:views}',
|
||||
{ status, author: authorId, views: minViews }
|
||||
)
|
||||
});
|
||||
return posts;
|
||||
}
|
||||
|
||||
// Reusing parameters
|
||||
async function searchBothFields(query) {
|
||||
const results = await pb.collection('posts').getList(1, 20, {
|
||||
filter: pb.filter(
|
||||
'title ~ {:q} || content ~ {:q}',
|
||||
{ q: query } // Same parameter used twice
|
||||
)
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
// Different parameter types
|
||||
async function complexFilter(options) {
|
||||
const filter = pb.filter(
|
||||
'created > {:date} && active = {:active} && category = {:cat}',
|
||||
{
|
||||
date: new Date('2024-01-01'), // Date objects handled correctly
|
||||
active: true, // Booleans
|
||||
cat: options.category // Strings auto-escaped
|
||||
}
|
||||
);
|
||||
|
||||
return pb.collection('posts').getList(1, 20, { filter });
|
||||
}
|
||||
|
||||
// Null handling
|
||||
async function filterWithOptional(category) {
|
||||
// Only include filter if value provided
|
||||
const filter = category
|
||||
? pb.filter('category = {:cat}', { cat: category })
|
||||
: '';
|
||||
|
||||
return pb.collection('posts').getList(1, 20, { filter });
|
||||
}
|
||||
|
||||
// Building dynamic filters
|
||||
async function dynamicSearch(filters) {
|
||||
const conditions = [];
|
||||
const params = {};
|
||||
|
||||
if (filters.title) {
|
||||
conditions.push('title ~ {:title}');
|
||||
params.title = filters.title;
|
||||
}
|
||||
|
||||
if (filters.author) {
|
||||
conditions.push('author = {:author}');
|
||||
params.author = filters.author;
|
||||
}
|
||||
|
||||
if (filters.minDate) {
|
||||
conditions.push('created >= {:minDate}');
|
||||
params.minDate = filters.minDate;
|
||||
}
|
||||
|
||||
const filter = conditions.length > 0
|
||||
? pb.filter(conditions.join(' && '), params)
|
||||
: '';
|
||||
|
||||
return pb.collection('posts').getList(1, 20, { filter });
|
||||
}
|
||||
```
|
||||
|
||||
**Supported parameter types:**
|
||||
|
||||
| Type | Example | Notes |
|
||||
|------|---------|-------|
|
||||
| string | `'hello'` | Auto-escaped, quotes handled |
|
||||
| number | `123`, `45.67` | No quotes added |
|
||||
| boolean | `true`, `false` | Converted correctly |
|
||||
| Date | `new Date()` | Formatted for PocketBase |
|
||||
| null | `null` | For null comparisons |
|
||||
| other | `{...}` | JSON.stringify() applied |
|
||||
|
||||
**Server-side is especially critical:**
|
||||
|
||||
```javascript
|
||||
// Server-side code (Node.js, Deno, etc.) MUST use binding
|
||||
// because malicious users control the input directly
|
||||
|
||||
export async function searchHandler(req) {
|
||||
const userQuery = req.query.q; // Untrusted input!
|
||||
|
||||
// ALWAYS use pb.filter() on server
|
||||
const results = await pb.collection('posts').getList(1, 20, {
|
||||
filter: pb.filter('title ~ {:q}', { q: userQuery })
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [PocketBase Filters](https://pocketbase.io/docs/api-rules-and-filters/)
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Initialize PocketBase Client Correctly
|
||||
impact: HIGH
|
||||
impactDescription: Proper setup enables auth persistence, SSR support, and optimal performance
|
||||
tags: sdk, initialization, client, setup
|
||||
---
|
||||
|
||||
## Initialize PocketBase Client Correctly
|
||||
|
||||
Client initialization should consider the environment (browser, Node.js, SSR), auth store persistence, and any required polyfills.
|
||||
|
||||
**Incorrect (environment-agnostic initialization):**
|
||||
|
||||
```javascript
|
||||
// Missing polyfills in Node.js
|
||||
import PocketBase from 'pocketbase';
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// Node.js: EventSource not defined error on realtime
|
||||
pb.collection('posts').subscribe('*', callback); // Fails!
|
||||
|
||||
// Missing base URL
|
||||
const pb = new PocketBase(); // Uses '/' - likely wrong
|
||||
```
|
||||
|
||||
**Correct (environment-aware initialization):**
|
||||
|
||||
```javascript
|
||||
// Browser setup (no polyfills needed)
|
||||
// IMPORTANT: Use HTTPS in production (http is only for local development)
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090'); // Use https:// in production
|
||||
|
||||
// Node.js setup (requires polyfills for realtime)
|
||||
import PocketBase from 'pocketbase';
|
||||
import { EventSource } from 'eventsource';
|
||||
|
||||
// Global polyfill for realtime
|
||||
global.EventSource = EventSource;
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// React Native setup (async auth store)
|
||||
import PocketBase, { AsyncAuthStore } from 'pocketbase';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import EventSource from 'react-native-sse';
|
||||
|
||||
global.EventSource = EventSource;
|
||||
|
||||
const store = new AsyncAuthStore({
|
||||
save: async (serialized) => AsyncStorage.setItem('pb_auth', serialized),
|
||||
initial: AsyncStorage.getItem('pb_auth'),
|
||||
});
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090', store);
|
||||
```
|
||||
|
||||
**SSR initialization (per-request client):**
|
||||
|
||||
```javascript
|
||||
// SvelteKit example
|
||||
// src/hooks.server.js
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
// Create fresh client for each request
|
||||
event.locals.pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// Load auth from request cookie
|
||||
event.locals.pb.authStore.loadFromCookie(
|
||||
event.request.headers.get('cookie') || ''
|
||||
);
|
||||
|
||||
// Validate token
|
||||
if (event.locals.pb.authStore.isValid) {
|
||||
try {
|
||||
await event.locals.pb.collection('users').authRefresh();
|
||||
} catch {
|
||||
event.locals.pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const response = await resolve(event);
|
||||
|
||||
// Send updated auth cookie with secure options
|
||||
response.headers.append(
|
||||
'set-cookie',
|
||||
event.locals.pb.authStore.exportToCookie({
|
||||
httpOnly: true, // Prevent XSS access to auth token
|
||||
secure: true, // HTTPS only
|
||||
sameSite: 'Lax', // CSRF protection
|
||||
})
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
**TypeScript with typed collections:**
|
||||
|
||||
```typescript
|
||||
import PocketBase, { RecordService } from 'pocketbase';
|
||||
|
||||
// Define your record types
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
// Create typed client interface
|
||||
interface TypedPocketBase extends PocketBase {
|
||||
collection(idOrName: string): RecordService;
|
||||
collection(idOrName: 'users'): RecordService<User>;
|
||||
collection(idOrName: 'posts'): RecordService<Post>;
|
||||
}
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090') as TypedPocketBase;
|
||||
|
||||
// Now methods are typed
|
||||
const post = await pb.collection('posts').getOne('abc'); // Returns Post
|
||||
const users = await pb.collection('users').getList(); // Returns ListResult<User>
|
||||
```
|
||||
|
||||
Reference: [PocketBase JS SDK](https://github.com/pocketbase/js-sdk)
|
||||
175
.claude/skills/pocketbase-best-practices/rules/sdk-send-hooks.md
Normal file
175
.claude/skills/pocketbase-best-practices/rules/sdk-send-hooks.md
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Use Send Hooks for Request Customization
|
||||
impact: MEDIUM
|
||||
impactDescription: Custom headers, logging, response transformation
|
||||
tags: sdk, hooks, middleware, headers, logging
|
||||
---
|
||||
|
||||
## Use Send Hooks for Request Customization
|
||||
|
||||
The SDK provides `beforeSend` and `afterSend` hooks for intercepting and modifying requests and responses globally.
|
||||
|
||||
**Incorrect (repeating logic in every request):**
|
||||
|
||||
```javascript
|
||||
// Adding headers to every request manually
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
headers: { 'X-Custom-Header': 'value' }
|
||||
});
|
||||
|
||||
const users = await pb.collection('users').getList(1, 20, {
|
||||
headers: { 'X-Custom-Header': 'value' } // Repeated!
|
||||
});
|
||||
|
||||
// Logging each request manually
|
||||
console.log('Fetching posts...');
|
||||
const posts = await pb.collection('posts').getList();
|
||||
console.log('Done');
|
||||
```
|
||||
|
||||
**Correct (using send hooks):**
|
||||
|
||||
```javascript
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
// beforeSend - modify requests before they're sent
|
||||
pb.beforeSend = function(url, options) {
|
||||
// Add custom headers to all requests
|
||||
options.headers = Object.assign({}, options.headers, {
|
||||
'X-Custom-Header': 'value',
|
||||
'X-Request-ID': crypto.randomUUID()
|
||||
});
|
||||
|
||||
// Log outgoing requests
|
||||
console.log(`[${options.method}] ${url}`);
|
||||
|
||||
// Must return { url, options }
|
||||
return { url, options };
|
||||
};
|
||||
|
||||
// afterSend - process responses
|
||||
pb.afterSend = function(response, data) {
|
||||
// Log response status
|
||||
console.log(`Response: ${response.status}`);
|
||||
|
||||
// Transform or extend response data
|
||||
if (data && typeof data === 'object') {
|
||||
data._fetchedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Return the (possibly modified) data
|
||||
return data;
|
||||
};
|
||||
|
||||
// All requests now automatically have headers and logging
|
||||
const posts = await pb.collection('posts').getList();
|
||||
const users = await pb.collection('users').getList();
|
||||
```
|
||||
|
||||
**Practical examples:**
|
||||
|
||||
```javascript
|
||||
// Request timing / performance monitoring
|
||||
let requestStart;
|
||||
pb.beforeSend = function(url, options) {
|
||||
requestStart = performance.now();
|
||||
return { url, options };
|
||||
};
|
||||
|
||||
pb.afterSend = function(response, data) {
|
||||
const duration = performance.now() - requestStart;
|
||||
console.log(`${response.url}: ${duration.toFixed(2)}ms`);
|
||||
|
||||
// Send to analytics
|
||||
trackApiPerformance(response.url, duration);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// Add auth token from different source
|
||||
pb.beforeSend = function(url, options) {
|
||||
const externalToken = getTokenFromExternalAuth();
|
||||
if (externalToken) {
|
||||
options.headers = Object.assign({}, options.headers, {
|
||||
'X-External-Auth': externalToken
|
||||
});
|
||||
}
|
||||
return { url, options };
|
||||
};
|
||||
|
||||
// Handle specific response codes globally
|
||||
pb.afterSend = function(response, data) {
|
||||
if (response.status === 401) {
|
||||
// Token expired - trigger re-auth
|
||||
handleAuthExpired();
|
||||
}
|
||||
|
||||
if (response.status === 503) {
|
||||
// Service unavailable - show maintenance message
|
||||
showMaintenanceMode();
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// Retry failed requests (simplified example)
|
||||
const originalSend = pb.send.bind(pb);
|
||||
pb.send = async function(path, options) {
|
||||
try {
|
||||
return await originalSend(path, options);
|
||||
} catch (error) {
|
||||
if (error.status === 429) { // Rate limited
|
||||
await sleep(1000);
|
||||
return originalSend(path, options); // Retry once
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Add request correlation for debugging
|
||||
let requestId = 0;
|
||||
pb.beforeSend = function(url, options) {
|
||||
requestId++;
|
||||
const correlationId = `req-${Date.now()}-${requestId}`;
|
||||
|
||||
options.headers = Object.assign({}, options.headers, {
|
||||
'X-Correlation-ID': correlationId
|
||||
});
|
||||
|
||||
console.log(`[${correlationId}] Starting: ${url}`);
|
||||
return { url, options };
|
||||
};
|
||||
|
||||
pb.afterSend = function(response, data) {
|
||||
console.log(`Complete: ${response.status}`);
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
**Hook signatures:**
|
||||
|
||||
```typescript
|
||||
// beforeSend
|
||||
beforeSend?: (
|
||||
url: string,
|
||||
options: SendOptions
|
||||
) => { url: string; options: SendOptions } | Promise<{ url: string; options: SendOptions }>;
|
||||
|
||||
// afterSend
|
||||
afterSend?: (
|
||||
response: Response,
|
||||
data: any
|
||||
) => any | Promise<any>;
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Add custom headers (API keys, correlation IDs)
|
||||
- Request/response logging
|
||||
- Performance monitoring
|
||||
- Global error handling
|
||||
- Response transformation
|
||||
- Authentication middleware
|
||||
|
||||
Reference: [PocketBase Send Hooks](https://github.com/pocketbase/js-sdk#send-hooks)
|
||||
Reference in New Issue
Block a user