From f3e56fb74323a8d3556d3b0468a76fecbe0296b7 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Mon, 15 Nov 2021 18:44:15 -0600 Subject: [PATCH] Add CLI --- README.md | 139 +++++++- basic.ts | 43 +-- basic_test.ts | 49 +-- cli.ts | 292 +++++++++++++++++ cli_test.ts | 597 +++++++++++++++++++++++++++++++++++ examples/postgres/deps.ts | 1 + examples/postgres/migrate.ts | 17 + postgres.ts | 27 +- postgres_test.ts | 6 +- 9 files changed, 1098 insertions(+), 73 deletions(-) create mode 100644 cli.ts create mode 100644 cli_test.ts create mode 100755 examples/postgres/migrate.ts diff --git a/README.md b/README.md index 548597b..db7afbf 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,130 @@ import { PostgresMigrate } "https://raw.githubusercontent.com/udibo/migrate/0.2. ## Usage -### CLI (TODO) +### CLI To use the command line interface, you must create a script that will initialize -the Migrate instance and call the run command from [cli.ts](cli.ts). +the Migrate instance and call the run command from [cli.ts](cli.ts). An example +can be found [here](#postgres-cli). See [deno docs](https://doc.deno.land/https/deno.land/x/migrate@0.2.0/cli.ts) for more information. +#### Command: init + +Initializes the migration table for tracking which migrations have been applied. + +``` +$ ./migrate.ts init +Connecting to database +Creating migration table if it does not exist +Created migration table +``` + +#### Command: load + +Loads all migrations current path values into the migration table. + +``` +$ ./migrate.ts load +Connecting to database +Acquiring migrate lock +Acquired migrate lock +Loading migrations +2 new migrations found +1 migration updated +No migrations deleted +Releasing migrate lock +Released migrate lock +Done +``` + +#### Command: status + +Outputs the status of all migrations. By default it just outputs the counts. + +``` +$ ./migrate.ts status +Connecting to database +Checking loaded migrations +Status: + Total: 5 + Applied: 4 + File moved: 1 + File deleted: 1 + Not applied: 1 +``` + +If the --details or -d flag is provided, it will log the filenames of migrations +that have not been applied or have been changed since being applied. + +``` +$ ./migrate.ts status --details +Connecting to database +Checking loaded migrations +Status: + Total: 5 + Applied: 4 + File moved: 1 + 2_user_add_kyle.sql -> 2_user_add_kyle.ts + File deleted: 1 + 3_user_add_staff.sql + Not applied: 1 + 4_user_add_column_email.sql +``` + +#### Command: list + +Outputs a list of migrations. By default it outputs all migrations. + +``` +$ ./migrate.ts list +Connecting to database +Checking loaded migrations +All migrations: + 0_user_create.sql + applied at: Tue Nov 09 2021 12:10:32 GMT-0600 (Central Standard Time) + 1_user_add_admin.sql + applied at: Wed Nov 11 2021 18:31:08 GMT-0600 (Central Standard Time) + 2_user_add_kyle.sql + applied at: Sat Nov 13 2021 05:31:08 GMT-0600 (Central Standard Time) + file moved to: 2_user_add_kyle.ts + 3_user_add_staff.sql + applied at: Mon Nov 15 2021 15:31:08 GMT-0600 (Central Standard Time) + file deleted + 4_user_add_column_email.sql + not applied +``` + +If the --filter flag is provided, it will filter the migrations to only include +migrations that match the filter. The filter options are applied, unapplied, +renamed, and deleted. + +``` +$ ./migrate.ts list --filter=unapplied +Unapplied migrations: + 4_user_add_column_email.sql +``` + +#### Command: apply + +Applies all unapplied migrations and outputs the filenames. + +``` +$ ./migrate.ts apply +Connecting to database +Acquiring migrate lock +Acquired migrate lock +Checking loaded migrations +2 unapplied migrations +Applying migration: 0_user_create.sql +Applying migration: 1_user_add_column_email.sql +Finished applying all migrations +Releasing migrate lock +Released migrate lock +Done +``` + ### Postgres Examples of how to use migrate with postgres can be found @@ -51,8 +167,8 @@ for more information. A basic migrate script that will apply all unapplied migrations. -To use this script, copy [migrate.ts](examples/postgres/migrate.ts) and update -it with your migrate configuration. +To use this script, copy [migrate_basic.ts](examples/postgres/migrate_basic.ts) +and update it with your migrate configuration. ``` $ ./migrate_basic.ts @@ -72,16 +188,21 @@ Released advisory lock Done ``` -#### Postgres CLI (TODO) +#### Postgres CLI A CLI for the migration tool. -To use this script, copy [migrate_basic.ts](examples/postgres/migrate_basic.ts) -and update it with your migrate configuration. +To use this script, copy [migrate.ts](examples/postgres/migrate.ts) and update +it with your migrate configuration. -```sh +``` $ ./migrate.ts status -# TODO +Connecting to database +Checking loaded migrations +Status: + Total: 5 + Applied: 4 + Not applied: 1 ``` See [CLI](#cli) for more information about available CLI commands. diff --git a/basic.ts b/basic.ts index c38d509..5a2938e 100644 --- a/basic.ts +++ b/basic.ts @@ -1,5 +1,4 @@ -// add test coverage for it using Deno.run - +import { applyMigrations, init, loadMigrations } from "./cli.ts"; import { Migrate } from "./migrate.ts"; export async function apply(migrate: Migrate): Promise { @@ -7,42 +6,22 @@ export async function apply(migrate: Migrate): Promise { try { await migrate.connect(); } catch (error) { - console.error("Failed to connect to database"); + console.log("Failed to connect to database"); throw error; } - console.log("Acquiring advisory lock"); + console.log("Acquiring migrate lock"); const lock = await migrate.lock(); - console.log("Acquired advisory lock"); + console.log("Acquired migrate lock"); - try { - console.log("Creating migration table if it does not exist"); - await migrate.init(); - console.log("Created migration table"); - } catch { - console.log("Migration table already exists"); - } + await init(migrate); + const migrations = await loadMigrations(migrate); + await applyMigrations(migrate, migrations); - console.log("Loading migrations"); - await migrate.load(); - - console.log("Checking for unapplied migrations"); - const migrations = await migrate.getUnapplied(); - const migrationTerm = `migration${migrations.length !== 1 ? "s" : ""}`; - console.log( - `${migrations.length || "No"} unapplied ${migrationTerm} found`, - ); - if (migrations.length) { - for (const migration of migrations) { - console.log(`Applying migration: ${migration.path}`); - await migrate.apply(migration); - } - console.log("Finished applying all migrations"); - } - - console.log("Releasing advisory lock"); + console.log("Releasing migrate lock"); await lock.release(); - console.log("Released advisory lock"); - await migrate.end(); + console.log("Released migrate lock"); + console.log("Done"); + await migrate.end(); } diff --git a/basic_test.ts b/basic_test.ts index 44a3140..e1d96f0 100644 --- a/basic_test.ts +++ b/basic_test.ts @@ -1,4 +1,4 @@ -import { resolve } from "./deps.ts"; +import { delay, resolve } from "./deps.ts"; import { PostgresMigrate } from "./postgres.ts"; import { assertEquals, test, TestSuite } from "./test_deps.ts"; import { @@ -7,6 +7,7 @@ import { InitializedMigrateTest, options, } from "./test_postgres.ts"; +import "./basic.ts"; const applyTests = new TestSuite({ name: "apply", @@ -48,18 +49,18 @@ test( decoder.decode(output), `\ Connecting to database -Acquiring advisory lock -Acquired advisory lock +Acquiring migrate lock +Acquired migrate lock Creating migration table if it does not exist Created migration table Loading migrations -Checking for unapplied migrations -2 unapplied migrations found +2 new migrations found +2 unapplied migrations Applying migration: 0_user_create.sql Applying migration: 1_user_add_column_email.sql Finished applying all migrations -Releasing advisory lock -Released advisory lock +Releasing migrate lock +Released migrate lock Done `, ); @@ -75,6 +76,9 @@ test(applyTests, "applies unapplied migrations", async ({ migrate }) => { await migrate.load(); const migrations = await migrate.getUnapplied(); await migrate.apply(migrations[0]); + await migrate.end(); + await delay(1); + const process = Deno.run({ cmd: [ resolve(migrate.migrationsDir, "../migrate_basic.ts"), @@ -88,17 +92,19 @@ test(applyTests, "applies unapplied migrations", async ({ migrate }) => { decoder.decode(output), `\ Connecting to database -Acquiring advisory lock -Acquired advisory lock +Acquiring migrate lock +Acquired migrate lock Creating migration table if it does not exist Migration table already exists Loading migrations -Checking for unapplied migrations -1 unapplied migration found +No new migrations found +No migrations updated +No migrations deleted +1 unapplied migration Applying migration: 1_user_add_column_email.sql Finished applying all migrations -Releasing advisory lock -Released advisory lock +Releasing migrate lock +Released migrate lock Done `, ); @@ -115,6 +121,9 @@ test(applyTests, "no unapplied migrations", async ({ migrate }) => { for (const migration of migrations) { await migrate.apply(migration); } + await migrate.end(); + await delay(1); + const process = Deno.run({ cmd: [ resolve(migrate.migrationsDir, "../migrate_basic.ts"), @@ -128,15 +137,17 @@ test(applyTests, "no unapplied migrations", async ({ migrate }) => { decoder.decode(output), `\ Connecting to database -Acquiring advisory lock -Acquired advisory lock +Acquiring migrate lock +Acquired migrate lock Creating migration table if it does not exist Migration table already exists Loading migrations -Checking for unapplied migrations -No unapplied migrations found -Releasing advisory lock -Released advisory lock +No new migrations found +No migrations updated +No migrations deleted +No unapplied migrations +Releasing migrate lock +Released migrate lock Done `, ); diff --git a/cli.ts b/cli.ts new file mode 100644 index 0000000..9d82461 --- /dev/null +++ b/cli.ts @@ -0,0 +1,292 @@ +import { parse } from "./deps.ts"; +import { Migrate, Migration } from "./migrate.ts"; + +/** Filters used by the list command. */ +export enum ListFilter { + Applied = "applied", + Unapplied = "unapplied", + Moved = "moved", + Deleted = "deleted", +} + +/** Initializes the migration table for tracking which migrations have been applied. */ +export async function init(migrate: Migrate): Promise { + console.log("Creating migration table if it does not exist"); + try { + await migrate.init(); + console.log("Created migration table"); + } catch { + console.log("Migration table already exists"); + } +} + +/** + * Loads all migrations current path values into the migration table. + * Returns all unapplied migrations. + */ +export async function loadMigrations(migrate: Migrate): Promise { + console.log("Loading migrations"); + const before = await migrate.now(); + let migrations = await migrate.getAll(); + const deletedMigrationIds = new Set(migrations.map(({ id }) => id)); + await migrate.load(); + migrations = await migrate.getAll(); + for (const { id } of migrations) { + deletedMigrationIds.delete(id); + } + + const createdMigrations = migrations.filter((migration) => + migration.createdAt >= before + ); + console.log( + `${createdMigrations.length || "No"} new migration${ + createdMigrations.length !== 1 ? "s" : "" + } found`, + ); + + if (createdMigrations.length < migrations.length) { + const updatedMigrations = migrations.filter((migration) => + migration.createdAt < before && migration.updatedAt >= before + ); + console.log( + `${updatedMigrations.length || "No"} migration${ + updatedMigrations.length !== 1 ? "s" : "" + } updated`, + ); + + console.log( + `${deletedMigrationIds.size || "No"} migration${ + deletedMigrationIds.size !== 1 ? "s" : "" + } deleted`, + ); + } + + return migrations; +} + +/** + * Loads all migrations current path values into the migration table. + */ +export async function load(migrate: Migrate): Promise { + console.log("Acquiring migrate lock"); + const lock = await migrate.lock(); + console.log("Acquired migrate lock"); + + await loadMigrations(migrate); + + console.log("Releasing migrate lock"); + await lock.release(); + console.log("Released migrate lock"); + + console.log("Done"); +} + +/** + * Outputs the status of all migrations. By default it just outputs the counts. + * If the --details or -d flag is provided, it will log the filenames of migrations + * that have not been applied or have been changed since being applied. + */ +export async function status( + migrate: Migrate, + args: string[] = [], +): Promise { + const parsedArgs = parse(args, { + alias: { d: "details" }, + }); + const { details } = parsedArgs; + let total = 0, + lastId = -1; + const unappliedMigrations: Migration[] = [], + movedMigrations: Migration[] = [], + deletedMigrations: Migration[] = []; + + console.log("Checking loaded migrations"); + for (const migration of await migrate.getAll()) { + total++; + const { id, path, appliedPath } = migration; + if (id > lastId) lastId = id; + + if (!appliedPath) unappliedMigrations.push(migration); + else if (!path) deletedMigrations.push(migration); + else if (path != appliedPath) movedMigrations.push(migration); + } + + console.log("Status:"); + console.log(` Total: ${total}`); + console.log(` Applied: ${total - unappliedMigrations.length}`); + + if (movedMigrations.length) { + console.log(` File moved: ${movedMigrations.length}`); + if (details) { + for (const migration of movedMigrations) { + const { path, appliedPath } = migration; + console.log(` ${appliedPath} -> ${path}`); + } + } + } + + if (deletedMigrations.length) { + console.log(` File deleted: ${deletedMigrations.length}`); + if (details) { + for (const migration of deletedMigrations) { + const { appliedPath } = migration; + console.log(` ${appliedPath}`); + } + } + } + + if (unappliedMigrations.length) { + console.log(` Not applied: ${unappliedMigrations.length}`); + if (details) { + for (const migration of unappliedMigrations) { + console.log(` ${migration.path}`); + } + } + } +} + +/** + * Outputs a list of migrations. By default it outputs all migrations. + * If the --filter flag is provided, it will filter the migrations to only include + * migrations that match the filter. + * The filter options are applied, unapplied, renamed, and deleted. + */ +export async function list( + migrate: Migrate, + args: string[] = [], +): Promise { + const parsedArgs = parse(args); + const { filter } = parsedArgs; + console.log("Checking loaded migrations"); + let migrations = await migrate.getAll(); + + switch (filter) { + case ListFilter.Applied: + console.log( + migrations.length ? "Applied migrations:" : "No applied migrations", + ); + migrations = migrations.filter((migration: Migration) => + migration.appliedAt + ); + break; + case ListFilter.Unapplied: + console.log( + migrations.length ? "Unapplied migrations:" : "No unapplied migrations", + ); + migrations = migrations.filter((migration: Migration) => + !migration.appliedAt + ); + break; + case ListFilter.Moved: + console.log( + migrations.length ? "Moved migrations:" : "No moved migrations", + ); + migrations = migrations.filter((migration: Migration) => + !!migration.appliedPath && !!migration.path && + migration.appliedPath !== migration.path + ); + break; + case ListFilter.Deleted: + console.log( + migrations.length ? "Deleted migrations:" : "No deleted migrations", + ); + migrations = migrations.filter((migration: Migration) => !migration.path); + break; + default: + if (filter != null) console.warn("invalid filter"); + console.log(migrations.length ? "All migrations:" : "No migrations"); + } + + for (const migration of migrations) { + const { path, appliedPath, appliedAt } = migration; + console.log(` ${appliedPath ?? path}`); + if (appliedAt) { + console.log(` applied at: ${appliedAt}`); + } else if (filter !== ListFilter.Unapplied) { + console.log(` not applied`); + } + + if (appliedPath && path && path !== appliedPath) { + console.log(` file moved to: ${path}`); + } + + if (!path && filter !== ListFilter.Deleted) { + console.log(` file deleted`); + } + } +} + +/** Applies all unapplied migrations. */ +export async function applyMigrations( + migrate: Migrate, + migrations: Migration[], +): Promise { + const unappliedMigrations = migrations.filter((migration) => + !migration.appliedPath + ); + console.log( + `${unappliedMigrations.length || "No"} unapplied migration${ + unappliedMigrations.length !== 1 ? "s" : "" + }`, + ); + if (unappliedMigrations.length) { + for (const migration of unappliedMigrations) { + console.log(`Applying migration: ${migration.path}`); + await migrate.apply(migration); + } + console.log("Finished applying all migrations"); + } +} + +/** + * Applies all unapplied migrations and outputs the filenames. + */ +export async function apply(migrate: Migrate): Promise { + console.log("Acquiring migrate lock"); + const lock = await migrate.lock(); + console.log("Acquired migrate lock"); + + console.log("Checking loaded migrations"); + const migrations = await migrate.getUnapplied(); + await applyMigrations(migrate, migrations); + + console.log("Releasing migrate lock"); + await lock.release(); + console.log("Released migrate lock"); + + console.log("Done"); +} + +export type Command = ( + migrate: Migrate, + args?: string[], +) => Promise; +export interface Commands { + [command: string]: Command; +} +/** Commands used by the migrate cli tool. */ +export const commands: Commands = { + init, + load, + status, + list, + apply, +}; + +/** Runs migrate commands based on `Deno.args`. */ +export async function run(migrate: Migrate) { + const [command] = Deno.args; + if (commands[command]) { + console.log("Connecting to database"); + try { + await migrate.connect(); + } catch (error) { + console.log("Failed to connect to database"); + throw error; + } + await commands[command](migrate, Deno.args.slice(1)); + await migrate.end(); + } else { + console.log("command not found"); + } +} diff --git a/cli_test.ts b/cli_test.ts new file mode 100644 index 0000000..5790bf8 --- /dev/null +++ b/cli_test.ts @@ -0,0 +1,597 @@ +import { delay, resolve } from "./deps.ts"; +import { PostgresMigrate } from "./postgres.ts"; +import { assertEquals, test, TestSuite } from "./test_deps.ts"; +import { + cleanupInit, + exampleMigrationsDir, + InitializedMigrateTest, + options, +} from "./test_postgres.ts"; +import "./cli.ts"; + +const cliTests = new TestSuite({ + name: "CLI", + async beforeEach(context: InitializedMigrateTest) { + context.migrate = new PostgresMigrate({ + ...options, + migrationsDir: exampleMigrationsDir, + }); + const { migrate } = context; + await cleanupInit(migrate); + try { + await migrate.connect(); + await migrate.client.queryArray(`DROP TABLE "user"`); + } catch { + // user table did not exist + } finally { + await migrate.end(); + } + }, + async afterEach({ migrate }: InitializedMigrateTest) { + await migrate.end(); + }, +}); + +const cliInitTests = new TestSuite({ + name: "init", + suite: cliTests, +}); + +test( + cliInitTests, + "creates migration table if it does not exist yet", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "init", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Creating migration table if it does not exist +Created migration table +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliInitTests, + "migration table already exists", + async ({ migrate }) => { + await migrate.connect(); + await migrate.init(); + await migrate.end(); + + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "init", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Creating migration table if it does not exist +Migration table already exists +`, + ); + } finally { + process.close(); + } + }, +); + +const cliLoadTests = new TestSuite({ + name: "load", + suite: cliTests, + async beforeEach({ migrate }: InitializedMigrateTest) { + await migrate.connect(); + await migrate.init(); + await migrate.end(); + }, +}); + +test( + cliLoadTests, + "new migrations only", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "load", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Acquiring migrate lock +Acquired migrate lock +Loading migrations +2 new migrations found +Releasing migrate lock +Released migrate lock +Done +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliLoadTests, + "moved migration", + async ({ migrate }) => { + await migrate.connect(); + await migrate.load(); + await migrate.client.queryArray + `UPDATE migration SET path = ${"1_old_name.sql"}, applied_at = now() WHERE id = ${1}`; + const migrations = await migrate.getUnapplied(); + await migrate.apply(migrations[0]); + await migrate.end(); + await delay(1); + + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "load", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Acquiring migrate lock +Acquired migrate lock +Loading migrations +No new migrations found +1 migration updated +No migrations deleted +Releasing migrate lock +Released migrate lock +Done +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliLoadTests, + "deleted migration", + async ({ migrate }) => { + await migrate.connect(); + await migrate.load(); + await migrate.client.queryArray` + INSERT INTO migration (id, path, applied_path, applied_at) VALUES + (2, '2_user_add_admin.sql', NULL, NULL); + `; + const migrations = await migrate.getUnapplied(); + await migrate.apply(migrations[0]); + await migrate.end(); + await delay(1); + + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "load", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Acquiring migrate lock +Acquired migrate lock +Loading migrations +No new migrations found +No migrations updated +1 migration deleted +Releasing migrate lock +Released migrate lock +Done +`, + ); + } finally { + process.close(); + } + }, +); + +const cliStatusTests = new TestSuite({ + name: "status", + suite: cliTests, + async beforeEach({ migrate }: InitializedMigrateTest) { + await migrate.connect(); + await migrate.init(); + await migrate.client.queryArray` + INSERT INTO migration (id, path, applied_path, applied_at) VALUES + (0, '0_user_create.sql', '0_user_create.sql', NOW()), + (1, '1_user_add_admin.sql', '1_user_add_admin.sql', NOW()), + (2, '2_user_add_kyle.ts', '2_user_add_kyle.sql', NOW()), + (3, NULL, '3_user_add_staff.sql', NOW()), + (4, '4_user_add_column_email.sql', NULL, NULL); + `; + await migrate.end(); + }, +}); + +test( + cliStatusTests, + "without details", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "status", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Checking loaded migrations +Status: + Total: 5 + Applied: 4 + File moved: 1 + File deleted: 1 + Not applied: 1 +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliStatusTests, + "with details", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "status", + "--details", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Checking loaded migrations +Status: + Total: 5 + Applied: 4 + File moved: 1 + 2_user_add_kyle.sql -> 2_user_add_kyle.ts + File deleted: 1 + 3_user_add_staff.sql + Not applied: 1 + 4_user_add_column_email.sql +`, + ); + } finally { + process.close(); + } + }, +); + +const cliListTests = new TestSuite({ + name: "list", + suite: cliTests, + async beforeEach({ migrate }: InitializedMigrateTest) { + await migrate.connect(); + await migrate.init(); + await migrate.client.queryArray` + INSERT INTO migration (id, path, applied_path, applied_at) VALUES + (0, '0_user_create.sql', '0_user_create.sql', NOW()), + (1, '1_user_add_admin.sql', '1_user_add_admin.sql', NOW()), + (2, '2_user_add_kyle.ts', '2_user_add_kyle.sql', NOW()), + (3, NULL, '3_user_add_staff.sql', NOW()), + (4, '4_user_add_column_email.sql', NULL, NULL); + `; + await migrate.end(); + }, +}); + +function decodeListOutput(output: Uint8Array): string { + const decoder = new TextDecoder(); + return decoder.decode(output) + .replace(/applied at: [^\n]*\n/g, "applied at: {DATE}\n"); +} + +test( + cliListTests, + "all migrations", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "list", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + assertEquals( + decodeListOutput(output), + `\ +Connecting to database +Checking loaded migrations +All migrations: + 0_user_create.sql + applied at: {DATE} + 1_user_add_admin.sql + applied at: {DATE} + 2_user_add_kyle.sql + applied at: {DATE} + file moved to: 2_user_add_kyle.ts + 3_user_add_staff.sql + applied at: {DATE} + file deleted + 4_user_add_column_email.sql + not applied +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliListTests, + "applied migrations", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "list", + "--filter=applied", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + assertEquals( + decodeListOutput(output), + `\ +Connecting to database +Checking loaded migrations +Applied migrations: + 0_user_create.sql + applied at: {DATE} + 1_user_add_admin.sql + applied at: {DATE} + 2_user_add_kyle.sql + applied at: {DATE} + file moved to: 2_user_add_kyle.ts + 3_user_add_staff.sql + applied at: {DATE} + file deleted +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliListTests, + "unapplied migrations", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "list", + "--filter=unapplied", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + assertEquals( + decodeListOutput(output), + `\ +Connecting to database +Checking loaded migrations +Unapplied migrations: + 4_user_add_column_email.sql +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliListTests, + "moved migrations", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "list", + "--filter=moved", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + assertEquals( + decodeListOutput(output), + `\ +Connecting to database +Checking loaded migrations +Moved migrations: + 2_user_add_kyle.sql + applied at: {DATE} + file moved to: 2_user_add_kyle.ts +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliListTests, + "deleted migrations", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "list", + "--filter=deleted", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + assertEquals( + decodeListOutput(output), + `\ +Connecting to database +Checking loaded migrations +Deleted migrations: + 3_user_add_staff.sql + applied at: {DATE} +`, + ); + } finally { + process.close(); + } + }, +); + +const cliApplyTests = new TestSuite({ + name: "apply", + suite: cliTests, + async beforeEach({ migrate }: InitializedMigrateTest) { + await migrate.connect(); + await migrate.init(); + await migrate.load(); + await migrate.end(); + }, +}); + +test( + cliApplyTests, + "all unapplied", + async ({ migrate }) => { + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "apply", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Acquiring migrate lock +Acquired migrate lock +Checking loaded migrations +2 unapplied migrations +Applying migration: 0_user_create.sql +Applying migration: 1_user_add_column_email.sql +Finished applying all migrations +Releasing migrate lock +Released migrate lock +Done +`, + ); + } finally { + process.close(); + } + }, +); + +test( + cliApplyTests, + "no unapplied", + async ({ migrate }) => { + await migrate.connect(); + const migrations = await migrate.getUnapplied(); + for (const migration of migrations) { + await migrate.apply(migration); + } + await migrate.end(); + + const process = Deno.run({ + cmd: [ + resolve(migrate.migrationsDir, "../migrate.ts"), + "apply", + ], + stdout: "piped", + }); + try { + const output = await process.output(); + const decoder = new TextDecoder(); + assertEquals( + decoder.decode(output), + `\ +Connecting to database +Acquiring migrate lock +Acquired migrate lock +Checking loaded migrations +No unapplied migrations +Releasing migrate lock +Released migrate lock +Done +`, + ); + } finally { + process.close(); + } + }, +); diff --git a/examples/postgres/deps.ts b/examples/postgres/deps.ts index f55e5ff..f9f0ec6 100644 --- a/examples/postgres/deps.ts +++ b/examples/postgres/deps.ts @@ -6,3 +6,4 @@ export { export { PostgresMigrate } from "../../postgres.ts"; export { apply } from "../../basic.ts"; +export { run } from "../../cli.ts"; diff --git a/examples/postgres/migrate.ts b/examples/postgres/migrate.ts new file mode 100755 index 0000000..09e9689 --- /dev/null +++ b/examples/postgres/migrate.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env -S deno run -A + +import { dirname, fromFileUrl, PostgresMigrate, resolve, run } from "./deps.ts"; + +const isTestBuild = Deno.env.get("MIGRATE_TEST_BUILD") === "true"; +const migrate = new PostgresMigrate({ + migrationsDir: resolve(dirname(fromFileUrl(import.meta.url)), "./migrations"), + client: { + hostname: isTestBuild ? "postgres" : "localhost", + port: isTestBuild ? 5432 : 6001, + database: "postgres", + user: "postgres", + password: "postgres", + }, +}); + +run(migrate); diff --git a/postgres.ts b/postgres.ts index 0c8d59d..51181f9 100644 --- a/postgres.ts +++ b/postgres.ts @@ -92,17 +92,22 @@ export class PostgresMigrate async load(): Promise { const migrationFiles = await this.getFiles(); - type Row = Pick; + type Row = Pick; const { rows } = await this.client.queryObject({ - text: `SELECT id, applied_path FROM migration WHERE path IS NOT NULL`, + text: + `SELECT id, path, applied_path FROM migration WHERE path IS NOT NULL`, camelcase: true, }); - const deletedMigrations = new Set(); - for (const row of rows) { - deletedMigrations.add(row); + const migrationPaths = new Map(); + const appliedMigrationIds = new Set(); + const deletedMigrationIds = new Set(); + for (const { id, path, appliedPath } of rows) { + deletedMigrationIds.add(id); + if (path) migrationPaths.set(id, path); + if (appliedPath) appliedMigrationIds.add(id); } - for (const row of migrationFiles) { - deletedMigrations.delete(row); + for (const { id } of migrationFiles) { + deletedMigrationIds.delete(id); } const UPSERT_MIGRATION_FILE_SQL = ` @@ -111,8 +116,8 @@ export class PostgresMigrate ON CONFLICT (id) DO UPDATE SET path = EXCLUDED.path; `; - for (const { id, appliedPath } of deletedMigrations) { - if (appliedPath) { + for (const id of deletedMigrationIds) { + if (appliedMigrationIds.has(id)) { await this.client.queryArray(UPSERT_MIGRATION_FILE_SQL, id, null); } else { await this.client.queryArray`DELETE FROM migration WHERE id = ${id}`; @@ -120,7 +125,9 @@ export class PostgresMigrate } for (const { id, path } of migrationFiles) { - await this.client.queryArray(UPSERT_MIGRATION_FILE_SQL, id, path); + if (path !== migrationPaths.get(id)) { + await this.client.queryArray(UPSERT_MIGRATION_FILE_SQL, id, path); + } } } diff --git a/postgres_test.ts b/postgres_test.ts index c794607..902513f 100644 --- a/postgres_test.ts +++ b/postgres_test.ts @@ -356,9 +356,9 @@ test( path: "unapplied/1_user_add_column_email.sql", appliedPath: null, }); - assert(migration.createdAt >= before); - assert(migration.createdAt <= after); - assertEquals(migration.updatedAt, migration.createdAt); + assert(migration.createdAt <= before); + assert(migration.updatedAt >= before); + assert(migration.updatedAt <= after); assertEquals(migrations.slice(2), []); } finally {