Twenty thousand rows exist and none of them have been on screen. This lesson puts ten of them there, adds sortable columns, and pages through the rest — while keeping the amount of data crossing the worker boundary fixed at exactly one page, no matter what is selected. Along the way, one parameter refuses to be bound and one column type arrives in a shape you did not ask for. Checkpoint: engine-06.

Let the database do the paging

There are two places to paginate, and only one of them scales. You can fetch every matching row and slice it in JavaScript, or you can ask for the slice:

const rows = await runPrepared(
    `SELECT date, item, amount FROM sales WHERE ${filterClause(fruits)}
     ORDER BY ${orderBy} ${direction}, date ASC LIMIT ? OFFSET ?`,
    [startISO, endISO, ...fruits, limit, offset],
);

LIMIT and OFFSET bind like any other value, so a page of ten costs ten rows across the boundary whether the filter matches twenty thousand or two hundred. The engine sorts and slices where the data already is.

The trailing , date ASC is a tiebreaker, and it is doing quiet work. Sorting by item leaves thousands of rows tied, and SQL makes no promise about the order of tied rows — page two could contain a row you already saw on page one, because nothing forces a stable order between the two queries. A tiebreaker on a column with many distinct values makes pagination deterministic. Skip it and you get a bug that only appears when a user pages through a sorted view, which is to say the bug appears in front of a user and never in front of you.

The parameter you cannot bind

The obvious next move is to bind the sort column too, and it does not work:

ORDER BY ? ASC        -- sorts every row by the constant string 'date'

A prepared statement is planned before its values arrive, and the planner has to know which column it is ordering by to make a plan at all. A bound value is data; a column name is structure. Passing 'date' there gives the planner a string constant, identical for every row, and a sort that does nothing.

So the column name must be part of the SQL text — which means it must never be the raw string the client sent. A lookup table is the whole defence:

// Sort columns are interpolated into SQL, so they pass through a whitelist —
// never the raw client string.
const SORT_COLUMNS: Record<SortColumn, string> = { date: 'date', item: 'item', amount: 'amount' };

const orderBy = SORT_COLUMNS[sortColumn] ?? 'date';
const direction = sortDirection === 'desc' ? 'DESC' : 'ASC';

The type SortColumn = 'date' | 'item' | 'amount' makes wrong values a compile error, and the ?? 'date' makes them harmless at runtime too, because types are erased at the boundary — a hand-written postMessage or a JSON.parse can put anything in that slot. Direction gets the same treatment as an explicit ternary rather than a passthrough: the only two strings that can reach the SQL are ones this file contains.

The rule generalizes. Values bind; identifiers get whitelisted. Every “dynamic SQL” injection you have read about is someone discovering the first half and improvising the second.

Arrow hands back what it wants

Query results arrive as Apache Arrow record batches — a columnar format, which is why a columnar engine is fast at handing them over. Converting to JavaScript is where the surprises live:

// Arrow hands DATE columns back as Date objects or epoch numbers depending on
// the bundle; normalize to an ISO date string at the boundary.
function toISODate(value: unknown): string {
    if (typeof value === 'string') return value;
    const date = value instanceof Date ? value : new Date(Number(value));
    return date.toISOString().split('T')[0];
}

A DATE column does not come back as '2025-03-14'. Depending on the bundle and version it is a Date, or a number of days since the epoch, or occasionally a string. Writing the code that works on your machine and shipping it means shipping a bug that appears on someone else’s browser — the mvp bundle you never test on, because your laptop always gets eh.

Normalizing at the boundary means the rest of the application handles one type:

return rows.map((r): SalesRow => [toISODate(r.date), String(r.item), Number(r.amount)]);

SalesRow is a labelled tuple, [date: string, item: string, amount: number]. A tuple rather than an object because ten thousand of these cross a worker boundary in the dashboard course, and three array slots clone faster than three keyed properties — with the labels keeping it readable at the call site.

String() and Number() around the other two are the same defensive move as toISODate: the values are typed on paper, and the paper is on the far side of a message channel.

Build it

File Action What goes in it
src/types.ts modify SalesRow, SortColumn, SortDirection; add queryPaginated to DbWorkerApi
src/db-worker.ts modify toISODate, SORT_COLUMNS, queryPaginated with LIMIT/OFFSET and the tiebreaker; expose it
index.html modify A table with three scope="col" headers, each wrapping a data-sort button, plus prev/next controls and a page-info element
src/index.ts modify Local page/sort state, a renderPage() that queries and fills the tbody, and handlers for the pager and sort buttons
src/style.css modify Plain table, button and pager styles

Done when: the table shows ten rows, the page indicator reads Page 1 of 2,000 against 20,000 rows, paging forward and back works, and sorting by amount puts 5 at the top ascending and 45 at the top descending — the exact bounds randInt(5, 45) seeded.

Answer key: engine-05...engine-06.

Challenge: delete the tiebreaker

Remove , date ASC from the ORDER BY, sort by item, and page through the first several pages while writing down the rows you see. With eight items and thousands of ties, repeats and omissions across page boundaries are likely. Then put it back. Non-deterministic pagination is a genuinely hard bug to diagnose from a report that says only “some rows are missing sometimes”.

Next lesson: aggregates, and a window function that looks like a typo.