Skip to content
Chapter 67Lesson 3

One checkpoint per page

Last lesson, clicking Export fired a validated run that landed on the per-org queue and completed, but its body did nothing: it set pagesDone to 0 and returned. Now you turn that placeholder into a real paginated export where every page of invoices is its own durable child run.

Run it against a seeded org and the progress bar fills as the Trigger.dev dashboard grows one paginate-page child per page under the export. The seed gives each org 200–240 rows and a page holds 500, so most exports are a single page, but the count, loop, and metadata writes all run regardless. An empty org aborts immediately with no children. The single idea behind all of this: every triggerAndWait is a checkpoint, so on a parent retry each completed page comes back from cache on its run-scoped key instead of re-running.

Instead of one long body that walks every row, each page of invoices is spawned as its own paginatePage child run through triggerAndWait, and the parent loops the pages, accumulates the CSV, and streams progress to the inspector. Splitting the work this way is the idea this lesson exists to install: every triggerAndWait is a checkpoint. A multi-page export has one checkpoint per page, so a worker killed between pages three and four resumes at page four — the retried parent re-issues the same per-page idempotency keys, and the runtime returns the cached results for pages that already finished instead of re-running them. The seed gives each org a single page, so multi-page resume is the reasoned story; the proof you can run today is cached-on-retry of that one page’s key.

The page key is idempotencyKeys.create([organizationId, 'page', String(page)]), scoped to the run so the cache is per-export and a retry reproduces it exactly. Each page reads through listInvoices({ orgId, view: 'active', cursor, pageSize: 500 }), the cursor pagination from earlier in the course: the cursor is a stable restart point, so a row inserted between two pages is still covered rather than shifting the window. Progress flows through run.metadata, written by the parent because its view is the one the user sees: pagesTotal once after counting, pagesDone after each page.

An empty resultset is a permanent failure — the same inputs will never produce rows — so on total === 0 the body throws AbortTaskRunError, which stops on the first attempt instead of burning all three retries. The rule worth memorizing: permanents abort, transients throw. The loop is sequential, a for with an await inside, because parallelizing the pages would race the queue’s concurrency limit and reorder the rows.

Two things stay out of scope. The CSV accumulates in memory, fine for the seed’s per-org row cap but not for an org with 100k+ invoices, which the next chapter streams to object storage instead. And the email step stays empty: the body ends by logging the CSV size and storing a placeholder downloadUrl, both of which the next lesson picks up.

Clicking Export against a seeded org drives the progress bar to full, and the dashboard shows one paginate-page child run per page (one for the single-page seed orgs), each parented under the export run with its own payload and output.
untested
pagesTotal is set once from the count and pagesDone is incremented per page, so the bar reflects real per-page advancement rather than a fixed or fabricated value.
tested
The page key derives from [organizationId, 'page', String(page)], so a parent retry re-issues the same key and the runtime returns the completed page’s cached result instead of re-executing it; the run reaches completed with the same runId.
tested
The child reads a single page via cursor pagination, returns the CSV fragment for those rows, and hands back the cursor for the next page.
tested
Exporting org_empty fails on the first attempt with no retries, via AbortTaskRunError, and spawns no paginate-page children.
tested
One page is awaited before the next starts, so the accumulated CSV keeps the rows in order.
untested

Implement trigger/paginate-page.ts and grow the exportInvoices body against the brief, the reference signatures from the project overview, and the tests. Try it before opening the solution below.

Reference solution and walkthrough

Start with the child, since the parent calls it. paginatePage is a schemaTask that reads one page and returns its CSV fragment plus the cursor for the next page.

trigger/paginate-page.ts
import { schemaTask } from '@trigger.dev/sdk/v3';
import { z } from 'zod';
import { listInvoices } from '@/db/queries/invoices';
import { rowsToCsv } from '@/lib/exports/to-csv';
export const paginatePage = schemaTask({
id: 'paginate-page',
schema: z.strictObject({
organizationId: z.string().min(1),
page: z.int().nonnegative(),
cursor: z.string().nullable(),
}),
run: async ({ organizationId, cursor }) => {
const { rows, nextCursor } = await listInvoices({
orgId: organizationId,
view: 'active',
cursor,
pageSize: 500,
});
return { csv: rowsToCsv(rows), nextCursor, rowCount: rows.length };
},
});

