/** * Capability compatibility between a module's declared requirements and the * providers actually deployed in the fleet. * * The pure half mirrors the runtime lookup's semantics (see * `deploy-preflight.ts` section 2): a requirement is served when ANY deployed * provider satisfies it under `compareConsumerToProvider`. The reason * `deploy-preflight` and the upgrade path must agree is the failure * celilo#1361 recorded: celilo-website was upgraded against a caddy whose * bundled provider rejected its publish calls, and every attempt burned a * release. The upgrade path now asks this question BEFORE updating, and * defers instead of deploying into a wall. */ import { compareConsumerToProvider } from '@celilo/capabilities'; import type { DbClient } from '../db/client'; import { capabilities } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { isPrivilegedCapability } from '../manifest/validate'; export interface CapabilityRequirement { name: string; version: string; } export interface DeployedCapabilityProvider { capabilityName: string; moduleId: string; version: string; } export interface UnservedCapability { capability: string; /** The version the module's manifest requires. */ required: string; /** Example provider that does not serve it (the first mismatch found). */ providerModuleId: string | null; providerVersion: string | null; reason: | 'no_provider' | 'major_mismatch_higher' | 'major_mismatch_lower' | 'minor_mismatch_higher' | 'caller_minor_too_old'; message: string; } /** * Which of a module's capability requirements no deployed provider serves. * Pure (Rule 10): the I/O — reading the capabilities table — happens in * `capabilityBlockersForManifest`. */ export function unservedCapabilityRequirements( requirements: CapabilityRequirement[] | undefined, providers: DeployedCapabilityProvider[], ): UnservedCapability[] { const unserved: UnservedCapability[] = []; for (const cap of requirements ?? []) { // Framework-granted privileges (e.g. cross_module_read) are not // provider-backed — the same skip `deploy-preflight` makes. if (isPrivilegedCapability(cap.name)) continue; const installed = providers.filter((p) => p.capabilityName === cap.name); if (installed.length === 0) { unserved.push({ capability: cap.name, required: cap.version, providerModuleId: null, providerVersion: null, reason: 'no_provider', message: `no deployed provider for '${cap.name}'`, }); continue; } let anyCompatible = false; let example: DeployedCapabilityProvider | undefined; let reason: UnservedCapability['reason'] = 'major_mismatch_higher'; for (const p of installed) { const result = compareConsumerToProvider(cap.version, p.version); if (result.compatible) { anyCompatible = true; break; } if (!example) { example = p; reason = result.reason; } } if (anyCompatible || !example) continue; unserved.push({ capability: cap.name, required: cap.version, providerModuleId: example.moduleId, providerVersion: example.version, reason, message: `requires ${cap.name}@${cap.version} but deployed provider '${example.moduleId}' provides ${cap.name}@${example.version} (${reason})`, }); } return unserved; } /** Every capability provider currently registered in the fleet. */ export function readDeployedProviders(db: DbClient): DeployedCapabilityProvider[] { return db .select({ capabilityName: capabilities.capabilityName, moduleId: capabilities.moduleId, version: capabilities.version, }) .from(capabilities) .all(); } /** * The upgrade-path entry point: which of a target manifest's capability * requirements the deployed fleet cannot serve. A non-empty result means the * upgrade would deploy into a wall the preflight would then reject — defer * instead of burning the attempt. */ export function capabilityBlockersForManifest( db: DbClient, manifest: Pick, ): UnservedCapability[] { return unservedCapabilityRequirements( manifest.requires?.capabilities as CapabilityRequirement[] | undefined, readDeployedProviders(db), ); }