@sap/cds 7.3.1 → 7.4.0

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 (107) hide show
  1. package/CHANGELOG.md +58 -3
  2. package/_i18n/i18n_es_MX.properties +110 -0
  3. package/apis/cds.d.ts +13 -12
  4. package/apis/core.d.ts +27 -108
  5. package/apis/cqn.d.ts +15 -18
  6. package/apis/csn.d.ts +95 -60
  7. package/apis/env.d.ts +25 -0
  8. package/apis/events.d.ts +124 -0
  9. package/apis/{reflect.d.ts → linked.d.ts} +27 -38
  10. package/apis/models.d.ts +60 -45
  11. package/apis/ql.d.ts +11 -5
  12. package/apis/{serve.d.ts → server.d.ts} +57 -31
  13. package/apis/services.d.ts +74 -145
  14. package/apis/test.d.ts +1 -1
  15. package/bin/serve.js +3 -0
  16. package/lib/compile/cds-compile.js +2 -2
  17. package/lib/compile/to/edm.js +8 -3
  18. package/lib/compile/to/gql.js +4 -0
  19. package/lib/dbs/cds-deploy.js +52 -4
  20. package/lib/env/cds-requires.js +27 -15
  21. package/lib/env/defaults.js +1 -0
  22. package/lib/env/schemas/index.js +10 -0
  23. package/lib/index.js +7 -4
  24. package/lib/linked/models.js +8 -5
  25. package/lib/ql/CREATE.js +2 -0
  26. package/lib/ql/DELETE.js +1 -0
  27. package/lib/ql/DROP.js +2 -0
  28. package/lib/ql/INSERT.js +2 -22
  29. package/lib/ql/Query.js +59 -22
  30. package/lib/ql/SELECT.js +5 -0
  31. package/lib/ql/STREAM.js +2 -0
  32. package/lib/ql/UPDATE.js +2 -0
  33. package/lib/ql/UPSERT.js +3 -1
  34. package/lib/ql/cds-ql.js +21 -5
  35. package/lib/ql/infer.js +129 -0
  36. package/lib/req/cds-context.js +8 -5
  37. package/lib/srv/cds-connect.js +3 -1
  38. package/lib/utils/axios.js +4 -2
  39. package/lib/utils/data.js +3 -0
  40. package/libx/_runtime/cds-services/adapter/odata-v4/handlers/error.js +12 -0
  41. package/libx/_runtime/cds-services/adapter/odata-v4/handlers/metadata.js +26 -8
  42. package/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-server/batch/BatchProcessor.js +1 -1
  43. package/libx/_runtime/cds-services/adapter/odata-v4/utils/data.js +11 -8
  44. package/libx/_runtime/common/code-ext/worker.js +5 -16
  45. package/libx/_runtime/common/generic/auth/capabilities.js +11 -2
  46. package/libx/_runtime/common/i18n/messages.properties +1 -0
  47. package/libx/_runtime/common/utils/postProcessing.js +1 -1
  48. package/libx/_runtime/common/utils/resolveView.js +20 -1
  49. package/libx/{common → _runtime/common}/utils/ucsn.js +19 -11
  50. package/libx/_runtime/db/expand/expandCQNToJoin.js +2 -2
  51. package/libx/_runtime/db/sql-builder/InsertBuilder.js +6 -1
  52. package/libx/_runtime/db/sql-builder/UpdateBuilder.js +6 -1
  53. package/libx/_runtime/db/sql-builder/dollar.js +7 -7
  54. package/libx/_runtime/fiori/generic/activate.js +2 -2
  55. package/libx/_runtime/fiori/generic/edit.js +25 -45
  56. package/libx/_runtime/fiori/generic/read.js +3 -5
  57. package/libx/_runtime/fiori/lean-draft.js +142 -64
  58. package/libx/_runtime/fiori/utils/delete.js +7 -1
  59. package/libx/_runtime/fiori/utils/handler.js +4 -6
  60. package/libx/_runtime/fiori/utils/lockInfo.js +27 -0
  61. package/libx/_runtime/fiori/utils/where.js +20 -1
  62. package/libx/_runtime/messaging/AMQPWebhookMessaging.js +3 -2
  63. package/libx/_runtime/messaging/Outbox.js +12 -47
  64. package/libx/_runtime/messaging/common-utils/AMQPClient.js +1 -3
  65. package/libx/_runtime/messaging/common-utils/authorizedRequest.js +3 -0
  66. package/libx/_runtime/messaging/common-utils/connections.js +1 -1
  67. package/libx/_runtime/messaging/enterprise-messaging.js +12 -13
  68. package/libx/_runtime/messaging/file-based.js +7 -5
  69. package/libx/_runtime/messaging/redis-messaging.js +10 -11
  70. package/libx/_runtime/messaging/service.js +12 -26
  71. package/libx/_runtime/remote/Service.js +52 -36
  72. package/libx/_runtime/remote/utils/client.js +22 -123
  73. package/libx/odata/afterburner.js +14 -5
  74. package/libx/odata/grammar.peggy +26 -7
  75. package/libx/odata/metadata.js +18 -1
  76. package/libx/odata/parser.js +1 -1
  77. package/libx/odata/service-document.js +0 -1
  78. package/libx/odata/utils.js +19 -3
  79. package/libx/{_runtime/messaging/outbox/utils.js → outbox/index.js} +94 -24
  80. package/libx/rest/middleware/parse.js +1 -1
  81. package/package.json +2 -2
  82. package/apis/connect.d.ts +0 -39
  83. package/bin/utils/modules.js +0 -7
  84. package/bin/utils/term.js +0 -56
  85. package/lib/env/schema.js +0 -9
  86. package/lib/linked/queries.js +0 -41
  87. package/lib/srv/protocols/odata-v2-proxy.js +0 -3699
  88. package/libx/common/asserts.js +0 -0
  89. package/libx/common/crud.js +0 -0
  90. package/libx/common/etag.js +0 -0
  91. package/libx/common/localized.js +0 -0
  92. package/libx/common/managed.js +0 -0
  93. package/libx/common/paging.js +0 -0
  94. package/libx/common/readme.md +0 -4
  95. package/libx/common/sorting.js +0 -0
  96. package/libx/common/temporal.js +0 -0
  97. package/libx/connect/auth.js +0 -0
  98. package/libx/connect/perf.js +0 -0
  99. package/libx/connect/readme.md +0 -3
  100. package/libx/fiori/draft/readme.md +0 -1
  101. package/libx/fiori/readme.md +0 -1
  102. package/libx/hana/readme.md +0 -1
  103. package/libx/msg/readme.md +0 -3
  104. package/libx/readme.md +0 -1
  105. package/libx/sqlite/readme.md +0 -1
  106. /package/libx/_runtime/{messaging/common-utils → common/utils}/waitingTime.js +0 -0
  107. /package/libx/{_runtime/messaging/outbox → outbox}/OutboxRunner.js +0 -0
