import { satisfies } from "@suaveplan/api-manifest/dep-graph"; import type { CapabilityId, CapabilityMap } from "@suaveplan/api-types"; import type { CapabilityVersion } from "@suaveplan/api-types/capabilities"; import { CAPABILITY_CODES, HostError } from "../error-codes/error-codes.js"; /** * Scope of a capability — controls how the host vends it into the per-plugin * vs per-request context. `host` is process-global, `plugin` is one-per-plugin * (injected on `PluginContext`), `request` is per-request (injected on * `RequestContext`, e.g. tenant-scoped DB). */ export type CapabilityScope = "host" | "plugin" | "request"; /** * Registration record for one capability adapter. The `provider` is the * concrete runtime that vends the capability shape (a logger, a db * client, etc.); the `disposer` is invoked in reverse-registration order * on registry teardown. */ export interface CapabilityRegistration< Id extends CapabilityId = CapabilityId, > { readonly id: Id; readonly version: CapabilityVersion; readonly scope: CapabilityScope; readonly provider: Id extends keyof CapabilityMap ? CapabilityMap[Id] : unknown; readonly disposer?: () => Promise | void; } /** * Author-facing helper. Identity at runtime; exists so `defineCapability(...)` * provides an inference-friendly entry point for adapter authors (mirrors * `defineCapability` in api-types' design intent, but the runtime lives here * per design doc §5.2). */ export function defineCapability( registration: CapabilityRegistration ): CapabilityRegistration { return registration; } const SEMVER_FULL = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const VALID_SCOPES = new Set(["host", "plugin", "request"]); /** * Live registry of every capability adapter wired into a host instance. * * Responsibilities: * - register / lookup / unregister of capability records keyed by `id`; * - input validation (semver shape, scope enum) at register time; * - duplicate detection; * - per-plugin version-compat check via `assertSatisfies` — the host's * lifecycle driver calls this on every install to enforce the * plugin's `requiredCapabilities` declarations against the actual * registered adapter versions; * - reverse-registration-order disposer invocation on `dispose()`. * * The registry is single-instance per host process. The host's bootstrap * builds it before any plugin is loaded; Stage-4 adapter packages call * `register(...)` from their own boot hooks. */ export class CapabilityRegistry { private readonly entries = new Map(); /** Registration order — used to compute reverse-disposer order. */ private readonly order: CapabilityId[] = []; private disposed = false; /** * Register a capability. Throws: * - `CAPABILITY.INVALID_VERSION` when `version` is not a parseable semver; * - `CAPABILITY.INVALID_SCOPE` when `scope` is outside `host|plugin|request`; * - `CAPABILITY.DUPLICATE_REGISTRATION` when the same id is registered twice. */ public register( registration: CapabilityRegistration ): void { if (this.disposed) { throw new HostError( CAPABILITY_CODES.NOT_REGISTERED, "capability-registry: already disposed", { context: { id: registration.id } } ); } if (!SEMVER_FULL.test(registration.version)) { throw new HostError( CAPABILITY_CODES.INVALID_VERSION, "capability-registry: version is not valid semver", { context: { id: registration.id, version: registration.version, }, } ); } if (!VALID_SCOPES.has(registration.scope)) { throw new HostError( CAPABILITY_CODES.INVALID_SCOPE, "capability-registry: scope must be host|plugin|request", { context: { id: registration.id, scope: registration.scope, }, } ); } if (this.entries.has(registration.id)) { throw new HostError( CAPABILITY_CODES.DUPLICATE_REGISTRATION, "capability-registry: id already registered", { context: { id: registration.id } } ); } this.entries.set( registration.id, registration as CapabilityRegistration ); this.order.push(registration.id); } /** Best-effort lookup. Returns `undefined` when the id is not registered. */ public lookup( id: Id ): CapabilityRegistration | undefined { return this.entries.get(id) as CapabilityRegistration | undefined; } /** * Strict lookup. Throws `CAPABILITY.NOT_REGISTERED` when the id is not * present — the lifecycle driver uses this to fail-fast on * `requiredCapabilities` resolution. */ public require( id: Id ): CapabilityRegistration { const entry = this.lookup(id); if (entry === undefined) { throw new HostError( CAPABILITY_CODES.NOT_REGISTERED, "capability-registry: required capability is not registered", { context: { id } } ); } return entry; } /** Enumeration of every registered id, in registration order. */ public ids(): readonly CapabilityId[] { return [...this.order]; } /** Count of currently registered capabilities. */ public size(): number { return this.entries.size; } /** `true` iff `id` is present in the registry. */ public has(id: CapabilityId): boolean { return this.entries.has(id); } /** * Per-plugin compat check. For every `(id, range)` pair, verifies the * registered adapter's version satisfies the requested semver range. * * Throws: * - `CAPABILITY.NOT_REGISTERED` when any required id is missing; * - `CAPABILITY.VERSION_MISMATCH` when the version does not satisfy * the range OR the range string is unparseable. * * The host's lifecycle driver invokes this during the `installing` * transition, after the manifest has been validated. */ public assertSatisfies(ranges: Readonly>): void { for (const [id, range] of Object.entries(ranges)) { const entry = this.entries.get(id as CapabilityId); if (entry === undefined) { throw new HostError( CAPABILITY_CODES.NOT_REGISTERED, "capability-registry: required capability missing", { context: { id, range } } ); } let ok: boolean; try { ok = satisfies(entry.version, range); } catch (cause) { throw new HostError( CAPABILITY_CODES.VERSION_MISMATCH, "capability-registry: requested range is unparseable", { cause: cause as Error, context: { id, range, actual: entry.version, }, } ); } if (!ok) { throw new HostError( CAPABILITY_CODES.VERSION_MISMATCH, "capability-registry: version does not satisfy range", { context: { id, range, actual: entry.version, }, } ); } } } /** * Dispose every registered adapter in reverse-registration order. * Disposer throws are collected and re-raised as a single * `CAPABILITY.DISPOSER_FAILED` error carrying the list of failing * ids — one bad disposer does not prevent the others from running. * * Idempotent: calling `dispose()` twice is a no-op on the second * call (returns immediately). */ public async dispose(): Promise { if (this.disposed) return; this.disposed = true; const failures: { id: CapabilityId; cause: unknown }[] = []; for (let i = this.order.length - 1; i >= 0; i--) { const id = this.order[i] as CapabilityId; const entry = this.entries.get(id); if (entry?.disposer !== undefined) { try { await entry.disposer(); } catch (cause) { failures.push({ id, cause }); } } } this.entries.clear(); this.order.length = 0; if (failures.length > 0) { throw new HostError( CAPABILITY_CODES.DISPOSER_FAILED, "capability-registry: one or more disposers threw", { context: { failed: failures.map((f) => f.id), }, } ); } } }