The payload is a strict object: organizationId is z.string().min(1) for the same base62-id reason as the parent, page is a non-negative int, and cursor is z.string().nullable() — null for page zero, the previous page’s cursor after that. The body destructures only what it reads; page rides along for the parent’s key and the dashboard payload record. listInvoices re-derives tenancy through tenantDb internally, so the child never needs request context, and rowsToCsv projects the rows into an RFC-4180 fragment. The child returns nextCursor so the parent can advance, and rowCount so it can see how much each page produced.

Now grow the parent body. Several parts are easy to get subtly wrong — the abort guard, the key, the two metadata writes, the cursor advance — so step through it part by part.

const PAGE_SIZE = 500;
// ...inside run: async ({ organizationId, requestedBy }, { ctx }) => {
const total = await countInvoices({ orgId: organizationId });
if (total === 0) {
throw new AbortTaskRunError(
new ExportError('EMPTY_RESULTSET', 'no invoices to export').message,
);
}
const pagesTotal = Math.ceil(total / PAGE_SIZE);
metadata.set('pagesTotal', pagesTotal);
let csv = '';
let cursor: string | null = null;
for (let page = 0; page < pagesTotal; page++) {
const result = await paginatePage
.triggerAndWait(
{ organizationId, page, cursor },
{
idempotencyKey: await idempotencyKeys.create([
organizationId,
'page',
String(page),
]),
},
)
.unwrap();
csv += result.csv;
cursor = result.nextCursor;
metadata.set('pagesDone', page + 1);
}
console.log('export-invoices csv built', { bytes: csv.length });
// The placeholder download URL — the next chapter wires the real R2 link.
const downloadUrl = `https://example.com/exports/${ctx.run.id}.csv`;
metadata.set('downloadUrl', downloadUrl);

PAGE_SIZE is a module-level constant, and countInvoices runs first. Counting up front gives the progress bar its denominator and catches the empty case before any work is spent.

const PAGE_SIZE = 500;
// ...inside run: async ({ organizationId, requestedBy }, { ctx }) => {
const total = await countInvoices({ orgId: organizationId });
if (total === 0) {
throw new AbortTaskRunError(
new ExportError('EMPTY_RESULTSET', 'no invoices to export').message,
);
}
const pagesTotal = Math.ceil(total / PAGE_SIZE);
metadata.set('pagesTotal', pagesTotal);
let csv = '';
let cursor: string | null = null;
for (let page = 0; page < pagesTotal; page++) {
const result = await paginatePage
.triggerAndWait(
{ organizationId, page, cursor },
{
idempotencyKey: await idempotencyKeys.create([
organizationId,
'page',
String(page),
]),
},
)
.unwrap();
csv += result.csv;
cursor = result.nextCursor;
metadata.set('pagesDone', page + 1);
}
console.log('export-invoices csv built', { bytes: csv.length });
// The placeholder download URL — the next chapter wires the real R2 link.
const downloadUrl = `https://example.com/exports/${ctx.run.id}.csv`;
metadata.set('downloadUrl', downloadUrl);

The empty-resultset guard. total === 0 is permanent — the same inputs will never produce rows — so it throws AbortTaskRunError, which stops on the first attempt. A plain throw is treated as transient and burns all three retries first. Permanents abort, transients throw.

const PAGE_SIZE = 500;
// ...inside run: async ({ organizationId, requestedBy }, { ctx }) => {
const total = await countInvoices({ orgId: organizationId });
if (total === 0) {
throw new AbortTaskRunError(
new ExportError('EMPTY_RESULTSET', 'no invoices to export').message,
);
}
const pagesTotal = Math.ceil(total / PAGE_SIZE);
metadata.set('pagesTotal', pagesTotal);
let csv = '';
let cursor: string | null = null;
for (let page = 0; page < pagesTotal; page++) {
const result = await paginatePage
.triggerAndWait(
{ organizationId, page, cursor },
{
idempotencyKey: await idempotencyKeys.create([
organizationId,
'page',
String(page),
]),
},
)
.unwrap();
csv += result.csv;
cursor = result.nextCursor;
metadata.set('pagesDone', page + 1);
}
console.log('export-invoices csv built', { bytes: csv.length });
// The placeholder download URL — the next chapter wires the real R2 link.
const downloadUrl = `https://example.com/exports/${ctx.run.id}.csv`;
metadata.set('downloadUrl', downloadUrl);

Math.ceil rounds up so a partial last page still gets its own iteration. metadata.set('pagesTotal', ...) writes the total once, before the loop; metadata is the module-level @trigger.dev/sdk import, not a field on the run’s second argument.

