UNPKG

fireway

Version:

Schema migration tool for Firestore

401 lines (343 loc) 10.5 kB
const path = require('path'); const {EventEmitter} = require('events'); const util = require('util'); const os = require('os'); const fs = require('fs'); const md5 = require('md5'); const admin = require('firebase-admin'); const {Firestore, WriteBatch, CollectionReference, FieldValue, FieldPath, Timestamp} = require('@google-cloud/firestore'); const semver = require('semver'); const asyncHooks = require('async_hooks'); const callsites = require('callsites'); const readFile = util.promisify(fs.readFile); const readdir = util.promisify(fs.readdir); const stat = util.promisify(fs.stat); const exists = util.promisify(fs.exists); // Track stats and dryrun setting so we only proxy once. // Multiple proxies would create a memory leak. const statsMap = new Map(); let proxied = false; function proxyWritableMethods() { // Only proxy once if (proxied) return; else proxied = true; const ogCommit = WriteBatch.prototype._commit; WriteBatch.prototype._commit = async function() { // Empty the queue while (this._fireway_queue && this._fireway_queue.length) { this._fireway_queue.shift()(); } for (const [stats, {dryrun}] of statsMap.entries()) { if (this._firestore._fireway_stats === stats) { if (dryrun) return []; } } return ogCommit.apply(this, Array.from(arguments)); }; const skipWriteBatch = Symbol('Skip the WriteBatch proxy'); function mitm(obj, key, fn) { const original = obj[key]; obj[key] = function() { const args = [...arguments]; for (const [stats, {log}] of statsMap.entries()) { if (this._firestore._fireway_stats === stats) { // If this is a batch if (this instanceof WriteBatch) { const [_, doc] = args; if (doc && doc[skipWriteBatch]) { delete doc[skipWriteBatch]; } else { this._fireway_queue = this._fireway_queue || []; this._fireway_queue.push(() => { fn.call(this, args, (stats.frozen ? {} : stats), log); }); } } else { fn.call(this, args, (stats.frozen ? {} : stats), log); } } } return original.apply(this, args); } } // Add logs for each WriteBatch item mitm(WriteBatch.prototype, 'create', ([_, doc], stats, log) => { stats.created += 1; log('Creating', JSON.stringify(doc)); }); mitm(WriteBatch.prototype, 'set', ([ref, doc, opts = {}], stats, log) => { stats.set += 1; log(opts.merge ? 'Merging' : 'Setting', ref.path, JSON.stringify(doc)); }); mitm(WriteBatch.prototype, 'update', ([ref, doc], stats, log) => { stats.updated += 1; log('Updating', ref.path, JSON.stringify(doc)); }); mitm(WriteBatch.prototype, 'delete', ([ref], stats, log) => { stats.deleted += 1; log('Deleting', ref.path); }); mitm(CollectionReference.prototype, 'add', ([doc], stats, log) => { doc[skipWriteBatch] = true; stats.added += 1; log('Adding', JSON.stringify(doc)); }); } const dontTrack = Symbol('Skip async tracking to short circuit'); async function trackAsync({log, file, forceWait}, fn) { // Track filenames for async handles const activeHandles = new Map(); const emitter = new EventEmitter(); function deleteHandle(id) { if (activeHandles.has(id)) { activeHandles.delete(id); emitter.emit('deleted', id); } } function waitForDeleted() { return new Promise(r => emitter.once('deleted', () => r())); } const hook = asyncHooks.createHook({ init(asyncId) { for (const call of callsites()) { // Prevent infinite loops const fn = call.getFunction(); if (fn && fn[dontTrack]) { return; } const name = call.getFileName(); if ( !name || name == __filename || name.startsWith('internal/') || name.startsWith('timers.js') ) continue; if (name === file.path) { const filename = call.getFileName(); const lineNumber = call.getLineNumber(); const columnNumber = call.getColumnNumber(); activeHandles.set(asyncId, `${filename}:${lineNumber}:${columnNumber}`); break; } } }, before: deleteHandle, after: deleteHandle, promiseResolve: deleteHandle }).enable(); let logged; async function handleCheck() { while (activeHandles.size) { if (forceWait) { // NOTE: Attempting to add a timeout requires // shutting down the entire process cleanly. // If someone decides not to return proper // Promises, and provides --forceWait, long // waits are expected. if (!logged) { log('Waiting for async calls to resolve'); logged = true; } await waitForDeleted(); } else { // This always logs in Node <12 const nodeVersion = semver.coerce(process.versions.node); if (nodeVersion.major >= 12) { console.warn( 'WARNING: fireway detected open async calls. Use --forceWait if you want to wait:', Array.from(activeHandles.values()) ); } break; } } } let rejection; const unhandled = reason => rejection = reason; process.once('unhandledRejection', unhandled); process.once('uncaughtException', unhandled); try { const res = await fn(); await handleCheck(); // Wait a tick or so for the unhandledRejection await new Promise(r => setTimeout(() => r(), 1)); process.removeAllListeners('unhandledRejection'); process.removeAllListeners('uncaughtException'); if (rejection) { log(`Error in ${file.filename}`, rejection); return false; } return res; } catch(e) { log(e); return false; } finally { hook.disable(); } } trackAsync[dontTrack] = true; async function migrate({path: dir, projectId, storageBucket, dryrun, app, debug = false, require: req, forceWait = false} = {}) { if (req) { try { require(req); } catch (e) { console.error(e); throw new Error(`Trouble executing require('${req}');`); } } const log = function() { return debug && console.log.apply(console, arguments); } const stats = { scannedFiles: 0, executedFiles: 0, created: 0, set: 0, updated: 0, deleted: 0, added: 0 }; // Get all the scripts if (!path.isAbsolute(dir)) { dir = path.join(process.cwd(), dir); } if (!(await exists(dir))) { throw new Error(`No directory at ${dir}`); } const filenames = []; for (const file of await readdir(dir)) { if (!(await stat(path.join(dir, file))).isDirectory()) { filenames.push(file); } } // Parse the version numbers from the script filenames const versionToFile = new Map(); let files = filenames.map(filename => { // Skip files that start with a dot if (filename[0] === '.') return; const [filenameVersion, description] = filename.split('__'); const coerced = semver.coerce(filenameVersion); if (!coerced) { if (description) { // If there's a description, we assume you meant to use this file log(`WARNING: ${filename} doesn't have a valid semver version`); } return null; } // If there's a version, but no description, we have an issue if (!description) { throw new Error(`This filename doesn't match the required format: ${filename}`); } const {version} = coerced; const existingFile = versionToFile.get(version); if (existingFile) { throw new Error(`Both ${filename} and ${existingFile} have the same version`); } versionToFile.set(version, filename); return { filename, path: path.join(dir, filename), version, description: path.basename(description, path.extname(description)) }; }).filter(Boolean); stats.scannedFiles = files.length; log(`Found ${stats.scannedFiles} migration files`); // Find the files after the latest migration number statsMap.set(stats, {dryrun, log}); dryrun && log('Making firestore read-only'); proxyWritableMethods(); if (!storageBucket && projectId) { storageBucket = `${projectId}.appspot.com`; } const providedApp = app; if (!app) { app = admin.initializeApp({ projectId, storageBucket }); } // Use Firestore directly so we can mock for dryruns const firestore = new Firestore({projectId}); firestore._fireway_stats = stats; const collection = firestore.collection('fireway'); // Get the latest migration const result = await collection .orderBy('installed_rank', 'desc') .limit(1) .get(); const [latestDoc] = result.docs; const latest = latestDoc && latestDoc.data(); if (latest && !latest.success) { throw new Error(`Migration to version ${latest.version} using ${latest.script} failed! Please restore backups and roll back database and code!`); } let installed_rank; if (latest) { files = files.filter(file => semver.gt(file.version, latest.version)); installed_rank = latest.installed_rank; } else { installed_rank = -1; } // Sort them by semver files.sort((f1, f2) => semver.compare(f1.version, f2.version)); log(`Executing ${files.length} migration files`); // Execute them in order for (const file of files) { stats.executedFiles += 1; log('Running', file.filename); let migration; try { migration = require(file.path); } catch (e) { log(e); throw e; } let start, finish; const success = await trackAsync({log, file, forceWait}, async () => { start = new Date(); try { await migration.migrate({app, firestore, FieldValue, FieldPath, Timestamp, dryrun}); return true; } catch(e) { log(`Error in ${file.filename}`, e); return false; } finally { finish = new Date(); } }); // Upload the results log(`Uploading the results for ${file.filename}`); // Freeze stat tracking stats.frozen = true; installed_rank += 1; const id = `${installed_rank}-${file.version}-${file.description}`; await collection.doc(id).set({ installed_rank, description: file.description, version: file.version, script: file.filename, type: path.extname(file.filename).slice(1), checksum: md5(await readFile(file.path)), installed_by: os.userInfo().username, installed_on: start, execution_time: finish - start, success }); // Unfreeze stat tracking delete stats.frozen; if (!success) { throw new Error('Stopped at first failure'); } } // Ensure firebase terminates if (!providedApp) { app.delete(); } const {scannedFiles, executedFiles, added, created, updated, set, deleted} = stats; log('Finished all firestore migrations'); log(`Files scanned:${scannedFiles} executed:${executedFiles}`); log(`Docs added:${added} created:${created} updated:${updated} set:${set} deleted:${deleted}`); statsMap.delete(stats); return stats; } module.exports = {migrate};