Tasks

Tasks are operational entrypoints: backfills, repairs, imports, exports, and release maintenance that an operator runs on purpose. A task pairs a stable name with a validated input schema and a handler that receives the same app-owned context as the rest of the application, so operational work goes through ports, audit, and logging instead of one-off scripts.

Use a job when the app decides to run work in the background, a schedule when time triggers the work, and a task when a person, CI job, or release pipeline decides when it runs. See the background work overview for choosing between primitives.

Define a task

Start with the Quickstart app and generate a task that checks its operational context:

bun beignet make task todos.check-runtime

The generator creates lib/tasks.ts, the feature definition, and the registries. This complete definition logs one message and returns its input so you can verify execution before adding a backfill or another operation:

// features/todos/tasks/check-runtime.ts
import { z } from "zod";
import { defineTask } from "@/lib/tasks";

export const CheckRuntimeTaskInputSchema = z.object({
  dryRun: z.boolean().default(true),
});
export type CheckRuntimeTaskInput = z.infer<typeof CheckRuntimeTaskInputSchema>;

export const checkRuntimeTask = defineTask("todos.check-runtime", {
  input: CheckRuntimeTaskInputSchema,
  description: "Check the Todo task runtime.",
  async handle({ input, ctx }) {
    ctx.ports.logger.info("Task handled", {
      taskName: "todos.check-runtime",
      dryRun: input.dryRun,
    });
    return { dryRun: input.dryRun };
  },
});

For real work, call use cases and ports from the handler. dryRun is an ordinary input field: a mutating task must explicitly honor it before writing.

Run a task

Prepare the starter database if you have not already, then run the task once:

bun beignet db migrate
bun run typecheck
bun beignet task run todos.check-runtime --input '{"dryRun":true}'

Expect a Task handled log and output containing dryRun: true. No workspace, search index, or job provider is needed. Invalid input such as {"dryRun":"yes"} fails validation before the handler runs. Add --json for machine-readable output.

The CLI loads server/tasks.ts, validates input, creates context, runs the task, and calls stopTaskContext(...) before exiting. --module selects a different registry file.

Register tasks

server/tasks.ts owns the task registry and service context. The generator creates and maintains this wiring; these are excerpts of the default registry:

// server/tasks.ts (excerpt)
import { defineTasks } from "@beignet/core/tasks";
import { todoTasks } from "@/features/todos/tasks";

export const tasks = defineTasks([...todoTasks] as const);

createTaskContext(args) receives the definition, task name, parsed input, and optional tenant value from --tenant. stopTaskContext(ctx, args) receives the created context and those arguments and closes the server. When context includes ports.errorReporter, the CLI reports terminal failures and performs a bounded flush before cleanup.

Tenant-scoped tasks

Use --tenant only when the app has a tenant model. The generated context passes that value as a tenant ID; it does not look up slugs or authorize an operator. Keep operational credentials restricted to callers allowed to run the task.

If your app already has a workspaces repository, you can resolve a slug before creating context. This excerpt assumes findBySlug and findById are declared and wired on that port:

// server/tasks.ts (excerpt)
export async function createTaskContext(args: TaskRunContextArgs): Promise<AppContext> {
  const server = await getServer();
  const workspace = args.tenant
    ? (await server.ports.workspaces.findBySlug(args.tenant)) ??
      (await server.ports.workspaces.findById(args.tenant))
    : undefined;
  if (args.tenant && !workspace) throw new Error(`Unknown tenant "${args.tenant}".`);
  return server.createServiceContext({
    actor: createServiceActor("beignet-cli"),
    tenantId: workspace?.id,
  });
}

Import TaskRunContextArgs from @beignet/core/tasks, createServiceActor from @beignet/core/ports, and your AppContext and getServer as in the generated file. See Tenancy for repository scoping.

Testing

Run the task definition directly with runTask and a test context:

import { runTask } from "@beignet/core/tasks";
import { createTestContext } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
import { checkRuntimeTask } from "@/features/todos/tasks";

const makeContext = createTestContext<AppContext>();
const fixture = makeContext();

try {
  const output = await runTask(checkRuntimeTask, {
    input: { dryRun: true },
    ctx: fixture.ctx,
  });
  expect(output.dryRun).toBe(true);
} finally {
  await fixture.dispose();
}

Memory ports make assertions cheap: capture logs and audit entries in memory, then assert the task recorded what it did. Keep these tests in features/<feature>/tests/.

Production

Tasks run from bounded entrypoints — a local shell, CI job, release job, or admin worker — so they need no exposed HTTP route. See Runtime recipes for task runtime entrypoints and operational auth.