const PAGE_SIZE = 500;
// ...inside run: async ({ organizationId, requestedBy }, { ctx }) => {
const total = await countInvoices({ orgId: organizationId });
if (total === 0) {
throw new AbortTaskRunError(
new ExportError('EMPTY_RESULTSET', 'no invoices to export').message,
);
}
const pagesTotal = Math.ceil(total / PAGE_SIZE);
metadata.set('pagesTotal', pagesTotal);
let csv = '';
let cursor: string | null = null;
for (let page = 0; page < pagesTotal; page++) {
const result = await paginatePage
.triggerAndWait(
{ organizationId, page, cursor },
{
idempotencyKey: await idempotencyKeys.create([
organizationId,
'page',
String(page),
]),
},
)
.unwrap();
csv += result.csv;
cursor = result.nextCursor;
metadata.set('pagesDone', page + 1);
}
console.log('export-invoices csv built', { bytes: csv.length });
// The placeholder download URL — the next chapter wires the real R2 link.
const downloadUrl = `https://example.com/exports/${ctx.run.id}.csv`;
metadata.set('downloadUrl', downloadUrl);

The sequential for loop. Each page is its own paginatePage.triggerAndWait(...).unwrap() child run, and every triggerAndWait is a checkpoint. The key idempotencyKeys.create([organizationId, 'page', String(page)]) defaults to scope: 'run', so on a parent retry the same key returns the completed page from cache instead of re-running it.

const PAGE_SIZE = 500;
// ...inside run: async ({ organizationId, requestedBy }, { ctx }) => {
const total = await countInvoices({ orgId: organizationId });
if (total === 0) {
throw new AbortTaskRunError(
new ExportError('EMPTY_RESULTSET', 'no invoices to export').message,
);
}
const pagesTotal = Math.ceil(total / PAGE_SIZE);
metadata.set('pagesTotal', pagesTotal);
let csv = '';
let cursor: string | null = null;
for (let page = 0; page < pagesTotal; page++) {
const result = await paginatePage
.triggerAndWait(
{ organizationId, page, cursor },
{
idempotencyKey: await idempotencyKeys.create([
organizationId,
'page',
String(page),
]),
},
)
.unwrap();
csv += result.csv;
cursor = result.nextCursor;
metadata.set('pagesDone', page + 1);
}
console.log('export-invoices csv built', { bytes: csv.length });
// The placeholder download URL — the next chapter wires the real R2 link.
const downloadUrl = `https://example.com/exports/${ctx.run.id}.csv`;
metadata.set('downloadUrl', downloadUrl);

Accumulate this page’s CSV fragment, then advance cursor to the child’s nextCursor. The cursor is the natural restart point and stays stable across writes, so a row inserted mid-export is still covered rather than shifting the window.

const PAGE_SIZE = 500;
// ...inside run: async ({ organizationId, requestedBy }, { ctx }) => {
const total = await countInvoices({ orgId: organizationId });
if (total === 0) {
throw new AbortTaskRunError(
new ExportError('EMPTY_RESULTSET', 'no invoices to export').message,
);
}
const pagesTotal = Math.ceil(total / PAGE_SIZE);
metadata.set('pagesTotal', pagesTotal);
let csv = '';
let cursor: string | null = null;
for (let page = 0; page < pagesTotal; page++) {
const result = await paginatePage
.triggerAndWait(
{ organizationId, page, cursor },
{
idempotencyKey: await idempotencyKeys.create([
organizationId,
'page',
String(page),
]),
},
)
.unwrap();
csv += result.csv;
cursor = result.nextCursor;
metadata.set('pagesDone', page + 1);
}
console.log('export-invoices csv built', { bytes: csv.length });
// The placeholder download URL — the next chapter wires the real R2 link.
const downloadUrl = `https://example.com/exports/${ctx.run.id}.csv`;
metadata.set('downloadUrl', downloadUrl);

pagesDone is written from the parent, once per page, because the parent is the run the inspector polls. Omit this one line and the export completes, every page succeeds, and the bar sits frozen at zero.

