Actions
Create custom API endpoints for business logic beyond CRUD. Build workflows, integrations, and complex operations with type-safe action handlers.
Actions are custom API endpoints for business logic beyond CRUD operations. They enable workflows, integrations, and complex operations.
Overview
Quickback supports two types of actions:
| Aspect | Record-Based | Standalone |
|---|---|---|
| Route | {METHOD} /:id/{actionName} | Custom path or /{actionName} |
| Record fetching | Automatic | None (record is undefined) |
| Firewall applied | Yes | No |
| Preconditions | Supported via access.record | Not applicable |
| Response types | JSON only | JSON, stream, file |
| Use case | Approve invoice, archive order | AI chat, bulk import, webhooks |
Defining Actions
Actions are defined in a separate actions.ts file that references your table:
// features/invoices/actions.ts
import { invoices } from './invoices';
import { defineActions } from '@quickback/compiler';
import { z } from 'zod';
export default defineActions(invoices, {
approve: {
description: "Approve invoice for payment",
input: z.object({ notes: z.string().optional() }),
access: { roles: ["admin", "finance"] },
execute: async ({ db, record, ctx }) => {
// Business logic
return record;
},
},
});Configuration Options
| Option | Required | Description |
|---|---|---|
description | Yes | Human-readable description of the action |
input | Yes | Zod schema for request validation |
access | Yes | Access control (roles, record conditions, or function) |
execute | Yes* | Inline execution function |
handler | Yes* | File path for complex logic (alternative to execute) |
standalone | No | Set true for non-record actions |
path | No | Custom route path (standalone only) |
method | No | HTTP method: GET, POST, PUT, PATCH, DELETE (default: POST) |
responseType | No | Response format: json, stream, file (default: json) |
sideEffects | No | Hint for AI tools: 'sync', 'async', or 'fire-and-forget' |
unsafe | No | When true, provides rawDb (unscoped) to the executor |
*Either execute or handler is required, not both.
Record-Based Actions
Record-based actions operate on an existing record. The record is automatically loaded and validated before your action executes.
Route pattern: {METHOD} /:id/{actionName}
POST /invoices/:id/approve
GET /orders/:id/status
DELETE /items/:id/archiveRuntime Flow
- Authentication - User token is validated
- Record Loading - The record is fetched by ID
- Firewall Check - Ensures user can access this record
- Access Check - Validates roles and preconditions
- Input Validation - Request body validated against Zod schema
- Execution - Your action handler runs
- Response - Result is returned to client
Example: Invoice Approval
// features/invoices/actions.ts
import { invoices } from './invoices';
import { defineActions } from '@quickback/compiler';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
export default defineActions(invoices, {
approve: {
description: "Approve invoice for payment",
input: z.object({
notes: z.string().optional(),
}),
access: {
roles: ["admin", "finance"],
record: { status: { equals: "pending" } }, // Precondition
},
execute: async ({ db, ctx, record, input }) => {
const [updated] = await db
.update(invoices)
.set({
status: "approved",
approvedBy: ctx.userId,
approvedAt: new Date(),
})
.where(eq(invoices.id, record.id))
.returning();
return updated;
},
},
});Request Example
POST /invoices/inv_123/approve
Content-Type: application/json
{
"notes": "Approved for Q1 budget"
}Response Example
{
"data": {
"id": "inv_123",
"status": "approved",
"approvedBy": "user_456",
"approvedAt": "2024-01-15T14:30:00Z"
}
}Standalone Actions
Standalone actions are independent endpoints that don't require a record context. Use standalone: true and optionally specify a custom path.
Route pattern: Custom path or /{actionName}
POST /chat
GET /reports/summary
POST /webhooks/stripeExample: AI Chat with Streaming
export default defineActions(sessions, {
chat: {
description: "Send a message to AI",
standalone: true,
path: "/chat",
method: "POST",
responseType: "stream",
input: z.object({
message: z.string().min(1).max(2000),
}),
access: {
roles: ["member", "admin"],
},
handler: "./handlers/chat",
},
});Streaming Response Example
For actions with responseType: 'stream':
Content-Type: text/event-stream
data: {"type": "start"}
data: {"type": "chunk", "content": "Hello"}
data: {"type": "chunk", "content": "! I'm"}
data: {"type": "chunk", "content": " here to help."}
data: {"type": "done"}Example: Report Generation
export default defineActions(invoices, {
generateReport: {
description: "Generate PDF report",
standalone: true,
path: "/invoices/report",
method: "GET",
responseType: "file",
input: z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}),
access: { roles: ["admin", "finance"] },
handler: "./handlers/generate-report",
},
});Access Configuration
Access controls who can execute an action and under what conditions.
Role-Based Access
access: {
roles: ["admin", "manager"] // OR logic: user needs any of these roles
}Record Conditions
For record-based actions, you can require the record to be in a specific state:
access: {
roles: ["admin"],
record: {
status: { equals: "pending" } // Precondition
}
}Supported operators:
| Operator | Description |
|---|---|
equals | Field must equal value |
notEquals | Field must not equal value |
in | Field must be one of the values |
notIn | Field must not be one of the values |
Context Substitution
Use $ctx to reference the current user's context:
access: {
record: {
ownerId: { equals: "$ctx.userId" },
orgId: { equals: "$ctx.orgId" }
}
}OR/AND Combinations
access: {
or: [
{ roles: ["admin"] },
{
roles: ["member"],
record: { ownerId: { equals: "$ctx.userId" } }
}
]
}Function Access
For complex logic, use an access function:
access: async (ctx, record) => {
return ctx.roles.includes('admin') || record.ownerId === ctx.userId;
}Scoped Database
All actions receive a scoped db instance that automatically enforces security:
| Operation | Org Scoping | Owner Scoping | Soft Delete Filter | Auto-inject on INSERT |
|---|---|---|---|---|
SELECT | WHERE organizationId = ? | WHERE ownerId = ? | WHERE deletedAt IS NULL | n/a |
INSERT | n/a | n/a | n/a | organizationId, ownerId from ctx |
UPDATE | WHERE organizationId = ? | WHERE ownerId = ? | WHERE deletedAt IS NULL | n/a |
DELETE | WHERE organizationId = ? | WHERE ownerId = ? | WHERE deletedAt IS NULL | n/a |
Scoping is duck-typed at runtime — tables with an organizationId column get org scoping, tables with ownerId get owner scoping, tables with deletedAt get soft delete filtering.
execute: async ({ db, ctx, input }) => {
// This query automatically includes WHERE organizationId = ? AND ownerId = ? AND deletedAt IS NULL
const items = await db.select().from(claims).where(eq(claims.status, 'active'));
// Inserts automatically include organizationId and ownerId
await db.insert(claims).values({ title: input.title });
return items;
}Not enforced in scoped DB (by design):
- Guards — actions ARE the authorized way to modify guarded fields
- Masking — actions are backend code that may need raw data
- Access — already checked before action execution
Unsafe Mode
Actions that need to bypass security (e.g., cross-org admin queries) can declare unsafe: true:
export default defineActions(invoices, {
adminReport: {
description: "Generate cross-org report",
unsafe: true,
input: z.object({ startDate: z.string() }),
access: { roles: ["admin"] },
execute: async ({ db, rawDb, ctx, input }) => {
// db is still scoped (safety net)
// rawDb bypasses all security — only available with unsafe: true
const allOrgs = await rawDb.select().from(invoices);
return allOrgs;
},
},
});Without unsafe: true, rawDb is undefined in the executor params.
Handler Files
For complex actions, separate the logic into handler files.
When to Use Handler Files
- Complex business logic spanning multiple operations
- External API integrations
- File generation or processing
- Logic reused across multiple actions
Handler Structure
// handlers/generate-report.ts
import type { ActionExecutor } from '@quickback/types';
export const execute: ActionExecutor = async ({ db, ctx, input, services }) => {
const invoices = await db
.select()
.from(invoicesTable)
.where(between(invoicesTable.createdAt, input.startDate, input.endDate));
const pdf = await services.pdf.generate(invoices);
return {
file: pdf,
filename: `invoices-${input.startDate}-${input.endDate}.pdf`,
contentType: 'application/pdf',
};
};Importing Tables
Handler files can import tables from their own feature or other features. The compiler generates alias files for each table, so you import by the table's file name:
// handlers/post-entry.ts
import type { ActionExecutor } from '@quickback/types';
import { eq, and } from 'drizzle-orm';
// Same feature — import from parent directory using the table's file name
import { ledgerEntries } from '../ledger-entries';
import { ledgerLines } from '../ledger-lines';
// Cross-feature — go up to the features directory, then into the other feature
import { ledgerAccounts } from '../../accounts/ledger-accounts';
import { accountBalances } from '../../accounts/account-balances';
export const execute: ActionExecutor = async ({ db, ctx, input, c }) => {
const [account] = await db
.select()
.from(ledgerAccounts)
.where(eq(ledgerAccounts.id, input.accountId))
.limit(1);
if (!account) {
return c.json({ error: 'Account not found', code: 'NOT_FOUND' }, 404);
}
// ... continue with business logic
};Path pattern from features/{name}/handlers/:
- Same feature table:
../{table-file-name}(e.g.,../invoices) - Other feature table:
../../{other-feature}/{table-file-name}(e.g.,../../accounts/ledger-accounts) - Generated lib files:
../../../lib/{module}(e.g.,../../../lib/realtime,../../../lib/webhooks)
Executor Parameters
interface ActionExecutorParams {
db: DrizzleDB; // Scoped database (auto-applies org/owner/soft-delete filters)
rawDb?: DrizzleDB; // Raw database (only available when unsafe: true)
ctx: AppContext; // User context (userId, roles, orgId)
record?: TRecord; // The record (record-based only, undefined for standalone)
input: TInput; // Validated input from Zod schema
services: TServices; // Configured integrations (billing, notifications, etc.)
c: HonoContext; // Raw Hono context for advanced use
auditFields: object; // { createdAt, modifiedAt } timestamps
}HTTP API Reference
Request Format
| Method | Input Source | Use Case |
|---|---|---|
GET | Query parameters | Read-only operations, fetching data |
POST | JSON body | Default, state-changing operations |
PUT | JSON body | Full replacement operations |
PATCH | JSON body | Partial updates |
DELETE | JSON body | Deletion with optional payload |
// GET action - input comes from query params
getStatus: {
method: "GET",
input: z.object({ format: z.string().optional() }),
// Called as: GET /invoices/:id/getStatus?format=detailed
}
// POST action (default) - input comes from JSON body
approve: {
// method: "POST" is implied
input: z.object({ notes: z.string().optional() }),
// Called as: POST /invoices/:id/approve with JSON body
}Response Formats
| Type | Content-Type | Use Case |
|---|---|---|
json | application/json | Standard API responses (default) |
stream | text/event-stream | Real-time streaming (AI chat, live updates) |
file | Varies | File downloads (reports, exports) |
Error Codes
| Status | Description |
|---|---|
400 | Invalid input / validation error |
401 | Not authenticated |
403 | Access check failed (role or precondition) |
404 | Record not found (record-based actions) |
500 | Handler execution error |
Validation Error Response
{
"error": "Invalid request data",
"layer": "validation",
"code": "VALIDATION_FAILED",
"details": {
"fields": {
"amount": "Expected positive number"
}
},
"hint": "Check the input schema for this action"
}Error Handling
Option 1: Return a JSON error response (recommended for most cases)
Since action handlers receive the Hono context (c), you can return error responses directly:
execute: async ({ ctx, record, input, c }) => {
if (record.balance < input.amount) {
return c.json({
error: 'Not enough balance',
code: 'INSUFFICIENT_FUNDS',
details: { required: input.amount, available: record.balance },
}, 400);
}
// ... continue
}Option 2: Throw an ActionError
import { ActionError } from '@quickback/compiler';
execute: async ({ ctx, record, input }) => {
if (record.balance < input.amount) {
throw new ActionError('Not enough balance', 'INSUFFICIENT_FUNDS', 400, {
required: input.amount,
available: record.balance,
});
}
// ... continue
}The ActionError constructor signature is (message, code, statusCode, details?).
Protected Fields
Actions can modify fields that are protected from regular CRUD operations:
// In resource.ts
guards: {
protected: {
status: ["approve", "reject"], // Only these actions can modify status
amount: ["reviseAmount"],
}
}This allows the approve action to set status = "approved" even though the field is protected from regular PATCH requests.
Examples
Invoice Approval (Record-Based)
export default defineActions(invoices, {
approve: {
description: "Approve invoice for payment",
input: z.object({ notes: z.string().optional() }),
access: {
roles: ["admin", "finance"],
record: { status: { equals: "pending" } },
},
execute: async ({ db, ctx, record, input }) => {
const [updated] = await db
.update(invoices)
.set({
status: "approved",
approvedBy: ctx.userId,
notes: input.notes,
})
.where(eq(invoices.id, record.id))
.returning();
return updated;
},
},
});AI Chat (Standalone with Streaming)
export default defineActions(sessions, {
chat: {
description: "Send a message to AI assistant",
standalone: true,
path: "/chat",
responseType: "stream",
input: z.object({
message: z.string().min(1).max(2000),
}),
access: { roles: ["member", "admin"] },
handler: "./handlers/chat",
},
});Bulk Import (Standalone, No Record)
export default defineActions(contacts, {
bulkImport: {
description: "Import contacts from CSV",
standalone: true,
path: "/contacts/import",
input: z.object({
data: z.array(z.object({
email: z.string().email(),
name: z.string(),
})),
}),
access: { roles: ["admin"] },
execute: async ({ db, input }) => {
const inserted = await db
.insert(contacts)
.values(input.data)
.returning();
return { imported: inserted.length };
},
},
});