* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
277 lines
11 KiB
JavaScript
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));
|
|
}
|
|
}
|