{post.title}
{post.content}
_ │
│ - Store SHA-256 hash in DB │
│ - Return full key (ONCE ONLY) │
└──────┬──────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Admin stores key securely: │
│ - Environment variable │
│ - Secret manager (Vault, AWS) │
│ - Configuration file (secured) │
└──────┬──────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Service uses API key: │
│ Authorization: Bearer sb_sk_... │
└──────┬──────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Server validates: │
│ 1. Extract key from header │
│ 2. Hash with SHA-256 │
│ 3. Lookup hash in database │
│ 4. Check not revoked │
│ 5. Get associated user/account │
└──────┬──────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Request processed with user │
│ context from API key │
└─────────────────────────────────┘
```
### API Key Data Model
```python theme={null}
# API Key in Database
{
"id": "ak_abc123xyz", # Key ID (public)
"key_hash": "", # Hashed API key (never plaintext)
"account_id": "550e8400-...", # Associated account
"account_code": "AB1234", # Human-readable account code
"created_by": "usr_abc123", # User who created the key
"name": "Production Service", # Human-readable name
"description": "Backend API for prod", # Optional description
"last_used_at": "2026-01-17T15:45:00Z", # Last authentication timestamp
"is_revoked": false, # Revocation status
"created_at": "2026-01-01T00:00:00Z",
"revoked_at": null, # Set when revoked
"revoked_by": null # User who revoked it
}
```
**Security Properties:**
* **SHA-256 Hashing**: Keys are hashed before storage (plaintext never persisted)
* **Single-User Association**: Each key is linked to one user in one account
* **Immediate Revocation**: Keys can be revoked instantly
* **Audit Trail**: Tracks creation, last used, and revocation
* **Account Scoping**: Keys are automatically scoped to their account
### Authentication Comparison
| Feature | API Keys | JWT Tokens |
| -------------------- | --------------------------------- | --------------------------------- |
| **Use Case** | Service-to-service, CLI, webhooks | Browser, mobile apps |
| **Lifetime** | Indefinite (until revoked) | Access: 1 hour, Refresh: 7 days |
| **Storage** | Server-side (hash) | Client-side (localStorage/cookie) |
| **Visibility** | Full key shown only at creation | Tokens visible in responses |
| **Rotation** | Manual (create new, revoke old) | Automatic (on refresh) |
| **Revocation** | Immediate | On refresh or logout |
| **User Context** | Single user per key | Can include user, role, account |
| **Security** | SHA-256 hashed at rest | Signed, verifiable signature |
| **Token Management** | Not applicable | Required (refresh mechanism) |
| **Session Support** | None (stateless) | Yes (with refresh tokens) |
### Creating API Keys
**Via API**:
```bash theme={null}
# Create API key
curl -X POST http://localhost:8000/api/v1/api-keys/ \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"name": "Production Service",
"description": "Backend API for production environment"
}'
# Response (only time full key is shown)
{
"id": "ak_abc123xyz",
"name": "Production Service",
"description": "Backend API for production environment",
"key": "sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", # SAVE THIS!
"account_id": "AB1234",
"created_at": "2026-01-17T10:30:00Z"
}
```
**Via Admin UI**:
1. Navigate to Settings → API Keys
2. Click "Create API Key"
3. Enter name and description
4. Copy the displayed key (shown only once)
5. Store securely in your application
### Using API Keys
API keys use the standard `Authorization: Bearer` header:
```bash theme={null}
# Using API key for authentication
curl -X GET http://localhost:8000/api/v1/posts \
-H "Authorization: Bearer sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
# Creating a record
curl -X POST http://localhost:8000/api/v1/posts \
-H "Authorization: Bearer sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
-H "Content-Type: application/json" \
-d '{
"title": "Hello World",
"content": "Created with API key"
}'
```
**In Code**:
```python theme={null}
import requests
# Configure API key
API_KEY = "sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
headers = {"Authorization": f"Bearer {API_KEY}"}
# Make requests
response = requests.get(
"http://localhost:8000/api/v1/posts",
headers=headers
)
```
```javascript theme={null}
// Using fetch
const API_KEY = "sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6";
const response = await fetch("http://localhost:8000/api/v1/posts", {
headers: {
"Authorization": `Bearer ${API_KEY}`
}
});
```
### Managing API Keys
**List API Keys**:
```bash theme={null}
curl -X GET http://localhost:8000/api/v1/api-keys/ \
-H "Authorization: Bearer "
```
**Response** (metadata only, no full keys):
```json theme={null}
{
"items": [
{
"id": "ak_abc123xyz",
"name": "Production Service",
"description": "Backend API",
"created_at": "2026-01-17T10:30:00Z",
"last_used_at": "2026-01-17T15:45:00Z",
"is_revoked": false
}
]
}
```
**Revoke API Key**:
```bash theme={null}
curl -X POST http://localhost:8000/api/v1/api-keys/ak_abc123xyz/revoke \
-H "Authorization: Bearer "
```
**Get Key Details**:
```bash theme={null}
curl -X GET http://localhost:8000/api/v1/api-keys/ak_abc123xyz \
-H "Authorization: Bearer "
```
### Security Best Practices
**1. Secure Storage**:
```python theme={null}
# ✅ Good: Environment variable
import os
API_KEY = os.environ.get("SNACKBASE_API_KEY")
# ✅ Good: Secret manager
import boto3
client = boto3.client('secretsmanager')
response = client.get_secret_value(SecretId='snackbase/api_key')
API_KEY = response['SecretString']
# ❌ Bad: Hardcoded in source
API_KEY = "sb_sk_AB1234_..." # NEVER do this
# ❌ Bad: Committed to version control
# .env files should be in .gitignore
```
**2. Key Rotation**:
```bash theme={null}
# 1. Create new key
new_key_response=$(curl -s -X POST http://localhost:8000/api/v1/api-keys/ \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"name": "Rotated Key"}')
# 2. Extract key
new_key=$(echo "$new_key_response" | jq -r '.key')
# 3. Update application configuration
export SNACKBASE_API_KEY="$new_key"
# 4. Verify new key works
curl -H "Authorization: Bearer $new_key" http://localhost:8000/api/v1/auth/me
# 5. Revoke old key
curl -X POST http://localhost:8000/api/v1/api-keys/ak_old_key/revoke \
-H "Authorization: Bearer "
```
**3. Scoping and Naming**:
```json theme={null}
{
"name": "Production - Payment Service",
"description": "Used by payment processing service in production environment"
}
```
Use descriptive names to identify:
* Environment (Production, Staging, Development)
* Service (Payment Service, Webhook Handler, CLI)
* Purpose (Backup Job, Monitoring Integration)
**4. Monitoring and Auditing**:
```bash theme={null}
# Check for unused keys
curl -X GET http://localhost:8000/api/v1/api-keys/ \
-H "Authorization: Bearer " | \
jq '.items[] | select(.last_used_at == null)'
# Check for old keys (not used in 90 days)
# Implement automated monitoring and alerting
```
**5. Revocation on Compromise**:
If an API key is accidentally exposed (committed to repo, logged, etc.):
1. Immediately revoke the compromised key
2. Create a replacement key
3. Update all services using the old key
4. Investigate potential unauthorized access
5. Review audit logs for suspicious activity
### API Key vs User Permissions
API keys inherit the permissions of the user who created them:
```python theme={null}
# Admin user creates API key
# → API key has admin permissions
# Viewer user creates API key
# → API key has viewer permissions
# Key permissions are tied to creating user's role
```
This means:
* Create dedicated service users with minimal required permissions
* Don't use personal admin accounts to create production API keys
* Regularly audit which users have created API keys
### Common Patterns
**Service Authentication**:
```python theme={null}
# Backend service calling SnackBase
import os
import requests
API_KEY = os.environ["SNACKBASE_API_KEY"]
headers = {"Authorization": f"Bearer {API_KEY}"}
def create_post(title, content):
response = requests.post(
"http://localhost:8000/api/v1/posts",
headers=headers,
json={"title": title, "content": content}
)
return response.json()
```
**CLI Tool**:
```bash theme={null}
# Configure CLI with API key
snackbase config set api-key sb_sk_AB1234_...
# CLI uses stored key for all commands
snackbase posts list
snackbase posts create --title "Hello"
```
**Webhook Handler**:
```python theme={null}
# Webhook endpoint validates API key
from fastapi import Header, HTTPException
async def webhook_handler(
authorization: str = Header(...)
):
if not authorization.startswith("Bearer sb_sk_"):
raise HTTPException(401, "Invalid API key")
# Process webhook
```
## Security Features
### Password Hashing (Argon2id)
SnackBase uses **Argon2id**, the OWASP-recommended password hashing algorithm:
```python theme={null}
import argon2
# Password hasher configuration
hasher = argon2.PasswordHasher(
time_cost=3, # Number of iterations
memory_cost=65536, # Memory in KiB (64 MB)
parallelism=4, # Number of threads
hash_len=32, # Hash length
salt_len=16 # Salt length
)
# Hash password
password_hash = hasher.hash("SecurePass123!")
# $argon2id$v=19$m=65536,t=3,p=4$...
# Verify password (timing-safe)
is_valid = hasher.verify(password_hash, "SecurePass123!")
```
### Password Requirements
Default password requirements (configurable):
| Requirement | Minimum |
| ----------------- | ------------ |
| Length | 8 characters |
| Uppercase | 1 character |
| Lowercase | 1 character |
| Number | 1 digit |
| Special character | 1 character |
### Token Expiration
| Token Type | Default Lifetime | Configurable Via |
| ------------- | ---------------- | --------------------------------------- |
| Access Token | 1 hour | `SNACKBASE_ACCESS_TOKEN_EXPIRE_MINUTES` |
| Refresh Token | 7 days | `SNACKBASE_REFRESH_TOKEN_EXPIRE_DAYS` |
## Best Practices
### 1. Token Storage
**For Web Applications:**
```javascript theme={null}
// ✅ Recommended: HttpOnly cookies for refresh tokens
// Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Strict
// ⚠️ Acceptable: localStorage for access token only
localStorage.setItem("access_token", token);
// ❌ Avoid: localStorage for refresh tokens
localStorage.setItem("refresh_token", token); // Vulnerable to XSS
```
### 2. Token Refresh
Implement proactive token refresh:
```javascript theme={null}
// Refresh token 5 minutes before expiration
const token = parseJwt(access_token);
const expiresAt = token.exp * 1000;
const now = Date.now();
const refreshBefore = 5 * 60 * 1000; // 5 minutes
if (expiresAt - now < refreshBefore) {
await refreshToken();
}
```
### 3. Handle Token Expiration
```javascript theme={null}
// Axios interceptor for automatic token refresh
axios.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Access token expired
try {
const newToken = await refreshToken();
// Retry original request
return axios.request(error.config);
} catch {
// Refresh token expired - redirect to login
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);
```
### 4. Logout Properly
```javascript theme={null}
async function logout() {
// Clear tokens from storage
localStorage.removeItem("access_token");
document.cookie = "refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
// Call backend logout to revoke refresh token
await axios.post("/api/v1/auth/logout");
// Redirect to login
window.location.href = "/login";
}
```
### 5. Use HTTPS in Production
Never send tokens over unencrypted connections:
```bash theme={null}
# ❌ Development only
http://localhost:8000
# ✅ Production
https://yourdomain.com
```
## Summary
| Concept | Key Takeaway |
| ------------------------ | ------------------------------------------------------------------------------ |
| **User Identity** | Defined by `(email, account_id)` tuple |
| **Account Registration** | Creates new tenant with UUID primary key and `XX####` display code |
| **User Registration** | Creates user within specific account, email unique per account |
| **Email Verification** | Required for login, tokens expire in 1 hour, single-use |
| **Login Flow** | Resolve account → Find user → Check verification → Verify password → Issue JWT |
| **Token Management** | Access token (1 hour) + Refresh token (7 days) with true rotation |
| **OAuth Authentication** | Redirect → Authorize → Callback → Exchange tokens → Create/update user |
| **SAML Authentication** | SSO request → IdP → ACS response → Verify → Create/update user |
| **Multi-Account Users** | Same email can exist in multiple accounts with different passwords |
| **Security** | Argon2id hashing, timing-safe comparison, token rotation, HTTPS required |
| **Configuration** | Hierarchical: system-level defaults → account-level overrides |
# Background Jobs
Source: https://docs.snackbase.dev/concepts/background-jobs
Asynchronous job execution engine for reliable background processing
SnackBase includes a **background job engine** that powers reliable asynchronous execution for webhooks, scheduled hooks, workflow delays, and email delivery. Jobs are managed by superadmins through the Admin API.
## Overview
The job system is the execution substrate that other automation features build on:
* **Webhook deliveries** are dispatched as jobs with automatic retries
* **Scheduled hooks** are executed via jobs on their cron schedule
* **Workflow delays** (`wait_delay` steps) enqueue resume jobs
* **Email sending** is handled by background jobs
### Key Features
* **Priority Queue**: Jobs execute in priority order (lower number = higher priority)
* **Automatic Retries**: Failed jobs retry with exponential backoff
* **Stale Job Recovery**: Jobs stuck in `running` state are automatically recovered
* **Job Statistics**: Aggregate counts by status with failure rate
* **Retention Cleanup**: Completed jobs are automatically purged after a configurable period
## Job Lifecycle
```
pending ──> running ──> completed
├──> failed ──> retrying ──> running (retry)
│ └──> dead (retries exhausted)
└──> (cancelled by admin, only from pending)
```
### Job Statuses
| Status | Meaning |
| ----------- | ------------------------------ |
| `pending` | Queued, waiting to execute |
| `running` | Currently executing |
| `completed` | Finished successfully |
| `failed` | Execution failed (may retry) |
| `retrying` | Waiting to retry after failure |
| `dead` | All retry attempts exhausted |
## Retry Logic
Failed jobs retry automatically with exponential backoff:
```
delay = retry_delay_seconds * 2^attempt_number
```
| Attempt | Delay (default base: 60s) |
| --------- | ------------------------- |
| 1st retry | 60 seconds |
| 2nd retry | 120 seconds |
| 3rd retry | 240 seconds |
Default `max_retries` is 3. Once exhausted, the job transitions to `dead`.
## Built-in Job Handlers
| Handler | Purpose |
| ------------------ | ----------------------------------------------- |
| `webhook_delivery` | Deliver outbound webhook payloads |
| `send_email` | Send emails via the configured provider |
| `scheduled_task` | Execute scheduled tasks |
| `workflow_resume` | Resume a workflow instance after a `wait_delay` |
| `scheduled_hook` | Execute a scheduled API-defined hook's actions |
## Admin API
The Jobs API is restricted to **superadmins only**. It provides a system-wide view across all accounts.
### Job Statistics
Get aggregate counts and failure rate:
```bash theme={null}
curl https://api.snackbase.dev/api/v1/admin/jobs/stats \
-H "Authorization: Bearer {superadmin_token}"
```
Response:
```json theme={null}
{
"pending": 12,
"running": 3,
"completed": 1547,
"failed": 8,
"retrying": 2,
"dead": 1,
"avg_duration_seconds": null,
"failure_rate": 0.0058
}
```
The `failure_rate` is calculated as `(failed + dead) / (completed + failed + dead)`.
### List Jobs
Filter by status, queue, or handler:
```bash theme={null}
curl "https://api.snackbase.dev/api/v1/admin/jobs?status=failed&handler=webhook_delivery" \
-H "Authorization: Bearer {superadmin_token}"
```
### Retry a Job
Manually retry a `dead`, `failed`, or `retrying` job:
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/admin/jobs/{job_id}/retry \
-H "Authorization: Bearer {superadmin_token}"
```
This resets the job to `pending` with `attempt_number` reset to 0.
### Cancel a Job
Cancel a `pending` job (only pending jobs can be cancelled):
```bash theme={null}
curl -X DELETE https://api.snackbase.dev/api/v1/admin/jobs/{job_id} \
-H "Authorization: Bearer {superadmin_token}"
```
## Job Fields
| Field | Type | Description |
| --------------------- | -------- | ----------------------------------------------- |
| `id` | string | UUID |
| `queue` | string | Queue name (default: `"default"`) |
| `handler` | string | Registered handler identifier |
| `payload` | object | JSON payload passed to handler |
| `status` | string | Current job status |
| `priority` | integer | Execution priority (lower = higher, default: 0) |
| `run_at` | datetime | Earliest execution time (null = immediately) |
| `started_at` | datetime | When execution began |
| `completed_at` | datetime | When completed successfully |
| `failed_at` | datetime | When last failed |
| `error_message` | string | Most recent error (truncated to \~5000 chars) |
| `attempt_number` | integer | Attempts made so far |
| `max_retries` | integer | Max retry attempts (default: 3) |
| `retry_delay_seconds` | integer | Base retry delay (default: 60) |
| `account_id` | string | Account context (null for system jobs) |
| `created_by` | string | User who enqueued (null for system jobs) |
## Worker Configuration
The job worker is configured via application settings:
| Setting | Description |
| -------------------------- | -------------------------------------------------- |
| `job_worker_poll_interval` | How often the worker checks for new jobs (seconds) |
| `job_execution_timeout` | Maximum execution time per job (seconds) |
| `job_retention_days` | How long to keep completed jobs before cleanup |
### Graceful Shutdown
When the worker shuts down, any currently running job is reset to `pending` so it can be picked up again.
### Stale Job Recovery
Jobs stuck in `running` state longer than the execution timeout are automatically recovered and reset to `pending`.
# Collections Model
Source: https://docs.snackbase.dev/concepts/collections
Dynamic schemas, table generation, and auto-generated APIs
SnackBase's **Collections** are the core abstraction for defining data schemas and generating APIs. This guide explains how collections work, the dynamic table system, and implications for developers.
## Overview
In traditional databases, you define tables with schemas. In SnackBase, you define **Collections**, which:
1. Store schema metadata in the `collections` table
2. Create/update physical tables dynamically
3. Auto-generate REST API endpoints
4. Provide validation and type safety
5. Support relationships between collections
6. Protect sensitive data with PII masking
## What is a Collection?
A **Collection** is a named data schema with fields, types, and configuration options.
### Collection Structure
```json theme={null}
{
"id": "col_abc123",
"name": "posts",
"description": "Blog posts and articles",
"account_id": "AB1001",
"migration_revision": "20250101_create_posts",
"schema": {
"fields": [
{ "name": "title", "type": "text", "required": true },
{ "name": "content", "type": "text", "required": false },
{ "name": "status", "type": "text", "default": "draft" },
{ "name": "views", "type": "number", "default": 0 },
{ "name": "published_at", "type": "datetime" },
{ "name": "author_email", "type": "email" },
{ "name": "cover_image", "type": "url" },
{ "name": "metadata", "type": "json" }
]
},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}
```
### Components of a Collection
| Component | Purpose | Example |
| ----------------------- | ------------------------------------------------------ | --------------------------- |
| **name** | Unique identifier, becomes table name | `posts` → `col_posts` table |
| **description** | Human-readable description | "Blog posts and articles" |
| **schema** | Field definitions with types and validation | See field types below |
| **account\_id** | Owner account (for collection management) | `AB1001` |
| **migration\_revision** | Tracks which migration created/modified the collection | `20250101_create_posts` |
## Collection vs Table
Understanding the distinction is critical:
### The Confusion
```
❌ Common Misconception:
"Creating a collection creates a separate table for each account"
✓ Reality:
"Creating a collection creates ONE shared table for ALL accounts"
```
### How It Actually Works
When you create a collection named `posts`:
1. **Schema Definition**: Stored in `collections` table (metadata)
2. **Table Creation**: Physical `col_posts` table created (if doesn't exist)
3. **API Generation**: `/api/v1/records/posts` endpoints registered
4. **Usage**: All accounts use the same physical table
```
collections table (metadata):
┌─────────────┬──────────────┬─────────────┬────────────────────────┐
│ id │ name │ account_id │ migration_revision │
├─────────────┼──────────────┼─────────────┼────────────────────────┤
│ col_abc123 │ posts │ AB1001 │ 20250101_create_posts │ ← Who created it
│ col_def456 │ products │ XY2048 │ 20250102_products │
└─────────────┴──────────────┴─────────────┴────────────────────────┘
col_posts table (actual data - ONE table for ALL accounts):
┌─────────────┬─────────────┬───────────────┬─────────────┬───────────────┬─────────────┐
│ id │ title │ content │ account_id │ created_at │ updated_by │
├─────────────┼─────────────┼───────────────┼─────────────┼───────────────┼─────────────┤
│ post_001 │ Hello │ Welcome... │ AB1001 │ 2025-01-01... │ user_123 │ ← AB1001's data
│ post_002 │ Acme News │ Latest... │ AB1001 │ 2025-01-02... │ user_456 │
│ post_003 │ Globex Post │ Update... │ XY2048 │ 2025-01-03... │ user_789 │ ← XY2048's data
└─────────────┴─────────────┴───────────────┴─────────────┴───────────────┴─────────────┘
```
### Table Naming Convention
**Critical**: Collection tables are prefixed with `col_` to avoid conflicts with system tables.
| Collection Name | Physical Table Name |
| --------------- | ------------------- |
| `posts` | `col_posts` |
| `products` | `col_products` |
| `users` | `col_users` |
### Why This Design?
| Approach | Pros | Cons | SnackBase |
| ------------------------ | --------------------------- | --------------------------------------- | ------------ |
| **Table per Account** | Complete isolation | Thousands of tables, complex migrations | ❌ |
| **Database per Account** | Maximum isolation | Complex operations, resource intensive | ❌ |
| **Shared Table** | Simple, scalable, efficient | Requires account filtering | ✅ **Chosen** |
## Field Types
Collections support multiple field types with built-in validation.
### Available Field Types
| Type | Description | Database Type | Example |
| ------------- | ------------------------------- | -------------- | ------------------------------------------- |
| **text** | Single-line text | `VARCHAR` | Name, title |
| **number** | Numeric value | `NUMERIC` | Price, quantity |
| **boolean** | True/false | `BOOLEAN` | is\_published |
| **datetime** | Date/time with validation | `TIMESTAMP` | published\_at, created\_at |
| **email** | Email with validation | `VARCHAR` | [user@example.com](mailto:user@example.com) |
| **url** | URL with validation | `VARCHAR` | [https://example.com](https://example.com) |
| **json** | JSON data | `JSONB` | metadata |
| **reference** | Reference to another collection | `VARCHAR` (FK) | user\_id |
| **file** | File upload reference | `VARCHAR` | avatar\_url |
### Field Configuration
Each field type has specific configuration options:
#### Text Field
```json theme={null}
{
"name": "title",
"type": "text",
"required": true,
"default": null,
"unique": false
}
```
#### Number Field
```json theme={null}
{
"name": "price",
"type": "number",
"required": true,
"default": 0,
"min": 0,
"max": 1000000
}
```
#### DateTime Field
```json theme={null}
{
"name": "published_at",
"type": "datetime",
"required": false,
"default": null
}
```
#### Email Field
```json theme={null}
{
"name": "author_email",
"type": "email",
"required": true,
"unique": true
}
```
#### URL Field
```json theme={null}
{
"name": "website",
"type": "url",
"required": false
}
```
#### JSON Field
```json theme={null}
{
"name": "metadata",
"type": "json",
"required": false,
"default": "{}"
}
```
#### Reference Field
```json theme={null}
{
"name": "author_id",
"type": "reference",
"target_collection": "users",
"on_delete": "set_null",
"required": false
}
```
#### File Field
```json theme={null}
{
"name": "attachment",
"type": "file",
"required": false,
"max_size": 10485760
}
```
## Dynamic Table Generation
SnackBase dynamically creates and modifies database tables based on collection schemas.
### Table Creation Flow
```
1. User creates collection via UI or API
POST /api/v1/collections
{
"name": "posts",
"schema": { "fields": [...] }
}
2. System validates collection name
- Must be alphanumeric with underscores
- Cannot conflict with system tables
- Cannot conflict with existing collections
3. System generates SQL
CREATE TABLE IF NOT EXISTS col_posts (
id VARCHAR(50) PRIMARY KEY,
account_id VARCHAR(10) NOT NULL,
title VARCHAR NOT NULL,
content TEXT,
status VARCHAR,
views NUMERIC DEFAULT 0,
published_at TIMESTAMP,
author_email VARCHAR,
cover_image VARCHAR,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
created_by VARCHAR(50),
updated_at TIMESTAMP DEFAULT NOW(),
updated_by VARCHAR(50),
FOREIGN KEY (account_id) REFERENCES accounts(id),
FOREIGN KEY (created_by) REFERENCES users(id),
FOREIGN KEY (updated_by) REFERENCES users(id)
);
4. System executes SQL via SQLAlchemy
5. System registers API routes
GET /api/v1/records/posts
POST /api/v1/records/posts
GET /api/v1/records/posts/:id
PUT /api/v1/records/posts/:id
DELETE /api/v1/records/posts/:id
```
### Built-in Fields
Every collection table includes **automatic fields** you don't need to define:
| Field | Type | Description | Auto-Managed |
| ------------ | ----------- | --------------------- | ---------------- |
| `id` | VARCHAR | Unique record ID | ✅ Auto-generated |
| `account_id` | VARCHAR(10) | Account isolation | ✅ Automatic |
| `created_at` | TIMESTAMP | Creation timestamp | ✅ Auto-set |
| `created_by` | VARCHAR(50) | Creator user ID | ✅ Auto-set |
| `updated_at` | TIMESTAMP | Last update timestamp | ✅ Auto-updated |
| `updated_by` | VARCHAR(50) | Last updater user ID | ✅ Auto-updated |
### Indexes and Constraints
SnackBase automatically creates:
```sql theme={null}
-- Primary key
PRIMARY KEY (id)
-- Account isolation index
INDEX idx_col_posts_account_id ON col_posts(account_id);
-- Timestamp indexes
INDEX idx_col_posts_created_at ON col_posts(created_at);
INDEX idx_col_posts_updated_at ON col_posts(updated_at);
-- Unique constraints (if specified)
UNIQUE (title) -- if field.unique = true
-- Foreign keys
FOREIGN KEY (account_id) REFERENCES accounts(id)
FOREIGN KEY (created_by) REFERENCES users(id)
FOREIGN KEY (updated_by) REFERENCES users(id)
```
## Auto-Generated APIs
Each collection automatically gets a complete REST API.
### Generated Endpoints
For a collection named `posts`:
| Method | Endpoint | Description | Permission |
| ------ | ---------------------------- | --------------------------------- | -------------------------------- |
| GET | `/api/v1/records/posts` | List all records (with filtering) | `posts:read` |
| POST | `/api/v1/records/posts` | Create a new record | `posts:create` |
| GET | `/api/v1/records/posts/:id` | Get single record | `posts:read` |
| PATCH | `/api/v1/records/posts/:id` | Partial update record | `posts:update` |
| PUT | `/api/v1/records/posts/:id` | Full update record | `posts:update` |
| DELETE | `/api/v1/records/posts/:id` | Delete record | `posts:delete` |
| POST | `/api/v1/records/posts/bulk` | Bulk operations | `posts:create`/`update`/`delete` |
### API Usage Examples
```bash theme={null}
# Create a record
POST /api/v1/records/posts
Authorization: Bearer
Content-Type: application/json
{
"title": "My First Post",
"content": "This is the content",
"status": "published",
"views": 0,
"author_email": "author@example.com",
"cover_image": "https://example.com/image.jpg",
"metadata": {
"seo_title": "SEO Title",
"tags": ["tag1", "tag2"]
}
}
# Response
{
"id": "post_abc123",
"title": "My First Post",
"content": "This is the content",
"status": "published",
"views": 0,
"author_email": "author@example.com",
"cover_image": "https://example.com/image.jpg",
"metadata": {
"seo_title": "SEO Title",
"tags": ["tag1", "tag2"]
},
"account_id": "AB1001",
"created_at": "2025-01-01T00:00:00Z",
"created_by": "user_xyz789",
"updated_at": "2025-01-01T00:00:00Z",
"updated_by": "user_xyz789"
}
```
### Query Filtering
List endpoints support powerful filtering:
```bash theme={null}
# Basic filtering
GET /api/v1/records/posts?status=published
# Multiple filters
GET /api/v1/records/posts?status=published&views.gt=100
# Sorting
GET /api/v1/records/posts?sort=-created_at
# Pagination
GET /api/v1/records/posts?page=1&limit=20
# Full text search (if configured)
GET /api/v1/records/posts?q=hello
# Date range filtering
GET /api/v1/records/posts?created_at.gte=2025-01-01T00:00:00Z
```
## PII Masking
SnackBase provides **automatic PII (Personally Identifiable Information) masking** to protect sensitive user data.
### How PII Masking Works
PII fields are automatically masked for users who don't have the `pii_access` group membership:
```json theme={null}
// User without pii_access sees:
{
"id": "user_123",
"email": "j***@example.com", // Masked
"phone": "***-***-1234", // Masked
"ssn": "***-**-****", // Masked
"name": "John D." // Masked
}
// User with pii_access sees:
{
"id": "user_123",
"email": "john.doe@example.com", // Full value
"phone": "555-123-4567", // Full value
"ssn": "123-45-6789", // Full value
"name": "John Doe" // Full value
}
```
### PII Mask Types
| Mask Type | Description | Example |
| ---------- | ----------------------------------------- | ------------------ |
| **email** | Shows first character and domain | `j***@example.com` |
| **ssn** | Shows only format | `***-**-****` |
| **phone** | Shows last 4 digits | `***-***-1234` |
| **name** | Shows first name initial and last initial | `John D.` |
| **full** | Completely hides value | `******` |
| **custom** | Custom mask pattern | Configurable |
### Configuring PII Fields
To enable PII masking on a field, use the `pii_mask` configuration:
```json theme={null}
{
"fields": [
{
"name": "email",
"type": "email",
"pii_mask": {
"enabled": true,
"mask_type": "email"
}
},
{
"name": "ssn",
"type": "text",
"pii_mask": {
"enabled": true,
"mask_type": "ssn"
}
},
{
"name": "phone",
"type": "text",
"pii_mask": {
"enabled": true,
"mask_type": "phone"
}
}
]
}
```
## Reference Fields
Reference fields allow you to create relationships between collections using foreign keys.
### Reference Field Configuration
```json theme={null}
{
"name": "author_id",
"type": "reference",
"target_collection": "users",
"on_delete": "set_null",
"required": false
}
```
### on\_delete Actions
| Action | Description | Use Case |
| ------------- | ----------------------------------------------- | ------------------------------------- |
| **cascade** | Delete referenced record when target is deleted | Dependent data (order items → orders) |
| **set\_null** | Set field to NULL when target is deleted | Optional relationships |
| **restrict** | Prevent deletion if referenced | Critical references (users → orders) |
### Reference Field Example
```json theme={null}
// Posts collection with author reference
{
"name": "posts",
"schema": {
"fields": [
{ "name": "title", "type": "text" },
{
"name": "author_id",
"type": "reference",
"target_collection": "users",
"on_delete": "set_null",
"required": false
}
]
}
}
```
This creates a foreign key constraint:
```sql theme={null}
ALTER TABLE col_posts
ADD CONSTRAINT fk_posts_author
FOREIGN KEY (author_id)
REFERENCES col_users(id)
ON DELETE SET NULL;
```
### Reference Validation
When creating or updating records with reference fields:
```bash theme={null}
# Valid reference
POST /api/v1/records/posts
{
"title": "My Post",
"author_id": "user_abc123" # ✅ Exists in users collection
}
# Invalid reference
POST /api/v1/records/posts
{
"title": "My Post",
"author_id": "user_xyz999" # ❌ Does not exist - 400 Bad Request
}
```
## Schema Evolution
Collections can evolve over time with schema updates.
### Supported Changes
| Change | Supported | Notes |
| -------------------- | --------- | ----------------------------- |
| Add new field | ✅ Yes | New field added to table |
| Remove field | ❌ **No** | Field deletion is NOT allowed |
| Rename field | ❌ **No** | Must create new field instead |
| Change type | ❌ **No** | Type changes are NOT allowed |
| Modify field options | ✅ Yes | Adding options is supported |
### Schema Update Flow
```
1. User updates collection schema
PUT /api/v1/collections/:id
{
"schema": { "fields": [...] }
}
2. System validates changes
- Field names are unique
- Types are valid
- No field deletions
- No type changes
3. System generates ALTER TABLE statements
ALTER TABLE col_posts ADD COLUMN category TEXT;
4. System executes SQL
5. Updated schema applies immediately
- New API validation
- Updated forms in UI
```
### Schema Evolution Rules
**Field Addition** (Only supported operation):
```json theme={null}
// Before: Collection with 3 fields
{
"name": "posts",
"schema": {
"fields": [
{ "name": "title", "type": "text" },
{ "name": "content", "type": "text" },
{ "name": "status", "type": "text" }
]
}
}
// After: Add new field "category"
{
"name": "posts",
"schema": {
"fields": [
{ "name": "title", "type": "text" },
{ "name": "content", "type": "text" },
{ "name": "status", "type": "text" },
{ "name": "category", "type": "text" } // ✅ New field added
]
}
}
```
**Field Deletion** (NOT allowed):
```json theme={null}
// Attempting to remove "status" field
{
"name": "posts",
"schema": {
"fields": [
{ "name": "title", "type": "text" },
{ "name": "content", "type": "text" }
// "status" removed - ❌ NOT ALLOWED
]
}
}
// System response: 400 Bad Request
{
"error": "Cannot remove fields from schema",
"detail": "Field removal is not supported. Fields can only be added."
}
```
**Type Changes** (NOT allowed):
```json theme={null}
// Attempting to change field type
{
"fields": [
{ "name": "views", "type": "text" } // ❌ Was "number", now "text"
]
}
// System response: 400 Bad Request
{
"error": "Cannot change field types",
"detail": "Type changes are not supported. Create a new field instead."
}
```
## Best Practices
### 1. Naming Conventions
Use **lowercase, plural** names for collections:
| Good | Bad |
| ------------- | ------------- |
| `posts` | `Posts` |
| `users` | `user` |
| `blog_posts` | `BlogPosts` |
| `order_items` | `order-items` |
### 2. Field Naming
Use **snake\_case** for field names:
```json theme={null}
{
"fields": [
{ "name": "first_name", "type": "text" }, // ✅ Good
{ "name": "lastName", "type": "text" }, // ❌ Bad
{ "name": "Email-Address", "type": "email" } // ❌ Bad
]
}
```
### 3. Use Appropriate Field Types
Choose the most specific type for your data:
```json theme={null}
{
"fields": [
{ "name": "email", "type": "email" }, // ✅ Specific type
{ "name": "published_at", "type": "datetime" }, // ✅ With validation
{ "name": "website", "type": "url" }, // ✅ URL validation
{ "name": "metadata", "type": "json" } // ✅ Flexible data
]
}
```
### 4. Use JSON for Flexible Data
For metadata or varying structures:
```json theme={null}
{
"name": "metadata",
"type": "json"
}
```
Store arbitrary data:
```json theme={null}
{
"metadata": {
"seo_title": "SEO Title",
"seo_description": "Description",
"tags": ["tag1", "tag2"],
"custom_field": "any value"
}
}
```
### 5. Plan Schema Evolution
Design schemas with evolution in mind:
* Only add new fields (deletion and type changes are not allowed)
* Use `required: false` for fields that might be optional later
* Document schema changes in migration revisions
* Test schema updates in development first
### 6. Use PII Masking for Sensitive Data
Protect user privacy with automatic PII masking:
```json theme={null}
{
"name": "email",
"type": "email",
"pii_mask": {
"enabled": true,
"mask_type": "email"
}
}
```
This ensures compliance with privacy regulations by default.
### 7. Choose Appropriate on\_delete Actions
Select the right action for reference fields:
| Scenario | Action |
| ---------------------------------------- | ---------- |
| Optional relationships (posts → authors) | `set_null` |
| Dependent data (order items → orders) | `cascade` |
| Critical references (prevent deletion) | `restrict` |
### 8. Avoid Too Many Collections
Each collection creates a table. Consider:
* Can related data be in the same collection?
* Would JSON fields work better for varying schemas?
* Do you really need separate tables?
**Example**: Instead of `blog_posts` and `news_posts`, use `posts` with a `category` field.
## Aggregation Queries
Collections support server-side aggregation via the `/{collection}/aggregate` endpoint:
| Function | Description |
| -------- | -------------------------- |
| `count` | Count matching records |
| `sum` | Sum a numeric field |
| `avg` | Average of a numeric field |
| `min` | Minimum value |
| `max` | Maximum value |
Aggregations support `filter`, `group_by`, and `having` clauses for powerful analytics without fetching raw records. See the [API Reference](/api-reference/introduction) for full details.
## Public Collections
Collections can be configured for **anonymous read access**, allowing unauthenticated users to query records. This is useful for public-facing data like product listings, blog posts, or FAQ content.
## Summary
| Concept | Key Takeaway |
| ------------------------- | -------------------------------------------------------------------------------------- |
| **Collection Definition** | Named schema with fields, stored in `collections` table |
| **Collection vs Table** | Collections are metadata; ONE shared table per collection for ALL accounts |
| **Table Naming** | Tables prefixed with `col_` (e.g., `col_posts`) |
| **Field Types** | 9 types available (text, number, boolean, datetime, email, url, json, reference, file) |
| **Dynamic Tables** | Tables created/modified automatically based on schema |
| **Auto-Generated APIs** | REST endpoints at `/api/v1/records/{collection}` |
| **PII Masking** | Automatic masking for sensitive data (email, ssn, phone, name) |
| **Reference Fields** | Foreign key relationships with on\_delete actions |
| **Schema Evolution** | Only adding fields is allowed; deletion and type changes are NOT supported |
| **Migration Tracking** | `migration_revision` field tracks schema changes |
| **Best Practices** | Use lowercase plurals, snake\_case fields, plan for evolution |
# Custom Endpoints
Source: https://docs.snackbase.dev/concepts/custom-endpoints
Define serverless-like HTTP endpoints without writing backend code
**Custom Endpoints** let you define serverless-like HTTP endpoints that execute action pipelines -- all configured through the REST API. Define a path, choose an HTTP method, configure actions, and your endpoint is live.
## Overview
Custom endpoints are dispatched through a dedicated URL namespace:
```
POST /api/v1/x/{your-slug}/{optional-path}
```
When a request arrives, SnackBase matches it to an endpoint definition, evaluates authorization conditions, executes the action pipeline, and returns the configured response.
### Key Features
* **No Code Required**: Define endpoints via API or Admin UI
* **Path Parameters**: Support for `:param` segments (e.g., `/users/:user_id/orders`)
* **Authorization Conditions**: Gate access with rule expressions
* **Action Pipelines**: Chain multiple actions with access to results from previous steps
* **Response Templates**: Customize response status, body, and headers
* **Execution History**: Track every invocation with timing and status
## How It Works
```
┌───────────────┐ ┌──────────────────────────────┐ ┌──────────────┐
│ HTTP Request │ │ Custom Endpoint Dispatcher │ │ Response │
│ │────>│ │────>│ │
│ POST /api/v1 │ │ 1. Match path + method │ │ Status code │
│ /x/feedback │ │ 2. Extract path params │ │ Body │
│ │ │ 3. Check auth (if required) │ │ Headers │
│ │ │ 4. Evaluate condition │ │ │
│ │ │ 5. Execute actions │ │ │
│ │ │ 6. Apply response template │ │ │
└───────────────┘ └──────────────────────────────┘ └──────────────┘
```
## Endpoint Definition
| Field | Type | Required | Description |
| ------------------- | ------- | -------- | ----------------------------------------------------------------------- |
| `name` | string | Yes | Human-readable name (max 200 chars) |
| `path` | string | Yes | URL path (max 500 chars), e.g., `/submit-feedback` or `/users/:user_id` |
| `method` | string | Yes | HTTP method: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
| `auth_required` | boolean | No | Require authentication (default: `true`) |
| `condition` | string | No | Rule expression -- returns 403 if false |
| `actions` | array | No | Ordered list of action configs (default: `[]`) |
| `response_template` | object | No | Custom response format |
| `enabled` | boolean | No | Active status (default: `true`) |
### Path Parameters
Use `:param` syntax to capture path segments:
```
/users/:user_id/orders/:order_id
```
These values are available as `{{request.params.user_id}}` and `{{request.params.order_id}}` in action templates.
### Uniqueness
Each endpoint is uniquely identified by the combination of `(account_id, path, method)`. You cannot create two endpoints with the same path and method within one account.
## Actions
Actions execute sequentially. Each action's result is available to subsequent actions.
### Supported Action Types
| Action Type | Description |
| ------------------- | ---------------------------------------------- |
| `send_webhook` | Send an HTTP request to an external URL |
| `send_email` | Send an email |
| `create_record` | Create a record in a collection |
| `update_record` | Update an existing record |
| `delete_record` | Delete a record |
| `enqueue_job` | Enqueue a background job |
| `query_records` | Query records from a collection |
| `aggregate_records` | Run an aggregation (count, sum, avg, min, max) |
| `transform` | Transform data using a template |
### Query Records
```json theme={null}
{
"type": "query_records",
"config": {
"collection": "orders",
"filter": "customer_id = '{{request.params.customer_id}}'",
"sort": "-created_at",
"limit": 10,
"offset": 0
}
}
```
### Aggregate Records
```json theme={null}
{
"type": "aggregate_records",
"config": {
"collection": "orders",
"function": "sum",
"field": "total",
"filter": "status = 'completed'",
"group_by": "category"
}
}
```
Supported functions: `count`, `sum`, `avg`, `min`, `max`.
### Transform
Reshape data from previous action results:
```json theme={null}
{
"type": "transform",
"config": {
"output": {
"total_orders": "{{actions[0].result}}",
"customer": "{{request.params.customer_id}}"
}
}
}
```
## Template Variables
All string values in action configs and response templates support these variables:
| Variable | Description |
| -------------------------- | --------------------------------------- |
| `{{request.body.field}}` | Value from the request body |
| `{{request.query.field}}` | URL query parameter |
| `{{request.params.field}}` | Path parameter (from `:param` segments) |
| `{{auth.user_id}}` | Authenticated user's ID |
| `{{auth.email}}` | Authenticated user's email |
| `{{auth.account_id}}` | Account ID |
| `{{actions[N].result}}` | Result of the Nth action (0-indexed) |
| `{{now}}` | Current UTC timestamp (ISO 8601) |
## Response Templates
Customize what your endpoint returns:
```json theme={null}
{
"response_template": {
"status": 200,
"body": {
"message": "Feedback received",
"order_count": "{{actions[0].result}}"
},
"headers": {
"X-Custom-Header": "value"
}
}
}
```
If no response template is configured, the endpoint returns HTTP 200 with the last action's result as the body.
## Authorization
### Authentication
Set `auth_required: true` (the default) to require a valid authentication token. Set to `false` for public endpoints.
### Condition Expressions
Add a `condition` to control who can access the endpoint:
```json theme={null}
{
"condition": "@has_role('admin') or @owns_record()"
}
```
If the condition evaluates to `false`, the endpoint returns 403 Forbidden.
## Example: Customer Order Summary Endpoint
```json theme={null}
{
"name": "Customer Order Summary",
"path": "/customers/:customer_id/summary",
"method": "GET",
"auth_required": true,
"actions": [
{
"type": "query_records",
"config": {
"collection": "orders",
"filter": "customer_id = '{{request.params.customer_id}}'",
"sort": "-created_at",
"limit": 5
}
},
{
"type": "aggregate_records",
"config": {
"collection": "orders",
"function": "sum",
"field": "total",
"filter": "customer_id = '{{request.params.customer_id}}'"
}
},
{
"type": "transform",
"config": {
"output": {
"recent_orders": "{{actions[0].result}}",
"lifetime_total": "{{actions[1].result}}"
}
}
}
],
"response_template": {
"status": 200,
"body": "{{actions[2].result}}"
}
}
```
Call it:
```bash theme={null}
curl https://api.snackbase.dev/api/v1/x/customers/cust-123/summary \
-H "Authorization: Bearer {token}"
```
## Limits
| Limit | Default |
| -------------------------- | ----------------- |
| Max endpoints per account | 20 (configurable) |
| Execution timeout | 30 seconds |
| Max action execution depth | 5 |
| Path max length | 500 characters |
# Multi-Tenancy Model
Source: https://docs.snackbase.dev/concepts/multi-tenancy
Account isolation, two-tier architecture, and tenant data management
SnackBase uses a **shared database, row-level isolation** multi-tenancy model. This guide explains how accounts work, how data is isolated, and what you need to know when building multi-tenant applications.
## Overview
SnackBase enables **Software-as-a-Service (SaaS)** applications by allowing multiple independent tenants (accounts) to coexist in a single database while maintaining complete data isolation.
### Key Characteristics
| Characteristic | Description |
| ------------------------ | ------------------------------------------------------------- |
| **Isolation Type** | Row-level isolation via `account_id` column |
| **Database Model** | Shared database, shared tables |
| **Account Scope** | All data (users, collections, records) scoped to `account_id` |
| **Cross-Account Access** | Not possible by design (enforced at database and API levels) |
## Account Model
### What is an Account?
An **Account** (also called a "tenant" or "organization") represents an isolated workspace containing:
* Users who belong to the account
* Collections (data schemas) defined for the account
* Records (actual data) created by the account's users
* Roles and permissions specific to the account
* Groups for organizing users
* Configuration overrides for providers (auth, email, storage)
### Account Hierarchy
```
SnackBase Instance
│
├── System Account (SY0000)
│ ├── Superadmin users
│ └── Manages all accounts
│
├── Account AB1001 (Acme Corp)
│ ├── Users: alice@acme.com, bob@acme.com
│ ├── Collections: posts, products, orders
│ ├── Roles: admin, editor, viewer
│ └── Records: (all scoped to account_id = "550e8400-...")
│
├── Account XY2048 (Globex Inc)
│ ├── Users: jane@globex.com
│ ├── Collections: customers, tickets
│ ├── Roles: support, manager
│ └── Records: (all scoped to account_id = "aabbccdd-...")
│
└── Account ZZ9999 (StartUp Co)
└── ... (completely isolated)
```
## Data Isolation
### How Isolation Works
Most tables in SnackBase include an `account_id` column that references the `accounts` table:
```sql theme={null}
-- Example: users table
┌─────────────┬──────────────────┬─────────────────────┐
│ id │ email │ account_id │
├─────────────┼──────────────────┼─────────────────────┤
│ user_abc123 │ alice@acme.com │ 550e8400-e29b-... │
│ user_def456 │ bob@acme.com │ 550e8400-e29b-... │
│ user_ghi789 │ jane@globex.com │ aabbccdd-1234-... │
└─────────────┴──────────────────┴─────────────────────┘
-- Example: Dynamic collection table (col_posts)
┌─────────────┬─────────────────────┬─────────────┬─────────────────────┐
│ id │ title │ content │ account_id │
├─────────────┼─────────────────────┼─────────────┼─────────────────────┤
│ post_001 │ Hello World │ Welcome... │ 550e8400-e29b-... │
│ post_002 │ Acme News │ Latest... │ 550e8400-e29b-... │
│ post_003 │ Globex Update │ News... │ aabbccdd-1234-... │
└─────────────┴─────────────────────┴─────────────┴─────────────────────┘
```
### Tables WITHOUT account\_id (Global Tables)
The following tables **do not have** an `account_id` column because they define global structures shared by all accounts:
| Table | Why No account\_id? |
| ------------- | ------------------------------------------------------------ |
| `accounts` | Defines accounts themselves (cannot be scoped to an account) |
| `roles` | Roles are global definitions shared by all accounts |
| `permissions` | Permissions are global rules shared by all accounts |
| `collections` | Collection schemas are global definitions (data is isolated) |
| `macros` | Macros are global SQL snippets shared by all accounts |
| `migrations` | Migrations are global and affect all accounts |
### Automatic Filtering
SnackBase **automatically filters** all queries by `account_id`. Users never see data from other accounts.
**Example API Request:**
```bash theme={null}
# User from AB1001 requests all posts
GET /api/v1/posts
# SQL executed (simplified):
SELECT * FROM col_posts WHERE account_id = '550e8400-e29b-41d4-a716-446655440000'
```
The user doesn't need to specify `account_id`—it's automatically added based on their authentication context.
### Enforcement Layers
Isolation is enforced at **multiple layers** for defense-in-depth:
| Layer | Mechanism | Details |
| --------------------- | -------------------------------------------------- | ------------------------------------------ |
| **Database** | `account_id` column with foreign key to accounts | Row-level filtering at SQL level |
| **Hook** | `account_isolation_hook` (priority -200) | Automatically injects `account_id` filters |
| **Repository** | All repositories enforce `account_id` in queries | Cannot bypass without explicit override |
| **API Middleware** | Authorization middleware validates account context | Checks permissions before execution |
| **Superadmin Bypass** | Superadmin can pass `account_id=None` | Allows cross-account visibility for admins |
## Two-Tier Architecture
SnackBase uses a **two-tier table architecture** that's critical to understand:
### Tier 1: Core System Tables
These tables define the platform structure and are shared across all accounts:
| Table | Purpose | Has account\_id? | Schema Changes |
| ------------- | ----------------------------- | --------------------- | -------------- |
| `accounts` | Account/tenant definitions | No (defines accounts) | Releases only |
| `users` | User identities (per-account) | Yes | Releases only |
| `roles` | Role definitions | No (global) | Releases only |
| `permissions` | Permission rules | No (global) | Releases only |
| `collections` | Collection schema definitions | No (global) | Releases only |
| `macros` | SQL macro definitions | No (global) | Releases only |
| `migrations` | Database migration history | No (global) | Automatic |
**Important**: Schema changes to these tables only happen via SnackBase releases.
### Tier 2: User-Created Collections
User collections are **single physical tables** shared by ALL accounts:
| Physical Table | Collection Name | Contains |
| -------------- | --------------- | -------------------------- |
| `col_posts` | "posts" | All accounts' post data |
| `col_products` | "products" | All accounts' product data |
| `col_orders` | "orders" | All accounts' order data |
**Critical Concept**: When you create a collection named "posts", you're creating:
1. A **schema definition** in the `collections` table (metadata)
2. A **physical table** named `col_posts` (if it doesn't exist)
3. All accounts' post data goes into this **single shared table**
### Physical Table Naming Convention
Collection tables are **prefixed with `col_`** to avoid conflicts with system tables:
| Collection Name | Physical Table Name | Example Query |
| --------------- | ------------------- | ------------------------------------------------------ |
| `posts` | `col_posts` | `SELECT * FROM col_posts WHERE account_id = ?` |
| `products` | `col_products` | `SELECT * FROM col_products WHERE account_id = ?` |
| `user_profiles` | `col_user_profiles` | `SELECT * FROM col_user_profiles WHERE account_id = ?` |
This prefix:
* Prevents naming conflicts with system tables
* Makes it clear which tables are user-created collections
* Allows easy identification of collection tables in database dumps
### Why This Architecture?
| Approach | Description | SnackBase Choice |
| ---------------------- | ------------------------------------------------------------------------- | ------------------------------------------- |
| **Separate Tables** | Each account gets their own `col_posts_AB1001`, `col_posts_XY2048` tables | ❌ Not scalable (thousands of tables) |
| **Separate Databases** | Each account gets their own database | ❌ Complex operations and migrations |
| **Shared Tables** | All accounts share one `col_posts` table with `account_id` | ✅ **Chosen for scalability and simplicity** |
## Account Identifiers
Accounts have **three distinct identifiers** that serve different purposes:
### Identifier Comparison
| Field | Format | Purpose | Example | Uniqueness |
| -------------- | ---------------- | ----------------------------------- | -------------------------------------- | --------------- |
| `id` | UUID (36 chars) | Primary key, foreign key references | `550e8400-e29b-41d4-a716-446655440000` | Globally unique |
| `account_code` | XX#### (6 chars) | Human-readable identifier | `AB1234` | Globally unique |
| `slug` | URL-friendly | Login and URL routing | `acme-corp` | Globally unique |
| `name` | Free text | Display name only | `Acme Corporation` | Not unique |
### Account ID (UUID)
The **internal primary key** for accounts is a standard UUID:
```
Format: 8-4-4-4-12 hexadecimal characters
Example: 550e8400-e29b-41d4-a716-446655440000
```
* **Purpose**: Primary key, used in foreign key references
* **Format**: Standard UUID v4 (36 characters)
* **Used by**: `account_id` columns in all tenant-scoped tables
* **Human-readable**: No (designed for systems, not humans)
### Account Code (XX####)
The **human-readable identifier** for accounts:
```
XX#### = 2 letters + 4 digits
Examples:
├── SY0000 (System account - reserved)
├── AB1001 (Acme Corp)
├── XY2048 (Globex Inc)
└── ZZ9999 (StartUp Co)
```
* **Letters (XX)**: Random uppercase letters A-Z
* **Digits (####)**: Sequential number starting from 0001
* **Total Capacity**: 6,760,000 unique codes (26×26×10,000)
* **Reserved Range**: SY#### (skipped during generation)
### Account Code Generation
Account codes are generated **sequentially** from the highest existing code:
```python theme={null}
# Generation logic
1. Find highest existing account code (e.g., AB2345)
2. Increment numeric portion (AB2346)
3. Skip SY#### range (reserved for system)
4. Assign to new account
```
**Important Notes**:
* Codes are never reused
* Sequential generation ensures predictability
* SY#### range is permanently reserved
* System account uses SY0000
### Identifier Usage
| Identifier | Used In... | Example |
| ----------------- | ---------------------------------- | ------------------------------------------------------ |
| **id** (UUID) | Foreign keys, `account_id` columns | `WHERE account_id = '550e8400-...'` |
| **account\_code** | Admin UI, support, logs | "Account AB1234" |
| **slug** | Login URLs, subdomain routing | `ab1234.snackbase.dev` or `/api/v1/accounts/acme-corp` |
| **name** | UI display, emails | "Welcome to Acme Corporation" |
## System Account vs User Accounts
### System Account (SY0000)
The **system account** is a special reserved account for superadmin operations:
| Attribute | Value |
| ---------------- | ---------------------------------------------------------- |
| **ID** | `00000000-0000-0000-0000-000000000000` (nil UUID) |
| **Account Code** | `SY0000` (fixed) |
| **Name** | "System" |
| **Purpose** | Superadmin operations, system-level configuration |
| **Access** | Superadmin users can operate across ALL accounts |
| **Data** | Contains minimal data (mostly metadata and system configs) |
**Superadmin users** are linked to the system account and have:
* Access to ALL accounts
* Ability to create/manage accounts
* Ability to manage global collections
* System-wide visibility (can pass `account_id=None` to see all data)
### User Accounts
**User accounts** are regular tenant accounts created by superadmins:
| Attribute | Value |
| ---------------- | ------------------------------------------------------------------ |
| **ID** | Auto-generated UUID (e.g., `550e8400-e29b-41d4-a716-446655440000`) |
| **Account Code** | Auto-generated (e.g., `AB1001`) |
| **Name** | User-defined (e.g., "Acme Corporation") |
| **Purpose** | Regular tenant operations |
| **Access** | Users can only access THEIR account |
| **Data** | Contains all tenant data (users, collections, records) |
**Regular users** (even with "admin" role) are linked to a specific account and have:
* Access ONLY to their account
* No cross-account visibility
* Full CRUD within their account (based on permissions)
## Multi-Account Users
### Enterprise Multi-Account Model
SnackBase supports **enterprise multi-account scenarios** where a single user can belong to multiple accounts with different roles and permissions.
### User Identity
A user's identity is defined by the **(email, account\_id) tuple**:
```
┌────────────────────┬─────────────────────┬──────────────┐
│ email │ account_id │ role │
├────────────────────┼─────────────────────┼──────────────┤
│ alice@acme.com │ 550e8400-e29b-... │ admin │
│ alice@acme.com │ aabbccdd-1234-... │ viewer │
│ bob@acme.com │ 550e8400-e29b-... │ editor │
│ jane@globex.com │ aabbccdd-1234-... │ admin │
└────────────────────┴─────────────────────┴──────────────┘
```
**Key Point**: The same email (`alice@acme.com`) can exist in multiple accounts with different roles.
### Password Scope
**Passwords are per-account**, not per-email.
This means:
* `alice@acme.com` in account `AB1001` has password `Password1!`
* `alice@acme.com` in account `XY2048` has password `Password2!`
* These are **different credentials** even though the email is the same
### Login Flow
When logging in, users must specify their account:
**Option 1: Account in URL**
```
POST /api/v1/auth/login
Host: ab1001.snackbase.dev # Account in subdomain
{
"email": "alice@acme.com",
"password": "Password1!"
}
```
**Option 2: Account in Request Body**
```
POST /api/v1/auth/login
{
"account": "acme-corp", # Account slug
"email": "alice@acme.com",
"password": "Password1!"
}
```
## Configuration Hierarchy
SnackBase uses a **hierarchical configuration model** for provider settings (authentication, email, storage, etc.):
### Two-Level Hierarchy
```
System-Level Configuration
├── account_id: 00000000-0000-0000-0000-000000000000 (nil UUID)
├── Purpose: Default configs for all accounts
└── Applied when: No account-level override exists
Account-Level Configuration
├── account_id:
├── Purpose: Per-account custom settings
└── Priority: Always overrides system defaults
```
### Configuration Resolution
When resolving a provider configuration:
1. **Check account-level** config for the specific account
2. **If not found**, use system-level default
3. **Merge** with fallback values for any missing keys
```python theme={null}
# Example: Email provider resolution
config = config_registry.get_config(
account_id="550e8400-e29b-41d4-a716-446655440000",
provider_name="email"
)
# Returns:
# - Account-specific config if exists
# - System-level config if no account override
# - Cached for 5 minutes
```
### Use Cases
| Configuration Type | System-Level | Account-Level |
| -------------------- | ------------------- | ----------------------- |
| **SMTP Settings** | Default SMTP server | Custom SMTP per account |
| **OAuth Providers** | Available to all | Custom app credentials |
| **Storage Backends** | Default S3 bucket | Per-account buckets |
| **Auth Providers** | Default providers | Custom provider config |
### Key Points
* System-level configs use the **nil UUID** (`00000000-0000-0000-0000-000000000000`)
* Account-level configs use the **account's UUID** as `account_id`
* Resolution is cached for **5 minutes** for performance
* Built-in providers are marked with `is_builtin` flag (cannot be deleted)
## Implications for Developers
### When Building Applications
Understanding multi-tenancy is critical when building on SnackBase:
### 1. Never Store Account ID Manually
```python theme={null}
# ❌ DON'T: Manual account_id
def create_post(title: str, account_id: str):
post = Post(title=title, account_id=account_id)
# Error-prone, security risk
# ✅ DO: Let the framework handle it
def create_post(title: str, context: Context):
post = Post(title=title, account_id=context.account_id)
# Automatic, secure
```
### 2. Account Isolation is Automatic
You don't need to write WHERE clauses for account filtering:
```python theme={null}
# ❌ DON'T: Manual filtering
def get_posts(account_id: str):
return db.query(Post).filter(Post.account_id == account_id).all()
# ✅ DO: Use the repository
def get_posts(context: Context):
return posts_repo.find_all(context) # Automatically filters by account_id
```
### 3. Cross-Account Queries Are Impossible
By design, you cannot query across accounts:
```python theme={null}
# ❌ This will NEVER return results
def get_all_posts_from_all_accounts():
return db.query(Post).all() # Only returns current account's posts
```
**Superadmin Exception**: Superadmins can explicitly pass `account_id=None` to bypass filtering:
```python theme={null}
# ✅ Superadmin-only cross-account query
def get_all_posts_as_superadmin():
return posts_repo.find_all(context, account_id=None) # Returns ALL posts
```
### 4. Collections Are Global
When creating a collection, remember:
* The collection schema is shared across ALL accounts
* The physical table (`col_`) is shared across ALL accounts
* Each account only sees their own data (via `account_id` filtering)
```python theme={null}
# Creating "posts" collection creates ONE global table
collections_service.create("posts", fields=[...])
# Result: col_posts table created (if not exists)
# All accounts can use "posts", but see only their data
```
### 5. Migrations Affect All Accounts
Database migrations affect ALL accounts simultaneously:
```python theme={null}
# ⚠️ CAUTION: This affects ALL accounts
alembic revision --autogenerate -m "Add index to col_posts"
# Result: ALL accounts' posts data is affected
```
Always test migrations thoroughly before deploying!
### 6. Use Account Code for Display
When displaying account identifiers in UI or logs:
```python theme={null}
# ✅ DO: Use account_code for display
account_code = account.account_code # "AB1234"
print(f"Processing account {account_code}")
# ❌ DON'T: Use UUID for display
account_id = account.id # "550e8400-e29b-41d4-a716-446655440000"
print(f"Processing account {account_id}") # Hard to read!
```
## Summary
| Concept | Key Takeaway |
| --------------------------- | ---------------------------------------------------------------------------------------- |
| **Account Model** | Accounts are isolated tenants with their own users, collections, and data |
| **Account Identifiers** | UUID (id) for system, account\_code (XX####) for humans, slug for URLs, name for display |
| **Data Isolation** | Row-level isolation via `account_id` column, enforced at multiple layers |
| **Global Tables** | accounts, roles, permissions, collections, macros, migrations have no account\_id |
| **Two-Tier Architecture** | Core system tables (release-only schema) + user collections (shared col\_\* tables) |
| **System Account** | Uses nil UUID for ID, SY0000 for account\_code, reserved for superadmin operations |
| **Multi-Account Users** | Same email can exist in multiple accounts with different passwords |
| **Configuration Hierarchy** | System-level (nil UUID) defaults + account-level overrides |
| **Developer Implications** | Never handle `account_id` manually; isolation is automatic; collections are global |
# Realtime System
Source: https://docs.snackbase.dev/concepts/realtime
WebSocket and Server-Sent Events for live data updates
SnackBase's **Realtime System** provides live data updates through WebSocket and Server-Sent Events (SSE), enabling your applications to react instantly to database changes without polling.
## Overview
The realtime system broadcasts events when records are created, updated, or deleted in collections. Clients can subscribe to specific collections and receive push notifications as changes occur.
### Key Benefits
* **Instant Updates**: No need to poll the server
* **Reduced Bandwidth**: Only receive relevant data changes
* **Account Isolation**: Events never cross account boundaries
* **Flexible Subscriptions**: Subscribe to specific collections and operations
* **Dual Protocol Support**: Choose WebSocket or SSE based on your needs
## How It Works
### Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Client Layer │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ WebSocket │ │ SSE │ │
│ │ Full-duplex │ │ One-way │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
└───────────┼──────────────────────────┼─────────────────────┘
│ │
└──────────┬───────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ Realtime Router │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ConnectionManager │ │
│ │ - Active connections │ │
│ │ - Subscriptions per connection │ │
│ │ - Account isolation │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ EventBroadcaster │ │
│ │ - Publish events to subscribers │ │
│ │ - Non-blocking async broadcast │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ Record Router │
│ POST /api/v1/records/{collection} │
│ PATCH /api/v1/records/{collection}/{id} │
│ DELETE /api/v1/records/{collection}/{id} │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ Database │
└─────────────────────────────────────────────────────────────┘
```
### Event Flow
1. A record is created, updated, or deleted via the REST API
2. The record operation completes successfully
3. `EventBroadcaster.publish_event()` is called with the event details
4. The event is broadcast to all active connections in the same account
5. Subscribers matching the collection and operation receive the event
## Event Format
All realtime events follow this structure:
```json theme={null}
{
"type": "posts.create",
"timestamp": "2026-01-17T12:34:56.789Z",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "New Post",
"status": "published",
"created_at": "2026-01-17T12:34:56.789Z"
}
}
```
* **type**: `{collection}.{operation}` - The event type
* **timestamp**: ISO 8601 timestamp of when the event occurred
* **data**: The full record data after the operation
### Event Types
| Type | Description |
| --------------------- | ------------------------------ |
| `{collection}.create` | A new record was created |
| `{collection}.update` | An existing record was updated |
| `{collection}.delete` | A record was deleted |
## WebSocket vs SSE
Choose the protocol that fits your use case:
### WebSocket
Full-duplex communication with bidirectional messaging.
**Best for:**
* Interactive applications (chat, collaboration)
* Real-time games
* Applications that need to send messages to the server
**Advantages:**
* Lower latency
* Can send messages to server
* More control over connection
```javascript theme={null}
const ws = new WebSocket(`ws://localhost:8000/api/v1/realtime/ws?token=${token}`);
ws.onopen = () => {
ws.send(JSON.stringify({
action: "subscribe",
collection: "posts"
}));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log("Event:", message);
};
```
### Server-Sent Events (SSE)
One-way communication from server to client over HTTP.
**Best for:**
* Simple notifications
* Live dashboards
* Feed updates
**Advantages:**
* Simpler implementation
* Automatic reconnection (handled by browser)
* Native browser support
```javascript theme={null}
const eventSource = new EventSource(
`http://localhost:8000/api/v1/realtime/subscribe?token=${token}&collection=posts`
);
eventSource.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
console.log("Event:", message);
});
```
## Subscriptions
### Subscribing to Collections
**WebSocket:**
```javascript theme={null}
ws.send(JSON.stringify({
action: "subscribe",
collection: "posts",
operations: ["create", "update", "delete"] // Optional
}));
```
**SSE:**
```
http://localhost:8000/api/v1/realtime/subscribe?token={token}&collection=posts
```
### Operation Filtering
Subscribe to specific operations to reduce noise:
```javascript theme={null}
ws.send(JSON.stringify({
action: "subscribe",
collection: "posts",
operations: ["create"] // Only receive create events
}));
```
### Multiple Collections
Subscribe to multiple collections by creating multiple subscriptions:
```javascript theme={null}
// WebSocket - send multiple subscribe messages
ws.send(JSON.stringify({ action: "subscribe", collection: "posts" }));
ws.send(JSON.stringify({ action: "subscribe", collection: "comments" }));
// SSE - specify collection parameter multiple times
const url = new URL("http://localhost:8000/api/v1/realtime/subscribe");
url.searchParams.set("token", token);
url.searchParams.append("collection", "posts");
url.searchParams.append("collection", "comments");
const eventSource = new EventSource(url);
```
## Authentication
Realtime connections require authentication via JWT access token.
### Authentication Methods
**Via Query Parameter** (recommended for WebSocket):
```
ws://localhost:8000/api/v1/realtime/ws?token=your_jwt_token
```
**Via Query Parameter** (SSE):
```
http://localhost:8000/api/v1/realtime/subscribe?token=your_jwt_token&collection=posts
```
**Via Authorization Header** (SSE only):
```
Authorization: Bearer your_jwt_token
```
### Token Expiration
When your access token expires (after 1 hour), the connection will be closed. Use your refresh token to obtain a new access token and reconnect.
## Connection Management
### Connection Limits
* **Maximum 100 subscriptions** per WebSocket connection
* **Heartbeat** sent every 30 seconds
* **Connection closed** on authentication failure
### Reconnection Strategy
Always implement reconnection logic:
```javascript theme={null}
class ReconnectingRealtime {
constructor(token) {
this.token = token;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
}
connect() {
this.ws = new WebSocket(`ws://localhost:8000/api/v1/realtime/ws?token=${this.token}`);
this.ws.onclose = () => {
setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
this.connect();
}, this.reconnectDelay);
};
this.ws.onopen = () => {
this.reconnectDelay = 1000; // Reset delay
// Resubscribe to collections
};
}
}
```
### Heartbeat Handling
Handle heartbeat messages to detect stale connections:
```javascript theme={null}
let lastHeartbeat = Date.now();
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "heartbeat") {
lastHeartbeat = Date.now();
return;
}
// Process data events
};
// Check for stale connection
setInterval(() => {
if (Date.now() - lastHeartbeat > 60000) {
console.warn("No heartbeat received, reconnecting");
ws.close();
this.connect();
}
}, 60000);
```
## Hook Integration
The realtime system integrates with SnackBase's hook system.
### Realtime Hook Events
| Event | Description |
| ------------------------- | ------------------------------------------------- |
| `on_realtime_connect` | Fired when a client connects |
| `on_realtime_disconnect` | Fired when a client disconnects |
| `on_realtime_subscribe` | Fired when a client subscribes to a collection |
| `on_realtime_unsubscribe` | Fired when a client unsubscribes |
| `on_realtime_message` | Fired when a message is received (WebSocket only) |
### Example: Logging Realtime Events
```python theme={null}
@app.hook.on_realtime_connect()
async def log_realtime_connection(connection_id, user_id, account_id):
logger.info(
"Realtime connection established",
connection_id=connection_id,
user_id=user_id,
account_id=account_id
)
@app.hook.on_realtime_subscribe()
async def log_subscription(connection_id, user_id, collection):
logger.info(
"User subscribed to collection",
connection_id=connection_id,
user_id=user_id,
collection=collection
)
```
## Security Considerations
1. **Token Security**: Always use HTTPS in production to protect tokens
2. **Account Isolation**: Events never cross account boundaries
3. **Permission Validation**: While realtime broadcasts to all subscribers, your application should validate permissions on the client side
4. **Token Expiration**: Handle token expiration gracefully and reconnect with a new token
## Best Practices
### 1. Filter Events on the Server
Use the `operations` parameter to filter events server-side:
```javascript theme={null}
ws.send(JSON.stringify({
action: "subscribe",
collection: "posts",
operations: ["create", "update"] // Don't send delete events
}));
```
### 2. Use SSE for Simple Use Cases
If you only need to receive events, SSE is simpler:
* Automatic reconnection handled by browser
* One-way communication (simpler API)
* Built-in heartbeat support
### 3. Monitor Connection Health
Handle heartbeat messages to detect stale connections
### 4. Limit Subscriptions
Stay within the 100 subscription limit per connection
### 5. Implement Backoff Reconnection
Use exponential backoff when reconnecting after failures
## API Endpoints
### WebSocket Endpoint
```
WS /api/v1/realtime/ws
```
### SSE Endpoint
```
GET /api/v1/realtime/subscribe
```
See the [API Reference](/api-reference/endpoints/realtime/websocket-endpoint) for detailed documentation.
# Security Model
Source: https://docs.snackbase.dev/concepts/security
Role-based access control, field-level permissions, and rule engine
SnackBase provides a comprehensive security model with role-based access control, field-level permissions, and a powerful rule engine. This guide explains the security architecture, authorization flows, and best practices.
## Overview
SnackBase security operates on **multiple layers** to ensure data protection:
| Layer | Purpose | Mechanism |
| ------------------------ | -------------------- | ------------------------------------- |
| **Authentication** | Verify user identity | JWT tokens, Argon2id password hashing |
| **Account Isolation** | Separate tenant data | Row-level filtering via `account_id` |
| **Authorization** | Control user actions | RBAC + Permission system |
| **Field-Level Security** | Hide sensitive data | Field-level access control |
| **Audit Logging** | Track all actions | Immutable audit logs (coming soon) |
## Authentication vs Authorization
Understanding the distinction is critical:
| Aspect | Authentication | Authorization |
| ------------------ | --------------------- | ------------------------- |
| **Question** | Who are you? | What can you do? |
| **Mechanism** | JWT tokens, passwords | Roles, permissions, rules |
| **Timing** | Once per session | Every request |
| **Failure Result** | 401 Unauthorized | 403 Forbidden |
### Example Scenario
```
Authentication (Who are you?):
├── User provides credentials
├── System verifies identity
└── Result: "You are alice@acme.com"
Authorization (What can you do?):
├── User requests DELETE /api/v1/posts/123
├── System checks permissions
├── User has "editor" role
├── Editor role does NOT have "delete" permission
└── Result: 403 Forbidden - "You cannot delete posts"
```
## Role-Based Access Control (RBAC)
SnackBase uses **RBAC** as the foundation of authorization.
### RBAC Hierarchy
```
Account (AB1001)
│
├── Users
│ ├── alice@acme.com
│ ├── bob@acme.com
│ └── jane@acme.com
│
├── Roles
│ ├── admin
│ │ └── Permissions: All operations on all collections
│ ├── editor
│ │ └── Permissions: Create, Read, Update on posts only
│ └── viewer
│ └── Permissions: Read on posts only
│
└── Role Assignments
├── alice@acme.com → admin
├── bob@acme.com → editor
└── jane@acme.com → viewer
```
### Default Roles
| Role | Description | Typical Permissions |
| ---------- | -------------------------- | -------------------------------------------- |
| **admin** | Full administrative access | All operations on all collections |
| **editor** | Content creator/manager | Create, Read, Update on specific collections |
| **viewer** | Read-only access | Read on specific collections |
### Custom Roles
You can create custom roles for any purpose:
```json theme={null}
{
"name": "moderator",
"description": "Can moderate user-generated content",
"permissions": [
{
"collection": "comments",
"create": false,
"read": true,
"update": true,
"delete": true
},
{
"collection": "users",
"create": false,
"read": true,
"update": false,
"delete": false
}
]
}
```
## Permission System
Permissions define **what operations** a user can perform on **which collections**.
### Permission Matrix
For a role with permissions:
| Collection | Create | Read | Update | Delete |
| ---------- | ------ | ---- | ------ | ------ |
| posts | ✅ | ✅ | ✅ | ❌ |
| comments | ✅ | ✅ | ✅ | ✅ |
| users | ❌ | ✅ | ❌ | ❌ |
### Permission Structure
```json theme={null}
{
"id": "perm_abc123",
"role_id": "role_editor",
"collection": "posts",
"create": true,
"read": true,
"update": true,
"delete": false,
"fields": ["title", "content", "status"],
"rules": {
"create": "@has_role('editor')",
"update": "@owns_record() or @has_role('admin')"
}
}
```
### Wildcard Collections
Use `*` to grant permissions on **all collections**:
```json theme={null}
{
"role": "admin",
"collection": "*",
"create": true,
"read": true,
"update": true,
"delete": true
}
```
This grants admin full access to ALL collections, including future ones.
### Permission Caching
Permissions are cached for **5 minutes** to improve performance:
```
┌──────────────────┐
│ First Request │
│ Check permissions│
│ from database │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Cache for 5 min │
│ Subsequent │
│ requests use │
│ cached perms │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ After 5 min or │
│ permission change│
│ Cache invalidated│
└──────────────────┘
```
## Rule Engine
SnackBase includes a **powerful rule engine** for fine-grained access control.
### Rule Syntax
Rules use a custom DSL (Domain Specific Language):
```python theme={null}
# Simple comparisons
user.id == "user_abc123"
user.email == "admin@example.com"
# Role checks
@has_role("admin")
@has_any_role(["admin", "moderator"])
# Record ownership
@owns_record()
# Field comparisons
status in ["draft", "published"]
priority >= 3
# Logical operators
@has_role("admin") or @owns_record()
@has_role("editor") and status == "draft"
not status == "archived"
# Complex expressions
(@has_role("admin") or @owns_record()) and not status == "locked"
```
### Built-in Functions
| Function | Description | Example |
| ------------------------ | --------------------------- | --------------------------------------- |
| `@has_role(role)` | User has specific role | `@has_role("admin")` |
| `@has_any_role([roles])` | User has any of these roles | `@has_any_role(["admin", "moderator"])` |
| `@owns_record()` | User created this record | `@owns_record()` |
| `@is_superadmin()` | User is superadmin | `@is_superadmin()` |
### Rule Evaluation Context
Rules have access to:
| Variable | Description | Example |
| --------- | --------------------- | ------------------------------------ |
| `user` | Current user object | `user.id`, `user.email` |
| `record` | Record being accessed | `record.created_by`, `record.status` |
| `context` | Request context | `context.account_id` |
### Permission Rules Example
```json theme={null}
{
"collection": "posts",
"update": true,
"rules": {
"update": "(@owns_record() and status in ['draft', 'pending']) or @has_role('admin')"
}
}
```
**Translation**: Users can update posts if:
* They created the post AND status is draft/pending, OR
* They have admin role
## Field-Level Security
SnackBase supports **field-level access control** to hide sensitive data.
### Field Visibility
Restrict which fields a role can see:
```json theme={null}
{
"role": "viewer",
"collection": "users",
"read": true,
"fields": ["name", "email"],
"excluded_fields": ["phone", "ssn", "salary"]
}
```
Users with this role will receive:
```json theme={null}
// Response (excluded fields filtered out)
{
"id": "user_abc123",
"name": "Alice Johnson",
"email": "alice@example.com"
// phone, ssn, salary NOT included
}
```
### Field-Level Rules
Apply rules to specific fields:
```json theme={null}
{
"collection": "users",
"field_rules": {
"salary": {
"read": "@has_role('admin') or @owns_record()",
"write": "@has_role('hr') or @is_superadmin()"
},
"email": {
"read": "true",
"write": "@has_role('admin')"
}
}
}
```
## Account Isolation
Account isolation is the **foundation of SnackBase security**.
### Multi-Tenant Isolation
All data is automatically isolated by `account_id`:
```sql theme={null}
-- User from AB1001 requests posts
SELECT * FROM posts WHERE account_id = 'AB1001';
-- User from XY2048 requests posts
SELECT * FROM posts WHERE account_id = 'XY2048';
```
Users cannot see or access data from other accounts.
### Enforcement Layers
Account isolation is enforced at **multiple layers**:
| Layer | Mechanism | Example |
| ------------------ | ----------------------------------- | -------------------------- |
| **Database** | `account_id` column in WHERE clause | `WHERE account_id = ?` |
| **Repository** | Automatic filtering in queries | `posts.find_all(context)` |
| **API Middleware** | Validates account in token | Token contains account\_id |
| **Hooks** | Built-in account\_isolation\_hook | Cannot be disabled |
### Cross-Account Access Prevention
Attempting to access another account's data:
```bash theme={null}
# User from AB1001 tries to access XY2048 data
GET /api/v1/posts?account_id=XY2048
# Result: 403 Forbidden
# The account_id filter is overridden and reset to AB1001
```
The system **ignores** malicious `account_id` parameters.
## Security Best Practices
### 1. Principle of Least Privilege
Grant minimum required permissions:
```json theme={null}
// ❌ Too permissive
{
"role": "viewer",
"collection": "*",
"delete": true // Viewers shouldn't delete!
}
// ✅ Correct
{
"role": "viewer",
"collection": "posts",
"read": true,
"create": false,
"update": false,
"delete": false
}
```
### 2. Use Rules for Fine-Grained Control
Leverage the rule engine for complex scenarios:
```json theme={null}
{
"rules": {
"update": "@owns_record() or @has_role('admin')",
"delete": "@has_role('admin') and not record.status == 'locked'"
}
}
```
### 3. Implement Field-Level Security
Hide sensitive fields by default:
```json theme={null}
{
"collection": "users",
"excluded_fields": ["password_hash", "ssn", "salary"]
}
```
### 4. Regular Permission Audits
Periodically review and update permissions:
* Remove unused roles
* Tighten overly permissive rules
* Document permission rationale
* Use audit logs (when available) to track access
### 5. Use Wildcards Carefully
Wildcard permissions (`*`) are powerful but dangerous:
```json theme={null}
// ⚠️ Use with caution
{
"collection": "*",
"delete": true // Can delete from ALL collections!
}
// ✅ Prefer explicit collections
{
"collection": "posts",
"delete": true
}
```
### 6. Test Permission Changes
Always test permission changes in development:
```python theme={null}
def test_editor_cannot_delete_posts():
editor_user = create_user(role="editor")
client = login_as(editor_user)
response = client.delete("/api/v1/posts/123")
assert response.status_code == 403
```
### 7. Monitor and Alert
Monitor for suspicious activity:
* Repeated failed authorization attempts
* Unusual access patterns
* Permission escalation attempts
* Cross-account access attempts
## Common Security Scenarios
### Scenario 1: User Can Only Edit Their Own Posts
```json theme={null}
{
"role": "author",
"collection": "posts",
"create": true,
"read": true,
"update": true,
"delete": true,
"rules": {
"update": "@owns_record()",
"delete": "@owns_record() and not status == 'published'"
}
}
```
### Scenario 2: Moderators Can Edit All Comments
```json theme={null}
{
"role": "moderator",
"collection": "comments",
"create": false,
"read": true,
"update": true,
"delete": true,
"field_rules": {
"author_ip": {
"read": "@has_role('admin')"
}
}
}
```
### Scenario 3: Public Read, Private Write
```json theme={null}
{
"role": "anonymous",
"collection": "posts",
"read": true,
"create": false,
"update": false,
"delete": false,
"excluded_fields": ["draft_notes", "internal_status"]
}
```
## Security Headers
SnackBase implements **defense-in-depth** security by automatically setting HTTP security headers on all responses.
### Automatic Headers
All responses include the following security headers:
| Header | Value | Purpose |
| ----------------------------- | ------------------------------------- | -------------------------------------------------- |
| **X-Content-Type-Options** | `nosniff` | Prevents MIME type sniffing attacks |
| **X-Frame-Options** | `DENY` | Prevents clickjacking by blocking iframe embedding |
| **X-XSS-Protection** | `1; mode=block` | Enables browser XSS protection (legacy browsers) |
| **Strict-Transport-Security** | `max-age=31536000; includeSubDomains` | Enforces HTTPS (production only) |
| **Content-Security-Policy** | Configurable | Prevents XSS and injection attacks |
| **Permissions-Policy** | Configurable | Restricts browser features |
| **Referrer-Policy** | `strict-origin-when-cross-origin` | Controls referrer information |
### Content Security Policy (CSP)
The default CSP is designed for maximum security while supporting the Admin UI:
```
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none'
```
**Key Directives**:
* `default-src 'self'`: Only load resources from same origin
* `script-src 'self'`: Block inline scripts and external scripts
* `style-src 'self' 'unsafe-inline'`: Allow inline styles (for React)
* `img-src 'self' data:`: Allow images from same origin and data URIs
* `frame-ancestors 'none'`: Prevent iframe embedding
### Customization
Customize security headers via environment variables:
```bash theme={null}
# Customize CSP for external CDN
SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.example.com"
# Adjust HSTS max-age
SNACKBASE_HSTS_MAX_AGE=63072000 # 2 years
# Customize Permissions Policy
SNACKBASE_PERMISSIONS_POLICY="geolocation=(), camera=(), microphone=()"
```
### Environment-Aware Behavior
Security headers adapt to the environment:
| Environment | HSTS Header | HTTPS Redirect |
| --------------- | ------------------ | -------------------------- |
| **Development** | ❌ Not set | ❌ Disabled |
| **Production** | ✅ Set with max-age | ⚙️ Optional (configurable) |
This prevents HSTS issues during local development while enforcing HTTPS in production.
## Summary
| Concept | Key Takeaway |
| ----------------------------------- | --------------------------------------------------------------------------------- |
| **Security Layers** | Authentication → Account Isolation → Authorization → Field-Level Security → Audit |
| **Authentication vs Authorization** | Authentication = Who are you? Authorization = What can you do? |
| **RBAC** | Users → Roles → Permissions → Collections |
| **Permission System** | CRUD permissions per collection, wildcard support, 5-minute cache |
| **Rule Engine** | Custom DSL for fine-grained control with built-in functions |
| **Field-Level Security** | Hide sensitive fields, field-specific rules |
| **Account Isolation** | Automatic via account\_id, enforced at multiple layers |
| **Best Practices** | Least privilege, use rules, hide sensitive data, audit permissions |
# Outbound Webhooks
Source: https://docs.snackbase.dev/concepts/webhooks
Push notifications to external URLs when data changes in your collections
SnackBase's **Outbound Webhooks** let you send HTTP notifications to external services whenever records are created, updated, or deleted. Instead of polling for changes, your external systems receive real-time push notifications.
## Overview
Webhooks are configured per-collection and fire on specific events. When an event occurs, SnackBase sends a signed HTTP POST to your configured URL with the record data.
### Key Features
* **Event-Driven**: Fire on `create`, `update`, or `delete` events
* **HMAC-SHA256 Signing**: Every delivery is signed with a per-webhook secret
* **Automatic Retries**: Failed deliveries are retried up to 5 times with exponential backoff
* **Delivery Tracking**: Full history of every delivery attempt with status codes and response bodies
* **Filter Expressions**: Conditionally fire webhooks based on record data
* **Custom Headers**: Add custom HTTP headers to webhook deliveries
## How It Works
```
┌──────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ Record │ │ SnackBase │ │ Your Server │
│ Operation │────>│ Webhook Engine │────>│ (HTTPS endpoint)│
│ (create/ │ │ │ │ │
│ update/ │ │ 1. Match webhook │ │ Verify signature│
│ delete) │ │ 2. Evaluate filter│ │ Process payload │
│ │ │ 3. Sign payload │ │ │
└──────────────┘ └────────────────────┘ └──────────────────┘
```
## Webhook Configuration
Each webhook is defined with:
| Field | Type | Required | Description |
| ------------ | --------- | -------- | ------------------------------------------------------------------- |
| `url` | string | Yes | Destination URL (max 2048 chars). HTTPS required in production. |
| `collection` | string | Yes | Collection to watch (max 100 chars) |
| `events` | string\[] | Yes | Events to fire on: `"create"`, `"update"`, `"delete"` |
| `secret` | string | No | HMAC signing secret. Auto-generated (64 hex chars) if not provided. |
| `filter` | string | No | Rule expression to conditionally fire |
| `headers` | object | No | Custom HTTP headers to include in deliveries |
| `enabled` | boolean | No | Active status (default: `true`) |
### Event Types
| Event | Fires When |
| -------- | ----------------------------------------- |
| `create` | A new record is created in the collection |
| `update` | An existing record is updated |
| `delete` | A record is deleted |
## Delivery Payload
Every webhook delivery sends a JSON POST request with this structure:
```json theme={null}
{
"event": "records.create",
"collection": "orders",
"record": {
"id": "abc-123",
"status": "pending",
"total": 99.99,
"created_at": "2025-01-15T10:30:00Z"
},
"previous": null,
"timestamp": "2025-01-15T10:30:01Z",
"webhook_id": "wh-uuid",
"account_id": "AB1234"
}
```
* **`record`**: The current state of the record (null for delete events)
* **`previous`**: The previous state (only present for `update` and `delete` events)
## Security
### HMAC-SHA256 Signing
Every delivery includes a signature header for verification:
```
X-SnackBase-Signature: sha256=a1b2c3d4e5f6...
```
The signature is computed as `HMAC-SHA256(secret, request_body)`. To verify on your server:
```python theme={null}
import hmac
import hashlib
def verify_signature(payload: bytes, secret: str, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
```
The webhook secret is only returned once -- when the webhook is created. Store
it securely.
### Additional Headers
Every delivery also includes:
| Header | Description |
| ------------------------ | ----------------------------------- |
| `Content-Type` | `application/json` |
| `X-SnackBase-Signature` | HMAC-SHA256 signature |
| `X-SnackBase-Event` | Event type (e.g., `records.create`) |
| `X-SnackBase-Webhook-Id` | Webhook UUID |
### URL Validation
In production, SnackBase enforces:
* **HTTPS only** -- HTTP URLs are rejected
* **No private IPs** -- Prevents SSRF attacks by blocking `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, and IPv6 equivalents
## Filter Expressions
Use filter expressions to conditionally fire webhooks based on record data:
```
status = "published"
rating >= 4.5
category IN ["electronics", "books"]
title ~ "Product%"
```
If a filter expression fails to evaluate, the webhook fires anyway (fail-open
design). This prevents silent data loss.
## Retry Logic
Failed deliveries are automatically retried with exponential backoff:
| Attempt | Delay |
| --------- | ---------- |
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 12 hours |
A delivery is considered successful when it receives a 2xx HTTP response. Non-2xx responses or network errors trigger retries.
### Delivery Statuses
| Status | Meaning |
| ----------- | ------------------------------------ |
| `pending` | Not yet delivered |
| `delivered` | Successfully received (2xx response) |
| `retrying` | Failed, scheduled for retry |
| `failed` | All retry attempts exhausted |
## Testing
Use the built-in test endpoint to verify your webhook setup:
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/webhooks/{webhook_id}/test \
-H "Authorization: Bearer {token}"
```
This sends a test payload to your configured URL and returns the result immediately (synchronous).
## Limits
| Limit | Default |
| ------------------------- | ---------------------------- |
| Max webhooks per account | 20 (configurable) |
| URL max length | 2048 characters |
| Response body storage | Truncated to 5000 characters |
| HTTP timeout per delivery | 30 seconds |
| Max retry attempts | 5 |
## When to Use Webhooks vs Other Features
| Feature | Best For |
| ---------------------------- | ----------------------------------------------- |
| **Webhooks** | Push notifications to external services |
| **API-Defined Hooks** | Internal automation (actions within SnackBase) |
| **Workflows** | Multi-step processes with conditions and delays |
| **Realtime (WebSocket/SSE)** | Client-side live updates |
# Workflow Automation
Source: https://docs.snackbase.dev/concepts/workflows
Multi-step automation with branching, delays, and parallel execution
SnackBase's **Workflow Engine** lets you build multi-step automation pipelines with conditional branching, timed delays, loops, and parallel execution -- all configured via the REST API.
## Overview
A workflow consists of:
1. **Trigger** -- What starts the workflow (event, schedule, manual, or webhook)
2. **Steps** -- An ordered list of operations with branching and control flow
3. **Instances** -- Each execution creates a trackable instance with step-by-step logs
### Key Features
* **Four Trigger Types**: Event, schedule, manual, and webhook
* **Seven Step Types**: Action, condition, wait\_delay, wait\_condition, wait\_event, loop, parallel
* **Instance Tracking**: Monitor running workflows with status, step logs, and error details
* **Cancel and Resume**: Cancel running workflows or resume failed ones
* **Template Variables**: Access trigger data and previous step outputs in any step
## Trigger Types
### Event Triggers
Fire when a record or auth event occurs:
```json theme={null}
{
"trigger_type": "event",
"trigger_config": {
"type": "event",
"event": "records.create",
"collection": "orders",
"condition": "total >= 100"
}
}
```
**Supported events:** `records.create`, `records.update`, `records.delete`, `auth.login`, `auth.register`
### Schedule Triggers
Fire on a cron schedule:
```json theme={null}
{
"trigger_type": "schedule",
"trigger_config": {
"type": "schedule",
"cron": "0 9 * * MON"
}
}
```
### Manual Triggers
Fire only via explicit API call:
```json theme={null}
{
"trigger_type": "manual",
"trigger_config": { "type": "manual" }
}
```
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/workflows/{id}/trigger \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"customer_id": "cust-123"}'
```
### Webhook Triggers
Fire via an unauthenticated HTTP POST with a secret token:
```json theme={null}
{
"trigger_type": "webhook",
"trigger_config": {
"type": "webhook",
"token": "auto-generated-32-char-secret"
}
}
```
The token is auto-generated on creation. Trigger with:
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/workflow-webhooks/{token} \
-H "Content-Type: application/json" \
-d '{"source": "stripe", "event": "payment.completed"}'
```
Webhook trigger endpoints are unauthenticated. The security relies on the secret token. Treat it like a password.
## Step Types
Steps are defined as an ordered list. Each step has a unique `name` and a `type`.
### Action
Execute an operation (same action types as [API-Defined Hooks](/concepts/api-hooks)):
```json theme={null}
{
"name": "send_notification",
"type": "action",
"config": {
"action_type": "send_webhook",
"config": {
"url": "https://slack.example.com/webhook",
"body_template": { "text": "Order {{trigger.id}} created" }
}
}
}
```
Supported actions: `send_webhook`, `send_email`, `create_record`, `update_record`, `delete_record`, `enqueue_job`.
### Condition
Branch based on a rule expression:
```json theme={null}
{
"name": "check_value",
"type": "condition",
"config": {
"expression": "trigger.total >= 500",
"on_true": "high_value_flow",
"on_false": "standard_flow"
}
}
```
Output: `{"result": true, "branch": "true"}` or `{"result": false, "branch": "false"}`
### Wait Delay
Pause execution for a specified duration:
```json theme={null}
{
"name": "wait_24h",
"type": "wait_delay",
"config": {
"duration": "24h",
"next": "follow_up_step"
}
}
```
**Duration format:** `` where unit is `s` (seconds), `m` (minutes), `h` (hours), or `d` (days).
The workflow instance transitions to `waiting` status. A background job resumes it after the delay.
### Loop
Iterate over a list, executing a step for each item:
```json theme={null}
{
"name": "process_items",
"type": "loop",
"config": {
"items": "{{trigger.line_items}}",
"step": "process_single_item"
}
}
```
Output: `{"items_count": 3, "outputs": [...]}`
### Parallel
Execute multiple branches concurrently:
```json theme={null}
{
"name": "parallel_notifications",
"type": "parallel",
"config": {
"branches": [
["send_email_step"],
["send_slack_step"],
["update_crm_step"]
]
}
}
```
Output: `{"branch_results": [[...], [...], [...]]}`
If any branch fails, the entire parallel step fails.
### Wait Condition / Wait Event
`wait_condition` and `wait_event` step types are defined in the schema but not yet implemented. They are skipped during execution.
## Template Variables
All step configurations support template variables:
| Variable | Description |
| -------------------------------------- | ---------------------------------- |
| `{{trigger.}}` | Data from the trigger context |
| `{{steps..output.}}` | Output from a previous step |
| `{{auth.user_id}}` | User ID (if authenticated trigger) |
| `{{auth.email}}` | User email |
| `{{now}}` | Current UTC timestamp (ISO 8601) |
## Workflow Instances
Each execution creates a **workflow instance** that tracks progress:
### Instance Lifecycle
```
pending ──> running ──> completed
├──> failed ──> (resume) ──> running
├──> waiting ──> (resume after delay) ──> running
└──> cancelled
```
### Instance Statuses
| Status | Meaning |
| ----------- | ------------------------------------- |
| `pending` | Created, not yet started |
| `running` | Actively executing steps |
| `waiting` | Paused on a `wait_delay` step |
| `completed` | All steps finished successfully |
| `failed` | A step failed (check `error_message`) |
| `cancelled` | Manually cancelled by user |
### Instance Management
**Cancel** a running or waiting instance:
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/workflow-instances/{instance_id}/cancel \
-H "Authorization: Bearer {token}"
```
**Resume** a failed or waiting instance:
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/workflow-instances/{instance_id}/resume \
-H "Authorization: Bearer {token}"
```
## Step Logs
Every step execution is logged with:
| Field | Description |
| --------------- | ------------------------------------------- |
| `step_name` | Name of the step |
| `step_type` | Type (action, condition, wait\_delay, etc.) |
| `status` | `success`, `failed`, or `skipped` |
| `input` | Snapshot of step inputs |
| `output` | Step output data |
| `error_message` | Error details (if failed) |
| `started_at` | When the step began |
| `completed_at` | When the step finished |
## Example: Order Processing Workflow
```json theme={null}
{
"name": "Order Processing",
"trigger_type": "event",
"trigger_config": {
"type": "event",
"event": "records.create",
"collection": "orders"
},
"steps": [
{
"name": "check_value",
"type": "condition",
"config": {
"expression": "trigger.total >= 500",
"on_true": "high_value_alert",
"on_false": "standard_confirm"
}
},
{
"name": "high_value_alert",
"type": "action",
"config": {
"action_type": "send_webhook",
"config": {
"url": "https://slack.example.com/webhook",
"body_template": {
"text": "High-value order #{{trigger.id}}: ${{trigger.total}}"
}
}
}
},
{
"name": "standard_confirm",
"type": "action",
"config": {
"action_type": "send_email",
"config": {
"to": "{{trigger.customer_email}}",
"subject": "Order Confirmed",
"template_name": "order_confirmation",
"variables": { "order_id": "{{trigger.id}}" }
}
}
},
{
"name": "wait_for_processing",
"type": "wait_delay",
"config": {
"duration": "2h",
"next": "update_status"
}
},
{
"name": "update_status",
"type": "action",
"config": {
"action_type": "update_record",
"config": {
"collection": "orders",
"record_id": "{{trigger.id}}",
"data": { "status": "processing" }
}
}
}
]
}
```
## Limits
| Limit | Default |
| ------------------------- | ------------------------ |
| Max workflows per account | 50 (configurable) |
| Max step execution depth | 5 |
| Webhook token length | 32 characters (URL-safe) |
# Deployment Guide
Source: https://docs.snackbase.dev/deployment
Deploy SnackBase in development and production environments
This guide covers deploying SnackBase in development and production environments.
## Prerequisites
### Required Software
* **Python**: 3.12 or higher
* **uv**: Package manager ([installation guide](https://github.com/astral-sh/uv))
* **Database**: SQLite (development) or PostgreSQL (production recommended)
### Optional
* **Docker**: For containerized deployment (coming soon)
* **Nginx**: For reverse proxy in production
* **systemd**: For service management on Linux
## Development Deployment
### Quick Start
**1. Clone the repository**:
```bash theme={null}
git clone
cd SnackBase
```
**2. Install dependencies**:
```bash theme={null}
uv sync
```
**3. Create environment file**:
```bash theme={null}
cp .env.example .env
# Edit .env with your settings
```
**4. Start the development server**:
```bash theme={null}
uv run python -m snackbase serve --reload
```
**5. Access the application**:
* API: [http://localhost:8000](http://localhost:8000)
* Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs)
* ReDoc: [http://localhost:8000/redoc](http://localhost:8000/redoc)
### Development Configuration
Create a `.env` file in the project root:
```bash theme={null}
# Application
SNACKBASE_ENVIRONMENT=development
SNACKBASE_DEBUG=true
SNACKBASE_API_PREFIX=/api/v1
# Server
SNACKBASE_HOST=0.0.0.0
SNACKBASE_PORT=8000
# Database (SQLite for development)
SNACKBASE_DATABASE_URL=sqlite+aiosqlite:///./sb_data/snackbase.db
# Security
SNACKBASE_SECRET_KEY=dev-secret-key-change-in-production
# CORS (allow localhost for development)
SNACKBASE_CORS_ORIGINS=http://localhost:3000,http://localhost:8000
# Logging
SNACKBASE_LOG_LEVEL=DEBUG
SNACKBASE_LOG_FORMAT=console
```
### Development Server Options
```bash theme={null}
# Start with auto-reload (watches for file changes)
uv run python -m snackbase serve --reload
# Custom host and port
uv run python -m snackbase serve --host 127.0.0.1 --port 3000
# Multiple workers (not recommended with --reload)
uv run python -m snackbase serve --workers 4
# Using uvicorn directly
uv run uvicorn snackbase.infrastructure.api.app:app --reload --port 8000
```
### Initialize Database
The database is automatically initialized on first run. To manually initialize:
```bash theme={null}
uv run python -m snackbase init-db
```
### Development Tools
```bash theme={null}
# Interactive Python shell with SnackBase context
uv run python -m snackbase shell
# View configuration
uv run python -m snackbase info
# Run tests
uv run pytest
# Run tests with coverage
uv run pytest --cov=snackbase
# Code formatting
uv run ruff format .
# Linting
uv run ruff check .
# Type checking
uv run mypy src/
```
## Production Deployment
### Deployment Checklist
* [ ] Set strong `SNACKBASE_SECRET_KEY`
* [ ] Use PostgreSQL database
* [ ] Set `SNACKBASE_ENVIRONMENT=production`
* [ ] Set `SNACKBASE_DEBUG=false`
* [ ] Configure proper CORS origins
* [ ] Set up reverse proxy (Nginx)
* [ ] Enable HTTPS/TLS
* [ ] Configure log aggregation
* [ ] Set up monitoring and alerts
* [ ] Configure automated backups
* [ ] Test health check endpoints
### Option 1: Direct Deployment (systemd)
#### 1. Prepare the Server
```bash theme={null}
# Update system
sudo apt update && sudo apt upgrade -y
# Install Python 3.12
sudo apt install python3.12 python3.12-venv -y
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create application user
sudo useradd -m -s /bin/bash snackbase
```
#### 2. Deploy Application
```bash theme={null}
# Switch to application user
sudo su - snackbase
# Clone repository
git clone /home/snackbase/app
cd /home/snackbase/app
# Install dependencies
uv sync --frozen
# Create production environment file
nano .env
```
#### 3. Production Environment Configuration
```bash theme={null}
# Application
SNACKBASE_ENVIRONMENT=production
SNACKBASE_DEBUG=false
SNACKBASE_API_PREFIX=/api/v1
# Server
SNACKBASE_HOST=0.0.0.0
SNACKBASE_PORT=8000
# Database (PostgreSQL)
SNACKBASE_DATABASE_URL=postgresql+asyncpg://snackbase:password@localhost/snackbase_prod
# Security
SNACKBASE_SECRET_KEY=
SNACKBASE_ACCESS_TOKEN_EXPIRE_MINUTES=60
SNACKBASE_REFRESH_TOKEN_EXPIRE_DAYS=7
# CORS (restrict to your domains)
SNACKBASE_CORS_ORIGINS=https://yourdomain.com,https://app.yourdomain.com
# Logging
SNACKBASE_LOG_LEVEL=INFO
SNACKBASE_LOG_FORMAT=json
# Storage
SNACKBASE_STORAGE_PATH=/home/snackbase/app/sb_data/files
```
Generate a strong secret key:
```bash theme={null}
python -c "import secrets; print(secrets.token_urlsafe(64))"
```
#### 4. Set Up PostgreSQL
```bash theme={null}
# Install PostgreSQL
sudo apt install postgresql postgresql-contrib -y
# Create database and user
sudo -u postgres psql << EOF
CREATE DATABASE snackbase_prod;
CREATE USER snackbase WITH PASSWORD 'your-secure-password';
GRANT ALL PRIVILEGES ON DATABASE snackbase_prod TO snackbase;
\q
EOF
```
#### 5. Create systemd Service
Create `/etc/systemd/system/snackbase.service`:
```ini theme={null}
[Unit]
Description=SnackBase API Server
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=notify
User=snackbase
Group=snackbase
WorkingDirectory=/home/snackbase/app
Environment="PATH=/home/snackbase/.local/bin:/usr/local/bin:/usr/bin:/bin"
# Use uv to run the application
ExecStart=/home/snackbase/.local/bin/uv run uvicorn snackbase.infrastructure.api.app:app \
--host 0.0.0.0 \
--port 8000 \
--workers 4 \
--log-config /home/snackbase/app/logging.json
# Restart policy
Restart=always
RestartSec=10
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/home/snackbase/app/sb_data
# Resource limits
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
```
#### 6. Start and Enable Service
```bash theme={null}
# Reload systemd
sudo systemctl daemon-reload
# Start service
sudo systemctl start snackbase
# Enable on boot
sudo systemctl enable snackbase
# Check status
sudo systemctl status snackbase
# View logs
sudo journalctl -u snackbase -f
```
### Option 2: Nginx Reverse Proxy
#### 1. Install Nginx
```bash theme={null}
sudo apt install nginx -y
```
#### 2. Configure Nginx
Create `/etc/nginx/sites-available/snackbase`:
```nginx theme={null}
# Upstream to SnackBase
upstream snackbase_backend {
server 127.0.0.1:8000;
}
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name api.yourdomain.com;
return 301 https://$server_name$request_uri;
}
# HTTPS Server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.yourdomain.com;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Security Headers (Defense in Depth)
# Note: SnackBase also sets these headers at the application level.
# Nginx headers provide an additional layer of security.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Logging
access_log /var/log/nginx/snackbase_access.log;
error_log /var/log/nginx/snackbase_error.log;
# Client body size (for file uploads)
client_max_body_size 100M;
# Proxy to SnackBase
location / {
proxy_pass http://snackbase_backend;
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;
# WebSocket support (for future real-time features)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Health check endpoint (bypass auth)
location /health {
proxy_pass http://snackbase_backend/health;
access_log off;
}
}
```
#### 3. Enable Site and Restart Nginx
```bash theme={null}
# Enable site
sudo ln -s /etc/nginx/sites-available/snackbase /etc/nginx/sites-enabled/
# Test configuration
sudo nginx -t
# Restart Nginx
sudo systemctl restart nginx
```
#### 4. Set Up SSL with Let's Encrypt
```bash theme={null}
# Install Certbot
sudo apt install certbot python3-certbot-nginx -y
# Obtain certificate
sudo certbot --nginx -d api.yourdomain.com
# Auto-renewal is configured automatically
# Test renewal
sudo certbot renew --dry-run
```
### Option 3: Docker Deployment
You can deploy SnackBase using the included `Dockerfile` and `docker-compose.yml` (optional).
#### 1. Build the Image
```bash theme={null}
docker build -t snackbase .
```
#### 2. Run the Container
```bash theme={null}
docker run -d \
-p 8000:8000 \
-v $(pwd)/sb_data:/app/sb_data \
--name snackbase \
snackbase
```
#### 3. Access Application
The application will be available at [http://localhost:8000](http://localhost:8000).
## Database Configuration
### SQLite (Development Only)
```bash theme={null}
SNACKBASE_DATABASE_URL=sqlite+aiosqlite:///./sb_data/snackbase.db
```
**Pros**:
* Zero configuration
* Perfect for development
* File-based, easy to backup
**Cons**:
* Not suitable for production
* Limited concurrency
* No network access
### PostgreSQL (Recommended for Production)
```bash theme={null}
SNACKBASE_DATABASE_URL=postgresql+asyncpg://user:password@host:port/database
```
**Setup**:
```bash theme={null}
# Install PostgreSQL
sudo apt install postgresql postgresql-contrib
# Create database
sudo -u postgres createdb snackbase_prod
# Create user
sudo -u postgres createuser snackbase -P
# Grant privileges
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE snackbase_prod TO snackbase;"
```
**Connection Pooling**:
SnackBase uses SQLAlchemy's async connection pooling. Configure in `.env`:
```bash theme={null}
# Pool size (default: 5)
SNACKBASE_DB_POOL_SIZE=10
# Max overflow (default: 10)
SNACKBASE_DB_MAX_OVERFLOW=20
# Pool timeout (default: 30 seconds)
SNACKBASE_DB_POOL_TIMEOUT=30
```
## Environment Variables
### Complete Reference
| Variable | Default | Description |
| --------------------------------------- | -------------------------------------------- | ---------------------------------------------- |
| `SNACKBASE_ENVIRONMENT` | `development` | Environment: development, staging, production |
| `SNACKBASE_DEBUG` | `false` | Enable debug mode |
| `SNACKBASE_APP_NAME` | `SnackBase` | Application name |
| `SNACKBASE_APP_VERSION` | `0.1.0` | Application version |
| `SNACKBASE_API_PREFIX` | `/api/v1` | API route prefix |
| `SNACKBASE_HOST` | `0.0.0.0` | Server bind address |
| `SNACKBASE_PORT` | `8000` | Server port |
| `SNACKBASE_DATABASE_URL` | `sqlite+aiosqlite:///./sb_data/snackbase.db` | Database connection URL |
| `SNACKBASE_SECRET_KEY` | (auto-generated) | JWT signing key |
| `SNACKBASE_ACCESS_TOKEN_EXPIRE_MINUTES` | `60` | Access token expiration |
| `SNACKBASE_REFRESH_TOKEN_EXPIRE_DAYS` | `7` | Refresh token expiration |
| `SNACKBASE_CORS_ORIGINS` | `*` | Allowed CORS origins (comma-separated) |
| `SNACKBASE_CORS_ALLOW_CREDENTIALS` | `true` | Allow credentials in CORS |
| `SNACKBASE_CORS_ALLOW_METHODS` | `*` | Allowed HTTP methods |
| `SNACKBASE_CORS_ALLOW_HEADERS` | `*` | Allowed headers |
| `SNACKBASE_LOG_LEVEL` | `INFO` | Logging level: DEBUG, INFO, WARNING, ERROR |
| `SNACKBASE_LOG_FORMAT` | `json` | Log format: json, console |
| `SNACKBASE_STORAGE_PATH` | `./sb_data/files` | File storage directory |
| `SNACKBASE_AUDIT_LOGGING_ENABLED` | `true` | Enable GxP-compliant audit logging |
| `SNACKBASE_SINGLE_TENANT_MODE` | `false` | Enable single-tenant mode |
| `SNACKBASE_SINGLE_TENANT_ACCOUNT` | (none) | Target account slug for single-tenant mode |
| `SNACKBASE_SINGLE_TENANT_ACCOUNT_NAME` | (none) | Optional display name for bootstrapped account |
## Health Checks
SnackBase provides three health check endpoints:
### `/health` - Basic Health Check
Returns 200 if the service is running.
```bash theme={null}
curl http://localhost:8000/health
```
Response:
```json theme={null}
{
"status": "healthy",
"service": "SnackBase",
"version": "0.1.0",
"audit_logging_enabled": true
}
```
### `/ready` - Readiness Check
Returns 200 if the service is ready to accept requests (includes database connectivity).
```bash theme={null}
curl http://localhost:8000/ready
```
Response:
```json theme={null}
{
"status": "ready",
"service": "SnackBase",
"version": "0.1.0",
"database": "connected",
"audit_logging_enabled": true
}
```
### `/live` - Liveness Check
Returns 200 if the service is alive (simple ping).
```bash theme={null}
curl http://localhost:8000/live
```
Response:
```json theme={null}
{
"status": "alive",
"service": "SnackBase",
"version": "0.1.0"
}
```
### Using Health Checks
**systemd**:
```ini theme={null}
[Service]
ExecStartPost=/bin/sleep 5
ExecStartPost=/usr/bin/curl -f http://localhost:8000/ready || exit 1
```
**Kubernetes** (future):
```yaml theme={null}
livenessProbe:
httpGet:
path: /live
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
```
## Troubleshooting
### Service Won't Start
**Check logs**:
```bash theme={null}
sudo journalctl -u snackbase -n 50 --no-pager
```
**Common issues**:
* Database connection failure
* Port already in use
* Missing environment variables
* File permission issues
### Database Connection Errors
**PostgreSQL**:
```bash theme={null}
# Test connection
psql -h localhost -U snackbase -d snackbase_prod
# Check PostgreSQL is running
sudo systemctl status postgresql
```
**SQLite**:
```bash theme={null}
# Check file permissions
ls -la sb_data/snackbase.db
# Ensure directory exists
mkdir -p sb_data
```
### Permission Denied Errors
```bash theme={null}
# Fix ownership
sudo chown -R snackbase:snackbase /home/snackbase/app
# Fix permissions
chmod 755 /home/snackbase/app
chmod 644 /home/snackbase/app/.env
```
### High Memory Usage
**Reduce worker count**:
```bash theme={null}
# In systemd service file
ExecStart=... --workers 2
```
**Configure connection pool**:
```bash theme={null}
# In .env
SNACKBASE_DB_POOL_SIZE=5
SNACKBASE_DB_MAX_OVERFLOW=10
```
### Slow API Responses
**Check database**:
```bash theme={null}
# PostgreSQL query performance
sudo -u postgres psql snackbase_prod -c "SELECT * FROM pg_stat_activity;"
```
**Enable query logging**:
```bash theme={null}
# In .env
SNACKBASE_LOG_LEVEL=DEBUG
```
### CORS Errors
**Update CORS origins**:
```bash theme={null}
# In .env
SNACKBASE_CORS_ORIGINS=https://yourdomain.com,https://app.yourdomain.com
```
**Check Nginx configuration**:
```bash theme={null}
# Ensure proxy headers are set
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
```
## Monitoring and Maintenance
### Log Management
**View logs**:
```bash theme={null}
# systemd logs
sudo journalctl -u snackbase -f
# Application logs (if file-based)
tail -f /home/snackbase/app/logs/snackbase.log
```
**Log rotation** (if using file-based logging):
Create `/etc/logrotate.d/snackbase`:
```
/home/snackbase/app/logs/*.log {
daily
rotate 14
compress
delaycompress
notifempty
create 0640 snackbase snackbase
sharedscripts
postrotate
systemctl reload snackbase > /dev/null 2>&1 || true
endscript
}
```
### Performance Monitoring
**System resources**:
```bash theme={null}
# CPU and memory
htop
# Disk usage
df -h
# Database size
du -sh sb_data/
```
**Application metrics** (future):
* Prometheus integration
* Grafana dashboards
* Custom metrics endpoint
### Backup Strategy
**Database backup** (PostgreSQL):
```bash theme={null}
# Manual backup
pg_dump -U snackbase snackbase_prod > backup_$(date +%Y%m%d_%H%M%S).sql
# Automated backup (cron)
0 2 * * * pg_dump -U snackbase snackbase_prod | gzip > /backups/snackbase_$(date +\%Y\%m\%d).sql.gz
```
**File storage backup**:
```bash theme={null}
# Backup uploaded files
tar -czf files_backup_$(date +%Y%m%d).tar.gz sb_data/files/
```
Automated backup/restore commands will be added in Phase 5 (F5.10)
## Security Best Practices
1. **Use strong secret keys**: Generate with `secrets.token_urlsafe(64)`
2. **Enable HTTPS**: Use Let's Encrypt for free SSL certificates
3. **Restrict CORS**: Only allow trusted domains
4. **Use PostgreSQL**: SQLite is not suitable for production
5. **Regular updates**: Keep dependencies up to date
6. **Monitor logs**: Set up log aggregation and alerts
7. **Firewall**: Only expose necessary ports (80, 443)
8. **Database security**: Use strong passwords, restrict network access
9. **File permissions**: Ensure proper ownership and permissions
10. **Rate limiting**: Add Nginx rate limiting (future enhancement)
### Security Headers
SnackBase automatically sets security headers on all HTTP responses to protect against common web vulnerabilities:
* **X-Content-Type-Options**: Prevents MIME type sniffing
* **X-Frame-Options**: Prevents clickjacking attacks
* **X-XSS-Protection**: Enables browser XSS protection
* **Strict-Transport-Security** (production only): Enforces HTTPS
* **Content-Security-Policy**: Prevents XSS and injection attacks
* **Permissions-Policy**: Restricts browser features
* **Referrer-Policy**: Controls referrer information
#### Customizing Security Headers
You can customize security headers via environment variables:
```bash theme={null}
# Disable security headers (not recommended)
SNACKBASE_SECURITY_HEADERS_ENABLED=false
# Customize HSTS max-age (default: 31536000 = 1 year)
SNACKBASE_HSTS_MAX_AGE=63072000 # 2 years
# Customize Content Security Policy
SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.example.com"
# Customize Permissions Policy
SNACKBASE_PERMISSIONS_POLICY="geolocation=(), camera=(), microphone=()"
# Enable HTTP to HTTPS redirect in production (optional)
SNACKBASE_HTTPS_REDIRECT_ENABLED=true
```
**Note**: The default CSP policy is designed to work with the SnackBase Admin UI. If you're using a custom frontend with external CDNs or inline scripts, you may need to adjust the CSP policy.
#### Troubleshooting CSP Issues
If your frontend is blocked by CSP:
1. **Check browser console**: Look for CSP violation errors
2. **Identify blocked resources**: Note the source and type (script, style, image, etc.)
3. **Update CSP policy**: Add the necessary sources to the appropriate directive
4. **Test thoroughly**: Ensure all functionality works after CSP changes
Example CSP for frontend with external CDN:
````bash theme={null}
SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'"
---
## Single-Tenant Mode
SnackBase supports a "Single-Tenant Mode" where the entire instance behaves as a dedicated application for a single account.
### How it works
When enabled:
1. All new users automatically join the specified `SNACKBASE_SINGLE_TENANT_ACCOUNT`.
2. Login and Registration endpoints no longer require an `account` identifier (though it's still accepted).
3. The first user to register becomes the admin of that account.
### Configuration
```bash
SNACKBASE_SINGLE_TENANT_MODE=true
SNACKBASE_SINGLE_TENANT_ACCOUNT=my-awesome-app
SNACKBASE_SINGLE_TENANT_ACCOUNT_NAME="My Awesome App"
````
### Authentication Flow
In single-tenant mode, the account is resolved server-side:
```bash theme={null}
# Registration (no account field needed)
curl -X POST http://localhost:8000/api/v1/auth/register \
-d '{"email": "user@example.com", "password": "password123"}'
# Login (no account field needed)
curl -X POST http://localhost:8000/api/v1/auth/login \
-d '{"email": "user@example.com", "password": "password123"}'
```
You can customize security headers via environment variables:
```bash theme={null}
# Disable security headers (not recommended)
SNACKBASE_SECURITY_HEADERS_ENABLED=false
# Customize HSTS max-age (default: 31536000 = 1 year)
SNACKBASE_HSTS_MAX_AGE=63072000 # 2 years
# Customize Content Security Policy
SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.example.com"
# Customize Permissions Policy
SNACKBASE_PERMISSIONS_POLICY="geolocation=(), camera=(), microphone=()"
# Enable HTTP to HTTPS redirect in production (optional)
SNACKBASE_HTTPS_REDIRECT_ENABLED=true
```
The default CSP policy is designed to work with the SnackBase Admin UI. If
you're using a custom frontend with external CDNs or inline scripts, you may
need to adjust the CSP policy.
#### Troubleshooting CSP Issues
If your frontend is blocked by CSP:
1. **Check browser console**: Look for CSP violation errors
2. **Identify blocked resources**: Note the source and type (script, style, image, etc.)
3. **Update CSP policy**: Add the necessary sources to the appropriate directive
4. **Test thoroughly**: Ensure all functionality works after CSP changes
Example CSP for frontend with external CDN:
```bash theme={null}
SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'"
```
## Next Steps
After deployment:
1. **Create first account**: Use the `/api/v1/auth/register` endpoint
2. **Test API**: Use Swagger UI at `/docs`
3. **Create collections**: Use the `/api/v1/collections` endpoint
4. **Set up monitoring**: Configure log aggregation and alerts
5. **Configure backups**: Set up automated database backups
6. **Review security**: Follow security best practices checklist
# Adding API Endpoints
Source: https://docs.snackbase.dev/guides/adding-api-endpoints
Learn how to add new API endpoints to SnackBase following established patterns and architecture
This guide explains how to add new API endpoints to SnackBase, following the established patterns and architecture.
## Overview
SnackBase uses **FastAPI** for REST API endpoints. All endpoints are organized in the `src/snackbase/infrastructure/api/routes/` directory.
### Existing API Routers
| Router | Purpose | Path |
| ----------------------- | --------------------- | ------------------------ |
| `auth_router.py` | Authentication | `/api/v1/auth/*` |
| `accounts_router.py` | Account management | `/api/v1/accounts/*` |
| `users_router.py` | User management | `/api/v1/users/*` |
| `roles_router.py` | Role management | `/api/v1/roles/*` |
| `permissions_router.py` | Permission management | `/api/v1/permissions/*` |
| `collections_router.py` | Collection CRUD | `/api/v1/collections/*` |
| `records_router.py` | Dynamic record CRUD | `/api/v1/{collection}/*` |
| `groups_router.py` | Group management | `/api/v1/groups/*` |
| `invitations_router.py` | User invitations | `/api/v1/invitations/*` |
| `macros_router.py` | SQL macros | `/api/v1/macros/*` |
| `dashboard_router.py` | Dashboard stats | `/api/v1/dashboard/*` |
| `audit_log_router.py` | Audit logs | `/api/v1/audit-logs/*` |
| `migrations_router.py` | DB migrations | `/api/v1/migrations/*` |
## Architecture Review
### Layer Structure
```
Request -> API Router -> Service -> Repository -> Database
```
| Layer | Responsibility | Location |
| -------------- | ------------------------------------ | ------------------------------------------------ |
| **API Router** | HTTP handling, validation, responses | `infrastructure/api/routes/` |
| **Service** | Business logic, orchestration | `domain/services/` or `infrastructure/services/` |
| **Repository** | Data access, database queries | `infrastructure/persistence/repositories/` |
| **Database** | Data storage | SQLAlchemy models |
### Clean Architecture Principles
* **Routers** handle HTTP concerns (status codes, headers, parsing)
* **Services** contain business logic
* **Repositories** abstract database access
* **Models** (Pydantic) define request/response schemas
## Where to Add Endpoints
### Decision Tree
```
Does endpoint handle dynamic collections?
|
+-- Yes --> Modify records_router.py
|
+-- No --> Does it fit existing router?
|
+-- Yes --> Add to existing router
|
+-- No --> Create new router
```
### When to Create a New Router
Create a new router when:
* Adding a new major feature area
* Existing routers don't match the domain concept
* The feature has 3+ related endpoints
Examples of when to create new routers:
* `/api/v1/webhooks/*` - Webhook management
* `/api/v1/scheduled-tasks/*` - Task scheduling
* `/api/v1/integrations/*` - Third-party integrations
## Step-by-Step Guide
Let's add a new feature: **Tags** for organizing records.
### Step 1: Define Pydantic Schemas
Create request/response models in `infrastructure/api/schemas/`:
```python theme={null}
# src/snackbase/infrastructure/api/schemas/tags.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
class TagBase(BaseModel):
name: str
color: str | None = None
class TagCreate(TagBase):
pass
class TagUpdate(TagBase):
name: str | None = None
color: str | None = None
class TagResponse(TagBase):
model_config = ConfigDict(from_attributes=True)
id: str
account_id: str
created_at: datetime
updated_at: datetime
created_by: str | None = None
```
### Step 2: Create Database Model
Add SQLAlchemy model in `infrastructure/persistence/models/`:
```python theme={null}
# src/snackbase/infrastructure/persistence/models/tag.py
from sqlalchemy import Column, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from src.snackbase.infrastructure.persistence.database import Base
class Tag(Base):
__tablename__ = "tags"
id: Mapped[str] = mapped_column(String(50), primary_key=True)
account_id: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_by: Mapped[str | None] = mapped_column(String(50), nullable=True)
```
### Step 3: Create Repository
Add repository in `infrastructure/persistence/repositories/`:
```python theme={null}
# src/snackbase/infrastructure/persistence/repositories/tag_repository.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from src.snackbase.infrastructure.persistence.models.tag import Tag
from src.snackbase.core.context import Context
class TagRepository:
def __init__(self, session: AsyncSession):
self._session = session
async def create(self, tag: Tag) -> Tag:
self._session.add(tag)
await self._session.flush()
return tag
async def get_by_id(self, tag_id: str, context: Context) -> Tag | None:
result = await self._session.execute(
select(Tag).where(Tag.id == tag_id, Tag.account_id == context.account_id)
)
return result.scalar_one_or_none()
async def find_all(self, context: Context) -> list[Tag]:
result = await self._session.execute(
select(Tag).where(Tag.account_id == context.account_id)
)
return list(result.scalars().all())
async def update(self, tag: Tag) -> Tag:
await self._session.flush()
return tag
async def delete(self, tag: Tag) -> None:
await self._session.delete(tag)
await self._session.flush()
```
### Step 4: Create API Router
Create router in `infrastructure/api/routes/`:
```python theme={null}
# src/snackbase/infrastructure/api/routes/tags_router.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.snackbase.infrastructure.api.dependencies import (
get_db,
get_context,
require_permission,
)
from src.snackbase.infrastructure.api.schemas.tags import (
TagCreate,
TagUpdate,
TagResponse,
)
from src.snackbase.core.context import Context
from src.snackbase.infrastructure.persistence.repositories.tag_repository import TagRepository
from src.snackbase.infrastructure.persistence.models.tag import Tag
from src.snackbase.core.id_generator import generate_id
router = APIRouter(prefix="/tags", tags=["tags"])
@router.post("/", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
async def create_tag(
tag_data: TagCreate,
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
"""Create a new tag."""
repo = TagRepository(db)
tag = Tag(
id=generate_id("tag"),
account_id=context.account_id,
name=tag_data.name,
color=tag_data.color,
created_at=datetime.nowUTC(),
updated_at=datetime.nowUTC(),
created_by=context.user_id,
)
created = await repo.create(tag)
return TagResponse.model_validate(created)
@router.get("/", response_model=list[TagResponse])
async def list_tags(
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
"""List all tags for the current account."""
repo = TagRepository(db)
tags = await repo.find_all(context)
return [TagResponse.model_validate(tag) for tag in tags]
@router.get("/{tag_id}", response_model=TagResponse)
async def get_tag(
tag_id: str,
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
"""Get a specific tag by ID."""
repo = TagRepository(db)
tag = await repo.get_by_id(tag_id, context)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tag not found"
)
return TagResponse.model_validate(tag)
@router.put("/{tag_id}", response_model=TagResponse)
async def update_tag(
tag_id: str,
tag_data: TagUpdate,
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
"""Update a tag."""
repo = TagRepository(db)
tag = await repo.get_by_id(tag_id, context)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tag not found"
)
# Update fields
if tag_data.name is not None:
tag.name = tag_data.name
if tag_data.color is not None:
tag.color = tag_data.color
tag.updated_at = datetime.nowUTC()
updated = await repo.update(tag)
return TagResponse.model_validate(updated)
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tag(
tag_id: str,
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
"""Delete a tag."""
repo = TagRepository(db)
tag = await repo.get_by_id(tag_id, context)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tag not found"
)
await repo.delete(tag)
```
### Step 5: Register Router
Add the router to `app.py`:
```python theme={null}
# src/snackbase/infrastructure/api/app.py
from src.snackbase.infrastructure.api.routes.tags_router import router as tags_router
# ... existing imports
app = FastAPI(title="SnackBase")
# ... existing middleware
# Register routers
app.include_router(auth_router, prefix=API_PREFIX, tags=["auth"])
app.include_router(accounts_router, prefix=API_PREFIX, tags=["accounts"])
# ... existing routers
# NEW: Add tags router BEFORE records_router
app.include_router(tags_router, prefix=API_PREFIX, tags=["tags"])
# IMPORTANT: records_router must be LAST (catches /{collection})
app.include_router(records_router, prefix=API_PREFIX, tags=["records"])
```
The `records_router` must be registered LAST because it uses dynamic route matching (/) that will catch any unmatched paths.
### Step 6: Create Migration
Generate and apply database migration:
```bash theme={null}
# Generate migration
uv run alembic revision --autogenerate -m "Add tags table"
# Apply migration
uv run alembic upgrade head
```
### Step 7: Add Permissions (Optional)
If your feature needs authorization, add permissions:
```python theme={null}
# In roles management UI or via API
{
"role": "admin",
"collection": "tags",
"create": true,
"read": true,
"update": true,
"delete": true
}
```
## Request/Response Patterns
### Request Body Validation
Use Pydantic for automatic validation:
```python theme={null}
from pydantic import BaseModel, Field, field_validator
class TagCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
color: str | None = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
@field_validator('name')
@classmethod
def name_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError('name cannot be empty or whitespace')
return v.strip()
```
### Response Formatting
Use consistent response formats:
```python theme={null}
# Success response
{
"id": "tag_abc123",
"name": "Important",
"color": "#ff0000",
"account_id": "AB1001",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}
# Error response
{
"detail": "Tag not found"
}
```
### Pagination
For list endpoints, support pagination:
```python theme={null}
from fastapi import Query
from typing import Optional
@router.get("/", response_model=list[TagResponse])
async def list_tags(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
repo = TagRepository(db)
tags = await repo.find_all(context, skip=skip, limit=limit)
return [TagResponse.model_validate(tag) for tag in tags]
```
## Authentication & Authorization
### Require Authentication
All endpoints automatically require authentication via `get_context()`:
```python theme={null}
from src.snackbase.infrastructure.api.dependencies import get_context
from src.snackbase.core.context import Context
@router.get("/tags")
async def list_tags(
context: Context = Depends(get_context), # Ensures valid token
db: AsyncSession = Depends(get_db),
):
# context.user_id, context.account_id available
pass
```
### Require Permissions
Use `require_permission()` for authorization:
```python theme={null}
from src.snackbase.infrastructure.api.dependencies import require_permission
@router.delete("/tags/{tag_id}")
async def delete_tag(
tag_id: str,
authorized: bool = Depends(require_permission("tags", "delete")),
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
# Only executes if user has "tags:delete" permission
pass
```
### Superadmin-Only Endpoints
For superadmin-only endpoints:
```python theme={null}
from src.snackbase.infrastructure.api.dependencies import require_superadmin
@router.post("/accounts")
async def create_account(
account_data: AccountCreate,
is_superadmin: bool = Depends(require_superadmin),
db: AsyncSession = Depends(get_db),
):
# Only superadmins can access
pass
```
## Testing Endpoints
### Unit Tests
Test router logic:
```python theme={null}
# tests/unit/test_tags_router.py
import pytest
from fastapi.testclient import TestClient
from src.snackbase.infrastructure.api.app import app
client = TestClient(app)
def test_create_tag_requires_auth():
response = client.post("/api/v1/tags/", json={"name": "Test"})
assert response.status_code == 401
def test_create_tag_success(superadmin_token):
response = client.post(
"/api/v1/tags/",
headers={"Authorization": f"Bearer {superadmin_token}"},
json={"name": "Test", "color": "#ff0000"}
)
assert response.status_code == 201
assert response.json()["name"] == "Test"
```
### Integration Tests
Test full flow with database:
```python theme={null}
# tests/integration/test_tags_integration.py
import pytest
from httpx import AsyncClient, ASGITransport
from src.snackbase.infrastructure.api.app import app
@pytest.mark.asyncio
async def test_create_and_retrieve_tag(db_session):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
# Create tag
create_response = await client.post(
"/api/v1/tags/",
json={"name": "Test Tag"}
)
assert create_response.status_code == 201
tag_id = create_response.json()["id"]
# Retrieve tag
get_response = await client.get(f"/api/v1/tags/{tag_id}")
assert get_response.status_code == 200
assert get_response.json()["name"] == "Test Tag"
```
### Manual Testing with Swagger UI
Visit `http://localhost:8000/docs` to test endpoints interactively.
## Best Practices
### 1. Use Appropriate Status Codes
| Code | Usage | Example |
| ---- | ------------------------- | -------------------------- |
| 200 | Success (GET, PUT, PATCH) | Tag retrieved successfully |
| 201 | Created (POST) | Tag created successfully |
| 204 | No Content (DELETE) | Tag deleted successfully |
| 400 | Bad Request | Validation error |
| 401 | Unauthorized | Missing or invalid token |
| 403 | Forbidden | Insufficient permissions |
| 404 | Not Found | Tag doesn't exist |
| 422 | Unprocessable Entity | Invalid request data |
### 2. Account Isolation
Always filter by `account_id` in repositories:
```python theme={null}
# ❌ BAD: No account filtering
async def get_by_id(self, tag_id: str) -> Tag | None:
result = await self._session.execute(
select(Tag).where(Tag.id == tag_id)
)
# ✅ GOOD: Account filtering
async def get_by_id(self, tag_id: str, context: Context) -> Tag | None:
result = await self._session.execute(
select(Tag).where(Tag.id == tag_id, Tag.account_id == context.account_id)
)
```
### 3. Use Dependency Injection
Inject dependencies via FastAPI's `Depends()`:
```python theme={null}
# ❌ BAD: Manual dependency handling
@router.get("/tags/{tag_id}")
async def get_tag(tag_id: str, token: str):
context = decode_token(token) # Manual
db = get_db() # Manual
...
# ✅ GOOD: Dependency injection
@router.get("/tags/{tag_id}")
async def get_tag(
tag_id: str,
context: Context = Depends(get_context), # Automatic
db: AsyncSession = Depends(get_db), # Automatic
):
...
```
### 4. Handle Errors Gracefully
```python theme={null}
@router.get("/tags/{tag_id}")
async def get_tag(
tag_id: str,
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
repo = TagRepository(db)
tag = await repo.get_by_id(tag_id, context)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tag {tag_id} not found"
)
return TagResponse.model_validate(tag)
```
### 5. Document Endpoints
Use FastAPI's docstring support:
```python theme={null}
@router.post(
"/",
response_model=TagResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new tag",
description="Creates a new tag for the current account. Tags can be used to organize and categorize records.",
responses={
201: {"description": "Tag created successfully"},
400: {"description": "Invalid request data"},
401: {"description": "Unauthorized"},
}
)
async def create_tag(
tag_data: TagCreate,
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db),
):
"""Create a new tag."""
...
```
## Summary
| Step | Action | Location |
| ---- | ----------------------- | ------------------------------------------ |
| 1 | Define Pydantic schemas | `infrastructure/api/schemas/` |
| 2 | Create SQLAlchemy model | `infrastructure/persistence/models/` |
| 3 | Create repository | `infrastructure/persistence/repositories/` |
| 4 | Create API router | `infrastructure/api/routes/` |
| 5 | Register router | `infrastructure/api/app.py` |
| 6 | Create migration | Alembic |
| 7 | Add permissions | Via UI or API |
| 8 | Write tests | `tests/unit/`, `tests/integration/` |
## Related Guides
* [Testing Guide](./testing)
* [Extending SnackBase](./extending-snackbase)
* [Creating Custom Hooks](./creating-custom-hooks)
# Building Workflows
Source: https://docs.snackbase.dev/guides/building-workflows
Step-by-step guide to creating multi-step automation workflows
This guide walks you through building a multi-step workflow that processes events, makes decisions, and performs actions automatically.
## Prerequisites
* A running SnackBase instance
* At least one collection (we'll use `orders` in this example)
* Familiarity with [Workflow Automation concepts](/concepts/workflows)
## Building Your First Workflow
Plan the steps before creating:
```
Order Created
└─> Check if high value (>= $500)
├─ Yes: Send Slack alert
└─ No: Send confirmation email
└─> Wait 2 hours
└─> Update order status to "processing"
```
```ts theme={null}
const workflow = await client.workflows.create({
name: "Order Processing",
trigger_type: "event",
trigger_config: {
type: "event",
event: "records.create",
collection: "orders",
},
steps: [
{
name: "check_value",
type: "condition",
config: {
expression: "trigger.total >= 500",
on_true: "high_value_alert",
on_false: "send_confirmation",
},
},
{
name: "high_value_alert",
type: "action",
config: {
action_type: "send_webhook",
config: {
url: "https://hooks.slack.com/services/...",
body_template: {
text: "High-value order #{{trigger.id}}: ${{trigger.total}}",
},
},
},
},
{
name: "send_confirmation",
type: "action",
config: {
action_type: "send_email",
config: {
to: "{{trigger.customer_email}}",
subject: "Order Confirmed",
template_name: "order_confirmation",
variables: { order_id: "{{trigger.id}}" },
},
},
},
{
name: "wait_before_processing",
type: "wait_delay",
config: {
duration: "2h",
next: "update_status",
},
},
{
name: "update_status",
type: "action",
config: {
action_type: "update_record",
config: {
collection: "orders",
record_id: "{{trigger.id}}",
data: { status: "processing" },
},
},
},
],
});
```
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/workflows \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"name": "Order Processing",
"trigger_type": "event",
"trigger_config": {
"type": "event",
"event": "records.create",
"collection": "orders"
},
"steps": [
{
"name": "check_value",
"type": "condition",
"config": {
"expression": "trigger.total >= 500",
"on_true": "high_value_alert",
"on_false": "send_confirmation"
}
}
]
}'
```
Before relying on events, test with manual trigger:
```ts theme={null}
const result = await client.workflows.trigger(workflow.id, {
id: "test-order-1",
total: 750,
customer_email: "test@example.com",
});
console.log("Instance ID:", result.instance_id);
```
Check the instance status and step logs:
```ts theme={null}
const instance = await client.workflows.getInstance(result.instance_id);
console.log("Status:", instance.status);
console.log("Current step:", instance.current_step);
for (const log of instance.step_logs) {
console.log(` ${log.step_name} (${log.step_type}): ${log.status}`);
if (log.output) console.log(" Output:", log.output);
if (log.error_message) console.log(" Error:", log.error_message);
}
```
If an instance fails, investigate and resume:
```ts theme={null}
// List failed instances
const failed = await client.workflows.listInstances(workflow.id, {
status: "failed",
});
// Resume a failed instance
if (failed.items.length > 0) {
await client.workflows.retryInstance(failed.items[0].id);
}
```
Or cancel an instance that shouldn't continue:
```ts theme={null}
await client.workflows.cancelInstance(instance.id);
```
## Advanced Patterns
### Webhook-Triggered Workflows
Accept events from external services (e.g., Stripe, GitHub):
```ts theme={null}
const workflow = await client.workflows.create({
name: "Stripe Payment Handler",
trigger_type: "webhook",
trigger_config: { type: "webhook" },
steps: [
{
name: "process_payment",
type: "action",
config: {
action_type: "update_record",
config: {
collection: "orders",
record_id: "{{trigger.metadata.order_id}}",
data: { payment_status: "paid" },
},
},
},
],
});
// The webhook token is in workflow.trigger_config.token
// External services POST to: /api/v1/workflow-webhooks/{token}
```
### Parallel Notifications
Send multiple notifications simultaneously:
```ts theme={null}
{
name: "notify_all",
type: "parallel",
config: {
branches: [
["send_email_step"],
["send_slack_step"],
["update_crm_step"]
]
}
}
```
### Loops
Process each item in a list:
```ts theme={null}
{
name: "process_line_items",
type: "loop",
config: {
items: "{{trigger.line_items}}",
step: "process_single_item"
}
}
```
## Troubleshooting
* Verify the workflow is `enabled`
* Check `trigger_config.event` matches the actual event
* If using `collection`, verify the collection name is correct
* Check if a `condition` in the trigger config is filtering out events
This is normal for `wait_delay` steps. The instance will resume automatically after the delay. You can also manually resume it via the API.
Check the `error_message` in the step log. Common causes:
* Invalid template variable references
* External service unavailable (for webhook actions)
* Record not found (for update/delete actions)
## Next Steps
* [Workflow Automation Concept](/concepts/workflows) -- full reference
* [Workflows API Reference](/api-reference/endpoints/workflows/create-workflow) -- all endpoints
* [Workflows SDK Reference](/sdk/js/services/workflows) -- SDK methods
# Creating Custom Endpoints
Source: https://docs.snackbase.dev/guides/creating-custom-endpoints
Step-by-step guide to building serverless-like HTTP endpoints
This guide walks you through creating custom HTTP endpoints that execute action pipelines -- without writing backend code.
## Prerequisites
* A running SnackBase instance
* At least one collection with data
* Familiarity with [Custom Endpoints concepts](/concepts/custom-endpoints)
## Building Your First Endpoint
Plan what the endpoint does:
```
GET /api/v1/x/customers/:customer_id/summary
1. Query recent orders for this customer
2. Calculate total spend
3. Return combined summary
```
```ts theme={null}
const endpoint = await client.endpoints.create({
name: "Customer Summary",
path: "/customers/:customer_id/summary",
method: "GET",
auth_required: true,
actions: [
{
type: "query_records",
config: {
collection: "orders",
filter: "customer_id = '{{request.params.customer_id}}'",
sort: "-created_at",
limit: 5,
},
},
{
type: "aggregate_records",
config: {
collection: "orders",
function: "sum",
field: "total",
filter: "customer_id = '{{request.params.customer_id}}'",
},
},
{
type: "transform",
config: {
output: {
recent_orders: "{{actions[0].result}}",
lifetime_spend: "{{actions[1].result}}",
},
},
},
],
response_template: {
status: 200,
body: "{{actions[2].result}}",
},
});
```
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/endpoints \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"name": "Customer Summary",
"path": "/customers/:customer_id/summary",
"method": "GET",
"auth_required": true,
"actions": [
{
"type": "query_records",
"config": {
"collection": "orders",
"filter": "customer_id = '\''{{request.params.customer_id}}'\''",
"sort": "-created_at",
"limit": 5
}
}
]
}'
```
Call your new endpoint:
```bash theme={null}
curl https://api.snackbase.dev/api/v1/x/customers/cust-123/summary \
-H "Authorization: Bearer {token}"
```
Expected response:
```json theme={null}
{
"recent_orders": [...],
"lifetime_spend": { "value": 2450.00 }
}
```
Use a condition expression to restrict access:
```ts theme={null}
const endpoint = await client.endpoints.update(endpoint.id, {
condition: "@has_role('admin') or auth.user_id = request.params.customer_id",
});
```
If the condition evaluates to `false`, the endpoint returns 403 Forbidden.
Track how your endpoint is being used:
```ts theme={null}
const executions = await client.endpoints.listExecutions(endpoint.id);
for (const exec of executions.items) {
console.log(`${exec.status} - ${exec.duration_ms}ms`);
}
```
## More Examples
### Form Submission Endpoint
Accept form data and create a record:
```ts theme={null}
await client.endpoints.create({
name: "Submit Feedback",
path: "/submit-feedback",
method: "POST",
auth_required: false, // Public endpoint
actions: [
{
type: "create_record",
config: {
collection: "feedback",
data: {
message: "{{request.body.message}}",
email: "{{request.body.email}}",
submitted_at: "{{now}}",
},
},
},
],
response_template: {
status: 201,
body: { message: "Thank you for your feedback!" },
},
});
```
### Dashboard Data Endpoint
Aggregate data for a dashboard:
```ts theme={null}
await client.endpoints.create({
name: "Sales Dashboard",
path: "/dashboard/sales",
method: "GET",
auth_required: true,
condition: "@has_role('admin')",
actions: [
{
type: "aggregate_records",
config: {
collection: "orders",
function: "count",
filter: "status = 'completed'",
},
},
{
type: "aggregate_records",
config: {
collection: "orders",
function: "sum",
field: "total",
filter: "status = 'completed'",
},
},
{
type: "aggregate_records",
config: {
collection: "orders",
function: "sum",
field: "total",
filter: "status = 'completed'",
group_by: "category",
},
},
{
type: "transform",
config: {
output: {
total_orders: "{{actions[0].result}}",
total_revenue: "{{actions[1].result}}",
revenue_by_category: "{{actions[2].result}}",
},
},
},
],
response_template: {
status: 200,
body: "{{actions[3].result}}",
},
});
```
## Troubleshooting
* Verify the endpoint is `enabled`
* Check the URL format: `/api/v1/x/{path}` (note the `/x/` prefix)
* Confirm the HTTP method matches (GET, POST, etc.)
* Check path parameter syntax uses `:param` format
* The `condition` expression evaluated to `false`
* If `auth_required` is `true`, ensure you're sending a valid auth token
* Another endpoint with the same `path` and `method` already exists
* Each (path, method) combination must be unique per account
Endpoints have a 30-second execution timeout. If your action pipeline is complex, consider:
* Reducing query `limit` values
* Simplifying filter expressions
* Breaking complex logic into separate endpoints
## Next Steps
* [Custom Endpoints Concept](/concepts/custom-endpoints) -- full reference
* [Endpoints API Reference](/api-reference/endpoints/custom-endpoints/create-endpoint) -- all endpoints
* [Endpoints SDK Reference](/sdk/js/services/endpoints) -- SDK methods
# Creating Custom Hooks
Source: https://docs.snackbase.dev/guides/creating-custom-hooks
Learn how to create custom hooks in SnackBase to extend functionality and automate workflows
This guide explains how to create custom hooks in SnackBase to extend functionality and automate workflows.
This guide covers **code-level hooks** written in Python. If you want to create hooks via the REST API without writing code, see [API-Defined Hooks](/concepts/api-hooks) and the [Hooks SDK Service](/sdk/js/services/hooks).
## Overview
Hooks allow you to **execute custom code** in response to events within SnackBase. They're the primary extension mechanism for adding custom business logic.
### What Can Hooks Do?
| Capability | Example |
| ------------------------------- | ----------------------------------------- |
| **Send Notifications** | Email when record is created |
| **Transform Data** | Auto-generate slugs from titles |
| **Integrate External Services** | Call webhook on record update |
| **Enforce Business Rules** | Prevent deletion of published posts |
| **Audit Changes** | Log all modifications to sensitive fields |
| **Sync Data** | Replicate changes to external systems |
### Hook System Stability
The hook registration mechanism is a **STABLE API contract** (v1.0). This means:
* Hook registration syntax won't change in breaking ways
* Existing hooks will continue to work in future versions
* New hook types will be additive, not breaking changes
The hook system API is stable and guaranteed to maintain backward compatibility.
## Hook System Review
### Hook Registration Pattern
Hooks are registered using the `app.hook` decorator:
```python theme={null}
from src.snackbase.infrastructure.api.app import app
@app.hook.on_record_after_create("posts", priority=10)
async def send_post_notification(record: dict, context: Context):
"""Send notification when post is created."""
await notification_service.send(
user_id=record["created_by"],
message="Your post has been published!"
)
```
### Built-in Hooks
SnackBase includes built-in hooks that **cannot be disabled**:
| Hook | Purpose | Event |
| ------------------------ | ---------------------------------- | --------------------- |
| `timestamp_hook` | Auto-set `created_at`/`updated_at` | All record operations |
| `account_isolation_hook` | Enforce `account_id` filtering | All record queries |
| `created_by_hook` | Set `created_by` user ID | Record creation |
## Hook Categories
Hooks are organized into **8 categories**:
### 1. App Lifecycle Hooks
| Event | Description | When |
| ----------------- | -------------- | --------------------- |
| `on_app_startup` | App starts up | Server initialization |
| `on_app_shutdown` | App shuts down | Server shutdown |
### 2. Model Operation Hooks
| Event | Description | When |
| ------------------------ | ------------------- | ---------------------- |
| `on_model_before_create` | Before model insert | Before database insert |
| `on_model_after_create` | After model insert | After database insert |
| `on_model_before_update` | Before model update | Before database update |
| `on_model_after_update` | After model update | After database update |
| `on_model_before_delete` | Before model delete | Before database delete |
| `on_model_after_delete` | After model delete | After database delete |
### 3. Record Operation Hooks
| Event | Description | When |
| ------------------------- | ----------------------- | ------------------------------- |
| `on_record_before_create` | Before record creation | Before inserting dynamic record |
| `on_record_after_create` | After record creation | After inserting dynamic record |
| `on_record_before_update` | Before record update | Before updating dynamic record |
| `on_record_after_update` | After record update | After updating dynamic record |
| `on_record_before_delete` | Before record deletion | Before deleting dynamic record |
| `on_record_after_delete` | After record deletion | After deleting dynamic record |
| `on_record_before_query` | Before querying records | Before executing query |
| `on_record_after_query` | After querying records | After executing query |
### 4. Collection Operation Hooks
| Event | Description | When |
| ----------------------------- | -------------------------- | -------------------------- |
| `on_collection_before_create` | Before collection creation | Before creating collection |
| `on_collection_after_create` | After collection creation | After creating collection |
| `on_collection_before_update` | Before collection update | Before updating collection |
| `on_collection_after_update` | After collection update | After updating collection |
| `on_collection_before_delete` | Before collection deletion | Before deleting collection |
| `on_collection_after_delete` | After collection deletion | After deleting collection |
### 5. Auth Operation Hooks
| Event | Description | When |
| ------------------------- | ------------------- | ----------------------------- |
| `on_auth_before_login` | Before login | Before validating credentials |
| `on_auth_after_login` | After login | After successful login |
| `on_auth_before_logout` | Before logout | Before logout processing |
| `on_auth_after_logout` | After logout | After logout processing |
| `on_auth_before_register` | Before registration | Before creating user |
| `on_auth_after_register` | After registration | After creating user |
### 6. User Operation Hooks
| Event | Description | When |
| ----------------------- | -------------------- | -------------------- |
| `on_user_before_create` | Before user creation | Before creating user |
| `on_user_after_create` | After user creation | After creating user |
| `on_user_before_update` | Before user update | Before updating user |
| `on_user_after_update` | After user update | After updating user |
| `on_user_before_delete` | Before user deletion | Before deleting user |
| `on_user_after_delete` | After user deletion | After deleting user |
### 7. Request Processing Hooks
| Event | Description | When |
| ------------------- | ------------------------- | --------------------- |
| `on_request_before` | Before request processing | At start of request |
| `on_request_after` | After request processing | At end of request |
| `on_request_error` | On request error | When exception occurs |
### 8. Custom Event Hooks
| Event | Description | When |
| ----------------- | ------------ | ------------------ |
| `on_custom_event` | Custom event | Triggered manually |
## Step-by-Step Guide
Let's create a custom hook that **sends a Slack notification** when a post is published.
### Step 1: Create Hook File
Create hooks in a dedicated file:
```python theme={null}
# src/snackbase/infrastructure/hooks/custom_hooks.py
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from src.snackbase.core.context import Context
from src.snackbase.infrastructure.api.app import app
from src.snackbase.core.config import settings
SLACK_WEBHOOK_URL = settings.slack_webhook_url
@app.hook.on_record_after_update("posts", priority=20)
async def notify_slack_on_publish(
record: dict,
context: Context,
old_record: dict | None = None,
db: AsyncSession = None
):
"""
Send Slack notification when post status changes to 'published'.
Trigger: After updating a post record
Condition: status changed to 'published'
"""
# Check if status changed to 'published'
if old_record and record.get("status") == "published":
if old_record.get("status") != "published":
await _send_slack_notification(record)
async def _send_slack_notification(post: dict):
"""Send notification to Slack."""
message = {
"text": f"New post published: {post.get('title')}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*New Post Published*\n*Title:* {post.get('title')}\n*Author:* "
}
}
]
}
async with httpx.AsyncClient() as client:
await client.post(SLACK_WEBHOOK_URL, json=message)
```
### Step 2: Import Hooks
Import your hooks file in `app.py` to register them:
```python theme={null}
# src/snackbase/infrastructure/api/app.py
# ... existing imports
# Import custom hooks (this registers them)
from src.snackbase.infrastructure.hooks.custom_hooks import (
notify_slack_on_publish,
)
# ... rest of app.py
```
### Step 3: Configure Environment
Add required configuration to `.env`:
```bash theme={null}
# Slack webhook URL
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
```
### Step 4: Test the Hook
```bash theme={null}
# Update a post to published
curl -X PUT http://localhost:8000/api/v1/posts/post_abc123 \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"status": "published"
}'
# Check Slack for notification
```
## Hook Context
### Hook Parameters
Hooks receive different parameters based on their type:
```python theme={null}
@app.hook.on_record_after_create("posts")
async def my_hook(
record: dict, # The record being created/updated/deleted
context: Context, # Request context (user_id, account_id)
old_record: dict | None = None, # Previous state (for updates)
db: AsyncSession = None, # Database session (optional)
):
pass
```
### Context Object
The `context` object provides request information:
| Field | Type | Description |
| --------------- | ------ | -------------------------- |
| `user_id` | `str` | Current user ID |
| `account_id` | `str` | Current account ID |
| `request_id` | `str` | Correlation ID for tracing |
| `is_superadmin` | `bool` | Whether user is superadmin |
## Advanced Features
### Priority Control
Hooks execute in priority order (higher priority = earlier execution):
```python theme={null}
@app.hook.on_record_after_create("posts", priority=100)
async def high_priority_hook(record: dict, context: Context):
"""Executes first."""
pass
@app.hook.on_record_after_create("posts", priority=10)
async def low_priority_hook(record: dict, context: Context):
"""Executes after high_priority_hook."""
pass
@app.hook.on_record_after_create("posts", priority=0)
async def default_priority_hook(record: dict, context: Context):
"""Executes last."""
pass
```
### Conditional Execution
Only execute hooks based on conditions:
```python theme={null}
@app.hook.on_record_after_update("posts")
async def conditional_hook(
record: dict,
context: Context,
old_record: dict | None = None
):
"""Only execute for specific conditions."""
# Only if specific field changed
if old_record and record.get("status") != old_record.get("status"):
# Status changed
pass
# Only for specific account
if context.account_id == "AB1001":
# Special handling for this account
pass
# Only if specific field value
if record.get("category") == "urgent":
# Handle urgent posts
pass
```
### Error Handling
Handle errors gracefully in hooks:
```python theme={null}
import logging
logger = logging.getLogger(__name__)
@app.hook.on_record_after_create("posts")
async def safe_hook(record: dict, context: Context):
"""Hook with error handling."""
try:
await external_api_call(record)
except httpx.HTTPError as e:
# Log error but don't fail the request
logger.error(f"Hook failed: {e}", exc_info=True)
# Optionally: send alert
except Exception as e:
# Unexpected error
logger.critical(f"Unexpected hook error: {e}", exc_info=True)
raise # Re-raise if critical
```
### Aborting Operations
Some `before_*` hooks can abort operations:
```python theme={null}
from src.snackbase.core.exceptions import HookAbortException
@app.hook.on_record_before_delete("posts")
async def prevent_published_deletion(
record: dict,
context: Context
):
"""Prevent deletion of published posts."""
if record.get("status") == "published":
raise HookAbortException(
message="Cannot delete published posts",
status_code=400
)
```
### Async Database Operations
Hooks can perform database operations:
```python theme={null}
from sqlalchemy import select
@app.hook.on_record_after_create("posts")
async def create_audit_log(
record: dict,
context: Context,
db: AsyncSession
):
"""Create audit log entry."""
from src.snackbase.infrastructure.persistence.models.audit_log import AuditLog
log = AuditLog(
id=generate_id("audit"),
account_id=context.account_id,
user_id=context.user_id,
action="create",
collection="posts",
record_id=record["id"],
changes=record,
timestamp=datetime.nowUTC()
)
db.add(log)
await db.commit()
```
## Best Practices
### 1. Keep Hooks Focused
Each hook should do one thing well:
```python theme={null}
# ❌ BAD: Hook doing too much
@app.hook.on_record_after_create("posts")
async def mega_hook(record: dict, context: Context):
await send_slack_notification(record)
await send_email_notification(record)
await update_search_index(record)
await create_audit_log(record)
await invalidate_cache(record)
# ✅ GOOD: Separate, focused hooks
@app.hook.on_record_after_create("posts", priority=50)
async def send_slack_notification(record: dict, context: Context):
await slack_service.notify(record)
@app.hook.on_record_after_create("posts", priority=40)
async def send_email_notification(record: dict, context: Context):
await email_service.notify(record)
@app.hook.on_record_after_create("posts", priority=30)
async def update_search_index(record: dict, context: Context):
await search_service.index(record)
```
### 2. Use Priority Wisely
Set appropriate priorities for execution order:
| Priority | Use Case |
| ----------- | ----------------------------------------- |
| 100+ | Critical validation that should run first |
| 50-99 | Core business logic |
| 10-49 | Notifications and integrations |
| 1-9 | Logging and analytics |
| 0 (default) | Cleanup and finalization |
### 3. Handle Idempotency
Make hooks idempotent when possible:
```python theme={null}
# ❌ NOT idempotent
@app.hook.on_record_after_create("posts")
async def send_notification(record: dict, context: Context):
await notification_service.send(record["created_by"], "Post created")
# Will send duplicate if hook runs twice
# ✅ Idempotent
@app.hook.on_record_after_create("posts")
async def send_notification(record: dict, context: Context):
await notification_service.send(
user_id=record["created_by"],
message=f"Post {record['id']} created",
deduplication_key=f"post_created:{record['id']}"
)
```
### 4. Log Hook Execution
Add logging for debugging:
```python theme={null}
import logging
logger = logging.getLogger(__name__)
@app.hook.on_record_after_create("posts")
async def logged_hook(record: dict, context: Context):
logger.info(
"Hook executed",
extra={
"hook": "logged_hook",
"record_id": record.get("id"),
"user_id": context.user_id,
"account_id": context.account_id
}
)
# ... hook logic
```
### 5. Avoid Blocking Operations
Keep hooks fast and non-blocking:
```python theme={null}
# ❌ BAD: Blocking operation
@app.hook.on_record_after_create("posts")
async def slow_hook(record: dict, context: Context):
result = await slow_external_api_call(timeout=30) # Blocks response
# User waits 30 seconds for response
# ✅ GOOD: Fire and forget
@app.hook.on_record_after_create("posts")
async def fast_hook(record: dict, context: Context):
# Queue for background processing
await background_queue.enqueue(
"slow_operation",
record_id=record["id"]
)
# Returns immediately
```
### 6. Test Hooks
Write tests for your hooks:
```python theme={null}
# tests/unit/test_custom_hooks.py
import pytest
from unittest.mock import AsyncMock
@pytest.mark.asyncio
async def test_slack_notification_on_publish(db_session, context):
"""Test Slack notification is sent when post is published."""
# Mock Slack service
slack_service.send = AsyncMock()
# Simulate hook execution
from src.snackbase.infrastructure.hooks.custom_hooks import notify_slack_on_publish
old_record = {"status": "draft"}
new_record = {"status": "published", "title": "Test Post"}
await notify_slack_on_publish(
record=new_record,
context=context,
old_record=old_record,
db=db_session
)
# Verify Slack was called
slack_service.send.assert_called_once()
```
## Examples
### Example 1: Auto-Generate Slugs
```python theme={null}
import re
@app.hook.on_record_before_create("posts")
async def generate_slug(
record: dict,
context: Context
):
"""Auto-generate slug from title if not provided."""
if "title" in record and "slug" not in record:
title = record["title"]
# Convert to slug
slug = re.sub(r"[^\w\s-]", "", title.lower())
slug = re.sub(r"[-\s]+", "-", slug)
record["slug"] = slug
```
### Example 2: Enforce Validation
```python theme={null}
from src.snackbase.core.exceptions import HookAbortException
@app.hook.on_record_before_create("posts")
async def validate_post_content(
record: dict,
context: Context
):
"""Ensure posts have minimum content length."""
content = record.get("content", "")
if len(content) < 50:
raise HookAbortException(
message="Post content must be at least 50 characters",
status_code=400
)
```
### Example 3: Sync to External System
```python theme={null}
import httpx
@app.hook.on_record_after_update("products")
async def sync_to_external_crm(
record: dict,
context: Context,
old_record: dict | None = None
):
"""Sync product changes to external CRM."""
if old_record:
# Only sync if fields changed
changed_fields = [
k for k in record
if k in old_record and record[k] != old_record[k]
]
if changed_fields:
async with httpx.AsyncClient() as client:
await client.put(
f"https://crm.example.com/api/products/{record['id']}",
json=record,
headers={"Authorization": f"Bearer {settings.crm_api_key}"}
)
```
### Example 4: Track Field Changes
```python theme={null}
@app.hook.on_record_after_update("users")
async def track_email_changes(
record: dict,
context: Context,
old_record: dict | None = None
):
"""Log when user email changes."""
if old_record and record.get("email") != old_record.get("email"):
await audit_service.log(
user_id=context.user_id,
action="email_changed",
details={
"old_email": old_record.get("email"),
"new_email": record.get("email")
}
)
```
## Summary
| Concept | Key Takeaway |
| ------------------- | ----------------------------------------------------------------------------------- |
| **Hook Categories** | 8 categories: App Lifecycle, Model, Record, Collection, Auth, User, Request, Custom |
| **Registration** | Use `@app.hook.on_event_name()` decorator |
| **Priority** | Higher priority = earlier execution (0-100+) |
| **Context** | Hooks receive record, context, old\_record, db parameters |
| **Error Handling** | Log errors, handle gracefully, use HookAbortException to abort |
| **Best Practices** | Keep focused, use priority wisely, ensure idempotency, log execution |
## Related Guides
* [Hooks Reference](../hooks)
* [Adding API Endpoints](./adding-api-endpoints)
* [Testing Guide](./testing)
# Extending SnackBase
Source: https://docs.snackbase.dev/guides/extending-snackbase
Learn various ways to extend SnackBase functionality beyond the core features
This guide provides an overview of the various ways to extend SnackBase functionality beyond the core features.
## Overview
SnackBase is designed to be **extensible** at multiple levels, allowing you to add custom functionality without modifying core code.
### Extension Philosophy
| Principle | Description |
| --------------------------------- | ------------------------------------------------------- |
| **Composition over Modification** | Add functionality through composition, not core changes |
| **Stable APIs** | Extension points have stable contracts |
| **Pluggable** | Features can be added/removed without affecting core |
| **Backward Compatible** | Extensions survive SnackBase upgrades |
### What Can Be Extended?
| Area | Extension Method |
| ------------------ | ------------------------------- |
| **Business Logic** | Hooks, custom services |
| **API Endpoints** | New routers, middleware |
| **Data Models** | Custom tables, extended schemas |
| **Authentication** | Custom auth providers, MFA |
| **Storage** | Custom storage backends |
| **Notifications** | Custom notification channels |
| **Validation** | Custom validators, rules |
## Extension Methods
### 1. Hooks (Recommended)
**Best for**: Business logic, event-driven automation, integrations
```python theme={null}
from src.snackbase.infrastructure.api.app import app
@app.hook.on_record_after_create("posts")
async def custom_logic(record: dict, context: Context):
"""Custom business logic after post creation."""
# Your code here
pass
```
**Pros:**
* Stable API (v1.0 contract)
* Automatic event triggering
* Account isolation built-in
* No core modifications
**Cons:**
* Limited to defined events
* Can't add new API endpoints
### 2. Custom API Endpoints
**Best for**: New features, external integrations, custom operations
```python theme={null}
from fastapi import APIRouter
from src.snackbase.infrastructure.api.app import app
custom_router = APIRouter(prefix="/custom", tags=["custom"])
@custom_router.get("/analytics")
async def get_analytics(context: Context = Depends(get_context)):
"""Custom analytics endpoint."""
return {"analytics": "data"}
# Register in app.py
app.include_router(custom_router, prefix=API_PREFIX)
```
**Pros:**
* Full control over endpoint
* Can access all SnackBase services
* Leverage existing authentication
**Cons:**
* Must manually handle permissions
* Requires more code
### 3. Custom Database Tables
**Best for**: Domain-specific data, complex relationships
```python theme={null}
# Create migration
# alembic/versions/xxx_add_custom_table.py
def upgrade():
op.create_table(
"custom_features",
sa.Column("id", sa.String(50), primary_key=True),
sa.Column("account_id", sa.String(10), nullable=False, index=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("config", sa.JSON, nullable=True),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"])
)
```
**Pros:**
* Full SQL control
* Complex relationships
* Migrations versioned
**Cons:**
* Requires manual migrations
* Not auto-integrated with collections
### 4. Middleware
**Best for**: Request/response processing, logging, custom auth
```python theme={null}
from src.snackbase.infrastructure.api.app import app
@app.middleware("http")
async def custom_middleware(request: Request, call_next):
"""Custom middleware for all requests."""
# Pre-processing
start_time = time.time()
# Call next middleware/route
response = await call_next(request)
# Post-processing
duration = time.time() - start_time
response.headers["X-Process-Time"] = str(duration)
return response
```
**Pros:**
* Runs on every request
* Can modify requests/responses
* Good for cross-cutting concerns
**Cons:**
* Adds latency to all requests
* Must be carefully designed
### 5. Custom Services
**Best for**: Reusable business logic, external integrations
```python theme={null}
# src/snackbase/infrastructure/services/custom_service.py
from typing import Any
class CustomAnalyticsService:
"""Custom analytics service."""
def __init__(self, db: AsyncSession):
self._db = db
async def calculate_metrics(self, account_id: str) -> dict[str, Any]:
"""Calculate custom metrics for account."""
# Your logic here
return {"metrics": "data"}
# Use in routes
@router.get("/analytics")
async def get_analytics(
context: Context = Depends(get_context),
db: AsyncSession = Depends(get_db)
):
service = CustomAnalyticsService(db)
return await service.calculate_metrics(context.account_id)
```
**Pros:**
* Encapsulates logic
* Reusable across endpoints
* Testable in isolation
**Cons:**
* More initial code
## Choosing the Right Approach
### Decision Tree
```
What do you want to do?
|
+-- Add business logic to existing operations
| +--> Use Hooks
|
+-- Create new API endpoints
| +--> Create Custom Router
|
+-- Store custom data structures
| |
| +-- Simple, per-record metadata
| | +--> Use JSON field in existing collection
| |
| +-- Complex, relational data
| +--> Create Custom Table
|
+-- Modify all requests/responses
| +--> Use Middleware
|
+-- Integrate external services
+--> Create Custom Service
```
### Comparison Matrix
| Method | Use Case | Complexity | Survives Updates | Example |
| --------------------- | ------------------ | ---------- | ---------------- | --------------------------- |
| **Hooks** | Event-driven logic | Low | Yes | Send notification on create |
| **Custom Router** | New endpoints | Medium | Yes | Custom analytics API |
| **Custom Table** | Complex data | High | Yes | Audit log storage |
| **Middleware** | Request/response | Medium | Maybe | Custom logging |
| **Core Modification** | Framework changes | Very High | No | Changing auth flow |
Modifying core files will cause conflicts when updating SnackBase. Always use extension methods instead.
## Extension Points
### 1. Database Layer
Extend the data layer:
```python theme={null}
# Custom repository
class CustomRepository:
def __init__(self, session: AsyncSession):
self._session = session
async def custom_query(self, account_id: str) -> list[dict]:
"""Custom database query."""
result = await self._session.execute(
select(CustomTable).where(
CustomTable.account_id == account_id
)
)
return [row.__dict__ for row in result.scalars()]
```
### 2. Service Layer
Add business logic services:
```python theme={null}
# src/snackbase/domain/services/analytics_service.py
class AnalyticsService:
"""Analytics business logic."""
async def generate_report(
self,
account_id: str,
date_range: DateRange
) -> Report:
"""Generate analytics report."""
# Business logic here
pass
```
### 3. API Layer
Extend the API:
```python theme={null}
# Custom router with authentication
from src.snackbase.infrastructure.api.dependencies import (
get_context,
require_permission
)
router = APIRouter(prefix="/analytics", tags=["analytics"])
@router.get("/report")
async def get_report(
context: Context = Depends(get_context),
authorized: bool = Depends(require_permission("analytics", "read"))
):
"""Get analytics report (requires permission)."""
# Your logic
pass
```
### 4. Authentication Layer
Extend authentication:
```python theme={null}
# Custom auth provider
class CustomAuthProvider:
async def authenticate(
self,
credentials: dict
) -> AuthResult:
"""Custom authentication logic."""
# Integrate with external auth provider
pass
# Register in auth service
auth_service.register_provider("custom", CustomAuthProvider())
```
## Architecture Considerations
### Clean Architecture Principles
When extending SnackBase, follow Clean Architecture:
```
+-----------------------------------------------------+
| API Layer |
| (Routers, Controllers, Middleware) |
+---------------------+-------------------------------+
|
+---------------------+-------------------------------+
| Application Layer |
| (Use Cases, Orchestration, Services) |
+---------------------+-------------------------------+
|
+---------------------+-------------------------------+
| Domain Layer |
| (Entities, Business Logic, Interfaces) |
+---------------------+-------------------------------+
|
+---------------------+-------------------------------+
| Infrastructure Layer |
| (Database, External APIs, Storage) |
+-----------------------------------------------------+
```
### Dependency Direction
```
CORRECT: Dependencies point inward
Router -> Service -> Repository -> Database
INCORRECT: Dependencies point outward
Repository -> Service -> Router
```
### Separation of Concerns
Keep extensions organized:
```
src/snackbase/
├── extensions/ # Your extensions
│ ├── analytics/ # Analytics feature
│ │ ├── router.py # API endpoints
│ │ ├── service.py # Business logic
│ │ └── repository.py # Data access
│ └── integrations/ # External integrations
│ └── slack.py # Slack integration
```
## Deployment Considerations
### Surviving Updates
| Extension Method | Survives SnackBase Updates |
| ---------------------- | -------------------------- |
| **Hooks** | Yes (stable API) |
| **Custom Routers** | Yes (separate files) |
| **Custom Tables** | Yes (separate migrations) |
| **Middleware** | Maybe (if core changes) |
| **Core Modifications** | No (will conflict) |
### Extension Isolation
Keep extensions isolated to avoid conflicts:
```python theme={null}
# BAD: Modifying core files
# src/snackbase/infrastructure/api/routes/users_router.py
# (Adding custom logic here will conflict with updates)
# GOOD: Separate extension file
# src/snackbase/extensions/custom_users.py
# (Separate file survives updates)
```
### Configuration
Use configuration for extension behavior:
```python theme={null}
# .env
ENABLE_ANALYTICS_FEATURE=true
SLACK_WEBHOOK_URL=https://hooks.slack.com/...
CUSTOM_API_KEY=your-key-here
# config.py
from pydantic import Settings
class ExtensionSettings(BaseSettings):
enable_analytics: bool = False
slack_webhook_url: str | None = None
custom_api_key: str | None = None
```
## Examples
### Example 1: Analytics Dashboard
Add custom analytics:
```python theme={null}
# 1. Create custom table (migration)
op.create_table(
"page_views",
sa.Column("id", sa.String(50), primary_key=True),
sa.Column("account_id", sa.String(10)),
sa.Column("path", sa.String(255)),
sa.Column("views", sa.Integer),
sa.Column("date", sa.Date)
)
# 2. Create service
class AnalyticsService:
async def get_page_views(self, account_id: str, days: int = 30):
"""Get page views for last N days."""
# Query and aggregate
pass
# 3. Create router
@router.get("/analytics/page-views")
async def page_views(
days: int = 30,
context: Context = Depends(get_context)
):
service = AnalyticsService(db)
return await service.get_page_views(context.account_id, days)
```
### Example 2: Slack Integration
Add Slack notifications:
```python theme={null}
# 1. Create hook
@app.hook.on_record_after_create("posts")
async def notify_slack(record: dict, context: Context):
"""Send Slack notification on post creation."""
if record.get("status") == "published":
await slack_service.send_notification(
webhook_url=settings.slack_webhook_url,
message=f"New post: {record['title']}"
)
# 2. Create service
class SlackService:
async def send_notification(self, webhook_url: str, message: str):
"""Send notification to Slack."""
async with httpx.AsyncClient() as client:
await client.post(webhook_url, json={"text": message})
```
### Example 3: Custom Validation
Add field validation:
```python theme={null}
# 1. Create hook
@app.hook.on_record_before_create("posts")
async def validate_post_content(record: dict, context: Context):
"""Validate post content before creation."""
content = record.get("content", "")
# Custom validation
if len(content) < 50:
raise HookAbortException(
message="Content must be at least 50 characters",
status_code=400
)
# Check for prohibited words
prohibited = ["spam", "advertisement"]
if any(word in content.lower() for word in prohibited):
raise HookAbortException(
message="Content contains prohibited words",
status_code=400
)
```
### Example 4: Custom Endpoint with Permissions
Add protected endpoint:
```python theme={null}
# Custom router with permissions
router = APIRouter(prefix="/reports", tags=["reports"])
@router.get("/sales")
async def sales_report(
context: Context = Depends(get_context),
authorized: bool = Depends(require_permission("reports", "read"))
):
"""Generate sales report (requires reports:read permission)."""
# Generate report
report = await report_service.generate_sales_report(context.account_id)
return report
# Register permission
# Via UI or API:
# {
# "role": "manager",
# "collection": "reports",
# "read": true,
# "create": false,
# "update": false,
# "delete": false
# }
```
## Summary
| Concept | Key Takeaway |
| ---------------------- | ----------------------------------------------------- |
| **Extension Methods** | Hooks, routers, tables, middleware, services |
| **Choosing Approach** | Decision tree based on requirements |
| **Clean Architecture** | Follow layering, dependency direction |
| **Deployment** | Keep extensions isolated for updates |
| **Best Practices** | Don't modify core, use configuration, test thoroughly |
## Related Guides
* [Creating Custom Hooks](./creating-custom-hooks)
* [Adding API Endpoints](./adding-api-endpoints)
* [Architecture](../architecture)
# Frontend Developer Guide
Source: https://docs.snackbase.dev/guides/frontend
Complete guide to the SnackBase React admin UI architecture, development patterns, and best practices
This guide covers the SnackBase React admin UI architecture, development patterns, and how to build and extend the frontend.
## Tech Stack
The SnackBase admin UI is built with modern, production-ready technologies:
| Technology | Version | Purpose |
| ------------------- | ------- | ------------------------------- |
| **React** | 19.2.0 | UI framework |
| **TypeScript** | 5.9.3 | Type safety |
| **Vite** | 7.2.4 | Build tool and dev server |
| **React Router** | 7.11.0 | Client-side routing |
| **TailwindCSS** | 4.1.18 | Utility-first styling |
| **Radix UI** | Latest | Accessible component primitives |
| **ShadCN** | Latest | Pre-built component library |
| **TanStack Query** | 5.90.12 | Server state management |
| **Zustand** | 5.0.9 | Client state management |
| **Zod** | 4.2.1 | Schema validation |
| **Axios** | 1.13.2 | HTTP client |
| **React Hook Form** | 7.69.0 | Form state management |
## Project Structure
```
ui/
├── src/
│ ├── main.tsx # Application entry point
│ ├── App.tsx # Root component with Router
│ ├── App.css # Global styles with TailwindCSS
│ │
│ ├── pages/ # Page components (15 pages)
│ │ ├── LoginPage.tsx
│ │ ├── DashboardPage.tsx
│ │ ├── AccountsPage.tsx
│ │ ├── UsersPage.tsx
│ │ └── ...
│ │
│ ├── components/ # Reusable components (83 components)
│ │ ├── ui/ # ShadCN components (DO NOT EDIT)
│ │ ├── accounts/
│ │ ├── collections/
│ │ ├── records/
│ │ ├── AppSidebar.tsx
│ │ └── ProtectedRoute.tsx
│ │
│ ├── services/ # API service layer (15 services)
│ │ ├── api.ts # Axios configuration
│ │ ├── auth.service.ts
│ │ ├── users.service.ts
│ │ └── ...
│ │
│ ├── stores/ # Zustand state stores
│ │ └── auth.store.ts
│ │
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities and helpers
│ └── types/ # TypeScript type definitions
│
├── index.html # HTML entry point
├── package.json # Dependencies and scripts
├── vite.config.ts # Vite build configuration
└── components.json # ShadCN configuration
```
## Architecture Overview
The frontend follows a **layered architecture** with clear separation of concerns:
```
┌─────────────────────────────────┐
│ Pages Layer │
│ (Route handlers, orchestration)│
└────────────┬────────────────────┘
│
┌────────────▼────────────────────┐
│ Components Layer │
│ (Reusable UI components) │
└────────────┬────────────────────┘
│
┌────────────▼────────────────────┐
│ Business Logic Layer │
│ (Zustand stores, custom hooks) │
└────────────┬────────────────────┘
│
┌────────────▼────────────────────┐
│ Data Layer │
│ (Services with TanStack Query) │
└────────────┬────────────────────┘
│
┌────────────▼────────────────────┐
│ Axios API │
│ (HTTP client with interceptors) │
└─────────────────────────────────┘
```
## State Management
### Global State: Zustand
Authentication state is managed in `src/stores/auth.store.ts`:
```typescript theme={null}
interface AuthState {
// State
user: UserInfo | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
// Actions
login: (email: string, password: string) => Promise;
logout: () => void;
restoreSession: () => Promise;
}
export const useAuthStore = create()(
persist(
(set, get) => ({
// Initial state and actions...
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
)
);
```
**Key Features:**
* Token stored in localStorage with key `auth-storage`
* Persist middleware for session persistence
* `restoreSession()` verifies token validity on app load
* Automatic logout on 401 responses
### Server State: TanStack Query
All data from the API is managed by TanStack Query:
```typescript theme={null}
const { data: collections, isLoading, error } = useQuery({
queryKey: ['collections'],
queryFn: collectionsService.getCollections,
});
```
## API Service Layer
All services follow a consistent pattern in `src/services/`:
```typescript theme={null}
// src/services/users.service.ts
import { apiClient } from '@/lib/api';
export const usersService = {
getAll: async (): Promise => {
const response = await apiClient.get('/users');
return response.data;
},
create: async (data: CreateUserDto): Promise => {
const response = await apiClient.post('/users', data);
return response.data;
},
update: async (id: string, data: UpdateUserDto): Promise => {
const response = await apiClient.put(`/users/${id}`, data);
return response.data;
},
delete: async (id: string): Promise => {
await apiClient.delete(`/users/${id}`);
},
};
```
### Axios Configuration
The `lib/api.ts` file configures the Axios instance:
```typescript theme={null}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: { 'Content-Type': 'application/json' },
});
// Request interceptor - add auth token
apiClient.interceptors.request.use((config) => {
const authState = localStorage.getItem('auth-storage');
if (authState) {
const parsedState = JSON.parse(authState);
const token = parsedState?.state?.token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
// Response interceptor - handle token refresh
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
// Attempt token refresh...
}
return Promise.reject(error);
}
);
```
## Authentication Flow
### Login Flow
```
User enters credentials
↓
POST /auth/login (auth.service.ts)
↓
Store in Zustand store with persist
↓
Redirect to /admin/dashboard
```
### Protected Routes
The `ProtectedRoute` component wraps routes that require authentication:
```typescript theme={null}
export default function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading, restoreSession } = useAuthStore();
useEffect(() => {
restoreSession();
}, [restoreSession]);
if (isLoading) return Loading...;
if (!isAuthenticated) return ;
return <>{children}>;
}
```
### Usage in App.tsx
All admin routes are under the `/admin` prefix:
```typescript theme={null}
} />
} />
}>
} />
} />
{/* ... more routes */}
```
## Components & Patterns
### ShadCN Components
ShadCN provides pre-built, accessible components. **Never edit ShadCN components directly** in `src/components/ui/`.
To add new ShadCN components:
```bash theme={null}
cd ui
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add table
```
### Component Composition Pattern
Build complex components by composing ShadCN primitives:
```typescript theme={null}
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
export function UsersTable({ users, onEdit, onDelete }) {
return (
Name
Email
Actions
{users.map((user) => (
{user.name}
{user.email}
))}
);
}
```
### Dialog-Based CRUD Operations
Most CRUD operations use ShadCN Dialog components:
```typescript theme={null}
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
export function CreateUserDialog({ open, onOpenChange, onSuccess }) {
const form = useForm({ resolver: zodResolver(schema) });
const createUser = useMutation({
mutationFn: usersService.create,
onSuccess: () => {
onSuccess();
onOpenChange(false);
},
});
return (
);
}
```
## Routing
All routes are under the `/admin` prefix:
```typescript theme={null}
}>
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
```
## Styling
SnackBase uses **TailwindCSS 4** with the new `@tailwindcss/vite` plugin.
### Theme Configuration
Theme is configured in `src/App.css`:
```css theme={null}
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
}
```
### Common Patterns
| Pattern | Classes | Usage |
| --------------- | ------------------------------------------------------ | ------------------------ |
| Card | `bg-white rounded-lg shadow-sm border p-6` | Container for content |
| Button Group | `flex gap-2` | Horizontal button layout |
| Grid | `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4` | Responsive grid |
| Section Spacing | `space-y-4` | Vertical spacing |
## Development Workflow
### Environment Setup
Create a `.env` file in the `ui` directory:
```bash theme={null}
VITE_API_BASE_URL=/api/v1
```
### Running the Dev Server
```bash theme={null}
cd ui
npm run dev
```
The Vite dev server starts at `http://localhost:5173` with:
* Hot Module Replacement (HMR)
* Fast refresh
* TypeScript checking
### Build for Production
```bash theme={null}
cd ui
npm run build
```
## Best Practices
### 1. Component Organization
* Keep components focused and single-purpose
* Extract reusable logic into custom hooks
* Co-locate related components in feature folders
### 2. Type Safety
* Always define TypeScript interfaces for API responses
* Use Zod schemas for runtime validation
* Avoid `any` type - use `unknown` if truly unknown
### 3. Error Handling
* Always handle loading and error states from TanStack Query
* Show user-friendly error messages
* Use the `handleApiError()` utility from `lib/api.ts`
### 4. Performance
* Use TanStack Query's caching to avoid redundant requests
* Implement pagination for large datasets
* Lazy load components with React.lazy()
## Related Guides
* [API Examples](../api-examples)
* [Architecture](../architecture)
# OAuth Authentication Overview
Source: https://docs.snackbase.dev/guides/oauth-overview
Understanding the OAuth 2.0 Authorization Code Flow in SnackBase
SnackBase implements the OAuth 2.0 Authorization Code Flow with PKCE (for supported providers) and state validation for security.
## Overview
The OAuth flow allows users to sign in to SnackBase using their existing accounts from providers like Google, GitHub, Microsoft, and Apple.
## Detailed Flow
### 1. Initiate Login
* The frontend calls `POST /api/v1/auth/oauth/{provider}/authorize` with a `redirect_uri`.
* SnackBase generates a secure random `state` token and stores it.
* SnackBase returns an authorization URL for the provider.
### 2. User Authorization
* The user is redirected to the provider's login page.
* The user grants permission to SnackBase.
### 3. Callback Handling
* The provider redirects the user back to the `redirect_uri` with a `code` and `state`.
* The frontend forwards these parameters to `POST /api/v1/auth/oauth/{provider}/callback`.
### 4. Token Exchange
* SnackBase verifies the `state` token against the stored one to prevent CSRF attacks.
* SnackBase exchanges the authorization `code` for an access token (and ID token) from the provider.
### 5. User Profile Retrieval
* SnackBase uses the access token to fetch user profile information (email, name).
### 6. Account Creation/Login
* If the email exists, the user is logged in.
* If the email is new, a new user account is created (auto-provisioning).
* SnackBase issues its own JWT access and refresh tokens for the session.
## Configuration
OAuth providers can be configured at:
* **System Level**: `config.yaml` or environment variables. Applies to all tenants.
* **Account Level**: Overrides via the `configs` table. Specific to a tenant account.
## Security
* **State Parameter**: Used to prevent Cross-Site Request Forgery (CSRF).
* **PKCE**: Proof Key for Code Exchange (where supported) adds protection against code interception.
* **Token Storage**: External provider tokens are used only during the callback to fetch data and are not permanently stored unless linked functionalities are enabled.
## Supported Providers
* [Google](./oauth-setup-google) - Google Account login
* [GitHub](./oauth-setup-github) - GitHub account login
* [Microsoft](./oauth-setup-microsoft) - Microsoft / Azure AD login
* [Apple](./oauth-setup-apple) - Sign in with Apple
## Next Steps
* Configure your preferred OAuth provider using the setup guides above
* See [Authentication Concepts](/concepts/authentication) for more details on multi-account users
# Apple OAuth Setup
Source: https://docs.snackbase.dev/guides/oauth-setup-apple
Configure Sign in with Apple for SnackBase
This guide explains how to configure Sign in with Apple for SnackBase.
## Prerequisites
* An Apple Developer account
* SnackBase installed and running on a domain with HTTPS (Apple requires HTTPS)
Apple requires HTTPS for all OAuth callbacks. Local development will need to use a service like ngrok or similar to test.
## Step 1: Create an App ID
1. Log in to the [Apple Developer Console](https://developer.apple.com/account/)
2. Go to **Certificates, Identifiers & Profiles** > **Identifiers**
3. Click **+** to add a new identifier
4. Select **App IDs**, continue
5. Select **App**, continue
6. Enter a **Description** and **Bundle ID**
7. Scroll down to **Capabilities** and check **Sign In with Apple**
8. Click **Continue** and **Register**
## Step 2: Create a Service ID
1. Go back to **Identifiers**
2. Click **+**, select **Service IDs**, continue
3. Enter a **Description** and **Identifier** (e.g., `com.example.snackbase.client`)
4. Click **Continue** and **Register**
5. Click on the newly created Service ID to edit it
6. Enable **Sign In with Apple** and click **Configure**
7. Select your **Primary App ID**
8. Add your **Web Domain** (verify ownership if required)
9. Add your **Return URLs** (Redirect URIs)
* Format: `https:///api/v1/auth/oauth/apple/callback`
10. Click **Save**, **Continue**, and **Save**
## Step 3: Create a Client Secret (Private Key)
1. Go to **Keys**
2. Click **+** to create a new key
3. Enter a name and check **Sign In with Apple**
4. Click **Configure**, select your Primary App ID
5. Click **Save**, **Continue**, and **Register**
6. **Download** the `.p8` file (save this securely, you cannot download it again)
7. Note the **Key ID**
8. Get your **Team ID** from the top right of the developer console
## Step 4: Configure SnackBase
In SnackBase, configure the Apple provider:
| Field | Value |
| --------------- | ------------------------------------------- |
| `client_id` | Your Service ID Identifier (from Step 2) |
| `team_id` | Your Apple Team ID |
| `key_id` | The Key ID (from Step 3) |
| `client_secret` | The contents of your `.p8` private key file |
| `redirect_uri` | The Return URL from Step 2 |
| `scopes` | `name email` (default) |
## Testing
1. Save your configuration
2. Attempt to sign in via the Apple button on the login page
## Troubleshooting
The `.p8` private key file is only available for download once. Store it securely as you cannot retrieve it again.
**Common Issues:**
* **invalid\_client**: Check that your client\_id (Service ID), team\_id, and key\_id are correct
* **HTTPS required**: Apple requires HTTPS for all callbacks
* **Domain verification**: Ensure your domain is verified in Apple Developer Console
## Related Guides
* [OAuth Overview](./oauth-overview) - Understanding the OAuth flow
* [Google OAuth Setup](./oauth-setup-google)
* [GitHub OAuth Setup](./oauth-setup-github)
# GitHub OAuth Setup
Source: https://docs.snackbase.dev/guides/oauth-setup-github
Configure GitHub as an OAuth 2.0 provider for SnackBase
This guide explains how to configure GitHub as an OAuth 2.0 provider for SnackBase.
## Prerequisites
* A GitHub account
* SnackBase installed and running
## Step 1: Register a New OAuth Application
1. Go to your GitHub account **Settings**
2. Navigate to **Developer settings** > **OAuth Apps**
3. Click **New OAuth App**
4. Fill in the application details:
* **Application name**: SnackBase
* **Homepage URL**: `https://`
* **Authorization callback URL**: `https:///api/v1/auth/oauth/github/callback`
* For local development: `http://localhost:8000/api/v1/auth/oauth/github/callback`
## Step 2: Generate Client Secret
1. After creating the app, you will see the **Client ID**
2. Click **Generate a new client secret**
3. Copy the **Client Secret**
## Step 3: Configure SnackBase
In SnackBase, configure the GitHub provider:
| Field | Value |
| --------------- | ------------------------------------------ |
| `client_id` | Your GitHub Client ID |
| `client_secret` | Your GitHub Client Secret |
| `redirect_uri` | The Authorization callback URL from Step 1 |
| `scopes` | `user:email read:user` (default) |
## Testing
1. Save your configuration
2. Attempt to sign in via the GitHub button on the login page
## Troubleshooting
The client secret is only shown once. Make sure to copy it immediately after generating.
**Common Issues:**
* **redirect\_uri\_mismatch**: Ensure the callback URL exactly matches what you configured
* **Application suspended**: GitHub may suspend apps that violate their terms of service
## Related Guides
* [OAuth Overview](./oauth-overview) - Understanding the OAuth flow
* [Google OAuth Setup](./oauth-setup-google)
* [Microsoft OAuth Setup](./oauth-setup-microsoft)
# Google OAuth Setup
Source: https://docs.snackbase.dev/guides/oauth-setup-google
Configure Google as an OAuth 2.0 provider for SnackBase
This guide explains how to configure Google as an OAuth 2.0 provider for SnackBase.
## Prerequisites
* A Google Cloud Platform (GCP) project
* SnackBase installed and running
## Step 1: Configure OAuth Consent Screen
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
2. Navigate to **APIs & Services** > **OAuth consent screen**
3. Select **User Type** (Internal or External) and click **Create**
4. Fill in the required application information (Name, User support email, etc.)
5. Add scopes: `.../auth/userinfo.email`, `.../auth/userinfo.profile`, `openid`
6. Add test users (if External and in Testing mode)
## Step 2: Create Credentials
1. Navigate to **APIs & Services** > **Credentials**
2. Click **Create Credentials** > **OAuth client ID**
3. Select **Web application**
4. **Name**: Enter "SnackBase"
5. **Authorized redirect URIs**: Add your SnackBase callback URL
* Format: `https:///api/v1/auth/oauth/google/callback`
* For local development: `http://localhost:8000/api/v1/auth/oauth/google/callback`
## Step 3: Configure SnackBase
Copy the **Client ID** and **Client Secret** from the Google Cloud Console.
In SnackBase, configure the Google provider:
| Field | Value |
| --------------- | ------------------------------------ |
| `client_id` | Your Google Client ID |
| `client_secret` | Your Google Client Secret |
| `redirect_uri` | The Redirect URI you added in Step 2 |
| `scopes` | `openid email profile` (default) |
## Testing
1. Save your configuration
2. Attempt to sign in via the Google button on the login page
## Troubleshooting
Make sure your redirect URI exactly matches what you configured in Google Cloud Console, including the protocol (http/https) and port.
**Common Issues:**
* **redirect\_uri\_mismatch**: Ensure the redirect URI in Google Cloud Console matches exactly
* **access\_denied**: User declined the authorization request
* **invalid\_client**: Check that your client ID and secret are correct
## Related Guides
* [OAuth Overview](./oauth-overview) - Understanding the OAuth flow
* [GitHub OAuth Setup](./oauth-setup-github)
* [Microsoft OAuth Setup](./oauth-setup-microsoft)
# Microsoft OAuth Setup
Source: https://docs.snackbase.dev/guides/oauth-setup-microsoft
Configure Microsoft Azure AD as an OAuth 2.0 provider for SnackBase
This guide explains how to configure Microsoft (Azure AD) as an OAuth 2.0 provider for SnackBase.
## Prerequisites
* An Azure account
* SnackBase installed and running
## Step 1: Register an Application
1. Go to the [Azure Portal](https://portal.azure.com/)
2. Navigate to **Microsoft Entra ID** (formerly Azure Active Directory) > **App registrations**
3. Click **New registration**
4. **Name**: SnackBase
5. **Supported account types**: Select who can use this application (e.g., "Accounts in any organizational directory and personal Microsoft accounts")
6. **Redirect URI**: Select **Web** and enter your callback URL
* Format: `https:///api/v1/auth/oauth/microsoft/callback`
* For local development: `http://localhost:8000/api/v1/auth/oauth/microsoft/callback`
7. Click **Register**
## Step 2: Create Client Secret
1. In the app overview, note the **Application (client) ID**
2. Navigate to **Certificates & secrets** > **Client secrets**
3. Click **New client secret**
4. Add a description and expiry
5. Copy the **Value** (not the Secret ID) immediately
## Step 3: Configure SnackBase
In SnackBase, configure the Microsoft provider:
| Field | Value |
| --------------- | --------------------------------------------------------- |
| `client_id` | Your Application (client) ID |
| `client_secret` | Your Client Secret Value |
| `tenant_id` | `common` (for multi-tenant) or your specific Tenant ID |
| `redirect_uri` | The Redirect URI from Step 1 |
| `scopes` | `User.Read email openid profile offline_access` (default) |
## Testing
1. Save your configuration
2. Attempt to sign in via the Microsoft button on the login page
## Troubleshooting
Microsoft Azure AD has been renamed to "Microsoft Entra ID". The functionality remains the same.
**Common Issues:**
* **AADSTS50105**: Your application doesn't have the required permissions. Check your scopes.
* **AADSTS700016**: Application with identifier was not found. Verify your client ID.
* **invalid\_client**: Check that your client secret is correct and hasn't expired.
## Related Guides
* [OAuth Overview](./oauth-overview) - Understanding the OAuth flow
* [Google OAuth Setup](./oauth-setup-google)
* [GitHub OAuth Setup](./oauth-setup-github)
# Azure AD SAML Setup
Source: https://docs.snackbase.dev/guides/saml-setup-azure-ad
Configure Microsoft Entra ID (Azure AD) as a SAML Identity Provider for SnackBase
This guide explains how to configure Azure Active Directory (Microsoft Entra ID) as a SAML Identity Provider (IdP) for SnackBase.
## Prerequisites
* Administrator access to your Azure AD tenant
* SnackBase installed and running
## Step 1: Create an Enterprise Application
1. Log in to the [Azure Portal](https://portal.azure.com/)
2. Navigate to **Microsoft Entra ID** > **Enterprise applications**
3. Click **New application**
4. Click **Create your own application**
5. Enter a name (e.g., "SnackBase") and select **Integrate any other application you don't find in the gallery (Non-gallery)**
6. Click **Create**
## Step 2: Set up Single Sign-On
1. In your new application, go to **Single sign-on** in the left menu
2. Select **SAML**
## Step 3: Configure Basic SAML Configuration
Click **Edit** on the **Basic SAML Configuration** card.
1. **Identifier (Entity ID)**: Enter a unique identifier for SnackBase
* Format: `https://` (must match `sp_entity_id` in SnackBase)
2. **Reply URL (Assertion Consumer Service URL)**: Enter your SnackBase ACS URL
* Format: `https:///api/v1/auth/saml/acs`
3. Click **Save**
## Step 4: Configure Attributes & Claims
Click **Edit** on the **Attributes & Claims** card.
Ensure the following claims are present (Azure AD usually adds them by default):
* `emailaddress` or `name` (user principal name) for email
* `givenname` and `surname` for name mapping
## Step 5: Configure SnackBase
1. On the **SAML-based Sign-on** page, scroll down to **SAML Certificates**
2. Download **Certificate (Base64)**, open it in a text editor to copy the content
3. Scroll down to the **Set up ** section
4. In SnackBase, create a new SAML provider configuration with the following values:
| SnackBase Field | Azure AD Value |
| ------------------------ | ------------------------------------------------ |
| `idp_entity_id` | **Azure AD Identifier** |
| `idp_sso_url` | **Login URL** |
| `idp_x509_cert` | Content of the **Certificate (Base64)** file |
| `sp_entity_id` | The **Identifier (Entity ID)** you set in Step 3 |
| `assertion_consumer_url` | The **Reply URL** you set in Step 3 |
## Testing
1. Save your configuration in SnackBase
2. Assign a user to the application in Azure AD (Users and groups > Add user/group)
3. Attempt to sign in via the SAML SSO button
## Troubleshooting
Microsoft Azure Active Directory has been renamed to "Microsoft Entra ID". The functionality remains the same.
**Common Issues:**
* **AADSTS50105**: The user is not assigned to the application. Assign the user or group in Azure AD
* **Invalid certificate**: Ensure you've copied the entire certificate content including BEGIN and END markers
* **Entity ID mismatch**: Verify the `sp_entity_id` in SnackBase matches the Identifier in Azure AD exactly
## Related Guides
* [Generic SAML Setup](./saml-setup-generic)
* [Okta SAML Setup](./saml-setup-okta)
* [Authentication Concepts](/concepts/authentication)
# Generic SAML Provider Setup
Source: https://docs.snackbase.dev/guides/saml-setup-generic
Configure a generic SAML 2.0 Identity Provider for SnackBase
This guide explains how to configure a generic SAML Identity Provider (IdP) for SnackBase.
## Prerequisites
* A SAML 2.0 compliant Identity Provider (e.g., Auth0, OneLogin, Keycloak, Shibboleth)
* SnackBase installed and running
## Step 1: Get Service Provider (SP) Information from SnackBase
You will need to provide the following information to your IdP:
1. **SP Entity ID (Audience URI)**: A unique identifier for your SnackBase instance
* Example: `https://snackbase.yourdomain.com`
2. **Assertion Consumer Service (ACS) URL**: The endpoint where the IdP sends the SAML assertion
* Example: `https://snackbase.yourdomain.com/api/v1/auth/saml/acs`
3. **NameID Format**: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`
## Step 2: Configure your Identity Provider
1. Create a new SAML application in your IdP
2. Input the **Entity ID** and **ACS URL** from Step 1
3. Ensure the IdP signs the assertion (SnackBase requires signed assertions or signed responses)
4. Configure attribute mapping (optional but recommended):
* Map user email to `email`, `mail`, or `Email`
* Map name to `firstName`/`lastName` or `displayName`
## Step 3: Configure SnackBase
Collect the following information from your IdP metadata:
1. **IdP Entity ID (Issuer)**: The unique identifier of your IdP
2. **IdP SSO URL**: The URL where SnackBase will redirect users for login
3. **X.509 Certificate**: The public certificate used to verify the IdP's signature
In SnackBase, configure the Generic SAML provider:
| Field | Description |
| ------------------------ | ------------------------------------------------------------------------- |
| `idp_entity_id` | The Issuer URI from your IdP |
| `idp_sso_url` | The Single Sign-On URL from your IdP |
| `idp_x509_cert` | The public certificate (PEM format) |
| `sp_entity_id` | The Entity ID you defined in Step 1 |
| `assertion_consumer_url` | The ACS URL you defined in Step 1 |
| `binding` | Set to `HTTP-Redirect` (default) |
| `name_id_format` | Set to `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` (default) |
## Testing
1. Save your configuration
2. Attempt to sign in via the Generic SAML SSO button
## Troubleshooting
Ensure the IdP is configured to sign either the assertion or the response. SnackBase requires at least one to be signed for security.
**Common Issues:**
* **Invalid signature**: Check that the X.509 certificate is correctly copied and matches the IdP's current certificate
* **NameID format mismatch**: Ensure the NameID format matches between IdP and SnackBase configuration
* **Attribute mapping**: Verify that attribute names in the SAML response match your SnackBase configuration
## Related Guides
* [Okta SAML Setup](./saml-setup-okta)
* [Azure AD SAML Setup](./saml-setup-azure-ad)
* [Authentication Concepts](/concepts/authentication)
# Okta SAML Setup
Source: https://docs.snackbase.dev/guides/saml-setup-okta
Configure Okta as a SAML Identity Provider for SnackBase
This guide explains how to configure Okta as a SAML Identity Provider (IdP) for SnackBase.
## Prerequisites
* Administrator access to your Okta organization
* SnackBase installed and running
## Step 1: Create an App Integration in Okta
1. Log in to your Okta Admin Console
2. Go to **Applications** > **Applications**
3. Click **Create App Integration**
4. Select **SAML 2.0** and click **Next**
## Step 2: General Settings
1. **App name**: Enter "SnackBase" (or your preferred name)
2. **App logo**: (Optional) Upload a logo
3. Click **Next**
## Step 3: Configure SAML
1. **Single Sign On URL (ACS URL)**: Enter your SnackBase ACS URL
* Format: `https:///api/v1/auth/saml/acs`
2. **Audience URI (SP Entity ID)**: Enter a unique identifier for SnackBase
* Format: `https://` (or `snackbase-app`)
* **Note**: This value must match the `sp_entity_id` in your SnackBase configuration
3. **Name ID format**: Select `EmailAddress`
4. **Application username**: Select `Email`
5. **Update application username on**: Create and update
## Step 4: Attribute Statements (Optional but Recommended)
Add the following attribute statements to map user details:
| Name | Name format | Value |
| --------- | ----------- | -------------- |
| email | Unspecified | user.email |
| firstName | Unspecified | user.firstName |
| lastName | Unspecified | user.lastName |
Click **Next** and then **Finish**.
## Step 5: Configure SnackBase
1. In Okta, go to the **Sign On** tab of your new application
2. Scroll down to **SAML Signing Certificates**
3. Locate the active certificate and click **View SAML setup instructions**
4. In SnackBase, create a new SAML provider configuration with the following values:
| SnackBase Field | Okta Value |
| ------------------------ | ---------------------------------------------------------- |
| `idp_entity_id` | **Identity Provider Issuer** |
| `idp_sso_url` | **Identity Provider Single Sign-On URL** |
| `idp_x509_cert` | **X.509 Certificate** (Paste the full certificate content) |
| `sp_entity_id` | The **Audience URI** you set in Step 3 |
| `assertion_consumer_url` | The **Single Sign On URL** you set in Step 3 |
## Testing
1. Save your configuration in SnackBase
2. Attempt to sign in via the SAML SSO button
## Troubleshooting
Okta certificates rotate periodically. Make sure to update the certificate in SnackBase after rotation.
**Common Issues:**
* **SAML response validation failed**: Verify the `sp_entity_id` matches exactly between Okta and SnackBase
* **User not found**: Check that the application username format matches the user email in Okta
* **Certificate expired**: Okta certificates expire - update to the new certificate in SnackBase
## Related Guides
* [Generic SAML Setup](./saml-setup-generic)
* [Azure AD SAML Setup](./saml-setup-azure-ad)
* [Authentication Concepts](/concepts/authentication)
# Setting Up Webhooks
Source: https://docs.snackbase.dev/guides/setting-up-webhooks
Step-by-step guide to configuring outbound webhooks for external notifications
This guide walks you through setting up outbound webhooks to send HTTP notifications to external services when data changes in your collections.
## Prerequisites
* A running SnackBase instance
* At least one collection with data
* An external HTTPS endpoint to receive webhooks (for production)
## Setup
Create a webhook using the API or SDK:
```ts theme={null}
const webhook = await client.webhooks.create({
url: "https://your-server.com/webhooks/orders",
collection: "orders",
events: ["create", "update"],
});
// IMPORTANT: Save this secret
console.log("Webhook secret:", webhook.secret);
```
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/webhooks \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/orders",
"collection": "orders",
"events": ["create", "update"]
}'
```
The webhook `secret` is only returned in the creation response. Store it securely -- you'll need it to verify signatures.
Store the returned secret in your server's environment variables:
```bash theme={null}
export SNACKBASE_WEBHOOK_SECRET="your-64-char-hex-secret"
```
Never hardcode the secret in your source code.
Every webhook delivery includes an `X-SnackBase-Signature` header. Verify it on your server:
```js theme={null}
const crypto = require("crypto");
function verifyWebhook(payload, secret, signatureHeader) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
// Express middleware
app.post("/webhooks/orders", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-snackbase-signature"];
if (!verifyWebhook(req.body, process.env.SNACKBASE_WEBHOOK_SECRET, signature)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
console.log("Event:", event.event);
console.log("Record:", event.record);
res.status(200).send("OK");
});
```
```python theme={null}
import hmac
import hashlib
def verify_webhook(payload: bytes, secret: str, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
# FastAPI example
@app.post("/webhooks/orders")
async def handle_webhook(request: Request):
body = await request.body()
signature = request.headers.get("x-snackbase-signature")
if not verify_webhook(body, os.environ["SNACKBASE_WEBHOOK_SECRET"], signature):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
print(f"Event: {event['event']}, Record: {event['record']}")
return {"status": "ok"}
```
Use the built-in test endpoint to verify everything works:
```ts theme={null}
const result = await client.webhooks.test("webhook-id");
console.log(result.success); // true
console.log(result.status_code); // 200
```
```bash theme={null}
curl -X POST https://api.snackbase.dev/api/v1/webhooks/{webhook_id}/test \
-H "Authorization: Bearer {token}"
```
Check delivery history to debug issues:
```ts theme={null}
const deliveries = await client.webhooks.listDeliveries("webhook-id");
for (const d of deliveries.items) {
console.log(`${d.event} - ${d.status} (HTTP ${d.response_status})`);
if (d.status === "failed") {
console.log("Error:", d.response_body);
}
}
```
## Adding Filters
Only fire webhooks when specific conditions are met:
```ts theme={null}
const webhook = await client.webhooks.create({
url: "https://your-server.com/webhooks/high-value",
collection: "orders",
events: ["create"],
filter: 'total >= 500 and status = "confirmed"',
});
```
## Troubleshooting
Check the delivery history for error details. Common causes:
* Your endpoint is returning non-2xx status codes
* Your endpoint is taking longer than 30 seconds to respond
* DNS resolution is failing for your URL
Ensure you're verifying against the raw request body (bytes), not a parsed/re-serialized JSON object. The signature is computed over the exact bytes sent.
* Verify the webhook is `enabled`
* Check that the event type matches (`create`, `update`, `delete`)
* If using a filter, verify the expression matches your record data
* Filters that fail to evaluate will still fire the webhook (fail-open)
In production, SnackBase requires HTTPS URLs. For local development, HTTP is allowed. Private IPs (localhost, 10.x, 192.168.x) are also blocked in production.
## Next Steps
* [Outbound Webhooks Concept](/concepts/webhooks) -- full reference
* [Webhooks API Reference](/api-reference/endpoints/webhooks/create-webhook) -- all endpoints
* [Webhooks SDK Reference](/sdk/js/services/webhooks) -- SDK methods
# Testing Guide
Source: https://docs.snackbase.dev/guides/testing
Learn how to write and run tests for SnackBase, covering unit tests, integration tests, and best practices
This guide explains how to write and run tests for SnackBase, covering unit tests, integration tests, and best practices.
## Overview
SnackBase uses **pytest** as its testing framework with comprehensive support for async operations and database testing.
### Test Stack
| Component | Purpose |
| ------------------ | ----------------------------- |
| **pytest** | Test framework and runner |
| **pytest-asyncio** | Async test support |
| **httpx** | Async HTTP client for testing |
| **ASGITransport** | Test FastAPI without network |
| **pytest-cov** | Code coverage reporting |
### Test Coverage Goals
| Area | Target Coverage |
| ---------------------------------------------- | --------------- |
| **Core Logic** (domain/) | 90%+ |
| **Repositories** (infrastructure/persistence/) | 85%+ |
| **API Routes** (infrastructure/api/routes/) | 80%+ |
| **Services** (infrastructure/services/) | 85%+ |
| **Hooks** (infrastructure/hooks/) | 75%+ |
| **Overall** | 85%+ |
## Testing Philosophy
### Test Pyramid
```
/\
/ \
/ E2E \ ← Few (manual/expensive)
/--------\
/Integration \ ← More (API/database)
/--------------\
/ Unit Tests \ ← Most (fast/isolated)
/------------------\
```
### Testing Principles
| Principle | Description |
| ----------------- | ------------------------------------------------------ |
| **Fast** | Tests should run quickly (unit tests \< 1 second each) |
| **Isolated** | Tests shouldn't depend on each other |
| **Deterministic** | Same input should always produce same output |
| **Readable** | Test names should describe what they test |
| **Maintainable** | Tests should be easy to update when code changes |
## Test Structure
### Directory Layout
```
tests/
├── unit/ # Unit tests (isolated)
│ ├── test_config.py # Configuration tests
│ ├── test_id_generator.py # ID generation tests
│ ├── test_rules_lexer.py # Rule lexer tests
│ ├── test_rules_parser.py # Rule parser tests
│ └── test_password_hasher.py # Password hasher tests
│
├── integration/ # Integration tests (API + DB)
│ ├── test_auth_endpoints.py # Authentication API tests
│ ├── test_collections.py # Collection CRUD tests
│ ├── test_records.py # Record CRUD tests
│ ├── test_permissions.py # Permission tests
│ └── test_hooks.py # Hook integration tests
│
├── security/ # Security-specific tests
│ ├── test_authentication.py # Auth security tests
│ ├── test_authorization.py # Permission security tests
│ └── test_injection.py # SQL injection tests
│
├── conftest.py # Shared fixtures and configuration
└── pytest.ini # Pytest configuration
```
### Test Naming Convention
Use descriptive test names that explain what is being tested:
```python theme={null}
# BAD: Vague
def test_create():
pass
def test_update():
pass
# GOOD: Descriptive
def test_create_post_returns_201_with_valid_data():
pass
def test_update_post_fails_with_invalid_id():
pass
def test_update_post_requires_authentication():
pass
```
## Running Tests
### Basic Commands
```bash theme={null}
# Run all tests
uv run pytest
# Run unit tests only
uv run pytest tests/unit/
# Run integration tests only
uv run pytest tests/integration/
# Run specific test file
uv run pytest tests/unit/test_id_generator.py
# Run specific test
uv run pytest tests/unit/test_id_generator.py::test_generate_id_format
# Run with verbose output
uv run pytest -v
# Run with coverage
uv run pytest --cov=snackbase --cov-report=html
```
### Coverage Report
```bash theme={null}
# Generate HTML coverage report
uv run pytest --cov=snackbase --cov-report=html
# Open report
open htmlcov/index.html
```
### Test Discovery
Pytest automatically discovers tests:
```
Tests are discovered in files matching:
- test_*.py
- *_test.py
Test functions must:
- Start with "test_"
- Be in a discovered file
Test classes must:
- Start with "Test"
- Have methods starting with "test_"
```
## Writing Unit Tests
### What to Unit Test
Unit tests should cover:
* **Business logic** (domain layer)
* **Pure functions** (no side effects)
* **Data transformations**
* **Validation logic**
### Example: Testing ID Generator
```python theme={null}
# tests/unit/test_id_generator.py
import pytest
from src.snackbase.core.id_generator import generate_id
class TestIDGenerator:
"""Test ID generation functionality."""
def test_generate_id_format(self):
"""Generated ID should match XX#### format."""
id = generate_id("test")
assert isinstance(id, str)
assert len(id) == 9 # XX + ####
assert id[:2].isalpha()
assert id[2:].isdigit()
assert id[2:].isnumeric()
def test_generate_id_prefix(self):
"""Generated ID should use correct prefix."""
id = generate_id("user")
assert id.startswith("user_")
assert len(id) == 14 # user_ + XX####
def test_generate_ids_are_unique(self):
"""Each generated ID should be unique."""
ids = [generate_id("test") for _ in range(100)]
assert len(set(ids)) == 100 # All unique
def test_generate_id_deterministic_prefix(self):
"""Prefix should be consistent."""
id1 = generate_id("test")
id2 = generate_id("test")
assert id1.startswith("test_")
assert id2.startswith("test_")
```
### Example: Testing Rule Parser
```python theme={null}
# tests/unit/test_rules_parser.py
import pytest
from src.snackbase.core.rules.parser import Parser
from src.snackbase.core.rules.ast import *
class TestRuleParser:
"""Test rule parsing functionality."""
def test_parse_simple_comparison(self):
"""Parser should handle simple comparisons."""
parser = Parser()
ast = parser.parse("user.id == 'user_123'")
assert isinstance(ast, BinaryOpNode)
assert ast.operator == "=="
assert isinstance(ast.left, FieldAccessNode)
assert ast.left.field == "user.id"
def test_parse_logical_and(self):
"""Parser should handle AND operations."""
parser = Parser()
ast = parser.parse("@has_role('admin') and @owns_record()")
assert isinstance(ast, BinaryOpNode)
assert ast.operator == "and"
assert isinstance(ast.left, FunctionCallNode)
def test_parse_grouping(self):
"""Parser should handle parentheses grouping."""
parser = Parser()
ast = parser.parse("(@has_role('a') or @has_role('b')) and status == 'draft'")
assert isinstance(ast, BinaryOpNode)
assert ast.operator == "and"
assert isinstance(ast.left, BinaryOpNode) # (a or b)
```
## Writing Integration Tests
### What to Integration Test
Integration tests should cover:
* **API endpoints** (request/response)
* **Database operations** (CRUD)
* **Authentication flows**
* **Permission enforcement**
* **Hook execution**
### Example: Testing POST Endpoint
```python theme={null}
# tests/integration/test_posts.py
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession
from src.snackbase.infrastructure.api.app import app
from src.snackbase.infrastructure.persistence.models.post import Post
from src.snackbase.core.id_generator import generate_id
@pytest.mark.asyncio
class TestPostsAPI:
"""Test posts API endpoints."""
async def test_create_post_success(
self,
client: AsyncClient,
superadmin_token: str
):
"""Creating a post with valid data should return 201."""
response = await client.post(
"/api/v1/posts",
headers={"Authorization": f"Bearer {superadmin_token}"},
json={
"title": "Test Post",
"content": "This is test content",
"status": "draft"
}
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test Post"
assert data["content"] == "This is test content"
assert data["status"] == "draft"
assert "id" in data
assert "created_at" in data
async def test_create_post_requires_auth(
self,
client: AsyncClient
):
"""Creating a post without auth should return 401."""
response = await client.post(
"/api/v1/posts",
json={"title": "Test Post"}
)
assert response.status_code == 401
async def test_create_post_validates_required_fields(
self,
client: AsyncClient,
superadmin_token: str
):
"""Creating a post without required fields should return 422."""
response = await client.post(
"/api/v1/posts",
headers={"Authorization": f"Bearer {superadmin_token}"},
json={} # Missing required fields
)
assert response.status_code == 422
data = response.json()
assert "detail" in data
```
### Example: Testing Permissions
```python theme={null}
# tests/integration/test_permissions.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
class TestPermissions:
"""Test permission enforcement."""
async def test_viewer_cannot_delete_posts(
self,
client: AsyncClient,
viewer_token: str,
test_post: dict
):
"""Viewer role should not be able to delete posts."""
response = await client.delete(
f"/api/v1/posts/{test_post['id']}",
headers={"Authorization": f"Bearer {viewer_token}"}
)
assert response.status_code == 403
async def test_editor_can_update_own_drafts(
self,
client: AsyncClient,
editor_token: str,
test_post: dict
):
"""Editor should be able to update their own draft posts."""
response = await client.put(
f"/api/v1/posts/{test_post['id']}",
headers={"Authorization": f"Bearer {editor_token}"},
json={"title": "Updated Title"}
)
assert response.status_code == 200
assert response.json()["title"] == "Updated Title"
```
## Test Fixtures
Fixtures provide reusable test setup.
### Available Fixtures
```python theme={null}
# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from httpx import AsyncClient, ASGITransport
from src.snackbase.infrastructure.api.app import app
from src.snackbase.infrastructure.persistence.database import get_db
from src.snackbase.core.config import settings
# Test database (in-memory SQLite)
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def engine():
"""Create test database engine."""
engine = create_async_engine(TEST_DATABASE_URL)
yield engine
engine.dispose()
@pytest.fixture
async def db_session(engine):
"""Create test database session."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session
await session.rollback()
@pytest.fixture
async def client(db_session):
"""Create test HTTP client."""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest.fixture
async def superadmin_token(client: AsyncClient):
"""Create and return superadmin auth token."""
response = await client.post(
"/api/v1/auth/login",
json={
"account": "system",
"email": "superadmin@example.com",
"password": "SuperAdmin123!"
}
)
return response.json()["access_token"]
```
### Using Fixtures
```python theme={null}
@pytest.mark.asyncio
async def test_with_fixtures(
client: AsyncClient, # HTTP client fixture
db_session: AsyncSession, # Database session fixture
superadmin_token: str # Auth token fixture
):
"""Test using fixtures."""
# Use client for API calls
response = await client.get(
"/api/v1/posts",
headers={"Authorization": f"Bearer {superadmin_token}"}
)
# Use db_session for direct database access
result = await db_session.execute(select(Post).limit(1))
post = result.scalar_one_or_none()
assert response.status_code == 200
assert post is not None
```
## Async Testing
### Marking Async Tests
Use `@pytest.mark.asyncio` for async test functions:
```python theme={null}
import pytest
@pytest.mark.asyncio
async def test_async_function():
"""Async test function."""
result = await async_function()
assert result is not None
```
### Async Test Classes
```python theme={null}
@pytest.mark.asyncio
class TestAsyncOperations:
"""Class containing async tests."""
async def setup_method(self):
"""Run before each test method."""
self.data = await load_test_data()
async def test_async_operation(self):
"""Test async operation."""
result = await async_operation(self.data)
assert result.success
async def teardown_method(self):
"""Run after each test method."""
await cleanup(self.data)
```
## Best Practices
### 1. Arrange-Act-Assert Pattern
Structure tests clearly:
```python theme={null}
def test_user_can_login():
# Arrange: Set up test data
user = create_test_user(email="test@example.com", password="password123")
# Act: Execute the function being tested
result = auth_service.login("test@example.com", "password123")
# Assert: Verify the result
assert result.success is True
assert result.token is not None
```
### 2. Use Descriptive Assertions
```python theme={null}
# BAD: Generic assertion
assert result is not None
# GOOD: Descriptive assertion
assert result.id == "user_123"
assert result.email == "test@example.com"
assert result.is_active is True
```
### 3. Test Edge Cases
```python theme={null}
@pytest.mark.parametrize("input,expected", [
("", False), # Empty string
("a", True), # Single character
("a" * 1000, True), # Long string
(" spaces ", True), # Whitespace
(None, False), # None value
])
def test_validate_email(input, expected):
"""Test email validation with various inputs."""
result = validate_email(input)
assert result == expected
```
### 4. Mock External Dependencies
```python theme={null}
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_send_notification():
"""Test notification sending with mocked external API."""
# Mock the external service
with patch("src.snackbase.services.notification_service.send_email") as mock_send:
mock_send.return_value = AsyncMock(return_value=True)
# Call the function
await send_user_notification("user_123", "Test message")
# Verify the mock was called
mock_send.assert_called_once_with(
email="user@example.com",
subject="Notification",
body="Test message"
)
```
### 5. Use Factory Boy for Test Data
```python theme={null}
# tests/factories.py
import factory
from factory.alchemy import SQLAlchemyModelFactory
from src.snackbase.infrastructure.persistence.models.user import User
from src.snackbase.infrastructure.persistence.models.post import Post
class UserFactory(SQLAlchemyModelFactory):
"""Factory for creating test users."""
class Meta:
model = User
sqlalchemy_session_persistence = "commit"
id = factory.LazyFunction(lambda: generate_id("user"))
email = factory.Sequence(lambda n: f"user{n}@example.com")
account_id = "AB1001"
password_hash = "$argon2id$v=19$m=65536,t=3,p=4$test"
class PostFactory(SQLAlchemyModelFactory):
"""Factory for creating test posts."""
class Meta:
model = Post
sqlalchemy_session_persistence = "commit"
id = factory.LazyFunction(lambda: generate_id("post"))
account_id = "AB1001"
title = factory.Faker("sentence")
content = factory.Faker("paragraph")
status = "draft"
# Use in tests
@pytest.mark.asyncio
async def test_with_factory(db_session: AsyncSession):
"""Test using factory for test data."""
post = await PostFactory.create_async(
title="Custom Title",
status="published"
)
assert post.title == "Custom Title"
assert post.status == "published"
```
### 6. Run Tests Frequently
```bash theme={null}
# Run tests on file save (using pytest-watch)
uv pip install pytest-watch
ptw
# Or use pytest-xdist for parallel runs
uv run pytest -n auto
```
## Summary
| Concept | Key Takeaway |
| --------------------- | ------------------------------------------------------------- |
| **Test Framework** | pytest with pytest-asyncio for async tests |
| **Test Structure** | tests/unit/, tests/integration/, tests/security/ |
| **Unit Tests** | Test business logic, pure functions, validation |
| **Integration Tests** | Test API endpoints, database operations, permissions |
| **Fixtures** | Reusable test setup (db\_session, client, tokens) |
| **Async Testing** | Use @pytest.mark.asyncio decorator |
| **Best Practices** | Arrange-Act-Assert, descriptive assertions, edge cases, mocks |
## Related Guides
* [Adding API Endpoints](./adding-api-endpoints)
* [Creating Custom Hooks](./creating-custom-hooks)
* [Writing Rules](./writing-rules)
# Writing Permission Rules
Source: https://docs.snackbase.dev/guides/writing-rules
Learn how to write and use permission rules in SnackBase's rule engine for fine-grained access control
SnackBase includes a **powerful rule engine** that compiles expression-based rules into efficient SQL WHERE clauses. This guide explains the syntax, variables, and best practices for writing rules.
## Overview
Rules are **boolean expressions** associated with collection operations (`list`, `view`, `create`, `update`, `delete`).
```python theme={null}
# Rule example: Users can only see their own records
created_by = @request.auth.id
```
### When to Use Rules
| Scenario | Example | | |
| :---------------------- | :------------------------------------------------- | - | -------------------------------- |
| **Record ownership** | `created_by = @request.auth.id` | | |
| **Role-based access** | `@request.auth.role = "admin"` | | |
| **Status-based access** | \`status = "published" | | @request.auth.role = "admin"\` |
| **Pattern matching** | `email ~ "%@company.com"` | | |
| **Complex logic** | \`(priority > 5 && @request.auth.role = "manager") | | created\_by = @request.auth.id\` |
## Rule Syntax
### Basic Expressions
```python theme={null}
# Field comparisons
@request.auth.id = "user_abc123"
@request.auth.email = "admin@example.com"
status = "published"
# Numeric comparisons
views > 1000
priority >= 3
quantity <= 50
# Pattern matching (SQL LIKE)
title ~ "Draft%" # Starts with Draft
email ~ "%@gmail.com" # Ends with @gmail.com
# Negation
!is_archived
status != "archived"
```
### Logical Operators
| Operator | Usage | Description | | | | |
| :------- | :--------------- | :--------------------------- | ----------- | ----------------------- | ------- | ----------------------------------- |
| `&&` | `cond1 && cond2` | Both conditions must be true | | | | |
| \` | | \` | \`cond1 | | cond1\` | At least one condition must be true |
| `!` | `!condition` | Simple negation | | | | |
| `()` | \`(c1 | | c2) && c3\` | Grouping for precedence | | |
**Operator Precedence**: `()` > `!` > comparisons > `&&` > `||`
## Evaluation Context
Rules have access to the following variables:
### 1. `@request.auth` (Standard)
| Field | Description |
| :------------------------- | :---------------------- |
| `@request.auth.id` | Current user ID |
| `@request.auth.email` | Current user email |
| `@request.auth.role` | Current user role |
| `@request.auth.account_id` | Current account context |
### 2. `@request.data` (Action-specific)
Available during **create** and **update** operations.
* `@request.data.fieldname`: Accesses the value being sent in the request body.
Example: `!@request.data.is_admin` (Don't allow setting admin flag via this route).
### 3. Record Fields (Direct Access)
You can reference any field in the record directly by name.
* `created_by`
* `status`
* `your_custom_field`
## Built-in Macros
| Macro | Description | Example |
| :------------------ | :---------------------- | :----------------------- |
| `@has_role(role)` | User has specific role | `@has_role("admin")` |
| `@has_group(group)` | User has specific group | `@has_group("managers")` |
| `@owns_record()` | User is the creator | `@owns_record()` |
| `@is_creator()` | Alias for @owns\_record | `@is_creator()` |
## Rule Examples
### Example 1: Edit Own Drafts
Only allow editing if the user is the creator AND the status is "draft".
```python theme={null}
@owns_record() && status = "draft"
```
### Example 2: Admin or Owner
```python theme={null}
@request.auth.role = "admin" || @owns_record()
```
### Example 3: String Pattern Matching
Allow viewing only if the record type starts with "public".
```python theme={null}
type ~ "public%"
```
### Example 4: Complex Multi-Condition
```python theme={null}
# User can edit if:
# - They're an admin, OR
# - They own it AND it's not locked
@request.auth.role = "admin" || (@owns_record() && !is_locked)
```
## Testing Rules
### Manual Testing in Admin UI
SnackBase provides a built-in **Rule Tester** in the Collections settings:
1. Navigate to **Collections** -> **Edit Collection** -> **Rules**.
2. Write your rule.
3. Use the "Test Rule" button to simulate evaluation against a mock user and record.
### API Testing
You can also test rules via the API:
```bash theme={null}
POST /api/v1/roles/test-rule
{
"rule": "@owns_record() && status = 'draft'",
"user_id": "usr_123",
"record": {"created_by": "usr_123", "status": "draft"}
}
```
## Best Practices
1. **Keep Rules Simple**: Complex logic is hard to debug. Use **SQL Macros** for complex multi-table checks.
2. **Use Parentheses**: Always group conditions in complex expressions to ensure correct evaluation order.
3. **Optimistic Rules**: In SnackBase, rules are compiled to `WHERE` clauses, so they are extremely fast.
4. **Deny by Default**: If no rule is defined for an operation, access is denied.
## Related Guides
* [Permissions Concept](/permissions)
* [SQL Macros Reference](../macros)
* [API Examples](../api-reference/examples)
# Hook System Reference
Source: https://docs.snackbase.dev/hooks
Complete reference for the SnackBase Hook System v1.0 - a stable extensibility framework
**Version**: 1.0 (Stable API)
**Status**: Production Ready
This page covers the **code-level Python hook system** for extending SnackBase internals with custom Python code.
To manage hooks via the REST API without writing code (event triggers, cron schedules, and manual triggers), see [API-Defined Hooks](/concepts/api-hooks).
## Overview
The SnackBase Hook System is an **extensibility framework** that allows developers to inject custom logic at specific points in the application lifecycle. It provides a stable, event-driven API for extending SnackBase without modifying core code.
### Key Features
* **Event-Driven Architecture**: Subscribe to lifecycle events
* **Priority-Based Execution**: Control hook execution order
* **Tag-Based Filtering**: Target specific collections or resources
* **Before/After Hooks**: Modify data or react to changes
* **Built-in Hooks**: Core functionality (timestamps, account isolation)
* **Abort Capability**: Cancel operations from before hooks
* **Async Support**: Full async/await support
* **Stable API**: Guaranteed backward compatibility
The Hook System API is stable and follows semantic versioning. Breaking
changes will only occur in major version releases.
## Stable API Contract
### Guaranteed Stability
**Stable (will not change)**:
* `HookRegistry.register()` method signature
* `HookRegistry.trigger()` method signature
* `HookRegistry.unregister()` method signature
* `HookContext` dataclass structure
* `AbortHookException` behavior
* Hook event naming convention
* Priority-based execution order
* Tag-based filtering mechanism
* Built-in hook behavior
**Additive Changes (non-breaking)**:
* New hook events
* New hook categories
* New `HookContext` fields (optional)
* New built-in hooks
* New utility functions
## Hook Categories
Hooks are organized into **8 categories**:
| Category | Description | Examples |
| ------------------------- | ---------------------------- | ----------------------------------------------------------- |
| **App Lifecycle** | Application startup/shutdown | `on_bootstrap`, `on_serve`, `on_terminate` |
| **Model Operations** | Internal SQLAlchemy models | `on_model_before_create`, `on_model_after_update` |
| **Record Operations** | Dynamic collection records | `on_record_before_create`, `on_record_after_delete` |
| **Collection Operations** | Schema changes | `on_collection_before_create`, `on_collection_after_update` |
| **Auth Operations** | Authentication events | `on_auth_after_login`, `on_auth_before_register` |
| **Request Processing** | HTTP request lifecycle | `on_before_request`, `on_after_request` |
| **Realtime** | WebSocket/SSE events | `on_realtime_connect`, `on_realtime_message` |
| **Mailer** | Email sending | `on_mailer_before_send`, `on_mailer_after_send` |
## Hook Events
### Naming Convention
All hook events follow a consistent pattern:
```
on___
Examples:
- on_record_before_create
- on_auth_after_login
- on_collection_before_delete
```
**Timing**:
* `before_*`: Called before operation (can modify data or abort)
* `after_*`: Called after successful operation (read-only, side effects)
### Complete Event List
#### Record Operations (Dynamic Collections)
| Event | Timing | Can Modify | Can Abort |
| ------------------------- | ------ | ---------- | --------- |
| `on_record_before_create` | Before | Yes | Yes |
| `on_record_after_create` | After | No | No |
| `on_record_before_update` | Before | Yes | Yes |
| `on_record_after_update` | After | No | No |
| `on_record_before_delete` | Before | No | Yes |
| `on_record_after_delete` | After | No | No |
| `on_record_before_query` | Before | Yes | Yes |
| `on_record_after_query` | After | Yes | No |
#### Auth Operations
| Event | Timing | Can Modify | Can Abort |
| ------------------------------- | ------ | ---------- | --------- |
| `on_auth_before_login` | Before | Yes | Yes |
| `on_auth_after_login` | After | No | No |
| `on_auth_before_register` | Before | Yes | Yes |
| `on_auth_after_register` | After | No | No |
| `on_auth_before_logout` | Before | No | Yes |
| `on_auth_after_logout` | After | No | No |
| `on_auth_before_password_reset` | Before | Yes | Yes |
| `on_auth_after_password_reset` | After | No | No |
#### Collection Operations
| Event | Timing | Can Modify | Can Abort |
| ----------------------------- | ------ | ---------- | --------- |
| `on_collection_before_create` | Before | Yes | Yes |
| `on_collection_after_create` | After | No | No |
| `on_collection_before_update` | Before | Yes | Yes |
| `on_collection_after_update` | After | No | No |
| `on_collection_before_delete` | Before | No | Yes |
| `on_collection_after_delete` | After | No | No |
## Usage Guide
### Decorator Registration (Recommended)
```python theme={null}
from snackbase.core.hooks import HookEvent
@app.state.hook.on_record_before_create("posts", priority=100)
async def validate_post(event, data, context):
"""Validate post before creation."""
if data and len(data.get("title", "")) < 5:
from snackbase.domain.entities.hook_context import AbortHookException
raise AbortHookException("Title must be at least 5 characters")
return data
```
### Available Decorator Methods
**Record Operations:**
* `on_record_before_create(collection, priority=0)`
* `on_record_after_create(collection, priority=0)`
* `on_record_before_update(collection, priority=0)`
* `on_record_after_update(collection, priority=0)`
* `on_record_before_delete(collection, priority=0)`
* `on_record_after_delete(collection, priority=0)`
* `on_record_before_query(collection, priority=0)`
* `on_record_after_query(collection, priority=0)`
**Collection Operations:**
* `on_collection_before_create(priority=0)`
* `on_collection_after_create(priority=0)`
* `on_collection_before_update(priority=0)`
* `on_collection_after_update(priority=0)`
* `on_collection_before_delete(priority=0)`
* `on_collection_after_delete(priority=0)`
**Auth Operations:**
* `on_auth_before_login(priority=0)`
* `on_auth_after_login(priority=0)`
* `on_auth_before_register(priority=0)`
* `on_auth_after_register(priority=0)`
### HookContext Structure
```python theme={null}
@dataclass
class HookContext:
"""Context passed to hooks."""
app: Any # FastAPI app instance
user: Optional["User"] # Current authenticated user
account_id: Optional[str] # Current account ID
request_id: str # Request correlation ID
request: Optional["Request"] # FastAPI/Starlette Request object
ip_address: Optional[str] # Client IP address
user_agent: Optional[str] # Client user agent
user_name: Optional[str] # User display name
```
### Aborting Operations
Use `AbortHookException` to cancel an operation from a `before_*` hook:
```python theme={null}
from snackbase.domain.entities.hook_context import AbortHookException
@app.state.hook.on_record_before_create("posts")
async def validate_post(event, data, context):
"""Prevent spam posts."""
if data and "spam" in data.get("content", "").lower():
raise AbortHookException("Spam content detected")
return data
```
## Built-in Hooks
SnackBase includes **4 built-in hooks** that provide core functionality. These hooks are **always active** and cannot be disabled.
### 1. Timestamp Hook
**Purpose**: Automatically set `created_at` and `updated_at` timestamps.
**Events**: `on_record_before_create`, `on_record_before_update`
**Priority**: `-100` (runs early)
### 2. Account Isolation Hook
**Purpose**: Enforce multi-tenancy by setting `account_id` from context.
**Events**: `on_record_before_create`
**Priority**: `-200` (runs very early)
### 3. Created By Hook
**Purpose**: Track which user created/updated records.
**Events**: `on_record_before_create`, `on_record_before_update`
**Priority**: `-150`
### 4. Audit Capture Hook
**Purpose**: Automatically capture audit log entries for record operations.
**Events**: `on_record_after_create`, `on_record_after_update`, `on_record_after_delete`
**Priority**: `100` (runs after all user hooks)
This hook respects the `SNACKBASE_AUDIT_LOGGING_ENABLED` configuration. When
disabled, no audit entries are captured.
### Built-in Hook Execution Order
```
Priority -200: account_isolation_hook (set account_id)
↓
Priority -150: created_by_hook (set created_by/updated_by)
↓
Priority -100: timestamp_hook (set timestamps)
↓
Priority 0+: User hooks (custom logic)
↓
Priority 100: audit_capture_hook (capture audit logs)
```
## Creating Custom Hooks
### Example 1: Data Validation
```python theme={null}
from snackbase.domain.entities.hook_context import AbortHookException
@app.state.hook.on_record_before_create("products", priority=50)
async def validate_product_price(event, data, context):
"""Ensure product price is positive."""
if data and data.get("price", 0) <= 0:
raise AbortHookException("Price must be greater than 0")
return data
```
### Example 2: Data Transformation
```python theme={null}
@app.state.hook.on_record_before_create("users", priority=50)
async def normalize_email(event, data, context):
"""Normalize email to lowercase."""
if data and "email" in data:
data["email"] = data["email"].lower().strip()
return data
```
### Example 3: Computed Fields
```python theme={null}
@app.state.hook.on_record_before_create("orders", priority=50)
async def calculate_total(event, data, context):
"""Calculate order total from line items."""
if data and "items" in data:
total = sum(item["price"] * item["quantity"] for item in data["items"])
data["total"] = total
return data
```
### Example 4: Notifications
```python theme={null}
@app.state.hook.on_record_after_create("orders", priority=50)
async def send_order_notification(event, data, context):
"""Send email notification when order is created."""
if data:
await send_email(
to=context.user.email,
subject="Order Confirmation",
body=f"Your order {data['id']} has been created."
)
return data
```
## Advanced Features
### Priority-Based Execution
Hooks execute in **priority order** (higher priority = earlier execution):
```python theme={null}
# Priority 200 - runs first
@app.state.hook.on_record_before_create("posts", priority=200)
async def hook_1(event, data, context):
data["step"] = "1"
return data
# Priority 100 - runs second
@app.state.hook.on_record_before_create("posts", priority=100)
async def hook_2(event, data, context):
data["step"] = "2"
return data
# Priority 0 (default) - runs last
@app.state.hook.on_record_before_create("posts")
async def hook_3(event, data, context):
data["step"] = "3"
return data
```
### Tag-Based Filtering
Target specific collections or resources:
```python theme={null}
# Only for 'posts' collection
registry.register(
event=HookEvent.ON_RECORD_BEFORE_CREATE,
callback=my_hook,
filters={"collection": "posts"}
)
```
### Error Handling
Hooks can fail without crashing the system:
```python theme={null}
# Default: errors are logged but don't stop execution
registry.register(
event=HookEvent.ON_RECORD_AFTER_CREATE,
callback=my_hook,
stop_on_error=False # Default
)
```
## Best Practices
### 1. Keep Hooks Focused
```python theme={null}
# Bad - does too much
async def mega_hook(event, data, context):
validate_data(data)
send_email(data)
update_cache(data)
return data
# Good - single responsibility
async def validate_data_hook(event, data, context):
validate_data(data)
return data
```
### 2. Use Appropriate Priorities
```python theme={null}
# Validation: High priority (runs early)
@app.state.hook.on_record_before_create("posts", priority=100)
async def validate(event, data, context):
pass
# Enrichment: Medium priority
@app.state.hook.on_record_before_create("posts", priority=50)
async def enrich(event, data, context):
pass
# Side effects: Low priority (runs late)
@app.state.hook.on_record_after_create("posts", priority=10)
async def notify(event, data, context):
pass
```
### 3. Handle Errors Gracefully
```python theme={null}
@app.state.hook.on_record_after_create("posts")
async def send_notification(event, data, context):
"""Send notification, but don't fail if it errors."""
try:
await send_email(data)
except Exception as e:
logger.error("Failed to send notification", error=str(e))
return data
```
## API Reference
### HookRegistry
```python theme={null}
class HookRegistry:
def register(
self,
event: str,
callback: Callable,
filters: Optional[dict[str, Any]] = None,
priority: int = 0,
stop_on_error: bool = False,
is_builtin: bool = False,
) -> str:
"""Register a hook."""
async def trigger(
self,
event: str,
data: Optional[dict[str, Any]] = None,
context: Optional[HookContext] = None,
filters: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Execute all hooks for an event."""
def unregister(self, hook_id: str) -> bool:
"""Remove a registered hook."""
```
### AbortHookException
```python theme={null}
class AbortHookException(Exception):
"""Raise to abort an operation from a before hook."""
def __init__(
self,
message: str,
status_code: int = 400
):
super().__init__(message)
self.status_code = status_code
```
## Related Guides
* [Creating Custom Hooks](./creating-custom-hooks)
* [Testing](./testing)
* [Extending SnackBase](./extending-snackbase)
# SnackBase
Source: https://docs.snackbase.dev/index
Build like a Startup.
Comply like an Enterprise.
The open-source Python backend with immutable audit logs, GxP compliance, and row-level security built-in.
No credit card required
Setup in 5 minutes
***
## Why SnackBase?
Define your data schema and get fully functional CRUD REST APIs instantly. No boilerplate, no manual endpoint creation.
Built-in support for multiple accounts/organizations with complete data
isolation. Perfect for SaaS applications.
SQL-native row-level security (RLS), field-level access, single-tenant mode, OAuth, SAML, and audit logs.
***
## Everything You Need to Ship Fast
SnackBase provides a complete backend foundation so you can focus on building your product.
Define your data models with a simple schema syntax. Get validation, types, and auto-generated APIs.
Fine-grained access control at the collection, record, or field level. Write
rules, not boilerplate.
Built-in OAuth (Google, GitHub, Microsoft, Apple) and SAML support. Or bring
your own auth provider.
Webhooks, scheduled hooks, multi-step workflows, and custom endpoints -- all configurable via API without writing backend code.
Write custom hooks, macros, and business logic in Python. Extend everything.
Reliable async job execution with automatic retries, priority queues, and built-in monitoring.
***
## Quick Start in 3 Steps
Install SnackBase locally or deploy to your cloud in seconds.
Create collections and define your schema using our intuitive syntax.
Use the auto-generated REST APIs or React Admin UI immediately.
***
## Developer Resources
***
# SQL Macros
Source: https://docs.snackbase.dev/macros
Learn how to use SQL Macros to extend SnackBase's rule engine with custom database logic
SQL Macros allow you to extend the rule expression language by injecting custom SQL queries directly into the permission engine. They are perfect for complex multi-table checks or business logic that requires joining data.
## Overview
A SQL Macro is a predefined SQL fragment that is expanded during rule compilation. It behaves like a boolean function when called in a rule.
```python theme={null}
# Rule using a macro
@is_project_member(project_id)
```
### Why use SQL Macros?
* **Database performance**: Checks are executed as part of the primary query.
* **Complexity management**: Move complex logic out of rules and into reusable SQL.
* **Transactional safety**: Macros execute within the same transaction as the request.
* **Context awareness**: Macros have access to the currently authenticated user.
## Core Concepts
### 1. Naming
Macro names must start with `@` when called in a rule, but are defined without it.
Example: Defined as `owns_record`, called as `@owns_record()`.
### 2. Parameters
Macros can accept parameters. Use `$1`, `$2`, etc., in your SQL query to reference them.
Parameters are passed from the rule expression.
### 3. Context Variables
Macros can also reference context variables directly in the SQL:
* `@request.auth.id`: Current user ID
* `@request.auth.account_id`: Current account ID
## Built-in Macros
SnackBase comes with several high-performance macros pre-installed:
| Macro | SQL Equivalent (approx) | Description |
| :------------------- | :---------------------------------------- | :--------------------------------- |
| `@has_role("name")` | `@request.auth.role = $1` | Checks if user has a specific role |
| `@has_group("name")` | `EXISTS(SELECT 1 FROM group_members ...)` | Checks group membership |
| `@owns_record()` | `created_by = @request.auth.id` | Checks if user created the record |
| `@is_creator()` | `created_by = @request.auth.id` | Alias for @owns\_record |
## Creating Custom Macros
### Example: `@is_project_member(project_id)`
1. **Name**: `is_project_member`
2. **Description**: Checks if a user is a member of a project.
3. **SQL Query**:
```sql theme={null}
SELECT count(*) > 0
FROM project_members
WHERE project_id = $1
AND user_id = @request.auth.id
AND account_id = @request.auth.account_id
```
**Usage in Rule**:
```python theme={null}
@is_project_member(project_id)
```
### Security Constraints
To ensure system stability, SQL macros are subject to the following rules:
* **SELECT only**: Only `SELECT` statements are allowed. `INSERT`, `UPDATE`, `DELETE` will be rejected.
* **Timeout**: Macros have a 5-second execution timeout.
* **Parameter Binding**: All parameters are bound safely to prevent SQL injection.
* **Read-Only**: Macros execute in a read-only transaction context.
## API Management
### List Macros
`GET /api/v1/macros`
### Create Macro
`POST /api/v1/macros`
**Payload**:
```json theme={null}
{
"name": "can_edit_document",
"description": "Checks if user has editor rights on a document",
"sql_query": "SELECT count(*) > 0 FROM doc_access WHERE doc_id = $1 AND user_id = @request.auth.id AND role = 'editor'"
}
```
### Test Macro
`POST /api/v1/macros/{id}/test`
Allows testing a macro with specific parameter values before using it in a rule.
## Best Practices
1. **Return Booleans**: Custom SQL macros should always return a result that can be interpreted as a boolean (usually `count(*) > 0` or `EXISTS(...)`).
2. **Index Columns**: Ensure columns used in `WHERE` clauses (like `user_id`, `project_id`) are indexed.
3. **Account Isolation**: Always include `account_id = @request.auth.account_id` in your queries to maintain proper isolation.
4. **Descriptive Names**: Use names like `is_subscriber`, `can_approve_invoice` to make rules self-documenting.
## Related Guides
* [Permissions Concept](/permissions)
* [Writing Rules](./guides/writing-rules)
# Configuration
Source: https://docs.snackbase.dev/mcp/configuration
Configure the SnackBase MCP server for your needs
The SnackBase MCP server uses environment variables for configuration. This guide covers all available options.
## Required Configuration
### Base URL
The URL of your SnackBase backend instance:
```bash theme={null}
export SNACKBASE_URL="https://your-snackbase-instance.com"
```
For local development, use: `http://localhost:8000`
### API Key
Your API key for authentication:
```bash theme={null}
export SNACKBASE_API_KEY="sb_ak.payload.signature"
```
The API key format is `sb_ak..` where:
* `sb_ak` identifies this as a SnackBase API key
* `payload` contains encoded metadata (account, permissions, expiry)
* `signature` verifies the key's authenticity
## Optional Configuration
### Account ID (Multi-Tenant)
If your API key has access to multiple accounts, specify which one to use:
```bash theme={null}
export SNACKBASE_ACCOUNT_ID="your-account-id"
```
If not specified, the MCP server will use the API key's default account.
### Timeout
Request timeout in milliseconds (default: 30000ms):
```bash theme={null}
export SNACKBASE_TIMEOUT=60000 # 60 seconds
```
### Debug Mode
Enable debug logging for troubleshooting:
```bash theme={null}
export SNACKBASE_DEBUG=true
```
This will log additional information to stderr, including:
* Incoming tool calls
* SDK requests
* Response times
* Error details
## Configuration Examples
### Local Development
```bash theme={null}
# .env for local development
SNACKBASE_URL=http://localhost:8000
SNACKBASE_API_KEY=your-dev-api-key
SNACKBASE_DEBUG=true
```
### Production (Self-Hosted)
```bash theme={null}
SNACKBASE_URL=https://api.example.com
SNACKBASE_API_KEY=sb_ak.xxx.xxx
SNACKBASE_TIMEOUT=60000
```
### SnackBase Cloud
```bash theme={null}
SNACKBASE_URL=https://api.snackbase.dev
SNACKBASE_API_KEY=sb_ak.xxx.xxx
```
## Claude Code Configuration
To use the MCP server with Claude Code, add it to your Claude configuration:
### Locate Your Config
The Claude Code configuration file is at:
* **macOS/Linux**: `~/.claude/settings.json`
* **Windows**: `%APPDATA%\claude\settings.json`
### Add MCP Server
Add the MCP server to the `mcpServers` section:
```json theme={null}
{
"mcpServers": {
"snackbase": {
"command": "snackbase-mcp",
"args": [],
"env": {
"SNACKBASE_URL": "https://your-snackbase-instance.com",
"SNACKBASE_API_KEY": "your-api-key"
}
}
}
}
```
### Multiple Instances
If you need to connect to multiple SnackBase instances:
```json theme={null}
{
"mcpServers": {
"snackbase-dev": {
"command": "snackbase-mcp",
"args": [],
"env": {
"SNACKBASE_URL": "http://localhost:8000",
"SNACKBASE_API_KEY": "dev-api-key"
}
},
"snackbase-prod": {
"command": "snackbase-mcp",
"args": [],
"env": {
"SNACKBASE_URL": "https://api.example.com",
"SNACKBASE_API_KEY": "prod-api-key"
}
}
}
}
```
Don't commit API keys to version control. Use different API keys for different environments and rotate them regularly.
## Cursor Configuration
For Cursor IDE, the configuration is similar but in a different location:
### Locate Your Config
* **macOS/Linux**: `~/.cursor/settings.json`
* **Windows**: `%APPDATA%\cursor\settings.json`
### Add MCP Server
```json theme={null}
{
"mcpServers": {
"snackbase": {
"command": "snackbase-mcp",
"args": [],
"env": {
"SNACKBASE_URL": "https://your-snackbase-instance.com",
"SNACKBASE_API_KEY": "your-api-key"
}
}
}
}
```
## Troubleshooting
### Connection Errors
If you see connection errors:
1. **Verify the URL** is correct and accessible
2. **Check your API key** is valid and not expired
3. **Ensure the backend is running** (for self-hosted)
4. **Test with curl**:
```bash theme={null}
curl -H "X-API-Key: $SNACKBASE_API_KEY" \
"$SNACKBASE_URL/api/v1/health"
```
### Permission Errors
If you get permission errors:
1. **Verify the API key** has the required permissions
2. **Check the account** the API key belongs to
3. **Review collection rules** if accessing specific collections
### Debug Mode
Enable debug mode to see detailed logs:
```bash theme={null}
export SNACKBASE_DEBUG=true
snackbase-mcp
```
Look for errors in the stderr output.
## Security Best Practices
### API Key Management
* **Use separate keys** for different environments
* **Rotate keys regularly** (e.g., every 90 days)
* **Use minimal permissions** - only grant what's needed
* **Monitor usage** via audit logs
* **Revoke compromised keys** immediately
### Environment Isolation
* **Never use production keys** in development
* **Use different API keys** for different applications
* **Document key purposes** in key names
### Audit Trail
Monitor MCP server usage via the audit logs:
```typescript theme={null}
import { SnackBaseClient } from '@snackbase/sdk';
const client = new SnackBaseClient({
baseUrl: 'https://api.example.com',
apiKey: 'admin-api-key',
});
// Get recent MCP activity
const logs = await client.auditLogs.list({
filter: { operation: { $in: ['create', 'update', 'delete'] } },
sort: '-created_at',
limit: 50
});
```
## Next Steps
* **[Integration Guide](/mcp/integration)** - Use with Claude Code and other clients
* **[Tools Reference](/mcp/tools)** - Detailed documentation for each tool
# MCP Server
Source: https://docs.snackbase.dev/mcp/index
Integrate SnackBase with AI assistants using the Model Context Protocol (MCP)
The Model Context Protocol (MCP) is a standardized protocol for connecting AI assistants to external data sources and tools. SnackBase's MCP server allows AI models to directly interact with your SnackBase backend.
* 15+ tools for complete SnackBase operations
* Type-safe data access
* Secure API key authentication
* Compatible with Claude, Cursor, and other MCP clients
* Natural language database queries
* AI-powered data analysis
* Automated workflows
* Developer productivity enhancement
***
## Overview
The SnackBase MCP server (`@snackbase/mcp`) exposes SnackBase's functionality as MCP tools that AI assistants can use. It provides a standardized interface for:
* **Data Operations**: Query, create, update, and delete records in any collection
* **User Management**: Manage users, groups, roles, and permissions
* **Account Management**: Handle multi-tenant accounts and their configurations
* **Admin Operations**: Access audit logs, email templates, macros, and more
* **Monitoring**: Retrieve dashboard statistics and migration status
## How It Works
The MCP server runs as a separate process that communicates via stdio (standard input/output). AI assistants that support MCP can:
1. Discover available tools through the MCP protocol
2. Call tools with appropriate parameters
3. Receive structured responses with data or errors
The AI assistant (e.g., Claude Code) sends a tool call to the MCP server via stdio.
The server authenticates using an API key, validates the request, and calls the appropriate SnackBase SDK service.
The SDK communicates with your SnackBase backend via REST API.
The MCP server formats the response and returns it to the AI assistant, which can then use the information to help you.
## Available Tools
The MCP server exposes 15 tools covering all major SnackBase operations:
| Tool | Description |
| ---------------------------- | ------------------------------------------------ |
| `snackbase_collections` | Manage collections (schemas) and their structure |
| `snackbase_records` | CRUD operations on collection records |
| `snackbase_collection_rules` | Configure access control rules for collections |
| `snackbase_users` | User management and authentication |
| `snackbase_groups` | Group management for team-based access |
| `snackbase_roles` | Role-based access control |
| `snackbase_accounts` | Multi-tenant account management |
| `snackbase_invitations` | User invitation management |
| `snackbase_api_keys` | API key creation and management |
| `snackbase_admin` | Admin operations and configurations |
| `snackbase_dashboard` | Dashboard statistics and metrics |
| `snackbase_audit_logs` | Audit log access and export |
| `snackbase_email_templates` | Email template management |
| `snackbase_macros` | SQL macro operations |
| `snackbase_migrations` | Migration status and history |
The `auth` and `realtime` services are excluded from the MCP server because they don't fit the request/response model well. Authentication should be handled by your application, and realtime requires persistent connections.
## Security Model
The MCP server uses **API key authentication** exclusively:
* **No user sessions**: MCP tools operate with the permissions of the API key
* **Service account**: Use a dedicated service account with appropriate permissions
* **Scoped access**: Create API keys with minimal required permissions
* **Audit trail**: All MCP operations are logged in the audit logs
Never expose API keys with admin privileges to untrusted AI assistants. Always use the minimum required permissions for your use case.
## Architecture
```
┌─────────────────┐ stdio ┌──────────────────┐
│ AI Assistant │◄──────────────►│ SnackBase MCP │
│ (Claude Code) │ │ Server │
└─────────────────┘ └────────┬─────────┘
│
▼
┌──────────────────┐
│ @snackbase/sdk │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ SnackBase API │
│ (Your Backend) │
└──────────────────┘
```
## Next Steps
* **[Installation](/mcp/installation)** - Set up the MCP server
* **[Configuration](/mcp/configuration)** - Configure environment variables
* **[Integration Guide](/mcp/integration)** - Use with Claude Code and other clients
* **[Tools Reference](/mcp/tools)** - Detailed documentation for each tool
# Installation
Source: https://docs.snackbase.dev/mcp/installation
Install and set up the SnackBase MCP server
The SnackBase MCP server can be installed via npm or used directly with npx.
## Prerequisites
Before installing the MCP server, ensure you have:
* **Node.js 20+** installed
* **A running SnackBase backend** (self-hosted or SnackBase Cloud)
* **An API key** from your SnackBase instance
## Installation Methods
### Option 1: Global Installation (Recommended)
Install the MCP server globally for system-wide availability:
```bash theme={null}
npm install -g @snackbase/mcp
# or
pnpm add -g @snackbase/mcp
# or
yarn global add @snackbase/mcp
```
After installation, the `snackbase-mcp` command will be available:
```bash theme={null}
snackbase-mcp
```
### Option 2: Local Installation
Install in your project for development:
```bash theme={null}
npm install @snackbase/mcp
# or
pnpm add @snackbase/mcp
# or
yarn add @snackbase/mcp
```
Run using npx:
```bash theme={null}
npx snackbase-mcp
```
### Option 3: Docker (Coming Soon)
```bash theme={null}
docker pull ghcr.io/lalitgehani/snackbase-mcp:latest
```
## Verification
Verify the installation by running the server with the required environment variables:
```bash theme={null}
export SNACKBASE_URL="https://your-snackbase-instance.com"
export SNACKBASE_API_KEY="your-api-key-here"
snackbase-mcp
```
You should see output like:
```
SnackBase MCP Server starting...
SnackBase MCP Server running on stdio
```
The MCP server communicates via stdio, so you won't see an HTTP port. It's designed to be used by MCP clients like Claude Code or Cursor.
## Creating an API Key
You need an API key to use the MCP server. Here's how to create one:
### Using the SnackBase Admin UI
1. Navigate to your SnackBase Admin UI
2. Go to **Settings** → **API Keys**
3. Click **Create API Key**
4. Give it a descriptive name (e.g., "MCP Server")
5. Set appropriate permissions (see below)
6. Copy the generated key
### Using the SDK
```typescript theme={null}
import { SnackBaseClient } from '@snackbase/sdk';
const client = new SnackBaseClient({
baseUrl: 'https://your-snackbase-instance.com',
apiKey: 'your-admin-api-key', // Use an admin key to create new keys
});
const apiKey = await client.apiKeys.create({
name: 'MCP Server',
expiresAt: new Date('2026-12-31'), // Optional expiry
});
console.log(apiKey.key); // Save this securely!
```
## Recommended Permissions
The permissions you grant to the MCP API key depend on your use case:
### For General Development
* Read access to all collections
* Create/update/delete access to development collections
* User management (if testing auth flows)
### For Data Analysis Only
* Read access to relevant collections
* No write permissions
### For Full Admin Access
Use with caution! Only for trusted environments.
Never commit API keys to version control. Store them in environment variables or a secure secrets manager.
## Environment Variables
The MCP server requires two environment variables:
| Variable | Required | Description | Example |
| ------------------- | -------- | ------------------------------- | --------------------------- |
| `SNACKBASE_URL` | Yes | Your SnackBase backend URL | `https://api.snackbase.dev` |
| `SNACKBASE_API_KEY` | Yes | Your API key for authentication | `sb_ak.xxx.xxx` |
The API key format changed in v0.3.0 to use three parts: `sb_ak..`. Old keys will continue to work, but new keys use this format.
## Setting Environment Variables
### On macOS/Linux
Add to your shell profile (\~/.zshrc, \~/.bashrc):
```bash theme={null}
export SNACKBASE_URL="https://your-snackbase-instance.com"
export SNACKBASE_API_KEY="sb_ak.xxx.xxx"
```
Then reload: `source ~/.zshrc`
### On Windows (PowerShell)
```powershell theme={null}
$env:SNACKBASE_URL="https://your-snackbase-instance.com"
$env:SNACKBASE_API_KEY="sb_ak.xxx.xxx"
```
Or set permanently:
```powershell theme={null}
[System.Environment]::SetEnvironmentVariable('SNACKBASE_URL', 'https://your-snackbase-instance.com', 'User')
[System.Environment]::SetEnvironmentVariable('SNACKBASE_API_KEY', 'sb_ak.xxx.xxx', 'User')
```
### Using .env file (for development)
Create a `.env` file in your project root:
```env theme={null}
SNACKBASE_URL=https://your-snackbase-instance.com
SNACKBASE_API_KEY=sb_ak.xxx.xxx
```
Then load it before running the server:
```bash theme={null}
source .env && snackbase-mcp
```
## Next Steps
* **[Configuration](/mcp/configuration)** - Advanced configuration options
* **[Integration Guide](/mcp/integration)** - Use with Claude Code and other clients
# Integration Guide
Source: https://docs.snackbase.dev/mcp/integration
Use the SnackBase MCP server with Claude Code, Cursor, and other AI assistants
This guide shows you how to integrate the SnackBase MCP server with various AI assistants and development tools.
## Claude Code Integration
Claude Code is a CLI tool that provides AI assistance directly in your terminal. It has native MCP support.
### Setup
1. **Install Claude Code** (if you haven't already):
```bash theme={null}
npm install -g claude-code
```
2. **Configure MCP Server**:
Edit `~/.claude/settings.json` (or `%APPDATA%\claude\settings.json` on Windows):
```json theme={null}
{
"mcpServers": {
"snackbase": {
"command": "snackbase-mcp",
"args": [],
"env": {
"SNACKBASE_URL": "https://your-snackbase-instance.com",
"SNACKBASE_API_KEY": "your-api-key"
}
}
}
}
```
3. **Restart Claude Code** to load the MCP server.
### Usage Examples
Once configured, you can interact with your SnackBase data naturally:
```bash theme={null}
# List all users
claude "List all users in my account"
# Create a record
claude "Create a new post in the posts collection with title 'Hello World'"
# Query with filters
claude "Show me all posts created in the last 7 days"
# Get statistics
claude "What are my dashboard statistics?"
```
### Common Workflows
#### Data Exploration
```
You: "Show me the structure of my 'products' collection"
Claude: [Uses snackbase_collections tool to get collection details]
```
#### Quick CRUD Operations
```
You: "Create a new user with email john@example.com"
Claude: [Uses snackbase_users tool to create user]
```
#### Data Analysis
```
You: "What are the top 10 most viewed articles?"
Claude: [Uses snackbase_records tool with sort and limit]
```
## Cursor IDE Integration
Cursor is an AI-powered code editor with MCP support.
### Setup
1. **Install Cursor** from [cursor.sh](https://cursor.sh)
2. **Configure MCP Server**:
Edit `~/.cursor/settings.json` (or `%APPDATA%\cursor\settings.json` on Windows):
```json theme={null}
{
"mcpServers": {
"snackbase": {
"command": "snackbase-mcp",
"args": [],
"env": {
"SNACKBASE_URL": "https://your-snackbase-instance.com",
"SNACKBASE_API_KEY": "your-api-key"
}
}
}
}
```
3. **Restart Cursor** to load the MCP server.
### Usage in Cursor
* Use **Cmd/Ctrl + K** to open the AI chat
* The AI will have access to your SnackBase data
* Ask questions about your data naturally
## Continue.dev Integration
Continue.dev is a VS Code extension for AI code assistance.
### Setup
1. **Install Continue.dev** from the VS Code marketplace
2. **Configure MCP Server**:
Edit your VS Code settings.json or Continue config:
```json theme={null}
{
"continue.mcpServers": {
"snackbase": {
"command": "snackbase-mcp",
"args": [],
"env": {
"SNACKBASE_URL": "https://your-snackbase-instance.com",
"SNACKBASE_API_KEY": "your-api-key"
}
}
}
}
```
### Usage in VS Code
* Use **Cmd/Ctrl + Shift + L** to open Continue
* Ask questions about your SnackBase data
* Get code suggestions based on your schema
## Cline (Formerly Claude Dev) Integration
Cline is an autonomous coding agent for VS Code.
### Setup
1. **Install Cline** from the VS Code marketplace
2. **Configure MCP Server** in Cline's settings:
```json theme={null}
{
"cline.mcpServers": {
"snackbase": {
"command": "snackbase-mcp",
"args": [],
"env": {
"SNACKBASE_URL": "https://your-snackbase-instance.com",
"SNACKBASE_API_KEY": "your-api-key"
}
}
}
}
```
## Custom Integration
If you want to build your own MCP client, you can use the MCP SDK directly:
### TypeScript Example
```typescript theme={null}
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
async function main() {
// Create transport to the MCP server
const transport = new StdioClientTransport({
command: 'snackbase-mcp',
args: [],
env: {
SNACKBASE_URL: 'https://your-snackbase-instance.com',
SNACKBASE_API_KEY: 'your-api-key',
},
});
// Create and connect client
const client = new Client({
name: 'my-mcp-client',
version: '1.0.0',
});
await client.connect(transport);
// List available tools
const tools = await client.listTools();
console.log('Available tools:', tools);
// Call a tool
const result = await client.callTool({
name: 'snackbase_collections',
arguments: {
action: 'list',
},
});
console.log('Collections:', result);
}
```
### Python Example
```python theme={null}
import asyncio
import subprocess
import json
async def call_mcp_tool(tool_name, arguments):
"""Call an MCP tool via stdio"""
process = subprocess.Popen(
['snackbase-mcp'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={
'SNACKBASE_URL': 'https://your-snackbase-instance.com',
'SNACKBASE_API_KEY': 'your-api-key',
}
)
# Send request
request = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments
}
}
process.stdin.write(json.dumps(request) + '\n')
process.stdin.flush()
# Read response
response = json.loads(process.stdout.readline())
return response
# Usage
asyncio.run(call_mcp_tool('snackbase_collections', {'action': 'list'}))
```
## Common Patterns
### Natural Language Queries
Transform natural language into SnackBase queries:
```
User: "Show me active users"
→ snackbase_records tool with filter: { status: 'active' }
User: "Count records by type"
→ snackbase_records with aggregation
```
### Schema-Aware Code Generation
The AI can generate code based on your collection schemas:
```
User: "Generate a TypeScript interface for the products collection"
→ [Uses snackbase_collections to get schema]
→ Generates TypeScript interface
```
### Data Validation
Use AI to validate data before insertion:
```
User: "Validate this user data before creating"
→ [Checks collection rules]
→ Validates required fields
→ Checks constraints
```
## Troubleshooting
### MCP Server Not Starting
1. **Verify installation**: `which snackbase-mcp`
2. **Check environment variables**: `echo $SNACKBASE_URL`
3. **Test manually**: Run `snackbase-mcp` in a terminal
### Tools Not Available
1. **Restart the AI assistant** after configuration
2. **Check the MCP server logs** for errors
3. **Verify API key** has required permissions
### Permission Errors
1. **Check API key** permissions
2. **Verify account** access
3. **Review collection rules** if accessing specific data
## Best Practices
### Security
* **Use scoped API keys** with minimal permissions
* **Rotate keys regularly**
* **Monitor audit logs** for unusual activity
* **Never expose keys** in client-side code
### Performance
* **Use pagination** for large result sets
* **Request only needed fields**
* **Cache frequently accessed data**
* **Use filters** to reduce data transfer
### Error Handling
```typescript theme={null}
try {
const result = await client.callTool({
name: 'snackbase_records',
arguments: { action: 'list', collection: 'posts' },
});
} catch (error) {
if (error.message.includes('401')) {
// API key invalid or expired
} else if (error.message.includes('403')) {
// Insufficient permissions
}
}
```
## Next Steps
* **[Tools Reference](/mcp/tools)** - Detailed documentation for each tool
# Tools Reference
Source: https://docs.snackbase.dev/mcp/tools/index
Complete reference for all SnackBase MCP tools
The SnackBase MCP server provides 15 tools covering all major SnackBase operations. Each tool supports multiple actions for CRUD operations and queries.
## Tool Overview
| Tool | Actions | Description |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| [`snackbase_collections`](#collections) | list, list\_names, get, create, update, delete, export, import | Manage collection schemas |
| [`snackbase_records`](#records) | list, get, create, update, patch, delete | CRUD operations on records |
| [`snackbase_collection_rules`](#collection-rules) | get, update, validate, test | Configure access control |
| [`snackbase_users`](#users) | list, get, create, update, delete, set\_password, verify\_email | User management |
| [`snackbase_groups`](#groups) | list, get, create, update, delete, add\_member, remove\_member | Group management |
| [`snackbase_roles`](#roles) | list, get, create, update, delete | Role-based access control |
| [`snackbase_accounts`](#accounts) | list, get, create, update, delete, get\_users | Multi-tenant accounts |
| [`snackbase_invitations`](#invitations) | list, create, resend, cancel | User invitations |
| [`snackbase_api_keys`](#api-keys) | list, create, revoke | API key management |
| [`snackbase_admin`](#admin) | get\_stats, get\_recent, list\_system, list\_account, get\_values, update\_values, create, list\_providers, test\_connection | Admin operations |
| [`snackbase_dashboard`](#dashboard) | get\_stats | Dashboard metrics |
| [`snackbase_audit_logs`](#audit-logs) | list, get, export | Audit log access |
| [`snackbase_email_templates`](#email-templates) | list, get, update, render, send\_test, list\_logs | Email management |
| [`snackbase_macros`](#macros) | list, get, create, update, delete, test | SQL macro operations |
| [`snackbase_migrations`](#migrations) | list, get\_current, get\_history | Migration status |
***
## Collections
**Tool Name**: `snackbase_collections`
Manage collection schemas and their structure.
### Actions
#### `list`
List all collections in the current account.
```json theme={null}
{
"action": "list",
"page": 1,
"page_size": 30
}
```
#### `list_names`
Get a simplified list of collection names.
```json theme={null}
{
"action": "list_names"
}
```
#### `get`
Get details of a specific collection by ID.
```json theme={null}
{
"action": "get",
"collection_id": "collection-id"
}
```
#### `create`
Create a new collection with defined fields.
```json theme={null}
{
"action": "create",
"name": "posts",
"fields": [
{
"name": "title",
"type": "text",
"required": true
},
{
"name": "content",
"type": "text"
},
{
"name": "published",
"type": "boolean",
"default": false
}
],
"list_rule": "user_id = {user_id}",
"view_rule": "user_id = {user_id}",
"create_rule": "user_id = {user_id}",
"update_rule": "user_id = {user_id}",
"delete_rule": "user_id = {user_id}"
}
```
#### `update`
Update an existing collection's schema.
```json theme={null}
{
"action": "update",
"collection_id": "collection-id",
"fields": [...]
}
```
#### `delete`
Delete a collection and all its records.
```json theme={null}
{
"action": "delete",
"collection_id": "collection-id"
}
```
***
## Records
**Tool Name**: `snackbase_records`
CRUD operations on collection records.
### Actions
#### `list`
List records from a collection with filtering and pagination.
```json theme={null}
{
"action": "list",
"collection": "posts",
"filter": { "status": "published" },
"sort": "-created_at",
"limit": 10,
"skip": 0,
"fields": ["id", "title", "created_at"],
"expand": ["author"]
}
```
#### `get`
Get a single record by ID.
```json theme={null}
{
"action": "get",
"collection": "posts",
"record_id": "record-id",
"expand": ["author", "comments"]
}
```
#### `create`
Create a new record.
```json theme={null}
{
"action": "create",
"collection": "posts",
"data": {
"title": "My Post",
"content": "Post content",
"status": "published"
}
}
```
#### `update`
Full update of a record (replaces all fields).
```json theme={null}
{
"action": "update",
"collection": "posts",
"record_id": "record-id",
"data": {
"title": "Updated Title",
"content": "Updated content"
}
}
```
#### `patch`
Partial update of a record (only updates specified fields).
```json theme={null}
{
"action": "patch",
"collection": "posts",
"record_id": "record-id",
"data": {
"title": "New Title Only"
}
}
```
#### `delete`
Delete a record.
```json theme={null}
{
"action": "delete",
"collection": "posts",
"record_id": "record-id"
}
```
***
## Collection Rules
**Tool Name**: `snackbase_collection_rules`
Configure access control rules for collections.
### Actions
#### `get`
Get access rules for a collection.
```json theme={null}
{
"action": "get",
"collection_name": "posts"
}
```
#### `update`
Update access rules for a collection.
```json theme={null}
{
"action": "update",
"collection_name": "posts",
"data": {
"list_rule": "status = 'published' or user_id = {user_id}",
"view_rule": "user_id = {user_id}",
"create_rule": "user_id = {user_id}",
"update_rule": "user_id = {user_id}",
"delete_rule": "user_id = {user_id}"
}
}
```
#### `validate`
Validate a rule expression.
```json theme={null}
{
"action": "validate",
"rule": "user_id = {user_id}",
"operation": "list",
"collection_fields": ["user_id", "title", "content"]
}
```
#### `test`
Test a rule with sample context.
```json theme={null}
{
"action": "test",
"rule": "user_id = {user_id}",
"context": {
"user_id": "user-123"
}
}
```
***
## Users
**Tool Name**: `snackbase_users`
Manage user accounts and authentication.
### Actions
#### `list`
List users with filtering and pagination.
```json theme={null}
{
"action": "list",
"search": "john",
"is_active": true,
"sort_by": "created_at",
"sort_order": "desc",
"page": 1,
"page_size": 30
}
```
#### `get`
Get a specific user by ID.
```json theme={null}
{
"action": "get",
"user_id": "user-id"
}
```
#### `create`
Create a new user.
```json theme={null}
{
"action": "create",
"email": "user@example.com",
"account_id": "account-id",
"password": "SecurePassword123!",
"role": "member"
}
```
#### `update`
Update user details.
```json theme={null}
{
"action": "update",
"user_id": "user-id",
"is_active": true,
"role": "admin"
}
```
#### `delete`
Deactivate a user.
```json theme={null}
{
"action": "delete",
"user_id": "user-id"
}
```
#### `set_password`
Set a user's password (admin operation).
```json theme={null}
{
"action": "set_password",
"user_id": "user-id",
"password": "NewPassword123!"
}
```
#### `verify_email`
Manually verify a user's email.
```json theme={null}
{
"action": "verify_email",
"user_id": "user-id"
}
```
***
## Groups
**Tool Name**: `snackbase_groups`
Manage groups for team-based access control.
### Actions
#### `list`
List all groups.
```json theme={null}
{
"action": "list",
"search": "engineering",
"page": 1,
"page_size": 30
}
```
#### `get`
Get group details.
```json theme={null}
{
"action": "get",
"group_id": "group-id"
}
```
#### `create`
Create a new group.
```json theme={null}
{
"action": "create",
"name": "Engineering",
"description": "Engineering team members"
}
```
#### `update`
Update group details.
```json theme={null}
{
"action": "update",
"group_id": "group-id",
"name": "Engineering Team",
"description": "All engineering staff"
}
```
#### `delete`
Delete a group.
```json theme={null}
{
"action": "delete",
"group_id": "group-id"
}
```
#### `add_member`
Add a user to a group.
```json theme={null}
{
"action": "add_member",
"group_id": "group-id",
"user_id": "user-id"
}
```
#### `remove_member`
Remove a user from a group.
```json theme={null}
{
"action": "remove_member",
"group_id": "group-id",
"user_id": "user-id"
}
```
***
## Roles
**Tool Name**: `snackbase_roles`
Manage roles for role-based access control.
### Actions
#### `list`
List all roles.
```json theme={null}
{
"action": "list"
}
```
#### `get`
Get role details.
```json theme={null}
{
"action": "get",
"role_id": "role-id"
}
```
#### `create`
Create a new role.
```json theme={null}
{
"action": "create",
"name": "Editor",
"description": "Can edit but not delete content"
}
```
#### `update`
Update role details.
```json theme={null}
{
"action": "update",
"role_id": "role-id",
"name": "Content Editor",
"description": "Editors with limited permissions"
}
```
#### `delete`
Delete a role.
```json theme={null}
{
"action": "delete",
"role_id": "role-id"
}
```
***
## Accounts
**Tool Name**: `snackbase_accounts`
Manage multi-tenant accounts.
### Actions
#### `list`
List all accounts.
```json theme={null}
{
"action": "list",
"search": "acme",
"is_active": true,
"sort_by": "name",
"sort_order": "asc",
"page": 1,
"page_size": 30
}
```
#### `get`
Get account details.
```json theme={null}
{
"action": "get",
"account_id": "account-id"
}
```
#### `create`
Create a new account.
```json theme={null}
{
"action": "create",
"name": "Acme Corp",
"slug": "acme-corp"
}
```
#### `update`
Update account details.
```json theme={null}
{
"action": "update",
"account_id": "account-id",
"name": "Acme Corporation"
}
```
#### `delete`
Delete an account.
```json theme={null}
{
"action": "delete",
"account_id": "account-id"
}
```
#### `get_users`
Get all users in an account.
```json theme={null}
{
"action": "get_users",
"account_id": "account-id",
"page": 1,
"page_size": 30
}
```
***
## Invitations
**Tool Name**: `snackbase_invitations`
Manage user invitations.
### Actions
#### `list`
List all invitations.
```json theme={null}
{
"action": "list",
"status": "pending",
"page": 1,
"page_size": 30
}
```
#### `create`
Create a new invitation.
```json theme={null}
{
"action": "create",
"email": "newuser@example.com",
"role_id": "role-id"
}
```
#### `resend`
Resend a pending invitation.
```json theme={null}
{
"action": "resend",
"invitation_id": "invitation-id"
}
```
#### `cancel`
Cancel a pending invitation.
```json theme={null}
{
"action": "cancel",
"invitation_id": "invitation-id"
}
```
***
## API Keys
**Tool Name**: `snackbase_api_keys`
Manage API keys for programmatic access.
### Actions
#### `list`
List all API keys.
```json theme={null}
{
"action": "list"
}
```
#### `create`
Create a new API key.
```json theme={null}
{
"action": "create",
"name": "Production API Key",
"expires_at": "2026-12-31T23:59:59Z"
}
```
#### `revoke`
Revoke an API key.
```json theme={null}
{
"action": "revoke",
"key_id": "key-id"
}
```
***
## Admin
**Tool Name**: `snackbase_admin`
Admin operations for configurations and providers.
### Actions
#### `get_stats`
Get admin statistics.
```json theme={null}
{
"action": "get_stats"
}
```
#### `get_recent`
Get recent configurations.
```json theme={null}
{
"action": "get_recent",
"limit": 10
}
```
#### `list_system`
List system-level configurations.
```json theme={null}
{
"action": "list_system"
}
```
#### `list_account`
List account-level configurations.
```json theme={null}
{
"action": "list_account",
"account_id": "account-id"
}
```
#### `get_values`
Get configuration values.
```json theme={null}
{
"action": "get_values",
"config_id": "config-id"
}
```
#### `update_values`
Update configuration values.
```json theme={null}
{
"action": "update_values",
"config_id": "config-id",
"values": {
"smtp_host": "smtp.example.com",
"smtp_port": 587
}
}
```
#### `create`
Create a new configuration.
```json theme={null}
{
"action": "create",
"name": "Custom Provider",
"provider_name": "custom",
"values": {...}
}
```
#### `list_providers`
List available providers.
```json theme={null}
{
"action": "list_providers",
"category": "email"
}
```
#### `test_connection`
Test a provider connection.
```json theme={null}
{
"action": "test_connection",
"config": {
"provider": "smtp",
"host": "smtp.example.com",
"port": 587
}
}
```
***
## Dashboard
**Tool Name**: `snackbase_dashboard`
Get dashboard statistics and metrics.
### Actions
#### `get_stats`
Get dashboard statistics.
```json theme={null}
{
"action": "get_stats"
}
```
Returns:
* Total accounts
* Total users
* Total collections
* Total records
* Recent activity
* System health
***
## Audit Logs
**Tool Name**: `snackbase_audit_logs`
Query and export audit logs.
### Actions
#### `list`
List audit log entries.
```json theme={null}
{
"action": "list",
"user_id": "user-id",
"table_name": "users",
"operation": "create",
"from_date": "2026-01-01T00:00:00Z",
"to_date": "2026-01-31T23:59:59Z",
"page": 1,
"limit": 50
}
```
#### `get`
Get a specific audit log entry.
```json theme={null}
{
"action": "get",
"log_id": "log-id"
}
```
#### `export`
Export audit logs.
```json theme={null}
{
"action": "export",
"format": "json",
"from_date": "2026-01-01T00:00:00Z",
"to_date": "2026-01-31T23:59:59Z"
}
```
***
## Email Templates
**Tool Name**: `snackbase_email_templates`
Manage email templates.
### Actions
#### `list`
List email templates.
```json theme={null}
{
"action": "list",
"template_type": "welcome",
"locale": "en"
}
```
#### `get`
Get email template details.
```json theme={null}
{
"action": "get",
"template_id": "template-id"
}
```
#### `update`
Update email template.
```json theme={null}
{
"action": "update",
"template_id": "template-id",
"subject": "Welcome to {{app_name}}!",
"html_body": "...",
"text_body": "Plain text version",
"enabled": true
}
```
#### `render`
Render template with variables.
```json theme={null}
{
"action": "render",
"template_type": "welcome",
"locale": "en",
"variables": {
"user_name": "John",
"app_name": "MyApp"
}
}
```
#### `send_test`
Send a test email.
```json theme={null}
{
"action": "send_test",
"template_id": "template-id",
"recipient_email": "test@example.com",
"variables": {...}
}
```
#### `list_logs`
List email send logs.
```json theme={null}
{
"action": "list_logs",
"start_date": "2026-01-01",
"end_date": "2026-01-31"
}
```
***
## Macros
**Tool Name**: `snackbase_macros`
Manage SQL macros for complex queries.
### Actions
#### `list`
List all macros.
```json theme={null}
{
"action": "list"
}
```
#### `get`
Get macro details.
```json theme={null}
{
"action": "get",
"macro_id": "macro-id"
}
```
#### `create`
Create a new macro.
```json theme={null}
{
"action": "create",
"name": "active_users",
"description": "Get active users in date range",
"sql_query": "SELECT * FROM users WHERE last_login >= {start_date} AND last_login < {end_date}",
"parameters": ["start_date", "end_date"]
}
```
#### `update`
Update macro.
```json theme={null}
{
"action": "update",
"macro_id": "macro-id",
"sql_query": "SELECT * FROM users WHERE last_login >= {start_date}",
"parameters": ["start_date"]
}
```
#### `delete`
Delete macro.
```json theme={null}
{
"action": "delete",
"macro_id": "macro-id"
}
```
#### `test`
Test macro with parameters.
```json theme={null}
{
"action": "test",
"macro_id": "macro-id",
"params": {
"start_date": "2026-01-01",
"end_date": "2026-02-01"
}
}
```
***
## Migrations
**Tool Name**: `snackbase_migrations`
Query migration status and history.
### Actions
#### `list`
List all migrations.
```json theme={null}
{
"action": "list"
}
```
#### `get_current`
Get current migration version.
```json theme={null}
{
"action": "get_current"
}
```
#### `get_history`
Get migration history.
```json theme={null}
{
"action": "get_history"
}
```
The MCP server provides read-only access to migrations. To apply migrations, use the SnackBase CLI: `snackbase migrate up`
***
## Response Format
All MCP tools return responses in this format:
```json theme={null}
{
"content": [
{
"type": "text",
"text": "{...json data...}"
}
]
}
```
The text field contains a JSON string with the tool's response data.
## Error Handling
Errors are returned in the same format with an error message:
```json theme={null}
{
"content": [
{
"type": "text",
"text": "{\"error\": \"Error message here\"}"
}
],
"isError": true
}
```
Common errors:
* `400` - Bad request (invalid parameters)
* `401` - Unauthorized (invalid API key)
* `403` - Forbidden (insufficient permissions)
* `404` - Not found
* `422` - Validation error (invalid data)
* `500+` - Server error
## Next Steps
* **[Integration Guide](/mcp/integration)** - Use these tools with AI assistants
* **[Configuration](/mcp/configuration)** - Set up the MCP server
# Permissions & Authorization
Source: https://docs.snackbase.dev/permissions
Complete guide to SnackBase's collection-centric access control rules
SnackBase uses a database-centric rule engine that compiles expression-based rules into efficient SQL WHERE clauses for granular row-level security (RLS).
## Overview
In snackbase v0.2, permissions have shifted from a role-based model to a **Collection-centric** model. Instead of defining what each role can do, you define **Rules** directly on the collection.
Each collection has 5 distinct operations that can be controlled:
1. **list**: Controls which records appear in list results (row-level filtering).
2. **view**: Controls access to a single record by ID.
3. **create**: Validates whether a new record can be created.
4. **update**: Controls whether an existing record can be modified.
5. **delete**: Controls whether a record can be removed.
### Role Management
While rules are defined on collections, they still leverage user roles. Roles are now used as labels/identifiers within rule expressions (e.g., `@request.auth.role = "admin"`).
### Performance
Rule expressions are compiled into native SQL. This means:
* **No performance overhead**: Checks happen at the database level.
* **Scalable**: Works efficiently even with millions of records.
* **SQL-Native**: Supports complex joining logic via SQL Macros.
## Rule Syntax
### Variables
| Variable | Description | Example |
| :---------------- | :------------------------------------- | :---------------------------------- |
| `@request.auth.*` | The authenticated user's data | `id`, `email`, `role`, `account_id` |
| `@request.data.*` | The incoming data (create/update only) | `@request.data.status` |
| `fieldname` | Direct access to record fields | `created_by`, `status`, `title` |
### Operators
| Category | Operator | Description | Example | | | | |
| :------------- | :-------- | :-------------------- | :----------------------------------- | ---------- | ------------------------------ | - | -------------------------------- |
| **Comparison** | `=` | Equal to | `created_by = @request.auth.id` | | | | |
| | `!=` | Not equal to | `status != "archived"` | | | | |
| | `<` `>` | Less/Greater than | `priority > 10` | | | | |
| | `<=` `>=` | Less/Greater or equal | `amount >= 100` | | | | |
| | `~` | LIKE (string match) | `title ~ "draft%"` | | | | |
| **Logical** | `&&` | Logical AND | `status = "public" && active = true` | | | | |
| | \` | | \` | Logical OR | \`@request.auth.role = "admin" | | created\_by = @request.auth.id\` |
| | `!` | Logical NOT | `!is_locked` | | | | |
### Literals
* **Strings**: `"text"` or `'text'`
* **Numbers**: `123`, `45.67`
* **Booleans**: `true`, `false`
## Built-in Macros
Macros are reusable expression fragments that simplify common patterns.
* `@has_role("role_name")`: Convenience for `@request.auth.role = "role_name"`
* `@has_group("group_name")`: Checks if user is in a specific group
* `@owns_record()`: Convenience for `created_by = @request.auth.id`
* `@is_creator()`: Same as `@owns_record()`
## SQL Macros
SQL macros allow you to create custom permission logic using raw SQL queries.
### Example: `@is_project_member(project_id)`
* **SQL Query**:
```sql theme={null}
SELECT count(*) > 0 FROM project_members
WHERE project_id = $1 AND user_id = @request.auth.id
```
* **Usage in Rule**:
```python theme={null}
@is_project_member(project_id)
```
## System Fields
| Field | Description |
| :----------- | :---------------------------------------- |
| `id` | Auto-generated record identifier |
| `account_id` | Account/tenant identifier (auto-set) |
| `created_at` | Record creation timestamp (auto-set) |
| `updated_at` | Record update timestamp (auto-set) |
| `created_by` | User ID who created the record (auto-set) |
| `updated_by` | User ID who last updated the record |
## Field-Level Access Control
You can restrict field visibility per operation. This is now managed via `list_fields`, `view_fields`, etc. in the collection rules.
```json theme={null}
{
"list_fields": ["id", "title", "status"],
"view_fields": "*"
}
```
## API Management
Collection rules are managed via the Collections API:
* `GET /api/v1/collections/{name}/rules`
* `PUT /api/v1/collections/{name}/rules`
## Common Patterns
### 1. Public Read Access
* **list\_rule**: `true`
* **view\_rule**: `true`
* **modify\_rules**: `@request.auth.role = "admin"`
### 2. Owner-Only Access
* **list\_rule**: `created_by = @request.auth.id`
* **view\_rule**: `created_by = @request.auth.id`
* **update\_rule**: `created_by = @request.auth.id`
## Related Guides
* [Writing Rules](./guides/writing-rules)
* [Macros Reference](./macros)
* [Deployment Guide](./deployment)
# Quick Start Guide
Source: https://docs.snackbase.dev/quickstart
Get up and running with SnackBase in 5 minutes
Get up and running with SnackBase in 5 minutes. This guide will walk you through the essential steps to set up your instance, create your first collection, add data, set up permissions, and make API requests.
## Prerequisites
Before you begin, ensure you have:
* **Python 3.12+** installed
* **Node.js 18+** installed (for the admin UI)
* **uv** package manager installed
* **macOS/Linux**: `curl -LsSf https://astral.sh/uv/install.sh | sh`
* **Windows**: `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"`
* **Git** installed (for cloning the repository)
**Windows Users**: If you don't have Node.js installed, download it from
[nodejs.org](https://nodejs.org/). For Python, use the [Microsoft
Store](https://apps.microsoft.com/store/detail/python-3-12/9NRWMJP3717K) or
[python.org](https://www.python.org/downloads/).
## Step 1: Install and Start SnackBase
### 1.1 Clone the Repository
```bash theme={null}
git clone https://github.com/lalitgehani/snackbase.git
cd SnackBase
```
### 1.2 Install Backend Dependencies
SnackBase uses `uv` for fast, reliable package management:
```bash theme={null}
uv sync
```
### 1.3 Install Frontend Dependencies
Navigate to the UI directory and install Node.js dependencies:
```bash theme={null}
cd ui
npm install
cd ..
```
### 1.4 Configure Environment
Create a `.env` file from the example template:
```bash theme={null}
cp .env.example .env
```
For local development, the default values work fine. However, for production, update at minimum:
* `SNACKBASE_SECRET_KEY` - Generate a secure random string
* `SNACKBASE_DATABASE_URL` - Switch to PostgreSQL
* `SNACKBASE_CORS_ORIGINS` - Add your frontend URL
### 1.5 Initialize the Database
```bash theme={null}
uv run python -m snackbase init-db
```
This command:
* Creates the database file at `./sb_data/snackbase.db`
* Runs Alembic migrations to set up all tables
* Creates the `system` account (ID: `SY0000`) for superadmin operations
### 1.6 Configure Email (Optional but Recommended)
SnackBase requires email configuration for user email verification. Choose one of these options:
**Option A: Use a Development SMTP Server (Recommended for Testing)**
```bash theme={null}
# Install MailHog for local email testing
# macOS
brew install mailhog
# Start MailHog
mailhog
```
Then update your `.env`:
```bash theme={null}
# For MailHog (localhost:1025)
EMAIL_PROVIDER=smtp
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@snackbase.local
```
**Option B: Use Resend (Recommended for Production)**
Sign up at [resend.com](https://resend.com) and get an API key:
```bash theme={null}
EMAIL_PROVIDER=resend
RESEND_API_KEY=re_xxxxxxxxxxxxx
EMAIL_FROM=noreply@yourdomain.com
```
**Option C: Use AWS SES**
```bash theme={null}
EMAIL_PROVIDER=aws_ses
AWS_SES_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_key
AWS_SECRET_ACCESS_KEY=your_secret
EMAIL_FROM=noreply@yourdomain.com
```
### 1.7 Create a Superadmin User
```bash theme={null}
uv run python -m snackbase create-superadmin
```
You'll be prompted to enter:
* Email address (e.g., `admin@example.com`)
* Password (minimum 8 characters, recommended: mix of letters, numbers, symbols)
* Password confirmation
The superadmin account will require email verification if email is configured.
You can verify the email via the API or auto-verify superadmin emails in
development mode.
### 1.8 Start the Backend Server
```bash theme={null}
uv run python -m snackbase serve
```
You should see output indicating the server is running:
```
INFO: Uvicorn running on http://0.0.0.0:8000
INFO: Application startup complete.
```
### 1.9 Start the Frontend (New Terminal)
Open a new terminal and start the React development server:
```bash theme={null}
cd ui
npm run dev
```
You should see:
```
VITE v7.x.x ready in xxx ms
➜ Local: http://localhost:5173/
```
### 1.10 Access the Admin UI
Open your browser and navigate to:
```
http://localhost:5173
```
## Step 2: Log In to the Admin UI
### 2.1 Enter Your Credentials
On the login page, enter:
* **Account**: `system` (or `SY0000`)
* **Email**: The superadmin email you created
* **Password**: The superadmin password you created
Click **Sign In**.
The superadmin account is always linked to the `system` account (ID:
`SY0000`). Use "system" as the account field when logging in.
### 2.2 Verify Your Email (If Required)
If email verification is enabled, you'll see a message asking you to verify your email.
**For Development with MailHog:**
1. Open `http://localhost:8025` in your browser
2. Find the verification email
3. Click the verification link
**To Skip Verification in Development:**
Use the admin API to verify your email:
```bash theme={null}
# Get your access token first, then:
curl -X POST http://localhost:8000/api/v1/admin/verify-email \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com"}'
```
### 2.3 Welcome to the Dashboard
After logging in (and verifying your email if required), you'll see the main dashboard with:
* Sidebar navigation on the left
* Statistics cards (collections, records, users, etc.)
* Recent activity or quick actions
## Step 3: Create Your First Collection
A **Collection** is like a table in a traditional database. It defines the structure of your data with fields and types.
### 3.1 Navigate to Collections
Click **Collections** in the sidebar.
### 3.2 Create a New Collection
Click the **+ New Collection** button.
A modal or form will appear. Enter:
* **Name**: `posts` (this will become the API endpoint: `/api/v1/records/posts`)
* **Description**: `Blog posts and articles` (optional)
Click **Create**.
### 3.3 Add Fields to Your Collection
After creating the collection, you'll see the collection detail page. Now let's add fields.
Click **+ Add Field** and add the following fields:
| Field Name | Type | Required | Options |
| -------------- | ------ | -------- | ----------------------------------------- |
| `title` | Text | Yes | - |
| `content` | Text | No | Multi-line: Yes |
| `status` | Select | Yes | Options: `draft`, `published`, `archived` |
| `published_at` | Date | No | - |
| `views` | Number | No | Default: `0` |
## Step 4: Add Your First Records
Now that your collection is set up, let's add some data.
### 4.1 Navigate to Records
Click **Records** in the sidebar, then select the **posts** collection.
### 4.2 Create a Record
Click **+ New Record**.
Fill in the form:
* **title**: `My First Blog Post`
* **content**: `This is my first post using SnackBase!`
* **status**: `published`
* **published\_at**: Select today's date
* **views**: Leave as `0` (default)
Click **Save**.
### 4.3 View Your Record
After saving, you'll see your record in the data table.
### 4.4 Add More Records
Create a few more records to have some test data:
1. "Getting Started with SnackBase" (status: `published`)
2. "Draft Post About API Design" (status: `draft`)
3. "Archived Announcement" (status: `archived`)
## Step 5: Set Up Roles and Permissions
SnackBase uses Role-Based Access Control (RBAC) to manage who can do what with your data.
### 5.1 Navigate to Roles
Click **Roles** in the sidebar.
### 5.2 Understand Default Roles
By default, you'll see:
* **admin** - Full access to all collections and operations
The superadmin user you created has the `admin` role.
### 5.3 Create a New Role
Click **+ New Role**.
Enter:
* **Name**: `editor`
* **Description**: `Can create and edit posts, but cannot delete`
Click **Create**.
### 5.4 Configure Permissions
On the role detail page for `editor`:
1. Click **+ Add Permission**
2. Configure:
* **Collection**: `posts`
* **Create**: Enabled
* **Read**: Enabled
* **Update**: Enabled
* **Delete**: Disabled
Click **Save**.
### 5.5 Create a Read-Only Role
Repeat the process to create a `viewer` role:
* **Name**: `viewer`
* **Collection**: `posts`
* **Read**: Enabled only
## Step 6: Create Additional Users
Now let's create users with different roles to test permissions.
### 6.1 Navigate to Users
Click **Users** in the sidebar.
### 6.2 Create an Editor User
Click **+ New User**.
Enter:
* **Email**: `editor@example.com`
* **Password**: `EditorPass123!`
* **Role**: `editor`
* **Active**: Yes
Click **Create**.
### 6.3 Create a Viewer User
Repeat to create a viewer user:
* **Email**: `viewer@example.com`
* **Password**: `ViewerPass123!`
* **Role**: `viewer`
## Step 7: Make Your First API Request
SnackBase automatically generates REST APIs for your collections. Let's test it!
### 7.1 Get Your Access Token
Open your browser's developer tools:
* Press `F12` or `Cmd+Option+I` (Mac)
* Go to the **Application** or **Storage** tab
* Find **Local Storage** → `http://localhost:5173`
* Copy the value of `access_token`
### 7.2 Test the API with curl
Open a new terminal and try fetching all posts:
```bash theme={null}
curl http://localhost:8000/api/v1/records/posts \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json"
```
Replace `YOUR_ACCESS_TOKEN` with the token you copied.
### 7.3 Create a Record via API
Create a new post using the API:
```bash theme={null}
curl -X POST http://localhost:8000/api/v1/records/posts \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Created via API",
"content": "This post was created using the REST API",
"status": "published",
"views": 0
}'
```
### 7.4 Explore the Interactive API Docs
SnackBase includes auto-generated API documentation powered by Swagger UI.
Open in your browser:
```
http://localhost:8000/docs
```
## Step 8: Test Permissions
Let's verify that the permission system works correctly.
### 8.1 Log In as Editor
Open an incognito/private window and navigate to:
```
http://localhost:5173
```
Log in as:
* **Account**: `system`
* **Email**: `editor@example.com`
* **Password**: `EditorPass123!`
### 8.2 Verify Edit Capabilities
Navigate to **Records** → **posts**.
You should see:
* Edit buttons on existing records
* No Delete buttons (editor role cannot delete)
### 8.3 Test Delete Restriction
Try to delete a record using the API with editor credentials:
```bash theme={null}
curl -X DELETE http://localhost:8000/api/v1/records/posts/RECORD_ID \
-H "Authorization: Bearer EDITOR_ACCESS_TOKEN"
```
You should receive a `403 Forbidden` error.
## Development Commands
### Backend Commands
```bash theme={null}
# Server management
uv run python -m snackbase serve # Start server (0.0.0.0:8000)
uv run python -m snackbase serve --reload # Dev mode with auto-reload
uv run python -m snackbase info # Show configuration
# Database
uv run python -m snackbase init-db # Initialize database (dev only)
uv run python -m snackbase create-superadmin # Create superadmin user
# Interactive shell
uv run python -m snackbase shell # IPython REPL with pre-loaded context
# Code quality
uv run ruff check . # Lint
uv run ruff format . # Format
uv run mypy src/ # Type check
# Testing
uv run pytest # Run all tests
uv run pytest tests/unit/ # Unit tests only
uv run pytest tests/integration/ # Integration tests only
uv run pytest --cov=snackbase # With coverage
uv run pytest -k "test_name" # Run specific test
```
### Frontend Commands
```bash theme={null}
cd ui
npm run dev # Start dev server (Vite)
npm run build # Production build
npm run lint # ESLint
npm run preview # Preview production build
```
## Troubleshooting
### Database Errors
**Problem**: `sqlite3.OperationalError: unable to open database file`
**Solution**: Ensure the `sb_data` directory exists and is writable:
```bash theme={null}
mkdir -p sb_data
chmod 755 sb_data
```
**Windows**: Create the folder manually in File Explorer if needed.
### Port Conflicts
**Problem**: `Error: listen tcp 0.0.0.0:8000: bind: address already in use`
**Solution**: Another process is using port 8000. Find and stop it:
**macOS/Linux**:
```bash theme={null}
# Find the process
lsof -ti:8000 | xargs kill -9
```
**Windows**:
```cmd theme={null}
# Find the process
netstat -ano | findstr :8000
# Kill the process (replace PID with the actual process ID)
taskkill /PID PID /F
```
Alternatively, change the port in `.env`:
```bash theme={null}
SNACKBASE_PORT=8001
```
### Email Verification Problems
**Problem**: Users can't log in due to unverified email
**Solution**: Manually verify the user via API:
```bash theme={null}
curl -X POST http://localhost:8000/api/v1/admin/verify-email \
-H "Authorization: Bearer YOUR_SUPERADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
```
**Problem**: Verification emails not being sent
**Solution**: Check your email configuration:
1. Verify email settings in `.env`
2. If using MailHog, ensure it's running (`mailhog` command)
3. Check server logs for email errors
## API Endpoints Reference
SnackBase provides 20+ API routers:
| Router | Endpoints | Description |
| ------------------------------ | --------------------------------- | -------------------------- |
| `/api/v1/auth` | Login, register, refresh, me | Authentication |
| `/api/v1/auth/oauth` | OAuth flow (Google, GitHub, etc.) | OAuth authentication |
| `/api/v1/auth/saml` | SAML SSO flow | SAML authentication |
| `/api/v1/accounts` | CRUD operations | Account management |
| `/api/v1/collections` | CRUD operations | Collection management |
| `/api/v1/records/{collection}` | CRUD operations | Dynamic collection records |
| `/api/v1/users` | CRUD operations | User management |
| `/api/v1/roles` | CRUD, permissions | Role management |
| `/api/v1/permissions` | CRUD operations | Permission management |
| `/api/v1/macros` | CRUD, execute | SQL macro management |
| `/api/v1/groups` | CRUD operations | Group management |
| `/api/v1/invitations` | Create, accept | User invitations |
| `/api/v1/dashboard` | Statistics | Dashboard data |
| `/api/v1/audit-logs` | List, export, filter | Audit log retrieval |
| `/api/v1/migrations` | Status, history | Alembic migration status |
| `/api/v1/files` | Upload, download, delete | File management |
| `/api/v1/admin` | System configuration | Admin controls |
| `/api/v1/admin/email` | Email templates, settings | Email management |
## Common Gotchas & Tips
### Gotcha 1: Account Context in API Requests
When making API requests, your user is always associated with an **account**. All data is isolated by `account_id`, even if you're using a single account setup.
**Solution**: Be aware that filtering by account happens automatically. You don't need to specify `account_id` in your queries.
### Gotcha 2: Collection Names Become URL Endpoints
The collection name you choose becomes part of the API URL:
* Collection `blog-posts` → `/api/v1/records/blog-posts`
* Collection `posts` → `/api/v1/records/posts`
**Solution**: Use simple, URL-friendly names with lowercase letters, numbers, and hyphens.
### Gotcha 3: Built-in Hooks Auto-Set Timestamps
Every record automatically gets `created_at` and `updated_at` timestamps. You don't need to add these as fields.
**Solution**: Don't create manual timestamp fields—they're built-in!
### Gotcha 4: Superadmin vs Admin
| Aspect | Superadmin | Admin (role) |
| ------- | ----------------- | -------------- |
| Account | `system` (SY0000) | Any account |
| Access | All accounts | Single account |
**Solution**: Use superadmin only for system-level operations. Use admin roles for day-to-day account management.
### Gotcha 5: Permission Caching
Permissions are cached for 5 minutes. If you change a role's permissions, it may take up to 5 minutes to take effect.
**Solution**: Wait 5 minutes or restart the server after permission changes for immediate effect.
## Production Considerations
Before deploying to production, review these important security and configuration items:
### Security Settings
```bash theme={null}
# Generate a secure secret key
python -c "import secrets; print(secrets.token_urlsafe(32))"
# Update .env with secure values
SNACKBASE_SECRET_KEY=your_generated_secret_key_here
```
### Database Setup
For production, switch from SQLite to PostgreSQL:
```bash theme={null}
# Install PostgreSQL
# macOS
brew install postgresql
# Ubuntu/Debian
sudo apt install postgresql postgresql-contrib
# Update .env
SNACKBASE_DATABASE_URL=postgresql+asyncpg://user:password@localhost/snackbase
```
### Environment Variables
Key production environment variables:
```bash theme={null}
# Required
SNACKBASE_SECRET_KEY=
SNACKBASE_DATABASE_URL=postgresql+asyncpg://...
# Recommended
SNACKBASE_ENVIRONMENT=production
SNACKBASE_DEBUG=false
SNACKBASE_CORS_ORIGINS=https://yourdomain.com
# Email Configuration
EMAIL_PROVIDER=smtp # or resend, aws_ses
SMTP_HOST=smtp.yourprovider.com
SMTP_PORT=587
SMTP_USER=your_email
SMTP_PASSWORD=your_password
SMTP_FROM=noreply@yourdomain.com
```
## Next Steps
Congratulations! You've completed the SnackBase Quick Start. Here's what to explore next:
* **[Deployment Guide](/deployment)** - Deploy SnackBase in development and production
* **[Authentication Model](/concepts/authentication)** - Deep dive into auth, multi-account users, and tokens
* **[Multi-Tenancy Model](/concepts/multi-tenancy)** - How accounts and data isolation work
* **[Hooks System](/hooks)** - Automate workflows with event-driven hooks
* **[API Examples](/api-examples)** - Practical API usage examples
# API Key Authentication
Source: https://docs.snackbase.dev/sdk/js/auth/api-keys
Use API keys for server-to-server authentication
API keys provide a secure way to authenticate server-to-server requests without requiring a user session. This guide explains how to use API keys with the SnackBase JavaScript SDK.
## What are API Keys?
API keys are long-lived credentials that allow your server to authenticate with SnackBase without requiring a user login flow. They're ideal for:
* Backend services and cron jobs
* Webhook endpoints
* Server-side data processing
* Automated scripts
API keys should only be used on the server side. Never expose API keys in
client-side code or public repositories.
## Setting Up API Keys
### Generate an API Key
1. Log in to your SnackBase admin panel
2. Navigate to **API Keys**
3. Click **+ New API Key**
4. Enter a description (e.g., "Production API")
5. Copy the generated key
API keys are only shown once at creation. Store them securely in your
environment variables.
## Using API Keys
### Basic Usage
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
apiKey: process.env.SNACKBASE_API_KEY,
});
// All requests will now use the API key
const posts = await client.records.list("posts");
```
### Environment Variables
Store your API key in environment variables:
```bash theme={null}
# .env
SNACKBASE_API_KEY=sbak_live_xxxxxxxxxxxxx
```
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: process.env.SNACKBASE_URL!,
apiKey: process.env.SNACKBASE_API_KEY!,
});
```
### Different Keys for Different Environments
Use different API keys for different environments:
```bash theme={null}
# .env.development
SNACKBASE_API_KEY=sbak_test_xxxxxxxxxxxxx
SNACKBASE_URL=http://localhost:8000
# .env.production
SNACKBASE_API_KEY=sbak_live_xxxxxxxxxxxxx
SNACKBASE_URL=https://api.example.com
```
## API Key vs User Authentication
| Feature | API Key | User Authentication |
| ------------ | ---------------------- | ------------------------ |
| Use Case | Server-to-server | End-user requests |
| Token Type | Long-lived | Short-lived (15 minutes) |
| Permissions | Full account access | Role-based |
| Rate Limits | Higher limits | Standard limits |
| Location | Server only | Client + Server |
| User Context | None (service account) | Specific user |
## Server-Side Operations
### Express.js Example
```ts theme={null}
import express from "express";
import { SnackBaseClient } from "@snackbase/sdk";
const app = express();
const client = new SnackBaseClient({
baseUrl: process.env.SNACKBASE_URL!,
apiKey: process.env.SNACKBASE_API_KEY!,
});
app.get("/posts", async (req, res) => {
try {
const posts = await client.records.list("posts", {
filter: { status: "published" },
limit: 10,
});
res.json(posts);
} catch (error) {
res.status(500).json({ error: "Failed to fetch posts" });
}
});
app.post("/posts", async (req, res) => {
try {
const post = await client.records.create("posts", req.body);
res.json(post);
} catch (error) {
res.status(500).json({ error: "Failed to create post" });
}
});
app.listen(3000);
```
### Next.js Example
```tsx theme={null}
// app/posts/page.tsx
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: process.env.SNACKBASE_URL!,
apiKey: process.env.SNACKBASE_API_KEY!,
});
export default async function PostsPage() {
const posts = await client.records.list("posts", {
filter: { status: "published" },
});
return (
Posts
{posts.items.map((post) => (
- {post.title}
))}
);
}
```
### Background Jobs
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: process.env.SNACKBASE_URL!,
apiKey: process.env.SNACKBASE_API_KEY!,
});
// Run daily cleanup job
async function cleanupOldRecords() {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const oldRecords = await client.records.list("logs", {
filter: { createdAt: `< ${thirtyDaysAgo.toISOString()}` },
});
for (const record of oldRecords.items) {
await client.records.delete("logs", record.id);
}
console.log(`Deleted ${oldRecords.total} old records`);
}
// Run with a scheduler like node-cron
```
## Webhook Handling
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: process.env.SNACKBASE_URL!,
apiKey: process.env.SNACKBASE_API_KEY!,
});
app.post("/webhooks/payment", async (req, res) => {
const { event, data } = req.body;
switch (event) {
case "payment.succeeded":
// Update order in SnackBase
await client.records.update("orders", data.orderId, {
status: "paid",
paidAt: new Date().toISOString(),
});
break;
case "payment.failed":
await client.records.update("orders", data.orderId, {
status: "payment_failed",
});
break;
}
res.sendStatus(200);
});
```
## Admin Operations
API keys have full access to account operations:
```ts theme={null}
// Create a new user
const user = await client.users.create({
email: "newuser@example.com",
password: "SecurePassword123!",
accountRole: "member",
});
// Assign a role
await client.roles.addUserToRole("roleId", "userId");
// Update collection rules
await client.collectionRules.update("collectionId", {
rules: [
{
name: "read-only",
permissions: ["read"],
expression: "user.role == 'viewer'",
},
],
});
```
## Error Handling
```ts theme={null}
import {
AuthenticationError,
AuthorizationError,
NetworkError,
} from "@snackbase/sdk";
try {
const posts = await client.records.list("posts");
} catch (error) {
if (error instanceof AuthenticationError) {
console.error("Invalid API key");
} else if (error instanceof AuthorizationError) {
console.error("API key lacks required permissions");
} else if (error instanceof NetworkError) {
console.error("Network error");
} else {
console.error("Unknown error:", error);
}
}
```
## Security Best Practices
### 1. Environment Variables
Never hardcode API keys:
```ts theme={null}
// Bad
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
apiKey: "sbak_live_xxxxxxxxxxxxx",
});
// Good
const client = new SnackBaseClient({
baseUrl: process.env.SNACKBASE_URL!,
apiKey: process.env.SNACKBASE_API_KEY!,
});
```
### 2. Use Different Keys for Different Services
Create separate API keys for different services:
```bash theme={null}
# .env
SNACKBASE_API_KEY_WEB=sbak_live_web_xxxxxxxxxxxxx
SNACKBASE_API_KEY_CRON=sbak_live_cron_xxxxxxxxxxxxx
SNACKBASE_API_KEY_WEBHOOKS=sbak_live_hooks_xxxxxxxxxxxxx
```
### 3. Rotate API Keys Regularly
Periodically rotate your API keys:
1. Create a new API key
2. Update your environment variables
3. Deploy the changes
4. Delete the old API key
### 4. Use Key Prefixes
API keys include prefixes to identify their type:
* `sbak_live_` - Production keys
* `sbak_test_` - Test/development keys
Verify the key type in production:
```ts theme={null}
const apiKey = process.env.SNACKBASE_API_KEY!;
if (!apiKey.startsWith("sbak_live_")) {
throw new Error("Production API key required");
}
```
### 5. Monitor Usage
Monitor your API key usage in the SnackBase admin panel to detect unauthorized access.
## Rate Limits
API keys have higher rate limits than user authentication:
| Authentication | Rate Limit |
| -------------- | -------------------- |
| User Token | 100 requests/minute |
| API Key | 1000 requests/minute |
Actual rate limits depend on your SnackBase plan. Contact support for
custom limits.
## Testing
Use test API keys during development:
```ts theme={null}
// Test environment
const client = new SnackBaseClient({
baseUrl: "https://test-api.example.com",
apiKey: process.env.SNACKBASE_TEST_API_KEY!,
});
```
## Next Steps
* **[Email/Password Auth](/sdk/js/auth/email-password)** - User authentication
* **[OAuth Guide](/sdk/js/auth/oauth)** - Social login
* **[Error Handling](/sdk/js/errors/overview)** - Handle errors gracefully
# Email and Password Authentication
Source: https://docs.snackbase.dev/sdk/js/auth/email-password
Implement traditional email and password authentication
Email and password authentication is the most common authentication method. This guide shows you how to implement it with the SnackBase JavaScript SDK.
## User Registration
Register a new user with email and password:
```ts theme={null}
const result = await client.auth.register({
email: "newuser@example.com",
password: "secure-password-123",
accountName: "My New Account",
});
console.log("User registered:", result.user.email);
console.log("Account created:", result.account.name);
```
The password must meet your SnackInstance's password requirements. The
default minimum is 8 characters.
### Registration Options
```ts theme={null}
interface RegisterData {
email: string;
password: string;
accountName: string;
accountSlug?: string; // Optional custom account slug
}
```
## User Login
Log in an existing user with email and password:
```ts theme={null}
const auth = await client.auth.loginWithPassword({
account: "my-account", // Account slug or ID
email: "user@example.com",
password: "user-password",
});
console.log("Logged in as:", auth.user.email);
console.log("Account:", auth.account.name);
```
### Login Options
```ts theme={null}
interface LoginCredentials {
account: string; // Account slug or ID
email: string;
password: string;
}
```
If you configured `defaultAccount` in the client, you can omit the account
parameter from login requests.
## Login with Default Account
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
defaultAccount: "my-account",
});
// No need to specify account
const auth = await client.auth.loginWithPassword({
email: "user@example.com",
password: "user-password",
});
```
## Get Current User
After authentication, get the current user's profile:
```ts theme={null}
const user = await client.auth.getCurrentUser();
console.log(user.id);
console.log(user.email);
console.log(user.fullName);
console.log(user.isActive);
console.log(user.isEmailVerified);
```
## Password Reset Flow
### 1. Request Password Reset
Send a password reset email to the user:
```ts theme={null}
await client.auth.forgotPassword({
email: "user@example.com",
});
console.log("Password reset email sent");
```
### 2. Reset Password with Token
Use the token from the reset email to set a new password:
```ts theme={null}
await client.auth.resetPassword({
token: "reset-token-from-email",
newPassword: "new-secure-password",
});
console.log("Password reset successfully");
```
## Email Verification
### Check Email Verification Status
```ts theme={null}
const user = await client.auth.getCurrentUser();
if (user.isEmailVerified) {
console.log("Email is verified");
} else {
console.log("Email is not verified");
}
```
### Send Verification Email
```ts theme={null}
await client.auth.sendVerificationEmail();
console.log("Verification email sent");
```
### Verify Email with Token
```ts theme={null}
await client.auth.verifyEmail("verification-token-from-email");
console.log("Email verified successfully");
```
### Resend Verification Email
```ts theme={null}
await client.auth.resendVerificationEmail();
console.log("Verification email resent");
```
## Complete Authentication Example
Here's a complete example showing the full authentication flow:
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
defaultAccount: "my-account",
});
// Register a new user
async function register() {
try {
const result = await client.auth.register({
email: "newuser@example.com",
password: "SecurePass123!",
accountName: "My Account",
});
console.log("Registration successful:", result.user.email);
// Send verification email
await client.auth.sendVerificationEmail();
console.log("Verification email sent");
} catch (error) {
console.error("Registration failed:", error);
}
}
// Login
async function login() {
try {
const auth = await client.auth.loginWithPassword({
email: "user@example.com",
password: "SecurePass123!",
});
console.log("Login successful:", auth.user.email);
// Get current user
const user = await client.auth.getCurrentUser();
console.log("Current user:", user);
} catch (error) {
console.error("Login failed:", error);
}
}
// Reset password
async function resetPassword() {
try {
// Step 1: Request reset
await client.auth.forgotPassword({
email: "user@example.com",
});
// Step 2: User receives email with token
// Step 3: Reset with token
const token = "token-from-email";
await client.auth.resetPassword({
token,
newPassword: "NewSecurePass456!",
});
console.log("Password reset successful");
} catch (error) {
console.error("Password reset failed:", error);
}
}
// Logout
async function logout() {
await client.auth.logout();
console.log("Logged out");
}
```
## React Integration
Using email/password authentication with React:
```tsx theme={null}
import { useAuth } from "@snackbase/sdk/react";
function LoginForm() {
const { login, logout, user, isLoading } = useAuth();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const email = (e.currentTarget.elements.namedItem("email") as HTMLInputElement).value;
const password = (e.currentTarget.elements.namedItem("password") as HTMLInputElement).value;
try {
await login({ email, password });
} catch (error) {
console.error("Login failed:", error);
}
};
if (isLoading) return Loading...
;
if (user) {
return (
Welcome, {user.email}!
);
}
return (
);
}
```
## Error Handling
Handle common authentication errors:
```ts theme={null}
import {
AuthenticationError,
ValidationError,
NetworkError,
} from "@snackbase/sdk";
try {
await client.auth.loginWithPassword({
email: "user@example.com",
password: "wrong-password",
});
} catch (error) {
if (error instanceof AuthenticationError) {
console.error("Invalid credentials");
} else if (error instanceof ValidationError) {
console.error("Validation error:", error.fields);
} else if (error instanceof NetworkError) {
console.error("Network error - please check your connection");
} else {
console.error("Unknown error:", error);
}
}
```
## Security Best Practices
### 1. Password Requirements
Ensure your SnackBase instance has secure password requirements:
* Minimum 8 characters
* Mix of letters, numbers, and symbols
* No common passwords
### 2. HTTPS Only
Always use HTTPS in production:
```ts theme={null}
// Good
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Bad - never use HTTP in production
const client = new SnackBaseClient({
baseUrl: "http://api.example.com",
});
```
### 3. Token Storage
Use appropriate storage for your use case:
```ts theme={null}
// For web apps - persists across sessions
const client = new SnackBaseClient({
storageBackend: "localStorage",
});
// For shared/public devices - cleared on tab close
const client = new SnackBaseClient({
storageBackend: "sessionStorage",
});
```
### 4. Auto-Logout on Token Expiry
Configure the SDK to handle token expiry:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
onAuthError: (error) => {
// Redirect to login on auth error
window.location.href = "/login";
},
});
```
## Next Steps
* **[OAuth Guide](/sdk/js/auth/oauth)** - Add social login options
* **[API Keys](/sdk/js/auth/api-keys)** - Use API keys for server operations
* **[Error Handling](/sdk/js/errors/overview)** - Handle authentication errors
# OAuth Authentication
Source: https://docs.snackbase.dev/sdk/js/auth/oauth
Implement OAuth social login with Google, GitHub, and more
OAuth authentication allows users to sign in with their existing social accounts. This guide shows you how to implement OAuth with the SnackBase JavaScript SDK.
## Supported OAuth Providers
SnackBase supports the following OAuth providers:
* Google
* GitHub
* Microsoft
* Apple
## OAuth Flow
### 1. Generate Authorization URL
Create the OAuth authorization URL for the provider:
```ts theme={null}
const url = client.auth.getOAuthUrl("google", "my-account");
console.log("Authorize URL:", url);
```
### 2. Redirect User to OAuth Provider
```ts theme={null}
// Redirect the user to the OAuth provider
window.location.href = url;
```
### 3. Handle OAuth Callback
After the user approves the authorization, they'll be redirected back to your application with a code in the URL. Exchange this code for an access token:
```ts theme={null}
// Get the code from the URL query parameters
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
// Exchange code for access token
const auth = await client.auth.handleOAuthCallback({
provider: "google",
code: code!,
});
console.log("Logged in as:", auth.user.email);
```
## Complete OAuth Implementation
### Frontend (Browser)
```tsx theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
defaultAccount: "my-account",
});
// Start OAuth flow
function loginWithGoogle() {
const url = client.auth.getOAuthUrl("google", "my-account");
window.location.href = url;
}
// Handle callback on redirect page
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
if (code) {
client.auth.handleOAuthCallback({
provider: "google",
code,
}).then((auth) => {
console.log("Logged in:", auth.user.email);
// Redirect to app
window.location.href = "/dashboard";
});
}
}, []);
```
### With React Router
```tsx theme={null}
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@snackbase/sdk/react";
function OAuthCallback() {
const [searchParams] = useSearchParams();
const { login } = useAuth();
const [error, setError] = useState(null);
useEffect(() => {
const code = searchParams.get("code");
const provider = searchParams.get("provider") || "google";
if (code) {
client.auth.handleOAuthCallback({ provider, code })
.then((auth) => {
// User is now logged in
window.location.href = "/dashboard";
})
.catch((err) => {
setError("Authentication failed");
console.error(err);
});
}
}, [searchParams]);
if (error) {
return Error: {error};
}
return Completing authentication...;
}
```
## OAuth Provider Configuration
### Google OAuth
```ts theme={null}
const url = client.auth.getOAuthUrl("google", "my-account", {
redirectUri: "https://myapp.com/oauth/callback",
});
```
### GitHub OAuth
```ts theme={null}
const url = client.auth.getOAuthUrl("github", "my-account", {
redirectUri: "https://myapp.com/oauth/callback",
});
```
### Microsoft OAuth
```ts theme={null}
const url = client.auth.getOAuthUrl("microsoft", "my-account", {
redirectUri: "https://myapp.com/oauth/callback",
});
```
### Apple OAuth
```ts theme={null}
const url = client.auth.getOAuthUrl("apple", "my-account", {
redirectUri: "https://myapp.com/oauth/callback",
});
```
Each provider must be configured in your SnackBase admin panel with the
appropriate client ID and secret.
## Custom Redirect URI
Specify a custom redirect URI:
```ts theme={null}
const url = client.auth.getOAuthUrl("google", "my-account", {
redirectUri: "https://myapp.com/custom-callback",
});
```
## State Parameter
Include a state parameter for security:
```ts theme={null}
const state = "random-state-string";
const url = client.auth.getOAuthUrl("google", "my-account", {
state,
redirectUri: "https://myapp.com/oauth/callback",
});
// Verify state on callback
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const returnedState = params.get("state");
if (code && returnedState === state) {
// Process callback
client.auth.handleOAuthCallback({ provider: "google", code });
}
}, []);
```
## Complete Example with Multiple Providers
```tsx theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
defaultAccount: "my-account",
});
function LoginButtons() {
const providers = [
{ name: "Google", id: "google" as const, icon: "🔵" },
{ name: "GitHub", id: "github" as const, icon: "🐙" },
{ name: "Microsoft", id: "microsoft" as const, icon: "🟦" },
{ name: "Apple", id: "apple" as const, icon: "🍎" },
];
const handleLogin = (provider: typeof providers[number]["id"]) => {
const url = client.auth.getOAuthUrl(provider, "my-account", {
redirectUri: `${window.location.origin}/oauth/callback`,
});
window.location.href = url;
};
return (
Sign in with
{providers.map((provider) => (
))}
);
}
function OAuthCallbackPage() {
const [searchParams] = useSearchParams();
useEffect(() => {
const code = searchParams.get("code");
const provider = searchParams.get("provider") as any;
if (code && provider) {
client.auth
.handleOAuthCallback({ provider, code })
.then((auth) => {
console.log("Logged in:", auth.user.email);
window.location.href = "/dashboard";
})
.catch(console.error);
}
}, [searchParams]);
return Completing sign in...;
}
```
## Server-Side OAuth
For Next.js and other server-side frameworks:
```tsx theme={null}
// app/login/route.ts
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: process.env.SNACKBASE_URL!,
});
export async function GET(request: Request) {
const url = client.auth.getOAuthUrl("google", "my-account", {
redirectUri: `${process.env.APP_URL}/api/oauth/callback`,
});
return Response.redirect(url);
}
// app/api/oauth/callback/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
if (code) {
const auth = await client.auth.handleOAuthCallback({
provider: "google",
code,
});
// Set session cookie or token
// Then redirect
}
return Response.redirect(new URL("/dashboard", request.url));
}
```
## Linking OAuth to Existing Accounts
Link an OAuth provider to an existing email/password account:
```ts theme={null}
// This would typically be done through the backend API
// The SDK doesn't currently expose this directly
```
## Error Handling
Handle common OAuth errors:
```ts theme={null}
try {
const auth = await client.auth.handleOAuthCallback({
provider: "google",
code: "invalid-code",
});
} catch (error) {
if (error instanceof AuthenticationError) {
console.error("OAuth failed:", error.message);
// Show error to user
} else if (error instanceof NetworkError) {
console.error("Network error during OAuth");
} else {
console.error("Unknown error:", error);
}
}
```
## OAuth vs Email/Password
Consider these factors when choosing between OAuth and email/password:
| Factor | OAuth | Email/Password |
| -------------- | ------------------------ | --------------- |
| Setup | Requires provider config | Simple |
| User Friction | Low (1-2 clicks) | Higher (typing) |
| Password Mgmt | None required | Required |
| Data Control | Limited by provider | Full control |
| Offline Access | No | Yes |
## Next Steps
* **[Email/Password Auth](/sdk/js/auth/email-password)** - Traditional authentication
* **[SAML Setup](/guides/saml-setup-generic)** - Enterprise SSO configuration
* **[API Keys](/sdk/js/auth/api-keys)** - Server authentication
# Authentication Overview
Source: https://docs.snackbase.dev/sdk/js/auth/overview
Understand authentication in the SnackBase JavaScript SDK
The SnackBase JavaScript SDK provides multiple authentication methods to secure your applications. This guide explains the authentication model and available options.
SDK v0.3.0 introduced token type detection, authentication method detection, and a new API key format. See [What's New in v0.3.0](/sdk/js/auth/v0.3.0-changes) for details.
## Authentication Methods
SnackBase supports several authentication methods:
| Method | Use Case | Location |
| -------------- | ----------------------------------- | --------------- |
| Email/Password | Traditional web applications | Client + Server |
| OAuth | Social login (Google, GitHub, etc.) | Client |
| SAML | Enterprise SSO | Client |
| API Keys | Server-to-server communication | Server Only |
## Authentication Flow
### 1. User Authentication
The user provides credentials (email/password, OAuth, or SAML):
```ts theme={null}
// Email and password
const auth = await client.auth.loginWithPassword({
account: "my-account",
email: "user@example.com",
password: "password",
});
// OAuth redirect
const url = client.auth.getOAuthUrl("google", "my-account");
window.location.href = url;
// SAML redirect
const url = await client.auth.getSAMLUrl("okta", "my-account");
window.location.href = url;
```
### 2. Token Storage
After successful authentication, the SDK receives:
* **Access Token**: Short-lived token for API requests (default: 15 minutes)
* **Refresh Token**: Long-lived token for obtaining new access tokens
Tokens are automatically stored based on your `storageBackend` configuration:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
storageBackend: "localStorage", // Tokens persist across sessions
});
```
### 3. Automatic Token Injection
The SDK automatically includes the access token in all API requests via the `Authorization` header:
```ts theme={null}
// This request automatically includes the auth header
const posts = await client.records.list("posts");
```
### 4. Automatic Token Refresh
When `enableAutoRefresh` is true (default), the SDK automatically refreshes the access token before it expires:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
enableAutoRefresh: true,
refreshBeforeExpiry: 300, // Refresh 5 minutes before expiry
});
```
## Auth State Management
The SDK manages authentication state internally:
```ts theme={null}
// Check if authenticated
if (client.isAuthenticated) {
console.log("Logged in as:", client.user?.email);
console.log("Account:", client.account?.name);
}
// Get the current token
const token = client.auth.token;
```
## Auth Events
Subscribe to authentication state changes:
```ts theme={null}
// Listen for login events
client.on("auth:login", (state) => {
console.log("User logged in:", state.user);
});
// Listen for logout events
client.on("auth:logout", () => {
console.log("User logged out");
// Redirect to login page
});
// Listen for token refresh
client.on("auth:refresh", (state) => {
console.log("Token refreshed");
});
// Listen for auth errors
client.on("auth:error", (error) => {
console.error("Auth error:", error);
});
```
## Multi-Account Users
SnackBase supports users that belong to multiple accounts. After authentication, you can switch accounts:
```ts theme={null}
// Get all accounts for the current user
const accounts = await client.accounts.list();
// Switch to a different account
await client.auth.switchAccount(accountId);
```
## Session Expiry
Access tokens have a limited lifetime. The SDK handles this automatically:
1. **Before expiry**: Token is refreshed automatically (if `enableAutoRefresh` is true)
2. **After expiry**: You'll receive a 401 error, and the SDK will attempt to refresh
3. **Refresh failure**: You'll need to re-authenticate
Check token expiry:
```ts theme={null}
// The SDK manages this internally
// If you need to check manually:
const authState = client.internalAuthManager.getState();
if (authState.expiresAt) {
const expiryDate = new Date(authState.expiresAt);
const now = new Date();
if (expiryDate <= now) {
console.log("Token has expired");
}
}
```
## Logout
Properly log out the current user:
```ts theme={null}
// Logout clears stored tokens and state
await client.auth.logout();
// The user is now logged out
console.log(client.isAuthenticated); // false
```
Logging out also disconnects any real-time subscriptions and clears the
authentication state from storage.
## Choosing an Authentication Method
### Email/Password
Use for traditional applications where users create accounts with email and password.
**Pros:**
* Full control over user data
* No external dependencies
* Works offline
**Cons:**
* Requires password management
* Users may forget passwords
**When to use:**
* B2B applications
* Internal tools
* Apps requiring full user control
### OAuth
Use for consumer applications where social login simplifies onboarding.
**Pros:**
* No password management
* Faster signup
* Trusted providers
**Cons:**
* Dependent on external providers
* Less control over user data
**When to use:**
* B2C applications
* Social apps
* Apps wanting low friction signup
### SAML
Use for enterprise customers requiring SSO.
**Pros:**
* Enterprise standard
* Centralized user management
* High security
**Cons:**
* Complex setup
* Requires enterprise identity provider
**When to use:**
* B2B enterprise apps
* Apps with security requirements
* Integrating with existing enterprise systems
### API Keys
Use for server-to-server communication.
**Pros:**
* No user session required
* Long-lived credentials
* Easy to revoke
**Cons:**
* Must be kept secret
* Cannot be used on the client side
**When to use:**
* Backend services
* Cron jobs
* Webhooks
## Next Steps
* **[Email/Password Auth](/sdk/js/auth/email-password)** - Implement traditional authentication
* **[OAuth Guide](/sdk/js/auth/oauth)** - Set up social login
* **[API Keys](/sdk/js/auth/api-keys)** - Use API keys for server operations
# New in v0.3.0
Source: https://docs.snackbase.dev/sdk/js/auth/v0.3.0-changes
Authentication enhancements in SDK v0.3.0
SDK v0.3.0 introduces several authentication enhancements including token type detection, authentication method detection, and a new API key format.
## What's New
### Token Type Detection
A new `TokenType` enum helps you identify the type of authentication token being used:
```typescript theme={null}
import { TokenType } from '@snackbase/sdk';
enum TokenType {
ACCESS = 'access',
REFRESH = 'refresh',
API_KEY = 'api_key',
}
```
### User Token Type Field
The `User` interface now includes a `token_type` field that indicates the current authentication method:
```typescript theme={null}
interface User {
id: string;
email: string;
// ... other fields
token_type?: TokenType; // NEW: Type of token being used
}
```
### Authentication Method Detection
New convenience methods on the `AuthService` make it easy to detect the current authentication method:
```typescript theme={null}
const auth = client.auth;
// Check if current session is a superadmin
if (auth.isSuperadmin()) {
console.log('Logged in as superadmin');
}
// Check if authenticated via API key
if (auth.isApiKeySession()) {
console.log('Using API key authentication');
}
// Check if authenticated via access token
if (auth.isAccessTokenSession()) {
console.log('Using access token authentication');
}
```
### New Error Types
Two new error types provide better error handling:
```typescript theme={null}
import { ApiKeyRestrictedError, EmailVerificationRequiredError } from '@snackbase/sdk';
try {
await client.records.create('posts', data);
} catch (error) {
if (error instanceof ApiKeyRestrictedError) {
// API key doesn't have permission for this operation
console.error('API key is restricted from this action');
} else if (error instanceof EmailVerificationRequiredError) {
// User must verify email before proceeding
console.error('Please verify your email first');
}
}
```
### API Key Format Change
API keys now use a three-part format for better security:
**Old Format (still supported)**:
```
sb_ak_base64payload
```
**New Format (v0.3.0+)**:
```
sb_ak.payload.signature
```
The new format provides:
* **Clearer structure** - Three distinct parts separated by dots
* **Embedded signature** - Cryptographic signature for verification
* **Better security** - Easier to validate and verify keys
Old API keys continue to work. The new format is used when creating new API keys in v0.3.0+.
## Migration Guide
If you're upgrading from an earlier version, here's what you need to know:
### No Breaking Changes
All existing code continues to work. The new features are additive and don't change existing behavior.
### Optional: Use New Detection Methods
You can optionally use the new authentication method detection in your code:
```typescript theme={null}
// Before (v0.2.x)
if (client.user && client.apiKey) {
// Has both user and API key
}
// After (v0.3.0) - More explicit
if (client.auth.isApiKeySession()) {
// Definitely using API key auth
} else if (client.isAuthenticated) {
// Using user authentication
}
```
### Handle New Error Types
Add handlers for the new error types where appropriate:
```typescript theme={null}
try {
await client.records.create('posts', data);
} catch (error) {
if (error instanceof ApiKeyRestrictedError) {
// Handle restricted API key
alert('Your API key does not have permission to create posts');
} else if (error instanceof EmailVerificationRequiredError) {
// Handle unverified email
await client.auth.sendVerificationEmail();
alert('Please check your email for a verification link');
} else {
// Handle other errors as before
throw error;
}
}
```
### Check Token Type When Needed
If you need to know the token type:
```typescript theme={null}
const user = client.user;
if (user) {
switch (user.token_type) {
case TokenType.ACCESS:
console.log('Authenticated with access token');
break;
case TokenType.REFRESH:
console.log('Authenticated with refresh token');
break;
case TokenType.API_KEY:
console.log('Authenticated with API key');
break;
}
}
```
## API Key Creation
When creating new API keys with the SDK v0.3.0+, they will use the new format:
```typescript theme={null}
const apiKey = await client.apiKeys.create({
name: 'Production API Key',
});
console.log(apiKey.key); // sb_ak.payload.signature (new format)
```
## Examples
### Conditional Logic Based on Auth Type
```typescript theme={null}
// Show different UI based on authentication method
if (client.auth.isSuperadmin()) {
// Show superadmin controls
renderSuperAdminPanel();
} else if (client.auth.isApiKeySession()) {
// API keys have limited permissions
renderApiKeyWarning();
renderLimitedControls();
} else {
// Regular user session
renderUserPanel();
}
```
### Email Verification Flow
```typescript theme={null}
import { EmailVerificationRequiredError } from '@snackbase/sdk';
async function createPost(data) {
try {
return await client.records.create('posts', data);
} catch (error) {
if (error instanceof EmailVerificationRequiredError) {
// Send verification email
await client.auth.sendVerificationEmail();
// Show message to user
showVerificationModal();
return null;
}
throw error;
}
}
```
### API Key Permission Check
```typescript theme={null}
import { ApiKeyRestrictedError } from '@snackbase/sdk';
async function adminOperation() {
try {
return await client.accounts.delete(accountId);
} catch (error) {
if (error instanceof ApiKeyRestrictedError) {
console.error('This operation requires a user session or admin API key');
return null;
}
throw error;
}
}
```
## Summary of Changes
| Feature | Type | Description |
| -------------------------------- | ------- | ----------------------------------------------- |
| `TokenType` enum | New | Enum for token types: ACCESS, REFRESH, API\_KEY |
| `User.token_type` field | New | Field indicating current authentication method |
| `isSuperadmin()` method | New | Check if superadmin session |
| `isApiKeySession()` method | New | Check if API key authentication |
| `isAccessTokenSession()` method | New | Check if access token authentication |
| `ApiKeyRestrictedError` | New | Error for restricted API key operations |
| `EmailVerificationRequiredError` | New | Error for unverified email requirement |
| API key format | Changed | New 3-part format: `sb_ak.payload.signature` |
## Next Steps
* **[Error Handling](/sdk/js/errors/overview)** - Learn about all error types
* **[Authentication Overview](/sdk/js/auth/overview)** - Complete authentication guide
# Configuration
Source: https://docs.snackbase.dev/sdk/js/configuration
Configure the SnackBase JavaScript SDK for your needs
The SnackBase JavaScript SDK provides many configuration options to customize its behavior for your application's needs.
## Configuration Options
### Required Options
#### `baseUrl`
The base URL of your SnackBase instance.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
});
```
### Authentication Options
#### `apiKey`
Optional API key for server-to-server authentication.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
apiKey: process.env.SNACKBASE_API_KEY,
});
```
API keys should only be used on the server side. Never expose API keys in
client-side code.
#### `defaultAccount`
Default account slug for single-tenant applications.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
defaultAccount: "my-account",
});
```
When set, users don't need to specify the account when logging in.
### Request Options
#### `timeout`
Request timeout in milliseconds. Default: `30000` (30 seconds).
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
timeout: 60000, // 60 seconds
});
```
#### `maxRetries`
Maximum number of retry attempts for failed requests. Default: `3`.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
maxRetries: 5,
});
```
#### `retryDelay`
Delay between retry attempts in milliseconds. Default: `1000`.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
retryDelay: 2000, // 2 seconds
});
```
### Storage Options
#### `storageBackend`
Storage backend for authentication tokens. Default: auto-detected based on platform.
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
storageBackend: "localStorage", // or "sessionStorage", "memory"
});
```
Available options:
| Backend | Platform | Description |
| ---------------- | ------------ | ------------------------------ |
| `localStorage` | Web | Persists across sessions |
| `sessionStorage` | Web | Cleared when tab closes |
| `memory` | Any | In-memory only |
| `asyncStorage` | React Native | Uses React Native AsyncStorage |
### Token Refresh Options
#### `enableAutoRefresh`
Enable automatic token refresh before expiry. Default: `true`.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
enableAutoRefresh: true,
});
```
#### `refreshBeforeExpiry`
Seconds before token expiry to refresh. Default: `300` (5 minutes).
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
refreshBeforeExpiry: 600, // 10 minutes
});
```
### Real-Time Options
#### `maxRealTimeRetries`
Maximum reconnection attempts for real-time connections. Default: `10`.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
maxRealTimeRetries: 20,
});
```
#### `realTimeReconnectionDelay`
Initial delay for real-time reconnection in milliseconds. Default: `1000`.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
realTimeReconnectionDelay: 2000,
});
```
### Logging Options
#### `enableLogging`
Enable request/response logging. Default: `false` in production.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
enableLogging: true,
});
```
#### `logLevel`
Logging level. Default: `'error'`.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
enableLogging: true,
logLevel: "debug", // "debug" | "info" | "warn" | "error"
});
```
### Error Callbacks
#### `onAuthError`
Callback for 401 authentication errors.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
onAuthError: (error) => {
console.error("Auth error:", error);
// Redirect to login page
window.location.href = "/login";
},
});
```
#### `onNetworkError`
Callback for network failures.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
onNetworkError: (error) => {
console.error("Network error:", error);
// Show offline message
},
});
```
#### `onRateLimitError`
Callback for 429 rate limit errors.
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
onRateLimitError: (error) => {
console.warn("Rate limited:", error.retryAfter);
// Show rate limit message
},
});
```
## Complete Configuration Example
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
// Required
baseUrl: "https://your-project.snackbase.dev",
// Authentication
apiKey: process.env.SNACKBASE_API_KEY,
defaultAccount: "my-account",
// Request settings
timeout: 60000,
maxRetries: 5,
retryDelay: 2000,
// Storage
storageBackend: "localStorage",
// Token refresh
enableAutoRefresh: true,
refreshBeforeExpiry: 600,
// Real-time
maxRealTimeRetries: 20,
realTimeReconnectionDelay: 2000,
// Logging
enableLogging: process.env.NODE_ENV === "development",
logLevel: "debug",
// Error callbacks
onAuthError: (error) => {
console.error("Auth error:", error);
},
onNetworkError: (error) => {
console.error("Network error:", error);
},
onRateLimitError: (error) => {
console.warn("Rate limited:", error.retryAfter);
},
});
```
## Accessing Configuration
You can access the current configuration at runtime:
```ts theme={null}
const config = client.getConfig();
console.log("Base URL:", config.baseUrl);
console.log("Timeout:", config.timeout);
console.log("Max retries:", config.maxRetries);
```
## Environment-Specific Configuration
### Development
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "http://localhost:8000",
enableLogging: true,
logLevel: "debug",
});
```
### Production
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.production.com",
enableLogging: false,
timeout: 30000,
maxRetries: 3,
});
```
### With React
```tsx theme={null}
import { SnackBaseProvider } from "@snackbase/sdk/react";
function App() {
return (
);
}
```
## Default Configuration
These are the default values used by the SDK:
```ts theme={null}
{
timeout: 30000,
enableAutoRefresh: true,
refreshBeforeExpiry: 300,
maxRetries: 3,
retryDelay: 1000,
storageBackend: auto-detected,
logLevel: "error",
enableLogging: false,
maxRealTimeRetries: 10,
realTimeReconnectionDelay: 1000,
}
```
# Error Handling Overview
Source: https://docs.snackbase.dev/sdk/js/errors/overview
Handle errors in the SnackBase JavaScript SDK
The SnackBase SDK provides a comprehensive error handling system with typed error classes for different failure scenarios.
## Overview
All SDK errors extend from the base `SnackBaseError` class, allowing you to catch errors generically or handle specific error types:
```ts theme={null}
import {
SnackBaseError,
AuthenticationError,
ValidationError,
NetworkError,
} from "@snackbase/sdk";
try {
const post = await client.records.create("posts", data);
} catch (error) {
if (error instanceof ValidationError) {
console.error("Validation failed:", error.fields);
} else if (error instanceof AuthenticationError) {
console.error("Authentication required");
} else if (error instanceof SnackBaseError) {
console.error("SDK error:", error.message);
} else {
console.error("Unknown error:", error);
}
}
```
## Error Hierarchy
```
SnackBaseError (base class)
├── AuthenticationError (401)
├── AuthorizationError (403)
├── NotFoundError (404)
├── ConflictError (409)
├── ValidationError (422)
├── RateLimitError (429)
├── NetworkError
├── TimeoutError
└── ServerError (500+)
```
## Error Properties
All errors include the following properties:
```ts theme={null}
interface SnackBaseError {
message: string; // Human-readable error message
code: string; // Error code (e.g., "AUTHENTICATION_ERROR")
status?: number; // HTTP status code
details?: any; // Additional error details
field?: string; // Field name (for validation errors)
retryable: boolean; // Whether the request can be retried
}
```
## Error Types
### AuthenticationError (401)
Thrown when authentication fails or tokens are invalid:
```ts theme={null}
try {
await client.auth.loginWithPassword({
email: "user@example.com",
password: "wrong-password",
});
} catch (error) {
if (error instanceof AuthenticationError) {
console.error("Invalid credentials");
// Redirect to login
}
}
```
### AuthorizationError (403)
Thrown when the user lacks permission for an action:
```ts theme={null}
try {
await client.records.delete("posts", "post-id");
} catch (error) {
if (error instanceof AuthorizationError) {
console.error("You don't have permission to delete this post");
// Show permission error to user
}
}
```
### NotFoundError (404)
Thrown when a resource is not found:
```ts theme={null}
try {
const post = await client.records.get("posts", "non-existent-id");
} catch (error) {
if (error instanceof NotFoundError) {
console.error("Post not found");
// Show 404 page
}
}
```
### ConflictError (409)
Thrown when a resource conflict occurs (e.g., duplicate):
```ts theme={null}
try {
await client.users.create({
email: "existing@example.com",
password: "password",
});
} catch (error) {
if (error instanceof ConflictError) {
console.error("User with this email already exists");
// Show error to user
}
}
```
### ValidationError (422)
Thrown when request validation fails:
```ts theme={null}
try {
await client.records.create("posts", {
title: "", // Required field is empty
});
} catch (error) {
if (error instanceof ValidationError) {
console.error("Validation failed:", error.fields);
// { title: ["This field is required"] }
// Show field-specific errors
}
}
```
### RateLimitError (429)
Thrown when rate limit is exceeded:
```ts theme={null}
try {
await client.records.list("posts");
} catch (error) {
if (error instanceof RateLimitError) {
console.error("Rate limited. Retry after:", error.retryAfter, "seconds");
// Show rate limit message
}
}
```
### NetworkError
Thrown when network request fails:
```ts theme={null}
try {
await client.records.list("posts");
} catch (error) {
if (error instanceof NetworkError) {
console.error("Network error - please check your connection");
// Show offline message
}
}
```
### TimeoutError
Thrown when request times out:
```ts theme={null}
try {
await client.records.list("posts");
} catch (error) {
if (error instanceof TimeoutError) {
console.error("Request timed out");
// Show timeout message
}
}
```
### ServerError (500+)
Thrown when server error occurs:
```ts theme={null}
try {
await client.records.list("posts");
} catch (error) {
if (error instanceof ServerError) {
console.error("Server error:", error.status);
// Show server error message
}
}
```
## Global Error Handlers
Configure global error callbacks:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
onAuthError: (error) => {
console.error("Auth error:", error);
// Redirect to login
},
onNetworkError: (error) => {
console.error("Network error:", error);
// Show offline banner
},
onRateLimitError: (error) => {
console.error("Rate limited:", error.retryAfter);
// Show rate limit message
},
});
```
## React Error Handling
```tsx theme={null}
function PostDetail({ postId }: { postId: string }) {
const { data: post, loading, error } = useRecord("posts", postId);
if (loading) return Loading...;
if (error) {
if (error instanceof NotFoundError) {
return Post not found;
}
if (error instanceof ValidationError) {
return Invalid request;
}
return Error loading post;
}
return ;
}
```
## Retry Logic
Check if error is retryable:
```ts theme={null}
async function fetchWithRetry(collection: string, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await client.records.list(collection);
} catch (error) {
if (error instanceof SnackBaseError && error.retryable && i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
```
## Custom Error Handler
Create a custom error handler:
```ts theme={null}
async function handleApiCall(
fn: () => Promise
): Promise {
try {
return await fn();
} catch (error) {
if (error instanceof ValidationError) {
console.error("Validation error:", error.fields);
showFieldErrors(error.fields);
} else if (error instanceof AuthenticationError) {
console.error("Authentication failed");
redirectToLogin();
} else if (error instanceof AuthorizationError) {
console.error("Permission denied");
showPermissionError();
} else if (error instanceof NotFoundError) {
console.error("Resource not found");
showNotFoundError();
} else if (error instanceof RateLimitError) {
console.error("Rate limited:", error.retryAfter);
showRateLimitError(error.retryAfter);
} else if (error instanceof NetworkError) {
console.error("Network error");
showNetworkError();
} else {
console.error("Unknown error:", error);
showGenericError();
}
return null;
}
}
// Usage
const post = await handleApiCall(() =>
client.records.get("posts", "post-id")
);
```
## Complete Example
```ts theme={null}
import {
SnackBaseError,
AuthenticationError,
ValidationError,
NetworkError,
} from "@snackbase/sdk";
async function createPost(data: PostCreate) {
try {
const post = await client.records.create("posts", data);
return { success: true, data: post };
} catch (error) {
if (error instanceof ValidationError) {
return {
success: false,
error: "Validation failed",
fields: error.fields,
};
}
if (error instanceof AuthenticationError) {
return {
success: false,
error: "Please log in to create posts",
};
}
if (error instanceof AuthorizationError) {
return {
success: false,
error: "You don't have permission to create posts",
};
}
if (error instanceof NetworkError) {
return {
success: false,
error: "Network error - please check your connection",
};
}
return {
success: false,
error: "An unexpected error occurred",
};
}
}
```
## Next Steps
* **[Error Types](/sdk/js/errors/types)** - Complete error type reference
* **[Authentication](/sdk/js/auth/overview)** - Authentication error handling
* **[Configuration](/sdk/js/configuration)** - Error callback configuration
# Error Types Reference
Source: https://docs.snackbase.dev/sdk/js/errors/types
Complete reference for all SDK error types
This is a complete reference for all error types in the SnackBase JavaScript SDK.
## Base Error: SnackBaseError
All SDK errors extend from the base `SnackBaseError` class.
### Properties
```ts theme={null}
class SnackBaseError extends Error {
public readonly code: string;
public readonly status?: number;
public readonly details?: any;
public readonly field?: string;
public readonly retryable: boolean;
constructor(
message: string,
code: string,
status?: number,
details?: any,
retryable: boolean = false,
field?: string
)
}
```
### Properties Reference
| Property | Type | Description |
| ----------- | --------------------- | ----------------------------------- |
| `message` | `string` | Human-readable error message |
| `name` | `string` | Error class name |
| `code` | `string` | Error code (e.g., "NETWORK\_ERROR") |
| `status` | `number \| undefined` | HTTP status code |
| `details` | `any \| undefined` | Additional error details |
| `field` | `string \| undefined` | Field name (validation) |
| `retryable` | `boolean` | Whether request can be retried |
## Error Classes
### AuthenticationError
**Status Code:** 401
**Code:** `AUTHENTICATION_ERROR`
**Retryable:** No
Thrown when authentication fails or tokens are invalid/expired.
```ts theme={null}
class AuthenticationError extends SnackBaseError {
constructor(message: string = "Authentication failed", details?: any)
}
```
**Common Causes:**
* Invalid credentials
* Expired access token
* Missing authorization header
* Invalid API key
**Example:**
```ts theme={null}
try {
await client.auth.loginWithPassword({
email: "user@example.com",
password: "wrong-password",
});
} catch (error) {
if (error instanceof AuthenticationError) {
console.error("Authentication failed");
}
}
```
***
### AuthorizationError
**Status Code:** 403
**Code:** `AUTHORIZATION_ERROR`
**Retryable:** No
Thrown when the authenticated user lacks permission for an action.
```ts theme={null}
class AuthorizationError extends SnackBaseError {
constructor(message: string = "Not authorized", details?: any)
}
```
**Common Causes:**
* Insufficient permissions
* Role-based access control violation
* Collection rule violation
**Example:**
```ts theme={null}
try {
await client.records.delete("posts", "post-id");
} catch (error) {
if (error instanceof AuthorizationError) {
console.error("Permission denied");
}
}
```
***
### NotFoundError
**Status Code:** 404
**Code:** `NOT_FOUND_ERROR`
**Retryable:** No
Thrown when a requested resource is not found.
```ts theme={null}
class NotFoundError extends SnackBaseError {
constructor(message: string = "Resource not found", details?: any)
}
```
**Common Causes:**
* Invalid record ID
* Non-existent collection
* Non-existent user/account
**Example:**
```ts theme={null}
try {
const post = await client.records.get("posts", "invalid-id");
} catch (error) {
if (error instanceof NotFoundError) {
console.error("Post not found");
}
}
```
***
### ConflictError
**Status Code:** 409
**Code:** `CONFLICT_ERROR`
**Retryable:** No
Thrown when a resource conflict occurs.
```ts theme={null}
class ConflictError extends SnackBaseError {
constructor(message: string = "Resource conflict", details?: any)
}
```
**Common Causes:**
* Duplicate unique field value
* Concurrent modification conflict
* Resource already exists
**Example:**
```ts theme={null}
try {
await client.users.create({
email: "existing@example.com",
password: "password",
});
} catch (error) {
if (error instanceof ConflictError) {
console.error("User already exists");
}
}
```
***
### ValidationError
**Status Code:** 422
**Code:** `VALIDATION_ERROR`
**Retryable:** No
Thrown when request validation fails.
```ts theme={null}
class ValidationError extends SnackBaseError {
public readonly fields?: Record;
constructor(message: string = "Validation failed", details?: any)
}
```
**Common Causes:**
* Missing required fields
* Invalid field values
* Type mismatch
* Constraint violation
**Example:**
```ts theme={null}
try {
await client.records.create("posts", {
title: "", // Required field
});
} catch (error) {
if (error instanceof ValidationError) {
console.error("Validation errors:", error.fields);
// { title: ["This field is required"] }
}
}
```
***
### RateLimitError
**Status Code:** 429
**Code:** `RATE_LIMIT_ERROR`
**Retryable:** Yes
Thrown when rate limit is exceeded.
```ts theme={null}
class RateLimitError extends SnackBaseError {
public readonly retryAfter?: number;
constructor(
message: string = "Rate limit exceeded",
details?: any,
retryAfter?: number
)
}
```
**Properties:**
* `retryAfter`: Seconds to wait before retrying
**Example:**
```ts theme={null}
try {
await client.records.list("posts");
} catch (error) {
if (error instanceof RateLimitError) {
console.error("Rate limited. Wait", error.retryAfter, "seconds");
}
}
```
***
### NetworkError
**Status Code:** N/A
**Code:** `NETWORK_ERROR`
**Retryable:** Yes
Thrown when network request fails.
```ts theme={null}
class NetworkError extends SnackBaseError {
constructor(message: string = "Network error", details?: any)
}
```
**Common Causes:**
* No internet connection
* DNS resolution failure
* Connection timeout
* CORS error
**Example:**
```ts theme={null}
try {
await client.records.list("posts");
} catch (error) {
if (error instanceof NetworkError) {
console.error("Network error - check connection");
}
}
```
***
### TimeoutError
**Status Code:** N/A
**Code:** `TIMEOUT_ERROR`
**Retryable:** Yes
Thrown when a request times out.
```ts theme={null}
class TimeoutError extends SnackBaseError {
constructor(message: string = "Request timed out", details?: any)
}
```
**Common Causes:**
* Server not responding
* Slow network
* Large request payload
**Example:**
```ts theme={null}
try {
await client.records.list("posts");
} catch (error) {
if (error instanceof TimeoutError) {
console.error("Request timed out");
}
}
```
***
### ServerError
**Status Code:** 500+
**Code:** `SERVER_ERROR`
**Retryable:** Yes
Thrown when a server error occurs.
```ts theme={null}
class ServerError extends SnackBaseError {
constructor(
message: string = "Internal server error",
status: number = 500,
details?: any
)
}
```
**Common Causes:**
* Server-side exception
* Database error
* Configuration error
**Example:**
```ts theme={null}
try {
await client.records.list("posts");
} catch (error) {
if (error instanceof ServerError) {
console.error("Server error:", error.status);
}
}
```
## Error Codes Reference
| Code | Status | Retryable | Error Class |
| ---------------------- | ------ | --------- | --------------------- |
| `AUTHENTICATION_ERROR` | 401 | No | `AuthenticationError` |
| `AUTHORIZATION_ERROR` | 403 | No | `AuthorizationError` |
| `NOT_FOUND_ERROR` | 404 | No | `NotFoundError` |
| `CONFLICT_ERROR` | 409 | No | `ConflictError` |
| `VALIDATION_ERROR` | 422 | No | `ValidationError` |
| `RATE_LIMIT_ERROR` | 429 | Yes | `RateLimitError` |
| `NETWORK_ERROR` | N/A | Yes | `NetworkError` |
| `TIMEOUT_ERROR` | N/A | Yes | `TimeoutError` |
| `SERVER_ERROR` | 500+ | Yes | `ServerError` |
## Type Guards
Create type guards for specific errors:
```ts theme={null}
function isAuthError(error: unknown): error is AuthenticationError {
return error instanceof AuthenticationError;
}
function isValidationError(error: unknown): error is ValidationError {
return error instanceof ValidationError;
}
function isRetryableError(error: unknown): error is SnackBaseError & { retryable: true } {
return error instanceof SnackBaseError && error.retryable;
}
// Usage
try {
await client.records.create("posts", data);
} catch (error) {
if (isAuthError(error)) {
// Handle auth error
} else if (isValidationError(error)) {
// Handle validation error
} else if (isRetryableError(error)) {
// Retry request
}
}
```
## Error Logging
Log errors with full context:
```ts theme={null}
function logError(error: unknown) {
if (error instanceof SnackBaseError) {
console.error({
type: error.name,
code: error.code,
message: error.message,
status: error.status,
details: error.details,
field: error.field,
retryable: error.retryable,
});
} else {
console.error("Unknown error:", error);
}
}
```
## Next Steps
* **[Error Overview](/sdk/js/errors/overview)** - Error handling guide
* **[Configuration](/sdk/js/configuration)** - Error callback configuration
* **[Authentication](/sdk/js/auth/overview)** - Authentication errors
# Installation
Source: https://docs.snackbase.dev/sdk/js/installation
Install and configure the SnackBase JavaScript SDK in your project
The SnackBase JavaScript SDK provides a type-safe, feature-complete interface for interacting with SnackBase from JavaScript and TypeScript applications.
## Prerequisites
Before installing the SDK, ensure you have:
* **Node.js 18+** or a compatible runtime
* **npm**, **yarn**, or **pnpm** for package management
* A SnackBase instance (local or hosted)
## Installation
### npm
```bash npm theme={null}
npm install @snackbase/sdk
```
### yarn
```bash yarn theme={null}
yarn add @snackbase/sdk
```
### pnpm
```bash pnpm theme={null}
pnpm add @snackbase/sdk
```
### React Integration
If you're using React, you'll also need React installed as a peer dependency:
```bash npm theme={null}
npm install @snackbase/sdk react
```
The React integration is exported from the same package (`@snackbase/sdk/react`)
and requires React 18 or higher.
## Platform Support
The SDK works across multiple JavaScript environments:
| Platform | Support Level | Notes |
| ---------------- | ------------- | ----------------------------- |
| Modern Browsers | Full | Chrome, Firefox, Safari, Edge |
| React Native | Full | Uses AsyncStorage for tokens |
| Node.js 18+ | Full | Server-side rendering, APIs |
| Next.js | Full | Both App and Pages routers |
| Vue/Nuxt | Full | Via the core SDK |
| Svelte/SvelteKit | Full | Via the core SDK |
## TypeScript Support
The SDK is written in TypeScript and includes full type definitions. No additional `@types` packages are needed.
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
// Full autocomplete and type safety
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
});
```
## Bundle Size
The SDK is optimized for production use:
* **Core SDK**: 14.87 KB (gzipped)
* **React Integration**: 1.42 KB (gzipped)
* **Total**: 16.29 KB (gzipped)
## Verification
After installation, verify the SDK is working:
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
});
console.log("SnackBase SDK version:", client.getConfig().version);
```
## Next Steps
* **[Quick Start Guide](/sdk/js/quickstart)** - Get started with your first query
* **[Configuration](/sdk/js/configuration)** - Explore all configuration options
* **[Authentication](/sdk/js/auth/overview)** - Learn about authentication methods
# Filtering Queries
Source: https://docs.snackbase.dev/sdk/js/query/filtering
Filter records with complex conditions
The Query Builder's `filter()` method allows you to add conditions to your queries for precise data retrieval.
## Overview
Filtering allows you to retrieve only the records that match specific conditions:
```ts theme={null}
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.get();
```
## Filter Operators
| Operator | Description | Example |
| -------- | ---------------------- | ------------------------------------ |
| `=` | Equals | `filter("status", "=", "published")` |
| `!=` | Not equals | `filter("status", "!=", "draft")` |
| `>` | Greater than | `filter("views", ">", 100)` |
| `>=` | Greater than or equal | `filter("views", ">=", 100)` |
| `<` | Less than | `filter("views", "<", 1000)` |
| `<=` | Less than or equal | `filter("views", "<=", 1000)` |
| `~` | Contains (text search) | `filter("title", "~", "tutorial")` |
| `!~` | Does not contain | `filter("title", "!~", "draft")` |
| `?=` | Is empty/null | `filter("excerpt", "?=")` |
| `?!` | Is not empty/null | `filter("excerpt", "?!=")` |
## String Filtering
```ts theme={null}
// Exact match
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.get();
// Contains text
const results = await client.records
.query("posts")
.filter("title", "~", "tutorial")
.get();
// Does not contain
const results = await client.records
.query("posts")
.filter("title", "!~", "draft")
.get();
```
## Number Filtering
```ts theme={null}
// Greater than
const popularPosts = await client.records
.query("posts")
.filter("views", ">", 1000)
.get();
// Range
const recentPopularPosts = await client.records
.query("posts")
.filter("views", ">=", 100)
.filter("views", "<=", 10000)
.get();
```
## Date Filtering
```ts theme={null}
// After a date
const recentPosts = await client.records
.query("posts")
.filter("createdAt", ">", "2024-01-01")
.get();
// Before a date
const oldPosts = await client.records
.query("posts")
.filter("createdAt", "<", "2023-01-01")
.get();
// Date range
const postsFrom2024 = await client.records
.query("posts")
.filter("createdAt", ">=", "2024-01-01")
.filter("createdAt", "<", "2025-01-01")
.get();
```
## Boolean Filtering
```ts theme={null}
// Is true
const activePosts = await client.records
.query("posts")
.filter("isPublished", "=", true)
.get();
// Is false/null
const draftPosts = await client.records
.query("posts")
.filter("isPublished", "?=")
.get();
```
## Multiple Filters
Multiple filters are combined with AND logic:
```ts theme={null}
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.filter("views", ">", 100)
.filter("createdAt", ">=", "2024-01-01")
.get();
// SQL equivalent:
// WHERE status = 'published' AND views > 100 AND createdAt >= '2024-01-01'
```
## Raw Filter Strings
For complex conditions, use raw filter strings:
```ts theme={null}
const results = await client.records
.query("posts")
.filter("(status = 'published' || status = 'featured') && views > 100")
.get();
```
### Raw Filter Operators
| Operator | Description | Example |
| -------- | ----------- | ---------------------------------------------------------------- |
| `&&` | AND | `status = 'published' && views > 100` |
| `\|\|` | OR | `status = 'published' \|\| status = 'featured'` |
| `()` | Grouping | `(status = 'published' \|\| status = 'featured') && views > 100` |
## Complex Examples
### OR Condition
```ts theme={null}
const results = await client.records
.query("posts")
.filter("(status = 'published' || status = 'featured') && views > 100")
.get();
```
### Nested Conditions
```ts theme={null}
const results = await client.records
.query("posts")
.filter("((status = 'published' || status = 'featured') && views > 100) || (author.id = 'user-id' && status = 'draft')")
.get();
```
### Select Fields in Filter
```ts theme={null}
const results = await client.records
.query("posts")
.filter("author.role", "=", "editor")
.get();
```
## Filter on Relations
Filter on expanded relations:
```ts theme={null}
const results = await client.records
.query("posts")
.expand("author")
.filter("author.role", "=", "editor")
.get();
```
## Case Sensitivity
Text filters are case-insensitive by default:
```ts theme={null}
// Matches "Tutorial", "TUTORIAL", "tutorial", etc.
const results = await client.records
.query("posts")
.filter("title", "~", "tutorial")
.get();
```
## Null Handling
Check for null/empty values:
```ts theme={null}
// Is null
const postsWithoutExcerpt = await client.records
.query("posts")
.filter("excerpt", "?=")
.get();
// Is not null
const postsWithExcerpt = await client.records
.query("posts")
.filter("excerpt", "?!")
.get();
```
## Filter Best Practices
### 1. Use Specific Filters
```ts theme={null}
// Good - specific filter
const posts = await client.records
.query("posts")
.filter("status", "=", "published")
.filter("authorId", "=", user.id)
.get();
// Avoid - fetch all and filter in code
const allPosts = await client.records.list("posts");
const userPosts = allPosts.filter(p => p.authorId === user.id);
```
### 2. Combine with Indexes
Filter on indexed fields when possible for better performance:
```ts theme={null}
// Assuming 'slug' and 'status' are indexed
const post = await client.records
.query("posts")
.filter("slug", "=", "my-post")
.filter("status", "=", "published")
.first();
```
### 3. Use Pagination with Filters
Always paginate filtered queries:
```ts theme={null}
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.page(1, 20)
.get();
```
## Complete Example
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
async function searchPosts() {
// Find published posts from 2024 with "tutorial" in title
const results = await client.records
.query("posts")
.expand("author")
.filter("status", "=", "published")
.filter("createdAt", ">=", "2024-01-01")
.filter("createdAt", "<", "2025-01-01")
.filter("title", "~", "tutorial")
.sort("createdAt", "desc")
.page(1, 20)
.get();
console.log(`Found ${results.total} posts`);
return results.items;
}
```
## Next Steps
* **[Sorting](/sdk/js/query/sorting)** - Sort query results
* **[Pagination](/sdk/js/query/pagination)** - Paginate large result sets
* **[Query Overview](/sdk/js/query/overview)** - Query builder basics
# Query Builder Overview
Source: https://docs.snackbase.dev/sdk/js/query/overview
Build complex queries with the fluent query builder API
The Query Builder provides a fluent API for building complex queries with filtering, sorting, pagination, and field selection.
## Overview
Instead of passing query parameters as an object, use the Query Builder for a more readable and maintainable way to construct queries:
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Using Query Builder
const results = await client.records
.query("posts")
.select("id", "title", "author.name")
.expand("author", "comments")
.filter("status", "=", "published")
.filter("createdAt", ">", "2024-01-01")
.sort("createdAt", "desc")
.page(1, 20)
.get();
// vs using parameters
const results2 = await client.records.list("posts", {
fields: ["id", "title", "author.name"],
expand: ["author", "comments"],
filter: {
status: "published",
createdAt: "> 2024-01-01",
},
sort: "-createdAt",
skip: 0,
limit: 20,
});
```
## Getting Started
### Create a Query Builder
Start a query by calling `query()` on the records service:
```ts theme={null}
const query = client.records.query("posts");
```
### Execute the Query
Execute the query with `get()`:
```ts theme={null}
const results = await query.get();
console.log(results.items);
console.log(results.total);
```
## Chaining Methods
The Query Builder uses method chaining for a fluent API:
```ts theme={null}
const results = await client.records
.query("posts")
.select("id", "title")
.filter("status", "=", "published")
.sort("createdAt", "desc")
.page(1, 20)
.get();
```
Each method returns the Query Builder instance, allowing you to chain multiple methods together.
## Complete Example
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
async function queryPosts() {
// Build and execute query
const results = await client.records
.query("posts")
.select("id", "title", "author", "createdAt")
.expand("author")
.filter("status", "=", "published")
.filter("views", ">=", 100)
.sort("createdAt", "desc")
.page(1, 20)
.get();
console.log(`Found ${results.total} posts`);
results.items.forEach((post) => {
console.log(post.title);
});
}
```
## First() Method
Get the first matching record:
```ts theme={null}
const post = await client.records
.query("posts")
.filter("slug", "=", "my-post")
.first();
if (post) {
console.log("Found post:", post.title);
} else {
console.log("No post found");
}
```
`first()` automatically sets `limit(1)` and `skip(0)` and returns a single
record or `null`.
## Reusable Queries
Create reusable query functions:
```ts theme={null}
function getPublishedPosts(client: SnackBaseClient) {
return client.records
.query("posts")
.filter("status", "=", "published")
.sort("createdAt", "desc");
}
// Use the reusable query
const recentPosts = await getPublishedPosts(client).page(1, 10).get();
const oldPosts = await getPublishedPosts(client).sort("createdAt", "asc").get();
```
## Query Builder Methods
| Method | Description |
| ---------- | --------------------------------------- |
| `select()` | Specify fields to return |
| `expand()` | Expand related records |
| `filter()` | Add filter conditions |
| `sort()` | Add sorting |
| `page()` | Set page number and size |
| `limit()` | Set max records (manual pagination) |
| `skip()` | Set records to skip (manual pagination) |
| `get()` | Execute query and return list |
| `first()` | Execute query and return first record |
## Next Steps
* **[Filtering](/sdk/js/query/filtering)** - Advanced filtering techniques
* **[Sorting](/sdk/js/query/sorting)** - Sort query results
* **[Pagination](/sdk/js/query/pagination)** - Paginate large result sets
# Paginating Queries
Source: https://docs.snackbase.dev/sdk/js/query/pagination
Paginate large result sets efficiently
Pagination allows you to retrieve large result sets in manageable chunks, improving performance and user experience.
## Overview
Use pagination to retrieve records in pages:
```ts theme={null}
const page1 = await client.records
.query("posts")
.page(1, 20)
.get();
console.log(page1.items); // 20 records
console.log(page1.total); // Total count
console.log(page1.skip); // 0
console.log(page1.limit); // 20
```
## Page-Based Pagination
Use `page()` for page-based pagination:
```ts theme={null}
const results = await client.records
.query("posts")
.page(1, 20) // Page 1, 20 items per page
.get();
```
### Page Parameters
* **page number**: 1-based index (first page is 1)
* **per page**: Number of items per page
```ts theme={null}
// Page 1
const page1 = await client.records
.query("posts")
.page(1, 20)
.get();
// Page 2
const page2 = await client.records
.query("posts")
.page(2, 20)
.get();
// Page 3
const page3 = await client.records
.query("posts")
.page(3, 20)
.get();
```
## Manual Pagination
Use `limit()` and `skip()` for manual offset pagination:
```ts theme={null}
const results = await client.records
.query("posts")
.skip(0)
.limit(20)
.get();
```
### Manual Pagination Parameters
* **skip**: Number of records to skip (offset)
* **limit**: Maximum number of records to return
```ts theme={null}
// First 20 records
const page1 = await client.records
.query("posts")
.skip(0)
.limit(20)
.get();
// Next 20 records
const page2 = await client.records
.query("posts")
.skip(20)
.limit(20)
.get();
// Next 20 records
const page3 = await client.records
.query("posts")
.skip(40)
.limit(20)
.get();
```
## Pagination Response
The pagination response includes metadata:
```ts theme={null}
interface RecordListResponse {
items: T[]; // Array of records
total: number; // Total count of matching records
skip: number; // Current offset
limit: number; // Current page size
}
```
## Calculating Total Pages
Calculate the total number of pages:
```ts theme={null}
const results = await client.records
.query("posts")
.page(1, 20)
.get();
const totalPages = Math.ceil(results.total / results.limit);
console.log(`Page 1 of ${totalPages}`);
```
## Building a Paginator
Create a reusable pagination helper:
```ts theme={null}
async function getPaginatedResults(
collection: string,
page: number,
perPage: number = 20
) {
const results = await client.records
.query(collection)
.page(page, perPage)
.get();
const totalPages = Math.ceil(results.total / perPage);
return {
items: results.items,
total: results.total,
page,
perPage,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
};
}
// Usage
const page = await getPaginatedResults("posts", 2, 20);
console.log(`Page ${page.page} of ${page.totalPages}`);
console.log("Has next:", page.hasNext);
console.log("Has previous:", page.hasPrev);
```
## Combining with Filtering and Sorting
Combine pagination with filtering and sorting:
```ts theme={null}
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.sort("createdAt", "desc")
.page(1, 20)
.get();
```
The `total` count reflects all matching records, not just the current page.
## Infinite Scroll
Implement infinite scroll pagination:
```ts theme={null}
let offset = 0;
const limit = 20;
let hasMore = true;
while (hasMore) {
const results = await client.records
.query("posts")
.skip(offset)
.limit(limit)
.get();
// Process results
results.items.forEach(post => {
console.log(post.title);
});
// Check if there are more results
hasMore = results.items.length === limit;
offset += limit;
}
```
## React Example
Create a paginated list component:
```tsx theme={null}
import { useState, useEffect } from "react";
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
function PaginatedList() {
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState(false);
const perPage = 20;
useEffect(() => {
async function loadPosts() {
setLoading(true);
const results = await client.records
.query("posts")
.page(page, perPage)
.get();
setPosts(results.items);
setTotalPages(Math.ceil(results.total / perPage));
setLoading(false);
}
loadPosts();
}, [page]);
return (
Posts (Page {page} of {totalPages})
{loading ? (
Loading...
) : (
{posts.map(post => (
- {post.title}
))}
)}
);
}
```
## Cursor-Based Pagination
For large or frequently-changing datasets, cursor-based pagination provides consistent results without skipping or duplicating records.
### Forward Pagination
```ts theme={null}
async function getPaginatedResults(cursor: string | null = null) {
const params: any = { limit: 20 };
if (cursor) {
params.cursor = cursor;
}
const results = await client.records.list("posts", params);
return {
items: results.items,
nextCursor: results.next_cursor, // Cursor for next page
prevCursor: results.prev_cursor, // Cursor for previous page
hasMore: results.has_more, // Whether there are more results
};
}
```
### Backward Pagination
Use `cursor_before` to paginate backwards:
```ts theme={null}
const previousPage = await client.records.list("posts", {
limit: 20,
cursor_before: currentCursor,
});
```
### When to Use Cursor vs Offset
| Approach | Best For |
| --------------------- | -------------------------------------------------------------------- |
| **Offset** (`page()`) | Small datasets, known total count, jump to specific page |
| **Cursor** | Large datasets, real-time data, infinite scroll, consistent ordering |
To get the total count with cursor pagination, pass `include_count: true` in your query parameters.
## Performance Best Practices
### 1. Use Appropriate Page Sizes
Choose page sizes based on your use case:
```ts theme={null}
// Mobile - smaller pages
const mobilePage = await client.records
.query("posts")
.page(1, 10)
.get();
// Desktop - larger pages
const desktopPage = await client.records
.query("posts")
.page(1, 50)
.get();
```
### 2. Filter Before Pagination
Reduce the result set before paginating:
```ts theme={null}
// Good - filter first
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.page(1, 20)
.get();
// Avoid - fetch all then paginate in code
const allPosts = await client.records.list("posts");
const page = allPosts.slice(0, 20);
```
### 3. Cache Pagination Metadata
Store total count and page metadata:
```ts theme={null}
const cache = new Map();
async function getPage(page: number) {
const cacheKey = `posts:page:${page}`;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const results = await client.records
.query("posts")
.page(page, 20)
.get();
cache.set(cacheKey, results);
return results;
}
```
## Complete Example
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
async function browsePosts() {
const perPage = 20;
let currentPage = 1;
// Get first page
const firstPage = await client.records
.query("posts")
.filter("status", "=", "published")
.sort("createdAt", "desc")
.page(currentPage, perPage)
.get();
const totalPages = Math.ceil(firstPage.total / perPage);
console.log(`Showing ${firstPage.items.length} of ${firstPage.total} posts`);
console.log(`Page ${currentPage} of ${totalPages}`);
// Get next page
if (currentPage < totalPages) {
const nextPage = await client.records
.query("posts")
.filter("status", "=", "published")
.sort("createdAt", "desc")
.page(currentPage + 1, perPage)
.get();
console.log("Next page:", nextPage.items);
}
}
```
## Reference
### `page(pageNum, perPage?)`
Set page number and size.
**Parameters:**
* `pageNum` (number) - Page number (1-based)
* `perPage` (number) - Items per page (default: 30)
**Returns:** `QueryBuilder`
### `limit(count)`
Set maximum records (manual pagination).
**Parameters:**
* `count` (number) - Maximum records to return
**Returns:** `QueryBuilder`
### `skip(count)`
Set records to skip (manual pagination).
**Parameters:**
* `count` (number) - Number of records to skip
**Returns:** `QueryBuilder`
## Next Steps
* **[Filtering](/sdk/js/query/filtering)** - Filter query results
* **[Sorting](/sdk/js/query/sorting)** - Sort query results
* **[Query Overview](/sdk/js/query/overview)** - Query builder basics
# Sorting Queries
Source: https://docs.snackbase.dev/sdk/js/query/sorting
Sort query results by one or more fields
The Query Builder's `sort()` method allows you to control the order of results in your queries.
## Overview
Sort your query results by one or more fields:
```ts theme={null}
// Sort by creation date (newest first)
const results = await client.records
.query("posts")
.sort("createdAt", "desc")
.get();
```
## Sort Direction
| Direction | Description | Example |
| --------- | --------------------- | ---------------------------- |
| `asc` | Ascending (A-Z, 0-9) | `.sort("title", "asc")` |
| `desc` | Descending (Z-A, 9-0) | `.sort("createdAt", "desc")` |
## Single Field Sorting
Sort by a single field:
```ts theme={null}
// Ascending order
const results = await client.records
.query("posts")
.sort("title", "asc")
.get();
// Descending order
const results = await client.records
.query("posts")
.sort("createdAt", "desc")
.get();
```
## Multiple Field Sorting
Sort by multiple fields by chaining `sort()` calls:
```ts theme={null}
const results = await client.records
.query("posts")
.sort("status", "asc")
.sort("createdAt", "desc")
.get();
// SQL equivalent:
// ORDER BY status ASC, createdAt DESC
```
The sort order is determined by the order you call `sort()`. The first
call has the highest priority.
## String Sorting
Sort text fields alphabetically:
```ts theme={null}
// A to Z
const results = await client.records
.query("posts")
.sort("title", "asc")
.get();
// Z to A
const results = await client.records
.query("posts")
.sort("title", "desc")
.get();
```
## Number Sorting
Sort numeric fields:
```ts theme={null}
// Lowest to highest
const results = await client.records
.query("posts")
.sort("views", "asc")
.get();
// Highest to lowest (most popular first)
const results = await client.records
.query("posts")
.sort("views", "desc")
.get();
```
## Date Sorting
Sort by date fields:
```ts theme={null}
// Oldest first
const results = await client.records
.query("posts")
.sort("createdAt", "asc")
.get();
// Newest first
const results = await client.records
.query("posts")
.sort("createdAt", "desc")
.get();
```
## Sorting with Filtering
Combine sorting with filtering:
```ts theme={null}
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.sort("createdAt", "desc")
.get();
```
## Sorting Relations
Sort on expanded relations:
```ts theme={null}
const results = await client.records
.query("posts")
.expand("author")
.sort("author.name", "asc")
.sort("createdAt", "desc")
.get();
```
## Common Patterns
### Latest Items First
```ts theme={null}
const latestPosts = await client.records
.query("posts")
.sort("createdAt", "desc")
.first();
```
### Most Popular
```ts theme={null}
const popularPosts = await client.records
.query("posts")
.sort("views", "desc")
.page(1, 10)
.get();
```
### Alphabetical
```ts theme={null}
const alphabeticalPosts = await client.records
.query("posts")
.sort("title", "asc")
.get();
```
### Priority Sorting
Sort by priority, then by date:
```ts theme={null}
const tasks = await client.records
.query("tasks")
.sort("priority", "desc")
.sort("createdAt", "asc")
.get();
// Results are sorted by priority first,
// then by creation date within each priority level
```
## Null Handling
Fields with null values are sorted last:
```ts theme={null}
// Posts with publishedAt come first (newest to oldest)
// Posts with null publishedAt come last
const results = await client.records
.query("posts")
.sort("publishedAt", "desc")
.get();
```
## Complete Example
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
async function getSortedPosts() {
// Get published posts, sorted by most viewed, then by date
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.sort("views", "desc")
.sort("createdAt", "desc")
.page(1, 20)
.get();
console.log(`Found ${results.total} posts`);
results.items.forEach((post) => {
console.log(`${post.views} views - ${post.title}`);
});
}
```
## Performance Considerations
### 1. Sort on Indexed Fields
For better performance, sort on indexed fields:
```ts theme={null}
// Good - createdAt is typically indexed
const results = await client.records
.query("posts")
.sort("createdAt", "desc")
.get();
// May be slower - title may not be indexed
const results = await client.records
.query("posts")
.sort("title", "asc")
.get();
```
### 2. Limit Results with Pagination
Always paginate sorted queries:
```ts theme={null}
const results = await client.records
.query("posts")
.sort("views", "desc")
.page(1, 20)
.get();
```
### 3. Combine Filtering and Sorting
Filter before sorting to reduce the result set:
```ts theme={null}
// Good - filter reduces data before sorting
const results = await client.records
.query("posts")
.filter("status", "=", "published")
.sort("views", "desc")
.get();
// Less efficient - sorts all data
const allPosts = await client.records
.query("posts")
.sort("views", "desc")
.get();
const publishedPosts = allPosts.items.filter(p => p.status === "published");
```
## Reference
### `sort(field, direction?)`
Add sorting to the query.
**Parameters:**
* `field` (string) - Field name to sort by
* `direction` (string) - Sort direction: `"asc"` or `"desc"` (default: `"asc"`)
**Returns:** `QueryBuilder` - The query builder for chaining
## Next Steps
* **[Pagination](/sdk/js/query/pagination)** - Paginate large result sets
* **[Filtering](/sdk/js/query/filtering)** - Filter query results
* **[Query Overview](/sdk/js/query/overview)** - Query builder basics
# Quick Start
Source: https://docs.snackbase.dev/sdk/js/quickstart
Get up and running with the SnackBase JavaScript SDK in minutes
Get started with the SnackBase JavaScript SDK in this 5-minute guide. You'll learn how to initialize the client, authenticate users, and perform CRUD operations on your collections.
## Prerequisites
* Complete the [Installation](/sdk/js/installation) guide
* Have a SnackBase instance running with an existing collection
* Have your API URL ready (e.g., `https://your-project.snackbase.dev`)
## Step 1: Initialize the Client
Create a new SnackBase client instance with your configuration:
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
// Optional: Add an API key for server-side operations
apiKey: process.env.SNACKBASE_API_KEY,
});
```
For client-side applications, omit the `apiKey` parameter. Users will
authenticate with their own credentials.
## Step 2: Authenticate a User
### Email and Password
```ts theme={null}
// Log in with email and password
const auth = await client.auth.loginWithPassword({
account: "your-account-slug",
email: "user@example.com",
password: "secure-password",
});
console.log("Logged in as:", auth.user.email);
console.log("Account:", auth.account.name);
```
### Register a New User
```ts theme={null}
// Register a new user and account
const result = await client.auth.register({
email: "newuser@example.com",
password: "secure-password",
accountName: "My Account",
});
console.log("User registered:", result.user.email);
```
## Step 3: Query Records
### List Records
```ts theme={null}
// Get all records from a collection
const posts = await client.records.list("posts");
console.log("Total:", posts.total);
console.log("Items:", posts.items);
// With filtering and sorting
const publishedPosts = await client.records.list("posts", {
filter: { status: "published" },
sort: "-createdAt",
limit: 10,
});
```
### Get a Single Record
```ts theme={null}
// Get a specific record by ID
const post = await client.records.get("posts", "record-id");
console.log(post.title);
console.log(post.content);
```
### Using the Query Builder
For complex queries, use the fluent query builder:
```ts theme={null}
const results = await client.records
.query("posts")
.select("id", "title", "author.name")
.filter("status", "=", "published")
.filter("createdAt", ">", "2024-01-01")
.sort("createdAt", "desc")
.page(1, 20)
.get();
console.log(results.items);
```
## Step 4: Create Records
```ts theme={null}
// Create a new record
const newPost = await client.records.create("posts", {
title: "My First Post",
content: "This is my first post using SnackBase!",
status: "published",
views: 0,
});
console.log("Created post with ID:", newPost.id);
```
## Step 5: Update Records
### Full Update (PUT)
Replaces all fields of the record:
```ts theme={null}
const updated = await client.records.update("posts", "record-id", {
title: "Updated Title",
content: "Updated content",
status: "published",
views: 10,
});
```
### Partial Update (PATCH)
Updates only the specified fields:
```ts theme={null}
const patched = await client.records.patch("posts", "record-id", {
views: 15,
});
```
Use `patch()` for partial updates to avoid overwriting fields you don't intend
to change.
## Step 6: Delete Records
```ts theme={null}
await client.records.delete("posts", "record-id");
console.log("Post deleted");
```
## Step 7: Real-Time Subscriptions
Subscribe to real-time updates for a collection:
```ts theme={null}
// Connect to the real-time service
await client.realtime.connect();
// Subscribe to collection events
await client.realtime.subscribe("posts", ["create", "update", "delete"]);
// Listen for events
client.realtime.on("posts.create", (data) => {
console.log("New post created:", data);
});
client.realtime.on("posts.update", (data) => {
console.log("Post updated:", data);
});
// Unsubscribe when done
const unsubscribe = client.realtime.on("posts.delete", (data) => {
console.log("Post deleted:", data);
});
// Call unsubscribe() to stop listening
unsubscribe();
```
## Complete Example
Here's a complete example combining all concepts:
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
async function main() {
// Initialize
const client = new SnackBaseClient({
baseUrl: "https://your-project.snackbase.dev",
});
// Authenticate
const auth = await client.auth.loginWithPassword({
account: "my-account",
email: "user@example.com",
password: "password",
});
console.log("Authenticated as:", auth.user.email);
// Query with the builder
const posts = await client.records
.query("posts")
.filter("status", "=", "published")
.sort("createdAt", "desc")
.page(1, 10)
.get();
console.log(`Found ${posts.total} published posts`);
// Create a new post
const newPost = await client.records.create("posts", {
title: "Hello, SnackBase!",
content: "My first post",
status: "published",
});
console.log("Created:", newPost.id);
// Update the post
const updated = await client.records.patch("posts", newPost.id, {
views: 1,
});
console.log("Updated:", updated.views);
// Cleanup
await client.records.delete("posts", newPost.id);
console.log("Deleted");
}
main().catch(console.error);
```
## Next Steps
* **[Query Builder](/sdk/js/query/overview)** - Learn advanced querying
* **[Realtime](/sdk/js/realtime/overview)** - Build real-time features
* **[React Integration](/sdk/js/react/setup)** - Use with React applications
* **[Error Handling](/sdk/js/errors/overview)** - Handle errors gracefully
Check out the [Feature Voting
App](https://github.com/lalitgehani/snackbase/tree/main/examples/feature-voting-app)
for a complete example demonstrating authentication, CRUD operations,
real-time subscriptions, and React integration with the SDK.
# React Setup
Source: https://docs.snackbase.dev/sdk/js/react/setup
Set up SnackBase SDK with React
The SnackBase SDK provides React hooks and context providers for seamless integration with React applications.
## Installation
Install the SDK and React peer dependency:
```bash npm theme={null}
npm install @snackbase/sdk react
```
```bash yarn theme={null}
yarn add @snackbase/sdk react
```
```bash pnpm theme={null}
pnpm add @snackbase/sdk react
```
React 18 or higher is required for the React integration.
## Setup
### 1. Wrap with Provider
Wrap your application with `SnackBaseProvider`:
```tsx theme={null}
import { SnackBaseProvider } from "@snackbase/sdk/react";
function App() {
return (
);
}
```
### 2. Use Hooks
Use the provided hooks in your components:
```tsx theme={null}
import { useAuth, useRecord } from "@snackbase/sdk/react";
function Profile() {
const { user, login, logout } = useAuth();
const { data: profile, loading } = useRecord("profiles", user?.id);
if (!user) {
return ;
}
return (
Welcome, {user.email}
{loading ? Loading...
: {JSON.stringify(profile, null, 2)}}
);
}
```
## Provider Props
| Prop | Type | Required | Description |
| ---------------- | ---------------- | -------- | --------------------------------- |
| `baseUrl` | `string` | Yes | API base URL |
| `apiKey` | `string` | No | API key for server authentication |
| `defaultAccount` | `string` | No | Default account slug |
| `timeout` | `number` | No | Request timeout (ms) |
| `maxRetries` | `number` | No | Max retry attempts |
| `storageBackend` | `StorageBackend` | No | Token storage backend |
| `enableLogging` | `boolean` | No | Enable request logging |
| `logLevel` | `LogLevel` | No | Logging level |
## Environment-Specific Setup
### Development
```tsx theme={null}
```
### Production
```tsx theme={null}
```
## With Next.js
### App Router
```tsx theme={null}
// app/layout.tsx
import { SnackBaseProvider } from "@snackbase/sdk/react";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
```
### Pages Router
```tsx theme={null}
// pages/_app.tsx
import type { AppProps } from "next/app";
import { SnackBaseProvider } from "@snackbase/sdk/react";
export default function App({ Component, pageProps }: AppProps) {
return (
);
}
```
## With Vite
```tsx theme={null}
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { SnackBaseProvider } from "@snackbase/sdk/react";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
);
```
## Access Client Directly
For advanced use cases, access the client directly:
```tsx theme={null}
import { useSnackBase } from "@snackbase/sdk/react";
function AdvancedComponent() {
const client = useSnackBase();
useEffect(() => {
// Use client directly
client.collections.list().then((collections) => {
console.log("Collections:", collections);
});
}, [client]);
return Check console for collections;
}
```
## Server-Side Rendering
For SSR, skip provider on server:
```tsx theme={null}
import { SnackBaseProvider } from "@snackbase/sdk/react";
function App({ children }: { children: React.ReactNode }) {
// Only provide on client
if (typeof window === "undefined") {
return <>{children}>;
}
return (
{children}
);
}
```
## TypeScript Support
The React integration is fully typed:
```tsx theme={null}
import { useAuth, useRecord } from "@snackbase/sdk/react";
import type { User, Post } from "@snackbase/sdk";
function Profile() {
const { user } = useAuth();
const { data: post } = useRecord("posts", "post-id");
// user and post are fully typed
console.log(user?.email);
console.log(post?.title);
}
```
## Complete Example
```tsx theme={null}
import { SnackBaseProvider, useAuth, useRecord } from "@snackbase/sdk/react";
function App() {
return (
);
}
function Main() {
const { user, login, logout, isLoading } = useAuth();
if (isLoading) {
return Loading...;
}
if (!user) {
return ;
}
return (
Welcome, {user.email}
);
}
function LoginForm({ onLogin }: { onLogin: (credentials: any) => Promise }) {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
const password = (form.elements.namedItem("password") as HTMLInputElement).value;
await onLogin({ email, password });
};
return (
);
}
function Dashboard() {
const { user } = useAuth();
const { data: profile, loading, error } = useRecord("profiles", user?.id || "");
if (loading) return Loading profile...;
if (error) return Error loading profile;
return (
Profile
{JSON.stringify(profile, null, 2)}
);
}
```
## Next Steps
* **[useAuth Hook](/sdk/js/react/use-auth)** - Authentication hook
* **[useRecord Hook](/sdk/js/react/use-record)** - Single record fetching
* **[useQuery Hook](/sdk/js/react/use-query)** - Query building
* **[useMutation Hook](/sdk/js/react/use-mutation)** - Mutations
* **[useSubscription Hook](/sdk/js/react/use-subscription)** - Real-time subscriptions
# useAuth Hook
Source: https://docs.snackbase.dev/sdk/js/react/use-auth
Handle authentication in React components
The `useAuth` hook provides authentication state and methods for React components.
## Import
```ts theme={null}
import { useAuth } from "@snackbase/sdk/react";
```
## Usage
```tsx theme={null}
function Profile() {
const { user, account, login, logout, register, isLoading } = useAuth();
if (isLoading) {
return Loading...;
}
if (!user) {
return ;
}
return (
Welcome, {user.email}
Account: {account?.name}
);
}
```
## Return Value
```ts theme={null}
interface UseAuthResult extends AuthState {
user: User | null;
account: Account | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
login: (credentials: LoginCredentials) => Promise;
logout: () => Promise;
register: (data: RegisterData) => Promise;
forgotPassword: (data: PasswordResetRequest) => Promise;
resetPassword: (data: PasswordResetConfirm) => Promise;
isLoading: boolean;
}
```
## Authentication State
### User Information
```tsx theme={null}
function UserInfo() {
const { user } = useAuth();
return (
Email: {user?.email}
Full Name: {user?.fullName}
Verified: {user?.isEmailVerified ? "Yes" : "No"}
);
}
```
### Account Information
```tsx theme={null}
function AccountInfo() {
const { account } = useAuth();
return (
Account: {account?.name}
Slug: {account?.slug}
);
}
```
### Authentication Status
```tsx theme={null}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return Loading...;
}
if (!isAuthenticated) {
return ;
}
return <>{children}>;
}
```
## Authentication Methods
### Login
```tsx theme={null}
function LoginForm() {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login({ email, password });
// Redirect or show success
} catch (err) {
setError("Login failed");
}
};
return (
);
}
```
### Register
```tsx theme={null}
function RegisterForm() {
const { register } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [accountName, setAccountName] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await register({ email, password, accountName });
// Redirect or show success
} catch (err) {
// Handle error
}
};
return (
);
}
```
### Logout
```tsx theme={null}
function LogoutButton() {
const { logout } = useAuth();
return (
);
}
```
### Password Reset
```tsx theme={null}
function ForgotPasswordForm() {
const { forgotPassword } = useAuth();
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await forgotPassword({ email });
setMessage("Check your email for reset instructions");
} catch (err) {
setMessage("Failed to send reset email");
}
};
return (
);
}
```
## Loading State
```tsx theme={null}
function ProtectedContent() {
const { user, isLoading } = useAuth();
if (isLoading) {
return Loading...;
}
if (!user) {
return Please log in;
}
return Welcome, {user.email}!;
}
```
## Authentication Events
Listen to authentication events:
```tsx theme={null}
function AuthListener() {
const { isAuthenticated } = useAuth();
useEffect(() => {
if (isAuthenticated) {
console.log("User logged in");
// Initialize user-specific resources
} else {
console.log("User logged out");
// Cleanup user-specific resources
}
}, [isAuthenticated]);
return null;
}
```
## OAuth Integration
```tsx theme={null}
function OAuthButtons() {
const client = useSnackBase();
const loginWithGoogle = () => {
const url = client.auth.getOAuthUrl("google", "my-account");
window.location.href = url;
};
return (
);
}
```
## Complete Example
```tsx theme={null}
import { useAuth } from "@snackbase/sdk/react";
function AuthenticatedApp() {
const { user, account, login, logout, register, isLoading, isAuthenticated } = useAuth();
if (isLoading) {
return Loading...;
}
if (!isAuthenticated) {
return (
Welcome to SnackBase
);
}
return (
{account?.name}
Signed in as {user?.email}
);
}
function LoginForm({ onLogin }: { onLogin: (creds: any) => Promise }) {
const [credentials, setCredentials] = useState({ email: "", password: "" });
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await onLogin(credentials);
} catch (err) {
setError("Invalid credentials");
}
};
return (
);
}
function RegisterForm({ onRegister }: { onRegister: (data: any) => Promise }) {
const [data, setData] = useState({
email: "",
password: "",
accountName: "",
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await onRegister(data);
} catch (err) {
console.error("Registration failed", err);
}
};
return (
);
}
```
## TypeScript
```tsx theme={null}
import type { User } from "@snackbase/sdk";
function Profile() {
const { user } = useAuth();
return (
Email: {user?.email}
Name: {user?.fullName}
);
}
```
## Next Steps
* **[React Setup](/sdk/js/react/setup)** - Set up React integration
* **[useRecord Hook](/sdk/js/react/use-record)** - Fetch single records
* **[useQuery Hook](/sdk/js/react/use-query)** - Build queries
* **[useSubscription Hook](/sdk/js/react/use-subscription)** - Real-time subscriptions
# useMutation Hook
Source: https://docs.snackbase.dev/sdk/js/react/use-mutation
Perform mutations on records in React components
The `useMutation` hook provides a simple way to perform create, update, and delete operations on records in React components.
## Import
```ts theme={null}
import { useMutation } from "@snackbase/sdk/react";
```
## Usage
```tsx theme={null}
function CreatePostForm() {
const { mutate: createPost, isLoading, error } = useMutation("posts");
const handleSubmit = async (data: PostCreate) => {
const result = await createPost(data);
console.log("Created:", result);
};
return (
);
}
```
## Parameters
```ts theme={null}
useMutation(collection: string)
```
| Parameter | Type | Required | Description |
| ------------ | -------- | -------- | --------------- |
| `collection` | `string` | Yes | Collection name |
## Return Value
```ts theme={null}
interface UseMutationResult {
mutate: (
idOrData: string | Partial,
data?: Partial
) => Promise;
isLoading: boolean;
error: Error | null;
reset: () => void;
}
```
| Property | Type | Description |
| ----------- | --------------------------------- | ------------------------------- |
| `mutate` | `(idOrData, data?) => Promise` | Function to perform mutation |
| `isLoading` | `boolean` | Whether mutation is in progress |
| `error` | `Error \| null` | Any error that occurred |
| `reset` | `() => void` | Reset error state |
## Operations
### Create
```tsx theme={null}
function CreatePostForm() {
const { mutate: createPost, isLoading, error } = useMutation("posts");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget;
const title = (form.elements.namedItem("title") as HTMLInputElement).value;
const content = (form.elements.namedItem("content") as HTMLTextAreaElement).value;
try {
const result = await createPost({ title, content });
console.log("Created post:", result);
// Redirect or show success
} catch (err) {
console.error("Failed to create post:", err);
}
};
return (
);
}
```
### Update (Full)
```tsx theme={null}
function EditPostForm({ post }: { post: Post }) {
const { mutate: updatePost, isLoading, error, reset } = useMutation("posts");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget;
const title = (form.elements.namedItem("title") as HTMLInputElement).value;
const content = (form.elements.namedItem("content") as HTMLTextAreaElement).value;
try {
const result = await updatePost(post.id, { title, content });
console.log("Updated post:", result);
} catch (err) {
console.error("Failed to update post:", err);
}
};
return (
);
}
```
### Patch (Partial Update)
For partial updates, use the same `mutate` function:
```tsx theme={null}
function IncrementViews({ postId }: { postId: string }) {
const { mutate: patchPost } = useMutation("posts");
const handleClick = async () => {
// This performs a partial update (PATCH)
await patchPost(postId, { views: 1 });
};
return ;
}
```
The SDK automatically uses PATCH when only some fields are provided.
### Delete
```tsx theme={null}
function DeletePostButton({ postId }: { postId: string }) {
const { mutate: deletePost, isLoading, error } = useMutation("posts");
const handleDelete = async () => {
if (!confirm("Are you sure?")) return;
try {
await deletePost(postId);
console.log("Post deleted");
// Navigate away or show success
} catch (err) {
console.error("Failed to delete post:", err);
}
};
return (
);
}
```
## Loading States
```tsx theme={null}
function CreatePostForm() {
const { mutate: createPost, isLoading } = useMutation("posts");
const handleSubmit = async (data: PostCreate) => {
await createPost(data);
};
return (
);
}
```
## Error Handling
```tsx theme={null}
function CreatePostForm() {
const { mutate: createPost, error, reset } = useMutation("posts");
const handleSubmit = async (data: PostCreate) => {
reset(); // Clear previous errors
try {
await createPost(data);
} catch (err) {
// Error is set automatically
}
};
return (
);
}
```
## Optimistic Updates
```tsx theme={null}
function LikeButton({ post }: { post: Post }) {
const { mutate: patchPost } = useMutation("posts");
const [localLikes, setLocalLikes] = useState(post.likes);
const handleLike = async () => {
// Optimistic update
const newLikes = localLikes + 1;
setLocalLikes(newLikes);
try {
await patchPost(post.id, { likes: newLikes });
} catch {
// Revert on error
setLocalLikes(localLikes);
}
};
return (
);
}
```
## Combined with Queries
```tsx theme={null}
function PostList() {
const { data, refetch: refetchList } = useQuery("posts", (q) => q.page(1, 20));
const { mutate: deletePost, isLoading } = useMutation("posts");
const handleDelete = async (postId: string) => {
await deletePost(postId);
refetchList(); // Refresh the list after deletion
};
return (
{data?.items.map((post) => (
-
{post.title}
))}
);
}
```
## TypeScript
```tsx theme={null}
import type { Post, PostCreate } from "@snackbase/sdk";
function CreatePostForm() {
const { mutate: createPost } = useMutation("posts");
const handleSubmit = async (data: PostCreate) => {
const result = await createPost(data);
// result is fully typed as Post & BaseRecord
console.log(result.id, result.title);
};
return (
);
}
```
## Complete Example
```tsx theme={null}
import { useMutation } from "@snackbase/sdk/react";
import type { Post } from "@snackbase/sdk";
import { useState } from "react";
function PostEditor({ post, onSave, onCancel }: {
post?: Post;
onSave: (post: Post) => void;
onCancel: () => void;
}) {
const { mutate: savePost, isLoading, error, reset } = useMutation("posts");
const [title, setTitle] = useState(post?.title || "");
const [content, setContent] = useState(post?.content || "");
const [status, setStatus] = useState(post?.status || "draft");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
reset();
try {
let result: Post;
if (post) {
// Update existing post
result = await savePost(post.id, { title, content, status });
} else {
// Create new post
result = await savePost({ title, content, status });
}
onSave(result);
} catch {
// Error is set automatically
}
};
return (
{post ? "Edit Post" : "New Post"}
{error && (
Error
{error.message}
)}
);
}
```
## Next Steps
* **[React Setup](/sdk/js/react/setup)** - Set up React integration
* **[useAuth Hook](/sdk/js/react/use-auth)** - Authentication
* **[useQuery Hook](/sdk/js/react/use-query)** - Query building
* **[useSubscription Hook](/sdk/js/react/use-subscription)** - Real-time subscriptions
# useQuery Hook
Source: https://docs.snackbase.dev/sdk/js/react/use-query
Build queries with the query builder in React
The `useQuery` hook provides a React interface for the Query Builder, allowing you to construct complex queries with filtering, sorting, and pagination.
## Import
```ts theme={null}
import { useQuery } from "@snackbase/sdk/react";
```
## Usage
```tsx theme={null}
function PostList() {
const { data, loading, error, refetch } = useQuery("posts", (query) =>
query
.filter("status", "=", "published")
.sort("createdAt", "desc")
.page(1, 20)
);
if (loading) return Loading...;
if (error) return Error: {error.message};
return (
{data?.items.map((post) => (
- {post.title}
))}
);
}
```
## Parameters
```ts theme={null}
useQuery(
collection: string,
builder: (query: QueryBuilder) => QueryBuilder,
options?: UseQueryOptions
)
```
| Parameter | Type | Required | Description |
| ------------ | ------------------ | -------- | ---------------------- |
| `collection` | `string` | Yes | Collection name |
| `builder` | `(query) => query` | Yes | Query builder function |
| `options` | `UseQueryOptions` | No | Additional options |
## Builder Function
The builder function receives a `QueryBuilder` and returns it after chaining methods:
```tsx theme={null}
const { data } = useQuery("posts", (query) =>
query
.select("id", "title", "author")
.filter("status", "=", "published")
.sort("createdAt", "desc")
.page(1, 20)
);
```
## Return Value
```ts theme={null}
interface UseQueryResult {
data: RecordListResponse | null;
loading: boolean;
error: Error | null;
refetch: () => Promise;
}
```
| Property | Type | Description |
| --------- | ---------------------------- | ----------------------------- |
| `data` | `RecordListResponse \| null` | Query results or null |
| `loading` | `boolean` | Whether data is loading |
| `error` | `Error \| null` | Any error that occurred |
| `refetch` | `() => Promise` | Function to refetch the query |
## Filtering
```tsx theme={null}
function PublishedPosts() {
const { data } = useQuery("posts", (query) =>
query.filter("status", "=", "published")
);
return (
{data?.items.map((post) => (
- {post.title}
))}
);
}
```
### Multiple Filters
```tsx theme={null}
function RecentPopularPosts() {
const { data } = useQuery("posts", (query) =>
query
.filter("status", "=", "published")
.filter("createdAt", ">", "2024-01-01")
.filter("views", ">=", 100)
);
return (
{data?.items.map((post) => (
- {post.title} ({post.views} views)
))}
);
}
```
## Sorting
```tsx theme={null}
function PopularPosts() {
const { data } = useQuery("posts", (query) =>
query.sort("views", "desc")
);
return (
{data?.items.map((post) => (
- {post.title} - {post.views} views
))}
);
}
```
### Multiple Sort
```tsx theme={null}
function SortedPosts() {
const { data } = useQuery("posts", (query) =>
query
.sort("status", "asc")
.sort("createdAt", "desc")
);
return (
{data?.items.map((post) => (
- {post.title} - {post.status}
))}
);
}
```
## Pagination
```tsx theme={null}
function PaginatedPostList({ page }: { page: number }) {
const { data } = useQuery("posts", (query) =>
query.page(page, 20)
);
const totalPages = data ? Math.ceil(data.total / 20) : 0;
return (
{data?.items.map((post) => (
- {post.title}
))}
Page {page} of {totalPages}
);
}
```
## Field Selection
```tsx theme={null}
function PostTitles() {
const { data } = useQuery("posts", (query) =>
query
.select("id", "title", "slug")
);
return (
{data?.items.map((post) => (
-
{post.title}
))}
);
}
```
## Expand Relations
```tsx theme={null}
function PostsWithAuthors() {
const { data } = useQuery("posts", (query) =>
query
.expand("author")
);
return (
{data?.items.map((post) => (
-
{post.title} by {post.author?.name}
))}
);
}
```
## Complex Queries
```tsx theme={null}
function ComplexSearch() {
const { data, loading } = useQuery("posts", (query) =>
query
.select("id", "title", "author.name", "createdAt")
.expand("author")
.filter("status", "=", "published")
.filter("createdAt", ">=", "2024-01-01")
.sort("views", "desc")
.sort("createdAt", "desc")
.page(1, 20)
);
if (loading) return Loading...;
return (
Found {data?.total} posts
{data?.items.map((post) => (
-
{post.title}
By {post.author?.name}
{post.views} views
))}
);
}
```
## Loading State
```tsx theme={null}
function PostList() {
const { data, loading } = useQuery("posts", (query) =>
query.page(1, 20)
);
if (loading) {
return (
);
}
return (
{data?.items.map((post) => (
- {post.title}
))}
);
}
```
## Error Handling
```tsx theme={null}
function PostList() {
const { data, loading, error } = useQuery("posts", (query) =>
query.page(1, 20)
);
if (loading) return Loading...;
if (error) {
return (
Error Loading Posts
{error.message}
);
}
return (
{data?.items.map((post) => (
- {post.title}
))}
);
}
```
## Refetching
```tsx theme={null}
function RefreshablePostList() {
const { data, loading, refetch } = useQuery("posts", (query) =>
query.page(1, 20)
);
return (
{data?.items.map((post) => (
- {post.title}
))}
);
}
```
## TypeScript
```tsx theme={null}
import type { Post } from "@snackbase/sdk";
function PostList() {
const { data } = useQuery("posts", (query) =>
query.filter("status", "=", "published")
);
return (
{data?.items.map((post) => (
-
{post.title} - {post.status} {/* Fully typed */}
))}
);
}
```
## Complete Example
```tsx theme={null}
import { useQuery } from "@snackbase/sdk/react";
import type { Post } from "@snackbase/sdk";
import { useState } from "react";
function PostList() {
const [page, setPage] = useState(1);
const perPage = 20;
const { data, loading, error } = useQuery("posts", (query) =>
query
.select("id", "title", "slug", "author", "views", "createdAt")
.expand("author")
.filter("status", "=", "published")
.sort("createdAt", "desc")
.page(page, perPage)
);
const totalPages = data ? Math.ceil(data.total / perPage) : 0;
if (loading) {
return (
);
}
if (error) {
return (
Error Loading Posts
{error.message}
);
}
return (
Published Posts
Showing {data?.items.length} of {data?.total} posts
{data?.items.map((post) => (
-
{post.title}
{post.author && (
By {post.author.name}
)}
{post.views} views
))}
{totalPages > 1 && (
)}
);
}
```
## Next Steps
* **[React Setup](/sdk/js/react/setup)** - Set up React integration
* **[useAuth Hook](/sdk/js/react/use-auth)** - Authentication
* **[useRecord Hook](/sdk/js/react/use-record)** - Single record fetching
* **[Query Builder](/sdk/js/query/overview)** - Query builder reference
# useRecord Hook
Source: https://docs.snackbase.dev/sdk/js/react/use-record
Fetch single records in React components
The `useRecord` hook provides a simple way to fetch a single record from a collection in React components.
## Import
```ts theme={null}
import { useRecord } from "@snackbase/sdk/react";
```
## Usage
```tsx theme={null}
function PostDetail({ postId }: { postId: string }) {
const { data: post, loading, error, refetch } = useRecord("posts", postId);
if (loading) return Loading...;
if (error) return Error: {error.message};
if (!post) return Post not found;
return (
{post.title}
{post.content}
);
}
```
## Parameters
```ts theme={null}
useRecord(
collection: string,
id: string,
options?: UseRecordOptions
)
```
| Parameter | Type | Required | Description |
| ------------ | ------------------ | -------- | --------------- |
| `collection` | `string` | Yes | Collection name |
| `id` | `string` | Yes | Record ID |
| `options` | `UseRecordOptions` | No | Query options |
## Options
```ts theme={null}
interface UseRecordOptions {
fields?: string[] | string;
expand?: string[] | string;
}
```
### Field Selection
```tsx theme={null}
function PostTitle({ postId }: { postId: string }) {
const { data: post } = useRecord("posts", postId, {
fields: ["id", "title"],
});
return {post?.title}
;
}
```
### Expand Relations
```tsx theme={null}
function PostWithAuthor({ postId }: { postId: string }) {
const { data: post, loading } = useRecord("posts", postId, {
expand: ["author", "comments"],
});
if (loading) return Loading...;
return (
{post?.title}
By {post?.author?.name}
);
}
```
## Return Value
```ts theme={null}
interface UseRecordResult {
data: (any & BaseRecord) | null;
loading: boolean;
error: Error | null;
refetch: () => Promise;
}
```
| Property | Type | Description |
| --------- | --------------------- | ------------------------------ |
| `data` | `T \| null` | The record data or null |
| `loading` | `boolean` | Whether data is loading |
| `error` | `Error \| null` | Any error that occurred |
| `refetch` | `() => Promise` | Function to refetch the record |
## Loading State
```tsx theme={null}
function PostDetail({ postId }: { postId: string }) {
const { data: post, loading } = useRecord("posts", postId);
if (loading) {
return (
Loading post...
);
}
return ;
}
```
## Error Handling
```tsx theme={null}
function PostDetail({ postId }: { postId: string }) {
const { data: post, loading, error } = useRecord("posts", postId);
if (loading) return Loading...;
if (error) {
return (
Error Loading Post
{error.message}
);
}
if (!post) {
return Post not found;
}
return ;
}
```
## Refetching
```tsx theme={null}
function PostDetail({ postId }: { postId: string }) {
const { data: post, loading, refetch } = useRecord("posts", postId);
return (
{loading ? Loading...
: }
);
}
```
## Conditional Fetching
Skip fetching when ID is not provided:
```tsx theme={null}
function PostDetail({ postId }: { postId: string | undefined }) {
const { data: post, loading } = useRecord("posts", postId || "");
// Hook won't fetch if postId is empty
if (!postId) {
return Select a post to view;
}
if (loading) return Loading...;
return ;
}
```
## TypeScript
```tsx theme={null}
import type { Post } from "@snackbase/sdk";
function PostDetail({ postId }: { postId: string }) {
const { data: post, loading, error } = useRecord("posts", postId);
if (loading) return Loading...;
if (error) return Error: {error.message};
if (!post) return Post not found;
// post is fully typed as Post
return (
{post.title}
{post.content}
{post.publishedAt && }
);
}
```
## Complete Example
```tsx theme={null}
import { useRecord } from "@snackbase/sdk/react";
import type { Post } from "@snackbase/sdk";
function PostDetail({ postId }: { postId: string }) {
const { data: post, loading, error, refetch } = useRecord("posts", postId, {
expand: ["author", "comments"],
});
if (loading) {
return (
Loading post...
);
}
if (error) {
return (
Error Loading Post
{error.message}
);
}
if (!post) {
return (
Post Not Found
The post you're looking for doesn't exist.
);
}
return (
{post.title}
{post.author && (
<>
{post.author.fullName}
>
)}
{post.publishedAt && (
<>
•
>
)}
{post.content}
{post.comments && post.comments.length > 0 && (
Comments
{post.comments.map((comment) => (
{comment.content}
))}
)}
);
}
```
## With useMutation
Combine with `useMutation` for edit functionality:
```tsx theme={null}
import { useRecord, useMutation } from "@snackbase/sdk/react";
function EditablePost({ postId }: { postId: string }) {
const { data: post, loading, error, refetch } = useRecord("posts", postId);
const { mutate: updatePost, isLoading: isUpdating } = useMutation("posts");
const handleUpdate = async (updates: Partial) => {
await updatePost(postId, updates);
refetch();
};
if (loading) return Loading...;
if (error) return Error: {error.message};
if (!post) return Post not found;
return (
{post.title}
{post.content}
);
}
```
## Next Steps
* **[React Setup](/sdk/js/react/setup)** - Set up React integration
* **[useAuth Hook](/sdk/js/react/use-auth)** - Authentication
* **[useQuery Hook](/sdk/js/react/use-query)** - Build queries
* **[useMutation Hook](/sdk/js/react/use-mutation)** - Mutations
# useSubscription Hook
Source: https://docs.snackbase.dev/sdk/js/react/use-subscription
Subscribe to real-time updates in React components
The `useSubscription` hook provides a React interface for real-time subscriptions to collection changes.
## Import
```ts theme={null}
import { useSubscription } from "@snackbase/sdk/react";
```
## Usage
```tsx theme={null}
function PostList() {
const [posts, setPosts] = useState([]);
useSubscription("posts", ["create", "update", "delete"], {
onCreate: (post) => {
setPosts((prev) => [...prev, post]);
},
onUpdate: (post) => {
setPosts((prev) => prev.map((p) => (p.id === post.id ? post : p)));
},
onDelete: (post) => {
setPosts((prev) => prev.filter((p) => p.id !== post.id));
},
});
return (
{posts.map((post) => (
- {post.title}
))}
);
}
```
## Parameters
```ts theme={null}
useSubscription(
collection: string,
operations: string[],
handlers: SubscriptionHandlers,
options?: SubscriptionOptions
)
```
| Parameter | Type | Required | Description |
| ------------ | ---------------------- | -------- | -------------------------- |
| `collection` | `string` | Yes | Collection name |
| `operations` | `string[]` | Yes | Operations to subscribe to |
| `handlers` | `SubscriptionHandlers` | Yes | Event handlers |
| `options` | `SubscriptionOptions` | No | Additional options |
## Handlers
```ts theme={null}
interface SubscriptionHandlers {
onCreate?: (data: any) => void;
onUpdate?: (data: any) => void;
onDelete?: (data: any) => void;
}
```
| Handler | Type | Description |
| ---------- | ---------------- | ----------------------------- |
| `onCreate` | `(data) => void` | Called when record is created |
| `onUpdate` | `(data) => void` | Called when record is updated |
| `onDelete` | `(data) => void` | Called when record is deleted |
## Basic Subscription
### Create Events
```tsx theme={null}
function LiveFeed() {
const [posts, setPosts] = useState([]);
useSubscription("posts", ["create"], {
onCreate: (post) => {
setPosts((prev) => [post, ...prev]);
},
});
return (
Live Feed
{posts.map((post) => (
- {post.title}
))}
);
}
```
### Update Events
```tsx theme={null}
function PostTable() {
const [posts, setPosts] = useState([]);
useEffect(() => {
// Initial load
client.records.list("posts").then((result) => {
setPosts(result.items);
});
}, []);
useSubscription("posts", ["update"], {
onUpdate: (post) => {
setPosts((prev) => prev.map((p) => (p.id === post.id ? post : p)));
},
});
return (
{posts.map((post) => (
{post.title}
{post.status}
{post.updatedAt}
))}
);
}
```
### Delete Events
```tsx theme={null}
function PostList() {
const [posts, setPosts] = useState([]);
useSubscription("posts", ["delete"], {
onDelete: (post) => {
setPosts((prev) => prev.filter((p) => p.id !== post.id)));
},
});
return (
{posts.map((post) => (
- {post.title}
))}
);
}
```
## Multiple Operations
```tsx theme={null}
function PostList() {
const [posts, setPosts] = useState([]);
useSubscription("posts", ["create", "update", "delete"], {
onCreate: (post) => {
setPosts((prev) => [...prev, post]);
},
onUpdate: (post) => {
setPosts((prev) => prev.map((p) => (p.id === post.id ? post : p)));
},
onDelete: (post) => {
setPosts((prev) => prev.filter((p) => p.id !== post.id)));
},
});
return ;
}
```
## Options
```ts theme={null}
interface SubscriptionOptions {
enabled?: boolean;
initialData?: T[];
}
```
### Conditional Subscription
```tsx theme={null}
function ConditionalSubscription({ enabled }: { enabled: boolean }) {
const [posts, setPosts] = useState([]);
useSubscription("posts", ["create"], {
onCreate: (post) => {
setPosts((prev) => [...prev, post]);
},
enabled,
});
return (
Subscription: {enabled ? "Active" : "Inactive"}
{posts.map((post) => (
- {post.title}
))}
);
}
```
## With Initial Data
```tsx theme={null}
function PostList() {
const [posts, setPosts] = useState([]);
useSubscription("posts", ["create", "update", "delete"], {
initialData: posts,
onCreate: (post) => {
setPosts((prev) => [...prev, post]);
},
onUpdate: (post) => {
setPosts((prev) => prev.map((p) => (p.id === post.id ? post : p)));
},
onDelete: (post) => {
setPosts((prev) => prev.filter((p) => p.id !== post.id)));
},
});
return ;
}
```
## TypeScript
```tsx theme={null}
import type { Post } from "@snackbase/sdk";
function PostList() {
const [posts, setPosts] = useState([]);
useSubscription("posts", ["create", "update", "delete"], {
onCreate: (post) => {
// post is fully typed as Post
setPosts((prev) => [...prev, post]);
},
onUpdate: (post) => {
setPosts((prev) => prev.map((p) => (p.id === post.id ? post : p)));
},
onDelete: (post) => {
setPosts((prev) => prev.filter((p) => p.id !== post.id)));
},
});
return ;
}
```
## Connection Status
```tsx theme={null}
import { useSnackBase } from "@snackbase/sdk/react";
function ConnectionIndicator() {
const client = useSnackBase();
const [status, setStatus] = useState("disconnected");
useEffect(() => {
const unsubscribes = [
client.realtime.on("connecting", () => setStatus("connecting")),
client.realtime.on("connected", () => setStatus("connected")),
client.realtime.on("disconnected", () => setStatus("disconnected")),
];
return () => {
unsubscribes.forEach((fn) => fn());
};
}, [client]);
const colors = {
connected: "bg-green-500",
connecting: "bg-yellow-500",
disconnected: "bg-red-500",
};
return (
{status}
);
}
```
## Complete Example
```tsx theme={null}
import { useState, useEffect } from "react";
import { useSubscription, useSnackBase } from "@snackbase/sdk/react";
import type { Post } from "@snackbase/sdk";
function LivePostFeed() {
const client = useSnackBase();
const [posts, setPosts] = useState([]);
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connecting" | "connected"
>("disconnected");
// Initial data load
useEffect(() => {
async function loadPosts() {
const result = await client.records.list("posts", {
filter: { status: "published" },
sort: "-createdAt",
limit: 50,
});
setPosts(result.items);
}
loadPosts();
}, [client]);
// Real-time subscription
useSubscription("posts", ["create", "update", "delete"], {
onCreate: (post) => {
if (post.status === "published") {
setPosts((prev) => [post, ...prev].slice(0, 50));
}
},
onUpdate: (post) => {
setPosts((prev) =>
prev.map((p) =>
p.id === post.id && post.status === "published" ? post : p
)
);
},
onDelete: (post) => {
setPosts((prev) => prev.filter((p) => p.id !== post.id)));
},
});
// Connection status
useEffect(() => {
const unsubscribes = [
client.realtime.on("connecting", () => setConnectionStatus("connecting")),
client.realtime.on("connected", () => setConnectionStatus("connected")),
client.realtime.on("disconnected", () => setConnectionStatus("disconnected")),
];
// Connect on mount
client.realtime.connect().catch(console.error);
return () => {
unsubscribes.forEach((fn) => fn());
client.realtime.disconnect();
};
}, [client]);
const statusColors = {
connected: "bg-green-500",
connecting: "bg-yellow-500",
disconnected: "bg-red-500",
};
return (
Live Feed
{connectionStatus}
{posts.length === 0 ? (
No posts yet
) : (
posts.map((post) => (
{post.title}
{post.content}
{post.views} views
{post.status}
))
)}
);
}
```
## Best Practices
### 1. Clean Up Subscriptions
The hook automatically cleans up on unmount:
```tsx theme={null}
useSubscription("posts", ["create"], {
onCreate: (post) => {
setPosts((prev) => [...prev, post]);
},
});
// Unsubscribe happens automatically when component unmounts
```
### 2. Handle Connection State
Show connection status to users:
```tsx theme={null}
const [status, setStatus] = useState("disconnected");
useEffect(() => {
const unsubscribes = [
client.realtime.on("connected", () => setStatus("connected")),
client.realtime.on("disconnected", () => setStatus("disconnected")),
];
return () => unsubscribes.forEach((fn) => fn());
}, [client]);
return (
{status === "connected" ? : }
);
```
### 3. Limit Subscriptions
Only subscribe to what you need:
```tsx theme={null}
// Good - specific operations
useSubscription("posts", ["create"], {
onCreate: (post) => handleNewPost(post),
});
// Avoid - all operations if you only need create
useSubscription("posts", ["create", "update", "delete"], {
onCreate: (post) => handleNewPost(post),
});
```
## Next Steps
* **[React Setup](/sdk/js/react/setup)** - Set up React integration
* **[useAuth Hook](/sdk/js/react/use-auth)** - Authentication
* **[Realtime Overview](/sdk/js/realtime/overview)** - Real-time concepts
# Realtime Events
Source: https://docs.snackbase.dev/sdk/js/realtime/events
Reference for realtime events and listeners
This guide provides a complete reference for all realtime events available in the SnackBase JavaScript SDK.
## Event Types
### Collection Events
Events emitted for collection operations:
| Event Pattern | Description | Example |
| --------------------- | -------------- | -------------- |
| `{collection}.create` | Record created | `posts.create` |
| `{collection}.update` | Record updated | `posts.update` |
| `{collection}.delete` | Record deleted | `posts.delete` |
| `{collection}.*` | Any operation | `posts.*` |
### System Events
Events for connection state changes:
| Event | Description |
| -------------- | ---------------------- |
| `connected` | Connection established |
| `connecting` | Connection in progress |
| `disconnected` | Connection lost |
| `error` | Error occurred |
| `auth_error` | Authentication error |
| `message` | Raw message received |
### Wildcard Events
Listen to all events:
| Event | Description |
| ----- | ----------------------------- |
| `*` | All events on all collections |
## Listening to Events
### Basic Event Listener
```ts theme={null}
client.realtime.on("posts.create", (data) => {
console.log("New post:", data);
});
```
### Wildcard Listeners
```ts theme={null}
// All events on a collection
client.realtime.on("posts.*", (data) => {
console.log("Posts event:", data);
});
// All events on all collections
client.realtime.on("*", (data) => {
console.log("Any event:", data);
});
```
### Removing Listeners
```ts theme={null}
// The on() method returns an unsubscribe function
const unsubscribe = client.realtime.on("posts.create", (data) => {
console.log("New post:", data);
});
// Remove the listener
unsubscribe();
```
## Event Data Structure
### Create Event
```ts theme={null}
client.realtime.on("posts.create", (data) => {
// data is the complete created record
console.log(data.id);
console.log(data.title);
console.log(data.content);
console.log(data.authorId);
console.log(data.createdAt);
console.log(data.updatedAt);
});
```
### Update Event
```ts theme={null}
client.realtime.on("posts.update", (data) => {
// data is the complete updated record
console.log(data.id);
console.log(data.title); // Updated value
console.log(data.updatedAt); // New timestamp
});
```
### Delete Event
```ts theme={null}
client.realtime.on("posts.delete", (data) => {
// data contains the deleted record's ID and basic info
console.log(data.id); // ID of deleted record
});
```
## Connection Event Data
### Connected Event
```ts theme={null}
client.realtime.on("connected", () => {
console.log("Connection established");
// No data payload
});
```
### Error Event
```ts theme={null}
client.realtime.on("error", (error: Error) => {
console.error("Error:", error.message);
console.error("Error details:", error);
});
```
### Auth Error Event
```ts theme={null}
client.realtime.on("auth_error", (error: Error) => {
console.error("Authentication error:", error.message);
// Redirect to login or refresh token
});
```
## Event Handling Patterns
### Type-Safe Event Handlers
```ts theme={null}
interface Post {
id: string;
title: string;
content: string;
authorId: string;
createdAt: string;
updatedAt: string;
}
client.realtime.on("posts.create", (data: Post) => {
console.log(data.title); // TypeScript knows this is a Post
});
```
### Filtering Events
```ts theme={null}
// Only handle posts by specific author
client.realtime.on("posts.create", (data: Post) => {
if (data.authorId === currentUserId) {
console.log("New post by current user:", data.title);
}
});
```
### Debouncing Events
```ts theme={null}
import { debounce } from "lodash";
// Debounce rapid updates
const debouncedHandler = debounce((data: Post) => {
console.log("Post updated (debounced):", data.title);
}, 300);
client.realtime.on("posts.update", debouncedHandler);
```
### Throttling Events
```ts theme={null}
import { throttle } from "lodash";
// Throttle rapid events
const throttledHandler = throttle((data: Post) => {
console.log("Post updated (throttled):", data.title);
}, 1000);
client.realtime.on("posts.update", throttledHandler);
```
## Complete Example
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
async function setupRealtime() {
// Connect
await client.realtime.connect();
// Subscribe to collections
await client.realtime.subscribe("posts", ["create", "update", "delete"]);
await client.realtime.subscribe("comments", ["create"]);
await client.realtime.subscribe("likes", ["create", "delete"]);
// Collection events
const unsubscribePosts = client.realtime.on("posts.create", (post) => {
console.log("New post:", post.title);
// Add to UI
});
client.realtime.on("posts.update", (post) => {
console.log("Updated post:", post.title);
// Update in UI
});
client.realtime.on("posts.delete", (post) => {
console.log("Deleted post:", post.id);
// Remove from UI
});
// Wildcard listener
const unsubscribeAll = client.realtime.on("*", (data) => {
console.log("Some event happened:", data);
});
// Connection events
client.realtime.on("connected", () => {
console.log("✅ Connected");
});
client.realtime.on("connecting", () => {
console.log("🔄 Connecting...");
});
client.realtime.on("disconnected", () => {
console.log("❌ Disconnected - will reconnect");
});
client.realtime.on("error", (error) => {
console.error("⚠️ Error:", error.message);
});
client.realtime.on("auth_error", (error) => {
console.error("🔐 Auth error:", error.message);
// Redirect to login
});
// Cleanup function
return () => {
unsubscribePosts();
unsubscribeAll();
client.realtime.disconnect();
};
}
```
## React Hook
Create a custom hook for realtime events:
```tsx theme={null}
import { useEffect } from "react";
import { useSnackBase } from "@snackbase/sdk/react";
function useRealtimeCollection(
collection: string,
onEvent: (data: T, event: string) => void
) {
const client = useSnackBase();
useEffect(() => {
const unsubscribes: (() => void)[] = [];
async function setup() {
await client.realtime.connect();
await client.realtime.subscribe(collection);
const operations = ["create", "update", "delete"] as const;
operations.forEach((op) => {
const unsubscribe = client.realtime.on(`${collection}.${op}`, (data) => {
onEvent(data as T, op);
});
unsubscribes.push(unsubscribe);
});
}
setup();
return () => {
unsubscribes.forEach((fn) => fn());
client.realtime.disconnect();
};
}, [client, collection, onEvent]);
}
// Usage
function PostList() {
const [posts, setPosts] = useState([]);
useRealtimeCollection("posts", (data, event) => {
switch (event) {
case "create":
setPosts((prev) => [...prev, data]);
break;
case "update":
setPosts((prev) => prev.map((p) => (p.id === data.id ? data : p)));
break;
case "delete":
setPosts((prev) => prev.filter((p) => p.id !== data.id));
break;
}
});
return (
{posts.map((post) => (
- {post.title}
))}
);
}
```
## Event Reference
### Method: `on(event, handler)`
Subscribe to an event.
**Parameters:**
* `event` (string) - Event name
* `handler` (function) - Event handler function
**Returns:** `() => void` - Unsubscribe function
### Method: `off(event, handler)`
Unsubscribe from an event.
**Parameters:**
* `event` (string) - Event name
* `handler` (function) - Event handler to remove
**Returns:** `void`
## Next Steps
* **[Realtime Overview](/sdk/js/realtime/overview)** - Getting started with realtime
* **[WebSocket](/sdk/js/realtime/websocket)** - WebSocket-specific features
* **[SSE](/sdk/js/realtime/sse)** - Server-Sent Events
# Realtime Overview
Source: https://docs.snackbase.dev/sdk/js/realtime/overview
Build real-time features with WebSocket and SSE
The Realtime service provides WebSocket and Server-Sent Events (SSE) connections for live data updates in your SnackBase collections.
## Overview
Subscribe to collection changes and receive instant updates when records are created, updated, or deleted:
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Connect to the realtime service
await client.realtime.connect();
// Subscribe to a collection
await client.realtime.subscribe("posts", ["create", "update", "delete"]);
// Listen for events
client.realtime.on("posts.create", (data) => {
console.log("New post created:", data);
});
client.realtime.on("posts.update", (data) => {
console.log("Post updated:", data);
});
client.realtime.on("posts.delete", (data) => {
console.log("Post deleted:", data);
});
```
## Connection Methods
SnackBase realtime supports two connection methods:
| Method | Description | Browser Support | Use Case |
| --------- | -------------------------------- | --------------- | ------------------------ |
| WebSocket | Full-duplex, low latency | Modern browsers | Interactive apps |
| SSE | Simple, one-way server to client | All browsers | Simple updates, fallback |
The SDK automatically uses WebSocket when available and falls back to SSE.
## Getting Started
### 1. Connect
Connect to the realtime service:
```ts theme={null}
await client.realtime.connect();
```
The SDK requires an authenticated user for realtime connections. Ensure
the user is logged in before connecting.
### 2. Subscribe to Collections
Subscribe to events on a collection:
```ts theme={null}
await client.realtime.subscribe("posts", ["create", "update", "delete"]);
```
### 3. Listen for Events
Listen for specific events:
```ts theme={null}
client.realtime.on("posts.create", (data) => {
console.log("New post:", data);
});
```
### 4. Disconnect
Disconnect when done:
```ts theme={null}
client.realtime.disconnect();
```
## Connection States
Monitor the connection state:
```ts theme={null}
// Get current state
const state = client.realtime.getState(); // "disconnected" | "connecting" | "connected" | "error"
// Listen for state changes
client.realtime.on("connected", () => {
console.log("Connected to realtime");
});
client.realtime.on("disconnected", () => {
console.log("Disconnected from realtime");
});
client.realtime.on("error", (error) => {
console.error("Realtime error:", error);
});
```
## Subscription Events
Subscribe to specific operations:
```ts theme={null}
// All operations
await client.realtime.subscribe("posts");
// Specific operations
await client.realtime.subscribe("posts", ["create"]);
await client.realtime.subscribe("posts", ["create", "update"]);
await client.realtime.subscribe("posts", ["create", "update", "delete"]);
```
## Event Data Structure
Events include the complete record data:
```ts theme={null}
client.realtime.on("posts.create", (data) => {
console.log(data.id);
console.log(data.title);
console.log(data.content);
console.log(data.createdAt);
// ... all record fields
});
```
## Multiple Subscriptions
Subscribe to multiple collections:
```ts theme={null}
await client.realtime.subscribe("posts");
await client.realtime.subscribe("comments");
await client.realtime.subscribe("likes");
client.realtime.on("posts.create", (data) => {
console.log("New post:", data);
});
client.realtime.on("comments.create", (data) => {
console.log("New comment:", data);
});
client.realtime.on("likes.create", (data) => {
console.log("New like:", data);
});
```
## Wildcard Events
Use wildcards to listen to all events:
```ts theme={null}
// All events on all collections
client.realtime.on("*", (data) => {
console.log("Any event:", data);
});
// All events on a specific collection
client.realtime.on("posts.*", (data) => {
console.log("Any posts event:", data);
});
```
## Unsubscribing
Unsubscribe from a collection:
```ts theme={null}
await client.realtime.unsubscribe("posts");
```
Remove event listeners:
```ts theme={null}
// The on() method returns an unsubscribe function
const unsubscribe = client.realtime.on("posts.create", (data) => {
console.log("New post:", data);
});
// Stop listening
unsubscribe();
```
## Complete Example
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
async function setupRealtime() {
// Authenticate first
await client.auth.loginWithPassword({
email: "user@example.com",
password: "password",
});
// Connect to realtime
await client.realtime.connect();
// Subscribe to posts
await client.realtime.subscribe("posts", ["create", "update", "delete"]);
// Listen for events
client.realtime.on("posts.create", (post) => {
console.log("New post created:", post.title);
// Update UI
});
client.realtime.on("posts.update", (post) => {
console.log("Post updated:", post.title);
// Update UI
});
client.realtime.on("posts.delete", (post) => {
console.log("Post deleted:", post.id);
// Remove from UI
});
// Handle connection events
client.realtime.on("connected", () => {
console.log("Realtime connected");
});
client.realtime.on("disconnected", () => {
console.log("Realtime disconnected - will reconnect");
});
client.realtime.on("error", (error) => {
console.error("Realtime error:", error);
});
}
```
## React Integration
Use realtime with React:
```tsx theme={null}
import { useEffect, useState } from "react";
import { useSnackBase } from "@snackbase/sdk/react";
function PostList() {
const client = useSnackBase();
const [posts, setPosts] = useState([]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
async function setup() {
// Connect
await client.realtime.connect();
// Subscribe
await client.realtime.subscribe("posts");
// Listen for new posts
unsubscribe = client.realtime.on("posts.create", (post) => {
setPosts((prev) => [...prev, post]);
});
// Listen for updates
client.realtime.on("posts.update", (post) => {
setPosts((prev) =>
prev.map((p) => (p.id === post.id ? post : p))
);
});
// Listen for deletions
client.realtime.on("posts.delete", (post) => {
setPosts((prev) => prev.filter((p) => p.id !== post.id));
});
}
setup();
// Cleanup
return () => {
unsubscribe?.();
client.realtime.disconnect();
};
}, [client]);
return (
{posts.map((post) => (
- {post.title}
))}
);
}
```
## Reconnection
The SDK automatically reconnects on connection loss:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
maxRealTimeRetries: 10, // Max reconnection attempts
realTimeReconnectionDelay: 1000, // Initial delay (ms)
});
```
Reconnection uses exponential backoff:
| Attempt | Delay |
| ------- | ---------- |
| 1 | 1 second |
| 2 | 2 seconds |
| 3 | 4 seconds |
| 4 | 8 seconds |
| ... | ... |
| Max | 30 seconds |
## Authentication and Realtime
Token refresh is handled automatically:
```ts theme={null}
// Initial connection
await client.realtime.connect();
// Token is refreshed automatically
// Connection is re-established with new token
```
## Error Handling
Handle realtime errors:
```ts theme={null}
client.realtime.on("error", (error) => {
console.error("Realtime error:", error.message);
// Check error type
if (error.message.includes("authentication")) {
console.error("Auth failed - please log in again");
} else if (error.message.includes("network")) {
console.error("Network error - will reconnect");
}
});
client.realtime.on("auth_error", (error) => {
console.error("Authentication error:", error);
// Redirect to login
});
```
## Best Practices
### 1. Subscribe to What You Need
Only subscribe to collections and operations you need:
```ts theme={null}
// Good - specific operations
await client.realtime.subscribe("posts", ["create"]);
// Avoid - all operations if you only need create
await client.realtime.subscribe("posts");
```
### 2. Clean Up Subscriptions
Always unsubscribe when done:
```ts theme={null}
useEffect(() => {
const unsubscribe = client.realtime.on("posts.create", handler);
return () => {
unsubscribe();
client.realtime.disconnect();
};
}, []);
```
### 3. Handle Connection States
Show connection status to users:
```tsx theme={null}
const [connectionState, setConnectionState] = useState("disconnected");
client.realtime.on("connected", () => setConnectionState("connected"));
client.realtime.on("connecting", () => setConnectionState("connecting"));
client.realtime.on("disconnected", () => setConnectionState("disconnected"));
return (
{connectionState === "connected" && Live}
{connectionState === "connecting" && Connecting...}
{connectionState === "disconnected" && Disconnected}
);
```
## Next Steps
* **[WebSocket](/sdk/js/realtime/websocket)** - WebSocket-specific features
* **[SSE](/sdk/js/realtime/sse)** - Server-Sent Events
* **[Events](/sdk/js/realtime/events)** - Event reference
# Server-Sent Events (SSE)
Source: https://docs.snackbase.dev/sdk/js/realtime/sse
Use Server-Sent Events for real-time updates
Server-Sent Events (SSE) provide a simple, one-way communication channel for real-time updates from the server to the client.
## Overview
The SnackBase SDK automatically falls back to SSE when WebSocket is not available:
```ts theme={null}
await client.realtime.connect();
await client.realtime.subscribe("posts");
client.realtime.on("posts.create", (data) => {
console.log("New post:", data);
});
```
## SSE vs WebSocket
| Feature | SSE | WebSocket |
| --------------- | ------- | ------------- |
| Direction | One-way | Bidirectional |
| Browser Support | All | Modern |
| Latency | Low | Very low |
| Server Load | Lower | Higher |
| Auto Reconnect | Yes | Manual |
| Use Case | Updates | Interactive |
The SDK prefers WebSocket but falls back to SSE automatically.
## SSE URL
The SDK constructs the SSE URL from your base URL:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// SSE URL: https://api.example.com/api/v1/realtime/subscribe?token=xxx&collections=posts:create,update
```
## Subscriptions with SSE
SSE subscriptions are specified at connection time:
```ts theme={null}
// Subscribe to collections
await client.realtime.subscribe("posts", ["create", "update"]);
await client.realtime.subscribe("comments", ["create"]);
// When SSE connects, it includes all subscriptions
// ?collections=posts:create,update&collections=comments:create
```
## Connection Lifecycle
### 1. Connection
```ts theme={null}
await client.realtime.connect();
// Creates EventSource with subscriptions
```
### 2. State Changes
```ts theme={null}
client.realtime.on("connecting", () => {
console.log("SSE connecting...");
});
client.realtime.on("connected", () => {
console.log("SSE connected");
});
client.realtime.on("disconnected", () => {
console.log("SSE disconnected");
});
client.realtime.on("error", (error) => {
console.error("SSE error:", error);
});
```
### 3. Disconnection
```ts theme={null}
client.realtime.disconnect();
// Closes EventSource
```
## SSE Message Format
Messages are received as text/event-stream:
```
data: {"type":"posts.create","data":{"id":"xxx","title":"..."}}
data: {"type":"posts.update","data":{"id":"xxx","title":"..."}}
data: {"type":"posts.delete","data":{"id":"xxx"}}
```
## Automatic Reconnection
SSE has built-in reconnection:
```ts theme={null}
// Browser automatically reconnects SSE
// SDK also handles reconnection with exponential backoff
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
maxRealTimeRetries: 10,
realTimeReconnectionDelay: 1000,
});
```
## Authentication
SSE connections include the authentication token in the URL:
```ts theme={null}
// After login
await client.auth.loginWithPassword({
email: "user@example.com",
password: "password",
});
// SSE URL includes token
await client.realtime.connect();
// ?token=eyJhbGc...&collections=posts:create,update
```
Tokens in URLs may be logged in server access logs. This is a limitation
of the SSE protocol.
## SSE Limitations
### 1. One-Way Communication
SSE is server-to-client only:
```ts theme={null}
// You CANNOT send messages to server with SSE
// Use WebSocket for bidirectional communication
```
### 2. Limited Subscriptions After Connection
With SSE, subscriptions are set at connection time:
```ts theme={null}
// Subscribe before connecting
await client.realtime.subscribe("posts");
await client.realtime.subscribe("comments");
// Then connect
await client.realtime.connect();
// Changing subscriptions requires reconnection
await client.realtime.subscribe("likes"); // May not work until reconnect
await client.realtime.connect(); // Reconnect to apply new subscriptions
```
### 3. No Heartbeat
SSE uses connection keep-alive instead of heartbeat:
```ts theme={null}
// SSE doesn't have ping/pong like WebSocket
// Browser automatically detects dead connections
```
## When to Use SSE
Use SSE when:
1. **WebSocket is unavailable** - Older browsers or restrictive networks
2. **Simple updates needed** - One-way server updates are sufficient
3. **Lower server load** - SSE uses fewer resources than WebSocket
```ts theme={null}
// SDK automatically uses SSE when appropriate
await client.realtime.connect();
// Uses WebSocket if available, falls back to SSE
```
## React Example
```tsx theme={null}
import { useEffect, useState } from "react";
import { useSnackBase } from "@snackbase/sdk/react";
function useRealtimePosts() {
const client = useSnackBase();
const [posts, setPosts] = useState([]);
useEffect(() => {
let mounted = true;
async function setup() {
// Subscribe to posts
await client.realtime.subscribe("posts", ["create", "update", "delete"]);
// Connect
await client.realtime.connect();
// Listen for events
client.realtime.on("posts.create", (post: Post) => {
if (mounted) {
setPosts((prev) => [...prev, post]);
}
});
client.realtime.on("posts.update", (post: Post) => {
if (mounted) {
setPosts((prev) =>
prev.map((p) => (p.id === post.id ? post : p))
);
}
});
client.realtime.on("posts.delete", (post: Post) => {
if (mounted) {
setPosts((prev) => prev.filter((p) => p.id !== post.id));
}
});
}
setup();
return () => {
mounted = false;
client.realtime.disconnect();
};
}, [client]);
return posts;
}
function PostList() {
const posts = useRealtimePosts();
return (
{posts.map((post) => (
- {post.title}
))}
);
}
```
## Cross-Browser Considerations
SSE is supported in most browsers:
| Browser | SSE Support |
| ------- | ----------------- |
| Chrome | Yes |
| Firefox | Yes |
| Safari | Yes |
| Edge | Yes |
| IE 11 | No (use polyfill) |
| Opera | Yes |
For IE 11, use an SSE polyfill:
```ts theme={null}
// Install polyfill
npm install event-source-polyfill
// Use polyfill
import { EventSourcePolyfill } from 'event-source-polyfill';
// Configure SDK to use polyfill
// (SDK will use native EventSource when available)
```
## Debugging SSE
Enable logging to debug SSE issues:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
enableLogging: true,
logLevel: "debug",
});
// Logs will show:
// RealTimeService: Connecting...
// RealTimeService: SSE connected
// RealTimeService: Received message
```
Check SSE connection in browser DevTools:
1. Open DevTools (F12)
2. Go to **Network** tab
3. Filter by **EventStream**
4. Select the SSE connection
5. View messages in **Response** tab
## Common Issues
### 1. Connection Closes Immediately
**Problem:** SSE connection closes after opening
**Solutions:**
* Check authentication token is valid
* Verify server allows SSE connections
* Check for proxy/load balancer issues
### 2. No Events Received
**Problem:** Connection stays open but no events received
**Solutions:**
* Verify subscriptions are set before connection
* Check collection names are correct
* Ensure server has events to send
### 3. Frequent Reconnections
**Problem:** SSE keeps reconnecting
**Solutions:**
* Check network stability
* Verify server timeout settings
* Increase reconnection delay
## Performance Tips
### 1. Minimize Subscriptions
Only subscribe to what you need:
```ts theme={null}
// Good - specific operations
await client.realtime.subscribe("posts", ["create"]);
// Avoid - all operations if not needed
await client.realtime.subscribe("posts");
```
### 2. Debounce Updates
Debounce rapid updates:
```ts theme={null}
import { debounce } from "lodash";
const debouncedUpdate = debounce((posts) => {
// Update UI
renderPosts(posts);
}, 100);
client.realtime.on("posts.update", debouncedUpdate);
```
### 3. Use Connection Pooling
For multiple tabs, use BroadcastChannel:
```ts theme={null}
const channel = new BroadcastChannel("snackbase-updates");
client.realtime.on("posts.create", (data) => {
channel.postMessage({ type: "posts.create", data });
});
// Other tabs listen to channel
channel.onmessage = (event) => {
// Update UI in other tabs
};
```
## Next Steps
* **[WebSocket](/sdk/js/realtime/websocket)** - WebSocket for bidirectional communication
* **[Events](/sdk/js/realtime/events)** - Event reference
* **[Realtime Overview](/sdk/js/realtime/overview)** - Getting started
# WebSocket Realtime
Source: https://docs.snackbase.dev/sdk/js/realtime/websocket
Use WebSocket for low-latency real-time updates
WebSocket provides a full-duplex communication channel for low-latency real-time updates in SnackBase.
## Overview
The SnackBase SDK automatically uses WebSocket when available, providing real-time updates with minimal latency:
```ts theme={null}
await client.realtime.connect();
await client.realtime.subscribe("posts");
client.realtime.on("posts.create", (data) => {
console.log("New post:", data);
});
```
## WebSocket vs SSE
| Feature | WebSocket | SSE |
| --------------- | ----------- | -------------- |
| Latency | Very low | Low |
| Bidirectional | Yes | No |
| Browser Support | Modern | All |
| Fallback | Yes | Primary |
| Server Load | Higher | Lower |
| Use Case | Interactive | Simple updates |
The SDK automatically falls back to SSE if WebSocket is not available.
## WebSocket URL
The SDK constructs the WebSocket URL from your base URL:
```ts theme={null}
// HTTPS becomes WSS
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// WebSocket URL: wss://api.example.com/api/v1/realtime/ws
// HTTP becomes WS
const client = new SnackBaseClient({
baseUrl: "http://localhost:8000",
});
// WebSocket URL: ws://localhost:8000/api/v1/realtime/ws
```
## Authentication
WebSocket connections include the authentication token:
```ts theme={null}
// After login, the token is automatically included
await client.auth.loginWithPassword({
email: "user@example.com",
password: "password",
});
// WebSocket connection uses the auth token
await client.realtime.connect();
```
## Connection Lifecycle
### 1. Connection
```ts theme={null}
await client.realtime.connect();
```
### 2. State Changes
```ts theme={null}
client.realtime.on("connecting", () => {
console.log("WebSocket connecting...");
});
client.realtime.on("connected", () => {
console.log("WebSocket connected");
});
client.realtime.on("disconnected", () => {
console.log("WebSocket disconnected");
});
client.realtime.on("error", (error) => {
console.error("WebSocket error:", error);
});
```
### 3. Disconnection
```ts theme={null}
client.realtime.disconnect();
```
## Subscriptions
WebSocket subscriptions are sent as messages:
```ts theme={null}
// Subscribe to collection
await client.realtime.subscribe("posts", ["create", "update", "delete"]);
// This sends: {"action": "subscribe", "collection": "posts", "operations": ["create", "update", "delete"]}
```
### Subscription Confirmation
The server confirms subscriptions:
```ts theme={null}
await client.realtime.subscribe("posts");
// When confirmed, you'll receive confirmation internally
// The subscribe() promise resolves when confirmed
```
## Heartbeat
WebSocket connections use heartbeat to detect dead connections:
```ts theme={null}
// The SDK sends ping every 30 seconds
// Server responds with pong
// If pong not received, connection is considered dead
```
## Reconnection
Automatic reconnection with exponential backoff:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
maxRealTimeRetries: 10, // Max reconnection attempts
realTimeReconnectionDelay: 1000, // Initial delay (ms)
});
// If connection is lost, SDK automatically reconnects
// All subscriptions are restored
```
## Manual Reconnection
Force a reconnection:
```ts theme={null}
// Disconnect
client.realtime.disconnect();
// Reconnect
await client.realtime.connect();
```
## Check Connection Method
The SDK can detect if WebSocket is available:
```ts theme={null}
// In browsers, check if WebSocket is available
if (typeof WebSocket !== "undefined") {
console.log("WebSocket is available");
// SDK will use WebSocket
} else {
console.log("WebSocket not available, will use SSE");
}
```
## Performance Optimization
### 1. Batch Operations
Wait for multiple events before updating UI:
```ts theme={null}
import { debounce } from "lodash";
const debouncedUpdate = debounce(() => {
// Refresh UI
refreshPosts();
}, 100);
client.realtime.on("posts.update", debouncedUpdate);
client.realtime.on("posts.delete", debouncedUpdate);
```
### 2. Selective Subscriptions
Only subscribe to operations you need:
```ts theme={null}
// Only new posts
await client.realtime.subscribe("posts", ["create"]);
// Not all operations
await client.realtime.subscribe("posts"); // Avoid if you only need create
```
### 3. Connection Pooling
For multiple tabs/windows, use a shared worker or broadcast channel:
```ts theme={null}
// Use BroadcastChannel for cross-tab updates
const channel = new BroadcastChannel("snackbase-updates");
client.realtime.on("posts.create", (data) => {
channel.postMessage({ type: "posts.create", data });
});
// In other tabs
channel.onmessage = (event) => {
if (event.data.type === "posts.create") {
// Update UI
}
};
```
## WebSocket Configuration
Configure WebSocket behavior:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
// Realtime settings
maxRealTimeRetries: 20, // Increase retries for unstable networks
realTimeReconnectionDelay: 2000, // Start with 2 second delay
});
```
## Debugging WebSocket
Enable logging to debug WebSocket issues:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
enableLogging: true,
logLevel: "debug",
});
// Now you'll see detailed logs:
// RealTimeService: Connecting...
// RealTimeService: WebSocket connected
// RealTimeService: Subscribing to posts
// RealTimeService: Received message
```
## Browser DevTools
Inspect WebSocket messages in browser devtools:
1. Open DevTools (F12)
2. Go to **Network** tab
3. Filter by **WS** (WebSocket)
4. Select the WebSocket connection
5. View messages in **Messages** tab
## React Example
```tsx theme={null}
import { useEffect, useState } from "react";
import { useSnackBase } from "@snackbase/sdk/react";
function RealtimeIndicator() {
const client = useSnackBase();
const [state, setState] = useState<"disconnected" | "connecting" | "connected">("disconnected");
useEffect(() => {
const unsubscribes: (() => void)[] = [];
// Monitor connection state
unsubscribes.push(client.realtime.on("connecting", () => setState("connecting")));
unsubscribes.push(client.realtime.on("connected", () => setState("connected")));
unsubscribes.push(client.realtime.on("disconnected", () => setState("disconnected")));
return () => {
unsubscribes.forEach((fn) => fn());
};
}, [client]);
const colors = {
connected: "bg-green-500",
connecting: "bg-yellow-500",
disconnected: "bg-red-500",
};
return (
{state}
);
}
```
## Common Issues
### 1. Connection Fails
**Problem:** WebSocket connection fails
**Solutions:**
* Check that user is authenticated
* Verify base URL is correct
* Check browser console for errors
* Try HTTPS/WSS (some browsers block WS on HTTPS pages)
### 2. Frequent Disconnections
**Problem:** Connection drops frequently
**Solutions:**
* Increase `maxRealTimeRetries`
* Check network stability
* Verify server WebSocket timeout settings
### 3. Events Not Received
**Problem:** Not receiving realtime events
**Solutions:**
* Verify subscription was successful
* Check connection state
* Ensure you're listening to correct event names
## Next Steps
* **[SSE](/sdk/js/realtime/sse)** - Server-Sent Events fallback
* **[Events](/sdk/js/realtime/events)** - Event reference
* **[Realtime Overview](/sdk/js/realtime/overview)** - Getting started
# Client Reference
Source: https://docs.snackbase.dev/sdk/js/reference/client
Complete reference for the SnackBaseClient class
This is a complete reference for the `SnackBaseClient` class, the main entry point for the SnackBase JavaScript SDK.
## Constructor
Create a new SnackBaseClient instance:
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient(config: SnackBaseConfig);
```
### Configuration
```ts theme={null}
interface SnackBaseConfig {
// Required
baseUrl: string;
// Optional - Authentication
apiKey?: string;
defaultAccount?: string;
// Optional - Request settings
timeout?: number;
maxRetries?: number;
retryDelay?: number;
// Optional - Token management
enableAutoRefresh?: boolean;
refreshBeforeExpiry?: number;
storageBackend?: StorageBackend;
// Optional - Realtime
maxRealTimeRetries?: number;
realTimeReconnectionDelay?: number;
// Optional - Logging
enableLogging?: boolean;
logLevel?: LogLevel;
// Optional - Error callbacks
onAuthError?: (error: any) => void;
onNetworkError?: (error: any) => void;
onRateLimitError?: (error: any) => void;
}
type StorageBackend = "localStorage" | "sessionStorage" | "memory" | "asyncStorage";
type LogLevel = "debug" | "info" | "warn" | "error";
```
## Properties
### Auth State
```ts theme={null}
// Current authenticated user
client.user: User | null
// Current account
client.account: Account | null
// Authentication status
client.isAuthenticated: boolean
```
### Services
```ts theme={null}
// Authentication service
client.auth: AuthService
// Account management
client.accounts: AccountService
// User management
client.users: UserService
// Collection management
client.collections: CollectionService
// Record operations
client.records: RecordService
// Group management
client.groups: GroupsService
// Invitation management
client.invitations: InvitationService
// API key management
client.apiKeys: ApiKeyService
// Audit log access
client.auditLogs: AuditLogService
// Role management
client.roles: RoleService
// Collection rule management
client.collectionRules: CollectionRuleService
// Macro operations
client.macros: MacroService
// Dashboard statistics
client.dashboard: DashboardService
// System administration
client.admin: AdminService
// Email template management
client.emailTemplates: EmailTemplateService
// File management
client.files: FileService
// Real-time features
client.realtime: RealTimeService
// Migration status
client.migrations: MigrationService
```
## Methods
### Configuration
#### `getConfig()`
Get the current client configuration.
```ts theme={null}
const config = client.getConfig(): Required
```
**Returns:** The merged configuration object with defaults applied.
***
### Authentication
#### `login(credentials)`
Authenticate with email and password.
```ts theme={null}
await client.login(credentials: LoginCredentials): Promise
```
**Alias for:** `client.auth.loginWithPassword()`
***
#### `logout()`
Log out the current user.
```ts theme={null}
await client.logout(): Promise
```
**Alias for:** `client.auth.logout()`
***
#### `register(data)`
Register a new user and account.
```ts theme={null}
await client.register(data: RegisterData): Promise
```
**Alias for:** `client.auth.register()`
***
#### `refreshToken()`
Refresh the access token.
```ts theme={null}
await client.refreshToken(): Promise
```
**Alias for:** `client.auth.refreshToken()`
***
#### `getCurrentUser()`
Get the current authenticated user profile.
```ts theme={null}
await client.getCurrentUser(): Promise
```
**Alias for:** `client.auth.getCurrentUser()`
***
#### `forgotPassword(data)`
Initiate password reset flow.
```ts theme={null}
await client.forgotPassword(data: PasswordResetRequest): Promise
```
**Alias for:** `client.auth.forgotPassword()`
***
#### `resetPassword(data)`
Reset password using a token.
```ts theme={null}
await client.resetPassword(data: PasswordResetConfirm): Promise
```
**Alias for:** `client.auth.resetPassword()`
***
#### `verifyEmail(token)`
Verify email using a token.
```ts theme={null}
await client.verifyEmail(token: string): Promise
```
**Alias for:** `client.auth.verifyEmail()`
***
#### `resendVerificationEmail()`
Resend the verification email to the current user.
```ts theme={null}
await client.resendVerificationEmail(): Promise
```
**Alias for:** `client.auth.resendVerificationEmail()`
***
#### `getSAMLUrl(provider, account, relayState?)`
Generate SAML SSO authorization URL.
```ts theme={null}
await client.getSAMLUrl(
provider: SAMLProvider,
account: string,
relayState?: string
): Promise
```
**Alias for:** `client.auth.getSAMLUrl()`
***
#### `handleSAMLCallback(params)`
Handle SAML callback.
```ts theme={null}
await client.handleSAMLCallback(params: SAMLCallbackParams): Promise
```
**Alias for:** `client.auth.handleSAMLCallback()`
***
#### `getSAMLMetadata(provider, account)`
Get SAML metadata.
```ts theme={null}
await client.getSAMLMetadata(
provider: SAMLProvider,
account: string
): Promise
```
**Alias for:** `client.auth.getSAMLMetadata()`
***
### Events
#### `on(event, listener)`
Subscribe to authentication events.
```ts theme={null}
client.on(
event: K,
listener: AuthEvents[K]
): () => void
```
**Returns:** Unsubscribe function
**Events:**
* `auth:login` - User logged in
* `auth:logout` - User logged out
* `auth:refresh` - Token refreshed
* `auth:error` - Authentication error
**Example:**
```ts theme={null}
const unsubscribe = client.on("auth:login", (state) => {
console.log("User logged in:", state.user);
});
// Unsubscribe later
unsubscribe();
```
***
## Complete Example
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
defaultAccount: "my-account",
timeout: 30000,
maxRetries: 3,
enableLogging: true,
logLevel: "debug",
});
// Listen to auth events
client.on("auth:login", (state) => {
console.log("Logged in as:", state.user.email);
});
client.on("auth:logout", () => {
console.log("Logged out");
window.location.href = "/login";
});
// Use services
const posts = await client.records.list("posts");
const collections = await client.collections.list();
// Access auth state
console.log("Authenticated:", client.isAuthenticated);
console.log("User:", client.user);
console.log("Account:", client.account);
```
## Next Steps
* **[Type Reference](/sdk/js/reference/types)** - Type definitions
* **[Services](/sdk/js/services/records)** - Service references
* **[Configuration](/sdk/js/configuration)** - Configuration options
# Type Reference
Source: https://docs.snackbase.dev/sdk/js/reference/types
TypeScript type definitions for the SDK
This is a reference for the main TypeScript types exported by the SnackBase JavaScript SDK.
## Core Types
### SnackBaseClient
The main SDK client class.
```ts theme={null}
class SnackBaseClient {
constructor(config: SnackBaseConfig)
// Auth state
readonly user: User | null
readonly account: Account | null
readonly isAuthenticated: boolean
// Services
readonly auth: AuthService
readonly accounts: AccountService
readonly users: UserService
readonly collections: CollectionService
readonly records: RecordService
readonly groups: GroupsService
readonly invitations: InvitationService
readonly apiKeys: ApiKeyService
readonly auditLogs: AuditLogService
readonly roles: RoleService
readonly collectionRules: CollectionRuleService
readonly macros: MacroService
readonly dashboard: DashboardService
readonly admin: AdminService
readonly emailTemplates: EmailTemplateService
readonly files: FileService
readonly realtime: RealTimeService
readonly migrations: MigrationService
// Methods
getConfig(): Required<SnackBaseConfig>
on<K extends keyof AuthEvents>(event: K, listener: AuthEvents[K]): () => void
login(credentials: LoginCredentials): Promise<AuthResponse>
logout(): Promise<void>
// ... (see Client Reference for full method list)
}
```
***
### SnackBaseConfig
Client configuration options.
```ts theme={null}
interface SnackBaseConfig {
// Required
baseUrl: string;
// Optional - Authentication
apiKey?: string;
defaultAccount?: string;
// Optional - Request settings
timeout?: number;
maxRetries?: number;
retryDelay?: number;
// Optional - Token management
enableAutoRefresh?: boolean;
refreshBeforeExpiry?: number;
storageBackend?: StorageBackend;
// Optional - Realtime
maxRealTimeRetries?: number;
realTimeReconnectionDelay?: number;
// Optional - Logging
enableLogging?: boolean;
logLevel?: LogLevel;
// Optional - Error callbacks
onAuthError?: (error: any) => void;
onNetworkError?: (error: any) => void;
onRateLimitError?: (error: any) => void;
}
```
***
## Authentication Types
### User
User account information.
```ts theme={null}
interface User {
id: string;
email: string;
fullName?: string;
avatarUrl?: string;
isActive: boolean;
isEmailVerified: boolean;
roleIds: string[];
account: {
id: string;
name: string;
slug: string;
};
createdAt: string;
updatedAt: string;
}
```
***
### Account
Account information.
```ts theme={null}
interface Account {
id: string;
name: string;
slug: string;
description?: string;
settings?: Record<string, any>;
createdAt: string;
updatedAt: string;
}
```
***
### AuthState
Authentication state.
```ts theme={null}
interface AuthState {
user: User | null;
account: Account | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
expiresAt: string | null;
}
```
***
### AuthEvents
Authentication event types.
```ts theme={null}
interface AuthEvents {
"auth:login": (state: AuthState) => void;
"auth:logout": () => void;
"auth:refresh": (state: AuthState) => void;
"auth:error": (error: Error) => void;
}
```
***
### LoginCredentials
Email/password login credentials.
```ts theme={null}
interface LoginCredentials {
account: string;
email: string;
password: string;
}
```
***
### RegisterData
User registration data.
```ts theme={null}
interface RegisterData {
email: string;
password: string;
accountName: string;
accountSlug?: string;
}
```
***
## Collection Types
### Collection
Collection definition.
```ts theme={null}
interface Collection {
id: string;
name: string;
description?: string;
fields: Field[];
accountId: string;
createdAt: string;
updatedAt: string;
}
```
***
### Field
Collection field definition.
```ts theme={null}
interface Field {
name: string;
type: FieldType;
required?: boolean;
unique?: boolean;
options?: FieldOptions;
}
type FieldType =
| "text"
| "number"
| "boolean"
| "date"
| "select"
| "multiselect"
| "file"
| "json"
| "relation";
interface FieldOptions {
// Text options
multiline?: boolean;
minLength?: number;
maxLength?: number;
pattern?: string;
// Number options
min?: number;
max?: number;
default?: number;
// Select options
choices?: string[];
// Relation options
relatedCollection?: string;
}
```
***
## Record Types
### BaseRecord
Base properties for all records.
```ts theme={null}
interface BaseRecord {
id: string;
createdAt: string;
updatedAt: string;
}
```
***
### RecordListParams
Query parameters for listing records.
```ts theme={null}
interface RecordListParams {
filter?: string | Record<string, any>;
sort?: string;
skip?: number;
limit?: number;
fields?: string[] | string;
expand?: string[] | string;
}
```
***
### RecordListResponse
Response from listing records.
```ts theme={null}
interface RecordListResponse<T> {
items: (T & BaseRecord)[];
total: number;
skip: number;
limit: number;
}
```
***
## Query Types
### FilterOperator
Filter operators for queries.
```ts theme={null}
type FilterOperator =
| "=" // Equals
| "!=" // Not equals
| ">" // Greater than
| ">=" // Greater than or equal
| "<" // Less than
| "<=" // Less than or equal
| "~" // Contains
| "!~" // Does not contain
| "?=" // Is empty
| "?!"; // Is not empty
```
***
### SortDirection
Sort direction.
```ts theme={null}
type SortDirection = "asc" | "desc";
```
***
### QueryBuilder
Query builder class.
```ts theme={null}
class QueryBuilder<T = any> {
select(fields: string | string[]): this;
expand(relations: string | string[]): this;
filter(field: string, operator: FilterOperator, value?: any): this;
filter(filterString: string): this;
sort(field: string, direction?: SortDirection): this;
limit(count: number): this;
skip(count: number): this;
page(pageNum: number, perPage?: number): this;
get(): Promise<RecordListResponse<T>>;
first(): Promise<(T & BaseRecord) | null>;
}
```
***
## Realtime Types
### RealTimeState
Realtime connection state.
```ts theme={null}
type RealTimeState = "disconnected" | "connecting" | "connected" | "error";
```
***
### ServerMessage
Realtime server message format.
```ts theme={null}
interface ServerMessage {
type: string;
collection?: string;
data?: any;
}
```
***
### RealTimeEvents
Realtime event types.
```ts theme={null}
interface RealTimeEvents {
"connecting": () => void;
"connected": () => void;
"disconnected": () => void;
"error": (error: Error) => void;
"auth_error": (error: Error) => void;
"message": (message: ServerMessage) => void;
[event: string]: (data: any) => void;
}
```
***
## Error Types
### SnackBaseError
Base error class.
```ts theme={null}
class SnackBaseError extends Error {
readonly code: string;
readonly status?: number;
readonly details?: any;
readonly field?: string;
readonly retryable: boolean;
}
```
***
### Error Classes
```ts theme={null}
class AuthenticationError extends SnackBaseError { }
class AuthorizationError extends SnackBaseError { }
class NotFoundError extends SnackBaseError { }
class ConflictError extends SnackBaseError { }
class ValidationError extends SnackBaseError {
readonly fields?: Record<string, string[]>;
}
class RateLimitError extends SnackBaseError {
readonly retryAfter?: number;
}
class NetworkError extends SnackBaseError { }
class TimeoutError extends SnackBaseError { }
class ServerError extends SnackBaseError { }
```
***
## React Types
### SnackBaseProviderProps
React provider props.
```ts theme={null}
interface SnackBaseProviderProps {
children: React.ReactNode;
baseUrl: string;
apiKey?: string;
defaultAccount?: string;
timeout?: number;
maxRetries?: number;
storageBackend?: StorageBackend;
enableLogging?: boolean;
logLevel?: LogLevel;
}
```
***
### UseAuthResult
Return type of useAuth hook.
```ts theme={null}
interface UseAuthResult extends AuthState {
login: (credentials: LoginCredentials) => Promise<any>;
logout: () => Promise<void>;
register: (data: RegisterData) => Promise<any>;
forgotPassword: (data: PasswordResetRequest) => Promise<any>;
resetPassword: (data: PasswordResetConfirm) => Promise<any>;
isLoading: boolean;
}
```
***
### UseRecordResult
Return type of useRecord hook.
```ts theme={null}
interface UseRecordResult<T> {
data: (T & BaseRecord) | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
```
***
### UseQueryResult
Return type of useQuery hook.
```ts theme={null}
interface UseQueryResult<T> {
data: RecordListResponse<T> | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
```
***
### UseMutationResult
Return type of useMutation hook.
```ts theme={null}
interface UseMutationResult<T> {
mutate: (idOrData: string | Partial<T>, data?: Partial<T>) => Promise<T & BaseRecord>;
isLoading: boolean;
error: Error | null;
reset: () => void;
}
```
***
## Utility Types
### StorageBackend
Storage backend options.
```ts theme={null}
type StorageBackend = "localStorage" | "sessionStorage" | "memory" | "asyncStorage";
```
***
### LogLevel
Logging levels.
```ts theme={null}
type LogLevel = "debug" | "info" | "warn" | "error";
```
***
## Complete Import Example
```ts theme={null}
import {
// Classes
SnackBaseClient,
QueryBuilder,
// Types - Config
SnackBaseConfig,
StorageBackend,
LogLevel,
// Types - Auth
User,
Account,
AuthState,
AuthEvents,
LoginCredentials,
RegisterData,
// Types - Collections
Collection,
Field,
FieldType,
// Types - Records
BaseRecord,
RecordListParams,
RecordListResponse,
// Types - Query
FilterOperator,
SortDirection,
// Types - Realtime
RealTimeState,
ServerMessage,
RealTimeEvents,
// Types - Errors
SnackBaseError,
AuthenticationError,
AuthorizationError,
NotFoundError,
ConflictError,
ValidationError,
RateLimitError,
NetworkError,
TimeoutError,
ServerError,
} from "@snackbase/sdk";
```
## Next Steps
* **[Client Reference](/sdk/js/reference/client)** - Client class reference
* **[Services](/sdk/js/services/records)** - Service references
* **[React Hooks](/sdk/js/react/setup)** - React integration types
# Accounts Service
Source: https://docs.snackbase.dev/sdk/js/services/accounts
Manage accounts in your SnackBase instance
The Accounts service provides methods for managing accounts, which are the top-level containers for users, collections, and data in SnackBase.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the accounts service
const accounts = client.accounts;
```
## List Accounts
```ts theme={null}
const result = await client.accounts.list();
console.log(result.items);
console.log(result.total);
```
## Get an Account
```ts theme={null}
const account = await client.accounts.get("account-id");
console.log(account.id);
console.log(account.name);
```
## Create an Account
```ts theme={null}
const account = await client.accounts.create({
name: "Acme Corporation",
slug: "acme-corp",
});
```
Account slugs must be unique across the entire SnackBase instance.
## Update an Account
```ts theme={null}
const updated = await client.accounts.update("account-id", {
name: "Updated Account Name",
});
```
## Delete an Account
```ts theme={null}
await client.accounts.delete("account-id");
```
Deleting an account permanently deletes all users, collections, and
records within it. This action cannot be undone.
## Get Account Users
```ts theme={null}
const users = await client.accounts.getUsers("account-id");
```
## Account Object
```ts theme={null}
interface Account {
id: string;
name: string;
slug: string;
description?: string;
settings?: Record;
createdAt: string;
updatedAt: string;
}
```
## Next Steps
* **[Users Service](/sdk/js/services/users)** - Manage users
* **[Roles Service](/sdk/js/services/roles)** - Manage roles and permissions
* **[Authentication](/sdk/js/auth/overview)** - Understand authentication
# Admin Service
Source: https://docs.snackbase.dev/sdk/js/services/admin
Manage system configuration and providers
The Admin service provides superadmin functionality for managing system configurations, providers, and testing external connections.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the admin service
const admin = client.admin;
```
All admin operations require superadmin authentication.
## Configuration Statistics
Get configuration statistics by category:
```ts theme={null}
const stats = await admin.getConfigurationStats();
// Returns: { email: number, oauth: number, saml: number, ... }
```
## Recent Configurations
Get recently modified configurations:
```ts theme={null}
const recent = await admin.getRecentConfigurations(10);
// Returns array of recently modified configurations
```
## System Configurations
List all system-level configurations:
```ts theme={null}
// Get all system configurations
const configs = await admin.listSystemConfigurations();
// Filter by category
const emailConfigs = await admin.listSystemConfigurations("email");
```
## Account Configurations
List configurations for a specific account:
```ts theme={null}
const configs = await admin.listAccountConfigurations("account-id");
// Filter by category
const oauthConfigs = await admin.listAccountConfigurations("account-id", "oauth");
```
## Configuration Values
Get configuration values (secrets are masked):
```ts theme={null}
const values = await admin.getConfigurationValues("config-id");
// Returns: { provider: "smtp", host: "smtp.example.com", password: "***" }
```
Update configuration values:
```ts theme={null}
const updated = await admin.updateConfigurationValues("config-id", {
host: "smtp.new-example.com",
port: 587
});
```
## Configuration Status
Enable or disable a configuration:
```ts theme={null}
const config = await admin.updateConfigurationStatus("config-id", false);
```
## Create Configuration
Create a new configuration record:
```ts theme={null}
const config = await admin.createConfiguration({
account_id: "00000000-0000-0000-0000-000000000000",
category: "email",
provider_name: "smtp",
enabled: true
});
```
## Delete Configuration
Delete a configuration:
```ts theme={null}
await admin.deleteConfiguration("config-id");
```
## Providers
List all available provider definitions:
```ts theme={null}
// Get all providers
const providers = await admin.listProviders();
// Filter by category
const emailProviders = await admin.listProviders("email");
```
Get provider schema:
```ts theme={null}
const schema = await admin.getProviderSchema("email", "smtp");
// Returns JSON schema for SMTP configuration
```
## Test Connection
Test a provider connection before saving:
```ts theme={null}
const result = await admin.testConnection("email", "smtp", {
host: "smtp.example.com",
port: 587,
username: "user@example.com",
password: "secret"
});
// Returns: { success: boolean, message: string, latency_ms?: number }
```
## Complete Example
Setting up a new email configuration:
```ts theme={null}
async function setupEmailProvider() {
// 1. Get the schema to understand required fields
const schema = await admin.getProviderSchema("email", "smtp");
// 2. Test the connection first
const testResult = await admin.testConnection("email", "smtp", {
host: "smtp.gmail.com",
port: 587,
username: "your-email@gmail.com",
password: "your-app-password"
});
if (!testResult.success) {
throw new Error(`Connection failed: ${testResult.message}`);
}
// 3. Create the configuration
const config = await admin.createConfiguration({
account_id: "00000000-0000-0000-0000-000000000000",
category: "email",
provider_name: "smtp",
enabled: true
});
// 4. Set the values
await admin.updateConfigurationValues(config.id, {
host: "smtp.gmail.com",
port: 587,
username: "your-email@gmail.com",
password: "your-app-password",
use_tls: true
});
return config;
}
```
## Next Steps
* **[Email Templates Service](/sdk/js/services/email-templates)** - Manage email templates
* **[Dashboard](/sdk/js/services/dashboard)** - View system statistics
* **[Audit Logs](/sdk/js/services/audit-logs)** - Track configuration changes
# API Keys Service
Source: https://docs.snackbase.dev/sdk/js/services/api-keys
Manage API keys for service-to-service authentication
The API Keys service allows you to create and manage API keys for authenticating service accounts and external applications.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the API keys service
const apiKeys = client.apiKeys;
```
API keys are scoped to the current user and account. When you create a key,
store it securely - the full key value is shown only once.
## List API Keys
Get all API keys for the current user:
```ts theme={null}
const keys = await client.apiKeys.list();
```
Keys are masked for security, showing only the last 4 characters:
```ts theme={null}
[
{
"id": "key-id-1",
"name": "Production App",
"key": "sb_sk_...x9k2", // Masked
"is_active": true,
"last_used": "2024-01-15T10:30:00Z",
"created_at": "2024-01-01T00:00:00Z"
}
]
```
## Get an API Key
Get details for a specific API key:
```ts theme={null}
const key = await client.apiKeys.get("key-id");
```
## Create an API Key
Create a new API key:
```ts theme={null}
const key = await client.apiKeys.create({
name: "My Integration"
});
```
The response includes the full key value:
```ts theme={null}
{
"id": "key-id-2",
"name": "My Integration",
"key": "sb_sk_a1b2c3d4e5f6g7h8i9j0", // Full key - save this!
"is_active": true,
"created_at": "2024-01-15T10:30:00Z"
}
```
Save the key value immediately after creation. The full key is shown only
once and cannot be retrieved later.
## Revoke an API Key
Revoke (delete) an API key:
```ts theme={null}
await client.apiKeys.revoke("key-id");
```
Revoking an API key is permanent. Any applications using this key will
immediately lose access.
## Using API Keys
Initialize a client with an API key:
```ts theme={null}
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
apiKey: "sb_sk_a1b2c3d4e5f6g7h8i9j0"
});
```
The API key will be included in all requests via the `X-API-Key` header.
## Complete Example
API key management component:
```ts theme={null}
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@snackbase/sdk/react";
function ApiKeyManager() {
const queryClient = useQueryClient();
const { data: keys, isLoading } = useQuery({
queryKey: ["api-keys"],
queryFn: () => client.apiKeys.list()
});
const createMutation = useMutation({
mutationFn: (data) => client.apiKeys.create(data),
onSuccess: (key) => {
// Show the key to the user to save
alert(`Save this key - it won't be shown again:\n\n${key.key}`);
queryClient.invalidateQueries(["api-keys"]);
}
});
const revokeMutation = useMutation({
mutationFn: (keyId) => client.apiKeys.revoke(keyId),
onSuccess: () => {
queryClient.invalidateQueries(["api-keys"]);
}
});
if (isLoading) return Loading...;
return (
API Keys
Name
Status
Last Used
Actions
{keys?.map(key => (
{key.name}
{key.is_active ? "Active" : "Inactive"}
{key.last_used ? new Date(key.last_used).toLocaleString() : "Never"}
))}
);
}
```
## Best Practices
1. **Store keys securely**: Use environment variables or secret management systems
2. **Use descriptive names**: Name your keys to identify their purpose
3. **Rotate regularly**: Create new keys and revoke old ones periodically
4. **Monitor usage**: Check `last_used` to identify inactive keys
5. **Revoke unused keys**: Remove keys that are no longer needed
## Next Steps
* **[Auth API Keys](/sdk/js/auth/api-keys)** - Authenticate with API keys
* **[Users Service](/sdk/js/services/users)** - Manage user accounts
* **[Audit Logs](/sdk/js/services/audit-logs)** - Track API key usage
# Audit Logs Service
Source: https://docs.snackbase.dev/sdk/js/services/audit-logs
View and export audit logs
The Audit Logs service provides access to system activity logs for compliance, security monitoring, and debugging purposes.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the audit logs service
const auditLogs = client.auditLogs;
```
Audit log access requires superadmin authentication.
## List Audit Logs
Retrieve audit logs with optional filtering:
```ts theme={null}
// Get all audit logs
const response = await client.auditLogs.list();
// Returns: { items: AuditLog[], total: number, audit_logging_enabled: boolean }
```
Filter by specific criteria:
```ts theme={null}
const response = await client.auditLogs.list({
account_id: "account-id",
table_name: "users",
operation: "update",
limit: 50,
skip: 0
});
```
Available filters:
| Parameter | Type | Description |
| ------------ | ------ | -------------------------------------------- |
| `account_id` | string | Filter by account |
| `table_name` | string | Filter by table/collection |
| `operation` | string | Filter by operation (create, update, delete) |
| `user_id` | string | Filter by user |
| `limit` | number | Max results per page |
| `skip` | number | Number of results to skip |
| `date_from` | string | ISO date filter (start) |
| `date_to` | string | ISO date filter (end) |
## Get a Single Audit Log
Get details for a specific audit log entry:
```ts theme={null}
const log = await client.auditLogs.get("log-id");
```
## Export Audit Logs
Export audit logs in various formats:
### Export as JSON
```ts theme={null}
const jsonData = await client.auditLogs.export(
{ table_name: "users" },
"json"
);
// Returns JSON string
```
### Export as CSV
```ts theme={null}
const csvData = await client.auditLogs.export(
{ table_name: "users" },
"csv"
);
// Returns CSV string
```
### Export as PDF
```ts theme={null}
const pdfBase64 = await client.auditLogs.export(
{ table_name: "users" },
"pdf"
);
// Returns base64-encoded PDF string
```
PDF exports are returned as base64-encoded strings. Decode them to binary
before saving or displaying.
## Complete Example
Audit log viewer component:
```ts theme={null}
import { useState } from "react";
import { useQuery } from "@snackbase/sdk/react";
function AuditLogViewer() {
const [filters, setFilters] = useState({
table_name: "",
operation: "",
limit: 50
});
const { data: response, isLoading } = useQuery({
queryKey: ["audit-logs", filters],
queryFn: () => client.auditLogs.list(filters)
});
const handleExport = async (format: "json" | "csv" | "pdf") => {
const data = await client.auditLogs.export(filters, format);
if (format === "pdf") {
// Decode base64 for PDF
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "audit-logs.pdf";
a.click();
} else {
// JSON or CSV - download directly
const blob = new Blob([data], {
type: format === "json" ? "application/json" : "text/csv"
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `audit-logs.${format}`;
a.click();
}
};
if (isLoading) return Loading...;
return (
Audit Logs
Status: {response.audit_logging_enabled ? "Enabled" : "Disabled"}
Total entries: {response.total}
Timestamp
User
Table
Operation
Changes
{response.items.map(log => (
{new Date(log.created_at).toLocaleString()}
{log.user_email}
{log.table_name}
{log.operation}
{JSON.stringify(log.changes)}
))}
);
}
```
## Next Steps
* **[Dashboard](/sdk/js/services/dashboard)** - View system statistics
* **[Admin Service](/sdk/js/services/admin)** - Manage system configuration
* **[Realtime](/sdk/js/realtime/overview)** - Monitor events in real-time
# Collection Rules Service
Source: https://docs.snackbase.dev/sdk/js/services/collection-rules
Manage access rules and field permissions for collections
The Collection Rules service allows you to define access rules and field-level permissions for collections. This service works with the Roles service to provide fine-grained access control.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the collection rules service
const collectionRules = client.collectionRules;
```
Collection rule management requires superadmin authentication.
## Get Collection Rules
Get the access rules and field permissions for a specific collection:
```ts theme={null}
const rules = await client.collectionRules.get("posts");
```
The response includes:
```ts theme={null}
{
"collection_name": "posts",
"rules": [
{
"name": "own_posts_only",
"effect": "allow",
"action": "read",
"condition": {
"sql": "{{current_user}} = posts.user_id"
}
}
],
"field_permissions": [
{
"field": "email",
"read_roles": ["admin"],
"write_roles": ["admin"]
}
]
}
```
## Update Collection Rules
Update the access rules and field permissions for a collection:
```ts theme={null}
const updated = await client.collectionRules.update("posts", {
rules: [
{
name: "own_posts_only",
effect: "allow",
action: "read",
condition: {
sql: "{{current_user}} = posts.user_id"
}
},
{
name: "authors_can_create",
effect: "allow",
action: "create",
condition: {
sql: "'author' IN {{current_user_roles}}"
}
}
],
field_permissions: [
{
field: "email",
read_roles: ["admin"],
write_roles: ["admin"]
},
{
field: "published",
read_roles: ["*"],
write_roles: ["admin", "editor"]
}
]
});
```
## Validate a Rule
Validate a rule expression before using it:
```ts theme={null}
const result = await client.collectionRules.validateRule(
"{{current_user}} = posts.user_id",
"read",
["id", "user_id", "title", "content"]
);
```
The response indicates if the rule is valid and any issues:
```ts theme={null}
{
"is_valid": true,
"errors": [],
"warnings": []
}
```
## Test a Rule
Test how a rule evaluates with a specific context:
```ts theme={null}
const result = await client.collectionRules.testRule(
"{{current_user}} = posts.user_id",
{
current_user: "user-123",
posts: { user_id: "user-123" }
}
);
```
The response shows the evaluation result:
```ts theme={null}
{
"allowed": true,
"reason": "Rule evaluated to true"
}
```
## Rule Structure
Rules are defined with the following structure:
```ts theme={null}
{
name: string; // Unique rule name
effect: "allow" | "deny"; // Allow or deny access
action: string; // Operation: list, view, create, update, delete
priority?: number; // Higher priority rules evaluated first
condition?: {
sql?: string; // SQL condition using macros
expression?: string; // Expression language condition
};
}
```
## Field Permissions
Field-level permissions control read/write access to specific fields:
```ts theme={null}
{
field: string; // Field name
read_roles: string[]; // Roles that can read this field ("*" for all)
write_roles: string[]; // Roles that can write this field
}
```
## Common Patterns
### Ownership-Based Access
Allow users to only access their own records:
```ts theme={null}
{
name: "own_records",
effect: "allow",
action: "read",
condition: {
sql: "{{current_user}} = records.user_id"
}
}
```
### Role-Based Access
Allow access based on user roles:
```ts theme={null}
{
name: "editors_only",
effect: "allow",
action: "update",
condition: {
sql: "'editor' IN {{current_user_roles}}"
}
}
```
### Group Membership
Allow access based on group membership:
```ts theme={null}
{
name: "group_members",
effect: "allow",
action: "read",
condition: {
sql: "{{current_user}} IN (SELECT user_id FROM group_members WHERE group_id = 'group-123')"
}
}
```
### Field-Level Privacy
Restrict sensitive fields to admins:
```ts theme={null}
{
field_permissions: [
{
field: "salary",
read_roles: ["admin", "hr"],
write_roles: ["admin"]
},
{
field: "email",
read_roles: ["*"],
write_roles: ["admin"]
}
]
}
```
## Complete Example
Collection rules editor:
```ts theme={null}
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@snackbase/sdk/react";
function CollectionRulesEditor({ collectionName }) {
const queryClient = useQueryClient();
const { data: rules, isLoading } = useQuery({
queryKey: ["collection-rules", collectionName],
queryFn: () => client.collectionRules.get(collectionName)
});
const updateMutation = useMutation({
mutationFn: (data) => client.collectionRules.update(collectionName, data),
onSuccess: () => {
queryClient.invalidateQueries(["collection-rules", collectionName]);
}
});
const handleSave = () => {
updateMutation.mutate(rules);
};
if (isLoading) return Loading...;
return (
Rules: {collectionName}
Access Rules
{rules.rules.map((rule, index) => (
{
const newRules = [...rules.rules];
newRules[index].name = e.target.value;
updateRules(newRules);
}}
/>
{/* ... more fields */}
))}
);
}
```
## Next Steps
* **[Roles Service](/sdk/js/services/roles)** - Define roles and permissions
* **[Macros Service](/sdk/js/services/macros)** - Create reusable SQL macros
* **[Permissions](/permissions)** - Understand the permission system
# Collections Service
Source: https://docs.snackbase.dev/sdk/js/services/collections
Manage collections and their schemas
The Collections service allows you to create, read, update, and delete collections.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the collections service
const collections = client.collections;
```
## List Collections
```ts theme={null}
const result = await client.collections.list();
```
## Get a Collection
```ts theme={null}
const collection = await client.collections.get("collection-id");
```
## Get Collection Names
```ts theme={null}
const names = await client.collections.getNames();
```
## Create a Collection
```ts theme={null}
const collection = await client.collections.create({
name: "posts",
description: "Blog posts and articles",
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "content",
type: "text",
required: false,
},
],
});
```
To update the fields of a collection, you typically need to create a
migration. See the Migrations service for more details.
## Update a Collection
```ts theme={null}
const updated = await client.collections.update("collection-id", {
description: "Updated description",
});
```
## Delete a Collection
```ts theme={null}
await client.collections.delete("collection-id");
```
Deleting a collection permanently deletes all records within it. This
action cannot be undone.
## Export Collections
Export collections to JSON format for backup or migration:
```ts theme={null}
// Export all collections
const exportData = await client.collections.export();
// exportData contains: { collections: Collection[], rules: CollectionRule[] }
```
You can also export specific collections:
```ts theme={null}
// Export specific collections by ID
const exportData = await client.collections.export({
collection_ids: ['col-123', 'col-456']
});
```
Exporting collections requires superadmin authentication. The export
includes collection schemas and rules, but not the actual records.
## Import Collections
Import collections from a previous export:
```ts theme={null}
// Import with error strategy (fail on conflicts)
const result = await client.collections.import({
data: exportData,
strategy: 'error'
});
```
The `strategy` parameter determines how conflicts are handled:
* **`error`** - Fail if a collection already exists (default)
* **`skip`** - Skip existing collections, only import new ones
* **`update`** - Update existing collections with the schema from the import
```ts theme={null}
// Import with skip strategy
const result = await client.collections.import({
data: exportData,
strategy: 'skip'
});
// Import with update strategy
const result = await client.collections.import({
data: exportData,
strategy: 'update'
});
```
Importing collections requires superadmin authentication. The import
result includes per-collection status and migration IDs for tracking.
## Field Types
| Type | Description |
| --------- | -------------------------- |
| `text` | Single-line text |
| `number` | Numeric value |
| `boolean` | True/false value |
| `date` | Date/time value |
| `select` | Single choice from options |
| `json` | JSON object |
## Next Steps
* **[Records Service](/sdk/js/services/records)** - Manage records in collections
* **[Query Builder](/sdk/js/query/overview)** - Query collection data
* **[Rules](/permissions)** - Understand permission rules
# Dashboard Service
Source: https://docs.snackbase.dev/sdk/js/services/dashboard
Retrieve dashboard statistics and metrics
The Dashboard service provides statistics and metrics for monitoring your SnackBase instance. It's useful for building admin dashboards and monitoring systems.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the dashboard service
const dashboard = client.dashboard;
```
Dashboard statistics require superadmin authentication.
## Get Statistics
Retrieve comprehensive dashboard statistics:
```ts theme={null}
const stats = await client.dashboard.getStats();
```
The response includes:
```ts theme={null}
{
accounts: {
total: number; // Total number of accounts
active: number; // Currently active accounts
new_this_month: number; // Accounts created this month
};
users: {
total: number; // Total number of users
active: number; // Currently active users
new_this_month: number; // Users created this month
};
collections: {
total: number; // Total number of collections
system: number; // Built-in system collections
custom: number; // User-created collections
};
records: {
total: number; // Total records across all collections
created_today: number; // Records created today
created_this_month: number; // Records created this month
};
recent_activity: {
logins: number; // Login count in last 24 hours
api_calls: number; // API calls in last 24 hours
errors: number; // Errors in last 24 hours
};
health: {
database_status: "healthy" | "degraded" | "down";
cache_status: "healthy" | "degraded" | "down";
uptime_seconds: number;
};
}
```
## Use Cases
### Admin Dashboard
Display system statistics in an admin interface:
```ts theme={null}
import { useQuery } from "@snackbase/sdk/react";
function DashboardStats() {
const { data: stats, isLoading, error } = useQuery({
queryKey: ["dashboard-stats"],
queryFn: () => client.dashboard.getStats(),
refetchInterval: 60000, // Refresh every minute
});
if (isLoading) return Loading...;
if (error) return Error loading stats;
return (
System Overview
Total Accounts: {stats.accounts.total}
Total Users: {stats.users.total}
Total Collections: {stats.collections.total}
Total Records: {stats.records.total}
Database Status: {stats.health.database_status}
);
}
```
### Health Monitoring
Check system health programmatically:
```ts theme={null}
async function checkSystemHealth() {
const stats = await client.dashboard.getStats();
const isHealthy = stats.health.database_status === "healthy" &&
stats.health.cache_status === "healthy";
if (!isHealthy) {
console.warn("System health check failed:", stats.health);
// Trigger alert or notification
}
return isHealthy;
}
```
### Activity Monitoring
Monitor recent activity levels:
```ts theme={null}
async function getActivityReport() {
const stats = await client.dashboard.getStats();
return {
logins: stats.recent_activity.logins,
apiCalls: stats.recent_activity.api_calls,
errors: stats.recent_activity.errors,
errorRate: (stats.recent_activity.errors / stats.recent_activity.api_calls * 100).toFixed(2)
};
}
```
## Next Steps
* **[Realtime](/sdk/js/realtime/overview)** - Monitor events in real-time
* **[Audit Logs](/sdk/js/services/audit-logs)** - Track system activity
* **[Admin Service](/sdk/js/services/admin)** - Manage system configuration
# Email Templates
Source: https://docs.snackbase.dev/sdk/js/services/email-templates
Manage email templates with the SnackBase JavaScript SDK
The Email Templates service allows you to manage transactional email templates for user communications.
## Overview
Email templates in SnackBase are used for:
* User welcome emails
* Password reset emails
* Email verification emails
* Invitation emails
* Custom notifications
## Installation
The Email Templates service is included in the `@snackbase/sdk` package:
```bash theme={null}
npm install @snackbase/sdk
```
## Setup
```typescript theme={null}
import { SnackBaseClient } from '@snackbase/sdk';
const client = new SnackBaseClient({
baseUrl: 'https://api.snackbase.dev',
apiKey: 'your-api-key',
});
const emailTemplates = client.emailTemplates;
```
## Methods
### list()
List all email templates with optional filtering.
```typescript theme={null}
const templates = await emailTemplates.list({
template_type: 'welcome',
locale: 'en',
enabled: true,
status: 'active',
start_date: '2026-01-01',
end_date: '2026-12-31',
page: 1,
limit: 30
});
```
**Parameters:**
* `template_type?: string` - Filter by template type
* `locale?: string` - Filter by locale (e.g., 'en', 'es')
* `enabled?: boolean` - Filter by enabled status
* `status?: string` - Filter by status
* `start_date?: string` - Filter by start date
* `end_date?: string` - Filter by end date
* `page?: number` - Page number
* `limit?: number` - Results per page
### get()
Get a specific email template by ID.
```typescript theme={null}
const template = await emailTemplates.get('template-id');
```
**Response:**
```typescript theme={null}
{
id: string;
name: string;
template_type: string;
locale: string;
subject: string;
html_body: string;
text_body: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
```
### update()
Update an email template.
```typescript theme={null}
const updated = await emailTemplates.update('template-id', {
subject: 'Welcome to {{app_name}}!',
html_body: '...',
text_body: 'Plain text version',
enabled: true
});
```
### render()
Render a template with variables (preview).
```typescript theme={null}
const rendered = await emailTemplates.render({
template_type: 'welcome',
locale: 'en',
variables: {
user_name: 'John',
app_name: 'MyApp',
verification_url: 'https://example.com/verify'
},
subjectOverride: 'Custom Subject', // Optional
htmlBodyOverride: '...', // Optional
textBodyOverride: 'Plain text' // Optional
});
```
### sendTest()
Send a test email to verify the template.
```typescript theme={null}
await emailTemplates.sendTest('template-id', {
recipient_email: 'test@example.com',
variables: {
user_name: 'Test User',
app_name: 'MyApp'
},
provider: 'smtp' // Optional: override email provider
});
```
### listLogs()
List email send logs.
```typescript theme={null}
const logs = await emailTemplates.listLogs({
template_type: 'welcome',
locale: 'en',
account_id: 'account-id',
enabled: true,
status: 'sent',
start_date: '2026-01-01',
end_date: '2026-12-31',
page: 1,
limit: 30
});
```
## Template Variables
Templates use Mustache syntax for variables:
```html theme={null}
Welcome, {{user_name}}!
Thanks for joining {{app_name}}.
Verify your email
```
## Built-in Template Types
| Type | Purpose |
| ---------------- | ---------------------- |
| `welcome` | New user welcome email |
| `verification` | Email verification |
| `password_reset` | Password reset link |
| `invitation` | User invitation |
| `magic_link` | Magic link login |
## Complete Example
```typescript theme={null}
import { SnackBaseClient } from '@snackbase/sdk';
const client = new SnackBaseClient({
baseUrl: 'https://api.snackbase.dev',
apiKey: 'your-api-key',
});
// List all welcome templates
const welcomeTemplates = await client.emailTemplates.list({
template_type: 'welcome'
});
// Update a template
const updated = await client.emailTemplates.update('template-id', {
subject: 'Welcome to {{app_name}}!',
html_body: `
Welcome, {{user_name}}!
Thanks for joining {{app_name}}.
Verify your email
`,
enabled: true
});
// Send a test email
await client.emailTemplates.sendTest('template-id', {
recipient_email: 'test@example.com',
variables: {
user_name: 'John Doe',
app_name: 'MyApp',
verification_url: 'https://example.com/verify?token=abc123'
}
});
// Check send logs
const logs = await client.emailTemplates.listLogs({
template_type: 'welcome',
limit: 10
});
```
## Error Handling
```typescript theme={null}
import { SnackBaseError } from '@snackbase/sdk';
try {
await client.emailTemplates.sendTest('template-id', {
recipient_email: 'test@example.com',
variables: {}
});
} catch (error) {
if (error instanceof SnackBaseError) {
console.error('Email send failed:', error.message);
if (error.statusCode === 422) {
console.error('Validation error:', error.fields);
}
}
}
```
## Next Steps
* **[Admin Service](/sdk/js/services/admin)** - Configure email providers
* **[Error Handling](/sdk/js/errors/overview)** - Handle errors gracefully
# Endpoints Service
Source: https://docs.snackbase.dev/sdk/js/services/endpoints
Manage custom serverless-like HTTP endpoints
The Endpoints service allows you to create and manage custom HTTP endpoints that execute action pipelines when called.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the endpoints service
const endpoints = client.endpoints;
```
## List Endpoints
```ts theme={null}
const result = await client.endpoints.list();
// result.items - array of endpoints
// result.total - total count
```
With filters:
```ts theme={null}
const result = await client.endpoints.list({
method: "POST",
enabled: true,
limit: 50,
offset: 0,
});
```
## Get an Endpoint
```ts theme={null}
const endpoint = await client.endpoints.get("endpoint-id");
```
## Create an Endpoint
```ts theme={null}
const endpoint = await client.endpoints.create({
name: "Submit Feedback",
path: "/submit-feedback",
method: "POST",
auth_required: true,
actions: [
{
type: "create_record",
config: {
collection: "feedback",
data: {
message: "{{request.body.message}}",
user_id: "{{auth.user_id}}",
submitted_at: "{{now}}",
},
},
},
],
response_template: {
status: 201,
body: { message: "Feedback received" },
},
});
```
## Update an Endpoint
```ts theme={null}
const updated = await client.endpoints.update("endpoint-id", {
name: "Updated Endpoint",
auth_required: false,
});
```
## Delete an Endpoint
```ts theme={null}
await client.endpoints.delete("endpoint-id");
```
## Toggle Enabled/Disabled
```ts theme={null}
const endpoint = await client.endpoints.toggle("endpoint-id");
console.log(endpoint.enabled); // toggled value
```
## List Executions
View the execution history:
```ts theme={null}
const executions = await client.endpoints.listExecutions("endpoint-id", {
limit: 50,
offset: 0,
});
for (const exec of executions.items) {
console.log(exec.status);
console.log(exec.duration_ms);
}
```
## Calling Custom Endpoints
Once created, call your custom endpoints at `/api/v1/x/{path}`:
```ts theme={null}
// Using the SDK's HTTP client directly
const response = await fetch(
"https://api.example.com/api/v1/x/submit-feedback",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "Great product!",
}),
}
);
```
With path parameters:
```ts theme={null}
// Endpoint path: /customers/:customer_id/summary
const response = await fetch(
"https://api.example.com/api/v1/x/customers/cust-123/summary",
{
headers: { Authorization: `Bearer ${token}` },
}
);
```
# Files Service
Source: https://docs.snackbase.dev/sdk/js/services/files
Upload, download, and delete files
The Files service handles file uploads, downloads, and deletion. It's useful for managing user-uploaded content, documents, images, and other binary data.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the files service
const files = client.files;
```
## Upload Files
Upload a file from a browser or React Native environment:
```ts theme={null}
// From a file input
const fileInput = document.querySelector("#file-input");
const file = fileInput.files[0];
const metadata = await client.files.upload(file);
// Returns: { path: string, filename: string, size: number, content_type: string, ... }
```
Upload with custom options:
```ts theme={null}
const metadata = await client.files.upload(file, {
filename: "custom-name.pdf",
contentType: "application/pdf"
});
```
Upload a Blob directly:
```ts theme={null}
const blob = new Blob(["Hello, world!"], { type: "text/plain" });
const metadata = await client.files.upload(blob, {
filename: "hello.txt"
});
```
## Download Files
Get a download URL for a file:
```ts theme={null}
const url = client.files.getDownloadUrl("/uploads/documents/report.pdf");
// Use in an
tag
const img = document.createElement("img");
img.src = url;
// Or open in new tab
window.open(url, "_blank");
// Or use with fetch
const response = await fetch(url);
const blob = await response.blob();
```
Download URLs include authentication tokens and are temporary. Don't cache them
for extended periods.
## Delete Files
Delete a file from the server:
```ts theme={null}
await client.files.delete("/uploads/documents/old-report.pdf");
```
## Complete Example
File upload component with progress:
```ts theme={null}
import { useState } from "react";
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com"
});
function FileUpload() {
const [uploading, setUploading] = useState(false);
const [uploadedFile, setUploadedFile] = useState(null);
const handleFileChange = async (event) => {
const file = event.target.files[0];
if (!file) return;
setUploading(true);
try {
const metadata = await client.files.upload(file);
setUploadedFile(metadata);
} catch (error) {
console.error("Upload failed:", error);
} finally {
setUploading(false);
}
};
const handleDelete = async () => {
if (uploadedFile) {
await client.files.delete(uploadedFile.path);
setUploadedFile(null);
}
};
return (
{uploading && Uploading...
}
{uploadedFile && (
)}
);
}
```
## Integration with Records
Store file metadata in records:
```ts theme={null}
// 1. Upload the file
const metadata = await client.files.upload(file);
// 2. Create a record with the file reference
const record = await client.records.create("documents", {
title: "My Document",
file_path: metadata.path,
file_name: metadata.filename,
file_size: metadata.size,
uploaded_at: new Date().toISOString()
});
```
Retrieve and display files from records:
```ts theme={null}
// Get records with files
const documents = await client.records.list("documents");
// Display each document
documents.items.forEach(doc => {
const url = client.files.getDownloadUrl(doc.file_path);
console.log(`${doc.title}: ${url}`);
});
```
## Error Handling
Handle file upload errors:
```ts theme={null}
try {
const metadata = await client.files.upload(file);
} catch (error) {
if (error instanceof ValidationError) {
console.error("Invalid file:", error.fields);
} else if (error instanceof PayloadTooLargeError) {
console.error("File too large");
} else {
console.error("Upload failed:", error.message);
}
}
```
## Next Steps
* **[Records Service](/sdk/js/services/records)** - Store file metadata in records
* **[Realtime](/sdk/js/realtime/overview)** - Get notified of new uploads
* **[Error Handling](/sdk/js/errors/overview)** - Handle upload errors
# Groups Service
Source: https://docs.snackbase.dev/sdk/js/services/groups
Manage user groups for easier access control
The Groups service provides methods for managing user groups.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the groups service
const groups = client.groups;
```
## List Groups
```ts theme={null}
const result = await client.groups.list();
```
## Get a Group
```ts theme={null}
const group = await client.groups.get("group-id");
```
## Create a Group
```ts theme={null}
const group = await client.groups.create({
name: "Editors",
description: "Content editors team",
});
```
## Update a Group
```ts theme={null}
const updated = await client.groups.update("group-id", {
name: "Senior Editors",
});
```
## Delete a Group
```ts theme={null}
await client.groups.delete("group-id");
```
Deleting a group removes it from all users. The users themselves are not
deleted.
## Add User to Group
```ts theme={null}
await client.groups.addUser("group-id", "user-id");
```
## Remove User from Group
```ts theme={null}
await client.groups.removeUser("group-id", "user-id");
```
## Get Group Members
```ts theme={null}
const members = await client.groups.getMembers("group-id");
```
## Next Steps
* **[Roles Service](/sdk/js/services/roles)** - Manage roles and permissions
* **[Users Service](/sdk/js/services/users)** - Manage users
* **[Invitations Service](/sdk/js/services/invitations)** - Invite users to accounts
# Hooks Service
Source: https://docs.snackbase.dev/sdk/js/services/hooks
Manage API-defined hooks with event, schedule, and manual triggers
The Hooks service allows you to create and manage API-defined hooks -- automated actions triggered by events, cron schedules, or manual invocation.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the hooks service
const hooks = client.hooks;
```
## List Hooks
```ts theme={null}
const result = await client.hooks.list();
// result.items - array of hooks
// result.total - total count
```
With filters:
```ts theme={null}
const result = await client.hooks.list({
trigger_type: "event",
enabled: true,
limit: 50,
offset: 0,
});
```
## Get a Hook
```ts theme={null}
const hook = await client.hooks.get("hook-id");
```
## Create a Hook
### Event-triggered hook
```ts theme={null}
const hook = await client.hooks.create({
name: "Notify on new orders",
trigger: {
type: "event",
event: "records.create",
collection: "orders",
},
condition: "total >= 100",
actions: [
{
type: "send_webhook",
config: {
url: "https://slack.example.com/webhook",
body_template: {
text: "New order: ${{record.total}}",
},
},
},
],
});
```
### Scheduled hook
```ts theme={null}
const hook = await client.hooks.create({
name: "Daily report",
trigger: {
type: "schedule",
cron: "0 9 * * *", // Every day at 9 AM
},
actions: [
{
type: "send_email",
config: {
to: "team@example.com",
subject: "Daily Report",
template_name: "daily_report",
},
},
],
});
```
### Manual hook
```ts theme={null}
const hook = await client.hooks.create({
name: "Data cleanup",
trigger: { type: "manual" },
actions: [
{
type: "delete_record",
config: {
collection: "temp_data",
record_id: "all-expired",
},
},
],
});
```
## Update a Hook
```ts theme={null}
const updated = await client.hooks.update("hook-id", {
name: "Updated hook name",
condition: "total >= 200",
});
```
## Delete a Hook
```ts theme={null}
await client.hooks.delete("hook-id");
```
## Toggle Enabled/Disabled
```ts theme={null}
const hook = await client.hooks.toggle("hook-id");
console.log(hook.enabled); // toggled value
```
Toggling a scheduled hook recalculates `next_run_at` when re-enabled, or clears it when disabled.
## Trigger a Hook Manually
```ts theme={null}
const result = await client.hooks.trigger("hook-id");
console.log(result.queued); // true
```
## List Executions
View the execution history for a hook:
```ts theme={null}
const executions = await client.hooks.listExecutions("hook-id", {
limit: 50,
offset: 0,
});
for (const exec of executions.items) {
console.log(exec.trigger_type); // "event", "schedule", "manual"
console.log(exec.status); // "success", "failed", "partial"
console.log(exec.actions_executed); // number of actions completed
console.log(exec.duration_ms); // execution time in ms
console.log(exec.error_message); // error details (if any)
}
```
# Invitations Service
Source: https://docs.snackbase.dev/sdk/js/services/invitations
Invite users to join your account
The Invitations service allows you to invite users to join your account and tracks the invitation status.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the invitations service
const invitations = client.invitations;
```
## List Invitations
Get all invitations for the current account:
```ts theme={null}
const invitations = await client.invitations.list();
```
With pagination:
```ts theme={null}
const invitations = await client.invitations.list({
limit: 20,
skip: 0
});
```
## Create an Invitation
Invite a new user to join your account:
```ts theme={null}
const invitation = await client.invitations.create({
email: "user@example.com",
role_id: "role-id"
});
```
The response includes:
```ts theme={null}
{
"id": "invitation-id",
"email": "user@example.com",
"role_id": "role-id",
"status": "pending",
"token": "unique-invitation-token",
"expires_at": "2024-02-15T00:00:00Z",
"created_at": "2024-01-15T00:00:00Z"
}
```
## Resend an Invitation
Resend the invitation email if it wasn't received:
```ts theme={null}
await client.invitations.resend("invitation-id");
```
## Get Public Invitation Details
Retrieve invitation details using the token (no authentication required):
```ts theme={null}
const invitation = await client.invitations.getPublic("invitation-token");
```
Useful for invitation acceptance pages where the user isn't authenticated yet.
## Accept an Invitation
Accept an invitation and create a user account:
```ts theme={null}
const authResponse = await client.invitations.accept(
"invitation-token",
"new-password"
);
```
The response includes authentication tokens and the created user:
```ts theme={null}
{
"user": { ... },
"account": { ... },
"token": "access-token",
"refresh_token": "refresh-token",
"expires_at": "2024-01-16T00:00:00Z"
}
// The auth state is automatically stored
console.log(client.isAuthenticated); // true
console.log(client.user); // User object
```
## Cancel an Invitation
Cancel a pending invitation:
```ts theme={null}
await client.invitations.cancel("invitation-id");
```
## Complete Example
Invitation management component:
```ts theme={null}
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@snackbase/sdk/react";
function InvitationManager() {
const queryClient = useQueryClient();
const [email, setEmail] = useState("");
const { data: invitations, isLoading } = useQuery({
queryKey: ["invitations"],
queryFn: () => client.invitations.list()
});
const createMutation = useMutation({
mutationFn: (data) => client.invitations.create(data),
onSuccess: () => {
setEmail("");
queryClient.invalidateQueries(["invitations"]);
}
});
const resendMutation = useMutation({
mutationFn: (id) => client.invitations.resend(id)
});
const cancelMutation = useMutation({
mutationFn: (id) => client.invitations.cancel(id),
onSuccess: () => {
queryClient.invalidateQueries(["invitations"]);
}
});
const handleCreate = (e) => {
e.preventDefault();
createMutation.mutate({ email, role_id: "default-role" });
};
if (isLoading) return Loading...;
return (
User Invitations
Email
Status
Expires
Actions
{invitations?.map(inv => (
{inv.email}
{inv.status}
{new Date(inv.expires_at).toLocaleDateString()}
{inv.status === "pending" && (
<>
>
)}
))}
);
}
```
## Invitation Acceptance Flow
For public invitation acceptance pages:
```ts theme={null}
import { useEffect, useState } from "react";
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com"
});
function InvitationAcceptPage({ token }) {
const [invitation, setInvitation] = useState(null);
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
useEffect(() => {
client.invitations.getPublic(token)
.then(setInvitation)
.catch(setError);
}, [token]);
const handleAccept = async (e) => {
e.preventDefault();
try {
await client.invitations.accept(token, password);
// User is now logged in, redirect to dashboard
window.location.href = "/dashboard";
} catch (err) {
setError(err.message);
}
};
if (!invitation) return Loading...;
if (error) return Error: {error};
return (
Accept Invitation
You've been invited to join {invitation.account_name}
);
}
```
## Next Steps
* **[Users Service](/sdk/js/services/users)** - Manage user accounts
* **[Roles Service](/sdk/js/services/roles)** - Assign roles to invited users
* **[Groups Service](/sdk/js/services/groups)** - Add users to groups
# Jobs Service
Source: https://docs.snackbase.dev/sdk/js/services/jobs
Monitor and manage background jobs (superadmin only)
The Jobs service provides superadmin access to the background job queue, allowing you to monitor job status, view statistics, retry failed jobs, and cancel pending ones.
All Jobs API endpoints require **superadmin** authentication.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the jobs service
const jobs = client.jobs;
```
## Get Job Statistics
View aggregate counts across all job statuses:
```ts theme={null}
const stats = await client.jobs.stats();
console.log(stats.pending); // jobs waiting to execute
console.log(stats.running); // currently executing
console.log(stats.completed); // finished successfully
console.log(stats.failed); // execution failed
console.log(stats.retrying); // waiting to retry
console.log(stats.dead); // retries exhausted
console.log(stats.failure_rate); // (failed + dead) / (completed + failed + dead)
```
## List Jobs
```ts theme={null}
const result = await client.jobs.list();
// result.items - array of jobs
// result.total - total count
```
With filters:
```ts theme={null}
const result = await client.jobs.list({
status: "failed",
queue: "default",
handler: "webhook_delivery",
limit: 50,
offset: 0,
});
for (const job of result.items) {
console.log(job.handler); // "webhook_delivery", "send_email", etc.
console.log(job.status); // "pending", "running", "completed", "failed", "retrying", "dead"
console.log(job.attempt_number);
console.log(job.error_message);
}
```
## Retry a Job
Manually retry a `dead`, `failed`, or `retrying` job. This resets it to `pending` with `attempt_number` reset to 0:
```ts theme={null}
const job = await client.jobs.retry("job-id");
console.log(job.status); // "pending"
```
## Cancel a Job
Cancel a `pending` job:
```ts theme={null}
await client.jobs.cancel("job-id");
```
Only jobs in `pending` status can be cancelled. Jobs that are already running, completed, or failed cannot be cancelled.
# Macros Service
Source: https://docs.snackbase.dev/sdk/js/services/macros
Manage SQL macros for use in permission rules
The Macros service allows you to create and manage custom SQL macros that can be used in permission rules. Macros provide reusable SQL fragments that help you write more complex and dynamic permission logic.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the macros service
const macros = client.macros;
```
Most macro operations require superadmin authentication.
## List Macros
Retrieve all available macros, including built-in ones:
```ts theme={null}
const response = await client.macros.list();
// Returns: { items: Macro[], total: number }
```
The response includes both built-in macros (provided by SnackBase) and custom macros created for your account.
## Get a Macro
Get details for a specific macro:
```ts theme={null}
const macro = await client.macros.get("macro-id");
```
## Create a Macro
Create a new custom macro:
```ts theme={null}
const macro = await client.macros.create({
name: "user_posts",
description: "Get posts owned by the current user",
template: "SELECT * FROM posts WHERE user_id = {{user_id}}",
parameters: ["user_id"]
});
```
The `template` property uses `{{parameter_name}}` syntax for parameter substitution.
## Update a Macro
Update an existing custom macro:
```ts theme={null}
const updated = await client.macros.update("macro-id", {
description: "Updated description",
template: "SELECT * FROM posts WHERE user_id = {{user_id}} AND status = 'published'"
});
```
Built-in macros cannot be updated.
## Delete a Macro
Delete a custom macro:
```ts theme={null}
await client.macros.delete("macro-id");
```
You cannot delete built-in macros or macros that are currently in use by permission rules.
## Test a Macro
Test a macro with specific parameters:
```ts theme={null}
const result = await client.macros.test("macro-id", {
user_id: "user-123"
});
// Returns: { sql: string, parameters: any[] }
```
This is useful for validating that your macro generates the correct SQL before using it in production rules.
## Built-in Macros
SnackBase provides several built-in macros:
| Name | Description |
| ----------------- | ---------------------------------------------- |
| `current_user` | Get the ID of the currently authenticated user |
| `current_account` | Get the ID of the current account |
| `user_roles` | Get roles for a specific user |
| `group_members` | Get members of a specific group |
## Using Macros in Rules
Macros can be referenced in permission rules using the `{{macro_name}}` syntax:
```ts theme={null}
// Example rule using a macro
const rule = {
name: "own_posts_only",
effect: "allow",
action: "read",
resource: "posts",
condition: {
sql: "{{current_user}} = posts.user_id"
}
};
```
## Next Steps
* **[Permissions](/permissions)** - Understand permission rules
* **[Roles Service](/sdk/js/services/roles)** - Manage roles and permissions
* **[Collection Rules](/sdk/js/services/collections)** - Apply rules to collections
# Migrations Service
Source: https://docs.snackbase.dev/sdk/js/services/migrations
View migration status and history
The Migrations service provides read-only access to the database migration status and history. It's useful for monitoring the current state of your SnackBase deployment and tracking schema changes.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the migrations service
const migrations = client.migrations;
```
Migration operations require superadmin authentication. This service only provides
read access to migration information. Actual migrations are run server-side.
## List Migrations
Get all available Alembic migrations with their application status:
```ts theme={null}
const response = await migrations.list();
// Returns: { items: Migration[], total: number }
```
Each migration includes:
```ts theme={null}
{
revision: string; // Migration revision ID
name: string; // Human-readable name
is_applied: boolean; // Whether this migration has been applied
created_at: string; // ISO timestamp
}
```
## Get Current Migration
Get the currently applied database revision:
```ts theme={null}
const current = await migrations.getCurrent();
if (current) {
console.log("Current revision:", current.revision);
console.log("Applied at:", current.created_at);
} else {
console.log("No migrations applied yet");
}
```
Returns `null` if no migrations have been applied yet.
## Get Migration History
Get the complete migration history in chronological order:
```ts theme={null}
const history = await migrations.getHistory();
// Returns: { items: Migration[], total: number }
```
This shows all migrations that have been applied to the database, in the order they were applied.
## Use Cases
### Version Check
Check if the database is up to date:
```ts theme={null}
async function isDatabaseUpToDate() {
const [allMigrations, current] = await Promise.all([
migrations.list(),
migrations.getCurrent()
]);
const latestRevision = allMigrations.items[allMigrations.items.length - 1].revision;
if (!current) {
return false;
}
return current.revision === latestRevision;
}
```
### Health Monitoring
Include migration status in health checks:
```ts theme={null}
async function getHealthStatus() {
const [current, history] = await Promise.all([
migrations.getCurrent(),
migrations.getHistory()
]);
return {
hasMigrations: !!current,
migrationCount: history.total,
currentRevision: current?.revision || null,
lastMigrationAt: history.items[history.items.length - 1]?.created_at
};
}
```
### Display Status
Show migration information in an admin dashboard:
```ts theme={null}
import { useQuery } from "@snackbase/sdk/react";
function MigrationStatus() {
const { data: current, isLoading } = useQuery({
queryKey: ["current-migration"],
queryFn: () => migrations.getCurrent()
});
if (isLoading) return Loading...;
if (!current) {
return Warning: No migrations applied;
}
return (
Database Migration Status
Revision: {current.revision}
Applied: {new Date(current.created_at).toLocaleString()}
);
}
```
## Next Steps
* **[Dashboard](/sdk/js/services/dashboard)** - View system statistics
* **[Collections](/sdk/js/services/collections)** - Manage database collections
* **[Admin Service](/sdk/js/services/admin)** - Manage system configuration
# Records Service
Source: https://docs.snackbase.dev/sdk/js/services/records
Perform CRUD operations on dynamic collections
The Records service provides methods for creating, reading, updating, and deleting records in your SnackBase collections.
## Overview
Records are the actual data stored in your collections. Each record in a collection has the same structure (schema) defined by the collection's fields.
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the records service
const records = client.records;
```
## List Records
Get all records from a collection with pagination:
```ts theme={null}
const result = await client.records.list("posts");
console.log(result.items); // Array of records
console.log(result.total); // Total count
console.log(result.skip); // Offset
console.log(result.limit); // Page size
```
### With Filtering
```ts theme={null}
const result = await client.records.list("posts", {
filter: { status: "published" },
});
```
### With Sorting
```ts theme={null}
// Sort by createdAt descending (newest first)
const result = await client.records.list("posts", {
sort: "-createdAt",
});
// Sort by multiple fields
const result = await client.records.list("posts", {
sort: "-createdAt,title",
});
```
### With Pagination
```ts theme={null}
const result = await client.records.list("posts", {
skip: 0,
limit: 20,
});
```
### With Field Selection
```ts theme={null}
const result = await client.records.list("posts", {
fields: ["id", "title", "createdAt"],
});
```
### Combined Example
```ts theme={null}
const result = await client.records.list("posts", {
filter: { status: "published" },
sort: "-createdAt",
skip: 0,
limit: 20,
fields: ["id", "title", "author", "createdAt"],
});
```
## Get a Single Record
Retrieve a specific record by ID:
```ts theme={null}
const post = await client.records.get("posts", "record-id");
console.log(post.id);
console.log(post.title);
console.log(post.content);
```
### With Field Selection
```ts theme={null}
const post = await client.records.get("posts", "record-id", {
fields: ["id", "title"],
});
```
### With Related Records
```ts theme={null}
const post = await client.records.get("posts", "record-id", {
expand: ["author", "comments"],
});
```
## Create a Record
Create a new record in a collection:
```ts theme={null}
const newPost = await client.records.create("posts", {
title: "My First Post",
content: "This is the content of my post.",
status: "published",
views: 0,
});
console.log("Created:", newPost.id);
```
The `id`, `createdAt`, and `updatedAt` fields are automatically generated
by SnackBase.
## Update a Record
### Full Update (PUT)
Replace all fields of a record:
```ts theme={null}
const updated = await client.records.update("posts", "record-id", {
title: "Updated Title",
content: "Updated content",
status: "published",
views: 10,
});
```
Using `update()` replaces all fields. Fields not included will be set to
null (unless they have default values).
### Partial Update (PATCH)
Update only specific fields:
```ts theme={null}
const patched = await client.records.patch("posts", "record-id", {
views: 15,
});
```
Use `patch()` for most updates to avoid accidentally clearing fields.
## Delete a Record
Remove a record from a collection:
```ts theme={null}
await client.records.delete("posts", "record-id");
console.log("Record deleted");
```
## Using the Query Builder
For complex queries, use the fluent query builder:
```ts theme={null}
const results = await client.records
.query("posts")
.select("id", "title", "author.name")
.expand("author", "comments")
.filter("status", "=", "published")
.filter("createdAt", ">=", "2024-01-01")
.sort("createdAt", "desc")
.page(1, 20)
.get();
console.log(results.items);
```
See the [Query Builder](/sdk/js/query/overview) guide for more details.
## TypeScript Support
Use TypeScript for type-safe record operations:
```ts theme={null}
interface Post {
id: string;
title: string;
content: string;
status: "draft" | "published" | "archived";
views: number;
createdAt: string;
updatedAt: string;
}
// Create with type safety
const newPost = await client.records.create("posts", {
title: "My Post",
content: "Content",
status: "published",
views: 0,
});
// Get with type safety
const post = await client.records.get("posts", "record-id");
// List with type safety
const result = await client.records.list("posts");
```
## Batch Operations
### Create Multiple Records
```ts theme={null}
const records = await Promise.all([
client.records.create("posts", { title: "Post 1", content: "Content 1" }),
client.records.create("posts", { title: "Post 2", content: "Content 2" }),
client.records.create("posts", { title: "Post 3", content: "Content 3" }),
]);
```
### Update Multiple Records
```ts theme={null}
const updates = [
{ id: "id1", data: { status: "published" } },
{ id: "id2", data: { status: "published" } },
{ id: "id3", data: { status: "published" } },
];
await Promise.all(
updates.map(({ id, data }) =>
client.records.patch("posts", id, data)
)
);
```
## Real-Time Updates
Combine with the real-time service for live updates:
```ts theme={null}
// Subscribe to collection changes
await client.realtime.connect();
await client.realtime.subscribe("posts");
client.realtime.on("posts.create", (data) => {
console.log("New post created:", data);
// Refresh your local data
});
client.realtime.on("posts.update", (data) => {
console.log("Post updated:", data);
// Update your local data
});
client.realtime.on("posts.delete", (data) => {
console.log("Post deleted:", data);
// Remove from your local data
});
```
## Error Handling
```ts theme={null}
import {
NotFoundError,
ValidationError,
AuthenticationError,
} from "@snackbase/sdk";
try {
const post = await client.records.get("posts", "non-existent-id");
} catch (error) {
if (error instanceof NotFoundError) {
console.error("Record not found");
} else if (error instanceof ValidationError) {
console.error("Validation error:", error.fields);
} else if (error instanceof AuthenticationError) {
console.error("Authentication required");
} else {
console.error("Unknown error:", error);
}
}
```
## Reference
### `list(collection, params?)`
List records from a collection.
**Parameters:**
* `collection` (string) - Collection name
* `params` (object) - Query parameters
* `filter` (object) - Filter expression
* `sort` (string) - Sort expression
* `skip` (number) - Records to skip
* `limit` (number) - Max records to return
* `fields` (string\[]) - Fields to return
* `expand` (string\[]) - Relations to expand
**Returns:** `Promise`
### `get(collection, recordId, params?)`
Get a single record by ID.
**Parameters:**
* `collection` (string) - Collection name
* `recordId` (string) - Record ID
* `params` (object) - Query parameters
* `fields` (string\[]) - Fields to return
* `expand` (string\[]) - Relations to expand
**Returns:** `Promise`
### `create(collection, data)`
Create a new record.
**Parameters:**
* `collection` (string) - Collection name
* `data` (object) - Record data
**Returns:** `Promise`
### `update(collection, recordId, data)`
Full update of a record (PUT).
**Parameters:**
* `collection` (string) - Collection name
* `recordId` (string) - Record ID
* `data` (object) - Complete record data
**Returns:** `Promise`
### `patch(collection, recordId, data)`
Partial update of a record (PATCH).
**Parameters:**
* `collection` (string) - Collection name
* `recordId` (string) - Record ID
* `data` (object) - Fields to update
**Returns:** `Promise`
### `delete(collection, recordId)`
Delete a record.
**Parameters:**
* `collection` (string) - Collection name
* `recordId` (string) - Record ID
**Returns:** `Promise<{ success: boolean }>`
### `query(collection)`
Create a query builder for the collection.
**Parameters:**
* `collection` (string) - Collection name
**Returns:** `QueryBuilder`
## Batch Operations
Perform multiple record operations atomically using the dedicated batch endpoint:
```ts theme={null}
// Batch create
const created = await client.records.batchCreate("posts", [
{ title: "Post 1", status: "draft" },
{ title: "Post 2", status: "published" },
]);
// Batch update
const updated = await client.records.batchUpdate("posts", [
{ id: "post-1", data: { status: "published" } },
{ id: "post-2", data: { status: "archived" } },
]);
// Batch delete
await client.records.batchDelete("posts", ["post-1", "post-2"]);
```
## Aggregation Queries
Run server-side aggregations without fetching raw records:
```ts theme={null}
const result = await client.records.aggregate("orders", {
function: "sum",
field: "total",
filter: "status = 'completed'",
group_by: "category",
});
```
Supported functions: `count`, `sum`, `avg`, `min`, `max`.
## Reference Expansion
Expand reference fields inline to avoid N+1 queries:
```ts theme={null}
const orders = await client.records.list("orders", {
expand: "customer_id,product_id",
});
// Each order now includes the full referenced records
console.log(orders.items[0].expand.customer_id.name);
```
## Next Steps
* **[Query Builder](/sdk/js/query/overview)** - Advanced querying
* **[Collections](/sdk/js/services/collections)** - Manage collections
* **[Realtime](/sdk/js/realtime/overview)** - Real-time subscriptions
# Roles Service
Source: https://docs.snackbase.dev/sdk/js/services/roles
Manage roles and permissions for access control
The Roles service provides methods for managing roles and permissions.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the roles service
const roles = client.roles;
```
## List Roles
```ts theme={null}
const result = await client.roles.list();
```
## Get a Role
```ts theme={null}
const role = await client.roles.get("role-id");
```
## Create a Role
```ts theme={null}
const role = await client.roles.create({
name: "editor",
description: "Can create and edit content",
permissions: [
{
collectionId: "posts",
actions: ["create", "read", "update"],
},
],
});
```
## Update a Role
```ts theme={null}
const updated = await client.roles.update("role-id", {
name: "Senior Editor",
});
```
## Delete a Role
```ts theme={null}
await client.roles.delete("role-id");
```
Deleting a role removes it from all users who have it. Ensure users have
alternative roles before deleting.
## Assign Role to User
```ts theme={null}
await client.roles.assignToUser("role-id", "user-id");
```
## Remove Role from User
```ts theme={null}
await client.roles.removeFromUser("role-id", "user-id");
```
## Next Steps
* **[Groups Service](/sdk/js/services/groups)** - Manage user groups
* **[Collection Rules](/sdk/js/services/collections)** - Fine-grained access control
* **[Permissions](/permissions)** - Understand the permission system
# Users Service
Source: https://docs.snackbase.dev/sdk/js/services/users
Manage users in your SnackBase account
The Users service provides methods for managing users within your SnackBase account.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the users service
const users = client.users;
```
## List Users
```ts theme={null}
const result = await client.users.list();
console.log(result.items);
```
## Get a User
```ts theme={null}
const user = await client.users.get("user-id");
```
## Create a User
```ts theme={null}
const user = await client.users.create({
email: "newuser@example.com",
password: "SecurePassword123!",
fullName: "John Doe",
isActive: true,
});
```
The user will need to verify their email if email verification is enabled
in your SnackBase configuration.
## Update a User
```ts theme={null}
const updated = await client.users.update("user-id", {
fullName: "Jane Doe",
});
```
## Deactivate a User
```ts theme={null}
await client.users.deactivate("user-id");
```
Deactivated users cannot log in but their data is preserved.
## User Object
```ts theme={null}
interface User {
id: string;
email: string;
fullName?: string;
avatarUrl?: string;
isActive: boolean;
isEmailVerified: boolean;
roleIds: string[];
account: {
id: string;
name: string;
slug: string;
};
createdAt: string;
updatedAt: string;
}
```
## Next Steps
* **[Accounts Service](/sdk/js/services/accounts)** - Manage accounts
* **[Roles Service](/sdk/js/services/roles)** - Manage roles and permissions
* **[Invitations Service](/sdk/js/services/invitations)** - Invite users to accounts
# Webhooks Service
Source: https://docs.snackbase.dev/sdk/js/services/webhooks
Manage outbound webhooks and delivery history
The Webhooks service allows you to create, manage, and monitor outbound webhooks that send HTTP notifications to external services when data changes.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the webhooks service
const webhooks = client.webhooks;
```
## List Webhooks
```ts theme={null}
const result = await client.webhooks.list();
// result.items - array of webhooks
// result.total - total count
```
With pagination:
```ts theme={null}
const result = await client.webhooks.list({
limit: 20,
offset: 0,
});
```
## Get a Webhook
```ts theme={null}
const webhook = await client.webhooks.get("webhook-id");
```
## Create a Webhook
```ts theme={null}
const webhook = await client.webhooks.create({
url: "https://your-server.com/webhook",
collection: "orders",
events: ["create", "update"],
headers: {
"X-Custom-Header": "value",
},
});
// IMPORTANT: Save the secret - it's only returned on creation
console.log(webhook.secret);
```
The `secret` field is only included in the response when creating a webhook. Store it securely for signature verification.
## Update a Webhook
```ts theme={null}
const updated = await client.webhooks.update("webhook-id", {
url: "https://new-server.com/webhook",
events: ["create", "update", "delete"],
enabled: true,
});
```
## Delete a Webhook
```ts theme={null}
await client.webhooks.delete("webhook-id");
```
## Test a Webhook
Send a test payload to verify your webhook endpoint:
```ts theme={null}
const result = await client.webhooks.test("webhook-id");
console.log(result.success); // boolean
console.log(result.status_code); // HTTP status code
console.log(result.response_body); // response body (truncated)
console.log(result.error); // error message if failed
```
## List Deliveries
View the delivery history for a webhook:
```ts theme={null}
const deliveries = await client.webhooks.listDeliveries("webhook-id", {
limit: 50,
offset: 0,
});
for (const delivery of deliveries.items) {
console.log(delivery.event); // "records.create"
console.log(delivery.status); // "delivered", "failed", "retrying", "pending"
console.log(delivery.response_status); // 200
console.log(delivery.attempt_number); // 1
}
```
# Workflows Service
Source: https://docs.snackbase.dev/sdk/js/services/workflows
Create and manage multi-step workflow automations
The Workflows service allows you to create, manage, and monitor multi-step automation workflows with triggers, conditions, and delays.
## Overview
```ts theme={null}
import { SnackBaseClient } from "@snackbase/sdk";
const client = new SnackBaseClient({
baseUrl: "https://api.example.com",
});
// Access the workflows service
const workflows = client.workflows;
```
## List Workflows
```ts theme={null}
const result = await client.workflows.list();
// result.items - array of workflows
// result.total - total count
```
With filters:
```ts theme={null}
const result = await client.workflows.list({
trigger_type: "event",
enabled: true,
limit: 50,
offset: 0,
});
```
## Get a Workflow
```ts theme={null}
const workflow = await client.workflows.get("workflow-id");
```
## Create a Workflow
```ts theme={null}
const workflow = await client.workflows.create({
name: "Order Processing",
trigger_type: "event",
trigger_config: {
type: "event",
event: "records.create",
collection: "orders",
},
steps: [
{
name: "check_value",
type: "condition",
config: {
expression: "trigger.total >= 500",
on_true: "high_value_alert",
on_false: "standard_confirm",
},
},
{
name: "high_value_alert",
type: "action",
config: {
action_type: "send_webhook",
config: {
url: "https://slack.example.com/webhook",
body_template: {
text: "High-value order: ${{trigger.total}}",
},
},
},
},
{
name: "standard_confirm",
type: "action",
config: {
action_type: "send_email",
config: {
to: "{{trigger.customer_email}}",
subject: "Order Confirmed",
template_name: "order_confirmation",
},
},
},
],
});
```
## Update a Workflow
```ts theme={null}
const updated = await client.workflows.update("workflow-id", {
name: "Updated Workflow",
enabled: true,
});
```
## Delete a Workflow
```ts theme={null}
await client.workflows.delete("workflow-id");
```
Deleting a workflow also deletes all its instances and step logs.
## Trigger a Workflow
Manually trigger a workflow with optional input data:
```ts theme={null}
const result = await client.workflows.trigger("workflow-id", {
customer_id: "cust-123",
reason: "manual test",
});
console.log(result.instance_id); // ID of the created instance
```
## List Instances
View execution instances for a workflow:
```ts theme={null}
const instances = await client.workflows.listInstances("workflow-id", {
status: "failed",
limit: 50,
offset: 0,
});
for (const instance of instances.items) {
console.log(instance.status); // "pending", "running", "waiting", "completed", "failed", "cancelled"
console.log(instance.current_step); // name of current/last step
console.log(instance.error_message); // error details (if failed)
}
```
## Get Instance Details
Get a single instance with its full step logs:
```ts theme={null}
const instance = await client.workflows.getInstance("instance-id");
console.log(instance.status);
console.log(instance.context); // accumulated execution context
// Step logs
for (const log of instance.step_logs) {
console.log(log.step_name);
console.log(log.step_type);
console.log(log.status); // "success", "failed", "skipped"
console.log(log.output);
console.log(log.duration_ms);
}
```
## Cancel an Instance
Cancel a running or waiting instance:
```ts theme={null}
const cancelled = await client.workflows.cancelInstance("instance-id");
console.log(cancelled.status); // "cancelled"
```
Only instances in `running` or `waiting` status can be cancelled. Attempting to cancel a completed or already cancelled instance returns a 409 error.
## Retry a Failed Instance
Resume a failed or waiting instance:
```ts theme={null}
const result = await client.workflows.retryInstance("instance-id");
console.log(result.instance_id);
```