Skip to content

Commit e26f308

Browse files
committed
Expose function to validate migration files
The 'consecutive IDs' check now runs earlier, after file loading. Fixes #51
1 parent 7b15e3a commit e26f308

File tree

10 files changed

+108
-50
lines changed

10 files changed

+108
-50
lines changed

CHANGELOG.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
# Changelog
22

3-
## V5
3+
## 5.1.0
4+
5+
- Validate migration ordering when loading files (instead of when applying migrations)
6+
- Expose `loadMigrationFiles` publicly, which can be used to validate files in e.g. a pre-push hook
7+
8+
## 5.0.0
49

510
- [BREAKING] Update `pg` to version 8. See the [pg changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md#pg800) for details.
611

7-
## V4
12+
## 4.0.0
813

914
- [BREAKING] Updated whole project to TypeScript
1015
- some types might differ, no functional change

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,27 @@ async function() {
7676
}
7777
```
7878

79+
### Validating migration files
80+
81+
Occasionally, if two people are working on the same codebase independently, they might both create a migration at the same time. For example, `5_add-table.sql` and `5_add-column.sql`. If these both get pushed, there will be a conflict.
82+
83+
While the migration system will notice this and refuse to apply the migrations, it can be useful to catch this as early as possible.
84+
85+
The `loadMigrationFiles` function can be used to check if the migration files satisfy the rules.
86+
87+
```typescript
88+
import {loadMigrationFiles} from "postgres-migrations"
89+
90+
async function validateMigrations() {
91+
try {
92+
await loadMigrationFiles("path/to/migration/files")
93+
} catch (e) {
94+
// Oh no! Something isn't right with the migration files.
95+
throw e
96+
}
97+
}
98+
```
99+
79100
## Design decisions
80101

81102
### No down migrations

src/__unit__/migration-file-validation/fixtures/conflict/1_add-column.sql

Whitespace-only changes.

src/__unit__/migration-file-validation/fixtures/conflict/1_create-table.sql

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// tslint:disable no-console
2+
import test from "ava"
3+
import {loadMigrationFiles} from "../.."
4+
process.on("uncaughtException", function (err) {
5+
console.log(err)
6+
})
7+
8+
test("two migrations with the same id", async (t) => {
9+
const error = await t.throwsAsync(async () =>
10+
loadMigrationFiles(
11+
"src/__unit__/migration-file-validation/fixtures/conflict",
12+
),
13+
)
14+
t.regex(error.message, /non-consecutive/)
15+
})

src/files-loader.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,47 @@ import * as path from "path"
33
import {promisify} from "util"
44
import {load as loadMigrationFile} from "./migration-file"
55
import {Logger, Migration} from "./types"
6+
import {validateMigrationOrdering} from "./validation"
67

78
const readDir = promisify(fs.readdir)
89

910
const isValidFile = (fileName: string) => /\.(sql|js)$/gi.test(fileName)
1011

12+
/**
13+
* Load the migration files and assert they are reasonably valid.
14+
*
15+
* 'Reasonably valid' in this case means obeying the file name and
16+
* consecutive ordering rules.
17+
*
18+
* No assertions are made about the validity of the SQL.
19+
*/
1120
export const load = async (
1221
directory: string,
13-
log: Logger,
22+
// tslint:disable-next-line no-empty
23+
log: Logger = () => {},
1424
): Promise<Array<Migration>> => {
1525
log(`Loading migrations from: ${directory}`)
1626

1727
const fileNames = await readDir(directory)
1828
log(`Found migration files: ${fileNames}`)
1929

20-
if (fileNames != null) {
21-
const migrationFiles = [
22-
path.join(__dirname, "migrations/0_create-migrations-table.sql"),
23-
...fileNames.map((fileName) => path.resolve(directory, fileName)),
24-
].filter(isValidFile)
30+
if (fileNames == null) {
31+
return []
32+
}
2533

26-
const unorderedMigrations = await Promise.all(
27-
migrationFiles.map(loadMigrationFile),
28-
)
34+
const migrationFiles = [
35+
path.join(__dirname, "migrations/0_create-migrations-table.sql"),
36+
...fileNames.map((fileName) => path.resolve(directory, fileName)),
37+
].filter(isValidFile)
2938

30-
// Arrange in ID order
31-
return unorderedMigrations.sort((a, b) => a.id - b.id)
32-
}
39+
const unorderedMigrations = await Promise.all(
40+
migrationFiles.map(loadMigrationFile),
41+
)
42+
43+
// Arrange in ID order
44+
const orderedMigrations = unorderedMigrations.sort((a, b) => a.id - b.id)
45+
46+
validateMigrationOrdering(orderedMigrations)
3347

34-
return []
48+
return orderedMigrations
3549
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {createDb} from "./create"
22
export {migrate} from "./migrate"
3+
export {load as loadMigrationFiles} from "./files-loader"
34

45
export {
56
ConnectionParams,

src/migrate.ts

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Migration,
1111
MigrationError,
1212
} from "./types"
13+
import {validateMigrationHashes} from "./validation"
1314
import {withConnection} from "./with-connection"
1415
import {withAdvisoryLock} from "./with-lock"
1516

@@ -78,7 +79,7 @@ function runMigrations(intendedMigrations: Array<Migration>, log: Logger) {
7879
log,
7980
)
8081

81-
validateMigrations(intendedMigrations, appliedMigrations)
82+
validateMigrationHashes(intendedMigrations, appliedMigrations)
8283

8384
const migrationsToRun = filterMigrations(
8485
intendedMigrations,
@@ -135,36 +136,6 @@ so the database is new and we need to run all migrations.`)
135136
return appliedMigrations
136137
}
137138

