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 diff --git a/cds-plugin.js b/cds-plugin.js index 37b04c56..f6b6e9c8 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -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(); diff --git a/lib/csn-enhancements/index.js b/lib/csn-enhancements/index.js index 86a31990..139fce59 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,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) => { @@ -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' @@ -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; @@ -134,11 +133,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 +145,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 +155,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 +167,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 +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?.( @@ -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'); @@ -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; } diff --git a/lib/hana/register.js b/lib/hana/register.js index 4d668360..af6364c3 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,15 @@ 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); @@ -19,7 +29,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'; diff --git a/lib/postgres/register.js b/lib/postgres/register.js index 1e8cf0f9..32354f77 100644 --- a/lib/postgres/register.js +++ b/lib/postgres/register.js @@ -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; @@ -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 }; diff --git a/lib/sqlite/register.js b/lib/sqlite/register.js index 8c9e7b87..b6f4f223 100644 --- a/lib/sqlite/register.js +++ b/lib/sqlite/register.js @@ -4,24 +4,28 @@ const { getEntitiesForTriggerGeneration, collectEntities } = require('../utils/e const { getLabelTranslations } = require('../localization.js'); const { generateTriggersForEntities } = require('../utils/trigger-utils.js'); -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))); + // Drop existing triggers + await Promise.all(dropTriggers.map((t) => cds.db.run(t))); + // 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)`), @@ -31,4 +35,47 @@ async function deploySQLiteTriggers() { ]); } -module.exports = { deploySQLiteTriggers }; +/** + * 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; + + if (cds.env.requires?.multitenancy) return; + + const model = cds.context?.model ?? cds.model; + await _deploySQLiteTriggersAndLabels(model); +} + +/** + * 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. + */ +function registerSQLiteDeploymentHandler() { + cds.on('serving:cds.xt.DeploymentService', (ds) => { + 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 cached = await mps.getCsn({ tenant, toggles: ['*'], activated: true }); + const csn = structuredClone(cached); + + // Compile for Node.js runtime (needed for trigger generation) + const model = cds.compile.for.nodejs(csn); + await _deploySQLiteTriggersAndLabels(model); + }); + }); +} + +module.exports = { registerSQLiteDeploymentHandler, deploySQLiteTriggers }; 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/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/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/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 new file mode 100644 index 00000000..23581541 --- /dev/null +++ b/tests/mtx/mtx.test.js @@ -0,0 +1,216 @@ +process.env.CDS_ENV = process.env.CDS_ENV ? `${process.env.CDS_ENV},with-mtx` : 'with-mtx'; + +const cds = require('@sap/cds'); +const path = require('path'); +const { APP_DIR, ensureSidecarPlugin, cleanDbFiles, startSidecar, subscribeTenant, upgradeTenant, stopSidecar } = require('./setup'); + +const { axios, POST, GET } = cds.test(APP_DIR); +axios.defaults.auth = { username: 'alice' }; + +const isSqlite = cds.env.requires?.db?.kind === 'sqlite'; +const describeIfSqlite = isSqlite ? describe : describe.skip; +let sidecar; + +if (isSqlite) { + beforeAll(async () => { + ensureSidecarPlugin(); + cleanDbFiles(); + sidecar = await startSidecar(); + const t1Status = await subscribeTenant('t1', sidecar.port); + const t2Status = await subscribeTenant('t2', sidecar.port); + expect(t1Status).toBeLessThan(300); + expect(t2Status).toBeLessThan(300); + }); + + afterAll(async () => { + await stopSidecar(sidecar?.proc); + }); +} + +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'); + }); + }); + + 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(); + }); + }); +}); diff --git a/tests/mtx/setup.js b/tests/mtx/setup.js new file mode 100644 index 00000000..4f2b1fac --- /dev/null +++ b/tests/mtx/setup.js @@ -0,0 +1,133 @@ +const { spawn, execSync } = require('child_process'); +const os = require('os'); +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'); +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 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 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' } + }); + + 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 };