@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/bind.js +4 -0
  3. package/bin/build.js +1 -0
  4. package/bin/compile/index.js +12 -11
  5. package/bin/completion/index.js +21 -10
  6. package/bin/completion/scripts/cds.fish +179 -127
  7. package/bin/completion/scripts/cds.ps1 +88 -14
  8. package/bin/completion/scripts/cds.sh +71 -5
  9. package/bin/completion/scripts/cds.zsh +81 -15
  10. package/bin/completion/templates/cds-tmpl.ps1 +15 -6
  11. package/bin/deploy/index.js +7 -3
  12. package/bin/env.js +4 -1
  13. package/bin/export.js +9 -1
  14. package/bin/import.js +4 -1
  15. package/bin/init.js +1 -0
  16. package/bin/inspect.js +3 -1
  17. package/bin/lint.js +5 -3
  18. package/bin/minify.js +6 -1
  19. package/bin/repl.js +11 -2
  20. package/bin/run.js +12 -1
  21. package/bin/serve.js +13 -2
  22. package/bin/up.js +4 -1
  23. package/bin/util/containerize.js +2 -0
  24. package/bin/watch/index.js +8 -1
  25. package/lib/build/plugins/internal.js +0 -26
  26. package/lib/build/plugins/nodejs/index.js +8 -3
  27. package/lib/build/util.js +2 -4
  28. package/lib/cds.js +8 -2
  29. package/lib/init/constants.js +1 -1
  30. package/lib/init/merge.js +21 -17
  31. package/lib/init/template/cloud-logging/index.js +3 -3
  32. package/lib/init/template/data/as-json.js +3 -0
  33. package/lib/init/template/destination/index.js +3 -3
  34. package/lib/init/template/enterprise-messaging/index.js +4 -1
  35. package/lib/init/template/event-mesh/index.js +3 -0
  36. package/lib/init/template/multitenancy/index.js +2 -2
  37. package/lib/init/template/workzone-standard/index.js +1 -1
  38. package/node_modules/@cap-js/db-service/CHANGELOG.md +30 -0
  39. package/node_modules/@cap-js/db-service/lib/common/generic-pool.js +4 -7
  40. package/node_modules/@cap-js/db-service/lib/cqn2pql.js +1 -1
  41. package/node_modules/@cap-js/db-service/lib/cqn2sql.js +15 -11
  42. package/node_modules/@cap-js/db-service/lib/cqn4sql.js +125 -21
  43. package/node_modules/@cap-js/db-service/lib/infer/index.js +27 -16
  44. package/node_modules/@cap-js/db-service/lib/recursion-guard.js +40 -0
  45. package/node_modules/@cap-js/db-service/lib/search.js +1 -1
  46. package/node_modules/@cap-js/db-service/package.json +1 -1
  47. package/node_modules/@cap-js/sqlite/CHANGELOG.md +28 -0
  48. package/node_modules/@cap-js/sqlite/lib/SQLiteService.js +110 -14
  49. package/node_modules/@cap-js/sqlite/package.json +3 -3
  50. package/node_modules/@sap/cds/CHANGELOG.md +19 -0
  51. package/node_modules/@sap/cds/common.cds +3 -3
  52. package/node_modules/@sap/cds/lib/compile/minify.js +3 -1
  53. package/node_modules/@sap/cds/lib/env/cds-env.js +1 -1
  54. package/node_modules/@sap/cds/lib/env/cds-requires.js +1 -0
  55. package/node_modules/@sap/cds/libx/_runtime/common/generic/crud.js +7 -1
  56. package/node_modules/@sap/cds/libx/_runtime/common/utils/cqn.js +1 -1
  57. package/node_modules/@sap/cds/libx/_runtime/fiori/lean-draft.js +1 -1
  58. package/node_modules/@sap/cds/libx/_runtime/messaging/common-utils/normalizeIncomingMessage.js +1 -1
  59. package/node_modules/@sap/cds/libx/_runtime/messaging/kafka.js +1 -1
  60. package/node_modules/@sap/cds/libx/http/location.js +10 -11
  61. package/node_modules/@sap/cds/libx/odata/middleware/read.js +1 -1
  62. package/node_modules/@sap/cds/libx/queue/SchedulingService.js +4 -0
  63. package/node_modules/@sap/cds/libx/queue/index.js +44 -10
  64. package/node_modules/@sap/cds/libx/queue/processing.js +47 -16
  65. package/node_modules/@sap/cds/libx/queue/utils.js +3 -0
  66. package/node_modules/@sap/cds/package.json +1 -1
  67. package/node_modules/@sap/cds/tasks/enterprise-messaging-deploy.js +13 -5
  68. package/node_modules/@sap/cds-compiler/CHANGELOG.md +15 -0
  69. package/node_modules/@sap/cds-compiler/lib/compiler/resolve.js +7 -3
  70. package/node_modules/@sap/cds-compiler/lib/model/csnUtils.js +2 -0
  71. package/node_modules/@sap/cds-compiler/lib/transform/db/applyTransformations.js +12 -0
  72. package/node_modules/@sap/cds-compiler/lib/transform/db/associations.js +5 -3
  73. package/node_modules/@sap/cds-compiler/lib/transform/db/flattening.js +4 -3
  74. package/node_modules/@sap/cds-compiler/lib/transform/effective/flattening.js +17 -1
  75. package/node_modules/@sap/cds-compiler/lib/transform/effective/main.js +19 -0
  76. package/node_modules/@sap/cds-compiler/lib/transform/fioriTreeViews.js +12 -1
  77. package/node_modules/@sap/cds-compiler/lib/transform/forRelationalDB.js +1 -1
  78. package/node_modules/@sap/cds-compiler/lib/transform/odata/adaptAnnotationRefs.js +11 -5
  79. package/node_modules/@sap/cds-compiler/lib/transform/translateAssocsToJoins.js +29 -2
  80. package/node_modules/@sap/cds-compiler/package.json +1 -1
  81. package/node_modules/@sap/cds-mtxs/CHANGELOG.md +34 -0
  82. package/node_modules/@sap/cds-mtxs/bin/cds-mtx-migrate.js +26 -5
  83. package/node_modules/@sap/cds-mtxs/bin/cds-mtx.js +3 -0
  84. package/node_modules/@sap/cds-mtxs/lib/utils.js +5 -4
  85. package/node_modules/@sap/cds-mtxs/package.json +1 -1
  86. package/node_modules/@sap/cds-mtxs/srv/cf/abstract-provisioning-service.js +62 -7
  87. package/node_modules/@sap/cds-mtxs/srv/cf/saas-provisioning-service.js +1 -1
  88. package/node_modules/@sap/cds-mtxs/srv/cf/sms-provisioning-service.js +23 -3
  89. package/node_modules/@sap/cds-mtxs/srv/extensibility/compat/update.js +15 -0
  90. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/annotations.js +2 -2
  91. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/config.js +2 -0
  92. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/index.js +2 -0
  93. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/protected-namespace.js +45 -0
  94. package/node_modules/@sap/cds-mtxs/srv/extensibility-service.js +8 -3
  95. package/node_modules/@sap/cds-mtxs/srv/jobs-service.js +49 -10
  96. package/node_modules/@sap/cds-mtxs/srv/model-provider.js +11 -2
  97. package/node_modules/@sap/cds-mtxs/srv/plugins/common.js +19 -12
  98. package/node_modules/@sap/cds-mtxs/srv/plugins/hana/hdi-ctnr-mgr.js +6 -3
  99. package/node_modules/@sap/cds-mtxs/srv/plugins/hana/serverless.js +271 -0
  100. package/node_modules/@sap/cds-mtxs/srv/plugins/hana.js +3 -2
  101. package/node_modules/@sap/cds-mtxs/srv/worker/job-worker-pool.js +212 -0
  102. package/node_modules/@sap/cds-mtxs/srv/worker/job-worker.js +61 -0
  103. package/node_modules/proxy-addr/HISTORY.md +60 -54
  104. package/node_modules/proxy-addr/README.md +1 -1
  105. package/node_modules/proxy-addr/index.js +32 -0
  106. package/node_modules/proxy-addr/package.json +8 -4
  107. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  108. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  109. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  110. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  111. package/node_modules/yaml/package.json +1 -1
  112. package/package.json +2 -2
  113. /package/node_modules/@sap/cds-mtxs/srv/worker/{pool.js → model-worker-pool.js} +0 -0
  114. /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.Countries with { code @Common.Text:name; }
