Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
efe1156
initial
tilwbr Mar 27, 2026
53ae877
refactoring
tilwbr Mar 27, 2026
e6f9f9f
business key initial
tilwbr Mar 27, 2026
6df98b0
business key fix
tilwbr Mar 27, 2026
f2835b3
initial commit for DELETE operations
tilwbr Mar 27, 2026
b0a1446
some build logic rework
tilwbr Mar 30, 2026
0a5b408
tests for multiple start annotations
tilwbr Mar 30, 2026
97455f5
initial commit for multiple lifeycycle annotations
tilwbr Mar 30, 2026
f38adf3
made businesskey logic consistent
tilwbr Mar 30, 2026
3b8e9b0
refactored process start method
tilwbr Mar 30, 2026
1a8f380
tests for lifecycle annotations
tilwbr Mar 30, 2026
81eb2a5
adapted build plugin and tests
tilwbr Mar 30, 2026
ed2e8ee
improvements
tilwbr Mar 31, 2026
3373b30
code improvements
tilwbr Mar 31, 2026
e893fa1
code refactoring
tilwbr Mar 31, 2026
b0264f9
removed other ifecycle annotations, will be moved to separate pr
tilwbr Mar 31, 2026
901ee0c
Merge remote-tracking branch 'origin/main' into multipleAnnotations
tilwbr Mar 31, 2026
93c4a67
imports
tilwbr Mar 31, 2026
edeeb73
code improvements
tilwbr Mar 31, 2026
2f13656
for PR
tilwbr Mar 31, 2026
f83e323
pr improvements
tilwbr Mar 31, 2026
37f5220
pr review
tilwbr Mar 31, 2026
d647aa4
added hybrid test setup
tilwbr Mar 31, 2026
66d6a5b
Merge remote-tracking branch 'origin/main' into multipleAnnotations
tilwbr Mar 31, 2026
f605556
fix tests and prettier
tilwbr Mar 31, 2026
9881dec
removed one duplicated test
tilwbr Mar 31, 2026
05faa5d
updated readme
tilwbr Mar 31, 2026
c903d32
removed unused function
tilwbr Mar 31, 2026
ceda6cd
pr comment
tilwbr Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

- Reducing the context sent to the workflow API to only needed fields
- Allowing the import of downloaded process models
- Support for multiple start annotations on the same entity using qualifier

