From 012bb95ccea8ce7e02b581f2520ed558c27b1a32 Mon Sep 17 00:00:00 2001 From: "Dr. David A. Kunz" Date: Mon, 21 Jul 2025 15:02:37 +0200 Subject: [PATCH 1/3] add refreshModel, cleanup --- lib/tools.js | 110 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 43 deletions(-) diff --git a/lib/tools.js b/lib/tools.js index b034fe1..05b0053 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -4,7 +4,61 @@ import cds from '@sap/cds' import { z } from 'zod' import { fuzzyTopN } from './utils.js' -const PROJECT_PATH = { projectPath: z.string().describe('Root path of the project') } +const PROJECT_PATH = { + projectPath: z.string().describe('Root path of the project') +} + +// Loads and compiles the CDS model, returns the compiled model or throws on error +async function loadModel(path) { + cds.root = path + const loaded = await cds.load('*', { docs: true, locations: true }) + if (!loaded || (Array.isArray(loaded) && loaded.length === 0)) { + throw new Error(`Failed to load CDS model from path: ${path}`) + } + const compiled = cds.compile.for.nodejs(loaded) + if (!compiled || !compiled.definitions || Object.keys(compiled.definitions).length === 0) { + throw new Error(`Compiled CDS model is invalid or empty for path: ${path}`) + } + const serviceInfo = cds.compile.to.serviceinfo(compiled) + + // merge with definitions + for (const info of serviceInfo) { + const def = compiled.definitions[info.name] + Object.assign(def, info) + } + + const _entities_in = service => { + const exposed = [], + { entities } = service + for (let each in entities) { + const e = entities[each] + if (e['@cds.autoexposed'] && !e['@cds.autoexpose']) continue + if (/DraftAdministrativeData$/.test(e.name)) continue + if (/[._]texts$/.test(e.name)) continue + if (cds.env.effective.odata.containment && service.definition._containedEntities.has(e.name)) continue + exposed.push(each) + } + return exposed + } + + // construct endpoint for each entity and add it to its definition + compiled.services + .flatMap(srv => srv.endpoints.map(endpoint => ({ srv, endpoint }))) + .map(({ srv, endpoint }) => { + const entities = _entities_in(srv) + for (const e of entities) { + const path = endpoint.path + e.replace(/\./g, '_') + const def = compiled.definitions[srv.name + '.' + e] + def.endpoints ??= [] + def.endpoints.push({ kind: endpoint.kind, path }) + // Add fully qualified entity names to each service as 'exposedEntities' + for (const service of compiled.services) { + service.exposedEntities = _entities_in(service).map(shortName => service.name + '.' + shortName) + } + } + }) + return compiled +} // Ensures only one CDS model compilation is ever in-flight. // The moment setModel is called, cds.model is set to a promise. @@ -15,56 +69,26 @@ async function setModel(path) { if (typeof cds.model.then === 'function') await cds.model return } - cds.root = path // Assign a promise immediately to cds.model to prevent duplicate compilations cds.model = (async () => { - const loaded = await cds.load('*', { docs: true, locations: true }) - const compiled = cds.compile.for.nodejs(loaded) - const serviceInfo = cds.compile.to.serviceinfo(compiled) - - // merge with definitions - for (const info of serviceInfo) { - const def = compiled.definitions[info.name] - Object.assign(def, info) - } - - const _entities_in = service => { - const exposed = [], - { entities } = service - for (let each in entities) { - const e = entities[each] - if (e['@cds.autoexposed'] && !e['@cds.autoexpose']) continue - if (/DraftAdministrativeData$/.test(e.name)) continue - if (/[._]texts$/.test(e.name)) continue - if (cds.env.effective.odata.containment && service.definition._containedEntities.has(e.name)) continue - exposed.push(each) - } - return exposed - } - - // construct endpoint for each entity and add it to its definition - compiled.services - .flatMap(srv => srv.endpoints.map(endpoint => ({ srv, endpoint }))) - .map(({ srv, endpoint }) => { - const entities = _entities_in(srv) - for (const e of entities) { - const path = endpoint.path + e.replace(/\./g, '_') - const def = compiled.definitions[srv.name + '.' + e] - def.endpoints ??= [] - def.endpoints.push({ kind: endpoint.kind, path }) - // Add fully qualified entity names to each service as 'exposedEntities' - for (const service of compiled.services) { - service.exposedEntities = _entities_in(service).map(shortName => service.name + '.' + shortName) - } - } - }) - // After compilation, replace cds.model with the resolved model + const compiled = await loadModel(path) cds.model = compiled return compiled })() await cds.model } +// Refreshes the CDS model, only replaces cds.model if compilation succeeds +async function refreshModel(path) { + try { + const compiled = await loadModel(path) + cds.model = compiled + return compiled + } catch (err) { + // If anything goes wrong, cds.model remains untouched + } +} + const tools = { search_cds_definitions: { title: 'Search for CDS definitions', From 3543e5b8684855e9f607cd250c8e68fc49b3bcd1 Mon Sep 17 00:00:00 2001 From: "Dr. David A. Kunz" Date: Mon, 21 Jul 2025 16:28:51 +0200 Subject: [PATCH 2/3] feat: implement model refresh --- lib/{utils.js => fuzzyTopN.js} | 2 +- lib/setModel.js | 138 +++++++++++++++++++++++++++++++++ lib/tools.js | 84 +------------------- 3 files changed, 141 insertions(+), 83 deletions(-) rename lib/{utils.js => fuzzyTopN.js} (94%) create mode 100644 lib/setModel.js diff --git a/lib/utils.js b/lib/fuzzyTopN.js similarity index 94% rename from lib/utils.js rename to lib/fuzzyTopN.js index 90e82d1..44f0f6e 100644 --- a/lib/utils.js +++ b/lib/fuzzyTopN.js @@ -1,4 +1,4 @@ -export function fuzzyTopN(searchTerm, list, n, min) { +export default function fuzzyTopN(searchTerm, list, n, min) { function modifiedLevenshtein(a, b) { const m = a.length const n = b.length diff --git a/lib/setModel.js b/lib/setModel.js new file mode 100644 index 0000000..52031d0 --- /dev/null +++ b/lib/setModel.js @@ -0,0 +1,138 @@ +import cds from '@sap/cds' +import fs from 'fs' + +// Ensures only one CDS model compilation is ever in-flight. +// The moment setModel is called, cds.model is set to a promise. +// All consumers must await cds.model if it's defined. +export default async function setModel(path) { + if (cds.model) { + // If cds.model is a promise, await it; if it's resolved, return it + if (typeof cds.model.then === 'function') await cds.model + return + } + // Assign a promise immediately to cds.model to prevent duplicate compilations + cds.model = (async () => { + const compiled = await loadModel(path) + cds.model = compiled + return compiled + })() + await cds.model +} + +// Global cache object for CDS file timestamps +const cache = { cdsFiles: new Map() } +let changeWatcher = null + +async function cdsFilesChanged(path) { + if (path.endsWith('/')) path = path.slice(0, -1) + const files = cds.resolve(path + '/*') + const currentTimestamps = new Map() + await Promise.all( + files.map(file => + fs.promises + .stat(file) + .then(stat => { + currentTimestamps.set(file, stat.mtimeMs) + }) + .catch(() => { + /* File might have been deleted between resolve and stat */ + }) + ) + ) + + const _hasChanged = () => { + if (currentTimestamps.size !== cache.cdsFiles.size) { + return true + } + // Check for changed timestamps + for (const f of files) { + const prev = cache.cdsFiles.get(f) + const curr = currentTimestamps.get(f) + if (prev !== curr) { + return true + } + } + } + if (_hasChanged()) { + cache.cdsFiles = currentTimestamps + return true + } + return false +} + +// Loads and compiles the CDS model, returns the compiled model or throws on error +async function loadModel(path) { + cds.root = path + const startTime = Date.now() + const loaded = await cds.load('*', { docs: true, locations: true }) + if (!loaded || (Array.isArray(loaded) && loaded.length === 0)) { + throw new Error(`Failed to load CDS model from path: ${path}`) + } + const compiled = cds.compile.for.nodejs(loaded) + if (!compiled || !compiled.definitions || Object.keys(compiled.definitions).length === 0) { + throw new Error(`Compiled CDS model is invalid or empty for path: ${path}`) + } + const serviceInfo = cds.compile.to.serviceinfo(compiled) + + // merge with definitions + for (const info of serviceInfo) { + const def = compiled.definitions[info.name] + Object.assign(def, info) + } + + const _entities_in = service => { + const exposed = [], + { entities } = service + for (let each in entities) { + const e = entities[each] + if (e['@cds.autoexposed'] && !e['@cds.autoexpose']) continue + if (/DraftAdministrativeData$/.test(e.name)) continue + if (/[._]texts$/.test(e.name)) continue + if (cds.env.effective.odata.containment && service.definition._containedEntities.has(e.name)) continue + exposed.push(each) + } + return exposed + } + + // construct endpoint for each entity and add it to its definition + compiled.services + .flatMap(srv => srv.endpoints.map(endpoint => ({ srv, endpoint }))) + .map(({ srv, endpoint }) => { + const entities = _entities_in(srv) + for (const e of entities) { + const path = endpoint.path + e.replace(/\./g, '_') + const def = compiled.definitions[srv.name + '.' + e] + def.endpoints ??= [] + def.endpoints.push({ kind: endpoint.kind, path }) + // Add fully qualified entity names to each service as 'exposedEntities' + for (const service of compiled.services) { + service.exposedEntities = _entities_in(service).map(shortName => service.name + '.' + shortName) + } + } + }) + + const endTime = Date.now() + const compileDuration = endTime - startTime + + // Only do it once + if (!changeWatcher) + changeWatcher = setInterval( + async () => { + const hasChanged = await cdsFilesChanged(path) + if (hasChanged) await refreshModel(path) + }, + Math.max(compileDuration * 10, 20000) + ).unref() // 10 times the initial compile duration seems reasonable, at least 20 seconds + return compiled +} + +// Refreshes the CDS model, only replaces cds.model if compilation succeeds +async function refreshModel(path) { + try { + const compiled = await loadModel(path) + cds.model = compiled + return compiled + } catch (err) { + // If anything goes wrong, cds.model remains untouched + } +} diff --git a/lib/tools.js b/lib/tools.js index 05b0053..ccd37d2 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -2,93 +2,13 @@ import cds from '@sap/cds' import { z } from 'zod' -import { fuzzyTopN } from './utils.js' +import setModel from './setModel.js' +import fuzzyTopN from './fuzzyTopN.js' const PROJECT_PATH = { projectPath: z.string().describe('Root path of the project') } -// Loads and compiles the CDS model, returns the compiled model or throws on error -async function loadModel(path) { - cds.root = path - const loaded = await cds.load('*', { docs: true, locations: true }) - if (!loaded || (Array.isArray(loaded) && loaded.length === 0)) { - throw new Error(`Failed to load CDS model from path: ${path}`) - } - const compiled = cds.compile.for.nodejs(loaded) - if (!compiled || !compiled.definitions || Object.keys(compiled.definitions).length === 0) { - throw new Error(`Compiled CDS model is invalid or empty for path: ${path}`) - } - const serviceInfo = cds.compile.to.serviceinfo(compiled) - - // merge with definitions - for (const info of serviceInfo) { - const def = compiled.definitions[info.name] - Object.assign(def, info) - } - - const _entities_in = service => { - const exposed = [], - { entities } = service - for (let each in entities) { - const e = entities[each] - if (e['@cds.autoexposed'] && !e['@cds.autoexpose']) continue - if (/DraftAdministrativeData$/.test(e.name)) continue - if (/[._]texts$/.test(e.name)) continue - if (cds.env.effective.odata.containment && service.definition._containedEntities.has(e.name)) continue - exposed.push(each) - } - return exposed - } - - // construct endpoint for each entity and add it to its definition - compiled.services - .flatMap(srv => srv.endpoints.map(endpoint => ({ srv, endpoint }))) - .map(({ srv, endpoint }) => { - const entities = _entities_in(srv) - for (const e of entities) { - const path = endpoint.path + e.replace(/\./g, '_') - const def = compiled.definitions[srv.name + '.' + e] - def.endpoints ??= [] - def.endpoints.push({ kind: endpoint.kind, path }) - // Add fully qualified entity names to each service as 'exposedEntities' - for (const service of compiled.services) { - service.exposedEntities = _entities_in(service).map(shortName => service.name + '.' + shortName) - } - } - }) - return compiled -} - -// Ensures only one CDS model compilation is ever in-flight. -// The moment setModel is called, cds.model is set to a promise. -// All consumers must await cds.model if it's defined. -async function setModel(path) { - if (cds.model) { - // If cds.model is a promise, await it; if it's resolved, return it - if (typeof cds.model.then === 'function') await cds.model - return - } - // Assign a promise immediately to cds.model to prevent duplicate compilations - cds.model = (async () => { - const compiled = await loadModel(path) - cds.model = compiled - return compiled - })() - await cds.model -} - -// Refreshes the CDS model, only replaces cds.model if compilation succeeds -async function refreshModel(path) { - try { - const compiled = await loadModel(path) - cds.model = compiled - return compiled - } catch (err) { - // If anything goes wrong, cds.model remains untouched - } -} - const tools = { search_cds_definitions: { title: 'Search for CDS definitions', From 447bb7a3ff41b4d6706c82a9ee5bb34688579319 Mon Sep 17 00:00:00 2001 From: "Dr. David A. Kunz" Date: Mon, 21 Jul 2025 16:36:44 +0200 Subject: [PATCH 3/3] . --- lib/setModel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/setModel.js b/lib/setModel.js index 52031d0..c4f33a1 100644 --- a/lib/setModel.js +++ b/lib/setModel.js @@ -16,6 +16,7 @@ export default async function setModel(path) { cds.model = compiled return compiled })() + await cds.model }