170
- annotate sap.common.Currencies with { code @Common.Text:name; }
171
- annotate sap.common.Languages with { code @Common.Text:name; }
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 (q.columns) _keep (q.columns)
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
  }
@@ -138,6 +138,7 @@ const _databases = {
138
138
 
139
139
  "sqlite": {
140
140
  impl: '@cap-js/sqlite', credentials: { url: 'db.sqlite' },
141
+ pool: { evictionRunIntervalMillis: 0, min: 1, max: 1 },
141
142
  ..._db_data,
142
143
  },
143
144
  "sqlite:memory": {
@@ -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(() => null)
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 = req.target.associations[assoc]._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 {
@@ -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
- acc[key] = value.toString('base64')
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 _type = target.elements[key]._type
17
- if (!keys_as_segments && typeof value === 'string' && _type !== 'cds.UUID') acc[key] = `'${value}'`
18
- else acc[key] = value
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].$count ?? 0) : (result.$count ?? 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.SELECT.forUpdate.ignoreLocked = true
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
- // The latter is for backwards compatibility where `service` was not stored and `target` was the service name
219
- const srvName = _msg.service || _task.target
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
- // only delete singleton task if not modified in the meantime
268
- if (task.msg.queue?.task) _delete.where({ timestamp: { '<': _timestampPlusOneMS(task.msg.timestamp) } })
269
- await _delete
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.timestamp
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
- if (task.msg.queue?.every) {
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 _addParamFalse = w => {
31
- if (w.xpr) return w.xpr.map(xpr => _addParamFalse(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 ${appid ? `appid = '${appid}' AND ` : ''}target = '${queueName}'
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 => _addParamFalse(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 => _addParamFalse(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
- // The latter is for backwards compatibility where `service` was not stored and `target` was the service name
261
- const srvName = _msg.service || _task.target
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
- await DELETE.from(OUTBOX).where({ ID: task.ID })
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 (only if not already a callback event)
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
- // Next recurrence for .every() tasks
356
- if (task.msg.queue?.every) {
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.timestamp
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "10.1.0",
3
+ "version": "10.1.1",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [
@@ -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 oa2 = {
23
- client: xsuaaSrv.credentials.clientid,
24
- secret: xsuaaSrv.credentials.clientsecret,
25
- endpoint: xsuaaSrv.credentials.url
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
- const location = combinedLocation( item.path[0], right.path[right.path.length - 1] );
1193
- message( 'ref-duplicate-self-comparison', [ location, assoc ], { id: '$self' },
1194
- 'Duplicate comparison with a bare $(ID) reference in an ON-condition' );
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].ref.every(s => typeof s === 'string')))
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.ref.every(s => typeof s === 'string')
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 {