Quickback Docs

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:

AspectRecord-BasedStandalone
Route{METHOD} /:id/{actionName}Custom path or /{actionName}
Record fetchingAutomaticNone (record is undefined)
Firewall appliedYesNo
PreconditionsSupported via access.recordNot applicable
Response typesJSON onlyJSON, stream, file
Use caseApprove invoice, archive orderAI 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

OptionRequiredDescription
descriptionYesHuman-readable description of the action
inputYesZod schema for request validation
accessYesAccess control (roles, record conditions, or function)
executeYes*Inline execution function
handlerYes*File path for complex logic (alternative to execute)
standaloneNoSet true for non-record actions
pathNoCustom route path (standalone only)
methodNoHTTP method: GET, POST, PUT, PATCH, DELETE (default: POST)
responseTypeNoResponse format: json, stream, file (default: json)
sideEffectsNoHint for AI tools: 'sync', 'async', or 'fire-and-forget'
unsafeNoWhen 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/archive

Runtime Flow

  1. Authentication - User token is validated
  2. Record Loading - The record is fetched by ID
  3. Firewall Check - Ensures user can access this record
  4. Access Check - Validates roles and preconditions
  5. Input Validation - Request body validated against Zod schema
  6. Execution - Your action handler runs
  7. 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/stripe

Example: 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:

OperatorDescription
equalsField must equal value
notEqualsField must not equal value
inField must be one of the values
notInField 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:

OperationOrg ScopingOwner ScopingSoft Delete FilterAuto-inject on INSERT
SELECTWHERE organizationId = ?WHERE ownerId = ?WHERE deletedAt IS NULLn/a
INSERTn/an/an/aorganizationId, ownerId from ctx
UPDATEWHERE organizationId = ?WHERE ownerId = ?WHERE deletedAt IS NULLn/a
DELETEWHERE organizationId = ?WHERE ownerId = ?WHERE deletedAt IS NULLn/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

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
// 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

TypeContent-TypeUse Case
jsonapplication/jsonStandard API responses (default)
streamtext/event-streamReal-time streaming (AI chat, live updates)
fileVariesFile downloads (reports, exports)

Error Codes

StatusDescription
400Invalid input / validation error
401Not authenticated
403Access check failed (role or precondition)
404Record not found (record-based actions)
500Handler 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 };
    },
  },
});

On this page