Build a report API with Web, ORM, and Jobs

This tutorial builds one portable backend slice: an HTTP endpoint stores a report with trb/orm, enqueues durable work with trb/jobs, and a worker updates the same report. The TypeRB source is shared by the Go, Ruby, and TypeScript backends.

The complete, executable project lives in examples/tutorials/web-orm-jobs. Repository tests run the request and worker flow in all three modes.

The application has this flow:

POST /reports
  -> Context#bind
  -> create_report
  -> application database transaction commits a pending report
  -> GenerateReportJob is enqueued
  -> 202 Accepted

trb jobs start --once
  -> GenerateReportJob#perform
  -> report status becomes ready

GET /reports/:id
  -> returns the current report

Prerequisites

Install trb, the toolchain for the selected backend, and the sqlite3 command-line program. The checked-in project defaults to Go mode.

Copy the example or work in its directory:

cd examples/tutorials/web-orm-jobs
mkdir -p tmp

Configure the project and database

The project selects SQLite for ORM access, reads the application database from DATABASE_URL, and points the Jobs compiler integration at config/jobs.

trbconfig.jsonc:

{
  "name": "report-api",
  "mode": "go",
  "sourceDir": "src",
  "packageOptions": {
    "trb/orm": {
      "adapter": "sqlite",
      "database": {
        "environment": "DATABASE_URL"
      }
    }
  },
  "db": {
    "adapter": "sqlite",
    "database": {
      "environment": "DATABASE_URL"
    },
    "schema": "db/schema.sql",
    "lock": "db/schema.lock.json"
  },
  "jobs": {
    "configuration": "config/jobs"
  },
  "go": {
    "module": "example.com/report-api"
  }
}

db/schema.sql:

CREATE TABLE reports (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  status TEXT NOT NULL
);

The example also commits the generated db/schema.lock.json. Check that the lock still describes the SQL schema, then initialize a local database:

trb db check
sqlite3 tmp/application.sqlite3 < db/schema.sql
export DATABASE_URL="$PWD/tmp/application.sqlite3"
export JOBS_DATABASE_URL="$PWD/tmp/jobs.sqlite3"

The two environment variables deliberately name different databases. The application owns reports; the SQL Jobs adapter owns its queue tables.

Configure the queue

The composition module selects the SQLite Jobs adapter. Other application modules import only trb/jobs, so storage selection remains at this boundary.

src/config/jobs.trb:

import { JobAdapter } from trb/jobs
import { SQLAdapter, SQLDialect } from trb/jobs/sql

JOBS_ADAPTER: JobAdapter := SQLAdapter.new(
	dialect: SQLDialect::SQLite,
	source_environment: "JOBS_DATABASE_URL",
)

Define the model and Job

The schema lock supplies the generated fields on the otherwise empty model.

src/models/report.trb:

import { Model } from trb/orm

class Report < Model
end

The Job loads the report, produces an immutable changed value with with, and persists it. It maps ORM failures to JobError, which tells the worker to apply its retry policy.

src/jobs/generate_report_job.trb:

import models/report
import { Job, JobError, JobResult, maximum_attempts, queue } from trb/jobs
import trb/std/unit

class GenerateReportJob < Job
	queue("reports")
	maximum_attempts(3)

	def perform(report_id: Integer): JobResult
		report := Report.find(report_id) catch |error|
			return JobResult::Err(JobError.new(message: "report lookup failed: " + error.message))
		end
		_saved := report.with(status: "ready").save() catch |error|
			return JobResult::Err(JobError.new(message: "report update failed: " + error.message))
		end
		return JobResult::Ok(Unit.new())
	end
end

Jobs use at-least-once delivery. Real work should therefore be idempotent; this example is safe to repeat because setting status to ready has the same result each time.

Put the transaction in an application service

The service owns the use case and its error vocabulary. The route does not need to know how the report is stored or how the Job is persisted.

src/services/create_report.trb:

import jobs/generate_report_job
import models/report
import { EnqueueError } from trb/jobs
import { Database, DbError } from trb/orm
import trb/std/result

enum CreateReportError
	Database(error: DbError)
	Queue(error: EnqueueError)
end

record AcceptedReport
	id: Integer
	job_id: String
	status: String
end

def create_report(title: String): Result<AcceptedReport, CreateReportError>
	report := Database.transaction() do |transaction|
		reports := Report.using(transaction)
		created := try reports.create(title: title, status: "pending")
		created
	end catch |error|
		return Result<AcceptedReport, CreateReportError>::Err(CreateReportError::Database(error))
	end

	reference := GenerateReportJob.perform_later(report.id) catch |error|
		return Result<AcceptedReport, CreateReportError>::Err(CreateReportError::Queue(error))
	end
	return Result<AcceptedReport, CreateReportError>::Ok(
		AcceptedReport.new(id: report.id, job_id: reference.id, status: report.status),
	)
end

try propagates DbError inside the transaction callback. The postfix catch translates the transaction and enqueue failures into application errors at the two boundaries where their meaning changes.

The enqueue intentionally happens after the application transaction commits. The initial Jobs contract does not provide a transaction spanning the application database and queue storage. If enqueueing fails, this example returns 503 while leaving a pending report behind. A production application that must close that gap needs an explicit retry/reconciliation process or a transactional outbox; moving perform_later inside this transaction would not make the two stores atomic.

Map errors once at the HTTP boundary

