refactor schema publishing logic (#7535)

This commit is contained in:
Laurin Quast 2026-01-27 15:56:40 +01:00 committed by GitHub
parent 305477f415
commit cb973b6b62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 518 additions and 613 deletions

View file

@ -2190,17 +2190,6 @@ test.concurrent(
});
await storage.destroy();
const validSdl = /* GraphQL */ `
type Query {
a(b: B!): String
}
input B {
a: String @deprecated(reason: "This field is deprecated")
b: String!
}
`;
const sdl = /* GraphQL */ `
type Query {
a(b: B!): String

View file

@ -7,16 +7,8 @@ import {
RegistryChecks,
type ConditionalBreakingChangeDiffConfig,
} from '../registry-checks';
import { swapServices } from '../schema-helper';
import { shouldUseLatestComposableVersion } from '../schema-manager';
import type { PublishInput } from '../schema-publisher';
import type {
DeletedCompositeSchema,
Organization,
Project,
PushedCompositeSchema,
Target,
} from './../../../../shared/entities';
import { CompositeSchemaInput, swapServices } from '../schema-helper';
import type { Organization, Project, Target } from './../../../../shared/entities';
import { ProjectType } from './../../../../shared/entities';
import { Logger } from './../../../shared/providers/logger';
import {
@ -32,7 +24,6 @@ import {
SchemaDeleteConclusion,
SchemaDeleteResult /* Publish */,
SchemaPublishConclusion,
SchemaPublishFailureReason,
SchemaPublishResult,
temp,
} from './shared';
@ -94,22 +85,14 @@ export class CompositeModel {
}
@traceFn('Composite modern: diffSchema')
async diffSchema({
input,
latest,
}: {
input: {
sdl: string;
serviceName: string;
url: string | null;
};
latest: {
schemas: Pick<PushedCompositeSchema, 'service_name' | 'sdl'>[];
} | null;
async diffSchema(args: {
existing: Array<Pick<CompositeSchemaInput, 'sdl' | 'serviceName'>> | null;
input: Pick<CompositeSchemaInput, 'sdl' | 'serviceName'>;
}) {
return this.checks.serviceDiff({
existingSdl: latest?.schemas?.find(s => s.service_name === input.serviceName)?.sdl ?? null,
incomingSdl: input.sdl,
existing:
args.existing?.find(schema => schema.serviceName === args.input.serviceName) ?? null,
incoming: args.input,
});
}
@ -133,11 +116,11 @@ export class CompositeModel {
contracts,
failDiffOnDangerousChange,
filterNestedChanges,
compareToLatestComposableVersion,
}: {
input: {
sdl: string;
serviceName: string;
url: string | null;
input: Pick<CompositeSchemaInput, 'sdl' | 'serviceName'> & {
// for a schema check the service url is optional
serviceUrl: string | null;
};
selector: {
organizationId: string;
@ -147,13 +130,13 @@ export class CompositeModel {
latest: {
isComposable: boolean;
sdl: string | null;
schemas: PushedCompositeSchema[];
schemas: CompositeSchemaInput[];
contractNames: string[] | null;
} | null;
latestComposable: {
isComposable: boolean;
sdl: string | null;
schemas: PushedCompositeSchema[];
schemas: CompositeSchemaInput[];
} | null;
baseSchema: string | null;
project: Project;
@ -161,6 +144,7 @@ export class CompositeModel {
approvedChanges: Map<string, SchemaChangeType>;
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
failDiffOnDangerousChange: null | boolean;
compareToLatestComposableVersion: boolean;
contracts: Array<
ContractInput & {
approvedChanges: Map<string, SchemaChangeType> | null;
@ -168,33 +152,21 @@ export class CompositeModel {
> | null;
filterNestedChanges: boolean;
}): Promise<SchemaCheckResult> {
const incoming: PushedCompositeSchema = {
kind: 'composite',
const incoming: CompositeSchemaInput = {
id: temp,
author: temp,
commit: temp,
target: selector.targetId,
date: Date.now(),
sdl: input.sdl,
service_name: input.serviceName,
service_url:
input.url ??
latest?.schemas?.find(s => s.service_name === input.serviceName)?.service_url ??
'temp',
action: 'PUSH',
serviceName: input.serviceName,
serviceUrl:
input.serviceUrl ??
latest?.schemas?.find(s => s.serviceName === input.serviceName)?.serviceUrl ??
temp,
metadata: null,
};
const schemaSwapResult = latest ? swapServices(latest.schemas, incoming) : null;
const schemas = schemaSwapResult ? schemaSwapResult.schemas : [incoming];
schemas.sort((a, b) => a.service_name.localeCompare(b.service_name));
const compareToPreviousComposableVersion = shouldUseLatestComposableVersion(
selector.targetId,
project,
organization,
);
const comparedVersion = compareToPreviousComposableVersion ? latestComposable : latest;
schemas.sort((a, b) => a.serviceName.localeCompare(b.serviceName));
const comparedVersion = compareToLatestComposableVersion ? latestComposable : latest;
const checksumCheck = await this.checks.checksum({
existing: schemaSwapResult?.existing
@ -346,93 +318,73 @@ export class CompositeModel {
contracts,
conditionalBreakingChangeDiffConfig,
failDiffOnDangerousChange,
compareToLatestComposableVersion,
}: {
input: PublishInput;
input: {
sdl: string;
service: string;
url: string | null;
metadata: string | null;
};
project: Project;
organization: Organization;
target: Target;
latest: {
isComposable: boolean;
sdl: string | null;
schemas: PushedCompositeSchema[];
schemas: CompositeSchemaInput[];
contractNames: string[] | null;
} | null;
latestComposable: {
isComposable: boolean;
sdl: string | null;
schemas: PushedCompositeSchema[];
schemas: CompositeSchemaInput[];
} | null;
baseSchema: string | null;
contracts: Array<ContractInput> | null;
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
failDiffOnDangerousChange: null | boolean;
compareToLatestComposableVersion: boolean;
}): Promise<SchemaPublishResult> {
const incoming: PushedCompositeSchema = {
kind: 'composite',
const latestSchemaVersion = latest;
const latestServiceVersion = latest?.schemas?.find(
schema => schema.serviceName === input.service,
);
const serviceUrlCheck = await this.checks.serviceUrl(
input.url ?? null,
latestServiceVersion?.serviceUrl ?? null,
);
if (serviceUrlCheck.status === 'failed') {
return {
conclusion: SchemaPublishConclusion.Reject,
reasons: [
{
code: PublishFailureReasonCode.MissingServiceUrl,
},
],
};
}
const incoming: CompositeSchemaInput = {
id: temp,
author: input.author,
sdl: input.sdl,
commit: input.commit,
target: target.id,
date: Date.now(),
service_name: input.service || '',
service_url: input.url || null,
action: 'PUSH',
serviceName: input.service,
serviceUrl: serviceUrlCheck.result.serviceUrl,
metadata: input.metadata ?? null,
};
const latestVersion = latest;
const schemaSwapResult = latestVersion ? swapServices(latestVersion.schemas, incoming) : null;
const schemaSwapResult = latestSchemaVersion
? swapServices(latestSchemaVersion.schemas, incoming)
: null;
const previousService = schemaSwapResult?.existing;
// default to previous service url if not provided.
incoming.service_url = incoming.service_url ?? previousService?.service_url ?? '';
const schemas = schemaSwapResult?.schemas ?? [incoming];
schemas.sort((a, b) => a.service_name.localeCompare(b.service_name));
const compareToLatestComposable = shouldUseLatestComposableVersion(
target.id,
project,
organization,
);
const schemaVersionToCompareAgainst = compareToLatestComposable ? latestComposable : latest;
const [serviceNameCheck, serviceUrlCheck] = await Promise.all([
this.checks.serviceName({
name: incoming.service_name,
}),
this.checks.serviceUrl(
{
url: incoming.service_url,
},
previousService
? {
url: previousService.service_url,
}
: null,
),
]);
if (serviceNameCheck.status === 'failed' || serviceUrlCheck.status === 'failed') {
const reasons: SchemaPublishFailureReason[] = [];
if (serviceNameCheck.status === 'failed') {
reasons.push({
code: PublishFailureReasonCode.MissingServiceName,
});
}
if (serviceUrlCheck.status === 'failed') {
reasons.push({
code: PublishFailureReasonCode.MissingServiceUrl,
});
}
return {
conclusion: SchemaPublishConclusion.Reject,
reasons,
};
}
schemas.sort((a, b) => a.serviceName.localeCompare(b.serviceName));
const schemaVersionToCompareAgainst = compareToLatestComposableVersion
? latestComposable
: latest;
const checksumCheck = await this.checks.checksum({
existing: schemaSwapResult?.existing
@ -503,7 +455,7 @@ export class CompositeModel {
if (
compositionCheck.status === 'failed' &&
compositionCheck.reason.errorsBySource.graphql.length > 0 &&
!compareToLatestComposable
!compareToLatestComposableVersion
) {
return {
conclusion: SchemaPublishConclusion.Reject,
@ -573,7 +525,7 @@ export class CompositeModel {
conclusion: SchemaPublishConclusion.Publish,
state: {
composable: compositionCheck.status === 'completed',
initial: latestVersion === null,
initial: latestSchemaVersion === null,
changes: diffCheck.result?.all ?? diffCheck.reason?.all ?? null,
coordinatesDiff:
diffCheck.result?.coordinatesDiff ??
@ -619,6 +571,7 @@ export class CompositeModel {
conditionalBreakingChangeDiffConfig,
contracts,
failDiffOnDangerousChange,
compareToLatestComposableVersion,
}: {
input: {
serviceName: string;
@ -634,50 +587,22 @@ export class CompositeModel {
latest: {
isComposable: boolean;
sdl: string | null;
schemas: PushedCompositeSchema[];
schemas: CompositeSchemaInput[];
};
latestComposable: {
isComposable: boolean;
sdl: string | null;
schemas: PushedCompositeSchema[];
schemas: CompositeSchemaInput[];
} | null;
contracts: Array<ContractInput> | null;
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
failDiffOnDangerousChange: null | boolean;
compareToLatestComposableVersion: boolean;
}): Promise<SchemaDeleteResult> {
const incoming: DeletedCompositeSchema = {
kind: 'composite',
id: temp,
target: selector.target,
date: Date.now(),
service_name: input.serviceName,
action: 'DELETE',
};
const latestVersion = latest;
const compareToLatestComposable = shouldUseLatestComposableVersion(
selector.target,
project,
organization,
);
const serviceNameCheck = await this.checks.serviceName({
name: incoming.service_name,
});
if (serviceNameCheck.status === 'failed') {
return {
conclusion: SchemaDeleteConclusion.Reject,
reasons: [
{
code: DeleteFailureReasonCode.MissingServiceName,
},
],
};
}
const schemas = latestVersion.schemas.filter(s => s.service_name !== input.serviceName);
schemas.sort((a, b) => a.service_name.localeCompare(b.service_name));
const schemas = latestVersion.schemas.filter(s => s.serviceName !== input.serviceName);
schemas.sort((a, b) => a.serviceName.localeCompare(b.serviceName));
const compositionCheck = await this.checks.composition({
targetId: selector.target,
@ -698,7 +623,7 @@ export class CompositeModel {
});
const previousVersionSdl = await this.checks.retrievePreviousVersionSdl({
version: compareToLatestComposable ? latestComposable : latest,
version: compareToLatestComposableVersion ? latestComposable : latest,
organization,
project,
targetId: selector.target,
@ -744,7 +669,7 @@ export class CompositeModel {
compositionCheck.status === 'failed' &&
compositionCheck.reason.errorsBySource.graphql.length > 0
) {
if (!compareToLatestComposable) {
if (!compareToLatestComposableVersion) {
return {
conclusion: SchemaDeleteConclusion.Reject,
reasons: [

View file

@ -1,4 +1,3 @@
import { PushedCompositeSchema, SingleSchema } from 'packages/services/api/src/shared/entities';
import type { CheckPolicyResponse } from '@hive/policy';
import { CompositionFailureError } from '@hive/schema';
import type { SchemaChangeType, SchemaCompositionError } from '@hive/storage';
@ -12,6 +11,7 @@ import type {
SchemaDiffSkip,
SchemaDiffSuccess,
} from '../registry-checks';
import type { CompositeSchemaInput, SingleSchemaInput } from '../schema-helper';
export const SchemaPublishConclusion = {
/**
@ -180,7 +180,6 @@ export const PublishIgnoreReasonCode = {
export const PublishFailureReasonCode = {
MissingServiceUrl: 'MISSING_SERVICE_URL',
MissingServiceName: 'MISSING_SERVICE_NAME',
CompositionFailure: 'COMPOSITION_FAILURE',
BreakingChanges: 'BREAKING_CHANGES',
MetadataParsingFailure: 'METADATA_PARSING_FAILURE',
@ -192,9 +191,6 @@ export type PublishFailureReasonCode =
(typeof PublishFailureReasonCode)[keyof typeof PublishFailureReasonCode];
export type SchemaPublishFailureReason =
| {
code: (typeof PublishFailureReasonCode)['MissingServiceName'];
}
| {
code: (typeof PublishFailureReasonCode)['MissingServiceUrl'];
}
@ -235,8 +231,8 @@ type SchemaPublishSuccess = {
message: string;
}> | null;
compositionErrors: Array<SchemaCompositionError> | null;
schema: SingleSchema | PushedCompositeSchema;
schemas: [SingleSchema] | PushedCompositeSchema[];
schema: SingleSchemaInput | CompositeSchemaInput;
schemas: [SingleSchemaInput] | CompositeSchemaInput[];
supergraph: string | null;
fullSchemaSdl: string | null;
tags: null | Array<string>;
@ -285,7 +281,7 @@ export type SchemaDeleteSuccess = {
conclusion: (typeof SchemaDeleteConclusion)['Accept'];
state: {
changes: Array<SchemaChangeType> | null;
schemas: PushedCompositeSchema[];
schemas: CompositeSchemaInput[];
breakingChanges: Array<SchemaChangeType> | null;
compositionErrors: Array<SchemaCompositionError> | null;
coordinatesDiff: SchemaCoordinatesDiffResult | null;

View file

@ -7,8 +7,8 @@ import {
GetAffectedAppDeployments,
RegistryChecks,
} from '../registry-checks';
import type { PublishInput } from '../schema-publisher';
import type { Organization, Project, SingleSchema, Target } from './../../../../shared/entities';
import { SingleSchemaInput } from '../schema-helper';
import type { Organization, Project, Target } from './../../../../shared/entities';
import { Logger } from './../../../shared/providers/logger';
import {
buildSchemaCheckFailureState,
@ -32,21 +32,11 @@ export class SingleModel {
) {}
@traceFn('Single modern: diffSchema')
async diffSchema({
input,
latest,
}: {
input: {
sdl: string;
};
latest: {
schemas: [SingleSchema];
} | null;
async diffSchema(args: {
incoming: Pick<SingleSchemaInput, 'sdl'>;
existing: Pick<SingleSchemaInput, 'sdl'> | null;
}) {
return this.checks.serviceDiff({
existingSdl: latest?.schemas[0]?.sdl ?? null,
incomingSdl: input.sdl,
});
return this.checks.serviceDiff(args);
}
@traceFn('Single modern: check', {
@ -69,9 +59,7 @@ export class SingleModel {
failDiffOnDangerousChange,
filterNestedChanges,
}: {
input: {
sdl: string;
};
input: Pick<SingleSchemaInput, 'sdl'>;
selector: {
organizationId: string;
projectId: string;
@ -80,12 +68,12 @@ export class SingleModel {
latest: {
isComposable: boolean;
sdl: string | null;
schemas: [SingleSchema];
schemas: [SingleSchemaInput];
} | null;
latestComposable: {
isComposable: boolean;
sdl: string | null;
schemas: [SingleSchema];
schemas: [SingleSchemaInput];
} | null;
baseSchema: string | null;
project: Project;
@ -95,21 +83,18 @@ export class SingleModel {
failDiffOnDangerousChange: boolean;
filterNestedChanges: boolean;
}): Promise<SchemaCheckResult> {
const incoming: SingleSchema = {
kind: 'single',
const incoming: SingleSchemaInput = {
id: temp,
author: temp,
commit: temp,
target: selector.targetId,
date: Date.now(),
sdl: input.sdl,
metadata: null,
serviceName: null,
serviceUrl: null,
};
const schemas = [incoming] as [SingleSchema];
const compareToPreviousComposableVersion =
organization.featureFlags.compareToPreviousComposableVersion;
const comparedVersion = compareToPreviousComposableVersion ? latestComposable : latest;
const schemas = [incoming] as [SingleSchemaInput];
const comparedVersion = organization.featureFlags.compareToPreviousComposableVersion
? latestComposable
: latest;
const checksumResult = await this.checks.checksum({
existing: latest
@ -220,37 +205,37 @@ export class SingleModel {
conditionalBreakingChangeDiffConfig,
failDiffOnDangerousChange,
}: {
input: PublishInput;
input: {
sdl: string;
metadata: string | null;
};
organization: Organization;
project: Project;
target: Target;
latest: {
isComposable: boolean;
sdl: string | null;
schemas: [SingleSchema];
schemas: [SingleSchemaInput];
} | null;
latestComposable: {
isComposable: boolean;
sdl: string | null;
schemas: [SingleSchema];
schemas: [SingleSchemaInput];
} | null;
baseSchema: string | null;
conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
failDiffOnDangerousChange: boolean;
}): Promise<SchemaPublishResult> {
const incoming: SingleSchema = {
kind: 'single',
const incoming: SingleSchemaInput = {
id: temp,
author: input.author,
sdl: input.sdl,
commit: input.commit,
target: target.id,
date: Date.now(),
metadata: input.metadata ?? null,
metadata: input.metadata,
serviceName: null,
serviceUrl: null,
};
const latestVersion = latest;
const schemas = [incoming] as [SingleSchema];
const schemas = [incoming] as [SingleSchemaInput];
const compareToPreviousComposableVersion =
organization.featureFlags.compareToPreviousComposableVersion;
const comparedVersion = compareToPreviousComposableVersion ? latestComposable : latest;

View file

@ -20,14 +20,12 @@ import type {
DateRange,
Organization,
Project,
PushedCompositeSchema,
SingleSchema,
} from './../../../shared/entities';
import { Logger } from './../../shared/providers/logger';
import { diffSchemaCoordinates, Inspector, SchemaCoordinatesDiffResult } from './inspector';
import { SchemaCheckWarning } from './models/shared';
import { CompositionOrchestrator } from './orchestrator/composition-orchestrator';
import { extendWithBase, isCompositeSchema, SchemaHelper } from './schema-helper';
import { CompositeSchemaInput, extendWithBase, SchemaHelper, SchemaInput } from './schema-helper';
export type ConditionalBreakingChangeDiffConfig = {
period: DateRange;
@ -75,8 +73,6 @@ export type CheckResult<C = unknown, F = unknown, S = unknown> =
data?: S;
};
type Schemas = [SingleSchema] | PushedCompositeSchema[];
type CompositionValidationError = {
message: string;
source: 'composition';
@ -205,11 +201,11 @@ export class RegistryChecks {
*/
async checksum(args: {
incoming: {
schema: SingleSchema | PushedCompositeSchema;
schema: SchemaInput;
contractNames: null | Array<string>;
};
existing: null | {
schema: SingleSchema | PushedCompositeSchema;
schema: SchemaInput;
contractNames: null | Array<string>;
};
}) {
@ -276,7 +272,7 @@ export class RegistryChecks {
targetId: string;
project: Project;
organization: Organization;
schemas: Schemas;
schemas: Array<SchemaInput>;
baseSchema: string | null;
contracts: null | ContractsInputType;
}) {
@ -337,7 +333,7 @@ export class RegistryChecks {
version: {
isComposable: boolean;
sdl: string | null;
schemas: Schemas;
schemas: Array<SchemaInput>;
} | null;
organization: Organization;
project: Project;
@ -433,28 +429,20 @@ export class RegistryChecks {
*/
async serviceDiff(args: {
/** The existing SDL */
existingSdl: string | null;
existing: Pick<SchemaInput, 'sdl'> | null;
/** The incoming SDL */
incomingSdl: string | null;
incoming: Pick<SchemaInput, 'sdl'> | null;
}) {
let existingSchema: GraphQLSchema | null;
let incomingSchema: GraphQLSchema | null;
try {
existingSchema = args.existingSdl
? buildSortedSchemaFromSchemaObject(
this.helper.createSchemaObject({
sdl: args.existingSdl,
}),
)
existingSchema = args.existing
? buildSortedSchemaFromSchemaObject(this.helper.createSchemaObject(args.existing))
: null;
incomingSchema = args.incomingSdl
? buildSortedSchemaFromSchemaObject(
this.helper.createSchemaObject({
sdl: args.incomingSdl,
}),
)
incomingSchema = args.incoming
? buildSortedSchemaFromSchemaObject(this.helper.createSchemaObject(args.incoming))
: null;
} catch (error) {
this.logger.error('Failed to build schema for diff. Skip diff check.');
@ -496,8 +484,8 @@ export class RegistryChecks {
includeUrlChanges:
| false
| {
schemasBefore: [SingleSchema] | PushedCompositeSchema[];
schemasAfter: [SingleSchema] | PushedCompositeSchema[];
schemasBefore: CompositeSchemaInput[];
schemasAfter: CompositeSchemaInput[];
};
/** Whether Federation directive related changes should be filtered out from the list of changes. These would only show up due to an internal bug. */
filterOutFederationChanges: boolean;
@ -522,6 +510,8 @@ export class RegistryChecks {
? buildSortedSchemaFromSchemaObject(
this.helper.createSchemaObject({
sdl: args.existingSdl,
serviceName: null,
serviceUrl: null,
}),
)
: null;
@ -530,6 +520,8 @@ export class RegistryChecks {
? buildSortedSchemaFromSchemaObject(
this.helper.createSchemaObject({
sdl: args.incomingSdl,
serviceName: null,
serviceUrl: null,
}),
)
: null;
@ -734,8 +726,8 @@ export class RegistryChecks {
if (args.includeUrlChanges) {
inspectorChanges.push(
...detectUrlChanges(
args.includeUrlChanges.schemasBefore.filter(isCompositeSchema),
args.includeUrlChanges.schemasAfter.filter(isCompositeSchema),
args.includeUrlChanges.schemasBefore,
args.includeUrlChanges.schemasAfter,
),
);
}
@ -812,23 +804,6 @@ export class RegistryChecks {
} satisfies SchemaDiffSuccess;
}
async serviceName(service: { name: string | null }) {
if (!service.name) {
this.logger.debug('No service name');
return {
status: 'failed',
reason: 'Service name is required',
} satisfies CheckResult;
}
this.logger.debug('Service name is defined');
return {
status: 'completed',
result: null,
} satisfies CheckResult;
}
private isValidURL(url: string): boolean {
try {
new URL(url);
@ -839,21 +814,35 @@ export class RegistryChecks {
}
}
async serviceUrl(
service: { url: string | null },
existingService: { url: string | null } | null,
) {
if (!service.url) {
this.logger.debug('No service url');
async serviceUrl(newServiceUrl: string | null, existingServiceUrl: string | null) {
if (newServiceUrl === null) {
if (existingServiceUrl) {
return {
status: 'completed',
result: {
status: 'unchanged' as const,
serviceUrl: existingServiceUrl,
},
} satisfies CheckResult;
}
return {
status: 'failed',
reason: 'Service url is required',
} satisfies CheckResult;
}
this.logger.debug('Service url is defined');
if (newServiceUrl === existingServiceUrl) {
return {
status: 'completed',
result: {
status: 'unchanged' as const,
serviceUrl: existingServiceUrl,
},
} satisfies CheckResult;
}
if (!this.isValidURL(service.url)) {
if (!this.isValidURL(newServiceUrl)) {
return {
status: 'failed',
reason: 'Invalid service URL provided',
@ -862,19 +851,13 @@ export class RegistryChecks {
return {
status: 'completed',
result:
existingService && service.url !== existingService.url
? {
before: existingService.url,
after: service.url,
message: service.url
? `New service url: ${service.url} (previously: ${existingService.url ?? 'none'})`
: `Service url removed (previously: ${existingService.url ?? 'none'})`,
status: 'modified' as const,
}
: {
status: 'unchanged' as const,
},
result: {
message: `New service url: ${newServiceUrl} (previously: ${existingServiceUrl ?? 'none'})`,
status: 'modified' as const,
before: existingServiceUrl,
after: newServiceUrl,
serviceUrl: newServiceUrl,
},
} satisfies CheckResult;
}
@ -948,8 +931,8 @@ export class RegistryChecks {
}
type SubgraphDefinition = {
service_name: string;
service_url: string | null;
serviceName: string;
serviceUrl: string | null;
};
export function detectUrlChanges(
@ -964,49 +947,49 @@ export function detectUrlChanges(
return [];
}
const nameToCompositeSchemaMap = new Map(subgraphsBefore.map(s => [s.service_name, s]));
const nameToCompositeSchemaMap = new Map(subgraphsBefore.map(s => [s.serviceName, s]));
const changes: Array<RegistryServiceUrlChangeSerializableChange> = [];
for (const schema of subgraphsAfter) {
const before = nameToCompositeSchemaMap.get(schema.service_name);
const before = nameToCompositeSchemaMap.get(schema.serviceName);
if (before && before.service_url !== schema.service_url) {
if (before.service_url != null && schema.service_url != null) {
if (before && before.serviceUrl !== schema.serviceUrl) {
if (before.serviceUrl != null && schema.serviceUrl != null) {
changes.push({
type: 'REGISTRY_SERVICE_URL_CHANGED',
meta: {
serviceName: schema.service_name,
serviceName: schema.serviceName,
serviceUrls: {
old: before.service_url,
new: schema.service_url,
old: before.serviceUrl,
new: schema.serviceUrl,
},
},
});
} else if (before.service_url != null && schema.service_url == null) {
} else if (before.serviceUrl != null && schema.serviceUrl == null) {
changes.push({
type: 'REGISTRY_SERVICE_URL_CHANGED',
meta: {
serviceName: schema.service_name,
serviceName: schema.serviceName,
serviceUrls: {
old: before.service_url,
old: before.serviceUrl,
new: null,
},
},
});
} else if (before.service_url == null && schema.service_url != null) {
} else if (before.serviceUrl == null && schema.serviceUrl != null) {
changes.push({
type: 'REGISTRY_SERVICE_URL_CHANGED',
meta: {
serviceName: schema.service_name,
serviceName: schema.serviceName,
serviceUrls: {
old: null,
new: schema.service_url,
new: schema.serviceUrl,
},
},
});
} else {
throw new Error(
`This shouldn't happen (before.service_url=${JSON.stringify(before.service_url)}, schema.service_url=${JSON.stringify(schema.service_url)}).`,
`This shouldn't happen (before.serviceUrl=${JSON.stringify(before.serviceUrl)}, schema.serviceUrl=${JSON.stringify(schema.serviceUrl)}).`,
);
}
}

