feat(db): Bun.SQL connection and portable migrations

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 02:57:34 +01:00
co-authored by Claude Opus 5.5
parent f71a31a5f1
commit 738d813fb8
8 changed files with 193 additions and 2 deletions
+1 -2
View File
@@ -14,8 +14,7 @@ export type JobStatus = "queued" | "running" | "done" | "failed";
export interface JobRecord { id: string; kind: string; status: JobStatus; progress: number; message: string | null; payload: unknown; result: unknown; error: string | null; createdAt: string; updatedAt: string }
export interface JobQueue { enqueue(kind: string, payload: unknown): Promise<string>; get(id: string): Promise<JobRecord | null>; list(kind?: string): Promise<JobRecord[]> }
export interface AuthService { userFromRequest(req: Request): Promise<AuthUser | null> }
// Placeholder until Task 7 (@stc/db) exists.
type Db = unknown;
import type { Db } from "@stc/db";
export interface ServerContext {
config: AppConfig; db: Db; courses: CourseRepository;
jobs: JobQueue; auth: AuthService;
+68
View File
@@ -0,0 +1,68 @@
-- Portable SQL: TEXT ids and ISO-8601 timestamps, INTEGER booleans, TEXT JSON.
CREATE TABLE user_roles (
user_id TEXT NOT NULL, role TEXT NOT NULL,
PRIMARY KEY (user_id, role)
);
CREATE TABLE groups (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE group_members (
group_id TEXT NOT NULL, user_id TEXT NOT NULL,
PRIMARY KEY (group_id, user_id)
);
CREATE TABLE course_authors (
course_id TEXT NOT NULL, user_id TEXT NOT NULL,
PRIMARY KEY (course_id, user_id)
);
CREATE TABLE course_acl (
course_id TEXT NOT NULL,
principal_type TEXT NOT NULL CHECK (principal_type IN ('user', 'group')),
principal_id TEXT NOT NULL,
PRIMARY KEY (course_id, principal_type, principal_id)
);
CREATE TABLE progress (
user_id TEXT NOT NULL, course_id TEXT NOT NULL, lesson_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('not-started', 'in-progress', 'done')),
position_seconds REAL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, course_id, lesson_id)
);
CREATE TABLE notes (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, course_id TEXT NOT NULL, lesson_id TEXT NOT NULL,
anchor_kind TEXT NOT NULL CHECK (anchor_kind IN ('none', 'video', 'slide')),
anchor_value TEXT, deck_id TEXT,
body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX notes_by_lesson ON notes (user_id, course_id, lesson_id);
CREATE TABLE exercise_attempts (
user_id TEXT NOT NULL, course_id TEXT NOT NULL, lesson_id TEXT NOT NULL, track TEXT NOT NULL,
code TEXT NOT NULL, result_json TEXT, passed INTEGER NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, course_id, lesson_id, track)
);
CREATE TABLE user_api_keys (
user_id TEXT NOT NULL, provider TEXT NOT NULL, ciphertext TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY (user_id, provider)
);
CREATE TABLE ai_settings (
scope TEXT NOT NULL CHECK (scope IN ('server', 'user')),
owner_id TEXT NOT NULL, -- '' for server scope
mentor_off INTEGER NOT NULL, config_json TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (scope, owner_id)
);
CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, course_id TEXT NOT NULL, title TEXT, created_at TEXT NOT NULL
);
CREATE TABLE ai_messages (
id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE INDEX ai_messages_by_conversation ON ai_messages (conversation_id);
CREATE TABLE library_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE collections (id TEXT PRIMARY KEY, title TEXT NOT NULL, sort INTEGER NOT NULL);
CREATE TABLE collection_courses (
collection_id TEXT NOT NULL, course_id TEXT NOT NULL, sort INTEGER NOT NULL,
PRIMARY KEY (collection_id, course_id)
);
CREATE TABLE jobs (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, status TEXT NOT NULL, progress REAL NOT NULL,
message TEXT, payload_json TEXT NOT NULL, result_json TEXT, error TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX jobs_by_status ON jobs (status);
+21
View File
@@ -0,0 +1,21 @@
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
export type Db = { sql: Bun.SQL; dialect: "sqlite" | "postgres"; close(): Promise<void> };
export async function connect(url: string): Promise<Db> {
if (url.startsWith("sqlite:")) {
const path = url.replace(/^sqlite:(\/\/)?/, "");
if (path !== ":memory:" && path !== "") mkdirSync(dirname(path), { recursive: true });
// A single connection keeps in-memory databases coherent and serialises writers.
const sql = new Bun.SQL(url, { max: 1 });
await sql.unsafe("PRAGMA foreign_keys = ON");
if (path !== ":memory:") await sql.unsafe("PRAGMA journal_mode = WAL");
return { sql, dialect: "sqlite", close: () => sql.close() };
}
if (url.startsWith("postgres:") || url.startsWith("postgresql:")) {
const sql = new Bun.SQL(url);
return { sql, dialect: "postgres", close: () => sql.close() };
}
throw new Error(`Unsupported database URL (expected sqlite:// or postgres://): ${url}`);
}
+2
View File
@@ -0,0 +1,2 @@
export { connect, type Db } from "./connect";
export { migrate } from "./migrate";
+27
View File
@@ -0,0 +1,27 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Db } from "./connect";
const MIGRATIONS_DIR = join(import.meta.dir, "..", "migrations");
function statements(text: string): string[] {
const noComments = text.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n");
return noComments.split(";").map((s) => s.trim()).filter(Boolean);
}
export async function migrate(db: Db): Promise<string[]> {
await db.sql.unsafe("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)");
const done = new Set((await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name));
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql")).sort();
const applied: string[] = [];
for (const name of files) {
if (done.has(name)) continue;
const stmts = statements(readFileSync(join(MIGRATIONS_DIR, name), "utf8"));
await db.sql.begin(async (tx) => {
for (const s of stmts) await tx.unsafe(s);
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${name}, ${new Date().toISOString()})`;
});
applied.push(name);
}
return applied;
}
+28
View File
@@ -0,0 +1,28 @@
import { test } from "bun:test";
import { connect, type Db } from "../src/connect";
import { migrate } from "../src/migrate";
const PG = process.env.STC_TEST_PG_URL;
/** Runs `fn` against a fresh migrated in-memory SQLite, and a fresh Postgres schema when STC_TEST_PG_URL is set. */
export function forEachDb(name: string, fn: (db: Db) => Promise<void>): void {
test(`${name} [sqlite]`, async () => {
const db = await connect("sqlite://:memory:");
try { await migrate(db); await fn(db); } finally { await db.close(); }
});
if (!PG) return;
test(`${name} [postgres]`, async () => {
const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`;
const admin = await connect(PG);
await admin.sql.unsafe(`CREATE SCHEMA ${schema}`);
const u = new URL(PG);
u.searchParams.set("options", `-c search_path=${schema}`);
const db = await connect(u.toString());
try { await migrate(db); await fn(db); }
finally {
await db.close();
await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`);
await admin.close();
}
});
}
+32
View File
@@ -0,0 +1,32 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { migrate } from "../src/migrate";
forEachDb("applies 0001 once", async (db) => {
// helpers already migrated once; a second run applies nothing
expect(await migrate(db)).toEqual([]);
const rows = await db.sql`SELECT name FROM schema_migrations`;
expect(rows.map((r: { name: string }) => r.name)).toEqual(["0001_init.sql"]);
});
forEachDb("all tables exist", async (db) => {
const s = db.sql;
const t = "2026-01-01T00:00:00.000Z";
const one = async (table: string, rows: Promise<unknown>) => { await rows; expect((await s.unsafe(`SELECT COUNT(*) AS n FROM ${table}`))[0].n.toString()).toBe("1"); };
await one("user_roles", s`INSERT INTO user_roles VALUES ('u','owner')`);
await one("groups", s`INSERT INTO groups VALUES ('g','G')`);
await one("group_members", s`INSERT INTO group_members VALUES ('g','u')`);
await one("course_authors", s`INSERT INTO course_authors VALUES ('c','u')`);
await one("course_acl", s`INSERT INTO course_acl VALUES ('c','user','u')`);
await one("progress", s`INSERT INTO progress VALUES ('u','c','l','done',1.5,${t})`);
await one("notes", s`INSERT INTO notes VALUES ('n','u','c','l','none',NULL,NULL,'b',${t},${t})`);
await one("exercise_attempts", s`INSERT INTO exercise_attempts VALUES ('u','c','l','py','x',NULL,1,${t})`);
await one("user_api_keys", s`INSERT INTO user_api_keys VALUES ('u','p','ct',${t})`);
await one("ai_settings", s`INSERT INTO ai_settings VALUES ('server','',0,'{}',${t})`);
await one("ai_conversations", s`INSERT INTO ai_conversations VALUES ('a','u','c',NULL,${t})`);
await one("ai_messages", s`INSERT INTO ai_messages VALUES ('m','a','user','hi',${t})`);
await one("library_settings", s`INSERT INTO library_settings VALUES ('k','v')`);
await one("collections", s`INSERT INTO collections VALUES ('co','T',0)`);
await one("collection_courses", s`INSERT INTO collection_courses VALUES ('co','c',0)`);
await one("jobs", s`INSERT INTO jobs VALUES ('j','k','queued',0,NULL,'{}',NULL,NULL,${t},${t})`);
});
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Runs the package tests against SQLite and a throwaway postgres:17 container.
set -euo pipefail
PORT="${STC_PG_PORT:-55432}"
cd "$(dirname "$0")/.."
trap 'docker stop stc-pg >/dev/null 2>&1 || true' EXIT
docker rm -f stc-pg >/dev/null 2>&1 || true
docker run -d --rm --name stc-pg -e POSTGRES_PASSWORD=stc -p 127.0.0.1:${PORT}:5432 postgres:17 >/dev/null
for _ in $(seq 1 60); do
docker exec stc-pg pg_isready -U postgres -h 127.0.0.1 >/dev/null 2>&1 && break
sleep 1
done
sleep 1
STC_TEST_PG_URL="postgres://postgres:[email protected]:${PORT}/postgres" bun test packages