dánwòdánwò

Examples

Background jobs

A team digest sent by background jobs on Inngest: a React page, a Hono API on Postgres, and a worker whose cron, fan-out, retries and onFailure send through Resend and bill through Stripe.

A team digest sent by background jobs on Inngest: a React page, a Hono API on Postgres, and a worker whose cron, fan-out, retries and onFailure send through Resend and bill through Stripe. See it on GitHub.

What it shows

  • Background jobs on the real Inngest dev server: settle() waits for every run, its fan-out, retries and onFailure handler included
  • time.travel to a digest's send time runs the hourly cron, which fans out one digest/send-one per subscriber in batches
  • Invariants that read Postgres against Resend and Stripe: one email per subscriber per digest, one invoice item per batch, and every failed copy told to its owner
  • danwo matrix faulting the event sends, Resend, Stripe and the jobs' own SQL (58 trials), and JOBS_BUG switches that turn off the idempotency keys or the steps

Stack

InngestHonoPostgresDrizzleViteReact

Emulates: Resend, Stripe. Runs on: A Vite page, a Hono API and an Inngest worker in Chromium, with the Inngest dev server and Postgres in Docker.

Run it

Copy examples/jobs anywhere, then from its folder:

npm install
npx @danwo/cli test

Its scripts:

npm run build          # vite build web
npm run typecheck      # tsc --noEmit && tsc --noEmit -p web
npm run test           # danwo test
npm run test:agentic   # danwo test tests/agentic.danwo.ts
npm run matrix         # danwo matrix tests/deterministic.danwo.ts --grep "sends a scheduled digest"
npm run matrix:plan    # danwo matrix tests/deterministic.danwo.ts --grep "sends a scheduled digest" --plan
npm run serve          # danwo serve

Its README says what else it needs and what the last full matrix found.

Until the danwo version it pins is on npm, install it from a local build instead: from the repository's root, bun run release <version> --pack, then node examples/link.ts jobs, which installs a copy and prints where it is.

Background jobs explains what this example leans on.

The test

tests/deterministic.danwo.ts
import { resendInbox } from "@danwo/sdk"

import {
  expectDigestsDelivered,
  OWNER,
  scheduleAndArrive,
  subscriberRow,
  TEAM,
  test,
  writeDigest,
} from "./digests.ts"

test("sends a scheduled digest to every subscriber once", async ({ app, screen, expect, time }) => {
  await writeDigest({ app, screen, expect }, { title: "Friday notes", subscribers: TEAM })
  await scheduleAndArrive({ screen, expect, time })

  await expect(screen.getByTestId("digest-status")).toHaveText("Sent")
  for (const person of TEAM) await expect(subscriberRow(screen, person.email)).toContainText("Sent")
  await expect(screen.getByTestId("charge")).toHaveText(/Batch 1: 3 emails, \$0\.06/)
}).invariant(expectDigestsDelivered)

test("retries a send whose answer was lost, and still sends one email", async ({
  app,
  screen,
  expect,
  time,
  proxy,
}) => {
  /* Resend takes the first email, but the step never hears back, so Inngest runs it again. */
  proxy.failNext((call) => call.host === "api.resend.com" && call.path === "/emails", {
    kind: "reset-after",
  })

  await writeDigest({ app, screen, expect }, { title: "Release notes", subscribers: TEAM })
  await scheduleAndArrive({ screen, expect, time })

  await expect(screen.getByTestId("digest-status")).toHaveText("Sent")
  await expect(screen.getByRole("row", { name: /Retried/ })).toContainText("Send one")
  await expect(screen.getByRole("row", { name: /Sent to .* 2 Completed/ })).toBeVisible()
}).invariant(expectDigestsDelivered)

test("bills a batch once when Stripe's answer is lost", async ({
  app,
  screen,
  expect,
  time,
  proxy,
}) => {
  /* Stripe makes the invoice item, but the step never hears back, so Inngest runs it again. */
  proxy.failNext((call) => call.host === "api.stripe.com" && call.path === "/v1/invoiceitems", {
    kind: "reset-after",
  })

  await writeDigest({ app, screen, expect }, { title: "Usage report", subscribers: TEAM })
  await scheduleAndArrive({ screen, expect, time })

  await expect(screen.getByTestId("charge")).toHaveCount(1)
  await expect(screen.getByTestId("charge")).toHaveText(/Batch 1: 3 emails, \$0\.06/)
}).invariant(expectDigestsDelivered)

test("a send that fails for good marks the copy failed and tells the owner", async ({
  app,
  screen,
  expect,
  time,
  resend,
}) => {
  /* The browser takes an address with no dot in its domain; Resend does not. */
  const typo = { name: "Kim", email: "kim@northwind" }

  await writeDigest(
    { app, screen, expect },
    { title: "Team update", subscribers: [...TEAM.slice(0, 1), typo] },
  )
  await scheduleAndArrive({ screen, expect, time })

  await expect(screen.getByTestId("digest-status")).toHaveText("Sent with failures")
  await expect(subscriberRow(screen, typo.email)).toContainText("Failed")
  await expect(screen.getByRole("row", { name: /Send one: on failure/ })).toContainText(
    `Told ${OWNER}`,
  )

  const [notice] = resendInbox(resend).list(OWNER)

  expect(notice?.subject).toBe('Couldn\'t deliver "Team update" to kim@northwind')
  expect(notice?.text).toContain("Invalid `to` field")
}).invariant(expectDigestsDelivered)

test("the digests page shows how each send went", async ({ app, screen, expect, time }) => {
  await writeDigest(
    { app, screen, expect },
    { title: "Monday plan", subscribers: TEAM.slice(0, 2) },
  )
  await scheduleAndArrive({ screen, expect, time })
  await writeDigest({ app, screen, expect }, { title: "Next week", subscribers: TEAM.slice(2) })
  await screen.getByRole("button", { name: "Schedule" }).click()
  await expect(screen.getByRole("status")).toContainText("Scheduled for")

  await app.open("/")
  await expect(screen.getByRole("row", { name: /Monday plan/ })).toContainText("Sent")
  await expect(screen.getByRole("row", { name: /Next week/ })).toContainText("Scheduled")
}).invariant(expectDigestsDelivered)

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI