stepwise-migrations 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Stepwise Migrations
2
+
3
+ A tool for managing migrations in a Postgres database.
4
+ Loosely based, but in Typescript.
5
+
6
+ Only "up" migrations are supported so far, but what more do you need?
7
+
8
+ ## Usage
9
+
10
+ ### Migrate
11
+
12
+ ```bash
13
+ npx stepwise-migrations migrate \
14
+ --connection=postgresql://postgres:postgres@127.0.0.1:5432/mydb \
15
+ --schema=myschema \
16
+ --path=./db/migration/
17
+ ```
18
+
19
+ Outputs:
20
+
21
+ ```
22
+ Connected to the database
23
+ Creating schema collie
24
+ Schema collie created
25
+ Creating migration history table
26
+ Migration history table created
27
+ Found 2 migration files
28
+ Applied migration V0_01__connect_session_table.sql
29
+ Applied migration V0_02__auth.sql
30
+
31
+ All done!
32
+ ```
33
+
34
+ ### Info
35
+
36
+ ```bash
37
+ npx stepwise-migrations info \
38
+ --connection=postgresql://postgres:postgres@127.0.0.1:5432/mydb \
39
+ --schema=myschema \
40
+ --path=./db/migration/
41
+ ```
42
+
43
+ Outputs:
44
+
45
+ ```
46
+ Connected to the database
47
+ Showing information about the current state of the migrations in the database
48
+ Migration history schema exists
49
+ Migration history table exists
50
+ Migration history:
51
+ ┌─────────┬────┬────────────────────────────────────┬────────────────────────────────────────────────────────────────────┬────────────┬──────────────────────────────┐
52
+ │ (index) │ id │ name │ hash │ applied_by │ applied_at │
53
+ ├─────────┼────┼────────────────────────────────────┼────────────────────────────────────────────────────────────────────┼────────────┼──────────────────────────────┤
54
+ │ 0 │ 1 │ 'V0_01__connect_session_table.sql' │ 'f08638e58139ae0e2dda24b1bdba29f3f2128597066a23d2bb382d448bbe9d7e' │ 'postgres' │ '2024-11-23 16:24:50.437496' │
55
+ │ 1 │ 2 │ 'V0_02__auth.sql' │ '0a4c5df39f03df85cb68ef0b297b913d7c15477fa9dcba13b6e0577d88258a8e' │ 'postgres' │ '2024-11-23 16:24:50.440493' │
56
+ └─────────┴────┴────────────────────────────────────┴────────────────────────────────────────────────────────────────────┴────────────┴──────────────────────────────┘
57
+ ```
58
+
59
+ ### Drop
60
+
61
+ ```bash
62
+ npx stepwise-migrations drop \
63
+ --connection=postgresql://postgres:postgres@127.0.0.1:5432/mydb \
64
+ --schema=myschema
65
+ ```
66
+
67
+ Outputs:
68
+
69
+ ```
70
+ Connected to the database
71
+ Dropping the tables, schema and migration history table
72
+
73
+ All done!
74
+ ```
package/dist/db.js ADDED
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.dbCreateHistoryTable = exports.dbCreateSchema = exports.dbMigrationHistory = exports.dbTableExists = exports.dbHistorySchemaExists = exports.dbConnect = void 0;
46
+ const pg_1 = __importStar(require("pg"));
47
+ pg_1.default.types.setTypeParser(1114, function (stringValue) {
48
+ return stringValue; //1114 for time without timezone type
49
+ });
50
+ pg_1.default.types.setTypeParser(1082, function (stringValue) {
51
+ return stringValue; //1082 for date type
52
+ });
53
+ const dbConnect = (argv) => __awaiter(void 0, void 0, void 0, function* () {
54
+ const pool = new pg_1.Pool({
55
+ connectionString: argv.connection,
56
+ ssl: argv.ssl === "true",
57
+ });
58
+ let client;
59
+ try {
60
+ client = yield pool.connect();
61
+ yield client.query("SELECT 1");
62
+ console.log("Connected to the database");
63
+ }
64
+ catch (error) {
65
+ console.error("Failed to connect to the database", error);
66
+ process.exit(1);
67
+ }
68
+ return client;
69
+ });
70
+ exports.dbConnect = dbConnect;
71
+ const dbHistorySchemaExists = (client, schema) => __awaiter(void 0, void 0, void 0, function* () {
72
+ const result = yield client.query(`SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '${schema}')`);
73
+ return result.rows[0].exists;
74
+ });
75
+ exports.dbHistorySchemaExists = dbHistorySchemaExists;
76
+ const dbTableExists = (client, schema) => __awaiter(void 0, void 0, void 0, function* () {
77
+ const tableExistsResult = yield client.query(`SELECT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stepwise_migrations' and schemaname = '${schema}')`);
78
+ return tableExistsResult.rows[0].exists;
79
+ });
80
+ exports.dbTableExists = dbTableExists;
81
+ const dbMigrationHistory = (client, schema) => __awaiter(void 0, void 0, void 0, function* () {
82
+ const migrationsQuery = yield client.query(`SELECT * FROM ${schema}.stepwise_migrations`);
83
+ return migrationsQuery.rows;
84
+ });
85
+ exports.dbMigrationHistory = dbMigrationHistory;
86
+ const dbCreateSchema = (client, schema) => __awaiter(void 0, void 0, void 0, function* () {
87
+ console.log(`Creating schema ${schema}`);
88
+ yield client.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
89
+ console.log(`Schema ${schema} created`);
90
+ });
91
+ exports.dbCreateSchema = dbCreateSchema;
92
+ const dbCreateHistoryTable = (client, schema) => __awaiter(void 0, void 0, void 0, function* () {
93
+ console.log(`Creating migration history table`);
94
+ yield client.query(`CREATE TABLE IF NOT EXISTS ${schema}.stepwise_migrations (
95
+ id SERIAL PRIMARY KEY,
96
+ name TEXT NOT NULL,
97
+ hash TEXT NOT NULL,
98
+ applied_by TEXT NOT NULL DEFAULT current_user,
99
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
100
+ )`);
101
+ console.log(`Migration history table created`);
102
+ });
103
+ exports.dbCreateHistoryTable = dbCreateHistoryTable;
package/dist/index.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
4
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
5
+ return new (P || (P = Promise))(function (resolve, reject) {
6
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
7
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
8
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
9
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
10
+ });
11
+ };
12
+ var __importDefault = (this && this.__importDefault) || function (mod) {
13
+ return (mod && mod.__esModule) ? mod : { "default": mod };
14
+ };
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ const yargs_1 = __importDefault(require("yargs"));
17
+ const db_1 = require("./db");
18
+ const migrate_1 = require("./migrate");
19
+ const utils_1 = require("./utils");
20
+ const main = () => __awaiter(void 0, void 0, void 0, function* () {
21
+ const argv = (0, yargs_1.default)(process.argv.slice(2)).argv;
22
+ (0, utils_1.validateArgs)(argv);
23
+ const schema = argv.schema;
24
+ const command = argv._[0];
25
+ const client = yield (0, db_1.dbConnect)(argv);
26
+ const historySchemaExists = yield (0, db_1.dbHistorySchemaExists)(client, schema);
27
+ const tableExists = yield (0, db_1.dbTableExists)(client, schema);
28
+ if (command === "migrate") {
29
+ if (!historySchemaExists) {
30
+ yield (0, db_1.dbCreateSchema)(client, schema);
31
+ }
32
+ if (!tableExists) {
33
+ yield (0, db_1.dbCreateHistoryTable)(client, schema);
34
+ }
35
+ const migrationHistory = yield (0, db_1.dbMigrationHistory)(client, schema);
36
+ const migrationFiles = yield (0, utils_1.readMigrationFiles)(argv.path);
37
+ console.log(`Found ${migrationFiles.length} migration files`);
38
+ (0, migrate_1.validateMigrationFiles)(migrationFiles, migrationHistory);
39
+ const migrationsToApply = migrationFiles.slice(migrationHistory.length);
40
+ for (const { filename, contents, hash } of migrationsToApply) {
41
+ yield (0, migrate_1.applyMigration)(client, schema, filename, contents, hash);
42
+ }
43
+ console.log("\nAll done!");
44
+ }
45
+ else if (command === "info") {
46
+ console.log("Showing information about the current state of the migrations in the database");
47
+ console.log(historySchemaExists ? "Schema exists" : "Schema does not exist");
48
+ console.log(tableExists
49
+ ? "Migration history table exists"
50
+ : "Migration history table does not exist");
51
+ console.log("Migration history:");
52
+ console.table(yield (0, db_1.dbMigrationHistory)(client, schema));
53
+ }
54
+ else if (command === "drop") {
55
+ console.log("Dropping the tables, schema and migration history table");
56
+ yield client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
57
+ console.log("\nAll done!");
58
+ }
59
+ client.release();
60
+ process.exit(0);
61
+ });
62
+ main();
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.applyMigration = exports.validateMigrationFiles = void 0;
13
+ const validateMigrationFiles = (migrationFiles, migrationHistory) => {
14
+ if (migrationFiles.length === 0) {
15
+ console.log("No migrations found");
16
+ process.exit(0);
17
+ }
18
+ if (migrationFiles.length < migrationHistory.length) {
19
+ console.error("Error: migration history is longer than the number of migration files, aborting.");
20
+ process.exit(1);
21
+ }
22
+ if (migrationFiles.length === migrationHistory.length) {
23
+ console.log("All migrations are already applied");
24
+ process.exit(0);
25
+ }
26
+ for (let i = 0; i < migrationFiles.length; i++) {
27
+ const { filename, hash: migrationHash } = migrationFiles[i];
28
+ if (i >= migrationHistory.length) {
29
+ continue;
30
+ }
31
+ if (migrationHistory[i].name !== filename) {
32
+ console.error(`Error: migration ${filename} has been renamed, aborting.`);
33
+ process.exit(1);
34
+ }
35
+ if (migrationHistory[i].hash !== migrationHash) {
36
+ console.error(`Error: migration ${filename} has been modified, aborting.`);
37
+ process.exit(1);
38
+ }
39
+ }
40
+ };
41
+ exports.validateMigrationFiles = validateMigrationFiles;
42
+ const applyMigration = (client, schema, filename, contents, hash) => __awaiter(void 0, void 0, void 0, function* () {
43
+ try {
44
+ yield client.query("BEGIN");
45
+ yield client.query(`SET search_path TO ${schema};
46
+ ${contents.toString()}`);
47
+ yield client.query(`INSERT INTO ${schema}.stepwise_migrations (name, hash) VALUES ($1, $2)`, [filename, hash]);
48
+ yield client.query("COMMIT");
49
+ console.log(`Applied migration ${filename}`);
50
+ }
51
+ catch (error) {
52
+ try {
53
+ yield client.query("ROLLBACK");
54
+ }
55
+ catch (error) {
56
+ console.error("Error rolling back transaction", error);
57
+ }
58
+ console.error("Error applying migration", error);
59
+ process.exit(1);
60
+ }
61
+ });
62
+ exports.applyMigration = applyMigration;
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/utils.js ADDED
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.readMigrationFiles = exports.validateArgs = exports.usage = exports.hashFile = void 0;
16
+ const crypto_1 = __importDefault(require("crypto"));
17
+ const promises_1 = __importDefault(require("fs/promises"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const hashFile = (path) => __awaiter(void 0, void 0, void 0, function* () {
20
+ const file = yield promises_1.default.readFile(path);
21
+ return crypto_1.default.createHash("sha256").update(file).digest("hex");
22
+ });
23
+ exports.hashFile = hashFile;
24
+ exports.usage = `
25
+ Usage: stepwise-migrations [command] [options]
26
+
27
+ Commands:
28
+ migrate
29
+ Migrate the database to the latest version
30
+ info
31
+ Show information about the current state of the migrations in the database
32
+ drop
33
+ Drop the tables, schema and migration history table
34
+
35
+ Options:
36
+ --connection <connection> The connection string to use to connect to the database
37
+ --schema <schema> The schema to use for the migrations
38
+ --path <path> The path to the migrations directory
39
+ --ssl true/false Whether to use SSL for the connection (default: false)
40
+
41
+ Example:
42
+ npx stepwise-migrations \
43
+ --connection=postgresql://postgres:postgres@127.0.0.1:5432/mydatabase \
44
+ --schema=myschema \
45
+ --path=./db/migration/ \
46
+ migrate
47
+ `;
48
+ const validateArgs = (argv) => {
49
+ const required = ["connection", "schema", "path", "_"];
50
+ if (required.some((key) => !(key in argv))) {
51
+ console.error("Missing required arguments", required.filter((key) => !(key in argv)));
52
+ console.log(exports.usage);
53
+ process.exit(1);
54
+ }
55
+ if (argv._.length !== 1) {
56
+ console.error(`Invalid number of arguments: ${argv._.length}`);
57
+ console.log(exports.usage);
58
+ process.exit(1);
59
+ }
60
+ if (argv._[0] !== "migrate" && argv._[0] !== "info" && argv._[0] !== "drop") {
61
+ console.error(`Invalid command: ${argv._[0]}`);
62
+ console.log(exports.usage);
63
+ process.exit(1);
64
+ }
65
+ };
66
+ exports.validateArgs = validateArgs;
67
+ const readMigrationFiles = (directory) => __awaiter(void 0, void 0, void 0, function* () {
68
+ const files = yield promises_1.default.readdir(directory, { withFileTypes: true });
69
+ const migrationFiles = files
70
+ .filter((file) => file.isFile() && file.name.endsWith(".sql"))
71
+ .map((file) => path_1.default.join(directory, file.name));
72
+ migrationFiles.sort();
73
+ const results = [];
74
+ for (const fullFilePath of migrationFiles) {
75
+ const hash = yield (0, exports.hashFile)(fullFilePath);
76
+ const contents = yield promises_1.default.readFile(fullFilePath, "utf8");
77
+ results.push({
78
+ fullFilePath,
79
+ filename: path_1.default.basename(fullFilePath),
80
+ hash,
81
+ contents,
82
+ });
83
+ }
84
+ return results;
85
+ });
86
+ exports.readMigrationFiles = readMigrationFiles;
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "stepwise-migrations",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "build": "tsc"
8
+ },
9
+ "keywords": [],
10
+ "author": "",
11
+ "license": "ISC",
12
+ "devDependencies": {
13
+ "@types/pg": "^8.11.10",
14
+ "@types/yargs": "^17.0.33"
15
+ },
16
+ "bin": {
17
+ "stepwise-migrations": "./dist/index.js"
18
+ }
19
+ }
package/src/db.ts ADDED
@@ -0,0 +1,79 @@
1
+ import pg, { Pool, PoolClient } from "pg";
2
+
3
+ pg.types.setTypeParser(1114, function (stringValue) {
4
+ return stringValue; //1114 for time without timezone type
5
+ });
6
+
7
+ pg.types.setTypeParser(1082, function (stringValue) {
8
+ return stringValue; //1082 for date type
9
+ });
10
+
11
+ export const dbConnect = async (argv: { connection: string; ssl?: string }) => {
12
+ const pool = new Pool({
13
+ connectionString: argv.connection,
14
+ ssl: argv.ssl === "true",
15
+ });
16
+
17
+ let client: PoolClient | undefined;
18
+ try {
19
+ client = await pool.connect();
20
+ await client.query("SELECT 1");
21
+ console.log("Connected to the database");
22
+ } catch (error) {
23
+ console.error("Failed to connect to the database", error);
24
+ process.exit(1);
25
+ }
26
+
27
+ return client;
28
+ };
29
+
30
+ export const dbHistorySchemaExists = async (
31
+ client: PoolClient,
32
+ schema: string
33
+ ) => {
34
+ const result = await client.query(
35
+ `SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '${schema}')`
36
+ );
37
+ return result.rows[0].exists;
38
+ };
39
+
40
+ export const dbTableExists = async (client: PoolClient, schema: string) => {
41
+ const tableExistsResult = await client.query(
42
+ `SELECT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stepwise_migrations' and schemaname = '${schema}')`
43
+ );
44
+
45
+ return tableExistsResult.rows[0].exists;
46
+ };
47
+
48
+ export const dbMigrationHistory = async (
49
+ client: PoolClient,
50
+ schema: string
51
+ ) => {
52
+ const migrationsQuery = await client.query(
53
+ `SELECT * FROM ${schema}.stepwise_migrations`
54
+ );
55
+ return migrationsQuery.rows;
56
+ };
57
+
58
+ export const dbCreateSchema = async (client: PoolClient, schema: string) => {
59
+ console.log(`Creating schema ${schema}`);
60
+ await client.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
61
+ console.log(`Schema ${schema} created`);
62
+ };
63
+
64
+ export const dbCreateHistoryTable = async (
65
+ client: PoolClient,
66
+ schema: string
67
+ ) => {
68
+ console.log(`Creating migration history table`);
69
+ await client.query(
70
+ `CREATE TABLE IF NOT EXISTS ${schema}.stepwise_migrations (
71
+ id SERIAL PRIMARY KEY,
72
+ name TEXT NOT NULL,
73
+ hash TEXT NOT NULL,
74
+ applied_by TEXT NOT NULL DEFAULT current_user,
75
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
76
+ )`
77
+ );
78
+ console.log(`Migration history table created`);
79
+ };
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+
3
+ import yargs from "yargs";
4
+ import {
5
+ dbConnect,
6
+ dbCreateHistoryTable,
7
+ dbCreateSchema,
8
+ dbHistorySchemaExists,
9
+ dbMigrationHistory,
10
+ dbTableExists,
11
+ } from "./db";
12
+ import { applyMigration, validateMigrationFiles } from "./migrate";
13
+ import { readMigrationFiles, validateArgs } from "./utils";
14
+
15
+ const main = async () => {
16
+ const argv: any = yargs(process.argv.slice(2)).argv;
17
+
18
+ validateArgs(argv);
19
+
20
+ const schema = argv.schema;
21
+ const command = argv._[0];
22
+
23
+ const client = await dbConnect(argv);
24
+ const historySchemaExists = await dbHistorySchemaExists(client, schema);
25
+ const tableExists = await dbTableExists(client, schema);
26
+
27
+ if (command === "migrate") {
28
+ if (!historySchemaExists) {
29
+ await dbCreateSchema(client, schema);
30
+ }
31
+ if (!tableExists) {
32
+ await dbCreateHistoryTable(client, schema);
33
+ }
34
+
35
+ const migrationHistory = await dbMigrationHistory(client, schema);
36
+ const migrationFiles = await readMigrationFiles(argv.path);
37
+ console.log(`Found ${migrationFiles.length} migration files`);
38
+
39
+ validateMigrationFiles(migrationFiles, migrationHistory);
40
+
41
+ const migrationsToApply = migrationFiles.slice(migrationHistory.length);
42
+
43
+ for (const { filename, contents, hash } of migrationsToApply) {
44
+ await applyMigration(client, schema, filename, contents, hash);
45
+ }
46
+ console.log("\nAll done!");
47
+ } else if (command === "info") {
48
+ console.log(
49
+ "Showing information about the current state of the migrations in the database"
50
+ );
51
+ console.log(
52
+ historySchemaExists ? "Schema exists" : "Schema does not exist"
53
+ );
54
+ console.log(
55
+ tableExists
56
+ ? "Migration history table exists"
57
+ : "Migration history table does not exist"
58
+ );
59
+ console.log("Migration history:");
60
+ console.table(await dbMigrationHistory(client, schema));
61
+ } else if (command === "drop") {
62
+ console.log("Dropping the tables, schema and migration history table");
63
+ await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
64
+ console.log("\nAll done!");
65
+ }
66
+
67
+ client.release();
68
+ process.exit(0);
69
+ };
70
+
71
+ main();
package/src/migrate.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { PoolClient } from "pg";
2
+ import { MigrationRow } from "./types";
3
+
4
+ export const validateMigrationFiles = (
5
+ migrationFiles: { fullFilePath: string; filename: string; hash: string }[],
6
+ migrationHistory: MigrationRow[]
7
+ ) => {
8
+ if (migrationFiles.length === 0) {
9
+ console.log("No migrations found");
10
+ process.exit(0);
11
+ }
12
+
13
+ if (migrationFiles.length < migrationHistory.length) {
14
+ console.error(
15
+ "Error: migration history is longer than the number of migration files, aborting."
16
+ );
17
+ process.exit(1);
18
+ }
19
+
20
+ if (migrationFiles.length === migrationHistory.length) {
21
+ console.log("All migrations are already applied");
22
+ process.exit(0);
23
+ }
24
+
25
+ for (let i = 0; i < migrationFiles.length; i++) {
26
+ const { filename, hash: migrationHash } = migrationFiles[i];
27
+ if (i >= migrationHistory.length) {
28
+ continue;
29
+ }
30
+ if (migrationHistory[i].name !== filename) {
31
+ console.error(`Error: migration ${filename} has been renamed, aborting.`);
32
+ process.exit(1);
33
+ }
34
+ if (migrationHistory[i].hash !== migrationHash) {
35
+ console.error(
36
+ `Error: migration ${filename} has been modified, aborting.`
37
+ );
38
+ process.exit(1);
39
+ }
40
+ }
41
+ };
42
+
43
+ export const applyMigration = async (
44
+ client: PoolClient,
45
+ schema: string,
46
+ filename: string,
47
+ contents: string,
48
+ hash: string
49
+ ) => {
50
+ try {
51
+ await client.query("BEGIN");
52
+
53
+ await client.query(
54
+ `SET search_path TO ${schema};
55
+ ${contents.toString()}`
56
+ );
57
+
58
+ await client.query(
59
+ `INSERT INTO ${schema}.stepwise_migrations (name, hash) VALUES ($1, $2)`,
60
+ [filename, hash]
61
+ );
62
+
63
+ await client.query("COMMIT");
64
+
65
+ console.log(`Applied migration ${filename}`);
66
+ } catch (error) {
67
+ try {
68
+ await client.query("ROLLBACK");
69
+ } catch (error) {
70
+ console.error("Error rolling back transaction", error);
71
+ }
72
+ console.error("Error applying migration", error);
73
+ process.exit(1);
74
+ }
75
+ };
package/src/types.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface MigrationRow {
2
+ id: string;
3
+ name: string;
4
+ hash: string;
5
+ applied_by: string;
6
+ applied_at: string;
7
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,80 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+
5
+ export const hashFile = async (path: string) => {
6
+ const file = await fs.readFile(path);
7
+ return crypto.createHash("sha256").update(file).digest("hex");
8
+ };
9
+
10
+ export const usage = `
11
+ Usage: stepwise-migrations [command] [options]
12
+
13
+ Commands:
14
+ migrate
15
+ Migrate the database to the latest version
16
+ info
17
+ Show information about the current state of the migrations in the database
18
+ drop
19
+ Drop the tables, schema and migration history table
20
+
21
+ Options:
22
+ --connection <connection> The connection string to use to connect to the database
23
+ --schema <schema> The schema to use for the migrations
24
+ --path <path> The path to the migrations directory
25
+ --ssl true/false Whether to use SSL for the connection (default: false)
26
+
27
+ Example:
28
+ npx stepwise-migrations \
29
+ --connection=postgresql://postgres:postgres@127.0.0.1:5432/mydatabase \
30
+ --schema=myschema \
31
+ --path=./db/migration/ \
32
+ migrate
33
+ `;
34
+
35
+ export const validateArgs = (argv: any) => {
36
+ const required = ["connection", "schema", "path", "_"];
37
+ if (required.some((key) => !(key in argv))) {
38
+ console.error(
39
+ "Missing required arguments",
40
+ required.filter((key) => !(key in argv))
41
+ );
42
+ console.log(usage);
43
+ process.exit(1);
44
+ }
45
+ if (argv._.length !== 1) {
46
+ console.error(`Invalid number of arguments: ${argv._.length}`);
47
+ console.log(usage);
48
+ process.exit(1);
49
+ }
50
+ if (argv._[0] !== "migrate" && argv._[0] !== "info" && argv._[0] !== "drop") {
51
+ console.error(`Invalid command: ${argv._[0]}`);
52
+ console.log(usage);
53
+ process.exit(1);
54
+ }
55
+ };
56
+
57
+ export const readMigrationFiles = async (directory: string) => {
58
+ const files = await fs.readdir(directory, { withFileTypes: true });
59
+ const migrationFiles = files
60
+ .filter((file) => file.isFile() && file.name.endsWith(".sql"))
61
+ .map((file) => path.join(directory, file.name));
62
+ migrationFiles.sort();
63
+ const results: {
64
+ fullFilePath: string;
65
+ filename: string;
66
+ hash: string;
67
+ contents: string;
68
+ }[] = [];
69
+ for (const fullFilePath of migrationFiles) {
70
+ const hash = await hashFile(fullFilePath);
71
+ const contents = await fs.readFile(fullFilePath, "utf8");
72
+ results.push({
73
+ fullFilePath,
74
+ filename: path.basename(fullFilePath),
75
+ hash,
76
+ contents,
77
+ });
78
+ }
79
+ return results;
80
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,111 @@
1
+ {
2
+ "compilerOptions": {
3
+ /* Visit https://aka.ms/tsconfig to read more about this file */
4
+
5
+ /* Projects */
6
+ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7
+ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8
+ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9
+ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10
+ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11
+ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12
+
13
+ /* Language and Environment */
14
+ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15
+ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16
+ // "jsx": "preserve", /* Specify what JSX code is generated. */
17
+ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
18
+ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19
+ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20
+ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21
+ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22
+ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23
+ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24
+ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25
+ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26
+
27
+ /* Modules */
28
+ "module": "commonjs", /* Specify what module code is generated. */
29
+ "rootDir": "./src", /* Specify the root folder within your source files. */
30
+ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
31
+ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32
+ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33
+ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34
+ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35
+ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
36
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37
+ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38
+ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
39
+ // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
40
+ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
41
+ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
42
+ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
43
+ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
44
+ // "resolveJsonModule": true, /* Enable importing .json files. */
45
+ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
46
+ // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
47
+
48
+ /* JavaScript Support */
49
+ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
50
+ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
51
+ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
52
+
53
+ /* Emit */
54
+ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
55
+ // "declarationMap": true, /* Create sourcemaps for d.ts files. */
56
+ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
57
+ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
58
+ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
59
+ // "noEmit": true, /* Disable emitting files from a compilation. */
60
+ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
61
+ "outDir": "./dist", /* Specify an output folder for all emitted files. */
62
+ // "removeComments": true, /* Disable emitting comments. */
63
+ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
64
+ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
65
+ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
66
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
67
+ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
68
+ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
69
+ // "newLine": "crlf", /* Set the newline character for emitting files. */
70
+ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
71
+ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
72
+ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
73
+ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
74
+ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
75
+
76
+ /* Interop Constraints */
77
+ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
78
+ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
79
+ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
80
+ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
81
+ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
82
+ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
83
+ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
84
+
85
+ /* Type Checking */
86
+ "strict": true, /* Enable all strict type-checking options. */
87
+ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
88
+ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
89
+ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
90
+ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
91
+ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
92
+ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
93
+ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
94
+ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
95
+ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
96
+ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
97
+ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
98
+ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
99
+ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
100
+ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
101
+ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
102
+ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
103
+ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
104
+ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
105
+ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
106
+
107
+ /* Completeness */
108
+ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
109
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */
110
+ }
111
+ }