The shared mapper converts input, database, and queue errors into HTTP responses. Both routes reuse it instead of repeating transport policy.

src/http/errors.trb:

import { CreateReportError } from services/create_report
import { DbError } from trb/orm
import { EndpointInputError, Response, json } from trb/web

record ErrorResponse
	code: String
	message: String
end

def endpoint_input_error_response(error: EndpointInputError): Response
	case error
	when EndpointInputError::Params(_error)
		return json(ErrorResponse.new(code: "invalid_path", message: "path parameters are invalid"), 400)
	when EndpointInputError::Query(_error)
		return json(ErrorResponse.new(code: "invalid_query", message: "query parameters are invalid"), 400)
	when EndpointInputError::Body(_error)
		return json(ErrorResponse.new(code: "invalid_body", message: "request body is invalid"), 400)
	end
end

def create_report_error_response(error: CreateReportError): Response
	case error
	when CreateReportError::Database(database_error)
		return database_error_response(database_error)
	when CreateReportError::Queue(queue_error)
		return json(ErrorResponse.new(code: "queue_unavailable", message: queue_error.message), 503)
	end
end

def database_error_response(error: DbError): Response
	return json(ErrorResponse.new(code: "database_error", message: error.message), 500)
end

Add file-based routes

Context#bind<T>() decodes the request sections named by the endpoint contract and returns Result<T, EndpointInputError>. The POST route binds a body; the dynamic GET route binds the :id path parameter.

src/routes/reports.trb:

import { create_report_error_response, endpoint_input_error_response } from http/errors
import { create_report } from services/create_report
import { Context, Response, json } from trb/web

record CreateReportBody
	title: String
end

record CreateReportEndpointInput
	body: CreateReportBody
end

def post(context: Context): Response
	input := context.bind<CreateReportEndpointInput>() catch |error|
		return endpoint_input_error_response(error)
	end
	accepted := create_report(input.body.title) catch |error|
		return create_report_error_response(error)
	end
	return json(accepted, 202)
end

src/routes/reports/[id].trb:

import { database_error_response, endpoint_input_error_response } from http/errors
import models/report
import { Context, Response, json } from trb/web

record ReportParams
	id: Integer
end

record ShowReportEndpointInput
	params: ReportParams
end

record ReportResponse
	id: Integer
	status: String
	title: String
end

def get(context: Context): Response
	input := context.bind<ShowReportEndpointInput>() catch |error|
		return endpoint_input_error_response(error)
	end
	report := Report.find(input.params.id) catch |error|
		return database_error_response(error)
	end
	return json(ReportResponse.new(id: report.id, status: report.status, title: report.title))
end

The static /reports route and dynamic /reports/:id route coexist; static segments take precedence when the router builds the file-routing table.

Run the server and worker

The runnable root only starts the generated Web server.

src/main.trb:

import { configure_server, serve } from trb/web

def main()
	serve(configure_server(host: "127.0.0.1", port: 3000))
end

Check the complete project, then start it:

trb check
trb lint
trb run

From another terminal with the same environment variables, create a report:

curl -i \
  -H 'content-type: application/json' \
  -d '{"title":"August report"}' \
  http://127.0.0.1:3000/reports

The response is 202 Accepted and contains an application record ID, a queue Job ID, and "status":"pending". Before the worker runs, this returns the pending report:

curl http://127.0.0.1:3000/reports/1

Process one report Job, then repeat the GET request:

trb jobs start --once --queue reports
curl http://127.0.0.1:3000/reports/1

The status is now ready.

Test the slice without a network server

trb/web/testing dispatches a request through the generated router in-process.

src/report_api_test.trb:

import { Body, Header, Headers, HttpMethod } from trb/http
import { Request } from trb/web
import { dispatch } from trb/web/testing
import { describe, expect, test } from trb/std/test

describe("Report API") do
	test("accepts a report for background processing") do
		request := Request.new(
			method: HttpMethod.post(),
			path: "/reports",
			query_string: "",
			headers: Headers.new([Header.new(name: "content-type", value: "application/json")]),
			body: Body.new("{\"title\":\"August report\"}".to_bytes()),
		)
		response := dispatch(request)
		expect(response.status).to_equal(202)

		show_request := Request.new(
			method: HttpMethod.get(),
			path: "/reports/1",
			query_string: "",
			headers: Headers.new([]),
			body: Body.empty(),
		)
		show_response := dispatch(show_request)
		expect(show_response.status).to_equal(200)
	end
end

Run it against fresh local databases:

trb test
trb jobs start --once --queue reports

The repository's integration test goes further: for each of Go, Ruby, and TypeScript/Bun, it creates fresh databases, runs this request test, checks the pending row and queued Job, runs one worker, and checks the ready row and empty queue.

Switch backends

All files under src/ and db/ stay unchanged. Select one target in trbconfig.jsonc, replace the target-specific section, then synchronize its native manifest. For Ruby, use a ruby section and the require_relative loader. For TypeScript server execution, select Bun:

{
  "mode": "typescript",
  "devDependencies": {
    "typescript": "^6.0.0"
  },
  "typescript": {
    "runtime": "bun",
    "packageManager": "bun",
    "moduleType": "module"
  }
}

Run trb sync and trb install, then repeat the same database, test, server, and worker commands. See Project configuration for the complete Go, Ruby, and TypeScript target settings.

Continue with the focused Web, ORM, Jobs, and testing guides when extending this slice.