feat(db): Bun.SQL connection and portable migrations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f71a31a5f1
commit
738d813fb8
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { connect, type Db } from "./connect";
|
||||
export { migrate } from "./migrate";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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})`);
|
||||
});
|
||||
Executable
+14
@@ -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
|
||||
Reference in New Issue
Block a user