Schema Design and Migrations
Schema design is deciding what tables to create, what columns they have, and what rules apply to the data. A well-designed schema prevents bad data and makes queries efficient.
CREATE TABLE
Section titled “CREATE TABLE”CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')));Anatomy of this statement:
INTEGER PRIMARY KEY AUTOINCREMENT— auto-incrementing unique IDTEXT NOT NULL— required string fieldUNIQUE— no two rows can have the same valueDEFAULT (datetime('now'))— automatically set to current time if not provided
SQLite data types
Section titled “SQLite data types”SQLite uses flexible typing. The practical types:
| Use case | SQLite type |
|---|---|
| Integer numbers | INTEGER |
| Decimal numbers | REAL |
| Text strings | TEXT |
| Binary data | BLOB |
| Booleans | INTEGER (0 or 1) |
| Dates/times | TEXT (ISO 8601: '2024-01-15T10:30:00Z') |
The Bulletin schema
Section titled “The Bulletin schema”-- usersCREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')));
-- postsCREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, upvotes INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')));
-- commentsCREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (datetime('now')));
-- upvote tracking (prevents duplicate upvotes)CREATE TABLE IF NOT EXISTS post_upvotes ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, PRIMARY KEY (user_id, post_id) -- composite PK prevents duplicates);Key design decisions:
REFERENCES users(id) ON DELETE CASCADE— when a user is deleted, their posts/comments are toopost_upvoteshas a composite primary key(user_id, post_id)— prevents the same user upvoting the same post twiceIF NOT EXISTS— safe to run the schema file multiple times
A simple migration system
Section titled “A simple migration system”A migration applies schema changes once and records that it ran:
import { db } from './index.js'import { readFileSync } from 'fs'import { join } from 'path'
// Create migrations tracking tabledb.exec(` CREATE TABLE IF NOT EXISTS migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, ran_at TEXT NOT NULL DEFAULT (datetime('now')) )`)
function migrate(name: string, sql: string) { const already = db.prepare('SELECT id FROM migrations WHERE name = ?').get(name) if (already) return // already ran
db.exec(sql) db.prepare('INSERT INTO migrations (name) VALUES (?)').run(name) console.log(`Migration applied: ${name}`)}
// Apply migrationsmigrate('001_initial_schema', readFileSync(join(process.cwd(), 'src/db/schema.sql'), 'utf8'))Run this at startup in src/index.ts:
import './db/migrate.js' // runs before app.listen()Exercise
Section titled “Exercise”- Create
src/db/schema.sqlwith the Bulletin schema above. - Create
src/db/migrate.tswith the migration system. - Import the migration in
src/index.ts(beforeapp.listen). - Run
npm run devand verify the tables are created. Check the.dbfile in DB Browser.
CREATE TABLE IF NOT EXISTSdefines a table’s structure — safe to re-run.NOT NULLenforces required fields;UNIQUEprevents duplicates.REFERENCES table(id) ON DELETE CASCADEcreates a foreign key with cascade deletion.- A composite primary key
PRIMARY KEY (a, b)prevents duplicate(a, b)pairs. - Run
CREATE TABLEstatements at startup — a simple migration system tracks what’s been applied.