graphile-migrate
Version:
Opinionated SQL-powered migration tool for PostgreSQL
240 lines • 9.62 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const assert = require("assert");
const fs_1 = require("fs");
const header_1 = require("./header");
const migration_1 = require("./migration");
exports.VALID_FILE_REGEX = /^([0-9]+)(-[-_a-zA-Z0-9]*)?\.sql$/;
async function statOrNull(path) {
try {
return await fs_1.promises.stat(path);
}
catch (e) {
if (e.code === "ENOENT") {
return null;
}
throw e;
}
}
async function readFileOrNull(path) {
try {
return await fs_1.promises.readFile(path, "utf8");
}
catch (e) {
if (e.code === "ENOENT") {
return null;
}
throw e;
}
}
async function readFileOrError(path) {
try {
return await fs_1.promises.readFile(path, "utf8");
}
catch (e) {
throw new Error(`Failed to read file at '${path}': ${e.message}`);
}
}
async function getCurrentMigrationLocation(parsedSettings) {
const filePath = `${parsedSettings.migrationsFolder}/current.sql`;
const dirPath = `${parsedSettings.migrationsFolder}/current`;
const fileStats = await statOrNull(filePath);
const dirStats = await statOrNull(dirPath);
if (fileStats && !fileStats.isFile()) {
throw new Error(`'${filePath}' exists but is not a file.`);
}
if (dirStats && !dirStats.isDirectory()) {
throw new Error(`'${dirPath}' exists but is not a directory.`);
}
if (fileStats && dirStats) {
throw new Error(`Invalid current migration: both the '${filePath}' file and the '${dirPath}' directory exist; only one of these may exist at a time.`);
}
const isFile = !dirStats;
const stats = isFile ? fileStats : dirStats;
const exists = !!stats;
return {
isFile,
path: isFile ? filePath : dirPath,
exists,
};
}
exports.getCurrentMigrationLocation = getCurrentMigrationLocation;
function idFromFilename(file) {
const matches = exports.VALID_FILE_REGEX.exec(file);
if (!matches) {
throw new Error(`Invalid current migration filename: '${file}'. File must follow the naming 001.sql or 001-message.sql, where 001 is a unique number (with optional zero padding) and message is an optional alphanumeric string.`);
}
const [, rawId, _message] = matches;
const id = parseInt(rawId, 10);
if (!id || !isFinite(id) || id < 1) {
throw new Error(`Invalid current migration filename: '${file}'. File must start with a (positive) number, could not coerce '${rawId}' to int.`);
}
return id;
}
async function readCurrentMigration(_parsedSettings, location) {
if (location.isFile) {
const content = await readFileOrNull(location.path);
// If file doesn't exist, treat it as if it were empty.
return content || "";
}
else {
const files = await fs_1.promises.readdir(location.path);
const parts = new Map();
for (const file of files) {
// Do not await during this loop, it will limit parallelism
if (file.startsWith(".")) {
// Ignore dotfiles
continue;
}
if (!file.endsWith(".sql")) {
// Skip non-SQL files
continue;
}
const id = idFromFilename(file);
const duplicate = parts.get(id);
if (duplicate) {
throw new Error(`Current migration filename clash: files must have a unique numeric prefix, but at least 2 files ('${file}' and '${duplicate.file}') have the prefix '${id}'.`);
}
const filePath = `${location.path}/${file}`;
const bodyPromise = readFileOrError(filePath);
parts.set(id, {
filePath,
file,
bodyPromise,
});
}
const ids = [...parts.keys()].sort((a, b) => a - b);
let wholeBody = "";
// Like hobbitses
const headerses = [];
for (const id of ids) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { file, filePath, bodyPromise } = parts.get(id);
const contents = await bodyPromise;
const { body, headers } = migration_1.parseMigrationText(filePath, contents, false);
headerses.push(headers);
if (header_1.isNoTransactionDefined(body)) {
throw new Error(`Error in '${location.path}/${file}': cannot use '--! no-transaction' with 'current/' directory migrations; use 'current.sql' instead.`);
}
if (wholeBody.length > 0) {
wholeBody += "\n";
}
// 'split' is not a "header", so it must NOT start with a capital.
wholeBody += `--! split: ${file}\n`;
wholeBody += body.trim() + "\n";
}
const headerLines = [];
for (const headers of headerses) {
for (const key of Object.keys(headers)) {
const value = headers[key];
headerLines.push(migration_1.serializeHeader(key, value));
}
}
if (headerLines.length) {
wholeBody = headerLines.join("\n") + "\n\n" + wholeBody;
}
return wholeBody;
}
}
exports.readCurrentMigration = readCurrentMigration;
async function writeCurrentMigration(parsedSettings, location, body) {
if (body.trim() + "\n" !== body) {
throw new Error("graphile-migrate error - 'body' should be sanitized before being passed to 'writeCurrentMigration'");
}
if (location.isFile) {
await fs_1.promises.writeFile(location.path, body);
}
else {
// Split body and write to files
const lines = body.split("\n");
/**
* List of filenames we've written, so we can determine which files not to
* delete.
*/
const filenamesWritten = [];
/**
* List of write operation promises, so we can do all our waiting in
* parallel at the end.
*/
const writePromises = [];
/**
* The next file that will be written to, once all lines are accumulated.
*/
let nextFileToWrite = null;
/**
* The lines being accumulated to write to `nextFileToWrite`.
*/
let linesToWrite = [];
/**
* The highest file index we've seen, so that we can ensure that we don't
* have any ambiguities or conflicts.
*/
let highestIndex = 0;
let highestIndexFilename = null;
/**
* Writes `linesToWrite` to `nextFileToWrite` (or '001.sql' if unknown), then
* resets these variables ready for the next batch.
*/
const flushToFile = (force = false) => {
if (!linesToWrite.length && !nextFileToWrite) {
// Optimisation to avoid writing the initial empty migration file before the first `--! split`
return;
}
const sql = linesToWrite.join("\n").trim() + "\n";
const fileName = nextFileToWrite || (force ? `${highestIndex + 1}-current.sql` : null);
if (!fileName) {
// Merge into first file
return;
}
const id = idFromFilename(fileName);
if (id <= highestIndex) {
throw new Error(`Bad migration, split ids must be monotonically increasing, but '${id}' (from '${fileName}') <= '${highestIndex}' (from '${highestIndexFilename}').`);
}
highestIndex = id;
highestIndexFilename = fileName;
writePromises.push(fs_1.promises.writeFile(`${location.path}/${fileName}`, sql));
filenamesWritten.push(fileName);
linesToWrite = [];
nextFileToWrite = null;
};
for (const line of lines) {
// Do not await in this loop, it decreases parallelism
const matches = /^--! split: ([0-9]+(?:-[-_a-zA-Z0-9]+)?\.sql)$/.exec(line);
if (matches) {
// Write out previous linesToWrite, if appropriate
flushToFile();
// Prepare to write next linesToWrite
nextFileToWrite = matches[1];
}
else {
// Regular line, just add to next linesToWrite
linesToWrite.push(line);
}
}
// Handle any trailing lines
flushToFile(true);
if (writePromises.length === 0) {
// Body must have been empty, so no files were written.
assert.strictEqual(body.length, 0);
// Lets write out just the one empty file.
const filename = `001.sql`;
const sql = parsedSettings.blankMigrationContent;
writePromises.push(fs_1.promises.writeFile(`${location.path}/${filename}`, sql));
filenamesWritten.push(filename);
}
// Clear out old files that were not overwritten
const files = await fs_1.promises.readdir(location.path);
for (const file of files) {
if (exports.VALID_FILE_REGEX.test(file) &&
!file.startsWith(".") &&
file.endsWith(".sql") &&
!filenamesWritten.includes(file)) {
writePromises.push(fs_1.promises.unlink(`${location.path}/${file}`));
}
}
// Wait for writing to finish
await Promise.all(writePromises);
}
}
exports.writeCurrentMigration = writeCurrentMigration;
//# sourceMappingURL=current.js.map