UNPKG

now-sync

Version:

A tool to help developers sync their JavaScript resources with ServiceNow.

529 lines (468 loc) 16 kB
const fs = require('fs'); const path = require('path'); const _ = require('lodash'); const moment = require('moment'); const { promisify } = require('util'); const Promise = require('bluebird'); const { NUM_CONCURRENT_REQUESTS } = require('../constants'); const { getFieldValuesFromFileName, getFileNameFields, trimCwd } = require('./file-naming'); const { generateFilesToWriteForRecord, getFieldsToRetrieve, writeFilesForTable } = require('./add'); const { parseConfigFile } = require('./config'); const { get } = require('./api'); const { buildTableApiUrl, convertServiceNowDatetimeToMoment, getRecord, updateRecord } = require('./service-now'); const readFileAsync = promisify(fs.readFile); const writeFileAsync = promisify(fs.writeFile); const statAsync = promisify(fs.stat); /** * Initializes the Sync functionality. * * @returns {Promise.<Object.<string, [{ createdFiles: string[], updatedFiles: string[], updatedRecordFields: string[] }]>>} */ async function sync() { const nonemptyRecords = getRecordsToSync(); const nonemptyTables = _.keys(nonemptyRecords); const syncResponses = await Promise.all( _.map(nonemptyTables, table => initSyncAllFilesForTable(table)) ); const tableResponsePairs = _.map(nonemptyTables, (table, i) => [ table, syncResponses[i] ]); return _.fromPairs(tableResponsePairs); } exports.sync = sync; /** * Returns a promise resolving to all local file stats and file sysIds * * @param {string} table ServiceNow table API Name * @returns {promise} Promise resolving to an object containing local file stats and sysIds */ function getSyncedFileStatsForTable(table) { const config = parseConfigFile(); const tableConfig = config.config[table]; const localRecords = config.records[table]; const folderPath = path.resolve(process.cwd(), config.filePath, table); const fsStatPromises = []; const fileStatsBySysIdByPath = {}; const missingFieldsBySysId = {}; // each record may have >1 related files, so we need to sort // record:file relations as a one-to-many object. _.forEach(localRecords, record => { // aggregate all sys_ids for table for API call const fileNameTemplate = _.find( tableConfig.formats, format => format.contentField === record.contentField ).fileName; const fileNameFieldValues = getFieldValuesFromFileName( record.fileName, fileNameTemplate ); const sysId = fileNameFieldValues.sys_id; // get stats for matching files const filePath = path.resolve(folderPath, record.fileName); fsStatPromises.push( statAsync(filePath) .then(stats => { if (!fileStatsBySysIdByPath[sysId]) { fileStatsBySysIdByPath[sysId] = {}; } fileStatsBySysIdByPath[sysId][filePath] = { field: record.contentField, stats }; }) .catch(() => { // record all fields that are missing files if (!missingFieldsBySysId[sysId]) { missingFieldsBySysId[sysId] = []; } missingFieldsBySysId[sysId].push(record.contentField); }) ); }); return Promise.all(fsStatPromises).then(() => ({ fileStatsBySysIdByPath, missingFieldsBySysId })); } exports.getSyncedFileStatsForTable = getSyncedFileStatsForTable; /** * Filters out all tables that aren’t synced to local files. * * @returns {object.<string, {contentField: string, fileName: string}[]>} * The new records configuration object in a table: record config array hash */ function getRecordsToSync() { const { records } = parseConfigFile(); const recordsToSync = {}; _.forEach(records, (tableRecords, table) => { if (tableRecords.length) { recordsToSync[table] = tableRecords; } }); return recordsToSync; } exports.getRecordsToSync = getRecordsToSync; /** * Retrieves all of the records for a ServiceNow table that are synced to local files. * * @param {string} table ServiceNow table’s API Name * @param {string[]} sysIds An array of sys_ids * @returns {promise.<object[]>} Promise resolving to the array of retrieved record objects */ function getSyncedRecordsForTable(table, sysIds) { let fieldsToRetrieve = getFieldsToRetrieve(table); fieldsToRetrieve = _.map(fieldsToRetrieve, name => { if (name === 'sys_scope') { return 'sys_scope.scope'; } return name; }); const url = buildTableApiUrl(table, { displayValue: false, fields: [...fieldsToRetrieve, 'sys_updated_on'], query: `sys_idIN${sysIds.join('%2C')}` }); return get(url) .then(response => response.result) .then(records => { _.forEach(records, record => { if (record['sys_scope.scope']) { record.sys_scope = record['sys_scope.scope']; delete record['sys_scope.scope']; } }); return records; }); } exports.getSyncedRecordsForTable = getSyncedRecordsForTable; /** * Compares a ServiceNow record’s datetime field with a file’s modified time. * * @param {(date|moment)} fileModifiedTime The file’s modified time * @param {(string|moment)} sysUpdatedOn The ServiceNow record’s datetime value * @returns {boolean} True if the file is same or newer than the ServiceNow record */ function isFileNewerThanRecord(fileModifiedTime, sysUpdatedOn) { let mSysUpdatedOn = sysUpdatedOn; // eslint-disable-next-line no-underscore-dangle if (!sysUpdatedOn._isAMomentObject) { mSysUpdatedOn = convertServiceNowDatetimeToMoment(sysUpdatedOn); } let mFileModifiedTime = fileModifiedTime; // eslint-disable-next-line no-underscore-dangle if (!fileModifiedTime._isAMomentObject) { mFileModifiedTime = moment.utc(fileModifiedTime.toISOString()); } return mFileModifiedTime.isSameOrAfter(mSysUpdatedOn); } exports.isFileNewerThanRecord = isFileNewerThanRecord; /** * Performs a one-time sync between ServiceNow records and local files for a given table. * * @param {string} table ServiceNow table’s API Name * @returns {promise} */ async function initSyncAllFilesForTable(table) { const { fileStatsBySysIdByPath, missingFieldsBySysId } = await getSyncedFileStatsForTable(table); const savedFileStatsBySysIdByPath = fileStatsBySysIdByPath; const sysIds = _.concat( _.keys(fileStatsBySysIdByPath), _.keys(missingFieldsBySysId) ); if (!sysIds.length) { throw new Error(`No local files found for table ${table}.`); } const apiRecords = await getSyncedRecordsForTable(table, _.uniq(sysIds)); if (!apiRecords) { throw new Error( `The records for table \`${ table }\` in the ServiceNow instance do not exist.` ); } return syncAllFilesForTable(table, apiRecords, savedFileStatsBySysIdByPath); } exports.initSyncAllFilesForTable = initSyncAllFilesForTable; /** * Performs a one-time sync between ServiceNow records and local files for a given table. * * @param {string} table ServiceNow table’s API Name * @param {object[]} apiRecords Array of table records retrieved from the ServiceNow Table API * @param {object} fileStatsBySysIdByPath * @returns {promise} */ function syncAllFilesForTable(table, apiRecords, fileStatsBySysIdByPath) { return Promise.map(apiRecords, record => syncRecord(table, record, fileStatsBySysIdByPath[record.sys_id]) ).catch(err => { throw err; }); } exports.syncAllFilesForTable = syncAllFilesForTable; /** * Does a dry-run sync for a record. * * @param {string} table ServiceNow table’s API Name * @param {object} recordData key:value hash of the full ServiceNow record * @param {object} fileStatsByPaths existing files sync’d to the ServiceNow record * @returns {Promise} Promise -> object with data to be uploaded and files to be written. */ async function calculateSyncRecordData(table, recordData, fileStatsByPaths) { const updatedOnMoment = convertServiceNowDatetimeToMoment( recordData.sys_updated_on ); const syncObj = { updateRecordData: {}, filesToUpdate: {}, missingFileFields: {} }; const filePromises = []; const nondataFields = _.concat(getFileNameFields(table), [ 'sys_id', 'sys_updated_on' ]); const fileData = _.omit(recordData, nondataFields); _.forEach(fileData, (val, key) => { const matchingFilePath = _.findKey( fileStatsByPaths, fsStat => fsStat.field === key ); if (matchingFilePath) { const fileObj = fileStatsByPaths[matchingFilePath]; const readFile = readFileAsync(matchingFilePath, 'utf8').then( fileContents => { if (recordData[fileObj.field] === fileContents) { return; } if (isFileNewerThanRecord(fileObj.stats.mtime, updatedOnMoment)) { syncObj.updateRecordData[fileObj.field] = fileContents; } else { syncObj.filesToUpdate[matchingFilePath] = recordData[fileObj.field]; } } ); filePromises.push(readFile); } else { syncObj.missingFileFields[key] = val; } }); await Promise.all(filePromises); return syncObj; } exports.calculateSyncRecordData = calculateSyncRecordData; /** * Compares the last updated time of the ServiceNow record against its matching local files’ * modified times and does the following: * * If the local file’s time => ServiceNow record’s time, * upload file contents to the record’s matching field * If the local file’s time < ServiceNow record’s time, * update the file with the record’s field contents * * @param {string} table The Table’s API name * @param {object} recordData A hash of the ServiceNow record’s field:value * @param {string} recordData.sys_id The ServiceNow record’s sys_id value * @param {string} recordData.sys_updated_on The ServiceNow record’s sys_updated_on value * @param {object} fileStatsByPaths A hash of filePath:object * @param {string} fileStatsByPaths.field The field value of the ServiceNow record * @param {string} fileStatsByPaths.stats The fsStats object for the filePath * * @example * syncRecord( * 'sys_ui_page', * { * sys_id: '0181f08913ea7a00ca1e70a76144b0a3', * html: '<some_field_content></some_field_content>', * sys_updated_on: '2017-01-01 12:00:00' * }, * { * './now/sys_ui_page/example_ui_page-html-0181f08913ea7a00ca1e70a76144b0a3.html': { * field: 'html', * stats: { mtime: 'Tue May 16 2017 15:36:45 GMT-0500 (CDT)' } // an fs.Stats object * } * } * }) * @returns {promise} */ async function syncRecord(table, recordData, fileStatsByPaths) { const syncData = await calculateSyncRecordData( table, recordData, fileStatsByPaths ); let createdFiles = []; const updatedFiles = []; const updatedRecordFields = []; const promises = _.map(syncData.filesToUpdate, (content, filePath) => writeFileAsync(filePath, content, 'utf8').then(() => { updatedFiles.push(trimCwd(filePath)); // logInfo(`Updated local file: ${trimCwd(filePath)}`); }) ); if (!_.isEmpty(syncData.updateRecordData)) { promises.push( updateRecord(table, recordData.sys_id, syncData.updateRecordData).then( ({ body, response }) => { updatedRecordFields.push({ body, response }); } ) ); } if (!_.isEmpty(syncData.missingFileFields)) { let filesToWrite = generateFilesToWriteForRecord(table, recordData); filesToWrite = _.filter( filesToWrite, fileObj => typeof syncData.missingFileFields[fileObj.contentField] !== 'undefined' ); promises.push( writeFilesForTable(table, filesToWrite).then(filesWritten => { createdFiles = createdFiles.concat(filesWritten); }) ); } await Promise.all(promises); return { createdFiles, updatedFiles, updatedRecordFields }; } exports.syncRecord = syncRecord; async function pull() { const { config } = parseConfigFile(); const recordsByTable = getRecordsToSync(); const tableNames = _.keys(recordsByTable); let getRecordPromises = []; const recordsToPullByTable = {}; let i; // prepping data to gather for (i = 0; i < tableNames.length; i++) { const table = tableNames[i]; const tableRecords = recordsByTable[table]; const tableFileFormats = config[table].formats; if (!recordsToPullByTable[table]) { recordsToPullByTable[table] = []; } const tableRecordsToPull = recordsToPullByTable[table]; let j; for (j = 0; j < tableRecords.length; j++) { const { contentField, fileName } = tableRecords[j]; const { fileName: fileTemplate } = _.find( tableFileFormats, format => format.contentField === contentField ); const fieldValues = getFieldValuesFromFileName(fileName, fileTemplate); tableRecordsToPull.push(fieldValues.sys_id); } } // preparing api requests const getRecordObjs = []; for (i = 0; i < tableNames.length; i++) { const table = tableNames[i]; const tableRecordsToPull = recordsToPullByTable[table]; const fields = getFieldsToRetrieve(table); let j; for (j = 0; j < tableRecordsToPull.length; j++) { const sysId = tableRecordsToPull[j]; getRecordObjs.push({ table, sysId, fields }); } } // getting record data const chunkedRecordObjs = _.chunk(getRecordObjs, NUM_CONCURRENT_REQUESTS); for (i = 0; i < chunkedRecordObjs.length; i++) { // eslint-disable-next-line no-await-in-loop const chunkedRecordRequest = await Promise.map( chunkedRecordObjs[i], ({ table, sysId, fields }) => getRecord(table, sysId, fields).then(recordData => { // write files const filesToWrite = generateFilesToWriteForRecord(table, recordData); return writeFilesForTable(table, filesToWrite); }) ); getRecordPromises = getRecordPromises.concat(chunkedRecordRequest); } return getRecordPromises; } exports.pull = pull; /** * Copies content of all local synced files to their respective ServiceNow records. * * @returns {promise<object[]>} The JSON responses of the update calls */ async function push() { const tableNames = _.keys(getRecordsToSync()); const syncedFileStats = await Promise.map(tableNames, table => getSyncedFileStatsForTable(table) ); const tableUpdates = {}; const recordsToUpdate = []; try { await Promise.map(tableNames, async (table, i) => { const { fileStatsBySysIdByPath } = syncedFileStats[i]; const sysIds = _.keys(fileStatsBySysIdByPath); const tableRecordsToUpdate = await Promise.map(sysIds, async sysId => { const fileStatsByPath = fileStatsBySysIdByPath[sysId]; const updateRecordData = {}; try { await Promise.map(_.keys(fileStatsByPath), filePath => readFileAsync(filePath, 'utf8').then(fileContent => { updateRecordData[fileStatsByPath[filePath].field] = fileContent; }) ); } catch (e) { throw e; } return { table, sysId, updateRecordData }; }); recordsToUpdate.push(...tableRecordsToUpdate); }); const chunkedRecordsToUpdate = _.chunk( recordsToUpdate, NUM_CONCURRENT_REQUESTS ); let i; for (i = 0; i < chunkedRecordsToUpdate.length; i++) { // wait for chunk to finish before starting next round // eslint-disable-next-line no-await-in-loop await Promise.map( chunkedRecordsToUpdate[i], ({ table, sysId, updateRecordData }) => { if (!tableUpdates[table]) { tableUpdates[table] = []; } return updateRecord(table, sysId, updateRecordData).then(response => { tableUpdates[table].push(response); return response; }); } ); } } catch (e) { throw e; } return tableUpdates; } exports.push = push;