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.