## Version 0.1.1 - 2026-03-27

Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ CAP Plugin to interact with SAP Build Process Automation to manage processes.
- [Importing Processes as a Service](#importing-processes-as-a-service)
- [Annotations](#annotations)
- [Starting a Process](#starting-a-process)
- [Multiple Start Annotations](#multiple-start-annotations)
- [Cancelling, Resuming, or Suspending a Process](#cancelling-resuming-or-suspending-a-process)
- [Conditional Execution](#conditional-execution)
- [Input Mapping](#input-mapping)
Expand Down Expand Up @@ -100,6 +101,34 @@ service MyService {

> See [Input Mapping](#input-mapping) below for detailed examples on controlling which entity fields are passed as process context.

#### Multiple Start Annotations

To start multiple processes from the same entity, use CDS qualifiers (`#qualifier`) to distinguish them. Each qualified annotation is evaluated independently, so each can have its own `id`, `on`, `if`, and `inputs`.

```cds
service MyService {

@bpm.process.start #orderProcess : {
id: 'orderProcess',
on: 'CREATE',
}
@bpm.process.start #notificationProcess : {
id: 'notificationProcess',
on: 'CREATE',
if: (field3 > 10)
}
entity MyEntity {
key ID : UUID;
field1 : String;
field2 : String;
field3 : Integer;
};

}
```

Both processes are started when a `CREATE` event occurs on the entity, but `notificationProcess`is only stated if field3 is > 10. You can also combine qualifiers with different events or conditions.

### Cancelling, Resuming, or Suspending a Process

- `@bpm.process.<cancel|resume|suspend>` -- Cancel/Suspend/Resume any processes with the given businessKey
Expand Down
96 changes: 54 additions & 42 deletions lib/build/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,28 @@ import {
validateBusinessKeyAnnotation,
} from './index';
import {
PROCESS_START_ID,
PROCESS_START_ON,
PROCESS_START_IF,
PROCESS_START_INPUTS,
PROCESS_START,
PROCESS_CANCEL,
PROCESS_CANCEL_ON,
PROCESS_CANCEL_CASCADE,
PROCESS_CANCEL_IF,
PROCESS_SUSPEND_ON,
PROCESS_SUSPEND_CASCADE,
PROCESS_SUSPEND_IF,
PROCESS_RESUME,
PROCESS_RESUME_ON,
PROCESS_RESUME_CASCADE,
PROCESS_RESUME_IF,
PROCESS_CANCEL,
PROCESS_SUSPEND,
PROCESS_RESUME,
PROCESS_START,
PROCESS_SUSPEND_ON,
PROCESS_SUSPEND_CASCADE,
PROCESS_SUSPEND_IF,
PROCESS_PREFIX,
SUFFIX_ID,
SUFFIX_ON,
SUFFIX_IF,
SUFFIX_INPUTS,
BUSINESS_KEY,
} from '../constants';

import { CsnDefinition, CsnEntity } from '../types/csn-extensions';
import { getAnnotationPrefixes } from '../shared/annotations-helper';

/**
* Configuration for lifecycle annotation validation (cancel, suspend, resume)
Expand Down Expand Up @@ -131,40 +131,52 @@ export class ProcessValidationPlugin extends BuildPluginBase {
processDefinitions: Map<string, CsnDefinition>,
allDefinitions: Record<string, CsnDefinition>,
) {
// check unknown annotations
const allowedAnnotations = [
PROCESS_START_ID,
PROCESS_START_ON,
PROCESS_START_IF,
PROCESS_START_INPUTS,
];
validateAllowedAnnotations(allowedAnnotations, def, entityName, PROCESS_START, this);

const hasId = def[PROCESS_START_ID] !== undefined;
const hasOn = def[PROCESS_START_ON] !== undefined;
const hasIf = def[PROCESS_START_IF] !== undefined;

// required fields
validateRequiredStartAnnotations(hasOn, hasId, entityName, this);

const processDef = processDefinitions.get(def[PROCESS_START_ID]);

if (hasId) {
validateIdAnnotation(def, entityName, processDef, this);
}
const startPrefixes = getAnnotationPrefixes(def, PROCESS_START);

if (hasOn) {
validateOnAnnotation(def, entityName, PROCESS_START_ON, this);
}
for (const prefix of startPrefixes) {
const annotationId = `${prefix}${SUFFIX_ID}` as `@${string}`;
const annotationOn = `${prefix}${SUFFIX_ON}` as `@${string}`;
const annotationIf = `${prefix}${SUFFIX_IF}` as `@${string}`;
const annotationInputs = `${prefix}${SUFFIX_INPUTS}` as `@${string}`;

if (hasIf) {
validateIfAnnotation(def, entityName, PROCESS_START_IF, this);
}
// check unknown annotations for this prefix
const allowedAnnotations = [annotationId, annotationOn, annotationIf, annotationInputs];
validateAllowedAnnotations(allowedAnnotations, def, entityName, prefix, this);

const hasId = def[annotationId] !== undefined;
const hasOn = def[annotationOn] !== undefined;
const hasIf = def[annotationIf] !== undefined;

// required fields
validateRequiredStartAnnotations(hasOn, hasId, entityName, annotationOn, annotationId, this);

const processDef = processDefinitions.get(def[annotationId]);

if (hasId) {
validateIdAnnotation(def, entityName, annotationId, processDef, this);
}

if (hasOn) {
validateOnAnnotation(def, entityName, annotationOn, this);
}

if (hasId && hasOn && processDef) {
const processInputs = allDefinitions[`${processDef.name}.ProcessInputs`];
if (typeof processInputs !== 'undefined') {
validateInputTypes(this, entityName, def, processInputs, allDefinitions);
if (hasIf) {
validateIfAnnotation(def, entityName, annotationIf, this);
}

if (hasId && hasOn && processDef) {
const processInputs = allDefinitions[`${processDef.name}.ProcessInputs`];
if (typeof processInputs !== 'undefined') {
validateInputTypes(
this,
entityName,
def,
processInputs,
allDefinitions,
annotationInputs,
annotationId,
);
}
}
}
}
Expand Down
9 changes: 6 additions & 3 deletions lib/build/validation-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CsnDefinition, CsnElement, CsnEntity } from '../types/csn-extensions';
import { PROCESS_PREFIX, PROCESS_START_INPUTS } from '../constants';
import { PROCESS_PREFIX } from '../constants';
import {
InputCSNEntry,
InputTreeNode,
Expand Down Expand Up @@ -272,8 +272,11 @@ export function getProcessDefinitions(
return processMap;
}

export function getParsedInputEntries(def: CsnEntity): ParsedInputEntry[] | undefined {
const inputsCSN = def[PROCESS_START_INPUTS] as InputCSNEntry[] | undefined;
export function getParsedInputEntries(
def: CsnEntity,
inputsAnnotationKey: `@${string}`,
): ParsedInputEntry[] | undefined {
const inputsCSN = def[inputsAnnotationKey] as InputCSNEntry[] | undefined;
if (!inputsCSN || inputsCSN.length === 0) {
return undefined;
}
Expand Down
21 changes: 13 additions & 8 deletions lib/build/validations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import cds from '@sap/cds';
import { ProcessValidationPlugin } from './plugin';
import { CsnDefinition, CsnElement, CsnEntity } from '../types/csn-extensions';
import { BUSINESS_KEY, PROCESS_START_ID, PROCESS_START_ON } from '../constants';
import { BUSINESS_KEY } from '../constants';
import {
createCsnEntityContext,
ElementType,
Expand Down Expand Up @@ -110,17 +110,19 @@ export function validateRequiredStartAnnotations(
hasOn: boolean,
hasId: boolean,
entityName: string,
annotationOn: string,
annotationId: string,
buildPlugin: ProcessValidationPlugin,
) {
if (hasOn && !hasId) {
buildPlugin.pushMessage(
ERROR_START_ON_REQUIRES_ID(entityName, PROCESS_START_ON, PROCESS_START_ID),
ERROR_START_ON_REQUIRES_ID(entityName, annotationOn, annotationId),
ERROR,
);
}
if (hasId && !hasOn) {
buildPlugin.pushMessage(
ERROR_START_ID_REQUIRES_ON(entityName, PROCESS_START_ID, PROCESS_START_ON),
ERROR_START_ID_REQUIRES_ON(entityName, annotationId, annotationOn),
ERROR,
);
}
Expand All @@ -129,16 +131,17 @@ export function validateRequiredStartAnnotations(
export function validateIdAnnotation(
def: CsnEntity,
entityName: string,
annotationId: `@${string}`,
processDef: CsnDefinition | undefined,
buildPlugin: ProcessValidationPlugin,
) {
if (typeof def[PROCESS_START_ID] !== 'string') {
buildPlugin.pushMessage(ERROR_START_ID_MUST_BE_STRING(entityName, PROCESS_START_ID), ERROR);
if (typeof def[annotationId] !== 'string') {
buildPlugin.pushMessage(ERROR_START_ID_MUST_BE_STRING(entityName, annotationId), ERROR);
}

if (!processDef) {
buildPlugin.pushMessage(
WARNING_NO_PROCESS_DEFINITION(entityName, PROCESS_START_ID, def[PROCESS_START_ID]),
WARNING_NO_PROCESS_DEFINITION(entityName, annotationId, def[annotationId]),
WARNING,
);
}
Expand Down Expand Up @@ -169,8 +172,10 @@ export function validateInputTypes(
def: CsnDefinition,
processDef: CsnDefinition,
allDefinitions: Record<string, CsnDefinition> | undefined,
inputsAnnotationKey: `@${string}`,
idAnnotationKey: `@${string}`,
) {
const parsedEntries = getParsedInputEntries(def as CsnEntity);
const parsedEntries = getParsedInputEntries(def as CsnEntity, inputsAnnotationKey);
const elements = (def as CsnEntity).elements ?? {};
const entityContext = createCsnEntityContext(
elements as Record<string, CsnElement>,
Expand All @@ -196,7 +201,7 @@ export function validateInputTypes(
entityName,
entityAttributes,
processDefInputs,
def[PROCESS_START_ID],
(def as CsnEntity)[idAnnotationKey],
);
}

Expand Down
48 changes: 26 additions & 22 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,37 +25,41 @@ export const BUSINESS_KEY_MAX_LENGTH = 255;
export const BUSINESS_KEY_ALIAS = 'as businessKey' as const;

/**
* Process Start Annotations
* Process annotation base prefixes
*/
export const PROCESS_START = '@bpm.process.start' as const;
export const PROCESS_START_ID = '@bpm.process.start.id' as const;
export const PROCESS_START_ON = '@bpm.process.start.on' as const;
export const PROCESS_START_IF = '@bpm.process.start.if' as const;
export const PROCESS_START_INPUTS = '@bpm.process.start.inputs' as const;

/**
* Process Cancel Annotations
*/
export const PROCESS_CANCEL = '@bpm.process.cancel' as const;
export const PROCESS_CANCEL_ON = '@bpm.process.cancel.on' as const;
export const PROCESS_CANCEL_CASCADE = '@bpm.process.cancel.cascade' as const;
export const PROCESS_CANCEL_IF = '@bpm.process.cancel.if' as const;
export const PROCESS_SUSPEND = '@bpm.process.suspend' as const;
export const PROCESS_RESUME = '@bpm.process.resume' as const;

/**
* Process Suspend Annotations
* Annotation property suffixes.
*/
export const PROCESS_SUSPEND = '@bpm.process.suspend' as const;
export const PROCESS_SUSPEND_ON = '@bpm.process.suspend.on' as const;
export const PROCESS_SUSPEND_CASCADE = '@bpm.process.suspend.cascade' as const;
export const PROCESS_SUSPEND_IF = '@bpm.process.suspend.if' as const;
export const SUFFIX_ID = '.id' as const;
export const SUFFIX_ON = '.on' as const;
export const SUFFIX_IF = '.if' as const;
export const SUFFIX_CASCADE = '.cascade' as const;
export const SUFFIX_INPUTS = '.inputs' as const;

/**
* Process Resume Annotations
* Derived full-path annotation keys (unqualified).
*/
export const PROCESS_RESUME = '@bpm.process.resume' as const;
export const PROCESS_RESUME_ON = '@bpm.process.resume.on' as const;
export const PROCESS_RESUME_CASCADE = '@bpm.process.resume.cascade' as const;
export const PROCESS_RESUME_IF = '@bpm.process.resume.if' as const;
export const PROCESS_START_ID = `${PROCESS_START}${SUFFIX_ID}` as const;
export const PROCESS_START_ON = `${PROCESS_START}${SUFFIX_ON}` as const;
export const PROCESS_START_IF = `${PROCESS_START}${SUFFIX_IF}` as const;
export const PROCESS_START_INPUTS = `${PROCESS_START}${SUFFIX_INPUTS}` as const;

export const PROCESS_CANCEL_ON = `${PROCESS_CANCEL}${SUFFIX_ON}` as const;
export const PROCESS_CANCEL_CASCADE = `${PROCESS_CANCEL}${SUFFIX_CASCADE}` as const;
export const PROCESS_CANCEL_IF = `${PROCESS_CANCEL}${SUFFIX_IF}` as const;

export const PROCESS_SUSPEND_ON = `${PROCESS_SUSPEND}${SUFFIX_ON}` as const;
export const PROCESS_SUSPEND_CASCADE = `${PROCESS_SUSPEND}${SUFFIX_CASCADE}` as const;
export const PROCESS_SUSPEND_IF = `${PROCESS_SUSPEND}${SUFFIX_IF}` as const;

export const PROCESS_RESUME_ON = `${PROCESS_RESUME}${SUFFIX_ON}` as const;
export const PROCESS_RESUME_CASCADE = `${PROCESS_RESUME}${SUFFIX_CASCADE}` as const;
export const PROCESS_RESUME_IF = `${PROCESS_RESUME}${SUFFIX_IF}` as const;

/**
* Annotation prefix for filtering
Expand Down
21 changes: 9 additions & 12 deletions lib/handlers/annotationCache.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import cds from '@sap/cds';
import { EntityEventCache } from '../types/cds-plugin';
import {
PROCESS_START_ID,
PROCESS_START_ON,
PROCESS_CANCEL_ON,
PROCESS_SUSPEND_ON,
PROCESS_RESUME_ON,
CUD_EVENTS,
} from '../constants';
import { CUD_EVENTS, PROCESS_CANCEL_ON, PROCESS_SUSPEND_ON, PROCESS_RESUME_ON } from '../constants';
import { findStartAnnotations } from '../shared/annotations-helper';

function expandEvent(event: string | undefined, entity: cds.entity): string[] {
if (!event) return [];
Expand All @@ -21,13 +15,15 @@ function expandEvent(event: string | undefined, entity: cds.entity): string[] {
export function buildAnnotationCache(service: cds.Service) {
const cache = new Map<string, EntityEventCache>();
for (const entity of Object.values(service.entities)) {
const startEvent = entity[PROCESS_START_ON];
const startAnnotations = findStartAnnotations(entity);
const cancelEvent = entity[PROCESS_CANCEL_ON];
const suspendEvent = entity[PROCESS_SUSPEND_ON];
const resumeEvent = entity[PROCESS_RESUME_ON];

const events = new Set<string>();
for (const ev of expandEvent(startEvent, entity)) events.add(ev);
for (const ann of startAnnotations) {
for (const ev of expandEvent(ann.on, entity)) events.add(ev);
}
for (const ev of expandEvent(cancelEvent, entity)) events.add(ev);
for (const ev of expandEvent(suspendEvent, entity)) events.add(ev);
for (const ev of expandEvent(resumeEvent, entity)) events.add(ev);
Expand All @@ -36,14 +32,15 @@ export function buildAnnotationCache(service: cds.Service) {
const matchesEvent = (annotationEvent: string | undefined) =>
annotationEvent === event || annotationEvent === '*';

const hasStart = !!(matchesEvent(startEvent) && entity[PROCESS_START_ID]);
// Filter annotations to those matching this event
const matchingStarts = startAnnotations.filter((ann) => matchesEvent(ann.on));
const hasCancel = !!matchesEvent(cancelEvent);
const hasSuspend = !!matchesEvent(suspendEvent);
const hasResume = !!matchesEvent(resumeEvent);

const cacheKey = `${entity.name}:${event}`;
cache.set(cacheKey, {
hasStart,
startAnnotations: matchingStarts,
hasCancel,
hasSuspend,
hasResume,
Expand Down
Loading
Loading