/** Build-time only. Fixed operator artifact; never invoked by server startup. */ import assert from 'node:assert/strict' import {createHash} from 'node:crypto' import {isBuiltin} from 'node:module' import {readFile,writeFile,mkdir,stat,lstat,readdir} from 'node:fs/promises' import path from 'node:path' import {fileURLToPath,pathToFileURL} from 'node:url' import ts from 'typescript' import {OPERATOR_SOURCES} from './database-operator-sources.mjs' export {OPERATOR_SOURCES} const emitted=source=>source.replace(/\.mts$/,'.mjs').replace(/\.ts$/,'.js') const digest=value=>createHash('sha256').update(value).digest('hex') const packageName=specifier=>specifier.startsWith('@')?specifier.split('/').slice(0,2).join('/'):specifier.split('/')[0] /** The transformer changes only module specifier AST nodes, never SQL/string data. */ export function compileOperatorModule(source,fileName){ const rewrite=specifier=>specifier.replace(/^(\.\.\/|\.\.\/\.\.\/)src\//,'$1dist/') const result=ts.transpileModule(source,{fileName,reportDiagnostics:true,compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,verbatimModuleSyntax:true,newLine:ts.NewLineKind.LineFeed},transformers:{before:[context=>{ const visit=node=>{ if(ts.isImportDeclaration(node)&&ts.isStringLiteral(node.moduleSpecifier))return context.factory.updateImportDeclaration(node,node.modifiers,node.importClause,context.factory.createStringLiteral(rewrite(node.moduleSpecifier.text)),node.attributes) if(ts.isExportDeclaration(node)&&node.moduleSpecifier&&ts.isStringLiteral(node.moduleSpecifier))return context.factory.updateExportDeclaration(node,node.modifiers,node.isTypeOnly,node.exportClause,context.factory.createStringLiteral(rewrite(node.moduleSpecifier.text)),node.attributes) if(ts.isCallExpression(node)&&node.expression.kind===ts.SyntaxKind.ImportKeyword){ assert(node.arguments.length===1&&ts.isStringLiteral(node.arguments[0]),'Only fixed literal operator dynamic imports are supported') return context.factory.updateCallExpression(node,node.expression,node.typeArguments,[context.factory.createStringLiteral(rewrite(node.arguments[0].text))]) } return ts.visitEachChild(node,visit,context) } return sourceFile=>ts.visitNode(sourceFile,visit) }]}}) const errors=result.diagnostics?.filter(item=>item.category===ts.DiagnosticCategory.Error)??[] assert.equal(errors.length,0,'Operator transpilation diagnostics: '+errors.map(item=>ts.flattenDiagnosticMessageText(item.messageText,' ')).join('; ')) return result.outputText } export function moduleSpecifiers(source,fileName){ const parsed=ts.createSourceFile(fileName,source,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS),specifiers=[] const visit=node=>{ if((ts.isImportDeclaration(node)||ts.isExportDeclaration(node))&&node.moduleSpecifier){assert(ts.isStringLiteral(node.moduleSpecifier));specifiers.push(node.moduleSpecifier.text)} if(ts.isCallExpression(node)&&node.expression.kind===ts.SyntaxKind.ImportKeyword){assert(node.arguments.length===1&&ts.isStringLiteral(node.arguments[0]),'Computed runtime import refused');specifiers.push(node.arguments[0].text)} ts.forEachChild(node,visit) } visit(parsed);return specifiers } export async function buildDatabaseOperator({backendDirectory=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),outputDirectory=path.join(backendDirectory,'dist-operator')}={}){ const backend=path.resolve(backendDirectory),output=path.resolve(outputDirectory),packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8')) const modules=new Map(),allowed=new Set(OPERATOR_SOURCES.map(emitted)),runtimeDependencies=new Set(),distImports=new Set() const productionDependency=specifier=>{ if(isBuiltin(specifier))return const dependency=packageName(specifier);assert(Object.hasOwn(packageJson.dependencies,dependency),'Operator runtime dependency must be production: '+dependency);runtimeDependencies.add(dependency) } for(const file of OPERATOR_SOURCES){ const raw=await readFile(path.join(backend,'scripts',file)),code=compileOperatorModule(new TextDecoder('utf-8',{fatal:true}).decode(raw),file),target=emitted(file) const imports=moduleSpecifiers(code,target) for(const specifier of imports){ assert(!/\.(?:ts|mts)$/.test(specifier)&&!/(?:^|\/)src\//.test(specifier),'Source runtime import refused') if(specifier.startsWith('.')){ const resolved=path.posix.normalize(path.posix.join(path.posix.dirname(target),specifier)) if(resolved.startsWith('../dist/')){ const relative=resolved.slice('../dist/'.length) assert(relative.endsWith('.js')&&!relative.split('/').includes('..'),'Fixed compiled backend helper required') assert((await stat(path.join(backend,'dist',relative))).isFile(),'Run backend tsc before operator build') distImports.add(relative) }else assert(allowed.has(resolved),'Operator import outside fixed build graph: '+specifier) }else productionDependency(specifier) } modules.set(target,{code,source:file,sourceSha256:digest(raw),sha256:digest(code),imports}) } // Validate the referenced compiled helper closure as part of the production // build, not only in tests. Keep this bounded to the small operator graph. const backendDistModules=new Map() async function visitDist(file){ assert(file.endsWith('.js')&&!/[\\:]/.test(file)&&!file.startsWith('/')&&!file.split('/').includes('..')&&!file.split('/').includes('src'),'Compiled helper escaped backend/dist') if(backendDistModules.has(file))return assert(backendDistModules.size<64,'Operator compiled helper graph exceeded 64 modules') const raw=await readFile(path.join(backend,'dist',file)),imports=moduleSpecifiers(new TextDecoder('utf-8',{fatal:true}).decode(raw),file) backendDistModules.set(file,{file,sha256:digest(raw),imports}) for(const specifier of imports){ assert(!/\.(?:ts|mts)$/.test(specifier)&&!/(?:^|\/)src\//.test(specifier),'Source runtime import refused') if(specifier.startsWith('.'))await visitDist(path.posix.normalize(path.posix.join(path.posix.dirname(file),specifier))) else productionDependency(specifier) } } for(const file of distImports)await visitDist(file) // Refuse stale/unlisted output rather than shipping an extra file (e.g. a key) // through Docker's directory COPY. Never delete/adopt unknown build contents. try{ assert((await lstat(output)).isDirectory(),'Ordinary operator output directory required') for(const item of await readdir(output,{recursive:true,withFileTypes:true})){ const relative=path.relative(output,path.join(item.parentPath,item.name)).split(path.sep).join('/') assert(item.isDirectory()?relative==='writer-profile':item.isFile()&&(allowed.has(relative)||relative==='build-manifest.json'),'Unexpected operator output entry: '+relative) } }catch(error){if(error.code!=='ENOENT')throw error} // Validate the whole fixed graph before publishing any generated module. await mkdir(output,{recursive:true}) for(const [file,module] of modules){await mkdir(path.dirname(path.join(output,file)),{recursive:true});await writeFile(path.join(output,file),module.code)} const manifest={format:'application-database-operator-build-v1',compilerVersion:ts.version,entrypoints:['database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mjs'],modules:[...modules].map(([file,module])=>({file,source:module.source,sourceSha256:module.sourceSha256,sha256:module.sha256,imports:module.imports})),backendDistImports:[...distImports].sort(),backendDistModules:[...backendDistModules.values()].sort((a,b)=>a.file.localeCompare(b.file)),runtimeDependencies:[...runtimeDependencies].sort(),automaticStartup:false,readyAuthorized:false} await writeFile(path.join(output,'build-manifest.json'),JSON.stringify(manifest,null,2)+'\n') return manifest } if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){ assert.equal(process.argv.length,2,'Build takes no runtime configuration or key arguments') const result=await buildDatabaseOperator();console.log(JSON.stringify({event:'database-operator-built',modules:result.modules.length,automaticStartup:false,readyAuthorized:false})) }