@sap/cds 8.0.3 → 8.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@
4
4
  - The format is based on [Keep a Changelog](http://keepachangelog.com/).
5
5
  - This project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## Version 8.0.4 - 2024-07-19
8
+
9
+ ### Fixed
10
+
11
+ - Atomicity group handling in `$batch`
12
+ - `$batch` im combination with `commit` hooks
13
+ - `continue-on-error` preference for JSON `$batch`
14
+
7
15
  ## Version 8.0.3 - 2024-07-12
8
16
 
9
17
  ### Added
@@ -233,6 +233,7 @@ const _messaging = {
233
233
  },
234
234
  "event-broker": {
235
235
  impl: `${_runtime}/messaging/event-broker.js`,
236
+ format: 'cloudevents',
236
237
  vcap: {
237
238
  label: "eventmesh-sap2sap-internal"
238
239
  }
@@ -82,7 +82,7 @@ const hasDeepInsert = (model, cqn) => {
82
82
  const getDeepInsertCQNs = (model, cqn) => {
83
83
  const into = (cqn.INSERT.into.ref && cqn.INSERT.into.ref[0]) || cqn.INSERT.into.name || cqn.INSERT.into
84
84
  const entityName = ensureNoDraftsSuffix(into)
85
- const draft = entityName !== into
85
+ const draft = entityName !== into || into.endsWith('.drafts') //lean draft
86
86
  const dataEntries = cqn.INSERT.entries ? deepCopy(cqn.INSERT.entries) : []
87
87
  const entity = model.definitions[entityName]
88
88
  const compositionTree = getCompositionTree({
@@ -60,17 +60,26 @@ const read = (executeSelectCQN, executeStreamCQN, convertStreams) => (model, dbc
60
60
 
61
61
  if (query.SELECT.count) {
62
62
  if (query.SELECT.limit) {
63
+ // IMPORTANT: do not change order!
64
+ // 1. create the count query synchronously, because it works on a copy
65
+ // 2. run the result query, duplicate names ( SELECT ID, ID ...) throw an error synchronously
66
+ // 3. run the count query
67
+ // reason is that executeSelectCQN modifies the query
63
68
  const countQuery = _createCountQuery(query)
64
- const countResultPromise = executeSelectCQN(model, dbc, countQuery, user, locale, isoTs)
65
- if (query.SELECT.limit?.rows?.val === 0) {
66
- // We don't need to perform our result query
67
- return countResultPromise.then(countResults => _arrayWithCount([], countValue(countResults)))
68
- }
69
+ const resultPromise =
70
+ query.SELECT.limit?.rows?.val === 0
71
+ ? Promise.resolve([])
72
+ : executeSelectCQN(model, dbc, query, user, locale, isoTs)
73
+ const countPromise = executeSelectCQN(model, dbc, countQuery, user, locale, isoTs)
69
74
 
70
- const resultPromise = executeSelectCQN(model, dbc, query, user, locale, isoTs)
71
- return Promise.all([countResultPromise, resultPromise]).then(([countResults, result]) =>
72
- _arrayWithCount(result, countValue(countResults))
73
- )
75
+ // use allSettled here, so all executions are awaited before we rollback
76
+ return Promise.allSettled([countPromise, resultPromise]).then(results => {
77
+ const rejection = results.find(p => p.status === 'rejected')
78
+ if (rejection) throw rejection.reason
79
+
80
+ const [{ value: countResult }, { value: result }] = results
81
+ return _arrayWithCount(result, countValue(countResult))
82
+ })
74
83
  }
75
84
 
76
85
  return executeSelectCQN(model, dbc, query, user, locale, isoTs).then(result =>
@@ -824,7 +824,7 @@ const Read = {
824
824
  else ownNewDrafts.push(draft)
825
825
  }
826
826
 
827
- const $count = ownDrafts.length + (isCount ? actives[0]?.$count : actives.$count ?? 0)
827
+ const $count = ownDrafts.length + (isCount ? actives[0]?.$count : (actives.$count ?? 0))
828
828
  if (isCount) return { $count }
829
829
 
830
830
  Read.merge(query._target, ownDrafts, [], row => {
@@ -1,5 +1,6 @@
1
1
  const cds = require('../cds')
2
2
 
3
+ const normalizeIncomingMessage = require('./common-utils/normalizeIncomingMessage')
3
4
  const express = require('express')
4
5
  const https = require('https')
5
6
  const crypto = require('crypto')
@@ -141,30 +142,57 @@ class EventBroker extends cds.MessagingService {
141
142
 
142
143
  // TODO: What if we're in single tenant variant?
143
144
  try {
144
- const ceSource = `${this.options.credentials.ceSource[0]}/${cds.context.tenant}`
145
145
  const hostname = this.options.credentials.eventing.http.x509.url.replace(/^https?:\/\//, '')
146
- // TODO Cloud Events Handler CAP
146
+
147
+ // take over and cleanse cloudevents headers
148
+ const headers = { ...(msg.headers ?? {}) }
149
+
150
+ const ceId = headers.id
151
+ delete headers.id
152
+
153
+ const ceSource = headers.source
154
+ delete headers.source
155
+
156
+ const ceType = headers.type
157
+ delete headers.type
158
+
159
+ const ceSpecversion = headers.specversion
160
+ delete headers.specversion
161
+
162
+ // const ceDatacontenttype = headers.datacontenttype // not part of the HTTP API
163
+ delete headers.datacontenttype
164
+
165
+ // const ceTime = headers.time // not part of the HTTP API
166
+ delete headers.time
167
+
147
168
  const options = {
148
169
  hostname: hostname,
149
170
  method: 'POST',
150
171
  headers: {
151
- 'ce-id': cds.utils.uuid(),
172
+ 'ce-id': ceId,
152
173
  'ce-source': ceSource,
153
- 'ce-type': msg.event,
154
- 'ce-specversion': '1.0',
155
- 'Content-Type': 'application/json'
174
+ 'ce-type': ceType,
175
+ 'ce-specversion': ceSpecversion,
176
+ 'Content-Type': 'application/json' // because of { data, ...headers } format
156
177
  },
157
178
  agent: this.agent
158
179
  }
159
180
  this.LOG.debug('HTTP headers:', JSON.stringify(options.headers))
160
181
  this.LOG.debug('HTTP body:', JSON.stringify(msg.data))
161
- await request(options, msg.data) // TODO: fetch does not work with mTLS as of today, requires another module. see https://github.com/nodejs/node/issues/48977
182
+ // what about headers?
183
+ // TODO: Clarify if we should send `{ data, ...headers }` vs. `data` + HTTP headers (`ce-*`)
184
+ await request(options, { data: msg.data, ...headers }) // TODO: fetch does not work with mTLS as of today, requires another module. see https://github.com/nodejs/node/issues/48977
162
185
  if (this.LOG._info) this.LOG.info('Emit', { topic: msg.event })
163
186
  } catch (e) {
164
187
  this.LOG.error('Emit failed:', e.message)
165
188
  }
166
189
  }
167
190
 
191
+ prepareHeaders(headers, event) {
192
+ if (!('source' in headers)) headers.source = `${this.options.credentials.ceSource[0]}/${cds.context.tenant}`
193
+ super.prepareHeaders(headers, event)
194
+ }
195
+
168
196
  async registerWebhookEndpoints() {
169
197
  const webhookBasePath = this.options.webhookPath || '/-/cds/event-broker/webhook'
170
198
  cds.app.post(webhookBasePath, _validateCertificate.bind(this))
@@ -176,17 +204,27 @@ class EventBroker extends cds.MessagingService {
176
204
  try {
177
205
  const event = req.headers['ce-type'] // TG27: type contains namespace, so there's no collision
178
206
  const tenant = req.headers['ce-sapconsumertenant']
179
- const msg = {
180
- inbound: true,
181
- event,
182
- tenant,
183
- data: req.body ? req.body : undefined,
184
- headers: req.headers
207
+
208
+ // take over cloudevents headers (`ce-*`) without the prefix
209
+ const headers = {}
210
+ for (const header in req.headers) {
211
+ if (header.startsWith('ce-')) headers[header.slice(3)] = req.headers[header]
185
212
  }
186
- cds.context = { user: cds.User.privileged }
187
- if (tenant) cds.context.tenant = tenant // TODO: In single tenant case, we don't need a tenant
188
- const tx = await this.tx()
189
- await tx.emit(msg)
213
+
214
+ const msg = normalizeIncomingMessage(req.body)
215
+ msg.event = event
216
+ Object.assign(msg.headers, headers)
217
+ if (tenant) msg.tenant = tenant
218
+
219
+ // for cds.context.http
220
+ msg._ = {}
221
+ msg._.req = req
222
+ msg._.res = res
223
+
224
+ const context = { user: cds.User.privileged, _: msg._ }
225
+ if (msg.tenant) context.tenant = msg.tenant
226
+
227
+ await this.tx(context, tx => tx.emit(msg))
190
228
  this.LOG.debug('Event processed successfully.')
191
229
  return res.status(200).json({ message: 'OK' })
192
230
  } catch (e) {
@@ -13,8 +13,6 @@ const HTTP_METHODS = { GET: 1, POST: 1, PUT: 1, PATCH: 1, DELETE: 1 }
13
13
  const CT = { JSON: 'application/json', MULTIPART: 'multipart/mixed' }
14
14
  const CRLF = '\r\n'
15
15
 
16
- const CONTINUE_ON_ERROR = /odata\.continue-on-error/
17
-
18
16
  /*
19
17
  * common
20
18
  */
@@ -99,16 +97,17 @@ const _validateBatch = body => {
99
97
  _validateProperty('dependent request ID', dependsOnId, 'string')
100
98
 
101
99
  const dependency = ids[dependsOnId]
102
- if (!dependency)
103
- throw _deserializationError(`Request ID '${dependsOnId}' used in dependsOn has not been defined before.`)
104
-
105
- const dependencyAtomicityGroup = dependency.atomicityGroup
106
- if (
107
- dependencyAtomicityGroup &&
108
- dependencyAtomicityGroup !== atomicityGroup &&
109
- !request.dependsOn.includes(dependencyAtomicityGroup)
110
- )
111
- request.dependsOn.push(dependencyAtomicityGroup)
100
+ if (!dependency) {
101
+ throw _deserializationError(
102
+ `"${dependsOnId}" does not match the id or atomicity group of any preceding request`
103
+ )
104
+ }
105
+
106
+ // automatically add the atomicityGroup of the dependency as a dependency (actually a client error)
107
+ const dag = dependency.atomicityGroup
108
+ if (dag && dag !== atomicityGroup && !request.dependsOn.includes(dag)) {
109
+ request.dependsOn.push(dag)
110
+ }
112
111
  })
113
112
  }
114
113
 
@@ -227,7 +226,7 @@ const _getNextForLookalike = lookalike => {
227
226
  const _transaction = async srv => {
228
227
  return new Promise(res => {
229
228
  const ret = {}
230
- const _tx = srv.tx(
229
+ const _tx = (ret._tx = srv.tx(
231
230
  async () =>
232
231
  (ret.promise = new Promise((resolve, reject) => {
233
232
  const proms = []
@@ -253,10 +252,49 @@ const _transaction = async srv => {
253
252
  }
254
253
  res(ret)
255
254
  }))
256
- )
255
+ ))
257
256
  })
258
257
  }
259
258
 
259
+ const _tx_done = async (tx, responses, isJson) => {
260
+ let rejected
261
+ try {
262
+ rejected = await tx.done()
263
+ } catch (e) {
264
+ // here, the commit was rejected even though all requests were successful (e.g., by custom handler or db consistency check)
265
+ rejected = 'rejected'
266
+ // construct commit error
267
+ let statusCode = e.statusCode || e.status || (e.code && Number(e.code))
268
+ if (isNaN(statusCode)) statusCode = 500
269
+ const code = String(e.code || statusCode)
270
+ const message = e.message || 'Internal Server Error'
271
+ const error = { error: { ...e, code, message } }
272
+ // replace all responses with commit error
273
+ for (const res of responses) {
274
+ res.status = 'fail'
275
+ // REVISIT: should error go through any error middleware/ customization logic?
276
+ if (isJson) {
277
+ let txt = ''
278
+ for (let i = 0; i < res.txt.length; i++) txt += Buffer.isBuffer(res.txt[i]) ? res.txt[i].toString() : res.txt[i]
279
+ txt = JSON.parse(txt)
280
+ txt.status = statusCode
281
+ txt.body = error
282
+ // REVISIT: content-length needed? not there in multipart case...
283
+ delete txt.headers['content-length']
284
+ res.txt = [JSON.stringify(txt)]
285
+ } else {
286
+ let txt = res.txt[0]
287
+ txt = txt.replace(/HTTP\/1\.1 \d\d\d \w+/, `HTTP/1.1 ${statusCode} ${message}`)
288
+ txt = txt.split(/\r\n/)
289
+ txt.splice(-1, 1, JSON.stringify(error))
290
+ txt = txt.join('\r\n')
291
+ res.txt = [txt]
292
+ }
293
+ }
294
+ }
295
+ return rejected
296
+ }
297
+
260
298
  const _processBatch = async (srv, router, req, res, next, body, ct, boundary) => {
261
299
  body ??= req.body
262
300
  ct ??= 'JSON'
@@ -268,6 +306,14 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
268
306
  }
269
307
  const _formatResponse = isJson ? _formatResponseJson : _formatResponseMultipart
270
308
 
309
+ // continue-on-error defaults to true in json batch
310
+ let continue_on_error = req.headers.prefer?.match(/odata\.continue-on-error(=(\w+))?/)
311
+ if (!continue_on_error) {
312
+ continue_on_error = isJson ? true : false
313
+ } else {
314
+ continue_on_error = continue_on_error[2] === 'false' ? false : true
315
+ }
316
+
271
317
  try {
272
318
  const ids = _validateBatch(body) // REVISIT: we will not be able to validate the whole once we stream
273
319
 
@@ -304,13 +350,13 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
304
350
  if (tx) {
305
351
  // Each change in `atomicityGroup` results in a new transaction. We execute them in sequence to avoid too many database connections.
306
352
  // In the future, we might make this configurable (e.g. allow X parallel connections per HTTP request).
307
- const rejected = await tx.done()
353
+ const rejected = await _tx_done(tx, responses, isJson)
308
354
  if (tx.failed?.res.statusCode === 401 && req._login) return req._login()
309
355
  else sendPreludeOnce()
310
356
  isJson
311
357
  ? _writeResponseJson(responses, res)
312
358
  : _writeResponseMultipart(responses, res, rejected, previousAtomicityGroup, boundary)
313
- if (rejected && !CONTINUE_ON_ERROR.test(req.headers.prefer)) {
359
+ if (rejected && !continue_on_error) {
314
360
  tx = null
315
361
  break
316
362
  }
@@ -318,7 +364,7 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
318
364
 
319
365
  responses = []
320
366
  tx = await _transaction(srv)
321
- if (atomicityGroup) ids[atomicityGroup].promise = tx.promise
367
+ if (atomicityGroup) ids[atomicityGroup].promise = tx._tx
322
368
  }
323
369
 
324
370
  tx.add(() => {
@@ -329,12 +375,13 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
329
375
  const results = await Promise.allSettled(dependencies)
330
376
  const dependendOnFailed = results.some(({ status }) => status === 'rejected')
331
377
  if (dependendOnFailed) {
378
+ tx.id = request.id
332
379
  tx.res = {
333
380
  getHeaders: () => {},
334
- statusCode: 404,
381
+ statusCode: 424,
335
382
  _chunk: JSON.stringify({
336
- status: 404,
337
- message: `Dependency for Request ${request.id} failed to execute`
383
+ code: '424',
384
+ message: 'Failed Dependency'
338
385
  })
339
386
  }
340
387
  throw tx
@@ -375,14 +422,14 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
375
422
  const resp = { status: 'ok' }
376
423
  if (separator) resp.separator = separator
377
424
  else separator = isJson ? Buffer.from(',') : Buffer.from(CRLF)
378
- resp.txt = _formatResponse(req, boundary)
425
+ resp.txt = _formatResponse(req, atomicityGroup)
379
426
  responses.push(resp)
380
427
  })
381
428
  .catch(failedReq => {
382
429
  const resp = { status: 'fail' }
383
430
  if (separator) resp.separator = separator
384
431
  else separator = isJson ? Buffer.from(',') : Buffer.from(CRLF)
385
- resp.txt = _formatResponse(failedReq, boundary)
432
+ resp.txt = _formatResponse(failedReq, atomicityGroup)
386
433
  tx.failed = failedReq
387
434
  responses.push(resp)
388
435
  })
@@ -392,7 +439,7 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
392
439
 
393
440
  if (tx) {
394
441
  // The last open transaction must be finished
395
- const rejected = await tx.done()
442
+ const rejected = await _tx_done(tx, responses, isJson)
396
443
  if (tx.failed?.res.statusCode === 401 && req._login) return req._login()
397
444
  else sendPreludeOnce()
398
445
  isJson
@@ -479,19 +526,19 @@ const _formatStatics = {
479
526
  close: Buffer.from('}')
480
527
  }
481
528
 
482
- const _formatResponseJson = request => {
529
+ const _formatResponseJson = (request, atomicityGroup) => {
483
530
  const { id, res: response } = request
484
531
 
485
- const raw = Buffer.from(
486
- JSON.stringify({
487
- id,
488
- status: response.statusCode,
489
- headers: {
490
- ...response.getHeaders(),
491
- 'content-type': 'application/json' //> REVISIT: why?
492
- }
493
- })
494
- )
532
+ const chunk = {
533
+ id,
534
+ status: response.statusCode,
535
+ headers: {
536
+ ...response.getHeaders(),
537
+ 'content-type': 'application/json' //> REVISIT: why?
538
+ }
539
+ }
540
+ if (atomicityGroup) chunk.atomicityGroup = atomicityGroup
541
+ const raw = Buffer.from(JSON.stringify(chunk))
495
542
 
496
543
  // body?
497
544
  if (!response._chunk) return [raw]
@@ -109,7 +109,7 @@ const _count = result => {
109
109
  ? result.reduce((acc, val) => {
110
110
  return acc + (val?.$count ?? val?._counted_ ?? (Array.isArray(val) && _count(val))) || 0
111
111
  }, 0)
112
- : result.$count ?? result._counted_ ?? 0
112
+ : (result.$count ?? result._counted_ ?? 0)
113
113
  }
114
114
 
115
115
  // REVISIT: integrate with default handler
@@ -35,7 +35,7 @@ const _odataContext = (query, options) => {
35
35
  path = '../'.repeat(ref.length - 1) + path
36
36
  }
37
37
  const lastRef = ref.at(-1)
38
- let entityName = isNavToDraftAdmin ? ref[0].id ?? ref[0] : query._target.name ?? edmName
38
+ let entityName = isNavToDraftAdmin ? (ref[0].id ?? ref[0]) : (query._target.name ?? edmName)
39
39
  const serviceName = query._target._service?.name
40
40
 
41
41
  if (query._target._isContained) {
@@ -1,7 +1,7 @@
1
- /*
2
-
3
1
  const cds = require('../../_runtime/cds')
4
2
 
3
+ /*
4
+
5
5
  // requesting logger without module on purpose!
6
6
  const LOG = cds.log()
7
7
 
@@ -44,7 +44,14 @@ const _log = require('../../_runtime/common/error/log')
44
44
 
45
45
  const { normalizeError } = require('../../_runtime/common/error/frontend')
46
46
 
47
+ function noop_error_middleware(err, req, res, next) {
48
+ next(err)
49
+ }
50
+
47
51
  module.exports = () => {
52
+ // REVISIT: unofficial hack for afc!!!
53
+ if (cds.env.features.rest_error_handler === false) return noop_error_middleware
54
+
48
55
  return function rest_error(err, req, res, next) {
49
56
  if (err == 401 || err.code == 401) return next(err) // speed up logins, at least temporary until we reviewed and eliminated overhead that may be involved below
50
57
  // REVISIT: keep?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "8.0.3",
3
+ "version": "8.0.4",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [