@compas/store
Version:
Postgres & S3-compatible wrappers for common things
453 lines (401 loc) • 11.4 kB
JavaScript
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { AppError, environment, pathJoin } from "@compas/stdlib";
import { query } from "./query.js";
/**
* @typedef {object} MigrateOptions
* @property {string} migrationsDirectory The directory from which to read migration
* files
* @property {number} uniqueLockNumber Unique migration lock value, preventing
* race-conditions while running the migrations
*/
/**
* @typedef {object} MigrationFile
* @property {number} number
* @property {boolean} repeatable
* @property {string} name
* @property {string} fullPath
* @property {boolean} isMigrated
* @property {string} source
* @property {string} hash
*/
/**
* @typedef {object} MigrateContext
* @property {MigrateOptions} options
* @property {Array<MigrationFile>} files
* @property {import("postgres").Sql<{}>} sql
* @property {any|undefined} [rebuild]
* @property {any|undefined} [info]
* @property {any|undefined} [do]
* @property {Record<number, string>} storedHashes
* @property {boolean} [missingMigrationTable]
*/
/**
* Create a new migration context, resolves all migration files
*
* @since 0.1.0
*
* @param {import("postgres").Sql<{}>} sql
* @param {Partial<MigrateOptions>} [migrateOptions]
* @returns {Promise<MigrateContext>}
*/
export async function migrationsInitContext(sql, migrateOptions) {
migrateOptions ??= {};
migrateOptions.uniqueLockNumber ??= -9876453452;
migrateOptions.migrationsDirectory ??= pathJoin(process.cwd(), "migrations");
if (typeof migrateOptions?.uniqueLockNumber !== "number") {
throw AppError.serverError({
message: "'uniqueLockNumber' should be a number",
});
}
if (
!migrateOptions?.migrationsDirectory ||
!existsSync(migrateOptions.migrationsDirectory)
) {
throw AppError.serverError({
message: "'migrationsDirectory' is not provided or does not exist.",
});
}
try {
const migrations = await readMigrationsDir(
migrateOptions.migrationsDirectory,
);
return {
// @ts-expect-error
options: migrateOptions,
files: migrations.sort((a, b) => {
return a.number - b.number;
}),
sql,
storedHashes: {},
};
} catch (/** @type {any} */ error) {
// Help user by dropping the sql connection so the application will exit
await sql?.end();
if (AppError.instanceOf(error)) {
throw error;
} else {
throw new AppError(
"store.migrateContext.error",
500,
{
message: "Could not create migration context",
},
error,
);
}
}
}
/**
* Get the migrations to be applied from the provided migration context.
* Note that 'repeatable' migrations are always in both the `migrationQueue` and
* `hashChanges`.
*
* @since 0.1.0
*
* @param {MigrateContext} mc
* @returns {Promise<{
* migrationQueue: Array<{
* name: string,
* number: number,
* repeatable: boolean
* }>,
* hashChanges: Array<{
* name: string,
* number: number,
* }>
* }>}
*/
export async function migrationsGetInfo(mc) {
await acquireLock(mc.sql, mc.options.uniqueLockNumber);
await syncWithSchemaState(mc);
const migrationQueue = filterMigrationsToBeApplied(mc).map((it) => ({
name: it.name,
number: it.number,
repeatable: it.repeatable,
}));
const hashChanges = [];
for (const it of mc.files) {
if (it.isMigrated && mc.storedHashes[it.number] !== it.hash) {
hashChanges.push({
name: it.name,
number: it.number,
});
}
}
await releaseLock(mc.sql, mc.options.uniqueLockNumber);
return {
migrationQueue,
hashChanges,
};
}
/**
* Run the migrations currently pending in the migration context.
*
* @since 0.1.0
*
* @param {MigrateContext} mc
* @returns {Promise<void>}
*/
export async function migrationsRun(mc) {
await acquireLock(mc.sql, mc.options.uniqueLockNumber);
await syncWithSchemaState(mc);
let current;
try {
const migrationFiles = filterMigrationsToBeApplied(mc);
if (mc.missingMigrationTable && migrationFiles.length > 0) {
mc.missingMigrationTable = false;
if (
!migrationFiles[0]?.source.includes(`migration_namespace_number_idx`)
) {
// Automatically create the migration table
await mc.sql.unsafe(`
CREATE TABLE IF NOT EXISTS migration
(
"namespace" varchar NOT NULL,
"number" int,
"name" varchar NOT NULL,
"createdAt" timestamptz DEFAULT now(),
"hash" varchar
);
CREATE INDEX IF NOT EXISTS migration_namespace_number_idx ON "migration" ("namespace", "number");
`);
}
}
for (const migration of migrationFiles) {
current = migration;
await runMigration(mc.sql, migration);
}
} catch (/** @type {any} */ error) {
await releaseLock(mc.sql, mc.options.uniqueLockNumber);
// Help user by dropping the sql connection so the application will exit
await mc?.sql?.end();
if (AppError.instanceOf(error)) {
throw error;
} else {
throw new AppError(
"store.migrateRun.error",
500,
{
message: "Could not run migration",
number: current?.number,
name: current?.name,
},
error,
);
}
}
await releaseLock(mc.sql, mc.options.uniqueLockNumber);
}
/**
* Rebuild migration table state based on the known migration files
*
* @since 0.1.0
*
* @param {MigrateContext} mc
* @returns {Promise<void>}
*/
export async function migrationsRebuildState(mc) {
await acquireLock(mc.sql, mc.options.uniqueLockNumber);
await syncWithSchemaState(mc);
try {
await mc.sql.begin(async (sql) => {
await sql`DELETE
FROM "migration"
WHERE
1 = 1`;
for (const file of mc.files) {
await runInsert(sql, file);
}
});
} catch (/** @type {any} */ e) {
await releaseLock(mc.sql, mc.options.uniqueLockNumber);
if ((e.message ?? "").indexOf(`"migration" does not exist`) === -1) {
throw new AppError(
"migrate.rebuild.error",
500,
{
message: "No migrations applied yet, can't rebuild migration table.",
},
e,
);
} else {
throw e;
}
}
await releaseLock(mc.sql, mc.options.uniqueLockNumber);
}
/**
* @param {MigrateContext} mc
* @returns {Array<MigrationFile>}
*/
function filterMigrationsToBeApplied(mc) {
const result = [];
for (const f of mc.files) {
if (!f.isMigrated) {
result.push(f);
} else if (mc.storedHashes[f.number] !== f.hash && f.repeatable) {
result.push(f);
}
}
return result;
}
/**
* @param {import("postgres").Sql<{}>} sql
* @param {MigrationFile} migration
* @returns {Promise<void>}
*/
async function runMigration(sql, migration) {
const useTransaction =
!migration.source.includes("-- disable auto transaction") &&
!migration.source.includes("// disable auto transaction");
/** @type {(sql: import("@compas/store").Postgres) => (Promise<void|any>|void)} */
let run = () => {
throw AppError.serverError({
message: "Unknown migration file",
fullPath: migration.fullPath,
});
};
if (migration.fullPath.endsWith(".sql")) {
run = (sql) => sql.unsafe(migration.source);
} else if (migration.fullPath.endsWith(".js")) {
run = async (sql) => {
// @ts-ignore
const { migrate } = await import(pathToFileURL(migration.fullPath));
if (typeof migrate !== "function") {
throw AppError.serverError({
message:
"JavaScript migration files should contain the following signature: 'export async function migrate(sql)'.",
});
}
return await migrate(sql);
};
}
if (useTransaction) {
await sql.begin(async (sql) => {
await run(sql);
await runInsert(sql, migration);
});
} else {
await run(sql);
await runInsert(sql, migration);
}
}
/**
* @param {import("postgres").Sql<{}>} sql
* @param {MigrationFile} migration
*/
function runInsert(sql, migration) {
return sql`INSERT INTO "migration" (namespace, number, name, hash)
VALUES (${environment.APP_NAME ?? "compas"}, ${migration.number},
${migration.name}, ${migration.hash});`;
}
/**
* @param {MigrateContext} mc
* @returns {Promise<void>}
*/
async function syncWithSchemaState(mc) {
let rows = [];
try {
rows = await mc.sql`
SELECT DISTINCT ON (number) number, hash
FROM migration
ORDER BY number, "createdAt" DESC
`;
} catch (/** @type {any} */ e) {
if ((e.message ?? "").includes(`"migration" does not exist`)) {
mc.missingMigrationTable = true;
} else {
throw new AppError(
"store.migrateSync.error",
500,
{
message: "Could not read existing migration table",
},
e,
);
}
return;
}
const numbers = [];
for (const row of rows) {
numbers.push(Number(row.number));
mc.storedHashes[Number(row.number)] = row.hash;
}
for (const mF of mc.files) {
if (numbers.includes(mF.number)) {
mF.isMigrated = true;
}
}
}
/**
* @param {import("postgres").Sql<{}>} sql
* @param {number} lockValue
*/
async function acquireLock(sql, lockValue) {
// Should be automatically released by Postgres once this connection ends.
// We expect that the user runs this process for migrations only
await query`SELECT pg_advisory_lock(${lockValue})`.exec(sql);
}
/**
* @param {import("postgres").Sql<{}>} sql
* @param {number} lockValue
*/
async function releaseLock(sql, lockValue) {
// Should be automatically released by Postgres once this connection ends.
// We expect that the user runs this process for migrations only
await query`SELECT pg_advisory_unlock(${lockValue})`.exec(sql);
}
/**
*
* @param directory
* @returns {Promise<Array<MigrationFile>>}
*/
async function readMigrationsDir(directory) {
if (!existsSync(directory)) {
return [];
}
const migrationFiles = [];
const files = await readdir(directory);
for (const f of files) {
const fullPath = pathJoin(directory, f);
if (!f.endsWith(".sql") && !f.endsWith(".js")) {
continue;
}
const { number, repeatable, name } = parseFileName(f);
const source = await readFile(fullPath, "utf-8");
const hash = createHash("sha1").update(source, "utf-8").digest("hex");
migrationFiles.push({
number,
repeatable,
name,
fullPath,
isMigrated: false,
source,
hash,
});
}
return migrationFiles;
}
/**
* @param {string} fileName
*/
function parseFileName(fileName) {
const filePattern = /(\d+)(-r)?-([a-zA-Z-]+).(js|sql)/g;
filePattern.lastIndex = 0;
if (!filePattern.test(fileName)) {
throw new Error(
`migration: only supports the following file pattern: '000-my-name.{sql,js}' or '001-r-name.sql' for repeatable migrations`,
);
}
filePattern.lastIndex = 0;
// @ts-ignore
const [, number, repeatable, name] = filePattern.exec(fileName);
return {
number: Number(number),
name,
repeatable: !!repeatable,
};
}