UNPKG

@13w/miri

Version:

MongoDB patch manager

379 lines 16 kB
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