32 β SQLite in TypeScript with better-sqlite3
Level: Intermediate Β· Time: ~40 min Β· Prerequisites: Lesson 31 β Databases and SQL Fundamentals
Why this matters
Lesson 31 typed SQL into a client. A program needs the same statements, constraints and transactions driven from TypeScript, with rows arriving as domain objects. SQLite is the smallest honest step.
What SQLite is, and what it is not
SQLite is a C library your process links against: no server, no port, no user account, just a file your program opens.
| SQLite | A PostgreSQL server | |
|---|---|---|
| Concurrency | Many readers, one writer at a time | Many concurrent writers |
| Types | Storage classes with column affinity; a TEXT column happily holds 123 |
Strict, rich types: jsonb, numeric, timestamptz, arrays |
The single-writer rule is the trade for zero administration; Lesson 33 shows what changes when the tracker becomes a service.
better-sqlite3 is synchronous on purpose
A SQLite read is a local file read measured in microseconds, so wrapping it in a promise buys nothing: the engine runs in-process, blocks deliberately, and statements are prepared once and reused.
// src/db/connection.ts
import Database from "better-sqlite3";
export type Db = InstanceType<typeof Database>;
export function openDatabase(path = "data/solar.db"): Db {
const db = new Database(path);
db.pragma("journal_mode = WAL"); // readers stop blocking the writer
db.pragma("foreign_keys = ON"); // off by default, and per connection
db.pragma("busy_timeout = 5000"); // wait for the writer instead of throwing SQLITE_BUSY
return db;
}
.run() returns changes and lastInsertRowid, .get() returns one row or undefined, and .all() returns an array.
Schema and a minimal migration mechanism
PRAGMA user_version is an integer kept in the file header: apply every migration numbered above it inside a transaction and raise it.
// src/db/migrate.ts
import type { Db } from "./connection.js";
const migrations: readonly string[] = [
`CREATE TABLE sites (site_id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE,
capacity_kwp REAL NOT NULL CHECK (capacity_kwp > 0));
CREATE TABLE daily_production (site_id INTEGER NOT NULL REFERENCES sites(site_id) ON DELETE CASCADE,
day TEXT NOT NULL, kwh REAL NOT NULL CHECK (kwh >= 0),
PRIMARY KEY (site_id, day));`,
];
export function migrate(db: Db): number {
let version = db.pragma("user_version", { simple: true }) as number;
for (const sql of migrations.slice(version)) {
db.transaction(() => {
db.exec(sql);
version += 1;
db.pragma(`user_version = ${version}`); // our own integer literal, never user input
})();
}
return version;
}
Adding a migration means appending to that array; nothing already applied is ever edited, so an old database and a fresh one end in the same state.
A typed repository: rows to domain objects
A prepared statement declares the shape of its rows, so the mapping from snake_case columns to camelCase fields happens once, in the repository.
// src/db/productionRepository.ts
import type { Db } from "./connection.js";
export interface DailyProduction { readonly siteId: number; readonly day: string; readonly kwh: number; }
export interface MonthlyTotal { readonly month: string; readonly kwh: number; readonly days: number; }
interface ProductionRow { readonly site_id: number; readonly day: string; readonly kwh: number; }
export class ProductionRepository {
constructor(private readonly db: Db) {}
saveMany(rows: readonly DailyProduction[]): number {
const statement = this.db.prepare<[number, string, number]>(
`INSERT INTO daily_production (site_id, day, kwh) VALUES (?, ?, ?)
ON CONFLICT (site_id, day) DO UPDATE SET kwh = excluded.kwh`,
);
const write = this.db.transaction((batch: readonly DailyProduction[]) => {
for (const row of batch) statement.run(row.siteId, row.day, row.kwh);
return batch.length;
});
return write(rows);
}
findBySite(siteId: number, from: string, to: string): DailyProduction[] {
return this.db.prepare<[number, string, string], ProductionRow>(
`SELECT site_id, day, kwh FROM daily_production WHERE site_id = ? AND day BETWEEN ? AND ? ORDER BY day`,
).all(siteId, from, to).map((row) => ({ siteId: row.site_id, day: row.day, kwh: row.kwh }));
}
monthlyTotals(siteId: number): MonthlyTotal[] {
return this.db.prepare<[number], MonthlyTotal>(
`SELECT substr(day, 1, 7) AS month, ROUND(SUM(kwh), 1) AS kwh, COUNT(*) AS days
FROM daily_production WHERE site_id = ? GROUP BY month ORDER BY month`,
).all(siteId);
}
}
ON CONFLICT β¦ DO UPDATE makes the import idempotent: re-running the same week updates values instead of
failing on the composite primary key.
Transactions, throughput, tests and the report
Batch inserts are the difference between a fast import and a slow one: every COMMIT is a durability write to
disk, so wrapping a batch in db.transaction() turns ten thousand inserts into well under a second. Tests need no reset step: :memory: creates a database that dies with the connection.
// test/productionRepository.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { openDatabase } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ProductionRepository, type DailyProduction } from "../src/db/productionRepository.js";
function fixtureRepository(): ProductionRepository {
const db = openDatabase(":memory:"); // a fresh database per test
migrate(db);
db.prepare("INSERT INTO sites (site_id, name, capacity_kwp) VALUES (?, ?, ?)").run(1, "Sokone", 12.5);
return new ProductionRepository(db);
}
const march: readonly DailyProduction[] = [
{ siteId: 1, day: "2026-03-01", kwh: 48.2 }, { siteId: 1, day: "2026-03-02", kwh: 51.7 }];
test("monthly totals sum the days that were written", () => {
const repository = fixtureRepository();
assert.equal(repository.saveMany(march), 2); // and a second import duplicates nothing
assert.deepEqual(repository.monthlyTotals(1), [{ month: "2026-03", kwh: 99.9, days: 2 }]);
});
The export is eleven lines: format each row as month,kwh,days, join with newlines, and write the file.
// src/report/monthlyReport.ts
import type { MonthlyTotal } from "../db/productionRepository.js";
export function toCsv(rows: readonly MonthlyTotal[]): string {
const body = rows.map((row) => `${row.month},${row.kwh.toFixed(1)},${row.days}`);
return ["month,kwh,days", ...body].join("\n") + "\n";
}
With that, the tracker's loop is closed: fetch the days the Lesson 30 boundary validated, saveMany them, then answer questions from the file.
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
Forgetting PRAGMA foreign_keys = ON |
REFERENCES is silently ignored and orphan rows appear |
Set it in the connection factory |
| Committing once per row in a loop | An import that should take a second takes a minute | Wrap the batch in db.transaction() |
| Preparing a statement inside a loop | The same SQL is compiled thousands of times | Prepare once, outside the loop |
| Treating column types as enforced | A TEXT column holds 123, and the compiler never sees a row |
Validate or coerce in the row mapping |
| Testing against the real database file | Tests depend on leftover rows and on each other | :memory: with a fresh repository per test |
Try it
- Install
better-sqlite3and@types/better-sqlite3, then prove the native module loaded:
node -e "const D=require('better-sqlite3');console.log(new D(':memory:').prepare('SELECT sqlite_version() AS v').get())"
Expected output: { v: '3.45.1' }. If the install tries to compile and fails, run
sudo apt install build-essential python3.
- Write the files above plus a
src/seed.tsinserting site 1 and 90 days of synthetic production, each value computed from the day of year so the run is repeatable, then runnpx tsc && node dist/src/seed.js.
Expected output: a line reporting 90 rows written and a data/solar.db of a few tens of kilobytes.
- Run
npx tsc && node --test dist/test/, then writereports/monthly.csvwithtoCsv.
Expected output: # pass 1 and # fail 0; the CSV holds a header plus one line per month of site 1.
Key takeaways
- SQLite is a library and a file, not a server: zero administration, one writer at a time, ideal for local tools and tests.
- better-sqlite3 is synchronous by design because a local file read is not worth a promise; prepare statements once and keep them short.
PRAGMA user_versionis enough of a migration mechanism for a local tool when each migration runs in a transaction.- Repositories map rows to domain objects in one place;
ON CONFLICT β¦ DO UPDATEmakes an import safe to repeat, anddb.transaction()with:memory:makes imports fast and tests independent.
Check yourself
- Why is a blocking, synchronous call right for a local file and wrong for a server across a network?
- Your first import wrote 90 rows and your second also wrote 90. How many rows are in the table, and which part of the SQL guarantees it?
- Why does
migrate()run each migration in a transaction, and what would a half-applied migration leave behind?