@butterbase/plugin
Advanced tools
| { | ||
| "name": "butterbase", | ||
| "description": "Butterbase plugin marketplace", | ||
| "owner": { | ||
| "name": "Butterbase", | ||
| "email": "ken@butterbase.ai" | ||
| }, | ||
| "plugins": [ | ||
| { | ||
| "name": "butterbase", | ||
| "description": "Build full-stack apps with Butterbase — AI-Native Backend-as-a-Service. Skills for app creation, schema design, frontend deployment, RLS debugging, serverless functions, and contributing.", | ||
| "version": "0.1.0", | ||
| "source": "./", | ||
| "author": { | ||
| "name": "Butterbase", | ||
| "email": "ken@butterbase.ai" | ||
| } | ||
| } | ||
| ] | ||
| } |
| --- | ||
| description: "Use the app's AI gateway: chat completions, embeddings, list models, configure defaults and BYOK, check usage" | ||
| --- | ||
| Use the butterbase:ai skill to guide this process. |
| --- | ||
| description: "Configure OAuth providers, auth hooks, JWT lifetimes, and service keys for a Butterbase app" | ||
| --- | ||
| Use the butterbase:auth-setup skill to guide this process. |
| --- | ||
| description: "Build a complete Butterbase app from scratch (init → schema → RLS → auth → functions → deploy)" | ||
| --- | ||
| Use the butterbase:build-app skill to guide this process step by step. |
| --- | ||
| description: "Learn how to contribute to the Butterbase codebase (add MCP tools, routes, migrations)" | ||
| --- | ||
| Use the butterbase:contributing skill for guidance on the monorepo architecture and development workflow. |
| --- | ||
| description: "Debug Row-Level Security issues (access denied, users seeing wrong data, policy problems)" | ||
| --- | ||
| Use the butterbase:debug-rls skill to systematically diagnose and fix the issue. |
| --- | ||
| description: "Deploy a frontend (React, Next.js, or static HTML) to a live URL on Butterbase" | ||
| --- | ||
| Use the butterbase:deploy-frontend skill to guide this process step by step. |
| --- | ||
| description: "Build a stateful Durable Object on Butterbase: chat rooms, multiplayer, rate limiters, long-running agents" | ||
| --- | ||
| Use the butterbase:durable-objects skill to guide this process. |
| --- | ||
| description: "Develop, deploy, or debug a Butterbase serverless function" | ||
| --- | ||
| Use the butterbase:function-dev skill to guide this process. |
| --- | ||
| description: "Move an app between regions, check migration status, abort or reverse a move, manage retained source replicas" | ||
| --- | ||
| Use the butterbase:migrations skill to guide this process. |
| --- | ||
| description: "Build a RAG knowledge base on Butterbase: ingest documents, run semantic search, generate synthesized answers" | ||
| --- | ||
| Use the butterbase:rag-dev skill to guide this process. |
| --- | ||
| description: "Enable WebSocket realtime subscriptions on Butterbase tables, or debug clients receiving no events" | ||
| --- | ||
| Use the butterbase:realtime skill to guide this process. |
| --- | ||
| description: "Design or modify a Butterbase database schema using the declarative DSL" | ||
| --- | ||
| Use the butterbase:schema-design skill to guide this process. |
| --- | ||
| description: "Upload, download, or manage files in Butterbase storage (presigned URLs, ACLs, object IDs)" | ||
| --- | ||
| Use the butterbase:storage skill to guide this process. |
+21
| MIT License | ||
| Copyright (c) 2026 Butterbase | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
| --- | ||
| name: ai | ||
| description: Use when calling the app's AI gateway from agent tools — chat completions, embeddings, listing models, configuring defaults or BYOK, reading token/cost usage | ||
| --- | ||
| # Butterbase AI Gateway | ||
| Every app has an LLM gateway with chat, embeddings, model listing, configuration, and usage reporting. One umbrella tool: **`manage_ai`**. | ||
| | Action | What it does | Returns | | ||
| |---|---|---| | ||
| | `chat` | Synchronous chat completion (no streaming) | OpenAI-shaped `{ choices: [...] }` | | ||
| | `embed` | Vector embeddings for string or string[] | OpenAI-shaped `{ data: [{ embedding: [...] }] }` | | ||
| | `list_models` | Available models with capabilities | `{ models: AiModel[] }` | | ||
| | `get_config` | Current AI config (default model, BYOK key flag, etc.) | `AiConfig` | | ||
| | `update_config` | Set defaults, allowed models, max tokens, BYOK | `AiConfig` | | ||
| | `get_usage` | Token + cost aggregate over a window | usage record | | ||
| --- | ||
| ## 1. Chat | ||
| ``` | ||
| manage_ai({ | ||
| action: "chat", | ||
| app_id, | ||
| messages: [ | ||
| { role: "system", content: "You are a helpful assistant." }, | ||
| { role: "user", content: "What's RAG?" } | ||
| ], | ||
| model: "openai/gpt-4o-mini", // optional — falls back to app's default | ||
| temperature: 0.2, // optional | ||
| max_tokens: 500 // optional | ||
| }) | ||
| ``` | ||
| This action sets `stream: false` deliberately — agent tools don't stream. If you need partial-token deltas, drive the SDK's `ai.chatStream(…)` from inside a function or DO instead. | ||
| `messages[].content` can be a string or an array of content parts (`{ type: "text", text }`, `{ type: "image_url", image_url: {...} }`, `{ type: "video_url", video_url: {...} }`). | ||
| --- | ||
| ## 2. Embed | ||
| ``` | ||
| manage_ai({ | ||
| action: "embed", | ||
| app_id, | ||
| input: "hello world", // or ["a", "b", "c"] | ||
| model: "openai/text-embedding-3-small", // optional | ||
| encoding_format: "float" // or "base64" | ||
| }) | ||
| ``` | ||
| --- | ||
| ## 3. List models | ||
| ``` | ||
| manage_ai({ action: "list_models", app_id }) | ||
| // → { models: [{ id, provider, capabilities: ["chat", "embed", ...], context_window, pricing }, ...] } | ||
| ``` | ||
| Use this to discover what the app can call — capabilities + context window matter when picking a model. | ||
| --- | ||
| ## 4. Configure | ||
| ``` | ||
| manage_ai({ | ||
| action: "update_config", | ||
| app_id, | ||
| config: { | ||
| defaultModel: "openai/gpt-4o-mini", | ||
| allowedModels: ["openai/gpt-4o-mini", "anthropic/claude-haiku-4-5"], | ||
| maxTokensPerRequest: 4000, | ||
| byokKey: "..." // optional — rotates the customer-supplied OpenRouter / Anthropic key | ||
| } | ||
| }) | ||
| ``` | ||
| - `maxTokensPerRequest` is server-clamped to 1–100000. | ||
| - `allowedModels` is a whitelist — empty means all models the provider exposes. | ||
| - Setting `byokKey` switches the app to route through that customer key. Clear it by passing `byokKey: ""` (returns to platform pool). | ||
| --- | ||
| ## 5. Usage | ||
| ``` | ||
| manage_ai({ | ||
| action: "get_usage", | ||
| app_id, | ||
| startDate: "2026-05-01", | ||
| endDate: "2026-05-31" | ||
| }) | ||
| ``` | ||
| Returns aggregate token counts + cost. Useful for billing reconciliation, spending-cap diagnostics, and showing dashboards. | ||
| --- | ||
| ## 6. Common pitfalls | ||
| - **Trying to stream from a tool** — `manage_ai` is synchronous. Use the SDK inside a function for streamed deltas. | ||
| - **Sending `stream: true` in the body** — the tool ignores it; always wired to `false`. | ||
| - **Hardcoding `model`** — better to omit, let the app's `defaultModel` win, and surface that knob via `update_config`. | ||
| - **Skipping `list_models` before suggesting one** — model availability shifts; verify before recommending. | ||
| --- | ||
| ## 7. What this skill does NOT cover | ||
| - Streaming chat — use the SDK (`ai.chatStream`) inside a function or DO. | ||
| - Vector storage / retrieval — see `butterbase:rag-dev` (RAG collections wrap embeddings + search together). | ||
| - AI in deployed functions — they import `@butterbase/sdk` and call `client.ai.*` directly; no MCP needed at runtime. |
| --- | ||
| name: auth-setup | ||
| description: Use when configuring OAuth providers (Google/GitHub/Apple/X/etc.), setting up post-login auth hooks, tuning JWT lifetimes, or generating service API keys | ||
| --- | ||
| # Butterbase Auth Setup | ||
| Two umbrella tools cover end-user authentication: | ||
| - **`manage_oauth`** — provider configuration (Google, GitHub, Apple, X, custom) | ||
| - **`manage_auth_config`** — auth hooks, JWT lifetimes, service key generation | ||
| For broad app build-out, see also `butterbase:build-app`. This skill is the deep dive. | ||
| --- | ||
| ## 1. The role model | ||
| Every request runs under one of three database roles: | ||
| | Auth header | Role | `current_user_id()` | RLS | | ||
| |-------------|------|---------------------|-----| | ||
| | _none_ | `butterbase_anon` | NULL | enforced; default deny | | ||
| | End-user JWT (issued by `manage_oauth` or email login) | `butterbase_user` | user UUID | enforced | | ||
| | Service key (`bb_sk_*`) | `butterbase_service` | NULL | bypassed | | ||
| Auth is what transforms a request into the right role. RLS is what filters the data. Both must be configured. | ||
| --- | ||
| ## 2. Configure an OAuth provider | ||
| ```js | ||
| manage_oauth({ | ||
| app_id: "app_abc123", | ||
| action: "configure", | ||
| provider: "google", | ||
| client_id: "123456789.apps.googleusercontent.com", | ||
| client_secret: "GOCSPX-...", | ||
| redirect_uris: ["https://api.butterbase.ai/auth/app_abc123/oauth/google/callback"] | ||
| // scopes / authorization_url / token_url / userinfo_url / provider_metadata are auto-filled for built-in providers | ||
| }) | ||
| ``` | ||
| **Built-in providers (URLs and scopes pre-filled):** `google`, `github`, `discord`, `facebook`, `linkedin`, `microsoft`, `apple`, `x`. | ||
| **Custom providers:** pass `authorization_url`, `token_url`, `userinfo_url`, and `scopes` explicitly. | ||
| ### Redirect URI format | ||
| ``` | ||
| https://api.butterbase.ai/auth/{app_id}/oauth/{provider}/callback | ||
| ``` | ||
| Register **this exact URI** in the provider's developer console. Mismatch is the most common reason OAuth flows fail. | ||
| ### Provider quirks | ||
| | Provider | Quirk | | ||
| |----------|-------| | ||
| | `apple` | Requires `provider_metadata: { teamId, keyId, privateKey }`. Apple only returns the user's name on **first** auth and uses POST callback (handled automatically). | | ||
| | `x` | Does not return email. Butterbase synthesises `{username}@users.noreply.x.local` for the user record. | | ||
| | `facebook` | Default scopes `email`, `public_profile`. | | ||
| | `google` | Standard. | | ||
| | `github` | Standard. | | ||
| ### List, update, delete | ||
| ```js | ||
| manage_oauth({ app_id, action: "get" }) // list all providers (secrets redacted) | ||
| manage_oauth({ app_id, action: "get", provider: "google" }) // single provider | ||
| manage_oauth({ app_id, action: "update", provider: "google", client_secret: "new-secret" }) | ||
| manage_oauth({ app_id, action: "delete", provider: "google" }) // disables future logins; existing sessions valid until expiry | ||
| ``` | ||
| ### Frontend flow | ||
| ``` | ||
| GET https://api.butterbase.ai/auth/{app_id}/oauth/{provider}?redirect_to=https://yourapp.com/auth/callback | ||
| ``` | ||
| User signs in at the provider, gets bounced back to `redirect_to` with `access_token` and `refresh_token` as query params. The Butterbase SDK wraps this: | ||
| ```ts | ||
| await client.auth.signInWithOAuth({ provider: "google" }); | ||
| const { user, accessToken } = await client.auth.getSession(); | ||
| ``` | ||
| --- | ||
| ## 3. Tune JWT lifetimes | ||
| ```js | ||
| manage_auth_config({ | ||
| app_id: "app_abc123", | ||
| action: "update_jwt", | ||
| accessTokenTtl: "15m", // formats: "15m", "1h", "2h", "1d" | ||
| refreshTokenTtlDays: 30 // integer days | ||
| }) | ||
| ``` | ||
| Defaults: 15-minute access tokens, 7-day refresh tokens. | ||
| | Use case | `accessTokenTtl` | `refreshTokenTtlDays` | | ||
| |----------|------------------|------------------------| | ||
| | High-security (banking, admin) | `5m`–`15m` | `1`–`7` | | ||
| | Standard SaaS | `15m` (default) | `30` | | ||
| | Low-friction consumer apps | `1h` | `90` | | ||
| **Important:** changes apply only to **new** tokens. Active tokens keep their original expiration — there is no global revoke. Treat TTL changes as forward-looking only. | ||
| --- | ||
| ## 4. Auth hooks (run code after every login) | ||
| A post-auth function is a deployed Butterbase function invoked **fire-and-forget** after every successful auth event (OAuth login, email login, email signup). | ||
| ### Wire it up | ||
| ```js | ||
| // 1. Deploy the function first (see butterbase:function-dev) | ||
| deploy_function({ | ||
| app_id: "app_abc123", | ||
| name: "after-auth", | ||
| code: postAuthHandlerCode, | ||
| trigger: { type: "http", config: { auth: "none" } } | ||
| }) | ||
| // 2. Register it as the auth hook | ||
| manage_auth_config({ | ||
| app_id: "app_abc123", | ||
| action: "configure_auth_hook", | ||
| post_auth_function: "after-auth" | ||
| }) | ||
| // To remove the hook later: pass post_auth_function: null | ||
| ``` | ||
| The function **must already exist** when you configure the hook. | ||
| ### Payload shape | ||
| The function receives a POST with this body: | ||
| ```json | ||
| { | ||
| "event": "oauth_login | login | signup", | ||
| "user": { | ||
| "id": "uuid", | ||
| "email": "...", | ||
| "provider": "google | github | email | ...", | ||
| "display_name": "...", | ||
| "avatar_url": "..." | ||
| }, | ||
| "isNewUser": true, | ||
| "provider": "google" | ||
| } | ||
| ``` | ||
| The function runs as `butterbase_service` (RLS bypassed, `ctx.user` is `null`). Use `body.user.id` to know who just logged in. | ||
| ### Common uses | ||
| ```ts | ||
| // after-auth/index.ts | ||
| export async function handler(req, ctx) { | ||
| const { user, isNewUser, event } = await req.json(); | ||
| if (isNewUser) { | ||
| // 1. Create profile row | ||
| await ctx.db.query( | ||
| "INSERT INTO profiles (user_id, display_name) VALUES ($1, $2) ON CONFLICT DO NOTHING", | ||
| [user.id, user.display_name] | ||
| ); | ||
| // 2. Send welcome email (via env-stored API key) | ||
| ctx.waitUntil(sendWelcomeEmail(ctx.env.RESEND_API_KEY, user.email)); | ||
| } | ||
| // 3. Audit log on every login | ||
| await ctx.db.query( | ||
| "INSERT INTO login_log (user_id, event, provider) VALUES ($1, $2, $3)", | ||
| [user.id, event, ctx.user ?? null] | ||
| ); | ||
| return new Response("ok", { status: 200 }); | ||
| } | ||
| ``` | ||
| > Auth hooks are fire-and-forget. Don't return data the user needs — they won't see it. Use them for side effects only. | ||
| --- | ||
| ## 5. Service keys (`bb_sk_*`) | ||
| Service keys grant **full access** to all your apps and bypass RLS. Treat them like passwords. | ||
| ### Generate | ||
| ```js | ||
| manage_auth_config({ | ||
| action: "generate_service_key", | ||
| name: "CI/CD pipeline" | ||
| }) | ||
| // → { key: "bb_sk_a1b2c3...", key_id, prefix, name, created_at } | ||
| ``` | ||
| > The full key is returned **once**. Store it immediately in your secret manager — you cannot retrieve it again. If you lose it, generate a new one and revoke the old. | ||
| ### List & revoke | ||
| ```js | ||
| manage_api_keys({ action: "list" }) | ||
| manage_api_keys({ action: "revoke", key_id: "uuid-..." }) | ||
| ``` | ||
| `list` returns metadata only (prefix, name, last_used_at), never the secret. `revoke` is **immediate and irreversible**. | ||
| ### Rotation workflow | ||
| 1. `manage_auth_config` (`generate_service_key`) — create the new key. | ||
| 2. Update CI/CD, MCP config, scripts to use the new key. | ||
| 3. Verify with a smoke test (e.g. `manage_app` `list`). | ||
| 4. `manage_api_keys` (`revoke`) — kill the old key. | ||
| Do steps 1–3 **before** step 4 to avoid downtime. | ||
| --- | ||
| ## 6. Anti-patterns | ||
| | Don't | Do | | ||
| |-------|----| | ||
| | Hardcode `bb_sk_*` keys in client code or commit them to git | Store in env vars / secret manager | | ||
| | Reuse one OAuth app between dev, staging, prod | Separate OAuth apps per environment, with their own redirect URIs | | ||
| | Use a service key from frontend code "for convenience" | Frontends use end-user JWTs; service keys are server-only | | ||
| | Increase `accessTokenTtl` to "fix" frequent re-auth | Use the refresh token; SDK handles this automatically | | ||
| | Forget that `manage_oauth` `delete` only stops *new* logins | Existing sessions remain valid until they expire — rotate JWT keys via support if you need a hard kill | | ||
| | Put critical logic in the auth hook | Hooks are fire-and-forget. Errors don't surface to the user. Keep them to side effects. | | ||
| | Log the full service key in audit / debug output | Log only the prefix (`bb_sk_a1b2c3`) — secrets must never appear in logs | | ||
| --- | ||
| ## 7. Quick reference | ||
| | Task | Tool | | ||
| |------|------| | ||
| | Add Google OAuth | `manage_oauth` (`configure`) | | ||
| | List OAuth providers | `manage_oauth` (`get`) | | ||
| | Set post-login hook | `manage_auth_config` (`configure_auth_hook`) | | ||
| | Change JWT lifetimes | `manage_auth_config` (`update_jwt`) | | ||
| | Create service key | `manage_auth_config` (`generate_service_key`) | | ||
| | List service keys | `manage_api_keys` (`list`) | | ||
| | Revoke service key | `manage_api_keys` (`revoke`) | |
| --- | ||
| name: durable-objects | ||
| description: Use when building stateful per-key actors — chat rooms, multiplayer rooms, rate limiters, long-running agents, leaderboards — that need persistent in-memory + storage state across requests | ||
| --- | ||
| # Butterbase Durable Objects | ||
| Durable Objects (DOs) are **stateful per-key actors** running on Cloudflare Workers. Each instance has its own in-memory state and a built-in transactional KV store. Use one when state must survive across requests for a single room/user/agent. For stateless work, use a serverless function instead (`butterbase:function-dev`). | ||
| One tool: **`manage_durable_objects`**. | ||
| --- | ||
| ## 1. The mental model | ||
| ``` | ||
| Class: ChatRoom (deployed once) | ||
| │ | ||
| ├── instance "lobby" ─► in-memory state + state.storage + WebSockets | ||
| ├── instance "general" ─► separate state, separate sockets | ||
| └── instance "user-123" ─► separate again | ||
| Each URL https://<app>.butterbase.dev/_do/chat-room/<instance-id> | ||
| gets routed to the instance with that id. State is isolated per id. | ||
| ``` | ||
| A class is shared code; an **instance** is a unique key (`/lobby`, `/general`, `/user-123`). Different ids = different state. There is no shared cross-instance state. | ||
| --- | ||
| ## 2. Constraints (read these first) | ||
| - **One TypeScript file per class.** No npm imports. Only `import { ... } from 'cloudflare:workers'` is allowed. | ||
| - **Exactly one exported class.** `export class Foo { ... }` — no extra exports, no helpers re-exported. | ||
| - **PascalCase class name** in source; **kebab-case** for the URL name (e.g. `ChatRoom` ↔ `chat-room`). | ||
| - File size: ≤ 5 MB. Total of all DO classes per app: ≤ 10 MB compressed. | ||
| - ≤ 5 DO classes per app (v1). | ||
| - **No service bindings yet.** Functions reach DOs over HTTP, not via env binding. | ||
| - `state.storage` keys/values capped at 128 KB. Larger blobs → Butterbase Storage. | ||
| - **WebSockets need `access_mode: "public"`** because browsers can't send custom headers on WS upgrade. Validate auth tokens inside `fetch()` instead. | ||
| --- | ||
| ## 3. The class skeleton | ||
| ```typescript | ||
| export class ChatRoom { | ||
| constructor(public state: DurableObjectState, public env: Env) {} | ||
| async fetch(req: Request): Promise<Response> { | ||
| if (req.headers.get("Upgrade") === "websocket") { | ||
| const pair = new WebSocketPair(); | ||
| this.state.acceptWebSocket(pair[1]); | ||
| return new Response(null, { status: 101, webSocket: pair[0] }); | ||
| } | ||
| if (req.method === "POST") { | ||
| // handle plain HTTP | ||
| } | ||
| return Response.json({ ok: true }); | ||
| } | ||
| // Optional WebSocket lifecycle hooks — called by the runtime | ||
| async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) { | ||
| if (typeof msg !== "string") return; // guard binary | ||
| for (const peer of this.state.getWebSockets()) { | ||
| try { peer.send(msg); } catch {} | ||
| } | ||
| } | ||
| async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {} | ||
| async webSocketError(ws: WebSocket, err: Error) {} | ||
| } | ||
| ``` | ||
| Key APIs: | ||
| | API | Purpose | | ||
| |-----|---------| | ||
| | `state.storage.get/put/delete/deleteAll/list` | Async transactional KV store | | ||
| | `state.acceptWebSocket(ws)` | Hold a WS connection; runtime routes messages to `webSocketMessage` | | ||
| | `state.getWebSockets()` | All active WS connections for this instance | | ||
| | `new WebSocketPair()` | Returns `[client, server]` — return `client` to browser, accept `server` | | ||
| | `this.env.KEY` | Read DO env vars (set via `set_env`) | | ||
| --- | ||
| ## 4. Deploy | ||
| ```js | ||
| manage_durable_objects({ | ||
| app_id: "app_abc123", | ||
| action: "deploy", | ||
| name: "chat-room", // kebab-case URL name | ||
| code: "<single TypeScript file>", | ||
| access_mode: "authenticated" // "public" | "authenticated" (default) | "service_key" | ||
| }) | ||
| // → { id, name, class_name, status: "READY", access_mode, last_deployed_at } | ||
| ``` | ||
| Re-deploying with the same `name` updates the class; old in-memory state is evicted on next request. Storage persists across redeploys (same instance id = same `state.storage`). | ||
| ### Access modes | ||
| | Mode | Auth required | | ||
| |------|---------------| | ||
| | `public` | None — validate tokens inside `fetch()` if you need any | | ||
| | `authenticated` (default) | End-user JWT in `Authorization: Bearer <token>` | | ||
| | `service_key` | Butterbase service key — backend-to-backend | | ||
| > The dispatcher only checks header **shape**, not validity. For real auth on production DOs, validate the token inside `fetch()`. | ||
| --- | ||
| ## 5. Address an instance | ||
| ``` | ||
| https://<your-subdomain>.butterbase.dev/_do/<name>/<instance-id> | ||
| ``` | ||
| - `<name>` = kebab-case DO name from deploy | ||
| - `<instance-id>` = anything you choose (`/lobby`, `/user-123`, `/main`) | ||
| Both HTTP and WebSocket upgrade work on the same URL. | ||
| ```js | ||
| // HTTP | ||
| fetch("https://app.butterbase.dev/_do/chat-room/lobby", { | ||
| method: "POST", | ||
| body: JSON.stringify({ user: "alice", text: "hi" }) | ||
| }); | ||
| // WebSocket | ||
| const ws = new WebSocket("wss://app.butterbase.dev/_do/chat-room/lobby"); | ||
| ``` | ||
| Different instance ids → completely separate state. There is no shared global view; if you need one, build it yourself (e.g. a `/registry` instance that other instances report into). | ||
| --- | ||
| ## 6. Env vars | ||
| Env vars are app-wide across all DO classes. Setting one redeploys the DO Worker — existing in-memory state is evicted, active WS connections drop. | ||
| ```js | ||
| manage_durable_objects({ app_id, action: "list_env" }) // keys only, never values | ||
| manage_durable_objects({ app_id, action: "set_env", key: "AI_API_KEY", value: "sk-..." }) | ||
| manage_durable_objects({ app_id, action: "delete_env", key: "AI_API_KEY" }) | ||
| ``` | ||
| - Keys must match `^[A-Z_][A-Z0-9_]*$` (UPPER_SNAKE). | ||
| - A key can't collide with a DO class binding (e.g. `chat-room` reserves `CHAT_ROOM`). | ||
| - Read in code as `this.env.KEY_NAME`. | ||
| --- | ||
| ## 7. Lifecycle, listing, deletion | ||
| ```js | ||
| manage_durable_objects({ app_id, action: "list" }) | ||
| manage_durable_objects({ app_id, action: "get", name: "chat-room" }) // includes full source + status + error_message | ||
| manage_durable_objects({ app_id, action: "delete", name: "chat-room" }) // IRREVERSIBLE: purges all instances + storage | ||
| manage_durable_objects({ app_id, action: "usage", name: "chat-room" }) // do_requests, do_cpu_ms (refreshed every 15 min) | ||
| ``` | ||
| Status transitions: `PENDING → BUILDING → READY` or `ERROR` (with `error_message`). | ||
| --- | ||
| ## 8. Patterns | ||
| ### Chat room (broadcast) | ||
| ```typescript | ||
| export class ChatRoom { | ||
| constructor(public state: DurableObjectState, public env: any) {} | ||
| async fetch(req: Request): Promise<Response> { | ||
| if (req.headers.get("Upgrade") === "websocket") { | ||
| const pair = new WebSocketPair(); | ||
| this.state.acceptWebSocket(pair[1]); | ||
| const history = (await this.state.storage.get("messages")) ?? []; | ||
| pair[1].send(JSON.stringify({ type: "init", messages: history })); | ||
| return new Response(null, { status: 101, webSocket: pair[0] }); | ||
| } | ||
| return Response.json(await this.state.storage.get("messages") ?? []); | ||
| } | ||
| async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) { | ||
| if (typeof msg !== "string") return; | ||
| const history: any[] = (await this.state.storage.get("messages")) ?? []; | ||
| const parsed = JSON.parse(msg); | ||
| history.push(parsed); | ||
| await this.state.storage.put("messages", history.slice(-200)); | ||
| for (const peer of this.state.getWebSockets()) { | ||
| try { peer.send(msg); } catch {} | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ### Sliding-window rate limiter | ||
| ```typescript | ||
| export class RateLimiter { | ||
| constructor(public state: DurableObjectState, public env: any) {} | ||
| async fetch(req: Request): Promise<Response> { | ||
| const now = Date.now(); | ||
| const window = 60_000; | ||
| const limit = 100; | ||
| const requests: number[] = (await this.state.storage.get("requests")) ?? []; | ||
| const recent = requests.filter(t => now - t < window); | ||
| if (recent.length >= limit) return new Response("rate limit", { status: 429 }); | ||
| recent.push(now); | ||
| await this.state.storage.put("requests", recent); | ||
| return Response.json({ ok: true, remaining: limit - recent.length }); | ||
| } | ||
| } | ||
| ``` | ||
| Address one instance per actor: `/_do/rate-limiter/<user-id>` or `/_do/rate-limiter/<api-key-hash>`. | ||
| ### Long-running AI agent | ||
| ```typescript | ||
| export class Agent { | ||
| constructor(public state: DurableObjectState, public env: any) {} | ||
| async fetch(req: Request): Promise<Response> { | ||
| const { prompt } = await req.json(); | ||
| const r = await fetch(this.env.AI_API_ENDPOINT, { | ||
| method: "POST", | ||
| headers: { Authorization: `Bearer ${this.env.AI_API_KEY}` }, | ||
| body: JSON.stringify({ prompt }) | ||
| }); | ||
| const data = await r.json(); | ||
| await this.state.storage.put("last_response", data); | ||
| return Response.json(data); | ||
| } | ||
| } | ||
| ``` | ||
| One DO instance per conversation; storage holds the rolling history. | ||
| ### Counter / leaderboard | ||
| Each instance is its own counter. `/_do/leaderboard/main` and `/_do/leaderboard/season-2` have independent state — no coordination needed in v1. | ||
| --- | ||
| ## 9. Errors | ||
| Build-time (rejected on deploy): | ||
| | Code | Cause | | ||
| |------|-------| | ||
| | `NO_EXPORTED_CLASS` | Source doesn't export a class | | ||
| | `MULTIPLE_EXPORTS` | More than one export, or export of non-class | | ||
| | `INVALID_IMPORT` | Imported anything other than `cloudflare:workers` | | ||
| | `CLASS_NAME_PARSE_ERROR` | TS AST couldn't extract the class name | | ||
| | `NAME_REGEX_VIOLATION` | `name` doesn't match `^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$` | | ||
| | `QUOTA_DO_LIMIT` | Already 5 classes for this app | | ||
| | `BUNDLE_SIZE_EXCEEDED` / `SOURCE_SIZE_EXCEEDED` | Over the 10 MB / 5 MB limits | | ||
| Runtime / async-deploy: | ||
| - Status `ERROR` after a deploy → check `manage_durable_objects` (`get`) `error_message`. | ||
| - WebSocket message handler only fires if you called `state.acceptWebSocket(ws)` — easy to forget. | ||
| - `webSocketMessage` receives `string | ArrayBuffer`. Always guard before `JSON.parse`. | ||
| --- | ||
| ## 10. Anti-patterns | ||
| | Don't | Do | | ||
| |-------|----| | ||
| | Try to share state between instances directly | Pick a single "registry" instance and have others fetch into it | | ||
| | Use a DO for stateless HTTP work | Use a function — DOs cost more and have stricter constraints | | ||
| | Rely on dispatcher access_mode for real auth | Validate JWTs inside `fetch()` for production | | ||
| | Use `access_mode: "authenticated"` for browser WebSockets | Use `public` + token-in-query-string + manual validation; browsers can't set headers | | ||
| | Stuff > 128 KB blobs into `state.storage` | Use Butterbase Storage and store the `object_id` in DO state | | ||
| | Update env vars in tight loops | Each `set_env` redeploys the Worker — drops connections | | ||
| | Forget redeploy semantics | Code change or env change evicts all instances; storage survives but in-memory caches don't | |
| --- | ||
| name: migrations | ||
| description: Use when moving a Butterbase app between regions, checking migration progress, aborting a stuck move, reverse-rolling a completed move, or tearing down retained source replicas after a move is stable | ||
| --- | ||
| # Butterbase App Migrations (Multi-Region) | ||
| Moving an app between regions is an orchestrated migration: data dumped from source, restored in dest, blobs copied, runtime brought up, then traffic flipped. Source replica is retained for safe rollback until you tear it down. | ||
| Four small tools wrap the orchestration: | ||
| | Tool | Purpose | | ||
| |---|---| | ||
| | `list_regions` | Discover what regions exist before suggesting one. | | ||
| | `move_app` | Start a migration. Returns a `migration_id`. | | ||
| | `move_app_status` | Read the current step + replica state of a specific migration. | | ||
| | `manage_migrations` | Operational ops: `get_active`, `abort`, `reverse`, `list_source_replicas`. | | ||
| | `teardown_source_replica` | Decommission the retained source replica once you're confident. | | ||
| --- | ||
| ## 1. The step sequence | ||
| A successful move walks through 11 steps in order: | ||
| ``` | ||
| requested → reserving_dest → blocking_writes → dumping_data → | ||
| restoring_data → copying_blobs → copying_runtime → flipping_routing → | ||
| setting_up_reverse_replication → unblocking_writes → completed | ||
| ``` | ||
| The cutover happens at `flipping_routing`. Before that, the app is still served from the source. After that, traffic is on the destination. | ||
| Terminal states: `completed`, `aborted`, `failed`. | ||
| --- | ||
| ## 2. Start a move | ||
| ``` | ||
| list_regions → confirm dest region is supported | ||
| move_app(app_id, dest_region) → { migration_id, status: "queued" } | ||
| ``` | ||
| Then poll with `move_app_status({ app_id, migration_id })` every few seconds (or call `manage_migrations({ action: "get_active", app_id })` if you've forgotten the id). | ||
| --- | ||
| ## 3. Recovery: pick the right escape hatch | ||
| Stuck or wrong choice? Different state, different tool: | ||
| - **Before `flipping_routing`** (still in dump / restore / copy phases): use `manage_migrations` `action: "abort"`. Cancels cleanly; no data flipped. | ||
| - **After `flipping_routing`** (already cut over): abort is locked. Use `manage_migrations` `action: "reverse"`. This rolls back to source — works only while the source replica is still retained (i.e. you haven't called `teardown_source_replica` yet). | ||
| - **Move is done and stable** (the new region is serving traffic without issues for a while): use `teardown_source_replica({ migration_id })`. Stops paying for source-region storage. After this, `reverse` is no longer available. | ||
| --- | ||
| ## 4. Inspecting in-flight + retained state | ||
| - `manage_migrations({ action: "get_active", app_id })` → `{ migration: AppMigration | null }`. Tells you if a move is in progress right now. | ||
| - `manage_migrations({ action: "list_source_replicas" })` → all retained replicas the caller owns. Call this before any teardown so you know what you'd lose. | ||
| --- | ||
| ## 5. Common pitfalls | ||
| - **Calling `move_app` again while one is active** — backend returns 409 `ineligible`. Resolve by waiting for the active migration to terminate, or aborting it first. | ||
| - **Trying to abort after cutover** — returns 409. The path forward is `reverse`, not abort. | ||
| - **Tearing down a replica too early** — once it's gone, reverse-move is no longer possible. Verify the destination region has been serving traffic cleanly before teardown. | ||
| - **Forgetting `dest_region` is validated** — invalid slugs return 400. Always `list_regions` first if you're not sure. | ||
| --- | ||
| ## 6. What this skill does NOT cover | ||
| - Schema migrations (table/column changes) — use `butterbase:schema-design`. | ||
| - Region selection at app *creation* time — use `init_app` directly with the `region` parameter. | ||
| - Cross-region replica reads (none — Butterbase is single-region-active per app). |
| --- | ||
| name: rag-dev | ||
| description: Use when building knowledge bases, ingesting documents, running semantic search, or adding LLM-synthesized Q&A over private content with Butterbase RAG | ||
| --- | ||
| # Butterbase RAG (Retrieval-Augmented Generation) | ||
| Two tools cover the entire RAG surface: | ||
| - **`manage_rag_content`** — collections, document ingestion, status polling, deletion | ||
| - **`rag_query`** — semantic search, optional LLM synthesis | ||
| Documents are ingested asynchronously: text or files become embeddings stored in pgvector, and queries do a similarity search at runtime. | ||
| --- | ||
| ## 1. The mental model | ||
| ``` | ||
| Collection Documents Chunks | ||
| ────────── ────────── ────── | ||
| "product-faq" ──────────────► doc_1 (PDF) ───────────► chunk 1, 2, 3... | ||
| doc_2 (text) ──────────► chunk 4, 5... | ||
| doc_3 (markdown) ──────► chunk 6... | ||
| ``` | ||
| A **collection** holds documents; a **document** is split into **chunks** and embedded; **`rag_query`** searches by cosine similarity across chunks within a collection. | ||
| `chunk_size` and `chunk_overlap` are set **once at collection creation** and immutable — to change them, delete and recreate the collection. | ||
| --- | ||
| ## 2. End-to-end workflow | ||
| ``` | ||
| ┌────────────────────────────────────────────┐ | ||
| │ 1. create_collection (once per knowledge) │ | ||
| ├────────────────────────────────────────────┤ | ||
| │ 2. ingest_document (text OR storage_object)│ | ||
| ├────────────────────────────────────────────┤ | ||
| │ 3. poll get_document_status until "ready" │ | ||
| ├────────────────────────────────────────────┤ | ||
| │ 4. rag_query (with or without synthesis) │ | ||
| └────────────────────────────────────────────┘ | ||
| ``` | ||
| ### Step 1 — create the collection | ||
| ```js | ||
| manage_rag_content({ | ||
| app_id: "app_abc123", | ||
| action: "create_collection", | ||
| name: "product-faq", | ||
| description: "Customer-facing product knowledge", | ||
| chunk_size: 512, // optional, default 512 tokens | ||
| chunk_overlap: 50, // optional, default 50 tokens | ||
| access_mode: "shared" // optional: "private" | "shared" | "custom" | ||
| }) | ||
| ``` | ||
| | `access_mode` | Who can query | | ||
| |----------------|---------------| | ||
| | `private` (default) | Only the app owner / service key | | ||
| | `shared` | Any authenticated end-user with a valid JWT | | ||
| | `custom` | Respects RLS policies — for fine-grained control | | ||
| ### Step 2a — ingest raw text | ||
| ```js | ||
| manage_rag_content({ | ||
| app_id: "app_abc123", | ||
| action: "ingest_document", | ||
| collection: "product-faq", | ||
| text: "Our return policy is 30 days from purchase...", | ||
| filename: "return-policy.txt", // optional, for display | ||
| metadata: { category: "returns", tier: "all" } // filter later in rag_query | ||
| }) | ||
| // → { document_id: "doc_xyz", status: "pending" } | ||
| ``` | ||
| ### Step 2b — ingest an uploaded file | ||
| Files come from `manage_storage` first. Two-step: | ||
| ```js | ||
| // 1. Upload the file via the storage skill — get an object_id | ||
| const { object_id } = await uploadPdfViaStorage(...); | ||
| // 2. Hand that object_id to RAG ingestion | ||
| manage_rag_content({ | ||
| app_id: "app_abc123", | ||
| action: "ingest_document", | ||
| collection: "product-faq", | ||
| storage_object_id: object_id, | ||
| filename: "manual.pdf", | ||
| metadata: { product: "v3" } | ||
| }) | ||
| ``` | ||
| Supported file types: **PDF, TXT, Markdown, CSV, HTML, DOCX, XLSX, PPTX**. | ||
| ### Step 3 — poll until ready | ||
| Ingestion is fire-and-forget. The document moves through `pending → processing → ready` (or `failed`). Poll: | ||
| ```js | ||
| manage_rag_content({ | ||
| app_id: "app_abc123", | ||
| action: "get_document_status", | ||
| collection: "product-faq", | ||
| document_id: "doc_xyz" | ||
| }) | ||
| // → { id, filename, status: "processing", processedAt, errorMessage? } | ||
| ``` | ||
| Recommended cadence: poll every 2–5 seconds for the first minute, back off after that. Bigger files (large PDFs, XLSX) take longer. | ||
| ### Step 4 — query | ||
| Two modes: raw retrieval (just chunks back) or synthesized (LLM answer + sources). | ||
| #### Raw retrieval | ||
| ```js | ||
| rag_query({ | ||
| app_id: "app_abc123", | ||
| collection: "product-faq", | ||
| query: "How long do I have to return an item?", | ||
| top_k: 5, // default 5, max 20 | ||
| threshold: 0.7, // optional similarity floor (0..1) | ||
| filter: { category: "returns" } // optional metadata filter | ||
| }) | ||
| // → { chunks: [{ text, score, document_id, metadata }, ...] } | ||
| ``` | ||
| #### Synthesized answer | ||
| ```js | ||
| rag_query({ | ||
| app_id: "app_abc123", | ||
| collection: "product-faq", | ||
| query: "How long do I have to return an item?", | ||
| synthesize: true, | ||
| model: "anthropic/claude-haiku-4.5" // default | ||
| }) | ||
| // → { answer, chunks, model } | ||
| ``` | ||
| `synthesize: true` runs the retrieved chunks through an LLM and returns a grounded answer. `chunks` is still included so you can show citations. | ||
| --- | ||
| ## 3. Listing and cleanup | ||
| ```js | ||
| manage_rag_content({ app_id, action: "list_collections" }) | ||
| manage_rag_content({ app_id, action: "get_collection", name: "product-faq" }) | ||
| manage_rag_content({ app_id, action: "list_documents", collection: "product-faq" }) | ||
| manage_rag_content({ app_id, action: "delete_document", collection: "product-faq", document_id: "doc_xyz" }) | ||
| manage_rag_content({ app_id, action: "delete_collection", name: "product-faq" }) | ||
| ``` | ||
| `get_collection` returns `{ name, description, accessMode, chunkSize, chunkOverlap, createdAt, documentCount: { pending, processing, ready, failed } }` — handy for a dashboard view. | ||
| > **Both `delete_document` and `delete_collection` are irreversible** and remove embeddings. To replace a document, delete then re-ingest. | ||
| --- | ||
| ## 4. Choosing chunk size and overlap | ||
| | Use case | Suggested `chunk_size` | `chunk_overlap` | | ||
| |----------|------------------------|------------------| | ||
| | Q&A over short FAQs / docs | 256–512 | 50 | | ||
| | Long-form documentation, manuals | 512–1024 | 100 | | ||
| | Code or structured content | 1024–2048 | 0–50 | | ||
| | Conversational logs / transcripts | 256 | 50 | | ||
| Larger chunks preserve more context but reduce retrieval granularity (you may pull in irrelevant nearby content). Overlap prevents semantic splits at boundaries from losing meaning. **You can't change these without recreating the collection** — pick them deliberately the first time. | ||
| --- | ||
| ## 5. Metadata-driven filtering | ||
| Anything you pass in `metadata` at ingest time is available as a filter at query time. Use it to scope queries: | ||
| ```js | ||
| // at ingest: | ||
| metadata: { product: "v3", region: "EU", language: "en" } | ||
| // at query: | ||
| filter: { product: "v3", language: "en" } | ||
| ``` | ||
| Filters are exact-match key/value. There's no full-text search beyond chunk content; design your metadata schema to match how you'll segment queries. | ||
| --- | ||
| ## 6. Common patterns | ||
| ### Customer-support bot | ||
| 1. Create `support-kb` (access_mode: `shared`). | ||
| 2. Ingest your help-center articles (markdown) and product PDFs. | ||
| 3. From a serverless function: `rag_query` with `synthesize: true`, return the answer + top 3 chunks as citations. | ||
| ### Per-tenant knowledge base | ||
| 1. Create one collection per tenant (`access_mode: "custom"`). | ||
| 2. Tag every document with `metadata: { tenant_id }`. | ||
| 3. Query with `filter: { tenant_id: ctx.user.tenant_id }` from a function. | ||
| 4. Use RLS on the wrapping table to gate which tenant a user can query. | ||
| ### Versioned docs | ||
| Tag with `metadata: { version: "v3" }`. Query with `filter: { version: "v3" }`. To deprecate `v2`, delete just those documents — no need to rebuild the collection. | ||
| --- | ||
| ## 7. Errors and pitfalls | ||
| | Error | Cause | | ||
| |-------|-------| | ||
| | `RESOURCE_NOT_FOUND` | App / collection / document doesn't exist | | ||
| | `VALIDATION_DUPLICATE_NAME` | Collection name already taken | | ||
| | `VALIDATION_ERROR` | `ingest_document` with neither `text` nor `storage_object_id` | | ||
| | `COLLECTION_EMPTY` | `rag_query` against a collection with no `ready` docs | | ||
| **Pitfalls:** | ||
| - Polling too aggressively wastes quota; backoff after the first minute. | ||
| - `chunk_size` / `chunk_overlap` are immutable — get them right up front. | ||
| - `synthesize: true` adds LLM latency + cost. For low-latency UX, do raw retrieval and synthesize on the frontend asynchronously. | ||
| - Metadata is exact-match only — no LIKE, no ranges. Pre-bucket continuous values (e.g. `tier: "free" | "pro"`) before ingesting. | ||
| - File ingestion **requires** prior upload to `manage_storage`; you cannot stream raw bytes into `ingest_document`. | ||
| - A failed document stays in the collection with `status: "failed"` and an `errorMessage`. Delete and re-ingest to retry. |
| --- | ||
| name: realtime | ||
| description: Use when enabling WebSocket subscriptions for live database changes, presence/multiplayer state, or when debugging clients that connect but receive no events | ||
| --- | ||
| # Butterbase Realtime | ||
| Live database change notifications over WebSocket, with per-row RLS enforcement. Once a table is enabled, INSERT/UPDATE/DELETE events stream to subscribed clients **filtered by the same RLS policies** that gate reads. | ||
| One tool: **`manage_realtime`** with two actions: `configure` and `get`. | ||
| --- | ||
| ## 1. The mental model | ||
| ``` | ||
| Postgres (data plane) Control API Browser | ||
| ───────────────────── ──────────── ──────── | ||
| INSERT/UPDATE/DELETE ──trigger──► realtime.changes ──WAL listener─► WebSocket ──► client | ||
| │ | ||
| └── RLS check per (role, user) ──► filter rows | ||
| ``` | ||
| When you `configure` a table: | ||
| 1. A Postgres trigger is installed → writes every change to `realtime.changes`. | ||
| 2. A LISTEN connection in `RealtimeManager` reads those changes. | ||
| 3. For each connected client, the change is RLS-checked **as that user** before broadcasting. | ||
| 4. Clients only receive events for rows they could read with a regular SELECT. | ||
| --- | ||
| ## 2. Prerequisites | ||
| Before calling `configure`, the table must: | ||
| 1. **Exist.** Run `manage_schema` (`action: "apply"`) first. Realtime won't auto-create it. | ||
| 2. **Have RLS configured (if you care about isolation).** Realtime respects whatever policies exist via `manage_rls`. **No policies = all events flow to all users of that role.** This is the #1 silent leak. | ||
| 3. **Have a primary key.** RLS checks query by PK. Tables without one can't be realtime-enabled cleanly. | ||
| --- | ||
| ## 3. Configure tables | ||
| ```js | ||
| manage_realtime({ | ||
| app_id: "app_abc123", | ||
| action: "configure", | ||
| tables: ["messages", "presence", "documents"] | ||
| }) | ||
| // → [{ table: "messages", status: "enabled" }, ...] | ||
| ``` | ||
| - Idempotent — already-enabled tables are skipped. | ||
| - All three events (INSERT / UPDATE / DELETE) are enabled together; per-event filtering happens client-side via subscription `filter`. | ||
| - Validation: every named table must exist or you get `VALIDATION_TABLE_NOT_FOUND`. | ||
| ### Inspect current state | ||
| ```js | ||
| manage_realtime({ app_id: "app_abc123", action: "get" }) | ||
| // → { | ||
| // tables: [{ table_name, enabled, trigger_installed, drift, created_at, updated_at }, ...], | ||
| // active_connection: true, | ||
| // websocket_url: "wss://api.butterbase.dev/v1/app_abc123/realtime" | ||
| // } | ||
| ``` | ||
| `drift: true` means the control-plane config says enabled but the data-plane trigger is missing — typically after a schema migration that dropped/recreated the table. Re-run `configure` to repair. | ||
| --- | ||
| ## 4. Connect from a client | ||
| ``` | ||
| wss://api.butterbase.dev/v1/{app_id}/realtime?token={JWT_or_API_KEY} | ||
| ``` | ||
| Browsers can't set custom headers on WebSocket upgrade, so the JWT goes in the query string. Server clients can use `Authorization: Bearer ...` instead. | ||
| ```js | ||
| const ws = new WebSocket( | ||
| `wss://api.butterbase.dev/v1/${appId}/realtime?token=${userJwt}` | ||
| ); | ||
| ws.onopen = () => { | ||
| ws.send(JSON.stringify({ type: "subscribe", table: "messages" })); | ||
| }; | ||
| ws.onmessage = (e) => { | ||
| const msg = JSON.parse(e.data); | ||
| if (msg.type === "change") handleChange(msg); // { type, table, op, record, old_record, timestamp } | ||
| }; | ||
| ``` | ||
| The Butterbase SDK wraps this: | ||
| ```ts | ||
| const realtime = client.realtime(appId, userJwt); | ||
| realtime.subscribe("messages", (change) => console.log(change.op, change.record)); | ||
| ``` | ||
| ### Welcome and protocol | ||
| On connect, the server sends: | ||
| ```json | ||
| { "type": "connected", "app_id": "app_abc123", "role": "butterbase_user" } | ||
| ``` | ||
| Then a heartbeat every 30s: | ||
| ```json | ||
| { "type": "heartbeat", "timestamp": "..." } | ||
| ``` | ||
| ### Client → server messages | ||
| | Type | Body | Purpose | | ||
| |------|------|---------| | ||
| | `subscribe` | `{ table, filter? }` | Subscribe to changes; optional client-side filter `{ col: value }` | | ||
| | `unsubscribe` | `{ table }` | Stop receiving | | ||
| | `presence_track` | `{ metadata }` | Announce yourself with arbitrary metadata (cursor, status) | | ||
| | `event` | `{ event, payload }` | Trigger a function with `trigger: { type: "websocket", config: { event } }` | | ||
| ### Server → client messages | ||
| | Type | Body | | ||
| |------|------| | ||
| | `change` | `{ table, op: "INSERT"\|"UPDATE"\|"DELETE", record, old_record, timestamp }` | | ||
| | `presence_state` | `{ clients: [{ client_id, user_id, metadata }] }` | | ||
| | `heartbeat` | `{ timestamp }` | | ||
| --- | ||
| ## 5. RLS enforcement (the critical pitfall) | ||
| For each broadcast, the server runs (roughly): | ||
| ```sql | ||
| SET LOCAL ROLE butterbase_user; | ||
| SET LOCAL request.jwt.claim.sub = '{user_id}'; | ||
| SELECT 1 FROM "{table}" WHERE "{pk}" = {record_pk} LIMIT 1; | ||
| ``` | ||
| If the row is **not visible** under RLS, the change is **silently dropped** for that client. There is no error. | ||
| **Common consequences:** | ||
| - Client connects, sees `connected`, subscribes — but receives no events. → RLS too restrictive (or no policies at all + access mode `authenticated`). | ||
| - Client receives some events but not others. → RLS works for those rows; others are filtered out (often correct). | ||
| - Service key clients see everything. → Service bypasses RLS. Don't use this to "verify realtime works" if testing user-scoped behaviour. | ||
| **Always test with a real end-user JWT**, not the service key. | ||
| ### Anonymous clients | ||
| If `manage_app` access mode is `authenticated`, anonymous WebSocket connections are rejected with close code `1008 (Policy Violation)`. To allow anon, the app must be in `public` mode AND the table must have a permissive policy for `butterbase_anon`. | ||
| --- | ||
| ## 6. Connection lifecycle | ||
| | Close code | Meaning | | ||
| |------------|---------| | ||
| | `1008` | App requires authentication, no token provided | | ||
| | `1013` (try again later) | Plan limit hit (`maxRealtimeListenersPerApp`) — upgrade | | ||
| | `1013` ("Realtime disabled by plan") | Free / starter tiers may have realtime off entirely | | ||
| | Normal close | Heartbeat missed, client disconnected, or server eviction | | ||
| The server caches table primary keys for 60s and batches RLS checks per `(role, user)` group, so connection cost is amortised. | ||
| --- | ||
| ## 7. Common patterns | ||
| ### Live chat | ||
| 1. `manage_schema` apply with a `messages` table (`id`, `room_id`, `user_id`, `body`, `created_at`). | ||
| 2. `manage_rls` `create_user_isolation` with `user_column: "user_id"` plus a custom policy that allows reading messages where `room_id IN (SELECT room_id FROM members WHERE user_id = current_user_id())`. | ||
| 3. `manage_realtime` `configure` with `tables: ["messages"]`. | ||
| 4. Client connects, subscribes to `messages`, optionally filters `{ room_id: "..." }`. | ||
| ### Live dashboard / activity feed | ||
| 1. Add a `notifications` table with `user_id`. | ||
| 2. RLS `create_user_isolation` so each user only sees their own. | ||
| 3. Realtime configure → notifications stream straight to the right user, no extra filtering needed. | ||
| ### Multiplayer cursor sharing | ||
| Use **presence**, not table changes: | ||
| ```js | ||
| ws.send(JSON.stringify({ | ||
| type: "presence_track", | ||
| metadata: { cursor: { x: 100, y: 50 }, color: "#f00" } | ||
| })); | ||
| ``` | ||
| Other clients receive `presence_state` updates with everyone's metadata. No DB writes. | ||
| ### Debugging "client connects but no events" | ||
| 1. Confirm trigger installed: `manage_realtime` `get` → `trigger_installed: true`, `drift: false`. | ||
| 2. Confirm RLS allows the user to SELECT the row: `select_rows` with `as_role: "user", as_user: "<id>"` → does the row appear? | ||
| 3. If yes to both and still no events: check for plan limits in close codes; check that the change *actually* happened in Postgres (look at `realtime.changes`). | ||
| --- | ||
| ## 8. Anti-patterns | ||
| | Don't | Do | | ||
| |-------|----| | ||
| | Enable realtime before configuring RLS | Set up policies first; otherwise events leak across users | | ||
| | Use a service key from the frontend "to make it work" | Service bypasses RLS — ship-stopping leak. Use end-user JWTs. | | ||
| | Trust client-side `filter` for security | `filter` is just a convenience to reduce client-side work; RLS is the security boundary | | ||
| | Hold thousands of subscriptions per client | One connection, one or two subscribed tables — the server handles fan-out | | ||
| | Re-call `configure` in a loop on every page load | It's idempotent but each call still touches the DB. Configure once during app setup. | | ||
| | Send custom auth headers from the browser | WebSocket API can't set them — pass the JWT as `?token=` query param | |
| --- | ||
| name: storage | ||
| description: Use when uploading or downloading files, generating presigned URLs, configuring storage ACLs, or persisting file references (avatars, attachments, images) in a Butterbase app | ||
| --- | ||
| # Butterbase Storage | ||
| Butterbase stores files in S3 (or LocalStack in dev) and exposes them via presigned URLs. Every file gets a stable `object_id` (UUID) that you persist in your tables; URLs are generated on demand and expire. | ||
| All storage operations go through one tool: **`manage_storage`** with an `action` parameter. | ||
| | Action | Purpose | | ||
| |--------|---------| | ||
| | `upload_url` | Generate a 15-minute presigned PUT URL and reserve an `object_id` | | ||
| | `download_url` | Generate a 1-hour presigned GET URL for a stored object | | ||
| | `list` | List objects (scoped by caller's role) | | ||
| | `delete` | Permanently remove an object from S3 + database | | ||
| | `update_config` | Toggle app-level `publicReadEnabled` and other storage settings | | ||
| --- | ||
| ## 1. The mental model: `object_id` vs `s3_key` | ||
| | Field | What it is | When you use it | | ||
| |-------|-----------|-----------------| | ||
| | `object_id` | UUID, stable, app-level handle | Persist in your tables (e.g. `users.avatar_id`, `posts.image_id`) | | ||
| | `s3_key` | Internal bucket path like `app_abc/user_uuid/file.jpg` | Internal only — **never** treat this as a URL | | ||
| **Critical:** `s3_key` is **not** a URL. You cannot use it as `<img src>` or `<a href>`. Always store the `object_id` and resolve a fresh download URL at render time. | ||
| --- | ||
| ## 2. The upload lifecycle | ||
| A single upload is two HTTP calls and one DB insert in your app: | ||
| ``` | ||
| ┌─────────────────────────┐ | ||
| │ 1. manage_storage( │ → returns { upload_url, object_id, expires_at } | ||
| │ action: upload_url)│ | ||
| ├─────────────────────────┤ | ||
| │ 2. PUT file -> S3 │ → must include exact Content-Type header | ||
| ├─────────────────────────┤ | ||
| │ 3. INSERT INTO ... │ → save object_id alongside the user/post/etc. | ||
| └─────────────────────────┘ | ||
| ``` | ||
| If you skip step 3, the file lives in S3 but no row references it — an **orphaned object** counting against your quota. Always persist the `object_id`. | ||
| ### Step 1 — request an upload URL | ||
| ```js | ||
| manage_storage({ | ||
| app_id: "app_abc123", | ||
| action: "upload_url", | ||
| filename: "avatar.jpg", | ||
| content_type: "image/jpeg", | ||
| size_bytes: 245123, | ||
| public: false // optional; default false | ||
| }) | ||
| ``` | ||
| Returns: | ||
| ```json | ||
| { | ||
| "upload_url": "https://s3.amazonaws.com/...", | ||
| "object_id": "9c14b2e0-...", | ||
| "expires_at": "2026-05-08T22:15:00Z" | ||
| } | ||
| ``` | ||
| The `object_id` is created **immediately** in the database; the file just hasn't been uploaded yet. | ||
| ### Step 2 — PUT the bytes | ||
| The Content-Type on the PUT must match exactly the `content_type` you sent in step 1. If it doesn't, S3 rejects the upload or stores the wrong MIME — breaking browser previews. | ||
| ```bash | ||
| curl -X PUT "{upload_url}" \ | ||
| -H "Content-Type: image/jpeg" \ | ||
| --data-binary @avatar.jpg | ||
| ``` | ||
| From the browser: | ||
| ```js | ||
| await fetch(uploadUrl, { | ||
| method: "PUT", | ||
| headers: { "Content-Type": file.type }, | ||
| body: file | ||
| }); | ||
| ``` | ||
| ### Step 3 — persist the `object_id` | ||
| ```sql | ||
| UPDATE users SET avatar_id = $1 WHERE id = $2 | ||
| ``` | ||
| Or via the auto-API / SDK — whatever your app uses for writes. Without this step, the file is unreachable. | ||
| --- | ||
| ## 3. Generating download URLs | ||
| Each call returns a fresh URL valid for 1 hour. Don't bake URLs into static HTML or long-lived caches — re-generate per render or per session. | ||
| ```js | ||
| manage_storage({ | ||
| app_id: "app_abc123", | ||
| action: "download_url", | ||
| object_id: "9c14b2e0-..." | ||
| }) | ||
| // → { download_url: "https://s3.amazonaws.com/..." } | ||
| ``` | ||
| For lists with many files, resolve URLs in parallel: | ||
| ```js | ||
| const urls = await Promise.all( | ||
| posts.map(p => getDownloadUrl(p.image_id)) | ||
| ); | ||
| ``` | ||
| If the caller is unauthorized, the response is `404` (not `403`) — Butterbase deliberately hides existence to avoid leaking object IDs. | ||
| --- | ||
| ## 4. Access control | ||
| Three tiers, evaluated in order: | ||
| | Caller | What they can read | | ||
| |--------|--------------------| | ||
| | Service key (`bb_sk_*`) | Everything in the app — RLS bypassed | | ||
| | End-user JWT | Files where `(user_id === caller_id) OR object.public === true OR app.publicReadEnabled === true` | | ||
| | Anonymous (no auth) | Only public objects (and only if app access mode allows anon) | | ||
| ### Per-object public flag | ||
| Set at upload time: | ||
| ```js | ||
| manage_storage({ app_id, action: "upload_url", filename, content_type, size_bytes, public: true }) | ||
| ``` | ||
| Use this for one-off public files (a marketing image, a shared avatar) without flipping the whole app to public-read. | ||
| ### App-wide public read | ||
| ```js | ||
| manage_storage({ | ||
| app_id: "app_abc123", | ||
| action: "update_config", | ||
| publicReadEnabled: true | ||
| }) | ||
| ``` | ||
| When `true`, any authenticated user in the app can download any file. Uploads and deletes stay user-scoped. | ||
| > Storage ACL is hardcoded — you cannot layer Postgres RLS policies on top of `storage_objects`. If you need fine-grained custom rules, gate downloads through a serverless function instead of handing out direct presigned URLs. | ||
| --- | ||
| ## 5. Listing and deleting | ||
| ```js | ||
| manage_storage({ app_id: "app_abc123", action: "list" }) | ||
| ``` | ||
| Service key sees everything; end-user JWT sees only their own files. Each item has `id, user_id, key, filename, content_type, size_bytes, created_at`. | ||
| ```js | ||
| manage_storage({ app_id: "app_abc123", action: "delete", object_id: "9c14b2e0-..." }) | ||
| ``` | ||
| Permanently removes the S3 object and DB row. **Clear foreign-key references first** (e.g. `UPDATE users SET avatar_id = NULL`) — `manage_storage` doesn't. | ||
| --- | ||
| ## 6. Quotas & error codes | ||
| | Limit | Default | Override | | ||
| |-------|---------|----------| | ||
| | Per-file size | 10 MB | `storage_config` | | ||
| | Total app storage | Plan-dependent | Upgrade plan | | ||
| | Allowed content types | All by default | `storage_config.allowedContentTypes` whitelist | | ||
| | Error | When | | ||
| |-------|------| | ||
| | `QUOTA_FILE_SIZE_EXCEEDED` (400) | `size_bytes` > per-file limit | | ||
| | `QUOTA_STORAGE_EXCEEDED` (429) | App total exhausted | | ||
| | `VALIDATION_INVALID_TYPE` (400) | `content_type` not in whitelist | | ||
| | `RESOURCE_NOT_FOUND` (404) | Object missing or caller unauthorized (deliberately ambiguous) | | ||
| | `S3_ERROR` (503) | Transient S3 failure — retry | | ||
| --- | ||
| ## 7. Common patterns | ||
| ### User avatar | ||
| 1. Upload form posts file → `manage_storage` (`upload_url`, `public: false`). | ||
| 2. Browser PUTs to `upload_url`. | ||
| 3. App calls `UPDATE users SET avatar_id = $object_id`. | ||
| 4. On profile render: `manage_storage` (`download_url`, `object_id: user.avatar_id`). | ||
| ### Public marketing image | ||
| Same flow, but `public: true` at upload time. Then the same download URL is reachable by anonymous visitors (no JWT needed) — useful for landing pages and OG images. | ||
| ### File attachment with auth | ||
| For attachments where access depends on app-level rules richer than "owner or public" (e.g. "members of this workspace can read"), don't expose the presigned URL directly. Wrap downloads in a serverless function: | ||
| ```ts | ||
| export async function handler(req, ctx) { | ||
| const { rows } = await ctx.db.query( | ||
| "SELECT object_id FROM attachments WHERE id = $1 AND workspace_id IN (SELECT workspace_id FROM members WHERE user_id = $2)", | ||
| [attachmentId, ctx.user.id] | ||
| ); | ||
| if (!rows.length) return new Response("forbidden", { status: 403 }); | ||
| const url = await getDownloadUrlServerSide(rows[0].object_id); | ||
| return Response.redirect(url, 302); | ||
| } | ||
| ``` | ||
| The function runs as `butterbase_user` and uses your tables' RLS to authorize, then mints a presigned URL with the service key. | ||
| --- | ||
| ## 8. Anti-patterns | ||
| | Don't | Do | | ||
| |-------|-----| | ||
| | Store the `s3_key` in your tables | Store the `object_id` UUID | | ||
| | Embed presigned URLs in HTML or DB rows | Re-fetch per render — they expire in 1 hour | | ||
| | Send the wrong `Content-Type` on PUT | Match exactly what you sent to `upload_url` | | ||
| | Call `upload_url` and forget step 3 | Always persist `object_id` after a successful PUT | | ||
| | Use `public: true` everywhere "just in case" | Default to private; opt files into public explicitly | | ||
| | Delete a file without clearing FKs | Update referencing rows first, then `action: "delete"` | |
@@ -7,8 +7,8 @@ { | ||
| "name": "Butterbase", | ||
| "email": "support@butterbase.ai" | ||
| "email": "ken@butterbase.ai" | ||
| }, | ||
| "homepage": "https://butterbase.ai", | ||
| "repository": "https://github.com/butterbase/butterbase", | ||
| "repository": "https://github.com/NetGPT-Inc/butterbase-plugin", | ||
| "license": "MIT", | ||
| "keywords": ["butterbase", "baas", "backend", "mcp", "database", "auth", "storage"] | ||
| } |
+38
-9
@@ -19,14 +19,36 @@ # Butterbase | ||
| 1. `init_app` — Create app, get `app_id` and `api_base` | ||
| 2. `apply_schema` — Define tables declaratively (preview with `dry_run_schema`) | ||
| 3. `create_user_isolation_policy` — Secure user-owned tables with RLS | ||
| 4. `configure_oauth_provider` — Set up social sign-in (Google, GitHub, etc.) | ||
| 2. `manage_schema` (`action: "apply"`) — Define tables declaratively (preview with `action: "dry_run"`) | ||
| 3. `manage_rls` (`action: "create_user_isolation"`) — Secure user-owned tables with RLS | ||
| 4. `manage_oauth` (`action: "configure"`) — Set up social sign-in (Google, GitHub, etc.) | ||
| 5. `deploy_function` — Add backend logic (HTTP, cron, WebSocket triggers) | ||
| 6. `create_frontend_deployment` + `start_frontend_deployment` — Deploy frontend to live URL | ||
| 6. `create_frontend_deployment` + `manage_frontend` (`action: "start_deployment"`) — Deploy frontend to live URL | ||
| ## Tool shape | ||
| Most operations live on a small set of `manage_*` umbrella tools and take an `action` enum: | ||
| - `manage_schema` — `get | dry_run | apply | list_migrations` | ||
| - `manage_rls` — `enable | create_policy | update_policy | create_user_isolation | list | delete` | ||
| - `manage_app` — `list | delete | pause | get_config | update_access_mode | secure | update_cors` | ||
| - `manage_oauth` — `configure | get | update | delete` | ||
| - `manage_auth_config` — `configure_auth_hook | update_jwt | generate_service_key` | ||
| - `manage_function` — `list | delete | get_logs | update_env` | ||
| - `manage_frontend` — `start_deployment | list_deployments | create_from_source | start_from_source | set_env | configure_custom_domain` | ||
| - `manage_edge_ssr` — `create | start | create_from_source | start_from_source | list` | ||
| - `manage_storage` — `upload_url | download_url | list | delete | update_config` | ||
| - `manage_rag_content` — `create_collection | list_collections | get_collection | delete_collection | ingest_document | list_documents | get_document_status | delete_document` | ||
| - `manage_realtime` — `configure | get` | ||
| - `manage_durable_objects` — `deploy | list | get | delete | usage | list_env | set_env | delete_env` | ||
| - `manage_migrations` — `get_active | abort | reverse | list_source_replicas` | ||
| - `manage_ai` — `chat | embed | list_models | get_config | update_config | get_usage` | ||
| - `manage_integrations`, `manage_billing`, `manage_api_keys` | ||
| Standalone tools (no `action`): `init_app`, `deploy_function`, `invoke_function`, `select_rows`, `insert_row`, `seed_database`, `create_frontend_deployment`, `rag_query`, `query_audit_logs`, `butterbase_docs`, `submit_suggestion`, `list_regions`, `move_app`, `move_app_status`, `teardown_source_replica`. | ||
| ## Important Patterns | ||
| ### Storage | ||
| - Persist `objectId` (UUID) from upload response — NOT `objectKey` (bucket path) | ||
| - `objectKey` is not a URL — it cannot be used as `img src` or `href` | ||
| - Resolve download URLs at render time via `generate_download_url(objectId)` — presigned URLs expire | ||
| - Persist `object_id` (UUID) from upload response — NOT `s3_key` (bucket path) | ||
| - `s3_key` is not a URL — it cannot be used as `img src` or `href` | ||
| - Resolve download URLs at render time via `manage_storage` (`action: "download_url"`, `object_id: ...`) — presigned URLs expire after 1 hour | ||
| - For lists with many files, resolve presigned URLs in parallel (`Promise.all`) | ||
@@ -48,3 +70,3 @@ | ||
| - Destructive operations require explicit opt-in: `_drop: ["table"]` or `_dropColumns: ["col"]` | ||
| - Preview changes with `dry_run_schema` before applying | ||
| - Preview changes with `manage_schema` (`action: "dry_run"`) before applying | ||
@@ -63,3 +85,3 @@ ### Branding | ||
| | `overview` | Platform introduction and key features | | ||
| | `mcp` | All 42+ MCP tools with usage examples | | ||
| | `mcp` | All MCP tools with usage examples | | ||
| | `rest` | Auto-generated REST API (CRUD, filtering, sorting, pagination) | | ||
@@ -96,1 +118,8 @@ | `auth` | End-user authentication (email/password, OAuth, JWT) | | ||
| | `butterbase:contributing` | Contributing to the Butterbase codebase (adding MCP tools, routes) | | ||
| | `butterbase:storage` | File uploads, downloads, presigned URLs, ACLs | | ||
| | `butterbase:rag-dev` | RAG collections, document ingestion, semantic search | | ||
| | `butterbase:auth-setup` | OAuth providers, auth hooks, JWT tuning, service keys | | ||
| | `butterbase:realtime` | WebSocket subscriptions for table changes (RLS-aware) | | ||
| | `butterbase:durable-objects` | Stateful per-key actors for chat, multiplayer, rate limiters | | ||
| | `butterbase:migrations` | Moving apps between regions and managing migrations | | ||
| | `butterbase:ai` | Using the AI gateway — chat, embeddings, models, BYOK | |
+11
-4
| { | ||
| "name": "@butterbase/plugin", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "description": "Claude Code plugin for Butterbase — AI-Native Backend-as-a-Service. Skills for app creation, schema design, deployment, debugging, and contributing.", | ||
| "keywords": ["butterbase", "claude-code-plugin", "mcp", "baas", "backend", "skills"], | ||
| "keywords": [ | ||
| "butterbase", | ||
| "claude-code-plugin", | ||
| "mcp", | ||
| "baas", | ||
| "backend", | ||
| "skills" | ||
| ], | ||
| "author": "Butterbase", | ||
@@ -11,4 +18,3 @@ "license": "MIT", | ||
| "type": "git", | ||
| "url": "https://github.com/butterbase/butterbase", | ||
| "directory": "packages/plugin" | ||
| "url": "https://github.com/NetGPT-Inc/butterbase-plugin" | ||
| }, | ||
@@ -19,2 +25,3 @@ "files": [ | ||
| "CLAUDE.md", | ||
| "commands", | ||
| "skills", | ||
@@ -21,0 +28,0 @@ "README.md" |
@@ -10,2 +10,4 @@ --- | ||
| > **Convention:** every JSON body below is the argument object for the tool named in its **Tool:** header. When the header reads `manage_schema` with `action: "apply"`, include `"action": "apply"` alongside the other fields when you make the call. | ||
| --- | ||
@@ -39,3 +41,3 @@ | ||
| **Tool:** `generate_service_key` | ||
| **Tool:** `manage_auth_config` with `action: "generate_service_key"` | ||
@@ -65,3 +67,3 @@ ```json | ||
| **Tool:** `dry_run_schema` | ||
| **Tool:** `manage_schema` with `action: "dry_run"` | ||
@@ -89,3 +91,3 @@ ```json | ||
| **Tool:** `apply_schema` | ||
| **Tool:** `manage_schema` with `action: "apply"` | ||
@@ -125,3 +127,3 @@ Below is a complete example for a **blog app** with posts and comments: | ||
| **Tool:** `get_schema` | ||
| **Tool:** `manage_schema` with `action: "get"` | ||
@@ -142,3 +144,3 @@ ```json | ||
| - Use `references: "table.column"` for foreign keys (cascades must be set carefully) | ||
| - `apply_schema` is idempotent — safe to call again if schema is unchanged | ||
| - `manage_schema` action `apply` is idempotent — safe to call again if schema is unchanged | ||
@@ -159,3 +161,3 @@ --- | ||
| **Tool:** `create_user_isolation_policy` | ||
| **Tool:** `manage_rls` with `action: "create_user_isolation"` | ||
@@ -242,3 +244,3 @@ ```json | ||
| **Tool:** `get_rls_policies` | ||
| **Tool:** `manage_rls` with `action: "list"` | ||
@@ -263,3 +265,3 @@ ```json | ||
| **Tool:** `configure_oauth_provider` | ||
| **Tool:** `manage_oauth` with `action: "configure"` | ||
@@ -333,3 +335,3 @@ **Google example:** | ||
| **Tool:** `update_jwt_config` | ||
| **Tool:** `manage_auth_config` with `action: "update_jwt"` | ||
@@ -413,3 +415,3 @@ ```json | ||
| To rotate secrets without redeploying code, use `update_function_env`. | ||
| To rotate secrets without redeploying code, call `manage_function` with `action: "update_env"`. | ||
@@ -436,3 +438,3 @@ ### Test a Function | ||
| **Tool:** `get_function_logs` | ||
| **Tool:** `manage_function` with `action: "get_logs"` | ||
@@ -468,3 +470,3 @@ ```json | ||
| **Tool:** `update_cors` | ||
| **Tool:** `manage_app` with `action: "update_cors"` | ||
@@ -485,3 +487,3 @@ ```json | ||
| **Tool:** `set_frontend_env` | ||
| **Tool:** `manage_frontend` with `action: "set_env"` | ||
@@ -554,3 +556,3 @@ ```json | ||
| **Tool:** `start_frontend_deployment` | ||
| **Tool:** `manage_frontend` with `action: "start_deployment"` | ||
@@ -606,11 +608,11 @@ ```json | ||
| - [ ] **1. CORS configured for production domain** — `update_cors` includes the live frontend URL (not just localhost) | ||
| - [ ] **2. RLS enabled on all user-data tables** — `get_rls_policies` shows policies for every table holding user-generated content; no table is accidentally wide-open | ||
| - [ ] **2. RLS enabled on all user-data tables** — `manage_rls` (`action: "list"`) shows policies for every table holding user-generated content; no table is accidentally wide-open | ||
| - [ ] **3. OAuth redirect URIs point to production** — Provider developer consoles have the Butterbase callback URL registered; no localhost URIs are the only option in production | ||
| - [ ] **4. Frontend env vars set for production API URL** — `VITE_API_BASE` (or equivalent) points to `https://api.butterbase.ai/v1/{app_id}`, not a localhost URL | ||
| - [ ] **5. Error handling in all functions** — Every `deploy_function` handler returns appropriate HTTP status codes (400 for bad input, 401 for auth failures, 500 for unexpected errors) rather than throwing unhandled exceptions | ||
| - [ ] **6. JWT config reviewed** — `update_jwt_config` has been called with intentional token lifetimes; access token TTL is appropriate for the security sensitivity of the app (default 15m is reasonable) | ||
| - [ ] **7. Storage quotas checked** — `get_storage_objects` and app config reviewed; storage usage is within plan limits and `allowedContentTypes` are restricted to what the app actually needs | ||
| - [ ] **8. Functions tested with invoke_function** — Every HTTP function has been invoked with realistic payloads and edge cases (missing fields, invalid auth, large inputs) and returned correct responses | ||
| - [ ] **9. Frontend deployed and verified** — `list_frontend_deployments` shows a `READY` deployment; the live URL loads correctly in a browser and all API calls succeed | ||
| - [ ] **10. Monitoring and audit logs reviewed** — `query_audit_logs` shows no unexpected login failures or suspicious activity; `get_function_logs` shows no recurring errors in production traffic | ||
| - [ ] **6. JWT config reviewed** — `manage_auth_config` (`action: "update_jwt"`) has been called with intentional token lifetimes; access token TTL is appropriate for the security sensitivity of the app (default 15m is reasonable) | ||
| - [ ] **7. Storage quotas checked** — `manage_storage` (`action: "list"`) and app config reviewed; storage usage is within plan limits and `allowedContentTypes` are restricted to what the app actually needs | ||
| - [ ] **8. Functions tested with `invoke_function`** — Every HTTP function has been invoked with realistic payloads and edge cases (missing fields, invalid auth, large inputs) and returned correct responses | ||
| - [ ] **9. Frontend deployed and verified** — `manage_frontend` (`action: "list_deployments"`) shows a `READY` deployment; the live URL loads correctly in a browser and all API calls succeed | ||
| - [ ] **10. Monitoring and audit logs reviewed** — `query_audit_logs` shows no unexpected login failures or suspicious activity; `manage_function` (`action: "get_logs"`) shows no recurring errors in production traffic | ||
@@ -623,9 +625,9 @@ --- | ||
| |---|---| | ||
| | 1 — Create App | `init_app`, `generate_service_key` | | ||
| | 2 — Schema | `dry_run_schema`, `apply_schema`, `get_schema` | | ||
| | 3 — RLS | `create_user_isolation_policy`, `create_policy`, `enable_rls`, `get_rls_policies`, `select_rows`, `insert_row` | | ||
| | 4 — Auth | `configure_oauth_provider`, `update_jwt_config` | | ||
| | 5 — Functions | `deploy_function`, `update_function_env`, `invoke_function`, `get_function_logs` | | ||
| | 6 — Frontend | `update_cors`, `set_frontend_env`, `create_frontend_deployment`, `start_frontend_deployment`, `list_frontend_deployments` | | ||
| | 7 — Production | `get_rls_policies`, `query_audit_logs`, `get_function_logs`, `get_storage_objects` | | ||
| | 1 — Create App | `init_app`, `manage_auth_config` (`generate_service_key`) | | ||
| | 2 — Schema | `manage_schema` (`dry_run`, `apply`, `get`) | | ||
| | 3 — RLS | `manage_rls` (`create_user_isolation`, `create_policy`, `enable`, `list`), `select_rows`, `insert_row` | | ||
| | 4 — Auth | `manage_oauth` (`configure`), `manage_auth_config` (`update_jwt`) | | ||
| | 5 — Functions | `deploy_function`, `invoke_function`, `manage_function` (`update_env`, `get_logs`, `list`, `delete`) | | ||
| | 6 — Frontend | `manage_app` (`update_cors`), `manage_frontend` (`set_env`, `start_deployment`, `list_deployments`), `create_frontend_deployment` | | ||
| | 7 — Production | `manage_rls` (`list`), `query_audit_logs`, `manage_function` (`get_logs`), `manage_storage` (`list`) | | ||
@@ -637,7 +639,7 @@ --- | ||
| **Schema** | ||
| - Do not drop and recreate tables to rename a column — use `apply_schema` with the new column name and migrate data separately | ||
| - Do not skip `dry_run_schema` — always preview before applying | ||
| - Do not drop and recreate tables to rename a column — use `manage_schema` (`action: "apply"`) with the new column name and migrate data separately | ||
| - Do not skip `action: "dry_run"` — always preview before applying | ||
| **RLS** | ||
| - Do not forget to call `create_user_isolation_policy` — a table without RLS is readable by all authenticated users | ||
| - Do not forget `manage_rls` (`action: "create_user_isolation"`) — a table without RLS is readable by all authenticated users | ||
| - Do not include `author_id` / `user_id` in INSERT bodies when a trigger is installed — it will be set automatically | ||
@@ -644,0 +646,0 @@ |
@@ -21,3 +21,3 @@ --- | ||
| | `services/control-api` | `@butterbase/control-api` | Fastify API server — the brain. Routes, plugins, services. Port 4000 | | ||
| | `services/mcp-server` | `@butterbase/mcp-server` | MCP server with 42+ tools. Runs via stdio or HTTP (served by control-api at `/mcp`) | | ||
| | `services/mcp-server` | `@butterbase/mcp-server` | MCP server with ~28 tools (consolidated `manage_*` action-based tools + a few standalone ones like `init_app`, `deploy_function`, `select_rows`). Runs via stdio or HTTP (served by control-api at `/mcp`) | | ||
| | `services/deno-runtime` | — | Serverless function executor. Deno-based worker isolation. Port 7133 | | ||
@@ -29,3 +29,3 @@ | `services/cron-scheduler` | `@butterbase/cron-scheduler` | Cron job runner using node-cron + cron-parser | | ||
| | `services/storage-indexer` | — | Cloudflare Worker for S3 event indexing | | ||
| | `db/control-plane` | — | SQL migrations (001_ through 027_). Control plane database schema | | ||
| | `db/control-plane` | — | SQL migrations (sequential numbering, `001_` upward). Control plane database schema | | ||
| | `db/data-plane` | — | Per-app database initialization scripts | | ||
@@ -39,4 +39,6 @@ | ||
| Follow the pattern from existing tools like `init-app.ts`: | ||
| Decide whether the new capability is a standalone tool (single, self-contained operation like `init_app`) or another action on an existing umbrella tool (`manage_schema`, `manage_function`, etc). Most new operations should be added as actions on an existing `manage_*` tool — this keeps the surface area small for AI agents. | ||
| For a brand-new standalone tool, follow the pattern from `init-app.ts`: | ||
| ```typescript | ||
@@ -104,4 +106,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| - **IMPORTANT**: Use `scripts/migrate.ts` or `scripts/backfill-migrations.ts`, NEVER raw `psql` | ||
| - Migration files: `db/control-plane/NNN_description.sql` (sequential numbering) | ||
| - Current range: `001_initial_schema.sql` through `027_storage_object_visibility.sql` | ||
| - Migration files: `db/control-plane/NNN_description.sql` (sequential numbering, starting at `001_initial_schema.sql`) | ||
| - Pick the next free three-digit prefix; never edit a committed migration | ||
| - Run migrations: `npx tsx scripts/migrate.ts` | ||
@@ -115,3 +117,3 @@ | ||
| |-----------|---------| | ||
| | MCP tool names | `snake_case`: `init_app`, `apply_schema`, `get_rls_policies` | | ||
| | MCP tool names | `snake_case`. Two flavours: standalone (`init_app`, `deploy_function`, `select_rows`) and `manage_*` umbrella tools that take an `action` enum (`manage_schema`, `manage_rls`, `manage_function`, `manage_frontend`, etc.) | | ||
| | App IDs | `app_` prefix: `app_abc123` | | ||
@@ -118,0 +120,0 @@ | Service keys | `bb_sk_` prefix: `bb_sk_a1b2c3...` | |
@@ -57,8 +57,10 @@ --- | ||
| Call `get_rls_policies` with the `app_id`: | ||
| Call `manage_rls` with `action: "list"` for the `app_id`: | ||
| ``` | ||
| get_rls_policies(app_id: "app_abc123") | ||
| manage_rls(app_id: "app_abc123", action: "list") | ||
| ``` | ||
| Returns `{ policies: [...], tables_with_rls: [...] }`. The `tables_with_rls` array shows which tables have RLS turned on but no policies yet (effective default deny). | ||
| - Look for the table in the response. | ||
@@ -174,4 +176,5 @@ - If the table has **no policies**, RLS might not be enabled at all — or it was enabled but no policies were added, which causes a default deny for all non-service roles. | ||
| ``` | ||
| create_user_isolation_policy( | ||
| manage_rls( | ||
| app_id: "app_abc123", | ||
| action: "create_user_isolation", | ||
| table_name: "posts", | ||
@@ -187,3 +190,3 @@ user_column: "author_id" | ||
| - Auto-populate trigger: sets `author_id` from the JWT on INSERT (clients don't need to send it) | ||
| - Service bypass policy: `butterbase_service` can still read/write all rows | ||
| - Service bypass: `butterbase_service` always passes through (built into the platform) | ||
@@ -199,4 +202,5 @@ This is the recommended starting point for any user-owned data table. | ||
| ``` | ||
| create_policy( | ||
| manage_rls( | ||
| app_id: "app_abc123", | ||
| action: "create_policy", | ||
| table_name: "posts", | ||
@@ -214,7 +218,8 @@ policy_name: "public_read_published", | ||
| Alternatively, if you haven't set up user isolation yet, you can use the shorthand in `create_user_isolation_policy`: | ||
| Alternatively, if you haven't set up user isolation yet, use the `public_read_column` shorthand: | ||
| ``` | ||
| create_user_isolation_policy( | ||
| manage_rls( | ||
| app_id: "app_abc123", | ||
| action: "create_user_isolation", | ||
| table_name: "posts", | ||
@@ -226,3 +231,3 @@ user_column: "author_id", | ||
| This sets up user isolation **and** public read access in a single call. | ||
| This sets up user isolation **and** adds permissive SELECT policies for both `butterbase_user` and `butterbase_anon` to read rows where `published = true` — in a single call. | ||
@@ -235,9 +240,10 @@ --- | ||
| **Option A — Recommended: Replace with `create_user_isolation_policy`** | ||
| **Option A — Recommended: replace with `create_user_isolation`** | ||
| This is the cleanest fix if you're starting fresh or can replace the existing policy: | ||
| The cleanest fix if you're starting fresh or can replace the existing policy: | ||
| ``` | ||
| create_user_isolation_policy( | ||
| manage_rls( | ||
| app_id: "app_abc123", | ||
| action: "create_user_isolation", | ||
| table_name: "posts", | ||
@@ -250,9 +256,10 @@ user_column: "author_id" | ||
| **Option B — Additive: Add trigger to existing setup via `create_policy` with `user_column`** | ||
| **Option B — additive: pass `user_column` on `create_policy`** | ||
| Use this when you want to keep existing policies but just add the trigger: | ||
| Use this when you want to keep existing policies but just install the trigger: | ||
| ``` | ||
| create_policy( | ||
| manage_rls( | ||
| app_id: "app_abc123", | ||
| action: "create_policy", | ||
| table_name: "posts", | ||
@@ -278,4 +285,5 @@ policy_name: "posts_user_insert", | ||
| ``` | ||
| create_policy( | ||
| manage_rls( | ||
| app_id: "app_abc123", | ||
| action: "create_policy", | ||
| table_name: "comments", | ||
@@ -342,3 +350,3 @@ policy_name: "comments_on_public_posts_only", | ||
| - [ ] `get_rls_policies` shows the expected policies for the table | ||
| - [ ] `manage_rls` (action: "list") shows the expected policies for the table | ||
| - [ ] `select_rows` with `as_role: "user"` returns only the user's own rows | ||
@@ -358,5 +366,5 @@ - [ ] `select_rows` with `as_role: "anon"` returns only publicly visible rows (or empty if no anon policy) | ||
| | Using `select_rows` without `as_role` to verify RLS | Service key bypasses RLS — result is meaningless for verification | Always use `as_role: "user"` or `as_role: "anon"` | | ||
| | `enable_rls` + `create_policy` without `user_column` | No auto-populate trigger; clients must send user column manually | Use `create_user_isolation_policy` or pass `user_column` to `create_policy` | | ||
| | `manage_rls` action `create_policy` without `user_column` | No auto-populate trigger; clients must send user column manually | Use `action: "create_user_isolation"` or pass `user_column` to `create_policy` | | ||
| | Single policy with `cmd: "ALL"` but no `WITH CHECK` | INSERT/UPDATE may silently pass or fail depending on expression | Explicitly provide `with_check_expression` for write commands | | ||
| | Relying on `butterbase_service` policies for end-user access | Service bypass is always on; end-users use `butterbase_user` or `butterbase_anon` | Write separate policies for each end-user role | | ||
| | Missing policy for one role while having it for another | Authenticated users may see data that anonymous users cannot, or vice versa — may be intentional but often a bug | Audit all roles with `get_rls_policies` | | ||
| | Missing policy for one role while having it for another | Authenticated users may see data that anonymous users cannot, or vice versa — may be intentional but often a bug | Audit all roles with `manage_rls` (action: "list") | |
@@ -26,9 +26,12 @@ --- | ||
| Use `set_frontend_env` to configure the API URL and app ID before building. These variables are injected at build time by the framework. | ||
| Use `manage_frontend` with `action: "set_env"` to configure the API URL and app ID before building. These variables are injected at build time by the framework. | ||
| **Example for Vite:** | ||
| ```json | ||
| { | ||
| "VITE_API_URL": "https://api.butterbase.ai/v1/app_abc123", | ||
| "VITE_APP_ID": "app_abc123" | ||
| "app_id": "app_abc123", | ||
| "action": "set_env", | ||
| "vars": { | ||
| "VITE_API_URL": "https://api.butterbase.ai/v1/app_abc123", | ||
| "VITE_APP_ID": "app_abc123" | ||
| } | ||
| } | ||
@@ -41,3 +44,3 @@ ``` | ||
| Call `set_frontend_env` with the `app_id` and `vars` object before running the build command. | ||
| `set_env` upserts; you can call it again to add or change variables. | ||
@@ -74,3 +77,3 @@ --- | ||
| Call `update_cors` with the deployment URL (use the Butterbase Pages URL pattern) and any local dev origins: | ||
| Call `manage_app` with `action: "update_cors"`. Pass the deployment URL (use the Butterbase Pages URL pattern) and any local dev origins: | ||
@@ -80,2 +83,3 @@ ```json | ||
| "app_id": "app_abc123", | ||
| "action": "update_cors", | ||
| "allowed_origins": [ | ||
@@ -156,3 +160,3 @@ "https://your-app.pages.dev", | ||
| Call `start_frontend_deployment` with the `app_id` and `deployment_id` from Step 4: | ||
| Call `manage_frontend` with `action: "start_deployment"` and the `deployment_id` from Step 4: | ||
@@ -162,2 +166,3 @@ ```json | ||
| "app_id": "app_abc123", | ||
| "action": "start_deployment", | ||
| "deployment_id": "uuid-1234" | ||
@@ -184,7 +189,35 @@ } | ||
| | MIME type errors / broken JS/CSS | Windows backslash in zip paths | Re-zip using Git Bash or WSL: `cd dist && zip -r ../frontend.zip .` | | ||
| | API calls return 403 | CORS not configured | Add deployment URL to `update_cors` | | ||
| | API calls return 403 | CORS not configured | Add deployment URL via `manage_app` action `update_cors` | | ||
| | Routes return 404 | SPA routing not set up | SPA routing is auto-handled for `react-vite` and `nextjs-static` framework flags | | ||
| | Deploy stuck in BUILDING | Build error | Check `list_frontend_deployments` for `error` field details | | ||
| | Deploy stuck in BUILDING | Build error | Check `manage_frontend` action `list_deployments` for `error` field | | ||
| | Upload fails or curl errors | Upload URL expired | Get a new URL by calling `create_frontend_deployment` again | | ||
| | Next.js pages not exporting | Missing static export config | Add `output: 'export'` to `next.config.js` and rebuild | | ||
| | Environment variables not found | Not set before build | Run `set_frontend_env` and rebuild — env vars are baked in at build time | | ||
| | Environment variables not found | Not set before build | Run `manage_frontend` action `set_env` and rebuild — env vars are baked in at build time | | ||
| --- | ||
| ## Bonus: server-side build (no local build needed) | ||
| If you want Butterbase to run `npm install` + build on the server, skip Steps 2–6 and use the source-build flow: | ||
| 1. `manage_frontend` action `create_from_source` → returns presigned URL for a source-code zip (≤ 50 MB) | ||
| 2. `cd <project> && zip -r ../source.zip . -x "node_modules/*" ".next/*" "dist/*"` — exclude build artefacts | ||
| 3. `curl -X PUT "{uploadUrl}" -H "Content-Type: application/zip" --data-binary @source.zip` | ||
| 4. `manage_frontend` action `start_from_source` with `deployment_id`, `lockfile_hash` (sha256 of `package-lock.json`), optional `build_command`, `output_dir`, `package_manager`, `user_env` | ||
| Same `lockfile_hash` across deploys = cached `node_modules` for faster rebuilds. | ||
| --- | ||
| ## Edge SSR (Next.js / Remix with server-side rendering) | ||
| Static deploys can't run server-side code. For Next.js (App Router with SSR/middleware) or Remix on Cloudflare Workers, use `manage_edge_ssr` instead of `create_frontend_deployment` + `manage_frontend`. Same shape, separate tool: | ||
| | Action | Purpose | | ||
| |--------|---------| | ||
| | `create` | Returns presigned URL for a pre-built Workers zip (framework default `nextjs-edge`) | | ||
| | `start` | Uploads + deploys; polls ≤ 60s | | ||
| | `create_from_source` / `start_from_source` | Server-side build flow (defaults: `npx @cloudflare/next-on-pages`, output `.vercel/output/static`) | | ||
| | `list` | Historical edge deployments | | ||
| Frameworks: `nextjs-edge`, `remix-edge`, `other-edge`. |
@@ -20,5 +20,9 @@ --- | ||
| context: { | ||
| db: PostgresClient, // Query your app database | ||
| env: Record<string, string>, // Access environment variables | ||
| user: { id: string } | null // Current user (if auth: required) | ||
| db: PostgresClient, // RLS-aware DB client | ||
| env: Record<string, string>, // env vars set on the function | ||
| user: { id: string } | null, // present for HTTP+auth:required; null for cron | ||
| waitUntil: (p: Promise<unknown>) => void, // background work after Response (≤30s) | ||
| idempotency: { | ||
| claim: (key: string, opts?: { scope?: string; ttlSeconds?: number }) => Promise<boolean> | ||
| } // atomic dedup for webhook retries | ||
| } | ||
@@ -323,9 +327,16 @@ ): Promise<Response> | ||
| The standalone tools `deploy_function` and `invoke_function` are unchanged. Everything else (logs, env updates, listing, deletion) is handled by `manage_function` with an `action` parameter. | ||
| ### Invoke a Function | ||
| ``` | ||
| invoke_function(app_id, function_name, method: "POST", body: { key: "value" }) | ||
| invoke_function( | ||
| app_id: "app_abc123", | ||
| function_name: "my-function", | ||
| method: "POST", | ||
| body: { key: "value" } | ||
| ) | ||
| ``` | ||
| Returns the full HTTP response including status, headers, and body. Use this immediately after deploying to verify behavior. | ||
| Returns the full HTTP response (status, headers, body, duration_ms). Use this immediately after deploying to verify behavior. | ||
@@ -335,6 +346,11 @@ ### View Error Logs | ||
| ``` | ||
| get_function_logs(app_id, function_name, level: "error") | ||
| manage_function( | ||
| app_id: "app_abc123", | ||
| action: "get_logs", | ||
| function_name: "my-function", | ||
| level: "error" | ||
| ) | ||
| ``` | ||
| Returns recent invocations that resulted in errors, with stack traces. | ||
| Returns recent invocations with errors, stack traces, and captured `console.log/warn/error` output. | ||
@@ -344,14 +360,20 @@ ### View All Logs | ||
| ``` | ||
| get_function_logs(app_id, function_name) | ||
| manage_function( | ||
| app_id: "app_abc123", | ||
| action: "get_logs", | ||
| function_name: "my-function", | ||
| limit: 100, | ||
| since: "2026-01-15T00:00:00Z" | ||
| ) | ||
| ``` | ||
| Returns all recent invocations with timestamps, status codes, and durations. | ||
| Filters: `limit` (default 100), `since` (ISO timestamp), `level` (`"error"` or `"all"`). | ||
| ### View Metrics | ||
| ### List Functions & Metrics | ||
| ``` | ||
| list_functions(app_id) | ||
| manage_function(app_id: "app_abc123", action: "list") | ||
| ``` | ||
| Shows per-function metrics: invocation count, error rate, average duration. | ||
| Returns each function's name, trigger, URL, status, and metrics (invocationCount, errorRate, avgDuration, lastInvoked). | ||
@@ -392,3 +414,8 @@ --- | ||
| ``` | ||
| update_function_env(app_id, function_name, envVars: { MY_SECRET: "new-value" }) | ||
| manage_function( | ||
| app_id: "app_abc123", | ||
| action: "update_env", | ||
| function_name: "my-function", | ||
| env: { MY_SECRET: "new-value", DELETE_ME: null } // null deletes the key | ||
| ) | ||
| ``` | ||
@@ -399,3 +426,7 @@ | ||
| ``` | ||
| delete_function(app_id, function_name) | ||
| manage_function( | ||
| app_id: "app_abc123", | ||
| action: "delete", | ||
| function_name: "my-function" | ||
| ) | ||
| ``` | ||
@@ -402,0 +433,0 @@ |
@@ -14,9 +14,19 @@ --- | ||
| Butterbase uses a **declarative schema DSL** — you describe the desired end state of your database, and the platform computes and applies the diff. You never write raw `ALTER TABLE` or `CREATE TABLE` SQL. Instead, call `apply_schema` with a JSON payload describing your tables, columns, and indexes. | ||
| Butterbase uses a **declarative schema DSL** — you describe the desired end state of your database, and the platform computes and applies the diff. You never write raw `ALTER TABLE` or `CREATE TABLE` SQL. Instead, call `manage_schema` with `action: "apply"` and a JSON payload describing your tables, columns, and indexes. | ||
| The single `manage_schema` tool exposes four actions: | ||
| | Action | Purpose | | ||
| |--------|---------| | ||
| | `"get"` | Read the current schema | | ||
| | `"dry_run"` | Preview SQL that `apply` would execute, without running it | | ||
| | `"apply"` | Apply a declarative schema (diffs against current, runs safe DDL) | | ||
| | `"list_migrations"` | List applied migrations, most recent first | | ||
| Key principles: | ||
| - **Idempotent**: applying the same schema twice is safe | ||
| - **Idempotent**: applying the same schema twice is safe — returns "Schema is up to date" if no changes needed | ||
| - **Additive by default**: new columns and tables are created automatically | ||
| - **Explicit drops**: destructive operations require opt-in via `_drop` / `_dropColumns` | ||
| - **Preview first**: use `dry_run_schema` to see what will change before committing | ||
| - **Preview first**: use `action: "dry_run"` to see what will change before committing | ||
| - **Transactional**: each migration runs in a single transaction — all changes commit or all roll back | ||
@@ -38,3 +48,3 @@ --- | ||
| | `double precision` | DOUBLE PRECISION | 64-bit floating point | | ||
| | `vector(N)` | VECTOR(N) | Embeddings (pgvector) | | ||
| | `vector(N)` | VECTOR(N) | Embeddings (pgvector); e.g. `vector(1536)` for OpenAI | | ||
@@ -56,17 +66,13 @@ > **Always use `timestamptz` instead of `timestamp`.** `timestamp` silently drops timezone info and causes subtle bugs with users in different time zones. | ||
| | `unique` | boolean | no | false | Add unique constraint | | ||
| | `references` | string | no | — | Foreign key target: `"table.column"` | | ||
| | `references` | string \| object | no | — | Foreign key target (see below) | | ||
| ### Default expressions | ||
| ### Foreign keys — short or long form | ||
| Pass SQL expressions as strings: | ||
| Short form (just the target): | ||
| ```json | ||
| "default": "gen_random_uuid()" // UUID primary keys | ||
| "default": "now()" // Timestamps | ||
| "default": "false" // Booleans | ||
| "default": "0" // Integers | ||
| "default": "'draft'" // String literals (single-quoted) | ||
| "author_id": { "type": "uuid", "nullable": false, "references": "users.id" } | ||
| ``` | ||
| ### Foreign keys | ||
| Long form (with cascade behavior): | ||
@@ -77,6 +83,25 @@ ```json | ||
| "nullable": false, | ||
| "references": "users.id" | ||
| "references": { | ||
| "table": "users", | ||
| "column": "id", | ||
| "onDelete": "CASCADE", | ||
| "onUpdate": "NO ACTION" | ||
| } | ||
| } | ||
| ``` | ||
| `onDelete` / `onUpdate` accept `CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION` (default `NO ACTION`). | ||
| ### Default expressions | ||
| Pass SQL expressions as strings: | ||
| ```json | ||
| "default": "gen_random_uuid()" // UUID primary keys | ||
| "default": "now()" // Timestamps | ||
| "default": "false" // Booleans | ||
| "default": "0" // Integers | ||
| "default": "'draft'" // String literals (single-quoted) | ||
| ``` | ||
| --- | ||
@@ -108,4 +133,4 @@ | ||
| | Type | Use case | Example opclass | | ||
| |------|----------|----------------| | ||
| | `method` | Use case | Example opclass | | ||
| |----------|----------|----------------| | ||
| | `btree` | Default, range queries, sorting | — | | ||
@@ -127,7 +152,7 @@ | `hash` | Exact-match lookups | — | | ||
| "columns": ["author_id"], | ||
| "type": "btree" | ||
| "method": "btree" | ||
| }, | ||
| "idx_posts_embedding": { | ||
| "columns": ["embedding"], | ||
| "type": "hnsw", | ||
| "method": "hnsw", | ||
| "opclass": "vector_cosine_ops" | ||
@@ -137,3 +162,3 @@ }, | ||
| "columns": ["content"], | ||
| "type": "gin" | ||
| "method": "gin" | ||
| } | ||
@@ -151,3 +176,3 @@ } | ||
| "columns": ["workspace_id", "user_id"], | ||
| "type": "btree", | ||
| "method": "btree", | ||
| "unique": true | ||
@@ -159,15 +184,24 @@ } | ||
| ## 6. Using `apply_schema` | ||
| ## 6. Using `manage_schema` | ||
| All schema operations go through one tool with an `action` parameter: | ||
| ```js | ||
| manage_schema({ app_id, action: "get" }) | ||
| manage_schema({ app_id, action: "dry_run", schema }) | ||
| manage_schema({ app_id, action: "apply", schema, name }) // name is optional | ||
| manage_schema({ app_id, action: "list_migrations" }) | ||
| ``` | ||
| ### Creating new tables | ||
| Simply include the table definition in your schema payload. The platform creates it if it does not exist. | ||
| Include the table definition in your `schema` payload and call `action: "apply"`. The platform creates the table if it doesn't exist. | ||
| ### Adding columns to existing tables | ||
| Add the new column(s) to the existing table definition and call `apply_schema`. Existing rows receive the column's `default` value (or NULL if no default). | ||
| Add the new column(s) to the existing table definition and call `action: "apply"`. Existing rows receive the column's `default` value (or NULL if no default). | ||
| ### Destructive operations | ||
| Dropping tables and columns is opt-in and explicit: | ||
| Dropping tables and columns is opt-in and explicit. Without `_drop` / `_dropColumns`, the platform refuses with `STATE_PREREQUISITE_MISSING`. | ||
@@ -195,14 +229,25 @@ ```json | ||
| > ⚠️ Drops are irreversible. Always run `dry_run_schema` first. | ||
| > ⚠️ Drops are irreversible. Always run `action: "dry_run"` first. | ||
| ### Preview with `dry_run_schema` | ||
| ### Preview before commit | ||
| Use the same payload with `dry_run_schema` to see a diff of what will be created, altered, or dropped — without touching the database: | ||
| Use the same payload with `action: "dry_run"` to see the SQL that `apply` would execute — without touching the database: | ||
| ```json | ||
| // Same payload, different tool: | ||
| // dry_run_schema({ appId, schema }) — preview only | ||
| // apply_schema({ appId, schema }) — commits changes | ||
| ```js | ||
| // Preview only: | ||
| manage_schema({ app_id, action: "dry_run", schema }) | ||
| // Commit: | ||
| manage_schema({ app_id, action: "apply", schema, name: "add_posts_table" }) | ||
| ``` | ||
| ### Common errors | ||
| | Code | Meaning | | ||
| |------|---------| | ||
| | `VALIDATION_INVALID_SCHEMA` | Schema format doesn't match the DSL | | ||
| | `STATE_PREREQUISITE_MISSING` | Destructive op without `_drop` / `_dropColumns` | | ||
| | `QUOTA_TABLE_LIMIT` | Exceeds the per-app table limit | | ||
| | `RESOURCE_NOT_FOUND` | `app_id` does not exist | | ||
| --- | ||
@@ -216,3 +261,4 @@ | ||
| { | ||
| "appId": "YOUR_APP_ID", | ||
| "app_id": "YOUR_APP_ID", | ||
| "action": "apply", | ||
| "schema": { | ||
@@ -229,3 +275,3 @@ "users": { | ||
| "indexes": { | ||
| "idx_users_email": { "columns": ["email"], "type": "btree", "unique": true } | ||
| "idx_users_email": { "columns": ["email"], "method": "btree", "unique": true } | ||
| } | ||
@@ -257,6 +303,6 @@ }, | ||
| "indexes": { | ||
| "idx_posts_author_id": { "columns": ["author_id"], "type": "btree" }, | ||
| "idx_posts_category_id": { "columns": ["category_id"], "type": "btree" }, | ||
| "idx_posts_slug": { "columns": ["slug"], "type": "btree", "unique": true }, | ||
| "idx_posts_published": { "columns": ["published", "published_at"], "type": "btree" } | ||
| "idx_posts_author_id": { "columns": ["author_id"], "method": "btree" }, | ||
| "idx_posts_category_id": { "columns": ["category_id"], "method": "btree" }, | ||
| "idx_posts_slug": { "columns": ["slug"], "method": "btree", "unique": true }, | ||
| "idx_posts_published": { "columns": ["published", "published_at"], "method": "btree" } | ||
| } | ||
@@ -274,4 +320,4 @@ }, | ||
| "indexes": { | ||
| "idx_comments_post_id": { "columns": ["post_id"], "type": "btree" }, | ||
| "idx_comments_author_id": { "columns": ["author_id"], "type": "btree" } | ||
| "idx_comments_post_id": { "columns": ["post_id"], "method": "btree" }, | ||
| "idx_comments_author_id": { "columns": ["author_id"], "method": "btree" } | ||
| } | ||
@@ -289,3 +335,4 @@ } | ||
| { | ||
| "appId": "YOUR_APP_ID", | ||
| "app_id": "YOUR_APP_ID", | ||
| "action": "apply", | ||
| "schema": { | ||
@@ -305,4 +352,4 @@ "products": { | ||
| "indexes": { | ||
| "idx_products_sku": { "columns": ["sku"], "type": "btree", "unique": true }, | ||
| "idx_products_metadata": { "columns": ["metadata"], "type": "gin", "opclass": "jsonb_path_ops" } | ||
| "idx_products_sku": { "columns": ["sku"], "method": "btree", "unique": true }, | ||
| "idx_products_metadata": { "columns": ["metadata"], "method": "gin", "opclass": "jsonb_path_ops" } | ||
| } | ||
@@ -321,4 +368,4 @@ }, | ||
| "indexes": { | ||
| "idx_orders_user_id": { "columns": ["user_id"], "type": "btree" }, | ||
| "idx_orders_status": { "columns": ["status"], "type": "btree" } | ||
| "idx_orders_user_id": { "columns": ["user_id"], "method": "btree" }, | ||
| "idx_orders_status": { "columns": ["status"], "method": "btree" } | ||
| } | ||
@@ -337,4 +384,4 @@ }, | ||
| "indexes": { | ||
| "idx_order_items_order_id": { "columns": ["order_id"], "type": "btree" }, | ||
| "idx_order_items_product_id": { "columns": ["product_id"], "type": "btree" } | ||
| "idx_order_items_order_id": { "columns": ["order_id"], "method": "btree" }, | ||
| "idx_order_items_product_id": { "columns": ["product_id"], "method": "btree" } | ||
| } | ||
@@ -354,4 +401,4 @@ }, | ||
| "indexes": { | ||
| "idx_reviews_product_id": { "columns": ["product_id"], "type": "btree" }, | ||
| "idx_reviews_user_id": { "columns": ["user_id"], "type": "btree" } | ||
| "idx_reviews_product_id": { "columns": ["product_id"], "method": "btree" }, | ||
| "idx_reviews_user_id": { "columns": ["user_id"], "method": "btree" } | ||
| } | ||
@@ -369,3 +416,4 @@ } | ||
| { | ||
| "appId": "YOUR_APP_ID", | ||
| "app_id": "YOUR_APP_ID", | ||
| "action": "apply", | ||
| "schema": { | ||
@@ -383,3 +431,3 @@ "workspaces": { | ||
| "indexes": { | ||
| "idx_workspaces_slug": { "columns": ["slug"], "type": "btree", "unique": true } | ||
| "idx_workspaces_slug": { "columns": ["slug"], "method": "btree", "unique": true } | ||
| } | ||
@@ -397,4 +445,4 @@ }, | ||
| "indexes": { | ||
| "idx_members_workspace_user": { "columns": ["workspace_id", "user_id"], "type": "btree", "unique": true }, | ||
| "idx_members_user_id": { "columns": ["user_id"], "type": "btree" } | ||
| "idx_members_workspace_user": { "columns": ["workspace_id", "user_id"], "method": "btree", "unique": true }, | ||
| "idx_members_user_id": { "columns": ["user_id"], "method": "btree" } | ||
| } | ||
@@ -413,3 +461,3 @@ }, | ||
| "indexes": { | ||
| "idx_projects_workspace_id": { "columns": ["workspace_id"], "type": "btree" } | ||
| "idx_projects_workspace_id": { "columns": ["workspace_id"], "method": "btree" } | ||
| } | ||
@@ -431,5 +479,5 @@ }, | ||
| "indexes": { | ||
| "idx_tasks_project_id": { "columns": ["project_id"], "type": "btree" }, | ||
| "idx_tasks_assignee_id": { "columns": ["assignee_id"], "type": "btree" }, | ||
| "idx_tasks_status": { "columns": ["status"], "type": "btree" } | ||
| "idx_tasks_project_id": { "columns": ["project_id"], "method": "btree" }, | ||
| "idx_tasks_assignee_id": { "columns": ["assignee_id"], "method": "btree" }, | ||
| "idx_tasks_status": { "columns": ["status"], "method": "btree" } | ||
| } | ||
@@ -447,3 +495,4 @@ } | ||
| { | ||
| "appId": "YOUR_APP_ID", | ||
| "app_id": "YOUR_APP_ID", | ||
| "action": "apply", | ||
| "schema": { | ||
@@ -461,4 +510,4 @@ "profiles": { | ||
| "indexes": { | ||
| "idx_profiles_username": { "columns": ["username"], "type": "btree", "unique": true }, | ||
| "idx_profiles_user_id": { "columns": ["user_id"], "type": "btree", "unique": true } | ||
| "idx_profiles_username": { "columns": ["username"], "method": "btree", "unique": true }, | ||
| "idx_profiles_user_id": { "columns": ["user_id"], "method": "btree", "unique": true } | ||
| } | ||
@@ -476,4 +525,4 @@ }, | ||
| "indexes": { | ||
| "idx_posts_author_id": { "columns": ["author_id"], "type": "btree" }, | ||
| "idx_posts_created_at": { "columns": ["created_at"], "type": "btree" } | ||
| "idx_posts_author_id": { "columns": ["author_id"], "method": "btree" }, | ||
| "idx_posts_created_at": { "columns": ["created_at"], "method": "btree" } | ||
| } | ||
@@ -489,4 +538,4 @@ }, | ||
| "indexes": { | ||
| "idx_follows_follower_following": { "columns": ["follower_id", "following_id"], "type": "btree", "unique": true }, | ||
| "idx_follows_following_id": { "columns": ["following_id"], "type": "btree" } | ||
| "idx_follows_follower_following": { "columns": ["follower_id", "following_id"], "method": "btree", "unique": true }, | ||
| "idx_follows_following_id": { "columns": ["following_id"], "method": "btree" } | ||
| } | ||
@@ -502,4 +551,4 @@ }, | ||
| "indexes": { | ||
| "idx_likes_user_post": { "columns": ["user_id", "post_id"], "type": "btree", "unique": true }, | ||
| "idx_likes_post_id": { "columns": ["post_id"], "type": "btree" } | ||
| "idx_likes_user_post": { "columns": ["user_id", "post_id"], "method": "btree", "unique": true }, | ||
| "idx_likes_post_id": { "columns": ["post_id"], "method": "btree" } | ||
| } | ||
@@ -506,0 +555,0 @@ } |
151597
85.63%33
200%41
95.24%