stepwise-migrations 1.0.14 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +20 -20
  2. package/dist/{db.js → src/db.js} +73 -28
  3. package/dist/src/index.js +146 -0
  4. package/dist/src/state.js +123 -0
  5. package/dist/src/types.js +13 -0
  6. package/dist/src/utils.js +184 -0
  7. package/dist/src/validate.js +1 -0
  8. package/dist/test/index.test.js +129 -0
  9. package/dist/test/utils.js +51 -0
  10. package/docker-compose.yml +21 -0
  11. package/package.json +12 -6
  12. package/src/db.ts +92 -37
  13. package/src/index.ts +115 -80
  14. package/src/state.ts +166 -0
  15. package/src/types.ts +49 -4
  16. package/src/utils.ts +122 -66
  17. package/test/index.test.ts +166 -0
  18. package/test/migrations-invalid/v0_get_number.repeatable.sql +6 -0
  19. package/test/migrations-invalid/v1_first.sql +1 -0
  20. package/test/migrations-invalid/v2_second.sql +4 -0
  21. package/test/migrations-invalid/v2_second.undo.sql +1 -0
  22. package/test/migrations-invalid/v3_third.sql +4 -0
  23. package/test/migrations-invalid/v3_third.undo.sql +1 -0
  24. package/test/migrations-template/v0_get_number.repeatable.sql +6 -0
  25. package/test/migrations-template/v1_first.sql +4 -0
  26. package/test/migrations-template/v2_second.sql +4 -0
  27. package/test/migrations-template/v2_second.undo.sql +1 -0
  28. package/test/migrations-template/v3_third.sql +4 -0
  29. package/test/migrations-template/v3_third.undo.sql +1 -0
  30. package/test/migrations-valid/v0_get_number.repeatable.sql +8 -0
  31. package/test/migrations-valid/v1_first.sql +4 -0
  32. package/test/migrations-valid/v2_second.sql +4 -0
  33. package/test/migrations-valid/v2_second.undo.sql +1 -0
  34. package/test/migrations-valid/v3_third.sql +4 -0
  35. package/test/migrations-valid/v3_third.undo.sql +1 -0
  36. package/test/utils.ts +69 -0
  37. package/tsconfig.json +1 -1
  38. package/dist/index.js +0 -115
  39. package/dist/migrate.js +0 -102
  40. package/dist/types.js +0 -2
  41. package/dist/utils.js +0 -132
  42. package/src/migrate.ts +0 -143
package/src/utils.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
- import { MigrationRow } from "./types";
3
+ import {
4
+ AppliedMigration,
5
+ MigrationFile,
6
+ MigrationState,
7
+ MigrationType,
8
+ } from "./types";
4
9
 
5
10
  export const usage = `
6
11
  Usage: stepwise-migrations [command] [options]
@@ -20,13 +25,13 @@ Options:
20
25
  --schema <schema> The schema to use for the migrations
21
26
  --path <path> The path to the migrations directory
22
27
  --ssl true/false Whether to use SSL for the connection (default: false)
23
- --nup Number of up migrations to apply (default: all)
24
- --ndown Number of down migrations to apply (default: 1)
28
+ --napply Number of up migrations to apply (default: all)
29
+ --nundo Number of down migrations to apply (default: 1)
25
30
 
26
31
  Example:
27
- npx stepwise-migrations migrate \
28
- --connection=postgresql://postgres:postgres@127.0.0.1:5432/mydatabase \
29
- --schema=myschema \
32
+ npx stepwise-migrations migrate \\
33
+ --connection=postgresql://postgres:postgres@127.0.0.1:5432/mydatabase \\
34
+ --schema=myschema \\
30
35
  --path=./db/migration/
31
36
  `;
32
37
 
@@ -47,67 +52,117 @@ export const validateArgs = (argv: any) => {
47
52
  }
48
53
  };
49
54
 
