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
| Need | Use | Why |
|---|---|---|
| Mutation must succeed or fail together | Trigger | Synchronous, same transaction |
| Retry later if it fails | Effect | Async, transactional outbox, configurable retry |
| Run on a clock | Schedule | Cron 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.