@cap-js/change-tracking 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,13 +4,18 @@ All notable changes to this project will be documented in this file.
4
4
  This project adheres to [Semantic Versioning](http://semver.org/).
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/).
6
6
 
7
- ## Version 1.1.4 - TBD
7
+ ## Version 1.2.0 - 25.03.26
8
8
 
9
9
  ### Added
10
+ - Added `createdAt` and `createdBy` columns to Changes table for v2 migration
11
+ - Merge `createdAt` and `createdBy` columns from ChangeLog to Changes in multitenancy scenario
12
+
13
+ ## Version 1.1.4 - 03.12.25
10
14
 
11
15
  ### Fixed
16
+ - Server no longer crashes when after a DB migration the service name or attribute name change
17
+ - Fix crash when applications uses feature toogles or extensibility
12
18
 
13
- ### Changed
14
19
 
15
20
  ## Version 1.1.3 - 27.10.25
16
21
 
package/cds-plugin.js CHANGED
@@ -1,260 +1,284 @@
1
- const cds = require('@sap/cds')
2
- const DEBUG = cds.debug('changelog')
1
+ const cds = require('@sap/cds');
2
+ const DEBUG = cds.debug('changelog');
3
3
 
4
- const isRoot = 'change-tracking-isRootEntity'
5
- const hasParent = 'change-tracking-parentEntity'
4
+ const isRoot = 'change-tracking-isRootEntity';
5
+ const hasParent = 'change-tracking-parentEntity';
6
6
 
7
7
  const isChangeTracked = (entity) => {
8
- if (entity.query?.SET?.op === 'union') return false // REVISIT: should that be an error or warning?
9
- if (entity['@changelog']) return true
10
- if (Object.values(entity.elements).some(e => e['@changelog'])) return true
11
- }
8
+ if (entity.query?.SET?.op === 'union') return false; // REVISIT: should that be an error or warning?
9
+ if (entity['@changelog']) return true;
10
+ if (Object.values(entity.elements).some((e) => e['@changelog'])) return true;
11
+ return false;
12
+ };
12
13
 
13
14
  // Add the appropriate Side Effects attribute to the custom action
14
15
  const addSideEffects = (actions, flag, element) => {
15
- if (!flag && (element === undefined || element === null)) {
16
- return
17
- }
18
-
19
- for (const se of Object.values(actions)) {
20
- const target = flag ? 'TargetProperties' : 'TargetEntities'
21
- const sideEffectAttr = se[`@Common.SideEffects.${target}`]
22
- const property = flag ? 'changes' : { '=': `${element}.changes` }
23
- if (sideEffectAttr?.length >= 0) {
24
- sideEffectAttr.findIndex(
25
- (item) =>
26
- (item['='] ? item['='] : item) ===
27
- (property['='] ? property['='] : property)
28
- ) === -1 && sideEffectAttr.push(property)
29
- } else {
30
- se[`@Common.SideEffects.${target}`] = [property]
31
- }
32
- }
33
- }
34
-
35
- function setChangeTrackingIsRootEntity (entity, csn, val = true) {
36
- if (csn.definitions?.[entity.name]) {
37
- csn.definitions[entity.name][isRoot] = val
38
- }
16
+ if (!flag && (element === undefined || element === null)) {
17
+ return;
18
+ }
19
+
20
+ for (const se of Object.values(actions)) {
21
+ const target = flag ? 'TargetProperties' : 'TargetEntities';
22
+ const sideEffectAttr = se[`@Common.SideEffects.${target}`];
23
+ const property = flag ? 'changes' : { '=': `${element}.changes` };
24
+ if (sideEffectAttr?.length >= 0) {
25
+ sideEffectAttr.findIndex((item) => (item['='] ? item['='] : item) === (property['='] ? property['='] : property)) === -1 && sideEffectAttr.push(property);
26
+ } else {
27
+ se[`@Common.SideEffects.${target}`] = [property];
28
+ }
29
+ }
30
+ };
31
+
32
+ function setChangeTrackingIsRootEntity(entity, csn, val = true) {
33
+ if (csn.definitions?.[entity.name]) {
34
+ csn.definitions[entity.name][isRoot] = val;
35
+ }
39
36
  }
