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 (required)
Record fetchingAutomaticNone (record is undefined)
Firewall appliedYesNo
PreconditionsSupported via access.recordNot applicable
Response typesJSON onlyJSON, stream, file
Use caseAdvance application, reject candidateAI chat, bulk import from job board, webhooks

Standalone actions are signalled by the presence of a path: field in the action config. Record-based actions omit path and bind to the feature's primary table.

Where do actions live?

One file per action. Each action is its own module under the feature's actions/ directory:

features/applications/
├── applications.ts             ← defineTable (primary table)
├── candidates.ts               ← defineTable (sibling table)
├── actions/
│   ├── advance.ts              ← defineAction, binds to applications
│   ├── reject.ts               ← defineAction, binds to applications
│   ├── stats.ts                ← defineAction, standalone (has `path:`)
│   └── candidates/
│       └── disqualify.ts       ← defineAction, binds to candidates
└── lib/
    └── inputs.ts               ← shared schemas (optional, copied verbatim)
LayoutBinds to
actions/<name>.tsThe feature's primary table (table file matching the feature name), or standalone if path: is set.
actions/<table>/<name>.tsThe sibling table file <table>.ts at the feature root. Standalone actions inside a subdir ignore the binding.

The action's filename (sans .ts) is the action name and the URL segment. Uniqueness is enforced per (bound table, name) pair — sibling-bound actions on different tables may share a verb (actions/episodes/publish.ts and actions/shows/publish.ts mount at /episodes/:id/publish and /shows/:id/publish and compile cleanly). The compiler errors only when two action files would mount at the same URL — for example, a flat actions/foo.ts (binds to primary) plus actions/<primary>/foo.ts, or two standalones with the same (method, path). Standalone actions are unique per (method, path).

Tableless features have no top-level *.ts files; every action under actions/ must be standalone (have path:). Use this for utility endpoints like webhooks, reports, or integrations that don't operate on a specific record.

Retired layouts

These are rejected at load time with migration guidance:

  • actions.ts (bundled multi-action file)
  • _feature.ts (shared schemas + non-table-bound actions)
  • handlers/ (separate handler-file directory)
  • defineActions(table, {...}) (the multi-action factory)
  • defineActions(null, {...}) (tableless variant)
  • *-actions.ts / *.actions.ts filename patterns

For shared Zod schemas or helpers, put them under <feature>/lib/ and import from each action file. Files under lib/ are copied verbatim into the generated output.

Defining Actions

// features/applications/actions/advance.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";

export default defineAction({
  description: "Move an application forward in the pipeline.",
  input: z.object({
    nextStatus: z.enum(["screening", "interview", "offer"]),
    notes: z.string().max(2000).optional(),
  }),
  access: { roles: ["owner", "admin"] },
  transition: {
    field: "status",
    via: "nextStatus",
    fromTo: {
      applied: ["screening"],
      screening: ["interview"],
      interview: ["offer"],
    },
  },
  async execute({ db, record, input, whereTransition }) {
    const [updated] = await db
      .update(applications)
      .set({
        status: input.nextStatus,
        notes: input.notes ?? record.notes,
      })
      .where(whereTransition!(applications))
      .returning();
    return updated;
  },
});

defineAction import. Import from "../.quickback/define-action" — the .quickback/ directory is generated alongside your feature with a typed helper that infers record and input types from the rest of the action config. Audit fields (createdAt, createdBy, modifiedAt, modifiedBy) are hard-stamped by the audit DB wrapper on every db.insert().values() and db.update().set() — handlers don't pass them and can't override them.

Configuration Options

OptionRequiredDescription
descriptionYesHuman-readable description of the action
inputYesZod schema for request validation
accessYesAccess control (roles, record conditions, or function)
executeYesInline async arrow function (async (ctx) => { … })
pathStandalone onlyCustom route path. Presence of path makes the action standalone.
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'
allowRawSqlNoExplicit compile-time opt-in for raw SQL in execute code
unsafeNoUnsafe raw DB mode. Object config (reason, adminOnly, crossTenant, targetScope).
transitionNoState-machine policy enforcing the from/to pair (record-based actions). See Transitions
bulkVariantNoAuto-generate a POST /:resource/batch/{action} route alongside the per-record route (record-based actions). See Bulk Variant
cmsNoPass-through metadata blob for CMS surfaces

