@sap/cds-dk 10.1.0 → 10.1.2
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 +24 -0
- package/bin/bind.js +4 -0
- package/bin/build.js +1 -0
- package/bin/compile/index.js +12 -11
- package/bin/completion/index.js +21 -10
- package/bin/completion/scripts/cds.fish +179 -127
- package/bin/completion/scripts/cds.ps1 +88 -14
- package/bin/completion/scripts/cds.sh +71 -5
- package/bin/completion/scripts/cds.zsh +81 -15
- package/bin/completion/templates/cds-tmpl.ps1 +15 -6
- package/bin/deploy/index.js +7 -3
- package/bin/env.js +4 -1
- package/bin/export.js +9 -1
- package/bin/import.js +4 -1
- package/bin/init.js +1 -0
- package/bin/inspect.js +3 -1
- package/bin/lint.js +5 -3
- package/bin/minify.js +6 -1
- package/bin/repl.js +11 -2
- package/bin/run.js +12 -1
- package/bin/serve.js +13 -2
- package/bin/up.js +4 -1
- package/bin/util/containerize.js +2 -0
- package/bin/watch/index.js +8 -1
- package/lib/build/plugins/internal.js +0 -26
- package/lib/build/plugins/nodejs/index.js +8 -3
- package/lib/build/util.js +2 -4
- package/lib/cds.js +8 -2
- package/lib/init/constants.js +1 -1
- package/lib/init/merge.js +21 -17
- package/lib/init/template/cloud-logging/index.js +3 -3
- package/lib/init/template/data/as-json.js +3 -0
- package/lib/init/template/destination/index.js +3 -3
- package/lib/init/template/enterprise-messaging/index.js +4 -1
- package/lib/init/template/event-mesh/index.js +3 -0
- package/lib/init/template/multitenancy/index.js +2 -2
- package/lib/init/template/workzone-standard/index.js +1 -1
- package/node_modules/@cap-js/db-service/CHANGELOG.md +30 -0
- package/node_modules/@cap-js/db-service/lib/common/generic-pool.js +4 -7
- package/node_modules/@cap-js/db-service/lib/cqn2pql.js +1 -1
- package/node_modules/@cap-js/db-service/lib/cqn2sql.js +15 -11
- package/node_modules/@cap-js/db-service/lib/cqn4sql.js +125 -21
- package/node_modules/@cap-js/db-service/lib/infer/index.js +27 -16
- package/node_modules/@cap-js/db-service/lib/recursion-guard.js +40 -0
- package/node_modules/@cap-js/db-service/lib/search.js +1 -1
- package/node_modules/@cap-js/db-service/package.json +1 -1
- package/node_modules/@cap-js/sqlite/CHANGELOG.md +28 -0
- package/node_modules/@cap-js/sqlite/lib/SQLiteService.js +110 -14
- package/node_modules/@cap-js/sqlite/package.json +3 -3
- package/node_modules/@sap/cds/CHANGELOG.md +19 -0
- package/node_modules/@sap/cds/common.cds +3 -3
- package/node_modules/@sap/cds/lib/compile/minify.js +3 -1
- package/node_modules/@sap/cds/lib/env/cds-env.js +1 -1
- package/node_modules/@sap/cds/lib/env/cds-requires.js +1 -0
- package/node_modules/@sap/cds/libx/_runtime/common/generic/crud.js +7 -1
- package/node_modules/@sap/cds/libx/_runtime/common/utils/cqn.js +1 -1
- package/node_modules/@sap/cds/libx/_runtime/fiori/lean-draft.js +1 -1
- package/node_modules/@sap/cds/libx/_runtime/messaging/common-utils/normalizeIncomingMessage.js +1 -1
- package/node_modules/@sap/cds/libx/_runtime/messaging/kafka.js +1 -1
- package/node_modules/@sap/cds/libx/http/location.js +10 -11
- package/node_modules/@sap/cds/libx/odata/middleware/read.js +1 -1
- package/node_modules/@sap/cds/libx/queue/SchedulingService.js +4 -0
- package/node_modules/@sap/cds/libx/queue/index.js +44 -10
- package/node_modules/@sap/cds/libx/queue/processing.js +47 -16
- package/node_modules/@sap/cds/libx/queue/utils.js +3 -0
- package/node_modules/@sap/cds/package.json +1 -1
- package/node_modules/@sap/cds/tasks/enterprise-messaging-deploy.js +13 -5
- package/node_modules/@sap/cds-compiler/CHANGELOG.md +15 -0
- package/node_modules/@sap/cds-compiler/lib/compiler/resolve.js +7 -3
- package/node_modules/@sap/cds-compiler/lib/model/csnUtils.js +2 -0
- package/node_modules/@sap/cds-compiler/lib/transform/db/applyTransformations.js +12 -0
- package/node_modules/@sap/cds-compiler/lib/transform/db/associations.js +5 -3
- package/node_modules/@sap/cds-compiler/lib/transform/db/flattening.js +4 -3
- package/node_modules/@sap/cds-compiler/lib/transform/effective/flattening.js +17 -1
- package/node_modules/@sap/cds-compiler/lib/transform/effective/main.js +19 -0
- package/node_modules/@sap/cds-compiler/lib/transform/fioriTreeViews.js +12 -1
- package/node_modules/@sap/cds-compiler/lib/transform/forRelationalDB.js +1 -1
- package/node_modules/@sap/cds-compiler/lib/transform/odata/adaptAnnotationRefs.js +11 -5
- package/node_modules/@sap/cds-compiler/lib/transform/translateAssocsToJoins.js +29 -2
- package/node_modules/@sap/cds-compiler/package.json +1 -1
- package/node_modules/@sap/cds-mtxs/CHANGELOG.md +34 -0
- package/node_modules/@sap/cds-mtxs/bin/cds-mtx-migrate.js +26 -5
- package/node_modules/@sap/cds-mtxs/bin/cds-mtx.js +3 -0
- package/node_modules/@sap/cds-mtxs/lib/utils.js +5 -4
- package/node_modules/@sap/cds-mtxs/package.json +1 -1
- package/node_modules/@sap/cds-mtxs/srv/cf/abstract-provisioning-service.js +62 -7
- package/node_modules/@sap/cds-mtxs/srv/cf/saas-provisioning-service.js +1 -1
- package/node_modules/@sap/cds-mtxs/srv/cf/sms-provisioning-service.js +23 -3
- package/node_modules/@sap/cds-mtxs/srv/extensibility/compat/update.js +15 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/annotations.js +2 -2
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/config.js +2 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/index.js +2 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/protected-namespace.js +45 -0
- package/node_modules/@sap/cds-mtxs/srv/extensibility-service.js +8 -3
- package/node_modules/@sap/cds-mtxs/srv/jobs-service.js +49 -10
- package/node_modules/@sap/cds-mtxs/srv/model-provider.js +11 -2
- package/node_modules/@sap/cds-mtxs/srv/plugins/common.js +19 -12
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana/hdi-ctnr-mgr.js +6 -3
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana/serverless.js +271 -0
- package/node_modules/@sap/cds-mtxs/srv/plugins/hana.js +3 -2
- package/node_modules/@sap/cds-mtxs/srv/worker/job-worker-pool.js +212 -0
- package/node_modules/@sap/cds-mtxs/srv/worker/job-worker.js +61 -0
- package/node_modules/proxy-addr/HISTORY.md +60 -54
- package/node_modules/proxy-addr/README.md +1 -1
- package/node_modules/proxy-addr/index.js +32 -0
- package/node_modules/proxy-addr/package.json +8 -4
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- package/package.json +2 -2
- /package/node_modules/@sap/cds-mtxs/srv/worker/{pool.js → model-worker-pool.js} +0 -0
- /package/node_modules/@sap/cds-mtxs/srv/worker/{load.js → model-worker.js} +0 -0
|
@@ -4,6 +4,25 @@
|
|
|
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 10.1.1 - 2026-09-17
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Messaging consumers (Event Mesh AMQP, Kafka, Redis) no longer crash with `TypeError: Cannot use 'in' operator to search for 'data' in null` when a message with a `null` payload is received
|
|
12
|
+
- `cds.env` now correctly replaces an array default with an object project override instead of assigning object properties onto the array
|
|
13
|
+
- SQLite now uses its default pool config (single, non-evicting connection) even when `@cap-js/sqlite` is installed globally rather than as a local dependency
|
|
14
|
+
- Stripping of `IsActiveEntity` in programmatic deep updates to draft instances
|
|
15
|
+
- Server crash when string keys contain control characters in OData `Location` response header
|
|
16
|
+
- Binary keys in OData `Location` response header are now formatted as binary literals (`binary'...'`)
|
|
17
|
+
- Skip periodic message processing when no provider tenant is configured
|
|
18
|
+
- The parent-readable check during CREATE/navigation no longer swallows system errors (e.g. database failures); such errors are now propagated instead of being masked as a `404`
|
|
19
|
+
- Support X.509 in `enterprise-messaging`'s deploy task
|
|
20
|
+
- Incompatibility of global config `cds.sql.lock_acquire_timeout` and event-queue implementation
|
|
21
|
+
- Outbox messages written before `appid` was introduced are now processed after an upgrade instead of being skipped
|
|
22
|
+
- A queued outbox message whose target service cannot be resolved is now dead-lettered instead of crashing the application
|
|
23
|
+
- Recurring scheduled tasks (`.every()`) are no longer resurrected when unscheduled while running, and re-submitting one while it runs no longer discards the updated schedule and payload
|
|
24
|
+
- Convenience handling of `/$count` requests for custom handlers simply returning arrays
|
|
25
|
+
|
|
7
26
|
## Version 10.1.0 - 2026-09-02
|
|
8
27
|
|
|
9
28
|
### Added
|
|
@@ -166,9 +166,9 @@ annotate managed with {
|
|
|
166
166
|
createdBy @Core.Immutable;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
annotate sap.common.
|
|
170
|
-
annotate sap.common.
|
|
171
|
-
annotate sap.common.Languages with
|
|
169
|
+
annotate sap.common.Currencies :code with @Common.Text:name;
|
|
170
|
+
annotate sap.common.Countries :code with @Common.Text:name;
|
|
171
|
+
annotate sap.common.Languages :code with @Common.Text:name;
|
|
172
172
|
|
|
173
173
|
|
|
174
174
|
//---------------------------------------------------------------------------
|
|
@@ -93,7 +93,8 @@ class Minifier {
|
|
|
93
93
|
let r = q.from.ref[0]; if (r.id) r = r.id; if (r in this.kept) return
|
|
94
94
|
let e = this.defs[r]; if (!e) return
|
|
95
95
|
let all = e.elements, kept = {}
|
|
96
|
-
if (
|
|
96
|
+
if (e['@cds.minify'] === 'unused-elements') return this.keep (r,e) // avoids propagation
|
|
97
|
+
if (q.columns) _keep (q.columns); else return this.keep (r,e)
|
|
97
98
|
if (q.where) _keep (q.where)
|
|
98
99
|
e.elements = kept
|
|
99
100
|
return this.keep (r,e)
|
|
@@ -101,6 +102,7 @@ class Minifier {
|
|
|
101
102
|
if (x.func) { _keep (x.args); continue }
|
|
102
103
|
if (x.xpr) { _keep (x.xpr); continue }
|
|
103
104
|
if (x.ref) { let [r] = x.ref; if (r in all) kept[r] = all[r] }
|
|
105
|
+
if (x === '*') return kept = all
|
|
104
106
|
}}
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -515,7 +515,7 @@ function _merge (dst, src, _profiles) {
|
|
|
515
515
|
const v = pd.value
|
|
516
516
|
if (typeof v === 'object' && !Array.isArray(v) && v != null) {
|
|
517
517
|
if (!dst[p]) dst[p] = {}
|
|
518
|
-
if (typeof dst[p] !== 'object') dst[p] = v
|
|
518
|
+
if (typeof dst[p] !== 'object' || Array.isArray(dst[p])) dst[p] = v
|
|
519
519
|
else _merge (dst[p], v, _profiles)
|
|
520
520
|
continue
|
|
521
521
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const cds = require('../../cds')
|
|
2
|
+
const LOG = cds.log('app')
|
|
2
3
|
const { SELECT } = cds.ql
|
|
3
4
|
|
|
4
5
|
module.exports = exports = cds.service.impl(function () {
|
|
@@ -18,7 +19,7 @@ module.exports = exports = cds.service.impl(function () {
|
|
|
18
19
|
? SELECT.one`1 as $parent`.from({ ref: ref.slice(0, -1) })
|
|
19
20
|
: null
|
|
20
21
|
const parent_readable_check = parent_exists_query
|
|
21
|
-
? async () => (parent_skip_auth ? cds.db : this).run(parent_exists_query).catch(
|
|
22
|
+
? async () => (parent_skip_auth ? cds.db : this).run(parent_exists_query).catch(parent_unreadable_error)
|
|
22
23
|
: null
|
|
23
24
|
|
|
24
25
|
if (req.event === 'CREATE' && parent_readable_check != null) (await parent_readable_check()) || req.reject(404)
|
|
@@ -84,6 +85,11 @@ async function legacy_behavior(req, result, parent) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
function parent_unreadable_error(e) {
|
|
89
|
+
if (cds.error.isSystemError(e)) throw e
|
|
90
|
+
LOG._debug && LOG.debug('Parent readable check failed:', e)
|
|
91
|
+
}
|
|
92
|
+
|
|
87
93
|
exports._handle_result = legacy_behavior
|
|
88
94
|
exports._always_return_arrays = on_off => {
|
|
89
95
|
if (on_off === undefined) return exports._handle_result === new_behavior
|
|
@@ -4,7 +4,7 @@ const { setEntityContained } = require('./csn')
|
|
|
4
4
|
|
|
5
5
|
// scope: simple wheres à la "[{ ref: ['foo'] }, '=', { val: 'bar' }, 'and', ... ]"
|
|
6
6
|
function where2obj(where, target = null, data = {}) {
|
|
7
|
-
for (let i = 0; i < where.length;) {
|
|
7
|
+
for (let i = 0; i < where.length; ) {
|
|
8
8
|
const a = where[i++]
|
|
9
9
|
if (a.xpr) {
|
|
10
10
|
where2obj(a.xpr, target, data)
|
|
@@ -1090,7 +1090,7 @@ const draftHandle = async function (req) {
|
|
|
1090
1090
|
for (const assoc in target.associations) {
|
|
1091
1091
|
const val = data[assoc]
|
|
1092
1092
|
if (val && typeof val === 'object') {
|
|
1093
|
-
const _target =
|
|
1093
|
+
const _target = target.associations[assoc]._target
|
|
1094
1094
|
if (Array.isArray(val)) {
|
|
1095
1095
|
val.forEach(v => _rmIsActiveEntity(v, _target))
|
|
1096
1096
|
} else {
|
package/node_modules/@sap/cds/libx/_runtime/messaging/common-utils/normalizeIncomingMessage.js
CHANGED
|
@@ -11,7 +11,7 @@ const _JSONorString = string => {
|
|
|
11
11
|
const normalizeIncomingMessage = message => {
|
|
12
12
|
const _payload = typeof message === 'object' ? message : _JSONorString(message)
|
|
13
13
|
let data, headers
|
|
14
|
-
if (typeof _payload === 'object' && 'data' in _payload) {
|
|
14
|
+
if (_payload !== null && typeof _payload === 'object' && 'data' in _payload) {
|
|
15
15
|
data = _payload.data
|
|
16
16
|
headers = { ..._payload }
|
|
17
17
|
delete headers.data
|
|
@@ -161,7 +161,7 @@ function _JSONorString(string) {
|
|
|
161
161
|
function _normalizeIncomingMessage(message) {
|
|
162
162
|
const _payload = _JSONorString(message)
|
|
163
163
|
let data, headers
|
|
164
|
-
if (typeof _payload === 'object' && 'data' in _payload) {
|
|
164
|
+
if (_payload !== null && typeof _payload === 'object' && 'data' in _payload) {
|
|
165
165
|
data = _payload.data
|
|
166
166
|
headers = { ..._payload }
|
|
167
167
|
delete headers.data
|
|
@@ -8,14 +8,16 @@ module.exports = (target, srv, result, keys_as_segments) => {
|
|
|
8
8
|
|
|
9
9
|
const keyValuePairs = filteredKeys.reduce((acc, key) => {
|
|
10
10
|
const value = result[key]
|
|
11
|
-
if (result[key] === undefined) return acc
|
|
12
11
|
|
|
13
12
|
if (Buffer.isBuffer(value)) {
|
|
14
|
-
|
|
13
|
+
const base64 = value.toString('base64')
|
|
14
|
+
acc[key] = keys_as_segments ? base64 : `binary'${base64}'`
|
|
15
|
+
} else if (keys_as_segments) {
|
|
16
|
+
acc[key] = value
|
|
15
17
|
} else {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const el = target.elements[key]
|
|
19
|
+
const needsQuotes = typeof value === 'string' && (el._type !== 'cds.UUID' || el['@odata.Type'] === 'Edm.String')
|
|
20
|
+
acc[key] = needsQuotes ? `'${value}'` : value
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
return acc
|
|
@@ -24,19 +26,16 @@ module.exports = (target, srv, result, keys_as_segments) => {
|
|
|
24
26
|
if (keys_as_segments) {
|
|
25
27
|
let location = targetName
|
|
26
28
|
for (const k in keyValuePairs) location += `/${keyValuePairs[k]}`
|
|
27
|
-
return location
|
|
29
|
+
return encodeURI(location)
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
let keys
|
|
31
33
|
const entries = Object.entries(keyValuePairs)
|
|
32
34
|
if (entries.length === 1) {
|
|
33
35
|
keys = entries[0][1]
|
|
34
|
-
if (target.elements[entries[0][0]]['@odata.Type'] === 'Edm.String') keys = `'${keys}'`
|
|
35
36
|
} else {
|
|
36
|
-
keys = entries
|
|
37
|
-
.map(([key, value]) => `${key}=${target.elements[key]['@odata.Type'] === 'Edm.String' ? `'${value}'` : value}`)
|
|
38
|
-
.join(',')
|
|
37
|
+
keys = entries.map(([key, value]) => `${key}=${value}`).join(',')
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
return `${targetName}(${keys})`
|
|
40
|
+
return encodeURI(`${targetName}(${keys})`)
|
|
42
41
|
}
|
|
@@ -126,7 +126,7 @@ const _isNullableSingleton = query => query._target._isSingleton && query._targe
|
|
|
126
126
|
const _isToOneAssoc = query =>
|
|
127
127
|
query.SELECT.from.ref.length > 1 && typeof query.SELECT.from.ref.slice(-1)[0] === 'string'
|
|
128
128
|
|
|
129
|
-
const _getCountFromResult = result => (Array.isArray(result) ? (result[0]
|
|
129
|
+
const _getCountFromResult = result => (Array.isArray(result) ? (result[0]?.$count ?? 0) : (result?.$count ?? 0))
|
|
130
130
|
|
|
131
131
|
const _getCountFromResultForArrayOfQueries = result => {
|
|
132
132
|
if (!Array.isArray(result)) return result.$count ?? 0
|
|
@@ -24,6 +24,8 @@ const _setLongTimeout = (cb, delay) => {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
let T0
|
|
27
|
+
const MISSING_T0 =
|
|
28
|
+
'Skipping periodic message processing: no provider tenant is configured via `cds.requires.multitenancy.t0`. This is unexpected outside the `mtx-sidecar` profile.'
|
|
27
29
|
|
|
28
30
|
module.exports = class SchedulingService extends cds.Service {
|
|
29
31
|
constructor(...args) {
|
|
@@ -101,6 +103,8 @@ module.exports = class SchedulingService extends cds.Service {
|
|
|
101
103
|
|
|
102
104
|
// NOTE: in the future, we also need to trigger task processing in sidecar for mtx
|
|
103
105
|
if (cds.env.profile !== 'mtx-sidecar') {
|
|
106
|
+
if (!T0) return LOG.warn(MISSING_T0)
|
|
107
|
+
|
|
104
108
|
const periodic_flush = setInterval(() => _flush(T0), flush_interval_ms).unref()
|
|
105
109
|
cds.on('shutdown', () => clearInterval(periodic_flush))
|
|
106
110
|
_flush(T0)
|
|
@@ -132,7 +132,7 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
132
132
|
|
|
133
133
|
if (opts.maxAttempts) tasksQuery.where({ attempts: { '<': opts.maxAttempts } })
|
|
134
134
|
if (opts.parallel) {
|
|
135
|
-
tasksQuery.
|
|
135
|
+
tasksQuery.forUpdate({ ignoreLocked: true })
|
|
136
136
|
const shifted = new Date(Math.max(0, currTime - _timeout)).toISOString()
|
|
137
137
|
tasksQuery.where({ status: null, or: { lastAttemptTimestamp: { '<': shifted } } })
|
|
138
138
|
// Note: If there are messages which are not yet timed out, but will hang eventually,
|
|
@@ -215,8 +215,14 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
215
215
|
const user = new cds.User.Privileged(userId)
|
|
216
216
|
context.user = user
|
|
217
217
|
|
|
218
|
-
//
|
|
219
|
-
|
|
218
|
+
// older messages have no `service`; the name is encoded in `target` as
|
|
219
|
+
// `<targetPrefix><serviceName>` (see utils.targetName)
|
|
220
|
+
let srvName = _msg.service
|
|
221
|
+
if (!srvName) {
|
|
222
|
+
srvName = _task.target
|
|
223
|
+
if (opts.targetPrefix && srvName.startsWith(opts.targetPrefix))
|
|
224
|
+
srvName = srvName.slice(opts.targetPrefix.length)
|
|
225
|
+
}
|
|
220
226
|
delete _msg.service
|
|
221
227
|
|
|
222
228
|
const fromSend = _msg._fromSend
|
|
@@ -227,6 +233,7 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
227
233
|
|
|
228
234
|
const task = {
|
|
229
235
|
ID: _task.ID,
|
|
236
|
+
timestamp: _task.timestamp,
|
|
230
237
|
msg,
|
|
231
238
|
context,
|
|
232
239
|
srvName,
|
|
@@ -246,6 +253,19 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
246
253
|
let minAttemptFailed
|
|
247
254
|
try {
|
|
248
255
|
const _handleWithErr = async task => {
|
|
256
|
+
if (!cds.services[task.srvName]) {
|
|
257
|
+
// service not served/registered: unrecoverable, so dead-letter it (attempts -> maxAttempts)
|
|
258
|
+
// instead of crashing the app or blocking the queue
|
|
259
|
+
LOG.error(`No service '${task.srvName}' found for a queued message; treating as unrecoverable.`)
|
|
260
|
+
if (!minAttemptFailed) minAttemptFailed = task.attempts
|
|
261
|
+
else minAttemptFailed = Math.min(minAttemptFailed, task.attempts)
|
|
262
|
+
task[$error] = new Error(`No service '${task.srvName}' found`)
|
|
263
|
+
if (opts.maxAttempts) {
|
|
264
|
+
task.updateData = { attempts: opts.maxAttempts }
|
|
265
|
+
toBeUpdated.push(task)
|
|
266
|
+
} else toBeDeleted.push(task)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
249
269
|
const service = cds.unqueued(await cds.connect.to(task.srvName))
|
|
250
270
|
try {
|
|
251
271
|
if (task.msg.query) {
|
|
@@ -264,9 +284,21 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
264
284
|
// we can only delete in the current tx if legacyLocking (i.e., long-lasting db locks) is false
|
|
265
285
|
if (!opts.legacyLocking) {
|
|
266
286
|
const _delete = DELETE.from(tasksEntity).where({ ID: task.ID })
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
287
|
+
// guard on the selected row's timestamp (not `task.msg.timestamp`: `timestamp` is in
|
|
288
|
+
// `_ignoredContext`, absent from the msg, and reads back as `now`)
|
|
289
|
+
if (task.msg.queue?.task)
|
|
290
|
+
_delete.where({ timestamp: { '<': _timestampPlusOneMS(new Date(task.timestamp)) } })
|
|
291
|
+
const _deleted = await _delete
|
|
292
|
+
const _cnt = typeof _deleted === 'number' ? _deleted : (_deleted?.affected ?? 0)
|
|
293
|
+
// recreate the next occurrence in the same tx; skip if the guarded delete removed nothing
|
|
294
|
+
// (the singleton was unscheduled/rescheduled concurrently)
|
|
295
|
+
if (task.msg.queue?.every && (!task.msg.queue?.task || _cnt > 0)) {
|
|
296
|
+
const _m = { ...task.msg }
|
|
297
|
+
_m._fromSend = task.msg instanceof cds.Request
|
|
298
|
+
await INSERT.into(tasksEntity).entries(create_task(target, _m, task.context, task.srvName, opts, true))
|
|
299
|
+
// re-trigger the drain to schedule the new occurrence
|
|
300
|
+
_setWaitingTime(0)
|
|
301
|
+
}
|
|
270
302
|
}
|
|
271
303
|
return result
|
|
272
304
|
})
|
|
@@ -353,7 +385,7 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
353
385
|
if (each.updateData.lastError && typeof each.updateData.lastError !== 'string') {
|
|
354
386
|
each.updateData.lastError = inspect(each.updateData.lastError)
|
|
355
387
|
}
|
|
356
|
-
each.updateData.lastAttemptTimestamp = cds.context
|
|
388
|
+
each.updateData.lastAttemptTimestamp = cds.context?.timestamp ?? new Date()
|
|
357
389
|
queries.push(UPDATE(tasksEntity).where({ ID: each.ID }).set(each.updateData))
|
|
358
390
|
}
|
|
359
391
|
|
|
@@ -409,11 +441,11 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
409
441
|
}
|
|
410
442
|
}
|
|
411
443
|
|
|
412
|
-
// handle `every`
|
|
413
|
-
|
|
444
|
+
// handle `every` — !legacyLocking recreates in the success tx above; the legacyLocking path
|
|
445
|
+
// (deferred delete) recreates here, using a read-back to detect a concurrent unschedule/reschedule
|
|
446
|
+
if (task.msg.queue?.every && opts.legacyLocking) {
|
|
414
447
|
const _m = { ...task.msg }
|
|
415
448
|
if (task.msg.queue.task) {
|
|
416
|
-
// Read from database as the singleton task might have been modified/deleted in the meantime
|
|
417
449
|
const dbTask = await SELECT.one(tasksEntity).where({ ID: task.ID })
|
|
418
450
|
if (!dbTask) continue //> was unscheduled -> no next occurrence
|
|
419
451
|
Object.assign(_m, JSON.parse(dbTask.msg))
|
|
@@ -425,6 +457,8 @@ const _processTasks = (target, tenant, _opts = {}) => {
|
|
|
425
457
|
}
|
|
426
458
|
// invoke failed handlers (only if max attempts is reached)
|
|
427
459
|
for (const task of toBeUpdated) {
|
|
460
|
+
// skip dead-lettered messages whose service is not served/registered (see above)
|
|
461
|
+
if (!cds.services[task.srvName]) continue
|
|
428
462
|
if (
|
|
429
463
|
!task.msg.event.endsWith('/#succeeded') &&
|
|
430
464
|
!task.msg.event.endsWith('/#failed') &&
|
|
@@ -25,10 +25,15 @@ const $service = Symbol('service')
|
|
|
25
25
|
const MAX_WAITING_TIME = 1480000
|
|
26
26
|
const CAPATTEMPTS = Math.ceil(Math.log(MAX_WAITING_TIME / 1000 + 1) / Math.log(1.5))
|
|
27
27
|
|
|
28
|
+
const _timestampPlusOneMS = ts => new Date(ts.getTime() + 1).toISOString()
|
|
29
|
+
|
|
28
30
|
let _processableTasksQuery = {}
|
|
29
31
|
|
|
30
|
-
const
|
|
31
|
-
if (w.xpr) return w.xpr.map(xpr =>
|
|
32
|
+
const _inlineLiterals = w => {
|
|
33
|
+
if (w.xpr) return w.xpr.map(xpr => _inlineLiterals(xpr))
|
|
34
|
+
// also recurse into function args (e.g. `startswith(target, '<appid>')`) so a literal arg isn't
|
|
35
|
+
// bound as a `?` and shift the positional binding of the query's real params
|
|
36
|
+
if (w.args) return (Array.isArray(w.args) ? w.args : Object.values(w.args)).map(a => _inlineLiterals(a))
|
|
32
37
|
if (w.val === undefined) return w
|
|
33
38
|
w.param = false
|
|
34
39
|
return w
|
|
@@ -50,9 +55,14 @@ const _getProcessableTasksQuery = ({ maxAttempts, chunkSize }, queueName) => {
|
|
|
50
55
|
|
|
51
56
|
const appid = cds.env.appid
|
|
52
57
|
|
|
58
|
+
// also match pre-`appid` rows: `appid IS NULL` with `target` starting with `appid` (the former app prefix)
|
|
59
|
+
const targetCondition = appid
|
|
60
|
+
? `(appid = '${appid}' AND target = '${queueName}') OR (appid IS NULL AND startswith(target, '${appid}') = true)`
|
|
61
|
+
: `target = '${queueName}'`
|
|
62
|
+
|
|
53
63
|
const sql = `
|
|
54
64
|
SELECT ID FROM ${entity.name}
|
|
55
|
-
WHERE ${
|
|
65
|
+
WHERE (${targetCondition})
|
|
56
66
|
AND attempts < ${maxAttempts}
|
|
57
67
|
AND (status is NULL OR lastAttemptTimestamp < ?)
|
|
58
68
|
AND (${attemptConditions.join(' OR ')})
|
|
@@ -61,10 +71,9 @@ const _getProcessableTasksQuery = ({ maxAttempts, chunkSize }, queueName) => {
|
|
|
61
71
|
|
|
62
72
|
_processableTasksQuery[queueName] = cds.ql(sql)
|
|
63
73
|
_processableTasksQuery[queueName].SELECT.limit.rows.param = false
|
|
64
|
-
_processableTasksQuery[queueName].SELECT.where.map(w =>
|
|
74
|
+
_processableTasksQuery[queueName].SELECT.where.map(w => _inlineLiterals(w))
|
|
65
75
|
_processableTasksQuery[queueName].SELECT.columns = Object.keys(entity.elements).map(k => ({ ref: [k] }))
|
|
66
|
-
_processableTasksQuery[queueName].forUpdate()
|
|
67
|
-
_processableTasksQuery[queueName].SELECT.forUpdate.ignoreLocked = true
|
|
76
|
+
_processableTasksQuery[queueName].forUpdate({ ignoreLocked: true })
|
|
68
77
|
|
|
69
78
|
return _processableTasksQuery[queueName]
|
|
70
79
|
}
|
|
@@ -101,10 +110,9 @@ const _getFlushTasksQuery = ({ maxAttempts, chunkSize }, queueName) => {
|
|
|
101
110
|
ORDER BY timestamp ASC, ID ASC
|
|
102
111
|
LIMIT ${chunkSize}`)
|
|
103
112
|
_flushTasksQuery[queueName].SELECT.limit.rows.param = false
|
|
104
|
-
_flushTasksQuery[queueName].SELECT.where.map(w =>
|
|
113
|
+
_flushTasksQuery[queueName].SELECT.where.map(w => _inlineLiterals(w))
|
|
105
114
|
_flushTasksQuery[queueName].SELECT.columns = Object.keys(entity.elements).map(k => ({ ref: [k] }))
|
|
106
|
-
_flushTasksQuery[queueName].forUpdate()
|
|
107
|
-
_flushTasksQuery[queueName].SELECT.forUpdate.ignoreLocked = true
|
|
115
|
+
_flushTasksQuery[queueName].forUpdate({ ignoreLocked: true })
|
|
108
116
|
}
|
|
109
117
|
return _flushTasksQuery[queueName]
|
|
110
118
|
}
|
|
@@ -257,8 +265,13 @@ const _processTasks = (queueName, tenant, _opts = {}) => {
|
|
|
257
265
|
const user = new cds.User.Privileged(userId)
|
|
258
266
|
context.user = user
|
|
259
267
|
|
|
260
|
-
//
|
|
261
|
-
|
|
268
|
+
// older messages have no `service`; the name is encoded in `target` as `<appid><serviceName>`
|
|
269
|
+
let srvName = _msg.service
|
|
270
|
+
if (!srvName) {
|
|
271
|
+
srvName = _task.target
|
|
272
|
+
const appid = cds.env.appid
|
|
273
|
+
if (appid && srvName.startsWith(appid)) srvName = srvName.slice(appid.length)
|
|
274
|
+
}
|
|
262
275
|
delete _msg.service
|
|
263
276
|
|
|
264
277
|
const fromSend = _msg._fromSend
|
|
@@ -301,6 +314,15 @@ const _processTasks = (queueName, tenant, _opts = {}) => {
|
|
|
301
314
|
|
|
302
315
|
try {
|
|
303
316
|
const _handleWithErr = async task => {
|
|
317
|
+
if (!cds.services[task.srvName]) {
|
|
318
|
+
// service not served/registered: unrecoverable, so dead-letter it (attempts -> maxAttempts)
|
|
319
|
+
// instead of crashing the app or blocking the queue
|
|
320
|
+
LOG.error(`No service '${task.srvName}' found for a queued message; treating as unrecoverable.`)
|
|
321
|
+
task[$error] = new Error(`No service '${task.srvName}' found`)
|
|
322
|
+
task.updateData = { attempts: opts.maxAttempts }
|
|
323
|
+
toBeUpdated.push(task)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
304
326
|
const service = cds.unqueued(await cds.connect.to(task.srvName))
|
|
305
327
|
try {
|
|
306
328
|
if (task.msg.query) {
|
|
@@ -330,12 +352,18 @@ const _processTasks = (queueName, tenant, _opts = {}) => {
|
|
|
330
352
|
.where({ msg: { like: `%"tenant":"${task.msg.data.tenant}"%` } })
|
|
331
353
|
.where({ msg: { like: `%"target":"${task.msg.data.target}"%` } })
|
|
332
354
|
} else {
|
|
333
|
-
|
|
355
|
+
const _delete = DELETE.from(OUTBOX).where({ ID: task.ID })
|
|
356
|
+
// guard on the selected row's timestamp so a concurrent unschedule/reschedule is
|
|
357
|
+
// serialized against the recurrence recreated below
|
|
358
|
+
if (task.msg.queue?.task)
|
|
359
|
+
_delete.where({ timestamp: { '<': _timestampPlusOneMS(new Date(task._task.timestamp)) } })
|
|
360
|
+
const _deleted = await _delete
|
|
361
|
+
const _cnt = typeof _deleted === 'number' ? _deleted : (_deleted?.affected ?? 0)
|
|
334
362
|
|
|
335
363
|
// For atomicity: create follow-up tasks in same transaction as delete
|
|
336
364
|
const follow_ups = []
|
|
337
365
|
|
|
338
|
-
// #succeeded callback
|
|
366
|
+
// #succeeded callback — always owed once the in-flight execution completed
|
|
339
367
|
if (!task.msg.event.endsWith('/#succeeded') && !task.msg.event.endsWith('/#failed')) {
|
|
340
368
|
const succeededMsg = _newMsgForCallbackFrom(task.msg)
|
|
341
369
|
succeededMsg.event = succeededMsg.event + '/#succeeded'
|
|
@@ -352,8 +380,9 @@ const _processTasks = (queueName, tenant, _opts = {}) => {
|
|
|
352
380
|
}
|
|
353
381
|
}
|
|
354
382
|
|
|
355
|
-
//
|
|
356
|
-
|
|
383
|
+
// next occurrence — only if the guarded delete removed the row (else it was unscheduled/
|
|
384
|
+
// rescheduled concurrently); non-singletons always recreate
|
|
385
|
+
if (task.msg.queue?.every && (!task.msg.queue?.task || _cnt > 0)) {
|
|
357
386
|
const _m = { ...task.msg }
|
|
358
387
|
_m._fromSend = task.msg instanceof cds.Request
|
|
359
388
|
const next_iteration_task = create_task(queueName, _m, task.context, task.srvName, opts, true)
|
|
@@ -423,7 +452,7 @@ const _processTasks = (queueName, tenant, _opts = {}) => {
|
|
|
423
452
|
if (each.updateData.lastError && typeof each.updateData.lastError !== 'string') {
|
|
424
453
|
each.updateData.lastError = inspect(each.updateData.lastError)
|
|
425
454
|
}
|
|
426
|
-
each.updateData.lastAttemptTimestamp = cds.context.
|
|
455
|
+
each.updateData.lastAttemptTimestamp = cds.context?.timestamp ?? new Date().toISOString()
|
|
427
456
|
follow_up_tasks.push(Object.assign({}, each._task, each.updateData))
|
|
428
457
|
afters.add(waitingTime(each.updateData.attempts))
|
|
429
458
|
}
|
|
@@ -441,6 +470,8 @@ const _processTasks = (queueName, tenant, _opts = {}) => {
|
|
|
441
470
|
|
|
442
471
|
// Create #failed callbacks (only if max attempts is reached)
|
|
443
472
|
for (const task of toBeUpdated) {
|
|
473
|
+
// skip dead-lettered messages whose service is not served/registered (see above)
|
|
474
|
+
if (!cds.services[task.srvName]) continue
|
|
444
475
|
if (
|
|
445
476
|
!is_t0 &&
|
|
446
477
|
!task.msg.event.endsWith('/#succeeded') &&
|
|
@@ -51,6 +51,9 @@ const create_task = (queueName, msg, context, serviceName, queueOpts, reoccurren
|
|
|
51
51
|
appid: cds.env.appid,
|
|
52
52
|
target: cds.env.appid ? queueName : targetName(queueName, queueOpts),
|
|
53
53
|
timestamp,
|
|
54
|
+
// reset status so re-submitting a task that is currently being processed (UPSERT over its
|
|
55
|
+
// 'processing' row) yields a processable row again instead of a stuck one
|
|
56
|
+
status: null,
|
|
54
57
|
msg: JSON.stringify(_msg)
|
|
55
58
|
}
|
|
56
59
|
|
|
@@ -19,11 +19,19 @@ const xsuaaSrv = Object.keys(vcap_services)
|
|
|
19
19
|
if (!xsuaaSrv)
|
|
20
20
|
throw new Error('You need to provide credentials of service `XSUAA` in environment variable `VCAP_SERVICES`')
|
|
21
21
|
const tokenStore = {}
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
const xsuaaCreds = xsuaaSrv.credentials
|
|
23
|
+
const oa2 =
|
|
24
|
+
xsuaaCreds['credential-type'] === 'x509'
|
|
25
|
+
? {
|
|
26
|
+
client: xsuaaCreds.clientid,
|
|
27
|
+
endpoint: xsuaaCreds.certurl,
|
|
28
|
+
mTLS: { cert: xsuaaCreds.certificate, key: xsuaaCreds.key }
|
|
29
|
+
}
|
|
30
|
+
: {
|
|
31
|
+
client: xsuaaCreds.clientid,
|
|
32
|
+
secret: xsuaaCreds.clientsecret,
|
|
33
|
+
endpoint: xsuaaCreds.url
|
|
34
|
+
}
|
|
27
35
|
|
|
28
36
|
const main = async () => {
|
|
29
37
|
// use authorizedRequest to fire against deploy endpoint
|
|
@@ -15,6 +15,21 @@ might use a deprecated flag only for a limited period of time.
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
## Version 7.1.1 - 2026-09-08
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
- **effective:**
|
|
23
|
+
+ Fix dump in flattening for actions with entity parameters.
|
|
24
|
+
+ Ensure consistent handling of reference-like annotation expressions during flattening.
|
|
25
|
+
- **sql:**
|
|
26
|
+
+ Add missing `@hierarchy` columns to localized convenience views.
|
|
27
|
+
+ Avoid crash on mixin backlink with renamed target element.
|
|
28
|
+
+ The generated SQL for a reference `assoc.elem` where `assoc` is an association defined with duplicate backlink comparisons with `$self` is now more likely
|
|
29
|
+
to be correct. Usually, there is an error for the definition of such an `assoc`, but the corresponding message can be downgraded to a warning.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
18
33
|
## Version 7.1.0 - 2026-08-27
|
|
19
34
|
|
|
20
35
|
### Features
|
|
@@ -1189,9 +1189,13 @@ function resolve( model ) {
|
|
|
1189
1189
|
const backlinkPath = itemIsPath ? item : right;
|
|
1190
1190
|
const last = backlinkPath.path[backlinkPath.path.length - 1];
|
|
1191
1191
|
if (assoc.$backlink) {
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1192
|
+
if (!assoc.on.$inferred) {
|
|
1193
|
+
const location = combinedLocation( item.path[0], right.path[right.path.length - 1] );
|
|
1194
|
+
message( 'ref-duplicate-self-comparison', [ location, assoc ], { id: '$self' },
|
|
1195
|
+
'Duplicate comparison with a bare $(ID) reference in an ON-condition' );
|
|
1196
|
+
}
|
|
1197
|
+
if (last?._artifact !== assoc.$backlink._origin)
|
|
1198
|
+
setLink( assoc.$backlink, '_origin', null );
|
|
1195
1199
|
return;
|
|
1196
1200
|
}
|
|
1197
1201
|
const backlinkRef = last?._artifact;
|
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
applyTransformationsOnNonDictionary,
|
|
9
9
|
applyTransformationsOnDictionary,
|
|
10
10
|
mergeTransformers,
|
|
11
|
+
isSimpleAnnoExprRef,
|
|
11
12
|
} = require('../transform/db/applyTransformations');
|
|
12
13
|
const { isBuiltinType, isAnnotationExpression, primaryExprProperties } = require('../base/builtins');
|
|
13
14
|
const { ModelError, CompilerAssertion } = require('../base/error');
|
|
@@ -1512,6 +1513,7 @@ module.exports = {
|
|
|
1512
1513
|
applyTransformationsOnNonDictionary,
|
|
1513
1514
|
applyTransformationsOnDictionary,
|
|
1514
1515
|
mergeTransformers,
|
|
1516
|
+
isSimpleAnnoExprRef,
|
|
1515
1517
|
setDependencies,
|
|
1516
1518
|
isPersistedOnDatabase,
|
|
1517
1519
|
isPersistedAsView,
|
|
@@ -423,6 +423,17 @@ function mergeTransformers( transformers, that ) {
|
|
|
423
423
|
return remapped;
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Return true if the annotation expression ref node is a simple reference:
|
|
428
|
+
* all ref steps are plain strings, and the node has neither `param` nor `cast`.
|
|
429
|
+
*
|
|
430
|
+
* @param {object} annotation An annotation expression node (has `ref`, optionally `param`, `cast`)
|
|
431
|
+
* @returns {boolean}
|
|
432
|
+
*/
|
|
433
|
+
function isSimpleAnnoExprRef(annotation) {
|
|
434
|
+
return annotation.ref && annotation.ref.every(s => typeof s === 'string') && !annotation.param && !annotation.cast;
|
|
435
|
+
}
|
|
436
|
+
|
|
426
437
|
module.exports = {
|
|
427
438
|
mergeTransformers,
|
|
428
439
|
transformExpression,
|
|
@@ -430,6 +441,7 @@ module.exports = {
|
|
|
430
441
|
applyTransformations,
|
|
431
442
|
applyTransformationsOnNonDictionary,
|
|
432
443
|
applyTransformationsOnDictionary,
|
|
444
|
+
isSimpleAnnoExprRef,
|
|
433
445
|
};
|
|
434
446
|
|
|
435
447
|
|
|
@@ -5,6 +5,7 @@ const {
|
|
|
5
5
|
applyTransformationsOnNonDictionary,
|
|
6
6
|
applyTransformations,
|
|
7
7
|
implicitAs,
|
|
8
|
+
isSimpleAnnoExprRef,
|
|
8
9
|
} = require('../../model/csnUtils');
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -138,7 +139,7 @@ function getFKAccessFinalizer( csn, options, csnUtils, pathDelimiter, processOnI
|
|
|
138
139
|
const annoAfter = JSON.stringify(obj[anno]);
|
|
139
140
|
if (annoBefore !== annoAfter &&
|
|
140
141
|
(options.transformation !== 'effective' || obj[anno]['=']) &&
|
|
141
|
-
!(obj[anno].ref && obj[anno]
|
|
142
|
+
!(obj[anno].ref && isSimpleAnnoExprRef(obj[anno])))
|
|
142
143
|
obj[anno]['='] = true;
|
|
143
144
|
});
|
|
144
145
|
}
|
|
@@ -251,10 +252,11 @@ function getFKAccessFinalizer( csn, options, csnUtils, pathDelimiter, processOnI
|
|
|
251
252
|
else {
|
|
252
253
|
refOwner.ref = [ ...ref.slice(0, i), newFkName ];
|
|
253
254
|
}
|
|
254
|
-
if (ctx?.annoExpr === refOwner)
|
|
255
|
-
ctx.annoExpr['='] = refOwner
|
|
255
|
+
if (ctx?.annoExpr === refOwner) {
|
|
256
|
+
ctx.annoExpr['='] = isSimpleAnnoExprRef(refOwner)
|
|
256
257
|
? refOwner.ref.join('.')
|
|
257
258
|
: true;
|
|
259
|
+
}
|
|
258
260
|
}
|
|
259
261
|
}
|
|
260
262
|
else {
|