@sap/cds 9.8.2 → 9.8.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,20 @@
4
4
  - The format is based on [Keep a Changelog](https://keepachangelog.com/).
5
5
  - This project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## Version 9.8.4 - 2026-03-26
8
+
9
+ ### Fixed
10
+
11
+ - `res.statusCode` of batch sub-requests did not consider potential modifications during `srv.on('error')`
12
+ - Restore login challenge for late `401` with mocked authentication in `$batch`
13
+ - Batched request fails when depended upon atomicity group fails
14
+
15
+ ## Version 9.8.3 - 2026-03-12
16
+
17
+ ### Fixed
18
+
19
+ - OData batch parallel processing: Drain remaining queue when aborting
20
+
7
21
  ## Version 9.8.2 - 2026-03-10
8
22
 
9
23
  ### Fixed
@@ -47,7 +47,7 @@ class HCQLAdapter extends require('./http') {
47
47
 
48
48
  // Error formatting
49
49
  router.use ((err, req, res, next) => {
50
- err.$response = e => ({ errors: [ { ...e, message: e.message } ] })
50
+ if (typeof err === 'object') err.$response = e => ({ errors: [ { ...e, message: e.message } ] })
51
51
  next(err)
52
52
  })
53
53
 
@@ -149,7 +149,12 @@ const _createSubrequest = (request, _req, _res) => {
149
149
  req.headers['content-type'] ??= _req.headers['content-type']
150
150
  if (request.content_id) req.headers['content-id'] = request.content_id
151
151
  req.body = request.body
152
- if (_req._login) req._login = _req._login
152
+ if (_req._login) {
153
+ req._login = () => {
154
+ _req._login() //> sends 401 to client
155
+ req.res.sendStatus(401) //> fails the subrequest
156
+ }
157
+ }
153
158
  // REVISIT: mark as subrequest (only needed for logging)
154
159
  req._subrequest = true
155
160
 
@@ -392,6 +397,7 @@ const _replaceResponsesWithCommitErrors = (err, responses, ids) => {
392
397
  const _serializeErrors = responses => {
393
398
  for (const response of responses) {
394
399
  if (response.status === 'fail' && typeof response.body === 'object') {
400
+ if (response.body.error?.status) response.statusCode = response.body.error.status
395
401
  if (response.body.error && !response.body.error.toJSON) response.body.error = _getODataError(response.body.error)
396
402
  response.body = JSON.stringify(response.body)
397
403
  }
@@ -435,9 +441,18 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
435
441
  if (only_gets && cds.env.odata.group_parallel_gets && _only_individual_requests())
436
442
  atomicityGroups = [atomicityGroups.reduce((acc, cur) => (acc.push(...cur), acc), [])]
437
443
 
438
- res.setHeader('Content-Type', isJson ? CT.JSON : CT.MULTIPART + ';boundary=' + boundary)
439
- res.status(200)
440
- res.write(isJson ? '{"responses":[' : '')
444
+ // IMPORTANT: Avoid sending headers and responses too eagerly, as we might still have to send a 401
445
+ let sendPostlude = () => {} //> only if prelude was sent
446
+ let sendPreludeOnce = () => {
447
+ res.setHeader('Content-Type', isJson ? CT.JSON : CT.MULTIPART + ';boundary=' + boundary)
448
+ res.status(200)
449
+ res.write(isJson ? '{"responses":[' : '')
450
+ sendPreludeOnce = () => {} //> only once
451
+ sendPostlude = () => {
452
+ res.write(isJson ? ']}' : `--${boundary}--${CRLF}`)
453
+ res.end()
454
+ }
455
+ }
441
456
 
442
457
  const queue = []
443
458
  let _continue = true
@@ -487,42 +502,49 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
487
502
 
488
503
  // ensure all subrequests run in this tx
489
504
  // (if first subrequest fails without really opening the tx, the rest are executed in a "dangling tx")
490
- return Promise.allSettled(subrequests)
491
- .then(ress => {
492
- // wait for all previous atomicity groups (ignoring errors via allSettled) for odata v2
493
- const prevs = []
494
- for (let i = 0; i < agIndex; i++) prevs.push(promises[i])
495
- return Promise.allSettled(prevs).then(() => ress)
496
- })
497
- .then(ress => {
498
- const failed = ress.filter(({ status }) => status === 'rejected')
499
- if (!failed.length) return
500
- // throw first error and call srv.on('error') for the others
501
- const first = failed.shift()
502
- if (srv.handlers._error?.length)
503
- for (const other of failed)
504
- for (const each of srv.handlers._error) each.handler.call(srv, other.reason, cds.context)
505
- throw first.reason
506
- })
505
+ return Promise.allSettled(subrequests).then(ress => {
506
+ const failed = ress.filter(({ status }) => status === 'rejected')
507
+ if (!failed.length) return
508
+ // throw first error and call srv.on('error') for the others
509
+ const first = failed.shift()
510
+ if (srv.handlers._error?.length)
511
+ for (const other of failed)
512
+ for (const each of srv.handlers._error) each.handler.call(srv, other.reason, cds.context)
513
+ throw first.reason
514
+ })
507
515
  })
508
516
  .catch(err => {
509
517
  responses._has_failure = true
510
518
 
511
- // abort batch on first failure with odata.continue-on-error: false
512
- if (!continue_on_error) _continue = false
519
+ // abort batch on first failure with odata.continue-on-error: false or if it was a 401
520
+ if (!continue_on_error || err.code == 401 || err.status == 401) {
521
+ _continue = false
522
+ while (queue.length) queue.shift()()
523
+ }
513
524
 
514
525
  if (!responses.some(r => r.status === 'fail')) {
515
526
  // here, the commit was rejected even though all requests were successful (e.g., by custom handler or db consistency check)
516
527
  _replaceResponsesWithCommitErrors(err, responses, ids)
517
528
  }
529
+
530
+ throw err
518
531
  })
519
- .finally(() => {
532
+ .finally(async () => {
520
533
  // trigger next in queue
521
534
  if (queue.length) queue.shift()()
522
535
 
523
536
  // late error serialization
524
537
  if (responses._has_failure) _serializeErrors(responses)
525
538
 
539
+ // wait for all previous atomicity groups (ignoring errors via allSettled) for odata v2
540
+ const prevs = []
541
+ for (let i = 0; i < agIndex; i++) prevs.push(promises[i])
542
+ await Promise.allSettled(prevs)
543
+
544
+ // don't write to res if already closed (e.g., due to 401 and login)
545
+ if (res.closed) return
546
+
547
+ sendPreludeOnce()
526
548
  if (isJson) _writeResponseJson(responses, res)
527
549
  else _writeResponseMultipart(responses, res, boundary)
528
550
  })
@@ -540,10 +562,9 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
540
562
  // trigger first max_parallel in queue
541
563
  for (let i = 0; i < max_parallel; i++) if (queue.length) queue.shift()()
542
564
 
543
- await Promise.all(promises)
565
+ await Promise.allSettled(promises)
544
566
 
545
- res.write(isJson ? ']}' : `--${boundary}--${CRLF}`)
546
- res.end()
567
+ sendPostlude()
547
568
  } catch (e) {
548
569
  next(e)
549
570
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "9.8.2",
3
+ "version": "9.8.4",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [