Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions cds-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,30 @@ 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');

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();
registerPostgresCompilerHook();
registerHDICompilerHook();
Expand Down
108 changes: 73 additions & 35 deletions lib/csn-enhancements/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -80,7 +74,11 @@ 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) => {
Expand All @@ -96,10 +94,11 @@ 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(
changeView.query.SELECT.columns.push(
{
ref: ['change', ...parents, 'entityKey'],
as: parents.join('_') + '_' + 'entityKey'
Expand All @@ -109,18 +108,18 @@ function enhanceModel(m) {
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);
changeView.elements[parents.join('_') + '_' + 'entityKey'] = structuredClone(changeView.elements.entityKey);
changeView.elements[parents.join('_') + '_' + 'entity'] = 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;

Expand All @@ -134,41 +133,41 @@ 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++) {
on.push('or', { xpr: replaceReferences(structuredClone(onTemplate), i) });
}
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']
},
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;
while (baseE) {
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;
Expand All @@ -179,14 +178,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?.(
Expand All @@ -199,7 +198,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');
Expand All @@ -212,15 +214,51 @@ 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 (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;
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;
}

Expand Down
12 changes: 11 additions & 1 deletion lib/hana/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -12,14 +13,23 @@ 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);

const { generateHANATriggers } = require('./triggers.js');
const { runtimeCSN, hierarchy, entities } = prepareCSNForTriggers(csn, true);

const triggers = generateTriggersForEntities(runtimeCSN, hierarchy, entities, generateHANATriggers);
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';
Expand Down
37 changes: 34 additions & 3 deletions lib/postgres/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,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;
Expand All @@ -44,4 +43,36 @@ async function deployPostgresLabels() {
await cds.upsert(labels).into(i18nKeys);
}

module.exports = { registerPostgresCompilerHook, deployPostgresLabels };
/**
* 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) => {
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 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);
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 };
Loading
Loading