From 8e34b19752a5275ad6b112528a5699d62a4b6e37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 04:49:17 +0000 Subject: [PATCH] feat(migrations): complete idempotent migrator CLI and Docker packaging Finish the migrations module so it is a working CLI that converges a database to the Kysely migrations baked into a Docker image, and can build that image. Engine (databases.ts): - Fix undefined `compileModule` -> `modules.compile` when persisting the compiled up/down script on each migration row. - Fix dialect resolution indexing `factory` by raw `url.protocol` instead of the mapped dialect name (sqlite/postgres/mysql were never resolved). - Bind SQLite `executed` timestamps as ISO strings; a `Date` object cannot be bound by the sqlite driver. - `create()` accepts a string URL; `build()`/`create()`/`dialect()`/`destroy()` get explicit return types; unused/type-only kysely imports cleaned up. - Fix inverted `getFutureMigrations` filter. CLI (cli/*): - Register subcommands on `migrate`: apply, status, list, rollback, build. - apply idempotently converges: migrates up when the image is ahead, and rolls down (using the compiled down script stored in the database) when the image is behind; `--no-rollback` refuses instead. status/list/rollback added. - Shared `--database` / `--migrations-dir` options with DATABASE / MIGRATIONS_DIR env fallbacks resolved in support.ts. - build renders a two-stage Dockerfile (compile migrator, run `apply` on start) and invokes `docker build`; `--dockerfile` renders only. - Remove dead migrator.ts; mod.ts re-exports the library API; add `./cli` export. Add README documenting the workflow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PsF13E7YqG6oAshFoTdaUM --- migrations/README.md | 127 ++++++++++++++++ migrations/cli/apply.ts | 87 +++++++---- migrations/cli/build.ts | 125 +++++++++++++++- migrations/cli/list.ts | 42 ++++++ migrations/cli/main.ts | 19 ++- migrations/cli/rollback.ts | 61 ++++++++ migrations/cli/status.ts | 66 +++++++++ migrations/cli/support.ts | 74 ++++++++++ migrations/commands.ts | 106 +++----------- migrations/databases.ts | 42 +++--- migrations/deno.json | 17 ++- migrations/migrator.ts | 292 ------------------------------------- migrations/mod.ts | 13 +- 13 files changed, 625 insertions(+), 446 deletions(-) create mode 100644 migrations/README.md create mode 100644 migrations/cli/list.ts create mode 100644 migrations/cli/rollback.ts create mode 100644 migrations/cli/status.ts create mode 100644 migrations/cli/support.ts delete mode 100644 migrations/migrator.ts diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..070730a --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,127 @@ +# @linefusion/migrations + +An opinionated database migration tool built on [Kysely](https://kysely.dev). + +It turns a folder of Kysely migration files into a **self-contained Docker +image** that, on start, idempotently converges a database to exactly the +migrations baked into the image — migrating **up** when the image is ahead and +rolling **down** when it is behind. + +## How it works + +Each migration is a TypeScript file named `-.ts` exporting +`up` (and optionally `down`) functions: + +```ts +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("users") + .addColumn("id", "integer", (c) => c.primaryKey()) + .addColumn("name", "text", (c) => c.notNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("users").execute(); +} +``` + +When a migration is applied, the tool records it in a +`_migrations_migrations_source` table together with its **compiled source** +(the `compiled` column). Because the down script lives in the database, a +migration can be rolled back **even after it has been removed from disk** — for +example when a container built from an older image rolls back a newer database. + +Two tables are used: + +- `_migrations` — Kysely's own ledger of which migrations are applied. +- `_migrations_migrations_source` — the name, path, source and compiled + script of every migration the database has ever seen (`executed` holds the + timestamp, or `NULL` once rolled back). + +## CLI + +``` +migrate [options] + +Commands: + apply Idempotently converge the database to the migrations on disk + status Show whether the database is in sync with the migrations on disk + list List all migrations and their status + rollback Roll migrations back down + build Build a Docker image that runs the migrations automatically + +Common options: + -d, --database Database URL (env: DATABASE, default sqlite::memory:) + -m, --migrations-dir Migrations folder (env: MIGRATIONS_DIR, default .) +``` + +Supported database URLs: `sqlite:./db.sqlite`, `sqlite::memory:`, +`postgres://…`, `mysql://…`. + +### apply + +`apply` is the command the container runs on start. It is safe to run any +number of times: + +- **Image ahead of the database** → the missing migrations are run **up**. +- **Image behind the database** (there are applied migrations that no longer + exist on disk) → those migrations are rolled **down** using the compiled down + scripts stored in the database. Pass `--no-rollback` to fail instead. +- **In sync** → nothing happens. + +```sh +migrate apply -d postgres://user:pass@localhost/app -m ./migrations +``` + +### rollback + +```sh +migrate rollback # roll back the most recently applied migration +migrate rollback --to # roll back down to (but not including) +migrate rollback --all # roll everything back +``` + +## Docker + +`migrate build` generates a Dockerfile and builds an image that carries your +migrations and the compiled migrator, with `apply` as the default command: + +```sh +migrate build -t myapp-migrations:latest -m ./migrations +``` + +The generated image is roughly: + +```dockerfile +FROM denoland/deno:2.4.0 AS build +WORKDIR /build +RUN ["deno", "compile", "--allow-all", "--no-check", "--output", "/build/migrate", "jsr:@linefusion/migrations/cli"] + +FROM debian:stable-slim +WORKDIR /app +COPY --from=build /build/migrate /usr/local/bin/migrate +COPY migrations /app/migrations +ENV MIGRATIONS_DIR=/app/migrations +ENTRYPOINT ["migrate"] +CMD ["apply"] +``` + +Run it against any database by providing `DATABASE`: + +```sh +docker run --rm -e DATABASE=postgres://user:pass@db/app myapp-migrations:latest +``` + +The container migrates up (or down) to match the migrations baked into the +image and exits. Drop it in as an init container / one-shot job before your app +starts and every deploy converges the schema automatically. + +Useful `build` flags: + +- `--dockerfile ` — write the Dockerfile without building. +- `--build-image` / `--runtime-image` — override the base images. +- `--platform

` — cross-build (e.g. `linux/arm64`). +- `--push` — push the image after building. diff --git a/migrations/cli/apply.ts b/migrations/cli/apply.ts index cc2950d..9c7cfef 100644 --- a/migrations/cli/apply.ts +++ b/migrations/cli/apply.ts @@ -1,44 +1,71 @@ -import { NO_MIGRATIONS, NoMigrations } from "kysely"; -import { command } from "../commands.ts"; -import { NamedMigration } from "../databases.ts"; +import { $ } from "@david/dax"; -import modules from "@linefusion/modules"; +import type { Command } from "@cliffy/command"; +import { NO_MIGRATIONS, type NoMigrations } from "kysely"; -export default command("apply", "Apply migrations into the database") - .useDatabase() - .option("--allow-rollbacks", "Allows down migrations from database to be applied.", { default: false }) - .action(async ({ database, allowRollbacks }) => { - const { provider, migrator } = await modules.compile(await database); +import { command, DATABASE_OPTION, MIGRATIONS_DIR_OPTION } from "../commands.ts"; +import { createMigrator, displayResults, resolveDatabaseUrl, resolveMigrationsDir } from "./support.ts"; - let targetMigration: string | NoMigrations = NO_MIGRATIONS; +/** + * Idempotently converges the target database to the set of migrations that + * ship on disk (i.e. baked into the container image). + * + * - If the image is *ahead* of the database (there are disk migrations that + * have not been applied yet) the missing migrations are run *up*. + * - If the image is *behind* the database (there are applied migrations that + * no longer exist on disk) those migrations are rolled *down* using the + * compiled down scripts persisted on each migration row. + * + * Running it repeatedly is safe: once the database matches the image nothing + * happens. + */ +const apply: Command = command("apply", "Idempotently converge the database to the migrations on disk") + .option(...DATABASE_OPTION) + .option(...MIGRATIONS_DIR_OPTION) + .option( + "--no-rollback", + "Fail instead of rolling back when the database is ahead of the migrations on disk.", + ) + .action(async ({ database, migrationsDir, rollback }) => { + const { provider, migrator } = await createMigrator( + resolveDatabaseUrl(database), + resolveMigrationsDir(migrationsDir), + ); - let latestMigration: NamedMigration | null = null; const diskMigrations = provider.getDiskMigrations(); - if (diskMigrations.length > 0) { - latestMigration = diskMigrations[diskMigrations.length - 1]; - targetMigration = latestMigration.name; - } - - const databaseMigrations = provider.getDatabaseOnlyMigrations().filter((m) => m.executed); - if (databaseMigrations.length > 0 && !allowRollbacks) { - throw new Error( - "There are applied migrations in the database that are not on disk. Use --allow-rollbacks to sync this build.", - ); - } + const appliedAhead = provider + .getDatabaseOnlyMigrations() + .filter((migration) => migration.executed); - if (latestMigration) { - const hasSkippedMigrations = databaseMigrations.some((dm) => - provider.getMigrationIndex(dm) < provider.getMigrationIndex(latestMigration) - ); - if (hasSkippedMigrations) { + // The image is behind the database: there are applied migrations that no + // longer exist on disk. Converging means rolling them back down. + if (appliedAhead.length > 0) { + if (!rollback) { throw new Error( - "There are applied migrations in the database that are not on disk and are older than the currently applied one.", + `The database is ahead of this image by ${appliedAhead.length} migration(s) ` + + `(${appliedAhead.map((m) => m.name).join(", ")}). ` + + `Refusing to roll back because --no-rollback was set.`, ); } + $.logStep( + "behind", + `Database is ahead by ${appliedAhead.length} migration(s); rolling back.`, + ); } - provider.setMigrationFlags(MigrationFlags.Disk | MigrationFlags.Database); + // Target is the latest migration on disk, or a fully empty database when + // there are no migrations on disk at all. + let target: string | NoMigrations = NO_MIGRATIONS; + if (diskMigrations.length > 0) { + target = diskMigrations[diskMigrations.length - 1].name; + } - const result = await migrator.migrateTo(targetMigration); + const result = await migrator.migrateTo(target); displayResults(result); + + if (result.error) { + throw result.error; + } }); + +export default apply; diff --git a/migrations/cli/build.ts b/migrations/cli/build.ts index abd6cb3..a2a5c08 100644 --- a/migrations/cli/build.ts +++ b/migrations/cli/build.ts @@ -1,6 +1,123 @@ -import { command } from "../commands.ts"; +import { $ } from "@david/dax"; -export default command("build", "Builds a migration executable.") - .action(async () => { - console.log(globalThis.location); +import type { Command } from "@cliffy/command"; + +import * as fs from "@std/fs"; +import * as path from "@std/path"; + +import { command, MIGRATIONS_DIR_OPTION } from "../commands.ts"; +import { resolveMigrationsDir } from "./support.ts"; + +const DEFAULT_BUILD_IMAGE = "denoland/deno:2.4.0"; +const DEFAULT_RUNTIME_IMAGE = "debian:stable-slim"; +const DEFAULT_ENTRYPOINT = "jsr:@linefusion/migrations/cli"; + +/** + * Renders the Dockerfile that packages the migrator together with the + * migrations found on disk. + * + * The image is built in two stages: + * + * 1. A build stage compiles the migrator CLI into a single self-contained + * binary with `deno compile`. The migration files are NOT bundled into the + * binary; they are read from disk at runtime so the same binary works for + * any set of migrations. + * 2. A minimal runtime stage carries that binary plus the migration files and + * runs `apply` on start, converging the database to the migrations baked + * into the image. + */ +export function renderDockerfile(options: { + buildImage: string; + runtimeImage: string; + entrypoint: string; + migrationsDir: string; +}): string { + return [ + `# Generated by @linefusion/migrations.`, + ``, + `FROM ${options.buildImage} AS build`, + `WORKDIR /build`, + `RUN ["deno", "compile", "--allow-all", "--no-check", "--output", "/build/migrate", "${options.entrypoint}"]`, + ``, + `FROM ${options.runtimeImage}`, + `WORKDIR /app`, + `COPY --from=build /build/migrate /usr/local/bin/migrate`, + `COPY ${options.migrationsDir} /app/migrations`, + `ENV MIGRATIONS_DIR=/app/migrations`, + `ENTRYPOINT ["migrate"]`, + `CMD ["apply"]`, + ``, + ].join("\n"); +} + +const build: Command = command("build", "Build a Docker image that runs the migrations automatically") + .option(...MIGRATIONS_DIR_OPTION) + .option("-t, --tag ", "Image tag to build.", { default: "migrations:latest" }) + .option("--build-image ", "Base image used to compile the migrator.", { + default: DEFAULT_BUILD_IMAGE, + }) + .option("--runtime-image ", "Base image the final container runs on.", { + default: DEFAULT_RUNTIME_IMAGE, + }) + .option("--entrypoint ", "Module specifier for the migrator CLI to compile.", { + default: DEFAULT_ENTRYPOINT, + }) + .option("--platform ", "Target platform passed to `docker build`.") + .option("--push", "Push the image after building.", { default: false }) + .option("--dockerfile ", "Write the generated Dockerfile to this path instead of building.") + .action(async ({ migrationsDir, tag, buildImage, runtimeImage, entrypoint, platform, push, dockerfile }) => { + const migrationsPath = resolveMigrationsDir(migrationsDir); + const migrations = path.relative(Deno.cwd(), path.resolve(migrationsPath)) || "."; + + if (!fs.existsSync(path.resolve(migrationsPath))) { + throw new Error(`Migrations directory "${migrationsPath}" does not exist.`); + } + + const contents = renderDockerfile({ + buildImage, + runtimeImage, + entrypoint, + migrationsDir: migrations, + }); + + // Only asked to render the Dockerfile. + if (dockerfile) { + await Deno.writeTextFile(dockerfile, contents); + $.logStep("dockerfile", `Wrote ${dockerfile}`); + return; + } + + const docker = await $.which("docker"); + if (!docker) { + throw new Error( + "`docker` was not found on PATH. Install Docker or use --dockerfile to only render the Dockerfile.", + ); + } + + // Build from a temporary Dockerfile so the whole working directory (which + // contains the migrations folder) is available as the build context. + const tempfile = await Deno.makeTempFile({ prefix: "migrations-", suffix: ".Dockerfile" }); + try { + await Deno.writeTextFile(tempfile, contents); + + const args = ["build", "-f", tempfile, "-t", tag]; + if (platform) { + args.push("--platform", platform); + } + args.push("."); + + $.logStep("build", `Building image ${tag} ...`); + await $`docker ${args}`; + + if (push) { + $.logStep("push", `Pushing image ${tag} ...`); + await $`docker push ${tag}`; + } + + $.logStep("success", `Image ${tag} built.`); + } finally { + await Deno.remove(tempfile).catch(() => {}); + } }); + +export default build; diff --git a/migrations/cli/list.ts b/migrations/cli/list.ts new file mode 100644 index 0000000..7ac5a30 --- /dev/null +++ b/migrations/cli/list.ts @@ -0,0 +1,42 @@ +import { $ } from "@david/dax"; + +import type { Command } from "@cliffy/command"; + +import { command, DATABASE_OPTION, MIGRATIONS_DIR_OPTION } from "../commands.ts"; +import { createMigrator, resolveDatabaseUrl, resolveMigrationsDir } from "./support.ts"; + +/** + * Lists every migration known to the tool, whether it lives on disk (baked into + * the image), only in the database, or both, and whether it has been applied. + */ +const list: Command = command("list", "List all migrations and their status") + .option(...DATABASE_OPTION) + .option(...MIGRATIONS_DIR_OPTION) + .action(async ({ database, migrationsDir }) => { + const { provider, migrator } = await createMigrator( + resolveDatabaseUrl(database), + resolveMigrationsDir(migrationsDir), + ); + + const migrations = await migrator.getMigrations(); + const diskMigrations = provider.getDiskMigrations(); + + if (migrations.length === 0) { + console.log(""); + $.logStep("success", "No migrations found."); + console.log(""); + return; + } + + migrations.forEach((migration) => { + const onDisk = diskMigrations.some((dm) => dm.name === migration.name); + $.logStep( + onDisk ? "local" : "remote", + `${migration.name} (${migration.executedAt ? "applied" : "pending"})`, + ); + }); + + console.log(""); + }); + +export default list; diff --git a/migrations/cli/main.ts b/migrations/cli/main.ts index 7d7a25a..9f07c8e 100644 --- a/migrations/cli/main.ts +++ b/migrations/cli/main.ts @@ -1,18 +1,31 @@ +import type { Command } from "@cliffy/command"; + import { command } from "../commands.ts"; import * as databases from "../databases.ts"; -const main = command("migrate", "Database project migration tool") +import apply from "./apply.ts"; +import build from "./build.ts"; +import list from "./list.ts"; +import rollback from "./rollback.ts"; +import status from "./status.ts"; + +const main: Command = command("migrate", "Database project migration tool") .action(function () { this.showHelp(); - }); + }) + .command("apply", apply) + .command("status", status) + .command("list", list) + .command("rollback", rollback) + .command("build", build); export default main; if (import.meta.main) { main.parse(Deno.args) .catch((error) => { - console.error("Error:", error.message); + console.error("Error:", error?.message ?? error); Deno.exit(1); }).finally(async () => { await databases.destroy(); diff --git a/migrations/cli/rollback.ts b/migrations/cli/rollback.ts new file mode 100644 index 0000000..35cd9b0 --- /dev/null +++ b/migrations/cli/rollback.ts @@ -0,0 +1,61 @@ +import { $ } from "@david/dax"; + +import type { Command } from "@cliffy/command"; +import { NO_MIGRATIONS } from "kysely"; + +import { command, DATABASE_OPTION, MIGRATIONS_DIR_OPTION } from "../commands.ts"; +import { createMigrator, displayResults, resolveDatabaseUrl, resolveMigrationsDir } from "./support.ts"; + +/** + * Rolls the database back. By default a single (the most recently applied) + * migration is rolled down; `--to` rolls back down to (but not including) a + * specific migration and `--all` rolls everything back. + * + * Down scripts are read from the compiled column persisted on each migration + * row, so migrations can be rolled back even when they no longer exist on disk. + */ +const rollback: Command = command("rollback", "Roll migrations back down") + .option(...DATABASE_OPTION) + .option(...MIGRATIONS_DIR_OPTION) + .option("--to ", "Roll back down to (but not including) this migration.") + .option("--all", "Roll back every applied migration.", { default: false }) + .action(async ({ database, migrationsDir, to, all }) => { + const { migrator } = await createMigrator( + resolveDatabaseUrl(database), + resolveMigrationsDir(migrationsDir), + ); + + const migrations = await migrator.getMigrations(); + const applied = migrations.filter((m) => m.executedAt); + + if (applied.length === 0) { + $.logStep("success", "No applied migrations to roll back."); + return; + } + + if (all) { + const result = await migrator.migrateTo(NO_MIGRATIONS); + displayResults(result); + if (result.error) throw result.error; + return; + } + + if (to) { + const targetIndex = applied.findIndex((m) => m.name === to); + if (targetIndex === -1) { + throw new Error(`Migration "${to}" is not applied. You can only roll back down to applied migrations.`); + } + + const result = await migrator.migrateTo(to); + displayResults(result); + if (result.error) throw result.error; + return; + } + + // Default: roll back a single migration. + const result = await migrator.migrateDown(); + displayResults(result); + if (result.error) throw result.error; + }); + +export default rollback; diff --git a/migrations/cli/status.ts b/migrations/cli/status.ts new file mode 100644 index 0000000..4fc8e22 --- /dev/null +++ b/migrations/cli/status.ts @@ -0,0 +1,66 @@ +import { $ } from "@david/dax"; + +import type { Command } from "@cliffy/command"; + +import { command, DATABASE_OPTION, MIGRATIONS_DIR_OPTION } from "../commands.ts"; +import { createMigrator, resolveDatabaseUrl, resolveMigrationsDir } from "./support.ts"; + +/** + * Reports whether the database matches the migrations baked into the image and, + * if not, in which direction `apply` would converge it. + */ +const status: Command = command( + "status", + "Show whether the database is in sync with the migrations on disk", +) + .option(...DATABASE_OPTION) + .option(...MIGRATIONS_DIR_OPTION) + .action(async ({ database, migrationsDir }) => { + const { provider, migrator } = await createMigrator( + resolveDatabaseUrl(database), + resolveMigrationsDir(migrationsDir), + ); + + const migrations = await migrator.getMigrations(); + + const diskMigrations = provider.getDiskMigrations(); + const pending = migrations.filter( + (m) => !m.executedAt && diskMigrations.some((dm) => dm.name === m.name), + ); + const ahead = provider + .getDatabaseOnlyMigrations() + .filter((m) => m.executed); + + const applied = migrations.filter((m) => m.executedAt); + + $.logStep("disk", `${diskMigrations.length} migration(s) on disk.`); + $.logStep("applied", `${applied.length} migration(s) applied in the database.`); + + console.log(""); + + if (pending.length === 0 && ahead.length === 0) { + $.logStep("success", "Database is in sync with the migrations on disk."); + console.log(""); + return; + } + + if (pending.length > 0) { + $.logWarn( + "behind", + `Database is behind by ${pending.length} migration(s) that would be applied up: ` + + pending.map((m) => m.name).join(", "), + ); + } + + if (ahead.length > 0) { + $.logWarn( + "ahead", + `Database is ahead by ${ahead.length} migration(s) that would be rolled down: ` + + ahead.map((m) => m.name).join(", "), + ); + } + + console.log(""); + }); + +export default status; diff --git a/migrations/cli/support.ts b/migrations/cli/support.ts new file mode 100644 index 0000000..928ae87 --- /dev/null +++ b/migrations/cli/support.ts @@ -0,0 +1,74 @@ +import { $ } from "@david/dax"; + +import type { MigrationResultSet } from "kysely"; + +import * as databases from "../databases.ts"; +import { MigrationFlags } from "../databases.ts"; + +export type BuiltMigrator = Awaited>; + +/** Resolves the database connection URL from the flag, env, then default. */ +export function resolveDatabaseUrl(database?: string): string { + return database ?? Deno.env.get("DATABASE") ?? "sqlite::memory:"; +} + +/** Resolves the migrations folder from the flag, env, then default. */ +export function resolveMigrationsDir(migrationsDir?: string): string { + return migrationsDir ?? Deno.env.get("MIGRATIONS_DIR") ?? "."; +} + +/** + * Builds a migrator + provider bound to the given database URL and migrations + * folder. + * + * The provider is initialized (source table created, disk migrations compiled + * and synced into the database) and both disk and database migrations are made + * available so the migrator can migrate up (disk migrations) and, when the + * database is ahead of what ships on disk, migrate down using the compiled + * scripts persisted on each migration row. + */ +export async function createMigrator( + databaseUrl: string, + migrationsDir: string, +): Promise { + const built = await databases.build(databaseUrl, { + migrationFolder: migrationsDir, + }); + + built.provider.setMigrationFlags( + MigrationFlags.Disk | MigrationFlags.Database, + ); + + return built; +} + +/** Pretty prints the outcome of a migration run. */ +export function displayResults(results: MigrationResultSet): void { + const applied = results.results ?? []; + + applied.forEach((result) => { + $.logStep( + result.direction.toLowerCase(), + `Migration ${result.migrationName} ... ${result.status.toLowerCase()}`, + ); + if (result.status === "Error") { + $.logError("error", (result as any)?.error); + } + }); + + if (results.error) { + $.logError("error", (results.error as any)?.message ?? results.error); + } + + console.log(""); + + if (applied.length === 0) { + $.logStep("success", "Database is up to date. No migrations were applied."); + } else if (applied.length === 1) { + $.logStep("success", "1 migration applied."); + } else { + $.logStep("success", `${applied.length} migrations applied.`); + } + + console.log(""); +} diff --git a/migrations/commands.ts b/migrations/commands.ts index 264659c..dc590f8 100644 --- a/migrations/commands.ts +++ b/migrations/commands.ts @@ -1,86 +1,26 @@ import { Command } from "@cliffy/command"; -import { type Kysely, sql } from "kysely"; - -import * as databases from "./databases.ts"; - -export function useMigrationsDir(this: T) { - return this - .env("MIGRATIONS_DIR=", "Path to the migrations folder") - .option( - "-m, --migrations-dir ", - "Path to the migrations folder", - { - default: ".", - }, - ); -} - -export function useDatabase(this: T) { - return this - .env("DATABASE=", "Database connection URL") - .option("-d, --database ", "Database connection URL", { - default: "sqlite::memory:", - async action(value): Promise> { - if (!URL.canParse(value.database)) { - throw new Error(`Invalid connection string: ${value.database}`); - } - - const database = databases.create(new URL(value.database)); - try { - await sql`SELECT 1;`.execute(database); - } catch (err) { - throw new Error(`Failed to connect to the database at ${value}`, { - cause: err, - }); - } - - return database; - }, - }); -} - -export type ExtensionOf = { - (this: Type, ...args: any): any; -}; - -export type ExtensionsOf = Record>; - -export type ExtendedWith> = - & Type - & { - [Extension in keyof Extensions]: Extensions[Extension]; - }; - -export interface Extensible { - use>(this: Type, extensions: Extensions): ExtendedWith; -} - -export function extensible(obj: Type): Extensible { - return Object.defineProperty(obj, "use", { - value(plugins: ExtensionsOf) { - Object.entries(plugins).forEach(([name, plugin]) => { - Object.defineProperty(obj, name, { - value: plugin.bind(obj), - writable: false, - enumerable: true, - configurable: false, - }); - }); - return obj; - }, - }); -} - -export function command(name: string, description: string) { - const cmd = extensible( - new Command() - .name(name) - .description(description), - ); - - return cmd.use({ - useMigrationsDir, - useDatabase, - }); +/** + * Shared option specs so every sub-command exposes `--database` and + * `--migrations-dir` (and their `DATABASE` / `MIGRATIONS_DIR` env fallbacks, + * resolved in `./cli/support.ts`) identically. + * + * They are applied inline (`cmd.option(...DATABASE_OPTION)`) rather than through + * a wrapper so Cliffy keeps tracking the option types into `.action()`. + */ +export const DATABASE_OPTION = [ + "-d, --database ", + "Database connection URL (defaults to DATABASE or sqlite::memory:).", +] as const; + +export const MIGRATIONS_DIR_OPTION = [ + "-m, --migrations-dir ", + "Path to the migrations folder (defaults to MIGRATIONS_DIR or the current directory).", +] as const; + +/** Creates a named {@link Command}. */ +export function command(name: string, description: string): Command { + return new Command() + .name(name) + .description(description); } diff --git a/migrations/databases.ts b/migrations/databases.ts index 0483aa0..bee18ca 100644 --- a/migrations/databases.ts +++ b/migrations/databases.ts @@ -3,19 +3,9 @@ import * as path from "@std/path"; import * as modules from "@linefusion/modules"; -import { - Dialect, - DialectAdapter, - Kysely, - Migration, - MigrationProvider, - Migrator, - MysqlDialect, - PostgresAdapter, - PostgresDialect, - SqliteDatabase, - SqliteDialect, -} from "kysely"; +import { Kysely, Migrator, MysqlDialect, PostgresDialect, SqliteDialect } from "kysely"; + +import type { Dialect, Migration, MigrationProvider } from "kysely"; import * as mysql from "mysql2"; import Sqlite from "libsql"; @@ -251,7 +241,7 @@ export class CustomMigrationProvider implements MigrationProvider { name: migration.name, path: migration.path, source: migration.source, - compiled: await compileModule(migration.source, migration.path), + compiled: await modules.compile(migration.source, migration.path), executed: null, }) .where("name", "=", migration.name) @@ -272,7 +262,7 @@ export class CustomMigrationProvider implements MigrationProvider { name: migration.name, path: migration.path, source: migration.source, - compiled: await compileModule(migration.source, migration.path), + compiled: await modules.compile(migration.source, migration.path), executed: null, }) .execute() @@ -297,7 +287,7 @@ export class CustomMigrationProvider implements MigrationProvider { up: async (db: Kysely) => { await current.module.up(db); await db.updateTable(this.options.migrationSourceTableName) - .set({ executed: new Date() }).where("name", "=", current.name) + .set({ executed: new Date().toISOString() }).where("name", "=", current.name) .execute(); }, down: async (db: Kysely) => { @@ -319,7 +309,7 @@ export class CustomMigrationProvider implements MigrationProvider { */ getFutureMigrations(): MigrationList { return this.databaseMigrations - .filter((migration) => this.diskMigrations.some((dm) => dm.name === migration.name)); + .filter((migration) => !this.diskMigrations.some((dm) => dm.name === migration.name)); } getDiskMigrations(): MigrationList { @@ -390,7 +380,7 @@ export class CustomMigrationProvider implements MigrationProvider { const databases: Kysely[] = []; -export function dialect(url: URL) { +export function dialect(url: URL): Dialect { const protocols = { "sqlite": "sqlite" as const, "sqlite:": "sqlite" as const, @@ -419,11 +409,11 @@ export function dialect(url: URL) { const factory: Record< (typeof protocols)[keyof typeof protocols], - (url: URL) => Promise | Dialect + (url: URL) => Dialect > = { sqlite(url) { return new SqliteDialect({ - async database() { + database() { // "sqlite:./database.db" or "sqlite::memory:" return new Sqlite(url.pathname); }, @@ -445,7 +435,7 @@ export function dialect(url: URL) { ); } - const dialect = url.protocol as keyof typeof factory; + const dialect = protocols[url.protocol as keyof typeof protocols]; return factory[dialect](url); } @@ -453,7 +443,7 @@ export function dialect(url: URL) { export async function build( database: Kysely | string, options?: Partial, -) { +): Promise<{ database: Kysely; migrator: Migrator; provider: CustomMigrationProvider }> { if (typeof database === "string") { database = create(database); } @@ -476,16 +466,18 @@ export async function build( return { database, migrator, provider }; } -export function create(url: URL) { +export function create(url: string | URL): Kysely { + const resolved = typeof url === "string" ? new URL(url) : url; + const db = new Kysely({ - dialect: dialect(url), + dialect: dialect(resolved), }); databases.push(db); return db; } -export async function destroy() { +export async function destroy(): Promise { for (const db of databases) { await db.destroy(); } diff --git a/migrations/deno.json b/migrations/deno.json index 351ed5e..4c53864 100644 --- a/migrations/deno.json +++ b/migrations/deno.json @@ -3,14 +3,17 @@ "description": "An opinionated migrations utility.", "version": "0.0.1", "license": "MIT", - "exports": "./mod.ts", + "exports": { + ".": "./mod.ts", + "./cli": "./cli/main.ts" + }, "tasks": { - "dev": "deno run --allow-all --watch generate.ts", - "run": "deno run --env-file=.env -A ./migrator.ts", - "run:list": "deno run --env-file=.env -A ./migrator.ts --database list", - "run:apply": "deno run --env-file=.env -A ./migrator.ts --database apply", - "run:rollback": "deno run --env-file=.env -A ./migrator.ts --database rollback", - "run:test": "deno run --env-file=.env -A ./migrator.ts --database test", + "dev": "deno run --allow-all --watch cli/main.ts", + "run": "deno run --env-file=.env -A ./cli/main.ts", + "list": "deno run --env-file=.env -A ./cli/main.ts list", + "apply": "deno run --env-file=.env -A ./cli/main.ts apply", + "rollback": "deno run --env-file=.env -A ./cli/main.ts rollback", + "status": "deno run --env-file=.env -A ./cli/main.ts status", "run:compiled": "./build/x64/windows/migrator.exe", "build": { "dependencies": [ diff --git a/migrations/migrator.ts b/migrations/migrator.ts deleted file mode 100644 index aeaa49e..0000000 --- a/migrations/migrator.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { $ } from "@david/dax"; - -import * as fs from "@std/fs"; -import * as path from "@std/path"; - -import { createPool } from "mysql2"; - -import { Kysely, Migrator, MysqlDialect, NO_MIGRATIONS, sql } from "kysely"; -import type { Migration, MigrationProvider, MigrationResultSet, NoMigrations } from "kysely"; - -import { Command } from "@cliffy/command"; -import { command } from "./commands.ts"; - -async function refreshPath() { - const paths = (Deno.env.get("PATH") ?? "").split(path.DELIMITER); - - const overriddenPaths: string[] = [ - path.join(Deno.env.get("USERPROFILE") ?? Deno.env.get("HOME") ?? "~", ".deno/bin"), - ]; - - if (Deno.build.os === "windows") { - const searchPaths = - await $`powershell -NoProfile -Command "[System.Environment]::ExpandEnvironmentVariables([System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User'))"` - //.env({}) - .noThrow() - .text(); - overriddenPaths.push(...searchPaths.split(path.DELIMITER)); - } - - Deno.env.set( - "PATH", - [...Array.from(new Set([...paths, ...overriddenPaths]).values())].join(path.DELIMITER), - ); -} - -const databases: Kysely[] = []; - -function displayResults(results: MigrationResultSet) { - results.results?.forEach((result) => { - $.logStep(result.direction.toLowerCase(), `Migration ${result.migrationName} ... ${result.status.toLowerCase()}`); - if (result.status == "Error") { - $.logError(`error`, (result as any)?.error); - } - }); - - console.log(""); - - if (results.results?.length === 0) { - $.logStep("success", "No migrations were applied."); - } else if (results.results?.length === 1) { - $.logStep("success", `${results.results?.length} migration applied.`); - } else { - $.logStep("success", `${results.results?.length} migrations applied.`); - } - - console.log(""); -} - -const list = command("list", "List all migrations") - .option(DATABASE_FLAG, DATABASE_FLAG_DESCRIPTION, DATABASE_FLAG_OPTIONS) - .action(async ({ database }) => { - const { migrator, provider } = await createMigrator(await database); - provider.setMigrationFlags(MigrationFlags.Disk | MigrationFlags.Database); - - const migrations = await migrator.getMigrations(); - - const diskMigrations = provider.getDiskMigrations(); - migrations.forEach((m) => - $.logStep( - diskMigrations.some((dm) => dm.name == m.name) ? "local" : "remote", - `${m.name} (${m.executedAt ? "applied" : "not applied"})`, - ) - ); - - if (!migrations.length) { - console.log(""); - $.logStep("success", "No migrations found."); - console.log(""); - } - }); - -const rollback = command("rollback", "Rollback migrations applied to the database") - .option(DATABASE_FLAG, DATABASE_FLAG_DESCRIPTION, DATABASE_FLAG_OPTIONS) - .option("--to ", "Rollbacks all migrations down to a specific migration.") - .action(async ({ database, to }) => { - const { provider, migrator } = await createMigrator(await database); - - provider.setMigrationFlags(MigrationFlags.Database | MigrationFlags.Disk); - - const migrations = await migrator.getMigrations(); - - const appliedMigrations = migrations.filter((m) => m.executedAt); - if (appliedMigrations.length === 0) { - $.logStep("success", "No applied migrations found."); - return; - } - - let targetMigration: NoMigrations | string = to ?? NO_MIGRATIONS; - - if (!to) { - if (appliedMigrations.length > 1) { - targetMigration = appliedMigrations[appliedMigrations.length - 2].name; - } - } - - if (typeof targetMigration === "string") { - const targetIndex = appliedMigrations.findIndex((m) => m.name === targetMigration); - if (targetIndex === -1) { - throw new Error(`Migration "${to}" not found.`); - } - - if (targetIndex >= appliedMigrations.length - 1) { - throw new Error(`Migration "${to}" is the latest migration. No rollbacks needed.`); - } - - const migration = appliedMigrations[targetIndex]; - if (!migration.executedAt) { - throw $.logError( - "Error", - `Migration "${to}" is not applied. You can only rollback down to applied migrations.`, - ); - } - } - - const result = await migrator.migrateTo(targetMigration); - displayResults(result); - }); - -const deno = command("deno", "Installs deno for you.") - .action(async () => { - async function command(name: string) { - let cmd: string | string[] | undefined = await $.which(name); - - if (Deno.build.os === "windows") { - if (!cmd) { - const commandType = - await $`powershell -NoProfile -Command ${`Get-Command ${name} | Select-Object -ExpandProperty CommandType`}` - .noThrow() - .text(); - if (commandType == "Application" || commandType == "Alias") { - cmd = `${name}`; - cmd = ["powershell", "-NoProfile", "-Command", ...(Array.isArray(cmd) ? cmd : [cmd])]; - } else { - cmd = undefined; - } - } - } - - if (typeof cmd === "string") { - cmd = [cmd]; - } - - const handler = (...args: unknown[]) => { - if (!cmd) { - throw new Error(`Command "${name}" not found.`); - } - return $`${cmd} ${args}`; - }; - - Object.defineProperty(handler, "installed", { - value: !!cmd, - }); - - return handler as typeof handler & { - installed: boolean; - }; - } - - await refreshPath(); - - const commands: Record>> = { - powershell: await command("powershell")!, - pwsh: await command("pwsh")!, - deno: await command("deno")!, - curl: await command("curl")!, - sevenzip: await command("7z")!, - tar: await command("tar")!, - winget: await command("winget")!, - }; - - async function refreshCommands() { - await refreshPath(); - await Promise.all( - Object - .keys(commands) - .filter((n) => n != "refresh") - .map(async (key) => { - commands[key] = await command(key); - }), - ); - } - - async function isDependencyInstalled(program: string): Promise { - if (program in commands) { - if (commands[program]?.installed ?? false) { - return true; - } - $.logWarn(program, `unable to find \`${program}\` executable`); - return false; - } - commands[program] = await command(program); - return isDependencyInstalled(program); - } - - async function isAnyDependencyInstalled(programs: string[]): Promise { - const hasDependencies = (await Promise.all(programs.map(isDependencyInstalled))).some((dep) => dep); - if (!hasDependencies) { - $.logError("dependencies", "Couldn't find some required dependencies. Please install them and retry."); - } - return hasDependencies; - } - - async function linuxInstall() { - return await $.logGroup(async () => { - if (!await isAnyDependencyInstalled(["sh", "curl", "unzip", "7z"])) { - return false; - } - - await $`curl -fsSL https://deno.land/install.sh | sh`; - }); - } - - async function windowsInstall() { - return await $.logGroup(async () => { - if (!await isDependencyInstalled("pwsh")) { - const installPowershell = await $.confirm( - "Could not find PowerShell 7+. Proceed with installing it (winget)?", - { - default: true, - }, - ); - - if (!installPowershell) { - $.logError("powershell", "PowerShell 7+ is required to install Deno on Windows."); - return false; - } - - await commands.winget("install", "--id", "Microsoft.Powershell").noThrow(); - await refreshCommands(); - } - - await commands.pwsh("-Command", "irm https://deno.land/install.ps1 | iex").quiet(); - await refreshCommands(); - - if (!await isDependencyInstalled("deno")) { - $.logError("deno", "Could not find deno executable. Please install Deno manually."); - return false; - } - - $.logStep("deno", "Deno installed successfully."); - return true; - }); - } - - const success = await $.logGroup(async () => { - if (await isDependencyInstalled("deno")) { - $.logWarn("skipped", "Deno is already installed."); - return true; - } - - if (Deno.build.os === "windows") { - return await windowsInstall(); - } else if (Deno.build.os === "linux" || Deno.build.os === "darwin") { - return await linuxInstall(); - } else { - $.logError("OS", `Migrator is not supported on ${Deno.build.os}.`); - return false; - } - }).catch((err) => { - $.logError("Failed", "Failed to install Deno", err); - Deno.exit(1); - }); - - // const SELF_SOURCE = await Deno.readTextFile(import.meta.filename!); - }); - -await refreshPath(); - -/* -await main - .parse(Deno.args) - .catch((err) => { - $.logError("error", err?.message ?? err); - $.logWarn("stack", err?.stack); - }) - .finally(async () => { - // Release all database connections - for (const db of databases) { - await db.destroy(); - } - }); -*/ diff --git a/migrations/mod.ts b/migrations/mod.ts index e497a75..eab4c2b 100644 --- a/migrations/mod.ts +++ b/migrations/mod.ts @@ -1,2 +1,11 @@ -import { command } from "./commands.ts"; -import * as database from "./databases.ts"; +/** + * @linefusion/migrations + * + * An opinionated migrations utility built on top of Kysely. + * + * The library half (this module) exposes the migration provider and the helpers + * used to connect to a database and build a migrator. The CLI half (`./cli`) + * wraps them into the `migrate` command used to build and run migration images. + */ +export * from "./databases.ts"; +export { command } from "./commands.ts";