An empty database is not interesting. This lesson gives it a schema and fills it with twenty thousand sales records, and the page reports how long that took — somewhere in the low hundreds of milliseconds. Getting there involves one decision that would be indefensible in a slightly different context, so we are going to be precise about which context that is. Checkpoint: engine-03.

Constants before code

Put the shape of the dataset in one file, src/config.ts, so the seeder and every filter control later agree without being told twice:

export const ITEMS = [
    "Apples", "Bananas", "Cherries", "Dates", "Elderberries", "Figs", "Grapes", "Honeydew",
];

export const DATE_MIN = '2025-01-01';
export const DATE_MAX = '2025-06-30';

export const SEED_ROWS = 20000;

Eight items over roughly six months. Those numbers appear later in assertions you will make by hand — twenty thousand rows across a hundred and eighty days is about a hundred and eleven rows a day, and filtering to two items should leave about a quarter of the total. Being able to predict a result before you run it is how you catch a query that is subtly wrong rather than obviously broken.

A schema that survives being run twice

Add one statement to init(), immediately after connecting:

await conn.query(`CREATE TABLE IF NOT EXISTS sales (date DATE, item VARCHAR, amount INT);`);

Three columns, three types, and IF NOT EXISTS. That last part is not defensive padding — from the next lesson the database is a file that outlives the page, and init() runs on every load. Idempotent startup is what lets the same code path serve a first visit and a fiftieth.

DATE is a real date type, not a string, which is what makes BETWEEN and strftime work later. It is also what will hand you Arrow values in a shape you do not expect, in lesson 6.

One statement, twenty thousand tuples

Here is the seeder, and the line worth arguing about:

async function seedRandom(items: string[], startISO: string, endISO: string, rows: number): Promise<SeedMetrics> {
    const t0 = performance.now();
    const values: string[] = [];
    for (let i = 0; i < rows; i++) {
        const item = items[randInt(0, items.length - 1)];
        const amount = randInt(5, 45);
        const d = randDateISO(startISO, endISO);
        values.push(`('${d}','${item}',${amount})`);
    }
    if (values.length) {
        await conn.query(`INSERT INTO sales VALUES ${values.join(",")};`);
    }
    const seedMs = performance.now() - t0;
    return { insertedRows: values.length, seedMs };
}

That builds one INSERT statement with twenty thousand value tuples in it, by string concatenation, and sends it once.

The alternative is twenty thousand separate statements. Each one is a message into the worker, a parse, a plan, and a message back — overhead that dwarfs the three values it carries. One statement pays that cost once. The difference is not marginal; it is the difference between a page that is usable immediately and one that is not.

Now the part that matters. Every value in that string was generated three lines above it. The dates come from randDateISO, the items from an array of literals in your own source, the amounts from randInt. None of it came from a user, a URL, a file, or a network response.

The moment any of it did, this code would be a SQL injection vulnerability of the most ordinary kind, and the fix would not be escaping — it would be prepared statements, which is exactly what every query path from lesson 5 onward uses. This is the only place in the entire codebase where a value is interpolated into SQL, and it is the only place where the provenance of every value is visible in the same function.

Bulk-loading real data is a different problem with a different answer: DuckDB can ingest Arrow tables directly, and for untrusted input at this scale that is where you would go. Here, generated fixtures, one statement, and a comment explaining the boundary is the honest engineering.

What the page reports

Add seedRandom to the contract, expose it, and call it after init():

log(`Seeding ${SEED_ROWS.toLocaleString()} rows…`);
const seed = await db.seedRandom(ITEMS, DATE_MIN, DATE_MAX, SEED_ROWS);
log(`Inserted ${seed.insertedRows.toLocaleString()} rows in ${seed.seedMs.toFixed(0)} ms.`);

SeedMetrics is { insertedRows, seedMs } — two numbers, structured-cloneable, crossing the boundary without complaint. Reload and you will see the seed run again, because the database still lives in memory and dies with the page. That is the gap the next lesson closes.

Build it

File Action What goes in it
src/config.ts write ITEMS, DATE_MIN, DATE_MAX, SEED_ROWS
src/types.ts modify Add SeedMetrics; add seedRandom to DbWorkerApi
src/db-worker.ts modify CREATE TABLE IF NOT EXISTS in init(); randInt, randDateISO, and seedRandom; expose it
src/index.ts modify Seed after init and report inserted rows and elapsed milliseconds

Done when: the page reports Inserted 20,000 rows with a seed time in milliseconds, and reloading runs the seed again from scratch.

Answer key: engine-02...engine-03.

Challenge: measure what one statement bought you

Write a second seeder that inserts the same rows one statement at a time, run it with SEED_ROWS lowered to 500 so you are not waiting all afternoon, and compare the milliseconds per row against the bulk path at the same size. Then work out what the slow version would have cost at twenty thousand. Keep the number — lesson 9 gives you EXPLAIN ANALYZE, and it is more interesting once you have already been surprised by a measurement.

Next lesson: the database becomes a file, and the second visit stops paying for the first one.