@@ -1,5 +1,7 @@
1
1
  const cds = require('../cds'),
2
2
  { Object_keys } = cds.utils
3
+ const { getTransition } = require('../common/utils/resolveView')
4
+ const { getKeyData } = require('./utils/where')
3
5
  const LOG = cds.log('fiori|drafts')
4
6
  const original = Symbol('original')
5
7
  const DRAFT_PARAMS = Symbol('draftParams')
@@ -12,9 +14,9 @@ const DRAFT_ELEMENTS = new Set([
12
14
  'DraftAdministrativeData_DraftUUID',
13
15
  'SiblingEntity'
14
16
  ])
15
-
17
+ const DRAFT_ELEMENTS_WITHOUT_HASACTIVE = new Set(DRAFT_ELEMENTS)
18
+ DRAFT_ELEMENTS_WITHOUT_HASACTIVE.delete('HasActiveEntity')
16
19
  const REDUCED_DRAFT_ELEMENTS = new Set(['IsActiveEntity', 'HasDraftEntity', 'SiblingEntity'])
17
-
18
20
  const DRAFT_ADMIN_ELEMENTS = [
19
21
  'DraftUUID',
20
22
  'LastChangedByUser',
@@ -41,16 +43,14 @@ const _fillIsActiveEntity = (row, IsActiveEntity, target) => {
41
43
  /// It's important to wait for the completion of all promises, otherwise a rollback might happen too soon
42
44
  const _promiseAll = async array => {
43
45
  const results = await Promise.allSettled(array)
44
- const e = results.find(r => r.status === 'rejected')
45
- if (e) throw e.reason
46
- return results.map(r => r.value)
46
+ const firstRejected = results.find(response => response.status === 'rejected')
47
+ if (firstRejected) throw firstRejected.reason
48
+ return results.map(result => result.value)
47
49
  }
48
50
 
49
51
  const _isCount = query => query.SELECT.columns?.length === 1 && query.SELECT.columns[0].func === 'count'
50
-
51
- const entity_keys = e => {
52
- return Object_keys(e.keys).filter(k => k !== 'IsActiveEntity' && !e.keys[k].isAssociation)
53
- }
52
+ const entity_keys = entity =>
53
+ Object_keys(entity.keys).filter(key => key !== 'IsActiveEntity' && !entity.keys[key].isAssociation)
54
54
 
55
55
  const _inProcessByUserXpr = lockShiftedNow => ({
56
56
  xpr: [
@@ -98,9 +98,13 @@ cds.ApplicationService.prototype.handle = async function (req) {
98
98
 
99
99
  if (
100
100
  !req.query ||
101
- req.query.UPSERT || // skip UPSERTs (might have an additional INSERT)
102
- (!req.query.SELECT && !req.query.INSERT && !req.query.UPDATE && !req.query.DELETE && !req.query.STREAM) ||
103
- req.query[DRAFT_PARAMS]
101
+ req.query[DRAFT_PARAMS] ||
102
+ (!req.query.SELECT &&
103
+ !req.query.INSERT &&
104
+ // !req.query.UPSERT && // skip UPSERTs (might have an additional INSERT)
105
+ !req.query.UPDATE &&
106
+ !req.query.DELETE &&
107
+ !req.query.STREAM)
104
108
  ) {
105
109
  return handle(req)
106
110
  }
@@ -119,8 +123,8 @@ cds.ApplicationService.prototype.handle = async function (req) {
119
123
  const _newReq = (req, query, draftParams, { event, headers }) => {
120
124
  // REVISIT: This is a bit hacky -> better way?
121
125
  query._target = undefined
126
+ query.target = undefined
122
127
  query[DRAFT_PARAMS] = draftParams
123
- cds.infer(query, this.model.definitions)
124
128
 
125
129
  // REVISIT: This is extremely bad. We should be able to just create a copy without such hacks.
126
130
  const _req = cds.Request.for(req._) // REVISIT: this causes req._.data of WRITE reqs copied to READ reqs
@@ -133,6 +137,7 @@ cds.ApplicationService.prototype.handle = async function (req) {
133
137
  // If we create a `READ` event based on a modifying request, we delete data
134
138
  if (event === 'READ' && req.event !== 'READ') delete _req.data // which we fix here -> but this is an ugly workaround
135
139
 
140
+ _req.target = cds.infer(query, this.model.definitions)
136
141
  _req.query = query
137
142
  _req.event =
138
143
  event ||
@@ -141,7 +146,6 @@ cds.ApplicationService.prototype.handle = async function (req) {
141
146
  (query.UPDATE && 'UPDATE') ||
142
147
  (query.DELETE && 'DELETE') ||
143
148
  req.event
144
- _req.target = query._target
145
149
  _req.params = req.params
146
150
  _req._ = Object.assign({}, req._ || {})
147
151
  _req._.params = req.params
@@ -224,18 +228,8 @@ cds.ApplicationService.prototype.handle = async function (req) {
224
228
  const inProcessByUser = draft?.DraftAdministrativeData?.InProcessByUser
225
229
  if (inProcessByUser && inProcessByUser !== cds.context.user.id)
226
230
  req.reject(403, 'DRAFT_LOCKED_BY_ANOTHER_USER', [inProcessByUser])
227
- const deletes = [run(DELETE.from({ ref: query.DELETE.from.ref }))]
228
- if (draft)
229
- deletes.push(
230
- DELETE.from(req.target.drafts).where({
231
- DraftAdministrativeData_DraftUUID: draft.DraftAdministrativeData_DraftUUID
232
- })
233
- )
234
- if (draft && req.target['@Common.DraftRoot.ActivationAction'])
235
- deletes.push(
236
- DELETE.from('DRAFT.DraftAdministrativeData').where({ DraftUUID: draft.DraftAdministrativeData_DraftUUID })
237
- )
238
- await _promiseAll(deletes)
231
+ if (draft) req.reject(403, 'DRAFT_ACTIVE_DELETE_FORBIDDEN_DRAFT_EXISTS')
232
+ await run(DELETE.from({ ref: query.DELETE.from.ref }))
239
233
  return req.data
240
234
  }
241
235
 
@@ -247,13 +241,13 @@ cds.ApplicationService.prototype.handle = async function (req) {
247
241
  req.reject(400, 'Action "draftActivate" can only be called on the root draft entity')
248
242
  }
249
243
  const targetDraft = req.target.drafts
250
- const targetWhere = query.SELECT.from.ref[0].where
251
244
  const cols = expandStarStar(targetDraft)
252
245
  // Use `run` (since also etags might need to be checked)
253
246
  // REVISIT: Find a better approach (`etag` as part of CQN?)
247
+ const draftRef = _redirectRefToDrafts(query.SELECT.from.ref, this.model)
254
248
  const res = await run(
255
249
  SELECT.one
256
- .from({ ref: _redirectRefToDrafts(query.SELECT.from.ref, this.model) })
250
+ .from({ ref: draftRef })
257
251
  .columns(cols)
258
252
  .columns([
259
253
  'HasActiveEntity',
@@ -271,11 +265,13 @@ cds.ApplicationService.prototype.handle = async function (req) {
271
265
  delete res.HasActiveEntity
272
266
  // First run the handlers as they might need access to DraftAdministrativeData or the draft entities
273
267
  const result = await run(
274
- HasActiveEntity ? UPDATE(req.target).data(res).where(targetWhere) : INSERT.into(req.target).entries(res),
268
+ HasActiveEntity
269
+ ? UPDATE({ ref: query.SELECT.from.ref }).data(res)
270
+ : INSERT.into({ ref: query.SELECT.from.ref }).entries(res),
275
271
  { headers: Object.assign({}, req.headers, { 'if-match': '*' }) }
276
272
  )
277
273
  await _promiseAll([
278
- DELETE.from(targetDraft).where(targetWhere),
274
+ DELETE.from({ ref: draftRef }),
279
275
  DELETE.from('DRAFT.DraftAdministrativeData').where({ DraftUUID: DraftAdministrativeData_DraftUUID })
280
276
  ])
281
277
 
@@ -407,6 +403,7 @@ const Read = {
407
403
  })
408
404
  return _requested(actives, query)
409
405
  },
406
+
410
407
  unchanged: async function (run, query) {
411
408
  LOG.debug('List Editing Status: Unchanged')
412
409
  const draftsQuery = query._drafts
@@ -421,6 +418,7 @@ const Read = {
421
418
  })
422
419
  return _requested(res, query)
423
420
  },
421
+
424
422
  ownDrafts: async function (run, query) {
425
423
  LOG.debug('List Editing Status: Own Draft')
426
424
 
@@ -453,6 +451,7 @@ const Read = {
453
451
  })
454
452
  return _requested(drafts, query)
455
453
  },
454
+
456
455
  all: async function (run, query) {
457
456
  LOG.debug('List Editing Status: All')
458
457
  if (!query._drafts) return []
@@ -518,10 +517,11 @@ const Read = {
518
517
  }
519
518
  _fillIsActiveEntity(row, true, query._target)
520
519
  })
521
- const res = isFirstPage ? [...ownNewDrafts, ...ownEditDrafts, ...actives] : actives
520
+ const res = isFirstPage ? [...ownDrafts, ...actives] : actives
522
521
  if (query.SELECT.count) res.$count = count
523
522
  return _requested(res, query)
524
523
  },
524
+
525
525
  activesFromDrafts: async function (run, query, { isLocked = true }) {
526
526
  const draftsQuery = query._drafts
527
527
  const additionalCols = draftsQuery.SELECT.columns
@@ -550,15 +550,19 @@ const Read = {
550
550
  })
551
551
  return _requested(actives, query)
552
552
  },
553
+
553
554
  unsavedChangesByAnotherUser: async function (run, query) {
554
555
  LOG.debug('List Editing Status: Unsaved Changes by Another User')
555
556
  return Read.activesFromDrafts(run, query, { isLocked: false })
556
557
  },
558
+
557
559
  lockedByAnotherUser: async function (run, query) {
558
560
  LOG.debug('List Editing Status: Locked by Another User')
559
561
  return Read.activesFromDrafts(run, query, { isLocked: true })
560
562
  },
563
+
561
564
  whereNotIn: (target, data) => Read.whereIn(target, data, true),
565
+
562
566
  whereIn: (target, data, not = false) => {
563
567
  const keys = entity_keys(target)
564
568
  const dataArray = data ? (Array.isArray(data) ? data : [data]) : []
@@ -568,6 +572,7 @@ const Read = {
568
572
  const right = { list: dataArray.map(r => ({ list: keys.map(k => ({ val: r[k] })) })) }
569
573
  return [left, ...op, right]
570
574
  },
575
+
571
576
  complementaryDrafts: (query, _actives) => {
572
577
  const actives = Array.isArray(_actives) ? _actives : [_actives]
573
578
  if (!actives.length) return []
@@ -587,7 +592,9 @@ const Read = {
587
592
  drafts.SELECT.one = undefined
588
593
  return drafts
589
594
  },
595
+
590
596
  _makeArray: data => (Array.isArray(data) ? data : data ? [data] : []),
597
+
591
598
  _index: (target, data) => {
592
599
  // Indexes the data for fast key access
593
600
  const dataArray = Read._makeArray(data)
@@ -600,6 +607,7 @@ const Read = {
600
607
  for (const row of dataArray) hashMap.set(hash(row), row)
601
608
  return { hashMap, hash }
602
609
  },
610
+
603
611
  // Calls `cb` for each entry of data with a potential counterpart in otherData
604
612
  merge: (target, data, otherData, cb) => {
605
613
  const dataArray = Read._makeArray(data)
@@ -611,6 +619,7 @@ const Read = {
611
619
  cb(row, other)
612
620
  }
613
621
  },
622
+
614
623
  // Deletes entries of data with a counterpart in otherData
615
624
  delete: (target, data, otherData) => {
616
625
  if (!Array.isArray(data) || !data.length) return
@@ -673,6 +682,12 @@ function _cleansed(query, model) {
673
682
  if (query.SELECT.columns && query._target.drafts)
674
683
  draftsQuery.SELECT.columns = _cleanseCols(query.SELECT.columns, REDUCED_DRAFT_ELEMENTS, draft)
675
684
 
685
+ if (query.SELECT.where && query._target.drafts)
686
+ draftsQuery.SELECT.where = _cleanseWhere(query.SELECT.where, {}, DRAFT_ELEMENTS_WITHOUT_HASACTIVE)
687
+
688
+ if (query.SELECT.orderBy && query._target.drafts)
689
+ draftsQuery.SELECT.orderBy = _cleanseWhere(query.SELECT.orderBy, {}, REDUCED_DRAFT_ELEMENTS)
690
+
676
691
  if (draftsQuery._target.name.endsWith('.DraftAdministrativeData')) {
677
692
  draftsQuery.SELECT.columns = _tweakAdminCols(draftsQuery.SELECT.columns)
678
693
  } else if (draftsQuery._target?.name.endsWith('.drafts')) {
@@ -712,7 +727,7 @@ function _cleansed(query, model) {
712
727
  const cleansedRef = ref.map(r => {
713
728
  entity = (entity && entity.elements[r.id || r]._target) || model.definitions[r.id || r]
714
729
  if (!entity?.drafts) return r
715
- return r.where ? { ...r, where: _cleanseWhere(r.where, draftParams) } : r
730
+ return r.where ? { ...r, where: _cleanseWhere(r.where, draftParams, DRAFT_ELEMENTS) } : r
716
731
  })
717
732
  if (q.SELECT) q.SELECT.from = { ...q.SELECT.from, ref: cleansedRef }
718
733
  else if (q.DELETE) q.DELETE.from = { ...q.DELETE.from, ref: cleansedRef }
@@ -730,8 +745,8 @@ function _cleansed(query, model) {
730
745
  }
731
746
  }
732
747
 
733
- if (target.drafts && cqn.where) cqn.where = _cleanseWhere(cqn.where, draftParams)
734
- if (target.drafts && cqn.orderBy) cqn.orderBy = _cleanseWhere(cqn.orderBy, {})
748
+ if (target.drafts && cqn.where) cqn.where = _cleanseWhere(cqn.where, draftParams, DRAFT_ELEMENTS)
749
+ if (target.drafts && cqn.orderBy) cqn.orderBy = _cleanseWhere(cqn.orderBy, {}, DRAFT_ELEMENTS)
735
750
  if (cqn.columns) cqn.columns = _cleanseCols(cqn.columns, DRAFT_ELEMENTS, target)
736
751
  return q
737
752
  }
@@ -800,15 +815,15 @@ function _cleansed(query, model) {
800
815
  })
801
816
  }
802
817
 
803
- function _cleanseWhere(xpr, draftParams) {
818
+ function _cleanseWhere(xpr, draftParams, ignoredElements) {
804
819
  const cleansed = []
805
820
  for (let i = 0; i < xpr.length; ++i) {
806
821
  let x = xpr[i]
807
822
  const e = x.ref?.[0]
808
- if (DRAFT_ELEMENTS.has(e) && !xpr[i + 2]) {
823
+ if (ignoredElements.has(e) && !xpr[i + 2]) {
809
824
  continue
810
825
  }
811
- if (DRAFT_ELEMENTS.has(e) && xpr[i + 2]) {
826
+ if (ignoredElements.has(e) && xpr[i + 2]) {
812
827
  let { val } = xpr[i + 2]
813
828
  draftParams[x.ref.join('_')] = xpr[i + 1] === '!=' ? (typeof val === 'boolean' ? !val : 'not ' + val) : val
814
829
  i += 2
@@ -817,7 +832,7 @@ function _cleansed(query, model) {
817
832
  continue
818
833
  }
819
834
  if (x.xpr) {
820
- x = { xpr: _cleanseWhere(x.xpr, draftParams) }
835
+ x = { xpr: _cleanseWhere(x.xpr, draftParams, ignoredElements) }
821
836
  if (!x.xpr) {
822
837
  i += 1
823
838
  continue
@@ -930,18 +945,20 @@ async function onNew(req) {
930
945
 
931
946
  async function onEdit(req) {
932
947
  LOG.debug('edit active')
948
+
933
949
  // use symbol for _draftParams
934
950
  const draftParams = req.query[DRAFT_PARAMS]
935
951
  if (req.query.SELECT.from.ref.length > 1 || draftParams.IsActiveEntity !== true) {
936
952
  req.reject(400, 'Action "draftEdit" can only be called on the root active entity')
937
953
  }
954
+
938
955
  if (
939
956
  req.target['@Capabilities.UpdateRestrictions.Updatable'] === false ||
940
957
  req.target['@insertonly'] ||
941
958
  req.target['@readonly']
942
- )
959
+ ) {
943
960
  req.reject(405)
944
- const targetWhere = req.query.SELECT.from.ref[0].where
961
+ }
945
962
 
946
963
  if (draftParams.IsActiveEntity !== true) req.reject(400)
947
964
 
@@ -963,34 +980,92 @@ async function onEdit(req) {
963
980
  }
964
981
  _addDraftColumns(req.target, cols)
965
982
 
966
- const existingDraft = SELECT.one(req.target.drafts)
967
- .columns({ ref: ['DraftAdministrativeData'], expand: [_inProcessByUserXpr(_lock.shiftedNow)] })
968
- .where(targetWhere)
983
+ const draftsRef = _redirectRefToDrafts(req.query.SELECT.from.ref, this.model)
984
+ const existingDraft = SELECT.one({ ref: draftsRef }).columns({
985
+ ref: ['DraftAdministrativeData'],
986
+ expand: [_inProcessByUserXpr(_lock.shiftedNow)]
987
+ })
988
+
969
989
  // prevent service to check for own user
970
990
  existingDraft[DRAFT_PARAMS] = draftParams
971
991
 
972
- const activeCQN = SELECT.one.from(req.target).columns(cols).where(targetWhere)
973
- activeCQN.SELECT.localized = false
974
-
975
- const activeCheck = SELECT.one(req.target).columns([1]).where(targetWhere).forUpdate()
976
- activeCheck[DRAFT_PARAMS] = draftParams
977
- // It's not possible to use `FOR UPDATE` in HANA if the view contains joins/unions. Unfortunately, we can't resolve the table entity
978
- // because we must trigger the app-service request on the target entity (which could be delegated to a remote service).
979
- // The best we can do is to catch a potential error
980
- try {
981
- await activeCheck
982
- } catch {} // eslint-disable-line no-empty
983
-
984
- const [res, draft] = await _promiseAll([
985
- // REVISIT: inofficial compat flag just in case it breaks something -> do not document
986
- cds.env.fiori.read_actives_from_db ? this._datasource.run(activeCQN) : this.run(activeCQN),
987
- // no user check must be done here...
988
- existingDraft
989
- ])
992
+ const selectActiveCQN = SELECT.one.from({ ref: req.query.SELECT.from.ref }).columns(cols)
993
+ selectActiveCQN.SELECT.localized = false
994
+
995
+ let res, draft
996
+
997
+ // Ensure exclusive access to the record of the active entity by applying a lock,
998
+ // which effectively prevents the creation or overwriting of duplicate draft entities.
999
+ // This lock mechanism enforces a strict processing order for active entities,
1000
+ // allowing only one entity to be worked on at any given time.
1001
+ // By using .forUpdate() with a wait value of 0, we immediately lock the record,
1002
+ // ensuring there is no waiting time for other users attempting to edit the same record
1003
+ // concurrently.
1004
+ if (this._datasource === cds.db) {
1005
+ const keys = entity_keys(req.target)
1006
+ const keyData = getKeyData(keys, req.query.SELECT.from.ref[0].where)
1007
+ const rootWhere = keys.reduce((res, key) => {
1008
+ res[key] = keyData[key]
1009
+ return res
1010
+ }, {})
1011
+ const transition = getTransition(req.target, cds.db)
1012
+
1013
+ // gets the underlying target entity, as record locking can't be
1014
+ // applied to localized views
1015
+ const lockTarget = transition.target
1016
+ const lockWhere =
1017
+ transition.mapping.size === 0
1018
+ ? rootWhere
1019
+ : (() => {
1020
+ const whereKeys = Object.keys(rootWhere)
1021
+ const w = {}
1022
+ whereKeys.forEach(key => {
1023
+ const mappedKey = transition.mapping.get(key)
1024
+ const lockKey = mappedKey ? mappedKey.ref[0] : key
1025
+ w[lockKey] = rootWhere[key]
1026
+ })
1027
+ return w
1028
+ })()
1029
+ const activeLockCQN = SELECT.from(lockTarget, [1]).where(lockWhere).forUpdate({ wait: 0 })
1030
+ activeLockCQN[DRAFT_PARAMS] = draftParams
1031
+
1032
+ try {
1033
+ await this.run(activeLockCQN)
1034
+ } catch (e) {
1035
+ const draft = await this.run(existingDraft)
1036
+ if (draft) req.reject(409, 'DRAFT_ALREADY_EXISTS')
1037
+ req.reject(409, 'ENTITY_LOCKED')
1038
+ }
1039
+
1040
+ const cqns = [
1041
+ cds.env.fiori.read_actives_from_db ? this._datasource.run(selectActiveCQN) : this.run(selectActiveCQN),
1042
+ this.run(existingDraft)
1043
+ ]
1044
+
1045
+ ;[res, draft] = await _promiseAll(cqns)
1046
+ } else {
1047
+ const activeLockCQN = SELECT.from({ ref: req.query.SELECT.from.ref }, [1]).forUpdate({ wait: 0 })
1048
+ activeLockCQN[DRAFT_PARAMS] = draftParams
1049
+
1050
+ // Locking the underlying database table is effective only when the database is not
1051
+ // hosted on an external service. This is because the active data might be stored in
1052
+ // a separate system.
1053
+ try {
1054
+ await activeLockCQN
1055
+ } catch {} // eslint-disable-line no-empty
1056
+
1057
+ ;[res, draft] = await _promiseAll([
1058
+ // REVISIT: inofficial compat flag just in case it breaks something -> do not document
1059
+ cds.env.fiori.read_actives_from_db ? this._datasource.run(selectActiveCQN) : this.run(selectActiveCQN),
1060
+ // no user check must be done here...
1061
+ existingDraft
1062
+ ])
1063
+ }
990
1064
 
991
1065
  if (!res) req.reject(404)
992
1066
  const preserveChanges = req.context?.data?.PreserveChanges
993
1067
  const inProcessByUser = draft?.DraftAdministrativeData?.InProcessByUser
1068
+
994
1069
  if (draft) {
995
1070
  if (inProcessByUser || preserveChanges) req.reject(409, 'DRAFT_ALREADY_EXISTS')
996
1071
  const keys = {}
@@ -1037,14 +1112,17 @@ async function onCancel(req) {
1037
1112
  .from({ ref: req.query.DELETE.from.ref })
1038
1113
  .columns([
1039
1114
  'DraftAdministrativeData_DraftUUID',
1040
- { ref: ['DraftAdministrativeData'], expand: [{ ref: ['InProcessByUser'] }] }
1115
+ { ref: ['DraftAdministrativeData'], expand: [_inProcessByUserXpr(_lock.shiftedNow)] }
1041
1116
  ])
1042
1117
  if (req._etagValidationClause) draftQuery.where(req._etagValidationClause)
1043
1118
  // do not add InProcessByUser restriction
1044
1119
  const draft = await draftQuery
1045
1120
  if (draftParams.IsActiveEntity === false && !draft) req.reject(req._etagValidationType ? 412 : 404)
1046
- if (draft && draft.DraftAdministrativeData?.InProcessByUser !== cds.context.user.id)
1047
- req.reject(403, 'DRAFT_LOCKED_BY_ANOTHER_USER', [draft.DraftAdministrativeData?.InProcessByUser])
1121
+ if (draft) {
1122
+ const processByUser = draft.DraftAdministrativeData?.InProcessByUser
1123
+ if (processByUser && processByUser !== cds.context.user.id)
1124
+ req.reject(403, 'DRAFT_LOCKED_BY_ANOTHER_USER', [processByUser])
1125
+ }
1048
1126
  const queries = !draft ? [] : [this.run(DELETE.from({ ref: req.query.DELETE.from.ref }))]
1049
1127
  if (draft && req.target['@Common.DraftRoot.ActivationAction'])
1050
1128
  // only for draft root
@@ -1,4 +1,5 @@
1
1
  const cds = require('../../cds')
2
+ const getError = require('../../common/error')
2
3
  const { SELECT, DELETE } = cds.ql
3
4
 
4
5
  const { isDraftRootEntity } = require('./csn')
@@ -39,8 +40,13 @@ const deleteDraft = async (req, srv, includingActive = false) => {
39
40
  const dbtx = cds.tx(req)
40
41
  const definitions = srv.model.definitions
41
42
 
43
+ const where = req.query.DELETE.from.ref?.[req.query.DELETE.from.ref.length - 1].where
44
+ if (!where) {
45
+ req.reject(getError(500, 'Invalid delete draft request'))
46
+ }
47
+
42
48
  // REVISIT: how to handle delete of to 1 assoc
43
- const keys = extractKeyConditions(req.query.DELETE.from.ref[req.query.DELETE.from.ref.length - 1].where)
49
+ const keys = extractKeyConditions(where)
44
50
 
45
51
  // IsActiveEntity is deleted from where clause in auth.js, hence keys.IsActiveEntity is undefined here.
46
52
  // Intentional?
@@ -1,4 +1,5 @@
1
1
  const cds = require('../../cds')
2
+ const { Object_keys } = cds.utils
2
3
  const { UPDATE, SELECT } = cds.ql
3
4
  const { getColumns } = require('../../cds-services/services/utils/columns')
4
5
  const { ensureNoDraftsSuffix, ensureDraftsSuffix, ensureUnlocalized } = require('../../common/utils/draft')
@@ -241,11 +242,8 @@ const draftIsLocked = lastChangedAt => {
241
242
  return DRAFT_CANCEL_TIMEOUT_IN_MS > Date.now() - Date.parse(lastChangedAt)
242
243
  }
243
244
 
244
- const filterKeys = keys => {
245
- return Object.keys(keys).filter(key => {
246
- return key !== 'IsActiveEntity' && !keys[key]._isAssociationStrict
247
- })
248
- }
245
+ const entity_keys = entity =>
246
+ Object_keys(entity.keys).filter(key => key !== 'IsActiveEntity' && !entity.keys[key]._isAssociationStrict)
249
247
 
250
248
  module.exports = {
251
249
  getSubCQNs,
@@ -260,7 +258,7 @@ module.exports = {
260
258
  proxifyToNoDraftsName,
261
259
  addColumnAlias,
262
260
  replaceRefWithDraft,
263
- filterKeys,
261
+ entity_keys,
264
262
  getDeleteDraftAdminCqn,
265
263
  getCompositionTargets
266
264
  }
@@ -0,0 +1,27 @@
1
+ const cds = require('../../cds')
2
+ const { getTransition } = require('../../common/utils/resolveView')
3
+ const { getKeyData, getLockWhere } = require('../utils/where')
4
+ const { entity_keys } = require('../utils/handler')
5
+
6
+ const getLockInfo = (req, dataSource) => {
7
+ const keys = entity_keys(req.target)
8
+ const keyData = getKeyData(keys, req.query.SELECT.from.ref[0].where)
9
+ const rootWhere = keys.reduce((res, key) => {
10
+ res[key] = keyData[key]
11
+ return res
12
+ }, {})
13
+
14
+ if (dataSource === undefined || dataSource === cds.db) {
15
+ const transition = getTransition(req.target, cds.db)
16
+
17
+ // gets the underlying target entity, as record locking can't be
18
+ // applied to localized views
19
+ const target = transition.target
20
+ const where = getLockWhere(rootWhere, transition.mapping)
21
+ return { target, where, rootWhere }
22
+ }
23
+
24
+ return { target: req.target, where: req.query.SELECT.from.ref[0].where, rootWhere }
25
+ }
26
+
27
+ module.exports = getLockInfo
@@ -26,19 +26,23 @@ const _calculateSpliceArgs = (index, whereCondition, isXpr = false) => {
26
26
  if (whereCondition[index - 1] in AND_OR) {
27
27
  return { index: index - 1, count: 1 + len }
28
28
  }
29
+
29
30
  if (whereCondition[index + len] in AND_OR) {
30
31
  return { index: index, count: len + 1 }
31
32
  }
33
+
32
34
  if (whereCondition[index - 1] === '(' && whereCondition[index + len] === ')') {
33
35
  if (whereCondition[index - 2] in AND_OR) {
34
36
  return { index: index - 2, count: len + 3 }
35
37
  }
38
+
36
39
  if (whereCondition[index + len + 1] in AND_OR) {
37
40
  return { index: index - 1, count: len + 3 }
38
41
  }
39
42
 
40
43
  return { index: index - 1, count: len + 2 }
41
44
  }
45
+
42
46
  return { index: index, count: len }
43
47
  }
44
48
 
@@ -224,6 +228,20 @@ const getKeysCondition = req => {
224
228
  return where
225
229
  }
226
230
 
231
+ const getLockWhere = (where, columnsMap) => {
232
+ if (columnsMap.size === 0) return where
233
+ const whereKeys = Object.keys(where)
234
+ const lockWhere = {}
235
+
236
+ whereKeys.forEach(key => {
237
+ const mappedKey = columnsMap.get(key)
238
+ const lockKey = mappedKey ? mappedKey.ref[0] : key
239
+ lockWhere[lockKey] = where[key]
240
+ })
241
+
242
+ return lockWhere
243
+ }
244
+
227
245
  module.exports = {
228
246
  deleteCondition,
229
247
  readAndDeleteKeywords,
@@ -231,5 +249,6 @@ module.exports = {
231
249
  isActiveEntityRequested,
232
250
  getKeyData,
233
251
  extractKeyConditions,
234
- getKeysCondition
252
+ getKeysCondition,
253
+ getLockWhere
235
254
  }
@@ -24,7 +24,8 @@ class AMQPWebhookMessaging extends MessagingService {
24
24
  return super.init()
25
25
  }
26
26
 
27
- async emit(msg) {
27
+ async handle(msg) {
28
+ if (msg.inbound) return super.handle(msg)
28
29
  const _msg = this.message4(msg)
29
30
  const client = this.getClient()
30
31
  await this.queued(() => {})()
@@ -50,7 +51,7 @@ class AMQPWebhookMessaging extends MessagingService {
50
51
  if (!msg._) msg._ = {}
51
52
  msg._.topic = _topic
52
53
  try {
53
- await super.emit(msg)
54
+ await this.tx({ user: cds.User.privileged, tenant: msg.tenant, _: msg._ }, tx => tx.emit(msg))
54
55
  done()
55
56
  } catch (e) {
56
57
  // In case of AMQP and Solace, the `failed` callback must be called
@@ -1,53 +1,18 @@
1
1
  const cds = require('../cds')
2
+ const LOG = cds.log()
2
3
 
3
- const {
4
- processMessages,
5
- registerMessageProcessor,
6
- writeInOutbox,
7
- hasPersistentOutbox,
8
- isUnrecoverable
9
- } = require('./outbox/utils')
4
+ let logged
10
5
 
11
- class OutboxService extends cds.Service {
12
- // eslint-disable-next-line require-await
13
- async init() {
14
- // REVISIT: add 'outbox' to list of module names?
15
- const LOG = cds.log(this.name)
16
-
17
- // REVISIT: Also allow to overwrite this.send
18
- this._emitImmediate = this.emit
19
- this.emit = async function (...args) {
20
- const msg = typeof args[0] === 'object' ? args[0] : { event: args[0], data: args[1], headers: args[2] }
21
- const context = this.context || cds.context
22
- if (this.options.outbox && context) {
23
- const outboxOpts = Object.assign(
24
- {},
25
- (typeof cds.requires.outbox === 'object' && cds.requires.outbox) || {},
26
- (this.options && typeof this.options.outbox === 'object' && this.options.outbox) || {}
27
- )
28
- if (hasPersistentOutbox(this, context.tenant)) {
29
- // returns true if not yet registered
30
- if (registerMessageProcessor(this.name, context)) {
31
- context.on('succeeded', () => processMessages(this, context.tenant, outboxOpts))
32
- }
33
- await writeInOutbox(this.name, msg, context)
34
- return
35
- }
36
- // Revisit: Also allow maxAttempts?
37
- context.on('succeeded', async () => {
38
- try {
39
- await this._emitImmediate(msg)
40
- } catch (e) {
41
- LOG.error('Emit failed', { event: msg.event, cause: e })
42
- // opts.crashOnError is not official!!!
43
- if (isUnrecoverable(this, e) && outboxOpts.crashOnError !== false) cds.exit(1)
44
- }
45
- })
46
- return
47
- }
48
- return this._emitImmediate(msg)
6
+ module.exports = class Outbox extends cds.Service {
7
+ constructor(...args) {
8
+ if (!logged && LOG._warn) {
9
+ // prettier-ignore
10
+ LOG.warn('Internal class `OutboxService` is deprecated and will be removed. Services are outboxable via config or `cds.outboxed()`.')
11
+ logged = true
49
12
  }
13
+
14
+ super(...args)
15
+
16
+ if (this.options.outbox) return cds.outboxed(this)
50
17
  }
51
18
  }
52
-
53
- module.exports = OutboxService
@@ -2,7 +2,6 @@ const cds = require('../../cds.js')
2
2
  // eslint-disable-next-line cds/no-missing-dependencies -- needs to be added by app dev
3
3
  const ClientAmqp = require('@sap/xb-msg-amqp-v100').Client
4
4
  const { connect, disconnect } = require('./connections')
5
- const { hasPersistentOutbox } = require('../outbox/utils')
6
5
 
7
6
  const addDataListener = (client, queue, prefix, cb) =>
8
7
  new Promise(resolve => {
@@ -79,8 +78,7 @@ class AMQPClient {
79
78
  async emit(msg) {
80
79
  if (!this.client) await this.connect()
81
80
  // REVISIT: Is this a robust way to find out if the connection is working?
82
- if (hasPersistentOutbox(this.service, cds.context && cds.context.tenant) && !this.sender.opened())
83
- throw new Error('AMQP: Sender is not open')
81
+ if (msg._fromOutbox && !this.sender.opened()) throw new Error('AMQP: Sender is not open')
84
82
  await emit(msg, this.stream, this.prefix.topic, this.service.LOG)
85
83
  if (!this.keepAlive) return this.disconnect()
86
84
  }