@13w/miri
Version:
MongoDB patch manager
379 lines • 16 kB
JavaScript
import { createHash } from 'node:crypto';
import { readdir, readFile, realpath } from 'node:fs/promises';
import { basename, extname, resolve } from 'node:path';
import colors from 'colors';
import { ObjectId } from 'mongodb';
import { evaluateJs, evaluateMongo } from './evaluator.js';
export var PatchStatus;
(function (PatchStatus) {
PatchStatus[PatchStatus["Ok"] = 0] = "Ok";
PatchStatus[PatchStatus["New"] = 1] = "New";
PatchStatus[PatchStatus["Updated"] = 2] = "Updated";
PatchStatus[PatchStatus["Changed"] = 3] = "Changed";
PatchStatus[PatchStatus["Removed"] = 4] = "Removed";
PatchStatus[PatchStatus["Degraded"] = 5] = "Degraded";
})(PatchStatus || (PatchStatus = {}));
export var IndexStatus;
(function (IndexStatus) {
IndexStatus[IndexStatus["New"] = 0] = "New";
IndexStatus[IndexStatus["Applied"] = 1] = "Applied";
IndexStatus[IndexStatus["Updated"] = 2] = "Updated";
IndexStatus[IndexStatus["Removed"] = 3] = "Removed";
})(IndexStatus || (IndexStatus = {}));
const sortPatches = (a, b) => {
if (a.group === b.group) {
return a.name.localeCompare(b.name);
}
return a.group.localeCompare(b.group);
};
export default class Migrator {
client;
options;
#client;
#collection;
#options;
constructor(client, options = {}) {
this.client = client;
this.options = options;
this.#client = client;
this.#collection = client.db().collection('migrations');
this.#options = options;
}
async [Symbol.asyncDispose]() {
await this.#client.close();
}
async diff(group) {
const localPatches = await this.getLocalPatches(false, group);
const remotePatches = await this.getRemotePatches(group);
for (const localPatch of localPatches) {
const remotePatch = remotePatches.find(({ group, name }) => localPatch.group === group && localPatch.name === name);
if (remotePatch) {
localPatch._id = remotePatch._id;
localPatch.status = PatchStatus.Ok;
if (localPatch.content.up?.hash !== remotePatch.content.up?.hash) {
localPatch.status = PatchStatus.Changed;
}
else if (localPatch.content.test?.hash !== remotePatch.content.test?.hash ||
localPatch.content.down?.hash !== remotePatch.content.down?.hash) {
localPatch.status = PatchStatus.Updated;
}
if (localPatch.status !== PatchStatus.Ok) {
Object.defineProperty(localPatch, 'remoteContent', {
value: remotePatch.content,
enumerable: false,
});
}
remotePatches.splice(remotePatches.indexOf(remotePatch), 1);
}
else {
localPatch.status = PatchStatus.New;
}
}
localPatches.push(...remotePatches.map((remotePatch) => {
remotePatch.status = PatchStatus.Removed;
return remotePatch;
}));
localPatches.sort(sortPatches);
return localPatches;
}
async applyPatchContent(content, filename, wrap = true) {
if (!content?.body) {
return -1;
}
const code = Buffer.from(content.body, 'base64').toString();
return (evaluateMongo(this.client, wrap ? `(async ${code})();` : code, filename).catch((error) => {
throw error;
}) ?? 0);
}
async sync({ remote = false, all = false, degraded = false } = {}) {
const patches = remote ? await this.getRemotePatches() : await this.diff();
// console.log(`Sync: remote: ${remote ? 'on' : 'off'} | all: ${all ? 'on' : 'off'} | degraded: ${degraded ? 'on' : 'off'}`)
for (const patch of patches) {
const filename = `${patch.group}/${patch.name}`;
console.group(`Patch ${patch.group} / ${patch.name}...`);
if (patch.status === PatchStatus.Removed) {
console.group(colors.cyan('reverting changes'));
const result = await this.applyPatchContent((patch.remoteContent ?? patch.content)?.down, filename);
console.log(result === -1 ? colors.white('revert script not found') : colors.green('done'));
console.groupEnd();
await this.#collection.deleteOne({ _id: patch._id });
console.groupEnd();
continue;
}
if (patch.status === PatchStatus.Changed) {
console.group(colors.cyan('reverting changes'));
const result = await this.applyPatchContent((patch.remoteContent ?? patch.content)?.down, filename);
console.log(result === -1 ? colors.white('revert script not found') : colors.green('done'));
console.groupEnd();
patch.status = PatchStatus.New;
}
if (all || degraded || patch.status === PatchStatus.New) {
console.group(colors.white('resting migration'));
const test = await this.applyPatchContent(patch.content.test, filename);
console.log(colors.white(`degradation level: ${test}`));
if (all || test !== 0) {
console.group(colors.cyan('applying migration...'));
await this.applyPatchContent(patch.content.up, filename);
console.log(colors.green('done'));
console.groupEnd();
}
else {
console.log(colors.green('nothing to apply'));
}
console.groupEnd();
}
console.groupEnd();
await this.#collection.updateOne({ _id: patch._id }, {
$set: {
_id: patch._id,
group: patch.group,
name: patch.name,
content: patch.content,
},
}, { upsert: true });
}
}
async getLocalPatches(raw = false, group = '', name = '') {
const migrationsDir = await realpath(resolve(process.cwd(), this.#options.localMigrations ?? 'migrations'));
console.debug(`Reading ${migrationsDir}...`);
const files = await readdir(migrationsDir, { recursive: true, withFileTypes: true });
const patches = [];
for (const file of files) {
if (!file.isFile()) {
continue;
}
const extension = extname(file.name);
if (!['.js', '.json'].includes(extension)) {
continue;
}
// Construct full file path manually for newer Node.js types compatibility
const filePath = file.parentPath || resolve(migrationsDir, file.name);
const fullFilePath = resolve(filePath, file.name);
const patchObject = {
_id: new ObjectId(),
group: filePath.substring(migrationsDir.length + 1),
name: file.name,
title: basename(file.name, extension),
content: {},
};
if (group ? patchObject.group !== group : ['init', 'indexes'].includes(patchObject.group)) {
continue;
}
if (name && patchObject.name !== name) {
continue;
}
patches.push(patchObject);
const patchContent = await readFile(fullFilePath);
if (raw) {
patchObject.raw = patchContent.toString('base64');
continue;
}
const patchExports = await evaluateJs(patchContent.toString()).catch(() => ({}));
for (const key of ['test', 'up', 'down']) {
const func = patchExports[key];
if (typeof func !== 'function') {
continue;
}
const body = Buffer.from(func.toString()).toString('base64');
patchObject.content[key] = {
body,
hash: createHash('sha256').update(body).digest('hex'),
};
}
}
return patches;
}
async getRemotePatches(group = '', name = '') {
const filter = {
group: { $nin: ['init', 'index'] },
};
if (group) {
filter.group = group;
}
if (name) {
filter.name = name;
}
const patches = await this.#collection
.find(filter, {
projection: {
_id: 1,
group: 1,
name: 1,
content: {
test: {
hash: 1,
body: 1,
},
up: {
hash: 1,
body: 1,
},
down: {
hash: 1,
body: 1,
},
},
},
})
.toArray();
patches.sort(sortPatches);
return patches;
}
async indexesDiff(collection) {
const localIndexes = await this.getLocalPatches(true, 'indexes', collection && `${collection}.json`);
const structure = {};
const db = this.#client.db();
for (const patch of localIndexes) {
const indexes = JSON.parse(Buffer.from(patch.raw, 'base64').toString());
structure[patch.title] = {};
const collection = structure[patch.title];
// console.log(`Reading indexes from ${patch.title}...`)
const remoteIndexes = await db
.collection(patch.title)
.indexes({ full: true })
.catch(() => []);
// console.dir([patch.title, remoteIndexes], { colors: true, customInspect: true, depth: 22 })
for (const index of indexes) {
// noinspection SuspiciousTypeOfGuard
if (typeof index === 'string') {
continue;
}
const [key, options] = Array.isArray(index) ? index : [index, {}];
const name = Object.entries(key).reduce((result, [key, value]) => `${result ? `${result}_` : ''}${key}_${value}`, '');
const remoteIndex = remoteIndexes.find(({ name: indexName }) => indexName === name);
collection[name] = { key, options, status: remoteIndex ? IndexStatus.Applied : IndexStatus.New };
if (remoteIndex) {
remoteIndexes.splice(remoteIndexes.indexOf(remoteIndex), 1);
}
}
if (remoteIndexes.length) {
for (const index of remoteIndexes) {
if (index.name === '_id_') {
continue;
}
collection[index.name] = { key: index.key, options: index, status: IndexStatus.Removed };
}
}
}
return structure;
}
async *indexesSync(collection) {
const structure = await this.indexesDiff(collection);
const db = this.#client.db();
for (const [collection, indexes] of Object.entries(structure)) {
const coll = db.collection(collection);
for (const [name, { key, status, options }] of Object.entries(indexes)) {
if (status === IndexStatus.Removed) {
const error = await coll
.dropIndex(name)
.then(() => { })
.catch((error) => error);
yield { collection, name, error, status };
continue;
}
if (status === IndexStatus.New) {
const error = await coll
.createIndex(key, options)
.then(() => { })
.catch((error) => error);
yield { collection, name, error, status };
continue;
}
yield { collection, name, status };
}
}
}
async init({ exec = false, force = false } = {}, patch) {
const localInits = await this.getLocalPatches(true, 'init', patch);
const remoteInits = await this.getRemotePatches('init', patch);
if (!localInits.length) {
console.log('Nothing to apply');
return;
}
console.group('Applying initial patches...');
for (const patch of localInits) {
console.group(`Testing ${patch.name}...`);
const remoteInit = remoteInits.find(({ group, name }) => patch.group === group && patch.name === name);
if (!force && remoteInit) {
console.log('skip');
console.groupEnd();
continue;
}
if (force || exec) {
console.log('applying');
await this.applyPatchContent({ body: patch.raw, hash: '' }, `${patch.group}/${patch.name}`, false);
}
else {
console.log('set as done');
}
if (!remoteInit) {
await this.#collection.insertOne({
group: patch.group,
name: patch.name,
content: {},
});
}
console.groupEnd();
}
console.groupEnd();
}
async stat(remote = false, group) {
const patches = await (remote ? this.getRemotePatches(group) : this.diff(group));
for (const patch of patches) {
patch.status = patch.status ?? PatchStatus.Ok;
const degradation = await this.applyPatchContent(patch.content.test, `${patch.group}/${patch.name}`);
patch.degradation = degradation === -1 ? '-' : degradation;
if (degradation > 0 && [PatchStatus.Ok, PatchStatus.Updated].includes(patch.status)) {
patch.status = PatchStatus.Degraded;
}
}
return patches;
}
async applySingle(group, name, exec = false) {
const [local] = await this.getLocalPatches(false, group, name);
const [remote] = await this.getRemotePatches(group, name);
if (!local) {
console.group(`Patch ${group}/${name} not found.`);
return;
}
console.group('Patch found');
if (exec && local.content.up) {
console.group(colors.cyan('applying migration...'));
await this.applyPatchContent(local.content.up, `${local.group}/${local.name}`);
console.log(colors.green('done'));
console.groupEnd();
}
else {
console.log('set as done');
}
const _id = remote?._id ?? local._id;
await this.#collection.updateOne({ _id }, {
$set: {
_id,
group: local.group,
name: local.name,
content: local.content,
},
}, { upsert: true });
console.groupEnd();
}
async remove(group, name, exec = false) {
const [patch] = await this.getRemotePatches(group, name);
if (!patch) {
console.group(`Patch ${group}/${name} not found.`);
return;
}
console.group('Patch found, start removing');
if (exec && patch.content.down) {
console.group('De-applying patch...');
await this.applyPatchContent(patch.content.down, `${patch.group}/${patch.name}`);
console.log('done');
console.groupEnd();
}
console.group('Removing patch from database');
await this.#collection.deleteOne({ group, name });
console.log('done');
console.groupEnd();
}
}
//# sourceMappingURL=miri.js.map