225 lines
9.5 KiB
TypeScript
225 lines
9.5 KiB
TypeScript
import type { BindingSchema, BindingContractSchema } from "../bindings/index.js";
|
|||
|
|
import type { InterfaceRevision, PackageRevision } from "../capability-model/types.js";
|
||
|
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||
|
|
import { moduleContract } from "../capability-model/modules.js";
|
||
|
|
import { generatePackageComponentBindings, publicQueryContract, type PublicQueryContract } from "./client-contracts.js";
|
||
|
|
import { moduleTypeContext } from "./module-contracts.js";
|
||
|
|
import { executionVersions, hexDigest, ExecutionPlanError, type PlanPackage } from "./plan.js";
|
||
|
|
import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js";
|
||
|
|
|
||
|
|
/** Inputs to compilation, independent of source pins and installed providers.
|
||
|
|
* Declared ports select interface contracts; the React consumer projects its
|
||
|
|
* imported interfaces, public queries and module inputs. Ordinary code
|
||
|
|
* and richer language type companions remain separate, explicit Nix inputs. */
|
||
|
|
export interface PackageBuildContract {
|
||
|
|
definition: {
|
||
|
|
codec: "camino-package-build-v1";
|
||
|
|
package: Omit<PlanPackage, "source" | "sourceDigest" | "bindingDigest">;
|
||
|
|
versions: typeof executionVersions;
|
||
|
|
bindings: BindingContractSchema;
|
||
|
|
queries: PublicQueryContract[];
|
||
|
|
component?: string;
|
||
|
|
moduleInterfaces: { revisionId: string; definitionId?: string; members: string[] }[];
|
||
|
|
diagnostics: string[];
|
||
|
|
};
|
||
|
|
digest: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const normalize = <T>(value: T): T => JSON.parse(canonicalJson(value)) as T;
|
||
|
|
const indexSchema = (schema: BindingSchema) => ({
|
||
|
|
interfaces: new Map<string, InterfaceRevision>(
|
||
|
|
[...schema.interfaces, ...(schema.interfaceTemplates ?? [])].map((entry) => [entry.revisionId, entry]),
|
||
|
|
),
|
||
|
|
packages: new Map<string, PackageRevision>(schema.packages.map((entry) => [entry.revisionId, entry])),
|
||
|
|
});
|
||
|
|
export function packageBuildContexts(
|
||
|
|
schema: BindingSchema,
|
||
|
|
): Record<string, { contract: PackageBuildContract; sourceDigest: string }> {
|
||
|
|
const index = indexSchema(schema);
|
||
|
|
return Object.fromEntries(
|
||
|
|
schema.packages.map((pkg) => [
|
||
|
|
pkg.revisionId,
|
||
|
|
{
|
||
|
|
contract: projectPackageContract(schema, pkg.revisionId, index),
|
||
|
|
sourceDigest: hexDigest({ revision: pkg.revisionId, source: pkg.source }),
|
||
|
|
},
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
export function packageBuildContract(schema: BindingSchema, revision: string): PackageBuildContract {
|
||
|
|
return projectPackageContract(schema, revision, indexSchema(schema));
|
||
|
|
}
|
||
|
|
function projectPackageContract(
|
||
|
|
schema: BindingSchema,
|
||
|
|
revision: string,
|
||
|
|
index: ReturnType<typeof indexSchema>,
|
||
|
|
): PackageBuildContract {
|
||
|
|
const declared = index.packages.get(revision);
|
||
|
|
if (!declared) throw new ExecutionPlanError("UNKNOWN_BUILD_PACKAGE", revision, "Missing package declaration");
|
||
|
|
const pkg = normalize(declared);
|
||
|
|
const interfaces = (ids: Iterable<string>) =>
|
||
|
|
[...ids].map((id) => {
|
||
|
|
const iface = index.interfaces.get(id);
|
||
|
|
if (!iface) throw new ExecutionPlanError("UNKNOWN_BUILD_INTERFACE", id, "Missing declared interface");
|
||
|
|
return normalize(iface);
|
||
|
|
});
|
||
|
|
const modules = [...(pkg.modules ?? [])]
|
||
|
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||
|
|
.map((module) => {
|
||
|
|
const contract = moduleContract(interfaces([module.interfaceRevisionId]), module);
|
||
|
|
if (!contract) throw new ExecutionPlanError("UNKNOWN_MODULE_CONTRACT", module.id, "Missing module contract");
|
||
|
|
return {
|
||
|
|
...module,
|
||
|
|
contract: contract.contract,
|
||
|
|
...(contract.subject ? { subject: contract.subject } : {}),
|
||
|
|
...moduleTypeContext(index.interfaces.get(module.interfaceRevisionId)!),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
const components = modules.flatMap((module) =>
|
||
|
|
["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes(
|
||
|
|
module.contract,
|
||
|
|
) && module.subject?.kind === "interface"
|
||
|
|
? [
|
||
|
|
{
|
||
|
|
id: module.id,
|
||
|
|
displayName: module.displayName,
|
||
|
|
subject: module.subject.interfaceRevisionId,
|
||
|
|
runtime: "react" as const,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
: [],
|
||
|
|
);
|
||
|
|
const shape: PackageBuildContract["definition"]["package"] = {
|
||
|
|
revision,
|
||
|
|
exports: [...pkg.exports]
|
||
|
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||
|
|
.map((entry) => ({ id: entry.id, execution: entry.execution ?? { kind: "remote" } })),
|
||
|
|
...(modules.length ? { modules } : {}),
|
||
|
|
...(components.length ? { components } : {}),
|
||
|
|
...(pkg.controllers?.length ? { controllers: [...pkg.controllers].sort((a, b) => a.id.localeCompare(b.id)) } : {}),
|
||
|
|
};
|
||
|
|
const portInterfaces = new Set(
|
||
|
|
pkg.exports.flatMap((entry) =>
|
||
|
|
entry.dependencyPorts.flatMap((port) =>
|
||
|
|
port.requirement.kind === "interface" ? [port.requirement.interfaceRevisionId] : [],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const bindings: BindingContractSchema = {
|
||
|
|
format: "quixos-bindings",
|
||
|
|
version: 1,
|
||
|
|
packages: [
|
||
|
|
{
|
||
|
|
revisionId: pkg.revisionId,
|
||
|
|
exports: [...pkg.exports].sort((a, b) => a.id.localeCompare(b.id)),
|
||
|
|
...(pkg.genericExports ? { genericExports: pkg.genericExports } : {}),
|
||
|
|
},
|
||
|
|
],
|
||
|
|
interfaces: interfaces(portInterfaces)
|
||
|
|
.map(({ source, ...contract }) => contract)
|
||
|
|
.sort((a, b) => a.revisionId.localeCompare(b.revisionId)),
|
||
|
|
};
|
||
|
|
const preparation = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined;
|
||
|
|
const diagnostics = components.flatMap((component) => {
|
||
|
|
const input = preparation?.modules.find((entry) => entry.module === component.id);
|
||
|
|
return input && pkg.queryCompositions?.some((entry) => entry.id === input.composition)
|
||
|
|
? []
|
||
|
|
: [`${component.id}: declare checked inputs in quixos.react.json`];
|
||
|
|
});
|
||
|
|
// Only interfaces whose modules are exposed by these bindings need language
|
||
|
|
// type companions. Reference types themselves do not recursively import the
|
||
|
|
// referenced object's implementation or its conformance inventory.
|
||
|
|
const used = new Set<string>(Object.values(pkg.clientInterfaces ?? {}));
|
||
|
|
for (const module of modules) used.add(module.interfaceRevisionId);
|
||
|
|
for (const component of components) used.add(component.subject);
|
||
|
|
for (const entry of preparation?.modules ?? [])
|
||
|
|
for (const child of entry.children) if (child.role) used.add(child.role.interface);
|
||
|
|
const moduleInterfaces = interfaces(used)
|
||
|
|
.flatMap((iface: InterfaceRevision) => {
|
||
|
|
const members = iface.members
|
||
|
|
.filter((member) => member.kind === "module")
|
||
|
|
.map((member) => member.id)
|
||
|
|
.sort();
|
||
|
|
return members.length
|
||
|
|
? [
|
||
|
|
{
|
||
|
|
revisionId: iface.revisionId,
|
||
|
|
...(iface.application ? { definitionId: iface.application.definitionId } : {}),
|
||
|
|
members,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
: [];
|
||
|
|
})
|
||
|
|
.sort((a, b) => a.revisionId.localeCompare(b.revisionId));
|
||
|
|
for (const module of modules)
|
||
|
|
for (const argument of index.interfaces.get(module.interfaceRevisionId)?.application?.arguments ?? [])
|
||
|
|
if (argument.kind === "object" && argument.target.kind === "interface")
|
||
|
|
used.add(argument.target.interfaceRevisionId);
|
||
|
|
const providers = new Set<string>(Object.values(pkg.clientPackages ?? {}));
|
||
|
|
for (const query of pkg.queries ?? [])
|
||
|
|
for (const imported of query.importedFragments ?? []) providers.add(imported.packageRevisionId);
|
||
|
|
providers.delete(pkg.revisionId);
|
||
|
|
const componentSchema: BindingSchema = {
|
||
|
|
format: "quixos-bindings",
|
||
|
|
version: 1,
|
||
|
|
interfaces: interfaces(used),
|
||
|
|
packages: [
|
||
|
|
pkg,
|
||
|
|
...[...providers].map((id) => {
|
||
|
|
const provider = index.packages.get(id);
|
||
|
|
if (!provider) throw new ExecutionPlanError("UNKNOWN_BUILD_PROVIDER", id, "Missing declared package provider");
|
||
|
|
return normalize(provider);
|
||
|
|
}),
|
||
|
|
],
|
||
|
|
};
|
||
|
|
const definition: PackageBuildContract["definition"] = {
|
||
|
|
codec: "camino-package-build-v1",
|
||
|
|
package: shape,
|
||
|
|
versions: { ...executionVersions },
|
||
|
|
bindings,
|
||
|
|
queries: [
|
||
|
|
...new Map(
|
||
|
|
pkg.exports.flatMap((entry) =>
|
||
|
|
entry.dependencyPorts.flatMap((port) => {
|
||
|
|
if (port.requirement.kind !== "query") return [];
|
||
|
|
const requirement = port.requirement;
|
||
|
|
const provider = index.packages.get(requirement.packageRevisionId);
|
||
|
|
if (!provider)
|
||
|
|
throw new ExecutionPlanError(
|
||
|
|
"UNKNOWN_QUERY_PROVIDER",
|
||
|
|
requirement.packageRevisionId,
|
||
|
|
"Missing declared query provider",
|
||
|
|
);
|
||
|
|
return [
|
||
|
|
[
|
||
|
|
JSON.stringify([requirement.packageRevisionId, requirement.queryId]),
|
||
|
|
publicQueryContract(provider, requirement.queryId),
|
||
|
|
] as const,
|
||
|
|
];
|
||
|
|
}),
|
||
|
|
),
|
||
|
|
).entries(),
|
||
|
|
]
|
||
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
||
|
|
.map(([, query]) => query),
|
||
|
|
...(components.length && !diagnostics.length
|
||
|
|
? { component: generatePackageComponentBindings(componentSchema, shape) }
|
||
|
|
: {}),
|
||
|
|
moduleInterfaces,
|
||
|
|
diagnostics,
|
||
|
|
};
|
||
|
|
const normalized = JSON.parse(canonicalJson(definition)) as typeof definition;
|
||
|
|
return { definition: normalized, digest: hexDigest(normalized) };
|
||
|
|
}
|
||
|
|
|
||
|
|
export function checkPackageBuildContract(contract: PackageBuildContract): void {
|
||
|
|
if (contract.definition.codec !== "camino-package-build-v1" || hexDigest(contract.definition) !== contract.digest)
|
||
|
|
throw new ExecutionPlanError("INVALID_BUILD_CONTRACT", contract.digest, "Package contract digest mismatch");
|
||
|
|
if (contract.definition.diagnostics.length)
|
||
|
|
throw new ExecutionPlanError(
|
||
|
|
"INCOMPLETE_BUILD_CONTRACT",
|
||
|
|
contract.definition.package.revision,
|
||
|
|
contract.definition.diagnostics.join("; "),
|
||
|
|
);
|
||
|
|
}
|