feat(learner): package scaffold, auth tables migration, migrations runner

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:16:31 +01:00
co-authored by Claude Opus 5.5
parent 0b117159cd
commit 5a215744e5
5 changed files with 116 additions and 0 deletions
@@ -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)
);
+1
View File
@@ -0,0 +1 @@
export { migrationsDirs, runMigrations } from "./server/migrate";
@@ -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<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 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;
}
+35
View File
@@ -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<void>, 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<void> }> {
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();
},
};
}
@@ -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");
});