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/index.ts CHANGED
@@ -2,26 +2,22 @@
2
2
 
3
3
  import yargs from "yargs";
4
4
  import {
5
- dbAuditHistory,
5
+ applyMigration,
6
+ applyUndoMigration,
6
7
  dbConnect,
7
- dbCreateHistoryTable,
8
+ dbCreateEventsTable,
8
9
  dbCreateSchema,
9
- dbGetScript,
10
- dbHistorySchemaExists,
11
- dbMigrationHistory,
10
+ dbDropAll,
11
+ dbGetAppliedScript,
12
+ dbSchemaExists,
12
13
  dbTableExists,
13
14
  } from "./db";
15
+ import { getUndoFilename, loadState } from "./state";
14
16
  import {
15
- applyDownMigration,
16
- applyMigration,
17
- validateDownMigrationFiles,
18
- validateMigrationFiles,
19
- } from "./migrate";
20
- import {
21
- printMigrationHistory,
17
+ abortIfErrors,
18
+ exitIfNotInitialized,
22
19
  printMigrationHistoryAndUnappliedMigrations,
23
- readDownMigrationFiles,
24
- readMigrationFiles,
20
+ sliceFromFirstNull,
25
21
  usage,
26
22
  validateArgs,
27
23
  } from "./utils";