const PAGE_SIZE = 500;
// ...inside run: async ({ organizationId, requestedBy }, { ctx }) => {
const total = await countInvoices({ orgId: organizationId });
if (total === 0) {
throw new AbortTaskRunError(
new ExportError('EMPTY_RESULTSET', 'no invoices to export').message,
);
}
const pagesTotal = Math.ceil(total / PAGE_SIZE);
metadata.set('pagesTotal', pagesTotal);
let csv = '';
let cursor: string | null = null;
for (let page = 0; page < pagesTotal; page++) {
const result = await paginatePage
.triggerAndWait(
{ organizationId, page, cursor },
{
idempotencyKey: await idempotencyKeys.create([
organizationId,
'page',
String(page),
]),
},
)
.unwrap();
csv += result.csv;
cursor = result.nextCursor;
metadata.set('pagesDone', page + 1);
}
console.log('export-invoices csv built', { bytes: csv.length });
// The placeholder download URL — the next chapter wires the real R2 link.
const downloadUrl = `https://example.com/exports/${ctx.run.id}.csv`;
metadata.set('downloadUrl', downloadUrl);

The hand-off point. The body logs the CSV size and stores a placeholder downloadUrl; the next lesson wires the email step and the real download link in its place.

1 / 1

Three details the steps gloss over are worth a closer look.

The first is the key, since it is what makes resume work, and one option silently destroys it. The two broken shapes are worth seeing beside the correct one.

// scope: 'global' collides across runs — every export reuses the first run's page result.
idempotencyKey: await idempotencyKeys.create(
[organizationId, 'page', String(page)],
{ scope: 'global' },
)
// ...or folding Date.now() in changes the key every attempt, so a retry re-runs everything.
idempotencyKey: await idempotencyKeys.create([
organizationId,
'page',
String(page),
String(Date.now()),
])

Both defeat the cache. scope: 'global' namespaces the key app-wide instead of per-run, so every export’s page zero collides on one key and gets back the first run’s result. Folding Date.now() in changes the key on every attempt, so a retry finds nothing cached and re-runs every page.

The second is .unwrap(). triggerAndWait returns a wrapper that distinguishes a completed child from a failed one; .unwrap() hands you the return value directly and rethrows if the child failed, so a failed page propagates and the parent’s retry policy handles it.

The third is the abort message. AbortTaskRunError wraps a string, so the body passes new ExportError('EMPTY_RESULTSET', ...).message — the message off a fresh instance, not the instance itself. ExportError keeps the export error codes in one open union; here it is only the source of a consistent message.

One thing stays out of scope: the CSV accumulates in memory across pages. That is fine for the seed because the per-org row cap keeps it to a few megabytes, but a six-figure-row org would stream each page straight into an object-storage multipart upload from inside the child rather than hold the whole file in memory — the next chapter builds exactly that. The scope semantics, the abort-versus-throw rule, and the metadata channel were all covered in the durable-execution chapter; reach back there if any feel thin.

The body ends, for this lesson, at the placeholder downloadUrl. The email child and the closing transaction are the next lesson’s work, so for now the body returns the same placeholder-shaped value as before:

trigger/export-invoices.ts
// The email send and the closing exports-row + audit transaction land next lesson.
return { ok: true };

Run the lesson’s test suite:

pnpm test:lesson 3
pnpm test:lesson 3

The gates run your task bodies in process: they intercept schemaTask to call each one directly with a recording metadata store and a fake paginatePage.triggerAndWait that emulates the platform’s idempotency dedup. Expect every test green:

pnpm test:lesson 3
Requirement 2 metadata drives real per-page progress (2)
Requirement 3 run-scoped per-page key returns cached on retry (3)
Requirement 5 empty org aborts without retries and spawns no children (2)
Requirement 4 the page child emits a single-page CSV fragment (2)
Test Files 1 passed (1)
Tests 9 passed (9)

The gates cover the body logic, but the live worker and dashboard behaviors need a manual check:

Click Export for a seeded org (one page at limit 500). The progress bar advances to full, and the Trigger.dev dashboard shows the paginate-page child with its payload and output, parented under the export run.
untested
Complete an export, then replay the parent run from the dashboard. The completed page’s child returns cached rather than re-executing on its run-scoped key, and the run reaches completed with the same runId. (The kill-the-worker-mid-page variant needs a multi-page org the seed does not ship — reason it through against the single page.)
untested
Trigger an export against org_empty. The body throws AbortTaskRunError; the dashboard shows one attempt, no retries, and no paginate-page children.
untested
Confirm one child’s idempotencyKey comes from idempotencyKeys.create([organizationId, 'page', 0]) (run-scoped to the parent), force a parent retry, and confirm the same key returns the cached output instead of re-executing.
untested