diff --git a/.claude/skills/database-migrations/SKILL.md b/.claude/skills/database-migrations/SKILL.md new file mode 100644 index 0000000..9ee6f75 --- /dev/null +++ b/.claude/skills/database-migrations/SKILL.md @@ -0,0 +1,244 @@ +--- +name: database-migrations +description: Authoring, building, validating, and running PostgreSQL database migrations with the @schemavaults/dbh package. Use when a project depends on @schemavaults/dbh and you are creating or editing Kysely migration files, setting up a migrations/ directory, or when the user mentions migrations, up()/down(), schema changes, or the dbh CLI's migrate / build-db-migrations / validate-migration-directory commands. +--- + +# Database Migrations with @schemavaults/dbh + +`@schemavaults/dbh` provides [Kysely](https://kysely.dev/) migrations for +PostgreSQL, applied through the `dbh` CLI. Migrations are opinionated: every file +is a numbered module that exports an `up()` and a `down()` function. TypeScript +source migrations are **built** to JavaScript first, then **applied** with the +CLI. + +Invoke the CLI with your package runner. Use **`bunx @schemavaults/dbh`** for +**validating and building** migrations — `build-db-migrations` uses Bun's +bundler and requires Bun anyway. Use **`npx @schemavaults/dbh`** for **running / +applying** migrations (`migrate` and `reverse`): most PostgreSQL drivers are +built for Node.js rather than Bun, so apply migrations on the Node runtime. + +## One-time setup (for consumers) + +Migrations import the `sql` template tag from `@/sql` rather than directly from +the package. This indirection is required by the build step (see the note under +"Building migrations"), so configure it once: + +1. **Create a local `sql` module** somewhere in your source tree, e.g. + `./src/db/sql.ts`, that re-exports the tag from the package: + + ```ts + // src/db/sql.ts + export { sql, sql as default } from "@schemavaults/dbh/sql"; + export type * from "@schemavaults/dbh/sql"; + ``` + +2. **Configure the `@/sql` path alias** in your `tsconfig.json` so migration + sources typecheck and resolve: + + ```jsonc + { + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/sql": ["./src/db/sql.ts"] + } + } + } + ``` + +3. **Create a migrations directory**, e.g. `./src/db/migrations/`, and add your + numbered migration files there. + +## Migration file format + +Each migration is a single file in your migrations directory. The rules are: + +1. **The directory is non-empty.** +2. **Each file name is prefixed with a 5-digit migration number**, followed by a + short kebab-case description, e.g. `00000-template-migration.ts`, + `00001-create-users-table.ts`. The number defines apply order. +3. **Each module exports an `up(db)` and a `down(db)` function.** `up()` applies + the change; `down()` must reverse it exactly so migrations can be rolled back. +4. **Migration numbers are unique** — never reuse a number. If two branches both + add `00040-*.ts`, that collision must be resolved by renumbering one of them + before merge. + +Both `up` and `down` receive a `Kysely` instance and return a `Promise`. +Import the `Kysely` type from the package: `import type { Kysely } from "@schemavaults/dbh"`. + +### Example: using the `Kysely` query builder + +Prefer the typed query builder for schema operations: + +```ts +// 00001-create-users-table.ts +import type { Kysely } from "@schemavaults/dbh"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("users") + .addColumn("user_id", "uuid", (col) => col.primaryKey()) + .addColumn("email", "text", (col) => col.notNull().unique()) + .addColumn("created_at", "bigint", (col) => col.notNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("users").execute(); +} +``` + +### Example: using the `sql` template tag + +For statements the builder can't express (or raw DDL), import `sql` from +`@/sql` (your local module from setup, which re-exports Kysely's `sql` tag) and +call `.execute(db)`: + +```ts +// 00002-create-squirrels-table.ts +import type { Kysely } from "@schemavaults/dbh"; +import { sql } from "@/sql"; + +export async function up(db: Kysely): Promise { + await sql` + CREATE TABLE IF NOT EXISTS EXAMPLE_SQUIRRELS ( + squirrel_id UUID PRIMARY KEY, + squirrel_name TEXT NOT NULL, + created_at BIGINT NOT NULL + ); + `.execute(db); + + // Always interpolate values via ${...}; the sql tag parameterizes them. + await sql`CREATE INDEX squirrels_name_idx ON EXAMPLE_SQUIRRELS (squirrel_name);`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE IF EXISTS EXAMPLE_SQUIRRELS;`.execute(db); +} +``` + +> Important: migration files must **always** import `sql` from `@/sql`, never +> directly from `@schemavaults/dbh/sql`. The `build-db-migrations` step rewrites +> the literal `@/sql` import specifier to a relative path pointing at the built, +> standalone `sql.js`, so the import must be written exactly as `@/sql` for the +> build to work. (This is why the one-time setup configures the `@/sql` alias.) + +### Empty template migration + +A no-op migration is valid (useful as a starting template): + +```ts +// 00000-template-migration.ts +import type { Kysely } from "@schemavaults/dbh"; + +export async function up( + db: Kysely, // eslint-disable-line @typescript-eslint/no-unused-vars +): Promise {} + +export async function down( + db: Kysely, // eslint-disable-line @typescript-eslint/no-unused-vars +): Promise {} +``` + +## Validating migrations + +Before building or applying, assert your source migrations directory is +well-formed. The `validate-migration-directory` command checks all four rules +above and exits `0` when valid, non-zero otherwise (good for CI / pre-commit): + +```bash +bunx @schemavaults/dbh validate-migration-directory ./src/db/migrations +``` + +It reports each problem with an `[ERROR]`/`[WARN]` prefix: +- empty directory, +- a file missing the 5-digit prefix, +- a module missing `up()` or `down()`, +- duplicate migration numbers (branch collisions). + +Treat duplicate numbers as warnings (non-fatal) with `--duplicates-as-warnings`. + +## Building migrations + +TypeScript migrations must be compiled to JavaScript before they're applied +(the `migrate` step runs on Node and imports `.js`). The `build-db-migrations` +command uses Bun's bundler and also builds the standalone `sql` module the +migrations depend on. Point `--sql-module` at the local `sql.ts` you created +during setup: + +```bash +bunx @schemavaults/dbh build-db-migrations ./src/db/migrations \ + --outdir ./dist/migrations \ + --sql-module ./src/db/sql.ts \ + --sql-outdir ./dist +``` + +Key options: +- `` — directory of `.ts` migration sources (positional). +- `--outdir ` — where compiled `.js` migrations are written (required). +- `--sql-module ` — path to your local `sql.ts` module to build alongside (required). +- `--sql-outdir ` — where the built `sql.js` goes (defaults to the parent of `--outdir`). +- `--external ` — packages to keep external (default: `@schemavaults/dbh`, `kysely`). + +`build-db-migrations` requires `bun` to be installed and on the PATH. + +## Running migrations + +Apply built migrations with `migrate`, and roll back with `reverse`. Both take +the **built** migration folder and require an `--environment`; credentials come +from `process.env` (or an `--env-file`). Run these with **`npx`** (Node.js): +most PostgreSQL drivers target Node rather than Bun. + +```bash +# Apply all pending migrations (to latest): +npx @schemavaults/dbh migrate ./dist/migrations --environment production --env-file ./.env.production + +# Apply up to a specific version (the migration name w/o extension): +npx @schemavaults/dbh migrate ./dist/migrations 00001-create-users-table --environment staging + +# Roll back down to a target version: +npx @schemavaults/dbh reverse ./dist/migrations 00000-template-migration --environment staging +``` + +Options for `migrate` / `reverse`: +- `` — path to the built migration folder (positional). +- `[version]` / `` — target migration name; `migrate` defaults to latest, `reverse` requires it. +- `-e, --environment ` — `development | test | staging | production` (required). +- `--ws-proxy-url ` — custom Neon-compatible WebSocket proxy URL. +- `--env-file ` — load DB credentials from a `.env` file first. + +Each result line prints as `[Up|Down] : `. + +### Programmatic API + +The same operations are available from `@schemavaults/dbh/migrate` for tests or +custom scripts, using the adapter's Kysely instance: + +```ts +import { migrate, reverse } from "@schemavaults/dbh/migrate"; + +await migrate({ db: adapter.db, migrationFolder, version /* optional */ }); +await reverse({ db: adapter.db, migrationFolder, version }); +``` + +## Typical end-to-end flow + +```bash +# 1. Validate the source migrations directory. +bunx @schemavaults/dbh validate-migration-directory ./src/db/migrations + +# 2. Build .ts migrations (+ sql module) to .js. +bunx @schemavaults/dbh build-db-migrations ./src/db/migrations \ + --outdir ./dist/migrations --sql-module ./src/db/sql.ts --sql-outdir ./dist + +# 3. Apply the built migrations (npx / Node.js — pg drivers target Node). +npx @schemavaults/dbh migrate ./dist/migrations --environment production --env-file ./.env.production +``` + +## Required environment variables (for migrate/reverse) + +`POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_URL`, `POSTGRES_HOST`, +`POSTGRES_PORT`, `POSTGRES_DATABASE` (and optional `POSTGRES_URL_NON_POOLING`). +Set `SCHEMAVAULTS_DBH_DEBUG=true` for verbose debug logging. diff --git a/.github/workflows/build-test-and-publish.yml b/.github/workflows/build-test-and-publish.yml index 0cedcb2..ecc1e5c 100644 --- a/.github/workflows/build-test-and-publish.yml +++ b/.github/workflows/build-test-and-publish.yml @@ -4,6 +4,7 @@ on: push: branches: - main + pull_request: env: BUN_VERSION: 1.3.14 @@ -127,6 +128,7 @@ jobs: Publish-To-Github-Packages: name: "Publish package to GitHub Packages" runs-on: ubuntu-latest + if: github.event_name == 'push' needs: - Build-Package - E2E-Tests @@ -166,6 +168,7 @@ jobs: Publish-To-NPMJS-Packages: name: "Publish package to NPMJS registry" + if: github.event_name == 'push' needs: - Build-Package - E2E-Tests @@ -203,6 +206,7 @@ jobs: Publish-Websocket-Proxy-To-GHCR: name: "Publish 'postgres-ws-proxy' Docker image" + if: github.event_name == 'push' needs: - E2E-Tests - CLI-Build-DB-Migrations-Tests diff --git a/README.md b/README.md index a0d7ffe..d0aeeb5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Ensure that you have both `postgres` and a `postgres-ws-proxy` containers runnin You'll likely want to replace the `build:` sections for the services in the e2e test example `.yml` file with `image:`. For example, use `image: postgres:17.7` for the `postgres` service. For the proxy, you can pull the docker image from `ghcr.io/schemavaults/dbh/postgres-ws-proxy`; use the version number equal to your `@schemavaults/dbh` npm package installation: ```md -# NPM Package: @schemavaults/dbh@0.10.2 => ghcr.io/schemavaults/dbh/postgres-ws-proxy:0.10.2 +# NPM Package: @schemavaults/dbh@0.11.0 => ghcr.io/schemavaults/dbh/postgres-ws-proxy:0.11.0 ``` ### In your application server code @@ -40,6 +40,20 @@ npx @schemavaults/dbh --help # or `bun run cli --help` if you have the dbh source repository as your working directory ``` +#### Validate the shape of a migrations directory +```bash +# assert the migrations directory is well-formed: +# - non-empty +# - every file is prefixed with a 5-digit migration number (e.g. 00000-my-migration.ts) +# - every module exports an up() and down() function +# - there are no duplicate migration numbers (branch collisions, e.g. 00040-a.ts and 00040-b.ts) +# exits 0 when the directory is valid, non-zero otherwise. +bunx @schemavaults/dbh validate-migration-directory ./src/db/migrations + +# treat duplicate migration numbers as warnings instead of errors (still exits 0) +bunx @schemavaults/dbh validate-migration-directory ./src/db/migrations --duplicates-as-warnings +``` + #### Build example database migrations with the CLI ```bash mkdir ./tests/tmp diff --git a/package.json b/package.json index e36ac0e..8bbe137 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/dbh", - "version": "0.10.2", + "version": "0.11.0", "description": "Easily connect to PostgresDB from serverless environment", "license": "UNLICENSED", "private": false, @@ -34,7 +34,7 @@ "build:pkg": "tsc --project tsconfig.json && tsc-alias --project tsconfig.json", "build:cli": "bun build ./src/cli.ts --outdir dist-cli --format esm --target node --minify && echo '#!/usr/bin/env node' | cat - dist-cli/cli.js > dist-cli/cli.tmp && mv dist-cli/cli.tmp dist-cli/cli.js", "build:build-db-migrations": "bun build ./src/build-db-migrations.ts --outdir dist-cli --format esm --target bun --minify", - "test:unit": "bun test ./src/tests/SchemaVaultsPostgresAdapterInit.test.ts && bun test src/tests/SchemaVaultsPostgresNeonProxyAdapterInit.test.ts", + "test:unit": "bun test ./src/tests/SchemaVaultsPostgresAdapterInit.test.ts && bun test src/tests/SchemaVaultsPostgresNeonProxyAdapterInit.test.ts && bun test ./src/tests/ValidateMigrationDirectory.test.ts", "test": "bun run test:unit", "test:e2e": "bun test ./src/tests/e2e/ConnectToLocalDatabaseWithPostgresAdapter.test.ts && bun test ./src/tests/e2e/ConnectToLocalDatabaseWithPostgresNeonProxyAdapter.test.ts && bun test ./src/tests/e2e/MigrateUpAndDown.test.ts", "test:e2e:cli": "/bin/bash ./tests/run_cli_e2e_tests.sh", diff --git a/src/cli.ts b/src/cli.ts index c2d863f..e704f92 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { SchemaVaultsPostgresNeonProxyAdapter } from "@/adapters/schemavaults-po import { migrate, reverse } from "@/migrate"; import type { SchemaVaultsAppEnvironment } from "@/types/SchemaVaultsAppEnvironment"; import { loadEnvFile } from "@/utils/loadEnvFile"; +import { validateMigrationDirectory } from "@/utils/validateMigrationDirectory"; const require = createRequire(import.meta.url); const { version } = require("../package.json") as { version: string }; @@ -201,6 +202,58 @@ dbhCli }, ); +dbhCli + .command("validate-migration-directory") + .alias("validate") + .description( + "Validate the shape of a migrations directory (5-digit prefixes, up()/down() exports, no duplicate numbers)", + ) + .argument("", "Path to the migrations directory") + .option( + "--duplicates-as-warnings", + "Report duplicate migration numbers as warnings instead of errors", + ) + .action( + async (folder: string, opts: { duplicatesAsWarnings?: boolean }) => { + const resolvedFolder = path.resolve(folder); + + let result; + try { + result = await validateMigrationDirectory(resolvedFolder, { + duplicatesAsWarnings: opts.duplicatesAsWarnings, + }); + } catch (error) { + console.error("Failed to validate migration directory:", error); + process.exit(1); + } + + for (const issue of result.issues) { + const prefix = issue.level === "error" ? "ERROR" : "WARN"; + console.error(`[${prefix}] ${issue.message}`); + } + + if (result.ok) { + const warningCount = result.issues.filter( + (i) => i.level === "warning", + ).length; + console.log( + `✓ Migration directory is valid (${result.migrationFiles.length} migration${ + result.migrationFiles.length === 1 ? "" : "s" + } checked${warningCount > 0 ? `, ${warningCount} warning(s)` : ""}): ${result.directory}`, + ); + process.exit(0); + } else { + const errorCount = result.issues.filter( + (i) => i.level === "error", + ).length; + console.error( + `✗ Migration directory is invalid (${errorCount} error(s)): ${result.directory}`, + ); + process.exit(1); + } + }, + ); + export default dbhCli; dbhCli.parse(); diff --git a/src/tests/ValidateMigrationDirectory.test.ts b/src/tests/ValidateMigrationDirectory.test.ts new file mode 100644 index 0000000..2b8db41 --- /dev/null +++ b/src/tests/ValidateMigrationDirectory.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { validateMigrationDirectory } from "@/utils/validateMigrationDirectory"; + +const UP_DOWN_MODULE = + "export async function up(){}\nexport async function down(){}\n"; + +describe("validateMigrationDirectory", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbh-validate-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeMigration(name: string, contents = UP_DOWN_MODULE): void { + fs.writeFileSync(path.join(tmpDir, name), contents); + } + + test("passes for the bundled example migrations", async () => { + const exampleDir = path.resolve(__dirname, "./example-migrations"); + const result = await validateMigrationDirectory(exampleDir); + expect(result.ok).toBeTrue(); + expect(result.issues).toBeEmpty(); + expect(result.migrationFiles.length).toBeGreaterThan(0); + }); + + test("passes for a well-formed directory", async () => { + writeMigration("00000-first.ts"); + writeMigration("00001-second.ts"); + const result = await validateMigrationDirectory(tmpDir); + expect(result.ok).toBeTrue(); + expect(result.issues).toBeEmpty(); + expect(result.migrationFiles).toEqual([ + "00000-first.ts", + "00001-second.ts", + ]); + }); + + test("fails when the directory does not exist", async () => { + const result = await validateMigrationDirectory( + path.join(tmpDir, "nope"), + ); + expect(result.ok).toBeFalse(); + expect(result.issues.some((i) => i.level === "error")).toBeTrue(); + }); + + test("fails when the directory is empty", async () => { + const result = await validateMigrationDirectory(tmpDir); + expect(result.ok).toBeFalse(); + expect(result.issues[0]?.message).toContain("empty"); + }); + + test("fails when a file is not prefixed with a 5-digit number", async () => { + writeMigration("00000-ok.ts"); + writeMigration("bad-name.ts"); + const result = await validateMigrationDirectory(tmpDir); + expect(result.ok).toBeFalse(); + expect( + result.issues.some( + (i) => i.file === "bad-name.ts" && i.level === "error", + ), + ).toBeTrue(); + }); + + test("fails for a 6-digit prefix (must be exactly 5 digits)", async () => { + writeMigration("000001-too-long.ts"); + const result = await validateMigrationDirectory(tmpDir); + expect(result.ok).toBeFalse(); + }); + + test("flags duplicate migration numbers as an error by default", async () => { + writeMigration("00040-a.ts"); + writeMigration("00040-b.ts"); + const result = await validateMigrationDirectory(tmpDir); + expect(result.ok).toBeFalse(); + const dupIssue = result.issues.find((i) => + i.message.includes("Duplicate migration number"), + ); + expect(dupIssue).toBeDefined(); + expect(dupIssue?.level).toBe("error"); + }); + + test("can downgrade duplicate migration numbers to warnings", async () => { + writeMigration("00040-a.ts"); + writeMigration("00040-b.ts"); + const result = await validateMigrationDirectory(tmpDir, { + duplicatesAsWarnings: true, + }); + expect(result.ok).toBeTrue(); + const dupIssue = result.issues.find((i) => + i.message.includes("Duplicate migration number"), + ); + expect(dupIssue?.level).toBe("warning"); + }); + + test("fails when a module is missing up()", async () => { + writeMigration("00000-x.ts", "export async function down(){}\n"); + const result = await validateMigrationDirectory(tmpDir); + expect(result.ok).toBeFalse(); + expect(result.issues.some((i) => i.message.includes("up()"))).toBeTrue(); + }); + + test("fails when a module is missing down()", async () => { + writeMigration("00000-x.ts", "export async function up(){}\n"); + const result = await validateMigrationDirectory(tmpDir); + expect(result.ok).toBeFalse(); + expect(result.issues.some((i) => i.message.includes("down()"))).toBeTrue(); + }); + + test("ignores .d.ts declaration files and dotfiles", async () => { + writeMigration("00000-first.ts"); + writeMigration("types.d.ts", "export type Foo = string;\n"); + writeMigration(".gitkeep", ""); + const result = await validateMigrationDirectory(tmpDir); + expect(result.ok).toBeTrue(); + expect(result.migrationFiles).toEqual(["00000-first.ts"]); + }); +}); diff --git a/src/utils/validateMigrationDirectory.ts b/src/utils/validateMigrationDirectory.ts new file mode 100644 index 0000000..9b3fc3b --- /dev/null +++ b/src/utils/validateMigrationDirectory.ts @@ -0,0 +1,204 @@ +import fs from "fs"; +import path from "path"; +import { pathToFileURL } from "url"; + +/** + * Extensions that are treated as migration modules. `.d.ts` declaration files + * are explicitly ignored (handled separately below). + */ +const MIGRATION_FILE_EXTENSIONS = [ + ".ts", + ".mts", + ".cts", + ".js", + ".mjs", + ".cjs", +] as const; + +/** Migration file names must begin with exactly 5 digits (e.g. 00000-foo.ts). */ +const MIGRATION_PREFIX_REGEX = /^(\d{5})(?:\D|$)/; + +export type MigrationValidationLevel = "error" | "warning"; + +export interface MigrationValidationIssue { + level: MigrationValidationLevel; + message: string; + /** The offending file name (relative to the migration directory), if any. */ + file?: string; +} + +export interface ValidateMigrationDirectoryOptions { + /** + * When true, duplicate migration numbers are reported as warnings instead of + * errors (they will not, on their own, cause validation to fail). + * @default false + */ + duplicatesAsWarnings?: boolean; +} + +export interface ValidateMigrationDirectoryResult { + /** True when there are no `error`-level issues. */ + ok: boolean; + /** The absolute path that was validated. */ + directory: string; + /** The migration file names that were considered (relative to `directory`). */ + migrationFiles: string[]; + issues: MigrationValidationIssue[]; +} + +function isMigrationFile(fileName: string): boolean { + if (fileName.startsWith(".")) { + // Ignore hidden/dotfiles. + return false; + } + if (fileName.endsWith(".d.ts")) { + // Ignore TypeScript declaration files. + return false; + } + return MIGRATION_FILE_EXTENSIONS.some((ext) => fileName.endsWith(ext)); +} + +/** + * Validates the shape of an opinionated Kysely migrations directory. + * + * Asserts that the directory: + * - exists and is non-empty, + * - contains only files prefixed with a 5-digit migration number, + * - has no duplicate migration numbers (branch collisions), and + * - exports an `up()` and `down()` function from every migration module. + */ +export async function validateMigrationDirectory( + directory: string, + options: ValidateMigrationDirectoryOptions = {}, +): Promise { + const { duplicatesAsWarnings = false } = options; + const resolved = path.resolve(directory); + const issues: MigrationValidationIssue[] = []; + + if (!fs.existsSync(resolved)) { + return { + ok: false, + directory: resolved, + migrationFiles: [], + issues: [ + { + level: "error", + message: `Migration directory does not exist: ${resolved}`, + }, + ], + }; + } + + const stat = fs.statSync(resolved); + if (!stat.isDirectory()) { + return { + ok: false, + directory: resolved, + migrationFiles: [], + issues: [ + { + level: "error", + message: `Migration path is not a directory: ${resolved}`, + }, + ], + }; + } + + const entries = fs.readdirSync(resolved, { withFileTypes: true }); + const migrationFiles = entries + .filter((entry) => entry.isFile() && isMigrationFile(entry.name)) + .map((entry) => entry.name) + .sort(); + + // 1) Directory must be non-empty. + if (migrationFiles.length === 0) { + issues.push({ + level: "error", + message: `Migration directory is empty (no migration files found): ${resolved}`, + }); + return { + ok: false, + directory: resolved, + migrationFiles, + issues, + }; + } + + // 2) Every file must be prefixed with a 5-digit migration number. + // Track which numbers map to which files for duplicate detection. + const numberToFiles = new Map(); + for (const file of migrationFiles) { + const match = MIGRATION_PREFIX_REGEX.exec(file); + if (!match) { + issues.push({ + level: "error", + file, + message: `File is not prefixed with a 5-digit migration number (e.g. 00000-my-migration.ts): ${file}`, + }); + continue; + } + const number = match[1]; + const existing = numberToFiles.get(number); + if (existing) { + existing.push(file); + } else { + numberToFiles.set(number, [file]); + } + } + + // 3) No duplicate migration numbers (collisions across branches). + for (const [number, files] of numberToFiles) { + if (files.length > 1) { + issues.push({ + level: duplicatesAsWarnings ? "warning" : "error", + message: `Duplicate migration number '${number}' used by ${files.length} files: ${files.join(", ")}`, + }); + } + } + + // 4) Every module must export an up() and down() function. + for (const file of migrationFiles) { + const fullPath = path.join(resolved, file); + let mod: Record; + try { + mod = (await import(pathToFileURL(fullPath).href)) as Record< + string, + unknown + >; + } catch (error) { + issues.push({ + level: "error", + file, + message: `Failed to import migration module '${file}': ${ + error instanceof Error ? error.message : String(error) + }`, + }); + continue; + } + + if (typeof mod.up !== "function") { + issues.push({ + level: "error", + file, + message: `Migration '${file}' does not export an up() function`, + }); + } + if (typeof mod.down !== "function") { + issues.push({ + level: "error", + file, + message: `Migration '${file}' does not export a down() function`, + }); + } + } + + const ok = !issues.some((issue) => issue.level === "error"); + return { + ok, + directory: resolved, + migrationFiles, + issues, + }; +} + +export default validateMigrationDirectory;