The platform uses SQLite by default via Drizzle ORM + better-sqlite3. This guide covers database management and how to switch to other databases.
src/lib/db/
index.ts — Connection setup (reads DB_URL env var)
schema.ts — Drizzle table definitions
seed.ts — Table creation + admin user seeding
drizzle.config.ts — Drizzle Kit config (migrations, dialect)
data/ — SQLite database file (gitignored)
Controlled by DB_URL env var (default: data/learning-platform.db):
# Relative path (from project root)
DB_URL=data/learning-platform.db
# Absolute path
DB_URL=/var/lib/myapp/database.db| Table | Purpose |
|---|---|
users |
User accounts (email, password hash, role, TOTP, avatar) |
oauth_accounts |
OAuth provider links (Google, GitHub, Azure) |
passkeys |
WebAuthn credentials |
audit_log |
Security audit trail |
progress |
User exercise progress (locked/available/completed) |
submissions |
Code submissions with pass/fail results |
modules |
Technology modules (terraform, kubernetes, ansible) |
exercises |
Exercise definitions (code, validations, terminal commands) |
rate_limits |
Login rate limiting |
# Create/reset tables + seed admin user
npm run db:seed
# Import exercises from YAML
npm run exercises:import
# Open database with sqlite3 CLI
sqlite3 data/learning-platform.db
# Useful queries
.schema -- Show all tables
SELECT * FROM modules; -- List modules
SELECT id, title FROM exercises; -- List exercises
SELECT id, email, role FROM users; -- List users
SELECT COUNT(*) FROM submissions; -- Count submissions# Simple file copy (SQLite is a single file)
cp data/learning-platform.db data/backup-$(date +%Y%m%d).db
# With Docker
docker cp learning-platform:/app/data/learning-platform.db ./backup.dbDrizzle ORM supports PostgreSQL natively. Here's how to migrate:
# Remove SQLite driver
npm uninstall better-sqlite3 @types/better-sqlite3
# Install PostgreSQL driver
npm install drizzle-orm/pg @neondatabase/serverless
# OR for node-postgres:
npm install pg
npm install -D @types/pgReplace SQLite-specific imports with PostgreSQL:
// Before (SQLite)
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
// After (PostgreSQL)
import { pgTable, text, integer, serial, boolean, timestamp } from "drizzle-orm/pg-core";Update table definitions:
// Before (SQLite)
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
email: text("email").unique(),
role: text("role", { enum: ["admin", "user", "anonymous"] }).notNull().default("user"),
totpEnabled: integer("totp_enabled", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull().default(new Date().toISOString()),
});
// After (PostgreSQL)
export const users = pgTable("users", {
id: text("id").primaryKey(),
email: text("email").unique(),
role: text("role").notNull().default("user"), // or use pgEnum
totpEnabled: boolean("totp_enabled").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
});Key differences:
sqliteTable→pgTableinteger("col", { mode: "boolean" })→boolean("col")text("col").default(new Date().toISOString())→timestamp("col").defaultNow()integer("id").primaryKey({ autoIncrement: true })→serial("id").primaryKey()
// Before (SQLite)
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import * as schema from "./schema";
const sqlite = new Database(process.env.DB_URL || "data/learning-platform.db");
sqlite.pragma("journal_mode = WAL");
export const db = drizzle(sqlite, { schema });
// After (PostgreSQL with node-postgres)
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL || "postgresql://localhost:5432/learning_platform",
});
export const db = drizzle(pool, { schema });
// After (PostgreSQL with Neon serverless)
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/db/schema.ts",
out: "./src/lib/db/migrations",
dialect: "postgresql", // Changed from "sqlite"
dbCredentials: {
url: process.env.DATABASE_URL || "postgresql://localhost:5432/learning_platform",
},
});Replace better-sqlite3 with pg:
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL || "postgresql://localhost:5432/learning_platform",
});
// Replace sqlite.exec() with pool.query()
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
password_hash TEXT,
username TEXT NOT NULL DEFAULT 'anonymous',
role TEXT NOT NULL DEFAULT 'user',
totp_enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
...
);
...
`);Replace better-sqlite3 prepared statements with pg queries:
// Before
const sqlite = new Database(dbPath);
const upsertModule = sqlite.prepare(`INSERT OR REPLACE INTO modules ...`);
upsertModule.run(id, title, ...);
// After
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await pool.query(
`INSERT INTO modules (id, title, ...) VALUES ($1, $2, ...)
ON CONFLICT (id) DO UPDATE SET title = $2, ...`,
[id, title, ...]
);Note: PostgreSQL uses ON CONFLICT ... DO UPDATE instead of SQLite's INSERT OR REPLACE.
# .env.local
DATABASE_URL=postgresql://user:password@localhost:5432/learning_platform# Generate migration from schema
npm run db:generate
# Apply migration
npm run db:migrate
# Or push schema directly (dev only)
npm run db:pushSimilar process to PostgreSQL:
// schema.ts
import { mysqlTable, text, int, boolean, timestamp } from "drizzle-orm/mysql-core";
// index.ts
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
const pool = mysql.createPool(process.env.DATABASE_URL || "mysql://localhost:3306/learning_platform");
export const db = drizzle(pool, { schema });
// drizzle.config.ts
export default defineConfig({
dialect: "mysql",
dbCredentials: { url: process.env.DATABASE_URL },
});Turso is a SQLite-compatible edge database:
npm uninstall better-sqlite3 @types/better-sqlite3
npm install @libsql/client// index.ts
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
const client = createClient({
url: process.env.TURSO_URL || "file:data/learning-platform.db",
authToken: process.env.TURSO_AUTH_TOKEN,
});
export const db = drizzle(client, { schema });The schema stays the same (SQLite dialect) — only the driver changes.
Full schema is in src/lib/db/schema.ts. Key relationships:
modules 1──N exercises (module_id → modules.id)
users 1──N progress (user_id → users.id)
users 1──N submissions (user_id → users.id)
users 1──N oauth_accounts(user_id → users.id)
users 1──N passkeys (user_id → users.id)
Exercise data is stored as JSON strings in TEXT columns:
prerequisites—["tf-01-broken-provider"]hints—["Hint 1", "Hint 2"]validations—[{"type":"syntax","check":{"contains":"x"},...}]terminal_commands—{"cmd":[{"when":{},"output":"...","exitCode":0}]}i18n—{"en":{"title":"...","briefing":"..."}}