Scaffold explicit query placement and authored GraphQL documents
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parse } from "graphql";
|
||||||
|
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||||
|
import type { StructuralRequest } from "./structural-plan.js";
|
||||||
|
import { parseQx, walkSyntax } from "./source.js";
|
||||||
|
|
||||||
|
/** Insert the authored query and its document together; placement never changes implicitly. */
|
||||||
|
export async function scaffoldQuery(root: string, prefix: string, spec: ScaffoldRecipe): Promise<StructuralRequest> {
|
||||||
|
if (
|
||||||
|
!spec.name ||
|
||||||
|
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) ||
|
||||||
|
!spec.id ||
|
||||||
|
!spec.declaration ||
|
||||||
|
typeof spec.queryDocument !== "string"
|
||||||
|
)
|
||||||
|
throw new Error("Query scaffold requires name, id, declaration and queryDocument");
|
||||||
|
const source = `package Draft id "draft" revision "draft@1" { ${spec.declaration} }`;
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
const pkg = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||||
|
const queries = [...walkSyntax(syntax.root)].filter((node) => node.kind === "packageQuery");
|
||||||
|
const query = queries[0];
|
||||||
|
if (
|
||||||
|
syntax.diagnostics.length ||
|
||||||
|
syntax.root.children.length !== 1 ||
|
||||||
|
!pkg ||
|
||||||
|
queries.length !== 1 ||
|
||||||
|
!query ||
|
||||||
|
pkg.children.filter((node) => node.kind === "packageExport").length !== 1 ||
|
||||||
|
pkg.children.some((node) => !["identifier", "stringLiteral", "packageExport"].includes(node.kind))
|
||||||
|
)
|
||||||
|
throw new Error("Expected one QX query declaration");
|
||||||
|
const identifier = query.children.find((node) => node.kind === "identifier")!;
|
||||||
|
const literals = query.children
|
||||||
|
.filter((node) => node.kind === "stringLiteral")
|
||||||
|
.map((node) => JSON.parse(source.slice(node.start, node.end)) as string);
|
||||||
|
const [id, document, operation] = literals;
|
||||||
|
if (source.slice(identifier.start, identifier.end) !== spec.name || id !== spec.id)
|
||||||
|
throw new Error("Query declaration name/ID must match its registration");
|
||||||
|
if (!document || !/^queries\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.graphql$/.test(document))
|
||||||
|
throw new Error("Query document must be a contained queries/*.graphql path");
|
||||||
|
const graphql = parse(spec.queryDocument);
|
||||||
|
const operations = graphql.definitions.filter((definition) => definition.kind === "OperationDefinition");
|
||||||
|
if (operations.length !== 1 || operations[0]!.operation !== "query" || operations[0]!.name?.value !== operation)
|
||||||
|
throw new Error("GraphQL document must contain the declared named query operation");
|
||||||
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||||
|
const existing = parseQx(authored);
|
||||||
|
const parent = existing.root.children.find((node) => node.kind === "packageResourceDecl");
|
||||||
|
if (existing.diagnostics.length || !parent) throw new Error("Fix package.qx before adding a query");
|
||||||
|
for (const node of parent.children.filter((node) => node.kind === "packageExport").flatMap((node) => node.children)) {
|
||||||
|
const name = node.children.find((child) => child.kind === "identifier");
|
||||||
|
const literal = node.children.find((child) => child.kind === "stringLiteral");
|
||||||
|
if (
|
||||||
|
(name && authored.slice(name.start, name.end) === spec.name) ||
|
||||||
|
(literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id)
|
||||||
|
)
|
||||||
|
throw new Error("New query requires a unique name and ID");
|
||||||
|
}
|
||||||
|
const parentID = parent.children.find((node) => node.kind === "stringLiteral")!;
|
||||||
|
return {
|
||||||
|
kind: "package",
|
||||||
|
source: spec.source,
|
||||||
|
resourceRoot: spec.directory,
|
||||||
|
validation: "syntax",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file: prefix + "package.qx",
|
||||||
|
edits: [
|
||||||
|
{
|
||||||
|
operation: "append",
|
||||||
|
parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(parentID.start, parentID.end)) },
|
||||||
|
source: spec.declaration,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ file: prefix + document, create: spec.queryDocument },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type { StructuralRequest } from "./structural-plan.js";
|
|||||||
import { addImplementation } from "./implementation-edit.js";
|
import { addImplementation } from "./implementation-edit.js";
|
||||||
import { reactPlatformTypes } from "../bindings/react-platform.js";
|
import { reactPlatformTypes } from "../bindings/react-platform.js";
|
||||||
import { executionScaffold, isExecutionScaffold } from "./execution-scaffold.js";
|
import { executionScaffold, isExecutionScaffold } from "./execution-scaffold.js";
|
||||||
|
import { scaffoldQuery } from "./query-scaffold.js";
|
||||||
|
|
||||||
type Source = { repository: string; commit: string };
|
type Source = { repository: string; commit: string };
|
||||||
type PackageEditModel = {
|
type PackageEditModel = {
|
||||||
@@ -29,6 +30,7 @@ export type ScaffoldRecipe = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
declaration?: string;
|
declaration?: string;
|
||||||
|
queryDocument?: string;
|
||||||
/** Initial authored files for a composed recipe; only used for a new package. */
|
/** Initial authored files for a composed recipe; only used for a new package. */
|
||||||
initialFiles?: Record<string, string>;
|
initialFiles?: Record<string, string>;
|
||||||
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
|
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
|
||||||
@@ -71,7 +73,7 @@ const ownedJson = async <T>(root: string, file: string): Promise<T> => {
|
|||||||
* Implementation files are created once; later edits preserve authored wiring. */
|
* Implementation files are created once; later edits preserve authored wiring. */
|
||||||
export const scaffoldRecipe = async (
|
export const scaffoldRecipe = async (
|
||||||
root: string,
|
root: string,
|
||||||
command: "package" | "function" | "migration" | "refresh",
|
command: "package" | "function" | "migration" | "refresh" | "query",
|
||||||
spec: ScaffoldRecipe,
|
spec: ScaffoldRecipe,
|
||||||
): Promise<StructuralRequest> => {
|
): Promise<StructuralRequest> => {
|
||||||
if (command === "refresh")
|
if (command === "refresh")
|
||||||
@@ -82,6 +84,7 @@ export const scaffoldRecipe = async (
|
|||||||
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory))
|
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory))
|
||||||
throw new Error("Scaffold directory must be contained");
|
throw new Error("Scaffold directory must be contained");
|
||||||
const prefix = spec.directory ? `${spec.directory}/` : "";
|
const prefix = spec.directory ? `${spec.directory}/` : "";
|
||||||
|
if (command === "query") return scaffoldQuery(root, prefix, spec);
|
||||||
if (
|
if (
|
||||||
spec.template === "typescript-execution" ||
|
spec.template === "typescript-execution" ||
|
||||||
(command !== "package" && (await isExecutionScaffold(root, prefix)))
|
(command !== "package" && (await isExecutionScaffold(root, prefix)))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import os from "node:os";
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
|
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
|
||||||
import { scaffoldRecipe, type ScaffoldRecipe } from "./scaffold-recipes.js";
|
import { scaffoldRecipe, type ScaffoldRecipe } from "./scaffold-recipes.js";
|
||||||
|
import { isExecutionScaffold } from "./execution-scaffold.js";
|
||||||
import { checkBundleSources } from "../bindings/bundle-policy.js";
|
import { checkBundleSources } from "../bindings/bundle-policy.js";
|
||||||
import { sealMigrations } from "./migration-seal.js";
|
import { sealMigrations } from "./migration-seal.js";
|
||||||
import { generatePackageDescriptor } from "../bindings/index.js";
|
import { generatePackageDescriptor } from "../bindings/index.js";
|
||||||
@@ -201,7 +202,7 @@ const main = async () => {
|
|||||||
}
|
}
|
||||||
if (!command || command === "--help" || args.includes("--help")) {
|
if (!command || command === "--help" || args.includes("--help")) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
"quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" +
|
"quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-query, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" +
|
||||||
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" +
|
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" +
|
||||||
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n",
|
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n",
|
||||||
);
|
);
|
||||||
@@ -387,7 +388,11 @@ const main = async () => {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
"Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.",
|
"Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.",
|
||||||
);
|
);
|
||||||
if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-install"].includes(command)) {
|
if (
|
||||||
|
["scaffold-package", "scaffold-function", "scaffold-query", "scaffold-migration", "scaffold-install"].includes(
|
||||||
|
command,
|
||||||
|
)
|
||||||
|
) {
|
||||||
const [root, specFile, ...flags] = args;
|
const [root, specFile, ...flags] = args;
|
||||||
let spec: ScaffoldRecipe;
|
let spec: ScaffoldRecipe;
|
||||||
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) {
|
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) {
|
||||||
@@ -421,16 +426,24 @@ const main = async () => {
|
|||||||
(flags.includes("--install") && !flags.includes("--write"))
|
(flags.includes("--install") && !flags.includes("--write"))
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]",
|
"usage: quixos-qx scaffold-package|function|query|migration ROOT SPEC_JSON [--write [--install]]",
|
||||||
);
|
);
|
||||||
const plan =
|
const plan =
|
||||||
command === "scaffold-install"
|
command === "scaffold-install"
|
||||||
? undefined
|
? undefined
|
||||||
: await planStructure(
|
: await planStructure(
|
||||||
root,
|
root,
|
||||||
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec),
|
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "query" | "migration", spec),
|
||||||
process.env.QUIXOS_SNAPSHOT_MAP,
|
process.env.QUIXOS_SNAPSHOT_MAP,
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
flags.includes("--install") &&
|
||||||
|
(spec.template === "typescript-execution" ||
|
||||||
|
(await isExecutionScaffold(root, spec.directory ? `${spec.directory}/` : "")))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Execution package dependencies are supplied by checkedArtifacts; omit --install and run workspace-root check after authoring.",
|
||||||
|
);
|
||||||
const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
|
const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
|
||||||
if (flags.includes("--install")) {
|
if (flags.includes("--install")) {
|
||||||
const cwd = path.resolve(root, spec.directory ?? "");
|
const cwd = path.resolve(root, spec.directory ?? "");
|
||||||
|
|||||||
@@ -166,3 +166,60 @@ test("execution scaffolds route typed handlers by declared effect and target wit
|
|||||||
await assert.rejects(apply("function", { name: "missing", id: "export:missing" }), /explicit execution/);
|
await assert.rejects(apply("function", { name: "missing", id: "export:missing" }), /explicit execution/);
|
||||||
await assert.rejects(apply("migration", { name: "migration", id: "export:migration" }), /not supported/);
|
await assert.rejects(apply("migration", { name: "migration", id: "export:migration" }), /not supported/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("query scaffold preserves placement and inserts its named GraphQL document atomically", async (context) => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-query-recipe-"));
|
||||||
|
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
await execFile("git", ["-C", root, "init"]);
|
||||||
|
const source = { repository: "https://example.test/query.git", commit: "a".repeat(40) };
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(root, "package.qx"),
|
||||||
|
'import interface Task;\npackage Tasks id "tasks" revision "tasks@1" {}\n',
|
||||||
|
);
|
||||||
|
const request = (name: string, placement: string) => ({
|
||||||
|
source,
|
||||||
|
name,
|
||||||
|
id: `tasks:${name}`,
|
||||||
|
declaration: `query ${name} id "tasks:${name}" root Task document "queries/${name}.graphql" operation "${name}" { ${placement} max rows 100; }`,
|
||||||
|
queryDocument: `query ${name} { root { title } }\n`,
|
||||||
|
});
|
||||||
|
for (const [name, placement] of [
|
||||||
|
["Inputs", "replica;"],
|
||||||
|
["Screen", "local from Inputs;"],
|
||||||
|
["Confirmed", ""],
|
||||||
|
]) {
|
||||||
|
const spec = request(name, placement);
|
||||||
|
const recipe = await scaffoldRecipe(root, "query", spec);
|
||||||
|
const planned = await planStructure(root, recipe);
|
||||||
|
await assert.rejects(fs.access(path.join(root, `queries/${name}.graphql`)), "Preview must not write files");
|
||||||
|
await applyStructure(planned);
|
||||||
|
assert.equal(await fs.readFile(path.join(root, `queries/${name}.graphql`), "utf8"), spec.queryDocument);
|
||||||
|
assert.ok((await fs.readFile(path.join(root, "package.qx"), "utf8")).includes(placement));
|
||||||
|
await assert.rejects(scaffoldRecipe(root, "query", spec), /unique/);
|
||||||
|
}
|
||||||
|
const bad = request("Bad", "replica;");
|
||||||
|
await assert.rejects(
|
||||||
|
scaffoldRecipe(root, "query", {
|
||||||
|
...bad,
|
||||||
|
declaration: bad.declaration.replace("queries/Bad.graphql", "../Bad.graphql"),
|
||||||
|
}),
|
||||||
|
/contained/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
scaffoldRecipe(root, "query", { ...bad, queryDocument: "query Other { root { title } }" }),
|
||||||
|
/declared named query/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
scaffoldRecipe(root, "query", { ...bad, queryDocument: "mutation Bad { title }" }),
|
||||||
|
/declared named query/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
scaffoldRecipe(root, "query", { ...bad, declaration: bad.declaration + ' function x id "x" : unit -> unit;' }),
|
||||||
|
/one QX query/,
|
||||||
|
);
|
||||||
|
await fs.mkdir(path.join(root, "queries"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(root, "queries/Bad.graphql"), "# authored content\n");
|
||||||
|
const before = await fs.readFile(path.join(root, "package.qx"), "utf8");
|
||||||
|
await assert.rejects(planStructure(root, await scaffoldRecipe(root, "query", bad)), /existing file/);
|
||||||
|
assert.equal(await fs.readFile(path.join(root, "package.qx"), "utf8"), before);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user