Complete prepared presentation build and runtime integration
This commit is contained in:
@@ -46,7 +46,7 @@ export type CapabilityRepositoryResolver = (
|
|||||||
/** An explicitly selected build frontend supplies ordinary query sources. The
|
/** An explicitly selected build frontend supplies ordinary query sources. The
|
||||||
* assembler never chooses a frontend by inspecting an interface/contract ID.
|
* assembler never chooses a frontend by inspecting an interface/contract ID.
|
||||||
* Generated sources go through the same query and imported-fragment checks. */
|
* Generated sources go through the same query and imported-fragment checks. */
|
||||||
export type PackageQueryGenerator = (input: {
|
export type PackageQueryGenerator = ((input: {
|
||||||
readonly package: Readonly<PackageRevision>;
|
readonly package: Readonly<PackageRevision>;
|
||||||
readonly interfaces: readonly InterfaceRevision[];
|
readonly interfaces: readonly InterfaceRevision[];
|
||||||
readonly readSource: (name: string) => Promise<string>;
|
readonly readSource: (name: string) => Promise<string>;
|
||||||
@@ -54,7 +54,12 @@ export type PackageQueryGenerator = (input: {
|
|||||||
readonly queries: readonly QueryDeclaration[];
|
readonly queries: readonly QueryDeclaration[];
|
||||||
readonly documents: ReadonlyMap<string, string>;
|
readonly documents: ReadonlyMap<string, string>;
|
||||||
readonly compositions?: readonly QueryComposition[];
|
readonly compositions?: readonly QueryComposition[];
|
||||||
}>;
|
readonly artifacts?: Readonly<Record<string, unknown>>;
|
||||||
|
}>) & {
|
||||||
|
/** Optional explicit frontend stage after workspace binding resolution.
|
||||||
|
* Its output still passes normal workspace/query validation. */
|
||||||
|
completeWorkspace?: (workspace: WorkspaceRevision) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ResolvedCapabilityResource = {
|
export type ResolvedCapabilityResource = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -297,6 +302,7 @@ const createResourceGraphResolver = (
|
|||||||
readSource: (name) => readRepositorySource(snapshot.directory, name),
|
readSource: (name) => readRepositorySource(snapshot.directory, name),
|
||||||
});
|
});
|
||||||
if (generated) {
|
if (generated) {
|
||||||
|
if (generated.artifacts) compiled.resource.revision.buildArtifacts = structuredClone(generated.artifacts);
|
||||||
const queries = [...(compiled.resource.revision.queries ?? [])];
|
const queries = [...(compiled.resource.revision.queries ?? [])];
|
||||||
for (const query of generated.queries) {
|
for (const query of generated.queries) {
|
||||||
if (queries.some((entry) => entry.id === query.id || entry.displayName === query.displayName))
|
if (queries.some((entry) => entry.id === query.id || entry.displayName === query.displayName))
|
||||||
@@ -534,6 +540,7 @@ export const compileWorkspaceRepository = async (options: {
|
|||||||
: {}),
|
: {}),
|
||||||
...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}),
|
...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}),
|
||||||
};
|
};
|
||||||
|
await options.generateQueries?.completeWorkspace?.(workspace);
|
||||||
const checked = compileWorkspaceRevision(workspace);
|
const checked = compileWorkspaceRevision(workspace);
|
||||||
if (!checked.ok) {
|
if (!checked.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -59,12 +59,26 @@ export async function scaffoldComponent(
|
|||||||
const configBefore = await fs.readFile(path.join(root, configFile), "utf8");
|
const configBefore = await fs.readFile(path.join(root, configFile), "utf8");
|
||||||
const config = JSON.parse(configBefore);
|
const config = JSON.parse(configBefore);
|
||||||
config.queryFrontends = [...new Set([...(config.queryFrontends ?? []), "react"])];
|
config.queryFrontends = [...new Set([...(config.queryFrontends ?? []), "react"])];
|
||||||
|
const preparationFile = prefix + "quixos.react.json";
|
||||||
|
let preparationBefore: string | undefined;
|
||||||
|
try {
|
||||||
|
preparationBefore = await fs.readFile(path.join(root, preparationFile), "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
const preparation = preparationBefore ? JSON.parse(preparationBefore) : { modules: {} };
|
||||||
|
if (Object.hasOwn(preparation.modules, spec.name)) throw Error("Duplicate presentation preparation");
|
||||||
|
preparation.modules[spec.name] = { inputs: [] };
|
||||||
|
const preparationText = JSON.stringify(preparation, null, 2) + "\n";
|
||||||
return {
|
return {
|
||||||
kind: "package",
|
kind: "package",
|
||||||
source: spec.source,
|
source: spec.source,
|
||||||
resourceRoot: spec.directory,
|
resourceRoot: spec.directory,
|
||||||
validation: "syntax",
|
validation: "syntax",
|
||||||
files: [
|
files: [
|
||||||
|
preparationBefore === undefined
|
||||||
|
? { file: preparationFile, create: preparationText }
|
||||||
|
: { file: preparationFile, expected: preparationBefore, replace: preparationText },
|
||||||
{ file: configFile, expected: configBefore, replace: JSON.stringify(config, null, 2) + "\n" },
|
{ file: configFile, expected: configBefore, replace: JSON.stringify(config, null, 2) + "\n" },
|
||||||
{
|
{
|
||||||
file: prefix + "package.qx",
|
file: prefix + "package.qx",
|
||||||
@@ -79,7 +93,7 @@ export async function scaffoldComponent(
|
|||||||
{ file: prefix + filename, expected: before, replace: after },
|
{ file: prefix + filename, expected: before, replace: after },
|
||||||
{
|
{
|
||||||
file: prefix + `src/components/${spec.name}.tsx`,
|
file: prefix + `src/components/${spec.name}.tsx`,
|
||||||
create: `import type { Modules } from "../gen/client.js";\n\n// Add explicit query admission and named interface operations from the generated client.\nexport const ${spec.name}: Modules[${JSON.stringify(spec.name)}] = ({subject}) => <p>{subject.object}</p>;\n`,
|
create: `import type { Modules } from "../gen/client.js";\n\n// Declare initial query inputs in quixos.react.json; use interfaces for actions.\nexport const ${spec.name}: Modules[${JSON.stringify(spec.name)}] = ({subject}) => <p>{subject.object}</p>;\n`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,5 +9,10 @@ export async function loadQueryGenerator(filename?: string): Promise<PackageQuer
|
|||||||
const module = await import(pathToFileURL(path.resolve(filename)).href);
|
const module = await import(pathToFileURL(path.resolve(filename)).href);
|
||||||
if (typeof module.generatePackageQueries !== "function")
|
if (typeof module.generatePackageQueries !== "function")
|
||||||
throw new Error("Query generator must export generatePackageQueries");
|
throw new Error("Query generator must export generatePackageQueries");
|
||||||
return module.generatePackageQueries;
|
const generator: PackageQueryGenerator = module.generatePackageQueries;
|
||||||
|
if (module.completeWorkspace !== undefined) {
|
||||||
|
if (typeof module.completeWorkspace !== "function") throw new Error("completeWorkspace must be a function");
|
||||||
|
generator.completeWorkspace = module.completeWorkspace;
|
||||||
|
}
|
||||||
|
return generator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,6 +322,9 @@ export interface WorkspacePresentation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageRevision {
|
export interface PackageRevision {
|
||||||
|
/** Explicit build consumers own these artifacts. Generic checking and query
|
||||||
|
* execution attach no semantics to their keys or contents. */
|
||||||
|
buildArtifacts?: Record<string, unknown>;
|
||||||
modules?: PackageModule[];
|
modules?: PackageModule[];
|
||||||
/** Closed interface imports exposed to this package's checked component bindings. */
|
/** Closed interface imports exposed to this package's checked component bindings. */
|
||||||
clientInterfaces?: Record<string, InterfaceRevisionId>;
|
clientInterfaces?: Record<string, InterfaceRevisionId>;
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
return (
|
return (
|
||||||
interfaces +
|
interfaces +
|
||||||
'\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps} from "@quixos/camino-react";\n' +
|
'\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps,type PreparedPresentation} from "@quixos/camino-react";\n' +
|
||||||
subjectBindings +
|
subjectBindings +
|
||||||
"\n" +
|
"\n" +
|
||||||
queries +
|
queries +
|
||||||
@@ -272,7 +272,27 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi
|
|||||||
.map((component) => {
|
.map((component) => {
|
||||||
const module = modules.find((module) => module.id === component.id)!;
|
const module = modules.find((module) => module.id === component.id)!;
|
||||||
const key = JSON.stringify([module.interfaceRevisionId, module.memberId]);
|
const key = JSON.stringify([module.interfaceRevisionId, module.memberId]);
|
||||||
return `${JSON.stringify(component.displayName)}: ComponentType<ComponentProps<${JSON.stringify(component.subject)}, ${JSON.stringify(module.contract)}, ${moduleArguments(find(module.interfaceRevisionId))}> & ExtraModuleProps<${JSON.stringify(key)}>>;`;
|
const artifact = pkg.buildArtifacts?.["org.quixos.react.preparation/1"] as
|
||||||
|
| import("../react-build/declarations.js").ReactPreparationArtifact
|
||||||
|
| undefined;
|
||||||
|
const preparation = artifact?.modules.find((entry) => entry.module === module.id);
|
||||||
|
const composition = pkg.queryCompositions?.find((entry) => entry.id === preparation?.composition);
|
||||||
|
if (!preparation || !composition)
|
||||||
|
throw new ExecutionPlanError("MISSING_PRESENTATION_INPUTS", module.id, "Declare inputs in quixos.react.json");
|
||||||
|
const children = preparation.children.map((child) => {
|
||||||
|
let extra = "{}";
|
||||||
|
if (child.role) {
|
||||||
|
const iface = find(child.role.interface);
|
||||||
|
const member = iface.members.find(
|
||||||
|
(entry) => entry.id === child.role!.member || entry.displayName === child.role!.member,
|
||||||
|
)!;
|
||||||
|
extra = props(iface.revisionId, member.id);
|
||||||
|
}
|
||||||
|
const handle = `PreparedPresentation<${JSON.stringify(child.subject)}, ${extra}>`;
|
||||||
|
return `readonly ${JSON.stringify(child.name)}: ${child.each ? `readonly ${handle}[]` : child.with || child.when ? `${handle} | null` : handle}`;
|
||||||
|
});
|
||||||
|
const input = `${tsType(composition.output)} & {readonly children: {${children.join(";")}}}`;
|
||||||
|
return `${JSON.stringify(component.displayName)}: ComponentType<ComponentProps<${JSON.stringify(component.subject)}, ${JSON.stringify(module.contract)}, ${moduleArguments(find(module.interfaceRevisionId))}, ${input}> & ExtraModuleProps<${JSON.stringify(key)}>>;`;
|
||||||
})
|
})
|
||||||
.join("\n") +
|
.join("\n") +
|
||||||
"\n}\n"
|
"\n}\n"
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ export interface InstalledComponent {
|
|||||||
readonly exportName: string;
|
readonly exportName: string;
|
||||||
readonly styles: readonly string[];
|
readonly styles: readonly string[];
|
||||||
readonly compatibility: ComponentArtifact["compatibility"];
|
readonly compatibility: ComponentArtifact["compatibility"];
|
||||||
|
/** Owned and interpreted by the React artifact consumer. */
|
||||||
|
readonly preparation?: unknown;
|
||||||
readonly sharedModules: Readonly<Record<keyof ComponentArtifact["sharedModules"], string>>;
|
readonly sharedModules: Readonly<Record<keyof ComponentArtifact["sharedModules"], string>>;
|
||||||
}
|
}
|
||||||
/** Build-time validation uses the compiler selected by the Nix derivation. */
|
/** Build-time validation uses the compiler selected by the Nix derivation. */
|
||||||
@@ -208,6 +210,14 @@ function assembleExecutionInstallation(
|
|||||||
routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType };
|
routes[url] ??= { path: path.join(root, file.path), mediaType: file.mediaType };
|
||||||
}
|
}
|
||||||
components.push({
|
components.push({
|
||||||
|
...(() => {
|
||||||
|
const pkg = plan.definition.workspace.packageImports.find((entry) => entry.revisionId === revision);
|
||||||
|
const artifact = pkg?.buildArtifacts?.["org.quixos.react.preparation/1"] as
|
||||||
|
| { modules: { module: string }[] }
|
||||||
|
| undefined;
|
||||||
|
const preparation = artifact?.modules.find((entry) => entry.module === component.id);
|
||||||
|
return preparation ? { preparation } : {};
|
||||||
|
})(),
|
||||||
role: (() => {
|
role: (() => {
|
||||||
const module = plan.definition.packages
|
const module = plan.definition.packages
|
||||||
.find((p) => p.revision === revision)!
|
.find((p) => p.revision === revision)!
|
||||||
|
|||||||
+49
-31
@@ -6,6 +6,7 @@ import { QueryCompileError, type CheckedQuery } from "./types.js";
|
|||||||
/** Bindings refer to checked values, never executable callbacks. A path cannot
|
/** Bindings refer to checked values, never executable callbacks. A path cannot
|
||||||
* silently cross a nullable value or expand an unbounded collection. */
|
* silently cross a nullable value or expand an unbounded collection. */
|
||||||
export type QueryBinding =
|
export type QueryBinding =
|
||||||
|
| { flatMap: { source: QueryBinding; path: string[] } }
|
||||||
| { literal: string | boolean }
|
| { literal: string | boolean }
|
||||||
| { equal: [QueryBinding, QueryBinding] }
|
| { equal: [QueryBinding, QueryBinding] }
|
||||||
| { from: "input"; path: string[] }
|
| { from: "input"; path: string[] }
|
||||||
@@ -54,37 +55,8 @@ export function compileQueryComposition(
|
|||||||
const outputs = new Map<string, ValueType>();
|
const outputs = new Map<string, ValueType>();
|
||||||
const dependencies = new Map<string, string>();
|
const dependencies = new Map<string, string>();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
function binding(expression: QueryBinding, item?: ValueType): ValueType {
|
const binding = (expression: QueryBinding, item?: ValueType) =>
|
||||||
if ("literal" in expression)
|
queryBindingType(expression, source.input, outputs, item);
|
||||||
return { kind: "scalar", name: typeof expression.literal === "string" ? "string" : "bool" };
|
|
||||||
if ("equal" in expression) {
|
|
||||||
const left = binding(expression.equal[0], item);
|
|
||||||
const right = binding(expression.equal[1], item);
|
|
||||||
if (left.kind !== "scalar" || !equal(left, right)) fail("equal requires matching scalar operands");
|
|
||||||
return { kind: "scalar", name: "bool" };
|
|
||||||
}
|
|
||||||
if ("record" in expression)
|
|
||||||
return {
|
|
||||||
kind: "record",
|
|
||||||
fields: Object.fromEntries(
|
|
||||||
Object.entries(expression.record).map(([key, value]) => [key, binding(value, item)]),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let type =
|
|
||||||
expression.from === "input" ? source.input : expression.from === "item" ? item : outputs.get(expression.name);
|
|
||||||
if (!type)
|
|
||||||
return fail(
|
|
||||||
expression.from === "result"
|
|
||||||
? `result ${expression.name} must name an earlier step (forward references and cycles are unsupported)`
|
|
||||||
: "item is only available within a bounded each step",
|
|
||||||
);
|
|
||||||
for (const key of expression.path) {
|
|
||||||
if (type.kind !== "record" || !Object.hasOwn(type.fields, key))
|
|
||||||
return fail(`cannot select ${key} from ${type.kind}; nulls and collections need an explicit boundary`);
|
|
||||||
type = type.fields[key]!;
|
|
||||||
}
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
const equal = (a: ValueType, b: ValueType) => canonicalJson(a) === canonicalJson(b);
|
const equal = (a: ValueType, b: ValueType) => canonicalJson(a) === canonicalJson(b);
|
||||||
for (const step of source.steps) {
|
for (const step of source.steps) {
|
||||||
if (!step.name || outputs.has(step.name)) fail(`duplicate or empty step name ${step.name}`);
|
if (!step.name || outputs.has(step.name)) fail(`duplicate or empty step name ${step.name}`);
|
||||||
@@ -137,3 +109,49 @@ export function compileQueryComposition(
|
|||||||
};
|
};
|
||||||
return { ...result, definitionDigest: createHash("sha256").update(canonicalJson(result)).digest("hex") };
|
return { ...result, definitionDigest: createHash("sha256").update(canonicalJson(result)).digest("hex") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function queryBindingType(
|
||||||
|
expression: QueryBinding,
|
||||||
|
input: ValueType,
|
||||||
|
outputs: ReadonlyMap<string, ValueType>,
|
||||||
|
item?: ValueType,
|
||||||
|
): ValueType {
|
||||||
|
const fail = (message: string): never => {
|
||||||
|
throw new QueryCompileError("QUERY_COMPOSITION", message);
|
||||||
|
};
|
||||||
|
const binding = (expression: QueryBinding, item?: ValueType) => queryBindingType(expression, input, outputs, item);
|
||||||
|
const equal = (a: ValueType, b: ValueType) => canonicalJson(a) === canonicalJson(b);
|
||||||
|
if ("flatMap" in expression) {
|
||||||
|
const rows = binding(expression.flatMap.source, item);
|
||||||
|
if (rows.kind !== "list") return fail("flatMap requires a list");
|
||||||
|
const children = binding({ from: "item", path: expression.flatMap.path }, rows.value);
|
||||||
|
if (children.kind !== "list") return fail("flatMap path requires a list");
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
if ("literal" in expression)
|
||||||
|
return { kind: "scalar", name: typeof expression.literal === "string" ? "string" : "bool" };
|
||||||
|
if ("equal" in expression) {
|
||||||
|
const left = binding(expression.equal[0], item);
|
||||||
|
const right = binding(expression.equal[1], item);
|
||||||
|
if (left.kind !== "scalar" || !equal(left, right)) fail("equal requires matching scalar operands");
|
||||||
|
return { kind: "scalar", name: "bool" };
|
||||||
|
}
|
||||||
|
if ("record" in expression)
|
||||||
|
return {
|
||||||
|
kind: "record",
|
||||||
|
fields: Object.fromEntries(Object.entries(expression.record).map(([key, value]) => [key, binding(value, item)])),
|
||||||
|
};
|
||||||
|
let type = expression.from === "input" ? input : expression.from === "item" ? item : outputs.get(expression.name);
|
||||||
|
if (!type)
|
||||||
|
return fail(
|
||||||
|
expression.from === "result"
|
||||||
|
? `result ${expression.name} must name an earlier step (forward references and cycles are unsupported)`
|
||||||
|
: "item is only available within a bounded each step",
|
||||||
|
);
|
||||||
|
for (const key of expression.path) {
|
||||||
|
if (type.kind !== "record" || !Object.hasOwn(type.fields, key))
|
||||||
|
return fail(`cannot select ${key} from ${type.kind}; nulls and collections need an explicit boundary`);
|
||||||
|
type = type.fields[key]!;
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { InterfaceRevision, PackageRevision, ValueType } from "../capability-model/types.js";
|
||||||
|
import type { QueryBinding, QueryComposition, QueryCompositionStep } from "../query/composition.js";
|
||||||
|
|
||||||
|
/** This is an artifact of the explicitly selected React frontend, not QX IR. */
|
||||||
|
export const preparationArtifact = "org.quixos.react.preparation/1";
|
||||||
|
export interface ReactChildDeclaration {
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
root: QueryBinding;
|
||||||
|
slot: string;
|
||||||
|
with?: QueryBinding;
|
||||||
|
each?: { source: QueryBinding; limit: number };
|
||||||
|
when?: QueryBinding;
|
||||||
|
role?: { interface: string; member: string };
|
||||||
|
provider?: { package: string; module: string };
|
||||||
|
}
|
||||||
|
export interface ReactModulePreparation {
|
||||||
|
module: string;
|
||||||
|
composition: string;
|
||||||
|
discoveryComposition?: string;
|
||||||
|
children: ReactChildDeclaration[];
|
||||||
|
}
|
||||||
|
export interface ReactPreparationArtifact {
|
||||||
|
modules: ReactModulePreparation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names are package imports/module declarations. All query bindings are passed
|
||||||
|
* to Camino's ordinary composition checker after generated queries are checked. */
|
||||||
|
export function presentationDeclarations(
|
||||||
|
text: string,
|
||||||
|
pkg: PackageRevision,
|
||||||
|
interfaces: readonly InterfaceRevision[],
|
||||||
|
): { compositions: QueryComposition[]; artifact: ReactPreparationArtifact } {
|
||||||
|
const source: unknown = JSON.parse(text);
|
||||||
|
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label}: expected an object`);
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const top = object(source, "quixos.react.json");
|
||||||
|
if (Object.keys(top).some((key) => key !== "modules")) throw Error("quixos.react.json: unknown field");
|
||||||
|
const modules = object(top.modules, "modules");
|
||||||
|
const compositions: QueryComposition[] = [];
|
||||||
|
const artifact: ReactPreparationArtifact = { modules: [] };
|
||||||
|
const imported = (name: string) => {
|
||||||
|
const id = pkg.clientInterfaces?.[name];
|
||||||
|
if (!id) throw Error(`React declaration: unknown imported interface ${name}`);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
for (const [name, value] of Object.entries(modules)) {
|
||||||
|
const declaration = object(value, `module ${name}`);
|
||||||
|
if (Object.keys(declaration).some((key) => !["inputs", "children"].includes(key)))
|
||||||
|
throw Error(`${name}: unknown preparation field`);
|
||||||
|
const module = pkg.modules?.find((entry) => entry.displayName === name);
|
||||||
|
if (!module) throw Error(`React declaration: unknown module ${name}`);
|
||||||
|
const contract = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId);
|
||||||
|
const member = contract?.members.find((entry) => entry.id === module.memberId);
|
||||||
|
if (
|
||||||
|
member?.kind !== "module" ||
|
||||||
|
member.subject?.kind !== "interface" ||
|
||||||
|
!["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes(
|
||||||
|
member.contract,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw Error(`${name}: preparation requires a React module contract`);
|
||||||
|
const fields: Record<string, ValueType> = {
|
||||||
|
subject: {
|
||||||
|
kind: "object-ref",
|
||||||
|
expectation: { kind: "interface", interfaceRevisionId: member.subject.interfaceRevisionId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (member.contract === "org.quixos.react.component/2") {
|
||||||
|
const state = contract?.application?.arguments[1];
|
||||||
|
if (state?.kind !== "object" || state.target.kind !== "interface")
|
||||||
|
throw Error(`${name}: expected a closed state interface`);
|
||||||
|
fields.navigation = {
|
||||||
|
kind: "object-ref",
|
||||||
|
expectation: { kind: "interface", interfaceRevisionId: state.target.interfaceRevisionId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!Array.isArray(declaration.inputs)) throw Error(`${name}: inputs must be an array of query bindings`);
|
||||||
|
const steps = declaration.inputs.map((value) => {
|
||||||
|
const step = object(value, `${name} input`) as unknown as QueryCompositionStep;
|
||||||
|
if (step.name === "children") throw Error(`${name}: children is reserved for prepared child inputs`);
|
||||||
|
const query = pkg.queries?.find((entry) => entry.displayName === step.query);
|
||||||
|
if (!query) throw Error(`${name}.${step.name}: unknown query ${step.query}`);
|
||||||
|
if (query.placement?.kind !== "local" && !query.fragment)
|
||||||
|
throw Error(`${name}.${step.name}: requires a local projection or fragment`);
|
||||||
|
return { ...step, query: query.id };
|
||||||
|
});
|
||||||
|
const composition = `react:inputs:${module.id}`;
|
||||||
|
compositions.push({
|
||||||
|
id: composition,
|
||||||
|
input: { kind: "record", fields },
|
||||||
|
steps,
|
||||||
|
maxQueries: steps.reduce((count, step) => count + (step.each?.limit ?? 1), 0) || 1,
|
||||||
|
});
|
||||||
|
const children = declaration.children ?? [];
|
||||||
|
if (!Array.isArray(children)) throw Error(`${name}: children must be an array`);
|
||||||
|
const names = new Set<string>();
|
||||||
|
const parsed = children.map((value) => {
|
||||||
|
const child = object(value, `${name} child`) as unknown as ReactChildDeclaration;
|
||||||
|
if (!child.name || names.has(child.name) || !child.slot || !child.root)
|
||||||
|
throw Error(`${name}: child requires a unique name, slot and root`);
|
||||||
|
names.add(child.name);
|
||||||
|
if (child.each && child.with) throw Error(`${name}.${child.name}: cannot combine each and with`);
|
||||||
|
if (child.each && (!Number.isSafeInteger(child.each.limit) || child.each.limit < 1))
|
||||||
|
throw Error(`${name}.${child.name}: finite positive collection limit required`);
|
||||||
|
return {
|
||||||
|
...child,
|
||||||
|
subject: imported(child.subject),
|
||||||
|
...(child.role ? { role: { ...child.role, interface: imported(child.role.interface) } } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
artifact.modules.push({ module: module.id, composition, children: parsed });
|
||||||
|
}
|
||||||
|
for (const module of pkg.modules ?? []) {
|
||||||
|
const contract = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId);
|
||||||
|
const member = contract?.members.find((entry) => entry.id === module.memberId);
|
||||||
|
if (
|
||||||
|
member?.kind === "module" &&
|
||||||
|
member.contract.startsWith("org.quixos.react.") &&
|
||||||
|
!artifact.modules.some((entry) => entry.module === module.id)
|
||||||
|
)
|
||||||
|
throw Error(`React module ${module.displayName} has no preparation declaration`);
|
||||||
|
}
|
||||||
|
return { compositions, artifact };
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
parse,
|
||||||
|
print,
|
||||||
|
visit,
|
||||||
|
Kind,
|
||||||
|
type SelectionSetNode,
|
||||||
|
type FragmentDefinitionNode,
|
||||||
|
type SelectionNode,
|
||||||
|
type DocumentNode,
|
||||||
|
} from "graphql";
|
||||||
|
import type { PackageRevision, WorkspaceRevision } from "../capability-model/types.js";
|
||||||
|
import { compileQuery } from "../query/compile.js";
|
||||||
|
import { compileQueryComposition, type QueryBinding } from "../query/composition.js";
|
||||||
|
import type { CheckedQuery, QueryDeclaration } from "../query/types.js";
|
||||||
|
import { preparationArtifact, type ReactModulePreparation, type ReactPreparationArtifact } from "./declarations.js";
|
||||||
|
|
||||||
|
interface Branch {
|
||||||
|
packageRevision: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
type Child = ReactModulePreparation["children"][number] & { branches: Branch[] };
|
||||||
|
interface Module {
|
||||||
|
pkg: PackageRevision;
|
||||||
|
preparation: ReactModulePreparation;
|
||||||
|
}
|
||||||
|
const identity = (pkg: string, id: string) => JSON.stringify([pkg, id]);
|
||||||
|
const suffix = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 20);
|
||||||
|
|
||||||
|
/** Lower bounded, statically bound subject projections into ordinary fragments.
|
||||||
|
* The generated query still passes the normal GraphQL/QX checker. Stateful
|
||||||
|
* lookups remain independent ordinary root-scoped queries owned by the adapter. */
|
||||||
|
export async function expandPresentationQueries(workspace: WorkspaceRevision): Promise<void> {
|
||||||
|
const modules = new Map<string, Module>();
|
||||||
|
for (const pkg of workspace.packageImports) {
|
||||||
|
const artifact = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined;
|
||||||
|
for (const preparation of artifact?.modules ?? [])
|
||||||
|
modules.set(identity(pkg.revisionId, preparation.module), { pkg, preparation });
|
||||||
|
}
|
||||||
|
const query = (pkg: string, id: string) => {
|
||||||
|
const result = workspace.packageImports
|
||||||
|
.find((entry) => entry.revisionId === pkg)
|
||||||
|
?.checkedQueries?.find((entry) => entry.declaration.id === id);
|
||||||
|
if (!result) throw Error(`Missing checked preparation query ${pkg}/${id}`);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const resolver = async (pkg: string, id: string) => query(pkg, id);
|
||||||
|
const install = async (pkg: PackageRevision, declaration: QueryDeclaration, document: string) => {
|
||||||
|
const views = new Map(declaration.views.map((view) => [view.atomId, view]));
|
||||||
|
const allowances = [...declaration.allowances];
|
||||||
|
for (const imported of declaration.importedFragments ?? []) {
|
||||||
|
const dependency = query(imported.packageRevisionId, imported.fragmentId).declaration;
|
||||||
|
for (const view of dependency.views) {
|
||||||
|
const previous = views.get(view.atomId);
|
||||||
|
if (previous && previous.interfaceRevisionId !== view.interfaceRevisionId)
|
||||||
|
throw Error(`${declaration.displayName}: conflicting interface views for ${view.atomId}`);
|
||||||
|
views.set(view.atomId, view);
|
||||||
|
}
|
||||||
|
for (const allowance of dependency.allowances)
|
||||||
|
if (!allowances.some((entry) => JSON.stringify(entry) === JSON.stringify(allowance)))
|
||||||
|
allowances.push(allowance);
|
||||||
|
}
|
||||||
|
declaration = { ...declaration, views: [...views.values()], allowances };
|
||||||
|
const checked = await compileQuery(declaration, workspace.interfaceImports, async () => document, resolver);
|
||||||
|
pkg.queries = [...(pkg.queries ?? []).filter((entry) => entry.id !== declaration.id), declaration];
|
||||||
|
pkg.checkedQueries = [
|
||||||
|
...(pkg.checkedQueries ?? []).filter((entry) => entry.declaration.id !== declaration.id),
|
||||||
|
checked,
|
||||||
|
];
|
||||||
|
return checked;
|
||||||
|
};
|
||||||
|
function expanded(source: CheckedQuery): SelectionSetNode {
|
||||||
|
const document = parse(source.document);
|
||||||
|
const fragments = new Map(
|
||||||
|
document.definitions
|
||||||
|
.filter((entry): entry is FragmentDefinitionNode => entry.kind === Kind.FRAGMENT_DEFINITION)
|
||||||
|
.map((entry) => [entry.name.value, entry]),
|
||||||
|
);
|
||||||
|
const operation = document.definitions.find((entry) => entry.kind === Kind.OPERATION_DEFINITION);
|
||||||
|
if (!operation || operation.kind !== Kind.OPERATION_DEFINITION)
|
||||||
|
throw Error(`Missing query operation ${source.declaration.id}`);
|
||||||
|
const expand = (selection: SelectionSetNode, parents: string[] = []): SelectionSetNode => ({
|
||||||
|
...selection,
|
||||||
|
selections: selection.selections.flatMap((entry): SelectionNode[] => {
|
||||||
|
if (entry.kind === Kind.FRAGMENT_SPREAD) {
|
||||||
|
if (parents.includes(entry.name.value)) throw Error("Cyclic query fragment");
|
||||||
|
const fragment = fragments.get(entry.name.value);
|
||||||
|
if (!fragment) throw Error(`Missing query fragment ${entry.name.value}`);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: Kind.INLINE_FRAGMENT,
|
||||||
|
typeCondition: fragment.typeCondition,
|
||||||
|
directives: entry.directives ?? [],
|
||||||
|
selectionSet: expand(fragment.selectionSet, [...parents, entry.name.value]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [{ ...entry, ...(entry.selectionSet ? { selectionSet: expand(entry.selectionSet, parents) } : {}) }];
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return expand(operation.selectionSet);
|
||||||
|
}
|
||||||
|
function insert(selection: SelectionSetNode, path: readonly string[], spread: string): SelectionSetNode {
|
||||||
|
if (!path.length)
|
||||||
|
return {
|
||||||
|
...selection,
|
||||||
|
selections: [...selection.selections, { kind: Kind.FRAGMENT_SPREAD, name: { kind: Kind.NAME, value: spread } }],
|
||||||
|
};
|
||||||
|
let found = false;
|
||||||
|
const selections = selection.selections.map((entry): SelectionNode => {
|
||||||
|
if (entry.kind === Kind.INLINE_FRAGMENT) {
|
||||||
|
try {
|
||||||
|
const nested = insert(entry.selectionSet, path, spread);
|
||||||
|
found = true;
|
||||||
|
return { ...entry, selectionSet: nested };
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error) || error.message !== "REACT_SELECTION_PATH") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.kind === Kind.FIELD && (entry.alias?.value ?? entry.name.value) === path[0] && entry.selectionSet) {
|
||||||
|
found = true;
|
||||||
|
return { ...entry, selectionSet: insert(entry.selectionSet, path.slice(1), spread) };
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
if (!found) throw Error("REACT_SELECTION_PATH");
|
||||||
|
return { ...selection, selections };
|
||||||
|
}
|
||||||
|
function location(binding: QueryBinding): { name: string; path: string[] } | undefined {
|
||||||
|
if ("from" in binding && binding.from === "result") return { name: binding.name, path: [...binding.path] };
|
||||||
|
if ("flatMap" in binding) {
|
||||||
|
const root = location(binding.flatMap.source);
|
||||||
|
return root ? { name: root.name, path: [...root.path, ...binding.flatMap.path] } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const done = new Set<string>();
|
||||||
|
const eager = new Map<string, { step: string; query: CheckedQuery }[]>();
|
||||||
|
const admissionFragments = new Map<string, CheckedQuery>();
|
||||||
|
const stateInputs = new Map<string, { pkg: PackageRevision; query: CheckedQuery }[]>();
|
||||||
|
async function expandModule(key: string): Promise<void> {
|
||||||
|
if (done.has(key)) return;
|
||||||
|
const module = modules.get(key);
|
||||||
|
if (!module) throw Error(`Missing preparation module ${key}`);
|
||||||
|
const { pkg, preparation } = module;
|
||||||
|
const children = preparation.children as Child[];
|
||||||
|
for (const child of children)
|
||||||
|
for (const branch of child.branches) await expandModule(identity(branch.packageRevision, branch.id));
|
||||||
|
const composition = pkg.queryCompositions!.find((entry) => entry.id === preparation.composition)!;
|
||||||
|
const discovery = { ...composition, id: composition.id + ":discovery" };
|
||||||
|
preparation.discoveryComposition = discovery.id;
|
||||||
|
pkg.queryCompositions!.push(discovery);
|
||||||
|
const steps = structuredClone(composition.steps);
|
||||||
|
const exported: { step: string; query: CheckedQuery }[] = [];
|
||||||
|
for (const step of steps) {
|
||||||
|
const original = query(pkg.revisionId, step.query);
|
||||||
|
// Variable roots/arguments remain checked dependent steps. Constants can
|
||||||
|
// be substituted without requesting new runtime parameters from a parent.
|
||||||
|
if (!("record" in step.arguments) || Object.values(step.arguments.record).some((value) => !("literal" in value)))
|
||||||
|
continue;
|
||||||
|
const constants = step.arguments.record;
|
||||||
|
let selection = expanded(original);
|
||||||
|
if (original.declaration.placement?.kind !== "local")
|
||||||
|
throw Error(key + "." + step.name + ": expected checked local input");
|
||||||
|
const source = query(pkg.revisionId, original.declaration.placement.sourceQueryId);
|
||||||
|
let admissionSelection = expanded(source);
|
||||||
|
const admissionImports: NonNullable<QueryDeclaration["importedFragments"]> = [];
|
||||||
|
const imports: NonNullable<QueryDeclaration["importedFragments"]> = [];
|
||||||
|
for (const child of children) {
|
||||||
|
const selected = child.with
|
||||||
|
? location(child.with)
|
||||||
|
: child.each
|
||||||
|
? location(child.each.source)
|
||||||
|
: location(child.root);
|
||||||
|
if (!selected || selected.name !== step.name) continue;
|
||||||
|
const rootPath = "from" in child.root && child.root.from === "item" ? child.root.path : [];
|
||||||
|
const path = [...selected.path, ...rootPath];
|
||||||
|
if (path.slice(-2).join(".") !== "_camino.ref") {
|
||||||
|
if (!rootPath.length && (child.with || child.each))
|
||||||
|
throw Error(`${key}.${child.name}: child root must select the item's reference`);
|
||||||
|
} else path.splice(-2);
|
||||||
|
for (const branch of child.branches) {
|
||||||
|
for (const input of eager.get(identity(branch.packageRevision, branch.id)) ?? []) {
|
||||||
|
const declaration = input.query.declaration;
|
||||||
|
if (
|
||||||
|
!imports.some(
|
||||||
|
(entry) => entry.packageRevisionId === branch.packageRevision && entry.fragmentId === declaration.id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
imports.push({ packageRevisionId: branch.packageRevision, fragmentId: declaration.id, variables: {} });
|
||||||
|
}
|
||||||
|
selection = insert(selection, path, declaration.fragment!.name);
|
||||||
|
const admission = admissionFragments.get(identity(branch.packageRevision, declaration.id))!;
|
||||||
|
if (
|
||||||
|
!admissionImports.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.packageRevisionId === branch.packageRevision && entry.fragmentId === admission.declaration.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
admissionImports.push({
|
||||||
|
packageRevisionId: branch.packageRevision,
|
||||||
|
fragmentId: admission.declaration.id,
|
||||||
|
variables: {},
|
||||||
|
});
|
||||||
|
admissionSelection = insert(admissionSelection, path, admission.declaration.fragment!.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const token = suffix(identity(pkg.revisionId, `${preparation.module}/${step.name}`));
|
||||||
|
const name = `ReactInput${token}`,
|
||||||
|
id = `react:input:${preparation.module}:${step.name}`;
|
||||||
|
const base: QueryDeclaration = {
|
||||||
|
...original.declaration,
|
||||||
|
id,
|
||||||
|
displayName: name,
|
||||||
|
document: `generated/react/${name}.graphql`,
|
||||||
|
operation: name,
|
||||||
|
fragments: [],
|
||||||
|
importedFragments: imports,
|
||||||
|
watch: true,
|
||||||
|
budgets: {
|
||||||
|
...original.declaration.budgets,
|
||||||
|
depth: Math.max(original.declaration.budgets.depth, 32),
|
||||||
|
rows: Math.max(original.declaration.budgets.rows, 5000),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
delete base.fragment;
|
||||||
|
function documentFor(selection: SelectionSetNode, fragmentName: string): DocumentNode {
|
||||||
|
const substituted = visit(selection, {
|
||||||
|
Variable(node) {
|
||||||
|
const constant = constants[node.name.value];
|
||||||
|
if (!constant || !("literal" in constant))
|
||||||
|
throw Error(key + "." + step.name + ": unbound query variable " + node.name.value);
|
||||||
|
return typeof constant.literal === "boolean"
|
||||||
|
? { kind: Kind.BOOLEAN, value: constant.literal }
|
||||||
|
: { kind: Kind.STRING, value: constant.literal };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (original.declaration.root.kind === "object") {
|
||||||
|
const root = substituted.selections.find((entry) => entry.kind === Kind.FIELD && entry.name.value === "root");
|
||||||
|
if (!root || root.kind !== Kind.FIELD || !root.selectionSet)
|
||||||
|
throw Error(key + ": expected object-rooted query");
|
||||||
|
const contract = workspace.interfaceImports.find(
|
||||||
|
(entry) => entry.revisionId === original.declaration.root.interface,
|
||||||
|
)!;
|
||||||
|
return {
|
||||||
|
kind: Kind.DOCUMENT,
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: Kind.FRAGMENT_DEFINITION,
|
||||||
|
name: { kind: Kind.NAME, value: fragmentName },
|
||||||
|
typeCondition: { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: contract.displayName } },
|
||||||
|
selectionSet: root.selectionSet,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: Kind.DOCUMENT,
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: Kind.OPERATION_DEFINITION,
|
||||||
|
operation: "query" as import("graphql").OperationTypeNode,
|
||||||
|
name: { kind: Kind.NAME, value: name },
|
||||||
|
selectionSet: substituted,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (original.declaration.root.kind === "object") base.fragment = { name };
|
||||||
|
const sourceId = id + ":fragment-inputs";
|
||||||
|
const admissionName = name + "Admission";
|
||||||
|
const admissionBase: QueryDeclaration = {
|
||||||
|
...base,
|
||||||
|
views: source.declaration.views,
|
||||||
|
allowances: source.declaration.allowances,
|
||||||
|
importedFragments: admissionImports,
|
||||||
|
...(base.fragment ? { fragment: { name: admissionName } } : {}),
|
||||||
|
};
|
||||||
|
const admissionDocument = documentFor(admissionSelection, admissionName);
|
||||||
|
await install(
|
||||||
|
pkg,
|
||||||
|
{
|
||||||
|
...admissionBase,
|
||||||
|
id: sourceId,
|
||||||
|
displayName: name + "$inputs",
|
||||||
|
placement: { kind: "replica" },
|
||||||
|
watch: false,
|
||||||
|
...(base.fragment ? { fragment: { name: admissionName, admission: true } } : {}),
|
||||||
|
},
|
||||||
|
print(admissionDocument),
|
||||||
|
);
|
||||||
|
const admissionFragment = await install(
|
||||||
|
pkg,
|
||||||
|
{
|
||||||
|
...admissionBase,
|
||||||
|
id: id + ":admission-projection",
|
||||||
|
displayName: name + "$admission",
|
||||||
|
placement: { kind: "local", sourceQueryId: sourceId },
|
||||||
|
},
|
||||||
|
print(admissionDocument),
|
||||||
|
);
|
||||||
|
const local = await install(
|
||||||
|
pkg,
|
||||||
|
{ ...base, placement: { kind: "local", sourceQueryId: sourceId } },
|
||||||
|
print(documentFor(selection, name)),
|
||||||
|
);
|
||||||
|
admissionFragments.set(identity(pkg.revisionId, local.declaration.id), admissionFragment);
|
||||||
|
step.query = local.declaration.id;
|
||||||
|
step.arguments = { record: {} };
|
||||||
|
if (
|
||||||
|
local.declaration.fragment &&
|
||||||
|
step.root &&
|
||||||
|
"from" in step.root &&
|
||||||
|
step.root.from === "input" &&
|
||||||
|
step.root.path.join(".") === "subject"
|
||||||
|
)
|
||||||
|
exported.push({ step: step.name, query: local });
|
||||||
|
if (
|
||||||
|
local.declaration.fragment &&
|
||||||
|
step.root &&
|
||||||
|
"from" in step.root &&
|
||||||
|
step.root.from === "input" &&
|
||||||
|
step.root.path.join(".") === "navigation"
|
||||||
|
) {
|
||||||
|
const iface = local.declaration.root.interface;
|
||||||
|
stateInputs.set(iface, [...(stateInputs.get(iface) ?? []), { pkg, query: local }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const available = new Map(
|
||||||
|
workspace.packageImports.flatMap((pkg) =>
|
||||||
|
(pkg.checkedQueries ?? []).map((query) => [identity(pkg.revisionId, query.declaration.id), query] as const),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const query of pkg.checkedQueries ?? []) available.set(query.declaration.id, query);
|
||||||
|
const checked = compileQueryComposition(
|
||||||
|
{ id: composition.id, input: composition.input, steps, maxQueries: composition.maxQueries },
|
||||||
|
available,
|
||||||
|
);
|
||||||
|
const checkedDiscovery = compileQueryComposition(
|
||||||
|
{ id: discovery.id, input: discovery.input, steps: discovery.steps, maxQueries: discovery.maxQueries },
|
||||||
|
available,
|
||||||
|
);
|
||||||
|
pkg.queryCompositions = pkg.queryCompositions!.map((entry) =>
|
||||||
|
entry.id === composition.id ? checked : entry.id === discovery.id ? checkedDiscovery : entry,
|
||||||
|
);
|
||||||
|
eager.set(key, exported);
|
||||||
|
done.add(key);
|
||||||
|
}
|
||||||
|
for (const key of modules.keys()) await expandModule(key);
|
||||||
|
// Retention queries are ordinary checked queries too. They carry the declared
|
||||||
|
// state projections, including any subject fragments reached through state.
|
||||||
|
// This is specific to this frontend; no navigation operator enters Camino.
|
||||||
|
for (const pkg of workspace.packageImports) {
|
||||||
|
for (const original of [...(pkg.checkedQueries ?? [])]) {
|
||||||
|
if (!original.declaration.id.startsWith("presentation:navigation:")) continue;
|
||||||
|
const inputs = (stateInputs.get(original.declaration.root.interface) ?? []).map((input) => ({
|
||||||
|
...input,
|
||||||
|
query:
|
||||||
|
original.declaration.placement?.kind === "replica"
|
||||||
|
? admissionFragments.get(identity(input.pkg.revisionId, input.query.declaration.id))!
|
||||||
|
: input.query,
|
||||||
|
}));
|
||||||
|
if (!inputs.length) continue;
|
||||||
|
const document = parse(original.document);
|
||||||
|
const collection = original.declaration.placement?.kind === "local" ? "availableObjects" : "objects";
|
||||||
|
const definitions = document.definitions.map((definition) => {
|
||||||
|
if (definition.kind !== Kind.OPERATION_DEFINITION) return definition;
|
||||||
|
return visit(definition, {
|
||||||
|
Field(node) {
|
||||||
|
if (node.name.value !== "root" || !node.selectionSet) return;
|
||||||
|
const source = node.selectionSet.selections.find(
|
||||||
|
(entry) => entry.kind === Kind.FIELD && entry.name.value === collection,
|
||||||
|
);
|
||||||
|
if (!source || source.kind !== Kind.FIELD) throw Error("Missing navigation collection");
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
selectionSet: {
|
||||||
|
...node.selectionSet,
|
||||||
|
selections: [
|
||||||
|
...node.selectionSet.selections,
|
||||||
|
...inputs.map((input) => ({
|
||||||
|
...source,
|
||||||
|
alias: {
|
||||||
|
kind: Kind.NAME as const,
|
||||||
|
value: `prepared${suffix(identity(input.pkg.revisionId, input.query.declaration.id))}`,
|
||||||
|
},
|
||||||
|
selectionSet: parse(
|
||||||
|
`{ entries { node { ...${input.query.declaration.fragment!.name} } } }`,
|
||||||
|
).definitions.flatMap((entry) =>
|
||||||
|
entry.kind === Kind.OPERATION_DEFINITION ? [entry.selectionSet] : [],
|
||||||
|
)[0]!,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await install(
|
||||||
|
pkg,
|
||||||
|
{
|
||||||
|
...original.declaration,
|
||||||
|
...(original.declaration.placement?.kind === "local"
|
||||||
|
? {
|
||||||
|
id: original.declaration.id.replace(/:view$/, ":prepared-view"),
|
||||||
|
displayName: original.declaration.displayName + "Prepared",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
importedFragments: inputs.map((input) => ({
|
||||||
|
packageRevisionId: input.pkg.revisionId,
|
||||||
|
fragmentId: input.query.declaration.id,
|
||||||
|
variables: {},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
print({ ...document, definitions }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { PackageQueryGenerator } from "../capability-language/assembly.js";
|
import type { PackageQueryGenerator } from "../capability-language/assembly.js";
|
||||||
import { presentationQueries } from "../bindings/presentation-queries.js";
|
import { presentationQueries } from "../bindings/presentation-queries.js";
|
||||||
|
import { presentationDeclarations, preparationArtifact } from "./declarations.js";
|
||||||
|
export { completeWorkspace } from "./workspace.js";
|
||||||
|
|
||||||
/** Selected explicitly by the React build. Its output is checked as ordinary
|
/** Selected explicitly by the React build. Its output is checked as ordinary
|
||||||
* Camino queries; no React identifiers are interpreted by package assembly. */
|
* Camino queries; no React identifiers are interpreted by package assembly. */
|
||||||
@@ -7,5 +9,22 @@ export const generatePackageQueries: PackageQueryGenerator = async (input) => {
|
|||||||
const pkg = structuredClone(input.package);
|
const pkg = structuredClone(input.package);
|
||||||
const before = new Set(pkg.queries?.map((query) => query.id));
|
const before = new Set(pkg.queries?.map((query) => query.id));
|
||||||
const documents = presentationQueries(pkg, input.interfaces);
|
const documents = presentationQueries(pkg, input.interfaces);
|
||||||
return { queries: (pkg.queries ?? []).filter((query) => !before.has(query.id)), documents };
|
const reactModules = (pkg.modules ?? []).filter((module) =>
|
||||||
|
input.interfaces
|
||||||
|
.find((entry) => entry.revisionId === module.interfaceRevisionId)
|
||||||
|
?.members.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.id === module.memberId && entry.kind === "module" && entry.contract.startsWith("org.quixos.react."),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const declarations = reactModules.length
|
||||||
|
? presentationDeclarations(await input.readSource("quixos.react.json"), pkg, input.interfaces)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
queries: (pkg.queries ?? []).filter((query) => !before.has(query.id)),
|
||||||
|
documents,
|
||||||
|
...(declarations
|
||||||
|
? { compositions: declarations.compositions, artifacts: { [preparationArtifact]: declarations.artifact } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { WorkspaceRevision, ValueType } from "../capability-model/types.js";
|
||||||
|
import { moduleContract } from "../capability-model/modules.js";
|
||||||
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||||||
|
import { queryBindingType } from "../query/composition.js";
|
||||||
|
import { moduleTypeContext } from "../execution-world/module-contracts.js";
|
||||||
|
import { preparationArtifact, type ReactPreparationArtifact } from "./declarations.js";
|
||||||
|
import { expandPresentationQueries } from "./expansion.js";
|
||||||
|
|
||||||
|
/** Explicit frontend stage. Resolve finite provider branches after workspace
|
||||||
|
* bindings exist; the generic workspace compiler does not invoke this itself. */
|
||||||
|
export async function completeWorkspace(workspace: WorkspaceRevision): Promise<void> {
|
||||||
|
const modules = workspace.packageImports.flatMap((pkg) => {
|
||||||
|
const artifact = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined;
|
||||||
|
return (artifact?.modules ?? []).map((preparation) => {
|
||||||
|
const module = pkg.modules?.find((entry) => entry.id === preparation.module);
|
||||||
|
if (!module) throw Error(`React preparation has no module ${preparation.module}`);
|
||||||
|
const contract = moduleContract(workspace.interfaceImports, module)!;
|
||||||
|
return { pkg, module, contract, preparation };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const graph = new Map<string, string[]>();
|
||||||
|
const key = (pkg: string, id: string) => JSON.stringify([pkg, id]);
|
||||||
|
for (const parent of modules) {
|
||||||
|
const composition = parent.pkg.queryCompositions?.find((entry) => entry.id === parent.preparation.composition);
|
||||||
|
if (!composition || composition.output.kind !== "record")
|
||||||
|
throw Error(`Missing checked inputs for ${parent.module.displayName}`);
|
||||||
|
const outputs = new Map(Object.entries(composition.output.fields));
|
||||||
|
const dependencies: string[] = [];
|
||||||
|
const children = parent.preparation.children.map((child) => {
|
||||||
|
const at = `${parent.module.displayName}.${child.name}`;
|
||||||
|
let item: ValueType | undefined;
|
||||||
|
if (child.with) {
|
||||||
|
const type = queryBindingType(child.with, composition.input, outputs);
|
||||||
|
item = type.kind === "optional" ? type.value : type;
|
||||||
|
}
|
||||||
|
if (child.each) {
|
||||||
|
const type = queryBindingType(child.each.source, composition.input, outputs);
|
||||||
|
if (type.kind !== "list") throw Error(`${at}: each requires a list`);
|
||||||
|
item = type.value;
|
||||||
|
}
|
||||||
|
const root = queryBindingType(child.root, composition.input, outputs, item);
|
||||||
|
if (
|
||||||
|
root.kind !== "object-ref" ||
|
||||||
|
root.expectation.kind !== "interface" ||
|
||||||
|
root.expectation.interfaceRevisionId !== child.subject
|
||||||
|
)
|
||||||
|
throw Error(`${at}: child root must be a ${child.subject} reference; unwrap nulls and lists explicitly`);
|
||||||
|
if (
|
||||||
|
child.when &&
|
||||||
|
canonicalJson(queryBindingType(child.when, composition.input, outputs, item)) !==
|
||||||
|
canonicalJson({ kind: "scalar", name: "bool" })
|
||||||
|
)
|
||||||
|
throw Error(`${at}: when requires a boolean`);
|
||||||
|
const atoms = workspace.atoms.filter((atom) =>
|
||||||
|
workspace.conformances.some((entry) => entry.atomId === atom.id && entry.interfaceRevisionId === child.subject),
|
||||||
|
);
|
||||||
|
if (!atoms.length) throw Error(`${at}: no installed subject atoms`);
|
||||||
|
const branches = atoms.map((atom) => {
|
||||||
|
let candidates = modules.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.contract.subject?.kind === "interface" &&
|
||||||
|
entry.contract.subject.interfaceRevisionId === child.subject,
|
||||||
|
);
|
||||||
|
if (child.role) {
|
||||||
|
const iface = workspace.interfaceImports.find((entry) => entry.revisionId === child.role!.interface);
|
||||||
|
const member = iface?.members.find(
|
||||||
|
(entry) => entry.displayName === child.role!.member || entry.id === child.role!.member,
|
||||||
|
);
|
||||||
|
if (member?.kind !== "module") throw Error(`${at}: unknown rendering role`);
|
||||||
|
if (
|
||||||
|
!workspace.conformances.some(
|
||||||
|
(entry) => entry.atomId === atom.id && entry.interfaceRevisionId === iface!.revisionId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw Error(`${at}: ${atom.id} does not implement the requested role`);
|
||||||
|
candidates = candidates.filter(
|
||||||
|
(entry) => entry.module.interfaceRevisionId === iface!.revisionId && entry.module.memberId === member.id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
candidates = candidates.filter((entry) =>
|
||||||
|
["org.quixos.react.subject-only/1", "org.quixos.react.component/2"].includes(entry.contract.contract),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (child.provider) {
|
||||||
|
const pkg = parent.pkg.clientPackages?.[child.provider.package];
|
||||||
|
if (!pkg) throw Error(`${at}: provider package is not imported`);
|
||||||
|
candidates = candidates.filter(
|
||||||
|
(entry) => entry.pkg.revisionId === pkg && entry.module.displayName === child.provider!.module,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const bound = candidates.filter((entry) =>
|
||||||
|
workspace.conformances.some(
|
||||||
|
(conformance) =>
|
||||||
|
conformance.atomId === atom.id &&
|
||||||
|
conformance.interfaceRevisionId === entry.module.interfaceRevisionId &&
|
||||||
|
conformance.moduleBindings?.some(
|
||||||
|
(binding) =>
|
||||||
|
binding.memberId === entry.module.memberId &&
|
||||||
|
binding.packageRevisionId === entry.pkg.revisionId &&
|
||||||
|
binding.moduleId === entry.module.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
candidates = bound.length
|
||||||
|
? bound
|
||||||
|
: child.role
|
||||||
|
? []
|
||||||
|
: candidates.filter((entry) =>
|
||||||
|
workspace.presentations?.some(
|
||||||
|
(presentation) =>
|
||||||
|
presentation.subject === child.subject &&
|
||||||
|
presentation.packageRevisionId === entry.pkg.revisionId &&
|
||||||
|
presentation.componentId === entry.module.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (candidates.length !== 1)
|
||||||
|
throw Error(`${at}: ${atom.id} has ${candidates.length} compatible providers; select one explicitly`);
|
||||||
|
const selected = candidates[0]!;
|
||||||
|
dependencies.push(key(selected.pkg.revisionId, selected.module.id));
|
||||||
|
return {
|
||||||
|
atom: atom.id,
|
||||||
|
packageRevision: selected.pkg.revisionId,
|
||||||
|
id: selected.module.id,
|
||||||
|
role: {
|
||||||
|
interface: selected.module.interfaceRevisionId,
|
||||||
|
member: selected.module.memberId,
|
||||||
|
subject: child.subject,
|
||||||
|
contract: selected.contract.contract,
|
||||||
|
...moduleTypeContext(
|
||||||
|
workspace.interfaceImports.find((entry) => entry.revisionId === selected.module.interfaceRevisionId)!,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { ...child, branches };
|
||||||
|
});
|
||||||
|
graph.set(key(parent.pkg.revisionId, parent.module.id), dependencies);
|
||||||
|
Object.assign(parent.preparation, { children });
|
||||||
|
}
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const visit = (node: string, ancestors: string[]) => {
|
||||||
|
if (ancestors.includes(node)) throw Error(`Eager React preparation cycle: ${[...ancestors, node].join(" -> ")}`);
|
||||||
|
if (visited.has(node)) return;
|
||||||
|
for (const child of graph.get(node) ?? []) visit(child, [...ancestors, node]);
|
||||||
|
visited.add(node);
|
||||||
|
};
|
||||||
|
for (const node of graph.keys()) visit(node, []);
|
||||||
|
await expandPresentationQueries(workspace);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/index.js";
|
||||||
|
import type { InterfaceRevision, PackageRevision } from "../src/capability-model/types.js";
|
||||||
|
import { compileQuery } from "../src/query/compile.js";
|
||||||
|
import { compileQueryComposition } from "../src/query/composition.js";
|
||||||
|
import { defaultQueryBudgets, type CheckedQuery } from "../src/query/types.js";
|
||||||
|
import {
|
||||||
|
presentationDeclarations,
|
||||||
|
preparationArtifact,
|
||||||
|
type ReactPreparationArtifact,
|
||||||
|
} from "../src/react-build/declarations.js";
|
||||||
|
import { completeWorkspace } from "../src/react-build/workspace.js";
|
||||||
|
|
||||||
|
const source = { repository: "https://example.test/prepared", commit: "a".repeat(40) };
|
||||||
|
async function fixture(replacement = "First", change?: (artifact: ReactPreparationArtifact) => void) {
|
||||||
|
const interfaces = new Map<string, InterfaceRevision>();
|
||||||
|
const resource = (text: string) => {
|
||||||
|
const result = compileCapabilityResourceSource(text, {
|
||||||
|
source,
|
||||||
|
environment: { interfaces, interfaceClosure: [...interfaces.values()] },
|
||||||
|
});
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
return result.resource;
|
||||||
|
};
|
||||||
|
for (const text of [
|
||||||
|
'interface Row id "row" revision "row@1" { queryable value title id "title" : string {get id "title:get" execution snapshot targets [browser, server];} queryable value detail id "detail" : string {get id "detail:get" execution snapshot targets [browser, server];} }',
|
||||||
|
'import interface Row; interface Library id "library" revision "library@1" { queryable relation selected id "selected" : optional-one interface Row {resolve id "selected:get" execution snapshot targets [browser, server];} }',
|
||||||
|
'import interface Row; interface RowRole id "row-role" revision "row-role@1" { module render id "row-render" contract "org.quixos.react.subject-only/1" subject interface Row; }',
|
||||||
|
'import interface Library; interface LibraryRole id "library-role" revision "library-role@1" { module render id "library-render" contract "org.quixos.react.subject-only/1" subject interface Library; }',
|
||||||
|
]) {
|
||||||
|
const result = resource(text);
|
||||||
|
assert.equal(result.kind, "interface");
|
||||||
|
if (result.kind === "interface") interfaces.set(result.revision.displayName, result.revision);
|
||||||
|
}
|
||||||
|
const result = resource(
|
||||||
|
'import interface Row; import interface Library; import interface RowRole; import interface LibraryRole; package UI id "ui" revision "ui@1" { module LibraryCard id "library-card" implements LibraryRole.render; module First id "first" implements RowRole.render; module Second id "second" implements RowRole.render; }',
|
||||||
|
);
|
||||||
|
assert.equal(result.kind, "package");
|
||||||
|
if (result.kind !== "package") throw Error("package expected");
|
||||||
|
const pkg: PackageRevision = result.revision;
|
||||||
|
const checked: CheckedQuery[] = [];
|
||||||
|
for (const [name, iface, fields] of [
|
||||||
|
["LibraryData", "library@1", "selected {_camino {ref atom}}"],
|
||||||
|
["Title", "row@1", "title"],
|
||||||
|
["Detail", "row@1", "detail"],
|
||||||
|
]) {
|
||||||
|
for (const local of [false, true]) {
|
||||||
|
const id = name + (local ? "" : "Inputs");
|
||||||
|
checked.push(
|
||||||
|
await compileQuery(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
displayName: id,
|
||||||
|
root: {
|
||||||
|
kind: "object",
|
||||||
|
interface: [...interfaces.values()].find((entry) => entry.revisionId === iface)!.revisionId,
|
||||||
|
},
|
||||||
|
document: id + ".graphql",
|
||||||
|
operation: id,
|
||||||
|
fragments: [],
|
||||||
|
views: [],
|
||||||
|
allowances: [],
|
||||||
|
budgets: { ...defaultQueryBudgets },
|
||||||
|
watch: local,
|
||||||
|
placement: local ? { kind: "local", sourceQueryId: name + "Inputs" } : { kind: "replica" },
|
||||||
|
},
|
||||||
|
[...interfaces.values()],
|
||||||
|
async () => `query ${id} {root {${fields}}}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pkg.checkedQueries = checked;
|
||||||
|
pkg.queries = checked.map((query) => query.declaration);
|
||||||
|
const input = (query: string) => ({
|
||||||
|
name: "data",
|
||||||
|
query,
|
||||||
|
root: { from: "input", path: ["subject"] },
|
||||||
|
arguments: { record: {} },
|
||||||
|
});
|
||||||
|
const authored = {
|
||||||
|
modules: {
|
||||||
|
LibraryCard: {
|
||||||
|
inputs: [input("LibraryData")],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "selected",
|
||||||
|
subject: "Row",
|
||||||
|
slot: "selected",
|
||||||
|
with: { from: "result", name: "data", path: ["root", "selected"] },
|
||||||
|
root: { from: "item", path: ["_camino", "ref"] },
|
||||||
|
role: { interface: "RowRole", member: "render" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
First: { inputs: [input("Title")] },
|
||||||
|
Second: { inputs: [input("Detail")] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const declarations = presentationDeclarations(JSON.stringify(authored), pkg, [...interfaces.values()]);
|
||||||
|
change?.(declarations.artifact);
|
||||||
|
pkg.buildArtifacts = { [preparationArtifact]: declarations.artifact };
|
||||||
|
pkg.queryCompositions = declarations.compositions.map((composition) =>
|
||||||
|
compileQueryComposition(composition, new Map(checked.map((query) => [query.declaration.id, query]))),
|
||||||
|
);
|
||||||
|
const workspace = compileCapabilitySource(
|
||||||
|
`workspace Example id "example" revision "example@1" commit "${source.commit}" {
|
||||||
|
import interface Row; import interface Library; import interface RowRole; import interface LibraryRole; import package UI;
|
||||||
|
atom A id "a"; atom B id "b";
|
||||||
|
conform A as Row id "a-row" {private state TitleA id "title-A" on A : string policy register default "title"; private state DetailA id "detail-A" on A : string policy register default "detail"; bind title.get to state TitleA.read; bind detail.get to state DetailA.read;}
|
||||||
|
conform B as Row id "b-row" {private state TitleB id "title-B" on B : string policy register default "title"; private state DetailB id "detail-B" on B : string policy register default "detail"; bind title.get to state TitleB.read; bind detail.get to state DetailB.read;}
|
||||||
|
conform A as RowRole id "a-role" {bind render to UI.${replacement};}
|
||||||
|
conform B as RowRole id "b-role" {bind render to UI.Second;}
|
||||||
|
}`,
|
||||||
|
"workspace.qx",
|
||||||
|
{ interfaces, interfaceClosure: [...interfaces.values()], packages: new Map([["UI", pkg]]) },
|
||||||
|
);
|
||||||
|
assert.ok(workspace.ok, JSON.stringify(workspace.diagnostics));
|
||||||
|
await completeWorkspace(workspace.workspace);
|
||||||
|
return { workspace: workspace.workspace, pkg: workspace.workspace.packageImports[0]!, authored, interfaces };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("workspace expands distinct providers' private inputs without parent field declarations", async () => {
|
||||||
|
const { pkg } = await fixture();
|
||||||
|
const parent = pkg.checkedQueries!.find((query) => query.declaration.id === "react:input:library-card:data")!;
|
||||||
|
assert.match(parent.document, /title/);
|
||||||
|
assert.match(parent.document, /detail/);
|
||||||
|
const plan = pkg.queryCompositions!.find((composition) => composition.id === "react:inputs:library-card")!;
|
||||||
|
assert.equal(plan.steps[0]!.query, parent.declaration.id);
|
||||||
|
assert.equal(Object.keys(parent.fragmentContracts ?? {}).length, 2);
|
||||||
|
const discovery = pkg.queryCompositions!.find(
|
||||||
|
(composition) => composition.id === "react:inputs:library-card:discovery",
|
||||||
|
)!;
|
||||||
|
assert.equal(
|
||||||
|
discovery.steps[0]!.query,
|
||||||
|
"LibraryData",
|
||||||
|
"Cold child data cannot block discovering its selected reference",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eager cycles and wrongly typed child bindings fail before runtime", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
fixture("First", (artifact) => {
|
||||||
|
artifact.modules
|
||||||
|
.find((module) => module.module === "first")!
|
||||||
|
.children.push({
|
||||||
|
name: "again",
|
||||||
|
subject: "row@1",
|
||||||
|
slot: "again",
|
||||||
|
root: { from: "input", path: ["subject"] },
|
||||||
|
role: { interface: "row-role@1", member: "render" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
/Eager React preparation cycle/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fixture("First", (artifact) => {
|
||||||
|
artifact.modules.find((module) => module.module === "library-card")!.children[0]!.root = {
|
||||||
|
literal: "not a reference",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
/child root must be/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace replacement changes acquisition requirements without changing the parent", async () => {
|
||||||
|
const { pkg } = await fixture("Second");
|
||||||
|
const parent = pkg.checkedQueries!.find((query) => query.declaration.id === "react:input:library-card:data")!;
|
||||||
|
assert.doesNotMatch(parent.document, /\btitle\b/);
|
||||||
|
assert.match(parent.document, /detail/);
|
||||||
|
assert.equal(Object.keys(parent.fragmentContracts ?? {}).length, 1);
|
||||||
|
});
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
type PackageArtifacts,
|
type PackageArtifacts,
|
||||||
} from "../src/execution-world/index.js";
|
} from "../src/execution-world/index.js";
|
||||||
import { validateWorkspaceRevision } from "../src/capability-model/index.js";
|
import { validateWorkspaceRevision } from "../src/capability-model/index.js";
|
||||||
|
import { presentationDeclarations, preparationArtifact } from "../src/react-build/declarations.js";
|
||||||
|
import { compileQueryComposition } from "../src/query/composition.js";
|
||||||
|
|
||||||
const source = { repository: "https://example.test/components.git", commit: "a".repeat(40) };
|
const source = { repository: "https://example.test/components.git", commit: "a".repeat(40) };
|
||||||
const contract = (name: string) => {
|
const contract = (name: string) => {
|
||||||
@@ -58,6 +60,13 @@ const compilePackage = (body?: string) =>
|
|||||||
const workspace = (presentation = 'presentation Card id "presentation:card" for Data using UI.Card;') => {
|
const workspace = (presentation = 'presentation Card id "presentation:card" for Data using UI.Card;') => {
|
||||||
const pkg = compilePackage();
|
const pkg = compilePackage();
|
||||||
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
|
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
|
||||||
|
const preparation = presentationDeclarations('{"modules":{"Card":{"inputs":[]}}}', pkg.resource.revision, [
|
||||||
|
...interfaces.values(),
|
||||||
|
]);
|
||||||
|
pkg.resource.revision.buildArtifacts = { [preparationArtifact]: preparation.artifact };
|
||||||
|
pkg.resource.revision.queryCompositions = preparation.compositions.map((entry) =>
|
||||||
|
compileQueryComposition(entry, new Map()),
|
||||||
|
);
|
||||||
return compileCapabilitySource(
|
return compileCapabilitySource(
|
||||||
`workspace Example id "workspace:example" revision "workspace:example@1" commit "${source.commit}" {
|
`workspace Example id "workspace:example" revision "workspace:example@1" commit "${source.commit}" {
|
||||||
import interface Data; import interface Other; import interface View; import package UI;
|
import interface Data; import interface Other; import interface View; import package UI;
|
||||||
|
|||||||
Reference in New Issue
Block a user