@sap/cds 9.6.1 → 9.6.3
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 +16 -0
- package/lib/compile/for/flows.js +21 -15
- package/lib/req/validate.js +18 -10
- package/libx/_runtime/messaging/enterprise-messaging-utils/registerEndpoints.js +1 -1
- package/libx/odata/middleware/error.js +1 -1
- package/libx/odata/parse/afterburner.js +22 -16
- package/libx/queue/index.js +6 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
- The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
5
5
|
- This project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## Version 9.6.3 - 2026-01-14
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `queue`: Exactly-once guarantee only with `legacyLocking: false`
|
|
12
|
+
|
|
13
|
+
## Version 9.6.2 - 2026-01-08
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Consider `@mandatory.message` for the error message, when a mandatory action / function parameter is not provided
|
|
18
|
+
- Respond with error code `400` when receiving requests that use `any()` / `all()` filters on an association to one
|
|
19
|
+
- Error message of unknown property check will no longer include `undefined` to identify structs without a name
|
|
20
|
+
- `enterprise-messaging`: Type error in check for unofficial XSUAA fallback
|
|
21
|
+
- Status Transition Flows: Exclusions for multiple projections
|
|
22
|
+
|
|
7
23
|
## Version 9.6.1 - 2025-12-18
|
|
8
24
|
|
|
9
25
|
### Fixed
|
package/lib/compile/for/flows.js
CHANGED
|
@@ -186,8 +186,7 @@ module.exports = function cds_compile_for_flows(csn) {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
const
|
|
190
|
-
const exclusions = new Set()
|
|
189
|
+
const to_be_extended = new Set()
|
|
191
190
|
|
|
192
191
|
for (const name in csn.definitions) {
|
|
193
192
|
const def = csn.definitions[name]
|
|
@@ -225,28 +224,35 @@ module.exports = function cds_compile_for_flows(csn) {
|
|
|
225
224
|
if (base.elements?.transitions_) continue //> manually added -> don't interfere
|
|
226
225
|
|
|
227
226
|
// add aspect FlowHistory to db entity
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// hack for "excludes" not possible via extensions
|
|
231
|
-
if (projections.length) projections.forEach(p => exclusions.add(p))
|
|
227
|
+
to_be_extended.add(base_name)
|
|
232
228
|
}
|
|
233
229
|
|
|
234
|
-
if (
|
|
230
|
+
if (to_be_extended.size) {
|
|
235
231
|
// REVISIT: ensure sap.common.FlowHistory is there
|
|
236
232
|
csn.definitions['sap.common.FlowHistory'] ??= JSON.parse(FlowHistory)
|
|
237
233
|
|
|
238
|
-
const
|
|
239
|
-
|
|
234
|
+
const extensions = [...to_be_extended].map(extend => ({ extend, includes: ['sap.common.FlowHistory'] }))
|
|
235
|
+
const dsn = cds.extend(csn).with({ extensions })
|
|
240
236
|
|
|
241
237
|
// REVISIT: annotate all generated X.transitions_ with @cds.autoexpose: false
|
|
242
|
-
for (const each of
|
|
243
|
-
}
|
|
238
|
+
for (const each of to_be_extended) dsn.definitions[`${each}.transitions_`]['@cds.autoexpose'] = false
|
|
244
239
|
|
|
245
|
-
|
|
246
|
-
for (const
|
|
247
|
-
|
|
248
|
-
|
|
240
|
+
// hack for "excludes" not possible via extensions
|
|
241
|
+
for (const name in dsn.definitions) {
|
|
242
|
+
const _new = dsn.definitions[name]
|
|
243
|
+
if (
|
|
244
|
+
_new.kind !== 'entity' ||
|
|
245
|
+
to_be_extended.has(name.replace(/\.transitions_$/, '')) ||
|
|
246
|
+
(!name.match(/\.transitions_$/) && !_new.elements.transitions_)
|
|
247
|
+
) {
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
const _old = csn.definitions[name]
|
|
251
|
+
if (!_old) delete dsn.definitions[name]
|
|
252
|
+
else if (_new.elements.transitions_ && !_old.elements.transitions_) delete _new.elements.transitions_
|
|
249
253
|
}
|
|
254
|
+
|
|
255
|
+
csn = dsn
|
|
250
256
|
}
|
|
251
257
|
|
|
252
258
|
// REVISIT: annotate all X.transitions_ with @odata.draft.enabled: false
|
package/lib/req/validate.js
CHANGED
|
@@ -28,14 +28,18 @@ class Validation {
|
|
|
28
28
|
this.cleanse = options.cleanse !== false
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
_targetFromPath (p,n) {
|
|
32
|
+
if (n === undefined) return p
|
|
33
|
+
if (n.row) return p + this.filter4(n) // > some/entity(ID=1)...
|
|
34
|
+
if (typeof n === 'number') return p + `[${n}]` // > some/array[1]...
|
|
35
|
+
if (p && n) return p+'/'+n // > some/element...
|
|
36
|
+
return n
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
error (code, path, leaf, i18n, ...args) {
|
|
32
40
|
const err = (this.errors ??= new ValidationErrors).add (code)
|
|
33
41
|
if (this.options.path) path = [ this.options.path, ...path ] // e.g. used to prefix 'in/' for actions
|
|
34
|
-
if (path) err.target = (!leaf ? path : path.concat(leaf)).reduce?.((p,n)=> (
|
|
35
|
-
n?.row ? p + this.filter4(n) : //> some/entity(ID=1)...
|
|
36
|
-
typeof n === 'number' ? p + `[${n}]` : //> some/array[1]...
|
|
37
|
-
p && n ? p+'/'+n : n //> some/element...
|
|
38
|
-
),'')
|
|
42
|
+
if (path) err.target = (!leaf ? path : path.concat(leaf)).reduce?.((p, n) => this._targetFromPath(p, n),'')
|
|
39
43
|
if (typeof i18n === 'string') err.i18n = i18n
|
|
40
44
|
if (args.length) err.args = args
|
|
41
45
|
return err
|
|
@@ -50,12 +54,16 @@ class Validation {
|
|
|
50
54
|
else if (typeof v === 'string' && !entity.elements[k].isUUID || entity.elements[k]['@odata.Type'] === 'Edm.String') v = `'${v}'`
|
|
51
55
|
filter.push (`${k}=${v}`)
|
|
52
56
|
}
|
|
53
|
-
|
|
57
|
+
if (filter.length) return `(${filter})`
|
|
58
|
+
if (index !== undefined) return `[${index}]`
|
|
59
|
+
return ''
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
unknown(e,d,input) {
|
|
62
|
+
unknown(e,d,input, path) {
|
|
57
63
|
if (this.protocol === 'odata' && e.match(/^\w*@\w+\.\w+$/)) return delete input[e] //> skip all annotations, like @odata.Type (according to OData spec annotations contain an "@" and a ".")
|
|
58
|
-
d['@open']
|
|
64
|
+
if (d['@open']) return
|
|
65
|
+
const target = d.name ?? path.reduce((p, n) => this._targetFromPath(p, n), '')
|
|
66
|
+
cds.error (`Property "${e}" does not exist in ${target}`, { status: 400, target })
|
|
59
67
|
}
|
|
60
68
|
}
|
|
61
69
|
|
|
@@ -209,12 +217,12 @@ class struct extends $any {
|
|
|
209
217
|
if (each.$struct in data) continue // got struct for flattened element/fk, e.g. {author:{ID:1}}
|
|
210
218
|
if ((each.elements && each.kind !== 'param' ) || each.foreignKeys) continue // skip struct-likes as we check flat payloads above, and deep payloads via struct.validate(), parameters don't have flat elements
|
|
211
219
|
if (each.isAssociation) continue // unmanaged associations are always ignored (no value like)
|
|
212
|
-
else ctx.error (ASSERT_MANDATORY, path_, each.name)
|
|
220
|
+
else ctx.error (ASSERT_MANDATORY, path_, each.name, each['@mandatory.message'] || each['@mandatory'])
|
|
213
221
|
}
|
|
214
222
|
// check values of given data
|
|
215
223
|
for (let each in data) { // will work for structured payloads as well as flattened ones with universal CSN
|
|
216
224
|
let /** @type {$any} */ d = Object.hasOwn(elements, each) && elements[each]
|
|
217
|
-
if (!d || (d['@cds.api.ignore'] && ctx.rejectIgnore)) ctx.unknown (each, this, data)
|
|
225
|
+
if (!d || (d['@cds.api.ignore'] && ctx.rejectIgnore)) ctx.unknown (each, this, data, path_)
|
|
218
226
|
else if (ctx.cleanse && d._is_readonly() && !d.key) delete data[each]
|
|
219
227
|
else if (ctx.cleanse && d._is_immutable() && !ctx.insert && !path) delete data[each] // @Core.Immutable processed only for root, children are handled when knowing db state
|
|
220
228
|
else if (d['@cds.validate'] !== false) d.validate (data[each], path_, ctx)
|
|
@@ -29,7 +29,7 @@ class EndpointRegistry {
|
|
|
29
29
|
cds.app.use(basePath, cds.middlewares.context())
|
|
30
30
|
if (_IS_SECURED) {
|
|
31
31
|
// unofficial XSUAA fallback
|
|
32
|
-
if (cds.env.requires.messaging
|
|
32
|
+
if (cds.env.requires.messaging?.xsuaa) {
|
|
33
33
|
cds.app.use(basePath, _xsuaa_fallback())
|
|
34
34
|
} else {
|
|
35
35
|
cds.app.use(basePath, cds.middlewares.auth())
|
|
@@ -56,7 +56,7 @@ const _normalize = (err, req, keep,
|
|
|
56
56
|
const status = err.status || err.statusCode || _status4(err) || _reduce(details)
|
|
57
57
|
|
|
58
58
|
// Determine error code and message
|
|
59
|
-
const key = err.message || err.code || status
|
|
59
|
+
const key = err.message || err.code || status // REVISIT: Should we use the message over the code every time?
|
|
60
60
|
const msg = i18n.messages.at (key, '', err.args) // lookup messages for log output from factory default texts
|
|
61
61
|
if (msg && msg !== key) {
|
|
62
62
|
if (typeof err.code !== 'string') err.code = key
|
|
@@ -85,7 +85,7 @@ function _addDefaultParams(ref, view) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function getResolvedElement(entity, { ref }) {
|
|
88
|
-
const element = entity.elements[ref[0]]
|
|
88
|
+
const element = entity.elements[ref[0]?.id ?? ref[0]]
|
|
89
89
|
|
|
90
90
|
if (element && element.isAssociation && ref.length > 1) {
|
|
91
91
|
return getResolvedElement(element._target, { ref: ref.slice(1) })
|
|
@@ -782,7 +782,9 @@ function _validateXpr(xpr, target, isOne, model, aliases = []) {
|
|
|
782
782
|
.map(element => element.name)
|
|
783
783
|
const newFoundAliases = []
|
|
784
784
|
|
|
785
|
-
for (
|
|
785
|
+
for (let i = 0; i < xpr.length; i++) {
|
|
786
|
+
const x = xpr[i]
|
|
787
|
+
|
|
786
788
|
if (x.as) newFoundAliases.push(x.as)
|
|
787
789
|
|
|
788
790
|
if (x.xpr) {
|
|
@@ -795,9 +797,7 @@ function _validateXpr(xpr, target, isOne, model, aliases = []) {
|
|
|
795
797
|
|
|
796
798
|
if (x.ref[0].where) {
|
|
797
799
|
const element = target.elements[refName]
|
|
798
|
-
if (!element)
|
|
799
|
-
_doesNotExistError(true, refName, target.name)
|
|
800
|
-
}
|
|
800
|
+
if (!element) _doesNotExistError(true, refName, target.name)
|
|
801
801
|
_validateXpr(x.ref[0].where, element._target ?? element.items, isOne, model)
|
|
802
802
|
}
|
|
803
803
|
|
|
@@ -818,28 +818,34 @@ function _validateXpr(xpr, target, isOne, model, aliases = []) {
|
|
|
818
818
|
}
|
|
819
819
|
}
|
|
820
820
|
|
|
821
|
+
if (xpr[i - 1] === 'exists') {
|
|
822
|
+
const element = getResolvedElement(target, x)
|
|
823
|
+
if (!element.isAssociation)
|
|
824
|
+
cds.error({ status: 400, message: 'Invalid use of lambda any / all on a non-association' })
|
|
825
|
+
if (element.is2one)
|
|
826
|
+
cds.error({ status: 400, message: 'Invalid use of lambda any / all on an association to one' })
|
|
827
|
+
}
|
|
828
|
+
|
|
821
829
|
if (x.expand) {
|
|
822
830
|
let element = target.elements[refName]
|
|
831
|
+
|
|
823
832
|
if (element.kind === 'element' && element.elements) {
|
|
824
833
|
// structured
|
|
825
834
|
_validateXpr([{ ref: x.ref.slice(1) }], element, isOne, model)
|
|
826
835
|
element = _structProperty(x.ref.slice(1), element)
|
|
827
836
|
}
|
|
837
|
+
|
|
828
838
|
const target_service = _get_service_of(target)
|
|
839
|
+
|
|
829
840
|
if (!element._target || element._target._service !== target_service) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
refName,
|
|
833
|
-
target_service ? target.name.replace(target_service.name + '.', '') : target.name
|
|
834
|
-
)
|
|
841
|
+
const targetName = target_service ? target.name.replace(target_service.name + '.', '') : target.name
|
|
842
|
+
_doesNotExistError(true, refName, targetName)
|
|
835
843
|
}
|
|
844
|
+
|
|
836
845
|
_validateXpr(x.expand, element._target, false, model)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
if (x.orderBy) {
|
|
841
|
-
_validateXpr(x.orderBy, element._target, false, model)
|
|
842
|
-
}
|
|
846
|
+
|
|
847
|
+
if (x.where) _validateXpr(x.where, element._target, false, model)
|
|
848
|
+
if (x.orderBy) _validateXpr(x.orderBy, element._target, false, model)
|
|
843
849
|
}
|
|
844
850
|
}
|
|
845
851
|
|
package/libx/queue/index.js
CHANGED
|
@@ -244,7 +244,8 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
244
244
|
const _run = opts.legacyLocking && cds.db?.kind === 'sqlite' ? cds._with : service.tx.bind(service)
|
|
245
245
|
const result = await _run({ ...task.context, tenant }, async () => {
|
|
246
246
|
const result = opts.handle ? await opts.handle.call(service, task.msg) : await service.handle(task.msg)
|
|
247
|
-
|
|
247
|
+
// we can only delete in the current tx if legacyLocking (i.e., long-lasting db locks) is false
|
|
248
|
+
if (!opts.legacyLocking) await DELETE.from(tasksEntity).where({ ID: task.ID })
|
|
248
249
|
return result
|
|
249
250
|
})
|
|
250
251
|
task.results = result
|
|
@@ -295,13 +296,15 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
295
296
|
}
|
|
296
297
|
|
|
297
298
|
const queries = []
|
|
298
|
-
|
|
299
|
+
const _toBeDeleted = opts.legacyLocking ? toBeDeleted.concat(succeeded) : toBeDeleted
|
|
300
|
+
if (_toBeDeleted.length) {
|
|
299
301
|
queries.push(
|
|
300
302
|
DELETE.from(tasksEntity).where(
|
|
301
303
|
'ID in',
|
|
302
|
-
|
|
304
|
+
_toBeDeleted.map(msg => msg.ID)
|
|
303
305
|
)
|
|
304
306
|
)
|
|
307
|
+
}
|
|
305
308
|
|
|
306
309
|
// There can be tasks which are not updated / deleted, their status must be set back to `null`
|
|
307
310
|
const updateTasks = selectedTasks.filter(
|