@prairielearn/migrations 1.0.0
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.
- package/.turbo/turbo-build.log +0 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +174 -0
- package/dist/index.js.map +1 -0
- package/dist/index.sql +61 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +127 -0
- package/dist/index.test.js.map +1 -0
- package/package.json +26 -0
- package/src/index.sql +61 -0
- package/src/index.test.ts +125 -0
- package/src/index.ts +175 -0
- package/tsconfig.json +8 -0
|
File without changes
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function init(migrationDir: string, project: string): Promise<void>;
|
|
2
|
+
interface MigrationFile {
|
|
3
|
+
filename: string;
|
|
4
|
+
timestamp: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function readAndValidateMigrationsFromDirectory(dir: string): Promise<MigrationFile[]>;
|
|
7
|
+
export declare function sortMigrationFiles(migrationFiles: MigrationFile[]): MigrationFile[];
|
|
8
|
+
export declare function getMigrationsToExecute(migrationFiles: MigrationFile[], executedMigrations: {
|
|
9
|
+
timestamp: string | null;
|
|
10
|
+
}[]): MigrationFile[];
|
|
11
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
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 (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.getMigrationsToExecute = exports.sortMigrationFiles = exports.readAndValidateMigrationsFromDirectory = exports.init = void 0;
|
|
30
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
31
|
+
const path_1 = __importDefault(require("path"));
|
|
32
|
+
const namedLocks = __importStar(require("@prairielearn/named-locks"));
|
|
33
|
+
const logger_1 = require("@prairielearn/logger");
|
|
34
|
+
const sqldb = __importStar(require("@prairielearn/postgres"));
|
|
35
|
+
const error = __importStar(require("@prairielearn/error"));
|
|
36
|
+
const sql = sqldb.loadSqlEquiv(__filename);
|
|
37
|
+
async function init(migrationDir, project) {
|
|
38
|
+
const lockName = 'migrations';
|
|
39
|
+
logger_1.logger.verbose(`Waiting for lock ${lockName}`);
|
|
40
|
+
await namedLocks.doWithLock(lockName, {}, async () => {
|
|
41
|
+
logger_1.logger.verbose(`Acquired lock ${lockName}`);
|
|
42
|
+
await initWithLock(migrationDir, project);
|
|
43
|
+
});
|
|
44
|
+
logger_1.logger.verbose(`Released lock ${lockName}`);
|
|
45
|
+
}
|
|
46
|
+
exports.init = init;
|
|
47
|
+
/**
|
|
48
|
+
* Timestamp prefixes will be of the form `YYYYMMDDHHMMSS`, which will have 14 digits.
|
|
49
|
+
* If this code is still around in the year 10000... good luck.
|
|
50
|
+
*/
|
|
51
|
+
const MIGRATION_FILENAME_REGEX = /^([0-9]{14})_.+\.sql$/;
|
|
52
|
+
async function readAndValidateMigrationsFromDirectory(dir) {
|
|
53
|
+
const migrationFiles = (await fs_extra_1.default.readdir(dir)).filter((m) => m.endsWith('.sql'));
|
|
54
|
+
const migrations = migrationFiles.map((mf) => {
|
|
55
|
+
const match = mf.match(MIGRATION_FILENAME_REGEX);
|
|
56
|
+
if (!match) {
|
|
57
|
+
throw new Error(`Invalid migration filename: ${mf}`);
|
|
58
|
+
}
|
|
59
|
+
const timestamp = match[1] ?? null;
|
|
60
|
+
if (timestamp === null) {
|
|
61
|
+
throw new Error(`Migration ${mf} does not have a timestamp`);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
filename: mf,
|
|
65
|
+
timestamp,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
// First pass: validate that all migrations have a unique timestamp prefix.
|
|
69
|
+
// This will avoid data loss and conflicts in unexpected scenarios.
|
|
70
|
+
let seenTimestamps = new Set();
|
|
71
|
+
for (const migration of migrations) {
|
|
72
|
+
const { filename, timestamp } = migration;
|
|
73
|
+
if (timestamp !== null) {
|
|
74
|
+
if (seenTimestamps.has(timestamp)) {
|
|
75
|
+
throw new Error(`Duplicate migration timestamp: ${timestamp} (${filename})`);
|
|
76
|
+
}
|
|
77
|
+
seenTimestamps.add(timestamp);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return migrations;
|
|
81
|
+
}
|
|
82
|
+
exports.readAndValidateMigrationsFromDirectory = readAndValidateMigrationsFromDirectory;
|
|
83
|
+
function sortMigrationFiles(migrationFiles) {
|
|
84
|
+
return migrationFiles.sort((a, b) => {
|
|
85
|
+
return a.timestamp.localeCompare(b.timestamp);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
exports.sortMigrationFiles = sortMigrationFiles;
|
|
89
|
+
function getMigrationsToExecute(migrationFiles, executedMigrations) {
|
|
90
|
+
// If no migrations have ever been run, run them all.
|
|
91
|
+
if (executedMigrations.length === 0) {
|
|
92
|
+
return migrationFiles;
|
|
93
|
+
}
|
|
94
|
+
const executedMigrationTimestamps = new Set(executedMigrations.map((m) => m.timestamp));
|
|
95
|
+
return migrationFiles.filter((m) => !executedMigrationTimestamps.has(m.timestamp));
|
|
96
|
+
}
|
|
97
|
+
exports.getMigrationsToExecute = getMigrationsToExecute;
|
|
98
|
+
async function initWithLock(migrationDir, project) {
|
|
99
|
+
logger_1.logger.verbose('Starting DB schema migration');
|
|
100
|
+
// Create the migrations table if needed
|
|
101
|
+
await sqldb.queryAsync(sql.create_migrations_table, {});
|
|
102
|
+
// Apply necessary changes to the migrations table as needed.
|
|
103
|
+
try {
|
|
104
|
+
await sqldb.queryAsync('SELECT project FROM migrations;', {});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err.routine === 'errorMissingColumn') {
|
|
108
|
+
logger_1.logger.info('Altering migrations table');
|
|
109
|
+
await sqldb.queryAsync(sql.add_projects_column, {});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await sqldb.queryAsync('SELECT timestamp FROM migrations;', {});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (err.routine === 'errorMissingColumn') {
|
|
120
|
+
logger_1.logger.info('Altering migrations table again');
|
|
121
|
+
await sqldb.queryAsync(sql.add_timestamp_column, {});
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let allMigrations = await sqldb.queryAsync(sql.get_migrations, { project });
|
|
128
|
+
const migrationFiles = await readAndValidateMigrationsFromDirectory(migrationDir);
|
|
129
|
+
// Validation: if we not all previously-executed migrations have timestamps,
|
|
130
|
+
// prompt the user to deploy an earlier version that includes both indexes
|
|
131
|
+
// and timestamps.
|
|
132
|
+
const migrationsMissingTimestamps = allMigrations.rows.filter((m) => !m.timestamp);
|
|
133
|
+
if (migrationsMissingTimestamps.length > 0) {
|
|
134
|
+
throw new Error([
|
|
135
|
+
'The following migrations are missing timestamps:',
|
|
136
|
+
migrationsMissingTimestamps.map((m) => ` ${m.filename}`),
|
|
137
|
+
// This revision was the most recent commit to `master` before the
|
|
138
|
+
// code handling indexes was removed.
|
|
139
|
+
'You must deploy revision 1aa43c7348fa24cf636413d720d06a2fa9e38ef2 first.',
|
|
140
|
+
].join('\n'));
|
|
141
|
+
}
|
|
142
|
+
// Refetch the list of migrations from the database.
|
|
143
|
+
allMigrations = await sqldb.queryAsync(sql.get_migrations, { project });
|
|
144
|
+
// Sort the migration files into execution order.
|
|
145
|
+
const sortedMigrationFiles = sortMigrationFiles(migrationFiles);
|
|
146
|
+
// Figure out which migrations have to be applied.
|
|
147
|
+
const migrationsToExecute = getMigrationsToExecute(sortedMigrationFiles, allMigrations.rows);
|
|
148
|
+
for (const { filename, timestamp } of migrationsToExecute) {
|
|
149
|
+
if (allMigrations.rows.length === 0) {
|
|
150
|
+
// if we are running all the migrations then log at a lower level
|
|
151
|
+
logger_1.logger.verbose(`Running migration ${filename}`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
logger_1.logger.info(`Running migration ${filename}`);
|
|
155
|
+
}
|
|
156
|
+
// Read the migration.
|
|
157
|
+
const migrationSql = await fs_extra_1.default.readFile(path_1.default.join(migrationDir, filename), 'utf8');
|
|
158
|
+
// Perform the migration.
|
|
159
|
+
try {
|
|
160
|
+
await sqldb.queryAsync(migrationSql, {});
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
error.addData(err, { sqlFile: filename });
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
// Record the migration.
|
|
167
|
+
await sqldb.queryAsync(sql.insert_migration, {
|
|
168
|
+
filename: filename,
|
|
169
|
+
timestamp,
|
|
170
|
+
project,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.sql
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
-- BLOCK create_migrations_table
|
|
2
|
+
CREATE TABLE IF NOT EXISTS
|
|
3
|
+
migrations (
|
|
4
|
+
id BIGSERIAL PRIMARY KEY,
|
|
5
|
+
filename TEXT,
|
|
6
|
+
index INTEGER,
|
|
7
|
+
project TEXT DEFAULT 'prairielearn',
|
|
8
|
+
applied_at TIMESTAMP WITH TIME ZONE,
|
|
9
|
+
timestamp TEXT,
|
|
10
|
+
UNIQUE (project, index),
|
|
11
|
+
UNIQUE (project, timestamp)
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- BLOCK add_projects_column
|
|
15
|
+
ALTER TABLE migrations
|
|
16
|
+
ADD COLUMN IF NOT EXISTS project TEXT DEFAULT 'prairielearn';
|
|
17
|
+
|
|
18
|
+
CREATE UNIQUE INDEX IF NOT EXISTS migrations_project_index_key ON migrations (index, project);
|
|
19
|
+
|
|
20
|
+
ALTER TABLE migrations
|
|
21
|
+
DROP CONSTRAINT migrations_index_key;
|
|
22
|
+
|
|
23
|
+
DROP INDEX IF EXISTS migrations_index_key;
|
|
24
|
+
|
|
25
|
+
-- BLOCK add_timestamp_column
|
|
26
|
+
ALTER TABLE migrations
|
|
27
|
+
ADD COLUMN IF NOT EXISTS timestamp TEXT;
|
|
28
|
+
|
|
29
|
+
CREATE UNIQUE INDEX IF NOT EXISTS migrations_project_timestamp_key ON migrations (timestamp, project);
|
|
30
|
+
|
|
31
|
+
-- BLOCK get_migrations
|
|
32
|
+
SELECT
|
|
33
|
+
id,
|
|
34
|
+
filename,
|
|
35
|
+
index,
|
|
36
|
+
timestamp
|
|
37
|
+
FROM
|
|
38
|
+
migrations
|
|
39
|
+
WHERE
|
|
40
|
+
project = $project;
|
|
41
|
+
|
|
42
|
+
-- BLOCK update_migration
|
|
43
|
+
UPDATE migrations
|
|
44
|
+
SET
|
|
45
|
+
filename = $filename,
|
|
46
|
+
timestamp = $timestamp
|
|
47
|
+
WHERE
|
|
48
|
+
id = $id;
|
|
49
|
+
|
|
50
|
+
-- BLOCK insert_migration
|
|
51
|
+
INSERT INTO
|
|
52
|
+
migrations (filename, timestamp, project, applied_at)
|
|
53
|
+
VALUES
|
|
54
|
+
(
|
|
55
|
+
$filename::TEXT,
|
|
56
|
+
$timestamp,
|
|
57
|
+
$project,
|
|
58
|
+
CURRENT_TIMESTAMP
|
|
59
|
+
)
|
|
60
|
+
RETURNING
|
|
61
|
+
id;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
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 (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
const chai_1 = __importStar(require("chai"));
|
|
30
|
+
const chai_as_promised_1 = __importDefault(require("chai-as-promised"));
|
|
31
|
+
const path_1 = __importDefault(require("path"));
|
|
32
|
+
const tmp_promise_1 = __importDefault(require("tmp-promise"));
|
|
33
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
34
|
+
const index_1 = require("./index");
|
|
35
|
+
chai_1.default.use(chai_as_promised_1.default);
|
|
36
|
+
async function withMigrationFiles(files, fn) {
|
|
37
|
+
await tmp_promise_1.default.withDir(async function (tmpDir) {
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
await fs_extra_1.default.writeFile(path_1.default.join(tmpDir.path, file), '');
|
|
40
|
+
}
|
|
41
|
+
await fn(tmpDir.path);
|
|
42
|
+
}, { unsafeCleanup: true });
|
|
43
|
+
}
|
|
44
|
+
describe('migrations', () => {
|
|
45
|
+
describe('readAndValidateMigrationsFromDirectory', () => {
|
|
46
|
+
it('handles migrations without a timestamp', async () => {
|
|
47
|
+
await withMigrationFiles(['001_testing.sql'], async (tmpDir) => {
|
|
48
|
+
await chai_1.assert.isRejected((0, index_1.readAndValidateMigrationsFromDirectory)(tmpDir), 'Invalid migration filename: 001_testing.sql');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('handles duplicate timestamps', async () => {
|
|
52
|
+
await withMigrationFiles(['20220101010101_testing.sql', '20220101010101_testing_again.sql'], async (tmpDir) => {
|
|
53
|
+
await chai_1.assert.isRejected((0, index_1.readAndValidateMigrationsFromDirectory)(tmpDir), 'Duplicate migration timestamp');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('sortMigrationFiles', () => {
|
|
58
|
+
it('sorts by timestamp', () => {
|
|
59
|
+
chai_1.assert.deepEqual((0, index_1.sortMigrationFiles)([
|
|
60
|
+
{
|
|
61
|
+
filename: '20220101010103_testing_3.sql',
|
|
62
|
+
timestamp: '20220101010103',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
filename: '20220101010101_testing_1.sql',
|
|
66
|
+
timestamp: '20220101010101',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
filename: '20220101010102_testing_2.sql',
|
|
70
|
+
timestamp: '20220101010102',
|
|
71
|
+
},
|
|
72
|
+
]), [
|
|
73
|
+
{
|
|
74
|
+
filename: '20220101010101_testing_1.sql',
|
|
75
|
+
timestamp: '20220101010101',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
filename: '20220101010102_testing_2.sql',
|
|
79
|
+
timestamp: '20220101010102',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
filename: '20220101010103_testing_3.sql',
|
|
83
|
+
timestamp: '20220101010103',
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('getMigrationsToExecute', () => {
|
|
89
|
+
it('handles the case of no executed migrations', () => {
|
|
90
|
+
const migrationFiles = [
|
|
91
|
+
{
|
|
92
|
+
filename: '001_testing.sql',
|
|
93
|
+
timestamp: '20220101010101',
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
chai_1.assert.deepEqual((0, index_1.getMigrationsToExecute)(migrationFiles, []), migrationFiles);
|
|
97
|
+
});
|
|
98
|
+
it('handles case where subset of migrations have been executed', () => {
|
|
99
|
+
const migrationFiles = [
|
|
100
|
+
{
|
|
101
|
+
filename: '20220101010101_testing_1.sql',
|
|
102
|
+
timestamp: '20220101010101',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
filename: '20220101010102_testing_2.sql',
|
|
106
|
+
timestamp: '20220101010102',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
filename: '20220101010103_testing_3.sql',
|
|
110
|
+
timestamp: '20220101010103',
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
const executedMigrations = [
|
|
114
|
+
{
|
|
115
|
+
timestamp: '20220101010101',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
timestamp: '20220101010102',
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
chai_1.assert.deepEqual((0, index_1.getMigrationsToExecute)(migrationFiles, executedMigrations), [
|
|
122
|
+
{ timestamp: '20220101010103', filename: '20220101010103_testing_3.sql' },
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
//# sourceMappingURL=index.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prairielearn/migrations",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "tsc && copyfiles -u 1 \"./src/**/*.sql\" dist",
|
|
7
|
+
"dev": "tsc --watch --preserveWatchOutput",
|
|
8
|
+
"test": "mocha --no-config --require ts-node/register src/*.test.ts"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@prairielearn/tsconfig": "*",
|
|
12
|
+
"@types/fs-extra": "^11.0.1",
|
|
13
|
+
"@types/mocha": "^10.0.1",
|
|
14
|
+
"@types/node": "^18.14.2",
|
|
15
|
+
"copyfiles": "^2.4.1",
|
|
16
|
+
"mocha": "^10.2.0",
|
|
17
|
+
"ts-node": "^10.9.1",
|
|
18
|
+
"typescript": "^4.9.5"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@prairielearn/error": "^1.0.0",
|
|
22
|
+
"@prairielearn/named-locks": "^1.0.0",
|
|
23
|
+
"@prairielearn/postgres": "^1.2.0",
|
|
24
|
+
"fs-extra": "^11.1.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/index.sql
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
-- BLOCK create_migrations_table
|
|
2
|
+
CREATE TABLE IF NOT EXISTS
|
|
3
|
+
migrations (
|
|
4
|
+
id BIGSERIAL PRIMARY KEY,
|
|
5
|
+
filename TEXT,
|
|
6
|
+
index INTEGER,
|
|
7
|
+
project TEXT DEFAULT 'prairielearn',
|
|
8
|
+
applied_at TIMESTAMP WITH TIME ZONE,
|
|
9
|
+
timestamp TEXT,
|
|
10
|
+
UNIQUE (project, index),
|
|
11
|
+
UNIQUE (project, timestamp)
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- BLOCK add_projects_column
|
|
15
|
+
ALTER TABLE migrations
|
|
16
|
+
ADD COLUMN IF NOT EXISTS project TEXT DEFAULT 'prairielearn';
|
|
17
|
+
|
|
18
|
+
CREATE UNIQUE INDEX IF NOT EXISTS migrations_project_index_key ON migrations (index, project);
|
|
19
|
+
|
|
20
|
+
ALTER TABLE migrations
|
|
21
|
+
DROP CONSTRAINT migrations_index_key;
|
|
22
|
+
|
|
23
|
+
DROP INDEX IF EXISTS migrations_index_key;
|
|
24
|
+
|
|
25
|
+
-- BLOCK add_timestamp_column
|
|
26
|
+
ALTER TABLE migrations
|
|
27
|
+
ADD COLUMN IF NOT EXISTS timestamp TEXT;
|
|
28
|
+
|
|
29
|
+
CREATE UNIQUE INDEX IF NOT EXISTS migrations_project_timestamp_key ON migrations (timestamp, project);
|
|
30
|
+
|
|
31
|
+
-- BLOCK get_migrations
|
|
32
|
+
SELECT
|
|
33
|
+
id,
|
|
34
|
+
filename,
|
|
35
|
+
index,
|
|
36
|
+
timestamp
|
|
37
|
+
FROM
|
|
38
|
+
migrations
|
|
39
|
+
WHERE
|
|
40
|
+
project = $project;
|
|
41
|
+
|
|
42
|
+
-- BLOCK update_migration
|
|
43
|
+
UPDATE migrations
|
|
44
|
+
SET
|
|
45
|
+
filename = $filename,
|
|
46
|
+
timestamp = $timestamp
|
|
47
|
+
WHERE
|
|
48
|
+
id = $id;
|
|
49
|
+
|
|
50
|
+
-- BLOCK insert_migration
|
|
51
|
+
INSERT INTO
|
|
52
|
+
migrations (filename, timestamp, project, applied_at)
|
|
53
|
+
VALUES
|
|
54
|
+
(
|
|
55
|
+
$filename::TEXT,
|
|
56
|
+
$timestamp,
|
|
57
|
+
$project,
|
|
58
|
+
CURRENT_TIMESTAMP
|
|
59
|
+
)
|
|
60
|
+
RETURNING
|
|
61
|
+
id;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import chai, { assert } from 'chai';
|
|
2
|
+
import chaiAsPromised from 'chai-as-promised';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import tmp from 'tmp-promise';
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
readAndValidateMigrationsFromDirectory,
|
|
9
|
+
sortMigrationFiles,
|
|
10
|
+
getMigrationsToExecute,
|
|
11
|
+
} from './index';
|
|
12
|
+
|
|
13
|
+
chai.use(chaiAsPromised);
|
|
14
|
+
|
|
15
|
+
async function withMigrationFiles(files: string[], fn: (tmpDir: string) => Promise<void>) {
|
|
16
|
+
await tmp.withDir(
|
|
17
|
+
async function (tmpDir) {
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
await fs.writeFile(path.join(tmpDir.path, file), '');
|
|
20
|
+
}
|
|
21
|
+
await fn(tmpDir.path);
|
|
22
|
+
},
|
|
23
|
+
{ unsafeCleanup: true }
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('migrations', () => {
|
|
28
|
+
describe('readAndValidateMigrationsFromDirectory', () => {
|
|
29
|
+
it('handles migrations without a timestamp', async () => {
|
|
30
|
+
await withMigrationFiles(['001_testing.sql'], async (tmpDir) => {
|
|
31
|
+
await assert.isRejected(
|
|
32
|
+
readAndValidateMigrationsFromDirectory(tmpDir),
|
|
33
|
+
'Invalid migration filename: 001_testing.sql'
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('handles duplicate timestamps', async () => {
|
|
39
|
+
await withMigrationFiles(
|
|
40
|
+
['20220101010101_testing.sql', '20220101010101_testing_again.sql'],
|
|
41
|
+
async (tmpDir) => {
|
|
42
|
+
await assert.isRejected(
|
|
43
|
+
readAndValidateMigrationsFromDirectory(tmpDir),
|
|
44
|
+
'Duplicate migration timestamp'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('sortMigrationFiles', () => {
|
|
52
|
+
it('sorts by timestamp', () => {
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
sortMigrationFiles([
|
|
55
|
+
{
|
|
56
|
+
filename: '20220101010103_testing_3.sql',
|
|
57
|
+
timestamp: '20220101010103',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
filename: '20220101010101_testing_1.sql',
|
|
61
|
+
timestamp: '20220101010101',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
filename: '20220101010102_testing_2.sql',
|
|
65
|
+
timestamp: '20220101010102',
|
|
66
|
+
},
|
|
67
|
+
]),
|
|
68
|
+
[
|
|
69
|
+
{
|
|
70
|
+
filename: '20220101010101_testing_1.sql',
|
|
71
|
+
timestamp: '20220101010101',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
filename: '20220101010102_testing_2.sql',
|
|
75
|
+
timestamp: '20220101010102',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
filename: '20220101010103_testing_3.sql',
|
|
79
|
+
timestamp: '20220101010103',
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('getMigrationsToExecute', () => {
|
|
87
|
+
it('handles the case of no executed migrations', () => {
|
|
88
|
+
const migrationFiles = [
|
|
89
|
+
{
|
|
90
|
+
filename: '001_testing.sql',
|
|
91
|
+
timestamp: '20220101010101',
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
assert.deepEqual(getMigrationsToExecute(migrationFiles, []), migrationFiles);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('handles case where subset of migrations have been executed', () => {
|
|
98
|
+
const migrationFiles = [
|
|
99
|
+
{
|
|
100
|
+
filename: '20220101010101_testing_1.sql',
|
|
101
|
+
timestamp: '20220101010101',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
filename: '20220101010102_testing_2.sql',
|
|
105
|
+
timestamp: '20220101010102',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
filename: '20220101010103_testing_3.sql',
|
|
109
|
+
timestamp: '20220101010103',
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const executedMigrations = [
|
|
113
|
+
{
|
|
114
|
+
timestamp: '20220101010101',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
timestamp: '20220101010102',
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
assert.deepEqual(getMigrationsToExecute(migrationFiles, executedMigrations), [
|
|
121
|
+
{ timestamp: '20220101010103', filename: '20220101010103_testing_3.sql' },
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import * as namedLocks from '@prairielearn/named-locks';
|
|
5
|
+
import { logger } from '@prairielearn/logger';
|
|
6
|
+
import * as sqldb from '@prairielearn/postgres';
|
|
7
|
+
import * as error from '@prairielearn/error';
|
|
8
|
+
|
|
9
|
+
const sql = sqldb.loadSqlEquiv(__filename);
|
|
10
|
+
|
|
11
|
+
export async function init(migrationDir: string, project: string) {
|
|
12
|
+
const lockName = 'migrations';
|
|
13
|
+
logger.verbose(`Waiting for lock ${lockName}`);
|
|
14
|
+
await namedLocks.doWithLock(lockName, {}, async () => {
|
|
15
|
+
logger.verbose(`Acquired lock ${lockName}`);
|
|
16
|
+
await initWithLock(migrationDir, project);
|
|
17
|
+
});
|
|
18
|
+
logger.verbose(`Released lock ${lockName}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Timestamp prefixes will be of the form `YYYYMMDDHHMMSS`, which will have 14 digits.
|
|
23
|
+
* If this code is still around in the year 10000... good luck.
|
|
24
|
+
*/
|
|
25
|
+
const MIGRATION_FILENAME_REGEX = /^([0-9]{14})_.+\.sql$/;
|
|
26
|
+
|
|
27
|
+
interface MigrationFile {
|
|
28
|
+
filename: string;
|
|
29
|
+
timestamp: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readAndValidateMigrationsFromDirectory(
|
|
33
|
+
dir: string
|
|
34
|
+
): Promise<MigrationFile[]> {
|
|
35
|
+
const migrationFiles = (await fs.readdir(dir)).filter((m) => m.endsWith('.sql'));
|
|
36
|
+
|
|
37
|
+
const migrations = migrationFiles.map((mf) => {
|
|
38
|
+
const match = mf.match(MIGRATION_FILENAME_REGEX);
|
|
39
|
+
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(`Invalid migration filename: ${mf}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const timestamp = match[1] ?? null;
|
|
45
|
+
|
|
46
|
+
if (timestamp === null) {
|
|
47
|
+
throw new Error(`Migration ${mf} does not have a timestamp`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
filename: mf,
|
|
52
|
+
timestamp,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// First pass: validate that all migrations have a unique timestamp prefix.
|
|
57
|
+
// This will avoid data loss and conflicts in unexpected scenarios.
|
|
58
|
+
let seenTimestamps = new Set();
|
|
59
|
+
for (const migration of migrations) {
|
|
60
|
+
const { filename, timestamp } = migration;
|
|
61
|
+
|
|
62
|
+
if (timestamp !== null) {
|
|
63
|
+
if (seenTimestamps.has(timestamp)) {
|
|
64
|
+
throw new Error(`Duplicate migration timestamp: ${timestamp} (${filename})`);
|
|
65
|
+
}
|
|
66
|
+
seenTimestamps.add(timestamp);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return migrations;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sortMigrationFiles(migrationFiles: MigrationFile[]): MigrationFile[] {
|
|
74
|
+
return migrationFiles.sort((a, b) => {
|
|
75
|
+
return a.timestamp.localeCompare(b.timestamp);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getMigrationsToExecute(
|
|
80
|
+
migrationFiles: MigrationFile[],
|
|
81
|
+
executedMigrations: { timestamp: string | null }[]
|
|
82
|
+
): MigrationFile[] {
|
|
83
|
+
// If no migrations have ever been run, run them all.
|
|
84
|
+
if (executedMigrations.length === 0) {
|
|
85
|
+
return migrationFiles;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const executedMigrationTimestamps = new Set(executedMigrations.map((m) => m.timestamp));
|
|
89
|
+
return migrationFiles.filter((m) => !executedMigrationTimestamps.has(m.timestamp));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function initWithLock(migrationDir: string, project: string) {
|
|
93
|
+
logger.verbose('Starting DB schema migration');
|
|
94
|
+
|
|
95
|
+
// Create the migrations table if needed
|
|
96
|
+
await sqldb.queryAsync(sql.create_migrations_table, {});
|
|
97
|
+
|
|
98
|
+
// Apply necessary changes to the migrations table as needed.
|
|
99
|
+
try {
|
|
100
|
+
await sqldb.queryAsync('SELECT project FROM migrations;', {});
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
if (err.routine === 'errorMissingColumn') {
|
|
103
|
+
logger.info('Altering migrations table');
|
|
104
|
+
await sqldb.queryAsync(sql.add_projects_column, {});
|
|
105
|
+
} else {
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await sqldb.queryAsync('SELECT timestamp FROM migrations;', {});
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
if (err.routine === 'errorMissingColumn') {
|
|
113
|
+
logger.info('Altering migrations table again');
|
|
114
|
+
await sqldb.queryAsync(sql.add_timestamp_column, {});
|
|
115
|
+
} else {
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let allMigrations = await sqldb.queryAsync(sql.get_migrations, { project });
|
|
121
|
+
|
|
122
|
+
const migrationFiles = await readAndValidateMigrationsFromDirectory(migrationDir);
|
|
123
|
+
|
|
124
|
+
// Validation: if we not all previously-executed migrations have timestamps,
|
|
125
|
+
// prompt the user to deploy an earlier version that includes both indexes
|
|
126
|
+
// and timestamps.
|
|
127
|
+
const migrationsMissingTimestamps = allMigrations.rows.filter((m) => !m.timestamp);
|
|
128
|
+
if (migrationsMissingTimestamps.length > 0) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
[
|
|
131
|
+
'The following migrations are missing timestamps:',
|
|
132
|
+
migrationsMissingTimestamps.map((m) => ` ${m.filename}`),
|
|
133
|
+
// This revision was the most recent commit to `master` before the
|
|
134
|
+
// code handling indexes was removed.
|
|
135
|
+
'You must deploy revision 1aa43c7348fa24cf636413d720d06a2fa9e38ef2 first.',
|
|
136
|
+
].join('\n')
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Refetch the list of migrations from the database.
|
|
141
|
+
allMigrations = await sqldb.queryAsync(sql.get_migrations, { project });
|
|
142
|
+
|
|
143
|
+
// Sort the migration files into execution order.
|
|
144
|
+
const sortedMigrationFiles = sortMigrationFiles(migrationFiles);
|
|
145
|
+
|
|
146
|
+
// Figure out which migrations have to be applied.
|
|
147
|
+
const migrationsToExecute = getMigrationsToExecute(sortedMigrationFiles, allMigrations.rows);
|
|
148
|
+
|
|
149
|
+
for (const { filename, timestamp } of migrationsToExecute) {
|
|
150
|
+
if (allMigrations.rows.length === 0) {
|
|
151
|
+
// if we are running all the migrations then log at a lower level
|
|
152
|
+
logger.verbose(`Running migration ${filename}`);
|
|
153
|
+
} else {
|
|
154
|
+
logger.info(`Running migration ${filename}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Read the migration.
|
|
158
|
+
const migrationSql = await fs.readFile(path.join(migrationDir, filename), 'utf8');
|
|
159
|
+
|
|
160
|
+
// Perform the migration.
|
|
161
|
+
try {
|
|
162
|
+
await sqldb.queryAsync(migrationSql, {});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
error.addData(err, { sqlFile: filename });
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Record the migration.
|
|
169
|
+
await sqldb.queryAsync(sql.insert_migration, {
|
|
170
|
+
filename: filename,
|
|
171
|
+
timestamp,
|
|
172
|
+
project,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|