Implement typed module contracts and conformance bindings
This commit is contained in:
@@ -39,7 +39,7 @@ export const componentSharedModules = [
|
||||
export interface ComponentArtifact {
|
||||
id: string;
|
||||
subject: string;
|
||||
compatibility: "quixos-react-component-v1";
|
||||
compatibility: "org.quixos.react.esm/1";
|
||||
path: string;
|
||||
exportName: string;
|
||||
sharedModules: Record<(typeof componentSharedModules)[number], string>;
|
||||
@@ -171,7 +171,7 @@ function parseComponent(value: unknown, index: number): ComponentArtifact {
|
||||
[],
|
||||
path,
|
||||
);
|
||||
if (entry.compatibility !== "quixos-react-component-v1") fail(path, "Unsupported component ABI");
|
||||
if (entry.compatibility !== "org.quixos.react.esm/1") fail(path, "Unsupported component ABI");
|
||||
const files = parseArtifactFiles(entry.files, `${path}.files`);
|
||||
const shared = object(entry.sharedModules, `${path}.sharedModules`);
|
||||
keys(shared, [...componentSharedModules], [], `${path}.sharedModules`);
|
||||
@@ -187,7 +187,7 @@ function parseComponent(value: unknown, index: number): ComponentArtifact {
|
||||
const component: ComponentArtifact = {
|
||||
id: text(entry.id, path),
|
||||
subject: text(entry.subject, path),
|
||||
compatibility: "quixos-react-component-v1",
|
||||
compatibility: "org.quixos.react.esm/1",
|
||||
path: entryPath,
|
||||
exportName,
|
||||
sharedModules,
|
||||
@@ -272,6 +272,9 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts {
|
||||
};
|
||||
}
|
||||
function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void {
|
||||
for (const module of expected.modules ?? [])
|
||||
if (!expected.components?.some((component) => component.id === module.id))
|
||||
fail(module.id, `No artifact consumer for module contract ${module.contract} and its subject`);
|
||||
if (expected.bindingDigest !== manifest.bindingDigest || expected.sourceDigest !== manifest.sourceDigest)
|
||||
fail(expected.revision, "Substituted binding schema or source revision");
|
||||
for (const component of manifest.components ?? []) {
|
||||
|
||||
@@ -136,7 +136,8 @@ export function generatePackageQueryClientBindings(
|
||||
* declared queries, never the workspace's complete atom/interface inventory. */
|
||||
export function generateComponentClientBindings(plan: ExecutionPlan, packageRevision: string): string {
|
||||
const pkg = plan.definition.workspace.packageImports.find((pkg) => pkg.revisionId === packageRevision);
|
||||
if (!pkg || !pkg.components?.length)
|
||||
const components = plan.definition.packages.find((pkg) => pkg.revision === packageRevision)?.components;
|
||||
if (!pkg || !components?.length)
|
||||
throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Package declares no component entries");
|
||||
const byId = new Map(plan.definition.workspace.interfaceImports.map((iface) => [iface.revisionId as string, iface]));
|
||||
const find = (id: string) => {
|
||||
@@ -148,9 +149,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
|
||||
const imports = Object.fromEntries(
|
||||
Object.entries(pkg.clientInterfaces ?? {}).map(([alias, id]) => [alias, find(id)]),
|
||||
);
|
||||
const subjects = Object.fromEntries(
|
||||
pkg.components.map((component) => [component.displayName, find(component.subject)]),
|
||||
);
|
||||
const subjects = Object.fromEntries(components.map((component) => [component.displayName, find(component.subject)]));
|
||||
const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react");
|
||||
const subjectBindings = generateInterfaceClientBindings(subjects, "@quixos/camino-react")
|
||||
.split("\n")
|
||||
@@ -168,7 +167,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
|
||||
"\n" +
|
||||
queries +
|
||||
"\nexport interface Components {\n" +
|
||||
pkg.components
|
||||
components
|
||||
.map(
|
||||
(component) =>
|
||||
`${JSON.stringify(component.displayName)}: ComponentType<ComponentProps<${JSON.stringify(component.subject)}>>;`,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
OperationId,
|
||||
} from "../capability-model/types.js";
|
||||
import { validateWorkspaceRevision } from "../capability-model/validation.js";
|
||||
import { moduleContract } from "../capability-model/modules.js";
|
||||
import { canonicalJson, contentDigest } from "../capability-model/evolution.js";
|
||||
import { resolveForwardedBinding } from "../capability-model/forwarding.js";
|
||||
import { linkQueries, type LinkedQuery } from "../query/link.js";
|
||||
@@ -58,14 +59,41 @@ export interface PlannedQuery {
|
||||
admittedOperations: string[];
|
||||
}
|
||||
export interface PlanPackage {
|
||||
modules?: (NonNullable<WorkspaceRevision["packageImports"][number]["modules"]>[number] & {
|
||||
contract: string;
|
||||
subject?: import("../capability-model/types.js").ObjectExpectation;
|
||||
})[];
|
||||
controllers?: NonNullable<WorkspaceRevision["packageImports"][number]["controllers"]>;
|
||||
components?: NonNullable<WorkspaceRevision["packageImports"][number]["components"]>;
|
||||
components?: { id: string; displayName: string; subject: InterfaceRevisionId; runtime: "react" }[];
|
||||
revision: string;
|
||||
source: WorkspaceRevision["packageImports"][number]["source"];
|
||||
sourceDigest: string;
|
||||
bindingDigest: string;
|
||||
exports: { id: string; execution: ExecutionContract }[];
|
||||
}
|
||||
|
||||
/** React consumes these module contracts; other contracts remain metadata until
|
||||
* an artifact consumer exists. This does not make modules callable operations. */
|
||||
function componentDeclarations(workspace: WorkspaceRevision, pkg: WorkspaceRevision["packageImports"][number]) {
|
||||
return [
|
||||
...(pkg.modules ?? []).flatMap((module) => {
|
||||
const contract = moduleContract(workspace.interfaceImports, module)!;
|
||||
if (
|
||||
!["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(contract.contract) ||
|
||||
contract.subject?.kind !== "interface"
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
id: module.id,
|
||||
displayName: module.displayName,
|
||||
subject: contract.subject.interfaceRevisionId,
|
||||
runtime: "react" as const,
|
||||
},
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
export interface ExecutionPlanDefinition {
|
||||
presentations?: NonNullable<WorkspaceRevision["presentations"]>;
|
||||
codec: "camino-execution-plan-v1";
|
||||
@@ -430,8 +458,22 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
|
||||
source: p.source,
|
||||
sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }),
|
||||
bindingDigest: hexDigest(normalizedSchema),
|
||||
...(p.modules?.length
|
||||
? {
|
||||
modules: sorted(p.modules, (module) => module.id).map((module) => {
|
||||
const contract = moduleContract(workspace.interfaceImports, module)!;
|
||||
return {
|
||||
...module,
|
||||
contract: contract.contract,
|
||||
...(contract.subject ? { subject: contract.subject } : {}),
|
||||
};
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(p.controllers?.length ? { controllers: sorted(p.controllers, (controller) => controller.id) } : {}),
|
||||
...(p.components?.length ? { components: sorted(p.components, (component) => component.id) } : {}),
|
||||
...(componentDeclarations(workspace, p).length
|
||||
? { components: sorted(componentDeclarations(workspace, p), (component) => component.id) }
|
||||
: {}),
|
||||
exports: sorted(p.exports, (e) => e.id).map((e) => ({ id: e.id, execution: e.execution ?? { kind: "remote" } })),
|
||||
})),
|
||||
owners: sorted(workspace.conformances, (c) => c.id ?? "").map((c) => {
|
||||
|
||||
Reference in New Issue
Block a user