Kizaki
Guides

Background Work

Use triggers, effects, and schedules for async workflows, retries, and recurring jobs.

Kizaki provides three levels of background behavior, each with a different failure model:

  • Triggers fire synchronously inside the database transaction. If a trigger fails, the mutation rolls back.
  • Effects fire asynchronously after the transaction commits. If an effect fails, the platform retries it.
  • Schedules run on a cron expression. No triggering event needed.

Choosing the wrong level creates real problems. A trigger that calls an external API blocks your database writes on that API's availability. An effect where you needed a trigger lets inconsistent data commit silently.

Triggers

Triggers run inside the same transaction as the mutation that caused them. Use them for validation, invariant enforcement, and derived data that must stay consistent with the write.

entity Order {
  total: decimal(10,2)
  customerEmail: string
  status: string = "Draft"

  @on(insert) trigger validateOrder
}
import { InferRow } from "@kizaki/sdk";
import { Order } from "@kizaki/schema";

export async function validateOrder(row: InferRow<typeof Order>) {
  if (!row.total || row.total <= 0) {
    throw new Error("Order total must be positive");
  }
}

If validateOrder throws, the insert does not commit. The caller receives the error. This is correct for validation: data that violates the rule should not exist.

Do not use triggers for work that can fail independently. A trigger that calls a third-party service means a slow or failing dependency blocks your database writes. Use an effect instead.

Effects

Effects fire after the transaction has committed. The row is already persisted. Delivery uses a transactional outbox pattern, so the effect runs even if the function runtime restarts between commit and delivery.

If the effect handler throws, Kizaki retries with the configured backoff.

entity Order {
  total: decimal(10,2)
  customerEmail: string
  status: OrderStatus

  @on(update, status) effect sendReceipt {
    retries: 3
    backoff: exponential(2s)
    timeout: 30s
  }
}
import { InferRow } from "@kizaki/sdk";
import { Order } from "@kizaki/schema";
import { sendEmail } from "@kizaki/sdk";

export async function sendReceipt(row: InferRow<typeof Order>) {
  if (row.status === "Paid") {
    await sendEmail({
      to: row.customerEmail,
      subject: "Your receipt",
      html: `<p>Order ${row.id} confirmed. Total: $${row.total}</p>`,
    });
  }
}

With retries: 3 and backoff: exponential(2s), the platform retries up to three times at increasing delays (2s, 4s, 8s) if the handler throws. The timeout: 30s caps execution time per attempt.

Effects suit work that matters but should not block the write: sending emails, calling webhooks, updating external systems, pushing notifications.

Field-Specific Triggers And Effects

The @on(update, status) syntax fires the handler only when the status field changes, not on any update to the row. Without the field qualifier, the handler fires on every update.

entity Project {
  name: string
  status: string
  archivedAt: datetime?

  @on(update, status) effect notifyStatusChange {
    retries: 2
    backoff: exponential(1s)
    timeout: 10s
  }

  @on(update) trigger updateTimestamps
}

Here, notifyStatusChange fires only when status changes. updateTimestamps fires on every update regardless of which field changed. The schema makes the distinction explicit.

Schedules

Schedules run on a cron expression, independent of data changes.

schedule nightlyDigest {
  cron: "0 2 * * *"
  function: sendNightlyDigest
  timeout: 5m
}
import { query, select } from "@kizaki/sdk";
import { sendEmail } from "@kizaki/sdk";
import { User } from "@kizaki/schema";

export async function sendNightlyDigest() {
  const users = await query(select(User).fields(User.id, User.email));
  for (const user of users) {
    await sendEmail({
      to: user.email,
      subject: "Your daily digest",
      html: "...",
    });
  }
}

Schedule functions run as system functions with no principal context and no authenticated user. They access all data, scoped only by system-level policies.

Set timeout generously for schedules. A nightly job processing thousands of rows needs room to finish. A schedule that misses its window does not run until the next cron match.

Rule Of Thumb

NeedUseWhy
Mutation must succeed or fail togetherTriggerSynchronous, same transaction
Retry later if it failsEffectAsync, transactional outbox, configurable retry
Run on a clockScheduleCron expression, no triggering event needed

Practical Guidance

Ask the rollback question. If sending an email fails, should the order insert also fail? If no, use an effect. If yes, use a trigger.

Keep triggers fast. Triggers hold a database lock for their full duration. A 500ms trigger means a 500ms insert. No network calls, no heavy computation.

Set realistic retry counts. Three retries with exponential backoff covers most transient failures. Setting retries to 50 usually means you have a permanent failure that retrying will not fix. Investigate the root cause instead.

Prefer field-specific events. An @on(update) effect that checks if (row.status === "Paid") fires on every update and does nothing most of the time. An @on(update, status) effect fires only when relevant.

On this page