diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 7046d852..53bd53d2 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -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 diff --git a/.gitignore b/.gitignore index 925bd502..9590890f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ clients/ @cds-models/ package-lock.json .cdsrc-private.json -@cds-models \ No newline at end of file +@cds-models +dist/ \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 0ffc26f3..174f613d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -tests/bookshop/workflows/* \ No newline at end of file +tests/bookshop/workflows/* +dist/* \ No newline at end of file diff --git a/cds-plugin.js b/cds-plugin.js new file mode 100644 index 00000000..1b631a67 --- /dev/null +++ b/cds-plugin.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require('./dist/cds-plugin'); diff --git a/cds-plugin.ts b/cds-plugin.ts index eeb5494d..cf327e49 100644 --- a/cds-plugin.ts +++ b/cds-plugin.ts @@ -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(); - 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(); - 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'; diff --git a/eslint.config.mjs b/eslint.config.mjs index 26c064a4..b969ea33 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, diff --git a/jest.config.js b/jest.config.js index 081476ed..8cf01d33 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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$' } }, + ], }, }; diff --git a/lib/build/plugin.ts b/lib/build/plugin.ts index 745f41bd..aef5f34c 100644 --- a/lib/build/plugin.ts +++ b/lib/build/plugin.ts @@ -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) diff --git a/lib/build/validation-utils.ts b/lib/build/validation-utils.ts index fcdeb9f7..c145a1a3 100644 --- a/lib/build/validation-utils.ts +++ b/lib/build/validation-utils.ts @@ -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, diff --git a/lib/build/validations.ts b/lib/build/validations.ts index ccde3095..18352c07 100644 --- a/lib/build/validations.ts +++ b/lib/build/validations.ts @@ -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, diff --git a/lib/index.ts b/lib/index.ts index 96f5b38d..67cc65ad 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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); + +// 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(); + 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(); + 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); + } +}); diff --git a/lib/processImport.ts b/lib/processImport.ts index 0e8bec2a..ed22fdd8 100644 --- a/lib/processImport.ts +++ b/lib/processImport.ts @@ -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, diff --git a/types/cds-extensions.d.ts b/lib/types/cds-extensions.d.ts similarity index 100% rename from types/cds-extensions.d.ts rename to lib/types/cds-extensions.d.ts diff --git a/types/cds-plugin.d.ts b/lib/types/cds-plugin.d.ts similarity index 100% rename from types/cds-plugin.d.ts rename to lib/types/cds-plugin.d.ts diff --git a/types/csn-extensions.ts b/lib/types/csn-extensions.ts similarity index 100% rename from types/csn-extensions.ts rename to lib/types/csn-extensions.ts diff --git a/package.json b/package.json index ebbafe3c..156edce0 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,16 @@ "name": "@cap-js/process", "version": "1.0.0", "description": "", - "main": "cds-plugin.ts", + "main": "cds-plugin.js", + "files": [ + "cds-plugin.js", + "dist/", + "srv/*.cds", + "srv/*.js" + ], "scripts": { + "prebuild": "npm run compile", + "pretest": "npm run compile", "test": "jest --silent --testPathIgnorePatterns=/tests/hybrid/programmaticApproach.test.ts", "test:watch": "jest --watch", "test:hana": "jest tests/integration --silent", @@ -12,6 +20,7 @@ "clean:build": "rm -rf gen tests/bookshop/gen && npm run cds-build && npm run cds-build --prefix tests/bookshop", "clean:all": "rm -rf node_modules tests/bookshop/node_modules gen tests/bookshop/gen @cds-models tests/bookshop/@cds-models && npm i && npm run cds-typer && npm run cds-typer --prefix tests/bookshop && npm run cds-build && npm run cds-build --prefix tests/bookshop", "build": "npm run cds-build && npm run cds-typer", + "compile": "tsc -p tsconfig.build.json", "cds-build": "cds-tsx build", "cds-typer": "npx @cap-js/cds-typer \"*\" --outputDirectory @cds-models", "watch": "cds-tsx watch", diff --git a/srv/BTPProcessService.cds b/srv/BTPProcessService.cds index cf4b2857..6619a69f 100644 --- a/srv/BTPProcessService.cds +++ b/srv/BTPProcessService.cds @@ -3,7 +3,7 @@ type AnyType {} type AttributesReturn : many AnyType; type InstancesReturn : many AnyType; -@impl: './BTPProcessService.ts' +@impl: './BTPProcessService' service ProcessService { event start { diff --git a/srv/BTPProcessService.js b/srv/BTPProcessService.js new file mode 100644 index 00000000..57589eb8 --- /dev/null +++ b/srv/BTPProcessService.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require('../dist/srv/BTPProcessService'); diff --git a/srv/localProcessService.cds b/srv/localProcessService.cds index 43adb449..daa4b012 100644 --- a/srv/localProcessService.cds +++ b/srv/localProcessService.cds @@ -1,3 +1,3 @@ using ProcessService as service from './BTPProcessService'; -annotate service with @impl: './localProcessService.ts'; +annotate service with @impl: './localProcessService'; diff --git a/srv/localProcessService.js b/srv/localProcessService.js new file mode 100644 index 00000000..66623a58 --- /dev/null +++ b/srv/localProcessService.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require('../dist/srv/localProcessService'); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..9a39b638 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": ["./cds-plugin.ts", "./srv/**/*.ts", "./lib/**/*.ts"], + "exclude": ["node_modules", "dist", "tests", "gen", "@cds-models"] +} diff --git a/tsconfig.json b/tsconfig.json index 6f15775d..e2d80340 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ } }, "include": [ - "./types/**/*.ts", + "lib/types/**/*.ts", "cds-plugin.ts", "./lib/**/*.ts", "./srv/**/*.ts",