From eaebc2479164fe362e7a93dfdc7173dd1a755176 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 16:55:08 +0000 Subject: [PATCH 1/8] feat(cli): add validate-migration-directory command Adds a CLI command that validates the opinionated shape of a Kysely migrations directory: - directory is non-empty - every file is prefixed with a 5-digit migration number - every module exports an up() and down() function - no duplicate migration numbers (branch collisions) Exits 0 when valid, non-zero otherwise. Duplicate numbers are errors by default but can be downgraded to warnings via --duplicates-as-warnings. https://claude.ai/code/session_01V7f97zUsEZoVrpfNj1S5KQ --- README.md | 14 ++ package.json | 2 +- src/cli.ts | 53 +++++ src/tests/ValidateMigrationDirectory.test.ts | 124 +++++++++++ src/utils/validateMigrationDirectory.ts | 204 +++++++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/tests/ValidateMigrationDirectory.test.ts create mode 100644 src/utils/validateMigrationDirectory.ts diff --git a/README.md b/README.md index a0d7ffe..a362401 100644 --- a/README.md +++ b/README.md @@ -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..14dcd37 100644 --- a/package.json +++ b/package.json @@ -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; From 333c00415838289c5252917963730f6aa90cc24b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 18:58:29 +0000 Subject: [PATCH 2/8] ci: run build & tests on pull requests, skip publish Add a pull_request trigger so lint, unit, E2E, and CLI test jobs run on PRs. Guard the three publish jobs (GitHub Packages, NPMJS, GHCR Docker image) with 'if: github.event_name == "push"' so publishing only happens on push to main. https://claude.ai/code/session_01V7f97zUsEZoVrpfNj1S5KQ --- .github/workflows/build-test-and-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) 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 From e18e99447081a9fa22e7e4c2ee1c6b69251510eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:10:52 +0000 Subject: [PATCH 3/8] docs: add database-migrations Claude Code skill Document the opinionated migration file format (5-digit prefix, up()/down() exports, unique numbers) with examples using both the Kysely query builder and the sql template tag from @/sql, plus how to validate, build, and run migrations via the dbh CLI. https://claude.ai/code/session_01V7f97zUsEZoVrpfNj1S5KQ --- .claude/skills/database-migrations/SKILL.md | 206 ++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 .claude/skills/database-migrations/SKILL.md diff --git a/.claude/skills/database-migrations/SKILL.md b/.claude/skills/database-migrations/SKILL.md new file mode 100644 index 0000000..2b0de8a --- /dev/null +++ b/.claude/skills/database-migrations/SKILL.md @@ -0,0 +1,206 @@ +--- +name: database-migrations +description: Authoring, building, validating, and running database migrations in the @schemavaults/dbh repo. Use when creating or editing Kysely migration files, when working in a migrations/ directory, when a migration must be built/compiled or applied, 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 (@schemavaults/dbh) + +This repo uses [Kysely](https://kysely.dev/) migrations, 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. + +## Migration file format + +Each migration is a single file in a migrations directory (e.g. +`./src/db/migrations/`). 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`. + +### 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` (which re-exports Kysely's `sql` tag) and call `.execute(db)`: + +```ts +// 00002-add-users-index.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); +} +``` + +> Note: outside the repo, `sql` is imported from `@schemavaults/dbh/sql` instead +> of `@/sql`. The `@/sql` alias only resolves inside this repo's source. + +### 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 the 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 +# locally in this repo: +bun run cli validate-migration-directory ./src/tests/example-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 `sql` module the migrations +depend on: + +```bash +bunx @schemavaults/dbh build-db-migrations ./src/db/migrations \ + --outdir ./dist/migrations \ + --sql-module ./src/sql.ts \ + --sql-outdir ./dist +# locally in this repo: +bun run cli build-db-migrations ./src/tests/example-migrations \ + --outdir ./tests/tmp/migrations \ + --sql-module ./src/sql.ts \ + --sql-outdir ./tests/tmp +``` + +Key options: +- `` — directory of `.ts` migration sources (positional). +- `--outdir ` — where compiled `.js` migrations are written (required). +- `--sql-module ` — path to the `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 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`). + +```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: + +```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. +bun run cli validate-migration-directory ./src/db/migrations + +# 2. Build .ts migrations (+ sql module) to .js. +bun run cli build-db-migrations ./src/db/migrations \ + --outdir ./dist/migrations --sql-module ./src/sql.ts --sql-outdir ./dist + +# 3. Apply the built migrations. +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. From bebe1f2424b2fc0dcc1657c0114408ee9a6d0b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:13:31 +0000 Subject: [PATCH 4/8] docs: correct @/sql import guidance in migrations skill Migration files must always import sql from @/sql; the build-db-migrations step rewrites that literal specifier to the built sql.js, so external consumers must configure the @/sql alias locally rather than importing from @schemavaults/dbh/sql. https://claude.ai/code/session_01V7f97zUsEZoVrpfNj1S5KQ --- .claude/skills/database-migrations/SKILL.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.claude/skills/database-migrations/SKILL.md b/.claude/skills/database-migrations/SKILL.md index 2b0de8a..7ba51a8 100644 --- a/.claude/skills/database-migrations/SKILL.md +++ b/.claude/skills/database-migrations/SKILL.md @@ -79,8 +79,13 @@ export async function down(db: Kysely): Promise { } ``` -> Note: outside the repo, `sql` is imported from `@schemavaults/dbh/sql` instead -> of `@/sql`. The `@/sql` alias only resolves inside this repo's source. +> Important: migrations must **always** import `sql` from `@/sql` — even in +> external/consumer repos. The `build-db-migrations` step rewrites the literal +> `@/sql` import specifier to a relative path pointing at the built `sql.js`, so +> the import must be written exactly as `@/sql` for the build to work. This means +> external consumers must configure the `@/sql` path alias locally (e.g. in their +> `tsconfig.json` `paths`) so their migration sources typecheck. Do **not** import +> `sql` from `@schemavaults/dbh/sql` in migration files. ### Empty template migration From 2fc48935e3027960a36d5b3e8c93d67350aed100 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:33:14 +0000 Subject: [PATCH 5/8] docs: make migrations skill consumer-facing Reframe the database-migrations skill for consumers of the @schemavaults/dbh package: use bunx/npx @schemavaults/dbh for all CLI commands, add a one-time consumer setup section (local sql.ts module + @/sql tsconfig path alias), and remove in-repo references (bun run cli, ./src/tests/example-migrations). https://claude.ai/code/session_01V7f97zUsEZoVrpfNj1S5KQ --- .claude/skills/database-migrations/SKILL.md | 110 +++++++++++++------- 1 file changed, 70 insertions(+), 40 deletions(-) diff --git a/.claude/skills/database-migrations/SKILL.md b/.claude/skills/database-migrations/SKILL.md index 7ba51a8..73f1c39 100644 --- a/.claude/skills/database-migrations/SKILL.md +++ b/.claude/skills/database-migrations/SKILL.md @@ -1,19 +1,55 @@ --- name: database-migrations -description: Authoring, building, validating, and running database migrations in the @schemavaults/dbh repo. Use when creating or editing Kysely migration files, when working in a migrations/ directory, when a migration must be built/compiled or applied, or when the user mentions migrations, up()/down(), schema changes, or the dbh CLI's migrate/build-db-migrations/validate-migration-directory commands. +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 (@schemavaults/dbh) +# Database Migrations with @schemavaults/dbh -This repo uses [Kysely](https://kysely.dev/) migrations, 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. +`@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 — `bunx @schemavaults/dbh ` or +`npx @schemavaults/dbh `. The examples below use `bunx`; substitute +`npx` if you prefer npm. + +## 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 a migrations directory (e.g. -`./src/db/migrations/`). The rules are: +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 @@ -26,6 +62,7 @@ Each migration is a single file in a migrations directory (e.g. 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 @@ -52,10 +89,11 @@ export async function down(db: Kysely): Promise { ### Example: using the `sql` template tag For statements the builder can't express (or raw DDL), import `sql` from -`@/sql` (which re-exports Kysely's `sql` tag) and call `.execute(db)`: +`@/sql` (your local module from setup, which re-exports Kysely's `sql` tag) and +call `.execute(db)`: ```ts -// 00002-add-users-index.ts +// 00002-create-squirrels-table.ts import type { Kysely } from "@schemavaults/dbh"; import { sql } from "@/sql"; @@ -79,13 +117,11 @@ export async function down(db: Kysely): Promise { } ``` -> Important: migrations must **always** import `sql` from `@/sql` — even in -> external/consumer repos. The `build-db-migrations` step rewrites the literal -> `@/sql` import specifier to a relative path pointing at the built `sql.js`, so -> the import must be written exactly as `@/sql` for the build to work. This means -> external consumers must configure the `@/sql` path alias locally (e.g. in their -> `tsconfig.json` `paths`) so their migration sources typecheck. Do **not** import -> `sql` from `@schemavaults/dbh/sql` in migration files. +> 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 @@ -106,14 +142,12 @@ export async function down( ## Validating migrations -Before building or applying, assert the 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): +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 -# locally in this repo: -bun run cli validate-migration-directory ./src/tests/example-migrations ``` It reports each problem with an `[ERROR]`/`[WARN]` prefix: @@ -128,29 +162,25 @@ Treat duplicate numbers as warnings (non-fatal) with `--duplicates-as-warnings`. 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 `sql` module the migrations -depend on: +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/sql.ts \ + --sql-module ./src/db/sql.ts \ --sql-outdir ./dist -# locally in this repo: -bun run cli build-db-migrations ./src/tests/example-migrations \ - --outdir ./tests/tmp/migrations \ - --sql-module ./src/sql.ts \ - --sql-outdir ./tests/tmp ``` Key options: - `` — directory of `.ts` migration sources (positional). - `--outdir ` — where compiled `.js` migrations are written (required). -- `--sql-module ` — path to the `sql.ts` module to build alongside (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 on the PATH. +`build-db-migrations` requires `bun` to be installed and on the PATH. ## Running migrations @@ -160,13 +190,13 @@ from `process.env` (or an `--env-file`). ```bash # Apply all pending migrations (to latest): -npx @schemavaults/dbh migrate ./dist/migrations --environment production --env-file ./.env.production +bunx @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 +bunx @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 +bunx @schemavaults/dbh reverse ./dist/migrations 00000-template-migration --environment staging ``` Options for `migrate` / `reverse`: @@ -181,7 +211,7 @@ Each result line prints as `[Up|Down] : Date: Mon, 1 Jun 2026 22:38:44 +0000 Subject: [PATCH 6/8] docs: recommend bunx for build/validate, npx for migrate/reverse Most PostgreSQL drivers target Node.js rather than Bun, so apply/run migrations with npx; validating and building (Bun's bundler) use bunx. https://claude.ai/code/session_01V7f97zUsEZoVrpfNj1S5KQ --- .claude/skills/database-migrations/SKILL.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.claude/skills/database-migrations/SKILL.md b/.claude/skills/database-migrations/SKILL.md index 73f1c39..9ee6f75 100644 --- a/.claude/skills/database-migrations/SKILL.md +++ b/.claude/skills/database-migrations/SKILL.md @@ -11,9 +11,11 @@ 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 — `bunx @schemavaults/dbh ` or -`npx @schemavaults/dbh `. The examples below use `bunx`; substitute -`npx` if you prefer npm. +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) @@ -186,17 +188,18 @@ Key options: 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`). +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): -bunx @schemavaults/dbh migrate ./dist/migrations --environment production --env-file ./.env.production +npx @schemavaults/dbh migrate ./dist/migrations --environment production --env-file ./.env.production # Apply up to a specific version (the migration name w/o extension): -bunx @schemavaults/dbh migrate ./dist/migrations 00001-create-users-table --environment staging +npx @schemavaults/dbh migrate ./dist/migrations 00001-create-users-table --environment staging # Roll back down to a target version: -bunx @schemavaults/dbh reverse ./dist/migrations 00000-template-migration --environment staging +npx @schemavaults/dbh reverse ./dist/migrations 00000-template-migration --environment staging ``` Options for `migrate` / `reverse`: @@ -230,8 +233,8 @@ bunx @schemavaults/dbh validate-migration-directory ./src/db/migrations 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. -bunx @schemavaults/dbh migrate ./dist/migrations --environment production --env-file ./.env.production +# 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) From 45598b482348828fc27538634ce472547cdfe6c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:39:10 +0000 Subject: [PATCH 7/8] chore: bump version to 0.11.0 Minor bump for the new validate-migration-directory CLI command. https://claude.ai/code/session_01V7f97zUsEZoVrpfNj1S5KQ --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 14dcd37..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, From 40d9405c7ce5a2d0eb4f6999647b0f5c2f2df36d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:39:34 +0000 Subject: [PATCH 8/8] docs: update README version references to 0.11.0 https://claude.ai/code/session_01V7f97zUsEZoVrpfNj1S5KQ --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a362401..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