40
37
 
41
- function checkAndSetRootEntity (parentEntity, entity, csn) {
42
- if (entity[isRoot] === false) {
43
- return entity
44
- }
45
- if (parentEntity) {
46
- return compositionRoot(parentEntity, csn)
47
- } else {
48
- setChangeTrackingIsRootEntity(entity, csn)
49
- return { ...csn.definitions?.[entity.name], name: entity.name }
50
- }
38
+ function checkAndSetRootEntity(parentEntity, entity, csn) {
39
+ if (entity[isRoot] === false) {
40
+ return entity;
41
+ }
42
+ if (parentEntity) {
43
+ return compositionRoot(parentEntity, csn);
44
+ } else {
45
+ setChangeTrackingIsRootEntity(entity, csn);
46
+ return { ...csn.definitions?.[entity.name], name: entity.name };
47
+ }
51
48
  }
52
49
 
53
- function processEntities (m) {
54
- for (let name in m.definitions) {
55
- compositionRoot({ ...m.definitions[name], name }, m)
56
- }
50
+ function processEntities(m) {
51
+ for (let name in m.definitions) {
52
+ compositionRoot({ ...m.definitions[name], name }, m);
53
+ }
57
54
  }
58
55
 
59
- function compositionRoot (entity, csn) {
60
- if (!entity || entity.kind !== 'entity') {
61
- return
62
- }
63
- const parentEntity = compositionParent(entity, csn)
64
- return checkAndSetRootEntity(parentEntity, entity, csn)
56
+ function compositionRoot(entity, csn) {
57
+ if (!entity || entity.kind !== 'entity') {
58
+ return;
59
+ }
60
+ const parentEntity = compositionParent(entity, csn);
61
+ return checkAndSetRootEntity(parentEntity, entity, csn);
65
62
  }
66
63
 
67
- function compositionParent (entity, csn) {
68
- if (!entity || entity.kind !== 'entity') {
69
- return
70
- }
71
- const parentAssociation = compositionParentAssociation(entity, csn)
72
- return parentAssociation ?? null
64
+ function compositionParent(entity, csn) {
65
+ if (!entity || entity.kind !== 'entity') {
66
+ return;
67
+ }
68
+ const parentAssociation = compositionParentAssociation(entity, csn);
69
+ return parentAssociation ?? null;
73
70
  }
74
71
 
75
- function compositionParentAssociation (entity, csn) {
76
- if (!entity || entity.kind !== 'entity') {
77
- return
78
- }
79
- const elements = entity.elements ?? {}
80
-
81
- // Add the change-tracking-isRootEntity attribute of the child entity
82
- processCompositionElements(entity, csn, elements)
83
-
84
- const hasChildFlag = entity[isRoot] !== false
85
- const hasParentEntity = entity[hasParent]
86
-
87
- if (hasChildFlag || !hasParentEntity) {
88
- // Find parent association of the entity
89
- const parentAssociation = findParentAssociation(entity, csn, elements)
90
- if (parentAssociation) {
91
- const parentAssociationTarget = elements[parentAssociation]?.target
92
- if (hasChildFlag) setChangeTrackingIsRootEntity(entity, csn, false)
93
- return {
94
- ...csn.definitions?.[parentAssociationTarget],
95
- name: parentAssociationTarget
96
- }
97
- } else return
98
- }
99
- return { ...csn.definitions?.[entity.name], name: entity.name }
72
+ function compositionParentAssociation(entity, csn) {
73
+ if (!entity || entity.kind !== 'entity') {
74
+ return;
75
+ }
76
+ const elements = entity.elements ?? {};
77
+
78
+ // Add the change-tracking-isRootEntity attribute of the child entity
79
+ processCompositionElements(entity, csn, elements);
80
+
81
+ const hasChildFlag = entity[isRoot] !== false;
82
+ const hasParentEntity = entity[hasParent];
83
+
84
+ if (hasChildFlag || !hasParentEntity) {
85
+ // Find parent association of the entity
86
+ const parentAssociation = findParentAssociation(entity, csn, elements);
87
+ if (parentAssociation) {
88
+ const parentAssociationTarget = elements[parentAssociation]?.target;
89
+ if (hasChildFlag) setChangeTrackingIsRootEntity(entity, csn, false);
90
+ return {
91
+ ...csn.definitions?.[parentAssociationTarget],
92
+ name: parentAssociationTarget
93
+ };
94
+ } else return;
95
+ }
96
+ return { ...csn.definitions?.[entity.name], name: entity.name };
100
97
  }
101
98
 
102
- function processCompositionElements (entity, csn, elements) {
103
- for (const name in elements) {
104
- const element = elements[name]
105
- const target = element?.target
106
- const definition = csn.definitions?.[target]
107
- if (
108
- element.type !== 'cds.Composition' ||
109
- target === entity.name ||
110
- !definition ||
111
- definition[isRoot] === false
112
- ) {
113
- continue
114
- }
115
- setChangeTrackingIsRootEntity({ ...definition, name: target }, csn, false)
116
- }
99
+ function processCompositionElements(entity, csn, elements) {
100
+ for (const name in elements) {
101
+ const element = elements[name];
102
+ const target = element?.target;
103
+ const definition = csn.definitions?.[target];
104
+ if (element.type !== 'cds.Composition' || target === entity.name || !definition || definition[isRoot] === false) {
105
+ continue;
106
+ }
107
+ setChangeTrackingIsRootEntity({ ...definition, name: target }, csn, false);
108
+ }
117
109
  }
118
110
 
119
- function findParentAssociation (entity, csn, elements) {
120
- return Object.keys(elements).find((name) => {
121
- const element = elements[name]
122
- const target = element?.target
123
- if (element.type === 'cds.Association' && target !== entity.name) {
124
- const parentDefinition = csn.definitions?.[target] ?? {}
125
- const parentElements = parentDefinition?.elements ?? {}
126
- return !!Object.keys(parentElements).find((parentEntityName) => {
127
- const parentElement = parentElements?.[parentEntityName] ?? {}
128
- if (parentElement.type === 'cds.Composition') {
129
- const isCompositionEntity = parentElement.target === entity.name
130
- // add parent information in the current entity
131
- if (isCompositionEntity) {
132
- csn.definitions[entity.name][hasParent] = {
133
- associationName: name,
134
- entityName: target
135
- }
136
- }
137
- return isCompositionEntity
138
- }
139
- })
140
- }
141
- })
111
+ function findParentAssociation(entity, csn, elements) {
112
+ return Object.keys(elements).find((name) => {
113
+ const element = elements[name];
114
+ const target = element?.target;
115
+ if (element.type === 'cds.Association' && target !== entity.name) {
116
+ const parentDefinition = csn.definitions?.[target] ?? {};
117
+ const parentElements = parentDefinition?.elements ?? {};
118
+ return !!Object.keys(parentElements).find((parentEntityName) => {
119
+ const parentElement = parentElements?.[parentEntityName] ?? {};
120
+ if (parentElement.type === 'cds.Composition') {
121
+ const isCompositionEntity = parentElement.target === entity.name;
122
+ // add parent information in the current entity
123
+ if (isCompositionEntity) {
124
+ csn.definitions[entity.name][hasParent] = {
125
+ associationName: name,
126
+ entityName: target
127
+ };
128
+ }
129
+ return isCompositionEntity;
130
+ }
131
+ });
132
+ }
133
+ });
142
134
  }
143
135
 
144
-
145
-
146
136
  /**
147
137
  * Returns an expression for the key of the given entity, which we can use as the right-hand-side of an ON condition.
148
138
  */
149
- function entityKey4 (entity) {
150
- const xpr = []
151
- for (let k in entity.elements) {
152
- const e = entity.elements[k]; if (!e.key) continue
153
- if (xpr.length) xpr.push('||')
154
- if (e.type === 'cds.Association') xpr.push({ ref: [k, e.keys?.[0]?.ref?.[0]] })
155
- else xpr.push({ ref:[k] })
156
- }
157
- return xpr
139
+ function entityKey4(entity) {
140
+ const xpr = [];
141
+ for (let k in entity.elements) {
142
+ const e = entity.elements[k];
143
+ if (!e.key) continue;
144
+ if (xpr.length) xpr.push('||');
145
+ if (e.type === 'cds.Association') xpr.push({ ref: [k, e.keys?.[0]?.ref?.[0]] });
146
+ else xpr.push({ ref: [k] });
147
+ }
148
+ return xpr;
158
149
  }
159
150
 
160
-
161
151
  // Unfold @changelog annotations in loaded model
162
- function enhanceModel (m) {
163
-
164
- const _enhanced = 'sap.changelog.enhanced'
165
- if (m.meta?.[_enhanced]) return // already enhanced
166
-
167
- // Get definitions from Dummy entity in our models
168
- const { 'sap.changelog.aspect': aspect } = m.definitions; if (!aspect) return // some other model
169
- const { '@UI.Facets': [facet], elements: { changes } } = aspect
170
- if (changes.on.length > 2) changes.on.pop() // remove ID -> filled in below
171
-
172
- processEntities(m) // REVISIT: why is that required ?!?
173
-
174
- for (let name in m.definitions) {
175
-
176
- const entity = m.definitions[name]
177
- if (entity.kind === 'entity' && !entity['@cds.autoexposed'] && isChangeTracked(entity)) {
178
-
179
- if (!entity['@changelog.disable_assoc']) {
180
-
181
- // Add association to ChangeView...
182
- const keys = entityKey4(entity); if (!keys.length) continue // If no key attribute is defined for the entity, the logic to add association to ChangeView should be skipped.
183
- const assoc = new cds.builtin.classes.Association({ ...changes, on: [ ...changes.on, ...keys ] })
184
-
185
- // --------------------------------------------------------------------
186
- // PARKED: Add auto-exposed projection on ChangeView to service if applicable
187
- // const namespace = name.match(/^(.*)\.[^.]+$/)[1]
188
- // const service = m.definitions[namespace]
189
- // if (service) {
190
- // const projection = {from:{ref:[assoc.target]}}
191
- // m.definitions[assoc.target = namespace + '.' + Changes] = {
192
- // '@cds.autoexposed':true, kind:'entity', projection
193
- // }
194
- // DEBUG?.(`\n
195
- // extend service ${namespace} with {
196
- // entity ${Changes} as projection on ${projection.from.ref[0]};
197
- // }
198
- // `.replace(/ {10}/g,''))
199
- // }
200
- // --------------------------------------------------------------------
201
-
202
- DEBUG?.(`\n
152
+ function enhanceModel(m) {
153
+ if (m.meta?.flavor !== 'inferred') {
154
+ // In MTX scenarios with extensibility the runtime model for deployed apps is not
155
+ // inferred but xtended and the logic requires inferred.
156
+ DEBUG?.(`Skipping model enhancement because model flavour is '${m.meta?.flavor}' and not 'inferred'`);
157
+ return;
158
+ }
159
+ const _enhanced = 'sap.changelog.enhanced';
160
+ if (m.meta?.[_enhanced]) return; // already enhanced
161
+
162
+ // Get definitions from Dummy entity in our models
163
+ const { 'sap.changelog.aspect': aspect } = m.definitions;
164
+ if (!aspect) return; // some other model
165
+ const {
166
+ '@UI.Facets': [facet],
167
+ elements: { changes }
168
+ } = aspect;
169
+ if (changes.on.length > 2) changes.on.pop(); // remove ID -> filled in below
170
+
171
+ processEntities(m); // REVISIT: why is that required ?!?
172
+
173
+ for (let name in m.definitions) {
174
+ const entity = m.definitions[name];
175
+ if (entity.kind === 'entity' && !entity['@cds.autoexposed'] && isChangeTracked(entity)) {
176
+ if (!entity['@changelog.disable_assoc']) {
177
+ // Add association to ChangeView...
178
+ const keys = entityKey4(entity);
179
+ if (!keys.length) continue; // If no key attribute is defined for the entity, the logic to add association to ChangeView should be skipped.
180
+ const assoc = new cds.builtin.classes.Association({ ...changes, on: [...changes.on, ...keys] });
181
+
182
+ // --------------------------------------------------------------------
183
+ // PARKED: Add auto-exposed projection on ChangeView to service if applicable
184
+ // const namespace = name.match(/^(.*)\.[^.]+$/)[1]
185
+ // const service = m.definitions[namespace]
186
+ // if (service) {
187
+ // const projection = {from:{ref:[assoc.target]}}
188
+ // m.definitions[assoc.target = namespace + '.' + Changes] = {
189
+ // '@cds.autoexposed':true, kind:'entity', projection
190
+ // }
191
+ // DEBUG?.(`\n
192
+ // extend service ${namespace} with {
193
+ // entity ${Changes} as projection on ${projection.from.ref[0]};
194
+ // }
195
+ // `.replace(/ {10}/g,''))
196
+ // }
197
+ // --------------------------------------------------------------------
198
+
199
+ DEBUG?.(
200
+ `\n
203
201
  extend ${name} with {
204
- changes : Association to many ${assoc.target} on ${ assoc.on.map(x => x.ref?.join('.') || x).join(' ') };
202
+ changes : Association to many ${assoc.target} on ${assoc.on.map((x) => x.ref?.join('.') || x).join(' ')};
205
203
  }
206
- `.replace(/ {8}/g,''))
207
- const query = entity.projection || entity.query?.SELECT
208
- if (query) (query.columns ??= ['*']).push({ as: 'changes', cast: assoc })
209
- else if (entity.elements) entity.elements.changes = assoc
210
-
211
- // Add UI.Facet for Change History List
212
- if (!entity['@changelog.disable_facet'])
213
- entity['@UI.Facets']?.push(facet)
214
- }
215
-
216
- if (entity.actions) {
217
- const hasParentInfo = entity[hasParent]
218
- const entityName = hasParentInfo?.entityName
219
- const parentEntity = entityName ? m.definitions[entityName] : null
220
- const isParentRootAndHasFacets = parentEntity?.[isRoot] && parentEntity?.['@UI.Facets']
221
- if (entity[isRoot] && entity['@UI.Facets']) {
222
- // Add side effects for root entity
223
- addSideEffects(entity.actions, true)
224
- } else if (isParentRootAndHasFacets) {
225
- // Add side effects for child entity
226
- addSideEffects(entity.actions, false, hasParentInfo?.associationName)
227
- }
228
- }
229
- }
230
- }
231
- (m.meta ??= {})[_enhanced] = true
204
+ `.replace(/ {8}/g, '')
205
+ );
206
+ const query = entity.projection || entity.query?.SELECT;
207
+ if (query) (query.columns ??= ['*']).push({ as: 'changes', cast: assoc });
208
+ else if (entity.elements) entity.elements.changes = assoc;
209
+
210
+ // Add UI.Facet for Change History List
211
+ if (!entity['@changelog.disable_facet']) entity['@UI.Facets']?.push(facet);
212
+ }
213
+
214
+ if (entity.actions) {
215
+ const hasParentInfo = entity[hasParent];
216
+ const entityName = hasParentInfo?.entityName;
217
+ const parentEntity = entityName ? m.definitions[entityName] : null;
218
+ const isParentRootAndHasFacets = parentEntity?.[isRoot] && parentEntity?.['@UI.Facets'];
219
+ if (entity[isRoot] && entity['@UI.Facets']) {
220
+ // Add side effects for root entity
221
+ addSideEffects(entity.actions, true);
222
+ } else if (isParentRootAndHasFacets) {
223
+ // Add side effects for child entity
224
+ addSideEffects(entity.actions, false, hasParentInfo?.associationName);
225
+ }
226
+ }
227
+ }
228
+ }
229
+ (m.meta ??= {})[_enhanced] = true;
232
230
  }
233
231
 
234
232
  // Add generic change tracking handlers
235
233
  function addGenericHandlers() {
236
- const { track_changes, _afterReadChangeView } = require("./lib/change-log")
237
- for (const srv of cds.services) {
238
- if (srv instanceof cds.ApplicationService) {
239
- let any = false
240
- for (const entity of Object.values(srv.entities)) {
241
- if (isChangeTracked(entity)) {
242
- cds.db.before("CREATE", entity, track_changes)
243
- cds.db.before("UPDATE", entity, track_changes)
244
- cds.db.before("DELETE", entity, track_changes)
245
- any = true
246
- }
247
- }
248
- if (any && srv.entities.ChangeView) {
249
- srv.after("READ", srv.entities.ChangeView, _afterReadChangeView)
250
- }
251
- }
252
- }
234
+ const { track_changes, _afterReadChangeView } = require('./lib/change-log');
235
+ for (const srv of cds.services) {
236
+ if (srv instanceof cds.ApplicationService) {
237
+ let any = false;
238
+ for (const entity of Object.values(srv.entities)) {
239
+ if (isChangeTracked(entity)) {
240
+ cds.db.before('CREATE', entity, track_changes);
241
+ cds.db.before('UPDATE', entity, track_changes);
242
+ cds.db.before('DELETE', entity, track_changes);
243
+ any = true;
244
+ }
245
+ }
246
+ if (any && srv.entities.ChangeView) {
247
+ srv.after('READ', srv.entities.ChangeView, _afterReadChangeView);
248
+ }
249
+ }
250
+
251
+ // Add migration handlers for MTX scenario
252
+ const profiles = cds.env.profiles ?? [cds.env.profile]
253
+ const isHana = cds.env.requires?.db?.kind === 'hana';
254
+ if (profiles.includes("mtx-sidecar") && isHana) {
255
+ const { 'cds.xt.DeploymentService': ds } = cds.services
256
+ ds.after('deploy', async (_, req) => {
257
+ const { tenant } = req.data
258
+
259
+ const MERGE_SQL = `MERGE INTO SAP_CHANGELOG_CHANGES AS c USING SAP_CHANGELOG_CHANGELOG AS cl ON c.changeLog_ID = cl.ID
260
+ + WHEN MATCHED THEN UPDATE SET c.createdAt = cl.createdAt, c.createdBy = cl.createdBy`;
261
+
262
+ await cds.tx({ tenant }, async (tx) => {
263
+ await tx.run(MERGE_SQL);
264
+ })
265
+ DEBUG(`Migration: copied createdAt/createdBy from ChangeLog to Changes for tenant: ${tenant}`)
266
+ })
267
+ }
268
+ }
253
269
  }
254
270
 
255
-
256
271
  // Register plugin hooks
257
- cds.on('compile.for.runtime', csn => { DEBUG?.('on','compile.for.runtime'); enhanceModel(csn) })
258
- cds.on('compile.to.edmx', csn => { DEBUG?.('on','compile.to.edmx'); enhanceModel(csn) })
259
- cds.on('compile.to.dbx', csn => { DEBUG?.('on','compile.to.dbx'); enhanceModel(csn) })
260
- cds.on('served', addGenericHandlers)
272
+ cds.on('compile.for.runtime', (csn) => {
273
+ DEBUG?.('on', 'compile.for.runtime');
274
+ enhanceModel(csn);
275
+ });
276
+ cds.on('compile.to.edmx', (csn) => {
277
+ DEBUG?.('on', 'compile.to.edmx');
278
+ enhanceModel(csn);
279
+ });
280
+ cds.on('compile.to.dbx', (csn) => {
281
+ DEBUG?.('on', 'compile.to.dbx');
282
+ enhanceModel(csn);
283
+ });
284
+ cds.on('served', addGenericHandlers);
package/index.cds CHANGED
@@ -49,6 +49,7 @@ entity ChangeLog : managed, cuid {
49
49
  * Attribute-level Changes with simple capturing of one-level
50
50
  * composition trees in parent... elements.
51
51
  */
52
+ @cds.persistence.journal
52
53
  entity Changes {
53
54
 
54
55
  key ID : UUID @UI.Hidden;
@@ -76,6 +77,8 @@ entity Changes {
76
77
 
77
78
  valueDataType : String(5000) @title: '{i18n>Changes.valueDataType}';
78
79
  changeLog : Association to ChangeLog @title: '{i18n>ChangeLog.ID}' @UI.Hidden;
80
+ createdAt : managed:createdAt @title : '{i18n>ChangeLog.createdAt}';
81
+ createdBy : managed:createdBy @title : '{i18n>ChangeLog.createdBy}';
79
82
  }
80
83
 
81
84
  annotate ChangeView with @(UI: {