50
- export const readMigrationFiles = async (directory: string) => {
55
+ export const filenameToType = (filename: string): MigrationType => {
56
+ if (filename.endsWith(".undo.sql")) {
57
+ return "undo";
58
+ } else if (filename.endsWith(".repeatable.sql")) {
59
+ return "repeatable";
60
+ }
61
+ return "versioned";
62
+ };
63
+
64
+ export const readMigrationFiles = async (
65
+ directory: string,
66
+ appliedVersionedMigrations: AppliedMigration[]
67
+ ) => {
68
+ let errors: string[] = [];
51
69
  const files = await fs.readdir(directory, { withFileTypes: true });
52
70
  const migrationFiles = files
53
- .filter(
54
- (file) =>
55
- file.isFile() &&
56
- file.name.endsWith(".sql") &&
57
- !file.name.endsWith(".down.sql")
58
- )
71
+ .filter((file) => file.isFile() && file.name.endsWith(".sql"))
59
72
  .map((file) => path.join(directory, file.name));
60
73
  migrationFiles.sort();
61
- const results: {
62
- type: "up";
63
- fullFilePath: string;
64
- filename: string;
65
- script: string;
66
- }[] = [];
67
- for (const fullFilePath of migrationFiles) {
68
- const script = await fs.readFile(fullFilePath, "utf8");
69
-
74
+ const results: MigrationFile[] = [];
75
+ for (const fullFileName of migrationFiles) {
76
+ const script = await fs.readFile(fullFileName, "utf8");
70
77
  results.push({
71
- type: "up",
72
- fullFilePath,
73
- filename: path.basename(fullFilePath),
78
+ type: filenameToType(path.basename(fullFileName)),
79
+ filename: path.basename(fullFileName),
74
80
  script,
75
81
  });
76
82
  }
77
- return results;
83
+
84
+ for (const appliedMigration of appliedVersionedMigrations) {
85
+ const file = results.find((f) => f.filename === appliedMigration.filename);
86
+ if (
87
+ file &&
88
+ file.type === "versioned" &&
89
+ file.script !== appliedMigration.script
90
+ ) {
91
+ errors.push(
92
+ `Versioned migration ${appliedMigration.filename} has been altered. Cannot migrate in current state.`
93
+ );
94
+ }
95
+ }
96
+
97
+ return { files: results, errors };
78
98
  };
79
99
 
80
100
  export const printMigrationHistoryAndUnappliedMigrations = (
81
- migrationFiles: { filename: string }[],
82
- migrationHistory: MigrationRow[]
101
+ state: MigrationState
83
102
  ) => {
84
- console.log("Migration history:");
103
+ console.log("All applied versioned migrations:");
85
104
  console.table(
86
- migrationHistory.map((h) => ({
105
+ state.current.appliedVersionedMigrations.map((h) => ({
87
106
  id: h.id,
88
- name: h.name,
107
+ type: h.type,
108
+ filename: h.filename,
89
109
  applied_by: h.applied_by,
90
110
  applied_at: h.applied_at,
91
111
  }))
92
112
  );
93
- console.log("Unapplied migrations:");
113
+ if (state.current.appliedRepeatableMigrations.length > 0) {
114
+ console.log("All applied repeatable migrations:");
115
+ console.table(
116
+ state.current.appliedRepeatableMigrations.map((h) => ({
117
+ id: h.id,
118
+ type: h.type,
119
+ filename: h.filename,
120
+ applied_by: h.applied_by,
121
+ applied_at: h.applied_at,
122
+ }))
123
+ );
124
+ }
125
+ console.log("Unapplied versioned migrations:");
94
126
  console.table(
95
- migrationFiles.slice(migrationHistory.length).map((m) => ({
96
- filename: m.filename,
127
+ state.files.unappliedVersionedFiles.map((h) => ({
128
+ type: h.type,
129
+ filename: h.filename,
97
130
  }))
98
131
  );
132
+ if (state.files.unappliedRepeatableFiles.length > 0) {
133
+ console.log("Unapplied repeatable migrations:");
134
+ console.table(
135
+ state.files.unappliedRepeatableFiles.map((h) => ({
136
+ type: h.type,
137
+ filename: h.filename,
138
+ }))
139
+ );
140
+ }
99
141
  };
100
142
 
101
- export const printMigrationHistory = (migrationHistory: MigrationRow[]) => {
102
- console.log("Migration history:");
143
+ export const printMigrationHistory = (state: MigrationState) => {
144
+ console.log("All applied versioned migrations:");
103
145
  console.table(
104
- migrationHistory.map((h) => ({
146
+ state.current.appliedVersionedMigrations.map((h) => ({
105
147
  id: h.id,
106
- name: h.name,
148
+ type: h.type,
149
+ filename: h.filename,
107
150
  applied_by: h.applied_by,
108
151
  applied_at: h.applied_at,
109
152
  }))
110
153
  );
154
+ if (state.current.appliedRepeatableMigrations.length > 0) {
155
+ console.log("All applied repeatable migrations:");
156
+ console.table(
157
+ state.current.appliedRepeatableMigrations.map((h) => ({
158
+ id: h.id,
159
+ type: h.type,
160
+ filename: h.filename,
161
+ applied_by: h.applied_by,
162
+ applied_at: h.applied_at,
163
+ }))
164
+ );
165
+ }
111
166
  };
112
167
 
113
168
  export const fileExists = async (path: string) => {
@@ -118,35 +173,36 @@ export const fileExists = async (path: string) => {
118
173
  }
119
174
  };
120
175
 
121
- export const readDownMigrationFiles = async (
122
- directory: string,
123
- migrationHistory: MigrationRow[]
176
+ export const abortIfErrors = (state: MigrationState) => {
177
+ if (state.errors.length > 0) {
178
+ console.error(
179
+ `There were errors loading the migration state. Please fix the errors and try again.`
180
+ );
181
+ console.error(state.errors.map((e) => " - " + e).join("\n"));
182
+ process.exit(1);
183
+ }
184
+ };
185
+
186
+ export const exitIfNotInitialized = (
187
+ schemaExists: boolean,
188
+ tableExists: boolean
124
189
  ) => {
125
- const results: {
126
- type: "down";
127
- fullFilePath: string;
128
- filename: string;
129
- upFilename: string;
130
-
131
- script: string;
132
- }[] = [];
133
- for (const migration of migrationHistory) {
134
- const fullFilePath = path.join(
135
- directory,
136
- `${migration.name.split(".sql")[0]}.down.sql`
190
+ if (!schemaExists) {
191
+ console.log("Schema does not exist. Run migrate to begin.");
192
+ process.exit(1);
193
+ }
194
+
195
+ if (!tableExists) {
196
+ console.log(
197
+ "Migration table has not been initialised. Run migrate to begin."
137
198
  );
138
- if (!(await fileExists(fullFilePath))) {
139
- console.error(`Down migration file not found: ${fullFilePath}`);
140
- process.exit(1);
141
- }
142
- const script = await fs.readFile(fullFilePath, "utf8");
143
- results.push({
144
- type: "down",
145
- fullFilePath,
146
- filename: path.basename(fullFilePath),
147
- upFilename: migration.name,
148
- script,
149
- });
199
+ process.exit(1);
150
200
  }
151
- return results;
201
+ };
202
+
203
+ export const sliceFromFirstNull = <T>(array: (T | undefined)[]): T[] => {
204
+ const indexOfFirstNull = array.findIndex((x) => x == null);
205
+ return indexOfFirstNull < 0
206
+ ? (array as T[])
207
+ : (array.slice(0, indexOfFirstNull) as T[]);
152
208
  };
@@ -0,0 +1,166 @@
1
+ import assert from "node:assert";
2
+ import fs from "node:fs";
3
+ import { beforeEach, describe, it } from "node:test";
4
+ import { assertIncludesAll, assertIncludesExcludesAll, execute } from "./utils";
5
+ const connection = "postgresql://postgres:postgres@127.0.0.1:5432/stepwise-db";
6
+ const schema = "stepwise";
7
+
8
+ const paths = {
9
+ valid: "./test/migrations-valid",
10
+ invalid: "./test/migrations-invalid",
11
+ };
12
+
13
+ const executeCommand = (
14
+ command: string,
15
+ path: string = "",
16
+ extraArgs: string = ""
17
+ ) =>
18
+ execute(`npm exec stepwise-migrations ${command} -- \\
19
+ --connection=${connection} \\
20
+ --schema=${schema} \\
21
+ --path=${path} ${extraArgs}
22
+ `);
23
+
24
+ describe("valid migrations", async () => {
25
+ beforeEach(async () => {
26
+ const { output, error, exitCode } = await executeCommand("drop", "");
27
+ assert.ok(
28
+ output.includes(
29
+ "Dropping the tables, schema and migration history table... done!"
30
+ )
31
+ );
32
+ assert.ok(exitCode === 0);
33
+
34
+ fs.rmSync(paths.valid, { recursive: true, force: true });
35
+ fs.cpSync("./test/migrations-template", paths.valid, {
36
+ recursive: true,
37
+ });
38
+ });
39
+
40
+ it("migrate without params", async () => {
41
+ assertIncludesAll(await execute("npm exec stepwise-migrations"), ["Usage"]);
42
+ });
43
+
44
+ it("migrate one versioned and undo, redo, undo", async () => {
45
+ assertIncludesAll(await executeCommand("migrate", paths.valid), [
46
+ "All done! Applied 4 migrations",
47
+ ]);
48
+ assertIncludesAll(await executeCommand("status"), [
49
+ "v0_get_number.repeatable.sql",
50
+ "v1_first.sql",
51
+ "v2_second.sql",
52
+ "v3_third.sql",
53
+ ]);
54
+
55
+ assertIncludesAll(await executeCommand("undo", paths.valid), [
56
+ "All done! Performed 1 undo migration",
57
+ ]);
58
+ assertIncludesExcludesAll(
59
+ await executeCommand("status"),
60
+ ["v0_get_number.repeatable.sql", "v1_first.sql", "v2_second.sql"],
61
+ ["v3_third.sql"]
62
+ );
63
+
64
+ assertIncludesAll(await executeCommand("migrate", paths.valid), [
65
+ "All done! Applied 1 migration",
66
+ ]);
67
+ assertIncludesAll(await executeCommand("status"), [
68
+ "v0_get_number.repeatable.sql",
69
+ "v1_first.sql",
70
+ "v2_second.sql",
71
+ "v3_third.sql",
72
+ ]);
73
+
74
+ assertIncludesAll(await executeCommand("undo", paths.valid, "--nundo=2"), [
75
+ "All done! Performed 2 undo migrations",
76
+ ]);
77
+ assertIncludesExcludesAll(
78
+ await executeCommand("status"),
79
+ ["v0_get_number.repeatable.sql", "v1_first.sql"],
80
+ ["v2_second.sql", "v3_third.sql"]
81
+ );
82
+
83
+ assertIncludesAll(await executeCommand("migrate", paths.valid), [
84
+ "All done! Applied 2 migrations",
85
+ ]);
86
+ assertIncludesAll(await executeCommand("status"), [
87
+ "v0_get_number.repeatable.sql",
88
+ "v1_first.sql",
89
+ "v2_second.sql",
90
+ "v3_third.sql",
91
+ ]);
92
+ });
93
+
94
+ it("migrate with altered repeatable migration", async () => {
95
+ assertIncludesAll(await executeCommand("migrate", paths.valid), [
96
+ "All done! Applied 4 migrations",
97
+ ]);
98
+ assertIncludesAll(await executeCommand("status"), [
99
+ "v0_get_number.repeatable.sql",
100
+ "v1_first.sql",
101
+ "v2_second.sql",
102
+ "v3_third.sql",
103
+ ]);
104
+
105
+ fs.writeFileSync(
106
+ "./test/migrations-valid/v0_get_number.repeatable.sql",
107
+ `
108
+ CREATE OR REPLACE FUNCTION get_number()
109
+ RETURNS integer AS $$
110
+ BEGIN
111
+ RETURN 2;
112
+ END; $$
113
+ LANGUAGE plpgsql;
114
+ `
115
+ );
116
+
117
+ assertIncludesAll(await executeCommand("migrate", paths.valid), [
118
+ "All done! Applied 1 migration",
119
+ ]);
120
+ });
121
+ });
122
+
123
+ describe.only("invalid migrations", async () => {
124
+ beforeEach(async () => {
125
+ const { output, error, exitCode } = await executeCommand("drop", "");
126
+ assert.ok(
127
+ output.includes(
128
+ "Dropping the tables, schema and migration history table... done!"
129
+ )
130
+ );
131
+ assert.ok(exitCode === 0);
132
+
133
+ fs.rmSync(paths.invalid, { recursive: true, force: true });
134
+ fs.cpSync("./test/migrations-template", paths.invalid, {
135
+ recursive: true,
136
+ });
137
+ });
138
+
139
+ it.only("missing undo migration", async () => {
140
+ assertIncludesAll(await executeCommand("migrate", paths.invalid), [
141
+ "All done!",
142
+ ]);
143
+
144
+ fs.unlinkSync("./test/migrations-invalid/v3_third.undo.sql");
145
+
146
+ assertIncludesAll(
147
+ await executeCommand("undo", paths.invalid, "--nundos=2"),
148
+ ["Error: not enough sequential (from last) undo migrations to apply"]
149
+ );
150
+ });
151
+
152
+ it("alter migration", async () => {
153
+ assertIncludesAll(await executeCommand("migrate", paths.invalid), [
154
+ "All done!",
155
+ ]);
156
+
157
+ fs.writeFileSync(
158
+ "./test/migrations-invalid/v1_first.sql",
159
+ "ALTER TABLE test ADD COLUMN test_column TEXT;"
160
+ );
161
+
162
+ assertIncludesAll(await executeCommand("migrate", paths.invalid), [
163
+ "Versioned migration v1_first.sql has been altered. Cannot migrate in current state.",
164
+ ]);
165
+ });
166
+ });
@@ -0,0 +1,6 @@
1
+ CREATE OR REPLACE FUNCTION get_number()
2
+ RETURNS integer AS $$
3
+ BEGIN
4
+ RETURN 1;
5
+ END; $$
6
+ LANGUAGE plpgsql;
@@ -0,0 +1 @@
1
+ ALTER TABLE test ADD COLUMN test_column TEXT;
@@ -0,0 +1,4 @@
1
+ create table second (
2
+ id serial primary key,
3
+ name text not null
4
+ );
@@ -0,0 +1 @@
1
+ drop table second;
@@ -0,0 +1,4 @@
1
+ create table third (
2
+ id serial primary key,
3
+ name text not null
4
+ );
@@ -0,0 +1 @@
1
+ drop table third;
@@ -0,0 +1,6 @@
1
+ CREATE OR REPLACE FUNCTION get_number()
2
+ RETURNS integer AS $$
3
+ BEGIN
4
+ RETURN 1;
5
+ END; $$
6
+ LANGUAGE plpgsql;
@@ -0,0 +1,4 @@
1
+ create table first (
2
+ id serial primary key,
3
+ name text not null
4
+ );
@@ -0,0 +1,4 @@
1
+ create table second (
2
+ id serial primary key,
3
+ name text not null
4
+ );
@@ -0,0 +1 @@
1
+ drop table second;
@@ -0,0 +1,4 @@
1
+ create table third (
2
+ id serial primary key,
3
+ name text not null
4
+ );
@@ -0,0 +1 @@
1
+ drop table third;
@@ -0,0 +1,8 @@
1
+
2
+ CREATE OR REPLACE FUNCTION get_number()
3
+ RETURNS integer AS $$
4
+ BEGIN
5
+ RETURN 2;
6
+ END; $$
7
+ LANGUAGE plpgsql;
8
+
@@ -0,0 +1,4 @@
1
+ create table first (
2
+ id serial primary key,
3
+ name text not null
4
+ );
@@ -0,0 +1,4 @@
1
+ create table second (
2
+ id serial primary key,
3
+ name text not null
4
+ );
@@ -0,0 +1 @@
1
+ drop table second;
@@ -0,0 +1,4 @@
1
+ create table third (
2
+ id serial primary key,
3
+ name text not null
4
+ );
@@ -0,0 +1 @@
1
+ drop table third;
package/test/utils.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { exec } from "node:child_process";
2
+
3
+ export const execute = (cmd: string) => {
4
+ const child = exec(cmd);
5
+ let scriptOutput = "";
6
+ let scriptError = "";
7
+
8
+ if (!child.stdout || !child.stderr) {
9
+ return { output: "", error: "", exitCode: 1 };
10
+ }
11
+
12
+ child.stdout.setEncoding("utf8");
13
+ child.stdout.on("data", (data) => {
14
+ scriptOutput += data.toString();
15
+ });
16
+
17
+ child.stderr.setEncoding("utf8");
18
+ child.stderr.on("data", (data) => {
19
+ scriptError += data.toString();
20
+ });
21
+
22
+ return new Promise((res, rej) =>
23
+ child.on("close", function (code) {
24
+ res({ output: scriptOutput, error: scriptError, exitCode: code ?? 1 });
25
+ })
26
+ ) as Promise<{ output: string; error: string; exitCode: number }>;
27
+ };
28
+
29
+ export const includesAll = (output: string, expected: string[]) =>
30
+ expected.every((x) => output.includes(x));
31
+
32
+ export const excludesAll = (output: string, expected: string[]) =>
33
+ expected.every((x) => !output.includes(x));
34
+
35
+ export const includesExcludesAll = (
36
+ output: string,
37
+ includes: string[],
38
+ excludes: string[]
39
+ ) => includesAll(output, includes) && excludesAll(output, excludes);
40
+
41
+ export const assertIncludesAll = (
42
+ { output, error }: { output: string; error: string },
43
+ expected: string[]
44
+ ) => {
45
+ if (!includesAll(output + error, expected)) {
46
+ console.log(output);
47
+ console.error(error);
48
+ throw new Error(`Expected ${expected} to be in output.`);
49
+ }
50
+ };
51
+ export const assertExcludesAll = (
52
+ { output, error }: { output: string; error: string },
53
+ expected: string[]
54
+ ) => {
55
+ if (!excludesAll(output + error, expected)) {
56
+ console.log(output);
57
+ console.error(error);
58
+ throw new Error(`Expected ${expected} to be be excluded from output.`);
59
+ }
60
+ };
61
+
62
+ export const assertIncludesExcludesAll = (
63
+ { output, error }: { output: string; error: string },
64
+ includes: string[],
65
+ excludes: string[]
66
+ ) => {
67
+ assertIncludesAll({ output, error }, includes);
68
+ assertExcludesAll({ output, error }, excludes);
69
+ };
package/tsconfig.json CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  /* Modules */
28
28
  "module": "commonjs", /* Specify what module code is generated. */
29
- "rootDir": "./src", /* Specify the root folder within your source files. */
29
+ "rootDir": "./", /* Specify the root folder within your source files. */
30
30
  // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
31
31
  // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32
32
  // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */