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