@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
203 lines (189 loc) • 7.99 kB
JavaScript
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
}
}