@cap-js/ord 1.3.9 → 1.3.11
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/lib/build.js +3 -1
- package/lib/constants.js +3 -0
- package/lib/interopCsn.js +127 -0
- package/lib/metaData.js +4 -2
- package/lib/ord-service.js +2 -1
- package/lib/ord.js +7 -5
- package/lib/templates.js +78 -10
- package/package.json +3 -3
package/lib/build.js
CHANGED
|
@@ -10,7 +10,9 @@ module.exports = class OrdBuildPlugin extends cds_dk.build.Plugin {
|
|
|
10
10
|
static taskDefaults = { src: cds.env.folders.srv };
|
|
11
11
|
|
|
12
12
|
init() {
|
|
13
|
-
this.task.dest
|
|
13
|
+
if (this.task.dest === undefined) {
|
|
14
|
+
this.task.dest = path.join(cds.root, BUILD_DEFAULT_PATH);
|
|
15
|
+
}
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
async _writeResourcesFiles(resObj, model, promises) {
|
package/lib/constants.js
CHANGED
|
@@ -44,6 +44,8 @@ const CONTENT_MERGE_KEY = "ordId";
|
|
|
44
44
|
|
|
45
45
|
const DATA_PRODUCT_ANNOTATION = "@DataIntegration.dataProduct.type";
|
|
46
46
|
|
|
47
|
+
const DATA_PRODUCT_SIMPLE_ANNOTATION = "@data.product";
|
|
48
|
+
|
|
47
49
|
const DATA_PRODUCT_TYPE = Object.freeze({
|
|
48
50
|
primary: "primary",
|
|
49
51
|
});
|
|
@@ -106,6 +108,7 @@ module.exports = {
|
|
|
106
108
|
COMPILER_TYPES,
|
|
107
109
|
CONTENT_MERGE_KEY,
|
|
108
110
|
DATA_PRODUCT_ANNOTATION,
|
|
111
|
+
DATA_PRODUCT_SIMPLE_ANNOTATION,
|
|
109
112
|
DATA_PRODUCT_TYPE,
|
|
110
113
|
DESCRIPTION_PREFIX,
|
|
111
114
|
ENTITY_RELATIONSHIP_ANNOTATION,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const localize = require("@sap/cds/lib/i18n/localize");
|
|
2
|
+
|
|
3
|
+
// turn effective CSN into interop CSN
|
|
4
|
+
function interopCSN(csn) {
|
|
5
|
+
if (typeof csn != "object" || csn === null) return csn; // handle non-object inputs early
|
|
6
|
+
add_i18n_texts(csn);
|
|
7
|
+
map_annotations(csn);
|
|
8
|
+
add_meta_info(csn);
|
|
9
|
+
return csn;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
//
|
|
13
|
+
// add i18n texts
|
|
14
|
+
//
|
|
15
|
+
// First fetch all texts defined in the app,
|
|
16
|
+
// then remove those that are not referenced in the csn.
|
|
17
|
+
//
|
|
18
|
+
function add_i18n_texts(csn) {
|
|
19
|
+
// get all texts of the app
|
|
20
|
+
const i18n = [...(localize.bundles4(csn) || [])]
|
|
21
|
+
.filter(([locale]) => !!locale)
|
|
22
|
+
.reduce((all, [locale, value]) => ({ ...all, [locale]: value }), {});
|
|
23
|
+
|
|
24
|
+
// get all i18n keys referenced in the csn
|
|
25
|
+
let i18n_keys = new Set();
|
|
26
|
+
for (let n1 in csn.definitions) {
|
|
27
|
+
let def = csn.definitions[n1];
|
|
28
|
+
collect_i18n(def, i18n_keys);
|
|
29
|
+
if (def.kind === "entity")
|
|
30
|
+
for (let n2 in def.elements) {
|
|
31
|
+
let el = def.elements[n2];
|
|
32
|
+
collect_i18n(el, i18n_keys);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// delete from i18n array all entries not occuring in i18n_keys and add result to csn
|
|
37
|
+
for (let locale in i18n) {
|
|
38
|
+
let texts = i18n[locale];
|
|
39
|
+
for (let k in texts) {
|
|
40
|
+
if (!i18n_keys.has(k)) delete texts[k];
|
|
41
|
+
}
|
|
42
|
+
if (Object.keys(texts).length === 0) delete i18n[locale];
|
|
43
|
+
}
|
|
44
|
+
csn["i18n"] = i18n;
|
|
45
|
+
|
|
46
|
+
// helper function: find all i18n keys referenced in annotations
|
|
47
|
+
// for an entity or element
|
|
48
|
+
// to be improved: currently only considers annotations with scalar, string-like value;
|
|
49
|
+
// doesn't drill into structured or array-like annotations
|
|
50
|
+
function collect_i18n(o, keys) {
|
|
51
|
+
for (let n in o) {
|
|
52
|
+
if (n.startsWith("@")) {
|
|
53
|
+
let annoVal = o[n];
|
|
54
|
+
if (typeof annoVal === "string" && annoVal.startsWith("{i18n>")) {
|
|
55
|
+
let [, x] = annoVal.match(/^\{i18n>(.*)\}$/) || [];
|
|
56
|
+
if (x) keys.add(x);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
//
|
|
64
|
+
// annotation mapping/replacement
|
|
65
|
+
//
|
|
66
|
+
function map_annotations(csn) {
|
|
67
|
+
for (let n1 in csn.definitions) {
|
|
68
|
+
let def = csn.definitions[n1];
|
|
69
|
+
replaceAnnos(def);
|
|
70
|
+
if (def.kind === "entity")
|
|
71
|
+
for (let n2 in def.elements) {
|
|
72
|
+
let el = def.elements[n2];
|
|
73
|
+
replaceAnnos(el);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// helper function: do the actual anno replacement
|
|
78
|
+
function replaceAnnos(o) {
|
|
79
|
+
// rhs null => anno is removed
|
|
80
|
+
const annoReplacement = {
|
|
81
|
+
"@Common.Label": "@EndUserText.label",
|
|
82
|
+
"@title": "@EndUserText.label",
|
|
83
|
+
"@label": "@EndUserText.label",
|
|
84
|
+
"@description": "@EndUserText.quickInfo",
|
|
85
|
+
"@cds.autoexpose": null,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
for (const [oldA, newA] of Object.entries(annoReplacement)) {
|
|
89
|
+
if (o[oldA]) {
|
|
90
|
+
if (newA) o[newA] ??= o[oldA];
|
|
91
|
+
delete o[oldA];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//
|
|
98
|
+
// add meta information
|
|
99
|
+
//
|
|
100
|
+
function add_meta_info(csn) {
|
|
101
|
+
if (typeof csn != "object") return csn; // needed to make tests pass
|
|
102
|
+
csn["csnInteropEffective"] = "1.0";
|
|
103
|
+
csn.meta ??= {};
|
|
104
|
+
csn.meta.flavor = "effective";
|
|
105
|
+
|
|
106
|
+
let services = Object.entries(csn.definitions).filter(([, def]) => def.kind === "service");
|
|
107
|
+
if (services.length === 1) {
|
|
108
|
+
// assumption: "short" service name contains no dots
|
|
109
|
+
let segments = services[0][0].split(".");
|
|
110
|
+
let v = "1";
|
|
111
|
+
let srv = segments.pop();
|
|
112
|
+
let m = srv.match(/^v(\d+)$/);
|
|
113
|
+
if (m) {
|
|
114
|
+
srv = segments.pop();
|
|
115
|
+
v = m[1];
|
|
116
|
+
}
|
|
117
|
+
csn.meta.document = { version: `${v}.0.0` };
|
|
118
|
+
csn.meta.__name = srv;
|
|
119
|
+
if (segments.length > 0) csn.meta.__namespace = segments.join(".");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return csn;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
interopCSN,
|
|
127
|
+
};
|
package/lib/metaData.js
CHANGED
|
@@ -3,6 +3,7 @@ const { compile: openapi } = require("@cap-js/openapi");
|
|
|
3
3
|
const { compile: asyncapi } = require("@cap-js/asyncapi");
|
|
4
4
|
const { COMPILER_TYPES } = require("./constants");
|
|
5
5
|
const { Logger } = require("./logger");
|
|
6
|
+
const { interopCSN } = require("./interopCsn.js");
|
|
6
7
|
const cdsc = require("@sap/cds-compiler/lib/main");
|
|
7
8
|
|
|
8
9
|
module.exports = async (url, model = null) => {
|
|
@@ -36,8 +37,9 @@ module.exports = async (url, model = null) => {
|
|
|
36
37
|
break;
|
|
37
38
|
case COMPILER_TYPES.csn:
|
|
38
39
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
40
|
+
const opt_eff = { beta: { effectiveCsn: true }, effectiveServiceName: serviceName };
|
|
41
|
+
let effCsn = cdsc.for.effective(csn, opt_eff);
|
|
42
|
+
responseFile = interopCSN(effCsn);
|
|
41
43
|
} catch (error) {
|
|
42
44
|
Logger.error("Csn error:", error.message);
|
|
43
45
|
throw error;
|
package/lib/ord-service.js
CHANGED
|
@@ -3,6 +3,7 @@ const { ord, getMetadata, defaults, authentication, Logger } = require("./index.
|
|
|
3
3
|
|
|
4
4
|
class OpenResourceDiscoveryService extends cds.ApplicationService {
|
|
5
5
|
init() {
|
|
6
|
+
const logger = Logger.Logger;
|
|
6
7
|
cds.app.get(`${this.path}`, cds.middlewares.before, (_, res) => {
|
|
7
8
|
return res.status(200).send(defaults.baseTemplate);
|
|
8
9
|
});
|
|
@@ -22,7 +23,7 @@ class OpenResourceDiscoveryService extends cds.ApplicationService {
|
|
|
22
23
|
const { contentType, response } = await getMetadata(req.url);
|
|
23
24
|
return res.status(200).contentType(contentType).send(response);
|
|
24
25
|
} catch (error) {
|
|
25
|
-
|
|
26
|
+
logger.error(error, "Error while processing the resource definition document");
|
|
26
27
|
return res.status(500).send(error.message);
|
|
27
28
|
}
|
|
28
29
|
});
|
package/lib/ord.js
CHANGED
|
@@ -101,7 +101,7 @@ function _triageCsnDefinitions(csn) {
|
|
|
101
101
|
const result = _handleEntity(definitionKey, definitionObj);
|
|
102
102
|
if (result) {
|
|
103
103
|
if (result.apiEndpoint) apiEndpoints.add(result.apiEndpoint);
|
|
104
|
-
if (result.entityTypeTarget) entityTypeTargets.push(result.entityTypeTarget);
|
|
104
|
+
if (result.entityTypeTarget) entityTypeTargets.push(...result.entityTypeTarget);
|
|
105
105
|
}
|
|
106
106
|
break;
|
|
107
107
|
}
|
|
@@ -177,10 +177,12 @@ function _isValidService(key, definition) {
|
|
|
177
177
|
function _handleEntity(key, keyDefinition) {
|
|
178
178
|
if (!key.includes(".texts") && _shouldNotSkipIfServiceProtocolIsNone(keyDefinition)) {
|
|
179
179
|
const apiEndpoint = key;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
let entityTypeTarget = null;
|
|
181
|
+
if (keyDefinition[ORD_ODM_ENTITY_NAME_ANNOTATION] || keyDefinition[ENTITY_RELATIONSHIP_ANNOTATION]) {
|
|
182
|
+
const mapping = createEntityTypeMappingsItemTemplate(keyDefinition);
|
|
183
|
+
if (Array.isArray(mapping)) entityTypeTarget = mapping;
|
|
184
|
+
else if (mapping) entityTypeTarget = [mapping];
|
|
185
|
+
}
|
|
184
186
|
return { apiEndpoint, entityTypeTarget };
|
|
185
187
|
}
|
|
186
188
|
return null;
|
package/lib/templates.js
CHANGED
|
@@ -5,6 +5,7 @@ const _ = require("lodash");
|
|
|
5
5
|
const {
|
|
6
6
|
AUTHENTICATION_TYPE,
|
|
7
7
|
DATA_PRODUCT_ANNOTATION,
|
|
8
|
+
DATA_PRODUCT_SIMPLE_ANNOTATION,
|
|
8
9
|
DATA_PRODUCT_TYPE,
|
|
9
10
|
DESCRIPTION_PREFIX,
|
|
10
11
|
ENTITY_RELATIONSHIP_ANNOTATION,
|
|
@@ -79,24 +80,28 @@ const _generatePaths = (srv, srvDefinition) => {
|
|
|
79
80
|
* @returns {Object} An entry of the entityTypeMappings array.
|
|
80
81
|
*/
|
|
81
82
|
const createEntityTypeMappingsItemTemplate = (entity) => {
|
|
83
|
+
const results = [];
|
|
82
84
|
if (entity[ORD_ODM_ENTITY_NAME_ANNOTATION]) {
|
|
83
|
-
|
|
85
|
+
results.push({
|
|
84
86
|
ordId: `sap.odm:entityType:${entity[ORD_ODM_ENTITY_NAME_ANNOTATION]}:v1`,
|
|
85
87
|
entityName: entity[ORD_ODM_ENTITY_NAME_ANNOTATION],
|
|
86
88
|
isODMMapping: true,
|
|
87
89
|
...entity,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (entity[ENTITY_RELATIONSHIP_ANNOTATION]) {
|
|
90
93
|
const ordIdParts = entity[ENTITY_RELATIONSHIP_ANNOTATION].split(":");
|
|
91
94
|
const namespace = ordIdParts[0];
|
|
92
95
|
const entityName = ordIdParts[1];
|
|
93
96
|
const version = ordIdParts[2] || "v1";
|
|
94
|
-
|
|
97
|
+
results.push({
|
|
95
98
|
ordId: `${namespace}:entityType:${entityName}:${version}`,
|
|
96
99
|
entityName,
|
|
97
100
|
...entity,
|
|
98
|
-
};
|
|
101
|
+
});
|
|
99
102
|
}
|
|
103
|
+
if (results.length === 0) return;
|
|
104
|
+
return results;
|
|
100
105
|
};
|
|
101
106
|
|
|
102
107
|
function _getGroupID(serviceDefinition, groupTypeId = defaults.groupTypeId, appConfig) {
|
|
@@ -308,7 +313,34 @@ const createAPIResourceTemplate = (serviceName, serviceDefinition, appConfig, pa
|
|
|
308
313
|
|
|
309
314
|
const paths = _generatePaths(serviceName, serviceDefinition);
|
|
310
315
|
const apiResources = [];
|
|
311
|
-
|
|
316
|
+
|
|
317
|
+
// Handle version suffix extraction for primary data product services
|
|
318
|
+
let cleanServiceName,
|
|
319
|
+
version,
|
|
320
|
+
semanticVersion,
|
|
321
|
+
extracted = null;
|
|
322
|
+
if (isPrimaryDataProductService(serviceDefinition)) {
|
|
323
|
+
extracted = _extractVersionFromServiceName(serviceDefinition.name);
|
|
324
|
+
if (extracted) {
|
|
325
|
+
// Create a temporary service definition with the clean name for namespace processing
|
|
326
|
+
const cleanServiceDefinition = { ...serviceDefinition, name: extracted.cleanName };
|
|
327
|
+
cleanServiceName = _getGroupNameWithNestedNamespace(cleanServiceDefinition, appConfig);
|
|
328
|
+
version = extracted.version;
|
|
329
|
+
semanticVersion = extracted.semanticVersion;
|
|
330
|
+
} else {
|
|
331
|
+
// Invalid pattern - use current behavior
|
|
332
|
+
cleanServiceName = _getGroupNameWithNestedNamespace(serviceDefinition, appConfig);
|
|
333
|
+
version = "v1";
|
|
334
|
+
semanticVersion = "1.0.0";
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
// Non-data product - use current behavior
|
|
338
|
+
cleanServiceName = _getGroupNameWithNestedNamespace(serviceDefinition, appConfig);
|
|
339
|
+
version = "v1";
|
|
340
|
+
semanticVersion = "1.0.0";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ordId = `${appConfig.ordNamespace}:apiResource:${cleanServiceName}:${version}`;
|
|
312
344
|
|
|
313
345
|
paths.forEach((generatedPath) => {
|
|
314
346
|
let resourceDefinitions = [
|
|
@@ -330,7 +362,7 @@ const createAPIResourceTemplate = (serviceName, serviceDefinition, appConfig, pa
|
|
|
330
362
|
title: serviceDefinition["@title"] ?? serviceDefinition["@Common.Label"] ?? serviceName,
|
|
331
363
|
shortDescription: SHORT_DESCRIPTION_PREFIX + serviceName,
|
|
332
364
|
description: serviceDefinition["@Core.Description"] ?? DESCRIPTION_PREFIX + serviceName,
|
|
333
|
-
version:
|
|
365
|
+
version: semanticVersion,
|
|
334
366
|
lastUpdate: appConfig.lastUpdate,
|
|
335
367
|
visibility,
|
|
336
368
|
partOfPackage: packageId,
|
|
@@ -352,6 +384,10 @@ const createAPIResourceTemplate = (serviceName, serviceDefinition, appConfig, pa
|
|
|
352
384
|
obj.direction = "outbound";
|
|
353
385
|
obj.implementationStandard = "sap.dp:data-subscription-api:v1";
|
|
354
386
|
obj.entryPoints = [];
|
|
387
|
+
if (extracted) {
|
|
388
|
+
// Overwrite partOfGroups
|
|
389
|
+
obj.partOfGroups = [`${defaults.groupTypeId}:${appConfig.ordNamespace}:${cleanServiceName}`];
|
|
390
|
+
}
|
|
355
391
|
obj.resourceDefinitions = [
|
|
356
392
|
_getResourceDefinition(
|
|
357
393
|
"sap-csn-interop-effective-v1",
|
|
@@ -425,7 +461,10 @@ const createEventResourceTemplate = (serviceName, serviceDefinition, appConfig,
|
|
|
425
461
|
};
|
|
426
462
|
|
|
427
463
|
function isPrimaryDataProductService(serviceDefinition) {
|
|
428
|
-
return
|
|
464
|
+
return (
|
|
465
|
+
serviceDefinition[DATA_PRODUCT_ANNOTATION] === DATA_PRODUCT_TYPE.primary ||
|
|
466
|
+
!!serviceDefinition[DATA_PRODUCT_SIMPLE_ANNOTATION]
|
|
467
|
+
);
|
|
429
468
|
}
|
|
430
469
|
|
|
431
470
|
function _getEntityTypeMappings(definitionObj) {
|
|
@@ -433,7 +472,9 @@ function _getEntityTypeMappings(definitionObj) {
|
|
|
433
472
|
return;
|
|
434
473
|
}
|
|
435
474
|
const entities = Object.values(definitionObj.entities).flatMap((entity) => {
|
|
436
|
-
const entityData = _flattenEntityGraph(entity)
|
|
475
|
+
const entityData = _flattenEntityGraph(entity)
|
|
476
|
+
.flatMap(createEntityTypeMappingsItemTemplate) // now returns arrays
|
|
477
|
+
.filter(Boolean);
|
|
437
478
|
return _.uniqBy(entityData, CONTENT_MERGE_KEY);
|
|
438
479
|
});
|
|
439
480
|
const entityTypeTargets = _.uniqBy(entities, CONTENT_MERGE_KEY)
|
|
@@ -453,7 +494,7 @@ function _getExposedEntityTypes(definitionObj) {
|
|
|
453
494
|
return;
|
|
454
495
|
}
|
|
455
496
|
const entities = Object.values(definitionObj.entities).flatMap((entity) => {
|
|
456
|
-
const entityData = _flattenEntityGraph(entity).
|
|
497
|
+
const entityData = _flattenEntityGraph(entity).flatMap(createEntityTypeMappingsItemTemplate).filter(Boolean);
|
|
457
498
|
return _.uniqBy(entityData, CONTENT_MERGE_KEY);
|
|
458
499
|
});
|
|
459
500
|
const exposedEntityTypes = _.uniqBy(entities, CONTENT_MERGE_KEY)
|
|
@@ -496,6 +537,32 @@ function _flattenEntityGraph(currentEntity, processedEntities = []) {
|
|
|
496
537
|
return [currentEntity, ...assertionsTodo.flatMap((entity) => _flattenEntityGraph(entity, processedEntities))];
|
|
497
538
|
}
|
|
498
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Extracts version suffix from service name for data product services.
|
|
542
|
+
* Only accepts pattern: .v<number> (e.g., .v0, .v1, .v2, .v10)
|
|
543
|
+
* Rejects patterns like: .v1.1, .v1.0, .version1, .beta
|
|
544
|
+
*
|
|
545
|
+
* @param {string} serviceName The full service name
|
|
546
|
+
* @returns {Object|null} Object with cleanName, version, and semanticVersion, or null if invalid pattern
|
|
547
|
+
*/
|
|
548
|
+
function _extractVersionFromServiceName(serviceName) {
|
|
549
|
+
// Only match pattern: .v<number> (where number is 1 or more digits)
|
|
550
|
+
const versionPattern = /\.v(\d+)$/;
|
|
551
|
+
const match = serviceName.match(versionPattern);
|
|
552
|
+
|
|
553
|
+
if (!match) {
|
|
554
|
+
return null; // No valid version suffix found
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const versionNumber = parseInt(match[1], 10);
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
cleanName: serviceName.replace(versionPattern, ""),
|
|
561
|
+
version: `v${versionNumber}`,
|
|
562
|
+
semanticVersion: `${versionNumber}.0.0`,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
499
566
|
function _getPackageID(namespace, packageIds, resourceType, visibility = RESOURCE_VISIBILITY.public) {
|
|
500
567
|
if (!packageIds) return;
|
|
501
568
|
|
|
@@ -544,4 +611,5 @@ module.exports = {
|
|
|
544
611
|
_getExposedEntityTypes,
|
|
545
612
|
_propagateORDVisibility,
|
|
546
613
|
_handleVisibility,
|
|
614
|
+
isPrimaryDataProductService,
|
|
547
615
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/ord",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.11",
|
|
4
4
|
"description": "CAP Plugin for generating ORD document.",
|
|
5
5
|
"repository": "cap-js/ord",
|
|
6
6
|
"author": "SAP SE (https://www.sap.com)",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"eslint": "^9.2.0",
|
|
28
|
-
"jest": "^
|
|
29
|
-
"prettier": "3.
|
|
28
|
+
"jest": "^30.0.0",
|
|
29
|
+
"prettier": "3.6.2"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@sap/cds": ">=8.9.4",
|