NODEDC_PLATFORM/services/ontology-core/src/registry.mjs

281 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadCatalog, serviceRoot } from './catalog.mjs'
import { validateCatalog } from './validate.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const contextBindingsPath = path.resolve(__dirname, '..', 'catalog', 'context-bindings.json')
function usage() {
return [
'Usage:',
' node src/registry.mjs list-contexts',
' node src/registry.mjs list-bindings',
' node src/registry.mjs add-context --input-json examples/engine-context.example.json [--dry-run]',
' node src/registry.mjs add-binding --input-json examples/ops-engine-binding.example.json [--dry-run]',
' node src/registry.mjs import-engine-context --input-json examples/engine-workflow-manifest.example.json [--dry-run]',
].join('\n')
}
function argValue(name) {
const index = process.argv.indexOf(name)
if (index < 0) return ''
return process.argv[index + 1] || ''
}
async function readInputJson() {
const inputPath = argValue('--input-json')
const inputRaw = argValue('--input')
if (inputRaw) return JSON.parse(inputRaw)
if (!inputPath) throw new Error('missing --input-json or --input')
const fullPath = path.resolve(process.cwd(), inputPath)
return JSON.parse(await fs.readFile(fullPath, 'utf8'))
}
async function readContextBindings() {
return JSON.parse(await fs.readFile(contextBindingsPath, 'utf8'))
}
async function writeContextBindings(next) {
const raw = `${JSON.stringify(next, null, 2)}\n`
await fs.writeFile(contextBindingsPath, raw, 'utf8')
}
function required(value, label) {
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`${label} is required`)
}
}
function isObject(value) {
return value && typeof value === 'object' && !Array.isArray(value)
}
function slugPart(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9а-яё]+/giu, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'unknown'
}
function validateDraftContext(draft, catalog, registry) {
required(draft.id, 'context.id')
required(draft.surface, 'context.surface')
required(draft.entityId, 'context.entityId')
required(draft.sourceSystem, 'context.sourceSystem')
required(draft.label, 'context.label')
required(draft.status, 'context.status')
if (!catalog.entityById.has(draft.entityId)) throw new Error(`unknown context.entityId: ${draft.entityId}`)
if (!registry.contextStatuses.includes(draft.status)) throw new Error(`invalid context.status: ${draft.status}`)
if (!isObject(draft.refs)) throw new Error('context.refs must be an object')
if (draft.parentContextId && !catalog.contextById.has(draft.parentContextId)) {
throw new Error(`unknown context.parentContextId: ${draft.parentContextId}`)
}
}
function validateDraftBinding(draft, catalog, registry) {
required(draft.id, 'binding.id')
required(draft.typeId, 'binding.typeId')
required(draft.status, 'binding.status')
required(draft.fromContextId, 'binding.fromContextId')
required(draft.toContextId, 'binding.toContextId')
if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`)
if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`)
if (!catalog.contextById.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`)
if (!catalog.contextById.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`)
}
function validateDraftBindingWithKnownContextIds(draft, catalog, registry, knownContextIds) {
required(draft.id, 'binding.id')
required(draft.typeId, 'binding.typeId')
required(draft.status, 'binding.status')
required(draft.fromContextId, 'binding.fromContextId')
required(draft.toContextId, 'binding.toContextId')
if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`)
if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`)
if (!knownContextIds.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`)
if (!knownContextIds.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`)
}
function upsertById(list, item) {
const index = list.findIndex((existing) => existing.id === item.id)
if (index >= 0) {
const next = [...list]
next[index] = item
return { list: next, action: 'updated' }
}
return { list: [...list, item], action: 'created' }
}
async function updateRegistry(mutator, dryRun) {
const beforeRaw = await fs.readFile(contextBindingsPath, 'utf8')
const registry = JSON.parse(beforeRaw)
const next = structuredClone(registry)
const result = mutator(next)
if (dryRun) return { dryRun: true, ...result, registry: next }
await writeContextBindings(next)
const validation = await validateCatalog()
if (!validation.ok) {
await fs.writeFile(contextBindingsPath, beforeRaw, 'utf8')
const err = new Error('registry validation failed; reverted context-bindings.json')
err.validation = validation
throw err
}
return { dryRun: false, ...result, validation: validation.counts }
}
async function listContexts() {
const catalog = await loadCatalog()
return catalog.contextBindings.contexts.map((context) => ({
id: context.id,
surface: context.surface,
entityId: context.entityId,
sourceSystem: context.sourceSystem,
label: context.label,
status: context.status,
refs: context.refs,
parentContextId: context.parentContextId || null,
}))
}
async function listBindings() {
const catalog = await loadCatalog()
return catalog.contextBindings.bindings.map((binding) => ({
id: binding.id,
typeId: binding.typeId,
status: binding.status,
fromContextId: binding.fromContextId,
toContextId: binding.toContextId,
summary: binding.summary || '',
}))
}
async function addContext(dryRun) {
const draft = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
validateDraftContext(draft, catalog, registry)
return updateRegistry((next) => {
const out = upsertById(next.contexts, draft)
next.contexts = out.list
return { action: out.action, context: draft }
}, dryRun)
}
async function addBinding(dryRun) {
const draft = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
validateDraftBinding(draft, catalog, registry)
return updateRegistry((next) => {
const out = upsertById(next.bindings, draft)
next.bindings = out.list
return { action: out.action, binding: draft }
}, dryRun)
}
function buildEngineContextFromManifest(manifest) {
if (!isObject(manifest)) throw new Error('manifest must be an object')
const workflow = manifest.workflow || {}
required(workflow.id, 'workflow.id')
required(workflow.label, 'workflow.label')
const refs = {
workflow_id: String(workflow.id),
}
if (workflow.nodeId) refs.node_id = String(workflow.nodeId)
if (workflow.runtimeWorkflowId) refs.runtime_workflow_id = String(workflow.runtimeWorkflowId)
if (workflow.runtimeInstanceId) refs.runtime_instance_id = String(workflow.runtimeInstanceId)
return {
id: `context.engine.${slugPart(workflow.id)}`,
surface: 'engine',
entityId: workflow.runtimeWorkflowId ? 'engine.workflow_l2' : 'engine.workflow_l1',
sourceSystem: String(manifest.context?.sourceSystem || 'nodedc-engine'),
label: String(workflow.label),
status: String(manifest.context?.status || 'planned'),
refs,
}
}
function buildOpsEngineBindingFromManifest(manifest, engineContext) {
const bind = manifest.bindToOps || {}
if (bind.enabled !== true) return null
required(bind.fromContextId, 'bindToOps.fromContextId')
return {
id: `binding.ops.${slugPart(bind.fromContextId)}.engine.${slugPart(engineContext.id)}`,
typeId: 'binding_type.ops_card_to_engine_workflow',
status: String(bind.status || 'planned'),
fromContextId: String(bind.fromContextId),
toContextId: engineContext.id,
summary: String(bind.summary || `Binds ${bind.fromContextId} to ${engineContext.id}.`),
}
}
async function importEngineContext(dryRun) {
const manifest = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
const engineContext = buildEngineContextFromManifest(manifest)
const binding = buildOpsEngineBindingFromManifest(manifest, engineContext)
validateDraftContext(engineContext, catalog, registry)
if (binding) {
const knownContextIds = new Set([
...catalog.contextById.keys(),
engineContext.id,
])
validateDraftBindingWithKnownContextIds(binding, catalog, registry, knownContextIds)
}
return updateRegistry((next) => {
const contextOut = upsertById(next.contexts, engineContext)
next.contexts = contextOut.list
let bindingOut = null
if (binding) {
bindingOut = upsertById(next.bindings, binding)
next.bindings = bindingOut.list
}
return {
action: bindingOut ? `${contextOut.action}_context_${bindingOut.action}_binding` : `${contextOut.action}_context`,
context: engineContext,
binding,
}
}, dryRun)
}
async function main() {
const command = process.argv[2]
const dryRun = process.argv.includes('--dry-run')
let output
if (command === 'list-contexts') output = await listContexts()
else if (command === 'list-bindings') output = await listBindings()
else if (command === 'add-context') output = await addContext(dryRun)
else if (command === 'add-binding') output = await addBinding(dryRun)
else if (command === 'import-engine-context') output = await importEngineContext(dryRun)
else {
console.error(usage())
process.exit(2)
}
console.log(JSON.stringify({ ok: true, serviceRoot, command, output }, null, 2))
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(JSON.stringify({
ok: false,
error: err.message || String(err),
validation: err.validation || undefined,
}, null, 2))
process.exit(1)
})
}