Quickback Docs

Actions API

Call custom business logic endpoints defined with defineActions.

Actions provide custom endpoints beyond CRUD operations. They support input validation, access conditions, and multiple response types including streaming.

Record-Based Actions

Record-based actions operate on a specific record identified by ID.

POST /api/v1/{resource}/:id/{action-name}

Request

# With JSON body
curl -X POST /api/v1/orders/ord_123/refund \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Customer request", "amount": 50.00 }'

The action's input schema validates the request body using Zod. Invalid input returns a 400 error.

Response

{
  "success": true,
  "data": {
    "refundId": "ref_456",
    "amount": 50.00,
    "status": "processed"
  }
}

Security Flow

  1. Authentication — User must be logged in (401 if not)
  2. Firewall — Record must exist and be accessible to the user (404 if not found or not owned)
  3. Access — User must have the required role (403 if insufficient)
  4. Input validation — Request body validated against the action's Zod schema (400 if invalid)
  5. Access conditions — Record must match access conditions (400 if not met)
  6. Masking — Applied to the response data

Access Conditions

Actions can require the record to be in a specific state:

// In actions.ts
complete: {
  access: {
    roles: ["owner", "admin", "member"],
    record: { completed: { equals: false } },
  },
}

If the record doesn't match (completed is already true), the action returns 400.

Standalone Actions

Standalone actions don't require a record ID. They're for operations that aren't tied to a specific record.

POST /api/v1/{resource}/{action-name}

Request

curl -X POST /api/v1/reports/generate-summary \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "startDate": "2025-01-01", "endDate": "2025-01-31" }'

Response

Same format as record-based actions. The difference is no record lookup or firewall check.

Security Flow

  1. Authentication — Required (401)
  2. Organization check — For org-scoped resources, user must have an active organization (403)
  3. Access — Role-based check (403)
  4. Scoped DB — The db passed to the handler is auto-scoped based on table columns (org isolation, owner filtering, soft delete)
  5. Input validation — Zod schema validation (400)

Scoped Database

All actions (both standalone and record-based) receive a scoped db instance that automatically enforces security based on the table's columns:

  • Tables with organizationId — org isolation (WHERE organizationId = ?)
  • Tables with ownerId — owner isolation (WHERE ownerId = ?)
  • Tables with deletedAt — soft delete filter (WHERE deletedAt IS NULL)
  • INSERT operations auto-inject organizationId and ownerId from context
// Your handler receives a scoped db — no manual filtering needed
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;
}

If you need unscoped access (e.g., cross-org admin operations), declare unsafe: true on the action:

adminReport: {
  unsafe: true,
  execute: async ({ db, rawDb, ctx, input }) => {
    // rawDb bypasses all security — only available with unsafe: true
    const allOrgs = await rawDb.select().from(invoices);
    return allOrgs;
  },
}

Without unsafe: true, rawDb is undefined.

Input Validation

Actions use Zod schemas for input validation. The schema is defined in your actions.ts file:

import { z } from "zod";
import { defineActions } from "@quickback/compiler";

export default defineActions("orders", {
  refund: {
    type: "record",
    input: z.object({
      reason: z.string().min(1),
      amount: z.number().positive(),
    }),
    access: {
      roles: ["admin"],
      record: { status: { equals: "completed" } },
    },
  },
});

When validation fails:

{
  "error": "Validation failed",
  "layer": "guards",
  "code": "GUARD_VALIDATION_FAILED",
  "details": {
    "issues": [
      { "path": ["amount"], "message": "Number must be greater than 0" }
    ]
  }
}

Response Types

Actions support three response types:

JSON (Default)

Returns a JSON object wrapped in { success: true, data: ... }. Masking is applied to the data.

Streaming

For long-running operations, actions can return a streaming response:

generateReport: {
  type: "standalone",
  responseType: "stream",
  // ...
}

The action handler returns a ReadableStream directly — no JSON wrapper.

File

For file downloads, actions can return a raw Response object:

exportCsv: {
  type: "record",
  responseType: "file",
  // ...
}

Action Handler

The compiler generates a handler skeleton. Your action receives:

{
  db,           // Scoped database (auto-enforces org/owner/soft-delete filters)
  rawDb,        // Raw database (only available when unsafe: true, otherwise undefined)
  ctx,          // Auth context (user, session, org)
  record,       // The existing record (record-based only, undefined for standalone)
  input,        // Validated input from Zod schema
  services,     // Injected services
  c,            // Hono context
  auditFields,  // { createdAt, modifiedAt } timestamps
}

HTTP Methods

Actions default to POST but can use any HTTP method:

getStatus: {
  type: "record",
  method: "GET",
  // GET actions receive input from query parameters instead of body
}

When using GET, input is parsed from query parameters instead of the request body.

Cascading Soft Delete

When soft-deleting a parent record, Quickback automatically soft-deletes related records in child/junction tables within the same feature. The compiler detects foreign key references at build time.

For example, if your feature has projects and projectMembers tables where projectMembers has a .references(() => projects.id), deleting a project will also soft-delete all its members:

DELETE /api/v1/projects/proj_123

Generated code:

// Soft delete parent
await db.update(projects).set({ deletedAt: now, modifiedAt: now })
  .where(and(buildFirewallConditions(ctx), eq(projects.id, id)));

// Cascade soft delete to child tables
await db.update(projectMembers).set({ deletedAt: now, modifiedAt: now })
  .where(eq(projectMembers.projectId, id));

Cascade rules:

  • Soft delete (default): Application-level cascade via generated UPDATE statements
  • Hard delete (mode: 'hard'): No compiler cascade — relies on DB-level ON DELETE CASCADE
  • Cross-feature: No cascade — only within the same feature's tables

See Also

On this page