Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/actions/integration-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ runs:
npm install @sap/cds@${{ matrix.cds-version }}
npm install

- name: Compile TypeScript to dist
shell: bash
run: npm run compile

# HANA Cloud Deployment and binding
- name: Set node env for HANA
run: echo "NODE_VERSION_HANA=$(echo ${{ inputs.NODE_VERSION }} | tr . _)" >> $GITHUB_ENV
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ clients/
@cds-models/
package-lock.json
.cdsrc-private.json
@cds-models
@cds-models
dist/
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
tests/bookshop/workflows/*
tests/bookshop/workflows/*
dist/*
2 changes: 2 additions & 0 deletions cds-plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require('./dist/cds-plugin');
151 changes: 1 addition & 150 deletions cds-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,150 +1 @@
import cds, { Results } from '@sap/cds';
import { EntityEventCache } from './types/cds-plugin';
import {
handleProcessCancel,
handleProcessResume,
handleProcessStart,
handleProcessSuspend,
ProcessValidationPlugin,
registerProcessServiceHandlers,
PROCESS_START_ID,
PROCESS_START_ON,
PROCESS_CANCEL_ON,
PROCESS_SUSPEND_ON,
PROCESS_RESUME_ON,
PROCESS_PREFIX,
CUD_EVENTS,
EntityRow,
addDeletedEntityToRequestCancel,
addDeletedEntityToRequestStart,
addDeletedEntityToRequestStartBusinessKey,
addDeletedEntityToRequestResume,
addDeletedEntityToRequestSuspend,
ProcessDeleteRequest,
} from './lib/index';
import { importProcess } from './lib/processImport';

// Register build plugin for annotation validation during cds build
cds.build?.register?.('process-validation', ProcessValidationPlugin);

// Register import handler for: cds import --from process
// @ts-expect-error: import does not exist on cds type
cds.import ??= {};
//@ts-expect-error: cds type does not exist
cds.import.options ??= {};
//@ts-expect-error: cds type does not exist
cds.import.options.process = { no_copy: true, as: 'cds', config: 'kind=process-service' };
// @ts-expect-error: process does not exist on cds.import type
cds.import.from ??= {};
// @ts-expect-error: from does not exist on cds.import type
cds.import.from.process = importProcess;

cds.on('serving', async (service: cds.Service) => {
if (service instanceof cds.ApplicationService == false) return;

const annotationCache = buildAnnotationCache(service);

service.before('DELETE', async (req: cds.Request) => {
const cacheKey = `${req.target.name}:${req.event}`;
const cached = annotationCache.get(cacheKey);

if (!cached) return; // Fast exit - no annotations
const results = await Promise.all(
[
cached.hasStart && addDeletedEntityToRequestStart(req),
cached.hasStart && addDeletedEntityToRequestStartBusinessKey(req),
cached.hasCancel && addDeletedEntityToRequestCancel(req),
cached.hasResume && addDeletedEntityToRequestResume(req),
cached.hasSuspend && addDeletedEntityToRequestSuspend(req),
].filter(Boolean),
);
(req as ProcessDeleteRequest)._Process = Object.assign({}, ...results);
});

service.after('*', async (results: Results, req: cds.Request) => {
if (!req.target) return;
const cacheKey = `${req.target.name}:${req.event}`;
const cached = annotationCache.get(cacheKey);

if (!cached) return; // Fast exit - no annotations

const rows: EntityRow[] = Array.isArray(results) ? results : [results];
if (rows.length > 0) {
await Promise.all(rows.map((row) => dispatchProcessHandlers(cached, req, row)));
} else {
await dispatchProcessHandlers(cached, req, req.data);
}
});
});

async function dispatchProcessHandlers(
cached: EntityEventCache,
req: cds.Request,
data: EntityRow,
) {
if (cached.hasStart) {
await handleProcessStart(req, data);
}
if (cached.hasCancel) {
await handleProcessCancel(req, data);
}
if (cached.hasSuspend) {
await handleProcessSuspend(req, data);
}
if (cached.hasResume) {
await handleProcessResume(req, data);
}
}

function expandEvent(event: string | undefined, entity: cds.entity): string[] {
if (!event) return [];
if (event === '*') {
const boundActions = entity.actions ? Object.keys(entity.actions) : [];
return [...CUD_EVENTS, ...boundActions];
}
return [event];
}

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 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 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);

for (const event of events) {
const matchesEvent = (annotationEvent: string | undefined) =>
annotationEvent === event || annotationEvent === '*';

const hasStart = !!(matchesEvent(startEvent) && entity[PROCESS_START_ID]);
const hasCancel = !!matchesEvent(cancelEvent);
const hasSuspend = !!matchesEvent(suspendEvent);
const hasResume = !!matchesEvent(resumeEvent);

const cacheKey = `${entity.name}:${event}`;
cache.set(cacheKey, {
hasStart,
hasCancel,
hasSuspend,
hasResume,
});
}
}
return cache;
}

cds.on('served', async (services) => {
const processServices = Object.values(services).filter(
(service) => service.definition?.[PROCESS_PREFIX],
);
for (const service of processServices) {
registerProcessServiceHandlers(service);
}
});
import './lib';
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint';

export default [
{
ignores: ['gen/**', 'node_modules/**', '@cds-models/**'],
ignores: ['dist/**', 'gen/**', 'node_modules/**', '@cds-models/**'],
},
...cds,
...tseslint.configs.recommended,
Expand Down
5 changes: 4 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ const config = {
detectOpenHandles: true,
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.ts$': ['ts-jest', { diagnostics: { ignoreCodes: [151002] } }],
'^.+\\.ts$': [
'ts-jest',
{ diagnostics: { ignoreCodes: [151002], pathRegex: '\\.test\\.ts$' } },
],
},
};

Expand Down
2 changes: 1 addition & 1 deletion lib/build/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
BUSINESS_KEY,
} from '../constants';

import { CsnDefinition, CsnEntity } from '../../types/csn-extensions';
import { CsnDefinition, CsnEntity } from '../types/csn-extensions';

/**
* Configuration for lifecycle annotation validation (cancel, suspend, resume)
Expand Down
2 changes: 1 addition & 1 deletion lib/build/validation-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CsnDefinition, CsnElement, CsnEntity } from '../../types/csn-extensions';
import { CsnDefinition, CsnElement, CsnEntity } from '../types/csn-extensions';
import { PROCESS_PREFIX, PROCESS_START_INPUTS } from '../constants';
import {
InputCSNEntry,
Expand Down
2 changes: 1 addition & 1 deletion lib/build/validations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import cds from '@sap/cds';
import { ProcessValidationPlugin } from './plugin';
import { CsnDefinition, CsnElement, CsnEntity } from '../../types/csn-extensions';
import { CsnDefinition, CsnElement, CsnEntity } from '../types/csn-extensions';
import { BUSINESS_KEY, PROCESS_START_ID, PROCESS_START_ON } from '../constants';
import {
createCsnEntityContext,
Expand Down
153 changes: 153 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,156 @@ export * from './handlers';
export * from './build';
export * from './api';
export * from './auth';

import cds, { Results } from '@sap/cds';
import { EntityEventCache } from './types/cds-plugin';
import {
handleProcessCancel,
handleProcessResume,
handleProcessStart,
handleProcessSuspend,
registerProcessServiceHandlers,
EntityRow,
addDeletedEntityToRequestCancel,
addDeletedEntityToRequestStart,
addDeletedEntityToRequestStartBusinessKey,
addDeletedEntityToRequestResume,
addDeletedEntityToRequestSuspend,
ProcessDeleteRequest,
} from './handlers';
import {
PROCESS_START_ID,
PROCESS_START_ON,
PROCESS_CANCEL_ON,
PROCESS_SUSPEND_ON,
PROCESS_RESUME_ON,
PROCESS_PREFIX,
CUD_EVENTS,
} from './constants';
import { ProcessValidationPlugin } from './build';
import { importProcess } from './processImport';

// Register build plugin for annotation validation during cds build
cds.build?.register?.('process-validation', ProcessValidationPlugin);
Copy link
Contributor

Choose a reason for hiding this comment

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

Logic Error: Side-effect plugin code (CDS event registrations, import handler setup) has been moved into lib/index.ts, which is also a barrel re-export module. Any consumer who imports anything from lib/index.ts (e.g. in tests or programmatically) will inadvertently trigger cds.on('serving', ...), cds.on('served', ...), and cds.build?.register?.(...) as side effects.

This breaks the separation of concerns that existed when the plugin code was isolated in cds-plugin.ts. Consider moving the plugin registration logic back to cds-plugin.ts (importing from ./lib for shared utilities) and keeping lib/index.ts as a pure export barrel.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful


// Register import handler for: cds import --from process
// @ts-expect-error: import does not exist on cds type
cds.import ??= {};
//@ts-expect-error: cds type does not exist
cds.import.options ??= {};
//@ts-expect-error: cds type does not exist
cds.import.options.process = { no_copy: true, as: 'cds', config: 'kind=process-service' };
// @ts-expect-error: process does not exist on cds.import type
cds.import.from ??= {};
// @ts-expect-error: from does not exist on cds.import type
cds.import.from.process = importProcess;

cds.on('serving', async (service: cds.Service) => {
if (service instanceof cds.ApplicationService == false) return;

const annotationCache = buildAnnotationCache(service);

service.before('DELETE', async (req: cds.Request) => {
const cacheKey = `${req.target.name}:${req.event}`;
const cached = annotationCache.get(cacheKey);

if (!cached) return; // Fast exit - no annotations
const results = await Promise.all(
[
cached.hasStart && addDeletedEntityToRequestStart(req),
cached.hasStart && addDeletedEntityToRequestStartBusinessKey(req),
cached.hasCancel && addDeletedEntityToRequestCancel(req),
cached.hasResume && addDeletedEntityToRequestResume(req),
cached.hasSuspend && addDeletedEntityToRequestSuspend(req),
].filter(Boolean),
);
(req as ProcessDeleteRequest)._Process = Object.assign({}, ...results);
});

service.after('*', async (results: Results, req: cds.Request) => {
if (!req.target) return;
const cacheKey = `${req.target.name}:${req.event}`;
const cached = annotationCache.get(cacheKey);

if (!cached) return; // Fast exit - no annotations

const rows: EntityRow[] = Array.isArray(results) ? results : [results];
if (rows.length > 0) {
await Promise.all(rows.map((row) => dispatchProcessHandlers(cached, req, row)));
} else {
await dispatchProcessHandlers(cached, req, req.data);
}
});
});

async function dispatchProcessHandlers(
cached: EntityEventCache,
req: cds.Request,
data: EntityRow,
) {
if (cached.hasStart) {
await handleProcessStart(req, data);
}
if (cached.hasCancel) {
await handleProcessCancel(req, data);
}
if (cached.hasSuspend) {
await handleProcessSuspend(req, data);
}
if (cached.hasResume) {
await handleProcessResume(req, data);
}
}

function expandEvent(event: string | undefined, entity: cds.entity): string[] {
if (!event) return [];
if (event === '*') {
const boundActions = entity.actions ? Object.keys(entity.actions) : [];
return [...CUD_EVENTS, ...boundActions];
}
return [event];
}

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 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 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);

for (const event of events) {
const matchesEvent = (annotationEvent: string | undefined) =>
annotationEvent === event || annotationEvent === '*';

const hasStart = !!(matchesEvent(startEvent) && entity[PROCESS_START_ID]);
const hasCancel = !!matchesEvent(cancelEvent);
const hasSuspend = !!matchesEvent(suspendEvent);
const hasResume = !!matchesEvent(resumeEvent);

const cacheKey = `${entity.name}:${event}`;
cache.set(cacheKey, {
hasStart,
hasCancel,
hasSuspend,
hasResume,
});
}
}
return cache;
}

cds.on('served', async (services) => {
const processServices = Object.values(services).filter(
(service) => service.definition?.[PROCESS_PREFIX],
);
for (const service of processServices) {
registerProcessServiceHandlers(service);
}
});
2 changes: 1 addition & 1 deletion lib/processImport.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import cds from '@sap/cds';
import * as csn from '../types/csn-extensions';
import * as csn from './types/csn-extensions';
import { getServiceCredentials, CachingTokenProvider, createXsuaaTokenProvider } from './auth';
import {
createProcessApiClient,
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading
Loading