Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
- The format is based on [Keep a Changelog](https://keepachangelog.com/).
- This project adheres to [Semantic Versioning](https://semver.org/).

## Version 0.3.0 - 2026-05-11

### Added

- `getInstances` function replacing `getInstancesByBusinessKey` — supports filtering by all SBPA workflow instance query parameters: `id`, `businessKey`, `status`, `definitionId`, `definitionVersion`, `startedAt`, `startedFrom`, `startedUpTo`, `completedAt`, `completedFrom`, `completedUpTo`, `startedBy`, `subject`, `containsText`, `rootInstanceId`, `parentInstanceId`, `orderBy`, `top`, `skip`, `inlinecount`

### Changed

- `getInstancesByBusinessKey` is replaced by `getInstances` in both the specific process services and the generic `ProcessService`

## Version 0.2.1 - 2026-04-20

### Fixed
Expand Down
72 changes: 54 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ CAP Plugin to interact with SAP Build Process Automation to manage processes.
- [What Gets Generated](#what-gets-generated)
- [Starting a Process](#starting-a-process)
- [Suspending, Resuming, and Cancelling a Process](#suspending-resuming-and-cancelling-a-process)
- [Querying Process Instances](#querying-process-instances)
- [Limitations](#limitations)
- [Querying Process Instances](#querying-process-instances) - [Limitations](#limitations)
- [Generic ProcessService](#generic-processservice)
- [Service Definition](#service-definition)
- [Usage](#usage)
Expand Down Expand Up @@ -567,7 +566,7 @@ The import generates:
- A CDS service definition in `./srv/external/` (annotated with `@bpm.process` and `@protocol: 'none'`)
- Typed `ProcessInputs`, `ProcessOutputs`, `ProcessAttribute`, and `ProcessInstance` types based on the process definition
- Typed actions: `start`, `suspend`, `resume`, `cancel`
- Typed functions: `getAttributes`, `getOutputs`, `getInstancesByBusinessKey`
- Typed functions: `getAttributes`, `getOutputs`, `getInstances`
- A process definition JSON in `./srv/workflows/`

After importing, run `cds-typer` to generate TypeScript types for the imported service.
Expand Down Expand Up @@ -609,11 +608,20 @@ The `cascade` parameter is optional and defaults to `false`. When set to `true`,

```typescript
// Get all instances matching a business key, optionally filtered by status
const instances = await processService.getInstancesByBusinessKey({
const instances = await processService.getInstances({
businessKey: 'order-12345',
status: ['RUNNING', 'SUSPENDED'],
});

// Filter by additional parameters
const instances = await processService.getInstances({
definitionId: 'eu12.myorg.myproject.myProcess',
startedFrom: '2024-01-01T00:00:00Z',
orderBy: 'startedAt desc',
top: 10,
skip: 0,
});

// Get attributes for a specific process instance
const attributes = await processService.getAttributes({
processInstanceId: 'instance-uuid',
Expand All @@ -625,8 +633,28 @@ const outputs = await processService.getOutputs({
});
```

Valid status values are: `RUNNING`, `SUSPENDED`, `CANCELLED`, `ERRONEOUS`, `COMPLETED`.
If no status filter is provided, all statuses except `CANCELLED` are returned.
All parameters are optional. Supported filter parameters:

| Parameter | Type | Description |
| ------------------- | --------------- | ------------------------------------------------------------------------------- |
| `id` | `String` | Filter by workflow instance ID |
| `businessKey` | `String` | Filter by business key |
| `status` | `Array<String>` | Filter by status (`RUNNING`, `SUSPENDED`, `CANCELED`, `ERRONEOUS`, `COMPLETED`) |
| `definitionId` | `String` | Filter by process definition ID |
| `definitionVersion` | `String` | Filter by process definition version |
| `startedFrom` | `Timestamp` | Filter instances started on or after this date |
| `startedUpTo` | `Timestamp` | Filter instances started on or before this date |
| `completedFrom` | `Timestamp` | Filter instances completed on or after this date |
| `completedUpTo` | `Timestamp` | Filter instances completed on or before this date |
| `startedBy` | `String` | Filter by the user who started the instance |
| `subject` | `String` | Filter by subject |
| `containsText` | `String` | Full-text search across instance fields |
| `rootInstanceId` | `String` | Filter by root instance ID |
| `parentInstanceId` | `String` | Filter by parent instance ID |
| `orderBy` | `String` | Sort order (e.g. `startedAt desc`, `businessKey asc`) |
| `top` | `Integer` | Maximum number of results to return |
| `skip` | `Integer` | Number of results to skip (for pagination) |
| `inlinecount` | `String` | Set to `allpages` to include total count in response |

#### Limitations

Expand All @@ -642,15 +670,15 @@ The generic `ProcessService` allows setting the business key to mimic the behavi

The generic `ProcessService` defines the following events and functions:

| Operation | Type | Description |
| --------------------------- | -------- | ----------------------------------------------------------------- |
| `start` | event | Start a workflow instance with a `definitionId` and `context` |
| `cancel` | event | Cancel all running/suspended instances matching a `businessKey` |
| `suspend` | event | Suspend all running instances matching a `businessKey` |
| `resume` | event | Resume all suspended instances matching a `businessKey` |
| `getAttributes` | function | Retrieve attributes for a specific process instance |
| `getOutputs` | function | Retrieve outputs for a specific process instance |
| `getInstancesByBusinessKey` | function | Find process instances by business key and optional status filter |
| Operation | Type | Description |
| --------------- | -------- | --------------------------------------------------------------- |
| `start` | event | Start a workflow instance with a `definitionId` and `context` |
| `cancel` | event | Cancel all running/suspended instances matching a `businessKey` |
| `suspend` | event | Suspend all running instances matching a `businessKey` |
| `resume` | event | Resume all suspended instances matching a `businessKey` |
| `getAttributes` | function | Retrieve attributes for a specific process instance |
| `getOutputs` | function | Retrieve outputs for a specific process instance |
| `getInstances` | function | Query process instances with flexible filter parameters |

#### Usage

Expand Down Expand Up @@ -688,12 +716,20 @@ await processService.emit('resume', {
cascade: false,
});

// Query instances by business key
const instances = await processService.send('getInstancesByBusinessKey', {
// Query instances with flexible filters
const instances = await processService.send('getInstances', {
businessKey: 'order-12345',
status: ['RUNNING', 'SUSPENDED'],
});

// Query with additional params
const instances = await processService.send('getInstances', {
definitionId: 'eu12.myorg.myproject.myProcess',
startedFrom: '2024-01-01T00:00:00Z',
orderBy: 'startedAt desc',
top: 10,
});

// Get attributes of a specific instance
const attributes = await processService.send('getAttributes', {
processInstanceId: 'instance-uuid',
Expand All @@ -705,7 +741,7 @@ const outputs = await processService.send('getOutputs', {
});
```

> **Note:** The generic ProcessService uses `emit` for lifecycle events (start, cancel, suspend, resume) which are processed asynchronously through the CDS outbox, and `send` for query functions (getAttributes, getOutputs, getInstancesByBusinessKey) which return data synchronously.
> **Note:** The generic ProcessService uses `emit` for lifecycle events (start, cancel, suspend, resume) which are processed asynchronously through the CDS outbox, and `send` for query functions (getAttributes, getOutputs, getInstances) which return data synchronously.
> Make sure to check whether the outbox is correctly used. If not, refer to cds.queued to make sure it is used.

## Build-Time Validation
Expand Down
6 changes: 6 additions & 0 deletions lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ export {
IWorkflowInstanceClient,
WorkflowInstance,
WorkflowStatus,
GetInstancesParams,
StartWorkflowResult,
UpdateStatusResult,
INSTANCES_PARAMS_SKIP_KEYS,
INSTANCES_PARAM_KEY_MAP,
createWorkflowInstanceClient,
startWorkflow,
getWorkflowsByBusinessKey,
getInstances,
updateWorkflowStatus,
updateMultipleWorkflowStatus,
getAttributes,
getOutputs,
} from './workflow-client';

// Local Workflow Store - for local development
Expand Down
78 changes: 78 additions & 0 deletions lib/api/local-workflow-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
WorkflowStatus,
WorkflowInstance,
GetInstancesParams,
INSTANCES_PARAMS_SKIP_KEYS,
StartWorkflowResult,
UpdateStatusResult,
} from './workflow-client';
Expand Down Expand Up @@ -75,6 +77,82 @@ export class LocalWorkflowStore {
return filtered;
}

getInstances(params: GetInstancesParams): LocalWorkflowInstance[] {
const specialKeys = new Set([
...INSTANCES_PARAMS_SKIP_KEYS,
'startedFrom',
'startedUpTo',
'completedFrom',
'completedUpTo',
'containsText',
'rootInstanceId',
'parentInstanceId',
'skip',
'top',
'orderBy',
'inlinecount',
]);

let filteredInstances = [...this.instances];

for (const [key, value] of Object.entries(params)) {
if (value == null || specialKeys.has(key)) continue;
filteredInstances = filteredInstances.filter(
(i) => i[key as keyof LocalWorkflowInstance] === value,
);
}

if (params.status && params.status.length > 0) {
filteredInstances = filteredInstances.filter((i) => params.status!.includes(i.status));
}

if (params.startedFrom != null) {
const from = new Date(params.startedFrom);
filteredInstances = filteredInstances.filter(
(i) => i.startedAt != null && new Date(i.startedAt) >= from,
);
}
if (params.startedUpTo != null) {
const upTo = new Date(params.startedUpTo);
filteredInstances = filteredInstances.filter(
(i) => i.startedAt != null && new Date(i.startedAt) <= upTo,
);
}
if (params.completedFrom != null) {
const from = new Date(params.completedFrom);
filteredInstances = filteredInstances.filter(
(i) => i.completedAt != null && new Date(i.completedAt) >= from,
);
}
if (params.completedUpTo != null) {
const upTo = new Date(params.completedUpTo);
filteredInstances = filteredInstances.filter(
(i) => i.completedAt != null && new Date(i.completedAt) <= upTo,
);
}

if (params.containsText != null) {
const text = params.containsText.toLowerCase();
filteredInstances = filteredInstances.filter(
(i) =>
i.id.toLowerCase().includes(text) ||
i.subject?.toLowerCase().includes(text) ||
i.businessKey?.toLowerCase().includes(text),
);
}

if (params.rootInstanceId != null)
filteredInstances = filteredInstances.filter((i) => i.id === params.rootInstanceId);
if (params.parentInstanceId != null)
filteredInstances = filteredInstances.filter((i) => i.id === params.parentInstanceId);

const skip = params.skip ?? 0;
const top = params.top ?? filteredInstances.length;
filteredInstances = filteredInstances.slice(skip, skip + top);

return filteredInstances;
}

getInstance(instanceId: string): LocalWorkflowInstance | undefined {
return this.instances.find((i) => i.id === instanceId);
}
Expand Down
84 changes: 84 additions & 0 deletions lib/api/workflow-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { PROCESS_LOGGER_PREFIX } from '../constants';
const LOG = cds.log(PROCESS_LOGGER_PREFIX);
const BASE_PATH = '/public/workflow/rest';

// Keys in GetInstancesParams that need special handling and are not direct API query params
export const INSTANCES_PARAMS_SKIP_KEYS = new Set<keyof GetInstancesParams>(['status']);

// Remap camelCase param keys to the API's expected query param names
export const INSTANCES_PARAM_KEY_MAP: Partial<Record<keyof GetInstancesParams, string>> = {
orderBy: '$orderby',
top: '$top',
skip: '$skip',
inlinecount: '$inlinecount',
};

// ============ Types & Enums ============

export enum WorkflowStatus {
Expand All @@ -19,6 +30,34 @@ export interface WorkflowInstance {
businessKey?: string;
status: WorkflowStatus;
definitionId?: string;
definitionVersion?: string;
startedAt?: string;
completedAt?: string;
startedBy?: string;
subject?: string;
}

export interface GetInstancesParams {
id?: string | null;
businessKey?: string | null;
status?: WorkflowStatus[];
definitionId?: string | null;
definitionVersion?: string | null;
startedAt?: string | null;
startedFrom?: string | null;
startedUpTo?: string | null;
completedAt?: string | null;
completedFrom?: string | null;
completedUpTo?: string | null;
startedBy?: string | null;
subject?: string | null;
containsText?: string | null;
rootInstanceId?: string | null;
parentInstanceId?: string | null;
orderBy?: string | null;
top?: number | null;
skip?: number | null;
inlinecount?: string | null;
Comment on lines +57 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if we should 1:1 mirror the wfApi here, I think it makes it more complicated to include those orderBy / top / skip / inlinecount

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean we try to mirror the sbpa workflow api as much as possible, if you think they are to noise I can remove orderBy and inlinecount since they are sbpa specific

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove them at least as they, from my POV dont bring any benefit

}

export interface StartWorkflowResult {
Expand All @@ -41,6 +80,8 @@ export interface IWorkflowInstanceClient {
status: WorkflowStatus[],
): Promise<WorkflowInstance[]>;

getInstances(params: GetInstancesParams): Promise<WorkflowInstance[]>;

updateWorkflowStatus(
instanceId: string,
status: WorkflowStatus,
Expand Down Expand Up @@ -120,6 +161,44 @@ export async function getWorkflowsByBusinessKey(
return await res.json();
}

export async function getInstances(
serviceUrl: string,
jwt: string,
params: GetInstancesParams,
): Promise<WorkflowInstance[]> {
const queryParts: string[] = [];

for (const [key, value] of Object.entries(params)) {
if (value == null || INSTANCES_PARAMS_SKIP_KEYS.has(key as keyof GetInstancesParams)) continue;
const apiKey = INSTANCES_PARAM_KEY_MAP[key as keyof GetInstancesParams] ?? key;
queryParts.push(`${apiKey}=${encodeURIComponent(String(value))}`);
}

for (const s of params.status ?? []) {
queryParts.push(`status=${encodeURIComponent(s)}`);
}

const queryString = queryParts.join('&');
const queryUrl = `${serviceUrl}${BASE_PATH}/v1/workflow-instances${queryString ? '?' + queryString : ''}`;
LOG.debug('Invoking url: ' + queryUrl);

const res = await fetch(queryUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${jwt}`,
'Content-Type': 'application/json',
},
});

if (!res.ok) {
const body = await res.text();
const errorMessage = `Failed to retrieve workflow instances: ${body || res.statusText || 'Unknown error'}`;
throw cds.error(res.status, errorMessage);
}

return await res.json();
}

export async function updateWorkflowStatus(
serviceUrl: string,
jwt: string,
Expand Down Expand Up @@ -256,6 +335,11 @@ export function createWorkflowInstanceClient(
return getWorkflowsByBusinessKey(serviceUrl, jwt, businessKey, status);
},

getInstances: async (params) => {
const jwt = await getToken();
return getInstances(serviceUrl, jwt, params);
},

updateWorkflowStatus: async (instanceId, status, cascade) => {
const jwt = await getToken();
return updateWorkflowStatus(serviceUrl, jwt, instanceId, status, cascade);
Expand Down
Loading
Loading