UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

203 lines (189 loc) 7.99 kB
const crypto = require('crypto') const stream = require('stream/promises') const cds = require('../cds'); const log = cds.log('cds'); module.exports = async function hotReloading() { const csn = await cds.compile('*'); const sqliteModel = cds.compile(csn).to.sql({ dialect: 'sqlite' }) const hash = crypto.createHash('sha256') await stream.pipeline(sqliteModel, hash) cds.model.$schemaHash = hash.digest('hex') if (cds.env.requires.db.impl === '@cap-js/sqlite') { cds.model.$schema = sqliteModel } process.on('message', async (msg) => { if (msg.code === 'refreshModel') { log.info(`Hot reloading cds model...`) const csn = await cds.compile('*'); const sqliteModel = cds.compile(csn).to.sql({ dialect: 'sqlite' }) const model = cds.compile.for.nodejs(csn) if (cds.env.requires.db.impl === '@cap-js/sqlite') { model.$schema = sqliteModel; } const hash = crypto.createHash('sha256') await stream.pipeline(sqliteModel, hash) model.$schemaHash = hash.digest('hex') if (model.$schemaHash !== cds.model.$schemaHash) { if (cds.env.requires.db.impl === '@cap-js/sqlite') { const db = await cds.connect.to('db'); const { dropTables, dropViews, newTables, newViews } = sqlDiff(model, cds.model) try { await db.run([...dropViews, ...dropTables].map(item => `DROP ${item.type} IF EXISTS ${item.name}`)) cds.debug('cli')(`dropped ${[...dropViews, ...dropTables].length} views/tables during cds watch`) } catch { // Restart if error happens to ensure it still works process.send({ code: 'restart' }) return; } try { await db.run([...newTables, ...newViews].map(item => item.sql)) cds.debug('cli')(`created new views and tables on cds watch`) } catch { // Restart if error happens to ensure it still works process.send({ code: 'restart' }) return; } // redeploy test data await redeployData(csn, db, (file, e) => newTables.some(table => table.name.toUpperCase() === e.replaceAll('.', '_').toUpperCase())) } else { // Restart instance when DB changes cannot be solved by redeploying process.send({ code: 'restart' }) return; } } if (didMockDataChange(msg.eventsAndOpts ?? [])) { const db = await cds.connect.to('db'); await redeployData(csn, db, (file) => msg.eventsAndOpts.some(({ name: fullPath }) => fullPath.endsWith(file)), true) } // Assign new model to CDS cds.model = model // Check for differences in services to restart if necessary and assign model to services as well const services = Object.keys(model.definitions).filter(name => model.definitions[name].kind === 'service') for (const srv in cds.services) { const existingSrvIdx = services.findIndex(s => s === srv); // Restart because an existing service was removed // Avoid restarts for services without a model, for example pure rest remote services if (existingSrvIdx < 0 && srv !== 'db' && cds.services[srv].model && cds.services[srv].definition) { process.send({ code: 'restart' }) } else if (existingSrvIdx >= 0) { const existingSrv = model.definitions[services.splice(existingSrvIdx, 1)]; // Ensure server restarts if protocol changes if ( Object.keys(existingSrv.protocols).length !== Object.keys(cds.services[srv].definition.protocols).length || Object.keys(existingSrv.protocols).some(existingProtocol => !Object.keys(cds.services[srv].definition.protocols).some(protocol => protocol === existingProtocol)) ) { process.send({ code: 'restart' }) return; } // Restart if @path annotation changes if (existingSrv['@path'] !== cds.services[srv].definition['@path']) { process.send({ code: 'restart' }) return; } } // Services somehow have their own model cache, thus override is needed cds.services[srv].model = model } // New service was added needing restart if (services.length) { process.send({ code: 'restart' }) return; } // This triggers the live-reload - its important to be at the end to avoid race-conditions process.send({ code: 'model-refreshed' }) log.info(`...cds model reloaded.`) } }) } /** * * @param {*} csn * @param {*} db * @param {*} includeFileFilter Function which is invoked to toggle which files are deployed */ async function redeployData(csn, db, includeFileFilter, withUndeploy = false) { const filesToDeploy = {} const entitiesToUndeploy = [] const resources = await cds.deploy.resources(csn, { testdata: cds.env.features.test_data }) const resEntries = Object.entries(resources).reverse() // reversed $sources, relevant as UPSERT order for (const [file, e] of resEntries) { if (includeFileFilter(file, e)) { let src = await cds.utils.read(file, 'utf8') filesToDeploy[file] = src; entitiesToUndeploy.push(e) } } if (entitiesToUndeploy.length) { try { if (withUndeploy) { await db.run(entitiesToUndeploy.map((entity) => DELETE.from(entity).where('1 = 1'))) } await cds.deploy.data(db, csn, {}, filesToDeploy) } catch { // Full restart in case redeploy fails, that might happen with referential constraints process.send({ code: 'restart' }) return; } } } function didMockDataChange(eventsAndOpts) { for (const { name: path } of eventsAndOpts) { if ((new RegExp(`test.*data.*\\.(csv|json)`)).test(path) || cds.env.roots.some(root => (new RegExp(`${root}.*data.*\\.(csv|json)`)).test(path))) { return true; } } return false; } const createRegex = /CREATE\s+(TABLE|VIEW)\s+([^\s(]+)/gi const parseSchema = (schema) => { const statements = {} const sql = schema.join('\n') let match while ((match = createRegex.exec(sql)) !== null) { const type = match[1].toUpperCase() const name = match[2] statements[name] = { type, name } } return statements } function sqlDiff(model, cdsModel) { const oldSchema = parseSchema(cdsModel.$schema || []) const newSchema = parseSchema(model.$schema || []) // Find changed or removed tables/views const toRecreate = [] for (const name in oldSchema) { if (!newSchema[name]) { // Removed - need to drop toRecreate.push({ ...oldSchema[name], action: 'drop' }) } else { // Check if definition changed by comparing the actual SQL const oldSql = (cdsModel.$schema || []).find(s => s.includes(`CREATE ${oldSchema[name].type} ${name}`)) const newSql = (model.$schema || []).find(s => s.includes(`CREATE ${newSchema[name].type} ${name}`)) if (oldSql !== newSql) { toRecreate.push({ ...oldSchema[name], action: 'drop' }) toRecreate.push({ ...newSchema[name], action: 'create', sql: newSql }) } } } // Find new tables/views for (const name in newSchema) { if (!oldSchema[name]) { const newSql = (model.$schema || []).find(s => s.includes(`CREATE ${newSchema[name].type} ${name}`)) toRecreate.push({ ...newSchema[name], action: 'create', sql: newSql }) } } // Execute drops first (views before tables) const drops = toRecreate.filter(t => t.action === 'drop') const views = drops.filter(t => t.type === 'VIEW').reverse() const tables = drops.filter(t => t.type === 'TABLE').reverse() // Execute creates (tables before views) const creates = toRecreate.filter(t => t.action === 'create') const newTables = creates.filter(t => t.type === 'TABLE') const newViews = creates.filter(t => t.type === 'VIEW') return { dropViews: views, dropTables: tables, newTables, newViews } }