138-
/** Validates mutation order and hash */
139-
function validateMigrations(
140-
migrations: Array<Migration>,
141-
appliedMigrations: Record<number, Migration | undefined>,
142-
) {
143-
const indexNotMatch = (migration: Migration, index: number) =>
144-
migration.id !== index
145-
const invalidHash = (migration: Migration) => {
146-
const appliedMigration = appliedMigrations[migration.id]
147-
return appliedMigration != null && appliedMigration.hash !== migration.hash
148-
}
149-
150-
// Assert migration IDs are consecutive integers
151-
const notMatchingId = migrations.find(indexNotMatch)
152-
if (notMatchingId) {
153-
throw new Error(
154-
`Found a non-consecutive migration ID on file: '${notMatchingId.fileName}'`,
155-
)
156-
}
157-
158-
// Assert migration hashes are still same
159-
const invalidHashes = migrations.filter(invalidHash)
160-
if (invalidHashes.length > 0) {
161-
// Someone has altered one or more migrations which has already run - gasp!
162-
const invalidFiles = invalidHashes.map(({fileName}) => fileName)
163-
throw new Error(`Hashes don't match for migrations '${invalidFiles}'.
164-
This means that the scripts have changed since it was applied.`)
165-
}
166-
}
167-
168139
/** Work out which migrations to apply */
169140
function filterMigrations(
170141
migrations: Array<Migration>,

src/migration-file.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,6 @@ export const load = async (filePath: string) => {
4949
sql,
5050
}
5151
} catch (err) {
52-
throw new Error(`${err.message}
53-
Offending file: '${fileName}'.`)
52+
throw new Error(`${err.message} - Offending file: '${fileName}'.`)
5453
}
5554
}
56-
57-
// module.exports._fileNameParser = fileNameParser

src/validation.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {Migration} from "./types"
2+
3+
const indexNotMatch = (migration: Migration, index: number) =>
4+
migration.id !== index
5+
6+
/** Assert migration IDs are consecutive integers */
7+
export function validateMigrationOrdering(migrations: Array<Migration>) {
8+
const notMatchingId = migrations.find(indexNotMatch)
9+
if (notMatchingId) {
10+
throw new Error(
11+
`Found a non-consecutive migration ID on file: '${notMatchingId.fileName}'`,
12+
)
13+
}
14+
}
15+
16+
/** Assert hashes match */
17+
export function validateMigrationHashes(
18+
migrations: Array<Migration>,
19+
appliedMigrations: Record<number, Migration | undefined>,
20+
) {
21+
const invalidHash = (migration: Migration) => {
22+
const appliedMigration = appliedMigrations[migration.id]
23+
return appliedMigration != null && appliedMigration.hash !== migration.hash
24+
}
25+
26+
// Assert migration hashes are still same
27+
const invalidHashes = migrations.filter(invalidHash)
28+
if (invalidHashes.length > 0) {
29+
// Someone has altered one or more migrations which has already run - gasp!
30+
const invalidFiles = invalidHashes.map(({fileName}) => fileName)
31+
throw new Error(`Hashes don't match for migrations '${invalidFiles}'.
32+
This means that the scripts have changed since it was applied.`)
33+
}
34+
}

0 commit comments

Comments
 (0)