From 5a215744e5118798db954a8c77f4d5d46f6e6394 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:16:31 +0100 Subject: [PATCH] feat(learner): package scaffold, auth tables migration, migrations runner Co-Authored-By: Claude Opus 5.5 --- .../migrations/0001_learner_auth.sql | 34 ++++++++++++++++++ packages/feature-learner/src/index.ts | 1 + .../feature-learner/src/server/migrate.ts | 33 +++++++++++++++++ packages/feature-learner/test/helpers.ts | 35 +++++++++++++++++++ packages/feature-learner/test/migrate.test.ts | 13 +++++++ 5 files changed, 116 insertions(+) create mode 100644 packages/feature-learner/migrations/0001_learner_auth.sql create mode 100644 packages/feature-learner/src/index.ts create mode 100644 packages/feature-learner/src/server/migrate.ts create mode 100644 packages/feature-learner/test/helpers.ts create mode 100644 packages/feature-learner/test/migrate.test.ts diff --git a/packages/feature-learner/migrations/0001_learner_auth.sql b/packages/feature-learner/migrations/0001_learner_auth.sql new file mode 100644 index 0000000..81d9f1e --- /dev/null +++ b/packages/feature-learner/migrations/0001_learner_auth.sql @@ -0,0 +1,34 @@ +-- Learner package: Better Auth tables (snake_case, ISO-8601 TEXT dates, INTEGER booleans) and helpers. +-- Portable SQL: runs unchanged on SQLite and Postgres. +CREATE TABLE auth_user ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, email_verified INTEGER NOT NULL, + image TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE auth_session ( + id TEXT PRIMARY KEY, expires_at TEXT NOT NULL, token TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ip_address TEXT, user_agent TEXT, + user_id TEXT NOT NULL +); +CREATE INDEX auth_session_by_user ON auth_session (user_id); +CREATE TABLE auth_account ( + id TEXT PRIMARY KEY, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL, + access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at TEXT, refresh_token_expires_at TEXT, + scope TEXT, password TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE INDEX auth_account_by_user ON auth_account (user_id); +CREATE TABLE auth_verification ( + id TEXT PRIMARY KEY, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE INDEX auth_verification_by_identifier ON auth_verification (identifier); +CREATE TABLE auth_sso_provider ( + id TEXT PRIMARY KEY, issuer TEXT NOT NULL, oidc_config TEXT, saml_config TEXT, user_id TEXT, + provider_id TEXT NOT NULL UNIQUE, organization_id TEXT, domain TEXT NOT NULL, domain_verified INTEGER +); +-- key/value for one-off facts such as which user claimed the owner role first +CREATE TABLE learner_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +-- group memberships that came from SAML/OIDC claims (replaced on each sign-in; manual ones are not listed here) +CREATE TABLE learner_group_claims ( + user_id TEXT NOT NULL, group_id TEXT NOT NULL, + PRIMARY KEY (user_id, group_id) +); diff --git a/packages/feature-learner/src/index.ts b/packages/feature-learner/src/index.ts new file mode 100644 index 0000000..a1b83eb --- /dev/null +++ b/packages/feature-learner/src/index.ts @@ -0,0 +1 @@ +export { migrationsDirs, runMigrations } from "./server/migrate"; diff --git a/packages/feature-learner/src/server/migrate.ts b/packages/feature-learner/src/server/migrate.ts new file mode 100644 index 0000000..ecf343d --- /dev/null +++ b/packages/feature-learner/src/server/migrate.ts @@ -0,0 +1,33 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Db } from "@stc/db"; + +/** Folders holding this package's migrations; Phase 2 wires these into `migrate()`. */ +export const migrationsDirs: string[] = [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); +} + +/** + * Applies the learner migrations with the same bookkeeping as `@stc/db`'s `migrate()` + * (table `schema_migrations`, keyed by file name). Safe to call repeatedly. + */ +export async function runMigrations(db: Db, dirs: string[] = migrationsDirs): 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 applied: string[] = []; + for (const dir of dirs) { + for (const name of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + if (done.has(name)) continue; + const stmts = statements(readFileSync(join(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/feature-learner/test/helpers.ts b/packages/feature-learner/test/helpers.ts new file mode 100644 index 0000000..2953d4f --- /dev/null +++ b/packages/feature-learner/test/helpers.ts @@ -0,0 +1,35 @@ +import { test } from "bun:test"; +import { connect, migrate, type Db } from "@stc/db"; +import { runMigrations } from "../src/server/migrate"; + +const PG = process.env.STC_TEST_PG_URL; + +/** Fresh migrated in-memory SQLite (and a fresh Postgres schema when STC_TEST_PG_URL is set), with learner migrations applied. */ +export function forEachDb(name: string, fn: (db: Db) => Promise, timeout?: number): void { + test(`${name} [sqlite]`, async () => { + const db = await connect("sqlite://:memory:"); + try { await migrate(db); await runMigrations(db); await fn(db); } finally { await db.close(); } + }, timeout); + if (!PG) return; + test(`${name} [postgres]`, async () => { + const { db, drop } = await pgSchemaDb(); + try { await migrate(db); await runMigrations(db); await fn(db); } finally { await drop(); } + }, timeout); +} + +export async function pgSchemaDb(): Promise<{ db: Db; drop(): Promise }> { + 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()); + return { + db, + async drop() { + await db.close(); + await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`); + await admin.close(); + }, + }; +} diff --git a/packages/feature-learner/test/migrate.test.ts b/packages/feature-learner/test/migrate.test.ts new file mode 100644 index 0000000..49e57bf --- /dev/null +++ b/packages/feature-learner/test/migrate.test.ts @@ -0,0 +1,13 @@ +import { expect } from "bun:test"; +import { runMigrations } from "../src/server/migrate"; +import { forEachDb } from "./helpers"; + +forEachDb("learner migrations create the auth tables and are idempotent", async (db) => { + for (const t of ["auth_user", "auth_session", "auth_account", "auth_verification", "auth_sso_provider", "learner_meta", "learner_group_claims"]) { + const rows = await db.sql.unsafe(`SELECT COUNT(*) AS n FROM ${t}`); + expect(Number(rows[0].n)).toBe(0); + } + expect(await runMigrations(db)).toEqual([]); + const names = (await db.sql`SELECT name FROM schema_migrations`).map((r: { name: string }) => r.name); + expect(names).toContain("0001_learner_auth.sql"); +});