zynagent-first application framework

Add a background job#

This example accepts POST /events, queues a job, and records the event key in PostgreSQL. GET /events/:key exposes the completed result. Duplicate job execution is safe because the event key is unique.

1. Create the result table#

zyn g table processed_events

Add the result columns to the generated table declaration, retaining its UUID primary key, timestamps, and update trigger. The table declaration becomes:

CREATE TABLE public."processed_events" (
    id uuid NOT NULL DEFAULT uuidv7() PRIMARY KEY,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now(),
    event_key text NOT NULL UNIQUE,
    processed_at timestamptz NOT NULL DEFAULT now()
);

Run zyn db migrate and commit the migration and refreshed database artifacts.

2. Implement and register the job#

Starting from the scaffold, src/jobs.zig contains the callback and its registry entry in the same file:

// src/jobs.zig
const zyn = @import("zyn");

pub const defaults: zyn.JobDefaults = .{
    .queue = "default",
};

fn record_event(job: *zyn.Job, args: struct { event_key: []const u8 }) !void {
    try job.db.exec(
        "INSERT INTO processed_events (event_key) VALUES ($1) ON CONFLICT DO NOTHING",
        .{args.event_key},
    );
}

pub const registry: zyn.JobRegistry = &.{
    .job("record_event", record_event, .{ .queue = "events" }),
};

pub const recurring: zyn.RecurringJobRegistry = &.{};

If src/jobs.zig already contains jobs or schedules, add the callback and entry to its existing declarations instead of replacing them or declaring a second registry. The generated src/app.zig already exports the jobs module; retain that export.

3. Add an HTTP producer and result action#

// src/actions/events.zig
const std = @import("std");
const zyn = @import("zyn");

const Payload = struct { key: []const u8 };

pub fn create(action: *zyn.Action) !zyn.Response {
    const payload = try action.expect_payload(Payload);
    if (std.mem.trim(u8, payload.key, " \t\r\n").len == 0) {
        return .empty(.{ .status = .unprocessable_entity });
    }
    try action.jobs.enqueue("record_event", .{ .event_key = payload.key }, .{});
    return action.json(.{ .queued = true }, .{ .status = .accepted });
}

pub fn show(action: *zyn.Action) !zyn.Response {
    const event = (try action.db.one(
        struct { event_key: []const u8 },
        "SELECT event_key FROM processed_events WHERE event_key = $1",
        .{action.param("key")},
    )) orelse return .empty(.{ .status = .not_found });
    return action.json(event, .{});
}

Add the import and entries to the existing route module, retaining its CSRF protection and other routes:

// src/routes.zig: imports
const events = @import("actions/events.zig");
// src/routes.zig: table entries
.post("/events", events.create, &.{}),
.get("/events/:key", events.show, &.{}),

These demonstration routes are public. Apply authorization and ownership scope when event production or results are private.

4. Run the worker and verify the result#

zyn jobs list
zyn check
zyn dev --http

zyn dev starts PostgreSQL, HTTP, and the worker. zyn serve starts no worker. With PostgreSQL already available, zyn jobs work runs the worker separately. Listing jobs does not execute them.

In another terminal:

curl -i http://localhost:7532/events \
  -H 'Content-Type: application/json' \
  -H 'Sec-Fetch-Site: same-origin' \
  --data '{"key":"example-event"}'
curl -i http://localhost:7532/events/example-event

The POST returns 202 with {"queued":true}. Repeat the GET after the worker has processed the job: it changes from 404 to 200 with {"event_key":"example-event"}. Repeating the POST still leaves one result row. Use the reported server port if it differs.

Managed tests can call app.jobs.perform_one(.{}) after enqueueing to execute work synchronously and assert its database effects without a worker process.

Jobs have at-least-once delivery. Pass durable, JSON-compatible identifiers, not secrets or binary UUID bytes. Use tx.jobs.enqueue when application writes and enqueueing must commit together. Use .after or .at for delayed eligibility. The callback name is persisted: for incompatible changes, register a versioned callback, move producers, and drain the old name before removing it.