execute must be an async (ctx) => { … } arrow function. async function declarations and non-async forms are rejected at parse time.

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
POST /applications/:id/reject
POST /applications/:id/schedule-interview

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: Advance Application Stage

// features/applications/actions/advance.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";

export default defineAction({
  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" } },
  },
  async execute({ db, 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
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"
  }
}

Bulk variant

Set bulkVariant: true to auto-generate a bulk version of the action at POST /:resource/batch/{action}. The compiler emits a second route alongside the per-record one, looping the same access + transition + execute pipeline over an array of ids. This is the answer to "I need to advance 50 applications at once" — you don't have to hand-roll a parallel standalone action that duplicates the per-record logic.

// features/applications/actions/advance.ts
export default defineAction({
  description: "Move an application forward in the pipeline.",
  input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
  access: { roles: ["owner", "admin"] },
  bulkVariant: true,
  transition: {
    field: "status",
    via: "nextStatus",
    fromTo: { applied: ["screening"], screening: ["interview"], interview: ["offer"] },
  },
  async execute({ db, record, input, whereTransition }) {
    const [updated] = await db
      .update(applications)
      .set({ status: input.nextStatus })
      .where(whereTransition!(applications))
      .returning();
    if (!updated) throw new TransitionLostError("status", record.status, input.nextStatus);
    return updated;
  },
});

Request shape:

POST /applications/batch/advance
Content-Type: application/json

{
  "ids": ["app_001", "app_002", "app_003"],
  "input": { "nextStatus": "interview" },
  "failFast": false
}

The input is shared across the batch — the action's Zod schema is validated once, not per id. For per-record input variation, use the single-record endpoint (POST /:id/{action}) or write a standalone action.

Response (207 Multi-Status when any record fails, 200 when clean):

{
  "success": [{ "id": "app_001", "status": "interview" }],
  "errors": [
    {
      "index": 1,
      "id": "app_002",
      "error": {
        "error": "Action not allowed for current state",
        "code": "ACCESS_ACTION_NOT_ALLOWED_FOR_STATE",
        "details": { "field": "status", "currentValue": "rejected" }
      }
    }
  ],
  "meta": {
    "total": 3,
    "succeeded": 2,
    "failed": 1,
    "failFast": false,
    "transactional": false
  }
}

Per-record pipeline mirrors the single-record route exactly — the same firewall fetch, access reason split (403 vs 409), transition pre-check, whereTransition factory, and TransitionLostError handling. Per-record failures land in errors[] with their original index; successful records land in success[] masked the same way the single-record response is.

Transactional matrix (same as PATCH /batch):

ProviderfailFasttransactionalBehavior
postgres / supabase / neon / libsql / sqlite driverstruetruedb.transaction() wraps the loop; any throw rolls back all writes
(any)falsefalseIndependent per-record outcomes, partial success
cloudflare-d1truefalseFail-fast loop without rollback — earlier records stay committed

Constraints:

  • Record-based actions only. Standalone actions reject bulkVariant: true at compile time (no record context to loop over).
  • unsafe actions reject bulkVariant: true in v1 — the cross-tenant per-record audit story is not yet wired through.
  • Method is forced to POST regardless of the action's method setting. The semantic asymmetry (one row → many rows) makes inheriting GET/DELETE confusing.
  • Max batch size is 100 ids per request.

Standalone Actions

Standalone actions are independent endpoints that don't require a record context. Set a path: to declare the action standalone.

Route pattern: Custom path

POST /chat
GET /reports/summary
POST /webhooks/stripe

Example: AI Chat with Streaming

// features/sessions/actions/chat.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Send a message to AI",
  path: "/chat",
  method: "POST",
  responseType: "stream",
  input: z.object({
    message: z.string().min(1).max(2000),
  }),
  access: { roles: ["recruiter", "hiring-manager"] },
  async execute({ input, c }) {
    // Stream response back to client...
    return c.newResponse(/* ReadableStream */);
  },
});

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"}