@@ -33,134 +29,173 @@ const main = async () => {
33
29
 
34
30
  const schema = argv.schema;
35
31
  const command = argv._[0];
32
+ const napply = argv.napply || Infinity;
33
+ const nundo = argv.nundo || 1;
34
+ const filePath = argv.path;
36
35
 
37
36
  const client = await dbConnect(argv);
38
- const historySchemaExists = await dbHistorySchemaExists(client, schema);
37
+ const schemaExists = await dbSchemaExists(client, schema);
39
38
  const tableExists = await dbTableExists(client, schema);
40
39
 
41
40
  if (command === "migrate") {
42
- const nUp = argv.nup || Infinity;
43
- if (!historySchemaExists) {
41
+ if (!schemaExists) {
44
42
  await dbCreateSchema(client, schema);
45
43
  }
46
44
  if (!tableExists) {
47
- await dbCreateHistoryTable(client, schema);
45
+ await dbCreateEventsTable(client, schema);
48
46
  }
49
47
 
50
- const migrationHistory = await dbMigrationHistory(client, schema);
51
- const migrationFiles = await readMigrationFiles(argv.path);
48
+ const state = await loadState(client, schema, argv.path);
52
49
 
53
- validateMigrationFiles(migrationFiles, migrationHistory);
50
+ abortIfErrors(state);
54
51
 
55
- if (migrationFiles.length === migrationHistory.length) {
52
+ if (
53
+ state.files.unappliedVersionedFiles.length === 0 &&
54
+ state.files.unappliedRepeatableFiles.length === 0
55
+ ) {
56
56
  console.log("All migrations are already applied");
57
57
  process.exit(0);
58
58
  }
59
59
 
60
- const migrationsToApply = migrationFiles.slice(
61
- migrationHistory.length,
62
- migrationHistory.length + nUp
63
- );
60
+ const migrationsToApply = [
61
+ ...state.files.unappliedVersionedFiles,
62
+ ...state.files.unappliedRepeatableFiles,
63
+ ].slice(0, napply);
64
64
 
65
- for (const { filename, script } of migrationsToApply) {
66
- await applyMigration(client, schema, filename, script);
65
+ for (const migration of migrationsToApply) {
66
+ await applyMigration(client, schema, migration);
67
67
  }
68
68
 
69
- console.log(`All done! Applied ${migrationsToApply.length} migrations`);
69
+ console.log(
70
+ `All done! Applied ${migrationsToApply.length} migration${
71
+ migrationsToApply.length === 1 ? "" : "s"
72
+ }`
73
+ );
70
74
 
71
75
  printMigrationHistoryAndUnappliedMigrations(
72
- await readMigrationFiles(argv.path),
73
- await dbMigrationHistory(client, schema)
76
+ await loadState(client, schema, filePath)
74
77
  );
75
78
  } else if (command === "info") {
76
- if (!historySchemaExists) {
79
+ if (!schemaExists) {
77
80
  console.log("Schema does not exist");
78
81
  }
79
82
 
80
83
  if (!tableExists) {
81
- console.log("Migration history table does not exist");
84
+ console.log(
85
+ "Migration table has not been initialised. Run migrate to begin."
86
+ );
82
87
  }
83
88
 
84
- if (historySchemaExists && tableExists) {
85
- printMigrationHistory(await dbMigrationHistory(client, schema));
89
+ if (schemaExists && tableExists) {
90
+ printMigrationHistoryAndUnappliedMigrations(
91
+ await loadState(client, schema, filePath)
92
+ );
86
93
  }
87
- } else if (command === "validate") {
88
- if (!historySchemaExists) {
94
+ } else if (command === "status") {
95
+ if (!schemaExists) {
89
96
  console.log("Schema does not exist");
90
97
  }
91
98
 
92
99
  if (!tableExists) {
93
- console.log("Migration history table does not exist");
100
+ console.log(
101
+ "Migration table has not been initialised. Run migrate to begin."
102
+ );
94
103
  }
95
104
 
96
- if (historySchemaExists && tableExists) {
97
- validateMigrationFiles(
98
- await readMigrationFiles(argv.path),
99
- await dbMigrationHistory(client, schema)
105
+ if (schemaExists && tableExists) {
106
+ printMigrationHistoryAndUnappliedMigrations(
107
+ await loadState(client, schema, filePath)
100
108
  );
101
109
  }
110
+ } else if (command === "validate") {
111
+ exitIfNotInitialized(schemaExists, tableExists);
112
+
113
+ const state = await loadState(client, schema, argv.path);
114
+ if (schemaExists && tableExists) {
115
+ abortIfErrors(state);
116
+ }
102
117
  console.log("Validation passed");
103
118
 
104
- printMigrationHistoryAndUnappliedMigrations(
105
- await readMigrationFiles(argv.path),
106
- await dbMigrationHistory(client, schema)
107
- );
119
+ printMigrationHistoryAndUnappliedMigrations(state);
108
120
  } else if (command === "drop") {
109
121
  process.stdout.write(
110
122
  `Dropping the tables, schema and migration history table... `
111
123
  );
112
- await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
124
+ await dbDropAll(client, schema);
113
125
  console.log(`done!`);
114
- } else if (command === "down") {
115
- const nDown = argv.ndown || 1;
116
-
117
- const migrationHistory = await dbMigrationHistory(client, schema);
118
- validateMigrationFiles(
119
- await readMigrationFiles(argv.path),
120
- migrationHistory,
121
- false
122
- );
126
+ } else if (command === "undo") {
127
+ const state = await loadState(client, schema, filePath);
123
128
 
124
- const reverseMigrationHistory = migrationHistory.reverse().slice(0, nDown);
125
- const downMigrationFilesToApply = await readDownMigrationFiles(
126
- argv.path,
127
- reverseMigrationHistory
128
- );
129
+ abortIfErrors(state);
129
130
 
130
- validateDownMigrationFiles(
131
- downMigrationFilesToApply,
132
- reverseMigrationHistory
131
+ const reversedAppliedVersionedMigrations =
132
+ state.current.appliedVersionedMigrations.slice().reverse();
133
+
134
+ const undosToApplyAll = reversedAppliedVersionedMigrations.map(
135
+ (migration) =>
136
+ state.files.undoFiles.find(
137
+ (file) => file.filename === getUndoFilename(migration.filename)
138
+ )
133
139
  );
134
- for (const { filename, script, upFilename } of downMigrationFilesToApply) {
135
- await applyDownMigration(client, schema, filename, script, upFilename);
140
+
141
+ const undosToApply = sliceFromFirstNull(undosToApplyAll).slice(0, nundo);
142
+
143
+ if (undosToApply.length < nundo) {
144
+ console.error(
145
+ `Error: not enough sequential (from last) undo migrations to apply ${nundo} undos.`
146
+ );
147
+ process.exit(1);
148
+ }
149
+
150
+ console.log(undosToApply);
151
+ for (const { filename, script } of undosToApply) {
152
+ await applyUndoMigration(client, schema, filename, script);
136
153
  }
137
154
  console.log(
138
- `All done! Applied ${downMigrationFilesToApply.length} down migration${
139
- downMigrationFilesToApply.length === 1 ? "" : "s"
155
+ `All done! Performed ${undosToApply.length} undo migration${
156
+ undosToApply.length === 1 ? "" : "s"
140
157
  }`
141
158
  );
142
159
 
143
- printMigrationHistoryAndUnappliedMigrations(
144
- await readMigrationFiles(argv.path),
145
- await dbMigrationHistory(client, schema)
146
- );
160
+ printMigrationHistoryAndUnappliedMigrations(state);
147
161
  } else if (command === "audit") {
148
- const auditHistory = await dbAuditHistory(client, schema);
149
- console.log("Audit history:");
162
+ exitIfNotInitialized(schemaExists, tableExists);
163
+
164
+ const state = await loadState(client, schema, argv.path);
165
+ console.log("Event history:");
150
166
  console.table(
151
- auditHistory.map((row) => ({
167
+ state.events.map((row) => ({
152
168
  id: row.id,
153
169
  type: row.type,
154
- name: row.name,
170
+ filename: row.filename,
155
171
  applied_by: row.applied_by,
156
172
  applied_at: row.applied_at,
157
173
  }))
158
174
  );
159
- } else if (command === "get-script") {
160
- const script = await dbGetScript(client, schema, argv.filename);
161
- console.log(script);
175
+ } else if (command === "get-applied-script") {
176
+ if (!schemaExists) {
177
+ console.log("Schema does not exist");
178
+ process.exit(1);
179
+ }
180
+
181
+ if (!tableExists) {
182
+ console.log(
183
+ "Migration table has not been initialised. Run migrate to begin."
184
+ );
185
+ process.exit(1);
186
+ }
187
+
188
+ const state = await loadState(client, schema, argv.path);
189
+ const script = await dbGetAppliedScript(state, argv.filename);
190
+ if (script) {
191
+ console.log(script);
192
+ } else {
193
+ console.error(
194
+ `Script for ${argv.filename} not found, use the audit command to check all applied migrations`
195
+ );
196
+ }
162
197
  } else {
163
- console.error(`Invalid command: ${argv._[0]}`);
198
+ console.error(`Invalid command: ${command}`);
164
199
  console.log(usage);
165
200
  process.exit(1);
166
201
  }
package/src/state.ts ADDED
@@ -0,0 +1,166 @@
1
+ import gitDiff from "git-diff";
2
+ import path from "path";
3
+ import { PoolClient } from "pg";
4
+ import { dbEventHistory } from "./db";
5
+ import { AppliedMigration, MigrationState } from "./types";
6
+ import { readMigrationFiles } from "./utils";
7
+
8
+ export const validateMigrationFiles = (state: MigrationState) => {
9
+ let errors: string[] = [];
10
+
11
+ if (state.files.allFiles.length === 0) {
12
+ return ["No migration files found"];
13
+ }
14
+
15
+ if (
16
+ state.current.appliedVersionedMigrations.length >
17
+ state.files.versionedFiles.length
18
+ ) {
19
+ errors.push(
20
+ "Migration history is in a bad state: more applied versioned migrations than files"
21
+ );
22
+ }
23
+
24
+ for (let i = 0; i < state.current.appliedVersionedMigrations.length; i++) {
25
+ const { filename, script } = state.files.versionedFiles[i];
26
+ if (state.current.appliedVersionedMigrations[i].filename !== filename) {
27
+ errors.push(
28
+ `Migration history is in a bad state: applied versioned migration ${state.current.appliedVersionedMigrations[i].filename} does not match file ${filename}`
29
+ );
30
+ break;
31
+ }
32
+ if (state.current.appliedVersionedMigrations[i].script !== script) {
33
+ errors.push(
34
+ `Migration history is in a bad state: applied versioned migration ${state.current.appliedVersionedMigrations[i].filename} has been modified.\n\n` +
35
+ gitDiff(state.current.appliedVersionedMigrations[i].script, script, {
36
+ color: true,
37
+ noHeaders: true,
38
+ })
39
+ );
40
+ break;
41
+ }
42
+ }
43
+
44
+ if (
45
+ state.current.appliedRepeatableMigrations.length >
46
+ state.files.repeatableFiles.length
47
+ ) {
48
+ errors.push(
49
+ "Migration history is in a bad state: more applied repeatable migrations than files"
50
+ );
51
+ }
52
+
53
+ for (let i = 0; i < state.current.appliedRepeatableMigrations.length; i++) {
54
+ const { filename } = state.files.repeatableFiles[i];
55
+ if (state.current.appliedRepeatableMigrations[i].filename !== filename) {
56
+ errors.push(
57
+ `Migration history is in a bad state: applied repeatable migration ${state.current.appliedRepeatableMigrations[i].filename} does not match file ${filename}`
58
+ );
59
+ break;
60
+ }
61
+ }
62
+
63
+ return errors;
64
+ };
65
+
66
+ export const loadState = async (
67
+ client: PoolClient,
68
+ schema: string,
69
+ migrationPath: string
70
+ ): Promise<MigrationState> => {
71
+ const events = await dbEventHistory(client, schema);
72
+ const {
73
+ appliedVersionedMigrations,
74
+ appliedRepeatableMigrations,
75
+ errors: appliedErrors,
76
+ } = eventsToApplied(events);
77
+ const { files: allFiles, errors: readFileErrors } = await readMigrationFiles(
78
+ path.join(process.cwd(), migrationPath),
79
+ appliedVersionedMigrations
80
+ );
81
+ const unappliedVersionedFiles = allFiles
82
+ .filter((file) => file.type === "versioned")
83
+ .filter(
84
+ (file) =>
85
+ !appliedVersionedMigrations.find(
86
+ (event) => event.filename === file.filename
87
+ )
88
+ );
89
+ const unappliedRepeatableFiles = allFiles
90
+ .filter((file) => file.type === "repeatable")
91
+ .filter(
92
+ (file) =>
93
+ !appliedRepeatableMigrations.find(
94
+ (event) =>
95
+ event.filename === file.filename && event.script === file.script
96
+ )
97
+ );
98
+
99
+ return {
100
+ schema,
101
+ current: {
102
+ appliedVersionedMigrations,
103
+ appliedRepeatableMigrations,
104
+ },
105
+ events,
106
+ files: {
107
+ allFiles,
108
+ unappliedVersionedFiles,
109
+ unappliedRepeatableFiles,
110
+ versionedFiles: allFiles.filter((file) => file.type === "versioned"),
111
+ undoFiles: allFiles.filter((file) => file.type === "undo"),
112
+ repeatableFiles: allFiles.filter((file) => file.type === "repeatable"),
113
+ },
114
+ errors: appliedErrors.concat(readFileErrors),
115
+ };
116
+ };
117
+
118
+ export const getUndoFilename = (filename: string) => {
119
+ return filename.replace(".sql", ".undo.sql");
120
+ };
121
+
122
+ export const eventsToApplied = (
123
+ events: MigrationState["events"]
124
+ ): {
125
+ errors: string[];
126
+ appliedVersionedMigrations: AppliedMigration[];
127
+ appliedRepeatableMigrations: AppliedMigration[];
128
+ } => {
129
+ let errors: string[] = [];
130
+ let appliedVersionedMigrations: AppliedMigration[] = [];
131
+ let appliedRepeatableMigrations: AppliedMigration[] = [];
132
+
133
+ for (const event of events) {
134
+ if (event.type === "versioned") {
135
+ appliedVersionedMigrations.push(event);
136
+ } else if (event.type === "undo") {
137
+ if (appliedVersionedMigrations.length === 0) {
138
+ errors.push(
139
+ "Events table is in a bad state: undo event without a migration to undo"
140
+ );
141
+ break;
142
+ } else if (
143
+ getUndoFilename(
144
+ appliedVersionedMigrations[appliedVersionedMigrations.length - 1]
145
+ .filename
146
+ ) !== event.filename
147
+ ) {
148
+ errors.push(
149
+ "Events table is in a bad state: down migration does not match the most recently applied migration"
150
+ );
151
+ break;
152
+ } else {
153
+ appliedVersionedMigrations.pop();
154
+ }
155
+ } else if (event.type === "repeatable") {
156
+ appliedRepeatableMigrations.push(event);
157
+ } else {
158
+ errors.push(
159
+ `Events table is in a bad state: unknown event type ${event.type}`
160
+ );
161
+ break;
162
+ }
163
+ }
164
+
165
+ return { errors, appliedVersionedMigrations, appliedRepeatableMigrations };
166
+ };
package/src/types.ts CHANGED
@@ -1,9 +1,54 @@
1
- export interface MigrationRow {
2
- id: string;
3
- name: string;
1
+ import { z } from "zod";
2
+
3
+ export type MigrationType = "versioned" | "undo" | "repeatable";
4
+
5
+ export interface MigrationFile {
6
+ type: MigrationType;
7
+ filename: string;
8
+ script: string;
9
+ }
10
+
11
+ export interface AppliedMigration {
12
+ id: number;
13
+ type: MigrationType;
14
+ filename: string;
4
15
  script: string;
5
16
  applied_by: string;
6
17
  applied_at: string;
7
18
  }
8
19
 
9
- export type AuditRow = MigrationRow & { type: "up" | "down" };
20
+ export interface MigrationState {
21
+ schema: string;
22
+ current: {
23
+ appliedVersionedMigrations: AppliedMigration[];
24
+ appliedRepeatableMigrations: AppliedMigration[];
25
+ };
26
+ events: {
27
+ id: number;
28
+ type: MigrationType;
29
+ filename: string;
30
+ script: string;
31
+ applied_by: string;
32
+ applied_at: string;
33
+ }[];
34
+ files: {
35
+ allFiles: MigrationFile[];
36
+ unappliedVersionedFiles: MigrationFile[];
37
+ unappliedRepeatableFiles: MigrationFile[];
38
+ versionedFiles: MigrationFile[];
39
+ undoFiles: MigrationFile[];
40
+ repeatableFiles: MigrationFile[];
41
+ };
42
+ errors: string[];
43
+ }
44
+
45
+ const MigrationType = z.enum(["versioned", "undo", "repeatable"]);
46
+
47
+ export const EventRow = z.object({
48
+ id: z.number(),
49
+ type: MigrationType,
50
+ filename: z.string(),
51
+ script: z.string(),
52
+ applied_by: z.string(),
53
+ applied_at: z.string(),
54
+ });