● David R. Longnecker
Chapter 10 · Backend

Embedded Databases

Your route handlers need somewhere durable to keep data — todos, notes, whatever your app is about — that survives a server restart. This chapter covers relational database basics, why SQLite is a great fit for a project like this, and how to evolve its schema safely over time.

Tables, rows, and columns #

A relational database organizes data into tables. Each table has a fixed set of columns (with types), and each row is one record. A small notes app might have two tables:

notestype
idINTEGER, primary key
titleTEXT
bodyTEXT
tag_idINTEGER, foreign key → tags.id

Every row needs a way to be referenced uniquely — that's the primary key, usually an auto-incrementing id. When one table needs to point at a row in another table — a note belonging to a tag — it stores that row's primary key in a column called a foreign key. That's the entire relationship: no duplication of the tag's name in every note, just a number pointing at where the real data lives.

A notes table with a tag_id column pointing at the primary key of a tags table notes id (PK) title body tag_id (FK) tags id (PK) name
A foreign key is just an integer that references a row in another table — the relationship lives in the data, not in a separate structure.

SQL basics #

SQL is the language you use to read and write rows. Three statements cover most of what a typical app needs:

reading, creating, and updating rows
-- read all notes with a given tag
SELECT * FROM notes WHERE tag_id = 3;

-- create a new note
INSERT INTO notes (title, body, tag_id) VALUES ('Groceries', 'Milk, eggs', 3);

-- update one note by id
UPDATE notes SET body = 'Milk, eggs, bread' WHERE id = 17;

Notice the WHERE clause on the update. Without it, that statement would overwrite the body column on every row in the table — one of the most common and most destructive SQL mistakes. Always know what a statement's WHERE clause is limiting before you run it.

Prepared statements, and why string concatenation is dangerous #

It's tempting to build a query by gluing a variable straight into the SQL string:

don't do this
const title = req.body.title;
db.exec(`INSERT INTO notes (title) VALUES ('${title}')`);

If title is ever something an untrusted user typed — and in a web app, it always is — this is dangerous. A value like '); DROP TABLE notes; -- would close the intended string early and splice in a second statement that the database would happily run. This class of bug is called SQL injection: not because of any exotic technique, but simply because the database can't tell the difference between "data" and "code" once they've been concatenated into the same string.

The fix is a prepared statement: you write the SQL with ? placeholders, and pass the actual values separately. The database library sends them as data, never as SQL text, so there's nothing for an attacker to break out of.

the same insert, done safely
const insert = db.prepare('INSERT INTO notes (title) VALUES (?)');
insert.run(req.body.title);
Danger

Treat every value that ultimately came from a request — a form field, a query parameter, a JSON body — as untrusted. Prepared statements aren't an optional hardening step; they're the default way you should write every query that includes a variable, full stop.

Embedded databases and WAL mode #

Some databases (Postgres, MySQL) run as their own long-lived process that your server connects to over the network. SQLite takes a different approach: there's no separate server, no network hop, no process to install and keep running. The entire database is one file on disk, and your application process reads and writes it directly through a library like better-sqlite3. For a single-server app, that's simpler to run and reason about. Backing it up is simple too, though not quite as simple as "copy the file" once WAL mode is on, below — reads and writes are also synchronous and run on the same thread as the rest of your code, a trade-off worth knowing about under heavy load.

The one thing you have to think about with an embedded database is concurrent access: what happens when a background job is writing while a request handler is reading? SQLite's answer is WAL mode (write-ahead logging, see SQLite's WAL documentation). Writes go to a separate log file first and get merged into the main database later, which means readers never block on a writer and generally see a consistent snapshot. You turn it on once, right after opening the database:

enabling WAL mode
import Database from 'better-sqlite3';

const db = new Database('app.db');
db.pragma('journal_mode = WAL');

A pragma is SQLite's mechanism for configuring the connection itself, as opposed to reading or writing data.

