Skip to content

Actions - Custom Routes

Actions are custom business logic beyond CRUD. Two types: record-based and standalone.

Action Configuration

typescript
interface Action {
  description: string;
  input: z.ZodType<any>;           // Zod schema for validation
  guard: Guard;                     // Access control

  // Inline execution
  execute?: ActionExecutor;

  // OR file reference (recommended for complex logic)
  handler?: string;

  // Standalone options
  standalone?: boolean;
  path?: string;                    // Custom path (standalone only)
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  responseType?: 'json' | 'stream' | 'file';
}

Guard Configuration

typescript
guard: {
  roles?: string[];          // OR logic
  record?: RecordConditions; // Preconditions (record-based only)
  or?: Guard[];
  and?: Guard[];
}

// Or use a function for complex logic
guard: async (ctx, record) => {
  return ctx.roles.includes('admin') || record.ownerId === ctx.userId;
}

HTTP Methods

Actions support all standard HTTP methods. Default is POST.

MethodInput SourceUse Case
GETQuery parametersRead-only operations, fetching data
POSTJSON bodyDefault, state-changing operations
PUTJSON bodyFull replacement operations
PATCHJSON bodyPartial updates
DELETEJSON bodyDeletion with optional payload
typescript
// 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
}

Record-Based Actions

Operates on an existing record. Route: {METHOD} /:id/{actionName}

typescript
// actions.ts
import { invoices } from '../../schema';
import { defineActions } from '@quickback/core';
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(),
    }),
    guard: {
      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;
    },
  },
});

Standalone Actions

Independent endpoint, no record required. Use standalone: true and optionally specify a custom path.

typescript
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),
    }),
    guard: {
      roles: ["member", "admin"],
    },
    handler: "./handlers/chat",  // Separate file
  },
});

Handler Files

For complex actions, separate the logic:

typescript
// handlers/chat.ts
import type { ActionExecutor } from '@quickback/types';

export const execute: ActionExecutor = async ({ db, ctx, input, services, c }) => {
  // Complex business logic
  return { success: true, data: result };
};

Executor Parameters

typescript
interface ActionExecutorParams {
  db: DrizzleDB;           // Database instance
  ctx: AppContext;         // User context (userId, roles, etc.)
  record?: TRecord;        // The record (record-based only)
  input: TInput;           // Validated input from Zod schema
  services: TServices;     // Integrations (billing, notifications, etc.)
  c: HonoContext;          // Raw Hono context for advanced use
}

Response Types

typescript
responseType: 'json'    // Standard JSON (default)
responseType: 'stream'  // Server-Sent Events
responseType: 'file'    // File download

Record-Based vs Standalone

AspectRecord-BasedStandalone
Route/:id/{actionName}Custom path or /{actionName}
Default methodPOSTPOST
Record fetchingYes (automatic)No
Firewall appliedYesNo
record in executorAvailableundefined
Record preconditionsSupported via guard.recordNot applicable
Response typesJSON onlyJSON, stream, file

Backend security, simplified.