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.
Your mission
Section titled “Your mission”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.
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.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.[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.org_empty fails on the first attempt with no retries, via AbortTaskRunError, and spawns no paginate-page children.Coding time
Section titled “Coding time”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.
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.
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.
idempotencyKey: await idempotencyKeys.create([ organizationId, 'page', String(page),])Boring on purpose.
scope defaults to 'run', so the key is namespaced to ctx.run.id and the v4 SDK hashes the parts together for you.
The same parts always produce the same string, so a parent retry re-issues the identical key and gets a cache hit.
page is wrapped in String(page) because the parts array is string[]; a raw number is rejected.
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:
// The email send and the closing exports-row + audit transaction land next lesson. return { ok: true };The run vs global scope distinction and idempotencyKeys.create — exactly the per-page key this lesson hinges on.
How triggerAndWait checkpoints the parent and how .unwrap() returns the child's result or rethrows on failure.
Why AbortTaskRunError stops on the first attempt while a plain throw burns every retry — permanents abort, transients throw.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 3The 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:
✓ 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:
paginate-page child with its payload and output, parented under the export run.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.)org_empty. The body throws AbortTaskRunError; the dashboard shows one attempt, no retries, and no paginate-page children.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.