Tip

SQLite still only allows one writer at a time — WAL mode doesn't change that. What it buys you is that reads don't have to wait for that writer, which is the access pattern most apps actually have: frequent reads, occasional writes.

Copying the file is no longer enough

With WAL mode on, recent writes can sit in a separate -wal file rather than the main database file until SQLite checkpoints them. Copying only the main file mid-write can capture a database missing its most recent transactions. Use better-sqlite3's own db.backup() method, which handles this correctly, or force a checkpoint (db.pragma('wal_checkpoint(TRUNCATE)')) immediately before copying the file by hand.

Evolving the schema: numbered migrations #

Your schema will change as the app grows — a new column, a new table, a new index. You can't just edit the CREATE TABLE statement and rerun it against a database that already has data in it. The standard pattern is a migration: a small function that applies exactly one change, numbered in the order it should run, tracked in a table so the database itself remembers which migrations it has already seen.

a couple of migrations
const migrations = [
  {
    id: 1,
    up: (db) => {
      db.exec(`CREATE TABLE notes (
        id INTEGER PRIMARY KEY,
        title TEXT NOT NULL,
        body TEXT
      )`);
    },
  },
  {
    id: 2,
    up: (db) => {
      db.exec('CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL)');
      db.exec('ALTER TABLE notes ADD COLUMN tag_id INTEGER REFERENCES tags(id)');
    },
  },
];

Every statement above uses db.exec(), the same method flagged earlier as unsafe for building queries out of user input. It's safe here for a different reason than prepared statements are safe: there is no user input involved at all — every string is a fixed literal you wrote, checked into source control. The danger was never db.exec() itself, only using it with data that didn't come from your own code.

That REFERENCES tags(id) in migration 2 declares a foreign key, but SQLite doesn't actually enforce foreign keys unless you turn that behavior on — the constraint above is currently decorative:

db.pragma('foreign_keys = ON');

Run that once per connection, typically right after opening the database. Without it, deleting a row from tags that a note still references silently leaves a dangling tag_id behind instead of being blocked or cascading, and the diagram's promised relationship isn't actually guaranteed by anything.

running only the migrations a database hasn't seen yet
function migrate(db) {
  db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
    id INTEGER PRIMARY KEY
  )`);

  const applied = new Set(
    db.prepare('SELECT id FROM schema_migrations').all().map((row) => row.id)
  );

  for (const migration of migrations) {
    if (applied.has(migration.id)) continue;
    const runMigration = db.transaction(() => {
      migration.up(db);
      db.prepare('INSERT INTO schema_migrations (id) VALUES (?)').run(migration.id);
    });
    runMigration();
  }
}

db.transaction() wraps the schema change and the bookkeeping insert together: if either one throws partway through, better-sqlite3 rolls back everything the transaction did, so a failed migration never leaves the schema half-changed with no record of what happened. Without it, a crash between the CREATE TABLE and the INSERT INTO schema_migrations line leaves a database that's already been changed but doesn't know it — the next run tries the same migration again and fails on a table that already exists.

Three migrations applied in order, each recorded in a schema_migrations table 001 create notes 002 create tags, add tag_id 003 add notes.archived schema_migrations: { 1, 2, 3 }
Each migration runs exactly once, in order, and records its id so a restart never re-applies it.

This is deliberately lower-tech than a full ORM migration tool. It's a list, a loop, and a table — but it gives you the property that actually matters: every environment (your laptop, a teammate's laptop, production) converges on the same schema by running the same numbered steps in the same order, no matter when it last started up.

Watch out

Never edit a migration that has already shipped. If migration 2 is wrong, write migration 3 that corrects it. Editing history works fine on a database that only ever existed on your machine; it breaks the moment two databases have already applied the old version of migration 2.

With storage and schema evolution covered, the next chapter tackles a different problem: how do you get an update from the server to the browser without the browser having to ask for it first?