From 738d813fb8ccea4973e65f6eb5c85af86a0ac48f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 02:57:34 +0100 Subject: [PATCH] feat(db): Bun.SQL connection and portable migrations Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/server.ts | 3 +- packages/db/migrations/0001_init.sql | 68 ++++++++++++++++++++++++++++ packages/db/src/connect.ts | 21 +++++++++ packages/db/src/index.ts | 2 + packages/db/src/migrate.ts | 27 +++++++++++ packages/db/test/helpers.ts | 28 ++++++++++++ packages/db/test/migrate.test.ts | 32 +++++++++++++ scripts/test-pg.sh | 14 ++++++ 8 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 packages/db/migrations/0001_init.sql create mode 100644 packages/db/src/connect.ts create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/src/migrate.ts create mode 100644 packages/db/test/helpers.ts create mode 100644 packages/db/test/migrate.test.ts create mode 100755 scripts/test-pg.sh diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 4dfd75f..1ac169e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -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; get(id: string): Promise; list(kind?: string): Promise } export interface AuthService { userFromRequest(req: Request): Promise } -// 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; diff --git a/packages/db/migrations/0001_init.sql b/packages/db/migrations/0001_init.sql new file mode 100644 index 0000000..ade4751 --- /dev/null +++ b/packages/db/migrations/0001_init.sql @@ -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); diff --git a/packages/db/src/connect.ts b/packages/db/src/connect.ts new file mode 100644 index 0000000..f30818a --- /dev/null +++ b/packages/db/src/connect.ts @@ -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 }; + +export async function connect(url: string): Promise { + 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}`); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..3aaa0f0 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,2 @@ +export { connect, type Db } from "./connect"; +export { migrate } from "./migrate"; diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 0000000..be5f38f --- /dev/null +++ b/packages/db/src/migrate.ts @@ -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 { + 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; +} diff --git a/packages/db/test/helpers.ts b/packages/db/test/helpers.ts new file mode 100644 index 0000000..b162836 --- /dev/null +++ b/packages/db/test/helpers.ts @@ -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 { + 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(); + } + }); +} diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts new file mode 100644 index 0000000..8619728 --- /dev/null +++ b/packages/db/test/migrate.test.ts @@ -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) => { 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})`); +}); diff --git a/scripts/test-pg.sh b/scripts/test-pg.sh new file mode 100755 index 0000000..45ba055 --- /dev/null +++ b/scripts/test-pg.sh @@ -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:stc@127.0.0.1:${PORT}/postgres" bun test packages