Tableless Features

A feature directory with no top-level *.ts files is "tableless" — every action under actions/ must be standalone. Use this for utility endpoints like reports, integrations, or webhooks that don't map to a specific resource.

quickback/features/
└── reports/
    └── actions/
        ├── trial-balance.ts        ← standalone (has path:)
        └── profit-loss.ts          ← standalone (has path:)
// features/reports/actions/trial-balance.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Generate trial balance report",
  path: "/reports/trial-balance",
  method: "GET",
  input: z.object({
    startDate: z.string(),
    endDate: z.string(),
  }),
  access: { roles: ["admin", "accountant"] },
  async execute({ db, input }) {
    // ... build report
    return { rows: [] };
  },
});

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/submit.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Public contact form submission",
  path: "/submit",
  input: z.object({
    name: z.string().min(1),
    email: z.string().email(),
    message: z.string().min(10),
  }),
  access: { roles: ["PUBLIC"] },
  async execute({ db, input }) {
    // ... persist submission
    return { ok: true };
  },
});

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:

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: ["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;
}

Relationship-roles in actions (v1 limitation)

Relationship-roles declared under authz.roles (e.g. guest: { via: 'guestOf' }) are not yet supported in action access blocks. The compiler refuses to build with a clear error message because the binding semantics — which input field or which loaded record's column to check the relationship against — differ between record-actions and standalone-actions and require explicit declaration not yet wired in v1.

The runtime evaluator fails closed on unrecognized relationship arms, so silent grants are impossible.

Workaround: gate the action on BA roles only and apply the relationship at the resource layer via a firewall via: predicate:

// quickback.config.ts
authz: {
  relationships: {
    guestOf: {
      from: 'event_guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
    },
  },
}

// features/sessions/sessions.ts — firewall scopes rows; action gates on BA role
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', via: 'guestOf' },         // ← row-level relationship check
],
actions: {
  comment: defineAction({
    access: { roles: ['member', 'admin'] },     // ← BA role only on the action
    input: z.object({ text: z.string() }),
    execute: async (input, ctx, db, record) => {
      // record is already filtered by the firewall — guest membership
      // is enforced before this handler runs.
      return db.insert(comments).values({ ... });
    },
  }),
}

This pattern composes cleanly: the firewall guarantees the caller has the relationship to the record being acted on; the action's access list keeps the role surface obvious.

A future actions.<name>.relationshipBindings declaration is on the roadmap to remove this limitation. See Access — relationship authorization for the full picture.

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.

