From 90f8d21b3fd2298e71f6f4aab50c077d47ce0830 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 27 May 2026 01:05:29 +0200 Subject: [PATCH 01/19] fix: add after deploy handler for SQLite and Postgres --- cds-plugin.js | 6 ++- lib/postgres/register.js | 61 ++++++++++++++++++++-- lib/sqlite/register.js | 110 +++++++++++++++++++++++++++++++++------ 3 files changed, 155 insertions(+), 22 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index 37b04c56..269f3b38 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -2,8 +2,8 @@ const cds = require('@sap/cds'); const { enhanceModel } = require('./lib/csn-enhancements'); const { registerSessionVariableHandlers } = require('./lib/skipHandlers.js'); -const { deploySQLiteTriggers } = require('./lib/sqlite/register.js'); -const { registerPostgresCompilerHook, deployPostgresLabels } = require('./lib/postgres/register.js'); +const { registerSQLiteDeploymentHandler, deploySQLiteTriggers } = require('./lib/sqlite/register.js'); +const { registerPostgresCompilerHook, registerPostgresDeploymentHandler, deployPostgresLabels } = require('./lib/postgres/register.js'); const { registerH2CompilerHook } = require('./lib/h2/register.js'); const { registerHDICompilerHook } = require('./lib/hana/register.js'); @@ -14,6 +14,8 @@ cds.once('served', async () => { await deployPostgresLabels(); }); +registerSQLiteDeploymentHandler(); +registerPostgresDeploymentHandler(); registerH2CompilerHook(); registerPostgresCompilerHook(); registerHDICompilerHook(); diff --git a/lib/postgres/register.js b/lib/postgres/register.js index dc6b2c43..a62cb4a2 100644 --- a/lib/postgres/register.js +++ b/lib/postgres/register.js @@ -3,6 +3,7 @@ const cds = require('@sap/cds'); const { getEntitiesForTriggerGeneration, collectEntities } = require('../utils/entity-collector.js'); const { getLabelTranslations } = require('../localization.js'); const { prepareCSNForTriggers, generateTriggersForEntities } = require('../utils/trigger-utils.js'); +const { enhanceModel } = require('../csn-enhancements'); function registerPostgresCompilerHook() { cds.on('compile.to.dbx', (csn, options, next) => { @@ -21,10 +22,9 @@ function registerPostgresCompilerHook() { // Handle standard compilation (array) or delta compilation (object with createsAndAlters/drops) if (Array.isArray(ddl)) { - return [...ddl, ...triggers]; + ddl.push(...triggers); } else if (ddl.createsAndAlters) { ddl.createsAndAlters.push(...triggers); - return ddl; } return ddl; @@ -44,4 +44,59 @@ async function deployPostgresLabels() { await Promise.all([cds.delete(i18nKeys), cds.insert(labels).into(i18nKeys)]); } -module.exports = { registerPostgresCompilerHook, deployPostgresLabels }; +/** + * Deploys the service-level ChangeView SQL views (e.g., AdminService_ChangeView) + * that are added by enhanceModel but missing from the deployed schema because + * the ModelProviderService compiles the CSN without firing the 'loaded' event. + */ +async function _deployChangeViews(csn) { + const sql = cds.compile.to.sql(csn, { kind: 'postgres' }); + const changeViewDDL = sql.filter((stmt) => /CREATE VIEW\s+\S*ChangeView/i.test(stmt)); + for (const stmt of changeViewDDL) { + const viewName = stmt.match(/CREATE VIEW\s+("[^"]+"|[^\s(]+)/i)?.[1]; + if (viewName) await cds.db.run(`DROP VIEW IF EXISTS ${viewName}`); + await cds.db.run(stmt); + } +} + +/** + * Registers a handler on the DeploymentService to deploy service-level ChangeViews + * and labels after each tenant's database schema is deployed in MTX scenarios. + * Triggers and indexes are already handled by the compile.to.dbx hook. + */ +function registerPostgresDeploymentHandler() { + cds.once('served', async () => { + const ds = await cds.connect.to('cds.xt.DeploymentService'); + ds.after('deploy', async (_, req) => { + const db = cds.env.requires?.db; + if (db?.kind !== 'postgres') return; + + // Skip for the t0 metadata tenant — it doesn't contain the application model + const tenant = req.data?.tenant ?? cds.context?.tenant; + const t0 = cds.env.requires?.multitenancy?.t0 ?? 't0'; + if (tenant === t0) return; + + // Get the tenant's application model from the ModelProviderService + const { 'cds.xt.ModelProviderService': mps } = cds.services; + const csn = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); + + // Enhance the CSN with service-level ChangeView definitions. + // This is needed because getCsn() compiles in a worker thread with silent:true, + // so the cds.on('loaded', enhanceModel) handler never fires on the tenant CSN. + enhanceModel(csn); + + // Deploy service-level ChangeViews (e.g., AdminService_ChangeView) + await _deployChangeViews(csn); + + // Deploy labels + const model = cds.compile.for.nodejs(csn); + const { collectedEntities } = collectEntities(model); + const entities = getEntitiesForTriggerGeneration(model.definitions, collectedEntities); + const labels = getLabelTranslations(entities, model); + const { i18nKeys } = cds.entities('sap.changelog'); + await Promise.all([cds.delete(i18nKeys), cds.insert(labels).into(i18nKeys)]); + }); + }); +} + +module.exports = { registerPostgresCompilerHook, registerPostgresDeploymentHandler, deployPostgresLabels }; diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index 8c9e7b87..a04eaef7 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -3,32 +3,108 @@ const cds = require('@sap/cds'); const { getEntitiesForTriggerGeneration, collectEntities } = require('../utils/entity-collector.js'); const { getLabelTranslations } = require('../localization.js'); const { generateTriggersForEntities } = require('../utils/trigger-utils.js'); +const { enhanceModel } = require('../csn-enhancements'); -async function deploySQLiteTriggers() { - const db = cds.env.requires?.db; - if (db?.kind !== 'sqlite') return; - - const model = cds.context?.model ?? cds.model; - const { collectedEntities, hierarchyMap } = collectEntities(model); +/** + * Deploys SQLite triggers, indexes, and labels for change tracking. + * Generates triggers from the given model and executes them sequentially + * against the current database connection. + */ +async function _deploySQLiteTriggersAndLabels(model) { const { generateSQLiteTrigger } = require('./triggers.js'); + const { collectedEntities, hierarchyMap } = collectEntities(model); const entities = getEntitiesForTriggerGeneration(model.definitions, collectedEntities); const triggers = generateTriggersForEntities(model, hierarchyMap, entities, generateSQLiteTrigger); - let deleteTriggers = triggers.map((t) => t.match(/CREATE\s+TRIGGER\s+IF NOT EXISTS\s+(\w+)/i)).map((m) => `DROP TRIGGER IF EXISTS ${m[1]};`); + if (triggers.length === 0) return; + + const dropTriggers = triggers + .map((t) => t.match(/CREATE\s+TRIGGER\s+IF NOT EXISTS\s+(\w+)/i)) + .map((m) => `DROP TRIGGER IF EXISTS ${m[1]};`); const labels = getLabelTranslations(entities, model); const { i18nKeys } = cds.entities('sap.changelog'); - // Delete existing triggers - await Promise.all(deleteTriggers.map((t) => cds.db.run(t))); + // Delete existing triggers (safe to run in parallel — they're all DROP IF EXISTS) + await Promise.all(dropTriggers.map((t) => cds.db.run(t))); + + // Deploy triggers and indexes sequentially to ensure referenced tables exist + for (const t of triggers) await cds.db.run(t); + await cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_ct_index ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)`); + await cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_parent_index ON sap_changelog_Changes (parent_ID)`); + + // Deploy labels + await cds.delete(i18nKeys); + await cds.insert(labels).into(i18nKeys); +} + +/** + * Deploys the service-level ChangeView SQL views (e.g., AdminService_ChangeView) + * that are added by enhanceModel but missing from the deployed schema because + * the ModelProviderService compiles the CSN without firing the 'loaded' event. + */ +async function _deployChangeViews(csn) { + const sql = cds.compile.to.sql(csn, { kind: 'sqlite' }); + const changeViewDDL = sql.filter((stmt) => /CREATE VIEW\s+\S*ChangeView/i.test(stmt)); + for (const stmt of changeViewDDL) { + // Use IF NOT EXISTS semantics via DROP + CREATE to handle upgrades + const viewName = stmt.match(/CREATE VIEW\s+("[^"]+"|[^\s(]+)/i)?.[1]; + if (viewName) await cds.db.run(`DROP VIEW IF EXISTS ${viewName}`); + await cds.db.run(stmt); + } +} + +/** + * Deploys SQLite triggers, indexes, and labels for non-MTX (single-tenant) scenarios. + * When multitenancy is enabled, this is a no-op because the DeploymentService handler + * takes care of deploying triggers after each tenant's schema is created. + */ +async function deploySQLiteTriggers() { + const db = cds.env.requires?.db; + if (db?.kind !== 'sqlite') return; + + // In MTX scenarios, triggers are deployed via the DeploymentService handler. + if (cds.env.requires?.multitenancy) return; + + const model = cds.context?.model ?? cds.model; + await _deploySQLiteTriggersAndLabels(model); +} + +/** + * Registers a handler on the DeploymentService to deploy SQLite triggers, + * indexes, labels, and service-level ChangeViews after each tenant's database + * schema is deployed. This runs after `cds.deploy()` completes, ensuring all + * tables exist before triggers referencing them are created. + */ +function registerSQLiteDeploymentHandler() { + cds.once('served', async () => { + const ds = await cds.connect.to('cds.xt.DeploymentService'); + ds.after('deploy', async (_, req) => { + const db = cds.env.requires?.db; + if (db?.kind !== 'sqlite') return; + + // Skip for the t0 metadata tenant — it doesn't contain the application model + const tenant = req.data?.tenant ?? cds.context?.tenant; + const t0 = cds.env.requires?.multitenancy?.t0 ?? 't0'; + if (tenant === t0) return; + + // Get the tenant's application model from the ModelProviderService + const { 'cds.xt.ModelProviderService': mps } = cds.services; + const csn = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); + + // Enhance the CSN with service-level ChangeView definitions. + // This is needed because getCsn() compiles in a worker thread with silent:true, + // so the cds.on('loaded', enhanceModel) handler never fires on the tenant CSN. + enhanceModel(csn); + + // Deploy service-level ChangeViews (e.g., AdminService_ChangeView) + await _deployChangeViews(csn); - await Promise.all([ - ...triggers.map((t) => cds.db.run(t)), - cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_ct_index ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)`), - cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_parent_index ON sap_changelog_Changes (parent_ID)`), - cds.delete(i18nKeys), - cds.insert(labels).into(i18nKeys) - ]); + // Compile for Node.js runtime (needed for trigger generation) + const model = cds.compile.for.nodejs(csn); + await _deploySQLiteTriggersAndLabels(model); + }); + }); } -module.exports = { deploySQLiteTriggers }; +module.exports = { registerSQLiteDeploymentHandler, deploySQLiteTriggers }; From d7148b307c1af5280979afa0a73e6bd36f04fee6 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 27 May 2026 01:05:45 +0200 Subject: [PATCH 02/19] fix: only generate indexes if there are any triggers --- lib/hana/register.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/hana/register.js b/lib/hana/register.js index 4d668360..45554b59 100644 --- a/lib/hana/register.js +++ b/lib/hana/register.js @@ -42,6 +42,19 @@ function registerHDICompilerHook() { suffix: '.csv' } ); + + // Add index for trigger deduplication and parent lookup queries + data.push({ + name: 'sap.changelog.Changes_CT_INDEX', + sql: 'INDEX "sap.changelog.Changes_CT_INDEX" ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)', + suffix: '.hdbindex' + }); + + data.push({ + name: 'sap.changelog.Changes_CT_parent_INDEX', + sql: 'INDEX "sap.changelog.Changes_CT_parent_INDEX" ON sap_changelog_Changes (parent_ID)', + suffix: '.hdbindex' + }); } const config = cds.env.requires?.['change-tracking']; @@ -58,19 +71,6 @@ function registerHDICompilerHook() { csn.definitions['sap.changelog.Changes']['@cds.persistence.journal'] = true; } - // Add index for trigger deduplication and parent lookup queries - data.push({ - name: 'sap.changelog.Changes_CT_INDEX', - sql: 'INDEX "sap.changelog.Changes_CT_INDEX" ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)', - suffix: '.hdbindex' - }); - - data.push({ - name: 'sap.changelog.Changes_CT_parent_INDEX', - sql: 'INDEX "sap.changelog.Changes_CT_parent_INDEX" ON sap_changelog_Changes (parent_ID)', - suffix: '.hdbindex' - }); - const ret = _hdi_migration(csn, options, beforeImage); ret.definitions = ret.definitions.concat(triggers).concat(data); return ret; From bee9d8a6df426f14d8aba7c636065a84a8bc2325 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 27 May 2026 01:16:57 +0200 Subject: [PATCH 03/19] use cds.on('serving:cds.xt.DeploymentService' --- lib/postgres/register.js | 3 +-- lib/sqlite/register.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/postgres/register.js b/lib/postgres/register.js index a62cb4a2..838186ac 100644 --- a/lib/postgres/register.js +++ b/lib/postgres/register.js @@ -65,8 +65,7 @@ async function _deployChangeViews(csn) { * Triggers and indexes are already handled by the compile.to.dbx hook. */ function registerPostgresDeploymentHandler() { - cds.once('served', async () => { - const ds = await cds.connect.to('cds.xt.DeploymentService'); + cds.on('serving:cds.xt.DeploymentService', (ds) => { ds.after('deploy', async (_, req) => { const db = cds.env.requires?.db; if (db?.kind !== 'postgres') return; diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index a04eaef7..8bee7db3 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -77,8 +77,7 @@ async function deploySQLiteTriggers() { * tables exist before triggers referencing them are created. */ function registerSQLiteDeploymentHandler() { - cds.once('served', async () => { - const ds = await cds.connect.to('cds.xt.DeploymentService'); + cds.on('serving:cds.xt.DeploymentService', (ds) => { ds.after('deploy', async (_, req) => { const db = cds.env.requires?.db; if (db?.kind !== 'sqlite') return; From 3d3aa4443b0fd069680499d907b7f7130b5820df Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 27 May 2026 02:11:55 +0200 Subject: [PATCH 04/19] fix: ensureUndeploy is only called during local cds build --- lib/hana/register.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hana/register.js b/lib/hana/register.js index 45554b59..2e108b43 100644 --- a/lib/hana/register.js +++ b/lib/hana/register.js @@ -19,7 +19,7 @@ function registerHDICompilerHook() { const data = []; if (triggers.length > 0) { delete csn.definitions['sap.changelog.CHANGE_TRACKING_DUMMY']['@cds.persistence.skip']; - ensureUndeployJsonHasTriggerPattern(); + if (!cds.requires.multitenancy) ensureUndeployJsonHasTriggerPattern(); const labels = getLabelTranslations(entities, runtimeCSN); const header = 'ID;locale;text'; From 0456f5d3b802559fb5f8fe58fe3e68e0fa530fe9 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 27 May 2026 02:37:14 +0200 Subject: [PATCH 05/19] make sure model is enhanced at sidecar runtime --- lib/hana/register.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/hana/register.js b/lib/hana/register.js index 2e108b43..bea05295 100644 --- a/lib/hana/register.js +++ b/lib/hana/register.js @@ -2,6 +2,7 @@ const cds = require('@sap/cds'); const { prepareCSNForTriggers, generateTriggersForEntities, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js'); const { getLabelTranslations } = require('../localization.js'); +const { enhanceModel } = require('../csn-enhancements'); const MIGRATION_TABLE_PATH = cds.utils.path.join('db', 'src', 'sap.changelog.Changes.hdbmigrationtable'); @@ -12,6 +13,10 @@ function hasMigrationTable() { function registerHDICompilerHook() { const _hdi_migration = cds.compiler.to.hdi.migration; cds.compiler.to.hdi.migration = function (csn, options, beforeImage) { + // Ensure CSN has ChangeView enhancements when compiled at runtime (extensibility upgrades). + // At build time, enhanceModel already ran via cds.on('loaded') and the idempotency check skips it. + enhanceModel(csn); + const { generateHANATriggers } = require('./triggers.js'); const { runtimeCSN, hierarchy, entities } = prepareCSNForTriggers(csn, true); From b9ba99f52d807ffcaba6a89eb87107cfffc81d25 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 27 May 2026 03:16:23 +0200 Subject: [PATCH 06/19] docs: add changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1f7ff5..38273316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Fixed - Migration table now correctly handles composite keys from v1 using `HIERARCHY_COMPOSITE_ID` (supports up to 5 key parts) - `@Capabilities.ReadRestrictions` on ChangeView is now only applied when the view is auto-created by the plugin. Services that explicitly expose ChangeView allow direct read access. +- SQLite sidecar crash on startup in multi-tenant applications due to trigger/index deployment running before tenant tables exist +- Invoke `enhanceModel` in after-deploy handler of `cds.xt.DeploymentService` for SQLite and Postgres because the `loaded` handler is not invoked on the tenant CSN from `ModelProviderService.getCsn()` +- HDI deployment failure (sap.changelog.Changes_CT_INDEX.hdbindex requires db://SAP_CHANGELOG_CHANGES which is not provided by any file) caused by adding `.hdbindex` artifacts even when Changes table does not exist in the compiled model +- HDI deployment failure (invalid column name: CHANGEVIEW_0.PARENT_ENTITYKEY) during extensibility upgrades since `enhanceModel` was not called on the tenant CSN before HANA compilation ## Version 2.0.0-beta.12 - 2026-05-18 From 396a16444ead2e92eb921fccfff374db9a6ab0da Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 27 May 2026 10:39:06 +0200 Subject: [PATCH 07/19] linter --- lib/sqlite/register.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index 8bee7db3..8eaf7e68 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -18,9 +18,7 @@ async function _deploySQLiteTriggersAndLabels(model) { const triggers = generateTriggersForEntities(model, hierarchyMap, entities, generateSQLiteTrigger); if (triggers.length === 0) return; - const dropTriggers = triggers - .map((t) => t.match(/CREATE\s+TRIGGER\s+IF NOT EXISTS\s+(\w+)/i)) - .map((m) => `DROP TRIGGER IF EXISTS ${m[1]};`); + const dropTriggers = triggers.map((t) => t.match(/CREATE\s+TRIGGER\s+IF NOT EXISTS\s+(\w+)/i)).map((m) => `DROP TRIGGER IF EXISTS ${m[1]};`); const labels = getLabelTranslations(entities, model); const { i18nKeys } = cds.entities('sap.changelog'); From de4700763fe796c57d389dd40021b85e61c05de0 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Fri, 29 May 2026 17:30:45 +0200 Subject: [PATCH 08/19] fix: mtx --- cds-plugin.js | 10 ++ lib/csn-enhancements/index.js | 134 +++++++++++++------ lib/postgres/register.js | 41 ++---- lib/sqlite/register.js | 35 +---- tests/bookshop-mtx/mtx/sidecar/package.json | 1 + tests/bookshop-mtx/package.json | 3 +- tests/mtx/mtx.test.js | 114 +++++++++++++++++ tests/mtx/setup.js | 135 ++++++++++++++++++++ 8 files changed, 370 insertions(+), 103 deletions(-) create mode 100644 tests/mtx/mtx.test.js create mode 100644 tests/mtx/setup.js diff --git a/cds-plugin.js b/cds-plugin.js index 269f3b38..f6b6e9c8 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -8,12 +8,22 @@ const { registerH2CompilerHook } = require('./lib/h2/register.js'); const { registerHDICompilerHook } = require('./lib/hana/register.js'); cds.on('loaded', enhanceModel); +cds.on('compile.to.edmx', enhanceModel); cds.on('listening', registerSessionVariableHandlers); cds.once('served', async () => { await deploySQLiteTriggers(); await deployPostgresLabels(); }); +// Enhance CSNs returned by cds.xt.ModelProviderService.getExtCsn +cds.on('serving', (srv) => { + if (srv.name !== 'cds.xt.ModelProviderService') return; + srv.after(['getCsn', 'getExtCsn'], (csn) => { + if (!csn || typeof csn !== 'object' || !csn.definitions) return; + enhanceModel(csn); + }); +}); + registerSQLiteDeploymentHandler(); registerPostgresDeploymentHandler(); registerH2CompilerHook(); diff --git a/lib/csn-enhancements/index.js b/lib/csn-enhancements/index.js index 86a31990..d7a2309a 100644 --- a/lib/csn-enhancements/index.js +++ b/lib/csn-enhancements/index.js @@ -64,12 +64,6 @@ function _replaceTablePlaceholders(on, tableName) { * Returns the updated hierarchyMap and collectedEntities for use by trigger generation. */ function enhanceModel(m) { - if (m.meta?.flavor !== 'inferred') { - // In MTX scenarios with extensibility the runtime model for deployed apps is not - // inferred but xtended and the logic requires inferred. - DEBUG?.(`Skipping model enhancement because model flavour is '${m.meta?.flavor}' and not 'inferred'`); - return; - } const _enhanced = 'sap.changelog.enhanced'; if (m.meta?.[_enhanced]) return; // already enhanced @@ -80,7 +74,14 @@ function enhanceModel(m) { elements: { changes } } = aspect; - const hierarchyMap = analyzeCompositions(m); + // For xtended models, compile to inferred to get .elements on projections. + // All work is done on inferredCSN; mutations are copied back to m at the end. + const inferredCSN = + m.meta?.flavor === 'xtended' + ? cds.compile({ 'csn.csn': JSON.stringify(m) }) + : m; + + const hierarchyMap = analyzeCompositions(inferredCSN); const collectedEntities = new Map(); const replaceReferences = (xpr, depth) => { @@ -96,31 +97,39 @@ function enhanceModel(m) { }; if (cds.env.requires['change-tracking'].maxDisplayHierarchyDepth > 1) { const depth = cds.env.requires['change-tracking'].maxDisplayHierarchyDepth; + const changeView = inferredCSN.definitions['sap.changelog.ChangeView']; const parents = []; for (let i = 1; i < depth; i++) { parents.push('parent'); - m.definitions['sap.changelog.ChangeView'].query.SELECT.columns.push( - { + const aliasKey = parents.join('_') + '_' + 'entityKey'; + const aliasEntity = parents.join('_') + '_' + 'entity'; + const cols = changeView.query.SELECT.columns; + if (!cols.some((c) => c?.as === aliasKey)) { + cols.push({ ref: ['change', ...parents, 'entityKey'], - as: parents.join('_') + '_' + 'entityKey' - }, - { + as: aliasKey + }); + } + if (!cols.some((c) => c?.as === aliasEntity)) { + cols.push({ ref: ['change', ...parents, 'entity'], - as: parents.join('_') + '_' + 'entity' - } - ); - m.definitions['sap.changelog.ChangeView'].elements[parents.join('_') + '_' + 'entityKey'] = structuredClone(m.definitions['sap.changelog.ChangeView'].elements.entityKey); - m.definitions['sap.changelog.ChangeView'].elements[parents.join('_') + '_' + 'entity'] = structuredClone(m.definitions['sap.changelog.ChangeView'].elements.entity); + as: aliasEntity + }); + } + if (changeView.elements) { + changeView.elements[aliasKey] ??= structuredClone(changeView.elements.entityKey); + changeView.elements[aliasEntity] ??= structuredClone(changeView.elements.entity); + } } - enhanceChangeViewWithTimeZones(m.definitions['sap.changelog.ChangeView'], m); + enhanceChangeViewWithTimeZones(changeView, inferredCSN); } - for (let name in m.definitions) { - const entity = m.definitions[name]; + for (let name in inferredCSN.definitions) { + const entity = inferredCSN.definitions[name]; const isServiceEntity = entity.kind === 'entity' && !!(entity.query || entity.projection); - const serviceName = getService(name, m); - if (isServiceEntity && isChangeTracked(entity) && serviceName) { + const serviceName = getService(name, inferredCSN); + if (isServiceEntity && isChangeTracked(entity, inferredCSN) && serviceName) { // Collect change-tracked service entity name with its underlying DB entity name - const baseInfo = getBaseEntity(entity, m); + const baseInfo = getBaseEntity(entity, inferredCSN); if (!baseInfo) continue; const { baseRef: dbEntityName } = baseInfo; @@ -134,11 +143,11 @@ function enhanceModel(m) { DEBUG?.(`Skipping changes association for ${name} - inline composition target with no independent key`); } else if (!entity['@changelog.disable_assoc']) { // Add association to ChangeView - const keys = entityKey4(entity, m); + const keys = entityKey4(entity, inferredCSN); if (!keys.length) continue; // skip if no key attribute is defined const onCondition = changes.on.flatMap((p) => (p?.ref && p.ref[0] === 'ID' ? keys : [p])); - const tableName = (entity.projection ?? entity.query?.SELECT)?.from?.ref[0]; + const tableName = (entity.projection ?? entity.query?.SELECT)?.from?.ref?.[0]; const onTemplate = _replaceTablePlaceholders(onCondition, tableName); const on = cds.env.requires['change-tracking'].maxDisplayHierarchyDepth > 1 ? [{ xpr: structuredClone(onTemplate) }] : onTemplate; for (let i = 1; i < cds.env.requires['change-tracking'].maxDisplayHierarchyDepth; i++) { @@ -146,9 +155,9 @@ function enhanceModel(m) { } const assoc = new cds.builtin.classes.Association({ ...changes, on }); assoc.target = `${serviceName}.ChangeView`; - if (!m.definitions[`${serviceName}.ChangeView`]) { - m.definitions[`${serviceName}.ChangeView`] = structuredClone(m.definitions['sap.changelog.ChangeView']); - m.definitions[`${serviceName}.ChangeView`].query = { + if (!inferredCSN.definitions[`${serviceName}.ChangeView`]) { + const srvChangeView = structuredClone(inferredCSN.definitions['sap.changelog.ChangeView']); + srvChangeView.query = { SELECT: { from: { ref: ['sap.changelog.ChangeView'] @@ -156,11 +165,11 @@ function enhanceModel(m) { columns: ['*'] } }; - - for (const ele in m.definitions[`${serviceName}.ChangeView`].elements) { - if (m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target && !m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target.startsWith(serviceName)) { - const target = m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target; - const serviceEntity = Object.keys(m.definitions) + inferredCSN.definitions[`${serviceName}.ChangeView`] = srvChangeView; + for (const ele in srvChangeView.elements) { + if (srvChangeView.elements[ele]?.target && !srvChangeView.elements[ele]?.target.startsWith(serviceName)) { + const target = srvChangeView.elements[ele]?.target; + const serviceEntity = Object.keys(inferredCSN.definitions) .filter((e) => e.startsWith(serviceName)) .find((e) => { let baseE = e; @@ -168,7 +177,7 @@ function enhanceModel(m) { if (baseE === target) { return true; } - const artefact = m.definitions[baseE]; + const artefact = inferredCSN.definitions[baseE]; const cqn = artefact.projection ?? artefact.query?.SELECT; if (!cqn) { return false; @@ -179,14 +188,14 @@ function enhanceModel(m) { return false; }); if (serviceEntity) { - m.definitions[`${serviceName}.ChangeView`].elements[ele].target = serviceEntity; + srvChangeView.elements[ele].target = serviceEntity; } } } - enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, m); - m.definitions[`${serviceName}.ChangeView`]['@Capabilities.ReadRestrictions.Readable'] = false; + enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, inferredCSN); + inferredCSN.definitions[`${serviceName}.ChangeView`]['@Capabilities.ReadRestrictions.Readable'] = false; } else { - enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, m); + enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, inferredCSN); } DEBUG?.( @@ -199,7 +208,10 @@ function enhanceModel(m) { const query = entity.projection || entity.query?.SELECT; if (query) { - (query.columns ??= ['*']).push({ as: 'changes', cast: assoc }); + (query.columns ??= ['*']); + if (!query.columns.some((c) => c?.as === 'changes')) { + query.columns.push({ as: 'changes', cast: assoc }); + } entity.elements.changes = assoc; entity['@Capabilities.NavigationRestrictions.RestrictedProperties'] ??= []; const alreadyRestricted = entity['@Capabilities.NavigationRestrictions.RestrictedProperties'].some((p) => p.NavigationProperty?.['='] === 'changes'); @@ -212,15 +224,53 @@ function enhanceModel(m) { }); } } - addUIFacet(entity, m); + addUIFacet(entity, inferredCSN); } if (entity.actions) { - const { baseRef: dbEntityName } = baseInfo; - addSideEffects(entity.actions, dbEntityName, hierarchyMap, m); + addSideEffects(entity.actions, dbEntityName, hierarchyMap, inferredCSN); } } } + + // Copy mutations from inferredCSN back to the original model + if (inferredCSN !== m) { + for (const name of Object.keys(inferredCSN.definitions)) { + const artefact = inferredCSN.definitions[name]; + if (!m.definitions[name] && name.endsWith('.ChangeView')) { + m.definitions[name] = artefact; + } else if (m.definitions[name]){ + // append 'changes' column to query/projection columns + const inferredQuery = artefact.projection ?? artefact.query?.SELECT; + const mQuery = m.definitions[name].projection ?? m.definitions[name].query?.SELECT; + if (inferredQuery?.columns && mQuery) { + const changesCol = inferredQuery.columns.find((c) => c.as === 'changes'); + if (changesCol && !mQuery.columns?.some((c) => c.as === 'changes')) { + (mQuery.columns ??= ['*']).push(changesCol); + } + } + // copy added annotations + for (const key of Object.keys(artefact)) { + if (key.startsWith('@') && m.definitions[name]?.[key] === undefined) { + m.definitions[name][key] = artefact[key]; + } + } + } + } + // Append parent_entityKey/parent_entity columns to ChangeView (if added) + if (m.definitions['sap.changelog.ChangeView'] && inferredCSN.definitions['sap.changelog.ChangeView']) { + const mColumns = m.definitions['sap.changelog.ChangeView'].query?.SELECT?.columns; + const inferredColumns = inferredCSN.definitions['sap.changelog.ChangeView'].query?.SELECT?.columns; + if (mColumns && inferredColumns) { + for (const col of inferredColumns) { + if (col.as && col.as.startsWith('parent_') && !mColumns.some((c) => c.as === col.as)) { + mColumns.push(col); + } + } + } + } + } + (m.meta ??= {})[_enhanced] = true; } diff --git a/lib/postgres/register.js b/lib/postgres/register.js index 838186ac..284e06e2 100644 --- a/lib/postgres/register.js +++ b/lib/postgres/register.js @@ -3,7 +3,6 @@ const cds = require('@sap/cds'); const { getEntitiesForTriggerGeneration, collectEntities } = require('../utils/entity-collector.js'); const { getLabelTranslations } = require('../localization.js'); const { prepareCSNForTriggers, generateTriggersForEntities } = require('../utils/trigger-utils.js'); -const { enhanceModel } = require('../csn-enhancements'); function registerPostgresCompilerHook() { cds.on('compile.to.dbx', (csn, options, next) => { @@ -45,24 +44,9 @@ async function deployPostgresLabels() { } /** - * Deploys the service-level ChangeView SQL views (e.g., AdminService_ChangeView) - * that are added by enhanceModel but missing from the deployed schema because - * the ModelProviderService compiles the CSN without firing the 'loaded' event. - */ -async function _deployChangeViews(csn) { - const sql = cds.compile.to.sql(csn, { kind: 'postgres' }); - const changeViewDDL = sql.filter((stmt) => /CREATE VIEW\s+\S*ChangeView/i.test(stmt)); - for (const stmt of changeViewDDL) { - const viewName = stmt.match(/CREATE VIEW\s+("[^"]+"|[^\s(]+)/i)?.[1]; - if (viewName) await cds.db.run(`DROP VIEW IF EXISTS ${viewName}`); - await cds.db.run(stmt); - } -} - -/** - * Registers a handler on the DeploymentService to deploy service-level ChangeViews - * and labels after each tenant's database schema is deployed in MTX scenarios. - * Triggers and indexes are already handled by the compile.to.dbx hook. + * Registers a handler on the DeploymentService to deploy service-level + * ChangeViews and labels after each tenant's database schema is deployed. + * Triggers and indexes are handled by the compile.to.dbx hook. */ function registerPostgresDeploymentHandler() { cds.on('serving:cds.xt.DeploymentService', (ds) => { @@ -75,19 +59,14 @@ function registerPostgresDeploymentHandler() { const t0 = cds.env.requires?.multitenancy?.t0 ?? 't0'; if (tenant === t0) return; - // Get the tenant's application model from the ModelProviderService + // Get the tenant's application model from the ModelProviderService. + // Work on a structuredClone so that subsequent enhanceModel / + // compile.for.nodejs / linker mutations do not pollute the cached CSN. const { 'cds.xt.ModelProviderService': mps } = cds.services; - const csn = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); - - // Enhance the CSN with service-level ChangeView definitions. - // This is needed because getCsn() compiles in a worker thread with silent:true, - // so the cds.on('loaded', enhanceModel) handler never fires on the tenant CSN. - enhanceModel(csn); - - // Deploy service-level ChangeViews (e.g., AdminService_ChangeView) - await _deployChangeViews(csn); - - // Deploy labels + const cached = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); + const csn = structuredClone(cached); + + // Compile for Node.js runtime, then deploy labels. const model = cds.compile.for.nodejs(csn); const { collectedEntities } = collectEntities(model); const entities = getEntitiesForTriggerGeneration(model.definitions, collectedEntities); diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index 8eaf7e68..d22c6b50 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -3,7 +3,6 @@ const cds = require('@sap/cds'); const { getEntitiesForTriggerGeneration, collectEntities } = require('../utils/entity-collector.js'); const { getLabelTranslations } = require('../localization.js'); const { generateTriggersForEntities } = require('../utils/trigger-utils.js'); -const { enhanceModel } = require('../csn-enhancements'); /** * Deploys SQLite triggers, indexes, and labels for change tracking. @@ -36,21 +35,6 @@ async function _deploySQLiteTriggersAndLabels(model) { await cds.insert(labels).into(i18nKeys); } -/** - * Deploys the service-level ChangeView SQL views (e.g., AdminService_ChangeView) - * that are added by enhanceModel but missing from the deployed schema because - * the ModelProviderService compiles the CSN without firing the 'loaded' event. - */ -async function _deployChangeViews(csn) { - const sql = cds.compile.to.sql(csn, { kind: 'sqlite' }); - const changeViewDDL = sql.filter((stmt) => /CREATE VIEW\s+\S*ChangeView/i.test(stmt)); - for (const stmt of changeViewDDL) { - // Use IF NOT EXISTS semantics via DROP + CREATE to handle upgrades - const viewName = stmt.match(/CREATE VIEW\s+("[^"]+"|[^\s(]+)/i)?.[1]; - if (viewName) await cds.db.run(`DROP VIEW IF EXISTS ${viewName}`); - await cds.db.run(stmt); - } -} /** * Deploys SQLite triggers, indexes, and labels for non-MTX (single-tenant) scenarios. @@ -71,8 +55,8 @@ async function deploySQLiteTriggers() { /** * Registers a handler on the DeploymentService to deploy SQLite triggers, * indexes, labels, and service-level ChangeViews after each tenant's database - * schema is deployed. This runs after `cds.deploy()` completes, ensuring all - * tables exist before triggers referencing them are created. + * schema is deployed. Runs after `cds.deploy()` completes, ensuring all tables + * exist before triggers/views referencing them are created. */ function registerSQLiteDeploymentHandler() { cds.on('serving:cds.xt.DeploymentService', (ds) => { @@ -85,19 +69,12 @@ function registerSQLiteDeploymentHandler() { const t0 = cds.env.requires?.multitenancy?.t0 ?? 't0'; if (tenant === t0) return; - // Get the tenant's application model from the ModelProviderService + // Get the tenant's application model from the ModelProviderService. const { 'cds.xt.ModelProviderService': mps } = cds.services; - const csn = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); - - // Enhance the CSN with service-level ChangeView definitions. - // This is needed because getCsn() compiles in a worker thread with silent:true, - // so the cds.on('loaded', enhanceModel) handler never fires on the tenant CSN. - enhanceModel(csn); - - // Deploy service-level ChangeViews (e.g., AdminService_ChangeView) - await _deployChangeViews(csn); + const cached = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); + const csn = structuredClone(cached); - // Compile for Node.js runtime (needed for trigger generation) + // Compile for Node.js runtime (needed for trigger generation). const model = cds.compile.for.nodejs(csn); await _deploySQLiteTriggersAndLabels(model); }); diff --git a/tests/bookshop-mtx/mtx/sidecar/package.json b/tests/bookshop-mtx/mtx/sidecar/package.json index 9f7b2db4..92fc5f29 100644 --- a/tests/bookshop-mtx/mtx/sidecar/package.json +++ b/tests/bookshop-mtx/mtx/sidecar/package.json @@ -1,6 +1,7 @@ { "name": "bookshop-mtx", "dependencies": { + "@cap-js/change-tracking": "*", "@cap-js/hana": "^2", "@sap/cds": "^9", "@sap/cds-mtxs": "^3", diff --git a/tests/bookshop-mtx/package.json b/tests/bookshop-mtx/package.json index cd901559..492eb6ab 100644 --- a/tests/bookshop-mtx/package.json +++ b/tests/bookshop-mtx/package.json @@ -12,7 +12,8 @@ "kind": "sql" }, "[production]": { - "multitenancy": true + "multitenancy": true, + "extensibility": true }, "[with-mtx]": { "multitenancy": true diff --git a/tests/mtx/mtx.test.js b/tests/mtx/mtx.test.js new file mode 100644 index 00000000..07e76878 --- /dev/null +++ b/tests/mtx/mtx.test.js @@ -0,0 +1,114 @@ +process.env.CDS_ENV = 'with-mtx'; + +const cds = require('@sap/cds'); +const path = require('path'); +const { APP_DIR, ensureSidecarPlugin, cleanDbFiles, startSidecar, subscribeTenant, upgradeTenant, stopSidecar } = require('./setup'); + +jest.setTimeout(60_000); + +const isSqlite = cds.env.requires?.db?.kind === 'sqlite' || cds.env.requires?.db?.kind === 'better-sqlite'; + +const describeIfSqlite = isSqlite ? describe : describe.skip; + +let sidecar; + +if (isSqlite) { + beforeAll(async () => { + ensureSidecarPlugin(); + cleanDbFiles(); + sidecar = await startSidecar(); + const status = await subscribeTenant('t1', sidecar.port); + expect(status).toBeLessThan(300); + }); + + afterAll(async () => { + await stopSidecar(sidecar?.proc); + }); + + const { axios } = cds.test(APP_DIR); + axios.defaults.auth = { username: 'alice' }; +} + +describeIfSqlite('Change-Tracking MTX', () => { + + describe('Tenant subscription deploys change-tracking artifacts', () => { + it('deploys triggers', () => { + const Database = require('better-sqlite3'); + const db = new Database(path.join(APP_DIR, 'db-t1.sqlite'), { readonly: true }); + const triggers = db.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger'").all(); + expect(triggers.length).toBeGreaterThan(0); + db.close(); + }); + + it('deploys indexes', () => { + const Database = require('better-sqlite3'); + const db = new Database(path.join(APP_DIR, 'db-t1.sqlite'), { readonly: true }); + const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE '%changelog%'").all(); + expect(indexes.some((i) => i.name === 'sap_changelog_Changes_ct_index')).toBe(true); + expect(indexes.some((i) => i.name === 'sap_changelog_Changes_parent_index')).toBe(true); + db.close(); + }); + + it('deploys service-level ChangeViews', () => { + const Database = require('better-sqlite3'); + const db = new Database(path.join(APP_DIR, 'db-t1.sqlite'), { readonly: true }); + const views = db.prepare("SELECT name FROM sqlite_master WHERE name LIKE '%ChangeView%'").all(); + expect(views.some((v) => v.name === 'AdminService_ChangeView')).toBe(true); + db.close(); + }); + + it('deploys i18n labels', () => { + const Database = require('better-sqlite3'); + const db = new Database(path.join(APP_DIR, 'db-t1.sqlite'), { readonly: true }); + const { cnt } = db.prepare('SELECT COUNT(*) as cnt FROM sap_changelog_i18nKeys').get(); + expect(cnt).toBeGreaterThan(0); + db.close(); + }); + }); + + describe('Tenant upgrade re-deploys artifacts', () => { + it('upgrade succeeds', async () => { + const status = await upgradeTenant('t1', sidecar.port); + expect(status).toBeLessThan(300); + }); + + it('triggers still exist after upgrade', () => { + const Database = require('better-sqlite3'); + const db = new Database(path.join(APP_DIR, 'db-t1.sqlite'), { readonly: true }); + const triggers = db.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger'").all(); + expect(triggers.length).toBeGreaterThan(0); + db.close(); + }); + + it('ChangeViews still exist after upgrade', () => { + const Database = require('better-sqlite3'); + const db = new Database(path.join(APP_DIR, 'db-t1.sqlite'), { readonly: true }); + const views = db.prepare("SELECT name FROM sqlite_master WHERE name LIKE '%ChangeView%'").all(); + expect(views.some((v) => v.name === 'AdminService_ChangeView')).toBe(true); + db.close(); + }); + + it('labels still exist after upgrade', () => { + const Database = require('better-sqlite3'); + const db = new Database(path.join(APP_DIR, 'db-t1.sqlite'), { readonly: true }); + const { cnt } = db.prepare('SELECT COUNT(*) as cnt FROM sap_changelog_i18nKeys').get(); + expect(cnt).toBeGreaterThan(0); + db.close(); + }); + }); + + describe('Model enhancement via compile.for.runtime', () => { + it('enhances tenant CSN with AdminService.ChangeView', () => { + // Verify enhanceModel is applied when compiling for runtime + // Load the app model (which goes through compile.for.runtime) + const csn = cds.model; + expect(csn.definitions['AdminService.ChangeView']).toBeDefined(); + }); + + it('adds changes association to tracked entities', () => { + const csn = cds.model; + expect(csn.definitions['AdminService.BookStores'].elements.changes).toBeDefined(); + expect(csn.definitions['AdminService.BookStores'].elements.changes.target).toBe('AdminService.ChangeView'); + }); + }); +}); diff --git a/tests/mtx/setup.js b/tests/mtx/setup.js new file mode 100644 index 00000000..a3c17caa --- /dev/null +++ b/tests/mtx/setup.js @@ -0,0 +1,135 @@ +const { spawn, execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const APP_DIR = path.resolve(__dirname, '../bookshop-mtx'); +const SIDECAR_DIR = path.join(APP_DIR, 'mtx', 'sidecar'); +const PLUGIN_ROOT = path.resolve(__dirname, '../..'); + +/** + * Pack @cap-js/change-tracking and install in sidecar to avoid dual @sap/cds load. + * Restores the original package.json afterward so the file: reference stays intact. + */ +function ensureSidecarPlugin() { + const pkgPath = path.join(SIDECAR_DIR, 'package.json'); + const originalPkg = fs.readFileSync(pkgPath, 'utf-8'); + const tmpDir = os.tmpdir(); + const tgz = execSync(`npm pack --pack-destination ${tmpDir}`, { + cwd: PLUGIN_ROOT, + encoding: 'utf-8' + }).trim(); + execSync(`npm install ${path.join(tmpDir, tgz)}`, { + cwd: SIDECAR_DIR, + encoding: 'utf-8', + stdio: 'ignore' + }); + fs.writeFileSync(pkgPath, originalPkg); +} + +/** + * db*.sqlite, db*.sqlite-shm and db*.sqlite-wal files from the app directory. + */ +function cleanDbFiles() { + let files; + try { + files = fs.readdirSync(APP_DIR); + } catch { + return; + } + for (const f of files.filter((f) => /^db.*\.sqlite(-shm|-wal)?$/.test(f))) { + try { + fs.unlinkSync(path.join(APP_DIR, f)); + } catch { + /* ignore */ + } + } +} + +/** + * Start the MTX sidecar via `cds watch` on a random port. + * Resolves with { proc, port } when the server is listening. + */ +function startSidecar() { + return new Promise((resolve, reject) => { + const proc = spawn('npx', ['cds', 'watch', '--port', '0'], { + cwd: SIDECAR_DIR, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, FORCE_COLOR: 'false', NODE_ENV: 'development' } + }); + + let output = ''; + const timeout = setTimeout(() => { + proc.kill(); + reject(new Error(`Sidecar failed to start within 30s.\nOutput: ${output}`)); + }, 30000); + + proc.stdout.on('data', (data) => { + output += data.toString(); + const match = output.match(/server listening on \{[^}]*url:\s*'http:\/\/localhost:(\d+)'/); + if (match) { + clearTimeout(timeout); + resolve({ proc, port: Number(match[1]) }); + } + }); + + proc.stderr.on('data', (data) => { + output += data.toString(); + }); + + proc.on('exit', (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error(`Sidecar exited with code ${code}.\nOutput: ${output}`)); + } + }); + }); +} + +/** + * Subscribe a tenant via the sidecar's Deployment endpoint. + */ +async function subscribeTenant(tenant, port) { + const res = await fetch(`http://localhost:${port}/-/cds/deployment/subscribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from('yves:').toString('base64') + }, + body: JSON.stringify({ tenant }) + }); + return res.status; +} + +/** + * Upgrade a tenant via the sidecar's Deployment endpoint. + */ +async function upgradeTenant(tenant, port) { + const res = await fetch(`http://localhost:${port}/-/cds/deployment/upgrade`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from('yves:').toString('base64') + }, + body: JSON.stringify({ tenant }) + }); + return res.status; +} + +/** + * Stop the sidecar process and clean up DB files. + */ +async function stopSidecar(proc) { + if (proc && !proc.killed) { + if (proc.exitCode === null) { + proc.kill(); + await Promise.race([ + new Promise((resolve) => proc.on('exit', resolve)), + new Promise((resolve) => setTimeout(resolve, 5000)) + ]); + } + } + cleanDbFiles(); +} + +module.exports = { APP_DIR, SIDECAR_DIR, ensureSidecarPlugin, cleanDbFiles, startSidecar, subscribeTenant, upgradeTenant, stopSidecar }; From c932cbdcd74dbbaf6e387eebc72e5db1f3d9b1de Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Fri, 29 May 2026 17:33:29 +0200 Subject: [PATCH 09/19] linter --- lib/csn-enhancements/index.js | 9 +++------ lib/postgres/register.js | 2 +- lib/sqlite/register.js | 1 - tests/mtx/mtx.test.js | 1 - tests/mtx/setup.js | 5 +---- 5 files changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/csn-enhancements/index.js b/lib/csn-enhancements/index.js index d7a2309a..0ec923fb 100644 --- a/lib/csn-enhancements/index.js +++ b/lib/csn-enhancements/index.js @@ -76,10 +76,7 @@ function enhanceModel(m) { // For xtended models, compile to inferred to get .elements on projections. // All work is done on inferredCSN; mutations are copied back to m at the end. - const inferredCSN = - m.meta?.flavor === 'xtended' - ? cds.compile({ 'csn.csn': JSON.stringify(m) }) - : m; + const inferredCSN = m.meta?.flavor === 'xtended' ? cds.compile({ 'csn.csn': JSON.stringify(m) }) : m; const hierarchyMap = analyzeCompositions(inferredCSN); const collectedEntities = new Map(); @@ -208,7 +205,7 @@ function enhanceModel(m) { const query = entity.projection || entity.query?.SELECT; if (query) { - (query.columns ??= ['*']); + query.columns ??= ['*']; if (!query.columns.some((c) => c?.as === 'changes')) { query.columns.push({ as: 'changes', cast: assoc }); } @@ -239,7 +236,7 @@ function enhanceModel(m) { const artefact = inferredCSN.definitions[name]; if (!m.definitions[name] && name.endsWith('.ChangeView')) { m.definitions[name] = artefact; - } else if (m.definitions[name]){ + } else if (m.definitions[name]) { // append 'changes' column to query/projection columns const inferredQuery = artefact.projection ?? artefact.query?.SELECT; const mQuery = m.definitions[name].projection ?? m.definitions[name].query?.SELECT; diff --git a/lib/postgres/register.js b/lib/postgres/register.js index 284e06e2..857864f0 100644 --- a/lib/postgres/register.js +++ b/lib/postgres/register.js @@ -65,7 +65,7 @@ function registerPostgresDeploymentHandler() { const { 'cds.xt.ModelProviderService': mps } = cds.services; const cached = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); const csn = structuredClone(cached); - + // Compile for Node.js runtime, then deploy labels. const model = cds.compile.for.nodejs(csn); const { collectedEntities } = collectEntities(model); diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index d22c6b50..17700f93 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -35,7 +35,6 @@ async function _deploySQLiteTriggersAndLabels(model) { await cds.insert(labels).into(i18nKeys); } - /** * Deploys SQLite triggers, indexes, and labels for non-MTX (single-tenant) scenarios. * When multitenancy is enabled, this is a no-op because the DeploymentService handler diff --git a/tests/mtx/mtx.test.js b/tests/mtx/mtx.test.js index 07e76878..7d4cc421 100644 --- a/tests/mtx/mtx.test.js +++ b/tests/mtx/mtx.test.js @@ -30,7 +30,6 @@ if (isSqlite) { } describeIfSqlite('Change-Tracking MTX', () => { - describe('Tenant subscription deploys change-tracking artifacts', () => { it('deploys triggers', () => { const Database = require('better-sqlite3'); diff --git a/tests/mtx/setup.js b/tests/mtx/setup.js index a3c17caa..e0efa91e 100644 --- a/tests/mtx/setup.js +++ b/tests/mtx/setup.js @@ -123,10 +123,7 @@ async function stopSidecar(proc) { if (proc && !proc.killed) { if (proc.exitCode === null) { proc.kill(); - await Promise.race([ - new Promise((resolve) => proc.on('exit', resolve)), - new Promise((resolve) => setTimeout(resolve, 5000)) - ]); + await Promise.race([new Promise((resolve) => proc.on('exit', resolve)), new Promise((resolve) => setTimeout(resolve, 5000))]); } } cleanDbFiles(); From d6eb562199671e28557cc3382ff5d80556fbe544 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Mon, 1 Jun 2026 10:49:43 +0200 Subject: [PATCH 10/19] ci: fix dependency mismatch for cds 8 --- .github/actions/integration-tests/action.yml | 30 -------------------- .github/workflows/test.yml | 24 ---------------- package.json | 4 +-- tests/bookshop-mtx/mtx/sidecar/package.json | 8 +++--- tests/bookshop-mtx/package.json | 2 +- tests/bookshop/package.json | 4 +-- 6 files changed, 9 insertions(+), 63 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 7f08fe24..d758dc45 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -64,36 +64,6 @@ runs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ inputs.NODE_VERSION }} - - name: Set CDS version-specific dependencies - shell: bash - run: | - if [ "${{ inputs.CDS_VERSION }}" == "8" ]; then - echo "Installing CDS 8 compatible packages..." - # Add @sap/cds as direct devDependency to anchor the version - # This prevents npm from resolving to v9 due to open-ended peer deps like ">=8" - npm pkg set "devDependencies.@sap/cds=^8.9.8" - npm pkg set "devDependencies.@cap-js/sqlite=^1" - npm pkg set "devDependencies.@cap-js/cds-test=^0.4.1" - # Root overrides enforce versions across the entire workspace - npm pkg set "overrides.@cap-js/sqlite=^1" - npm pkg set "overrides.@cap-js/hana=^1" - npm pkg set "overrides.@cap-js/db-service=^1" - npm pkg set "overrides.@cap-js/postgres=^1" - npm pkg set "overrides.@sap/cds-mtxs=^2" - npm pkg set "overrides.@cap-js/cds-test=^0.4.1" - npm pkg set "devDependencies.@cap-js/cds-types=^0.11" - # Update workspace package dependencies to CDS 8 compatible versions - cd tests/bookshop - npm pkg set "dependencies.@cap-js/hana=^1" - npm pkg set "dependencies.@cap-js/postgres=^1" - npm pkg set "devDependencies.@cap-js/sqlite=^1" - cd ../.. - cd tests/bookshop-mtx - npm pkg set "dependencies.@sap/cds-mtxs=^2" - npm pkg set "devDependencies.@cap-js/sqlite=^1" - cd ../.. - fi - - name: Install global CDS shell: bash run: npm i -g @sap/cds-dk@${{ inputs.CDS_VERSION }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3468b43a..67743e67 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,30 +64,6 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ matrix.node-version }} - - name: Set CDS 8 compatible dependencies - if: matrix.cds-version == 8 - run: | - # Add @sap/cds as direct devDependency to anchor the version - npm pkg set "devDependencies.@sap/cds=^8.9.8" - npm pkg set "devDependencies.@cap-js/sqlite=^1.0.0" - npm pkg set "devDependencies.@cap-js/cds-test=^0.4.1" - # Root overrides enforce versions across the entire workspace - npm pkg set "overrides.@cap-js/sqlite=^1.0.0" - npm pkg set "overrides.@cap-js/hana=^1.0.0" - npm pkg set "overrides.@cap-js/db-service=^1.0.0" - npm pkg set "overrides.@cap-js/postgres=^1.0.0" - npm pkg set "overrides.@sap/cds-mtxs=^2.0.0" - npm pkg set "overrides.@cap-js/cds-test=^0.4.1" - npm pkg set "devDependencies.@cap-js/cds-types=^0.11" - # Update workspace package dependencies - cd tests/bookshop - npm pkg set "dependencies.@cap-js/hana=^1.0.0" - npm pkg set "dependencies.@cap-js/postgres=^1.0.0" - npm pkg set "devDependencies.@cap-js/sqlite=^1.0.0" - cd ../.. - cd tests/bookshop-mtx - npm pkg set "dependencies.@sap/cds-mtxs=^2.0.0" - npm pkg set "devDependencies.@cap-js/sqlite=^1.0.0" - name: Start PostgreSQL run: docker compose -f tests/bookshop/pg-stack.yml up -d --wait - run: npm i -g @sap/cds-dk@${{ matrix.cds-version }} diff --git a/package.json b/package.json index ba83a16b..fa87739b 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "node": ">=20.0.0" }, "devDependencies": { - "@cap-js/cds-test": "*", - "@cap-js/cds-types": "^0.17.0" + "@cap-js/cds-test": "^0 || ^1", + "@cap-js/cds-types": "^0" }, "cds": { "requires": { diff --git a/tests/bookshop-mtx/mtx/sidecar/package.json b/tests/bookshop-mtx/mtx/sidecar/package.json index 92fc5f29..f018166e 100644 --- a/tests/bookshop-mtx/mtx/sidecar/package.json +++ b/tests/bookshop-mtx/mtx/sidecar/package.json @@ -2,14 +2,14 @@ "name": "bookshop-mtx", "dependencies": { "@cap-js/change-tracking": "*", - "@cap-js/hana": "^2", - "@sap/cds": "^9", - "@sap/cds-mtxs": "^3", + "@cap-js/hana": "^1 || ^2", + "@sap/cds": "^8 || ^9", + "@sap/cds-mtxs": "^2 || ^3", "@sap/xssec": "^4", "express": "^4" }, "devDependencies": { - "@cap-js/sqlite": "^2" + "@cap-js/sqlite": "^1 || ^2" }, "engines": { "node": ">=20" diff --git a/tests/bookshop-mtx/package.json b/tests/bookshop-mtx/package.json index 492eb6ab..df6f1bd4 100644 --- a/tests/bookshop-mtx/package.json +++ b/tests/bookshop-mtx/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@cap-js/change-tracking": "file:../../.", - "@sap/cds-mtxs": "^3" + "@sap/cds-mtxs": "^2 || ^3" }, "devDependencies": { "@cap-js/sqlite": "^1 || ^2" diff --git a/tests/bookshop/package.json b/tests/bookshop/package.json index 023d3cc3..9693e70d 100644 --- a/tests/bookshop/package.json +++ b/tests/bookshop/package.json @@ -2,12 +2,12 @@ "dependencies": { "@cap-js/attachments": "^3", "@cap-js/change-tracking": "file:../../.", - "@cap-js/hana": "^2", + "@cap-js/hana": "^1 || ^2", "@cap-js/postgres": "^1 || ^2" }, "devDependencies": { "@cap-js/sqlite": "^1 || ^2", - "@sap/cds-dk": "^9", + "@sap/cds-dk": "^8 || ^9", "puppeteer": "^24.40.0", "ui5-test-runner": "^5.13.1" }, From 493f01931a0b8359b4a1a3787c064839e92fa388 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Mon, 1 Jun 2026 13:41:39 +0200 Subject: [PATCH 11/19] . --- lib/csn-enhancements/index.js | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/lib/csn-enhancements/index.js b/lib/csn-enhancements/index.js index 0ec923fb..75756410 100644 --- a/lib/csn-enhancements/index.js +++ b/lib/csn-enhancements/index.js @@ -98,25 +98,18 @@ function enhanceModel(m) { const parents = []; for (let i = 1; i < depth; i++) { parents.push('parent'); - const aliasKey = parents.join('_') + '_' + 'entityKey'; - const aliasEntity = parents.join('_') + '_' + 'entity'; - const cols = changeView.query.SELECT.columns; - if (!cols.some((c) => c?.as === aliasKey)) { - cols.push({ + changeView.query.SELECT.columns.push( + { ref: ['change', ...parents, 'entityKey'], - as: aliasKey - }); - } - if (!cols.some((c) => c?.as === aliasEntity)) { - cols.push({ + as: parents.join('_') + '_' + 'entityKey' + }, + { ref: ['change', ...parents, 'entity'], - as: aliasEntity - }); - } - if (changeView.elements) { - changeView.elements[aliasKey] ??= structuredClone(changeView.elements.entityKey); - changeView.elements[aliasEntity] ??= structuredClone(changeView.elements.entity); - } + as: parents.join('_') + '_' + 'entity' + } + ); + changeView.elements[parents.join('_') + '_' + 'entityKey'] = structuredClone(changeView.elements.entityKey); + changeView.elements[parents.join('_') + '_' + 'entity'] = structuredClone(changeView.elements.entity); } enhanceChangeViewWithTimeZones(changeView, inferredCSN); } From 1d7f9517978a887690a6e181c79ae82386bbb136 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Mon, 1 Jun 2026 15:12:13 +0200 Subject: [PATCH 12/19] . --- lib/csn-enhancements/index.js | 10 ++++------ lib/hana/register.js | 2 +- lib/postgres/register.js | 10 ++++------ lib/sqlite/register.js | 16 +++++++--------- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/lib/csn-enhancements/index.js b/lib/csn-enhancements/index.js index 75756410..139fce59 100644 --- a/lib/csn-enhancements/index.js +++ b/lib/csn-enhancements/index.js @@ -247,15 +247,13 @@ function enhanceModel(m) { } } } - // Append parent_entityKey/parent_entity columns to ChangeView (if added) + // Append parent_entityKey/parent_entity columns to ChangeView if (m.definitions['sap.changelog.ChangeView'] && inferredCSN.definitions['sap.changelog.ChangeView']) { const mColumns = m.definitions['sap.changelog.ChangeView'].query?.SELECT?.columns; const inferredColumns = inferredCSN.definitions['sap.changelog.ChangeView'].query?.SELECT?.columns; - if (mColumns && inferredColumns) { - for (const col of inferredColumns) { - if (col.as && col.as.startsWith('parent_') && !mColumns.some((c) => c.as === col.as)) { - mColumns.push(col); - } + for (const col of inferredColumns) { + if (col.as && col.as.startsWith('parent_') && !mColumns.some((c) => c.as === col.as)) { + mColumns.push(col); } } } diff --git a/lib/hana/register.js b/lib/hana/register.js index bea05295..4559fef8 100644 --- a/lib/hana/register.js +++ b/lib/hana/register.js @@ -13,7 +13,7 @@ function hasMigrationTable() { function registerHDICompilerHook() { const _hdi_migration = cds.compiler.to.hdi.migration; cds.compiler.to.hdi.migration = function (csn, options, beforeImage) { - // Ensure CSN has ChangeView enhancements when compiled at runtime (extensibility upgrades). + // Ensure CSN has ChangeView enhancements when compiled at runtime (extensibility upgrades) // At build time, enhanceModel already ran via cds.on('loaded') and the idempotency check skips it. enhanceModel(csn); diff --git a/lib/postgres/register.js b/lib/postgres/register.js index 857864f0..30829fc4 100644 --- a/lib/postgres/register.js +++ b/lib/postgres/register.js @@ -44,9 +44,9 @@ async function deployPostgresLabels() { } /** - * Registers a handler on the DeploymentService to deploy service-level - * ChangeViews and labels after each tenant's database schema is deployed. - * Triggers and indexes are handled by the compile.to.dbx hook. + * Registers an after handler on the DeploymentService to deploy service-level + * ChangeViews and labels after each tenant's database schema is deployed + * Triggers and indexes are handled by the compile.to.dbx hook */ function registerPostgresDeploymentHandler() { cds.on('serving:cds.xt.DeploymentService', (ds) => { @@ -59,9 +59,7 @@ function registerPostgresDeploymentHandler() { const t0 = cds.env.requires?.multitenancy?.t0 ?? 't0'; if (tenant === t0) return; - // Get the tenant's application model from the ModelProviderService. - // Work on a structuredClone so that subsequent enhanceModel / - // compile.for.nodejs / linker mutations do not pollute the cached CSN. + // Get the tenant's application model from the ModelProviderService const { 'cds.xt.ModelProviderService': mps } = cds.services; const cached = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); const csn = structuredClone(cached); diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index 17700f93..f5d26d62 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -22,7 +22,7 @@ async function _deploySQLiteTriggersAndLabels(model) { const labels = getLabelTranslations(entities, model); const { i18nKeys } = cds.entities('sap.changelog'); - // Delete existing triggers (safe to run in parallel — they're all DROP IF EXISTS) + // Delete existing triggers await Promise.all(dropTriggers.map((t) => cds.db.run(t))); // Deploy triggers and indexes sequentially to ensure referenced tables exist @@ -36,15 +36,14 @@ async function _deploySQLiteTriggersAndLabels(model) { } /** - * Deploys SQLite triggers, indexes, and labels for non-MTX (single-tenant) scenarios. - * When multitenancy is enabled, this is a no-op because the DeploymentService handler + * Deploys SQLite triggers, indexes, and labels for single-tenant scenarios. + * Skipped when multitenancy is enabled because the DeploymentService handler * takes care of deploying triggers after each tenant's schema is created. */ async function deploySQLiteTriggers() { const db = cds.env.requires?.db; if (db?.kind !== 'sqlite') return; - // In MTX scenarios, triggers are deployed via the DeploymentService handler. if (cds.env.requires?.multitenancy) return; const model = cds.context?.model ?? cds.model; @@ -52,10 +51,9 @@ async function deploySQLiteTriggers() { } /** - * Registers a handler on the DeploymentService to deploy SQLite triggers, + * Registers an after handler on the DeploymentService to deploy SQLite triggers, * indexes, labels, and service-level ChangeViews after each tenant's database - * schema is deployed. Runs after `cds.deploy()` completes, ensuring all tables - * exist before triggers/views referencing them are created. + * schema is deployed. */ function registerSQLiteDeploymentHandler() { cds.on('serving:cds.xt.DeploymentService', (ds) => { @@ -68,12 +66,12 @@ function registerSQLiteDeploymentHandler() { const t0 = cds.env.requires?.multitenancy?.t0 ?? 't0'; if (tenant === t0) return; - // Get the tenant's application model from the ModelProviderService. + // Get the tenant's application model from the ModelProviderService const { 'cds.xt.ModelProviderService': mps } = cds.services; const cached = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); const csn = structuredClone(cached); - // Compile for Node.js runtime (needed for trigger generation). + // Compile for Node.js runtime (needed for trigger generation) const model = cds.compile.for.nodejs(csn); await _deploySQLiteTriggersAndLabels(model); }); From 00143731b0260facbddbea49c1241393e3067fa3 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Tue, 2 Jun 2026 18:00:46 +0200 Subject: [PATCH 13/19] fix: add feature toggle and call cds.test first --- tests/bookshop-mtx/fts/isbn/change-service.cds | 10 ++++++++++ tests/mtx/mtx.test.js | 10 ++++------ 2 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 tests/bookshop-mtx/fts/isbn/change-service.cds diff --git a/tests/bookshop-mtx/fts/isbn/change-service.cds b/tests/bookshop-mtx/fts/isbn/change-service.cds new file mode 100644 index 00000000..da7302f5 --- /dev/null +++ b/tests/bookshop-mtx/fts/isbn/change-service.cds @@ -0,0 +1,10 @@ +using AdminService from '../../srv/admin-service'; +using {sap.capire.bookshop as my} from '../../db/schema'; + +annotate AdminService.Books with { + stock @changelog; +} + +extend my.Books with { + isbn : String @changelog; +} diff --git a/tests/mtx/mtx.test.js b/tests/mtx/mtx.test.js index 7d4cc421..066832bb 100644 --- a/tests/mtx/mtx.test.js +++ b/tests/mtx/mtx.test.js @@ -4,12 +4,13 @@ const cds = require('@sap/cds'); const path = require('path'); const { APP_DIR, ensureSidecarPlugin, cleanDbFiles, startSidecar, subscribeTenant, upgradeTenant, stopSidecar } = require('./setup'); -jest.setTimeout(60_000); +const { axios } = cds.test(APP_DIR); +axios.defaults.auth = { username: 'alice' }; -const isSqlite = cds.env.requires?.db?.kind === 'sqlite' || cds.env.requires?.db?.kind === 'better-sqlite'; +jest.setTimeout(60_000); +const isSqlite = cds.env.requires?.db?.kind === 'sqlite'; const describeIfSqlite = isSqlite ? describe : describe.skip; - let sidecar; if (isSqlite) { @@ -24,9 +25,6 @@ if (isSqlite) { afterAll(async () => { await stopSidecar(sidecar?.proc); }); - - const { axios } = cds.test(APP_DIR); - axios.defaults.auth = { username: 'alice' }; } describeIfSqlite('Change-Tracking MTX', () => { From 8d3d87c10969d1c41c728e52eae8c62004968e96 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 3 Jun 2026 13:36:55 +0200 Subject: [PATCH 14/19] test: adjust mtx tests --- tests/bookshop-mtx/srv/admin-service.cds | 2 + tests/integration/configuration.test.js | 11 --- tests/mtx/mtx.test.js | 117 +++++++++++++++++++++-- 3 files changed, 113 insertions(+), 17 deletions(-) diff --git a/tests/bookshop-mtx/srv/admin-service.cds b/tests/bookshop-mtx/srv/admin-service.cds index 7f0e36cf..d79f07b5 100644 --- a/tests/bookshop-mtx/srv/admin-service.cds +++ b/tests/bookshop-mtx/srv/admin-service.cds @@ -3,6 +3,8 @@ using {sap.capire.bookshop as my} from '../db/schema'; service AdminService { @odata.draft.enabled entity BookStores @(cds.autoexpose) as projection on my.BookStores; + + entity Books as projection on my.Books; } annotate AdminService.BookStores with @changelog: [name] { diff --git a/tests/integration/configuration.test.js b/tests/integration/configuration.test.js index 8d5c6e55..fd751344 100644 --- a/tests/integration/configuration.test.js +++ b/tests/integration/configuration.test.js @@ -915,14 +915,3 @@ describe('Configuration Options', () => { expect(restoredChange.objectID).toEqual(parentID); }); }); -describe('MTX Build', () => { - it('adds changes association only during runtime compilation, not during xtended CSN build', async () => { - const csn = await cds.load([path.join(__dirname, '../bookshop-mtx/srv'), '@cap-js/change-tracking'], { flavor: 'xtended' }); - expect(csn.definitions['AdminService.BookStores'].elements?.changes).toBeFalsy(); - - const csn2 = await cds.load([path.join(__dirname, '../bookshop-mtx/srv'), '@cap-js/change-tracking'], { flavor: 'inferred' }); - const effectiveCSN2 = await cds.compile.for.nodejs(csn2); - - expect(effectiveCSN2.definitions['AdminService.BookStores'].elements.changes).toBeTruthy(); - }); -}); diff --git a/tests/mtx/mtx.test.js b/tests/mtx/mtx.test.js index 066832bb..4b609dd4 100644 --- a/tests/mtx/mtx.test.js +++ b/tests/mtx/mtx.test.js @@ -1,14 +1,12 @@ process.env.CDS_ENV = 'with-mtx'; const cds = require('@sap/cds'); -const path = require('path'); +const { path } = cds.utils; const { APP_DIR, ensureSidecarPlugin, cleanDbFiles, startSidecar, subscribeTenant, upgradeTenant, stopSidecar } = require('./setup'); -const { axios } = cds.test(APP_DIR); +const { axios, POST, GET } = cds.test(APP_DIR); axios.defaults.auth = { username: 'alice' }; -jest.setTimeout(60_000); - const isSqlite = cds.env.requires?.db?.kind === 'sqlite'; const describeIfSqlite = isSqlite ? describe : describe.skip; let sidecar; @@ -18,8 +16,10 @@ if (isSqlite) { ensureSidecarPlugin(); cleanDbFiles(); sidecar = await startSidecar(); - const status = await subscribeTenant('t1', sidecar.port); - expect(status).toBeLessThan(300); + const t1Status = await subscribeTenant('t1', sidecar.port); + const t2Status = await subscribeTenant('t2', sidecar.port); + expect(t1Status).toBeLessThan(300); + expect(t2Status).toBeLessThan(300); }); afterAll(async () => { @@ -108,4 +108,109 @@ describeIfSqlite('Change-Tracking MTX', () => { expect(csn.definitions['AdminService.BookStores'].elements.changes.target).toBe('AdminService.ChangeView'); }); }); + + describe('Change-log records via HTTP for Books', () => { + const createBook = async (bookData, username = 'alice') => { + const auth = { username }; + const { data: store } = await POST('/odata/v4/admin/BookStores', { name: 'Store-' + cds.utils.uuid().slice(0, 8) }, { auth }); + const bookID = cds.utils.uuid(); + await POST(`/odata/v4/admin/BookStores(ID=${store.ID},IsActiveEntity=false)/books`, { ID: bookID, ...bookData }, { auth }); + await POST(`/odata/v4/admin/BookStores(ID=${store.ID},IsActiveEntity=false)/AdminService.draftActivate`, {}, { auth }); + return bookID; + }; + + it('logs change records when fred creates a Book (tenant t2, isbn feature)', async () => { + const bookTitle = 'Book-' + cds.utils.uuid().slice(0, 3); + const bookID = await createBook({ title: bookTitle, descr: 'A test book' }, 'fred'); + + const { + data: { value: changes } + } = await GET(`/odata/v4/admin/Books(ID=${bookID},IsActiveEntity=true)/changes`, { + auth: { username: 'fred' } + }); + + expect(changes.length).toBeGreaterThan(0); + const titleChange = changes.find((c) => c.attribute === 'title'); + expect(titleChange).toMatchObject({ + entity: 'sap.capire.bookshop.Books', + attribute: 'title', + modification: 'create', + modificationLabel: 'Create', + valueChangedFrom: null, + valueChangedTo: bookTitle + }); + const descrChange = changes.find((c) => c.attribute === 'descr'); + expect(descrChange).toMatchObject({ + attribute: 'descr', + modification: 'create', + valueChangedTo: 'A test book' + }); + }); + + it('tracks the feature-toggled isbn column when fred creates a Book', async () => { + // `isbn` exists only because the `isbn` feature toggle is active for fred (tenant t2) + const isbnValue = '978-0345391803'; + const bookID = await createBook({ title: 'Book-with-isbn', isbn: isbnValue }, 'fred'); + + const { + data: { value: changes } + } = await GET(`/odata/v4/admin/Books(ID=${bookID},IsActiveEntity=true)/changes`, { + auth: { username: 'fred' } + }); + + const isbnChange = changes.find((c) => c.attribute === 'isbn'); + expect(isbnChange).toBeTruthy(); + expect(isbnChange).toMatchObject({ + entity: 'sap.capire.bookshop.Books', + attribute: 'isbn', + modification: 'create', + valueChangedFrom: null, + valueChangedTo: isbnValue + }); + }); + + it('tracks the feature-toggled stock annotation when fred creates a Book', async () => { + // `stock` exists in the base schema but is only annotated with @changelog when the `isbn` feature toggle is active + const bookID = await createBook({ title: 'Book-with-stock', stock: 42 }, 'fred'); + + const { + data: { value: changes } + } = await GET(`/odata/v4/admin/Books(ID=${bookID},IsActiveEntity=true)/changes`, { + auth: { username: 'fred' } + }); + + const stockChange = changes.find((c) => c.attribute === 'stock'); + expect(stockChange).toBeTruthy(); + expect(stockChange).toMatchObject({ + entity: 'sap.capire.bookshop.Books', + attribute: 'stock', + modification: 'create', + valueChangedFrom: null, + valueChangedTo: '42' + }); + }); + + // SKIPPED: this asserts the *intended* user-level feature-toggle gating, which + // is not yet implemented. Currently change-tracking triggers are deployed at + // tenant subscription time and fire regardless of the request user's features. + // To make this pass we'd need request-time gating via session variables that + // suppress writes for entities/elements whose @changelog only exists due to a + // feature the current user lacks. See discussion on lib/skipHandlers.js. + it.skip('does not track stock when isbn toggle is not set (dave on tenant t1, features:[])', async () => { + const bookID = await createBook({ title: 'Book-no-features', stock: 99 }, 'dave'); + + const { + data: { value: changes } + } = await GET(`/odata/v4/admin/Books(ID=${bookID},IsActiveEntity=true)/changes`, { + auth: { username: 'dave' } + }); + + // Sanity: title IS tracked (annotated in admin-service.cds, base model) + expect(changes.find((c) => c.attribute === 'title')).toBeTruthy(); + // stock @changelog only applies under the isbn feature toggle -> NOT tracked for dave + expect(changes.find((c) => c.attribute === 'stock')).toBeFalsy(); + // isbn column doesn't exist for dave -> no change record for it either + expect(changes.find((c) => c.attribute === 'isbn')).toBeFalsy(); + }); + }); }); From ba4b5378fc1663d454e6bd78740f64f211cbd60e Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 3 Jun 2026 14:22:17 +0200 Subject: [PATCH 15/19] test: fix setup.js for cds 8 --- tests/mtx/setup.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/mtx/setup.js b/tests/mtx/setup.js index e0efa91e..b3236381 100644 --- a/tests/mtx/setup.js +++ b/tests/mtx/setup.js @@ -1,7 +1,7 @@ const { spawn, execSync } = require('child_process'); -const path = require('path'); -const fs = require('fs'); const os = require('os'); +const cds = require('@sap/cds'); +const { path, readFileSync, writeFileSync, readdirSync, unlinkSync } = cds.utils; const APP_DIR = path.resolve(__dirname, '../bookshop-mtx'); const SIDECAR_DIR = path.join(APP_DIR, 'mtx', 'sidecar'); @@ -13,7 +13,7 @@ const PLUGIN_ROOT = path.resolve(__dirname, '../..'); */ function ensureSidecarPlugin() { const pkgPath = path.join(SIDECAR_DIR, 'package.json'); - const originalPkg = fs.readFileSync(pkgPath, 'utf-8'); + const originalPkg = readFileSync(pkgPath, 'utf-8'); const tmpDir = os.tmpdir(); const tgz = execSync(`npm pack --pack-destination ${tmpDir}`, { cwd: PLUGIN_ROOT, @@ -24,7 +24,7 @@ function ensureSidecarPlugin() { encoding: 'utf-8', stdio: 'ignore' }); - fs.writeFileSync(pkgPath, originalPkg); + writeFileSync(pkgPath, originalPkg); } /** @@ -33,13 +33,13 @@ function ensureSidecarPlugin() { function cleanDbFiles() { let files; try { - files = fs.readdirSync(APP_DIR); + files = readdirSync(APP_DIR); } catch { return; } for (const f of files.filter((f) => /^db.*\.sqlite(-shm|-wal)?$/.test(f))) { try { - fs.unlinkSync(path.join(APP_DIR, f)); + unlinkSync(path.join(APP_DIR, f)); } catch { /* ignore */ } @@ -47,12 +47,13 @@ function cleanDbFiles() { } /** - * Start the MTX sidecar via `cds watch` on a random port. - * Resolves with { proc, port } when the server is listening. + * Start the MTX sidecar via the locally-installed `@sap/cds` server (`bin/serve.js`) + * on a random port. Resolves with { proc, port } when the server is listening. */ function startSidecar() { return new Promise((resolve, reject) => { - const proc = spawn('npx', ['cds', 'watch', '--port', '0'], { + const serveJs = path.join(SIDECAR_DIR, 'node_modules', '@sap', 'cds', 'bin', 'serve.js'); + const proc = spawn(process.execPath, [serveJs, '--port', '0'], { cwd: SIDECAR_DIR, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, FORCE_COLOR: 'false', NODE_ENV: 'development' } From 56e69110b2f1f8b452bb4102778c7423c412a6d8 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Wed, 3 Jun 2026 16:01:45 +0200 Subject: [PATCH 16/19] test: skip MTX tests for hana --- lib/hana/register.js | 31 ++++++++++++++++++------------- tests/bookshop-mtx/.cdsrc.yaml | 19 +++++++++++++++++++ tests/bookshop-mtx/package.json | 3 --- tests/mtx/mtx.test.js | 4 ++-- tests/mtx/setup.js | 12 ++++++------ 5 files changed, 45 insertions(+), 24 deletions(-) create mode 100644 tests/bookshop-mtx/.cdsrc.yaml diff --git a/lib/hana/register.js b/lib/hana/register.js index 4559fef8..af6364c3 100644 --- a/lib/hana/register.js +++ b/lib/hana/register.js @@ -13,6 +13,11 @@ function hasMigrationTable() { function registerHDICompilerHook() { const _hdi_migration = cds.compiler.to.hdi.migration; cds.compiler.to.hdi.migration = function (csn, options, beforeImage) { + // Check if the plugin's base model is part of the CSN + if (!csn.definitions?.['sap.changelog.Changes']) { + return _hdi_migration(csn, options, beforeImage); + } + // Ensure CSN has ChangeView enhancements when compiled at runtime (extensibility upgrades) // At build time, enhanceModel already ran via cds.on('loaded') and the idempotency check skips it. enhanceModel(csn); @@ -47,19 +52,6 @@ function registerHDICompilerHook() { suffix: '.csv' } ); - - // Add index for trigger deduplication and parent lookup queries - data.push({ - name: 'sap.changelog.Changes_CT_INDEX', - sql: 'INDEX "sap.changelog.Changes_CT_INDEX" ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)', - suffix: '.hdbindex' - }); - - data.push({ - name: 'sap.changelog.Changes_CT_parent_INDEX', - sql: 'INDEX "sap.changelog.Changes_CT_parent_INDEX" ON sap_changelog_Changes (parent_ID)', - suffix: '.hdbindex' - }); } const config = cds.env.requires?.['change-tracking']; @@ -76,6 +68,19 @@ function registerHDICompilerHook() { csn.definitions['sap.changelog.Changes']['@cds.persistence.journal'] = true; } + // Add index for trigger deduplication and parent lookup queries + data.push({ + name: 'sap.changelog.Changes_CT_INDEX', + sql: 'INDEX "sap.changelog.Changes_CT_INDEX" ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)', + suffix: '.hdbindex' + }); + + data.push({ + name: 'sap.changelog.Changes_CT_parent_INDEX', + sql: 'INDEX "sap.changelog.Changes_CT_parent_INDEX" ON sap_changelog_Changes (parent_ID)', + suffix: '.hdbindex' + }); + const ret = _hdi_migration(csn, options, beforeImage); ret.definitions = ret.definitions.concat(triggers).concat(data); return ret; diff --git a/tests/bookshop-mtx/.cdsrc.yaml b/tests/bookshop-mtx/.cdsrc.yaml new file mode 100644 index 00000000..313e15d2 --- /dev/null +++ b/tests/bookshop-mtx/.cdsrc.yaml @@ -0,0 +1,19 @@ +requires: + db: + "[sqlite]": + kind: sql + "[pg]": + kind: postgres + credentials: + host: localhost + port: 5432 + user: postgres + password: postgres + database: postgres + pool: + min: 0 + max: 10 + acquireTimeoutMillis: 1000 + destroyTimeoutMillis: 1000 + "[hana]": + kind: hana diff --git a/tests/bookshop-mtx/package.json b/tests/bookshop-mtx/package.json index df6f1bd4..40e8782f 100644 --- a/tests/bookshop-mtx/package.json +++ b/tests/bookshop-mtx/package.json @@ -8,9 +8,6 @@ }, "cds": { "requires": { - "db": { - "kind": "sql" - }, "[production]": { "multitenancy": true, "extensibility": true diff --git a/tests/mtx/mtx.test.js b/tests/mtx/mtx.test.js index 4b609dd4..23581541 100644 --- a/tests/mtx/mtx.test.js +++ b/tests/mtx/mtx.test.js @@ -1,7 +1,7 @@ -process.env.CDS_ENV = 'with-mtx'; +process.env.CDS_ENV = process.env.CDS_ENV ? `${process.env.CDS_ENV},with-mtx` : 'with-mtx'; const cds = require('@sap/cds'); -const { path } = cds.utils; +const path = require('path'); const { APP_DIR, ensureSidecarPlugin, cleanDbFiles, startSidecar, subscribeTenant, upgradeTenant, stopSidecar } = require('./setup'); const { axios, POST, GET } = cds.test(APP_DIR); diff --git a/tests/mtx/setup.js b/tests/mtx/setup.js index b3236381..4f2b1fac 100644 --- a/tests/mtx/setup.js +++ b/tests/mtx/setup.js @@ -1,7 +1,7 @@ const { spawn, execSync } = require('child_process'); const os = require('os'); -const cds = require('@sap/cds'); -const { path, readFileSync, writeFileSync, readdirSync, unlinkSync } = cds.utils; +const path = require('path'); +const fs = require('fs'); const APP_DIR = path.resolve(__dirname, '../bookshop-mtx'); const SIDECAR_DIR = path.join(APP_DIR, 'mtx', 'sidecar'); @@ -13,7 +13,7 @@ const PLUGIN_ROOT = path.resolve(__dirname, '../..'); */ function ensureSidecarPlugin() { const pkgPath = path.join(SIDECAR_DIR, 'package.json'); - const originalPkg = readFileSync(pkgPath, 'utf-8'); + const originalPkg = fs.readFileSync(pkgPath, 'utf-8'); const tmpDir = os.tmpdir(); const tgz = execSync(`npm pack --pack-destination ${tmpDir}`, { cwd: PLUGIN_ROOT, @@ -24,7 +24,7 @@ function ensureSidecarPlugin() { encoding: 'utf-8', stdio: 'ignore' }); - writeFileSync(pkgPath, originalPkg); + fs.writeFileSync(pkgPath, originalPkg); } /** @@ -33,13 +33,13 @@ function ensureSidecarPlugin() { function cleanDbFiles() { let files; try { - files = readdirSync(APP_DIR); + files = fs.readdirSync(APP_DIR); } catch { return; } for (const f of files.filter((f) => /^db.*\.sqlite(-shm|-wal)?$/.test(f))) { try { - unlinkSync(path.join(APP_DIR, f)); + fs.unlinkSync(path.join(APP_DIR, f)); } catch { /* ignore */ } From efd0abc661378acad5317622ac5b78775c911376 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Fri, 5 Jun 2026 11:17:18 +0200 Subject: [PATCH 17/19] . --- lib/sqlite/register.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index f5d26d62..380b3b59 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -22,15 +22,17 @@ async function _deploySQLiteTriggersAndLabels(model) { const labels = getLabelTranslations(entities, model); const { i18nKeys } = cds.entities('sap.changelog'); - // Delete existing triggers + // Drop existing triggers await Promise.all(dropTriggers.map((t) => cds.db.run(t))); - // Deploy triggers and indexes sequentially to ensure referenced tables exist - for (const t of triggers) await cds.db.run(t); - await cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_ct_index ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)`); - await cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_parent_index ON sap_changelog_Changes (parent_ID)`); + // Create triggers and indexes + await Promise.all([ + ...triggers.map((t) => cds.db.run(t)), + cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_ct_index ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)`), + cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_parent_index ON sap_changelog_Changes (parent_ID)`) + ]); - // Deploy labels + // Refresh i18n labels await cds.delete(i18nKeys); await cds.insert(labels).into(i18nKeys); } From 888ff19e97703b2bd8e99e267b515c4599e3a751 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Fri, 5 Jun 2026 12:51:09 +0200 Subject: [PATCH 18/19] ci: ignore mtx tests for HANA --- .github/actions/integration-tests/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index fc54cd96..e2c0be93 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -116,7 +116,7 @@ runs: shell: bash # Run tests in hybrid mode - - run: cds bind --exec -- npx jest --runInBand --silent + - run: cds bind --exec -- npx jest --testPathIgnorePatterns='tests/mtx/mtx.test.js' --runInBand --silent shell: bash # Cleanup BTP services From 3f4cbf5e2fdfdcb5ad6ef15996c2b75a772e8a27 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Thu, 11 Jun 2026 15:23:15 +0200 Subject: [PATCH 19/19] . --- lib/sqlite/register.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index 380b3b59..b6f4f223 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -29,12 +29,10 @@ async function _deploySQLiteTriggersAndLabels(model) { await Promise.all([ ...triggers.map((t) => cds.db.run(t)), cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_ct_index ON sap_changelog_Changes (entity, entityKey, attribute, valueDataType, transactionID)`), - cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_parent_index ON sap_changelog_Changes (parent_ID)`) + cds.db.run(`CREATE INDEX IF NOT EXISTS sap_changelog_Changes_parent_index ON sap_changelog_Changes (parent_ID)`), + cds.delete(i18nKeys), + cds.insert(labels).into(i18nKeys) ]); - - // Refresh i18n labels - await cds.delete(i18nKeys); - await cds.insert(labels).into(i18nKeys); } /**