Add checked query composition foundations for prepared presentations
Move React navigation query generation behind an explicit build frontend. Add checked result-dependent query bindings and pure composition evaluation, including one-snapshot server execution. Preserve the existing rendering path while the React manifest, worker preparation, and replica admission integration remain pending; see docs/PREPARED_PRESENTATIONS_PROGRESS.md.
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
|
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
|
||||||
yarn build
|
yarn build
|
||||||
esbuild dist/src/execution-world/index.js --bundle --platform=node --target=node24 --format=esm --outfile=execution-world.mjs
|
esbuild dist/src/execution-world/index.js --bundle --platform=node --target=node24 --format=esm --outfile=execution-world.mjs
|
||||||
|
esbuild dist/src/react-build/package-queries.js --bundle --platform=node --target=node24 --format=esm --outfile=react-query-generator.mjs
|
||||||
esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs
|
esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
||||||
@@ -122,6 +123,7 @@
|
|||||||
chmod +x "$out/bin/quixos-lock-check"
|
chmod +x "$out/bin/quixos-lock-check"
|
||||||
mkdir -p "$out/libexec/quixos-protocol"
|
mkdir -p "$out/libexec/quixos-protocol"
|
||||||
install -m644 execution-world.mjs "$out/libexec/quixos-protocol/execution-world.mjs"
|
install -m644 execution-world.mjs "$out/libexec/quixos-protocol/execution-world.mjs"
|
||||||
|
install -m644 react-query-generator.mjs "$out/libexec/quixos-protocol/react-query-generator.mjs"
|
||||||
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
|
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
|
||||||
install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.mjs"
|
install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.mjs"
|
||||||
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
||||||
|
|||||||
@@ -65,6 +65,27 @@ let
|
|||||||
}) (flatten root)
|
}) (flatten root)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
# Frontends are an explicit build choice. The capability assembler itself
|
||||||
|
# neither discovers React contracts nor loads executable package code.
|
||||||
|
queryFrontends = pkgs.lib.unique (
|
||||||
|
pkgs.lib.concatMap (
|
||||||
|
node:
|
||||||
|
let
|
||||||
|
config = node.directory + "/quixos.check.json";
|
||||||
|
in
|
||||||
|
if builtins.pathExists config then
|
||||||
|
(builtins.fromJSON (builtins.readFile config)).queryFrontends or [ ]
|
||||||
|
else
|
||||||
|
[ ]
|
||||||
|
) (capabilityFlatten root)
|
||||||
|
);
|
||||||
|
queryGeneratorFlags =
|
||||||
|
if queryFrontends == [ ] then
|
||||||
|
""
|
||||||
|
else if queryFrontends == [ "react" ] then
|
||||||
|
"--query-generator ${protocol}/libexec/quixos-protocol/react-query-generator.mjs"
|
||||||
|
else
|
||||||
|
throw "Unsupported query frontend selection: ${builtins.toJSON queryFrontends}";
|
||||||
snapshots = pkgs.writeText "qx-nix-source-graph.json" (
|
snapshots = pkgs.writeText "qx-nix-source-graph.json" (
|
||||||
builtins.toJSON {
|
builtins.toJSON {
|
||||||
resources = map (node: {
|
resources = map (node: {
|
||||||
@@ -85,6 +106,7 @@ let
|
|||||||
if node.kind == "workspace" then
|
if node.kind == "workspace" then
|
||||||
''
|
''
|
||||||
${protocol}/bin/quixos-workspace-compile --root ${node.directory} \
|
${protocol}/bin/quixos-workspace-compile --root ${node.directory} \
|
||||||
|
${queryGeneratorFlags} \
|
||||||
--source-root-commit ${pkgs.lib.escapeShellArg node.commit} \
|
--source-root-commit ${pkgs.lib.escapeShellArg node.commit} \
|
||||||
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \
|
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \
|
||||||
--graph-out "$out/graph.json" --schemas-out "$out/package-schemas.json" \
|
--graph-out "$out/graph.json" --schemas-out "$out/package-schemas.json" \
|
||||||
@@ -93,6 +115,7 @@ let
|
|||||||
else
|
else
|
||||||
''
|
''
|
||||||
${protocol}/bin/quixos-resource-compile --root ${node.directory} \
|
${protocol}/bin/quixos-resource-compile --root ${node.directory} \
|
||||||
|
${queryGeneratorFlags} \
|
||||||
--kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \
|
--kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \
|
||||||
--commit ${pkgs.lib.escapeShellArg node.commit} \
|
--commit ${pkgs.lib.escapeShellArg node.commit} \
|
||||||
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \
|
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { readFile, realpath } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
||||||
import { compileQuery, compileRepositoryQuery } from "../query/compile.js";
|
import { compileQuery, compileRepositoryQuery } from "../query/compile.js";
|
||||||
import { presentationQueries } from "../bindings/presentation-queries.js";
|
import type { QueryDeclaration } from "../query/types.js";
|
||||||
|
import { compileQueryComposition, type QueryComposition } from "../query/composition.js";
|
||||||
import { checkQueryTemplate } from "../query/templates.js";
|
import { checkQueryTemplate } from "../query/templates.js";
|
||||||
import { readRepositorySource } from "./source-loader.js";
|
import { readRepositorySource } from "./source-loader.js";
|
||||||
import { parseControllerCatalog } from "../capability-model/controllers.js";
|
import { parseControllerCatalog } from "../capability-model/controllers.js";
|
||||||
@@ -42,6 +43,19 @@ export type CapabilityRepositoryResolver = (
|
|||||||
kind: LockedResource["kind"],
|
kind: LockedResource["kind"],
|
||||||
) => Promise<CapabilityRepositorySnapshot>;
|
) => Promise<CapabilityRepositorySnapshot>;
|
||||||
|
|
||||||
|
/** An explicitly selected build frontend supplies ordinary query sources. The
|
||||||
|
* assembler never chooses a frontend by inspecting an interface/contract ID.
|
||||||
|
* Generated sources go through the same query and imported-fragment checks. */
|
||||||
|
export type PackageQueryGenerator = (input: {
|
||||||
|
readonly package: Readonly<PackageRevision>;
|
||||||
|
readonly interfaces: readonly InterfaceRevision[];
|
||||||
|
readonly readSource: (name: string) => Promise<string>;
|
||||||
|
}) => Promise<{
|
||||||
|
readonly queries: readonly QueryDeclaration[];
|
||||||
|
readonly documents: ReadonlyMap<string, string>;
|
||||||
|
readonly compositions?: readonly QueryComposition[];
|
||||||
|
}>;
|
||||||
|
|
||||||
export type ResolvedCapabilityResource = {
|
export type ResolvedCapabilityResource = {
|
||||||
key: string;
|
key: string;
|
||||||
kind: LockedResource["kind"];
|
kind: LockedResource["kind"];
|
||||||
@@ -198,7 +212,11 @@ const environmentFor = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createResourceGraphResolver = (quixosCommit: string, resolveResource: CapabilityRepositoryResolver) => {
|
const createResourceGraphResolver = (
|
||||||
|
quixosCommit: string,
|
||||||
|
resolveResource: CapabilityRepositoryResolver,
|
||||||
|
generateQueries?: PackageQueryGenerator,
|
||||||
|
) => {
|
||||||
const resolved = new Map<string, Promise<ResolvedCapabilityResource>>();
|
const resolved = new Map<string, Promise<ResolvedCapabilityResource>>();
|
||||||
const active: string[] = [];
|
const active: string[] = [];
|
||||||
|
|
||||||
@@ -273,7 +291,22 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
|||||||
...(environment.interfaceClosure ?? []),
|
...(environment.interfaceClosure ?? []),
|
||||||
...(compiled.resource.specializations ?? []),
|
...(compiled.resource.specializations ?? []),
|
||||||
];
|
];
|
||||||
const presentationDocuments = presentationQueries(compiled.resource.revision, queryInterfaces);
|
const generated = await generateQueries?.({
|
||||||
|
package: structuredClone(compiled.resource.revision),
|
||||||
|
interfaces: structuredClone(queryInterfaces),
|
||||||
|
readSource: (name) => readRepositorySource(snapshot.directory, name),
|
||||||
|
});
|
||||||
|
if (generated) {
|
||||||
|
const queries = [...(compiled.resource.revision.queries ?? [])];
|
||||||
|
for (const query of generated.queries) {
|
||||||
|
if (queries.some((entry) => entry.id === query.id || entry.displayName === query.displayName))
|
||||||
|
throw new Error(`Generated query conflicts with an authored query: ${query.id}`);
|
||||||
|
if (!generated.documents.has(query.document))
|
||||||
|
throw new Error(`Generated query has no source: ${query.document}`);
|
||||||
|
queries.push(structuredClone(query));
|
||||||
|
}
|
||||||
|
compiled.resource.revision.queries = queries;
|
||||||
|
}
|
||||||
for (const template of compiled.resource.revision.queryTemplates ?? [])
|
for (const template of compiled.resource.revision.queryTemplates ?? [])
|
||||||
await checkQueryTemplate(template, queryInterfaces, (name) =>
|
await checkQueryTemplate(template, queryInterfaces, (name) =>
|
||||||
readRepositorySource(snapshot.directory, name),
|
readRepositorySource(snapshot.directory, name),
|
||||||
@@ -290,29 +323,42 @@ const createResourceGraphResolver = (quixosCommit: string, resolveResource: Capa
|
|||||||
if (previous) return previous;
|
if (previous) return previous;
|
||||||
const query = pkg.queries!.find((query) => query.id === id);
|
const query = pkg.queries!.find((query) => query.id === id);
|
||||||
if (!query) throw new Error(`Unknown fragment ${pkg.revisionId}/${id}`);
|
if (!query) throw new Error(`Unknown fragment ${pkg.revisionId}/${id}`);
|
||||||
const generatedDocument = presentationDocuments.get(query.document);
|
const resolveFragment = async (packageId: string, fragmentId: string) => {
|
||||||
const result =
|
|
||||||
generatedDocument !== undefined
|
|
||||||
? await compileQuery(query, queryInterfaces, async () => generatedDocument)
|
|
||||||
: await compileRepositoryQuery(
|
|
||||||
snapshot.directory,
|
|
||||||
query,
|
|
||||||
queryInterfaces,
|
|
||||||
async (packageId, fragmentId) => {
|
|
||||||
if (packageId === pkg.revisionId) return compile(fragmentId, [...parents, id]);
|
if (packageId === pkg.revisionId) return compile(fragmentId, [...parents, id]);
|
||||||
const imported = environment.packageClosure
|
const imported = environment.packageClosure
|
||||||
?.find((other) => other.revisionId === packageId)
|
?.find((other) => other.revisionId === packageId)
|
||||||
?.checkedQueries?.find((query) => query.declaration.id === fragmentId);
|
?.checkedQueries?.find((query) => query.declaration.id === fragmentId);
|
||||||
if (!imported) throw new Error(`Unchecked imported fragment ${packageId}/${fragmentId}`);
|
if (!imported) throw new Error(`Unchecked imported fragment ${packageId}/${fragmentId}`);
|
||||||
return imported;
|
return imported;
|
||||||
},
|
};
|
||||||
);
|
const result = generated?.documents.has(query.document)
|
||||||
|
? await compileQuery(
|
||||||
|
query,
|
||||||
|
queryInterfaces,
|
||||||
|
async (name) =>
|
||||||
|
generated.documents.get(name) ?? (await readRepositorySource(snapshot.directory, name)),
|
||||||
|
resolveFragment,
|
||||||
|
)
|
||||||
|
: await compileRepositoryQuery(snapshot.directory, query, queryInterfaces, resolveFragment);
|
||||||
checked.set(id, result);
|
checked.set(id, result);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
pkg.checkedQueries = [];
|
pkg.checkedQueries = [];
|
||||||
for (const query of pkg.queries!) pkg.checkedQueries.push(await compile(query.id));
|
for (const query of pkg.queries!) pkg.checkedQueries.push(await compile(query.id));
|
||||||
}
|
}
|
||||||
|
if (generated?.compositions?.length) {
|
||||||
|
const pkg = compiled.resource.revision;
|
||||||
|
const queries = new Map((pkg.checkedQueries ?? []).map((query) => [query.declaration.id, query]));
|
||||||
|
for (const dependency of environment.packageClosure ?? [])
|
||||||
|
for (const query of dependency.checkedQueries ?? [])
|
||||||
|
queries.set(JSON.stringify([dependency.revisionId, query.declaration.id]), query);
|
||||||
|
const ids = new Set<string>();
|
||||||
|
pkg.queryCompositions = generated.compositions.map((composition) => {
|
||||||
|
if (ids.has(composition.id)) throw new Error(`Duplicate query composition: ${composition.id}`);
|
||||||
|
ids.add(composition.id);
|
||||||
|
return compileQueryComposition(composition, queries);
|
||||||
|
});
|
||||||
|
}
|
||||||
let controllerText: string | undefined;
|
let controllerText: string | undefined;
|
||||||
try {
|
try {
|
||||||
controllerText = await readRepositorySource(snapshot.directory, "quixos.controllers.json");
|
controllerText = await readRepositorySource(snapshot.directory, "quixos.controllers.json");
|
||||||
@@ -377,12 +423,17 @@ export const compileCapabilityResourceRepository = async (options: {
|
|||||||
kind: LockedResource["kind"];
|
kind: LockedResource["kind"];
|
||||||
source: GitSource;
|
source: GitSource;
|
||||||
resolveResource: CapabilityRepositoryResolver;
|
resolveResource: CapabilityRepositoryResolver;
|
||||||
|
generateQueries?: PackageQueryGenerator;
|
||||||
}): Promise<CompiledCapabilityResourceRepository> => {
|
}): Promise<CompiledCapabilityResourceRepository> => {
|
||||||
const lockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
const lockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
||||||
if (!lockResult.ok) {
|
if (!lockResult.ok) {
|
||||||
throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics));
|
throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics));
|
||||||
}
|
}
|
||||||
const resolver = createResourceGraphResolver(lockResult.lock.quixos.commit, options.resolveResource);
|
const resolver = createResourceGraphResolver(
|
||||||
|
lockResult.lock.quixos.commit,
|
||||||
|
options.resolveResource,
|
||||||
|
options.generateQueries,
|
||||||
|
);
|
||||||
const root = await resolver.visit(
|
const root = await resolver.visit(
|
||||||
{
|
{
|
||||||
kind: options.kind,
|
kind: options.kind,
|
||||||
@@ -408,13 +459,18 @@ export const compileWorkspaceRepository = async (options: {
|
|||||||
sourceRootCommit?: string;
|
sourceRootCommit?: string;
|
||||||
/** An editor's proposed source snapshot; locks and dependency revisions remain exact. */
|
/** An editor's proposed source snapshot; locks and dependency revisions remain exact. */
|
||||||
readSource?: (name: string) => Promise<string>;
|
readSource?: (name: string) => Promise<string>;
|
||||||
|
generateQueries?: PackageQueryGenerator;
|
||||||
}): Promise<CompiledWorkspaceRepository> => {
|
}): Promise<CompiledWorkspaceRepository> => {
|
||||||
const rootLockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
const rootLockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
||||||
if (!rootLockResult.ok) {
|
if (!rootLockResult.ok) {
|
||||||
throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics));
|
throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics));
|
||||||
}
|
}
|
||||||
const rootLock = rootLockResult.lock;
|
const rootLock = rootLockResult.lock;
|
||||||
const resolver = createResourceGraphResolver(rootLock.quixos.commit, options.resolveResource);
|
const resolver = createResourceGraphResolver(
|
||||||
|
rootLock.quixos.commit,
|
||||||
|
options.resolveResource,
|
||||||
|
options.generateQueries,
|
||||||
|
);
|
||||||
|
|
||||||
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
|
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
|
||||||
for (const locked of rootLock.resources) {
|
for (const locked of rootLock.resources) {
|
||||||
|
|||||||
@@ -55,12 +55,17 @@ export async function scaffoldComponent(
|
|||||||
const insertion = ` ${nixLiteral(spec.id)} = { entry = ${nixLiteral(`src/components/${spec.name}.tsx`)}; export = ${nixLiteral(spec.name)}; };\n`;
|
const insertion = ` ${nixLiteral(spec.id)} = { entry = ${nixLiteral(`src/components/${spec.name}.tsx`)}; export = ${nixLiteral(spec.name)}; };\n`;
|
||||||
const after = before.replace(/\n}(\s*)$/, (_match, trailing) => "\n" + insertion + "}" + trailing);
|
const after = before.replace(/\n}(\s*)$/, (_match, trailing) => "\n" + insertion + "}" + trailing);
|
||||||
const parentID = parent.children.find((node) => node.kind === "stringLiteral")!;
|
const parentID = parent.children.find((node) => node.kind === "stringLiteral")!;
|
||||||
|
const configFile = prefix + "quixos.check.json";
|
||||||
|
const configBefore = await fs.readFile(path.join(root, configFile), "utf8");
|
||||||
|
const config = JSON.parse(configBefore);
|
||||||
|
config.queryFrontends = [...new Set([...(config.queryFrontends ?? []), "react"])];
|
||||||
return {
|
return {
|
||||||
kind: "package",
|
kind: "package",
|
||||||
source: spec.source,
|
source: spec.source,
|
||||||
resourceRoot: spec.directory,
|
resourceRoot: spec.directory,
|
||||||
validation: "syntax",
|
validation: "syntax",
|
||||||
files: [
|
files: [
|
||||||
|
{ file: configFile, expected: configBefore, replace: JSON.stringify(config, null, 2) + "\n" },
|
||||||
{
|
{
|
||||||
file: prefix + "package.qx",
|
file: prefix + "package.qx",
|
||||||
edits: [
|
edits: [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { PackageQueryGenerator } from "./assembly.js";
|
||||||
|
|
||||||
|
/** The build invoker selects this executable explicitly. Never resolve a
|
||||||
|
* generator by a capability name or execute a module discovered in user QX. */
|
||||||
|
export async function loadQueryGenerator(filename?: string): Promise<PackageQueryGenerator | undefined> {
|
||||||
|
if (!filename) return undefined;
|
||||||
|
const module = await import(pathToFileURL(path.resolve(filename)).href);
|
||||||
|
if (typeof module.generatePackageQueries !== "function")
|
||||||
|
throw new Error("Query generator must export generatePackageQueries");
|
||||||
|
return module.generatePackageQueries;
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import process from "node:process";
|
|||||||
import { bindingSchema } from "../bindings/index.js";
|
import { bindingSchema } from "../bindings/index.js";
|
||||||
import { compileCapabilityResourceRepository, type ResolvedCapabilityResource } from "./assembly.js";
|
import { compileCapabilityResourceRepository, type ResolvedCapabilityResource } from "./assembly.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
import { loadQueryGenerator } from "./query-generator.js";
|
||||||
|
|
||||||
const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package|build
|
const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package|build
|
||||||
--repository URL --commit GIT_REV --checkout-root DIRECTORY
|
--repository URL --commit GIT_REV --checkout-root DIRECTORY
|
||||||
[--snapshot-map PATH] [--snapshot-only true] [--graph-out PATH] [--schema-out PATH]
|
[--snapshot-map PATH] [--snapshot-only true] [--graph-out PATH] [--schema-out PATH]
|
||||||
|
[--query-generator BUILD_MODULE_PATH]
|
||||||
|
|
||||||
Resolves a standalone resource's recursive dependency-lock graph, validates
|
Resolves a standalone resource's recursive dependency-lock graph, validates
|
||||||
its interface.qx or package.qx manifest, and emits the checked resource as JSON.`;
|
its interface.qx or package.qx manifest, and emits the checked resource as JSON.`;
|
||||||
@@ -30,6 +32,7 @@ const parseArgs = (args: string[]) => {
|
|||||||
"--snapshot-only",
|
"--snapshot-only",
|
||||||
"--graph-out",
|
"--graph-out",
|
||||||
"--schema-out",
|
"--schema-out",
|
||||||
|
"--query-generator",
|
||||||
].includes(key) ||
|
].includes(key) ||
|
||||||
values.has(key)
|
values.has(key)
|
||||||
)
|
)
|
||||||
@@ -65,6 +68,7 @@ const parseArgs = (args: string[]) => {
|
|||||||
snapshotOnly: values.get("--snapshot-only") === "true",
|
snapshotOnly: values.get("--snapshot-only") === "true",
|
||||||
graphOut: values.get("--graph-out"),
|
graphOut: values.get("--graph-out"),
|
||||||
schemaOut: values.get("--schema-out"),
|
schemaOut: values.get("--schema-out"),
|
||||||
|
queryGenerator: values.get("--query-generator"),
|
||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ const main = async () => {
|
|||||||
commit: options.commit,
|
commit: options.commit,
|
||||||
},
|
},
|
||||||
resolveResource,
|
resolveResource,
|
||||||
|
generateQueries: await loadQueryGenerator(options.queryGenerator),
|
||||||
});
|
});
|
||||||
if (options.graphOut) {
|
if (options.graphOut) {
|
||||||
await writeFile(
|
await writeFile(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readFile, writeFile } from "node:fs/promises";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { compileWorkspaceRepository } from "./assembly.js";
|
import { compileWorkspaceRepository } from "./assembly.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
import { loadQueryGenerator } from "./query-generator.js";
|
||||||
import { compileExecutionPlan } from "../execution-world/index.js";
|
import { compileExecutionPlan } from "../execution-world/index.js";
|
||||||
import { bindingSchema, specializeBindingSchema } from "../bindings/index.js";
|
import { bindingSchema, specializeBindingSchema } from "../bindings/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,7 @@ const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root
|
|||||||
[--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
|
[--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
|
||||||
[--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH]
|
[--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH]
|
||||||
[--reviews REVIEW_JSON] [--schemas-out PATH] [--execution-plan-out PATH]
|
[--reviews REVIEW_JSON] [--schemas-out PATH] [--execution-plan-out PATH]
|
||||||
|
[--query-generator BUILD_MODULE_PATH]
|
||||||
|
|
||||||
Resolves a workspace's recursive resource-lock graph, clones every exact
|
Resolves a workspace's recursive resource-lock graph, clones every exact
|
||||||
resource revision, validates standalone interface/package manifests, and emits
|
resource revision, validates standalone interface/package manifests, and emits
|
||||||
@@ -46,6 +48,7 @@ const parseArgs = (args: string[]) => {
|
|||||||
baseline: values.get("--baseline"),
|
baseline: values.get("--baseline"),
|
||||||
evolutionOut: values.get("--evolution-out"),
|
evolutionOut: values.get("--evolution-out"),
|
||||||
reviews: values.get("--reviews"),
|
reviews: values.get("--reviews"),
|
||||||
|
queryGenerator: values.get("--query-generator"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ const main = async () => {
|
|||||||
workspaceRevisionId: options.workspaceRevisionId,
|
workspaceRevisionId: options.workspaceRevisionId,
|
||||||
sourceRootCommit: options.sourceRootCommit,
|
sourceRootCommit: options.sourceRootCommit,
|
||||||
resolveResource,
|
resolveResource,
|
||||||
|
generateQueries: await loadQueryGenerator(options.queryGenerator),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.graphOut) {
|
if (options.graphOut) {
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ export interface PackageRevision {
|
|||||||
controllers?: PackageController[];
|
controllers?: PackageController[];
|
||||||
queries?: import("../query/types.js").QueryDeclaration[];
|
queries?: import("../query/types.js").QueryDeclaration[];
|
||||||
checkedQueries?: import("../query/types.js").CheckedQuery[];
|
checkedQueries?: import("../query/types.js").CheckedQuery[];
|
||||||
|
queryCompositions?: import("../query/composition.js").CheckedQueryComposition[];
|
||||||
queryTemplates?: import("../query/types.js").QueryTemplate[];
|
queryTemplates?: import("../query/types.js").QueryTemplate[];
|
||||||
genericExports?: import("./generic-packages.js").GenericPackageExport[];
|
genericExports?: import("./generic-packages.js").GenericPackageExport[];
|
||||||
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { compileInspectionTemplates } from "./inspection.js";
|
|||||||
import { portableValueSchema } from "../bindings/portable.js";
|
import { portableValueSchema } from "../bindings/portable.js";
|
||||||
import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||||
import type { ValueType } from "../capability-model/types.js";
|
import type { ValueType } from "../capability-model/types.js";
|
||||||
|
import { compileQueryComposition } from "../query/composition.js";
|
||||||
|
|
||||||
const lowerTypes = (value: unknown): unknown =>
|
const lowerTypes = (value: unknown): unknown =>
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
@@ -25,9 +26,40 @@ export function compileQueryProgram(plan: ExecutionPlan): unknown {
|
|||||||
if (hexDigest(plan.definition) !== plan.digest)
|
if (hexDigest(plan.definition) !== plan.digest)
|
||||||
throw new ExecutionPlanError("INVALID_PLAN", "queries", "Plan digest mismatch");
|
throw new ExecutionPlanError("INVALID_PLAN", "queries", "Plan digest mismatch");
|
||||||
const interfaces = plan.definition.workspace.interfaceImports;
|
const interfaces = plan.definition.workspace.interfaceImports;
|
||||||
|
const allQueries = new Map(plan.definition.queries.map(({ id, query }) => [id, query.checked]));
|
||||||
|
const compositions = plan.definition.workspace.packageImports.flatMap((pkg) =>
|
||||||
|
(pkg.queryCompositions ?? []).map((composition) => {
|
||||||
|
const scoped = new Map(allQueries);
|
||||||
|
for (const query of pkg.checkedQueries ?? []) scoped.set(query.declaration.id, query);
|
||||||
|
const checked = compileQueryComposition(
|
||||||
|
{
|
||||||
|
id: composition.id,
|
||||||
|
input: composition.input,
|
||||||
|
steps: composition.steps,
|
||||||
|
maxQueries: composition.maxQueries,
|
||||||
|
},
|
||||||
|
scoped,
|
||||||
|
);
|
||||||
|
if (checked.definitionDigest !== composition.definitionDigest)
|
||||||
|
throw new ExecutionPlanError("INVALID_QUERY_COMPOSITION", composition.id, "Checked dependencies changed");
|
||||||
|
return {
|
||||||
|
id: JSON.stringify([pkg.revisionId, composition.id]),
|
||||||
|
input: portableValueSchema(checked.input),
|
||||||
|
output: portableValueSchema(checked.output),
|
||||||
|
maxQueries: checked.maxQueries,
|
||||||
|
steps: checked.steps.map((step) => ({
|
||||||
|
...step,
|
||||||
|
query: (pkg.checkedQueries ?? []).some((query) => query.declaration.id === step.query)
|
||||||
|
? JSON.stringify([pkg.revisionId, step.query])
|
||||||
|
: step.query,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
codec: "camino-query-program-v1",
|
codec: "camino-query-program-v1",
|
||||||
plan: (compileKernelProgram(plan) as { plan: string }).plan,
|
plan: (compileKernelProgram(plan) as { plan: string }).plan,
|
||||||
|
...(compositions.length ? { compositions } : {}),
|
||||||
queries: [
|
queries: [
|
||||||
...compileInspectionTemplates(plan).queries,
|
...compileInspectionTemplates(plan).queries,
|
||||||
...plan.definition.queries.map(({ id, query, admittedOperations }) => {
|
...plan.definition.queries.map(({ id, query, admittedOperations }) => {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { ValueType } from "../capability-model/types.js";
|
||||||
|
import { canonicalJson } from "../capability-model/evolution.js";
|
||||||
|
import { QueryCompileError, type CheckedQuery } from "./types.js";
|
||||||
|
|
||||||
|
/** Bindings refer to checked values, never executable callbacks. A path cannot
|
||||||
|
* silently cross a nullable value or expand an unbounded collection. */
|
||||||
|
export type QueryBinding =
|
||||||
|
| { literal: string | boolean }
|
||||||
|
| { equal: [QueryBinding, QueryBinding] }
|
||||||
|
| { from: "input"; path: string[] }
|
||||||
|
| { from: "item"; path: string[] }
|
||||||
|
| { from: "result"; name: string; path: string[] }
|
||||||
|
| { record: Record<string, QueryBinding> };
|
||||||
|
|
||||||
|
export interface QueryCompositionStep {
|
||||||
|
name: string;
|
||||||
|
query: string;
|
||||||
|
root: QueryBinding | null;
|
||||||
|
arguments: QueryBinding;
|
||||||
|
/** Null roots skip this step and produce null, rather than querying an
|
||||||
|
* arbitrary object or treating unavailable query inputs as absence. */
|
||||||
|
optional?: boolean;
|
||||||
|
when?: QueryBinding;
|
||||||
|
/** An optional object is unwrapped as item; absence skips the query. */
|
||||||
|
with?: QueryBinding;
|
||||||
|
each?: { source: QueryBinding; limit: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryComposition {
|
||||||
|
id: string;
|
||||||
|
input: ValueType;
|
||||||
|
steps: QueryCompositionStep[];
|
||||||
|
maxQueries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckedQueryComposition extends QueryComposition {
|
||||||
|
output: ValueType;
|
||||||
|
definitionDigest: string;
|
||||||
|
dependencies: { query: string; digest: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks a finite dependency graph. Query identities are resolved by the
|
||||||
|
* caller's package/workspace scope; no presentation concepts participate. */
|
||||||
|
export function compileQueryComposition(
|
||||||
|
source: QueryComposition,
|
||||||
|
queries: ReadonlyMap<string, CheckedQuery>,
|
||||||
|
): CheckedQueryComposition {
|
||||||
|
const fail = (message: string): never => {
|
||||||
|
throw new QueryCompileError("QUERY_COMPOSITION", `${source.id}: ${message}`);
|
||||||
|
};
|
||||||
|
if (!Number.isSafeInteger(source.maxQueries) || source.maxQueries < 1) fail("maxQueries must be a positive integer");
|
||||||
|
if (source.input.kind !== "record") fail("input must be a record");
|
||||||
|
const outputs = new Map<string, ValueType>();
|
||||||
|
const dependencies = new Map<string, string>();
|
||||||
|
let count = 0;
|
||||||
|
function binding(expression: QueryBinding, item?: ValueType): ValueType {
|
||||||
|
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" ? 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);
|
||||||
|
for (const step of source.steps) {
|
||||||
|
if (!step.name || outputs.has(step.name)) fail(`duplicate or empty step name ${step.name}`);
|
||||||
|
const query = queries.get(step.query);
|
||||||
|
if (!query) return fail(`unknown query ${step.query}`);
|
||||||
|
let item: ValueType | undefined;
|
||||||
|
if (step.with) {
|
||||||
|
if (step.each) fail(`${step.name} cannot combine with and each`);
|
||||||
|
const value = binding(step.with);
|
||||||
|
item = value.kind === "optional" ? value.value : value;
|
||||||
|
}
|
||||||
|
if (step.each) {
|
||||||
|
const collection = binding(step.each.source);
|
||||||
|
if (collection.kind !== "list") return fail(`${step.name}.each requires a list`);
|
||||||
|
if (!Number.isSafeInteger(step.each.limit) || step.each.limit < 1)
|
||||||
|
fail(`${step.name}.each.limit must be a positive integer`);
|
||||||
|
item = collection.value;
|
||||||
|
}
|
||||||
|
count += step.each?.limit ?? 1;
|
||||||
|
if (!Number.isSafeInteger(count) || count > source.maxQueries) fail("declared steps exceed maxQueries");
|
||||||
|
if (step.when && !equal(binding(step.when, item), { kind: "scalar", name: "bool" }))
|
||||||
|
fail(`${step.name}.when requires a boolean`);
|
||||||
|
if (query.declaration.root.kind === "workspace-interface") {
|
||||||
|
if (step.root !== null) fail(`${step.name} workspace query takes no object root`);
|
||||||
|
} else {
|
||||||
|
if (step.root === null) return fail(`${step.name} requires an object root`);
|
||||||
|
let root = binding(step.root, item);
|
||||||
|
if (root.kind === "optional" && step.optional) root = root.value;
|
||||||
|
const expected: ValueType = {
|
||||||
|
kind: "object-ref",
|
||||||
|
expectation: {
|
||||||
|
kind: "interface",
|
||||||
|
interfaceRevisionId: query.declaration.root.interface,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!equal(root, expected)) fail(`${step.name} root must be a reference to ${query.declaration.root.interface}`);
|
||||||
|
}
|
||||||
|
if (!equal(binding(step.arguments, item), query.variables))
|
||||||
|
fail(`${step.name} arguments do not match ${step.query}`);
|
||||||
|
let output = query.output;
|
||||||
|
if (step.optional || step.with || step.when) output = { kind: "optional", value: output };
|
||||||
|
if (step.each) output = { kind: "list", value: output };
|
||||||
|
outputs.set(step.name, output);
|
||||||
|
dependencies.set(step.query, query.definitionDigest);
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
...structuredClone(source),
|
||||||
|
output: { kind: "record" as const, fields: Object.fromEntries(outputs) },
|
||||||
|
dependencies: [...dependencies].map(([query, digest]) => ({ query, digest })),
|
||||||
|
};
|
||||||
|
return { ...result, definitionDigest: createHash("sha256").update(canonicalJson(result)).digest("hex") };
|
||||||
|
}
|
||||||
+6
-1
@@ -197,7 +197,12 @@ export function querySchema(
|
|||||||
const metadata = new GraphQLObjectType({
|
const metadata = new GraphQLObjectType({
|
||||||
name: `QxMetadata_${name(id)}`,
|
name: `QxMetadata_${name(id)}`,
|
||||||
fields: {
|
fields: {
|
||||||
...(id === workspaceQueryRootId ? {} : { ref: { type: new GraphQLNonNull(relational.refs(id)) } }),
|
...(id === workspaceQueryRootId
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
ref: { type: new GraphQLNonNull(relational.refs(id)) },
|
||||||
|
atom: { type: new GraphQLNonNull(GraphQLString) },
|
||||||
|
}),
|
||||||
...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}),
|
...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PackageQueryGenerator } from "../capability-language/assembly.js";
|
||||||
|
import { presentationQueries } from "../bindings/presentation-queries.js";
|
||||||
|
|
||||||
|
/** Selected explicitly by the React build. Its output is checked as ordinary
|
||||||
|
* Camino queries; no React identifiers are interpreted by package assembly. */
|
||||||
|
export const generatePackageQueries: PackageQueryGenerator = async (input) => {
|
||||||
|
const pkg = structuredClone(input.package);
|
||||||
|
const before = new Set(pkg.queries?.map((query) => query.id));
|
||||||
|
const documents = presentationQueries(pkg, input.interfaces);
|
||||||
|
return { queries: (pkg.queries ?? []).filter((query) => !before.has(query.id)), documents };
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { compileCapabilityResourceSource } from "../src/capability-language/index.js";
|
||||||
|
import type { InterfaceRevision } from "../src/capability-model/types.js";
|
||||||
|
import { valueType } from "../src/capability-model/types.js";
|
||||||
|
import { compileQuery } from "../src/query/compile.js";
|
||||||
|
import { compileQueryComposition, type QueryComposition } from "../src/query/composition.js";
|
||||||
|
import { defaultQueryBudgets } from "../src/query/types.js";
|
||||||
|
|
||||||
|
const iface = (text: string, dependencies: InterfaceRevision[] = []) => {
|
||||||
|
const result = compileCapabilityResourceSource(text, {
|
||||||
|
source: { repository: "https://example.test/devices", commit: "a".repeat(40) },
|
||||||
|
environment: {
|
||||||
|
interfaces: new Map(dependencies.map((entry) => [entry.displayName, entry])),
|
||||||
|
interfaceClosure: dependencies,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||||
|
if (result.resource.kind !== "interface") throw Error("Expected interface");
|
||||||
|
return result.resource.revision;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("dependent query roots and arguments are checked without a presentation library", async () => {
|
||||||
|
const sensor = iface(`interface Sensor id "sensor" revision "sensor@1" {
|
||||||
|
queryable value reading id "reading" : int64 { get id "reading:get"; }
|
||||||
|
}`);
|
||||||
|
const control = iface(
|
||||||
|
`import interface Sensor;
|
||||||
|
interface Control id "control" revision "control@1" {
|
||||||
|
queryable value enabled id "enabled" : bool { get id "enabled:get"; }
|
||||||
|
queryable relation selected id "selected" : optional-one interface Sensor { resolve id "selected:get"; }
|
||||||
|
}`,
|
||||||
|
[sensor],
|
||||||
|
);
|
||||||
|
const query = (id: string, root: InterfaceRevision["revisionId"], text: string) =>
|
||||||
|
compileQuery(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
displayName: id,
|
||||||
|
document: `${id}.graphql`,
|
||||||
|
operation: id,
|
||||||
|
root: { kind: "object", interface: root },
|
||||||
|
fragments: [],
|
||||||
|
views: [],
|
||||||
|
allowances: [],
|
||||||
|
budgets: { ...defaultQueryBudgets },
|
||||||
|
watch: false,
|
||||||
|
placement: { kind: "replica" },
|
||||||
|
},
|
||||||
|
[control, sensor],
|
||||||
|
async () => text,
|
||||||
|
);
|
||||||
|
const selection = await query(
|
||||||
|
"Selection",
|
||||||
|
control.revisionId,
|
||||||
|
"query Selection { root { enabled selected { _camino { ref atom } } } }",
|
||||||
|
);
|
||||||
|
const reading = await query(
|
||||||
|
"Reading",
|
||||||
|
sensor.revisionId,
|
||||||
|
"query Reading($enabled: Boolean!) { root { reading @include(if: $enabled) } }",
|
||||||
|
);
|
||||||
|
const queries = new Map([
|
||||||
|
["Selection", selection],
|
||||||
|
["Reading", reading],
|
||||||
|
]);
|
||||||
|
const source: QueryComposition = {
|
||||||
|
id: "device-display",
|
||||||
|
input: { kind: "record", fields: { controller: valueType.interfaceRef(control.revisionId) } },
|
||||||
|
maxQueries: 2,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
name: "selection",
|
||||||
|
query: "Selection",
|
||||||
|
root: { from: "input", path: ["controller"] },
|
||||||
|
arguments: { record: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reading",
|
||||||
|
query: "Reading",
|
||||||
|
with: { from: "result", name: "selection", path: ["root", "selected"] },
|
||||||
|
root: { from: "item", path: ["_camino", "ref"] },
|
||||||
|
arguments: { record: { enabled: { from: "result", name: "selection", path: ["root", "enabled"] } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const checked = compileQueryComposition(source, queries);
|
||||||
|
assert.equal(checked.output.kind, "record");
|
||||||
|
assert.equal(checked.dependencies.length, 2);
|
||||||
|
assert.ok(checked.definitionDigest);
|
||||||
|
const changed = new Map(queries);
|
||||||
|
changed.set("Reading", { ...reading, definitionDigest: "different-source" });
|
||||||
|
assert.notEqual(compileQueryComposition(source, changed).definitionDigest, checked.definitionDigest);
|
||||||
|
assert.throws(() => compileQueryComposition({ ...source, maxQueries: 1 }, queries), /exceed maxQueries/);
|
||||||
|
assert.throws(
|
||||||
|
() => compileQueryComposition({ ...source, steps: [...source.steps].reverse() }, queries),
|
||||||
|
/earlier step/,
|
||||||
|
);
|
||||||
|
const wrongArguments = structuredClone(source);
|
||||||
|
wrongArguments.steps[1]!.arguments = { record: { enabled: { from: "input", path: ["controller"] } } };
|
||||||
|
assert.throws(() => compileQueryComposition(wrongArguments, queries), /arguments do not match/);
|
||||||
|
const wrongRoot = structuredClone(source);
|
||||||
|
wrongRoot.steps[1]!.root = { from: "input", path: ["controller"] };
|
||||||
|
assert.throws(() => compileQueryComposition(wrongRoot, queries), /reference to sensor@1/);
|
||||||
|
const nullableTraversal = structuredClone(source);
|
||||||
|
nullableTraversal.steps[1]!.root = {
|
||||||
|
from: "result",
|
||||||
|
name: "selection",
|
||||||
|
path: ["root", "selected", "_camino", "ref"],
|
||||||
|
};
|
||||||
|
assert.throws(() => compileQueryComposition(nullableTraversal, queries), /explicit boundary/);
|
||||||
|
const branch = structuredClone(source);
|
||||||
|
branch.steps[1]!.when = { equal: [{ from: "item", path: ["_camino", "atom"] }, { literal: "thermometer" }] };
|
||||||
|
assert.ok(compileQueryComposition(branch, queries).definitionDigest);
|
||||||
|
branch.steps[1]!.when = { literal: "not a boolean" };
|
||||||
|
assert.throws(() => compileQueryComposition(branch, queries), /when requires a boolean/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
compileCapabilityResourceRepository,
|
||||||
|
type PackageQueryGenerator,
|
||||||
|
} from "../src/capability-language/assembly.js";
|
||||||
|
import { defaultQueryBudgets } from "../src/query/types.js";
|
||||||
|
|
||||||
|
test("an explicit non-React frontend emits checked queries for its own selection model", async (context) => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "qx-query-generator-"));
|
||||||
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const commit = "a".repeat(40);
|
||||||
|
const source = (name: string) => ({
|
||||||
|
resolver: "git" as const,
|
||||||
|
repository: `https://example.test/${name}.git`,
|
||||||
|
commit,
|
||||||
|
});
|
||||||
|
const lock = (imports: string) => `quixos-lock version 1 {
|
||||||
|
quixos source { repository "https://example.test/quixos.git"; commit "${commit}"; }
|
||||||
|
${imports}
|
||||||
|
}`;
|
||||||
|
const dependency = (name: string) =>
|
||||||
|
`interface ${name} source { repository "${source(name).repository}"; commit "${commit}"; }`;
|
||||||
|
for (const name of ["Sensor", "DeviceSelection", "Device"]) await mkdir(path.join(directory, name));
|
||||||
|
await writeFile(path.join(directory, "Sensor", "quixos.lock"), lock(""));
|
||||||
|
await writeFile(
|
||||||
|
path.join(directory, "Sensor", "interface.qx"),
|
||||||
|
`
|
||||||
|
interface Sensor id "sensor" revision "sensor@1" {
|
||||||
|
queryable value reading id "reading" : int64 { get id "reading:get"; }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await writeFile(path.join(directory, "DeviceSelection", "quixos.lock"), lock(dependency("Sensor")));
|
||||||
|
await writeFile(
|
||||||
|
path.join(directory, "DeviceSelection", "interface.qx"),
|
||||||
|
`
|
||||||
|
import interface Sensor;
|
||||||
|
interface DeviceSelection id "device-selection" revision "device-selection@1" {
|
||||||
|
queryable relation selectedSensor id "selected-sensor" : optional-one interface Sensor {
|
||||||
|
resolve id "selected-sensor:resolve";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await writeFile(path.join(directory, "Device", "quixos.lock"), lock(dependency("DeviceSelection")));
|
||||||
|
await writeFile(
|
||||||
|
path.join(directory, "Device", "package.qx"),
|
||||||
|
`
|
||||||
|
import interface DeviceSelection;
|
||||||
|
package Device id "device" revision "device@1" {}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(directory, "Device", "inputs.graphql"),
|
||||||
|
`query Selected { root { selectedSensor { reading } } }`,
|
||||||
|
);
|
||||||
|
const options = {
|
||||||
|
rootDirectory: path.join(directory, "Device"),
|
||||||
|
kind: "package" as const,
|
||||||
|
source: source("Device"),
|
||||||
|
resolveResource: async (ref: { repository: string }) => ({
|
||||||
|
directory: path.join(directory, new URL(ref.repository).pathname.slice(1, -4)),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const plain = await compileCapabilityResourceRepository(options);
|
||||||
|
assert.equal(plain.resource.kind, "package");
|
||||||
|
if (plain.resource.kind !== "package") throw Error("Expected package");
|
||||||
|
assert.equal(plain.resource.revision.queries?.length ?? 0, 0);
|
||||||
|
|
||||||
|
const generateQueries: PackageQueryGenerator = async ({ interfaces, readSource }) => ({
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
id: "selected-inputs",
|
||||||
|
displayName: "SelectedInputs",
|
||||||
|
root: {
|
||||||
|
kind: "object",
|
||||||
|
interface: interfaces.find((iface) => iface.displayName === "DeviceSelection")!.revisionId,
|
||||||
|
},
|
||||||
|
document: "generated/selected.graphql",
|
||||||
|
operation: "Selected",
|
||||||
|
fragments: [],
|
||||||
|
views: [],
|
||||||
|
allowances: [],
|
||||||
|
budgets: { ...defaultQueryBudgets },
|
||||||
|
watch: false,
|
||||||
|
placement: { kind: "replica" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
documents: new Map([["generated/selected.graphql", await readSource("inputs.graphql")]]),
|
||||||
|
compositions: [
|
||||||
|
{
|
||||||
|
id: "display",
|
||||||
|
maxQueries: 1,
|
||||||
|
input: {
|
||||||
|
kind: "record",
|
||||||
|
fields: {
|
||||||
|
device: {
|
||||||
|
kind: "object-ref",
|
||||||
|
expectation: {
|
||||||
|
kind: "interface",
|
||||||
|
interfaceRevisionId: interfaces.find((iface) => iface.displayName === "DeviceSelection")!.revisionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
name: "selection",
|
||||||
|
query: "selected-inputs",
|
||||||
|
root: { from: "input", path: ["device"] },
|
||||||
|
arguments: { record: {} },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const built = await compileCapabilityResourceRepository({ ...options, generateQueries });
|
||||||
|
assert.equal(built.resource.kind, "package");
|
||||||
|
if (built.resource.kind !== "package") throw Error("Expected package");
|
||||||
|
assert.equal(built.resource.revision.checkedQueries?.length, 1);
|
||||||
|
const checked = built.resource.revision.checkedQueries![0]!;
|
||||||
|
assert.equal(checked.declaration.root.interface, "device-selection@1");
|
||||||
|
assert.ok(checked.effects.some((effect) => effect.memberId === "reading"));
|
||||||
|
assert.ok(checked.effects.some((effect) => effect.memberId === "selected-sensor"));
|
||||||
|
assert.ok(checked.definitionDigest);
|
||||||
|
assert.equal(built.resource.revision.queryCompositions?.[0]?.dependencies[0]?.digest, checked.definitionDigest);
|
||||||
|
assert.equal(
|
||||||
|
built.resources.some((resource) => /React|PresentationMount/.test(resource.key)),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
compileCapabilityResourceRepository({
|
||||||
|
...options,
|
||||||
|
generateQueries: async (input) => {
|
||||||
|
const result = await generateQueries(input);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
documents: new Map([["generated/selected.graphql", "query Selected { root { invented } }"]]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/invented/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
compileCapabilityResourceRepository({
|
||||||
|
...options,
|
||||||
|
generateQueries: async (input) => {
|
||||||
|
const result = await generateQueries(input);
|
||||||
|
return { ...result, queries: [...result.queries, ...result.queries] };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/Generated query conflicts/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user