View file

@ -4,6 +4,7 @@ import { Injectable, Scope } from 'graphql-modules';
import objectHash from 'object-hash';
import type {
CompositeSchema,
CreateSchemaObjectInput,
PushedCompositeSchema,
Schema,
SchemaObject,
@ -46,15 +47,15 @@ export function serviceExists(schemas: CompositeSchema[], serviceName: string) {
}
export function swapServices(
schemas: CompositeSchema[],
newSchema: CompositeSchema,
schemas: CompositeSchemaInput[],
newSchema: CompositeSchemaInput,
): {
schemas: CompositeSchema[];
existing: CompositeSchema | null;
schemas: CompositeSchemaInput[];
existing: CompositeSchemaInput | null;
} {
let swapped: CompositeSchema | null = null;
let swapped: CompositeSchemaInput | null = null;
const output = schemas.map(existing => {
if (existing.service_name === newSchema.service_name) {
if (existing.serviceName === newSchema.serviceName) {
swapped = existing;
return newSchema;
}
@ -72,10 +73,7 @@ export function swapServices(
};
}
export function extendWithBase(
schemas: CompositeSchema[] | [SingleSchema],
baseSchema: string | null,
) {
export function extendWithBase(schemas: Array<SchemaInput>, baseSchema: string | null) {
if (!baseSchema) {
return schemas;
}
@ -105,8 +103,6 @@ export function removeDescriptions(documentNode: DocumentNode): DocumentNode {
});
}
type CreateSchemaObjectInput = Parameters<typeof createSchemaObject>[0];
@Injectable({
scope: Scope.Operation,
global: true,
@ -117,31 +113,60 @@ export class SchemaHelper {
return createSchemaObject(schema);
}
createChecksum(schema: SingleSchema | PushedCompositeSchema): string {
createChecksum(schema: SchemaInput): string {
const hasher = createHash('md5');
hasher.update(print(sortDocumentNode(this.createSchemaObject(schema).document)), 'utf-8');
hasher.update(`service_name: ${schema.serviceName ?? ''}`, 'utf-8');
hasher.update(`service_url: ${schema.serviceUrl ?? ''}`, 'utf-8');
hasher.update(
`service_name: ${
'service_name' in schema && typeof schema.service_name === 'string'
? schema.service_name
: ''
}`,
'utf-8',
);
hasher.update(
`service_url: ${
'service_url' in schema && typeof schema.service_url === 'string' ? schema.service_url : ''
}`,
'utf-8',
);
hasher.update(
`metadata: ${
'metadata' in schema && schema.metadata ? objectHash(JSON.parse(schema.metadata)) : ''
}`,
`metadata: ${schema.metadata ? objectHash(JSON.parse(schema.metadata)) : ''}`,
'utf-8',
);
return hasher.digest('hex');
}
}
export function toCompositeSchemaInput(schema: PushedCompositeSchema): CompositeSchemaInput {
return {
id: schema.id,
metadata: schema.metadata,
sdl: schema.sdl,
serviceName: schema.service_name,
// service_url can be null for very old records from 2023
// The default value mapping should happen on the database read level
// but right now we are doing that here until we refactor the database read level (Storage class)
serviceUrl: schema.service_url ?? '',
};
}
export function toSingleSchemaInput(schema: SingleSchema): SingleSchemaInput {
return {
id: schema.id,
metadata: schema.metadata,
sdl: schema.sdl,
// Note: due to a "bug" we inserted service_name for single schemas into the schema_log table.
// We set it explicitly to null to avoid any confusion in other parts of the business logic
serviceName: null,
serviceUrl: null,
};
}
export type CompositeSchemaInput = {
id: string;
sdl: string;
serviceName: string;
serviceUrl: string;
metadata: string | null;
};
export type SingleSchemaInput = {
id: string;
sdl: string;
serviceName: null;
serviceUrl: null;
metadata: string | null;
};
export type SchemaInput = CompositeSchemaInput | SingleSchemaInput;

View file

@ -162,7 +162,7 @@ export class SchemaManager {
},
});
const [organization, project, latestSchemas] = await Promise.all([
const [organization, project, target] = await Promise.all([
this.storage.getOrganization({
organizationId: selector.organizationId,
}),
@ -170,11 +170,10 @@ export class SchemaManager {
organizationId: selector.organizationId,
projectId: selector.projectId,
}),
this.storage.getLatestSchemas({
this.storage.getTarget({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
onlyComposable: input.onlyComposable,
}),
]);
@ -185,21 +184,26 @@ export class SchemaManager {
};
}
const latestSchemas = await this.getLatestSchemaVersionWithSchemaLogs({
target,
onlyComposable: input.onlyComposable,
});
const existingServices = ensureCompositeSchemas(latestSchemas ? latestSchemas.schemas : []);
const services = existingServices
// remove provided services from the list
.filter(service => !input.services.some(s => s.name === service.service_name))
.map(service => ({
service_name: service.service_name,
serviceName: service.service_name,
sdl: service.sdl,
service_url: service.service_url,
serviceUrl: service.service_url,
}))
// add provided services to the list
.concat(
input.services.map(service => ({
service_name: service.name,
serviceName: service.name,
sdl: service.sdl,
service_url: service.url ?? null,
serviceUrl: service.url ?? null,
})),
)
.map(service => this.schemaHelper.createSchemaObject(service));
@ -346,6 +350,29 @@ export class SchemaManager {
};
}
/**
* Retrieve the latest schema version including the schema logs.
*/
async getLatestSchemaVersionWithSchemaLogs(args: { target: Target; onlyComposable?: boolean }) {
const schemaVersion = await (args.onlyComposable
? this.getMaybeLatestValidVersion(args.target)
: this.getMaybeLatestVersion(args.target));
if (!schemaVersion) {
return null;
}
const schemas = await this.storage.getSchemasOfVersion({
versionId: schemaVersion.id,
includeMetadata: true,
});
return {
version: schemaVersion,
schemas,
};
}
async getPaginatedSchemaVersionsForTargetId(args: {
targetId: string;
organizationId: string;
@ -1228,8 +1255,8 @@ export class SchemaManager {
ensureCompositeSchemas(schemas).map(s =>
this.schemaHelper.createSchemaObject({
sdl: s.sdl,
service_name: s.service_name,
service_url: s.service_url,
serviceName: s.service_name,
serviceUrl: s.service_url,
}),
),
{

View file

@ -14,7 +14,7 @@ import type {
} from '@hive/storage';
import * as Sentry from '@sentry/node';
import * as Types from '../../../__generated__/types';
import { Organization, Project, ProjectType, Schema, Target } from '../../../shared/entities';
import { Organization, Project, ProjectType, Target } from '../../../shared/entities';
import { HiveError } from '../../../shared/errors';
import { createPeriod } from '../../../shared/helpers';
import { isGitHubRepositoryString } from '../../../shared/is-github-repository-string';
@ -54,7 +54,13 @@ import {
} from './models/shared';
import { SingleModel } from './models/single';
import type { ConditionalBreakingChangeDiffConfig } from './registry-checks';
import { ensureCompositeSchemas, ensureSingleSchema } from './schema-helper';
import {
ensureCompositeSchemas,
ensureSingleSchema,
SchemaInput,
toCompositeSchemaInput,
toSingleSchemaInput,
} from './schema-helper';
import { SchemaManager, shouldUseLatestComposableVersion } from './schema-manager';
import { SchemaVersionHelper } from './schema-version-helper';
@ -102,6 +108,10 @@ type BreakPromise<T> = T extends Promise<infer U> ? U : never;
type PublishResult =
| BreakPromise<ReturnType<SchemaPublisher['internalPublish']>>
| {
readonly __typename: 'SchemaPublishMissingServiceError';
message: 'Missing service name';
}
| {
readonly __typename: 'SchemaPublishRetry';
readonly reason: string;
@ -327,37 +337,26 @@ export class SchemaPublisher {
},
});
const [target, project, organization, latestVersion, latestComposableVersion, schemaProposal] =
await Promise.all([
this.storage.getTarget({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
this.storage.getProject({
organizationId: selector.organizationId,
projectId: selector.projectId,
}),
this.storage.getOrganization({
organizationId: selector.organizationId,
}),
this.storage.getLatestSchemas({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
this.storage.getLatestSchemas({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
onlyComposable: true,
}),
input.schemaProposalId
? this.schemaProposals.getProposal({
id: input.schemaProposalId,
})
: null,
]);
const [target, project, organization, schemaProposal] = await Promise.all([
this.storage.getTarget({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
this.storage.getProject({
organizationId: selector.organizationId,
projectId: selector.projectId,
}),
this.storage.getOrganization({
organizationId: selector.organizationId,
}),
input.schemaProposalId
? this.schemaProposals.getProposal({
id: input.schemaProposalId,
})
: null,
]);
if (input.schemaProposalId && schemaProposal?.targetId !== selector.targetId) {
return {
@ -374,6 +373,16 @@ export class SchemaPublisher {
} as const;
}
const [latestVersion, latestComposableVersion] = await Promise.all([
this.schemaManager.getLatestSchemaVersionWithSchemaLogs({
target,
}),
this.schemaManager.getLatestSchemaVersionWithSchemaLogs({
target,
onlyComposable: true,
}),
]);
if (input.service) {
let serviceExists = false;
if (latestVersion?.schemas) {
@ -398,11 +407,6 @@ export class SchemaPublisher {
}
}
const [latestSchemaVersion, latestComposableSchemaVersion] = await Promise.all([
this.schemaManager.getMaybeLatestVersion(target),
this.schemaManager.getMaybeLatestValidVersion(target),
]);
function increaseSchemaCheckCountMetric(conclusion: 'rejected' | 'accepted') {
schemaCheckCount.inc({
model: 'modern',
@ -593,9 +597,9 @@ export class SchemaPublisher {
selector,
});
const latestSchemaVersionContracts = latestSchemaVersion
const latestSchemaVersionContracts = latestVersion
? await this.contracts.getContractVersionsForSchemaVersion({
schemaVersionId: latestSchemaVersion.id,
schemaVersionId: latestVersion.version.id,
})
: null;
@ -607,14 +611,12 @@ export class SchemaPublisher {
if (input.schemaProposalId) {
try {
const diffSchema = await this.models[project.type].diffSchema({
input: {
existing: latestVersion
? toSingleSchemaInput(ensureSingleSchema(latestVersion.schemas))
: null,
incoming: {
sdl,
},
latest: latestVersion
? {
schemas: [ensureSingleSchema(latestVersion.schemas)],
}
: null,
});
if ('result' in diffSchema) {
proposalChanges = diffSchema.result ?? null;
@ -625,20 +627,22 @@ export class SchemaPublisher {
}
checkResult = await this.models[ProjectType.SINGLE].check({
input,
input: {
sdl: input.sdl,
},
selector,
latest: latestVersion
? {
isComposable: latestVersion.valid,
sdl: latestSchemaVersion?.compositeSchemaSDL ?? null,
schemas: [ensureSingleSchema(latestVersion.schemas)],
isComposable: latestVersion.version.isComposable,
sdl: latestVersion.version.compositeSchemaSDL,
schemas: [toSingleSchemaInput(ensureSingleSchema(latestVersion.schemas))],
}
: null,
latestComposable: latestComposableVersion
? {
isComposable: latestComposableVersion.valid,
sdl: latestComposableSchemaVersion?.compositeSchemaSDL ?? null,
schemas: [ensureSingleSchema(latestComposableVersion.schemas)],
isComposable: latestComposableVersion.version.isComposable,
sdl: latestComposableVersion.version.compositeSchemaSDL,
schemas: [toSingleSchemaInput(ensureSingleSchema(latestComposableVersion.schemas))],
}
: null,
baseSchema,
@ -665,12 +669,9 @@ export class SchemaPublisher {
input: {
sdl,
serviceName: input.service,
url: input.url ?? null,
},
latest: latestVersion
? {
schemas: ensureCompositeSchemas(latestVersion.schemas),
}
existing: latestVersion
? ensureCompositeSchemas(latestVersion.schemas).map(toCompositeSchemaInput)
: null,
});
if ('result' in diffSchema) {
@ -685,23 +686,25 @@ export class SchemaPublisher {
input: {
sdl,
serviceName: input.service,
url: input.url ?? null,
serviceUrl: input.url ?? null,
},
selector,
latest: latestVersion
? {
isComposable: latestVersion.valid,
sdl: latestSchemaVersion?.compositeSchemaSDL ?? null,
schemas: ensureCompositeSchemas(latestVersion.schemas),
isComposable: latestVersion.version.isComposable,
sdl: latestVersion.version.compositeSchemaSDL,
schemas: ensureCompositeSchemas(latestVersion.schemas).map(toCompositeSchemaInput),
contractNames:
latestSchemaVersionContracts?.edges.map(edge => edge.node.contractName) ?? null,
}
: null,
latestComposable: latestComposableVersion
? {
isComposable: latestComposableVersion.valid,
sdl: latestComposableSchemaVersion?.compositeSchemaSDL ?? null,
schemas: ensureCompositeSchemas(latestComposableVersion.schemas),
isComposable: latestComposableVersion.version.isComposable,
sdl: latestComposableVersion.version.compositeSchemaSDL,
schemas: ensureCompositeSchemas(latestComposableVersion.schemas).map(
toCompositeSchemaInput,
),
}
: null,
baseSchema,
@ -716,7 +719,12 @@ export class SchemaPublisher {
conditionalBreakingChangeDiffConfig:
conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null,
failDiffOnDangerousChange,
filterNestedChanges: true,
compareToLatestComposableVersion: shouldUseLatestComposableVersion(
selector.targetId,
project,
organization,
),
filterNestedChanges: !input.schemaProposalId,
});
break;
default:
@ -736,7 +744,7 @@ export class SchemaPublisher {
serviceUrl: input.url ?? null,
meta: input.meta ?? null,
targetId: target.id,
schemaVersionId: latestVersion?.versionId ?? null,
schemaVersionId: latestVersion?.version.id ?? null,
isSuccess: false,
breakingSchemaChanges: checkResult.state.schemaChanges?.breaking ?? null,
safeSchemaChanges: checkResult.state.schemaChanges?.safe ?? null,
@ -792,7 +800,7 @@ export class SchemaPublisher {
serviceUrl: input.url ?? null,
meta: input.meta ?? null,
targetId: target.id,
schemaVersionId: latestVersion?.versionId ?? null,
schemaVersionId: latestVersion?.version.id ?? null,
isSuccess: true,
breakingSchemaChanges: checkResult.state?.schemaChanges?.breaking ?? null,
safeSchemaChanges: checkResult.state?.schemaChanges?.safe ?? null,
@ -834,14 +842,14 @@ export class SchemaPublisher {
});
this.logger.info('created successful schema check. (schemaCheckId=%s)', schemaCheck.id);
} else if (checkResult.conclusion === SchemaCheckConclusion.Skip) {
if (!latestVersion || !latestSchemaVersion) {
if (!latestVersion) {
throw new Error('This cannot happen 1 :)');
}
const [compositeSchemaSdl, supergraphSdl, compositionErrors] = await Promise.all([
this.schemaVersionHelper.getCompositeSchemaSdl(latestSchemaVersion),
this.schemaVersionHelper.getSupergraphSdl(latestSchemaVersion),
this.schemaVersionHelper.getSchemaCompositionErrors(latestSchemaVersion),
this.schemaVersionHelper.getCompositeSchemaSdl(latestVersion.version),
this.schemaVersionHelper.getSupergraphSdl(latestVersion.version),
this.schemaVersionHelper.getSchemaCompositionErrors(latestVersion.version),
]);
schemaCheck = await this.storage.createSchemaCheck({
@ -850,7 +858,7 @@ export class SchemaPublisher {
serviceUrl: input.url ?? null,
meta: input.meta ?? null,
targetId: target.id,
schemaVersionId: latestSchemaVersion.id ?? null,
schemaVersionId: latestVersion.version.id ?? null,
breakingSchemaChanges: null,
safeSchemaChanges: null,
schemaPolicyWarnings: null,
@ -963,14 +971,14 @@ export class SchemaPublisher {
// SchemaCheckConclusion.Skip
if (!latestVersion || !latestSchemaVersion) {
if (!latestVersion) {
throw new Error('This cannot happen 2 :)');
}
if (latestSchemaVersion.isComposable) {
if (latestVersion.version.isComposable) {
increaseSchemaCheckCountMetric('accepted');
const contracts = await this.contracts.getContractVersionsForSchemaVersion({
schemaVersionId: latestSchemaVersion.id,
schemaVersionId: latestVersion.version.id,
});
const failedContractCompositionCount =
contracts?.edges.filter(edge => edge.node.schemaCompositionErrors !== null).length ?? 0;
@ -999,7 +1007,7 @@ export class SchemaPublisher {
conclusion: SchemaCheckConclusion.Failure,
changes: null,
breakingChanges: null,
compositionErrors: latestSchemaVersion.schemaCompositionErrors,
compositionErrors: latestVersion.version.schemaCompositionErrors,
warnings: null,
errors: null,
schemaCheckId: schemaCheck?.id ?? null,
@ -1084,11 +1092,11 @@ export class SchemaPublisher {
// SchemaCheckConclusion.Skip
if (!latestVersion || !latestSchemaVersion) {
if (!latestVersion) {
throw new Error('This cannot happen 3 :)');
}
if (latestSchemaVersion.isComposable) {
if (latestVersion.version.isComposable) {
increaseSchemaCheckCountMetric('accepted');
return {
__typename: 'SchemaCheckSuccess',
@ -1101,7 +1109,7 @@ export class SchemaPublisher {
}
const contractVersions = await this.contracts.getContractVersionsForSchemaVersion({
schemaVersionId: latestSchemaVersion.id,
schemaVersionId: latestVersion.version.id,
});
increaseSchemaCheckCountMetric('rejected');
@ -1111,7 +1119,7 @@ export class SchemaPublisher {
changes: [],
warnings: [],
errors: [
...(latestSchemaVersion.schemaCompositionErrors?.map(error => ({
...(latestVersion.version.schemaCompositionErrors?.map(error => ({
message: error.message,
source: error.source,
})) ?? []),
@ -1184,32 +1192,40 @@ export class SchemaPublisher {
selector.targetId,
);
const target = await this.storage.getTarget({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
});
const [contracts, latestVersion, latestSchemas] = await Promise.all([
this.contracts.getActiveContractsByTargetId({ targetId: selector.targetId }),
this.schemaManager.getMaybeLatestVersion(target),
input.service
? this.storage.getLatestSchemas({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
})
: Promise.resolve(),
const [target, project] = await Promise.all([
this.storage.getTarget({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
this.storage.getProject({
organizationId: selector.organizationId,
projectId: selector.projectId,
}),
]);
// If trying to push with a service name and there are existing services
if (input.service) {
const [contracts, latestVersion] = await Promise.all([
this.contracts.getActiveContractsByTargetId({ targetId: selector.targetId }),
this.schemaManager.getLatestSchemaVersionWithSchemaLogs({
target,
}),
]);
if (project.type !== Types.ProjectType.SINGLE) {
if (!input.service) {
return {
__typename: 'SchemaPublishMissingServiceError' as const,
message: 'Missing service name',
} as const;
}
let serviceExists = false;
if (latestSchemas?.schemas) {
serviceExists = !!ensureCompositeSchemas(latestSchemas.schemas).find(
if (latestVersion?.schemas) {
serviceExists = !!ensureCompositeSchemas(latestVersion.schemas).find(
({ service_name }) => service_name === input.service,
);
}
// this is a new service. Validate the service name.
if (!serviceExists && !isValidServiceName(input.service)) {
return {
@ -1241,7 +1257,7 @@ export class SchemaPublisher {
// We include the latest version ID to avoid caching a schema publication that targets different versions.
// When deleting a schema, and publishing it again, the latest version ID will be different.
// If we don't include it, the cache will return the previous result.
latestVersionId: latestVersion?.id,
latestVersionId: latestVersion?.version.id,
}),
)
.update(this.session.id)
@ -1346,55 +1362,48 @@ export class SchemaPublisher {
signal,
},
async () => {
const [organization, project, target, latestVersion, latestComposableVersion, baseSchema] =
await Promise.all([
this.storage.getOrganization({
organizationId: selector.organizationId,
}),
this.storage.getProject({
organizationId: selector.organizationId,
projectId: selector.projectId,
}),
this.storage.getTarget({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
this.storage.getLatestSchemas({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
this.storage.getLatestSchemas({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
onlyComposable: true,
}),
this.storage.getBaseSchema({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
]);
const [latestSchemaVersion, latestComposableSchemaVersion] = await Promise.all([
this.schemaManager.getMaybeLatestVersion(target),
this.schemaManager.getMaybeLatestValidVersion(target),
const [organization, project, target] = await Promise.all([
this.storage.getOrganization({
organizationId: selector.organizationId,
}),
this.storage.getProject({
organizationId: selector.organizationId,
projectId: selector.projectId,
}),
this.storage.getTarget({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
]);
const compareToPreviousComposableVersion = shouldUseLatestComposableVersion(
selector.targetId,
project,
organization,
);
schemaDeleteCount.inc({ model: 'modern', projectType: project.type });
if (project.type !== ProjectType.FEDERATION && project.type !== ProjectType.STITCHING) {
throw new HiveError(`${project.type} project not supported`);
}
const [latestVersion, latestComposableVersion, baseSchema] = await Promise.all([
this.schemaManager.getLatestSchemaVersionWithSchemaLogs({
target,
}),
this.schemaManager.getLatestSchemaVersionWithSchemaLogs({
target,
onlyComposable: true,
}),
this.storage.getBaseSchema({
organizationId: selector.organizationId,
projectId: selector.projectId,
targetId: selector.targetId,
}),
]);
const compareToLatestComposableVersion = shouldUseLatestComposableVersion(
selector.targetId,
project,
organization,
);
if (!latestVersion || latestVersion.schemas.length === 0) {
throw new HiveError('Registry is empty');
}
@ -1412,7 +1421,7 @@ export class SchemaPublisher {
if (!serviceExists) {
return {
__typename: 'SchemaDeleteError',
valid: latestVersion.valid,
valid: false,
errors: [
{
message: `Service "${input.serviceName}" not found`,
@ -1442,15 +1451,17 @@ export class SchemaPublisher {
serviceName: input.serviceName,
},
latest: {
isComposable: latestVersion.valid,
sdl: latestSchemaVersion?.compositeSchemaSDL ?? null,
schemas,
isComposable: latestVersion.version.isComposable,
sdl: latestVersion.version.compositeSchemaSDL,
schemas: schemas.map(toCompositeSchemaInput),
},
latestComposable: latestComposableVersion
? {
isComposable: latestComposableVersion.valid,
sdl: latestComposableSchemaVersion?.compositeSchemaSDL ?? null,
schemas: ensureCompositeSchemas(latestComposableVersion.schemas),
isComposable: latestComposableVersion.version.isComposable,
sdl: latestComposableVersion.version.compositeSchemaSDL ?? null,
schemas: ensureCompositeSchemas(latestComposableVersion.schemas).map(
toCompositeSchemaInput,
),
}
: null,
baseSchema,
@ -1465,15 +1476,16 @@ export class SchemaPublisher {
conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null,
contracts,
failDiffOnDangerousChange,
compareToLatestComposableVersion,
});
let diffSchemaVersionId: string | null = null;
if (compareToPreviousComposableVersion && latestComposableSchemaVersion) {
diffSchemaVersionId = latestComposableSchemaVersion.id;
if (compareToLatestComposableVersion && latestComposableVersion) {
diffSchemaVersionId = latestComposableVersion.version.id;
}
if (!compareToPreviousComposableVersion && latestSchemaVersion) {
diffSchemaVersionId = latestSchemaVersion.id;
if (!compareToLatestComposableVersion && latestVersion) {
diffSchemaVersionId = latestVersion.version.id;
}
if (deleteResult.conclusion === SchemaDeleteConclusion.Accept) {
@ -1599,12 +1611,6 @@ export class SchemaPublisher {
DeleteFailureReasonCode.CompositionFailure,
)?.compositionErrors;
if (getReasonByCode(deleteResult.reasons, DeleteFailureReasonCode.MissingServiceName)) {
errors.push({
message: 'Service name is required',
});
}
if (compositionErrors?.length) {
errors.push(...compositionErrors);
}
@ -1640,41 +1646,35 @@ export class SchemaPublisher {
metadata: !!input.metadata,
});
const [organization, project, target, latestVersion, latestComposable, baseSchema] =
await Promise.all([
this.storage.getOrganization({
organizationId: organizationId,
}),
this.storage.getProject({
organizationId: organizationId,
projectId: projectId,
}),
this.storage.getTarget({
organizationId: organizationId,
projectId: projectId,
targetId: targetId,
}),
this.storage.getLatestSchemas({
organizationId: organizationId,
projectId: projectId,
targetId: targetId,
}),
this.storage.getLatestSchemas({
organizationId: organizationId,
projectId: projectId,
targetId: targetId,
onlyComposable: true,
}),
this.storage.getBaseSchema({
organizationId: organizationId,
projectId: projectId,
targetId: targetId,
}),
]);
const [organization, project, target, baseSchema] = await Promise.all([
this.storage.getOrganization({
organizationId: organizationId,
}),
this.storage.getProject({
organizationId: organizationId,
projectId: projectId,
}),
this.storage.getTarget({
organizationId: organizationId,
projectId: projectId,
targetId: targetId,
}),
const [latestSchemaVersion, latestComposableSchemaVersion] = await Promise.all([
this.schemaManager.getMaybeLatestVersion(target),
this.schemaManager.getMaybeLatestValidVersion(target),
this.storage.getBaseSchema({
organizationId: organizationId,
projectId: projectId,
targetId: targetId,
}),
]);
const [latestVersion, latestComposable] = await Promise.all([
this.schemaManager.getLatestSchemaVersionWithSchemaLogs({
target,
}),
this.schemaManager.getLatestSchemaVersionWithSchemaLogs({
target,
onlyComposable: true,
}),
]);
function increaseSchemaPublishCountMetric(conclusion: 'rejected' | 'accepted' | 'ignored') {
@ -1771,18 +1771,18 @@ export class SchemaPublisher {
})
: null;
const compareToPreviousComposableVersion = shouldUseLatestComposableVersion(
const compareToLatestComposableVersion = shouldUseLatestComposableVersion(
target.id,
project,
organization,
);
const comparedSchemaVersion = compareToPreviousComposableVersion
? latestComposableSchemaVersion
: latestSchemaVersion;
const comparedSchemaVersion = compareToLatestComposableVersion
? latestComposable
: latestVersion;
const latestSchemaVersionContracts = latestSchemaVersion
const latestSchemaVersionContracts = latestVersion
? await this.contracts.getContractVersionsForSchemaVersion({
schemaVersionId: latestSchemaVersion.id,
schemaVersionId: latestVersion.version.id,
})
: null;
@ -1795,19 +1795,22 @@ export class SchemaPublisher {
organization.featureFlags,
);
publishResult = await this.models[ProjectType.SINGLE].publish({
input,
input: {
sdl: input.sdl,
metadata: input.metadata ?? null,
},
latest: latestVersion
? {
isComposable: latestVersion.valid,
sdl: latestSchemaVersion?.compositeSchemaSDL ?? null,
schemas: [ensureSingleSchema(latestVersion.schemas)],
isComposable: latestVersion.version.isComposable,
sdl: latestVersion.version.compositeSchemaSDL,
schemas: [toSingleSchemaInput(ensureSingleSchema(latestVersion.schemas))],
}
: null,
latestComposable: latestComposable
? {
isComposable: latestComposable.valid,
sdl: latestComposableSchemaVersion?.compositeSchemaSDL ?? null,
schemas: [ensureSingleSchema(latestComposable.schemas)],
isComposable: latestComposable.version.isComposable,
sdl: latestComposable.version.compositeSchemaSDL,
schemas: [toSingleSchemaInput(ensureSingleSchema(latestComposable.schemas))],
}
: null,
organization,
@ -1826,22 +1829,34 @@ export class SchemaPublisher {
project.type,
organization.featureFlags,
);
if (!input.service) {
throw new Error('Invalid state. input.service should have been validated by now.');
}
publishResult = await this.models[project.type].publish({
input,
input: {
sdl: input.sdl,
service: input.service,
metadata: input.metadata ?? null,
url: input.url ?? null,
},
latest: latestVersion
? {
isComposable: latestVersion.valid,
sdl: latestSchemaVersion?.compositeSchemaSDL ?? null,
schemas: ensureCompositeSchemas(latestVersion.schemas),
isComposable: latestVersion.version.isComposable,
sdl: latestVersion.version.compositeSchemaSDL,
schemas: ensureCompositeSchemas(latestVersion.schemas).map(toCompositeSchemaInput),
contractNames:
latestSchemaVersionContracts?.edges.map(edge => edge.node.contractName) ?? null,
}
: null,
latestComposable: latestComposable
? {
isComposable: latestComposable.valid,
sdl: latestComposableSchemaVersion?.compositeSchemaSDL ?? null,
schemas: ensureCompositeSchemas(latestComposable.schemas),
isComposable: latestComposable.version.isComposable,
sdl: latestComposable.version.compositeSchemaSDL,
schemas: ensureCompositeSchemas(latestComposable.schemas).map(
toCompositeSchemaInput,
),
}
: null,
organization,
@ -1852,6 +1867,7 @@ export class SchemaPublisher {
conditionalBreakingChangeDiffConfig:
conditionalBreakingChangeConfiguration?.conditionalBreakingChangeDiffConfig ?? null,
failDiffOnDangerousChange,
compareToLatestComposableVersion: compareToLatestComposableVersion,
});
break;
default: {
@ -1877,7 +1893,7 @@ export class SchemaPublisher {
target: {
slug: target.slug,
},
version: latestVersion ? { id: latestVersion.versionId } : undefined,
version: latestVersion ? { id: latestVersion.version.id } : undefined,
})
: null;
@ -1912,13 +1928,6 @@ export class SchemaPublisher {
increaseSchemaPublishCountMetric('rejected');
if (getReasonByCode(publishResult.reasons, PublishFailureReasonCode.MissingServiceName)) {
return {
__typename: 'SchemaPublishMissingServiceError' as const,
message: 'Missing service name',
} as const;
}
if (getReasonByCode(publishResult.reasons, PublishFailureReasonCode.MissingServiceUrl)) {
return {
__typename: 'SchemaPublishMissingUrlError' as const,
@ -2000,7 +2009,7 @@ export class SchemaPublisher {
const supergraph = publishResult.state.supergraph ?? null;
const diffSchemaVersionId = comparedSchemaVersion?.id ?? null;
const diffSchemaVersionId = comparedSchemaVersion?.version.id ?? null;
this.logger.debug(`Assigning ${schemaLogIds.length} schemas to new version`);
@ -2010,9 +2019,9 @@ export class SchemaPublisher {
if (
(project.type === ProjectType.FEDERATION || project.type === ProjectType.STITCHING) &&
serviceUrl == null &&
pushedSchema.kind === 'composite'
pushedSchema.serviceName
) {
serviceUrl = pushedSchema.service_url;
serviceUrl = pushedSchema.serviceUrl;
}
const schemaVersion = await this.schemaManager.createVersion({
@ -2057,7 +2066,7 @@ export class SchemaPublisher {
changes,
coordinatesDiff: publishResult.state.coordinatesDiff,
diffSchemaVersionId,
previousSchemaVersion: latestVersion?.versionId ?? null,
previousSchemaVersion: latestVersion?.version.id ?? null,
conditionalBreakingChangeMetadata: await this.getConditionalBreakingChangeMetadata({
conditionalBreakingChangeConfiguration,
organizationId,
@ -2336,7 +2345,7 @@ export class SchemaPublisher {
project: Project;
supergraph: string | null;
fullSchemaSdl: string;
schemas: readonly Schema[];
schemas: readonly SchemaInput[];
contracts: null | Array<{ name: string; supergraph: string; sdl: string }>;
versionId: string;
}) {
@ -2362,16 +2371,14 @@ export class SchemaPublisher {
};
const publishCompositeSchema = async () => {
const compositeSchema = ensureCompositeSchemas(schemas);
await Promise.all([
await this.artifactStorageWriter.writeArtifact({
targetId: target.id,
artifactType: 'services',
artifact: compositeSchema.map(s => ({
name: s.service_name,
artifact: schemas.map(s => ({
name: s.serviceName,
sdl: s.sdl,
url: s.service_url,
url: s.serviceUrl,
})),
contractName: null,
versionId,

View file

@ -17,7 +17,7 @@ import { Logger } from '../../shared/providers/logger';
import { Storage } from '../../shared/providers/storage';
import { CompositionOrchestrator } from './orchestrator/composition-orchestrator';
import { RegistryChecks } from './registry-checks';
import { ensureCompositeSchemas, SchemaHelper } from './schema-helper';
import { ensureCompositeSchemas, SchemaHelper, toCompositeSchemaInput } from './schema-helper';
import { SchemaManager } from './schema-manager';
@Injectable({
@ -69,7 +69,13 @@ export class SchemaVersionHelper {
const validation = await this.compositionOrchestrator.composeAndValidate(
CompositionOrchestrator.projectTypeToOrchestratorType(project.type),
schemas.map(s => this.schemaHelper.createSchemaObject(s)),
schemas.map(s =>
this.schemaHelper.createSchemaObject({
sdl: s.sdl,
serviceName: s.kind === 'composite' ? s.service_name : null,
serviceUrl: s.kind === 'composite' ? s.service_url : null,
}),
),
{
external: project.externalComposition,
native: this.schemaManager.checkProjectNativeFederationSupport({
@ -237,8 +243,8 @@ export class SchemaVersionHelper {
existingSdl,
incomingSdl,
includeUrlChanges: {
schemasBefore: ensureCompositeSchemas(schemaBefore),
schemasAfter: ensureCompositeSchemas(schemasAfter),
schemasBefore: ensureCompositeSchemas(schemaBefore).map(toCompositeSchemaInput),
schemasAfter: ensureCompositeSchemas(schemasAfter).map(toCompositeSchemaInput),
},
filterOutFederationChanges: project.type === ProjectType.FEDERATION,
conditionalBreakingChangeConfig: null,

View file

@ -352,16 +352,6 @@ export interface Storage {
hasSchema(_: TargetSelector): Promise<boolean>;
getLatestSchemas(
_: {
onlyComposable?: boolean;
} & TargetSelector,
): Promise<{
schemas: Schema[];
versionId: string;
valid: boolean;
} | null>;
getLatestValidVersion(_: { targetId: string }): Promise<SchemaVersion | never>;
getMaybeLatestValidVersion(_: { targetId: string }): Promise<SchemaVersion | null | never>;

View file

@ -116,11 +116,13 @@ export function createSDLHash(sdl: string): string {
);
}
export function createSchemaObject(
schema:
| Pick<SingleSchema, 'sdl'>
| Pick<PushedCompositeSchema, 'sdl' | 'service_name' | 'service_url'>,
): SchemaObject {
export type CreateSchemaObjectInput = {
sdl: string;
serviceName?: string | null;
serviceUrl?: string | null;
};
export function createSchemaObject(schema: CreateSchemaObjectInput): SchemaObject {
let document: DocumentNode;
try {
@ -135,8 +137,8 @@ export function createSchemaObject(
return {
document,
raw: schema.sdl,
source: 'service_name' in schema ? schema.service_name : emptySource,
url: 'service_url' in schema ? schema.service_url : null,
source: schema.serviceName ?? emptySource,
url: schema.serviceUrl ?? null,
};
}

View file

@ -2125,36 +2125,6 @@ export async function createStorage(
return SchemaVersionModel.parse(version);
},
async getLatestSchemas({ projectId: project, targetId: target, onlyComposable }) {
const latest = await pool.maybeOne<
Pick<schema_versions, 'id' | 'is_composable'>
>(sql`/* getLatestSchemas */
SELECT sv.id, sv.is_composable
FROM schema_versions as sv
LEFT JOIN targets as t ON (t.id = sv.target_id)
LEFT JOIN schema_log as sl ON (sl.id = sv.action_id)
WHERE t.id = ${target} AND t.project_id = ${project} AND ${
onlyComposable ? sql`sv.is_composable IS TRUE` : true
}
ORDER BY sv.created_at DESC
LIMIT 1
`);
if (!latest) {
return null;
}
const schemas = await storage.getSchemasOfVersion({
versionId: latest.id,
includeMetadata: true,
});
return {
versionId: latest.id,
valid: latest.is_composable,
schemas,
};
},
async getSchemaByNameOfVersion(args) {
const result = await pool.maybeOne<
Pick<