diff --git a/src/query/compile.ts b/src/query/compile.ts index 68a9728..d9e0d49 100644 --- a/src/query/compile.ts +++ b/src/query/compile.ts @@ -634,15 +634,26 @@ export async function compileQuery( const definitionDigest = createHash("sha256") .update( canonicalJson({ - semantics: 5, + semantics: 6, declaration, normalized, fragmentContracts: Object.fromEntries(fragmentContracts), // Source provenance belongs to the checked installation. Identical public // contracts keep their query identity when retained in another repository. - // Closed generic identity remains explicit in revisionId/application. - interfaces: [...generated.byName.values()] - .map(({ source: _source, ...contract }) => contract) + // Closed generic identity remains explicit in revisionId/application; + // its template's retention source is provenance too. byName also holds + // names probed while discovering nominal projections, so only include + // interfaces actually present in the generated query schema. + interfaces: Object.values(generated.schema.getTypeMap()) + .filter(isObjectType) + .flatMap((type) => { + const revision = generated.byName.get(type.name); + if (!revision) return []; + const { source: _source, application, ...contract } = revision; + if (!application) return [contract]; + const { source: _templateSource, ...identity } = application; + return [{ ...contract, application: identity }]; + }) .sort((a, b) => (a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0)), }), ) diff --git a/test/query-compiler.test.ts b/test/query-compiler.test.ts index f4e1cb5..6ccac88 100644 --- a/test/query-compiler.test.ts +++ b/test/query-compiler.test.ts @@ -36,6 +36,50 @@ interface Tasks id "tasks" revision "tasks@1" { [facts], ); +test("query identity ignores unrelated imports and generic retention provenance", async () => { + const generic = iface(`interface Box id "box" revision "box@1" { + queryable relation item id "box:item" : optional-one object Item {resolve id "box:item:resolve";} + }`); + const compiled = compileCapabilityResourceSource( + `import interface Box; import interface TaskFacts; + package Queries id "queries" revision "queries@1" { + query Row id "row" root Box document "row.graphql" operation "Row" {replica;max rows 10;} + }`, + { + source, + environment: { + interfaces: new Map([ + ["Box", generic], + ["TaskFacts", facts], + ]), + interfaceClosure: [generic, facts], + }, + }, + ); + assert.ok(compiled.ok, JSON.stringify(compiled.diagnostics)); + if (compiled.resource.kind !== "package") throw new Error("package required"); + const declaration = compiled.resource.revision.queries![0]!; + const closed = compiled.resource.specializations![0]!; + const read = async () => "query Row {root {item {title}}}"; + const before = await compileQuery(declaration, [facts, closed], read); + const retained = structuredClone(closed); + retained.source = { repository: "https://local.test/box.git", commit: "b".repeat(40) }; + retained.application!.source = retained.source; + const unrelated = iface(`interface Unrelated id "unrelated" revision "unrelated@1" { + queryable value label id "label" : string {get id "label:get";} + }`); + assert.equal( + (await compileQuery(declaration, [unrelated, generic, retained, facts], read)).definitionDigest, + before.definitionDigest, + ); + const changed = structuredClone(facts); + changed.members[0]!.operations[0]!.id = "changed:title:get" as (typeof changed.members)[0]["operations"][0]["id"]; + assert.notEqual( + (await compileQuery(declaration, [changed, retained], read)).definitionDigest, + before.definitionDigest, + ); +}); + test("generic query templates check declared bounds before closed specialization", async () => { const generic = iface( `import interface TaskFacts;