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 | Advance application, reject candidate | AI chat, bulk import from job board, webhooks |
Defining Actions
Actions are defined in a separate actions.ts file that references your table:
// features/applications/actions.ts
import { applications } from './applications';
import { defineActions } from '@quickback/compiler';
import { z } from 'zod';
export default defineActions(applications, {
'advance-stage': {
description: "Move application to the next pipeline stage",
input: z.object({ stage: z.enum(["screening", "interview", "offer", "hired"]), notes: z.string().optional() }),
access: { roles: ["owner", "hiring-manager"] },
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' |
allowRawSql | No | Explicit compile-time opt-in for raw SQL in execute/handler code |
unsafe | No | Unsafe raw DB mode. Use true (legacy) or object config (reason, adminOnly, crossTenant, targetScope) |
*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 /applications/:id/advance-stage
POST /applications/:id/reject
POST /applications/:id/schedule-interviewRuntime 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: Advance Application Stage
// features/applications/actions.ts
import { applications } from './applications';
import { defineActions } from '@quickback/compiler';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
export default defineActions(applications, {
'advance-stage': {
description: "Move application to the next pipeline stage",
input: z.object({
stage: z.enum(["screening", "interview", "offer", "hired"]),
notes: z.string().optional(),
}),
access: {
roles: ["owner", "hiring-manager"],
record: { stage: { notEquals: "rejected" } }, // Precondition
},
execute: async ({ db, ctx, record, input }) => {
const [updated] = await db
.update(applications)
.set({
stage: input.stage,
notes: input.notes ?? record.notes,
})
.where(eq(applications.id, record.id))
.returning();
return updated;
},
},
});Request Example
POST /applications/app_123/advance-stage
Content-Type: application/json
{
"stage": "interview",
"notes": "Strong technical background, moving to interview"
}Response Example
{
"data": {
"id": "app_123",
"stage": "interview",
"notes": "Strong technical background, moving to interview"
}
}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: ["recruiter", "hiring-manager"],
},
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(applications, {
generateReport: {
description: "Generate hiring pipeline report",
standalone: true,
path: "/applications/report",
method: "GET",
responseType: "file",
input: z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}),
access: { roles: ["owner", "hiring-manager"] },
handler: "./handlers/generate-report",
},
});Actions-Only Features
You can create feature directories that contain only standalone actions — no table definitions required. This is useful for utility endpoints like reports, integrations, or webhooks that don't map to a specific resource.
quickback/features/
└── reports/
├── actions.ts # Standalone actions only (no table file)
└── handlers/
├── trial-balance.ts
└── profit-loss.ts// features/reports/actions.ts
export default defineActions(null, {
trialBalance: {
description: "Generate trial balance report",
standalone: true,
path: "/reports/trial-balance",
method: "GET",
input: z.object({
startDate: z.string(),
endDate: z.string(),
}),
access: { roles: ["admin", "accountant"] },
handler: "./handlers/trial-balance",
},
});Pass null as the schema argument since there is no table. All actions in a tableless feature must have standalone: true — record-based actions require a table to operate on.
Access Configuration
Access controls who can execute an action and under what conditions.
Role-Based Access
access: {
roles: ["hiring-manager", "recruiter"] // OR logic: user needs any of these roles
}Public Access
Use roles: ["PUBLIC"] for unauthenticated endpoints (contact forms, webhooks, public APIs). Every invocation is mandatory audit logged with IP address, input, result, and timing.
// features/contact/actions.ts
export default defineActions(null, {
submit: {
description: "Public contact form submission",
standalone: true,
path: "/submit",
input: z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10),
}),
access: { roles: ["PUBLIC"] },
handler: "./handlers/submit",
},
});The wildcard "*" is not supported and will throw a compile-time error. Use "PUBLIC" explicitly.
Record Conditions
For record-based actions, you can require the record to be in a specific state:
access: {
roles: ["hiring-manager"],
record: {
stage: { equals: "screening" } // 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: ["hiring-manager"] },
{
roles: ["recruiter"],
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 deletedAt IS NULL
const items = await db.select().from(applications).where(eq(applications.stage, 'interview'));
// Inserts automatically include organizationId
await db.insert(applications).values({ candidateId: input.candidateId, jobId: input.jobId, stage: 'applied' });
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 scoped DB filters (for example, platform-level support operations) can enable unsafe mode.
Use object form for explicit policy and mandatory audit metadata:
export default defineActions(applications, {
adminReport: {
description: "Generate cross-org hiring report",
unsafe: {
reason: "Support investigation for enterprise customer",
adminOnly: true, // default true
crossTenant: true, // default true
targetScope: "all", // "all" | "organization"
},
input: z.object({ startDate: z.string() }),
access: { roles: ["owner"] },
execute: async ({ db, rawDb, ctx, input }) => {
// db is still scoped (safety net)
// rawDb bypasses scoped filters
const allOrgs = await rawDb.select().from(applications);
return allOrgs;
},
},
});Unsafe cross-tenant actions are generated with:
- Better Auth authentication required (no unauthenticated path)
- platform admin gate (
ctx.userRole === "admin") - mandatory audit logging on deny/success/error
PUBLIC actions also receive mandatory audit logging (same audit table), since unauthenticated endpoints are high-risk by nature.
Without unsafe mode, rawDb is undefined in the executor params.
Legacy unsafe: true is still supported, but object mode is recommended so audit logs include an explicit reason.
Raw SQL Policy
By default, the compiler rejects raw SQL in action code and handler files. Use Drizzle query-builder syntax whenever possible.
If a specific action must use raw SQL, opt in explicitly:
reconcileLedger: {
description: "Run custom reconciliation query",
allowRawSql: true,
input: z.object({}),
access: { roles: ["owner"] },
execute: async ({ db }) => {
// Allowed because allowRawSql: true
return db.execute(sql`select 1`);
},
}Without allowRawSql: true, compilation fails with a loud error pointing to the action and snippet.
The detector checks for SQL keywords in string arguments, so non-SQL method calls like headers.get("X-Forwarded-For") or map.get("key") will not trigger false positives.
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 apps = await db
.select()
.from(applicationsTable)
.where(between(applicationsTable.createdAt, input.startDate, input.endDate));
const pdf = await services.pdf.generate(apps);
return {
file: pdf,
filename: `hiring-report-${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/advance-stage.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 { applications } from '../applications';
import { interviewScores } from '../interview-scores';
// Cross-feature — go up to the features directory, then into the other feature
import { candidates } from '../../candidates/candidates';
import { jobs } from '../../jobs/jobs';
export const execute: ActionExecutor = async ({ db, ctx, input, c }) => {
const [job] = await db
.select()
.from(jobs)
.where(eq(jobs.id, input.jobId))
.limit(1);
if (!job) {
return c.json({ error: 'Job not found', code: 'NOT_FOUND' }, 404);
}
// ... continue with business logic
};Path pattern from features/{name}/handlers/:
- Same feature table:
../{table-file-name}(e.g.,../applications) - Other feature table:
../../{other-feature}/{table-file-name}(e.g.,../../candidates/candidates) - 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 mode is enabled)
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
getStageHistory: {
method: "GET",
input: z.object({ format: z.string().optional() }),
// Called as: GET /applications/:id/getStageHistory?format=detailed
}
// POST action (default) - input comes from JSON body
'advance-stage': {
// method: "POST" is implied
input: z.object({ stage: z.enum(["screening", "interview", "offer", "hired"]) }),
// Called as: POST /applications/:id/advance-stage 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.stage === 'hired') {
return c.json({
error: 'Cannot modify a hired application',
code: 'ALREADY_HIRED',
details: { currentStage: record.stage },
}, 400);
}
// ... continue
}Option 2: Throw an ActionError
import { ActionError } from '@quickback/compiler';
execute: async ({ ctx, record, input }) => {
if (record.stage === 'hired') {
throw new ActionError('Cannot modify a hired application', 'ALREADY_HIRED', 400, {
currentStage: record.stage,
});
}
// ... 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: {
stage: ["advance-stage", "reject"], // Only these actions can modify stage
}
}This allows the advance-stage action to set stage = "interview" even though the field is protected from regular PATCH requests.
Examples
Application Stage Advance (Record-Based)
export default defineActions(applications, {
'advance-stage': {
description: "Move application to the next pipeline stage",
input: z.object({
stage: z.enum(["screening", "interview", "offer", "hired"]),
notes: z.string().optional(),
}),
access: {
roles: ["owner", "hiring-manager"],
record: { stage: { notEquals: "rejected" } },
},
execute: async ({ db, ctx, record, input }) => {
const [updated] = await db
.update(applications)
.set({
stage: input.stage,
notes: input.notes ?? record.notes,
})
.where(eq(applications.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: ["recruiter", "hiring-manager"] },
handler: "./handlers/chat",
},
});Bulk Import (Standalone, No Record)
export default defineActions(candidates, {
bulkImport: {
description: "Import candidates from job board CSV",
standalone: true,
path: "/candidates/import",
input: z.object({
data: z.array(z.object({
email: z.string().email(),
name: z.string(),
})),
}),
access: { roles: ["hiring-manager", "recruiter"] },
execute: async ({ db, input }) => {
const inserted = await db
.insert(candidates)
.values(input.data)
.returning();
return { imported: inserted.length };
},
},
});