async execute({ 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:

// features/admin/actions/cross-tenant-report.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../../applications/applications";

export default defineAction({
  description: "Generate cross-org hiring report",
  path: "/admin/cross-tenant-report",
  input: z.object({ startDate: z.string() }),
  access: { roles: ["owner"] },
  unsafe: {
    reason: "Support investigation for enterprise customer",
    adminOnly: true,      // default true
    crossTenant: true,    // default true
    targetScope: "all",   // "all" | "organization"
  },
  async execute({ unsafeDb, input }) {
    // unsafeDb bypasses scoped filters
    const allOrgs = await unsafeDb.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, unsafeDb is undefined in the executor params.

Raw SQL Policy

By default, the compiler rejects raw SQL in action code. Use Drizzle query-builder syntax whenever possible.

If a specific action must use raw SQL, opt in explicitly:

// features/ledgers/actions/reconcile.ts
import { z } from "zod";
import { sql } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Run custom reconciliation query",
  input: z.object({}),
  access: { roles: ["owner"] },
  allowRawSql: true,
  async execute({ db }) {
    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.

Sharing Code Between Actions

For Zod schemas, helpers, or constants used across multiple actions in a feature, put them under <feature>/lib/ and import from each action file. Files under lib/ are copied verbatim into the generated output.

features/applications/
├── applications.ts
├── actions/
│   ├── advance.ts
│   └── reject.ts
└── lib/
    ├── inputs.ts          ← shared Zod schemas
    └── transitions.ts     ← state-machine helpers
// features/applications/lib/inputs.ts
import { z } from "zod";

export const advanceInput = z.object({
  nextStatus: z.enum(["screening", "interview", "offer"]),
  notes: z.string().max(2000).optional(),
});
// features/applications/actions/advance.ts
import { defineAction } from "../.quickback/define-action";
import { advanceInput } from "../lib/inputs";

export default defineAction({
  description: "Move forward",
  input: advanceInput,
  access: { roles: ["owner"] },
  async execute({ db, record, input }) { /* … */ },
});

Importing Tables

Action files 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:

// features/applications/actions/advance.ts
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";

Path pattern from features/{name}/actions/:

  • 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

import type { Auth } from "better-auth";

interface ActionExecutorParams {
  db: DrizzleDB;           // Scoped database (auto-applies org/owner/soft-delete filters)
  unsafeDb?: DrizzleDB;    // Unscoped 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
  env: Env;                // Runtime bindings (Cloudflare env / process env). Alias for c.env.
  auth: Auth;              // Better Auth instance — call auth.api.getSession(...) etc. Lazy on Cloudflare.
  whereRecord?: (table) => SQL; // Pre-built WHERE: firewall + soft-delete + eq(table.id, record.id). Record-based actions only.
  whereTransition?: (table) => SQL; // whereRecord plus the transition's "from" precondition. Transition actions only.
}

auth — Better Auth instance

auth is the Better Auth instance, ready to call from inside an action handler. Use it for things like minting an OAuth access token, fetching the current session in a custom way, or invoking a Better Auth plugin method that isn't already exposed by the generated middleware.

// features/integrations/actions/connect-google-drive.ts
import { defineAction } from "../.quickback/define-action";
import { z } from "zod";

export default defineAction({
  description: "Get a fresh Google Drive access token for the signed-in user",
  input: z.object({}),
  access: { roles: ["AUTHENTICATED"] },
  async execute({ auth, ctx, c }) {
    // genericOAuth plugin method — needs a cast since the default-generic
    // `Auth` type doesn't carry plugin-specific API surface.
    const token = await (auth.api as any).getAccessToken({
      headers: c.req.raw.headers,
      body: { providerId: "google", userId: ctx.userId },
    });
    return { accessToken: token.accessToken };
  },
});

Lazy on Cloudflare: the generated handler emits a Proxy so createAuth(c.env, c.req.raw.cf) only runs the first time you read a property on auth. Actions that never touch auth pay zero construction cost. On Bun/Node auth is the imported singleton (an IIFE that lazy-loads the database adapter) wrapped in the same proxy shape — call sites are identical across runtimes.

Typing: auth is typed as Auth from better-auth. The base API (getSession, signOut, signInEmail, etc.) is fully typed. Plugin-only methods (getAccessToken from genericOAuth, admin-plugin methods, etc.) need a cast at the call site — the default-generic Auth doesn't infer the project's specific plugin set.

whereRecord — explicit, scope-safe WHERE

For protected-field writes inside record-based actions, prefer whereRecord(table) over eq(table.id, record.id). It returns a Drizzle expression that AND-merges the resource's firewall and soft-delete predicates with the record id, so the WHERE is explicit even if a future refactor swaps db back to an unscoped client:

// features/applications/actions/advance.ts
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";

export default defineAction({
  description: "Advance",
  input: /* … */,
  access: { roles: ["owner"] },
  async execute({ db, record, input, whereRecord }) {
    const [updated] = await db
      .update(applications)
      .set({
        status: input.nextStatus,
        notes: input.notes ?? record.notes,
      })
      .where(whereRecord!(applications))   // firewall + soft-delete + id, all in one
      .returning();
    return updated;
  },
});

The audit DB wrapper hard-stamps modifiedAt/modifiedBy on every .set() call, so handlers don't write them. The same wrapper hard-stamps createdAt/createdBy/modifiedAt/modifiedBy on every .values(). Caller-supplied audit fields are dropped on the floor — the audit log always reflects the real actor and the real moment.

whereRecord is undefined for standalone actions (they have no record), so the parameter is typed as optional. whereTransition extends whereRecord with the transition's "from" precondition for transition actions.

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

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 (also GUARD_FIELD_PROTECTED for direct protected-field writes)
401Not authenticated
403Access role check failed (ACCESS_ROLE_REQUIRED)
404Record not found (record-based actions)
409Record is in the wrong state for this action — access.record predicate or transition policy failed (ACCESS_ACTION_NOT_ALLOWED_FOR_STATE)
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:

async execute({ 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/types";

async execute({ 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 the table file (defineTable config)
guards: {
  protected: {
    status: ["advance", "reject", "hire", "withdraw"],  // Only these actions can modify status
  }
}

This allows the advance action to set status = "interview" even though the field is protected from regular PATCH requests. Direct PATCH /:id attempts to set status are rejected by the guards layer with 400 GUARD_FIELD_PROTECTED, and the field is omitted from the generated POST/PATCH body Zod schemas — clients cannot even send it past validation.

Transition (state-machine policy)

For protected fields that follow a state machine — application status, invoice lifecycle, order fulfilment — use transition on the action so the compiler generates a runtime guard that enforces the from/to pair, not just the current state. Without it, an advance action whose input enum allows ["screening", "interview", "offer"] and whose access.record allows status in ["applied", "screening", "interview"] will happily run applied → offer (a skip) or interview → screening (a regression).

// features/applications/actions/advance.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";

export default defineAction({
  description: "Move an application forward in the pipeline.",
  input: z.object({ nextStatus: z.enum(["screening", "interview", "offer"]) }),
  access: { roles: ["owner", "admin"] },
  transition: {
    field: "status",
    via: "nextStatus",      // read target from input.nextStatus
    fromTo: {
      applied:   ["screening"],
      screening: ["interview"],
      interview: ["offer"],
    },
  },
  async execute({ db, record, input, whereTransition }) { /* … */ },
});
// features/applications/actions/hire.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../applications";

export default defineAction({
  description: "Finalize an offer.",
  input: z.object({}),
  access: { roles: ["owner"] },
  transition: {
    field: "status",
    to: "hired",            // fixed target — same value every call
    fromTo: { offer: ["hired"] },
  },
  async execute({ db, record, whereTransition }) { /* … */ },
});
FieldRequiredDescription
fieldYesRecord column to validate (e.g. "status")
fromToYesMap of current value → permitted next values. Keys also enumerate the legal source states.
viaYes (or to)Input key that carries the target value. Use for variable-target actions like advance.
toYes (or via)Literal target value the action always writes. Use for fixed-target actions like hire/reject.

Exactly one of via or to must be provided. The compiler rejects both at validate time. When to is set, it must appear in some fromTo[*] array — otherwise the action would be unreachable, which is almost certainly a bug.

Wire-level errors

The compiled guard returns 409 ACCESS_ACTION_NOT_ALLOWED_FOR_STATE with the field, the current value, the requested target, and the allowed set:

{
  "error": "Action not allowed for current record state",
  "layer": "access",
  "code": "ACCESS_ACTION_NOT_ALLOWED_FOR_STATE",
  "details": {
    "field": "status",
    "current": "interview",
    "target": "screening",
    "allowedTargets": ["offer"]
  },
  "hint": "From \"interview\", status can transition to: offer"
}

This is distinct from a role failure: if the caller has the wrong role they get 403 ACCESS_ROLE_REQUIRED as before. Splitting the two lets clients tell "you can't do this" from "you can't do this yet" — the latter is recoverable by the user, the former isn't.

The same 409 code is used when an access.record predicate (without transition) fails — for example record: { status: { equals: "pending" } } called against a paid invoice now returns 409 ACCESS_ACTION_NOT_ALLOWED_FOR_STATE, not a misleading 403.

Examples

Bulk Import (Standalone, No Record)

// features/candidates/actions/bulk-import.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { candidates } from "../candidates";

export default defineAction({
  description: "Import candidates from job board CSV",
  path: "/candidates/import",
  input: z.object({
    data: z.array(z.object({
      email: z.string().email(),
      name: z.string(),
    })),
  }),
  access: { roles: ["hiring-manager", "recruiter"] },
  async execute({ db, input }) {
    const inserted = await db
      .insert(candidates)
      .values(input.data)
      .returning();
    return { imported: inserted.length };
  },
});

On this page