Files
vendoo/app/kernel/module-registry.mjs
T

277 lines
11 KiB
JavaScript

import { PlatformError } from './errors.mjs';
import { validateModuleManifest } from './module-contract.mjs';
export class ModuleRegistry {
#modules = new Map();
#startOrder = [];
#ownership = new Map();
#started = false;
register({ manifest, lifecycle = {}, source = 'builtin' }, { enabled = true } = {}) {
const validated = validateModuleManifest(manifest);
if (this.#modules.has(validated.id)) {
throw new PlatformError(`Modul bereits registriert: ${validated.id}`, { code: 'MODULE_DUPLICATE' });
}
for (const resource of validated.owns) {
const owner = this.#ownership.get(resource);
if (owner) throw new PlatformError(`Ressource ${resource} wird bereits von ${owner} besessen.`, { code: 'MODULE_OWNERSHIP_CONFLICT' });
}
const active = validated.status !== 'disabled' && enabled !== false;
const record = {
manifest: validated,
lifecycle: {
start: typeof lifecycle.start === 'function' ? lifecycle.start : async () => {},
stop: typeof lifecycle.stop === 'function' ? lifecycle.stop : async () => {},
health: typeof lifecycle.health === 'function' ? lifecycle.health : async () => ({ ok: true }),
},
source: String(source || 'builtin'),
enabled: active,
state: active ? 'registered' : 'disabled',
startedAt: null,
error: null,
};
this.#modules.set(validated.id, record);
for (const resource of validated.owns) this.#ownership.set(resource, validated.id);
return validated;
}
get(id) {
return this.#modules.get(id) || null;
}
has(id) {
return this.#modules.has(id);
}
isEnabled(id) {
return this.#modules.get(id)?.enabled === true;
}
dependentsOf(id, { enabledOnly = false, recursive = true } = {}) {
const found = new Set();
const visit = dependency => {
for (const [candidateId, record] of this.#modules) {
if (enabledOnly && !record.enabled) continue;
if (!record.manifest.requires.includes(dependency) || found.has(candidateId)) continue;
found.add(candidateId);
if (recursive) visit(candidateId);
}
};
visit(id);
return [...found];
}
#resolveStartOrder() {
const visiting = new Set();
const visited = new Set();
const order = [];
const visit = id => {
if (visited.has(id)) return;
if (visiting.has(id)) throw new PlatformError(`Zyklische Modulabhängigkeit bei ${id}.`, { code: 'MODULE_DEPENDENCY_CYCLE' });
const record = this.#modules.get(id);
if (!record) throw new PlatformError(`Fehlende Modulabhängigkeit: ${id}`, { code: 'MODULE_DEPENDENCY_MISSING' });
if (!record.enabled) return;
visiting.add(id);
for (const dependency of record.manifest.requires) {
const dependencyRecord = this.#modules.get(dependency);
if (!dependencyRecord) throw new PlatformError(`Fehlende Modulabhängigkeit: ${dependency}`, { code: 'MODULE_DEPENDENCY_MISSING' });
if (!dependencyRecord.enabled) {
throw new PlatformError(`Modul ${id} benötigt das deaktivierte Modul ${dependency}.`, {
code: 'MODULE_DEPENDENCY_DISABLED', details: { module: id, dependency },
});
}
visit(dependency);
}
visiting.delete(id);
visited.add(id);
order.push(id);
};
for (const [id, record] of this.#modules) if (record.enabled) visit(id);
const capabilities = new Set([...this.#modules.values()].filter(record => record.enabled).flatMap(record => record.manifest.provides));
for (const record of this.#modules.values()) {
if (!record.enabled) continue;
for (const capability of record.manifest.consumes) {
if (!capabilities.has(capability)) throw new PlatformError(`Fehlende Capability ${capability} für ${record.manifest.id}.`, { code: 'MODULE_CAPABILITY_MISSING' });
}
}
return order;
}
validateGraph() {
const order = this.#resolveStartOrder();
return Object.freeze({
order: Object.freeze([...order]),
enabledModules: Object.freeze(order.map(id => this.#modules.get(id)?.manifest.id).filter(Boolean)),
capabilities: Object.freeze([...new Set([...this.#modules.values()]
.filter(record => record.enabled)
.flatMap(record => record.manifest.provides))].sort()),
});
}
async #startRecord(id, context) {
const record = this.#modules.get(id);
if (!record || !record.enabled || record.state === 'running') return;
for (const dependency of record.manifest.requires) {
const dependencyRecord = this.#modules.get(dependency);
if (!dependencyRecord?.enabled) {
throw new PlatformError(`Modul ${id} benötigt das deaktivierte Modul ${dependency}.`, {
code: 'MODULE_DEPENDENCY_DISABLED', status: 409, expose: true,
details: { module: id, dependency },
});
}
if (dependencyRecord.state !== 'running') await this.#startRecord(dependency, context);
}
try {
record.state = 'starting';
record.error = null;
await record.lifecycle.start(Object.freeze({ ...context, module: record.manifest }));
record.state = 'running';
record.startedAt = new Date().toISOString();
if (!this.#startOrder.includes(id)) this.#startOrder.push(id);
} catch (error) {
record.state = 'failed';
record.error = error?.message || String(error);
throw new PlatformError(`Modulstart fehlgeschlagen: ${id}`, { code: 'MODULE_START_FAILED', cause: error, details: { module: id } });
}
}
async startAll(context) {
this.#startOrder = this.#resolveStartOrder();
const started = [];
for (const id of this.#startOrder) {
const record = this.#modules.get(id);
if (!record.enabled) continue;
try {
await this.#startRecord(id, context);
started.push(id);
} catch (error) {
for (const startedId of started.reverse()) {
const startedRecord = this.#modules.get(startedId);
try { await startedRecord.lifecycle.stop(Object.freeze({ ...context, module: startedRecord.manifest, rollback: true })); } catch {}
startedRecord.state = 'stopped';
}
throw error;
}
}
this.#started = true;
}
async enable(id, context) {
const record = this.#modules.get(id);
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
if (record.enabled && record.state === 'running') return this.describe(id);
record.enabled = true;
record.state = 'registered';
try {
this.#resolveStartOrder();
if (this.#started) await this.#startRecord(id, context);
return this.describe(id);
} catch (error) {
record.enabled = false;
record.state = 'disabled';
throw error;
}
}
async disable(id, context, { cascade = false } = {}) {
const record = this.#modules.get(id);
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
if (!record.enabled) return { module: this.describe(id), disabled: [] };
const dependents = this.dependentsOf(id, { enabledOnly: true, recursive: true });
if (dependents.length && !cascade) {
throw new PlatformError('Das Modul wird von aktiven Modulen benötigt.', {
code: 'MODULE_HAS_ACTIVE_DEPENDENTS', status: 409, expose: true, details: { dependents },
});
}
const orderIndex = new Map(this.#startOrder.map((moduleId, index) => [moduleId, index]));
const toDisable = [...dependents, id].sort((a, b) => (orderIndex.get(b) ?? -1) - (orderIndex.get(a) ?? -1));
for (const moduleId of toDisable) {
const target = this.#modules.get(moduleId);
if (!target?.enabled) continue;
if (target.state === 'running') {
target.state = 'stopping';
try {
await target.lifecycle.stop(Object.freeze({ ...context, module: target.manifest, disabled: true }));
target.state = 'disabled';
} catch (error) {
target.state = 'failed';
target.error = error?.message || String(error);
throw new PlatformError(`Modul konnte nicht deaktiviert werden: ${moduleId}`, { code: 'MODULE_STOP_FAILED', status: 500, cause: error });
}
} else {
target.state = 'disabled';
}
target.enabled = false;
target.startedAt = null;
}
return { module: this.describe(id), disabled: toDisable };
}
async unregister(id, context) {
const record = this.#modules.get(id);
if (!record) return false;
if (record.enabled || record.state === 'running') await this.disable(id, context, { cascade: false });
const dependents = this.dependentsOf(id, { enabledOnly: false, recursive: false });
if (dependents.length) {
throw new PlatformError('Modul kann wegen vorhandener Abhängigkeiten nicht entfernt werden.', {
code: 'MODULE_HAS_DEPENDENTS', status: 409, expose: true, details: { dependents },
});
}
for (const resource of record.manifest.owns) if (this.#ownership.get(resource) === id) this.#ownership.delete(resource);
this.#modules.delete(id);
this.#startOrder = this.#startOrder.filter(moduleId => moduleId !== id);
return true;
}
async stopAll(context) {
for (const id of [...this.#startOrder].reverse()) {
const record = this.#modules.get(id);
if (!record || record.state !== 'running') continue;
try {
record.state = 'stopping';
await record.lifecycle.stop(Object.freeze({ ...context, module: record.manifest }));
record.state = 'stopped';
} catch (error) {
record.state = 'failed';
record.error = error?.message || String(error);
}
}
this.#started = false;
}
async health() {
const checks = [];
for (const [id, record] of this.#modules) {
if (!record.enabled || record.state === 'disabled') {
checks.push({ id, ok: true, state: 'disabled' });
continue;
}
try {
const result = await record.lifecycle.health({ module: record.manifest });
checks.push({ id, state: record.state, ok: record.state === 'running' && result?.ok !== false, ...(result || {}) });
} catch (error) {
checks.push({ id, state: record.state, ok: false, error: error?.message || String(error) });
}
}
return checks;
}
describe(id) {
const record = this.#modules.get(id);
if (!record) return null;
return {
...record.manifest,
source: record.source,
enabled: record.enabled,
state: record.state,
startedAt: record.startedAt,
error: record.error,
dependents: this.dependentsOf(id, { enabledOnly: false, recursive: false }),
};
}
snapshot() {
return [...this.#modules.keys()].map(id => this.describe(id));
}
}